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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 15:37:11 -03:00
parent c81633b699
commit 274e87e239
5 changed files with 215 additions and 10 deletions

View File

@ -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**

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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)