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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue