From e35020382d5930abf8e0d0624bd63e2b263565b2 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 17:15:51 -0300 Subject: [PATCH] Add auto-assembly (Make connection) + feature rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Make connection" checkbox in the Fit dialog moves/orients the other board so its tenon seats into the mortise (faces meet, insertion axes aligned, cross-axes matched): - scene.connect(anchor, moving): builds the moving feature's desired world frame from Part.feature_world_frame, solves R = [dN|dU|dV]·[n|u|v]^T, decomposes to yaw/tilt/roll via matrix_to_ypr (inverse of local_frame's Rz·Ry(-tilt)·Rx(roll)), and positions so the contact points coincide. Verified: tenon-board stands and seats into a top mortise; Euler round-trip exact. - Feature.rotation_deg: spin a feature about its face normal (geometry rotates the cut/add solid; preview + connect honor it) so cross-sections line up. - Shared face_frame/rotation math moved to scene.py (geometry imports it). - CLI `connect`, `--rotation` on features; voice `wood-connect`; GUI rotation field + "Make connection" checkbox. 22 wood-* tools. 79 tests pass (ypr round-trip, connect seats tenon, rotated feature cuts). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 12 +++- scripts/gen_wood_tools.py | 12 +++- src/woodshop/cli.py | 20 ++++++- src/woodshop/geometry.py | 22 ++----- src/woodshop/gui/controller.py | 18 +++++- src/woodshop/gui/feature_panel.py | 20 +++++-- src/woodshop/scene.py | 97 ++++++++++++++++++++++++++++++- src/woodshop/viewer.py | 2 + tests/test_geometry.py | 9 +++ tests/test_scene.py | 31 ++++++++++ 10 files changed, 214 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 826965a..d057be3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,8 +111,16 @@ pytest # 25 tests parameters. `controller.active_feature` is the one being edited. A **Fit to mate…** button (`controller.fit_feature`) resizes a mortise to a chosen tenon (or vice versa) — pocket = tongue + clearance (1/32"), pocket slightly deeper; - a dialog lists the complementary features (`features_of_kind`). Not yet: - countersinks, and click-a-face-to-place in the GUI. + a dialog lists the complementary features (`features_of_kind`). The dialog's + **Make connection** checkbox calls `controller.make_connection` → + `scene.connect(anchor, moving)`, which moves/orients the moving feature's + board so the tenon seats into the mortise (faces meet, axes aligned). Connect + builds the target world rotation from the feature frames and decomposes it to + the board's yaw/tilt/roll via `matrix_to_ypr` (inverse of `Part.local_frame`'s + Rz·Ry(-tilt)·Rx(roll)); `Part.feature_world_frame` gives each feature's world + point/normal/u/v. Features also have `rotation_deg` (spin about the face + normal) to line up cross-sections. CLI `connect`; voice `wood-connect`. Not + yet: countersinks, click-a-face-to-place. 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. diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 221bc0d..a7292f4 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -175,16 +175,26 @@ TOOLS = { {"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'"}, + {"flag": "--rotation", "variable": "rotation", "default": "", "description": "Rotate the feature about its face normal, degrees"}, ], "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' + ' ("--height", height), ("--depth", depth), ("--diameter", diameter),\n' + ' ("--rotation", rotation)]:\n' ' if val != "": cmd += [flag, str(val)]' ), }, + "wood-connect": { + "description": "Move/orient one board so its tenon/mortise seats into another's matching feature. Use for 'connect', 'assemble', 'join the pieces together', 'fit them together'.", + "arguments": [ + {"flag": "--anchor", "variable": "anchor", "description": "Feature id that stays put, e.g. f1"}, + {"flag": "--moving", "variable": "moving", "description": "Feature id whose board moves to mate, e.g. f2"}, + ], + "code": code('cmd = [ws, "connect", anchor, moving]'), + }, "wood-feature-delete": { "description": "Remove a joinery feature by its id. Use for 'delete the mortise', 'remove that hole'.", "arguments": [ diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 200b321..90252dc 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -148,12 +148,17 @@ def _optlen(v, unit="inch"): return to_inches(v, default_unit=unit) if v not in (None, "") else None +def _optdeg(v): + return float(v) 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)) + depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter), + rotation_deg=_optdeg(args.rotation)) part = scene.find_feature(feat.id)[0] return f"Added {feat.kind} ({feat.id}) to {part.id} on {feat.face}." @@ -163,10 +168,15 @@ def cmd_feature_edit(scene: Scene, args) -> str: 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)) + depth_in=_optlen(args.depth), diameter_in=_optlen(args.diameter), + rotation_deg=_optdeg(args.rotation)) return f"Updated feature {feat.id}." +def cmd_connect(scene: Scene, args) -> str: + return scene.connect(args.anchor, args.moving) + + def cmd_feature_delete(scene: Scene, args) -> str: return scene.delete_feature(args.fid) @@ -312,6 +322,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--height", help="feature height/thickness") parser.add_argument("--depth", help="cut depth / tenon protrusion") parser.add_argument("--diameter", help="hole diameter") + parser.add_argument("--rotation", help="rotate the feature about its face normal (deg)") 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") @@ -332,6 +343,11 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--part", default=None) sp.set_defaults(func=cmd_feature_list) + sp = sub.add_parser("connect", help="Move a board so its feature seats into another") + sp.add_argument("anchor", help="Anchor feature id (stays put)") + sp.add_argument("moving", help="Feature id whose board moves to mate") + sp.set_defaults(func=cmd_connect) + 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) diff --git a/src/woodshop/geometry.py b/src/woodshop/geometry.py index 6e870cd..be0cfe0 100644 --- a/src/woodshop/geometry.py +++ b/src/woodshop/geometry.py @@ -12,20 +12,7 @@ from __future__ import annotations from pathlib import Path -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] +from .scene import Feature, Part, Scene, face_frame as _face_frame def _orient_z_to(solid, n): @@ -62,10 +49,13 @@ def _feature_solid_local(feat: Feature, L: float, w: float, t: float): 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") + solid = Pos(*c) * Box(sx, sy, sz) + if feat.rotation_deg: # spin the cross-section about the normal + from build123d import Axis + solid = solid.rotate(Axis(fp, n), feat.rotation_deg) + return solid, (feat.kind != "tenon") def _face_plane(face: str, L: float, w: float, t: float): diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 5235b4e..3652419 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -49,8 +49,10 @@ TOOL_CMD = { "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")))), + height=_opt(a.get("height")), depth=_opt(a.get("depth")), diameter=_opt(a.get("diameter")), + rotation=_opt(a.get("rotation")))), "wood-feature-delete": lambda a: (cli.cmd_feature_delete, SimpleNamespace(fid=a["fid"])), + "wood-connect": lambda a: (cli.cmd_connect, SimpleNamespace(anchor=a["anchor"], moving=a["moving"])), "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"])), @@ -285,6 +287,18 @@ class Controller(QObject): self.scene.edit_feature(feat.id, **dims) self._commit(f"Fitted {feat.id} to {target_fid}.") + def make_connection(self, target_fid: str) -> None: + """Move/orient the target's board so its feature seats into the active one.""" + feat = self.active_feature_obj() + if not feat: + return + try: + msg = self.scene.connect(feat.id, target_fid) + except SceneError as exc: + self.logged.emit("sys", str(exc).strip('"')) + return + self._commit(msg) + # ----- live preview of a pending feature edit ---------------------- def set_preview(self, **fields) -> None: """Stash a pending edit (does NOT change the model) and redraw the ghost.""" @@ -314,7 +328,7 @@ class Controller(QObject): pending.id, face=pending.face, along_in=pending.along_in, across_in=pending.across_in, width_in=pending.width_in, height_in=pending.height_in, depth_in=pending.depth_in, - diameter_in=pending.diameter_in) + diameter_in=pending.diameter_in, rotation_deg=pending.rotation_deg) self._commit() # re-tessellates with the new geometry self.preview_changed.emit() # clear the ghost diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index 43f7ad9..ceb3c56 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -4,9 +4,9 @@ clicking a kind drops a sensibly-sized feature you can immediately adjust.""" from __future__ import annotations from PySide6.QtCore import Qt -from PySide6.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, - QFormLayout, QGridLayout, QHBoxLayout, QLabel, - QListWidget, QListWidgetItem, QMessageBox, +from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFormLayout, QGridLayout, QHBoxLayout, + QLabel, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget) from ..scene import FACES @@ -51,10 +51,15 @@ class FeaturePanel(QWidget): ("height_in", "Height", "Feature size across the face (2nd dimension)"), ("depth_in", "Depth", "How deep it cuts — or how far a tenon sticks out"), ("diameter_in", "Diameter", "Hole diameter (holes only)"), + ("rotation_deg", "Rotate", "Spin the feature about its face normal to line up the cross-section"), ] self._spins = {} for key, label, tip in self._fields: - sp = QDoubleSpinBox(); sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in") + sp = QDoubleSpinBox() + if key == "rotation_deg": + sp.setRange(-180, 180); sp.setSingleStep(15); sp.setSuffix(" °") + else: + sp.setRange(-48, 96); sp.setSingleStep(0.25); sp.setSuffix(" in") sp.setToolTip(tip) sp.valueChanged.connect(self._preview) # live red ghost as you drag self._spins[key] = sp @@ -156,12 +161,17 @@ class FeaturePanel(QWidget): lst.addItem(item) lst.setCurrentRow(0) lay.addWidget(lst) + connect_cb = QCheckBox("Make connection (move && orient the other board to seat the joint)") + lay.addWidget(connect_cb) bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(dlg.accept) bb.rejected.connect(dlg.reject) lay.addWidget(bb) if dlg.exec() and lst.currentItem(): - self.c.fit_feature(lst.currentItem().data(Qt.UserRole)) + target = lst.currentItem().data(Qt.UserRole) + self.c.fit_feature(target) # size to match + if connect_cb.isChecked(): + self.c.make_connection(target) # then assemble _HINTS = { diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 09797e5..6630db9 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -52,6 +52,43 @@ def _rot_z(deg): return [[c, -s, 0], [s, c, 0], [0, 0, 1]] +def face_frame(face, L, w, t): + """Local-frame (origin, outward normal, in-plane u, in-plane v) of a board + face. X in [0,L], Y in [-w/2,w/2], Z in [-t/2,t/2].""" + 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 _rodrigues(x, n, deg): + """Rotate vector x about unit axis n by deg (Rodrigues' formula).""" + a = math.radians(deg) + c, s = math.cos(a), math.sin(a) + cross = (n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]) + d = n[0] * x[0] + n[1] * x[1] + n[2] * x[2] + return tuple(x[i] * c + cross[i] * s + n[i] * d * (1 - c) for i in range(3)) + + +def matrix_to_ypr(R): + """Decompose a 3x3 rotation into (yaw, tilt, roll) for our convention + R = Rz(yaw)·Ry(-tilt)·Rx(roll).""" + sy = math.hypot(R[0][0], R[1][0]) + if sy > 1e-6: + yaw = math.degrees(math.atan2(R[1][0], R[0][0])) + d = math.degrees(math.atan2(-R[2][0], sy)) + roll = math.degrees(math.atan2(R[2][1], R[2][2])) + else: # gimbal lock (pointing straight up/down) + yaw = math.degrees(math.atan2(-R[1][2], R[1][1])) + d = math.degrees(math.atan2(-R[2][0], sy)) + roll = 0.0 + return yaw, -d, roll + + def _data_dir() -> Path: return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop" @@ -115,6 +152,7 @@ class Feature: height_in: float = 1.0 depth_in: float = 1.0 diameter_in: float = 0.375 + rotation_deg: float = 0.0 # rotation of the feature about its face normal @property def is_cut(self) -> bool: @@ -153,6 +191,27 @@ class Part: """Unit vector along the board's length, from end_a toward end_b.""" return self.local_frame()[0] + def rotation_matrix(self) -> list[list[float]]: + """3x3 world rotation (columns = length/width/thickness world axes).""" + cl, cw, ct = self.local_frame() + return [[cl[i], cw[i], ct[i]] for i in range(3)] + + def feature_world_frame(self, feat) -> tuple: + """(contact point, outward normal, u, v) of a feature, in world space, + with the feature's own rotation about its normal applied to u/v.""" + R = self.rotation_matrix() + t, w = self.section_in + o, n, u, v = face_frame(feat.face, self.length_in, w, t) + off_u = feat.along_in - (self.length_in / 2 if u == (1, 0, 0) else 0.0) + fp = tuple(o[i] + off_u * u[i] + feat.across_in * v[i] for i in range(3)) + ur, vr = _rodrigues(u, n, feat.rotation_deg), _rodrigues(v, n, feat.rotation_deg) + + def to_world(x): + return tuple(sum(R[i][j] * x[j] for j in range(3)) for i in range(3)) + + point = tuple(self.position_in[i] + to_world(fp)[i] for i in range(3)) + return point, to_world(n), to_world(ur), to_world(vr) + @property def is_vertical(self) -> bool: return abs(self.tilt_deg) > 45 @@ -423,7 +482,8 @@ class Scene: 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"} + allowed = {"along_in", "across_in", "width_in", "height_in", "depth_in", + "diameter_in", "rotation_deg"} 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) @@ -457,6 +517,41 @@ class Scene: self.selection = part.id return f"Deleted feature {fid} from {part.id}." + def connect(self, anchor_fid: str, moving_fid: str) -> str: + """Move/orient the board owning ``moving_fid`` so its feature mates with + ``anchor_fid`` (tenon seated into mortise: faces meet, axes aligned).""" + anchor_part, _ = self.find_feature(anchor_fid) + moving_part, moving_feat = self.find_feature(moving_fid) + if anchor_part is moving_part: + raise SceneError("Connect features on two different boards.") + self._checkpoint() + + pa, na, ua, va = anchor_part.feature_world_frame(self.find_feature(anchor_fid)[1]) + + # moving feature's LOCAL frame (in its own board), with its rotation. + t, w = moving_part.section_in + o, n, u, v = face_frame(moving_feat.face, moving_part.length_in, w, t) + off_u = moving_feat.along_in - (moving_part.length_in / 2 if u == (1, 0, 0) else 0.0) + fp_l = tuple(o[i] + off_u * u[i] + moving_feat.across_in * v[i] for i in range(3)) + n_l = n + u_l, v_l = _rodrigues(u, n, moving_feat.rotation_deg), _rodrigues(v, n, moving_feat.rotation_deg) + + # Desired world axes for the moving feature: insertion opposite the + # anchor's outward normal; cross-axes aligned (v flips to stay right-handed). + dN = tuple(-x for x in na) + dU, dV = ua, tuple(-x for x in va) + + # R such that R·n_l = dN, R·u_l = dU, R·v_l = dV (R = [dN|dU|dV]·[n_l|u_l|v_l]^T) + cols_d, cols_f = (dN, dU, dV), (n_l, u_l, v_l) + R = [[sum(cols_d[k][i] * cols_f[k][j] for k in range(3)) for j in range(3)] + for i in range(3)] + moving_part.yaw_deg, moving_part.tilt_deg, moving_part.roll_deg = matrix_to_ypr(R) + + moved_fp = tuple(sum(R[i][j] * fp_l[j] for j in range(3)) for i in range(3)) + moving_part.position_in = [pa[i] - moved_fp[i] for i in range(3)] + self.selection = moving_part.id + return f"Connected {moving_part.id} to {anchor_part.id}." + def delete(self, ref: str | None) -> str: self._checkpoint() part = self.resolve(ref) diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index 042492d..5834ada 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -96,6 +96,8 @@ def feature_preview_mesh(part, feat): c[1] - dims[1] / 2, c[1] + dims[1] / 2, c[2] - dims[2] / 2, c[2] + dims[2] / 2)) + if feat.rotation_deg and feat.kind not in ("hole", "chamfer"): + mesh.rotate_vector(n, feat.rotation_deg, point=fp, inplace=True) mesh.rotate_x(part.roll_deg, point=(0, 0, 0), inplace=True) mesh.rotate_y(-part.tilt_deg, point=(0, 0, 0), inplace=True) mesh.rotate_z(part.yaw_deg, point=(0, 0, 0), inplace=True) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index c206394..85398ac 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -47,6 +47,15 @@ def test_oversized_chamfer_falls_back(): assert part_solid(s.get_part("p1")).volume > 0 +def test_rotated_box_feature_still_cuts(): + s = Scene() + s.place("2x4", 12) + base = part_solid(s.get_part("p1")).volume + s.add_feature("p1", "mortise", face="top", width_in=2, height_in=0.5, + depth_in=0.5, rotation_deg=90) + assert part_solid(s.get_part("p1")).volume < base + + def test_featured_part_tessellates(): s = Scene() s.place("2x4", 12) diff --git a/tests/test_scene.py b/tests/test_scene.py index 628aa67..890512e 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -226,6 +226,37 @@ def test_feature_roundtrip(tmp_path): assert feat.kind == "hole" and feat.diameter_in == 0.5 +@pytest.mark.parametrize("ypr", [(30, 0, 0), (0, 40, 0), (0, 0, 55), (35, 20, -15), (120, 30, -45)]) +def test_matrix_to_ypr_roundtrip(ypr): + from woodshop.scene import matrix_to_ypr + s = Scene() + p = s.place("2x4", 12) + p.yaw_deg, p.tilt_deg, p.roll_deg = ypr + assert matrix_to_ypr(p.rotation_matrix()) == pytest.approx(ypr, abs=1e-6) + + +def test_connect_seats_tenon_in_mortise(): + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1) + s.place("2x4", 12) + s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) + s.connect("f1", "f2") # move p2 so its tenon seats into p1's mortise + pa, na, _, _ = s.get_part("p1").feature_world_frame(s.find_feature("f1")[1]) + pb, nb, _, _ = s.get_part("p2").feature_world_frame(s.find_feature("f2")[1]) + assert pb == pytest.approx(pa, abs=1e-6) # faces meet + assert nb == pytest.approx(tuple(-x for x in na), abs=1e-6) # tenon points into mortise + + +def test_connect_needs_two_boards(): + s = Scene() + s.place("2x4", 24) + s.add_feature("p1", "tenon", face="end_a") + s.add_feature("p1", "mortise", face="top") + with pytest.raises(SceneError, match="two different boards"): + s.connect("f1", "f2") + + def test_clear(): s = Scene() s.place("2x4", 24)