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:
parent
c81633b699
commit
274e87e239
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue