From 774ddd3480b8923369a127312fd8cf6e3d5e454d Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 09:39:48 -0300 Subject: [PATCH] GUI: assembly subtree + connection right-click menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 13 +++++- src/woodshop/gui/controller.py | 13 ++++++ src/woodshop/gui/panels.py | 78 +++++++++++++++++++++++++++------- 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d057be3..a7cddba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** ~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. diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 7839fb4..f5fc726 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -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() diff --git a/src/woodshop/gui/panels.py b/src/woodshop/gui/panels.py index a17149d..6ab90aa 100644 --- a/src/woodshop/gui/panels.py +++ b/src/woodshop/gui/panels.py @@ -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("Parts")) + root.addWidget(QLabel("Parts (connected boards group into assemblies)")) - 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()