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:
rob 2026-05-29 11:05:39 -03:00
parent b24e65548e
commit e9422aa133
17 changed files with 958 additions and 25 deletions

View File

@ -41,9 +41,23 @@ small local model) for reliable structured tool-calling.
| 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-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 <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-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/<slug>.json`.

View File

@ -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"
```

View File

@ -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",

View File

@ -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": [],

View File

@ -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)

View File

@ -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))

View File

@ -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.
"""

30
src/woodshop/gui/app.py Normal file
View File

@ -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())

View File

@ -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)))

View File

@ -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)

View File

@ -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.
"""

124
src/woodshop/gui/panels.py Normal file
View File

@ -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())

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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()

View File

@ -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)