From 914c86303f9202ff9f63e020dcdd6b637a86142f Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 01:42:33 -0300 Subject: [PATCH] Add 3D orientation, richer operations, and cut list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/gen_wood_tools.py | 200 ++++++++++++++++++++++++-------------- src/woodshop/cli.py | 125 ++++++++++++++++++++++-- src/woodshop/cutlist.py | 77 +++++++++++++++ src/woodshop/driver.py | 18 ++-- src/woodshop/geometry.py | 5 +- src/woodshop/scene.py | 141 ++++++++++++++++++++++----- src/woodshop/units.py | 9 +- src/woodshop/viewer.py | 4 +- tests/test_cutlist.py | 38 ++++++++ tests/test_scene.py | 70 ++++++++++++- 10 files changed, 568 insertions(+), 119 deletions(-) create mode 100644 src/woodshop/cutlist.py create mode 100644 tests/test_cutlist.py diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 835e3c7..8380da4 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -1,6 +1,11 @@ """Generate the wood-* CmdForge tools: the documented woodworking command 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 stat from pathlib import Path @@ -10,117 +15,164 @@ import yaml CMDFORGE_PY = "/home/rob/.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")' -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 -{WS} -cmd = [ws, "join", part_b] -if to: cmd += ["--to", to] -if angle: cmd += ["--angle", str(angle)] -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() -''' +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" + "r = subprocess.run(cmd, capture_output=True, text=True)\n" + "out = (r.stdout + r.stderr).strip()\n") -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 = { "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": [ - {"flag": "--stock", "variable": "stock", - "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": "--stock", "variable": "stock", "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'"}, ], - "code": PLACE, + "code": code('cmd = [ws, "place", stock, length]'), }, "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": [ - {"flag": "--part-b", "variable": "part_b", - "description": "Id of the board being attached, e.g. p2"}, - {"flag": "--to", "variable": "to", "default": "", - "description": "Id of the board to attach to, e.g. p1. Omit to use the most recently touched board."}, - {"flag": "--angle", "variable": "angle", "default": "90", - "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)"}, + {"flag": "--part-b", "variable": "part_b", "description": "Id or name 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": "--angle", "variable": "angle", "default": "90", "description": "Angle in degrees between the boards (default 90)"}, + {"flag": "--offset", "variable": "offset", "default": "", "description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the end."}, + {"flag": "--anchor", "variable": "anchor", "default": "end_b", "description": "Measure offset from 'end' (far end) or 'start'"}, ], - "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": { "description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.", "arguments": [ - {"flag": "--part", "variable": "part", "default": "", - "description": "Id of the board to sand, e.g. p1. Omit to sand the most recently touched board ('it')."}, + {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name. Omit to sand the most recent board ('it')."}, ], - "code": SAND, + "code": code('cmd = [ws, "sand"] + ([part] if part else [])'), }, "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": [ - {"flag": "--part", "variable": "part", "default": "", - "description": "Id of the board to delete, e.g. p2. Omit for the most recently touched board."}, + {"flag": "--part", "variable": "part", "default": "", "description": "Board id or name (default: most recent)"}, ], - "code": DELETE, + "code": code('cmd = [ws, "delete"] + ([part] if part else [])'), }, "wood-undo": { "description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.", "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 -# CmdForge wrapper for '{name}' -# Auto-generated - do not edit -exec "{py}" -m cmdforge.runner "{name}" "$@" -''' +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.1.0", - "arguments": spec["arguments"], + "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}: {tool_dir/'config.yaml'} + {wrapper}") + print(f"created {name}") +print(f"\n{len(TOOLS)} wood-* tools written to {CMDFORGE_DIR} and {BIN_DIR}") diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 39c456f..387ffc1 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -62,9 +62,53 @@ def cmd_undo(scene: Scene, args) -> str: return scene.undo() -def cmd_reset(scene: Scene, args) -> str: - scene.__dict__.update(Scene().__dict__) - return "Cleared the scene." +def cmd_stand(scene: Scene, args) -> str: + part = scene.stand(args.part, tilt_deg=args.tilt) + 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: @@ -73,12 +117,32 @@ def cmd_export(scene: Scene, args) -> str: 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: + if not scene.parts: + return "The scene is empty." lines = [f"{len(scene.parts)} part(s), {len(scene.joints)} joint(s); " f"selection: {scene.selection or 'none'}"] - 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}") + lines += [_describe_part(p) for p in scene.parts] return "\n".join(lines) @@ -111,12 +175,57 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("part", nargs="?", default=None) 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.add_argument("path", help="Output file, e.g. table.stl or table.step") 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("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) return p @@ -129,7 +238,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"): + if args.command not in ("status", "export", "cutlist"): scene.save(args.scene) print(message) return 0 diff --git a/src/woodshop/cutlist.py b/src/woodshop/cutlist.py new file mode 100644 index 0000000..8692344 --- /dev/null +++ b/src/woodshop/cutlist.py @@ -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) diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index 5e99d9a..e9b584e 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -22,7 +22,7 @@ import re import subprocess 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 # 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: - 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: @@ -44,7 +44,8 @@ def scene_summary() -> str: 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): {schemas} @@ -55,11 +56,16 @@ Current scene: Rules: - Respond with ONLY a JSON array. No prose, no markdown fences. - Each element is {{"tool": "", "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, ... 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 - board it attaches to. Anchor is "end" (far end) or "start". +- Legs and uprights must be stood up: place the board, then wood-stand it. +- 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 {{"tool": "say", "args": {{"text": ""}}}}. diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index 2c59cb9..a2c5cb7 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -22,7 +22,10 @@ def part_solid(part: Part): thickness, width = part.section_in 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 = 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 return box diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 954ba2d..9cd41a0 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -19,7 +19,7 @@ import copy import json import math import os -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass, field, fields, asdict from pathlib import Path from .lumber import actual_section, normalize_stock @@ -43,20 +43,29 @@ class Part: length_in: float section_in: tuple[float, float] # (thickness, width) 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) - def axis_unit(self) -> tuple[float, float]: - a = math.radians(self.rotation_deg) - return (math.cos(a), math.sin(a)) + def axis_unit(self) -> tuple[float, float, float]: + """Unit vector along the board's length, from end_a toward end_b.""" + 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]: """The far end (end_b) of the board in world space.""" - ux, uy = self.axis_unit() + ux, uy, uz = self.axis_unit() return [ self.position_in[0] + ux * 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 ------------------------------------------------------- def get_part(self, ref: str) -> Part: for p in self.parts: - if p.id == ref: + if p.id == ref or (p.name and p.name.lower() == ref.lower()): 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: - """Resolve a part reference, defaulting to the current selection.""" - if ref in (None, "", "it", "selection", "current"): + """Resolve a part reference (id, name, or 'it'), defaulting to selection.""" + if ref in (None, "", "it", "selection", "current", "that", "this"): if not self.selection: raise SceneError("No part is selected; say which board.") return self.get_part(self.selection) @@ -146,17 +156,19 @@ class Scene: a = self.resolve(part_a) 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) - ux, uy = a.axis_unit() - # Stack B on A's top face (in Z) so the boards rest against each other - # instead of interpenetrating at the centerlines, as real lumber would. - stack_z = a.section_in[0] / 2 + b.section_in[0] / 2 - attach = [a.position_in[0] + ux * along, - a.position_in[1] + uy * along, - a.position_in[2] + stack_z] - b.position_in = attach - b.rotation_deg = a.rotation_deg + angle_deg + ux, uy, uz = a.axis_unit() + # B inherits A's heading plus the requested angle, but keeps its own + # tilt/roll (so a board you stood up stays standing when attached). + b.yaw_deg = a.yaw_deg + angle_deg + # 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[2] + uz * along + stack_z] jid = f"j{self._next_joint}" self._next_joint += 1 @@ -166,6 +178,83 @@ class Scene: self.selection = b.id 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: self._checkpoint() part = self.resolve(ref) @@ -188,8 +277,14 @@ class Scene: @classmethod def from_dict(cls, data: dict) -> "Scene": - parts = [Part(**{**p, "section_in": tuple(p["section_in"])}) - for p in data.get("parts", [])] + 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", [])] return cls( version=data.get("version", SCENE_VERSION), diff --git a/src/woodshop/units.py b/src/woodshop/units.py index d449144..4aed5da 100644 --- a/src/woodshop/units.py +++ b/src/woodshop/units.py @@ -11,12 +11,12 @@ import re _FEET = r"(?:feet|foot|ft|')" _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( - rf"^\s*(?:(?P[\d.]+)\s*{_FEET})?\s*(?:(?P[\d.]+)\s*{_INCH})?\s*$", + rf"^\s*(?P-)?\s*(?:(?P[\d.]+)\s*{_FEET})?\s*(?:(?P[\d.]+)\s*{_INCH})?\s*$", re.IGNORECASE, ) -_BARE = re.compile(r"^\s*(?P[\d.]+)\s*$") +_BARE = re.compile(r"^\s*(?P-?[\d.]+)\s*$") 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")): ft = float(m.group("ft") 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}") diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index efb47e1..856558a 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -28,7 +28,9 @@ def _part_mesh(part: Part): thickness, width = part.section_in cube = pv.Cube(center=(length / 2, 0, 0), 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) return cube diff --git a/tests/test_cutlist.py b/tests/test_cutlist.py new file mode 100644 index 0000000..ab32f43 --- /dev/null +++ b/tests/test_cutlist.py @@ -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()) == {} diff --git a/tests/test_scene.py b/tests/test_scene.py index 9343f46..1bd8033 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -62,14 +62,80 @@ def test_the_example_sentence(): 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 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 - ux, uy = p2.axis_unit() + ux, uy, uz = p2.axis_unit() assert ux == pytest.approx(0.0, abs=1e-9) assert uy == pytest.approx(1.0) + assert uz == pytest.approx(0.0, abs=1e-9) 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(): s = Scene() with pytest.raises(SceneError, match="selected"):