Polish viewport, add named projects, concise voice summaries, docs

Viewport (woodshop-view): part labels (id/name), dimensioned floor grid in
inches, parallel-projection isometric default, selection highlight, quieter VTK.

Named projects: woodshop save/open/projects (slugified names under
~/.local/share/woodshop/projects/); wood-save/open/projects tools.

Driver: concise spoken summaries (verb+count roll-up so "build a table" speaks
one short line, not 12; queries/clarifications spoken verbatim); per-utterance
errors no longer kill the session; auto-discovers all wood-* tools.

Docs: real README and CLAUDE.md (architecture, full command set, limitations).
17 wood-* tools. 41 tests passing.

Verified end-to-end: "build a coffee table" and "make a bookshelf side frame"
each produce correct multi-board models with cut lists and STEP/STL export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 01:50:07 -03:00
parent 914c86303f
commit 892a376669
9 changed files with 294 additions and 43 deletions

View File

@ -41,11 +41,17 @@ small local model) for reliable structured tool-calling.
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `woodshop <op>` | CLI: `place`, `join`, `sand`, `delete`, `undo`, `export`, `status` | | `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`) | | `woodshop-view` | Live 3D viewport (watches `scene.json`; labels, grid, isometric) |
| `woodshop-talk` | Conversational driver (`--voice` for mic, `--once "..."` for one command) | | `woodshop-talk` | Conversational driver (`--voice` for mic, `--once "..."` for one command) |
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`.
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick
shopping estimate.
### CmdForge tools (the documented command vocabulary) ### CmdForge tools (the documented command vocabulary)
@ -64,14 +70,14 @@ pytest # 25 tests
### Known limitations / next steps ### Known limitations / next steps
1. **No vertical orientation.** Boards only rotate in the horizontal (XY) plane 1. **Joins stack in Z** (board B rests on A's top face). This avoids
(`rotation_deg` about Z). Furniture legs that "stand up" (length along Z) interpenetration and handles vertical legs, but isn't true joinery (no
aren't representable yet — this is the top priority for real furniture. butt/mortise/lap geometry).
2. **Joins stack in Z** (board B rests on B's top face). This avoids 2. **Latency** ~713s per utterance (one `claude -p` call).
interpenetration but isn't true joinery (no butt/mortise/lap geometry). 3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
3. **Latency** ~713s per utterance (one `claude -p` call). Fine for now. failures but the mic path isn't exercised in the unit tests.
4. Voice path (`--voice`) reuses `dictate`; not yet exercised on real hardware 4. Auto-placement of parts in a multi-step "build a table" request depends on
in this repo's tests. the LLM choosing good offsets; geometry is correct but corners may need nudging.
## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals ## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals

104
README.md
View File

@ -1,39 +1,105 @@
# WoodShop # WoodShop
Voice-driven conversational 3D woodworking & furniture modeler **Voice-driven conversational 3D woodworking & furniture modeler.**
Talk to it like the Star Trek holodeck and watch furniture build itself:
> *"Place a 6 foot 2x4, sand it, then attach a 2 foot 2x4 at 90 degrees, 10 inches from the end."*
> *"Build a coffee table: a four foot by two foot frame from 2x4s, with four legs 18 inches tall standing at the corners."*
Each board is real dimensional lumber (a 2x4 is modeled at its true 1.5″ × 3.5″),
so the result is buildable — export to **STEP** (CAD/CNC) or **STL** (3D print),
and get a **cut list with board-feet and a shopping estimate**.
## How it works
WoodShop reuses the existing [CmdForge](https://gitea.brrd.tech/rob/cmdforge)
tool ecosystem for everything that isn't woodworking-specific, so no wheels are
reinvented:
```
woodshop-talk ── the conversational loop
│ dictate ............. speech → text (CmdForge tool)
│ pa-load-tools ....... wood-* → Claude schemas (CmdForge tool)
│ claude -p ........... interpret → tool calls (provider)
│ pa-execute-tool ..... dispatch each wood-* (CmdForge tool)
│ read-aloud .......... speak confirmation (CmdForge tool)
scene.json ← single source of truth (parts, joints, selection, undo)
▲ │ writes
│ reads/mutates ▼
wood-* CmdForge tools woodshop-view
(place/join/stand/move/...) live pyvista 3D, watches scene.json
```
The `wood-*` tools are thin wrappers over the `woodshop` CLI, so the modeling
logic lives in one place and the tools double as the LLM's documented command
vocabulary.
## Installation ## Installation
```bash ```bash
pip install -e . python -m venv .venv && source .venv/bin/activate
pip install -e ".[viewer,dev]" # 'viewer' pulls build123d + pyvista
python scripts/gen_wood_tools.py # register the wood-* CmdForge tools
``` ```
## Usage ## Usage
*TODO: Add usage instructions* ```bash
woodshop-view & # live 3D window (watches the scene)
woodshop-talk # type commands; --voice to speak them
woodshop-talk --once "build a workbench top from five 2x6 boards 6 feet long"
```
## Documentation Or drive it directly from the CLI:
Full documentation is available at: https://pages.brrd.tech/rob/woodshop/ ```bash
woodshop place 2x4 "6 ft" # place a board
woodshop stand # stand it up (a leg)
woodshop join p2 --to p1 --angle 90 --offset "10 in"
woodshop rename "front-left leg"
woodshop cutlist # bill of materials
woodshop export table.step # STEP / STL export
woodshop save "coffee table" # named projects
woodshop open "coffee table"
```
Run `woodshop --help` for the full command list (place, join, stand, lay,
rotate, move, trim, copy, rename, sand, delete, undo, clear, status, cutlist,
export, save, open, projects).
The active scene lives at `$WOODSHOP_SCENE` or
`~/.local/share/woodshop/scene.json`; named projects in
`~/.local/share/woodshop/projects/`.
## Development ## Development
```bash ```bash
# Clone the repository pytest # 41 tests
git clone https://gitea.brrd.tech/rob/woodshop.git
cd woodshop
# Create virtual environment
python -m venv .venv
source .venv/bin/activate
# Install for development
pip install -e ".[dev]"
# Run tests
pytest
``` ```
Key modules:
| Module | Role |
|--------|------|
| `scene.py` | Part/Joint/Scene model, operations, undo, persistence |
| `lumber.py` | nominal → actual dimensional lumber table |
| `units.py` | parse "6 ft" / "3 ft 6 in" / "-2 ft" → inches |
| `cli.py` | the `woodshop` command |
| `geometry.py` | build123d solids + STL/STEP export |
| `cutlist.py` | cut list, board-feet, shopping estimate |
| `viewer.py` | live pyvista 3D viewport (`woodshop-view`) |
| `driver.py` | conversational loop (`woodshop-talk`) |
| `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools |
### Known limitations
- Joints rest boards on each other's faces (Z-stacking); no true mortise/lap
joinery geometry yet.
- Command interpretation latency is ~713s per utterance (one `claude -p` call).
## License ## License
*TODO: Add license* MIT

View File

@ -156,6 +156,25 @@ TOOLS = {
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "cutlist"]'), "code": code('cmd = [ws, "cutlist"]'),
}, },
"wood-save": {
"description": "Save the current design as a named project. Use for 'save this as', 'save the project', 'remember this design'.",
"arguments": [
{"flag": "--name", "variable": "name", "description": "Project name, e.g. 'coffee table'"},
],
"code": code('cmd = [ws, "save", name]'),
},
"wood-open": {
"description": "Open a previously saved project (replaces the current scene). Use for 'open', 'load the', 'go back to my'.",
"arguments": [
{"flag": "--name", "variable": "name", "description": "Name of the project to open"},
],
"code": code('cmd = [ws, "open", name]'),
},
"wood-projects": {
"description": "List saved projects. Use for 'what projects do I have', 'list my designs'.",
"arguments": [],
"code": code('cmd = [ws, "projects"]'),
},
} }
WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n' WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'

View File

@ -111,6 +111,30 @@ def cmd_clear(scene: Scene, args) -> str:
return scene.clear() return scene.clear()
def cmd_save(scene: Scene, args) -> str:
from .scene import project_path
path = scene.save(project_path(args.name))
return f"Saved project '{args.name}' ({len(scene.parts)} parts)."
def cmd_open(scene: Scene, args) -> str:
from .scene import project_path
path = project_path(args.name)
if not path.exists():
from .scene import list_projects
avail = ", ".join(list_projects()) or "none"
raise SceneError(f"No project '{args.name}'. Available: {avail}")
loaded = Scene.load(path)
scene.__dict__.update(loaded.__dict__)
return f"Opened project '{args.name}' ({len(scene.parts)} parts)."
def cmd_projects(scene: Scene, args) -> str:
from .scene import list_projects
names = list_projects()
return "Saved projects: " + (", ".join(names) if names else "none yet")
def cmd_export(scene: Scene, args) -> str: def cmd_export(scene: Scene, args) -> str:
from .geometry import export # lazy: keeps build123d out of the core path from .geometry import export # lazy: keeps build123d out of the core path
path = export(scene, args.path) path = export(scene, args.path)
@ -219,6 +243,16 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--part", default=None) sp.add_argument("--part", default=None)
sp.set_defaults(func=cmd_rename) sp.set_defaults(func=cmd_rename)
sp = sub.add_parser("save", help="Save the current scene as a named project")
sp.add_argument("name", help="Project name, e.g. 'coffee table'")
sp.set_defaults(func=cmd_save)
sp = sub.add_parser("open", help="Open a saved project")
sp.add_argument("name", help="Project name to open")
sp.set_defaults(func=cmd_open)
sub.add_parser("projects", help="List saved projects").set_defaults(func=cmd_projects)
sp = sub.add_parser("export", help="Export the scene to STL or STEP") sp = sub.add_parser("export", help="Export the scene to STL or STEP")
sp.add_argument("path", help="Output file, e.g. table.stl or table.step") sp.add_argument("path", help="Output file, e.g. table.stl or table.step")
sp.set_defaults(func=cmd_export) sp.set_defaults(func=cmd_export)
@ -238,7 +272,7 @@ def main(argv: list[str] | None = None) -> int:
except (SceneError, ValueError, KeyError) as exc: except (SceneError, ValueError, KeyError) as exc:
print(str(exc).strip('"'), file=sys.stderr) print(str(exc).strip('"'), file=sys.stderr)
return 1 return 1
if args.command not in ("status", "export", "cutlist"): if args.command not in ("status", "export", "cutlist", "save", "projects"):
scene.save(args.scene) scene.save(args.scene)
print(message) print(message)
return 0 return 0

View File

@ -131,13 +131,46 @@ def speak(text: str) -> None:
subprocess.run(["read-aloud", "--strip-md", "true"], input=text, text=True) subprocess.run(["read-aloud", "--strip-md", "true"], input=text, text=True)
# Concise spoken verbs per tool (for a short summary instead of reading every line).
_VERB = {
"wood-place": "placed", "wood-join": "joined", "wood-stand": "stood up",
"wood-lay": "laid flat", "wood-rotate": "rotated", "wood-move": "moved",
"wood-trim": "cut", "wood-copy": "copied", "wood-rename": "named",
"wood-sand": "sanded", "wood-delete": "removed", "wood-undo": "undid",
"wood-clear": "cleared the scene", "wood-save": "saved", "wood-open": "opened",
}
# Tools whose text output IS the answer and should be spoken verbatim.
_QUERY_TOOLS = {"wood-cutlist", "wood-projects"}
def summarize(calls: list[dict], messages: list[str]) -> str:
"""A short, speakable summary. Verbatim for queries/clarifications; otherwise
a verb+count roll-up so building a table doesn't read 12 sentences aloud."""
from collections import Counter
verbatim = [m for c, m in zip(calls, messages)
if c.get("tool") in _QUERY_TOOLS or c.get("tool") == "say"]
if verbatim:
return " ".join(verbatim).strip()
counts = Counter(c.get("tool", "") for c in calls)
chunks = []
for tool, n in counts.items():
verb = _VERB.get(tool)
if not verb:
continue
chunks.append(verb if "scene" in verb or n == 1 else f"{verb} {n}")
return ("Done — " + ", ".join(chunks) + ".") if chunks else "Done."
def handle(utterance: str, schemas: str, voice: bool, verbose: bool) -> None: def handle(utterance: str, schemas: str, voice: bool, verbose: bool) -> None:
calls = interpret(utterance, schemas) calls = interpret(utterance, schemas)
messages = dispatch(calls, verbose=verbose) messages = dispatch(calls, verbose=verbose)
summary = " ".join(m for m in messages if m).strip() full = " ".join(m for m in messages if m).strip()
print(f"WoodShop: {summary}") spoken = summarize(calls, messages)
if voice and summary: print(f"WoodShop: {full or spoken}")
speak(summary) if voice:
speak(spoken)
def get_utterance(voice: bool, duration: int) -> str | None: def get_utterance(voice: bool, duration: int) -> str | None:
@ -178,7 +211,10 @@ def main(argv: list[str] | None = None) -> int:
return 0 return 0
if utterance.lower() in ("quit", "exit", "stop", "done"): if utterance.lower() in ("quit", "exit", "stop", "done"):
return 0 return 0
try:
handle(utterance, schemas, voice=args.voice, verbose=not args.quiet) handle(utterance, schemas, voice=args.voice, verbose=not args.quiet)
except Exception as exc: # never let one bad command kill the session
print(f"WoodShop: sorry, that command failed ({exc}).")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -27,13 +27,36 @@ from .lumber import actual_section, normalize_stock
SCENE_VERSION = 1 SCENE_VERSION = 1
def _data_dir() -> Path:
return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop"
def default_scene_path() -> Path: def default_scene_path() -> Path:
"""Where the active scene lives (override with $WOODSHOP_SCENE).""" """Where the active scene lives (override with $WOODSHOP_SCENE)."""
env = os.environ.get("WOODSHOP_SCENE") env = os.environ.get("WOODSHOP_SCENE")
if env: if env:
return Path(env).expanduser() return Path(env).expanduser()
base = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() return _data_dir() / "scene.json"
return base / "woodshop" / "scene.json"
def slugify(name: str) -> str:
return "-".join("".join(c if c.isalnum() else " " for c in name).split()).lower()
def projects_dir() -> Path:
return _data_dir() / "projects"
def project_path(name: str) -> Path:
slug = slugify(name)
if not slug:
raise ValueError("Please give the project a name.")
return projects_dir() / f"{slug}.json"
def list_projects() -> list[str]:
d = projects_dir()
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
@dataclass @dataclass

View File

@ -35,34 +35,61 @@ def _part_mesh(part: Part):
return cube return cube
def _quiet_vtk() -> None:
"""Stop VTK from spamming warnings (esp. headless) through Python logging."""
try:
import vtk
vtk.vtkObject.GlobalWarningDisplayOff()
except Exception:
pass
def _render(plotter, scene: Scene) -> None: def _render(plotter, scene: Scene) -> None:
import pyvista as pv
plotter.clear() plotter.clear()
plotter.clear_actors()
labels, label_pts = [], []
for i, part in enumerate(scene.parts): for i, part in enumerate(scene.parts):
edge = part.id == scene.selection edge = part.id == scene.selection
plotter.add_mesh( plotter.add_mesh(
_part_mesh(part), _part_mesh(part),
color=_PALETTE[i % len(_PALETTE)], color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)],
show_edges=True, show_edges=True,
line_width=3 if edge else 1, line_width=3 if edge else 1,
edge_color="yellow" if edge else "black", edge_color="black",
smooth_shading=False,
) )
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)
label_pts.append(mid)
n = len(scene.parts) n = len(scene.parts)
plotter.add_text(f"{n} part(s) | selection: {scene.selection or '-'}", if label_pts:
font_size=10, name="hud") plotter.add_point_labels(
if n: label_pts, labels, font_size=12, text_color="white",
shape_color="#222222", shape_opacity=0.5, point_size=1,
name="labels", always_visible=True,
)
plotter.show_grid(color="#555555", xtitle="X (in)", ytitle="Y (in)", ztitle="Z (in)")
plotter.add_text(f"WoodShop — {n} part(s) | selection: {scene.selection or '-'}",
font_size=11, color="white", name="hud")
plotter.add_axes() plotter.add_axes()
def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None: def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None:
import pyvista as pv import pyvista as pv
_quiet_vtk()
scene_path = Path(scene_path) if scene_path else default_scene_path() scene_path = Path(scene_path) if scene_path else default_scene_path()
plotter = pv.Plotter(title="WoodShop") plotter = pv.Plotter(title="WoodShop")
plotter.set_background("#2b2b2b") plotter.set_background("#2b2b2b")
plotter.enable_parallel_projection()
last_mtime = -1.0 last_mtime = -1.0
scene = Scene.load(scene_path) if scene_path.exists() else Scene() scene = Scene.load(scene_path) if scene_path.exists() else Scene()
_render(plotter, scene) _render(plotter, scene)
plotter.view_isometric()
plotter.show(interactive_update=True, auto_close=False) plotter.show(interactive_update=True, auto_close=False)
while True: while True:

View File

@ -57,6 +57,28 @@ def test_interpret_tolerates_fenced_json(monkeypatch):
assert calls == [{"tool": "wood-undo", "args": {}}] assert calls == [{"tool": "wood-undo", "args": {}}]
def test_summarize_rolls_up_many_ops():
calls = ([{"tool": "wood-place", "args": {}}] * 8
+ [{"tool": "wood-join", "args": {}}] * 2
+ [{"tool": "wood-stand", "args": {}}] * 4)
summary = driver.summarize(calls, [""] * len(calls))
assert "placed 8" in summary
assert "joined 2" in summary
assert "stood up 4" in summary
assert len(summary) < 80 # short enough to speak
def test_summarize_speaks_queries_verbatim():
calls = [{"tool": "wood-cutlist", "args": {}}]
messages = ["CUT LIST\n 2 x 2x4 ..."]
assert driver.summarize(calls, messages).startswith("CUT LIST")
def test_summarize_speaks_clarification():
calls = [{"tool": "say", "args": {"text": "which end?"}}]
assert driver.summarize(calls, ["which end?"]) == "which end?"
def test_interpret_handles_garbage(monkeypatch): def test_interpret_handles_garbage(monkeypatch):
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean") monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean")
calls = driver.interpret("blah", schemas="[]") calls = driver.interpret("blah", schemas="[]")

View File

@ -136,6 +136,24 @@ def test_migrate_old_rotation_field(tmp_path):
assert s.get_part("p1").yaw_deg == 45 assert s.get_part("p1").yaw_deg == 45
def test_slugify():
from woodshop.scene import slugify
assert slugify("Coffee Table!") == "coffee-table"
assert slugify(" My Bench ") == "my-bench"
def test_project_save_open_list(tmp_path, monkeypatch):
import woodshop.scene as scene_mod
monkeypatch.setattr(scene_mod, "_data_dir", lambda: tmp_path)
s = Scene()
s.place("2x4", 48)
s.place("2x4", 24)
s.save(scene_mod.project_path("coffee table"))
assert scene_mod.list_projects() == ["coffee-table"]
reopened = Scene.load(scene_mod.project_path("Coffee Table")) # name normalizes
assert len(reopened.parts) == 2
def test_resolve_it_without_selection_errors(): def test_resolve_it_without_selection_errors():
s = Scene() s = Scene()
with pytest.raises(SceneError, match="selected"): with pytest.raises(SceneError, match="selected"):