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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 02:31:20 -03:00
parent 17e7554ff1
commit 391bbcb3f9
4 changed files with 77 additions and 18 deletions

View File

@ -70,9 +70,11 @@ pytest # 25 tests
### Known limitations / next steps ### Known limitations / next steps
1. **Joins stack in Z** (board B rests on A's top face). This avoids 1. **Joins are butt joints**: B's end sits flush against A's surface (offset by
interpenetration and handles vertical legs, but isn't true joinery (no A's cross-section half-extent in B's approach direction), so boards meet at
butt/mortise/lap geometry). 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** ~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

@ -96,8 +96,8 @@ Key modules:
### Known limitations ### Known limitations
- Joints rest boards on each other's faces (Z-stacking); no true mortise/lap - Joins are butt joints (B's end sits flush against A's face, not its
joinery geometry yet. centerline); joinery *cuts* (mortise/tenon, lap, pocket holes) aren't modeled yet.
- Command interpretation latency is ~713s per utterance (one `claude -p` call). - Command interpretation latency is ~713s per utterance (one `claude -p` call).
## License ## License

View File

@ -27,6 +27,30 @@ from .lumber import actual_section, normalize_stock
SCENE_VERSION = 1 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: def _data_dir() -> Path:
return Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop" 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" 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 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]: def axis_unit(self) -> tuple[float, float, float]:
"""Unit vector along the board's length, from end_a toward end_b.""" """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) return self.local_frame()[0]
c = math.cos(tilt)
return (math.cos(yaw) * c, math.sin(yaw) * c, math.sin(tilt))
@property @property
def is_vertical(self) -> bool: def is_vertical(self) -> bool:
@ -181,17 +217,22 @@ class Scene:
# Attach point: distance measured along A's axis from the chosen end. # 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, 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 # 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). # tilt/roll (so a board you stood up stays standing when attached).
b.yaw_deg = a.yaw_deg + angle_deg b.yaw_deg = a.yaw_deg + angle_deg
# Rest B against A's top face. A horizontal B sits its full half-thickness b_len = b.axis_unit() # direction B extends, away from the joint
# above the face; a vertical B sets its base (end_a) on the face. cos(tilt)
# blends smoothly between the two. # Real butt joint: B's end sits flush on A's SURFACE, not on A's
stack_z = a.section_in[0] / 2 + (b.section_in[0] / 2) * math.cos(math.radians(b.tilt_deg)) # centerline. Push out from the centerline to A's face along B's
b.position_in = [a.position_in[0] + ux * along, # direction, by A's cross-section half-extent in that direction
a.position_in[1] + uy * along, # (width/thickness only — B butts the side, not the end).
a.position_in[2] + uz * along + stack_z] 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}" jid = f"j{self._next_joint}"
self._next_joint += 1 self._next_joint += 1

View File

@ -60,8 +60,10 @@ def test_the_example_sentence():
assert "sanded" in p1.finishes assert "sanded" in p1.finishes
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X) # attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
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 # butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide ->
assert p2.position_in[2] == pytest.approx(1.5) # 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) assert p2.yaw_deg == pytest.approx(90.0)
# p2 now runs along +Y # p2 now runs along +Y
ux, uy, uz = p2.axis_unit() 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 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(): def test_join_preserves_vertical_tilt():
"""A stood-up leg stays vertical when attached to a horizontal apron.""" """A stood-up leg stays vertical when attached to a horizontal apron."""
s = Scene() s = Scene()