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
|
stock-type compatibility; waste/score recompute after manual edits; rotation legality
|
||||||
(settings/grain); position-aware jig grouping.
|
(settings/grain); position-aware jig grouping.
|
||||||
|
|
||||||
Known follow-ups: **Phase 1 is partial** — bounded exact search, a real "Best of N"
|
**Phase 1 now complete:** bounded branch-and-bound exact lumber packing
|
||||||
control, guillotine/maxrects plywood strategies, richer scoring. Also: lock-aware
|
(`_min_bins`/`_pack_lumber_exact`, ≤12 pieces, FFD-seeded with a count bound),
|
||||||
re-optimization (locked pieces preserved through "Find better layout"), grain-direction
|
guillotine free-rectangle plywood packing (`_pack_plywood_guillotine`, best-area-fit +
|
||||||
in auto-layout, on-hand offcut inventory, opt-in jig material in the BOM.
|
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
|
## Guiding principle
|
||||||
The **math layer is deterministic and inspectable**; AI is used **only for narrative**
|
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
|
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,
|
def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
strategy: str = "decreasing") -> CutPlan:
|
strategy: str = "decreasing") -> CutPlan:
|
||||||
s = settings or ShopSettings()
|
s = settings or ShopSettings()
|
||||||
|
|
@ -263,7 +379,10 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
stock_pieces, unplaced, warnings = [], [], []
|
stock_pieces, unplaced, warnings = [], [], []
|
||||||
for stock, its in by_stock.items():
|
for stock, its in by_stock.items():
|
||||||
if its[0].is_sheet:
|
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:
|
else:
|
||||||
sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
|
sps, un = _pack_lumber(its, stock, s, ids, fit=fit)
|
||||||
stock_pieces += sps
|
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)
|
waste_area += w.length_in * (w.width_in or sp.width_in)
|
||||||
if w.reusable:
|
if w.reusable:
|
||||||
reusable += 1
|
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 {
|
return {
|
||||||
"strategy_name": strategy,
|
"strategy_name": strategy,
|
||||||
"stock_count": len(stock_pieces),
|
"stock_count": len(stock_pieces),
|
||||||
"waste_area": round(waste_area, 1),
|
"waste_area": round(waste_area, 1),
|
||||||
"reusable_offcuts": reusable,
|
"reusable_offcuts": reusable,
|
||||||
|
"reusable_in": round(reusable_in, 1),
|
||||||
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
|
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
|
||||||
"warnings": list(warnings),
|
"warnings": list(warnings),
|
||||||
}
|
}
|
||||||
|
|
@ -402,19 +524,20 @@ def _pack_lumber_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
|
||||||
|
|
||||||
|
|
||||||
def _plan_key(plan: CutPlan):
|
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
|
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 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:
|
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan:
|
||||||
"""Find a better layout by trying several strategies + shuffle restarts and
|
"""Find a better layout by trying several strategies + shuffle restarts and
|
||||||
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
|
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))]
|
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
|
||||||
best = None
|
best = None
|
||||||
for st in strategies:
|
for st in strategies:
|
||||||
|
|
|
||||||
|
|
@ -255,11 +255,15 @@ class BomWindow(QDialog):
|
||||||
opt = QPushButton("Find better layout")
|
opt = QPushButton("Find better layout")
|
||||||
opt.setToolTip("Try several packing strategies and keep the best-scoring one")
|
opt.setToolTip("Try several packing strategies and keep the best-scoring one")
|
||||||
opt.clicked.connect(self._optimize)
|
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 = QPushButton("Try alternative")
|
||||||
alt.clicked.connect(self._next_arrangement)
|
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(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)
|
v.addLayout(row)
|
||||||
self._draw_layout()
|
self._draw_layout()
|
||||||
return w
|
return w
|
||||||
|
|
@ -276,6 +280,18 @@ class BomWindow(QDialog):
|
||||||
self._status.setText("✓ optimized around locked pieces")
|
self._status.setText("✓ optimized around locked pieces")
|
||||||
else:
|
else:
|
||||||
self._set_plan(best_cut_plan(self.c.scene))
|
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:
|
def _next_arrangement(self) -> None:
|
||||||
self._optimized = False
|
self._optimized = False
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,17 @@ def test_drop_onto_incompatible_stock_reverts(tmp_path):
|
||||||
assert "can't go" in w._status.text()
|
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):
|
def test_valid_move_commits(tmp_path):
|
||||||
c = Controller(str(tmp_path / "s.json"))
|
c = Controller(str(tmp_path / "s.json"))
|
||||||
c.place("2x4", 20)
|
c.place("2x4", 20)
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,56 @@ def test_reoptimize_preserves_locked_placement():
|
||||||
assert validate_cut_plan(re) == []
|
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():
|
def test_custom_settings_kerf():
|
||||||
s = Scene()
|
s = Scene()
|
||||||
s.place("2x4", 48)
|
s.place("2x4", 48)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue