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:
parent
38391175b4
commit
c81633b699
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue