diff --git a/CLAUDE.md b/CLAUDE.md index 2dcdda8..826965a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,10 @@ pytest # 25 tests the pending change over the committed feature; **Apply** commits it (`controller.set_preview`/`apply_preview`, `preview_changed` signal → `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. 2. **Latency** ~7–13s per utterance (one `claude -p` call). 3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index b721195..5235b4e 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -257,6 +257,34 @@ class Controller(QObject): except SceneError: 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 ---------------------- def set_preview(self, **fields) -> None: """Stash a pending edit (does NOT change the model) and redraw the ghost.""" diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index 17dd036..43f7ad9 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -4,8 +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, QDoubleSpinBox, QFormLayout, QGridLayout, - QHBoxLayout, QLabel, QListWidget, QListWidgetItem, +from PySide6.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, + QFormLayout, QGridLayout, QHBoxLayout, QLabel, + QListWidget, QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget) from ..scene import FACES @@ -61,6 +62,11 @@ class FeaturePanel(QWidget): form.insertRow(0, "Face", self.face) 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() self.apply_btn = QPushButton("Apply") 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)) self.hint.setText(_HINTS.get(feat.kind, "") if feat else "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 if self.c.preview is not None: # committed state -> drop any stale ghost self.c.clear_preview() @@ -126,6 +135,34 @@ class FeaturePanel(QWidget): dims = {key: sp.value() for key, sp in self._spins.items()} 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 = { "tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; " diff --git a/tests/test_gui_controller.py b/tests/test_gui_controller.py index 19cfa30..809ae2f 100644 --- a/tests/test_gui_controller.py +++ b/tests/test_gui_controller.py @@ -99,6 +99,20 @@ def test_feature_preview_mesh_builds(): 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): c = _controller(tmp_path) assert "unknown" in c.execute_call("wood-bogus", {}).lower()