Right-click a feature to break its connection

The Joinery tab feature list now has a context menu: right-click a mortise/tenon
to "Break this connection" (only shown when it's connected) or "Delete feature".
controller.break_feature_connection breaks just the connection(s) that feature
is part of, in one undo step.

86 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 11:06:25 -03:00
parent 5e8a1c7926
commit 3e7375344e
3 changed files with 45 additions and 2 deletions

View File

@ -301,6 +301,23 @@ class Controller(QObject):
self._do(lambda: self.scene.disconnect(part=part_id) if part_id
else self.scene.disconnect())
def feature_connection_ids(self, fid: str) -> list[str]:
return [c.id for c in self.scene.connections if fid in (c.anchor, c.moving)]
def break_feature_connection(self, fid: str) -> None:
"""Break the connection(s) that this specific feature is part of."""
cids = self.feature_connection_ids(fid)
if not cids:
return
def op():
with self.scene.batch():
for cid in cids:
self.scene.disconnect(cid=cid)
return f"Broke {len(cids)} connection(s) on {fid}."
self._do(op)
def groups(self) -> list[list[str]]:
return self.scene.groups()

View File

@ -6,8 +6,8 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFormLayout, QGridLayout, QHBoxLayout,
QLabel, QListWidget, QListWidgetItem, QMessageBox,
QPushButton, QVBoxLayout, QWidget)
QLabel, QListWidget, QListWidgetItem, QMenu,
QMessageBox, QPushButton, QVBoxLayout, QWidget)
from ..scene import FACES
from .controller import Controller
@ -33,6 +33,8 @@ class FeaturePanel(QWidget):
self.list = QListWidget()
self.list.itemSelectionChanged.connect(self._on_row)
self.list.setContextMenuPolicy(Qt.CustomContextMenu)
self.list.customContextMenuRequested.connect(self._feat_menu)
root.addWidget(self.list, 1)
self.hint = QLabel("")
@ -136,6 +138,19 @@ class FeaturePanel(QWidget):
if items:
self.c.select_feature(items[0].data(Qt.UserRole))
def _feat_menu(self, pos) -> None:
item = self.list.itemAt(pos)
if not item:
return
fid = item.data(Qt.UserRole)
self.c.select_feature(fid)
menu = QMenu(self)
if self.c.feature_connection_ids(fid):
menu.addAction("Break this connection",
lambda: self.c.break_feature_connection(fid))
menu.addAction("Delete feature", self.c.delete_active_feature)
menu.exec(self.list.viewport().mapToGlobal(pos))
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:

View File

@ -124,6 +124,17 @@ def test_highlight_feature(tmp_path):
assert c.preview is None
def test_break_feature_connection(tmp_path):
c = _controller(tmp_path)
c.place("2x4", 24); c.add_feature("mortise") # f1 on p1
c.place("2x4", 12); c.add_feature("tenon") # f2 on p2
c.scene.connect("f1", "f2")
assert c.feature_connection_ids("f1") == ["c1"]
c.break_feature_connection("f1")
assert c.scene.connections == []
assert c.feature_connection_ids("f1") == []
def test_unknown_tool_is_safe(tmp_path):
c = _controller(tmp_path)
assert "unknown" in c.execute_call("wood-bogus", {}).lower()