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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 15:27:39 -03:00
parent 38391175b4
commit c81633b699
3 changed files with 131 additions and 3 deletions

View File

@ -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): def _plan_key(plan: CutPlan):
"""Lower is better: fewest stock pieces, then least waste, then more reusable offcuts.""" """Lower is better: fewest stock pieces, then least waste, then more reusable offcuts."""
sc = plan.score sc = plan.score

View File

@ -16,7 +16,8 @@ from collections import Counter
from ..cutlist import _fmt_len, board_feet from ..cutlist import _fmt_len, board_feet
from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement, 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 ..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 .workers import run_async from .workers import run_async
@ -263,14 +264,26 @@ class BomWindow(QDialog):
self._draw_layout() self._draw_layout()
return w 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: def _optimize(self) -> None:
self._optimized = True self._optimized = True
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)) self._set_plan(best_cut_plan(self.c.scene))
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._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: def _draw_layout(self) -> None:
plan = self._plan plan = self._plan

View File

@ -191,6 +191,24 @@ def test_stable_hash_is_deterministic():
assert _stable_hash("ci1x") == _stable_hash("ci1x") 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(): def test_custom_settings_kerf():
s = Scene() s = Scene()
s.place("2x4", 48) s.place("2x4", 48)