Add unified desktop studio (woodshop / woodshop-gui)
A single PySide6 window combining the 3D viewport, parts panel, and command bar — mouse, keyboard, and voice all drive the same scene and the same visible selection (which resolves the "delete that" ambiguity). - gui/controller.py: one in-memory Scene; buttons call typed methods, voice/ typed commands go through driver.interpret and apply via execute_call, which REUSES the CLI command functions (no drift). Saves to disk + emits `changed`. - gui/viewport.py: embedded pyvistaqt QtInteractor; click-to-select a board; camera presets; reuses _part_mesh/_PALETTE. - gui/panels.py: parts list + selected inspector (editable length/yaw/tilt) + quick actions (stand/lay/rotate90/sand/duplicate/rename/delete). - gui/command_bar.py + workers.py: text + push-to-talk mic + transcript + speak toggle; LLM/dictate/TTS run on a QThreadPool so the UI never blocks. - gui/main_window.py: layout + menus (File open/save/export/render, Edit undo/redo/clear/delete, View cameras, Build templates + cut list, Help). - Scene: added select() and redo() (+ _redo stack, CLI select/redo, wood-select/ wood-redo tools). driver.dispatch takes a pluggable executor; interpret takes scene_text so the GUI feeds its in-memory state. - Bare `woodshop` launches the studio; 'gui' extra; woodshop-gui entry point. 52 tests (incl. controller); GUI verified by import + offscreen controller exercise (live VTK window needs a real display, untested headless). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b24e65548e
commit
e9422aa133
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -41,9 +41,23 @@ small local model) for reliable structured tool-calling.
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `woodshop <op>` | CLI ops: place, join, stand, lay, rotate, move, trim, copy, rename, sand, delete, undo, clear, status, cutlist, export, save, open, projects |
|
| `woodshop` (no args) / `woodshop-gui` | **The unified desktop studio** (viewport + parts panel + command bar) |
|
||||||
| `woodshop-view` | Live 3D viewport (watches `scene.json`; labels, grid, isometric) |
|
| `woodshop <op>` | CLI ops: place, join, stand, lay, rotate, move, trim, copy, rename, sand, delete, select, undo, redo, clear, status, cutlist, export, render, save, open, projects |
|
||||||
| `woodshop-talk` | Conversational driver (`--voice` for mic, `--once "..."` for one command) |
|
| `woodshop-view` | Standalone live 3D viewport (watches `scene.json`; labels, grid, isometric) |
|
||||||
|
| `woodshop-talk` | Standalone conversational driver (`--voice` for mic, `--once "..."` for one command) |
|
||||||
|
|
||||||
|
The studio (`src/woodshop/gui/`) is a thin PySide6 shell over the same Scene +
|
||||||
|
operations + interpreter:
|
||||||
|
- `controller.py` — one in-memory `Scene`; buttons/menus call typed methods,
|
||||||
|
voice/typed commands go through `driver.interpret` and are applied via
|
||||||
|
`execute_call`, which **reuses the CLI command functions** (no behavioral
|
||||||
|
drift). Every mutation saves to disk and emits `changed`.
|
||||||
|
- `viewport.py` — embedded `pyvistaqt.QtInteractor`; click a board to select.
|
||||||
|
- `panels.py` — parts list + selected-part inspector (editable length/yaw/tilt)
|
||||||
|
+ quick-action buttons. `command_bar.py` — text + push-to-talk + transcript,
|
||||||
|
with slow work (LLM/dictate/TTS) on a `QThreadPool` (`workers.py`).
|
||||||
|
- One visible **selection** (`scene.selection`) is shared by 3D click, the
|
||||||
|
list, and voice — so "delete that" is unambiguous.
|
||||||
|
|
||||||
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
|
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
|
||||||
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
|
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -41,15 +41,31 @@ vocabulary.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv && source .venv/bin/activate
|
python -m venv .venv && source .venv/bin/activate
|
||||||
pip install -e ".[viewer,dev]" # 'viewer' pulls build123d + pyvista
|
pip install -e ".[gui,dev]" # 'gui' pulls build123d + pyvista + PySide6 + pyvistaqt
|
||||||
python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
|
python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### The studio (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
woodshop-view & # live 3D window (watches the scene)
|
woodshop # launches the unified desktop app
|
||||||
woodshop-talk # type commands; --voice to speak them
|
```
|
||||||
|
|
||||||
|
One window with the **3D viewport** (click a board to select it), a **parts
|
||||||
|
panel** (list + selected-part inspector + quick-action buttons), and a
|
||||||
|
**command bar** at the bottom where you type or push-to-talk (🎤). Mouse,
|
||||||
|
keyboard, and voice all drive the same scene and the same visible selection, so
|
||||||
|
"delete that" / the Delete button / saying "delete the front-left leg" are
|
||||||
|
interchangeable. Menus cover New/Open/Save projects, Export STL/STEP, Save
|
||||||
|
Image, Undo/Redo, camera views, and Build templates.
|
||||||
|
|
||||||
|
### Standalone tools (headless / scripting)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
woodshop-view & # just the live 3D window (watches the scene)
|
||||||
|
woodshop-talk # just the voice/text loop; --voice to speak
|
||||||
woodshop-talk --once "build a workbench top from five 2x6 boards 6 feet long"
|
woodshop-talk --once "build a workbench top from five 2x6 boards 6 feet long"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ dependencies = []
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
woodshop = "woodshop.cli:main"
|
woodshop = "woodshop.cli:main"
|
||||||
|
woodshop-gui = "woodshop.gui.app:main"
|
||||||
woodshop-view = "woodshop.viewer:main"
|
woodshop-view = "woodshop.viewer:main"
|
||||||
woodshop-talk = "woodshop.driver:main"
|
woodshop-talk = "woodshop.driver:main"
|
||||||
|
|
||||||
|
|
@ -21,6 +22,13 @@ viewer = [
|
||||||
"build123d>=0.6",
|
"build123d>=0.6",
|
||||||
"pyvista>=0.43",
|
"pyvista>=0.43",
|
||||||
]
|
]
|
||||||
|
# The unified desktop studio (embeds the viewport in a Qt window).
|
||||||
|
gui = [
|
||||||
|
"build123d>=0.6",
|
||||||
|
"pyvista>=0.43",
|
||||||
|
"PySide6>=6.6",
|
||||||
|
"pyvistaqt>=0.11",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"pytest-cov>=4.0",
|
"pytest-cov>=4.0",
|
||||||
|
|
|
||||||
|
|
@ -141,11 +141,23 @@ TOOLS = {
|
||||||
],
|
],
|
||||||
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
|
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
|
||||||
},
|
},
|
||||||
|
"wood-select": {
|
||||||
|
"description": "Set the current selection — the board future commands like 'rotate that' or 'delete it' act on. Use for 'select', 'pick', 'grab the', 'use the'.",
|
||||||
|
"arguments": [
|
||||||
|
{"flag": "--part", "variable": "part", "description": "Board id or name to select, e.g. p3 or 'front-left leg'"},
|
||||||
|
],
|
||||||
|
"code": code('cmd = [ws, "select", part]'),
|
||||||
|
},
|
||||||
"wood-undo": {
|
"wood-undo": {
|
||||||
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
|
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
|
||||||
"arguments": [],
|
"arguments": [],
|
||||||
"code": code('cmd = [ws, "undo"]'),
|
"code": code('cmd = [ws, "undo"]'),
|
||||||
},
|
},
|
||||||
|
"wood-redo": {
|
||||||
|
"description": "Redo the last undone operation. Use for 'redo', 'put it back', 'never mind that undo'.",
|
||||||
|
"arguments": [],
|
||||||
|
"code": code('cmd = [ws, "redo"]'),
|
||||||
|
},
|
||||||
"wood-clear": {
|
"wood-clear": {
|
||||||
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
|
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
|
||||||
"arguments": [],
|
"arguments": [],
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,19 @@ def cmd_delete(scene: Scene, args) -> str:
|
||||||
return scene.delete(args.part)
|
return scene.delete(args.part)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_select(scene: Scene, args) -> str:
|
||||||
|
part = scene.select(args.part)
|
||||||
|
return f"Selected {part.id}" + (f" ('{part.name}')" if part.name else "") + "."
|
||||||
|
|
||||||
|
|
||||||
def cmd_undo(scene: Scene, args) -> str:
|
def cmd_undo(scene: Scene, args) -> str:
|
||||||
return scene.undo()
|
return scene.undo()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_redo(scene: Scene, args) -> str:
|
||||||
|
return scene.redo()
|
||||||
|
|
||||||
|
|
||||||
def cmd_stand(scene: Scene, args) -> str:
|
def cmd_stand(scene: Scene, args) -> str:
|
||||||
part = scene.stand(args.part, tilt_deg=args.tilt)
|
part = scene.stand(args.part, tilt_deg=args.tilt)
|
||||||
how = "standing up" if part.is_vertical else f"tilted to {args.tilt:g}°"
|
how = "standing up" if part.is_vertical else f"tilted to {args.tilt:g}°"
|
||||||
|
|
@ -181,7 +190,8 @@ def cmd_status(scene: Scene, args) -> str:
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
p = argparse.ArgumentParser(prog="woodshop", description="Voice/CLI woodworking operations.")
|
p = argparse.ArgumentParser(prog="woodshop", description="Voice/CLI woodworking operations.")
|
||||||
p.add_argument("--scene", help="Path to scene.json (default: $WOODSHOP_SCENE or XDG data dir)")
|
p.add_argument("--scene", help="Path to scene.json (default: $WOODSHOP_SCENE or XDG data dir)")
|
||||||
sub = p.add_subparsers(dest="command", required=True)
|
# No subcommand launches the GUI studio (see main()).
|
||||||
|
sub = p.add_subparsers(dest="command", required=False)
|
||||||
|
|
||||||
sp = sub.add_parser("place", help="Place a new board")
|
sp = sub.add_parser("place", help="Place a new board")
|
||||||
sp.add_argument("stock", help="Nominal stock, e.g. 2x4")
|
sp.add_argument("stock", help="Nominal stock, e.g. 2x4")
|
||||||
|
|
@ -270,7 +280,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
sp.set_defaults(func=cmd_render)
|
sp.set_defaults(func=cmd_render)
|
||||||
|
|
||||||
sub.add_parser("cutlist", help="Show the cut list / bill of materials").set_defaults(func=cmd_cutlist)
|
sub.add_parser("cutlist", help="Show the cut list / bill of materials").set_defaults(func=cmd_cutlist)
|
||||||
|
sp = sub.add_parser("select", help="Set the current selection")
|
||||||
|
sp.add_argument("part", help="Board id or name to select")
|
||||||
|
sp.set_defaults(func=cmd_select)
|
||||||
|
|
||||||
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
|
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
|
||||||
|
sub.add_parser("redo", help="Redo the last undone operation").set_defaults(func=cmd_redo)
|
||||||
sub.add_parser("clear", help="Clear the scene").set_defaults(func=cmd_clear)
|
sub.add_parser("clear", help="Clear the scene").set_defaults(func=cmd_clear)
|
||||||
sub.add_parser("status", help="Show the scene").set_defaults(func=cmd_status)
|
sub.add_parser("status", help="Show the scene").set_defaults(func=cmd_status)
|
||||||
return p
|
return p
|
||||||
|
|
@ -278,6 +293,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = build_parser().parse_args(argv)
|
args = build_parser().parse_args(argv)
|
||||||
|
if not args.command: # bare `woodshop` -> launch the GUI studio
|
||||||
|
from .gui.app import main as gui_main # lazy: keep Qt out of CLI use
|
||||||
|
return gui_main(["--scene", args.scene] if args.scene else [])
|
||||||
scene = Scene.load(args.scene)
|
scene = Scene.load(args.scene)
|
||||||
try:
|
try:
|
||||||
message = args.func(scene, args)
|
message = args.func(scene, args)
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,9 @@ def _extract_calls(raw: str) -> list[dict] | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def interpret(utterance: str, schemas: str) -> list[dict]:
|
def interpret(utterance: str, schemas: str, scene_text: str | None = None) -> list[dict]:
|
||||||
prompt = SYSTEM.format(schemas=schemas, scene=scene_summary(), utterance=utterance)
|
scene = scene_text if scene_text is not None else scene_summary()
|
||||||
|
prompt = SYSTEM.format(schemas=schemas, scene=scene, utterance=utterance)
|
||||||
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
|
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
|
||||||
calls = _extract_calls(raw)
|
calls = _extract_calls(raw)
|
||||||
if calls is None:
|
if calls is None:
|
||||||
|
|
@ -116,8 +117,25 @@ def interpret(utterance: str, schemas: str) -> list[dict]:
|
||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
|
||||||
def dispatch(calls: list[dict], verbose: bool = True) -> list[str]:
|
def _subprocess_executor(tool: str, args: dict) -> str:
|
||||||
"""Execute calls in order, resolving $N to ids of boards placed this turn."""
|
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-tool."""
|
||||||
|
result = _run(["pa-execute-tool", "--tool-name", tool,
|
||||||
|
"--tool-args", json.dumps(args)])
|
||||||
|
try:
|
||||||
|
payload = json.loads(result)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {"success": False, "output": "", "error": result}
|
||||||
|
return payload.get("output") or payload.get("error") or "(no output)"
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(calls: list[dict], verbose: bool = True, executor=None) -> list[str]:
|
||||||
|
"""Execute calls in order, resolving $N to ids of boards placed this turn.
|
||||||
|
|
||||||
|
`executor(tool, args) -> message` performs one operation; defaults to the
|
||||||
|
CmdForge subprocess. The GUI passes an in-process executor that mutates its
|
||||||
|
live Scene directly while reusing this $N-resolution logic.
|
||||||
|
"""
|
||||||
|
executor = executor or _subprocess_executor
|
||||||
placed: list[str] = []
|
placed: list[str] = []
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
|
|
@ -137,15 +155,8 @@ def dispatch(calls: list[dict], verbose: bool = True) -> list[str]:
|
||||||
messages.append(args.get("text", ""))
|
messages.append(args.get("text", ""))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = _run(["pa-execute-tool", "--tool-name", tool,
|
out = executor(tool, args)
|
||||||
"--tool-args", json.dumps(args)])
|
if tool == "wood-place":
|
||||||
try:
|
|
||||||
payload = json.loads(result)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
payload = {"success": False, "output": "", "error": result}
|
|
||||||
|
|
||||||
out = payload.get("output") or payload.get("error") or "(no output)"
|
|
||||||
if payload.get("success") and tool == "wood-place":
|
|
||||||
m = re.search(r"\b(p\d+)\b", out) # remember the new id for $N
|
m = re.search(r"\b(p\d+)\b", out) # remember the new id for $N
|
||||||
if m:
|
if m:
|
||||||
placed.append(m.group(1))
|
placed.append(m.group(1))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""The WoodShop desktop studio: a unified PySide6 window combining the live 3D
|
||||||
|
viewport, a parts panel with quick actions, and a voice/text command bar.
|
||||||
|
|
||||||
|
It's a thin shell over the same Scene model, operations, and Claude interpreter
|
||||||
|
used by the CLI and the standalone tools — see controller.py.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Entry point for the WoodShop desktop studio (`woodshop-gui`, or bare
|
||||||
|
`woodshop`)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(prog="woodshop-gui", description="WoodShop desktop studio.")
|
||||||
|
ap.add_argument("--scene", help="Path to scene.json")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
# Make every subprocess we spawn (dictate, tools) use the same scene file.
|
||||||
|
if args.scene:
|
||||||
|
os.environ["WOODSHOP_SCENE"] = args.scene
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from .main_window import MainWindow
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication(sys.argv[:1])
|
||||||
|
app.setApplicationName("WoodShop")
|
||||||
|
window = MainWindow(args.scene)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Command bar: type a command or push-to-talk, see the transcript, optionally
|
||||||
|
hear the reply. Slow work (LLM, dictate, TTS) runs off the UI thread."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtWidgets import (QCheckBox, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QPushButton, QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
from .controller import Controller
|
||||||
|
from .workers import run_async
|
||||||
|
|
||||||
|
_WHO_COLOR = {"you": "#9cdcfe", "ws": "#c8965a", "sys": "#e06c75"}
|
||||||
|
|
||||||
|
|
||||||
|
class CommandBar(QWidget):
|
||||||
|
def __init__(self, controller: Controller, pool: QThreadPool, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.c = controller
|
||||||
|
self.pool = pool
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
self.transcript = QTextEdit(readOnly=True)
|
||||||
|
self.transcript.setMaximumHeight(150)
|
||||||
|
root.addWidget(self.transcript)
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self.mic = QPushButton("🎤")
|
||||||
|
self.mic.setToolTip("Click and speak a command")
|
||||||
|
self.mic.setFixedWidth(40)
|
||||||
|
self.mic.clicked.connect(self._listen)
|
||||||
|
row.addWidget(self.mic)
|
||||||
|
|
||||||
|
self.input = QLineEdit()
|
||||||
|
self.input.setPlaceholderText("Type a command, e.g. 'build a coffee table' — Enter to send")
|
||||||
|
self.input.returnPressed.connect(self._send)
|
||||||
|
row.addWidget(self.input, 1)
|
||||||
|
|
||||||
|
send = QPushButton("Send")
|
||||||
|
send.clicked.connect(self._send)
|
||||||
|
row.addWidget(send)
|
||||||
|
root.addLayout(row)
|
||||||
|
|
||||||
|
bottom = QHBoxLayout()
|
||||||
|
self.speak = QCheckBox("Speak replies")
|
||||||
|
bottom.addWidget(self.speak)
|
||||||
|
bottom.addStretch()
|
||||||
|
self.status = QLabel("")
|
||||||
|
bottom.addWidget(self.status)
|
||||||
|
root.addLayout(bottom)
|
||||||
|
|
||||||
|
self.c.logged.connect(self._log)
|
||||||
|
|
||||||
|
# ----- logging -----------------------------------------------------
|
||||||
|
def _log(self, who: str, text: str) -> None:
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
color = _WHO_COLOR.get(who, "#cccccc")
|
||||||
|
label = {"you": "you", "ws": "WoodShop", "sys": "⚠"}.get(who, who)
|
||||||
|
self.transcript.append(f'<span style="color:{color}"><b>{label}:</b> '
|
||||||
|
f'{text.replace(chr(10), "<br>")}</span>')
|
||||||
|
self.transcript.verticalScrollBar().setValue(self.transcript.verticalScrollBar().maximum())
|
||||||
|
|
||||||
|
def _busy(self, on: bool, msg: str = "") -> None:
|
||||||
|
self.input.setEnabled(not on)
|
||||||
|
self.mic.setEnabled(not on)
|
||||||
|
self.status.setText(msg)
|
||||||
|
|
||||||
|
# ----- send typed/spoken command -----------------------------------
|
||||||
|
def _send(self) -> None:
|
||||||
|
text = self.input.text().strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
self.input.clear()
|
||||||
|
self._run(text)
|
||||||
|
|
||||||
|
def submit(self, text: str) -> None:
|
||||||
|
"""Run a command programmatically (e.g. from a Build-menu template)."""
|
||||||
|
self._run(text)
|
||||||
|
|
||||||
|
def _run(self, text: str) -> None:
|
||||||
|
self._log("you", text)
|
||||||
|
self._busy(True, "thinking…")
|
||||||
|
|
||||||
|
def work():
|
||||||
|
return self.c.run_command(text)
|
||||||
|
|
||||||
|
def done(summary):
|
||||||
|
self._busy(False)
|
||||||
|
if self.speak.isChecked() and summary:
|
||||||
|
run_async(self.pool, lambda: subprocess.run(
|
||||||
|
["read-aloud", "--strip-md", "true"], input=summary, text=True))
|
||||||
|
|
||||||
|
run_async(self.pool, work, on_done=done, on_error=lambda e: (self._busy(False), self._log("sys", e)))
|
||||||
|
|
||||||
|
# ----- voice -------------------------------------------------------
|
||||||
|
def _listen(self) -> None:
|
||||||
|
self._busy(True, "listening…")
|
||||||
|
|
||||||
|
def work():
|
||||||
|
r = subprocess.run(["dictate", "--duration", "6"], capture_output=True, text=True)
|
||||||
|
return (r.stdout or "").strip()
|
||||||
|
|
||||||
|
def done(text):
|
||||||
|
self._busy(False)
|
||||||
|
if text:
|
||||||
|
self._run(text)
|
||||||
|
else:
|
||||||
|
self._log("sys", "Didn't catch that.")
|
||||||
|
|
||||||
|
run_async(self.pool, work, on_done=done, on_error=lambda e: (self._busy(False), self._log("sys", e)))
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""Controller: the single in-memory Scene and every way to mutate it.
|
||||||
|
|
||||||
|
Buttons/menus call the typed methods directly (instant); voice/typed commands
|
||||||
|
go through the Claude interpreter and are applied via `execute_call`, which
|
||||||
|
reuses the CLI's command functions so GUI and CLI behave identically. Every
|
||||||
|
mutation saves to disk (keeping the CLI/headless tools interoperable) and emits
|
||||||
|
`changed` so the views refresh.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
from .. import cli, driver
|
||||||
|
from ..scene import Scene, SceneError, default_scene_path
|
||||||
|
|
||||||
|
|
||||||
|
def _f(v):
|
||||||
|
"""Parse an optional float arg ('' / None -> None)."""
|
||||||
|
return float(v) if v not in (None, "") else None
|
||||||
|
|
||||||
|
|
||||||
|
def _opt(v):
|
||||||
|
"""Normalize an optional string arg ('' -> None)."""
|
||||||
|
return v if v not in (None, "") else None
|
||||||
|
|
||||||
|
|
||||||
|
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
|
||||||
|
# command functions means voice and CLI share one implementation.
|
||||||
|
TOOL_CMD = {
|
||||||
|
"wood-place": lambda a: (cli.cmd_place, SimpleNamespace(stock=a["stock"], length=a["length"], unit="inch")),
|
||||||
|
"wood-join": lambda a: (cli.cmd_join, SimpleNamespace(
|
||||||
|
part_b=a["part_b"], part_a=_opt(a.get("to")), angle=float(a.get("angle") or 90),
|
||||||
|
offset=_opt(a.get("offset")), anchor=a.get("anchor") or "end_b", unit="inch")),
|
||||||
|
"wood-stand": lambda a: (cli.cmd_stand, SimpleNamespace(part=_opt(a.get("part")), tilt=float(a.get("tilt") or 90))),
|
||||||
|
"wood-lay": lambda a: (cli.cmd_lay, SimpleNamespace(part=_opt(a.get("part")))),
|
||||||
|
"wood-rotate": lambda a: (cli.cmd_rotate, SimpleNamespace(
|
||||||
|
part=_opt(a.get("part")), yaw=_f(a.get("yaw")), tilt=_f(a.get("tilt")), roll=_f(a.get("roll")))),
|
||||||
|
"wood-move": lambda a: (cli.cmd_move, SimpleNamespace(
|
||||||
|
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")),
|
||||||
|
absolute=False, unit="inch")),
|
||||||
|
"wood-trim": lambda a: (cli.cmd_trim, SimpleNamespace(length=a["length"], part=_opt(a.get("part")), unit="inch")),
|
||||||
|
"wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace(
|
||||||
|
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")),
|
||||||
|
"wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))),
|
||||||
|
"wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))),
|
||||||
|
"wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
|
||||||
|
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
|
||||||
|
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
|
||||||
|
"wood-redo": lambda a: (cli.cmd_redo, SimpleNamespace()),
|
||||||
|
"wood-clear": lambda a: (cli.cmd_clear, SimpleNamespace()),
|
||||||
|
"wood-cutlist": lambda a: (cli.cmd_cutlist, SimpleNamespace()),
|
||||||
|
"wood-save": lambda a: (cli.cmd_save, SimpleNamespace(name=a["name"])),
|
||||||
|
"wood-open": lambda a: (cli.cmd_open, SimpleNamespace(name=a["name"])),
|
||||||
|
"wood-projects": lambda a: (cli.cmd_projects, SimpleNamespace()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Controller(QObject):
|
||||||
|
changed = Signal() # scene or selection changed -> refresh views
|
||||||
|
logged = Signal(str, str) # (who, text): who in {"you","ws","sys"}
|
||||||
|
|
||||||
|
def __init__(self, scene_path: str | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self.scene_path = Path(scene_path) if scene_path else default_scene_path()
|
||||||
|
self.scene = Scene.load(self.scene_path)
|
||||||
|
self._schemas: str | None = None
|
||||||
|
|
||||||
|
# ----- persistence / notify ----------------------------------------
|
||||||
|
def save(self) -> None:
|
||||||
|
self.scene.save(self.scene_path)
|
||||||
|
|
||||||
|
def _commit(self, message: str | None = None) -> None:
|
||||||
|
self.save()
|
||||||
|
if message:
|
||||||
|
self.logged.emit("ws", message)
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
# ----- selection ---------------------------------------------------
|
||||||
|
def select(self, ref: str | None) -> None:
|
||||||
|
if ref:
|
||||||
|
try:
|
||||||
|
self.scene.select(ref)
|
||||||
|
except SceneError:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.scene.selection = None
|
||||||
|
self.changed.emit()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_id(self) -> str | None:
|
||||||
|
return self.scene.selection
|
||||||
|
|
||||||
|
# ----- direct operations (buttons / menus) -------------------------
|
||||||
|
def _do(self, fn, *args, **kwargs) -> None:
|
||||||
|
try:
|
||||||
|
msg = fn(*args, **kwargs)
|
||||||
|
except (SceneError, ValueError, KeyError) as exc:
|
||||||
|
self.logged.emit("sys", str(exc).strip('"'))
|
||||||
|
return
|
||||||
|
self._commit(msg if isinstance(msg, str) else None)
|
||||||
|
|
||||||
|
def place(self, stock: str, length_in: float):
|
||||||
|
self._do(lambda: f"Placed {self.scene.place(stock, length_in).id}.")
|
||||||
|
|
||||||
|
def stand(self, ref=None): self._do(lambda: f"Set {self.scene.stand(ref).id} standing up.")
|
||||||
|
def lay(self, ref=None): self._do(lambda: f"Laid {self.scene.stand(ref, 0.0).id} flat.")
|
||||||
|
def sand(self, ref=None): self._do(lambda: f"Sanded {self.scene.finish(ref).id}.")
|
||||||
|
def delete(self, ref=None): self._do(lambda: self.scene.delete(ref))
|
||||||
|
def undo(self): self._do(self.scene.undo)
|
||||||
|
def redo(self): self._do(self.scene.redo)
|
||||||
|
def clear(self): self._do(self.scene.clear)
|
||||||
|
def duplicate(self, ref=None): self._do(lambda: f"Copied to {self.scene.copy(ref).id}.")
|
||||||
|
def rename(self, ref, name): self._do(lambda: f"Named {self.scene.rename(ref, name).id}.")
|
||||||
|
|
||||||
|
def rotate(self, ref=None, yaw=None, tilt=None, roll=None):
|
||||||
|
self._do(lambda: f"Oriented {self.scene.orient(ref, yaw=yaw, tilt=tilt, roll=roll).id}.")
|
||||||
|
|
||||||
|
def rotate_90(self, ref=None):
|
||||||
|
part = self.scene.resolve(ref) if ref or self.scene.selection else None
|
||||||
|
if part:
|
||||||
|
self.rotate(part.id, yaw=(part.yaw_deg + 90) % 360)
|
||||||
|
|
||||||
|
def set_length(self, ref, length_in): self._do(lambda: f"Cut {self.scene.set_length(ref, length_in).id}.")
|
||||||
|
def move(self, ref=None, dx=0.0, dy=0.0, dz=0.0):
|
||||||
|
self._do(lambda: f"Moved {self.scene.move(ref, dx, dy, dz).id}.")
|
||||||
|
|
||||||
|
# ----- project / export --------------------------------------------
|
||||||
|
def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name)))
|
||||||
|
def save_project(self, name):
|
||||||
|
from ..scene import project_path
|
||||||
|
self.scene.save(project_path(name))
|
||||||
|
self.logged.emit("ws", f"Saved project '{name}'.")
|
||||||
|
|
||||||
|
def export(self, path: str):
|
||||||
|
from ..geometry import export
|
||||||
|
self.logged.emit("ws", f"Exported to {export(self.scene, path)}.")
|
||||||
|
|
||||||
|
def cutlist_text(self) -> str:
|
||||||
|
from ..cutlist import format_cutlist
|
||||||
|
return format_cutlist(self.scene)
|
||||||
|
|
||||||
|
# ----- voice / typed commands --------------------------------------
|
||||||
|
def schemas(self) -> str:
|
||||||
|
if self._schemas is None:
|
||||||
|
self._schemas = driver.load_schemas()
|
||||||
|
return self._schemas
|
||||||
|
|
||||||
|
def execute_call(self, tool: str, args: dict) -> str:
|
||||||
|
"""In-process executor for driver.dispatch (mutates the live scene)."""
|
||||||
|
entry = TOOL_CMD.get(tool)
|
||||||
|
if entry is None:
|
||||||
|
return f"(unknown tool {tool})"
|
||||||
|
func, ns = entry(args)
|
||||||
|
try:
|
||||||
|
return func(self.scene, ns)
|
||||||
|
except (SceneError, ValueError, KeyError) as exc:
|
||||||
|
return str(exc).strip('"')
|
||||||
|
|
||||||
|
def run_command(self, text: str) -> str:
|
||||||
|
"""Interpret a spoken/typed command and apply it. Returns a spoken summary.
|
||||||
|
(Slow — call from a worker thread.)"""
|
||||||
|
self.save() # ensure disk reflects current state
|
||||||
|
scene_text = cli.cmd_status(self.scene, None)
|
||||||
|
calls = driver.interpret(text, self.schemas(), scene_text=scene_text)
|
||||||
|
messages = driver.dispatch(calls, verbose=False, executor=self.execute_call)
|
||||||
|
self._commit()
|
||||||
|
return driver.summarize(calls, messages)
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""The WoodShop studio main window: viewport + parts panel on top, command bar
|
||||||
|
below, with menus tying everything to the controller."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtGui import QAction, QKeySequence
|
||||||
|
from PySide6.QtWidgets import (QFileDialog, QInputDialog, QMainWindow, QMessageBox,
|
||||||
|
QSplitter)
|
||||||
|
|
||||||
|
from ..cutlist import board_feet
|
||||||
|
from ..scene import list_projects
|
||||||
|
from .command_bar import CommandBar
|
||||||
|
from .controller import Controller
|
||||||
|
from .panels import PartsPanel
|
||||||
|
from .viewport import Viewport
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self, scene_path: str | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("WoodShop")
|
||||||
|
self.resize(1280, 860)
|
||||||
|
self.pool = QThreadPool.globalInstance()
|
||||||
|
|
||||||
|
self.controller = Controller(scene_path)
|
||||||
|
self.viewport = Viewport()
|
||||||
|
self.parts = PartsPanel(self.controller)
|
||||||
|
self.command = CommandBar(self.controller, self.pool)
|
||||||
|
|
||||||
|
top = QSplitter(Qt.Horizontal)
|
||||||
|
top.addWidget(self.viewport)
|
||||||
|
top.addWidget(self.parts)
|
||||||
|
top.setStretchFactor(0, 3)
|
||||||
|
top.setStretchFactor(1, 1)
|
||||||
|
|
||||||
|
split = QSplitter(Qt.Vertical)
|
||||||
|
split.addWidget(top)
|
||||||
|
split.addWidget(self.command)
|
||||||
|
split.setStretchFactor(0, 4)
|
||||||
|
split.setStretchFactor(1, 1)
|
||||||
|
self.setCentralWidget(split)
|
||||||
|
|
||||||
|
self.viewport.picked.connect(self.controller.select)
|
||||||
|
self.controller.changed.connect(self._on_changed)
|
||||||
|
self._build_menus()
|
||||||
|
self._on_changed() # initial render + status
|
||||||
|
|
||||||
|
# ----- menus -------------------------------------------------------
|
||||||
|
def _act(self, menu, text, slot, shortcut=None):
|
||||||
|
a = QAction(text, self)
|
||||||
|
if shortcut:
|
||||||
|
a.setShortcut(QKeySequence(shortcut))
|
||||||
|
a.triggered.connect(slot)
|
||||||
|
menu.addAction(a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
def _build_menus(self):
|
||||||
|
mb = self.menuBar()
|
||||||
|
|
||||||
|
f = mb.addMenu("&File")
|
||||||
|
self._act(f, "&New", self.controller.clear, "Ctrl+N")
|
||||||
|
self._act(f, "&Open Project…", self._open_project, "Ctrl+O")
|
||||||
|
self._act(f, "&Save Project…", self._save_project, "Ctrl+S")
|
||||||
|
f.addSeparator()
|
||||||
|
self._act(f, "&Export STL/STEP…", self._export)
|
||||||
|
self._act(f, "Save &Image…", self._render)
|
||||||
|
f.addSeparator()
|
||||||
|
self._act(f, "&Quit", self.close, "Ctrl+Q")
|
||||||
|
|
||||||
|
e = mb.addMenu("&Edit")
|
||||||
|
self._act(e, "&Undo", self.controller.undo, "Ctrl+Z")
|
||||||
|
self._act(e, "&Redo", self.controller.redo, "Ctrl+Y")
|
||||||
|
e.addSeparator()
|
||||||
|
self._act(e, "&Delete selected", self.controller.delete, "Del")
|
||||||
|
self._act(e, "&Clear scene", self.controller.clear)
|
||||||
|
|
||||||
|
v = mb.addMenu("&View")
|
||||||
|
self._act(v, "Top", lambda: self._camera(self.viewport.plotter.view_xy))
|
||||||
|
self._act(v, "Front", lambda: self._camera(self.viewport.plotter.view_xz))
|
||||||
|
self._act(v, "Side", lambda: self._camera(self.viewport.plotter.view_yz))
|
||||||
|
self._act(v, "Isometric", lambda: self._camera(self.viewport.plotter.view_isometric))
|
||||||
|
self._act(v, "Fit", lambda: self._camera(self.viewport.plotter.reset_camera))
|
||||||
|
|
||||||
|
b = mb.addMenu("&Build")
|
||||||
|
self._act(b, "Cut list / BOM…", self._show_cutlist)
|
||||||
|
self._act(b, "Table base…", lambda: self._template(
|
||||||
|
"build a table base: a {L} by {W} frame of 2x4s with four legs {H} tall standing at the corners",
|
||||||
|
[("Length", "48 in"), ("Width", "24 in"), ("Leg height", "29 in")]))
|
||||||
|
self._act(b, "Bookshelf side…", lambda: self._template(
|
||||||
|
"build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them",
|
||||||
|
[("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")]))
|
||||||
|
|
||||||
|
h = mb.addMenu("&Help")
|
||||||
|
self._act(h, "Commands…", self._show_help)
|
||||||
|
|
||||||
|
# ----- slots -------------------------------------------------------
|
||||||
|
def _on_changed(self):
|
||||||
|
self.viewport.render_scene(self.controller.scene)
|
||||||
|
scene = self.controller.scene
|
||||||
|
bf = sum(board_feet(p.stock, p.length_in) for p in scene.parts)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"{len(scene.parts)} part(s) · {bf:.1f} board-feet · "
|
||||||
|
f"selection: {scene.selection or 'none'}")
|
||||||
|
|
||||||
|
def _camera(self, fn):
|
||||||
|
fn()
|
||||||
|
self.viewport.plotter.render()
|
||||||
|
|
||||||
|
def _open_project(self):
|
||||||
|
names = list_projects()
|
||||||
|
if not names:
|
||||||
|
QMessageBox.information(self, "Open", "No saved projects yet.")
|
||||||
|
return
|
||||||
|
name, ok = QInputDialog.getItem(self, "Open Project", "Project:", names, 0, False)
|
||||||
|
if ok and name:
|
||||||
|
self.controller.open_project(name)
|
||||||
|
|
||||||
|
def _save_project(self):
|
||||||
|
name, ok = QInputDialog.getText(self, "Save Project", "Name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
self.controller.save_project(name.strip())
|
||||||
|
|
||||||
|
def _export(self):
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self, "Export", "model.step",
|
||||||
|
"CAD/Mesh (*.step *.stl)")
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
self.controller.export(path)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, "Export failed", str(exc))
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self, "Save Image", "model.png", "PNG (*.png)")
|
||||||
|
if path:
|
||||||
|
self.viewport.plotter.screenshot(path)
|
||||||
|
self.controller.logged.emit("ws", f"Saved image to {path}.")
|
||||||
|
|
||||||
|
def _template(self, template, fields):
|
||||||
|
keys = {"Length": "L", "Width": "W", "Leg height": "H",
|
||||||
|
"Height": "H", "Shelves": "N"}
|
||||||
|
vals = {}
|
||||||
|
for label, default in fields:
|
||||||
|
text, ok = QInputDialog.getText(self, "Build", f"{label}:", text=default)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
vals[keys[label]] = text
|
||||||
|
self.command.submit(template.format(**vals))
|
||||||
|
|
||||||
|
def _show_cutlist(self):
|
||||||
|
QMessageBox.information(self, "Cut List", self.controller.cutlist_text())
|
||||||
|
|
||||||
|
def _show_help(self):
|
||||||
|
QMessageBox.information(self, "Commands", _HELP)
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self.controller.save()
|
||||||
|
self.viewport.close_viewport()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
_HELP = """Speak or type, e.g.:
|
||||||
|
• place a 6 foot 2x4
|
||||||
|
• build a coffee table with four 18 inch legs
|
||||||
|
• stand it up / lay it flat / rotate 90 degrees
|
||||||
|
• move that 5 inches along x
|
||||||
|
• select the front-left leg (or click a board)
|
||||||
|
• sand it / delete that / undo / redo
|
||||||
|
• what's my cut list? / save this as my table
|
||||||
|
|
||||||
|
Click a board (3D or list) to select it. Buttons act on the selection.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Parts panel: the list of boards, the selected-part inspector with editable
|
||||||
|
fields, and quick-action buttons. Makes the current selection visible (the thing
|
||||||
|
that solves "delete that" ambiguity)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (QDoubleSpinBox, QFormLayout, QGridLayout, QGroupBox,
|
||||||
|
QHBoxLayout, QInputDialog, QLabel, QListWidget,
|
||||||
|
QListWidgetItem, QPushButton, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
|
from .controller import Controller
|
||||||
|
|
||||||
|
|
||||||
|
class PartsPanel(QWidget):
|
||||||
|
def __init__(self, controller: Controller, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.c = controller
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.addWidget(QLabel("<b>Parts</b>"))
|
||||||
|
|
||||||
|
self.list = QListWidget()
|
||||||
|
self.list.itemSelectionChanged.connect(self._on_row_selected)
|
||||||
|
root.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
box = QGroupBox("Selected")
|
||||||
|
bl = QVBoxLayout(box)
|
||||||
|
self.detail = QLabel("nothing selected")
|
||||||
|
self.detail.setWordWrap(True)
|
||||||
|
bl.addWidget(self.detail)
|
||||||
|
|
||||||
|
# quick actions
|
||||||
|
grid = QGridLayout()
|
||||||
|
actions = [
|
||||||
|
("Stand", lambda: self.c.stand()), ("Lay", lambda: self.c.lay()),
|
||||||
|
("Rotate 90°", lambda: self.c.rotate_90()), ("Sand", lambda: self.c.sand()),
|
||||||
|
("Duplicate", lambda: self.c.duplicate()), ("Rename", self._rename),
|
||||||
|
("Delete", lambda: self.c.delete()),
|
||||||
|
]
|
||||||
|
for i, (label, fn) in enumerate(actions):
|
||||||
|
b = QPushButton(label)
|
||||||
|
b.clicked.connect(lambda _=False, f=fn: f())
|
||||||
|
grid.addWidget(b, i // 2, i % 2)
|
||||||
|
bl.addLayout(grid)
|
||||||
|
|
||||||
|
# editable fields
|
||||||
|
form = QFormLayout()
|
||||||
|
self.len_spin = QDoubleSpinBox(); self.len_spin.setRange(0.1, 480); self.len_spin.setSuffix(" in")
|
||||||
|
self.yaw_spin = QDoubleSpinBox(); self.yaw_spin.setRange(-360, 360); self.yaw_spin.setSuffix(" °")
|
||||||
|
self.tilt_spin = QDoubleSpinBox(); self.tilt_spin.setRange(-180, 180); self.tilt_spin.setSuffix(" °")
|
||||||
|
self.len_spin.editingFinished.connect(self._apply_length)
|
||||||
|
self.yaw_spin.editingFinished.connect(self._apply_orientation)
|
||||||
|
self.tilt_spin.editingFinished.connect(self._apply_orientation)
|
||||||
|
form.addRow("Length", self.len_spin)
|
||||||
|
form.addRow("Yaw", self.yaw_spin)
|
||||||
|
form.addRow("Tilt", self.tilt_spin)
|
||||||
|
bl.addLayout(form)
|
||||||
|
root.addWidget(box)
|
||||||
|
|
||||||
|
self.c.changed.connect(self.refresh)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# ----- refresh from scene ------------------------------------------
|
||||||
|
def refresh(self) -> None:
|
||||||
|
self._loading = True
|
||||||
|
self.list.clear()
|
||||||
|
sel_row = -1
|
||||||
|
for row, p in enumerate(self.c.scene.parts):
|
||||||
|
name = f" · {p.name}" if p.name else ""
|
||||||
|
item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}")
|
||||||
|
item.setData(Qt.UserRole, p.id)
|
||||||
|
self.list.addItem(item)
|
||||||
|
if p.id == self.c.selected_id:
|
||||||
|
sel_row = row
|
||||||
|
if sel_row >= 0:
|
||||||
|
self.list.setCurrentRow(sel_row)
|
||||||
|
|
||||||
|
part = self._selected_part()
|
||||||
|
if part:
|
||||||
|
ori = "vertical" if part.is_vertical else f"yaw {part.yaw_deg:g}°, tilt {part.tilt_deg:g}°"
|
||||||
|
fin = f" · {', '.join(part.finishes)}" if part.finishes else ""
|
||||||
|
self.detail.setText(f"<b>{part.id}</b>{' · ' + part.name if part.name else ''}<br>"
|
||||||
|
f"{part.length_in:g}\" {part.stock} · {ori}{fin}")
|
||||||
|
self.len_spin.setValue(part.length_in)
|
||||||
|
self.yaw_spin.setValue(part.yaw_deg)
|
||||||
|
self.tilt_spin.setValue(part.tilt_deg)
|
||||||
|
else:
|
||||||
|
self.detail.setText("nothing selected")
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
def _selected_part(self):
|
||||||
|
pid = self.c.selected_id
|
||||||
|
if not pid:
|
||||||
|
return None
|
||||||
|
return next((p for p in self.c.scene.parts if p.id == pid), None)
|
||||||
|
|
||||||
|
# ----- handlers ----------------------------------------------------
|
||||||
|
def _on_row_selected(self) -> None:
|
||||||
|
if self._loading:
|
||||||
|
return
|
||||||
|
items = self.list.selectedItems()
|
||||||
|
if items:
|
||||||
|
self.c.select(items[0].data(Qt.UserRole))
|
||||||
|
|
||||||
|
def _rename(self) -> None:
|
||||||
|
part = self._selected_part()
|
||||||
|
if not part:
|
||||||
|
return
|
||||||
|
name, ok = QInputDialog.getText(self, "Rename", "Name:", text=part.name)
|
||||||
|
if ok and name.strip():
|
||||||
|
self.c.rename(part.id, name.strip())
|
||||||
|
|
||||||
|
def _apply_length(self) -> None:
|
||||||
|
part = self._selected_part()
|
||||||
|
if part and not self._loading and abs(self.len_spin.value() - part.length_in) > 1e-6:
|
||||||
|
self.c.set_length(part.id, self.len_spin.value())
|
||||||
|
|
||||||
|
def _apply_orientation(self) -> None:
|
||||||
|
part = self._selected_part()
|
||||||
|
if part and not self._loading and (
|
||||||
|
abs(self.yaw_spin.value() - part.yaw_deg) > 1e-6
|
||||||
|
or abs(self.tilt_spin.value() - part.tilt_deg) > 1e-6):
|
||||||
|
self.c.rotate(part.id, yaw=self.yaw_spin.value(), tilt=self.tilt_spin.value())
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Embedded 3D viewport (pyvistaqt). Renders the live Scene, highlights the
|
||||||
|
selection, and emits a signal when a board is clicked."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from ..scene import Scene
|
||||||
|
from ..viewer import _PALETTE, _part_mesh, _quiet_vtk
|
||||||
|
|
||||||
|
|
||||||
|
class Viewport(QWidget):
|
||||||
|
picked = Signal(str) # part id clicked in the 3D view
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
from pyvistaqt import QtInteractor
|
||||||
|
|
||||||
|
_quiet_vtk()
|
||||||
|
self.plotter = QtInteractor(self)
|
||||||
|
self.plotter.set_background("#2b2b2b")
|
||||||
|
self.plotter.enable_parallel_projection()
|
||||||
|
self._actor_to_pid: dict = {}
|
||||||
|
self._first = True
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.addWidget(self.plotter.interactor)
|
||||||
|
|
||||||
|
bar = QHBoxLayout()
|
||||||
|
for label, fn in [("Top", self.plotter.view_xy), ("Front", self.plotter.view_xz),
|
||||||
|
("Side", self.plotter.view_yz), ("Iso", self.plotter.view_isometric),
|
||||||
|
("Fit", self.plotter.reset_camera)]:
|
||||||
|
b = QPushButton(label)
|
||||||
|
b.clicked.connect(lambda _=False, f=fn: (f(), self.plotter.render()))
|
||||||
|
bar.addWidget(b)
|
||||||
|
bar.addStretch()
|
||||||
|
layout.addLayout(bar)
|
||||||
|
|
||||||
|
self._enable_picking()
|
||||||
|
|
||||||
|
def _enable_picking(self) -> None:
|
||||||
|
try: # left-click picking; degrade gracefully if the API differs
|
||||||
|
self.plotter.enable_mesh_picking(
|
||||||
|
callback=self._on_pick, use_actor=True, show=False,
|
||||||
|
show_message=False, left_clicking=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_pick(self, actor) -> None:
|
||||||
|
pid = self._actor_to_pid.get(actor)
|
||||||
|
if pid:
|
||||||
|
self.picked.emit(pid)
|
||||||
|
|
||||||
|
def render_scene(self, scene: Scene) -> None:
|
||||||
|
cam = None if self._first else self.plotter.camera_position
|
||||||
|
self.plotter.clear()
|
||||||
|
self._actor_to_pid.clear()
|
||||||
|
|
||||||
|
labels, pts = [], []
|
||||||
|
for i, part in enumerate(scene.parts):
|
||||||
|
selected = part.id == scene.selection
|
||||||
|
actor = self.plotter.add_mesh(
|
||||||
|
_part_mesh(part),
|
||||||
|
color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)],
|
||||||
|
show_edges=True, line_width=3 if selected else 1, edge_color="black",
|
||||||
|
reset_camera=False, pickable=True,
|
||||||
|
)
|
||||||
|
self._actor_to_pid[actor] = part.id
|
||||||
|
mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)]
|
||||||
|
labels.append(part.name or part.id)
|
||||||
|
pts.append(mid)
|
||||||
|
|
||||||
|
if pts:
|
||||||
|
self.plotter.add_point_labels(
|
||||||
|
pts, labels, font_size=12, text_color="white",
|
||||||
|
shape_color="#222222", shape_opacity=0.5, point_size=1,
|
||||||
|
name="labels", always_visible=True, reset_camera=False,
|
||||||
|
)
|
||||||
|
self.plotter.show_grid(color="#555555", xtitle="X (in)", ytitle="Y (in)", ztitle="Z (in)")
|
||||||
|
self.plotter.add_axes()
|
||||||
|
|
||||||
|
if self._first:
|
||||||
|
self.plotter.view_isometric()
|
||||||
|
self._first = False
|
||||||
|
elif cam is not None:
|
||||||
|
self.plotter.camera_position = cam # keep the user's viewpoint
|
||||||
|
self.plotter.render()
|
||||||
|
|
||||||
|
def close_viewport(self) -> None:
|
||||||
|
try:
|
||||||
|
self.plotter.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Run slow work (dictate, the LLM call, read-aloud) off the Qt event loop so
|
||||||
|
the UI never freezes."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||||
|
|
||||||
|
|
||||||
|
class _Signals(QObject):
|
||||||
|
done = Signal(object)
|
||||||
|
error = Signal(str)
|
||||||
|
|
||||||
|
|
||||||
|
class _Task(QRunnable):
|
||||||
|
def __init__(self, fn):
|
||||||
|
super().__init__()
|
||||||
|
self.fn = fn
|
||||||
|
self.signals = _Signals()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
self.signals.done.emit(self.fn())
|
||||||
|
except Exception as exc: # surface, don't crash the pool
|
||||||
|
self.signals.error.emit(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def run_async(pool: QThreadPool, fn, on_done=None, on_error=None) -> None:
|
||||||
|
task = _Task(fn)
|
||||||
|
if on_done:
|
||||||
|
task.signals.done.connect(on_done)
|
||||||
|
if on_error:
|
||||||
|
task.signals.error.connect(on_error)
|
||||||
|
pool.start(task)
|
||||||
|
|
@ -152,6 +152,7 @@ class Scene:
|
||||||
_next_part: int = 1
|
_next_part: int = 1
|
||||||
_next_joint: int = 1
|
_next_joint: int = 1
|
||||||
_undo: list[str] = field(default_factory=list, repr=False)
|
_undo: list[str] = field(default_factory=list, repr=False)
|
||||||
|
_redo: list[str] = field(default_factory=list, repr=False)
|
||||||
|
|
||||||
# ----- lookup -------------------------------------------------------
|
# ----- lookup -------------------------------------------------------
|
||||||
def get_part(self, ref: str) -> Part:
|
def get_part(self, ref: str) -> Part:
|
||||||
|
|
@ -169,20 +170,38 @@ class Scene:
|
||||||
return self.get_part(self.selection)
|
return self.get_part(self.selection)
|
||||||
return self.get_part(ref)
|
return self.get_part(ref)
|
||||||
|
|
||||||
# ----- undo ---------------------------------------------------------
|
# ----- undo / redo --------------------------------------------------
|
||||||
def _checkpoint(self) -> None:
|
def _checkpoint(self) -> None:
|
||||||
self._undo.append(json.dumps(self._raw(), sort_keys=True))
|
self._undo.append(json.dumps(self._raw(), sort_keys=True))
|
||||||
del self._undo[:-50] # keep the last 50 steps
|
del self._undo[:-50] # keep the last 50 steps
|
||||||
|
self._redo.clear() # a new action invalidates the redo history
|
||||||
|
|
||||||
|
def _restore(self, snapshot: dict) -> None:
|
||||||
|
restored = Scene.from_dict(snapshot)
|
||||||
|
restored._undo = self._undo
|
||||||
|
restored._redo = self._redo
|
||||||
|
self.__dict__.update(restored.__dict__)
|
||||||
|
|
||||||
def undo(self) -> str:
|
def undo(self) -> str:
|
||||||
if not self._undo:
|
if not self._undo:
|
||||||
raise SceneError("Nothing to undo.")
|
raise SceneError("Nothing to undo.")
|
||||||
snapshot = json.loads(self._undo.pop())
|
self._redo.append(json.dumps(self._raw(), sort_keys=True))
|
||||||
restored = Scene.from_dict(snapshot)
|
self._restore(json.loads(self._undo.pop()))
|
||||||
restored._undo = self._undo
|
|
||||||
self.__dict__.update(restored.__dict__)
|
|
||||||
return "Undid last operation."
|
return "Undid last operation."
|
||||||
|
|
||||||
|
def redo(self) -> str:
|
||||||
|
if not self._redo:
|
||||||
|
raise SceneError("Nothing to redo.")
|
||||||
|
self._undo.append(json.dumps(self._raw(), sort_keys=True))
|
||||||
|
self._restore(json.loads(self._redo.pop()))
|
||||||
|
return "Redid last operation."
|
||||||
|
|
||||||
|
def select(self, ref: str) -> Part:
|
||||||
|
"""Set the current selection (by id or name). Not undoable."""
|
||||||
|
part = self.get_part(ref)
|
||||||
|
self.selection = part.id
|
||||||
|
return part
|
||||||
|
|
||||||
# ----- operations ---------------------------------------------------
|
# ----- operations ---------------------------------------------------
|
||||||
def place(self, stock: str, length_in: float) -> Part:
|
def place(self, stock: str, length_in: float) -> Part:
|
||||||
self._checkpoint()
|
self._checkpoint()
|
||||||
|
|
@ -333,6 +352,7 @@ class Scene:
|
||||||
def _raw(self) -> dict:
|
def _raw(self) -> dict:
|
||||||
d = asdict(self)
|
d = asdict(self)
|
||||||
d.pop("_undo", None)
|
d.pop("_undo", None)
|
||||||
|
d.pop("_redo", None)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
|
|
@ -359,6 +379,7 @@ class Scene:
|
||||||
_next_part=data.get("_next_part", len(parts) + 1),
|
_next_part=data.get("_next_part", len(parts) + 1),
|
||||||
_next_joint=data.get("_next_joint", len(joints) + 1),
|
_next_joint=data.get("_next_joint", len(joints) + 1),
|
||||||
_undo=data.get("_undo", []),
|
_undo=data.get("_undo", []),
|
||||||
|
_redo=data.get("_redo", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, path: Path | None = None) -> Path:
|
def save(self, path: Path | None = None) -> Path:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Tests for the GUI controller's in-process command execution (no display)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
from PySide6.QtCore import QCoreApplication # noqa: E402
|
||||||
|
|
||||||
|
from woodshop import driver # noqa: E402
|
||||||
|
from woodshop.gui.controller import Controller # noqa: E402
|
||||||
|
|
||||||
|
_app = QCoreApplication.instance() or QCoreApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(tmp_path):
|
||||||
|
return Controller(str(tmp_path / "scene.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_calls_with_symbols(tmp_path):
|
||||||
|
"""The controller's executor applies wood-* calls in-process, with $N."""
|
||||||
|
c = _controller(tmp_path)
|
||||||
|
calls = [
|
||||||
|
{"tool": "wood-place", "args": {"stock": "2x4", "length": "4 ft"}},
|
||||||
|
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
|
||||||
|
{"tool": "wood-stand", "args": {"part": "$2"}},
|
||||||
|
{"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "0"}},
|
||||||
|
]
|
||||||
|
driver.dispatch(calls, verbose=False, executor=c.execute_call)
|
||||||
|
assert [p.id for p in c.scene.parts] == ["p1", "p2"]
|
||||||
|
assert c.scene.get_part("p2").is_vertical
|
||||||
|
assert len(c.scene.joints) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_button_ops_and_persistence(tmp_path):
|
||||||
|
c = _controller(tmp_path)
|
||||||
|
c.place("2x4", 48)
|
||||||
|
c.stand() # acts on selection (p1)
|
||||||
|
assert c.scene.get_part("p1").is_vertical
|
||||||
|
c.duplicate() # p2
|
||||||
|
assert len(c.scene.parts) == 2
|
||||||
|
c.delete() # deletes selection p2
|
||||||
|
assert [p.id for p in c.scene.parts] == ["p1"]
|
||||||
|
# changes are persisted to disk
|
||||||
|
from woodshop.scene import Scene
|
||||||
|
assert len(Scene.load(c.scene_path).parts) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_and_undo_redo(tmp_path):
|
||||||
|
c = _controller(tmp_path)
|
||||||
|
c.place("2x4", 24)
|
||||||
|
c.place("2x4", 36)
|
||||||
|
c.select("p1")
|
||||||
|
assert c.selected_id == "p1"
|
||||||
|
c.undo() # removes p2
|
||||||
|
assert len(c.scene.parts) == 1
|
||||||
|
c.redo()
|
||||||
|
assert len(c.scene.parts) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_tool_is_safe(tmp_path):
|
||||||
|
c = _controller(tmp_path)
|
||||||
|
assert "unknown" in c.execute_call("wood-bogus", {}).lower()
|
||||||
|
|
@ -133,6 +133,39 @@ def test_copy_and_set_length_and_rename():
|
||||||
assert s.get_part("p2").length_in == 36.0
|
assert s.get_part("p2").length_in == 36.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_by_id_and_name():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.rename("p1", "front rail")
|
||||||
|
assert s.select("front rail").id == "p1"
|
||||||
|
assert s.selection == "p1"
|
||||||
|
assert s.select("p2").id == "p2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redo_after_undo():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.place("2x4", 36)
|
||||||
|
assert len(s.parts) == 2
|
||||||
|
s.undo()
|
||||||
|
assert len(s.parts) == 1
|
||||||
|
s.redo()
|
||||||
|
assert len(s.parts) == 2
|
||||||
|
assert s.get_part("p2").length_in == 36
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_action_clears_redo():
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 24)
|
||||||
|
s.place("2x4", 36)
|
||||||
|
s.undo() # redo now has the p2 placement
|
||||||
|
s.place("2x6", 12) # a new action should invalidate redo
|
||||||
|
import pytest as _pt
|
||||||
|
with _pt.raises(SceneError, match="Nothing to redo"):
|
||||||
|
s.redo()
|
||||||
|
|
||||||
|
|
||||||
def test_clear():
|
def test_clear():
|
||||||
s = Scene()
|
s = Scene()
|
||||||
s.place("2x4", 24)
|
s.place("2x4", 24)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue