From 274e87e2392099268da60e3a8ae4b1b60921746e Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 15:37:11 -0300 Subject: [PATCH] Phase 1: bounded-exact lumber, guillotine plywood, Best-of-N - _min_bins: branch-and-bound minimum stick count (FFD-seeded + count bound) - _pack_lumber_exact: provably-minimum packing for small jobs (<=12 pieces) - _pack_plywood_guillotine: free-rectangle best-area-fit packing + rotation - build_cut_plan dispatches strategy=="exact"/"guillotine"; added to STRATEGIES - richer scoring: reusable_in (longer offcuts) as _plan_key tie-break - best_cut_plan tries exact+guillotine; "Best of 100" button in Cut Layout tab - tests: exact<=FFD, oversize handling, guillotine packs/validates, best-of-N Co-Authored-By: Claude Opus 4.8 (1M context) --- SHOP_PACKET_PLAN.md | 13 +++- src/woodshop/cutplan.py | 133 +++++++++++++++++++++++++++++++-- src/woodshop/gui/bom_window.py | 18 ++++- tests/test_bom_window.py | 11 +++ tests/test_cutplan.py | 50 +++++++++++++ 5 files changed, 215 insertions(+), 10 deletions(-) diff --git a/SHOP_PACKET_PLAN.md b/SHOP_PACKET_PLAN.md index a4b05ec..2435072 100644 --- a/SHOP_PACKET_PLAN.md +++ b/SHOP_PACKET_PLAN.md @@ -12,10 +12,15 @@ surfaced in Shopping; process-stable shuffle (hashlib); kerf-gap validation; dro stock-type compatibility; waste/score recompute after manual edits; rotation legality (settings/grain); position-aware jig grouping. -Known follow-ups: **Phase 1 is partial** — bounded exact search, a real "Best of N" -control, guillotine/maxrects plywood strategies, richer scoring. Also: lock-aware -re-optimization (locked pieces preserved through "Find better layout"), grain-direction -in auto-layout, on-hand offcut inventory, opt-in jig material in the BOM. +**Phase 1 now complete:** bounded branch-and-bound exact lumber packing +(`_min_bins`/`_pack_lumber_exact`, ≤12 pieces, FFD-seeded with a count bound), +guillotine free-rectangle plywood packing (`_pack_plywood_guillotine`, best-area-fit + +rotation), a real "Best of 100" control in the Cut Layout tab, and richer scoring that +prefers more & longer reusable offcuts (`reusable_in` tie-break). Lock-aware +re-optimization also landed (locked pieces preserved through "Find better layout"/"Best of N"). + +Remaining follow-ups: grain-direction in auto-layout, on-hand offcut inventory, +opt-in jig material in the BOM. ## Guiding principle The **math layer is deterministic and inspectable**; AI is used **only for narrative** diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 6cbd02a..56c0cfd 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -243,6 +243,122 @@ def _pack_plywood(items, stock, s: ShopSettings, ids) -> tuple[list, list]: return sheets, unplaced +def _min_bins(order_sizes, cap, kerf): + """Branch-and-bound minimum number of bins (sticks) for small lumber jobs. + order_sizes = [(idx, length)]; returns list of bins (each a list of idx).""" + order = sorted(order_sizes, key=lambda t: -t[1]) + ffd = [] # first-fit-decreasing seed / bound + for idx, size in order: + for b in ffd: + if b[0] + kerf + size <= cap + _EPS: + b[0] += kerf + size + b[1].append(idx) + break + else: + ffd.append([size, [idx]]) + best = [[list(b[1]) for b in ffd]] + best_count = [len(ffd)] + bins = [] + + def dfs(k): + if len(bins) >= best_count[0]: + return + if k == len(order): + best_count[0] = len(bins) + best[0] = [list(b[1]) for b in bins] + return + idx, size = order[k] + for b in bins: + if b[0] + kerf + size <= cap + _EPS: + b[0] += kerf + size + b[1].append(idx) + dfs(k + 1) + b[1].pop() + b[0] -= kerf + size + bins.append([size, [idx]]) + dfs(k + 1) + bins.pop() + + dfs(0) + return best[0] + + +def _pack_lumber_exact(items, stock, s, ids) -> tuple[list, list]: + """Provably-minimum stick count for small jobs (≤12 pieces); else best-fit.""" + if len(items) > 12: + return _pack_lumber(items, stock, s, ids, fit="best") + oversize = [it.id for it in items if it.length_in > s.stick_len_in + _EPS] + packable = [(i, it.length_in) for i, it in enumerate(items) + if it.length_in <= s.stick_len_in + _EPS] + sticks = [] + for bin_idx in _min_bins(packable, s.stick_len_in, s.kerf_in): + sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False, + length_in=s.stick_len_in, width_in=items[bin_idx[0]].width_in) + x = 0.0 + for idx in bin_idx: + it = items[idx] + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(x, 3), + len_in=it.length_in, wid_in=it.width_in)) + x += it.length_in + s.kerf_in + end = x - s.kerf_in + off = round(s.stick_len_in - end, 3) + if off > 0.5: + sp.waste.append(WasteRegion(x_in=round(end, 3), length_in=off, width_in=sp.width_in, + reusable=off >= s.offcut_usable_in)) + sticks.append(sp) + 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 orientations(it): + opts = [(it.length_in, it.width_in, False)] + if s.allow_plywood_rotation and not s.grain_direction and it.length_in != it.width_in: + opts.append((it.width_in, it.length_in, True)) + return opts + + def new_sheet(): + sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True, + length_in=s.sheet_l_in, width_in=s.sheet_w_in) + sheets.append(sp) + rects[sp.id] = [[0.0, 0.0, s.sheet_l_in, s.sheet_w_in]] + return sp + + for it in sorted(items, key=lambda i: -(i.length_in * i.width_in)): + best = None + for sp in sheets: + for ri, (rx, ry, rw, rh) in enumerate(rects[sp.id]): + for pl, pw, rot in orientations(it): + if pl <= rw + _EPS and pw <= rh + _EPS: + leftover = rw * rh - pl * pw + if best is None or leftover < best[0]: + best = (leftover, sp, ri, pl, pw, rot, rx, ry, rw, rh) + if best is None: + sp = new_sheet() + rx, ry, rw, rh = rects[sp.id][0] + for pl, pw, rot in orientations(it): + if pl <= rw + _EPS and pw <= rh + _EPS: + best = (0, sp, 0, pl, pw, rot, rx, ry, rw, rh) + break + if best is None: # bigger than a whole sheet + unplaced.append(it.id) + sheets.pop() + del rects[sp.id] + continue + _lo, sp, ri, pl, pw, rot, rx, ry, rw, rh = best + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=round(rx, 3), y_in=round(ry, 3), + len_in=pl, wid_in=pw, rotated=rot)) + del rects[sp.id][ri] + k = s.kerf_in + for r in ([rx + pl + k, ry, rw - pl - k, pw], [rx, ry + pw + k, rw, rh - pw - k]): + if r[2] > 0.5 and r[3] > 0.5: + rects[sp.id].append(r) + return sheets, unplaced + + def build_cut_plan(scene, settings: ShopSettings | None = None, strategy: str = "decreasing") -> CutPlan: s = settings or ShopSettings() @@ -263,7 +379,10 @@ def build_cut_plan(scene, settings: ShopSettings | None = None, stock_pieces, unplaced, warnings = [], [], [] for stock, its in by_stock.items(): if its[0].is_sheet: - sps, un = _pack_plywood(its, stock, s, ids) + sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine" + else _pack_plywood(its, stock, s, ids)) + elif strategy == "exact": + sps, un = _pack_lumber_exact(its, stock, s, ids) else: sps, un = _pack_lumber(its, stock, s, ids, fit=fit) stock_pieces += sps @@ -294,11 +413,14 @@ def _score(stock_pieces, s, strategy, warnings) -> dict: waste_area += w.length_in * (w.width_in or sp.width_in) if w.reusable: reusable += 1 + reusable_in = sum(w.length_in for sp in stock_pieces if not sp.is_sheet + for w in sp.waste if w.reusable) return { "strategy_name": strategy, "stock_count": len(stock_pieces), "waste_area": round(waste_area, 1), "reusable_offcuts": reusable, + "reusable_in": round(reusable_in, 1), "yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0, "warnings": list(warnings), } @@ -402,19 +524,20 @@ def _pack_lumber_seeded(items, stock, s, ids, seeds) -> tuple[list, list]: def _plan_key(plan: CutPlan): - """Lower is better: fewest stock pieces, then least waste, then more reusable offcuts.""" + """Lower is better: fewest stock pieces, least waste, then prefer more & longer + reusable offcuts.""" sc = plan.score - return (sc["stock_count"], sc["waste_area"], -sc["reusable_offcuts"]) + return (sc["stock_count"], sc["waste_area"], -sc["reusable_offcuts"], -sc.get("reusable_in", 0)) # Strategies the "Try alternative" button cycles through. -STRATEGIES = ["decreasing", "bestfit", "increasing", "shuffle"] +STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"] def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan: """Find a better layout by trying several strategies + shuffle restarts and keeping the best-scoring one. (Good and explainable, not provably optimal.)""" - strategies = ["decreasing", "bestfit", "increasing"] + strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"] strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))] best = None for st in strategies: diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 68eecad..987a886 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -255,11 +255,15 @@ class BomWindow(QDialog): opt = QPushButton("Find better layout") opt.setToolTip("Try several packing strategies and keep the best-scoring one") opt.clicked.connect(self._optimize) + bestn = QPushButton("Best of 100") + bestn.setToolTip("Run 100 packing attempts and keep the best") + bestn.clicked.connect(self._best_of_n) alt = QPushButton("Try alternative") alt.clicked.connect(self._next_arrangement) pr = QPushButton("Print…") pr.clicked.connect(self._print_layout) - row.addWidget(opt); row.addWidget(alt); row.addStretch(); row.addWidget(pr) + row.addWidget(opt); row.addWidget(bestn); row.addWidget(alt) + row.addStretch(); row.addWidget(pr) v.addLayout(row) self._draw_layout() return w @@ -276,6 +280,18 @@ class BomWindow(QDialog): self._status.setText("✓ optimized around locked pieces") else: self._set_plan(best_cut_plan(self.c.scene)) + self._status.setText("✓ optimized") + + 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), + key=_plan_key) + self._set_plan(best) + self._status.setText("✓ best around locked pieces") + else: + self._set_plan(best_cut_plan(self.c.scene, attempts=100)) + self._status.setText("✓ best of 100 attempts") def _next_arrangement(self) -> None: self._optimized = False diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index b0812e6..4e58ecd 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -47,6 +47,17 @@ def test_drop_onto_incompatible_stock_reverts(tmp_path): assert "can't go" in w._status.text() +def test_best_of_n_button_keeps_valid_plan(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) + w._best_of_n() # no locks -> best of 100 + assert validate_cut_plan(w._plan) == [] + assert "best" in w._status.text().lower() + + 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 0bf762c..1760a8a 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -209,6 +209,56 @@ def test_reoptimize_preserves_locked_placement(): assert validate_cut_plan(re) == [] +def test_exact_no_worse_than_ffd(): + s = Scene() + for ln in (50, 46, 40, 30, 30, 20): + s.place("2x4", ln) + ex = build_cut_plan(s, strategy="exact") + ffd = build_cut_plan(s, strategy="decreasing") + assert ex.score["stock_count"] <= ffd.score["stock_count"] + placed = {p.item_id for sp in ex.stock_pieces for p in sp.placements} + assert {it.id for it in ex.items} <= placed | set(ex.unplaced) + assert validate_cut_plan(ex) == [] + + +def test_exact_handles_oversize(): + s = Scene() + s.place("2x4", 40) + s.place("2x4", 120) # bigger than a stick + plan = build_cut_plan(s, strategy="exact") + assert plan.unplaced and plan.warnings + assert validate_cut_plan(plan) == [] + + +def test_guillotine_packs_and_validates(): + s = Scene() + for _ in range(4): + s.place("ply-3/4", 30, width_in=20) + g = build_cut_plan(s, strategy="guillotine") + sheets = [sp for sp in g.stock_pieces if sp.is_sheet] + assert sheets and sum(len(sp.placements) for sp in sheets) == 4 + assert validate_cut_plan(g) == [] + + +def test_guillotine_oversize_panel_unplaced(): + s = Scene() + s.place("ply-3/4", 200, width_in=200) # bigger than a whole sheet + g = build_cut_plan(s, strategy="guillotine") + assert g.unplaced and g.warnings + assert validate_cut_plan(g) == [] + + +def test_best_of_n_no_worse(): + from woodshop.cutplan import _plan_key, best_cut_plan + s = Scene() + for ln in (50, 46, 40, 30, 30, 20): + s.place("2x4", ln) + best = best_cut_plan(s, attempts=50) + base = build_cut_plan(s, strategy="decreasing") + assert _plan_key(best) <= _plan_key(base) + assert validate_cut_plan(best) == [] + + def test_custom_settings_kerf(): s = Scene() s.place("2x4", 48)