From d44d36a7732df8c3062654ad32f1fc49af6064c3 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 14:31:58 -0300 Subject: [PATCH] Phase 1: smarter auto-layout (best-fit, plywood rotation, optimize) - Lumber packing supports first-fit (FFD) and best-fit (BFD, tightest fit) via a `fit` mode; strategy "bestfit" selects it. - Plywood panels now rotate to fit (when allowed and grain isn't honored); placements record `rotated`. Rotation-disabled oversize panels are flagged. - best_cut_plan() tries decreasing/bestfit/increasing + shuffle restarts and keeps the best by (stock_count, waste_area, -reusable_offcuts); marks it "optimized". STRATEGIES drives "Try alternative". - BOM Cut Layout tab: "Find better layout" (optimize) + "Try alternative" (cycle strategies) buttons; the score line explains the result. 107 tests pass (rotation fits/!fits, optimizer no-worse-than-baseline). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/cutplan.py | 98 +++++++++++++++++++++++++--------- src/woodshop/gui/bom_window.py | 23 +++++--- tests/test_cutplan.py | 30 +++++++++++ 3 files changed, 118 insertions(+), 33 deletions(-) diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 79ce29c..4ef2087 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -144,25 +144,32 @@ def _ordered(items, strategy): key = lambda it: max(it.length_in, it.width_in) if strategy == "increasing": return sorted(items, key=key) - if strategy == "shuffle": - return sorted(items, key=lambda it: (hash(it.id) & 0xffff)) - return sorted(items, key=key, reverse=True) + if strategy.startswith("shuffle"): # "shuffle", "shuffle1", ... distinct salts + salt = strategy[7:] + return sorted(items, key=lambda it: (hash(it.id + salt) & 0xffffff)) + return sorted(items, key=key, reverse=True) # decreasing (FFD) & bestfit (BFD) -def _pack_lumber(items, stock, s: ShopSettings, ids) -> tuple[list, list]: - """First-fit-decreasing into sticks. Returns (stock_pieces, unplaced_ids).""" +def _lumber_avail(sp, s): + end = max((p.x_in + p.len_in for p in sp.placements), default=0.0) + cursor = end + (s.kerf_in if sp.placements else 0.0) + return sp.length_in - cursor, cursor # (room left, x where the next piece starts) + + +def _pack_lumber(items, stock, s: ShopSettings, ids, fit="first") -> tuple[list, list]: + """Pack lengths into sticks. fit='first' (FFD) or 'best' (BFD = tightest fit).""" sticks, unplaced = [], [] for it in items: if it.length_in > s.stick_len_in + _EPS: unplaced.append(it.id) continue - for sp in sticks: - end = max((p.x_in + p.len_in for p in sp.placements), default=0.0) - cursor = end + (s.kerf_in if sp.placements else 0.0) - if cursor + it.length_in <= s.stick_len_in + _EPS: - sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=cursor, - len_in=it.length_in, wid_in=it.width_in)) - break + candidates = [(sp,) + _lumber_avail(sp, s) for sp in sticks] + candidates = [(sp, room, x) for sp, room, x in candidates if it.length_in <= room + _EPS] + if candidates: + sp, _room, x = (min(candidates, key=lambda c: c[1]) if fit == "best" + else candidates[0]) + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, + len_in=it.length_in, wid_in=it.width_in)) else: sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False, length_in=s.stick_len_in, width_in=it.width_in) @@ -183,27 +190,39 @@ def _pack_plywood(items, stock, s: ShopSettings, ids) -> tuple[list, list]: sheets, rest = [], list(items) unplaced = [] + def orientations(it): + # (len_along_sheet, width_across, rotated). Rotation allowed unless grain is honored. + opts = [(it.length_in, it.width_in, False)] + if s.allow_plywood_rotation and not s.grain_direction and it.width_in != it.length_in: + opts.append((it.width_in, it.length_in, True)) + return opts + def pack_one(panels): sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True, length_in=s.sheet_l_in, width_in=s.sheet_w_in) shelves, leftover = [], [] # shelves: [y, height, x_cursor] for it in panels: done = False - for sh in shelves: - x = sh[2] + (s.kerf_in if sh[2] else 0.0) - if it.width_in <= sh[1] + _EPS and x + it.length_in <= s.sheet_l_in + _EPS: - sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, y_in=sh[0], - len_in=it.length_in, wid_in=it.width_in)) - sh[2] = x + it.length_in - done = True + for pl, pw, rot in orientations(it): + for sh in shelves: # fit into an existing shelf + x = sh[2] + (s.kerf_in if sh[2] else 0.0) + if pw <= sh[1] + _EPS and x + pl <= s.sheet_l_in + _EPS: + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, y_in=sh[0], + len_in=pl, wid_in=pw, rotated=rot)) + sh[2] = x + pl + done = True + break + if done: break if not done: - y = (shelves[-1][0] + shelves[-1][1] + s.kerf_in) if shelves else 0.0 - if y + it.width_in <= s.sheet_w_in + _EPS and it.length_in <= s.sheet_l_in + _EPS: - shelves.append([y, it.width_in, it.length_in]) - sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y, - len_in=it.length_in, wid_in=it.width_in)) - done = True + for pl, pw, rot in orientations(it): # start a new shelf + y = (shelves[-1][0] + shelves[-1][1] + s.kerf_in) if shelves else 0.0 + if y + pw <= s.sheet_w_in + _EPS and pl <= s.sheet_l_in + _EPS: + shelves.append([y, pw, pl]) + sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y, + len_in=pl, wid_in=pw, rotated=rot)) + done = True + break if not done: leftover.append(it) return sp, leftover @@ -230,6 +249,7 @@ def build_cut_plan(scene, settings: ShopSettings | None = None, counter["n"] += 1 return f"{prefix}{counter['n']}" + fit = "best" if strategy == "bestfit" else "first" by_stock: dict[str, list] = {} for it in _ordered(items, strategy): by_stock.setdefault(it.stock, []).append(it) @@ -239,7 +259,7 @@ def build_cut_plan(scene, settings: ShopSettings | None = None, if its[0].is_sheet: sps, un = _pack_plywood(its, stock, s, ids) else: - sps, un = _pack_lumber(its, stock, s, ids) + sps, un = _pack_lumber(its, stock, s, ids, fit=fit) stock_pieces += sps unplaced += un for item_id in unplaced: @@ -278,6 +298,32 @@ def _score(stock_pieces, s, strategy, warnings) -> dict: } +def _plan_key(plan: CutPlan): + """Lower is better: fewest stock pieces, then least waste, then more reusable offcuts.""" + sc = plan.score + return (sc["stock_count"], sc["waste_area"], -sc["reusable_offcuts"]) + + +# Strategies the "Try alternative" button cycles through. +STRATEGIES = ["decreasing", "bestfit", "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 += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))] + best = None + for st in strategies: + plan = build_cut_plan(scene, settings, strategy=st) + if best is None or _plan_key(plan) < _plan_key(best): + best = plan + if best is not None: + best.strategy = "optimized" + best.score["strategy_name"] = "optimized" + return best + + def validate_cut_plan(plan: CutPlan) -> list: """Return a list of problems ([] means valid): pieces inside stock, no overlaps, kerf respected, every item placed-or-warned.""" diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 42ee6a0..3b1cbbe 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -11,10 +11,9 @@ from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget) from ..cutlist import _fmt_len, cut_rows, shopping -from ..cutplan import build_cut_plan +from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan from ..layout import waste_summary -_ORDERS = ["decreasing", "increasing", "shuffle"] _PX = 7.0 # pixels per inch in the layout view _PIECE = "#c8965a" _WASTE = "#3a3a3a" @@ -27,6 +26,7 @@ class BomWindow(QDialog): self.setWindowTitle("Cut List & BOM") self.resize(820, 640) self._order = 0 + self._optimized = False tabs = QTabWidget() tabs.addTab(self._text_tab(self._cut_text()), "Cut List") @@ -91,22 +91,31 @@ class BomWindow(QDialog): self.view = QGraphicsView(self.scene) v.addWidget(self.view) row = QHBoxLayout() - again = QPushButton("Try another arrangement") - again.clicked.connect(self._next_arrangement) + opt = QPushButton("Find better layout") + opt.setToolTip("Try several packing strategies and keep the best-scoring one") + opt.clicked.connect(self._optimize) + alt = QPushButton("Try alternative") + alt.clicked.connect(self._next_arrangement) pr = QPushButton("Print…") pr.clicked.connect(self._print_layout) - row.addWidget(again); row.addStretch(); row.addWidget(pr) + row.addWidget(opt); row.addWidget(alt); row.addStretch(); row.addWidget(pr) v.addLayout(row) self._draw_layout() return w + def _optimize(self) -> None: + self._optimized = True + self._draw_layout() + def _next_arrangement(self) -> None: - self._order = (self._order + 1) % len(_ORDERS) + self._optimized = False + self._order = (self._order + 1) % len(STRATEGIES) self._draw_layout() def _draw_layout(self) -> None: self.scene.clear() - plan = build_cut_plan(self.c.scene, strategy=_ORDERS[self._order]) + plan = (best_cut_plan(self.c.scene) if self._optimized + else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) names = {p.id: (p.name or p.id) for p in self.c.scene.parts} part_of = {it.id: it.part_id for it in plan.items} label = lambda iid: names.get(part_of.get(iid, ""), iid) diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index 5061537..e414d22 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -71,6 +71,36 @@ def test_json_roundtrip(): assert validate_cut_plan(plan2) == [] +def test_plywood_rotation_fits_panel(): + s = Scene() + s.place("ply-3/4", 30, width_in=60) # 60" wide > 48" sheet — needs rotating + plan = build_cut_plan(s) # rotation allowed by default + sheets = [sp for sp in plan.stock_pieces if sp.is_sheet] + assert len(sheets) == 1 + p = sheets[0].placements[0] + assert p.rotated and p.len_in == 60 and p.wid_in == 30 + assert validate_cut_plan(plan) == [] + + +def test_rotation_disabled_flags_unfit(): + s = Scene() + s.place("ply-3/4", 30, width_in=60) + plan = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False)) + assert plan.unplaced and plan.warnings + + +def test_best_cut_plan_is_no_worse(): + from woodshop.cutplan import _plan_key, best_cut_plan + s = Scene() + for ln in (50, 46, 30, 30, 20): + s.place("2x4", ln) + best = best_cut_plan(s) + base = build_cut_plan(s, strategy="decreasing") + assert _plan_key(best) <= _plan_key(base) + assert best.strategy == "optimized" + assert validate_cut_plan(best) == [] + + def test_custom_settings_kerf(): s = Scene() s.place("2x4", 48)