Live red preview + Apply for feature editing

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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 15:39:00 -03:00
parent d0e40cdcbc
commit 70f8e9f0a2
7 changed files with 186 additions and 15 deletions

View File

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

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

View File

@ -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).",
}

View File

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

View File

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

View File

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

View File

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