GUI: assembly subtree + connection right-click menu

Parts tab is now a QTreeWidget: connected boards group under an "⛓ Assembly"
node (expandable) showing their members; standalone boards stay top-level. A
board with features is flagged ⊕. Multi-select still drives controller.selected
(selecting an assembly node selects its members).

Right-click menu: Back off connections (explode), Re-fit connections (assemble),
Break this board's connections / Break all. Controller wrappers: explode/
assemble/break_connections/groups.

83 tests pass; GUI imports clean (live tree behavior needs a display to verify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 09:39:48 -03:00
parent fad56f4fc3
commit 774ddd3480
3 changed files with 86 additions and 18 deletions

View File

@ -119,8 +119,17 @@ pytest # 25 tests
the board's yaw/tilt/roll via `matrix_to_ypr` (inverse of `Part.local_frame`'s the board's yaw/tilt/roll via `matrix_to_ypr` (inverse of `Part.local_frame`'s
Rz·Ry(-tilt)·Rx(roll)); `Part.feature_world_frame` gives each feature's world Rz·Ry(-tilt)·Rx(roll)); `Part.feature_world_frame` gives each feature's world
point/normal/u/v. Features also have `rotation_deg` (spin about the face point/normal/u/v. Features also have `rotation_deg` (spin about the face
normal) to line up cross-sections. CLI `connect`; voice `wood-connect`. Not normal) to line up cross-sections. CLI `connect`; voice `wood-connect`.
yet: countersinks, click-a-face-to-place. 3. **Connections / assemblies**: `connect` RECORDS a `Connection`; connected
boards form an assembly (`scene.groups()`, connection-graph union-find). They
stay separate boards (not fused — cut list & disassembly keep working). Ops:
`scene.explode(d)` backs each moving board off along its joint axis,
`assemble()` re-seats (reverse), `disconnect(cid/part)` breaks (pieces stay
put). `_seat()` is the shared mate math. CLI `connections/disconnect/explode/
assemble`; voice `wood-explode/assemble/disconnect`. The GUI Parts tab is a
QTreeWidget grouping connected boards under an assembly node, with a
right-click menu (back off / re-fit / break). Not yet: countersinks,
click-a-face-to-place, per-assembly naming/rename.
2. **Latency** ~713s per utterance (one `claude -p` call). 2. **Latency** ~713s per utterance (one `claude -p` call).
3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against 3. Voice path (`--voice`) reuses `dictate`; the driver loop is hardened against
failures but the mic path isn't exercised in the unit tests. failures but the mic path isn't exercised in the unit tests.

View File

@ -290,6 +290,19 @@ class Controller(QObject):
self.scene.edit_feature(feat.id, **dims) self.scene.edit_feature(feat.id, **dims)
self._commit(f"Fitted {feat.id} to {target_fid}.") self._commit(f"Fitted {feat.id} to {target_fid}.")
def explode(self, distance: float = 3.0) -> None:
self._do(lambda: self.scene.explode(distance))
def assemble(self) -> None:
self._do(self.scene.assemble)
def break_connections(self, part_id: str | None = None) -> None:
self._do(lambda: self.scene.disconnect(part=part_id) if part_id
else self.scene.disconnect())
def groups(self) -> list[list[str]]:
return self.scene.groups()
def make_connection(self, target_fid: str) -> None: def make_connection(self, target_fid: str) -> None:
"""Move/orient the target's board so its feature seats into the active one.""" """Move/orient the target's board so its feature seats into the active one."""
feat = self.active_feature_obj() feat = self.active_feature_obj()

View File

@ -6,7 +6,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout, from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout,
QGridLayout, QGroupBox, QInputDialog, QLabel, QGridLayout, QGroupBox, QInputDialog, QLabel,
QListWidget, QListWidgetItem, QPushButton, QMenu, QPushButton, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget) QVBoxLayout, QWidget)
from .controller import Controller from .controller import Controller
@ -19,12 +19,15 @@ class PartsPanel(QWidget):
self._loading = False self._loading = False
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget(QLabel("<b>Parts</b>")) root.addWidget(QLabel("<b>Parts</b> <span style='color:#888'>(connected boards group into assemblies)</span>"))
self.list = QListWidget() self.tree = QTreeWidget()
self.list.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select self.tree.setHeaderHidden(True)
self.list.itemSelectionChanged.connect(self._on_row_selected) self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
root.addWidget(self.list, 1) self.tree.itemSelectionChanged.connect(self._on_row_selected)
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self._context_menu)
root.addWidget(self.tree, 1)
box = QGroupBox("Selected") box = QGroupBox("Selected")
bl = QVBoxLayout(box) bl = QVBoxLayout(box)
@ -63,18 +66,36 @@ class PartsPanel(QWidget):
self.c.changed.connect(self.refresh) self.c.changed.connect(self.refresh)
self.refresh() self.refresh()
def _part_label(self, p) -> str:
name = f" · {p.name}" if p.name else ""
extra = "" if p.features else ""
return f"{p.id} {p.stock} {p.length_in:g}\"{name}{extra}"
def _add_leaf(self, parent, pid, selected):
p = next((q for q in self.c.scene.parts if q.id == pid), None)
if not p:
return
item = QTreeWidgetItem(parent, [self._part_label(p)])
item.setData(0, Qt.UserRole, pid)
if pid in selected:
item.setSelected(True)
# ----- refresh from scene ------------------------------------------ # ----- refresh from scene ------------------------------------------
def refresh(self) -> None: def refresh(self) -> None:
self._loading = True self._loading = True
self.list.clear() self.tree.clear()
selected = set(self.c.selected) selected = set(self.c.selected)
for p in self.c.scene.parts: by_id = {p.id: p for p in self.c.scene.parts}
name = f" · {p.name}" if p.name else "" for group in self.c.groups():
item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}") if len(group) > 1: # an assembly -> parent node
item.setData(Qt.UserRole, p.id) names = [by_id[i].name or i for i in group if i in by_id]
self.list.addItem(item) node = QTreeWidgetItem(self.tree, [f"⛓ Assembly: {' + '.join(names)}"])
if p.id in selected: node.setData(0, Qt.UserRole, None)
item.setSelected(True) node.setExpanded(True)
for pid in group:
self._add_leaf(node, pid, selected)
elif group:
self._add_leaf(self.tree, group[0], selected)
part = self._selected_part() part = self._selected_part()
if part: if part:
@ -96,11 +117,36 @@ class PartsPanel(QWidget):
return next((p for p in self.c.scene.parts if p.id == pid), None) return next((p for p in self.c.scene.parts if p.id == pid), None)
# ----- handlers ---------------------------------------------------- # ----- handlers ----------------------------------------------------
def _selected_ids(self) -> list[str]:
ids = []
for it in self.tree.selectedItems():
pid = it.data(0, Qt.UserRole)
if pid: # a part leaf
ids.append(pid)
else: # an assembly node -> its members
ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())]
return list(dict.fromkeys(ids))
def _on_row_selected(self) -> None: def _on_row_selected(self) -> None:
if self._loading: if self._loading:
return return
ids = [it.data(Qt.UserRole) for it in self.list.selectedItems()] self.c.set_selected(self._selected_ids())
self.c.set_selected(ids)
def _context_menu(self, pos) -> None:
item = self.tree.itemAt(pos)
menu = QMenu(self)
if self.c.scene.connections:
menu.addAction("Back off connections", lambda: self.c.explode(3.0))
menu.addAction("Re-fit connections", self.c.assemble)
if item:
pid = item.data(0, Qt.UserRole)
if pid:
menu.addAction("Break this board's connections",
lambda: self.c.break_connections(pid))
menu.addAction("Break all connections", lambda: self.c.break_connections())
if menu.isEmpty():
menu.addAction("No connections yet").setEnabled(False)
menu.exec(self.tree.viewport().mapToGlobal(pos))
def _rename(self) -> None: def _rename(self) -> None:
part = self._selected_part() part = self._selected_part()