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)
|
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])
|
||||||
len_in=it.length_in, wid_in=it.width_in))
|
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x,
|
||||||
break
|
len_in=it.length_in, wid_in=it.width_in))
|
||||||
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):
|
||||||
x = sh[2] + (s.kerf_in if sh[2] else 0.0)
|
for sh in shelves: # fit into an existing shelf
|
||||||
if it.width_in <= sh[1] + _EPS and x + it.length_in <= s.sheet_l_in + _EPS:
|
x = sh[2] + (s.kerf_in if sh[2] else 0.0)
|
||||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, y_in=sh[0],
|
if pw <= sh[1] + _EPS and x + pl <= s.sheet_l_in + _EPS:
|
||||||
len_in=it.length_in, wid_in=it.width_in))
|
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=x, y_in=sh[0],
|
||||||
sh[2] = x + it.length_in
|
len_in=pl, wid_in=pw, rotated=rot))
|
||||||
done = True
|
sh[2] = x + pl
|
||||||
|
done = True
|
||||||
|
break
|
||||||
|
if done:
|
||||||
break
|
break
|
||||||
if not done:
|
if not done:
|
||||||
y = (shelves[-1][0] + shelves[-1][1] + s.kerf_in) if shelves else 0.0
|
for pl, pw, rot in orientations(it): # start a new shelf
|
||||||
if y + it.width_in <= s.sheet_w_in + _EPS and it.length_in <= s.sheet_l_in + _EPS:
|
y = (shelves[-1][0] + shelves[-1][1] + s.kerf_in) if shelves else 0.0
|
||||||
shelves.append([y, it.width_in, it.length_in])
|
if y + pw <= s.sheet_w_in + _EPS and pl <= s.sheet_l_in + _EPS:
|
||||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y,
|
shelves.append([y, pw, pl])
|
||||||
len_in=it.length_in, wid_in=it.width_in))
|
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0, y_in=y,
|
||||||
done = True
|
len_in=pl, wid_in=pw, rotated=rot))
|
||||||
|
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."""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue