From 52be9ee5b161f315492a5643da67debae4087524 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 12:17:41 -0300 Subject: [PATCH] Spatial feedback to the AI + manual add-board control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spatial feedback (direction #1): scene.spatial_summary() reports each board's world bounding box (Part.bbox) and flags interpenetrating pairs; the GUI feeds it into the interpreter prompt. The SYSTEM prompt now tells the AI to use the layout to position boards flush against each other (computed wood-move) and to fix overlaps — so relative commands like "position the left side flush against p2" work. Verified live: "stack p2 on top of p1" moved p2 onto p1's top face. Manual add: the Parts tab gained a stock dropdown + length + "Add board" button to place a board instantly without the AI (controller.place). 88 tests pass (bbox axis-aligned, spatial_summary flags/clears overlap). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +++++ src/woodshop/driver.py | 6 +++++ src/woodshop/gui/controller.py | 4 +++- src/woodshop/gui/panels.py | 24 +++++++++++++++---- src/woodshop/scene.py | 42 ++++++++++++++++++++++++++++++++++ tests/test_scene.py | 20 ++++++++++++++++ 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99ef3e1..656cfc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,12 @@ operations + interpreter: - `numpad.py` — a numberpad control panel (2/4/6/8 move, 1/3/7/9 rotate, +/− raise/lower, 0/. front/iso, 5 fit) that also responds to the **physical numpad keys** (MainWindow.keyPressEvent forwards them when not typing). +- **Spatial feedback**: `scene.spatial_summary()` (each board's world bounding + box via `Part.bbox()` + flagged interpenetrations) is fed into the interpreter + prompt, so the AI can reason about where boards are — e.g. "position the left + side flush against p2" becomes a computed `wood-move`, and it can fix overlaps. +- **Manual add**: the Parts tab has a stock dropdown + length + "Add board" to + place a board instantly without the AI (`controller.place`). - **Multi-selection**: `controller.selected` is a list driven by 3D Ctrl+click (`viewport.picked` carries an additive flag) and list multi-select. Group ops (`move_selected`/`rotate_selected`/stand/lay/sand/delete) apply to all selected diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index f9dbb7b..a377f48 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -59,6 +59,12 @@ Rules: - Refer to boards that ALREADY exist by their real id (p1, p2, ...) or their name. - For a board you place earlier in THIS response, refer to it later as $1, $2, ... numbered by the order you place boards in this response (the first wood-place is $1). +- A "Layout" section gives each board's bounding box (in inches). Use it to + reason about where boards are. Boards should TOUCH at faces, never overlap or + leave gaps. To position one board flush against / next to another, compute the + offset from the two bounding boxes and emit wood-move with the relative + dx/dy/dz that makes their faces meet (e.g. move so the moving board's x-max + equals the target board's x-min). Fix any "Interpenetrating" pairs the same way. - "these" / "them" / "the selected ones" refer to the currently-selected boards listed under the scene; emit one call per selected board (e.g. wood-move for each). - Legs and uprights must be stood up: place the board, then wood-stand it. diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 37640fb..6e8ad63 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -418,10 +418,12 @@ class Controller(QObject): def run_command(self, text: str) -> str: """Interpret a spoken/typed command and apply it. Returns a spoken summary. (Slow — call from a worker thread.)""" + from ..scene import spatial_summary self.save() # ensure disk reflects current state sel = ", ".join(self.selected) if self.selected else "none" scene_text = (cli.cmd_status(self.scene, None) - + f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}") + + f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}" + + "\n" + spatial_summary(self.scene)) calls = driver.interpret(text, self.schemas(), scene_text=scene_text) messages = driver.dispatch(calls, verbose=False, executor=self.execute_call) self._commit() diff --git a/src/woodshop/gui/panels.py b/src/woodshop/gui/panels.py index 6ab90aa..cf79364 100644 --- a/src/woodshop/gui/panels.py +++ b/src/woodshop/gui/panels.py @@ -4,11 +4,12 @@ that solves "delete that" ambiguity).""" from __future__ import annotations from PySide6.QtCore import Qt -from PySide6.QtWidgets import (QAbstractItemView, QDoubleSpinBox, QFormLayout, - QGridLayout, QGroupBox, QInputDialog, QLabel, - QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget) +from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox, + QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, + QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget) +from ..lumber import NOMINAL_TO_ACTUAL from .controller import Controller @@ -21,6 +22,18 @@ class PartsPanel(QWidget): root = QVBoxLayout(self) root.addWidget(QLabel("Parts (connected boards group into assemblies)")) + # Quick manual add — no AI needed. + add = QHBoxLayout() + self.stock = QComboBox() + self.stock.addItems(sorted(NOMINAL_TO_ACTUAL, key=lambda s: (s[0], len(s), s))) + self.stock.setCurrentText("2x4") + self.add_len = QDoubleSpinBox(); self.add_len.setRange(0.5, 480) + self.add_len.setValue(24); self.add_len.setSuffix(" in") + add_btn = QPushButton("+ Add board") + add_btn.clicked.connect(self._add_board) + add.addWidget(self.stock); add.addWidget(self.add_len); add.addWidget(add_btn) + root.addLayout(add) + self.tree = QTreeWidget() self.tree.setHeaderHidden(True) self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection) # Ctrl/Shift multi-select @@ -127,6 +140,9 @@ class PartsPanel(QWidget): ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())] return list(dict.fromkeys(ids)) + def _add_board(self) -> None: + self.c.place(self.stock.currentText(), self.add_len.value()) + def _on_row_selected(self) -> None: if self._loading: return diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index 069f0c9..a13404b 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -167,6 +167,32 @@ class Feature: return self.kind in CUT_KINDS +def _aabb_overlap(b1, b2, eps=0.05) -> bool: + (lo1, hi1), (lo2, hi2) = b1, b2 + return all(min(hi1[i], hi2[i]) - max(lo1[i], lo2[i]) > eps for i in range(3)) + + +def spatial_summary(scene) -> str: + """A compact text description of where each board sits (bounding boxes) and + which boards interpenetrate — fed to the AI so it can reason spatially.""" + if not scene.parts: + return "empty" + boxes = {p.id: p.bbox() for p in scene.parts} + lines = ["Layout (inch bounding boxes, x=length-ish, z=up):"] + for p in scene.parts: + lo, hi = boxes[p.id] + label = f"{p.id}" + (f" ({p.name})" if p.name else "") + lines.append(f" {label}: x[{lo[0]:.1f},{hi[0]:.1f}] " + f"y[{lo[1]:.1f},{hi[1]:.1f}] z[{lo[2]:.1f},{hi[2]:.1f}]") + ids = list(boxes) + overlaps = [f"{ids[i]}&{ids[j]}" for i in range(len(ids)) for j in range(i + 1, len(ids)) + if _aabb_overlap(boxes[ids[i]], boxes[ids[j]])] + if overlaps: + lines.append("Interpenetrating (usually unintended — boards should touch, not overlap): " + + ", ".join(overlaps)) + return "\n".join(lines) + + @dataclass class Part: id: str @@ -204,6 +230,22 @@ class Part: cl, cw, ct = self.local_frame() return [[cl[i], cw[i], ct[i]] for i in range(3)] + def bbox(self) -> tuple: + """World axis-aligned bounding box (min_xyz, max_xyz) of the board.""" + t, w = self.section_in + L = self.length_in + R = self.rotation_matrix() + corners = [] + for x in (0.0, L): + for y in (-w / 2, w / 2): + for z in (-t / 2, t / 2): + loc = (x, y, z) + corners.append(tuple(self.position_in[i] + sum(R[i][j] * loc[j] for j in range(3)) + for i in range(3))) + lo = tuple(min(c[i] for c in corners) for i in range(3)) + hi = tuple(max(c[i] for c in corners) for i in range(3)) + return lo, hi + def feature_world_frame(self, feat) -> tuple: """(contact point, outward normal, u, v) of a feature, in world space, with the feature's own rotation about its normal applied to u/v.""" diff --git a/tests/test_scene.py b/tests/test_scene.py index d6792e5..28f963c 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -321,6 +321,26 @@ def test_connect_needs_two_boards(): s.connect("f1", "f2") +def test_bbox_axis_aligned(): + s = Scene() + p = s.place("2x4", 24) # 1.5 x 3.5 section + lo, hi = p.bbox() + assert lo == pytest.approx((0, -1.75, -0.75)) + assert hi == pytest.approx((24, 1.75, 0.75)) + + +def test_spatial_summary_flags_overlap(): + from woodshop.scene import spatial_summary + s = Scene() + s.place("2x4", 24) # p1 at origin + s.place("2x4", 24) # p2 at origin -> overlaps p1 + summ = spatial_summary(s) + assert "p1" in summ and "p2" in summ + assert "p1&p2" in summ # interpenetration flagged + s.move("p2", dy=10) # slide clear + assert "p1&p2" not in spatial_summary(s) + + def test_clear(): s = Scene() s.place("2x4", 24)