Fix lock-aware plywood reopt + honest Best-of-100 when locked

Codex findings:
1. reoptimize sent unlocked plywood to fresh sheets whenever any sheet
   placement was locked, instead of packing into free space on the locked
   sheet — so locking one of two panels that share a sheet split them onto
   two sheets. Added _free_rects_sheet (guillotine subtraction carving free
   rectangles around locked panels) + _pack_plywood_seeded, and refactored
   _pack_plywood_guillotine onto a shared _guillotine_pack core that accepts
   seeded sheets. reoptimize now uses it for the plywood branch.
2. "Best of 100" only tried the ~6 STRATEGIES when locks existed. The locked
   path now runs strategies + shuffle restarts up to 100 attempts via
   reoptimize, matching the label.

Tests: plywood lock keeps both panels on one sheet; locked Best-of-100 stays valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 15:46:39 -03:00
parent 60957ae4af
commit 9d80be4e7f
4 changed files with 94 additions and 12 deletions

View File

@ -309,10 +309,40 @@ def _pack_lumber_exact(items, stock, s, ids) -> tuple[list, list]:
return sticks, oversize
def _pack_plywood_guillotine(items, stock, s, ids) -> tuple[list, list]:
"""Guillotine free-rectangle packing (best-area-fit + rotation) — usually
tighter than shelf packing for mixed panel sizes."""
sheets, unplaced, rects = [], [], {}
def _free_rects_sheet(sp, s) -> list[list]:
"""Non-overlapping free rectangles on a sheet, carved around its (locked)
placements with a kerf clearance. Guillotine subtraction: below/above span the
full width, then the middle band is cut left/right of the obstacle."""
rects = [[0.0, 0.0, sp.length_in, sp.width_in]]
k = s.kerf_in
for p in sp.placements:
ox, oy = p.x_in - k, p.y_in - k
ox1, oy1 = p.x_in + p.len_in + k, p.y_in + p.wid_in + k
new = []
for fx, fy, fw, fh in rects:
fx1, fy1 = fx + fw, fy + fh
if ox >= fx1 or ox1 <= fx or oy >= fy1 or oy1 <= fy:
new.append([fx, fy, fw, fh]) # no overlap — keep
continue
cx0, cy0 = max(ox, fx), max(oy, fy) # obstacle clipped to rect
cx1, cy1 = min(ox1, fx1), min(oy1, fy1)
for r in ([fx, fy, fw, cy0 - fy], # below
[fx, cy1, fw, fy1 - cy1], # above
[fx, cy0, cx0 - fx, cy1 - cy0], # left of obstacle
[cx1, cy0, fx1 - cx1, cy1 - cy0]): # right of obstacle
if r[2] > 0.5 and r[3] > 0.5:
new.append(r)
rects = new
return rects
def _guillotine_pack(items, stock, s, ids, seed_sheets) -> tuple[list, list]:
"""Guillotine free-rectangle packing (best-area-fit + rotation). `seed_sheets`
is a list of (StockPiece, free_rect_list) to pack into first (their existing
placements are kept); fresh sheets are opened as needed."""
sheets = [sp for sp, _ in seed_sheets]
rects = {sp.id: list(fr) for sp, fr in seed_sheets}
unplaced = []
def orientations(it):
opts = [(it.length_in, it.width_in, False)]
@ -359,6 +389,19 @@ def _pack_plywood_guillotine(items, stock, s, ids) -> tuple[list, list]:
return sheets, unplaced
def _pack_plywood_guillotine(items, stock, s, ids) -> tuple[list, list]:
"""Guillotine free-rectangle packing — usually tighter than shelf packing."""
return _guillotine_pack(items, stock, s, ids, [])
def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
"""Pack unlocked panels into the free space AROUND locked panels on seeded
sheets first, then onto fresh sheets the plywood analogue of
`_pack_lumber_seeded`."""
seed_sheets = [(sp, _free_rects_sheet(sp, s)) for sp in seeds]
return _guillotine_pack(items, stock, s, ids, seed_sheets)
def build_cut_plan(scene, settings: ShopSettings | None = None,
strategy: str = "decreasing") -> CutPlan:
s = settings or ShopSettings()
@ -442,9 +485,9 @@ def _free_segments(sp: StockPiece, kerf: float) -> list:
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)."""
"""Re-pack while PRESERVING locked placements where they sit. Unlocked pieces
are packed into the free space around locked ones first (free segments on
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
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]
@ -479,11 +522,10 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
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
sps, un = _pack_plywood_seeded(its, stock, s, ids, seed_pieces)
else:
sps, un = _pack_lumber_seeded(its, stock, s, ids, seed_pieces)
stock_pieces += sps
stock_pieces += sps
unplaced += un
for iid in unplaced:
it = next(i for i in items if i.id == iid)

View File

@ -285,10 +285,12 @@ class BomWindow(QDialog):
def _best_of_n(self) -> None:
self._optimized = True
if self._has_locks():
best = min((reoptimize(self.c.scene, self._plan, st) for st in STRATEGIES),
strategies = list(STRATEGIES)
strategies += [f"shuffle{i}" for i in range(max(100 - len(strategies), 0))]
best = min((reoptimize(self.c.scene, self._plan, st) for st in strategies),
key=_plan_key)
self._set_plan(best)
self._status.setText("✓ best around locked pieces")
self._status.setText("✓ best of 100 around locked pieces")
else:
self._set_plan(best_cut_plan(self.c.scene, attempts=100))
self._status.setText("✓ best of 100 attempts")

View File

@ -58,6 +58,19 @@ def test_best_of_n_button_keeps_valid_plan(tmp_path):
assert "best" in w._status.text().lower()
def test_best_of_n_with_lock_runs_and_validates(tmp_path):
from woodshop.cutplan import validate_cut_plan
c = Controller(str(tmp_path / "s.json"))
for ln in (50, 46, 40, 30):
c.place("2x4", ln)
w = BomWindow(c)
p = next(p for sp in w._plan.stock_pieces for p in sp.placements)
p.locked = True
w._best_of_n() # locked path: strategies + shuffles
assert validate_cut_plan(w._plan) == []
assert "locked" in w._status.text()
def test_valid_move_commits(tmp_path):
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 20)

View File

@ -259,6 +259,31 @@ def test_best_of_n_no_worse():
assert validate_cut_plan(best) == []
def test_reoptimize_plywood_keeps_unlocked_on_locked_sheet():
"""Locking one panel must NOT push the other onto a fresh sheet when they
still share one sheet (Codex finding #1)."""
from woodshop.cutplan import reoptimize
s = Scene()
s.place("ply-3/4", 40, width_in=20)
s.place("ply-3/4", 40, width_in=20)
plan = build_cut_plan(s, strategy="guillotine")
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
assert len(sheets) == 1
locked = sheets[0].placements[0]
locked.locked = True
lx, ly, lid = locked.x_in, locked.y_in, locked.id
re = reoptimize(s, plan, "guillotine")
re_sheets = [sp for sp in re.stock_pieces if sp.is_sheet]
assert len(re_sheets) == 1 # still one sheet, not split
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)
kept = [p for sp in re.stock_pieces for p in sp.placements if p.id == lid]
assert kept and kept[0].locked
assert abs(kept[0].x_in - lx) < 1e-6 and abs(kept[0].y_in - ly) < 1e-6
assert validate_cut_plan(re) == []
def test_custom_settings_kerf():
s = Scene()
s.place("2x4", 48)