From c81633b699b233da11cdd880d4b55d7f48f220a6 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 15:27:39 -0300 Subject: [PATCH] Lock-aware re-optimization reoptimize(scene, base_plan, strategy) preserves locked placements where they sit and re-packs the unlocked items around them: unlocked lumber goes into the free segments beside locked pieces (then new sticks) via _pack_lumber_seeded + _free_segments; unlocked plywood goes onto fresh sheets (locked sheets keep their locked panels). The BOM window's "Find better layout" / "Try alternative" now call reoptimize when any piece is locked (Find better tries all strategies and keeps the best), so locks survive re-optimization instead of just blocking drags. Tests: locked placement keeps its id/position, nothing is lost, plan stays valid. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/cutplan.py | 97 ++++++++++++++++++++++++++++++++++ src/woodshop/gui/bom_window.py | 19 +++++-- tests/test_cutplan.py | 18 +++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 0472136..6cbd02a 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -304,6 +304,103 @@ def _score(stock_pieces, s, strategy, warnings) -> dict: } +def _free_segments(sp: StockPiece, kerf: float) -> list: + """Usable free intervals [start, length] on a lumber stick, leaving a kerf + margin beside each occupied placement.""" + occ = sorted((p.x_in, p.x_in + p.len_in) for p in sp.placements) + segs, cursor = [], 0.0 + for a, b in occ: + end = a - kerf + if end - cursor > 0.5: + segs.append([cursor, end - cursor]) + cursor = b + kerf + if sp.length_in - cursor > 0.5: + segs.append([cursor, sp.length_in - cursor]) + return segs + + +def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPlan: + """Re-pack while PRESERVING locked placements where they sit. Unlocked lumber + is packed into the free space around locked pieces (then new sticks); unlocked + plywood goes onto fresh sheets (locked sheets keep their locked panels).""" + s = base_plan.settings + items = _cut_items(scene) + locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked] + locked_ids = {p.item_id for p in locked} + counter = {"n": 0} + + def ids(prefix="rpl"): + counter["n"] += 1 + return f"{prefix}{counter['n']}" + + # Seed stock pieces from those holding locked placements (keep only locked). + seeds: dict[str, dict] = {} + for sp in base_plan.stock_pieces: + kept = [p for p in sp.placements if p.locked] + if not kept: + continue + seeds.setdefault(sp.stock, {})[sp.id] = StockPiece( + id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet, + length_in=sp.length_in, width_in=sp.width_in, + placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in, + len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True) + for p in kept]) + + unlocked = [it for it in _ordered(items, strategy) if it.id not in locked_ids] + by_stock: dict[str, list] = {} + for it in unlocked: + by_stock.setdefault(it.stock, []).append(it) + + stock_pieces, unplaced, warnings = [], [], [] + for stock in set(by_stock) | set(seeds): + its = by_stock.get(stock, []) + seed_pieces = list(seeds.get(stock, {}).values()) + is_sheet = (its and its[0].is_sheet) or (seed_pieces and seed_pieces[0].is_sheet) + if is_sheet: + new_sheets, un = _pack_plywood(its, stock, s, ids) if its else ([], []) + stock_pieces += seed_pieces + new_sheets + else: + sps, un = _pack_lumber_seeded(its, stock, s, ids, seed_pieces) + stock_pieces += sps + unplaced += un + for iid in unplaced: + it = next(i for i in items if i.id == iid) + warnings.append(f"{it.part_id} ({it.stock}) doesn't fit standard stock — too big.") + + plan = CutPlan(settings=s, items=items, stock_pieces=stock_pieces, unplaced=unplaced, + strategy=strategy + "+locked", + score=_score(stock_pieces, s, strategy + "+locked", warnings), warnings=warnings) + recompute(plan) + return plan + + +def _pack_lumber_seeded(items, stock, s, ids, seeds) -> tuple[list, list]: + """Place items into the free segments of seeded sticks first, then new sticks.""" + sticks = list(seeds) + free = {sp.id: _free_segments(sp, s.kerf_in) for sp in sticks} + unplaced = [] + for it in items: + if it.length_in > s.stick_len_in + _EPS: + unplaced.append(it.id) + continue + cands = [(sp, seg) for sp in sticks for seg in free[sp.id] if it.length_in <= seg[1] + _EPS] + if cands: + sp, seg = min(cands, key=lambda c: c[1][1]) # tightest free segment + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(seg[0], 3), + len_in=it.length_in, wid_in=it.width_in)) + used = it.length_in + s.kerf_in + seg[0] += used + seg[1] -= used + else: + sp = StockPiece(id=ids("rsp"), stock=stock, is_sheet=False, + length_in=s.stick_len_in, width_in=it.width_in) + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, + len_in=it.length_in, wid_in=it.width_in)) + sticks.append(sp) + free[sp.id] = [[it.length_in + s.kerf_in, s.stick_len_in - it.length_in - s.kerf_in]] + return sticks, unplaced + + def _plan_key(plan: CutPlan): """Lower is better: fewest stock pieces, then least waste, then more reusable offcuts.""" sc = plan.score diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 3c9575a..68eecad 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -16,7 +16,8 @@ from collections import Counter from ..cutlist import _fmt_len, board_feet from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement, - placement_fits, recompute, relocate, rotate_placement, snap_x) + placement_fits, recompute, relocate, reoptimize, rotate_placement, + snap_x, _plan_key) from ..instructions import build_steps, format_steps, polish_prompt from ..jigs import explain_prompt, format_jigs, suggest_jigs from .workers import run_async @@ -263,14 +264,26 @@ class BomWindow(QDialog): self._draw_layout() return w + def _has_locks(self) -> bool: + return any(p.locked for sp in self._plan.stock_pieces for p in sp.placements) + def _optimize(self) -> None: self._optimized = True - self._set_plan(best_cut_plan(self.c.scene)) + if self._has_locks(): + best = min((reoptimize(self.c.scene, self._plan, st) for st in STRATEGIES), + key=_plan_key) + self._set_plan(best) + self._status.setText("✓ optimized around locked pieces") + else: + self._set_plan(best_cut_plan(self.c.scene)) def _next_arrangement(self) -> None: self._optimized = False self._order = (self._order + 1) % len(STRATEGIES) - self._set_plan(build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) + st = STRATEGIES[self._order] + plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks() + else build_cut_plan(self.c.scene, strategy=st)) + self._set_plan(plan) def _draw_layout(self) -> None: plan = self._plan diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index f343402..0bf762c 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -191,6 +191,24 @@ def test_stable_hash_is_deterministic(): assert _stable_hash("ci1x") == _stable_hash("ci1x") +def test_reoptimize_preserves_locked_placement(): + from woodshop.cutplan import reoptimize + s = Scene() + for ln in (40, 40, 40): + s.place("2x4", ln) + plan = build_cut_plan(s) + sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet] + locked = sticks[-1].placements[0] + locked.locked = True + lx, lid = locked.x_in, locked.id + re = reoptimize(s, plan, "decreasing") + kept = [p for sp in re.stock_pieces for p in sp.placements if p.id == lid] + assert kept and kept[0].locked and abs(kept[0].x_in - lx) < 1e-6 + placed = {p.item_id for sp in re.stock_pieces for p in sp.placements} + assert {it.id for it in re.items} <= placed | set(re.unplaced) # nothing lost + assert validate_cut_plan(re) == [] + + def test_custom_settings_kerf(): s = Scene() s.place("2x4", 48)