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:
parent
417bf39d09
commit
a0072e6271
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -93,11 +93,16 @@ pytest # 25 tests
|
|||
|
||||
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
|
||||
than centered — so mixed-size boards line up cleanly. The flush step snaps
|
||||
B's +faces to A's +faces on A's cross-section axes, skipping the axis B
|
||||
extends along (so the butt contact is preserved). Not yet modeled: joinery
|
||||
*cuts* (mortise/tenon, lap, pocket holes), and the flush corner is fixed
|
||||
(A's +width/+thick side; no per-join choice of which corner / centered).
|
||||
than centered. The flush corner is fixed (A's +width/+thick side; no per-join
|
||||
choice of which corner / centered).
|
||||
2. **Joinery features** (`Feature` on each `Part`) are parametric booleans applied
|
||||
in `geometry.part_solid`: `tenon` adds a protruding tongue (fuse); `mortise`,
|
||||
`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** ~7–13s per utterance (one `claude -p` call).
|
||||
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
|
||||
failures but the mic path isn't exercised in the unit tests.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,35 @@ TOOLS = {
|
|||
"arguments": [],
|
||||
"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": {
|
||||
"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": [],
|
||||
|
|
|
|||
|
|
@ -144,6 +144,46 @@ def cmd_projects(scene: Scene, args) -> str:
|
|||
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:
|
||||
from .geometry import export # lazy: keeps build123d out of the core path
|
||||
path = export(scene, args.path)
|
||||
|
|
@ -175,6 +215,8 @@ def _describe_part(p) -> str:
|
|||
bits.append(f"yaw {p.yaw_deg:g}°")
|
||||
if 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)
|
||||
|
||||
|
||||
|
|
@ -261,6 +303,35 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
sp.add_argument("--part", default=None)
|
||||
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.add_argument("name", help="Project name, e.g. 'coffee table'")
|
||||
sp.set_defaults(func=cmd_save)
|
||||
|
|
@ -302,7 +373,8 @@ 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", "cutlist", "render", "save", "projects"):
|
||||
if args.command not in ("status", "export", "cutlist", "render", "save",
|
||||
"projects", "features"):
|
||||
scene.save(args.scene)
|
||||
print(message)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -12,7 +12,60 @@ from __future__ import annotations
|
|||
|
||||
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):
|
||||
|
|
@ -20,14 +73,18 @@ def part_solid(part: Part):
|
|||
|
||||
length = part.length_in
|
||||
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
|
||||
# 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
|
||||
solid = Pos(length / 2, 0, 0) * Box(length, width, thickness) # local, start at origin
|
||||
|
||||
for feat in part.features: # apply joinery as booleans
|
||||
fsolid, is_cut = _feature_solid_local(feat, length, width, thickness)
|
||||
solid = (solid - fsolid) if is_cut else (solid + fsolid)
|
||||
|
||||
# place: roll about its own axis (X), tilt up toward Z (about Y), heading (Z).
|
||||
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):
|
||||
|
|
@ -44,6 +101,7 @@ def export(scene: Scene, path: str | Path) -> Path:
|
|||
from build123d import export_step, export_stl
|
||||
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
compound = scene_compound(scene)
|
||||
if compound is None:
|
||||
raise ValueError("Nothing to export: the scene is empty.")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ TOOL_CMD = {
|
|||
"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")),
|
||||
"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-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
|
||||
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ class Viewport(QWidget):
|
|||
actor = self.plotter.add_mesh(
|
||||
_part_mesh(part),
|
||||
color="#f5d76e" if selected else _PALETTE[i % len(_PALETTE)],
|
||||
show_edges=True, line_width=3 if selected else 1, edge_color="black",
|
||||
reset_camera=False, pickable=True,
|
||||
show_edges=not part.features, line_width=3 if selected else 1,
|
||||
edge_color="black", reset_camera=False, pickable=True,
|
||||
)
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -84,6 +84,41 @@ def list_projects() -> list[str]:
|
|||
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
|
||||
class Part:
|
||||
id: str
|
||||
|
|
@ -96,6 +131,7 @@ class Part:
|
|||
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)
|
||||
features: list[Feature] = field(default_factory=list)
|
||||
|
||||
def local_frame(self) -> tuple[tuple, tuple, tuple]:
|
||||
"""The board's (length, width, thickness) unit axes in world space.
|
||||
|
|
@ -152,6 +188,7 @@ class Scene:
|
|||
selection: str | None = None
|
||||
_next_part: int = 1
|
||||
_next_joint: int = 1
|
||||
_next_feat: int = 1
|
||||
_undo: 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._next_part = 1
|
||||
self._next_joint = 1
|
||||
self._next_feat = 1
|
||||
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:
|
||||
self._checkpoint()
|
||||
part = self.resolve(ref)
|
||||
|
|
@ -396,11 +480,14 @@ class Scene:
|
|||
def from_dict(cls, data: dict) -> "Scene":
|
||||
parts = []
|
||||
valid = {f.name for f in fields(Part)}
|
||||
feat_fields = {f.name for f in fields(Feature)}
|
||||
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"])
|
||||
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}))
|
||||
joints = [Joint(**j) for j in data.get("joints", [])]
|
||||
return cls(
|
||||
|
|
@ -411,6 +498,7 @@ class Scene:
|
|||
selection=data.get("selection"),
|
||||
_next_part=data.get("_next_part", len(parts) + 1),
|
||||
_next_joint=data.get("_next_joint", len(joints) + 1),
|
||||
_next_feat=data.get("_next_feat", 1),
|
||||
_undo=data.get("_undo", []),
|
||||
_redo=data.get("_redo", []),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,28 @@ from .scene import Part, Scene, default_scene_path
|
|||
_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):
|
||||
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
|
||||
thickness, width = part.section_in
|
||||
cube = pv.Cube(center=(length / 2, 0, 0),
|
||||
|
|
@ -55,7 +74,7 @@ def _render(plotter, scene: Scene) -> None:
|
|||
plotter.add_mesh(
|
||||
_part_mesh(part),
|
||||
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,
|
||||
edge_color="black",
|
||||
smooth_shading=False,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -192,6 +192,40 @@ def test_batch_is_one_undo():
|
|||
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():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
|
|
|
|||
Loading…
Reference in New Issue