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
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}")

View File

@ -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

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 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": "<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, ...
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": "<short question or reply>"}}}}.

View File

@ -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

View File

@ -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),

View File

@ -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<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,
)
_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:
@ -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}")

View File

@ -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

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)
# 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"):