Spatial feedback to the AI + manual add-board control
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) <noreply@anthropic.com>
This commit is contained in:
parent
35adf5ee0d
commit
52be9ee5b1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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("<b>Parts</b> <span style='color:#888'>(connected boards group into assemblies)</span>"))
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue