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:
parent
60957ae4af
commit
9d80be4e7f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue