diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 56c0cfd..f7cb89d 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -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) diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 987a886..55bd962 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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") diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index 4e58ecd..3ec1292 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -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) diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index 1760a8a..e46302e 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -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)