Portability + consistency polish (Codex review)

Housekeeping over features, per Codex: consistency and portability now matter
more than another feature.

- driver.scene_summary no longer hardcodes ~/PycharmProjects/.venv/bin/woodshop;
  new driver.woodshop_cmd() resolves the CLI portably (PATH, else `python -m
  woodshop`). Used by the voice/GUI status path.
- scripts/gen_wood_tools.py: CMDFORGE_PY overridable via env; generated tool
  bodies resolve `woodshop` at RUNTIME (shutil.which → python -m woodshop), no
  baked-in local path; file-writing moved under main()/__main__ (was running at
  import); PyYAML declared under dev deps.
- cutlist.py: drop the misleading "+10% waste" label — shopping already uses the
  kerf-aware CutPlan nesting.
- Docs refreshed (README + CLAUDE): real test count, parametric joinery is
  modeled, new cutplan/prices/estimate/inventory/colors modules + GUI windows,
  portable tool regeneration.
- tests: driver path discovery (PATH + module fallback), generated tool bodies
  compile and contain no hardcoded paths. 207 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 21:56:28 -03:00
parent 01c4dee0bc
commit 970b88bc7b
8 changed files with 149 additions and 60 deletions

View File

@ -83,14 +83,26 @@ board-feet / 8' sticks).
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.
set with `rename`. Each part also carries `material` + `finish` (raw/sanded/
clear/stain/paint) + `finish_color`, which drive the viewer color and cost.
**Shop output & inventory subsystems** (all deterministic; AI only narrates):
`cutplan.py` is the keystone — kerf-aware cutting-stock nesting into a JSON-
serializable `CutPlan` (rough-vs-final sanding allowance, batch quantity, owned-
offcut reuse, grouped by (stock, material)). `prices.py` = editable Kent-NB price
book + material-aware cost; `estimate.py` = full quote (consumables + labor +
suggested price); `inventory.py` = shop-wide event-sourced stock/offcut/build
ledger; `instructions.py`/`jigs.py` = build steps + jig suggestions. The GUI
**Cut List & BOM** window (`gui/bom_window.py`) and **Inventory** window
(`gui/inventory_window.py`) render these. `cutlist.py` is a quick text summary.
### CmdForge tools (the documented command vocabulary)
`wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in
`~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with
`/tmp/gen_wood_tools.py` (kept in the repo plan) if their schemas change. The
`python scripts/gen_wood_tools.py` if their schemas change (set `CMDFORGE_PY` to
point at your cmdforge interpreter; needs PyYAML). The generated wrappers resolve
`woodshop` at runtime (PATH, else `python -m woodshop`), so they're portable. The
arg descriptions ARE the LLM's documentation, so keep them clear.
### Setup
@ -98,7 +110,7 @@ arg descriptions ARE the LLM's documentation, so keep them clear.
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista
pytest # 25 tests
pytest # 200+ tests
```
### Known limitations / next steps

View File

@ -96,28 +96,37 @@ The active scene lives at `$WOODSHOP_SCENE` or
## Development
```bash
pytest # 41 tests
pytest # 200+ tests
```
Key modules:
| Module | Role |
|--------|------|
| `scene.py` | Part/Joint/Scene model, operations, undo, persistence |
| `lumber.py` | nominal → actual dimensional lumber table |
| `scene.py` | Part/Joint/Connection/Feature/Scene model, ops, undo, persistence |
| `lumber.py` | nominal → actual dimensions, material colors/defaults |
| `colors.py` | color name → hex + lightness blends for the viewer |
| `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 |
| `geometry.py` | build123d solids (incl. joinery booleans) + STL/STEP export |
| `cutlist.py` | quick cut list / board-feet / shopping summary |
| `cutplan.py` | the deterministic keystone: kerf-aware nesting, rough/final, batch, offcut reuse |
| `prices.py` | editable price book (Kent NB) + material-aware cost estimate |
| `estimate.py` | project quote: consumables + labor + suggested selling price |
| `inventory.py` | shop-wide event-sourced stock / offcut / build ledger |
| `instructions.py` / `jigs.py` | deterministic build steps & jig suggestions |
| `viewer.py` | live pyvista 3D viewport (`woodshop-view`) |
| `driver.py` | conversational loop (`woodshop-talk`) |
| `gui/` | the unified PySide6 studio (`woodshop-gui`), incl. the Cut List & BOM window |
| `scripts/gen_wood_tools.py` | (re)generate the `wood-*` CmdForge tools |
### Known limitations
- Joins are flush butt joints: B's end sits against A's face and B aligns to
A's reference corner (tops level + one side flush), so mixed-size boards line
up. Joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet.
- **Joinery** is parametric (tenon/mortise/dado/rabbet/hole/slot/chamfer as
build123d boolean ops, with connections/assemblies); what's *not* modeled is
joinery-fit compensation for material lost to sanding, and lap/pocket-hole
presets. Boards still attach as flush butt joints unless you add features.
- Render is flat colors per material/finish — no image textures (wood grain) yet.
- Command interpretation latency is ~713s per utterance (one `claude -p` call).
## License

View File

@ -32,6 +32,7 @@ gui = [
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"PyYAML>=6.0", # scripts/gen_wood_tools.py emits CmdForge tool YAML
]
[tool.setuptools.packages.find]

View File

@ -7,20 +7,31 @@ Run this after changing the woodshop CLI to refresh the tools:
python scripts/gen_wood_tools.py
"""
import os
import shutil
import stat
import sys
from pathlib import Path
import yaml
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python"
# The cmdforge interpreter for the generated wrappers — overridable via env so
# this script isn't pinned to one machine's pipx layout.
CMDFORGE_PY = os.environ.get(
"CMDFORGE_PY",
str(Path.home() / ".local" / "share" / "pipx" / "venvs" / "cmdforge" / "bin" / "python"))
CMDFORGE_DIR = Path.home() / ".cmdforge"
BIN_DIR = Path.home() / ".local" / "bin"
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
# Resolved at TOOL RUNTIME (not generation time) so the tools are portable:
# the installed `woodshop` entry point if on PATH, else `python -m woodshop`.
WS = ("_ws = shutil.which('woodshop')\n"
"ws = [_ws] if _ws else [sys.executable, '-m', 'woodshop']")
def code(body: str) -> str:
"""Wrap a command-building body that sets `cmd`, then runs it."""
return (f"import subprocess, os\n{WS}\n{body}\n"
"""Wrap a command-building body that sets `cmd`, then runs it. `ws` is the
resolved woodshop command PREFIX (a list)."""
return (f"import subprocess, shutil, sys\n{WS}\n{body}\n"
"r = subprocess.run(cmd, capture_output=True, text=True)\n"
"out = (r.stdout + r.stderr).strip()\n")
@ -34,7 +45,7 @@ TOOLS = {
{"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"},
],
"code": code(
'cmd = [ws, "place", stock, length]\n'
'cmd = ws + ["place", stock, length]\n'
'if width != "": cmd += ["--width", str(width)]'
),
},
@ -48,7 +59,7 @@ TOOLS = {
{"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"},
],
"code": code(
'cmd = [ws, "join", part_b]\n'
'cmd = ws + ["join", part_b]\n'
'if to: cmd += ["--to", to]\n'
'if angle: cmd += ["--angle", str(angle)]\n'
'if offset: cmd += ["--offset", offset]\n'
@ -61,14 +72,14 @@ TOOLS = {
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
{"flag": "--tilt", "variable": "tilt", "default": "90", "description": "Tilt degrees, 90 = straight up (default 90)"},
],
"code": code('cmd = [ws, "stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
"code": code('cmd = ws + ["stand"] + ([part] if part else []) + ["--tilt", str(tilt)]'),
},
"wood-lay": {
"description": "Lay a board flat / horizontal. Use for 'lay it down', 'make it flat', 'horizontal'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit for the most recent board."},
],
"code": code('cmd = [ws, "lay"] + ([part] if part else [])'),
"code": code('cmd = ws + ["lay"] + ([part] if part else [])'),
},
"wood-rotate": {
"description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.",
@ -79,7 +90,7 @@ TOOLS = {
{"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"},
],
"code": code(
'cmd = [ws, "rotate"] + ([part] if part else [])\n'
'cmd = ws + ["rotate"] + ([part] if part else [])\n'
'if yaw != "": cmd += ["--yaw", str(yaw)]\n'
'if tilt != "": cmd += ["--tilt", str(tilt)]\n'
'if roll != "": cmd += ["--roll", str(roll)]'
@ -94,7 +105,7 @@ TOOLS = {
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"},
],
"code": code(
'cmd = [ws, "move"] + ([part] if part else [])\n'
'cmd = ws + ["move"] + ([part] if part else [])\n'
'if dx != "": cmd += ["--dx", dx]\n'
'if dy != "": cmd += ["--dy", dy]\n'
'if dz != "": cmd += ["--dz", dz]'
@ -106,7 +117,7 @@ TOOLS = {
{"flag": "--length", "variable": "length", "description": "New length with units, e.g. '4 ft'"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
],
"code": code('cmd = [ws, "trim", length] + (["--part", part] if part else [])'),
"code": code('cmd = ws + ["trim", length] + (["--part", part] if part else [])'),
},
"wood-copy": {
"description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.",
@ -117,7 +128,7 @@ TOOLS = {
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"},
],
"code": code(
'cmd = [ws, "copy"] + ([part] if part else [])\n'
'cmd = ws + ["copy"] + ([part] if part else [])\n'
'if dx != "": cmd += ["--dx", dx]\n'
'if dy != "": cmd += ["--dy", dy]\n'
'if dz != "": cmd += ["--dz", dz]'
@ -129,43 +140,43 @@ TOOLS = {
{"flag": "--name", "variable": "name", "description": "The name, e.g. 'front-left leg'"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id (default: most recent)"},
],
"code": code('cmd = [ws, "rename", name] + (["--part", part] if part else [])'),
"code": code('cmd = ws + ["rename", name] + (["--part", part] if part else [])'),
},
"wood-sand": {
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."},
],
"code": code('cmd = [ws, "sand"] + ([part] if part else [])'),
"code": code('cmd = ws + ["sand"] + ([part] if part else [])'),
},
"wood-delete": {
"description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
],
"code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
"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]'),
"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"]'),
"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"]'),
"code": code('cmd = ws + ["redo"]'),
},
"wood-clear": {
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
"arguments": [],
"code": code('cmd = [ws, "clear"]'),
"code": code('cmd = ws + ["clear"]'),
},
"wood-feature": {
"description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.",
@ -182,7 +193,7 @@ TOOLS = {
{"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"},
],
"code": code(
'cmd = [ws, "feature", kind]\n'
'cmd = ws + ["feature", kind]\n'
'if part: cmd += ["--part", part]\n'
'if face: cmd += ["--face", face]\n'
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
@ -197,75 +208,81 @@ TOOLS = {
{"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"},
{"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"},
],
"code": code('cmd = [ws, "connect", anchor, moving]'),
"code": code('cmd = ws + ["connect", anchor, moving]'),
},
"wood-explode": {
"description": "Back connected boards off along their joint axes for an exploded view. Use for 'explode', 'back off the connections', 'show it pre-assembled'.",
"arguments": [
{"flag": "--distance", "variable": "distance", "description": "How far to separate, e.g. '3 in'"},
],
"code": code('cmd = [ws, "explode", distance]'),
"code": code('cmd = ws + ["explode", distance]'),
},
"wood-assemble": {
"description": "Re-seat all connections (reverse an explode / re-fit the joints). Use for 'assemble', 'put it back together', 're-fit', 'close it up'.",
"arguments": [],
"code": code('cmd = [ws, "assemble"]'),
"code": code('cmd = ws + ["assemble"]'),
},
"wood-disconnect": {
"description": "Break a connection so the pieces become independent (they stay where they are). Use for 'disconnect', 'break the connection', 'separate them'.",
"arguments": [
{"flag": "--connection", "variable": "connection", "description": "Connection id, e.g. c1"},
],
"code": code('cmd = [ws, "disconnect", connection]'),
"code": code('cmd = ws + ["disconnect", connection]'),
},
"wood-feature-delete": {
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
"arguments": [
{"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"},
],
"code": code('cmd = [ws, "feature-delete", fid]'),
"code": code('cmd = ws + ["feature-delete", fid]'),
},
"wood-cutlist": {
"description": "Report the cut list / bill of materials: every board, board-feet, and how much lumber to buy. Use for 'cut list', 'what do I need to buy', 'bill of materials', 'how much wood'.",
"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]'),
"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]'),
"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"]'),
"code": code('cmd = ws + ["projects"]'),
},
}
WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'
'exec "{py}" -m cmdforge.runner "{name}" "$@"\n')
for name, spec in TOOLS.items():
tool_dir = CMDFORGE_DIR / name
tool_dir.mkdir(parents=True, exist_ok=True)
config = {
"name": name, "description": spec["description"], "category": "Other",
"version": "0.2.0", "arguments": spec["arguments"],
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
"output": "{out}",
}
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
wrapper = BIN_DIR / name
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
print(f"created {name}")
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
def main() -> None:
for name, spec in TOOLS.items():
tool_dir = CMDFORGE_DIR / name
tool_dir.mkdir(parents=True, exist_ok=True)
config = {
"name": name, "description": spec["description"], "category": "Other",
"version": "0.2.0", "arguments": spec["arguments"],
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
"output": "{out}",
}
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
wrapper = BIN_DIR / name
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
print(f"created {name}")
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")
if __name__ == "__main__":
main()

View File

@ -95,7 +95,7 @@ def format_cutlist(scene: Scene) -> str:
lines.append(" Total: " + tot)
if any(cut_length(p) > p.length_in for p in scene.parts):
lines.append(" (cut lengths include protruding tenons)")
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)")
lines.append("SHOPPING (8' sticks / 4×8 sheets, kerf-aware nesting)")
for stock, qty in shopping(scene).items():
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
lines.append(f" {qty} × {stock} {unit}")

View File

@ -17,8 +17,8 @@ from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
@ -39,9 +39,15 @@ def load_schemas() -> str:
return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"])
def woodshop_cmd() -> list[str]:
"""Resolve the `woodshop` CLI portably: the installed entry point if it's on
PATH, else `python -m woodshop` with the current interpreter."""
exe = shutil.which("woodshop")
return [exe] if exe else [sys.executable, "-m", "woodshop"]
def scene_summary() -> str:
ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")
return _run([ws, "status"]) or "empty"
return _run(woodshop_cmd() + ["status"]) or "empty"
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \

View File

@ -135,3 +135,14 @@ def test_handle_appends_to_history(monkeypatch):
history = []
driver.handle("hello", schemas="[]", voice=False, verbose=False, history=history)
assert history == [("hello", "hi there")]
def test_woodshop_cmd_prefers_path(monkeypatch):
monkeypatch.setattr(driver.shutil, "which", lambda name: "/opt/bin/woodshop")
assert driver.woodshop_cmd() == ["/opt/bin/woodshop"]
def test_woodshop_cmd_falls_back_to_module(monkeypatch):
monkeypatch.setattr(driver.shutil, "which", lambda name: None)
cmd = driver.woodshop_cmd()
assert cmd[1:] == ["-m", "woodshop"] and cmd[0] # python -m woodshop

33
tests/test_gen_tools.py Normal file
View File

@ -0,0 +1,33 @@
"""Smoke tests for the wood-* tool generator (the fragile external layer).
Loads the generator without running it (writes are under main()) and checks
every generated tool body is valid, portable Python."""
import importlib.util
from pathlib import Path
import pytest
pytest.importorskip("yaml")
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "gen_wood_tools.py"
def _load():
spec = importlib.util.spec_from_file_location("gen_wood_tools", _SCRIPT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # safe: file-writing is under main()
return mod
def test_every_tool_body_compiles_and_is_portable():
mod = _load()
assert mod.TOOLS and "wood-place" in mod.TOOLS
for name, spec in mod.TOOLS.items():
compile(spec["code"], name, "exec") # valid Python
assert "ws + [" in spec["code"] or "ws +[" in spec["code"] # uses resolved prefix
assert "PycharmProjects" not in spec["code"] # no hardcoded local path
def test_generator_resolves_woodshop_at_runtime():
mod = _load()
assert "shutil.which('woodshop')" in mod.WS
assert "PycharmProjects" not in mod.WS