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:
parent
fad56f4fc3
commit
774ddd3480
13
CLAUDE.md
13
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
|
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** ~7–13s per utterance (one `claude -p` call).
|
2. **Latency** ~7–13s 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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue