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()