Add joinery features (parametric boolean tenon/mortise/hole/slot)

Features as re-editable objects attached to a board, each a boolean op:
- scene.py: Feature dataclass (kind/face/position/size/depth), Part.features,
  add_feature/edit_feature/delete_feature/find_feature, serialization + counter.
- geometry.py: part_solid now builds the local board then fuses tenons / cuts
  mortise/hole/slot/dado/rabbet via build123d booleans, then places it. _face_frame
  maps each board face; holes are oriented cylinders, others oriented boxes.
- viewer.py: featured boards render the tessellated true solid (edges off to
  avoid triangle noise); plain boards keep the fast pyvista box.
- cli.py: feature / feature-edit / feature-delete / features commands; status
  shows feature kinds. gui/controller: wood-feature(-delete) dispatch.
- 21 wood-* tools (added wood-feature, wood-feature-delete).

64 tests pass (feature model + build123d volume/tessellation checks). Verified
with a render: tenon + mortise + through-hole on one board, and STEP/STL export.

Phase A (model + geometry + CLI/voice). Next: GUI feature panel; chamfers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 13:27:57 -03:00
parent 417bf39d09
commit a0072e6271
10 changed files with 367 additions and 18 deletions

View File

@ -93,11 +93,16 @@ pytest # 25 tests
1. **Joins are flush butt joints**: B's end sits flush against A's surface, and 1. **Joins are flush butt joints**: B's end sits flush against A's surface, and
B is aligned to A's reference corner (top faces level + one side flush) rather B is aligned to A's reference corner (top faces level + one side flush) rather
than centered — so mixed-size boards line up cleanly. The flush step snaps than centered. The flush corner is fixed (A's +width/+thick side; no per-join
B's +faces to A's +faces on A's cross-section axes, skipping the axis B choice of which corner / centered).
extends along (so the butt contact is preserved). Not yet modeled: joinery 2. **Joinery features** (`Feature` on each `Part`) are parametric booleans applied
*cuts* (mortise/tenon, lap, pocket holes), and the flush corner is fixed in `geometry.part_solid`: `tenon` adds a protruding tongue (fuse); `mortise`,
(A's +width/+thick side; no per-join choice of which corner / centered). `hole`, `slot`, `dado`, `rabbet` cut into a chosen face. The viewport
tessellates featured boards via build123d (plain boards stay fast pyvista
boxes). CLI: `feature/feature-edit/feature-delete/features`; voice:
`wood-feature`/`wood-feature-delete`. Not yet: **chamfers/bevels** (need edge
selection, not a box/cylinder cut), countersinks, and the **GUI feature panel**
(add/edit features by clicking — currently CLI/voice only).
2. **Latency** ~713s per utterance (one `claude -p` call). 2. **Latency** ~713s per utterance (one `claude -p` call).
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against 3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
failures but the mic path isn't exercised in the unit tests. failures but the mic path isn't exercised in the unit tests.

View File

@ -163,6 +163,35 @@ TOOLS = {
"arguments": [], "arguments": [],
"code": code('cmd = [ws, "clear"]'), "code": code('cmd = [ws, "clear"]'),
}, },
"wood-feature": {
"description": "Add a joinery feature to a board: tenon (male tongue), mortise (pocket), hole, slot, dado, or rabbet. Use for 'add a tenon', 'cut a mortise', 'drill a hole', 'cut a slot'.",
"arguments": [
{"flag": "--kind", "variable": "kind", "description": "tenon | mortise | hole | slot | dado | rabbet"},
{"flag": "--part", "variable": "part", "default": "", "description": "Board id/name (default: most recent)"},
{"flag": "--face", "variable": "face", "default": "end_b", "description": "Which face: end_a, end_b, top, bottom, left, right"},
{"flag": "--along", "variable": "along", "default": "", "description": "Position along the board (e.g. '3 in'), or 1st offset on an end"},
{"flag": "--across", "variable": "across", "default": "", "description": "Offset across the face from centre"},
{"flag": "--width", "variable": "width", "default": "", "description": "Feature width, e.g. '1.5 in'"},
{"flag": "--height", "variable": "height", "default": "", "description": "Feature height/thickness"},
{"flag": "--depth", "variable": "depth", "default": "", "description": "Cut depth, or tenon protrusion length"},
{"flag": "--diameter", "variable": "diameter", "default": "", "description": "Hole diameter, e.g. '0.5 in'"},
],
"code": code(
'cmd = [ws, "feature", kind]\n'
'if part: cmd += ["--part", part]\n'
'if face: cmd += ["--face", face]\n'
'for flag, val in [("--along", along), ("--across", across), ("--width", width),\n'
' ("--height", height), ("--depth", depth), ("--diameter", diameter)]:\n'
' if val != "": cmd += [flag, str(val)]'
),
},
"wood-feature-delete": {
"description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.",
"arguments": [
{"flag": "--fid", "variable": "fid", "description": "Feature id, e.g. f1"},
],
"code": code('cmd = [ws, "feature-delete", fid]'),
},
"wood-cutlist": { "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'.", "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": [], "arguments": [],

View File

@ -144,6 +144,46 @@ def cmd_projects(scene: Scene, args) -> str:
return "Saved projects: " + (", ".join(names) if names else "none yet") return "Saved projects: " + (", ".join(names) if names else "none yet")
def _optlen(v, unit="inch"):
return to_inches(v, default_unit=unit) if v not in (None, "") else None
def cmd_feature(scene: Scene, args) -> str:
feat = scene.add_feature(
args.part, args.kind, face=args.face,
along_in=_optlen(args.along), across_in=_optlen(args.across),
width_in=_optlen(args.width), height_in=_optlen(args.height),
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter))
part = scene.find_feature(feat.id)[0]
return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}."
def cmd_feature_edit(scene: Scene, args) -> str:
feat = scene.edit_feature(
args.fid, face=args.face,
along_in=_optlen(args.along), across_in=_optlen(args.across),
width_in=_optlen(args.width), height_in=_optlen(args.height),
depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter))
return f"Updated feature {feat.id}."
def cmd_feature_delete(scene: Scene, args) -> str:
return scene.delete_feature(args.fid)
def cmd_feature_list(scene: Scene, args) -> str:
rows = [(p, f) for p in scene.parts for f in p.features
if not args.part or p.id == args.part or p.name == args.part]
if not rows:
return "No features."
lines = []
for p, f in rows:
dims = (f"{f.diameter_in:g}" if f.kind == "hole"
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}")
lines.append(f" {f.id}: {f.kind} on {p.id} {f.face} ({dims})")
return "\n".join(lines)
def cmd_export(scene: Scene, args) -> str: def cmd_export(scene: Scene, args) -> str:
from .geometry import export # lazy: keeps build123d out of the core path from .geometry import export # lazy: keeps build123d out of the core path
path = export(scene, args.path) path = export(scene, args.path)
@ -175,6 +215,8 @@ def _describe_part(p) -> str:
bits.append(f"yaw {p.yaw_deg:g}°") bits.append(f"yaw {p.yaw_deg:g}°")
if p.finishes: if p.finishes:
bits.append(f"[{', '.join(p.finishes)}]") bits.append(f"[{', '.join(p.finishes)}]")
if p.features:
bits.append(f"{{{', '.join(f.kind for f in p.features)}}}")
return f" {p.id}: " + ", ".join(bits) return f" {p.id}: " + ", ".join(bits)
@ -261,6 +303,35 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--part", default=None) sp.add_argument("--part", default=None)
sp.set_defaults(func=cmd_rename) sp.set_defaults(func=cmd_rename)
def add_dim_flags(parser, face_default="end_b"):
parser.add_argument("--face", default=face_default,
help="end_a|end_b|top|bottom|left|right")
parser.add_argument("--along", help="position along the board / 1st offset")
parser.add_argument("--across", help="offset across the face / 2nd offset")
parser.add_argument("--width", help="feature width")
parser.add_argument("--height", help="feature height/thickness")
parser.add_argument("--depth", help="cut depth / tenon protrusion")
parser.add_argument("--diameter", help="hole diameter")
sp = sub.add_parser("feature", help="Add a joinery feature (tenon/mortise/hole/slot)")
sp.add_argument("kind", help="tenon | mortise | hole | slot | dado | rabbet")
sp.add_argument("--part", default=None, help="Board id/name (default: selection)")
add_dim_flags(sp)
sp.set_defaults(func=cmd_feature)
sp = sub.add_parser("feature-edit", help="Adjust an existing feature")
sp.add_argument("fid", help="Feature id, e.g. f1")
add_dim_flags(sp, face_default=None)
sp.set_defaults(func=cmd_feature_edit)
sp = sub.add_parser("feature-delete", help="Remove a feature")
sp.add_argument("fid")
sp.set_defaults(func=cmd_feature_delete)
sp = sub.add_parser("features", help="List joinery features")
sp.add_argument("--part", default=None)
sp.set_defaults(func=cmd_feature_list)
sp = sub.add_parser("save", help="Save the current scene as a named project") sp = sub.add_parser("save", help="Save the current scene as a named project")
sp.add_argument("name", help="Project name, e.g. 'coffee table'") sp.add_argument("name", help="Project name, e.g. 'coffee table'")
sp.set_defaults(func=cmd_save) sp.set_defaults(func=cmd_save)
@ -302,7 +373,8 @@ 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", "cutlist", "render", "save", "projects"): if args.command not in ("status", "export", "cutlist", "render", "save",
"projects", "features"):
scene.save(args.scene) scene.save(args.scene)
print(message) print(message)
return 0 return 0

View File

@ -12,7 +12,60 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from .scene import Part, Scene from .scene import Feature, Part, Scene
# Each face of the LOCAL board (X in [0,L], Y in [-w/2,w/2], Z in [-t/2,t/2]):
# (origin on the face, outward normal, in-plane u axis, in-plane v axis).
def _face_frame(face: str, L: float, w: float, t: float):
return {
"top": ((L / 2, 0, t / 2), (0, 0, 1), (1, 0, 0), (0, 1, 0)),
"bottom": ((L / 2, 0, -t / 2), (0, 0, -1), (1, 0, 0), (0, 1, 0)),
"right": ((L / 2, w / 2, 0), (0, 1, 0), (1, 0, 0), (0, 0, 1)),
"left": ((L / 2, -w / 2, 0), (0, -1, 0), (1, 0, 0), (0, 0, 1)),
"end_b": ((L, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1)),
"end_a": ((0, 0, 0), (-1, 0, 0), (0, 1, 0), (0, 0, 1)),
}[face]
def _orient_z_to(solid, n):
"""Rotate a Z-axis primitive (Cylinder) so its axis points along n."""
from build123d import Rot
if abs(n[2]) > 0.9:
return solid
if abs(n[1]) > 0.9:
return Rot(X=90) * solid
return Rot(Y=90) * solid
def _feature_solid_local(feat: Feature, L: float, w: float, t: float):
"""Return (solid, is_cut) for one feature, in the board's local frame."""
from build123d import Box, Cylinder, Pos
o, n, u, v = _face_frame(feat.face, L, w, t)
# Position on the face. When u is the length axis, `along_in` is measured
# from end_a; otherwise both offsets are from the face centre.
off_u = feat.along_in - (L / 2 if u == (1, 0, 0) else 0.0)
off_v = feat.across_in
fp = tuple(o[i] + off_u * u[i] + off_v * v[i] for i in range(3))
depth = feat.depth_in
if feat.kind == "hole":
r = feat.diameter_in / 2
thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1
h = depth if depth > 0 else thru
cyl = _orient_z_to(Cylinder(radius=r, height=h), n)
c = tuple(fp[i] - n[i] * h / 2 for i in range(3)) # extend into the board
return Pos(*c) * cyl, True
# Box-shaped feature: cross-section width(u) × height(v), depth along normal.
sx = feat.width_in * abs(u[0]) + feat.height_in * abs(v[0]) + depth * abs(n[0])
sy = feat.width_in * abs(u[1]) + feat.height_in * abs(v[1]) + depth * abs(n[1])
sz = feat.width_in * abs(u[2]) + feat.height_in * abs(v[2]) + depth * abs(n[2])
box = Box(sx, sy, sz)
sign = 1 if feat.kind == "tenon" else -1 # tenon protrudes; others cut inward
c = tuple(fp[i] + sign * n[i] * depth / 2 for i in range(3))
return Pos(*c) * box, (feat.kind != "tenon")
def part_solid(part: Part): def part_solid(part: Part):
@ -20,14 +73,18 @@ def part_solid(part: Part):
length = part.length_in length = part.length_in
thickness, width = part.section_in thickness, width = part.section_in
box = Box(length, width, thickness) # X=length, Y=width, Z=thickness solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin
box = Pos(length / 2, 0, 0) * box # move start (end_a) to origin
# roll about its own axis (X), tilt up toward Z (about Y), then heading (Z). for feat in part.features: # apply joinery as booleans
box = Rot(X=part.roll_deg) * box fsolid, is_cut = _feature_solid_local(feat, length, width, thickness)
box = Rot(Y=-part.tilt_deg) * box solid = (solid - fsolid) if is_cut else (solid + fsolid)
box = Rot(Z=part.yaw_deg) * box
box = Pos(*part.position_in) * box # place in the scene # place: roll about its own axis (X), tilt up toward Z (about Y), heading (Z).
return box solid = Rot(X=part.roll_deg) * solid
solid = Rot(Y=-part.tilt_deg) * solid
solid = Rot(Z=part.yaw_deg) * solid
solid = Pos(*part.position_in) * solid
return solid
def scene_compound(scene: Scene): def scene_compound(scene: Scene):
@ -44,6 +101,7 @@ def export(scene: Scene, path: str | Path) -> Path:
from build123d import export_step, export_stl from build123d import export_step, export_stl
path = Path(path) path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
compound = scene_compound(scene) compound = scene_compound(scene)
if compound is None: if compound is None:
raise ValueError("Nothing to export: the scene is empty.") raise ValueError("Nothing to export: the scene is empty.")

View File

@ -45,6 +45,11 @@ TOOL_CMD = {
"wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace( "wood-copy": lambda a: (cli.cmd_copy, SimpleNamespace(
part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")), part=_opt(a.get("part")), dx=_opt(a.get("dx")), dy=_opt(a.get("dy")), dz=_opt(a.get("dz")), unit="inch")),
"wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))), "wood-rename": lambda a: (cli.cmd_rename, SimpleNamespace(name=a["name"], part=_opt(a.get("part")))),
"wood-feature": lambda a: (cli.cmd_feature, SimpleNamespace(
kind=a["kind"], part=_opt(a.get("part")), face=a.get("face") or "end_b",
along=_opt(a.get("along")), across=_opt(a.get("across")), width=_opt(a.get("width")),
height=_opt(a.get("height")), depth=_opt(a.get("depth")), diameter=_opt(a.get("diameter")))),
"wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])),
"wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))), "wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))),
"wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))), "wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])), "wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),

View File

@ -67,8 +67,8 @@ class Viewport(QWidget):
actor = self.plotter.add_mesh( actor = self.plotter.add_mesh(
_part_mesh(part), _part_mesh(part),
color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)], color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)],
show_edges=True, line_width=3 if selected else 1, edge_color="black", show_edges=not part.features, line_width=3 if selected else 1,
reset_camera=False, pickable=True, edge_color="black", reset_camera=False, pickable=True,
) )
self._actor_to_pid[actor] = part.id self._actor_to_pid[actor] = part.id
mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)] mid = [part.position_in[j] + part.axis_unit()[j] * part.length_in / 2 for j in range(3)]

View File

@ -84,6 +84,41 @@ def list_projects() -> list[str]:
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else [] return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
# Feature kinds and whether they ADD material (tenon) or CUT it (everything else).
ADD_KINDS = {"tenon"}
CUT_KINDS = {"mortise", "slot", "hole", "dado", "rabbet"}
FEATURE_KINDS = ADD_KINDS | CUT_KINDS
FACES = ("end_a", "end_b", "top", "bottom", "left", "right")
@dataclass
class Feature:
"""A parametric joinery feature attached to a board — a boolean op (add for a
tenon, cut for mortise/slot/hole) on a chosen face, re-editable and movable.
Placement on the face uses two offsets: for face=top/bottom/left/right,
``along_in`` is the position along the board length (from end_a) and
``across_in`` is offset from the face centre; for face=end_a/end_b the two
offsets are the lateral (width) and vertical (thickness) positions from
centre. ``width_in`` × ``height_in`` is the feature's cross-section on the
face and ``depth_in`` is how deep it cuts (or how far a tenon protrudes).
``diameter_in`` is used for round holes instead of width/height.
"""
id: str
kind: str
face: str = "end_b"
along_in: float = 0.0
across_in: float = 0.0
width_in: float = 1.0
height_in: float = 1.0
depth_in: float = 1.0
diameter_in: float = 0.375
@property
def is_cut(self) -> bool:
return self.kind in CUT_KINDS
@dataclass @dataclass
class Part: class Part:
id: str id: str
@ -96,6 +131,7 @@ class Part:
roll_deg: float = 0.0 # rotation about the board's own length axis roll_deg: float = 0.0 # rotation about the board's own length axis
name: str = "" # optional human alias, e.g. "front-left leg" name: str = "" # optional human alias, e.g. "front-left leg"
finishes: list[str] = field(default_factory=list) finishes: list[str] = field(default_factory=list)
features: list[Feature] = field(default_factory=list)
def local_frame(self) -> tuple[tuple, tuple, tuple]: def local_frame(self) -> tuple[tuple, tuple, tuple]:
"""The board's (length, width, thickness) unit axes in world space. """The board's (length, width, thickness) unit axes in world space.
@ -152,6 +188,7 @@ class Scene:
selection: str | None = None selection: str | None = None
_next_part: int = 1 _next_part: int = 1
_next_joint: int = 1 _next_joint: int = 1
_next_feat: int = 1
_undo: list[str] = field(default_factory=list, repr=False) _undo: list[str] = field(default_factory=list, repr=False)
_redo: list[str] = field(default_factory=list, repr=False) _redo: list[str] = field(default_factory=list, repr=False)
@ -369,8 +406,55 @@ class Scene:
self.selection = None self.selection = None
self._next_part = 1 self._next_part = 1
self._next_joint = 1 self._next_joint = 1
self._next_feat = 1
return "Cleared the scene." return "Cleared the scene."
# ----- joinery features --------------------------------------------
def add_feature(self, ref: str | None, kind: str, face: str = "end_b",
**dims) -> Feature:
kind = kind.lower().strip()
if kind not in FEATURE_KINDS:
raise SceneError(f"Unknown feature {kind!r}. Known: {', '.join(sorted(FEATURE_KINDS))}")
if face not in FACES:
raise SceneError(f"Unknown face {face!r}. Faces: {', '.join(FACES)}")
self._checkpoint()
part = self.resolve(ref)
fid = f"f{self._next_feat}"
self._next_feat += 1
allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in", "diameter_in"}
feat = Feature(id=fid, kind=kind, face=face,
**{k: float(v) for k, v in dims.items() if k in allowed and v is not None})
part.features.append(feat)
self.selection = part.id
return feat
def find_feature(self, fid: str) -> tuple[Part, Feature]:
for p in self.parts:
for f in p.features:
if f.id == fid:
return p, f
raise SceneError(f"No feature {fid!r}.")
def edit_feature(self, fid: str, **dims) -> Feature:
self._checkpoint()
part, feat = self.find_feature(fid)
for k, v in dims.items():
if v is None:
continue
if k == "face":
feat.face = v
elif hasattr(feat, k):
setattr(feat, k, float(v))
self.selection = part.id
return feat
def delete_feature(self, fid: str) -> str:
self._checkpoint()
part, feat = self.find_feature(fid)
part.features.remove(feat)
self.selection = part.id
return f"Deleted feature {fid} from {part.id}."
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)
@ -396,11 +480,14 @@ class Scene:
def from_dict(cls, data: dict) -> "Scene": def from_dict(cls, data: dict) -> "Scene":
parts = [] parts = []
valid = {f.name for f in fields(Part)} valid = {f.name for f in fields(Part)}
feat_fields = {f.name for f in fields(Feature)}
for p in data.get("parts", []): for p in data.get("parts", []):
p = dict(p) p = dict(p)
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
p["yaw_deg"] = p.pop("rotation_deg") p["yaw_deg"] = p.pop("rotation_deg")
p["section_in"] = tuple(p["section_in"]) p["section_in"] = tuple(p["section_in"])
p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields})
for f in p.get("features", [])]
parts.append(Part(**{k: v for k, v in p.items() if k in valid})) 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(
@ -411,6 +498,7 @@ class Scene:
selection=data.get("selection"), selection=data.get("selection"),
_next_part=data.get("_next_part", len(parts) + 1), _next_part=data.get("_next_part", len(parts) + 1),
_next_joint=data.get("_next_joint", len(joints) + 1), _next_joint=data.get("_next_joint", len(joints) + 1),
_next_feat=data.get("_next_feat", 1),
_undo=data.get("_undo", []), _undo=data.get("_undo", []),
_redo=data.get("_redo", []), _redo=data.get("_redo", []),
) )

View File

@ -21,9 +21,28 @@ from .scene import Part, Scene, default_scene_path
_PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"] _PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"]
def _featured_mesh(part: Part):
"""Tessellate the true build123d solid (with joinery booleans) for display."""
import pyvista as pv
from .geometry import part_solid
verts, tris = part_solid(part).tessellate(0.02)
points = [(v.X, v.Y, v.Z) for v in verts]
faces = []
for tri in tris:
faces += [3, tri[0], tri[1], tri[2]]
return pv.PolyData(points, faces)
def _part_mesh(part: Part): def _part_mesh(part: Part):
import pyvista as pv import pyvista as pv
if part.features: # show real joinery (slower; only featured boards)
try:
return _featured_mesh(part)
except Exception:
pass # fall back to the plain box if booleans fail
length = part.length_in length = part.length_in
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),
@ -55,7 +74,7 @@ def _render(plotter, scene: Scene) -> None:
plotter.add_mesh( plotter.add_mesh(
_part_mesh(part), _part_mesh(part),
color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)], color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)],
show_edges=True, show_edges=not part.features, # triangle mesh would look noisy with edges
line_width=3 if edge else 1, line_width=3 if edge else 1,
edge_color="black", edge_color="black",
smooth_shading=False, smooth_shading=False,

39
tests/test_geometry.py Normal file
View File

@ -0,0 +1,39 @@
"""Geometry tests that exercise the build123d boolean features."""
import pytest
pytest.importorskip("build123d")
from woodshop.geometry import part_solid # noqa: E402
from woodshop.scene import Scene # noqa: E402
def test_hole_reduces_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=1.0, depth_in=0) # through
assert part_solid(s.get_part("p1")).volume < base
def test_mortise_reduces_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "mortise", face="top", along_in=6, width_in=1, height_in=2, depth_in=0.75)
assert part_solid(s.get_part("p1")).volume < base
def test_tenon_adds_volume():
s = Scene()
s.place("2x4", 12)
base = part_solid(s.get_part("p1")).volume
s.add_feature("p1", "tenon", face="end_b", width_in=1.5, height_in=0.75, depth_in=1.5)
assert part_solid(s.get_part("p1")).volume > base
def test_featured_part_tessellates():
s = Scene()
s.place("2x4", 12)
s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5, depth_in=0)
verts, tris = part_solid(s.get_part("p1")).tessellate(0.05)
assert len(verts) > 8 and len(tris) > 12

View File

@ -192,6 +192,40 @@ def test_batch_is_one_undo():
assert s.get_part("p2").position_in[0] == 0 assert s.get_part("p2").position_in[0] == 0
def test_add_edit_delete_feature():
s = Scene()
s.place("2x4", 12)
f = s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5)
assert f.id == "f1" and f.is_cut
assert s.get_part("p1").features[0].kind == "mortise"
s.edit_feature("f1", depth_in=0.75)
assert s.find_feature("f1")[1].depth_in == 0.75
s.delete_feature("f1")
assert s.get_part("p1").features == []
def test_tenon_is_additive():
s = Scene()
s.place("2x4", 12)
assert not s.add_feature("p1", "tenon", face="end_b", depth_in=1).is_cut
def test_unknown_feature_kind_errors():
s = Scene()
s.place("2x4", 12)
with pytest.raises(SceneError, match="Unknown feature"):
s.add_feature("p1", "dovetailzzz")
def test_feature_roundtrip(tmp_path):
s = Scene()
s.place("2x4", 12)
s.add_feature("p1", "hole", face="top", along_in=3, diameter_in=0.5)
loaded = Scene.load(s.save(tmp_path / "s.json"))
feat = loaded.get_part("p1").features[0]
assert feat.kind == "hole" and feat.diameter_in == 0.5
def test_clear(): def test_clear():
s = Scene() s = Scene()
s.place("2x4", 24) s.place("2x4", 24)