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:
parent
d0e40cdcbc
commit
70f8e9f0a2
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue