diff --git a/CLAUDE.md b/CLAUDE.md index 39fe9ba..712ac97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,9 +41,23 @@ small local model) for reliable structured tool-calling. | Command | Purpose | |---------|---------| -| `woodshop ` | CLI ops: place, join, stand, lay, rotate, move, trim, copy, rename, sand, delete, undo, clear, status, cutlist, export, save, open, projects | -| `woodshop-view` | Live 3D viewport (watches `scene.json`; labels, grid, isometric) | -| `woodshop-talk` | Conversational driver (`--voice` for mic, `--once "..."` for one command) | +| `woodshop` (no args) / `woodshop-gui` | **The unified desktop studio** (viewport + parts panel + command bar) | +| `woodshop ` | 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-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`. Named projects: `~/.local/share/woodshop/projects/.json`. diff --git a/README.md b/README.md index 9519bf7..40dd7f7 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,31 @@ vocabulary. ```bash 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 ``` ## Usage +### The studio (recommended) + ```bash -woodshop-view & # live 3D window (watches the scene) -woodshop-talk # type commands; --voice to speak them +woodshop # launches the unified desktop app +``` + +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" ``` diff --git a/pyproject.toml b/pyproject.toml index cc8d168..923e1ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [] [project.scripts] woodshop = "woodshop.cli:main" +woodshop-gui = "woodshop.gui.app:main" woodshop-view = "woodshop.viewer:main" woodshop-talk = "woodshop.driver:main" @@ -21,6 +22,13 @@ viewer = [ "build123d>=0.6", "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 = [ "pytest>=7.0", "pytest-cov>=4.0", diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 53366f4..7481998 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -141,11 +141,23 @@ TOOLS = { ], "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": { "description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.", "arguments": [], "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": { "description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.", "arguments": [], diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index f8287a7..a6450d5 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -58,10 +58,19 @@ def cmd_delete(scene: Scene, args) -> str: 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: return scene.undo() +def cmd_redo(scene: Scene, args) -> str: + return scene.redo() + + def cmd_stand(scene: Scene, args) -> str: part = scene.stand(args.part, tilt_deg=args.tilt) 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: 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)") - 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.add_argument("stock", help="Nominal stock, e.g. 2x4") @@ -270,7 +280,12 @@ def build_parser() -> argparse.ArgumentParser: sp.set_defaults(func=cmd_render) 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("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("status", help="Show the scene").set_defaults(func=cmd_status) return p @@ -278,6 +293,9 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: 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) try: message = args.func(scene, args) diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index 56d9956..a2b6b60 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -107,8 +107,9 @@ def _extract_calls(raw: str) -> list[dict] | None: return None -def interpret(utterance: str, schemas: str) -> list[dict]: - prompt = SYSTEM.format(schemas=schemas, scene=scene_summary(), utterance=utterance) +def interpret(utterance: str, schemas: str, scene_text: str | None = None) -> list[dict]: + 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) calls = _extract_calls(raw) if calls is None: @@ -116,8 +117,25 @@ def interpret(utterance: str, schemas: str) -> list[dict]: return calls -def dispatch(calls: list[dict], verbose: bool = True) -> list[str]: - """Execute calls in order, resolving $N to ids of boards placed this turn.""" +def _subprocess_executor(tool: str, args: dict) -> str: + """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] = [] messages: list[str] = [] @@ -137,15 +155,8 @@ def dispatch(calls: list[dict], verbose: bool = True) -> list[str]: messages.append(args.get("text", "")) continue - 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} - - out = payload.get("output") or payload.get("error") or "(no output)" - if payload.get("success") and tool == "wood-place": + out = executor(tool, args) + if tool == "wood-place": m = re.search(r"\b(p\d+)\b", out) # remember the new id for $N if m: placed.append(m.group(1)) diff --git a/src/woodshop/gui/__init__.py b/src/woodshop/gui/__init__.py new file mode 100644 index 0000000..d3bb69c --- /dev/null +++ b/src/woodshop/gui/__init__.py @@ -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. +""" diff --git a/src/woodshop/gui/app.py b/src/woodshop/gui/app.py new file mode 100644 index 0000000..b60636b --- /dev/null +++ b/src/woodshop/gui/app.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()) diff --git a/src/woodshop/gui/command_bar.py b/src/woodshop/gui/command_bar.py new file mode 100644 index 0000000..78f99f5 --- /dev/null +++ b/src/woodshop/gui/command_bar.py @@ -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'{label}: ' + f'{text.replace(chr(10), "
")}
') + 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))) diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py new file mode 100644 index 0000000..7ad5f8d --- /dev/null +++ b/src/woodshop/gui/controller.py @@ -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) diff --git a/src/woodshop/gui/main_window.py b/src/woodshop/gui/main_window.py new file mode 100644 index 0000000..51c49cc --- /dev/null +++ b/src/woodshop/gui/main_window.py @@ -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. +""" diff --git a/src/woodshop/gui/panels.py b/src/woodshop/gui/panels.py new file mode 100644 index 0000000..42f6216 --- /dev/null +++ b/src/woodshop/gui/panels.py @@ -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("Parts")) + + 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"{part.id}{' · ' + part.name if part.name else ''}
" + 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()) diff --git a/src/woodshop/gui/viewport.py b/src/woodshop/gui/viewport.py new file mode 100644 index 0000000..3aeb828 --- /dev/null +++ b/src/woodshop/gui/viewport.py @@ -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 diff --git a/src/woodshop/gui/workers.py b/src/woodshop/gui/workers.py new file mode 100644 index 0000000..048032b --- /dev/null +++ b/src/woodshop/gui/workers.py @@ -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) diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index d89a3e1..bbfb194 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -152,6 +152,7 @@ class Scene: _next_part: int = 1 _next_joint: int = 1 _undo: list[str] = field(default_factory=list, repr=False) + _redo: list[str] = field(default_factory=list, repr=False) # ----- lookup ------------------------------------------------------- def get_part(self, ref: str) -> Part: @@ -169,20 +170,38 @@ class Scene: return self.get_part(self.selection) return self.get_part(ref) - # ----- undo --------------------------------------------------------- + # ----- undo / redo -------------------------------------------------- def _checkpoint(self) -> None: self._undo.append(json.dumps(self._raw(), sort_keys=True)) 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: if not self._undo: raise SceneError("Nothing to undo.") - snapshot = json.loads(self._undo.pop()) - restored = Scene.from_dict(snapshot) - restored._undo = self._undo - self.__dict__.update(restored.__dict__) + self._redo.append(json.dumps(self._raw(), sort_keys=True)) + self._restore(json.loads(self._undo.pop())) 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 --------------------------------------------------- def place(self, stock: str, length_in: float) -> Part: self._checkpoint() @@ -333,6 +352,7 @@ class Scene: def _raw(self) -> dict: d = asdict(self) d.pop("_undo", None) + d.pop("_redo", None) return d def to_dict(self) -> dict: @@ -359,6 +379,7 @@ class Scene: _next_part=data.get("_next_part", len(parts) + 1), _next_joint=data.get("_next_joint", len(joints) + 1), _undo=data.get("_undo", []), + _redo=data.get("_redo", []), ) def save(self, path: Path | None = None) -> Path: diff --git a/tests/test_gui_controller.py b/tests/test_gui_controller.py new file mode 100644 index 0000000..0e9fd29 --- /dev/null +++ b/tests/test_gui_controller.py @@ -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() diff --git a/tests/test_scene.py b/tests/test_scene.py index 545f096..111e831 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -133,6 +133,39 @@ def test_copy_and_set_length_and_rename(): 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(): s = Scene() s.place("2x4", 24)