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:
parent
914c86303f
commit
892a376669
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -41,11 +41,17 @@ small local model) for reliable structured tool-calling.
|
|||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `woodshop <op>` | CLI: `place`, `join`, `sand`, `delete`, `undo`, `export`, `status` |
|
||||
| `woodshop-view` | Live 3D viewport (watches `scene.json`) |
|
||||
| `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) |
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
104
README.md
104
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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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="[]")
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
Loading…
Reference in New Issue