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:
parent
aabf289562
commit
6f829a2c50
|
|
@ -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** ~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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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; "
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue