Add "Fit to mate" — size a mortise to a tenon (and vice versa)

In the Joinery tab, a tenon/mortise shows a "Fit to mortise…/tenon…" button
that opens a dialog listing the complementary features on other boards; picking
one resizes the active feature to mate:
- mortise = tenon cross-section + 1/32" clearance, pocket slightly deeper;
- tenon = mortise opening − 1/32" clearance, tongue reaching the pocket bottom.
controller.fit_feature + features_of_kind; commits + re-renders.

71 tests pass (fit mortise->tenon dims).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 16:17:08 -03:00
parent aabf289562
commit 6f829a2c50
4 changed files with 85 additions and 3 deletions

View File

@ -108,7 +108,10 @@ pytest # 25 tests
the pending change over the committed feature; **Apply** commits it the pending change over the committed feature; **Apply** commits it
(`controller.set_preview`/`apply_preview`, `preview_changed` signal → (`controller.set_preview`/`apply_preview`, `preview_changed` signal →
`viewport.set_preview`). Per-kind hints + field tooltips explain the `viewport.set_preview`). Per-kind hints + field tooltips explain the
parameters. `controller.active_feature` is the one being edited. Not yet: 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. countersinks, and click-a-face-to-place in the GUI.
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

View File

@ -257,6 +257,34 @@ class Controller(QObject):
except SceneError: except SceneError:
return None return None
def features_of_kind(self, kind: str) -> list[tuple]:
"""All (part, feature) of a kind, excluding the one being edited."""
return [(p, f) for p in self.scene.parts for f in p.features
if f.kind == kind and f.id != self.active_feature]
def fit_feature(self, target_fid: str, clearance: float = 1 / 32) -> None:
"""Resize the active feature to mate with another (tenon<->mortise)."""
feat = self.active_feature_obj()
if not feat:
return
try:
_, target = self.scene.find_feature(target_fid)
except SceneError:
return
if feat.kind == "mortise" and target.kind == "tenon":
dims = dict(width_in=target.width_in + clearance,
height_in=target.height_in + clearance,
depth_in=target.depth_in + clearance) # pocket slightly deeper
elif feat.kind == "tenon" and target.kind == "mortise":
dims = dict(width_in=max(target.width_in - clearance, 0.05),
height_in=max(target.height_in - clearance, 0.05),
depth_in=target.depth_in) # tongue reaches the bottom
else:
self.logged.emit("sys", "Fit a mortise to a tenon (or a tenon to a mortise).")
return
self.scene.edit_feature(feat.id, **dims)
self._commit(f"Fitted {feat.id} to {target_fid}.")
# ----- live preview of a pending feature edit ---------------------- # ----- live preview of a pending feature edit ----------------------
def set_preview(self, **fields) -> None: def set_preview(self, **fields) -> None:
"""Stash a pending edit (does NOT change the model) and redraw the ghost.""" """Stash a pending edit (does NOT change the model) and redraw the ghost."""

View File

@ -4,8 +4,9 @@ clicking a kind drops a sensibly-sized feature you can immediately adjust."""
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QComboBox, QDoubleSpinBox, QFormLayout, QGridLayout, from PySide6.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QFormLayout, QGridLayout, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QMessageBox,
QPushButton, QVBoxLayout, QWidget) QPushButton, QVBoxLayout, QWidget)
from ..scene import FACES from ..scene import FACES
@ -61,6 +62,11 @@ class FeaturePanel(QWidget):
form.insertRow(0, "Face", self.face) form.insertRow(0, "Face", self.face)
root.addLayout(form) root.addLayout(form)
self.fit_btn = QPushButton("Fit to mate…")
self.fit_btn.setToolTip("Resize this tenon/mortise to fit a matching one on another board")
self.fit_btn.clicked.connect(self._fit)
root.addWidget(self.fit_btn)
btns = QHBoxLayout() btns = QHBoxLayout()
self.apply_btn = QPushButton("Apply") self.apply_btn = QPushButton("Apply")
self.apply_btn.setToolTip("Commit the previewed change (cuts/adds the real geometry)") self.apply_btn.setToolTip("Commit the previewed change (cuts/adds the real geometry)")
@ -108,6 +114,9 @@ class FeaturePanel(QWidget):
sp.setValue(getattr(feat, key)) sp.setValue(getattr(feat, key))
self.hint.setText(_HINTS.get(feat.kind, "") if feat else self.hint.setText(_HINTS.get(feat.kind, "") if feat else
"Add a feature above, then adjust it here.") "Add a feature above, then adjust it here.")
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
self.fit_btn.setEnabled(bool(mate))
self.fit_btn.setText(f"Fit to {mate}" if mate else "Fit to mate…")
self._loading = False self._loading = False
if self.c.preview is not None: # committed state -> drop any stale ghost if self.c.preview is not None: # committed state -> drop any stale ghost
self.c.clear_preview() self.c.clear_preview()
@ -126,6 +135,34 @@ class FeaturePanel(QWidget):
dims = {key: sp.value() for key, sp in self._spins.items()} dims = {key: sp.value() for key, sp in self._spins.items()}
self.c.set_preview(face=self.face.currentText(), **dims) self.c.set_preview(face=self.face.currentText(), **dims)
def _fit(self) -> None:
feat = self.c.active_feature_obj()
mate = {"tenon": "mortise", "mortise": "tenon"}.get(feat.kind if feat else "")
if not mate:
return
cands = self.c.features_of_kind(mate)
if not cands:
QMessageBox.information(self, "Fit", f"No {mate} on another board to fit to.")
return
dlg = QDialog(self)
dlg.setWindowTitle(f"Fit {feat.kind} to a {mate}")
lay = QVBoxLayout(dlg)
lay.addWidget(QLabel(f"Select the {mate} to mate with:"))
lst = QListWidget()
for part, f in cands:
label = f"{part.id} · {f.id}: {f.kind} {f.width_in:g}×{f.height_in:g}×{f.depth_in:g}"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, f.id)
lst.addItem(item)
lst.setCurrentRow(0)
lay.addWidget(lst)
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))
_HINTS = { _HINTS = {
"tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; " "tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; "

View File

@ -99,6 +99,20 @@ def test_feature_preview_mesh_builds():
assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0 assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0
def test_fit_mortise_to_tenon(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24)
c.add_feature("tenon") # f1 on p1, active
c.scene.edit_feature("f1", width_in=1.0, height_in=0.75, depth_in=1.5)
c.place("2x4", 24)
c.add_feature("mortise") # f2 on p2, now active
c.fit_feature("f1") # fit the mortise to the tenon
_, m = c.scene.find_feature("f2")
assert m.width_in == 1.0 + 1 / 32 # pocket = tongue + clearance
assert m.height_in == 0.75 + 1 / 32
assert m.depth_in == 1.5 + 1 / 32
def test_unknown_tool_is_safe(tmp_path): def test_unknown_tool_is_safe(tmp_path):
c = _controller(tmp_path) c = _controller(tmp_path)
assert "unknown" in c.execute_call("wood-bogus", {}).lower() assert "unknown" in c.execute_call("wood-bogus", {}).lower()