Add 3D orientation, richer operations, and cut list

3D orientation (the key gap): boards now have yaw/tilt/roll, so legs and
uprights can stand vertically. geometry.py and viewer.py apply the full
rotation; join is orientation-aware (vertical boards rest their base on the
target face). Old rotation_deg scenes migrate transparently.

New operations + CLI subcommands + wood-* tools: stand, lay, rotate, move,
trim (cut to length), copy, rename (human aliases, resolvable by name), clear.
Parts resolve by id OR name.

Cut list (cutlist.py): grouped cut list, board-feet (nominal), and an 8'-stick
shopping estimate with waste — the workshop-assistant payoff.

Driver: auto-discovers all wood-* tools (glob), richer prompt that decomposes
"build a table" into place/stand/join/move and labels parts. Verified: one
sentence -> an 8-board table base with a correct cut list.

14 wood-* CmdForge tools regenerated. 36 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 01:42:33 -03:00
parent fa03ee71d3
commit 914c86303f
10 changed files with 568 additions and 119 deletions

View File

@ -1,6 +1,11 @@
"""Generate the wood-* CmdForge tools: the documented woodworking command """Generate the wood-* CmdForge tools: the documented woodworking command
vocabulary. Each is a thin wrapper over the `woodshop` CLI so the logic lives in vocabulary. Each is a thin wrapper over the `woodshop` CLI so the logic lives in
one place; pa-load-tools turns these into Claude function schemas.""" one place; pa-load-tools turns these into Claude function schemas.
The arg descriptions ARE the LLM's documentation — keep them clear and example-rich.
Run this after changing the woodshop CLI to refresh the tools:
python scripts/gen_wood_tools.py
"""
import os import os
import stat import stat
from pathlib import Path from pathlib import Path
@ -10,117 +15,164 @@ import yaml
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python" CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python"
CMDFORGE_DIR = Path.home() / ".cmdforge" CMDFORGE_DIR = Path.home() / ".cmdforge"
BIN_DIR = Path.home() / ".local" / "bin" BIN_DIR = Path.home() / ".local" / "bin"
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")' WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
PLACE = f'''import subprocess, os
{WS}
r = subprocess.run([ws, "place", stock, length], capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
'''
JOIN = f'''import subprocess, os def code(body: str) -> str:
{WS} """Wrap a command-building body that sets `cmd`, then runs it."""
cmd = [ws, "join", part_b] return (f"import subprocess, os\n{WS}\n{body}\n"
if to: cmd += ["--to", to] "r = subprocess.run(cmd, capture_output=True, text=True)\n"
if angle: cmd += ["--angle", str(angle)] "out = (r.stdout + r.stderr).strip()\n")
if offset: cmd += ["--offset", offset]
if anchor: cmd += ["--anchor", anchor]
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
'''
SAND = f'''import subprocess, os
{WS}
cmd = [ws, "sand"] + ([part] if part else [])
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
'''
DELETE = f'''import subprocess, os
{WS}
cmd = [ws, "delete"] + ([part] if part else [])
r = subprocess.run(cmd, capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
'''
UNDO = f'''import subprocess, os
{WS}
r = subprocess.run([ws, "undo"], capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
'''
TOOLS = { TOOLS = {
"wood-place": { "wood-place": {
"description": "Place a new board of dimensional lumber into the scene. Use for any 'place', 'add', 'put', 'grab', 'cut me a' board command.", "description": "Place a new board of dimensional lumber. Use for 'place', 'add', 'put', 'grab', 'cut me a' board.",
"arguments": [ "arguments": [
{"flag": "--stock", "variable": "stock", {"flag": "--stock", "variable": "stock", "description": "Nominal lumber size, e.g. 2x4, 2x6, 1x4, 4x4"},
"description": "Nominal lumber size, e.g. 2x4, 2x6, 1x4, 4x4"}, {"flag": "--length", "variable": "length", "description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
{"flag": "--length", "variable": "length",
"description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
], ],
"code": PLACE, "code": code('cmd = [ws, "place", stock, length]'),
}, },
"wood-join": { "wood-join": {
"description": "Attach/join one board to another at an angle, optionally offset along the target board. Use for 'attach', 'join', 'connect', 'fasten', 'screw to'.", "description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.",
"arguments": [ "arguments": [
{"flag": "--part-b", "variable": "part_b", {"flag": "--part-b", "variable": "part_b", "description": "Id or name of the board being attached, e.g. p2"},
"description": "Id of the board being attached, e.g. p2"}, {"flag": "--to", "variable": "to", "default": "", "description": "Board to attach to, e.g. p1 or 'front rail'. Omit for the most recent board."},
{"flag": "--to", "variable": "to", "default": "", {"flag": "--angle", "variable": "angle", "default": "90", "description": "Angle in degrees between the boards (default 90)"},
"description": "Id of the board to attach to, e.g. p1. Omit to use the most recently touched board."}, {"flag": "--offset", "variable": "offset", "default": "", "description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the end."},
{"flag": "--angle", "variable": "angle", "default": "90", {"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"},
"description": "Angle in degrees between the two boards (default 90)"},
{"flag": "--offset", "variable": "offset", "default": "",
"description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the very end."},
{"flag": "--anchor", "variable": "anchor", "default": "end_b",
"description": "Measure offset from 'end_a' (start) or 'end_b' (far end)"},
], ],
"code": JOIN, "code": code(
'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'
'if anchor: cmd += ["--anchor", anchor]'
),
},
"wood-stand": {
"description": "Stand a board up vertically (e.g. a table or chair leg). Use for 'stand up', 'make it vertical', 'upright'.",
"arguments": [
{"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)]'),
},
"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 [])'),
},
"wood-rotate": {
"description": "Rotate / re-orient a board. Use for 'rotate', 'turn', 'angle it'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
{"flag": "--yaw", "variable": "yaw", "default": "", "description": "Heading in the horizontal plane, degrees"},
{"flag": "--tilt", "variable": "tilt", "default": "", "description": "Elevation toward vertical, degrees"},
{"flag": "--roll", "variable": "roll", "default": "", "description": "Rotation about the board's own length, degrees"},
],
"code": code(
'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)]'
),
},
"wood-move": {
"description": "Move/slide a board by an offset (or to an absolute position). Use for 'move', 'slide', 'shift', 'nudge'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
{"flag": "--dx", "variable": "dx", "default": "", "description": "X offset, e.g. '5 in', '-2 ft' (X = along the first board)"},
{"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"},
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset (up/down)"},
],
"code": code(
'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]'
),
},
"wood-trim": {
"description": "Cut a board down to a new length. Use for 'cut it to', 'trim to', 'shorten to', 'make it N feet'.",
"arguments": [
{"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 [])'),
},
"wood-copy": {
"description": "Duplicate a board, offset by dx/dy/dz. Use for 'copy', 'duplicate', 'another one like that'.",
"arguments": [
{"flag": "--part", "variable": "part", "default": "", "description": "Board to copy (default: most recent)"},
{"flag": "--dx", "variable": "dx", "default": "", "description": "X offset for the copy, e.g. '46 in'"},
{"flag": "--dy", "variable": "dy", "default": "", "description": "Y offset"},
{"flag": "--dz", "variable": "dz", "default": "", "description": "Z offset"},
],
"code": code(
'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]'
),
},
"wood-rename": {
"description": "Give a board a human-friendly name so it can be referred to by name later. Use for 'call it', 'name it', 'this is the'.",
"arguments": [
{"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 [])'),
}, },
"wood-sand": { "wood-sand": {
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.", "description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
"arguments": [ "arguments": [
{"flag": "--part", "variable": "part", "default": "", {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."},
"description": "Id of the board to sand, e.g. p1. Omit to sand the most recently touched board ('it')."},
], ],
"code": SAND, "code": code('cmd = [ws, "sand"] + ([part] if part else [])'),
}, },
"wood-delete": { "wood-delete": {
"description": "Remove a board from the scene. Use for 'delete', 'remove', 'get rid of', 'scrap'.", "description": "Remove a board. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
"arguments": [ "arguments": [
{"flag": "--part", "variable": "part", "default": "", {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"},
"description": "Id of the board to delete, e.g. p2. Omit for the most recently touched board."},
], ],
"code": DELETE, "code": code('cmd = [ws, "delete"] + ([part] if part else [])'),
}, },
"wood-undo": { "wood-undo": {
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.", "description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
"arguments": [], "arguments": [],
"code": UNDO, "code": code('cmd = [ws, "undo"]'),
},
"wood-clear": {
"description": "Clear the whole scene and start over. Use for 'clear', 'start over', 'reset', 'new project'.",
"arguments": [],
"code": code('cmd = [ws, "clear"]'),
},
"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"]'),
}, },
} }
WRAPPER = '''#!/bin/bash WRAPPER = ('#!/bin/bash\n# CmdForge wrapper for \'{name}\'\n# Auto-generated - do not edit\n'
# CmdForge wrapper for '{name}' 'exec "{py}" -m cmdforge.runner "{name}" "$@"\n')
# Auto-generated - do not edit
exec "{py}" -m cmdforge.runner "{name}" "$@"
'''
for name, spec in TOOLS.items(): for name, spec in TOOLS.items():
tool_dir = CMDFORGE_DIR / name tool_dir = CMDFORGE_DIR / name
tool_dir.mkdir(parents=True, exist_ok=True) tool_dir.mkdir(parents=True, exist_ok=True)
config = { config = {
"name": name, "name": name, "description": spec["description"], "category": "Other",
"description": spec["description"], "version": "0.2.0", "arguments": spec["arguments"],
"category": "Other",
"version": "0.1.0",
"arguments": spec["arguments"],
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}], "steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
"output": "{out}", "output": "{out}",
} }
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False)) (tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
wrapper = BIN_DIR / name wrapper = BIN_DIR / name
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY)) 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) wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
print(f"created {name}: {tool_dir/'config.yaml'} + {wrapper}") print(f"created {name}")
print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}")

View File

@ -62,9 +62,53 @@ def cmd_undo(scene: Scene, args) -> str:
return scene.undo() return scene.undo()
def cmd_reset(scene: Scene, args) -> str: def cmd_stand(scene: Scene, args) -> str:
scene.__dict__.update(Scene().__dict__) part = scene.stand(args.part, tilt_deg=args.tilt)
return "Cleared the scene." how = "standing up" if part.is_vertical else f"tilted to {args.tilt:g}°"
return f"Set {part.id} {how}."
def cmd_lay(scene: Scene, args) -> str:
part = scene.stand(args.part, tilt_deg=0.0)
return f"Laid {part.id} flat."
def cmd_rotate(scene: Scene, args) -> str:
part = scene.orient(args.part, yaw=args.yaw, tilt=args.tilt, roll=args.roll)
return (f"Oriented {part.id}: yaw {part.yaw_deg:g}°, "
f"tilt {part.tilt_deg:g}°, roll {part.roll_deg:g}°.")
def cmd_move(scene: Scene, args) -> str:
dx = to_inches(args.dx, args.unit) if args.dx else 0.0
dy = to_inches(args.dy, args.unit) if args.dy else 0.0
dz = to_inches(args.dz, args.unit) if args.dz else 0.0
part = scene.move(args.part, dx, dy, dz, absolute=args.absolute)
verb = "Positioned" if args.absolute else "Moved"
return f"{verb} {part.id}."
def cmd_trim(scene: Scene, args) -> str:
length = to_inches(args.length, default_unit=args.unit)
part = scene.set_length(args.part, length)
return f"Cut {part.id} to {_fmt_len(length)}."
def cmd_copy(scene: Scene, args) -> str:
dx = to_inches(args.dx, args.unit) if args.dx else 0.0
dy = to_inches(args.dy, args.unit) if args.dy else 0.0
dz = to_inches(args.dz, args.unit) if args.dz else 0.0
part = scene.copy(args.part, dx, dy, dz)
return f"Copied to {part.id}."
def cmd_rename(scene: Scene, args) -> str:
part = scene.rename(args.part, args.name)
return f"Named {part.id} '{part.name}'."
def cmd_clear(scene: Scene, args) -> str:
return scene.clear()
def cmd_export(scene: Scene, args) -> str: def cmd_export(scene: Scene, args) -> str:
@ -73,12 +117,32 @@ def cmd_export(scene: Scene, args) -> str:
return f"Exported {len(scene.parts)} part(s) to {path}." return f"Exported {len(scene.parts)} part(s) to {path}."
def cmd_cutlist(scene: Scene, args) -> str:
from .cutlist import format_cutlist # lazy
return format_cutlist(scene)
def _describe_part(p) -> str:
bits = [f"{_fmt_len(p.length_in)} {p.stock}"]
if p.name:
bits.append(f'"{p.name}"')
if p.is_vertical:
bits.append("vertical")
elif p.tilt_deg:
bits.append(f"tilt {p.tilt_deg:g}°")
if p.yaw_deg:
bits.append(f"yaw {p.yaw_deg:g}°")
if p.finishes:
bits.append(f"[{', '.join(p.finishes)}]")
return f" {p.id}: " + ", ".join(bits)
def cmd_status(scene: Scene, args) -> str: def cmd_status(scene: Scene, args) -> str:
if not scene.parts:
return "The scene is empty."
lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); " lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); "
f"selection: {scene.selection or 'none'}"] f"selection: {scene.selection or 'none'}"]
for p in scene.parts: lines += [_describe_part(p) for p in scene.parts]
fin = f" [{', '.join(p.finishes)}]" if p.finishes else ""
lines.append(f" {p.id}: {_fmt_len(p.length_in)} {p.stock}{fin}")
return "\n".join(lines) return "\n".join(lines)
@ -111,12 +175,57 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("part", nargs="?", default=None) sp.add_argument("part", nargs="?", default=None)
sp.set_defaults(func=cmd_delete) sp.set_defaults(func=cmd_delete)
sp = sub.add_parser("stand", help="Stand a board up (vertical), e.g. a leg")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--tilt", type=float, default=90.0, help="Tilt degrees (90 = straight up)")
sp.set_defaults(func=cmd_stand)
sp = sub.add_parser("lay", help="Lay a board flat (horizontal)")
sp.add_argument("part", nargs="?", default=None)
sp.set_defaults(func=cmd_lay)
sp = sub.add_parser("rotate", help="Set a board's orientation angles")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--yaw", type=float, default=None, help="Heading in the XY plane")
sp.add_argument("--tilt", type=float, default=None, help="Elevation toward vertical")
sp.add_argument("--roll", type=float, default=None, help="Rotation about the board's axis")
sp.set_defaults(func=cmd_rotate)
sp = sub.add_parser("move", help="Move a board by an offset (or set its position)")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--dx", default=None, help="e.g. '5 in', '-2 ft'")
sp.add_argument("--dy", default=None)
sp.add_argument("--dz", default=None)
sp.add_argument("--absolute", action="store_true", help="Treat dx/dy/dz as absolute position")
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_move)
sp = sub.add_parser("trim", help="Cut a board to a new length")
sp.add_argument("length", help="New length, e.g. '4 ft'")
sp.add_argument("--part", default=None)
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_trim)
sp = sub.add_parser("copy", help="Duplicate a board, offset by dx/dy/dz")
sp.add_argument("part", nargs="?", default=None)
sp.add_argument("--dx", default=None)
sp.add_argument("--dy", default=None)
sp.add_argument("--dz", default=None)
sp.add_argument("--unit", default="inch")
sp.set_defaults(func=cmd_copy)
sp = sub.add_parser("rename", help="Give a board a human-friendly name")
sp.add_argument("name", help="e.g. 'front-left leg'")
sp.add_argument("--part", default=None)
sp.set_defaults(func=cmd_rename)
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)
sub.add_parser("cutlist", help="Show the cut list / bill of materials").set_defaults(func=cmd_cutlist)
sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo) sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo)
sub.add_parser("reset", help="Clear the scene").set_defaults(func=cmd_reset) 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) sub.add_parser("status", help="Show the scene").set_defaults(func=cmd_status)
return p return p
@ -129,7 +238,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"): if args.command not in ("status", "export", "cutlist"):
scene.save(args.scene) scene.save(args.scene)
print(message) print(message)
return 0 return 0

77
src/woodshop/cutlist.py Normal file
View File

@ -0,0 +1,77 @@
"""Cut list, board-feet, and a stock shopping estimate.
This is the workshop-assistant payoff: turn the model into something you can
actually build from. Board-feet use NOMINAL dimensions (the lumber-industry
convention) parsed from the stock name; the shopping estimate assumes standard
8-foot sticks.
"""
from __future__ import annotations
import math
from collections import defaultdict
from .scene import Scene
STICK_LENGTH_IN = 96.0 # a standard 8' stick
def nominal_dims(stock: str) -> tuple[float, float]:
"""'2x4' -> (2.0, 4.0). Falls back to (1, 1) for odd names."""
try:
t, w = stock.lower().split("x")[:2]
return float(t), float(w)
except (ValueError, IndexError):
return 1.0, 1.0
def board_feet(stock: str, length_in: float) -> float:
t, w = nominal_dims(stock)
return t * w * length_in / 144.0
def _fmt_len(inches: float) -> str:
feet, rem = divmod(round(inches, 2), 12)
if feet and rem:
return f"{int(feet)}' {rem:g}\""
if feet:
return f"{int(feet)}'"
return f'{rem:g}"'
def cut_rows(scene: Scene) -> list[dict]:
"""One row per distinct (stock, length), with a count."""
groups: dict[tuple[str, float], int] = defaultdict(int)
for p in scene.parts:
groups[(p.stock, round(p.length_in, 2))] += 1
rows = []
for (stock, length), count in sorted(groups.items()):
rows.append({
"stock": stock, "length_in": length, "count": count,
"board_feet": board_feet(stock, length) * count,
})
return rows
def shopping(scene: Scene) -> dict[str, int]:
"""Sticks of standard length to buy per stock (by total length, +10% waste)."""
total: dict[str, float] = defaultdict(float)
for p in scene.parts:
total[p.stock] += p.length_in
return {stock: math.ceil(length * 1.10 / STICK_LENGTH_IN)
for stock, length in sorted(total.items())}
def format_cutlist(scene: Scene) -> str:
if not scene.parts:
return "Nothing to cut yet — the scene is empty."
rows = cut_rows(scene)
lines = ["CUT LIST"]
for r in rows:
lines.append(f" {r['count']:>2} × {r['stock']:<5} @ {_fmt_len(r['length_in']):<8}"
f" ({r['board_feet']:.1f} bd-ft)")
total_bf = sum(r["board_feet"] for r in rows)
lines.append(f" Total: {len(scene.parts)} board(s), {total_bf:.1f} board-feet")
lines.append("SHOPPING (8' sticks, +10% waste)")
for stock, sticks in shopping(scene).items():
lines.append(f" {sticks} × {stock}")
return "\n".join(lines)

View File

@ -22,7 +22,7 @@ import re
import subprocess import subprocess
import sys import sys
WOOD_TOOLS = ["wood-place", "wood-join", "wood-sand", "wood-delete", "wood-undo"] TOOL_FILTER = "wood-*" # auto-discover every wood-* tool, no hardcoded list
REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling
# A board placed earlier in the SAME utterance is referenced as $1, $2, ... # A board placed earlier in the SAME utterance is referenced as $1, $2, ...
@ -35,7 +35,7 @@ def _run(cmd: list[str], stdin: str = "") -> str:
def load_schemas() -> str: def load_schemas() -> str:
return _run(["pa-load-tools", "--tools", ",".join(WOOD_TOOLS), "--format", "anthropic"]) return _run(["pa-load-tools", "--filter", TOOL_FILTER, "--format", "anthropic"])
def scene_summary() -> str: def scene_summary() -> str:
@ -44,7 +44,8 @@ def scene_summary() -> str:
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \ SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \
user's spoken command into a JSON array of tool calls. user's spoken command into a JSON array of tool calls that build/modify a 3D model \
of furniture from dimensional lumber.
Tools (JSON schemas): Tools (JSON schemas):
{schemas} {schemas}
@ -55,11 +56,16 @@ Current scene:
Rules: Rules:
- Respond with ONLY a JSON array. No prose, no markdown fences. - Respond with ONLY a JSON array. No prose, no markdown fences.
- Each element is {{"tool": "<name>", "args": {{...}}}}. - Each element is {{"tool": "<name>", "args": {{...}}}}.
- Refer to boards that ALREADY exist by their real id (p1, p2, ...). - Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name.
- For a board you place earlier in THIS response, refer to it later as $1, $2, ... - For a board you place earlier in THIS response, refer to it later as $1, $2, ...
numbered by the order you place boards in this response (the first wood-place is $1). numbered by the order you place boards in this response (the first wood-place is $1).
- For wood-join, "part_b" is the board being attached (it gets moved); "to" is the - Legs and uprights must be stood up: place the board, then wood-stand it.
board it attaches to. Anchor is "end" (far end) or "start". - For wood-join, "part_b" is the board being attached (it gets moved into place);
"to" is the board it attaches to. Anchor is "end" (far end) or "start".
- Decompose multi-step requests (e.g. "build a table frame") into the full sequence
of place/stand/join/move calls. Use wood-rename to label important parts (legs, rails).
- For questions like "what do I have" or "cut list / how much wood", call
wood-cutlist (or answer with "say").
- If the command is ambiguous or not about woodworking, return a single - If the command is ambiguous or not about woodworking, return a single
{{"tool": "say", "args": {{"text": "<short question or reply>"}}}}. {{"tool": "say", "args": {{"text": "<short question or reply>"}}}}.

View File

@ -22,7 +22,10 @@ def part_solid(part: Part):
thickness, width = part.section_in thickness, width = part.section_in
box = Box(length, width, thickness) # X=length, Y=width, Z=thickness box = Box(length, width, thickness) # X=length, Y=width, Z=thickness
box = Pos(length / 2, 0, 0) * box # move start (end_a) to origin box = Pos(length / 2, 0, 0) * box # move start (end_a) to origin
box = Rot(0, 0, part.rotation_deg) * box # orient in the XY plane # roll about its own axis (X), tilt up toward Z (about Y), then heading (Z).
box = Rot(X=part.roll_deg) * box
box = Rot(Y=-part.tilt_deg) * box
box = Rot(Z=part.yaw_deg) * box
box = Pos(*part.position_in) * box # place in the scene box = Pos(*part.position_in) * box # place in the scene
return box return box

View File

@ -19,7 +19,7 @@ import copy
import json import json
import math import math
import os import os
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, fields, asdict
from pathlib import Path from pathlib import Path
from .lumber import actual_section, normalize_stock from .lumber import actual_section, normalize_stock
@ -43,20 +43,29 @@ class Part:
length_in: float length_in: float
section_in: tuple[float, float] # (thickness, width) section_in: tuple[float, float] # (thickness, width)
position_in: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.0]) position_in: list[float] = field(default_factory=lambda: [0.0, 0.0, 0.0])
rotation_deg: float = 0.0 # about Z, in the XY plane yaw_deg: float = 0.0 # heading about Z, in the XY plane
tilt_deg: float = 0.0 # elevation from horizontal toward +Z (90 = standing up)
roll_deg: float = 0.0 # rotation about the board's own length axis
name: str = "" # optional human alias, e.g. "front-left leg"
finishes: list[str] = field(default_factory=list) finishes: list[str] = field(default_factory=list)
def axis_unit(self) -> tuple[float, float]: def axis_unit(self) -> tuple[float, float, float]:
a = math.radians(self.rotation_deg) """Unit vector along the board's length, from end_a toward end_b."""
return (math.cos(a), math.sin(a)) yaw, tilt = math.radians(self.yaw_deg), math.radians(self.tilt_deg)
c = math.cos(tilt)
return (math.cos(yaw) * c, math.sin(yaw) * c, math.sin(tilt))
@property
def is_vertical(self) -> bool:
return abs(self.tilt_deg) > 45
def end_point(self) -> list[float]: def end_point(self) -> list[float]:
"""The far end (end_b) of the board in world space.""" """The far end (end_b) of the board in world space."""
ux, uy = self.axis_unit() ux, uy, uz = self.axis_unit()
return [ return [
self.position_in[0] + ux * self.length_in, self.position_in[0] + ux * self.length_in,
self.position_in[1] + uy * self.length_in, self.position_in[1] + uy * self.length_in,
self.position_in[2], self.position_in[2] + uz * self.length_in,
] ]
@ -88,13 +97,14 @@ class Scene:
# ----- lookup ------------------------------------------------------- # ----- lookup -------------------------------------------------------
def get_part(self, ref: str) -> Part: def get_part(self, ref: str) -> Part:
for p in self.parts: for p in self.parts:
if p.id == ref: if p.id == ref or (p.name and p.name.lower() == ref.lower()):
return p return p
raise SceneError(f"No part {ref!r}. Parts: {[p.id for p in self.parts] or 'none'}") labels = [p.id + (f" ({p.name})" if p.name else "") for p in self.parts]
raise SceneError(f"No part {ref!r}. Parts: {labels or 'none'}")
def resolve(self, ref: str | None) -> Part: def resolve(self, ref: str | None) -> Part:
"""Resolve a part reference, defaulting to the current selection.""" """Resolve a part reference (id, name, or 'it'), defaulting to selection."""
if ref in (None, "", "it", "selection", "current"): if ref in (None, "", "it", "selection", "current", "that", "this"):
if not self.selection: if not self.selection:
raise SceneError("No part is selected; say which board.") raise SceneError("No part is selected; say which board.")
return self.get_part(self.selection) return self.get_part(self.selection)
@ -146,17 +156,19 @@ class Scene:
a = self.resolve(part_a) a = self.resolve(part_a)
b = self.get_part(part_b) b = self.get_part(part_b)
# Distance measured along A from its start. # Attach point: distance measured along A's axis from the chosen end.
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0) along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
ux, uy = a.axis_unit() ux, uy, uz = a.axis_unit()
# Stack B on A's top face (in Z) so the boards rest against each other # B inherits A's heading plus the requested angle, but keeps its own
# instead of interpenetrating at the centerlines, as real lumber would. # tilt/roll (so a board you stood up stays standing when attached).
stack_z = a.section_in[0] / 2 + b.section_in[0] / 2 b.yaw_deg = a.yaw_deg + angle_deg
attach = [a.position_in[0] + ux * along, # Rest B against A's top face. A horizontal B sits its full half-thickness
# above the face; a vertical B sets its base (end_a) on the face. cos(tilt)
# blends smoothly between the two.
stack_z = a.section_in[0] / 2 + (b.section_in[0] / 2) * math.cos(math.radians(b.tilt_deg))
b.position_in = [a.position_in[0] + ux * along,
a.position_in[1] + uy * along, a.position_in[1] + uy * along,
a.position_in[2] + stack_z] a.position_in[2] + uz * along + stack_z]
b.position_in = attach
b.rotation_deg = a.rotation_deg + angle_deg
jid = f"j{self._next_joint}" jid = f"j{self._next_joint}"
self._next_joint += 1 self._next_joint += 1
@ -166,6 +178,83 @@ class Scene:
self.selection = b.id self.selection = b.id
return joint return joint
def stand(self, ref: str | None, tilt_deg: float = 90.0) -> Part:
"""Tilt a board up toward vertical (90 = standing straight up)."""
self._checkpoint()
part = self.resolve(ref)
part.tilt_deg = float(tilt_deg)
self.selection = part.id
return part
def orient(self, ref: str | None, yaw: float | None = None,
tilt: float | None = None, roll: float | None = None) -> Part:
"""Set any of the board's orientation angles (degrees)."""
self._checkpoint()
part = self.resolve(ref)
if yaw is not None:
part.yaw_deg = float(yaw)
if tilt is not None:
part.tilt_deg = float(tilt)
if roll is not None:
part.roll_deg = float(roll)
self.selection = part.id
return part
def move(self, ref: str | None, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0,
absolute: bool = False) -> Part:
"""Translate a board by (dx, dy, dz), or set its position if absolute."""
self._checkpoint()
part = self.resolve(ref)
if absolute:
part.position_in = [float(dx), float(dy), float(dz)]
else:
part.position_in = [part.position_in[0] + dx,
part.position_in[1] + dy,
part.position_in[2] + dz]
self.selection = part.id
return part
def set_length(self, ref: str | None, length_in: float) -> Part:
"""Cut a board to a new length."""
self._checkpoint()
part = self.resolve(ref)
part.length_in = float(length_in)
self.selection = part.id
return part
def copy(self, ref: str | None, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0) -> Part:
"""Duplicate a board, offset by (dx, dy, dz)."""
self._checkpoint()
src = self.resolve(ref)
pid = f"p{self._next_part}"
self._next_part += 1
clone = Part(id=pid, stock=src.stock, length_in=src.length_in,
section_in=src.section_in,
position_in=[src.position_in[0] + dx, src.position_in[1] + dy,
src.position_in[2] + dz],
yaw_deg=src.yaw_deg, tilt_deg=src.tilt_deg, roll_deg=src.roll_deg,
finishes=list(src.finishes))
self.parts.append(clone)
self.selection = pid
return clone
def rename(self, ref: str | None, name: str) -> Part:
"""Give a board a human-friendly alias, e.g. 'front-left leg'."""
self._checkpoint()
part = self.resolve(ref)
part.name = name.strip()
self.selection = part.id
return part
def clear(self) -> str:
self._checkpoint()
self.parts = []
self.joints = []
self.selection = None
self._next_part = 1
self._next_joint = 1
return "Cleared the scene."
def delete(self, ref: str | None) -> str: def delete(self, ref: str | None) -> str:
self._checkpoint() self._checkpoint()
part = self.resolve(ref) part = self.resolve(ref)
@ -188,8 +277,14 @@ class Scene:
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Scene": def from_dict(cls, data: dict) -> "Scene":
parts = [Part(**{**p, "section_in": tuple(p["section_in"])}) parts = []
for p in data.get("parts", [])] valid = {f.name for f in fields(Part)}
for p in data.get("parts", []):
p = dict(p)
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
p["yaw_deg"] = p.pop("rotation_deg")
p["section_in"] = tuple(p["section_in"])
parts.append(Part(**{k: v for k, v in p.items() if k in valid}))
joints = [Joint(**j) for j in data.get("joints", [])] joints = [Joint(**j) for j in data.get("joints", [])]
return cls( return cls(
version=data.get("version", SCENE_VERSION), version=data.get("version", SCENE_VERSION),

View File

@ -11,12 +11,12 @@ import re
_FEET = r"(?:feet|foot|ft|')" _FEET = r"(?:feet|foot|ft|')"
_INCH = r"(?:inches|inch|in|\")" _INCH = r"(?:inches|inch|in|\")"
# e.g. "3 ft 6 in", "6 foot", "10 inches", "2'", "72" # e.g. "3 ft 6 in", "6 foot", "10 inches", "2'", "72", "-5"
_COMBINED = re.compile( _COMBINED = re.compile(
rf"^\s*(?:(?P<ft>[\d.]+)\s*{_FEET})?\s*(?:(?P<inch>[\d.]+)\s*{_INCH})?\s*$", rf"^\s*(?P<sign>-)?\s*(?:(?P<ft>[\d.]+)\s*{_FEET})?\s*(?:(?P<inch>[\d.]+)\s*{_INCH})?\s*$",
re.IGNORECASE, re.IGNORECASE,
) )
_BARE = re.compile(r"^\s*(?P<n>[\d.]+)\s*$") _BARE = re.compile(r"^\s*(?P<n>-?[\d.]+)\s*$")
def to_inches(value: str | float | int, default_unit: str = "inch") -> float: def to_inches(value: str | float | int, default_unit: str = "inch") -> float:
@ -38,6 +38,7 @@ def to_inches(value: str | float | int, default_unit: str = "inch") -> float:
if m and (m.group("ft") or m.group("inch")): if m and (m.group("ft") or m.group("inch")):
ft = float(m.group("ft") or 0) ft = float(m.group("ft") or 0)
inch = float(m.group("inch") or 0) inch = float(m.group("inch") or 0)
return ft * 12.0 + inch total = ft * 12.0 + inch
return -total if m.group("sign") else total
raise ValueError(f"Could not parse length: {value!r}") raise ValueError(f"Could not parse length: {value!r}")

View File

@ -28,7 +28,9 @@ def _part_mesh(part: Part):
thickness, width = part.section_in thickness, width = part.section_in
cube = pv.Cube(center=(length / 2, 0, 0), cube = pv.Cube(center=(length / 2, 0, 0),
x_length=length, y_length=width, z_length=thickness) x_length=length, y_length=width, z_length=thickness)
cube.rotate_z(part.rotation_deg, point=(0, 0, 0), inplace=True) cube.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True)
cube.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True)
cube.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True)
cube.translate(part.position_in, inplace=True) cube.translate(part.position_in, inplace=True)
return cube return cube

38
tests/test_cutlist.py Normal file
View File

@ -0,0 +1,38 @@
"""Tests for the cut list / board-feet / shopping estimate."""
import pytest
from woodshop.cutlist import board_feet, cut_rows, nominal_dims, shopping
from woodshop.scene import Scene
def test_nominal_dims():
assert nominal_dims("2x4") == (2.0, 4.0)
assert nominal_dims("4x4") == (4.0, 4.0)
def test_board_feet_uses_nominal():
# 2x4 at 96in = (2*4*96)/144 = 5.333 bd-ft
assert board_feet("2x4", 96) == pytest.approx(5.3333, abs=1e-3)
def test_cut_rows_groups_and_counts():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48)
s.place("2x4", 29)
rows = cut_rows(s)
by_len = {r["length_in"]: r for r in rows}
assert by_len[48.0]["count"] == 2
assert by_len[29.0]["count"] == 1
assert by_len[48.0]["board_feet"] == pytest.approx(board_feet("2x4", 48) * 2)
def test_shopping_rounds_up_with_waste():
s = Scene()
s.place("2x4", 48)
s.place("2x4", 48) # 96in total -> with 10% waste = 105.6 -> 2 sticks of 96in
assert shopping(s) == {"2x4": 2}
def test_empty_shopping():
assert shopping(Scene()) == {}

View File

@ -62,14 +62,80 @@ def test_the_example_sentence():
assert p2.position_in[0] == pytest.approx(62.0) assert p2.position_in[0] == pytest.approx(62.0)
# p2 rests on p1's top face: z = t_a/2 + t_b/2 = 0.75 + 0.75 # p2 rests on p1's top face: z = t_a/2 + t_b/2 = 0.75 + 0.75
assert p2.position_in[2] == pytest.approx(1.5) assert p2.position_in[2] == pytest.approx(1.5)
assert p2.rotation_deg == pytest.approx(90.0) assert p2.yaw_deg == pytest.approx(90.0)
# p2 now runs along +Y # p2 now runs along +Y
ux, uy = p2.axis_unit() ux, uy, uz = p2.axis_unit()
assert ux == pytest.approx(0.0, abs=1e-9) assert ux == pytest.approx(0.0, abs=1e-9)
assert uy == pytest.approx(1.0) assert uy == pytest.approx(1.0)
assert uz == pytest.approx(0.0, abs=1e-9)
assert len(s.joints) == 1 assert len(s.joints) == 1
def test_stand_makes_board_vertical():
s = Scene()
s.place("2x4", 30)
s.stand("it")
p = s.get_part("p1")
assert p.is_vertical
ux, uy, uz = p.axis_unit()
assert uz == pytest.approx(1.0) # length axis points straight up
assert (ux, uy) == pytest.approx((0.0, 0.0), abs=1e-9)
assert p.end_point()[2] == pytest.approx(30.0) # top is 30in up
def test_join_preserves_vertical_tilt():
"""A stood-up leg stays vertical when attached to a horizontal apron."""
s = Scene()
s.place("2x4", 48) # p1 apron
s.place("2x4", 29) # p2 leg
s.stand("p2")
s.join("p1", "p2", angle_deg=0, offset_in=0, anchor="end_a")
leg = s.get_part("p2")
assert leg.is_vertical
# base sits on the apron's top face (z = t_a/2 = 0.75) since the leg is vertical
assert leg.position_in[2] == pytest.approx(0.75)
def test_move_relative_and_absolute():
s = Scene()
s.place("2x4", 24)
s.move("it", dx=5, dy=2, dz=1)
assert s.get_part("p1").position_in == [5.0, 2.0, 1.0]
s.move("it", dx=10, dy=0, dz=0, absolute=True)
assert s.get_part("p1").position_in == [10.0, 0.0, 0.0]
def test_copy_and_set_length_and_rename():
s = Scene()
s.place("2x4", 24)
s.rename("p1", "front rail")
assert s.get_part("front rail").id == "p1" # resolvable by alias
clone = s.copy("p1", dy=10)
assert clone.id == "p2"
assert clone.position_in[1] == 10.0
s.set_length("p2", 36)
assert s.get_part("p2").length_in == 36.0
def test_clear():
s = Scene()
s.place("2x4", 24)
s.place("2x4", 24)
s.clear()
assert s.parts == [] and s.selection is None
def test_migrate_old_rotation_field(tmp_path):
"""Scenes saved with the old rotation_deg field still load."""
import json
old = {"version": 1, "parts": [{"id": "p1", "stock": "2x4", "length_in": 24,
"section_in": [1.5, 3.5], "position_in": [0, 0, 0], "rotation_deg": 45}]}
path = tmp_path / "old.json"
path.write_text(json.dumps(old))
s = Scene.load(path)
assert s.get_part("p1").yaw_deg == 45
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"):