Phase 4: constrained manual layout editing (drag/snap/lock/rotate)

cutplan.py gains deterministic editing helpers: find_placement, placement_fits
(bounds + kerf-aware overlap), snap_x (snap to stock edges / neighbour ±kerf),
relocate (move a placement to a stock piece / position), rotate_placement.

The BOM Cut Layout tab is now editable: it holds a persistent CutPlan; pieces are
draggable (_Piece) — on drop they snap, validate, and either commit or revert with
a status message; you can drag a piece onto another stick/sheet to reassign it,
double-click a panel to rotate it, and right-click to lock (locked pieces can't be
dragged). "Find better layout" / "Try alternative" regenerate the auto plan.

119 tests pass (snap, fits/overlap, relocate-between-sticks, rotate). Window +
drag/rotate handlers verified offscreen; interactive drag/print needs a display.

Known follow-up: lock-aware re-optimization (locked pieces currently protect against
drags but aren't yet preserved across a fresh auto-layout).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 14:52:03 -03:00
parent ee00ec7ce5
commit 93d1b186e3
4 changed files with 236 additions and 18 deletions

View File

@ -2,6 +2,13 @@
A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go. A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go.
**Status:** Phases 04 implemented (cutplan.py model; multi-strategy auto-layout;
deterministic instructions + AI polish; rule-based jig suggestions; constrained
drag-edit layout). Logic is unit-tested; the drag/print GUI needs a real display to
verify interactively. Known follow-ups: lock-aware re-optimization (locked pieces
preserved through "Find better layout"), grain-direction handling, on-hand offcut
inventory, and opt-in jig material in the BOM.
## Guiding principle ## Guiding principle
The **math layer is deterministic and inspectable**; AI is used **only for narrative** The **math layer is deterministic and inspectable**; AI is used **only for narrative**
(instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig (instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig

View File

@ -324,6 +324,62 @@ def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 2
return best return best
# --- manual editing helpers (deterministic; the drag UI builds on these) -----
def find_placement(plan: CutPlan, pid: str):
for sp in plan.stock_pieces:
for p in sp.placements:
if p.id == pid:
return sp, p
raise KeyError(pid)
def placement_fits(sp: StockPiece, placement: Placement, kerf: float) -> bool:
"""Is `placement` inside `sp` and not overlapping its other placements (kerf-aware)?"""
if placement.x_in < -_EPS or placement.x_in + placement.len_in > sp.length_in + _EPS:
return False
if placement.y_in < -_EPS or placement.y_in + placement.wid_in > sp.width_in + _EPS:
return False
for q in sp.placements:
if q.id == placement.id:
continue
x_ov = (min(placement.x_in + placement.len_in, q.x_in + q.len_in)
- max(placement.x_in, q.x_in))
y_ov = (min(placement.y_in + placement.wid_in, q.y_in + q.wid_in)
- max(placement.y_in, q.y_in))
if x_ov > kerf - _EPS and y_ov > _EPS:
return False
return True
def snap_x(sp: StockPiece, placement: Placement, x: float, kerf: float, tol: float = 2.0) -> float:
"""Snap an x position to stock edges / neighbour edges (+kerf), within `tol`."""
cands = [0.0, sp.length_in - placement.len_in]
for q in sp.placements:
if q.id == placement.id:
continue
cands.append(q.x_in + q.len_in + kerf) # butt to the right of q (+kerf)
cands.append(q.x_in - placement.len_in - kerf) # butt to the left of q
best = min(cands, key=lambda c: abs(c - x))
return best if abs(best - x) <= tol else x
def relocate(plan: CutPlan, pid: str, target_sp_id: str, x_in: float, y_in: float = 0.0) -> None:
"""Move a placement to a stock piece at (x,y). Does not validate (caller checks)."""
sp, p = find_placement(plan, pid)
target = next(s for s in plan.stock_pieces if s.id == target_sp_id)
if target is not sp:
sp.placements.remove(p)
target.placements.append(p)
p.x_in, p.y_in = x_in, y_in
def rotate_placement(plan: CutPlan, pid: str) -> None:
"""Swap a placement's footprint (plywood rotation)."""
_sp, p = find_placement(plan, pid)
p.len_in, p.wid_in = p.wid_in, p.len_in
p.rotated = not p.rotated
def validate_cut_plan(plan: CutPlan) -> list: def validate_cut_plan(plan: CutPlan) -> list:
"""Return a list of problems ([] means valid): pieces inside stock, no """Return a list of problems ([] means valid): pieces inside stock, no
overlaps, kerf respected, every item placed-or-warned.""" overlaps, kerf respected, every item placed-or-warned."""

View File

@ -8,12 +8,13 @@ import subprocess
from PySide6.QtCore import Qt, QThreadPool from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene, from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget) QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from ..cutlist import _fmt_len, cut_rows, shopping from ..cutlist import _fmt_len, cut_rows, shopping
from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement,
placement_fits, relocate, rotate_placement, snap_x)
from ..instructions import build_steps, format_steps, polish_prompt from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs from ..jigs import explain_prompt, format_jigs, suggest_jigs
from ..layout import waste_summary from ..layout import waste_summary
@ -24,6 +25,40 @@ _PIECE = "#c8965a"
_WASTE = "#3a3a3a" _WASTE = "#3a3a3a"
class _Piece(QGraphicsRectItem):
"""A draggable cut piece on the layout. Reports drops/rotate/lock back to the
window, which snaps + validates against the CutPlan."""
def __init__(self, win, pid, sp_id, w, h, locked, text):
super().__init__(0, 0, w, h)
self.win, self.pid, self.sp_id = win, pid, sp_id
self.setBrush(QBrush(QColor(_PIECE)))
self.setPen(QPen(QColor("#ffd700" if locked else "#111111"), 2 if locked else 1))
if not locked:
self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setCursor(Qt.OpenHandCursor)
if text:
t = QGraphicsSimpleTextItem(("🔒 " if locked else "") + text, self)
t.setBrush(QBrush(QColor("white")))
t.setPos(3, 3)
self._home = None
def mousePressEvent(self, e):
self._home = (self.sp_id, self.pos().x(), self.pos().y())
super().mousePressEvent(e)
def mouseReleaseEvent(self, e):
super().mouseReleaseEvent(e)
if self._home is not None:
self.win._drop_piece(self, self._home)
def mouseDoubleClickEvent(self, e):
self.win._rotate_piece(self.pid)
def contextMenuEvent(self, e):
self.win._piece_menu(self.pid, e.screenPos())
class BomWindow(QDialog): class BomWindow(QDialog):
def __init__(self, controller, parent=None): def __init__(self, controller, parent=None):
super().__init__(parent) super().__init__(parent)
@ -32,6 +67,10 @@ class BomWindow(QDialog):
self.resize(820, 640) self.resize(820, 640)
self._order = 0 self._order = 0
self._optimized = False self._optimized = False
self._plan = None # persistent editable plan for the layout tab
self._rebuild = True # regenerate from auto-layout on next draw
self._px = _PX
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
self.pool = QThreadPool.globalInstance() self.pool = QThreadPool.globalInstance()
tabs = QTabWidget() tabs = QTabWidget()
@ -170,13 +209,17 @@ class BomWindow(QDialog):
run_async(self.pool, work, on_done=done, on_error=failed) run_async(self.pool, work, on_done=done, on_error=failed)
# ----- layout tab --------------------------------------------------- # ----- layout tab (editable) ---------------------------------------
def _layout_tab(self) -> QWidget: def _layout_tab(self) -> QWidget:
w = QWidget() w = QWidget()
v = QVBoxLayout(w) v = QVBoxLayout(w)
self.scene = QGraphicsScene() self.scene = QGraphicsScene()
self.view = QGraphicsView(self.scene) self.view = QGraphicsView(self.scene)
v.addWidget(self.view) v.addWidget(self.view)
self._status = QLabel("Drag a piece to re-place it · double-click a panel to rotate "
"· right-click to lock")
self._status.setStyleSheet("color:#aaaaaa; font-size:11px;")
v.addWidget(self._status)
row = QHBoxLayout() row = QHBoxLayout()
opt = QPushButton("Find better layout") opt = QPushButton("Find better layout")
opt.setToolTip("Try several packing strategies and keep the best-scoring one") opt.setToolTip("Try several packing strategies and keep the best-scoring one")
@ -191,26 +234,31 @@ class BomWindow(QDialog):
return w return w
def _optimize(self) -> None: def _optimize(self) -> None:
self._optimized = True self._optimized, self._rebuild = True, True
self._draw_layout() self._draw_layout()
def _next_arrangement(self) -> None: def _next_arrangement(self) -> None:
self._optimized = False self._optimized = False
self._order = (self._order + 1) % len(STRATEGIES) self._order = (self._order + 1) % len(STRATEGIES)
self._rebuild = True
self._draw_layout() self._draw_layout()
def _draw_layout(self) -> None: def _draw_layout(self) -> None:
self.scene.clear() if self._rebuild or self._plan is None:
plan = (best_cut_plan(self.c.scene) if self._optimized self._plan = (best_cut_plan(self.c.scene) if self._optimized
else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order]))
self._rebuild = False
plan = self._plan
self.scene.clear()
self._rows = []
names = {p.id: (p.name or p.id) for p in self.c.scene.parts} names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
part_of = {it.id: it.part_id for it in plan.items} part_of = {it.id: it.part_id for it in plan.items}
label = lambda iid: names.get(part_of.get(iid, ""), iid) label = lambda iid: names.get(part_of.get(iid, ""), iid)
px, y, bar = _PX, 30.0, 34.0 px, y, bar = self._px, 30.0, 34.0
sc = plan.score sc = plan.score
self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock piece(s) · " self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock · "
f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable offcut(s)") f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable")
n = m = 0 n = m = 0
for sp in plan.stock_pieces: # lumber sticks first for sp in plan.stock_pieces: # lumber sticks first
@ -218,31 +266,97 @@ class BomWindow(QDialog):
continue continue
n += 1 n += 1
self._label(0, y - 15, f"{sp.stock} stick {n}") self._label(0, y - 15, f"{sp.stock} stick {n}")
for p in sp.placements: self._rows.append((y, y + bar, sp))
self._rect(p.x_in * px, y, p.len_in * px, bar, _PIECE,
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
for w in sp.waste: for w in sp.waste:
self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE, self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE,
f"waste {_fmt_len(w.length_in)}") f"waste {_fmt_len(w.length_in)}")
for p in sp.placements:
self._add_piece(sp, p, p.x_in * px, y, p.len_in * px, bar,
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
y += bar + 24 y += bar + 24
for sp in plan.stock_pieces: # then plywood sheets for sp in plan.stock_pieces: # then plywood sheets
if not sp.is_sheet: if not sp.is_sheet:
continue continue
m += 1 m += 1
h = sp.width_in * px
self._label(0, y - 15, f"{sp.stock} sheet {m} " self._label(0, y - 15, f"{sp.stock} sheet {m} "
f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})") f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})")
self._rect(0, y, sp.length_in * px, sp.width_in * px, _WASTE, "") self._rect(0, y, sp.length_in * px, h, _WASTE, "")
self._rows.append((y, y + h, sp))
for p in sp.placements: for p in sp.placements:
self._rect(p.x_in * px, y + p.y_in * px, p.len_in * px, p.wid_in * px, self._add_piece(sp, p, p.x_in * px, y + p.y_in * px,
_PIECE, label(p.item_id)) p.len_in * px, p.wid_in * px, label(p.item_id))
y += sp.width_in * px + 34 y += h + 34
for warn in plan.warnings: for warn in plan.warnings:
self._label(0, y, "" + warn) self._label(0, y, "" + warn)
y += 18 y += 18
self.view.setSceneRect(self.scene.itemsBoundingRect()) self.view.setSceneRect(self.scene.itemsBoundingRect())
def _add_piece(self, sp, p, x, y, w, h, text) -> None:
item = _Piece(self, p.id, sp.id, w, h, p.locked, text)
item.setPos(x, y)
self.scene.addItem(item)
# ----- drag / rotate / lock handlers -------------------------------
def _row_of(self, sp_id):
return next((y0 for y0, _y1, s in self._rows if s.id == sp_id), 0.0)
def _drop_piece(self, item, home) -> None:
plan, px = self._plan, self._px
cy = item.sceneBoundingRect().center().y()
target = next((s for y0, y1, s in self._rows if y0 - 2 <= cy <= y1 + 2), None)
sp_cur, p = find_placement(plan, item.pid)
if target is None:
target = sp_cur
row_y0 = self._row_of(target.id)
x_in = max(item.pos().x() / px, 0.0)
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
relocate(plan, item.pid, target.id, x_in, y_in)
if not target.is_sheet:
p.x_in = snap_x(target, p, p.x_in, plan.settings.kerf_in)
if placement_fits(target, p, plan.settings.kerf_in):
self._status.setText("✓ placed")
else:
hsp, hx, hy = home
hrow = self._row_of(hsp)
home_sp = next(s for s in plan.stock_pieces if s.id == hsp)
relocate(plan, item.pid, hsp, hx / px,
(hy - hrow) / px if home_sp.is_sheet else 0.0)
self._status.setText("✗ overlap / off-stock — move reverted")
self._rebuild = False
self._draw_layout()
def _rotate_piece(self, pid) -> None:
plan = self._plan
sp, p = find_placement(plan, pid)
if not sp.is_sheet:
return
rotate_placement(plan, pid)
if placement_fits(sp, p, plan.settings.kerf_in):
self._status.setText("✓ rotated")
else:
rotate_placement(plan, pid) # rotate back
self._status.setText("✗ rotation doesn't fit")
self._rebuild = False
self._draw_layout()
def _piece_menu(self, pid, screen_pos) -> None:
plan = self._plan
sp, p = find_placement(plan, pid)
menu = QMenu(self)
menu.addAction("Unlock" if p.locked else "Lock", lambda: self._toggle_lock(pid))
if sp.is_sheet:
menu.addAction("Rotate", lambda: self._rotate_piece(pid))
menu.exec(screen_pos)
def _toggle_lock(self, pid) -> None:
_sp, p = find_placement(self._plan, pid)
p.locked = not p.locked
self._rebuild = False
self._draw_layout()
def _rect(self, x, y, w, h, color, text) -> None: def _rect(self, x, y, w, h, color, text) -> None:
item = QGraphicsRectItem(x, y, w, h) item = QGraphicsRectItem(x, y, w, h)
item.setBrush(QBrush(QColor(color))) item.setBrush(QBrush(QColor(color)))

View File

@ -101,6 +101,47 @@ def test_best_cut_plan_is_no_worse():
assert validate_cut_plan(best) == [] assert validate_cut_plan(best) == []
def test_snap_and_fits():
from woodshop.cutplan import placement_fits, snap_x
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30) # both fit one stick
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
p1, p2 = stick.placements[0], stick.placements[1]
k = plan.settings.kerf_in
assert abs(snap_x(stick, p2, 31.0, k) - (p1.x_in + p1.len_in + k)) < 1e-6
p2.x_in = 0.0
assert not placement_fits(stick, p2, k) # now overlaps p1
p2.x_in = p1.len_in + k
assert placement_fits(stick, p2, k) # butted clear
def test_relocate_between_sticks():
from woodshop.cutplan import relocate
s = Scene()
for _ in range(3):
s.place("2x4", 60) # each needs its own stick
plan = build_cut_plan(s)
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
assert len(sticks) == 3
pid = sticks[2].placements[0].id
relocate(plan, pid, sticks[0].id, 0.0)
assert any(p.id == pid for p in sticks[0].placements)
assert all(p.id != pid for p in sticks[2].placements)
def test_rotate_placement_swaps_footprint():
from woodshop.cutplan import rotate_placement
s = Scene()
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s)
p = next(sp for sp in plan.stock_pieces if sp.is_sheet).placements[0]
L, W, rot = p.len_in, p.wid_in, p.rotated
rotate_placement(plan, p.id)
assert p.len_in == W and p.wid_in == L and p.rotated != rot
def test_custom_settings_kerf(): def test_custom_settings_kerf():
s = Scene() s = Scene()
s.place("2x4", 48) s.place("2x4", 48)