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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 14:31:58 -03:00
parent 77444c546a
commit d44d36a773
3 changed files with 118 additions and 33 deletions

View File

@ -144,25 +144,32 @@ def _ordered(items, strategy):
key = lambda it: max(it.length_in, it.width_in) key = lambda it: max(it.length_in, it.width_in)
if strategy == "increasing": if strategy == "increasing":
return sorted(items, key=key) return sorted(items, key=key)
if strategy == "shuffle": if strategy.startswith("shuffle"): # "shuffle", "shuffle1", ... distinct salts
return sorted(items, key=lambda it: (hash(it.id) & 0xffff)) salt = strategy[7:]
return sorted(items, key=key, reverse=True) 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]: def _lumber_avail(sp, s):
"""First-fit-decreasing into sticks. Returns (stock_pieces, unplaced_ids).""" 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 = [], [] sticks, unplaced = [], []
for it in items: for it in items:
if it.length_in > s.stick_len_in + _EPS: if it.length_in > s.stick_len_in + _EPS:
unplaced.append(it.id) unplaced.append(it.id)
continue continue
for sp in sticks: candidates = [(sp,) + _lumber_avail(sp, s) for sp in sticks]
end = max((p.x_in + p.len_in for p in sp.placements), default=0.0) candidates = [(sp, room, x) for sp, room, x in candidates if it.length_in <= room + _EPS]
cursor = end + (s.kerf_in if sp.placements else 0.0) if candidates:
if cursor + it.length_in <= s.stick_len_in + _EPS: sp, _room, x = (min(candidates, key=lambda c: c[1]) if fit == "best"
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=cursor, 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)) len_in=it.length_in, wid_in=it.width_in))
break
else: else:
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False, sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False,
length_in=s.stick_len_in, width_in=it.width_in) 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) sheets, rest = [], list(items)
unplaced = [] 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): def pack_one(panels):
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True, sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=True,
length_in=s.sheet_l_in, width_in=s.sheet_w_in) length_in=s.sheet_l_in, width_in=s.sheet_w_in)
shelves, leftover = [], [] # shelves: [y, height, x_cursor] shelves, leftover = [], [] # shelves: [y, height, x_cursor]
for it in panels: for it in panels:
done = False done = False
for sh in shelves: 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) 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: 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], 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)) len_in=pl, wid_in=pw, rotated=rot))
sh[2] = x + it.length_in sh[2] = x + pl
done = True done = True
break break
if done:
break
if not done: if not done:
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 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: if y + pw <= s.sheet_w_in + _EPS and pl <= s.sheet_l_in + _EPS:
shelves.append([y, it.width_in, it.length_in]) shelves.append([y, pw, pl])
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y, 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)) len_in=pl, wid_in=pw, rotated=rot))
done = True done = True
break
if not done: if not done:
leftover.append(it) leftover.append(it)
return sp, leftover return sp, leftover
@ -230,6 +249,7 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
counter["n"] += 1 counter["n"] += 1
return f"{prefix}{counter['n']}" return f"{prefix}{counter['n']}"
fit = "best" if strategy == "bestfit" else "first"
by_stock: dict[str, list] = {} by_stock: dict[str, list] = {}
for it in _ordered(items, strategy): for it in _ordered(items, strategy):
by_stock.setdefault(it.stock, []).append(it) 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: if its[0].is_sheet:
sps, un = _pack_plywood(its, stock, s, ids) sps, un = _pack_plywood(its, stock, s, ids)
else: else:
sps, un = _pack_lumber(its, stock, s, ids) sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
stock_pieces += sps stock_pieces += sps
unplaced += un unplaced += un
for item_id in unplaced: 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: def validate_cut_plan(plan: CutPlan) -> list:
"""Return a list of problems ([] means valid): pieces inside stock, no """Return a list of problems ([] means valid): pieces inside stock, no
overlaps, kerf respected, every item placed-or-warned.""" overlaps, kerf respected, every item placed-or-warned."""

View File

@ -11,10 +11,9 @@ from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget) QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from ..cutlist import _fmt_len, cut_rows, shopping 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 from ..layout import waste_summary
_ORDERS = ["decreasing", "increasing", "shuffle"]
_PX = 7.0 # pixels per inch in the layout view _PX = 7.0 # pixels per inch in the layout view
_PIECE = "#c8965a" _PIECE = "#c8965a"
_WASTE = "#3a3a3a" _WASTE = "#3a3a3a"
@ -27,6 +26,7 @@ class BomWindow(QDialog):
self.setWindowTitle("Cut List & BOM") self.setWindowTitle("Cut List & BOM")
self.resize(820, 640) self.resize(820, 640)
self._order = 0 self._order = 0
self._optimized = False
tabs = QTabWidget() tabs = QTabWidget()
tabs.addTab(self._text_tab(self._cut_text()), "Cut List") tabs.addTab(self._text_tab(self._cut_text()), "Cut List")
@ -91,22 +91,31 @@ class BomWindow(QDialog):
self.view = QGraphicsView(self.scene) self.view = QGraphicsView(self.scene)
v.addWidget(self.view) v.addWidget(self.view)
row = QHBoxLayout() row = QHBoxLayout()
again = QPushButton("Try another arrangement") opt = QPushButton("Find better layout")
again.clicked.connect(self._next_arrangement) 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 = QPushButton("Print…")
pr.clicked.connect(self._print_layout) 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) v.addLayout(row)
self._draw_layout() self._draw_layout()
return w return w
def _optimize(self) -> None:
self._optimized = True
self._draw_layout()
def _next_arrangement(self) -> None: 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() self._draw_layout()
def _draw_layout(self) -> None: def _draw_layout(self) -> None:
self.scene.clear() 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} 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} part_of = {it.id: it.part_id for it in plan.items}
label = lambda iid: names.get(part_of.get(iid, ""), iid) label = lambda iid: names.get(part_of.get(iid, ""), iid)

View File

@ -71,6 +71,36 @@ def test_json_roundtrip():
assert validate_cut_plan(plan2) == [] 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(): def test_custom_settings_kerf():
s = Scene() s = Scene()
s.place("2x4", 48) s.place("2x4", 48)