Highlight the selected feature in the 3D scene (cyan)

Clicking a feature in the Joinery tab now highlights it in the viewport with a
cyan ghost so you can see which mortise/tenon it is; browsing candidates in the
"Fit to…" dialog highlights each one as you select it (restored to the active
feature on cancel). Reuses the overlay mechanism with a kind ("edit"=red pending
vs "highlight"=cyan); controller.highlight_feature drives it.

84 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 09:56:49 -03:00
parent 774ddd3480
commit 20327ee9d3
5 changed files with 44 additions and 9 deletions

View File

@ -81,7 +81,8 @@ 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
self.preview = None # (Part, Feature) shown as an overlay, or None
self.preview_kind = "edit" # "edit" (red pending) | "highlight" (cyan)
# ----- persistence / notify ----------------------------------------
def save(self) -> None:
@ -328,6 +329,20 @@ class Controller(QObject):
if val is not None and hasattr(pending, k):
setattr(pending, k, val)
self.preview = (part, pending)
self.preview_kind = "edit"
self.preview_changed.emit()
def highlight_feature(self, fid: str | None) -> None:
"""Show a cyan highlight of a feature in the scene (no edit)."""
if not fid:
self.preview = None
else:
try:
part, feat = self.scene.find_feature(fid)
self.preview = (part, copy.copy(feat))
self.preview_kind = "highlight"
except SceneError:
self.preview = None
self.preview_changed.emit()
def clear_preview(self) -> None:

View File

@ -123,8 +123,8 @@ class FeaturePanel(QWidget):
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()
# Highlight the selected feature in the scene (cyan), or clear if none.
self.c.highlight_feature(feat.id if feat else None)
def _on_row(self) -> None:
if self._loading:
@ -160,6 +160,9 @@ class FeaturePanel(QWidget):
item = QListWidgetItem(label)
item.setData(Qt.UserRole, f.id)
lst.addItem(item)
# Highlight whichever candidate is selected so the user sees it in 3D.
lst.currentItemChanged.connect(
lambda cur, _prev: self.c.highlight_feature(cur.data(Qt.UserRole)) if cur else None)
lst.setCurrentRow(0)
lay.addWidget(lst)
connect_cb = QCheckBox("Make connection (move && orient the other board to seat the joint)")
@ -168,11 +171,14 @@ class FeaturePanel(QWidget):
bb.accepted.connect(dlg.accept)
bb.rejected.connect(dlg.reject)
lay.addWidget(bb)
if dlg.exec() and lst.currentItem():
accepted = dlg.exec()
if accepted and lst.currentItem():
target = lst.currentItem().data(Qt.UserRole)
self.c.fit_feature(target) # size to match
if connect_cb.isChecked():
self.c.make_connection(target) # then assemble
else:
self.c.highlight_feature(feat.id) # restore highlight on cancel
_HINTS = {

View File

@ -57,7 +57,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))
lambda: self.viewport.set_preview(self.controller.preview,
self.controller.preview_kind))
self._build_menus()
self._on_changed() # initial render + status

View File

@ -95,8 +95,9 @@ 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."""
def set_preview(self, preview, kind="edit") -> None:
"""Draw (or clear) a feature overlay: red for a pending edit, cyan to
highlight the selected feature."""
for name in ("preview_face", "preview_edges"):
try:
self.plotter.remove_actor(name)
@ -104,13 +105,14 @@ class Viewport(QWidget):
pass
if preview is not None:
part, feat = preview
color = "#33ccff" if kind == "highlight" else "red"
try:
mesh = feature_preview_mesh(part, feat)
self.plotter.add_mesh(mesh, color="red", opacity=0.4, pickable=False,
self.plotter.add_mesh(mesh, color=color, 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,
self.plotter.add_mesh(edges, color=color, line_width=3,
pickable=False, reset_camera=False, name="preview_edges")
except Exception:
pass

View File

@ -113,6 +113,17 @@ def test_fit_mortise_to_tenon(tmp_path):
assert m.depth_in == 1.5 + 1 / 32
def test_highlight_feature(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 12)
c.add_feature("mortise") # f1
c.highlight_feature("f1")
assert c.preview is not None and c.preview_kind == "highlight"
assert c.preview[1].id == "f1"
c.highlight_feature(None)
assert c.preview is None
def test_unknown_tool_is_safe(tmp_path):
c = _controller(tmp_path)
assert "unknown" in c.execute_call("wood-bogus", {}).lower()