Add auto-assembly (Make connection) + feature rotation

"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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 17:15:51 -03:00
parent 6f829a2c50
commit e35020382d
10 changed files with 214 additions and 29 deletions

View File

@ -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** ~713s 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.

View File

@ -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": [

View File

@ -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)

View File

@ -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):

View File

@ -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

View File

@ -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 = {

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)