From 70f8e9f0a2be5d8cbe549d4a2f917b25fca286bf Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 15:39:00 -0300 Subject: [PATCH] Live red preview + Apply for feature editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjusting a feature's fields was abstract and gave unclear feedback. Now: - Dragging any field (Face/Along/Across/Width/Height/Depth/Diameter) shows a live translucent RED ghost of the pending feature over the committed one — a cheap pyvista box/cylinder (viewer.feature_preview_mesh), no re-tessellation, so it updates instantly. - An Apply button commits the pending edit (controller.set_preview / apply_preview, preview_changed -> viewport.set_preview red overlay). - Per-kind hint text + per-field tooltips explain what each parameter does. 68 tests pass (preview-then-apply, preview-mesh builds). Verified by render: committed mortise + red ghost of a moved/resized pending edit. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 9 +++-- src/woodshop/gui/controller.py | 36 +++++++++++++++++++ src/woodshop/gui/feature_panel.py | 59 ++++++++++++++++++++++++------- src/woodshop/gui/main_window.py | 2 ++ src/woodshop/gui/viewport.py | 24 ++++++++++++- src/woodshop/viewer.py | 49 +++++++++++++++++++++++++ tests/test_gui_controller.py | 22 ++++++++++++ 7 files changed, 186 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a880147..2dcdda8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,8 +103,13 @@ pytest # 25 tests viewport tessellates featured boards (plain boards stay fast pyvista boxes). Edit paths: CLI `feature/feature-edit/feature-delete/features`; voice `wood-feature`/`wood-feature-delete`; **GUI Joinery tab** (`feature_panel.py`) - — add-with-default then edit fields; `controller.active_feature` is the one - being edited. Not yet: countersinks, and click-a-face-to-place in the GUI. + — add-with-default, then dragging a field shows a **live red preview ghost** + (`viewer.feature_preview_mesh` — a cheap box/cylinder, no re-tessellation) of + 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: + 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 failures but the mic path isn't exercised in the unit tests. diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index bacfb60..b721195 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -8,6 +8,7 @@ mutation saves to disk (keeping the CLI/headless tools interoperable) and emits """ from __future__ import annotations +import copy from pathlib import Path from types import SimpleNamespace @@ -66,6 +67,7 @@ TOOL_CMD = { class Controller(QObject): changed = Signal() # scene or selection changed -> refresh views logged = Signal(str, str) # (who, text): who in {"you","ws","sys"} + preview_changed = Signal() # pending feature preview changed -> redraw red ghost def __init__(self, scene_path: str | None = None): super().__init__() @@ -74,6 +76,7 @@ class Controller(QObject): self._schemas: str | None = None self.selected: list[str] = [self.scene.selection] if self.scene.selection else [] self.active_feature: str | None = None # feature currently being edited + self.preview = None # (Part, Feature) pending preview, or None # ----- persistence / notify ---------------------------------------- def save(self) -> None: @@ -254,6 +257,39 @@ class Controller(QObject): except SceneError: return None + # ----- 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.""" + feat = self.active_feature_obj() + if not feat: + self.preview = None + else: + part = self.scene.find_feature(feat.id)[0] + pending = copy.copy(feat) + for k, val in fields.items(): + if val is not None and hasattr(pending, k): + setattr(pending, k, val) + self.preview = (part, pending) + self.preview_changed.emit() + + def clear_preview(self) -> None: + self.preview = None + self.preview_changed.emit() + + def apply_preview(self) -> None: + """Commit the pending edit to the real feature (then re-render geometry).""" + if not self.preview: + return + _, pending = self.preview + self.preview = None + self.scene.edit_feature( + 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) + self._commit() # re-tessellates with the new geometry + self.preview_changed.emit() # clear the ghost + # ----- project / export -------------------------------------------- def open_project(self, name): self._do(lambda: cli.cmd_open(self.scene, SimpleNamespace(name=name))) def save_project(self, name): diff --git a/src/woodshop/gui/feature_panel.py b/src/woodshop/gui/feature_panel.py index 9c93a77..17dd036 100644 --- a/src/woodshop/gui/feature_panel.py +++ b/src/woodshop/gui/feature_panel.py @@ -34,25 +34,40 @@ class FeaturePanel(QWidget): self.list.itemSelectionChanged.connect(self._on_row) root.addWidget(self.list, 1) + self.hint = QLabel("") + self.hint.setWordWrap(True) + self.hint.setStyleSheet("color:#aaaaaa; font-size:11px;") + root.addWidget(self.hint) + form = QFormLayout() self.face = QComboBox(); self.face.addItems(FACES) - self.face.currentIndexChanged.connect(self._apply) + self.face.currentIndexChanged.connect(self._preview) + # (key, label, tooltip) + self._fields = [ + ("along_in", "Along board", "Position along the board's length (or 1st offset on an end)"), + ("across_in", "Across", "Offset from the centre of the face"), + ("width_in", "Width", "Feature size across the face (1st dimension)"), + ("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)"), + ] self._spins = {} - for key, label, lo, hi in [ - ("along_in", "Along", -48, 96), ("across_in", "Across", -24, 24), - ("width_in", "Width", 0, 24), ("height_in", "Height", 0, 24), - ("depth_in", "Depth", 0, 24), ("diameter_in", "Diameter", 0, 12)]: - sp = QDoubleSpinBox(); sp.setRange(lo, hi); sp.setSingleStep(0.25); sp.setSuffix(" in") - sp.editingFinished.connect(self._apply) + for key, label, tip in self._fields: + sp = QDoubleSpinBox(); 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 - form.addRow("Face", self.face) - for key, sp in self._spins.items(): - form.addRow(key.replace("_in", "").capitalize(), sp) + form.addRow(label, sp) + form.insertRow(0, "Face", self.face) root.addLayout(form) btns = QHBoxLayout() + self.apply_btn = QPushButton("Apply") + self.apply_btn.setToolTip("Commit the previewed change (cuts/adds the real geometry)") + self.apply_btn.clicked.connect(self.c.apply_preview) self.del_btn = QPushButton("Delete feature") self.del_btn.clicked.connect(self.c.delete_active_feature) + btns.addWidget(self.apply_btn) btns.addWidget(self.del_btn) root.addLayout(btns) @@ -84,13 +99,18 @@ class FeaturePanel(QWidget): editable = feat is not None self.face.setEnabled(editable) self.del_btn.setEnabled(editable) + self.apply_btn.setEnabled(editable) for sp in self._spins.values(): sp.setEnabled(editable) if feat: self.face.setCurrentText(feat.face) for key, sp in self._spins.items(): sp.setValue(getattr(feat, key)) + self.hint.setText(_HINTS.get(feat.kind, "") if feat else + "Add a feature above, then adjust it here.") self._loading = False + if self.c.preview is not None: # committed state -> drop any stale ghost + self.c.clear_preview() def _on_row(self) -> None: if self._loading: @@ -99,8 +119,23 @@ class FeaturePanel(QWidget): if items: self.c.select_feature(items[0].data(Qt.UserRole)) - def _apply(self) -> None: + def _preview(self) -> None: + """Live: show a red ghost of the pending values (no commit until Apply).""" if self._loading or not self.c.active_feature: return dims = {key: sp.value() for key, sp in self._spins.items()} - self.c.edit_active_feature(face=self.face.currentText(), **dims) + self.c.set_preview(face=self.face.currentText(), **dims) + + +_HINTS = { + "tenon": "Tenon — a tongue on the chosen end. Width × Height = its cross-section; " + "Depth = how far it sticks out; Across = shift it off-centre.", + "mortise": "Mortise — a pocket. Along = position down the board; Width × Height = " + "the opening on the face; Depth = how deep it goes.", + "hole": "Hole — Along = position down the board; Across = off-centre; " + "Diameter = size; Depth 0 = drilled all the way through.", + "slot": "Slot — a channel. Along = position; Width × Height = the opening; " + "Depth = how deep.", + "chamfer": "Chamfer — bevels the edges around the chosen Face; Width = bevel size " + "(the red preview highlights the face).", +} diff --git a/src/woodshop/gui/main_window.py b/src/woodshop/gui/main_window.py index 2d4db5a..9115f6c 100644 --- a/src/woodshop/gui/main_window.py +++ b/src/woodshop/gui/main_window.py @@ -56,6 +56,8 @@ class MainWindow(QMainWindow): self.viewport.picked.connect(self._on_pick) self.controller.changed.connect(self._on_changed) + self.controller.preview_changed.connect( + lambda: self.viewport.set_preview(self.controller.preview)) self._build_menus() self._on_changed() # initial render + status diff --git a/src/woodshop/gui/viewport.py b/src/woodshop/gui/viewport.py index 5c6a518..05c655b 100644 --- a/src/woodshop/gui/viewport.py +++ b/src/woodshop/gui/viewport.py @@ -7,7 +7,8 @@ from PySide6.QtWidgets import (QApplication, QHBoxLayout, QPushButton, QVBoxLayout, QWidget) from ..scene import Scene -from ..viewer import _PALETTE, _add_feature_edges, _part_mesh, _quiet_vtk +from ..viewer import (_PALETTE, _add_feature_edges, _part_mesh, _quiet_vtk, + feature_preview_mesh) class Viewport(QWidget): @@ -94,6 +95,27 @@ class Viewport(QWidget): self.plotter.camera_position = cam # keep the user's viewpoint self.plotter.render() + def set_preview(self, preview) -> None: + """Draw (or clear) the translucent red ghost of a pending feature edit.""" + for name in ("preview_face", "preview_edges"): + try: + self.plotter.remove_actor(name) + except Exception: + pass + if preview is not None: + part, feat = preview + try: + mesh = feature_preview_mesh(part, feat) + self.plotter.add_mesh(mesh, color="red", opacity=0.4, pickable=False, + reset_camera=False, name="preview_face") + edges = mesh.extract_feature_edges() + if edges.n_points: + self.plotter.add_mesh(edges, color="red", line_width=3, + pickable=False, reset_camera=False, name="preview_edges") + except Exception: + pass + self.plotter.render() + def set_front(self): self.plotter.view_xz(); self.plotter.render() def set_iso(self): self.plotter.view_isometric(); self.plotter.render() def fit(self): self.plotter.reset_camera(); self.plotter.render() diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index 08018c1..042492d 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -54,6 +54,55 @@ def _part_mesh(part: Part): return cube +def _axis_extent(axis, L, w, t): + if axis == (1, 0, 0): + return L + if axis in ((0, 1, 0), (0, -1, 0)): + return w + return t + + +def feature_preview_mesh(part, feat): + """A cheap pyvista box/cylinder showing a feature's footprint (no build123d), + for the live red preview while adjusting fields.""" + import pyvista as pv + + from .geometry import _face_frame + L = part.length_in + t, w = part.section_in + o, n, u, v = _face_frame(feat.face, L, w, t) + off_u = feat.along_in - (L / 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)) + + if feat.kind == "hole": + thru = abs(n[0]) * L + abs(n[1]) * w + abs(n[2]) * t + 0.1 + h = feat.depth_in if feat.depth_in > 0 else thru + c = tuple(fp[i] - n[i] * h / 2 for i in range(3)) + mesh = pv.Cylinder(center=c, direction=n, radius=feat.diameter_in / 2, height=h) + elif feat.kind == "chamfer": # can't cheaply preview the bevel — highlight the face + ue, ve, thin = _axis_extent(u, L, w, t), _axis_extent(v, L, w, t), 0.08 + dims = tuple(ue * abs(u[i]) + ve * abs(v[i]) + thin * abs(n[i]) for i in range(3)) + c = fp + mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2, + c[1] - dims[1] / 2, c[1] + dims[1] / 2, + c[2] - dims[2] / 2, c[2] + dims[2] / 2)) + else: # tenon (out) / mortise/slot/dado/rabbet (in) + d = feat.depth_in + dims = tuple(feat.width_in * abs(u[i]) + feat.height_in * abs(v[i]) + d * abs(n[i]) + for i in range(3)) + sign = 1 if feat.kind == "tenon" else -1 + c = tuple(fp[i] + sign * n[i] * d / 2 for i in range(3)) + mesh = pv.Box(bounds=(c[0] - dims[0] / 2, c[0] + dims[0] / 2, + c[1] - dims[1] / 2, c[1] + dims[1] / 2, + c[2] - dims[2] / 2, c[2] + dims[2] / 2)) + + 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) + mesh.translate(part.position_in, inplace=True) + return mesh + + def _add_feature_edges(plotter, mesh, selected: bool) -> None: """Overlay a tessellated solid's real edges (corners/holes/chamfers) so it reads as crisply as a plain board, without the triangle-mesh noise.""" diff --git a/tests/test_gui_controller.py b/tests/test_gui_controller.py index 1229ecc..19cfa30 100644 --- a/tests/test_gui_controller.py +++ b/tests/test_gui_controller.py @@ -77,6 +77,28 @@ def test_group_move_is_single_undo(tmp_path): assert all(p.position_in[1] == 0 for p in c.scene.parts) +def test_feature_preview_then_apply(tmp_path): + c = _controller(tmp_path) + c.place("2x4", 12) + c.add_feature("mortise") # active feature with defaults + orig = c.active_feature_obj().depth_in + c.set_preview(depth_in=orig + 0.5) # preview only — model unchanged + assert c.preview is not None + assert c.active_feature_obj().depth_in == orig + c.apply_preview() # commit + assert c.preview is None + assert c.active_feature_obj().depth_in == orig + 0.5 + + +def test_feature_preview_mesh_builds(): + pytest.importorskip("pyvista") + from woodshop.scene import Scene + from woodshop.viewer import feature_preview_mesh + s = Scene(); s.place("2x4", 12) + feat = s.add_feature("p1", "hole", face="top", along_in=6, diameter_in=0.5) + assert feature_preview_mesh(s.get_part("p1"), feat).n_points > 0 + + def test_unknown_tool_is_safe(tmp_path): c = _controller(tmp_path) assert "unknown" in c.execute_call("wood-bogus", {}).lower()