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:
parent
17e7554ff1
commit
391bbcb3f9
|
|
@ -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** ~7–13s per utterance (one `claude -p` call).
|
2. **Latency** ~7–13s 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.
|
||||||
|
|
|
||||||
|
|
@ -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 ~7–13s per utterance (one `claude -p` call).
|
- Command interpretation latency is ~7–13s per utterance (one `claude -p` call).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue