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
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
normal) to line up cross-sections. CLI `connect`; voice `wood-connect`. Not
yet: countersinks, click-a-face-to-place.
normal) to line up cross-sections. CLI `connect`; voice `wood-connect`.
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).
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

@ -290,6 +290,19 @@ class Controller(QObject):
self.scene.edit_feature(feat.id, **dims)
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:
"""Move/orient the target's board so its feature seats into the active one."""
feat = self.active_feature_obj()

View File

@ -6,7 +6,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout,
QGridLayout, QGroupBox, QInputDialog, QLabel,
QListWidget, QListWidgetItem, QPushButton,
QMenu, QPushButton, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget)
from .controller import Controller
@ -19,12 +19,15 @@ class PartsPanel(QWidget):
self._loading = False
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.list.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
self.list.itemSelectionChanged.connect(self._on_row_selected)
root.addWidget(self.list, 1)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select
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")
bl = QVBoxLayout(box)
@ -63,18 +66,36 @@ class PartsPanel(QWidget):
self.c.changed.connect(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 ------------------------------------------
def refresh(self) -> None:
self._loading = True
self.list.clear()
self.tree.clear()
selected = set(self.c.selected)
for p in self.c.scene.parts:
name = f" · {p.name}" if p.name else ""
item = QListWidgetItem(f"{p.id} {p.stock} {p.length_in:g}\"{name}")
item.setData(Qt.UserRole, p.id)
self.list.addItem(item)
if p.id in selected:
item.setSelected(True)
by_id = {p.id: p for p in self.c.scene.parts}
for group in self.c.groups():
if len(group) > 1: # an assembly -> parent node
names = [by_id[i].name or i for i in group if i in by_id]
node = QTreeWidgetItem(self.tree, [f"⛓ Assembly: {' + '.join(names)}"])
node.setData(0, Qt.UserRole, None)
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()
if part:
@ -96,11 +117,36 @@ class PartsPanel(QWidget):
return next((p for p in self.c.scene.parts if p.id == pid), None)
# ----- 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:
if self._loading:
return
ids = [it.data(Qt.UserRole) for it in self.list.selectedItems()]
self.c.set_selected(ids)
self.c.set_selected(self._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:
part = self._selected_part()