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:
parent
77444c546a
commit
d44d36a773
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue