diff --git a/CLAUDE.md b/CLAUDE.md index e6afd98..ffc9a8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,17 @@ small local model) for reliable structured tool-calling. | Command | Purpose | |---------|---------| -| `woodshop ` | CLI: `place`, `join`, `sand`, `delete`, `undo`, `export`, `status` | -| `woodshop-view` | Live 3D viewport (watches `scene.json`) | +| `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) | Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`. +Named projects: `~/.local/share/woodshop/projects/.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) @@ -64,14 +70,14 @@ pytest # 25 tests ### Known limitations / next steps -1. **No vertical orientation.** Boards only rotate in the horizontal (XY) plane - (`rotation_deg` about Z). Furniture legs that "stand up" (length along Z) - aren't representable yet — this is the top priority for real furniture. -2. **Joins stack in Z** (board B rests on B's top face). This avoids - interpenetration but isn't true joinery (no butt/mortise/lap geometry). -3. **Latency** ~7–13s per utterance (one `claude -p` call). Fine for now. -4. Voice path (`--voice`) reuses `dictate`; not yet exercised on real hardware - in this repo's tests. +1. **Joins stack in Z** (board B rests on A's top face). This avoids + interpenetration and handles vertical legs, but isn't true joinery (no + butt/mortise/lap geometry). +2. **Latency** ~7–13s per utterance (one `claude -p` call). +3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against + failures but the mic path isn't exercised in the unit tests. +4. Auto-placement of parts in a multi-step "build a table" request depends on + the LLM choosing good offsets; geometry is correct but corners may need nudging. ## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals diff --git a/README.md b/README.md index c672381..177caa8 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,105 @@ # 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 ```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 -*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 ```bash -# Clone the repository -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 +pytest # 41 tests ``` +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 ~7–13s per utterance (one `claude -p` call). + ## License -*TODO: Add license* +MIT diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 8380da4..53366f4 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -156,6 +156,25 @@ TOOLS = { "arguments": [], "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' diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 387ffc1..5750394 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -111,6 +111,30 @@ def cmd_clear(scene: Scene, args) -> str: 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: from .geometry import export # lazy: keeps build123d out of the core path path = export(scene, args.path) @@ -219,6 +243,16 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--part", default=None) 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.add_argument("path", help="Output file, e.g. table.stl or table.step") sp.set_defaults(func=cmd_export) @@ -238,7 +272,7 @@ def main(argv: list[str] | None = None) -> int: except (SceneError, ValueError, KeyError) as exc: print(str(exc).strip('"'), file=sys.stderr) return 1 - if args.command not in ("status", "export", "cutlist"): + if args.command not in ("status", "export", "cutlist", "save", "projects"): scene.save(args.scene) print(message) return 0 diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index e9b584e..0bb2a73 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -131,13 +131,46 @@ def speak(text: str) -> None: 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: calls = interpret(utterance, schemas) messages = dispatch(calls, verbose=verbose) - summary = " ".join(m for m in messages if m).strip() - print(f"WoodShop: {summary}") - if voice and summary: - speak(summary) + full = " ".join(m for m in messages if m).strip() + spoken = summarize(calls, messages) + print(f"WoodShop: {full or spoken}") + if voice: + speak(spoken) def get_utterance(voice: bool, duration: int) -> str | None: @@ -178,7 +211,10 @@ def main(argv: list[str] | None = None) -> int: return 0 if utterance.lower() in ("quit", "exit", "stop", "done"): return 0 - handle(utterance, schemas, voice=args.voice, verbose=not args.quiet) + try: + 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__": diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 9cd41a0..22aeb37 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -27,13 +27,36 @@ from .lumber import actual_section, normalize_stock SCENE_VERSION = 1 +def _data_dir() -> Path: + return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop" + + def default_scene_path() -> Path: """Where the active scene lives (override with $WOODSHOP_SCENE).""" env = os.environ.get("WOODSHOP_SCENE") if env: return Path(env).expanduser() - base = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() - return base / "woodshop" / "scene.json" + return _data_dir() / "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 diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index 856558a..6cb8d30 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -35,34 +35,61 @@ def _part_mesh(part: Part): 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: + import pyvista as pv + plotter.clear() + plotter.clear_actors() + labels, label_pts = [], [] for i, part in enumerate(scene.parts): edge = part.id == scene.selection plotter.add_mesh( _part_mesh(part), - color=_PALETTE[i % len(_PALETTE)], + color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)], show_edges=True, 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) - plotter.add_text(f"{n} part(s) | selection: {scene.selection or '-'}", - font_size=10, name="hud") - if n: - plotter.add_axes() + if label_pts: + plotter.add_point_labels( + 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() def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None: import pyvista as pv + _quiet_vtk() scene_path = Path(scene_path) if scene_path else default_scene_path() plotter = pv.Plotter(title="WoodShop") plotter.set_background("#2b2b2b") + plotter.enable_parallel_projection() last_mtime = -1.0 scene = Scene.load(scene_path) if scene_path.exists() else Scene() _render(plotter, scene) + plotter.view_isometric() plotter.show(interactive_update=True, auto_close=False) while True: diff --git a/tests/test_driver.py b/tests/test_driver.py index 8f25568..a967c03 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -57,6 +57,28 @@ def test_interpret_tolerates_fenced_json(monkeypatch): 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): monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean") calls = driver.interpret("blah", schemas="[]") diff --git a/tests/test_scene.py b/tests/test_scene.py index 1bd8033..dfdbc10 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -136,6 +136,24 @@ def test_migrate_old_rotation_field(tmp_path): 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(): s = Scene() with pytest.raises(SceneError, match="selected"):