From 93d1b186e39d4ce4654e4a8efed4f6202559e76e Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 14:52:03 -0300 Subject: [PATCH] Phase 4: constrained manual layout editing (drag/snap/lock/rotate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- SHOP_PACKET_PLAN.md | 7 ++ src/woodshop/cutplan.py | 56 ++++++++++++ src/woodshop/gui/bom_window.py | 150 +++++++++++++++++++++++++++++---- tests/test_cutplan.py | 41 +++++++++ 4 files changed, 236 insertions(+), 18 deletions(-) diff --git a/SHOP_PACKET_PLAN.md b/SHOP_PACKET_PLAN.md index 36af3f6..1826d28 100644 --- a/SHOP_PACKET_PLAN.md +++ b/SHOP_PACKET_PLAN.md @@ -2,6 +2,13 @@ A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go. +**Status:** Phases 0–4 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 The **math layer is deterministic and inspectable**; AI is used **only for narrative** (instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 4ef2087..22a8e14 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -324,6 +324,62 @@ def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 2 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: """Return a list of problems ([] means valid): pieces inside stock, no overlaps, kerf respected, every item placed-or-warned.""" diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 93d340f..87e91e3 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -8,12 +8,13 @@ import subprocess from PySide6.QtCore import Qt, QThreadPool from PySide6.QtGui import QBrush, QColor, QFont, QPen from PySide6.QtPrintSupport import QPrintDialog, QPrinter -from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene, - QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, - QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget) +from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraphicsScene, + QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel, + QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget) 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 ..jigs import explain_prompt, format_jigs, suggest_jigs from ..layout import waste_summary @@ -24,6 +25,40 @@ _PIECE = "#c8965a" _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): def __init__(self, controller, parent=None): super().__init__(parent) @@ -32,6 +67,10 @@ class BomWindow(QDialog): self.resize(820, 640) self._order = 0 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() tabs = QTabWidget() @@ -170,13 +209,17 @@ class BomWindow(QDialog): run_async(self.pool, work, on_done=done, on_error=failed) - # ----- layout tab --------------------------------------------------- + # ----- layout tab (editable) --------------------------------------- def _layout_tab(self) -> QWidget: w = QWidget() v = QVBoxLayout(w) self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) 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() opt = QPushButton("Find better layout") opt.setToolTip("Try several packing strategies and keep the best-scoring one") @@ -191,26 +234,31 @@ class BomWindow(QDialog): return w def _optimize(self) -> None: - self._optimized = True + self._optimized, self._rebuild = True, True self._draw_layout() def _next_arrangement(self) -> None: self._optimized = False self._order = (self._order + 1) % len(STRATEGIES) + self._rebuild = True self._draw_layout() def _draw_layout(self) -> None: + if self._rebuild or self._plan is None: + self._plan = (best_cut_plan(self.c.scene) if self._optimized + else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) + self._rebuild = False + plan = self._plan self.scene.clear() - plan = (best_cut_plan(self.c.scene) if self._optimized - else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) + self._rows = [] 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} 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 - self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock piece(s) · " - f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable offcut(s)") + self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock · " + f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable") n = m = 0 for sp in plan.stock_pieces: # lumber sticks first @@ -218,31 +266,97 @@ class BomWindow(QDialog): continue n += 1 self._label(0, y - 15, f"{sp.stock} stick {n}") - for p in sp.placements: - self._rect(p.x_in * px, y, p.len_in * px, bar, _PIECE, - f"{label(p.item_id)} · {_fmt_len(p.len_in)}") + self._rows.append((y, y + bar, sp)) for w in sp.waste: self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE, 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 for sp in plan.stock_pieces: # then plywood sheets if not sp.is_sheet: continue m += 1 + h = sp.width_in * px self._label(0, y - 15, f"{sp.stock} sheet {m} " 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: - self._rect(p.x_in * px, y + p.y_in * px, p.len_in * px, p.wid_in * px, - _PIECE, label(p.item_id)) - y += sp.width_in * px + 34 + self._add_piece(sp, p, p.x_in * px, y + p.y_in * px, + p.len_in * px, p.wid_in * px, label(p.item_id)) + y += h + 34 for warn in plan.warnings: self._label(0, y, "⚠ " + warn) y += 18 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: item = QGraphicsRectItem(x, y, w, h) item.setBrush(QBrush(QColor(color))) diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index e414d22..4a9f7fc 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -101,6 +101,47 @@ def test_best_cut_plan_is_no_worse(): 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(): s = Scene() s.place("2x4", 48)