From 391bbcb3f9030ccef282dc9dc48a9c4ce9c9982d Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 02:31:20 -0300 Subject: [PATCH] Real butt-joint geometry (faces, not centerlines) Boards now connect like real lumber: B's end butts flush against A's surface, offset from A's centerline by A's cross-section half-extent in B's approach direction (width/thickness, whichever B faces). Previously B's center landed on A's centerline, so boards interpenetrated and shared centerlines. - Added Part.local_frame() (length/width/thickness world axes via composed rotation matrices, matching geometry/viewer). - join() computes the surface-contact offset; handles perpendicular T/L joints and vertical legs (leg base butts the rail face). - Tests: butt joint meets surface not centerline; example sentence updated; vertical-leg join still correct. 45 passing. Default alignment is B centered on A at the attach point. Not yet: joinery cuts and flush-outer-face alignment options. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 8 +++--- README.md | 4 +-- src/woodshop/scene.py | 63 +++++++++++++++++++++++++++++++++++-------- tests/test_scene.py | 20 ++++++++++++-- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ffc9a8b..39fe9ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,9 +70,11 @@ pytest # 25 tests ### Known limitations / next steps -1. **Joins stack in Z** (board B rests on A's top face). This avoids - interpenetration and handles vertical legs, but isn't true joinery (no - butt/mortise/lap geometry). +1. **Joins are butt joints**: B's end sits flush against A's surface (offset by + A's cross-section half-extent in B's approach direction), so boards meet at + faces rather than centerlines and don't interpenetrate. Not yet modeled: + joinery *cuts* (mortise/tenon, lap, pocket holes) and flush-outer-face vs + centered alignment options (currently B is centered on A at the attach point). 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/README.md b/README.md index 177caa8..9519bf7 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,8 @@ Key modules: ### Known limitations -- Joints rest boards on each other's faces (Z-stacking); no true mortise/lap - joinery geometry yet. +- Joins are butt joints (B's end sits flush against A's face, not its + centerline); joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet. - Command interpretation latency is ~7–13s per utterance (one `claude -p` call). ## License diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 22aeb37..d89a3e1 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -27,6 +27,30 @@ from .lumber import actual_section, normalize_stock SCENE_VERSION = 1 +# --- small 3x3 rotation helpers (degrees) for board orientation ------------- +def _matmul(a, b): + return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)] + + +def _dot(u, v): + return u[0] * v[0] + u[1] * v[1] + u[2] * v[2] + + +def _rot_x(deg): + c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) + return [[1, 0, 0], [0, c, -s], [0, s, c]] + + +def _rot_y(deg): + c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) + return [[c, 0, s], [0, 1, 0], [-s, 0, c]] + + +def _rot_z(deg): + c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg)) + return [[c, -s, 0], [s, c, 0], [0, 0, 1]] + + def _data_dir() -> Path: return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop" @@ -72,11 +96,23 @@ class Part: name: str = "" # optional human alias, e.g. "front-left leg" finishes: list[str] = field(default_factory=list) + def local_frame(self) -> tuple[tuple, tuple, tuple]: + """The board's (length, width, thickness) unit axes in world space. + + Built by composing the same rotation geometry.py / viewer.py apply: + R = Rz(yaw) · Ry(-tilt) · Rx(roll), then taking R's columns (the images + of the local X=length, Y=width, Z=thickness axes). + """ + R = _matmul(_rot_z(self.yaw_deg), + _matmul(_rot_y(-self.tilt_deg), _rot_x(self.roll_deg))) + length = (R[0][0], R[1][0], R[2][0]) + width = (R[0][1], R[1][1], R[2][1]) + thick = (R[0][2], R[1][2], R[2][2]) + return length, width, thick + def axis_unit(self) -> tuple[float, float, float]: """Unit vector along the board's length, from end_a toward end_b.""" - yaw, tilt = math.radians(self.yaw_deg), math.radians(self.tilt_deg) - c = math.cos(tilt) - return (math.cos(yaw) * c, math.sin(yaw) * c, math.sin(tilt)) + return self.local_frame()[0] @property def is_vertical(self) -> bool: @@ -181,17 +217,22 @@ class Scene: # Attach point: distance measured along A's axis from the chosen end. along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0) - ux, uy, uz = a.axis_unit() + a_len, a_width, a_thick = a.local_frame() + anchor_pt = [a.position_in[i] + a_len[i] * along for i in range(3)] + # B inherits A's heading plus the requested angle, but keeps its own # tilt/roll (so a board you stood up stays standing when attached). b.yaw_deg = a.yaw_deg + angle_deg - # Rest B against A's top face. A horizontal B sits its full half-thickness - # above the face; a vertical B sets its base (end_a) on the face. cos(tilt) - # blends smoothly between the two. - stack_z = a.section_in[0] / 2 + (b.section_in[0] / 2) * math.cos(math.radians(b.tilt_deg)) - b.position_in = [a.position_in[0] + ux * along, - a.position_in[1] + uy * along, - a.position_in[2] + uz * along + stack_z] + b_len = b.axis_unit() # direction B extends, away from the joint + + # Real butt joint: B's end sits flush on A's SURFACE, not on A's + # centerline. Push out from the centerline to A's face along B's + # direction, by A's cross-section half-extent in that direction + # (width/thickness only — B butts the side, not the end). + a_half_w, a_half_t = a.section_in[1] / 2, a.section_in[0] / 2 + surface = (a_half_w * abs(_dot(b_len, a_width)) + + a_half_t * abs(_dot(b_len, a_thick))) + b.position_in = [anchor_pt[i] + b_len[i] * surface for i in range(3)] jid = f"j{self._next_joint}" self._next_joint += 1 diff --git a/tests/test_scene.py b/tests/test_scene.py index dfdbc10..545f096 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -60,8 +60,10 @@ def test_the_example_sentence(): assert "sanded" in p1.finishes # attach point is 10in back from p1's far end (72 - 10 = 62 along +X) assert p2.position_in[0] == pytest.approx(62.0) - # p2 rests on p1's top face: z = t_a/2 + t_b/2 = 0.75 + 0.75 - assert p2.position_in[2] == pytest.approx(1.5) + # butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide -> + # 1.75" from centerline), in the same horizontal plane (z = 0). + assert p2.position_in[1] == pytest.approx(1.75) + assert p2.position_in[2] == pytest.approx(0.0, abs=1e-9) assert p2.yaw_deg == pytest.approx(90.0) # p2 now runs along +Y ux, uy, uz = p2.axis_unit() @@ -83,6 +85,20 @@ def test_stand_makes_board_vertical(): assert p.end_point()[2] == pytest.approx(30.0) # top is 30in up +def test_butt_joint_meets_surface_not_centerline(): + """B's end should sit on A's face, with no interpenetration past A's centerline.""" + s = Scene() + s.place("2x4", 48) # p1 along +X, flat + s.place("2x4", 12) # p2 + s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a") + p2 = s.get_part("p2") + # p2 runs along +Y starting at p1's +Y face (1.75 from centerline), not at + # p1's centerline (which would be y=0). + assert p2.position_in[1] == pytest.approx(1.75) + # its far end is 1.75 + 12 out, fully clear of p1's body. + assert p2.end_point()[1] == pytest.approx(13.75) + + def test_join_preserves_vertical_tilt(): """A stood-up leg stays vertical when attached to a horizontal apron.""" s = Scene()