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:
parent
fa03ee71d3
commit
914c86303f
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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>"}}}}.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()) == {}
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue