Phase 0: CutPlan model (deterministic shop-output artifact)
Adds SHOP_PACKET_PLAN.md (living plan) and cutplan.py: ShopSettings, CutItem,
StockPiece, Placement, WasteRegion, CutPlan — plain dataclasses, JSON-serializable,
stable ids throughout. build_cut_plan(scene, settings, strategy) packs lumber
(first-fit-decreasing, kerf-aware) and plywood (shelf) into the model with waste
regions and a detailed score {strategy, stock_count, waste_area, reusable_offcuts,
yield_pct, warnings}. validate_cut_plan checks bounds/overlap/kerf/placed-or-warned.
Old APIs kept as thin wrappers over build_cut_plan: layout.nest_lumber/nest_plywood/
stock_counts/waste_summary (so existing tests/UI/cutlist.shopping keep working).
The BOM window's Cut Layout tab now renders directly from the CutPlan (score line,
stick/sheet placements, waste, warnings).
104 tests pass incl. kerf, tenon-extended items, oversize warnings, JSON roundtrip,
validation, custom settings. UI says "Try another arrangement", not "optimal".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3643aac50d
commit
77444c546a
|
|
@ -0,0 +1,75 @@
|
|||
# Shop Packet Plan
|
||||
|
||||
A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go.
|
||||
|
||||
## Guiding principle
|
||||
The **math layer is deterministic and inspectable**; AI is used **only for narrative**
|
||||
(instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig
|
||||
dimensions, validation, and warnings all come from code — the AI never invents a number.
|
||||
|
||||
UI language: say **"Optimize" / "Find better layout"**, never "optimal" (woodworking
|
||||
wants explainable good layouts, not slow provably-perfect ones).
|
||||
|
||||
## Data flow
|
||||
```
|
||||
Scene → CutItems → StockInventory → CutPlan → ShopPacket(view)
|
||||
```
|
||||
|
||||
## The keystone: `CutPlan` (cutplan.py)
|
||||
Dataclasses, JSON-friendly, **stable IDs everywhere** (`CutItem.id`, `StockPiece.id`,
|
||||
`Placement.id`) — never rely on list position. Serializable from day one
|
||||
(`to_dict`/`from_dict`) so we can save manual layouts, compare strategies, export, debug.
|
||||
|
||||
- `ShopSettings` — kerf, stick/sheet sizes, offcut-usable thresholds, plywood rotation
|
||||
allowed, grain direction (future), tolerances (mortise/tenon clearance, sanding
|
||||
allowance, reveal). Defaults present from day one even before they're in the UI.
|
||||
- `CutItem` — a required piece (part id, stock, length, width, is_sheet, note e.g. "incl. tenon").
|
||||
- `StockPiece` — a physical stick/sheet with its `placements` and `waste` regions.
|
||||
- `Placement` — a cut item on a stock piece: position (x[,y]), rotated?, locked?.
|
||||
- `WasteRegion` — leftover, with a `reusable` flag (≥ threshold).
|
||||
- `CutPlan` — settings, items, stock_pieces, unplaced, strategy, **score**, warnings.
|
||||
- `score = {stock_count, waste_area, reusable_offcuts, warnings, strategy_name}` —
|
||||
detailed, so the UI can explain *why* one layout beats another.
|
||||
- `build_cut_plan(scene, settings=None, strategy="decreasing") -> CutPlan`.
|
||||
- `validate_cut_plan(plan) -> [problems]` — no piece outside stock, no overlaps, kerf
|
||||
respected, every item placed-or-warned, stock dims respected, rotations legal.
|
||||
|
||||
`ShopPacket` stays thin (a view/composition over cut rows + shopping rows + cut plan +
|
||||
warnings) until `CutPlan` is solid.
|
||||
|
||||
## Phases (commit after each)
|
||||
|
||||
**Phase 0 — CutPlan + ShopSettings (keystone).**
|
||||
New `cutplan.py` with the model + `build_cut_plan` + `validate_cut_plan`. Port the current
|
||||
FFD (lumber) / shelf (plywood) packers behind it. **Keep old APIs** (`layout.nest_lumber/
|
||||
nest_plywood/stock_counts/waste_summary`, `cutlist.shopping/waste_summary`) as thin wrappers
|
||||
over `build_cut_plan` so existing tests/UI keep working. BOM window renders from `CutPlan`.
|
||||
Tests: lumber, plywood, kerf, tenon extra length, unplaced/oversize warnings, JSON roundtrip.
|
||||
|
||||
**Phase 1 — smart auto-layout.** Strategies behind the buttons: FFD, BFD, bounded exact
|
||||
(small jobs, capped), random restarts / best-of-N for big jobs; objective "minimize stock,
|
||||
then maximize useful offcuts (bonus for common 12/24/36″)". Plywood: per-panel rotation;
|
||||
shelf/guillotine/maxrects; score by sheet count, waste area, reusable-offcut size. Buttons:
|
||||
**Optimize · Try Alternative · Best of N**; surface warnings.
|
||||
|
||||
**Phase 2 — structured instructions.** Deterministic ordered steps from CutPlan + scene
|
||||
(buy → cut per plan → mark joinery → repeated cuts/jigs → cut/drill features → dry-fit →
|
||||
assemble → finish); **then** AI polishes wording (numbers stay from code). Instructions tab.
|
||||
|
||||
**Phase 3 — jig suggestions (rule-based → AI explanation).** Detect patterns (identical
|
||||
crosscuts, repeated end-offsets, repeated mortises/holes, mirrored L/R, repeated angles,
|
||||
repeated panel widths) → candidates with **computed dims** (stop block, spacer, drill
|
||||
template, story stick, mortise template, angle sled). AI explains build/use. Jigs are
|
||||
**shop aids** kept separate from project parts — optional, opt-in before any jig material
|
||||
enters the BOM. Jigs tab.
|
||||
|
||||
**Phase 4 — constrained manual layout editing.** Drag in the layout view as a *constrained
|
||||
planner*: snap to stock edges / kerf / neighbors; invalid = red; move pieces between
|
||||
sticks/sheets; rotate plywood (if grain allows); **lock** a piece so re-optimization works
|
||||
around it; live "valid / invalid / saves a stick / wastes more" feedback. Builds on
|
||||
`CutPlan.locked` + `validate_cut_plan`.
|
||||
|
||||
## Deterministic vs AI
|
||||
| Code (deterministic) | AI (narrative only) |
|
||||
|---|---|
|
||||
| lengths, kerf, counts, layouts, scores, jig dims, validation, warnings | instruction wording, jig build/use explanations, summaries |
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
"""CutPlan: the deterministic shop-output artifact everything else builds on.
|
||||
|
||||
A CutPlan packs the scene's required pieces (CutItems) onto physical stock
|
||||
(StockPieces) at explicit positions (Placements), with kerf, waste, warnings,
|
||||
and a detailed score. It is plain-dataclass, JSON-serializable, and uses stable
|
||||
ids (never list position) so manual edits, alternate strategies, instructions,
|
||||
and jig references can all point at the same objects.
|
||||
|
||||
The math here is deterministic and inspectable; AI is never used for numbers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
|
||||
from .cutlist import cut_length
|
||||
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
|
||||
|
||||
_EPS = 1e-6
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopSettings:
|
||||
kerf_in: float = 0.125
|
||||
stick_len_in: float = 96.0 # an 8' stick
|
||||
sheet_w_in: float = 48.0
|
||||
sheet_l_in: float = 96.0
|
||||
offcut_usable_in: float = 12.0 # lumber offcut ≥ this counts as reusable
|
||||
offcut_usable_sqft: float = 1.0 # plywood reusable threshold
|
||||
allow_plywood_rotation: bool = True
|
||||
grain_direction: bool = False # honor grain (future; disables rotation)
|
||||
# tolerances — defaults present from day one even before they're in the UI
|
||||
mortise_tenon_clearance_in: float = 1 / 32
|
||||
sanding_allowance_in: float = 0.0
|
||||
reveal_in: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict | None) -> "ShopSettings":
|
||||
valid = {f.name for f in fields(cls)}
|
||||
return cls(**{k: v for k, v in (d or {}).items() if k in valid})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutItem:
|
||||
id: str
|
||||
part_id: str
|
||||
stock: str
|
||||
length_in: float
|
||||
width_in: float
|
||||
is_sheet: bool
|
||||
note: str = "" # e.g. "incl. tenon"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
id: str
|
||||
item_id: str
|
||||
x_in: float # along the stock length
|
||||
y_in: float = 0.0 # across the stock width (plywood)
|
||||
len_in: float = 0.0 # placed footprint along length
|
||||
wid_in: float = 0.0 # placed footprint across width
|
||||
rotated: bool = False
|
||||
locked: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class WasteRegion:
|
||||
x_in: float
|
||||
length_in: float
|
||||
width_in: float = 0.0 # 0 -> full section width (lumber offcut)
|
||||
reusable: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class StockPiece:
|
||||
id: str
|
||||
stock: str
|
||||
is_sheet: bool
|
||||
length_in: float
|
||||
width_in: float
|
||||
placements: list = field(default_factory=list) # Placement
|
||||
waste: list = field(default_factory=list) # WasteRegion
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutPlan:
|
||||
settings: ShopSettings
|
||||
items: list = field(default_factory=list) # CutItem
|
||||
stock_pieces: list = field(default_factory=list) # StockPiece
|
||||
unplaced: list = field(default_factory=list) # CutItem ids that didn't fit
|
||||
strategy: str = "decreasing"
|
||||
score: dict = field(default_factory=dict)
|
||||
warnings: list = field(default_factory=list)
|
||||
|
||||
def item(self, item_id: str) -> CutItem:
|
||||
return next(i for i in self.items if i.id == item_id)
|
||||
|
||||
# ----- serialization (JSON-friendly) -------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"settings": self.settings.to_dict(),
|
||||
"items": [asdict(i) for i in self.items],
|
||||
"stock_pieces": [asdict(sp) for sp in self.stock_pieces],
|
||||
"unplaced": list(self.unplaced),
|
||||
"strategy": self.strategy,
|
||||
"score": self.score,
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "CutPlan":
|
||||
def sp_from(s):
|
||||
return StockPiece(
|
||||
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
|
||||
length_in=s["length_in"], width_in=s["width_in"],
|
||||
placements=[Placement(**p) for p in s.get("placements", [])],
|
||||
waste=[WasteRegion(**w) for w in s.get("waste", [])])
|
||||
return cls(
|
||||
settings=ShopSettings.from_dict(d.get("settings")),
|
||||
items=[CutItem(**i) for i in d.get("items", [])],
|
||||
stock_pieces=[sp_from(s) for s in d.get("stock_pieces", [])],
|
||||
unplaced=list(d.get("unplaced", [])),
|
||||
strategy=d.get("strategy", "decreasing"),
|
||||
score=d.get("score", {}),
|
||||
warnings=list(d.get("warnings", [])))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
def _cut_items(scene) -> list:
|
||||
items = []
|
||||
for n, p in enumerate(scene.parts, 1):
|
||||
ln = cut_length(p)
|
||||
items.append(CutItem(
|
||||
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
||||
length_in=round(ln, 3), width_in=round(p.section_in[1], 3),
|
||||
is_sheet=is_plywood(p.stock),
|
||||
note="incl. tenon" if ln > p.length_in + _EPS else ""))
|
||||
return items
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _pack_lumber(items, stock, s: ShopSettings, ids) -> tuple[list, list]:
|
||||
"""First-fit-decreasing into sticks. Returns (stock_pieces, unplaced_ids)."""
|
||||
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
|
||||
else:
|
||||
sp = StockPiece(id=ids("sp"), stock=stock, is_sheet=False,
|
||||
length_in=s.stick_len_in, width_in=it.width_in)
|
||||
sp.placements.append(Placement(id=ids(), item_id=it.id, x_in=0.0,
|
||||
len_in=it.length_in, wid_in=it.width_in))
|
||||
sticks.append(sp)
|
||||
for sp in sticks: # offcut at the end of each stick
|
||||
end = max((p.x_in + p.len_in for p in sp.placements), default=0.0)
|
||||
off = round(sp.length_in - end, 3)
|
||||
if off > 0.5:
|
||||
sp.waste.append(WasteRegion(x_in=end, length_in=off, width_in=sp.width_in,
|
||||
reusable=off >= s.offcut_usable_in))
|
||||
return sticks, unplaced
|
||||
|
||||
|
||||
def _pack_plywood(items, stock, s: ShopSettings, ids) -> tuple[list, list]:
|
||||
"""Shelf packing onto sheets (no rotation yet — Phase 1 adds it)."""
|
||||
sheets, rest = [], list(items)
|
||||
unplaced = []
|
||||
|
||||
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
|
||||
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
|
||||
if not done:
|
||||
leftover.append(it)
|
||||
return sp, leftover
|
||||
|
||||
while rest:
|
||||
sp, rest = pack_one(rest)
|
||||
if not sp.placements: # a single panel bigger than a sheet
|
||||
big = rest.pop(0)
|
||||
unplaced.append(big.id)
|
||||
continue
|
||||
sheets.append(sp)
|
||||
return sheets, unplaced
|
||||
|
||||
|
||||
def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||
strategy: str = "decreasing") -> CutPlan:
|
||||
s = settings or ShopSettings()
|
||||
items = _cut_items(scene)
|
||||
by_id = {it.id: it for it in items}
|
||||
|
||||
counter = {"n": 0}
|
||||
|
||||
def ids(prefix="pl"):
|
||||
counter["n"] += 1
|
||||
return f"{prefix}{counter['n']}"
|
||||
|
||||
by_stock: dict[str, list] = {}
|
||||
for it in _ordered(items, strategy):
|
||||
by_stock.setdefault(it.stock, []).append(it)
|
||||
|
||||
stock_pieces, unplaced, warnings = [], [], []
|
||||
for stock, its in by_stock.items():
|
||||
if its[0].is_sheet:
|
||||
sps, un = _pack_plywood(its, stock, s, ids)
|
||||
else:
|
||||
sps, un = _pack_lumber(its, stock, s, ids)
|
||||
stock_pieces += sps
|
||||
unplaced += un
|
||||
for item_id in unplaced:
|
||||
it = by_id[item_id]
|
||||
warnings.append(f"{it.part_id} ({it.stock}) doesn't fit standard stock — too big.")
|
||||
|
||||
score = _score(stock_pieces, s, strategy, warnings)
|
||||
if score["yield_pct"] < 50 and stock_pieces:
|
||||
warnings.append(f"Low yield: only {score['yield_pct']:.0f}% of bought stock is used.")
|
||||
return CutPlan(settings=s, items=items, stock_pieces=stock_pieces,
|
||||
unplaced=unplaced, strategy=strategy, score=score, warnings=warnings)
|
||||
|
||||
|
||||
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||
waste_area = used_area = bought_area = 0.0
|
||||
reusable = 0
|
||||
for sp in stock_pieces:
|
||||
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
||||
used_area += used
|
||||
if sp.is_sheet:
|
||||
bought_area += sp.length_in * sp.width_in
|
||||
waste_area += sp.length_in * sp.width_in - used
|
||||
else:
|
||||
bought_area += sp.length_in * sp.width_in
|
||||
for w in sp.waste:
|
||||
waste_area += w.length_in * (w.width_in or sp.width_in)
|
||||
if w.reusable:
|
||||
reusable += 1
|
||||
return {
|
||||
"strategy_name": strategy,
|
||||
"stock_count": len(stock_pieces),
|
||||
"waste_area": round(waste_area, 1),
|
||||
"reusable_offcuts": reusable,
|
||||
"yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0,
|
||||
"warnings": list(warnings),
|
||||
}
|
||||
|
||||
|
||||
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."""
|
||||
problems = []
|
||||
s = plan.settings
|
||||
placed_items = set()
|
||||
for sp in plan.stock_pieces:
|
||||
for p in sp.placements:
|
||||
placed_items.add(p.item_id)
|
||||
if p.x_in < -_EPS or p.x_in + p.len_in > sp.length_in + _EPS:
|
||||
problems.append(f"{p.id} runs off {sp.id} lengthwise")
|
||||
if p.y_in < -_EPS or p.y_in + p.wid_in > sp.width_in + _EPS:
|
||||
problems.append(f"{p.id} runs off {sp.id} widthwise")
|
||||
# pairwise overlap (with kerf) on the same stock piece
|
||||
ps = sp.placements
|
||||
for i in range(len(ps)):
|
||||
for j in range(i + 1, len(ps)):
|
||||
a, b = ps[i], ps[j]
|
||||
x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in)
|
||||
y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in)
|
||||
if x_ov > s.kerf_in - _EPS and y_ov > _EPS:
|
||||
problems.append(f"{a.id} and {b.id} overlap on {sp.id}")
|
||||
for it in plan.items:
|
||||
if it.id not in placed_items and it.id not in plan.unplaced:
|
||||
problems.append(f"{it.part_id} ({it.id}) is neither placed nor flagged unplaced")
|
||||
return problems
|
||||
|
|
@ -11,7 +11,8 @@ from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
|
|||
QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
from ..cutlist import _fmt_len, cut_rows, shopping
|
||||
from ..layout import nest_lumber, nest_plywood, waste_summary
|
||||
from ..cutplan import build_cut_plan
|
||||
from ..layout import waste_summary
|
||||
|
||||
_ORDERS = ["decreasing", "increasing", "shuffle"]
|
||||
_PX = 7.0 # pixels per inch in the layout view
|
||||
|
|
@ -105,31 +106,45 @@ class BomWindow(QDialog):
|
|||
|
||||
def _draw_layout(self) -> None:
|
||||
self.scene.clear()
|
||||
order = _ORDERS[self._order]
|
||||
plan = build_cut_plan(self.c.scene, strategy=_ORDERS[self._order])
|
||||
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
||||
px, y, bar = _PX, 16.0, 34.0
|
||||
part_of = {it.id: it.part_id for it in plan.items}
|
||||
label = lambda iid: names.get(part_of.get(iid, ""), iid)
|
||||
px, y, bar = _PX, 30.0, 34.0
|
||||
|
||||
# 1D lumber: each stick a horizontal bar (all coords in pixels).
|
||||
for stock, sticks in nest_lumber(self.c.scene, order=order).items():
|
||||
for i, st in enumerate(sticks):
|
||||
self._label(0, y - 15, f"{stock} stick {i + 1}")
|
||||
x = 0.0
|
||||
for pid, ln in st["pieces"]:
|
||||
self._rect(x * px, y, ln * px, bar, _PIECE, f"{names[pid]} · {_fmt_len(ln)}")
|
||||
x += ln
|
||||
if st["offcut"] > 0.5:
|
||||
self._rect(x * px, y, st["offcut"] * px, bar, _WASTE, f"waste {_fmt_len(st['offcut'])}")
|
||||
y += bar + 24
|
||||
sc = plan.score
|
||||
self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock piece(s) · "
|
||||
f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable offcut(s)")
|
||||
|
||||
# 2D plywood: each sheet a rectangle with placed panels.
|
||||
for stock, sheets in nest_plywood(self.c.scene, order=order).items():
|
||||
for i, sh in enumerate(sheets):
|
||||
sw, sl = sh["sheet"]
|
||||
self._label(0, y - 15, f"{stock} sheet {i + 1} ({_fmt_len(sw)}×{_fmt_len(sl)})")
|
||||
self._rect(0, y, sl * px, sw * px, _WASTE, "")
|
||||
for pid, x, yy, pl, pw in sh["placements"]:
|
||||
self._rect(x * px, y + yy * px, pl * px, pw * px, _PIECE, names[pid])
|
||||
y += sw * px + 34
|
||||
n = m = 0
|
||||
for sp in plan.stock_pieces: # lumber sticks first
|
||||
if sp.is_sheet:
|
||||
continue
|
||||
n += 1
|
||||
self._label(0, y - 15, f"{sp.stock} stick {n}")
|
||||
for p in sp.placements:
|
||||
self._rect(p.x_in * px, y, p.len_in * px, bar, _PIECE,
|
||||
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
|
||||
for w in sp.waste:
|
||||
self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE,
|
||||
f"waste {_fmt_len(w.length_in)}")
|
||||
y += bar + 24
|
||||
|
||||
for sp in plan.stock_pieces: # then plywood sheets
|
||||
if not sp.is_sheet:
|
||||
continue
|
||||
m += 1
|
||||
self._label(0, y - 15, f"{sp.stock} sheet {m} "
|
||||
f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})")
|
||||
self._rect(0, y, sp.length_in * px, sp.width_in * px, _WASTE, "")
|
||||
for p in sp.placements:
|
||||
self._rect(p.x_in * px, y + p.y_in * px, p.len_in * px, p.wid_in * px,
|
||||
_PIECE, label(p.item_id))
|
||||
y += sp.width_in * px + 34
|
||||
|
||||
for warn in plan.warnings:
|
||||
self._label(0, y, "⚠ " + warn)
|
||||
y += 18
|
||||
self.view.setSceneRect(self.scene.itemsBoundingRect())
|
||||
|
||||
def _rect(self, x, y, w, h, color, text) -> None:
|
||||
|
|
|
|||
|
|
@ -1,122 +1,62 @@
|
|||
"""Cutting-stock layout: pack the required pieces onto standard stock to estimate
|
||||
waste and drive the cut-layout view.
|
||||
"""Backwards-compatible nesting helpers — thin wrappers over cutplan.build_cut_plan.
|
||||
|
||||
- Lumber is 1D: pack piece lengths into 8' sticks (first-fit-decreasing + kerf).
|
||||
- Plywood is 2D: shelf/guillotine packing of panels onto 4'×8' sheets.
|
||||
|
||||
Heuristics, not provably optimal — 2D nesting is NP-hard — but good, and the
|
||||
ordering can be varied ("try another arrangement"). Pure logic, no GUI.
|
||||
The real packing now lives in cutplan.py (the CutPlan artifact). These keep the
|
||||
older dict-shaped APIs working for existing callers/tests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections import Counter
|
||||
|
||||
from .cutlist import cut_length
|
||||
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
|
||||
from .cutplan import build_cut_plan
|
||||
|
||||
STICK_LEN = 96.0 # an 8' stick
|
||||
KERF = 0.125 # saw kerf between cuts
|
||||
STICK_LEN = 96.0
|
||||
KERF = 0.125
|
||||
|
||||
|
||||
def nest_lumber(scene, stick_len=STICK_LEN, kerf=KERF, order="decreasing") -> dict:
|
||||
"""{stock: [ {pieces:[(part_id,len)], used, offcut}, ... ]} — sticks per stock."""
|
||||
by_stock: dict[str, list] = defaultdict(list)
|
||||
for p in scene.parts:
|
||||
if not is_plywood(p.stock):
|
||||
by_stock[p.stock].append((p.id, round(cut_length(p), 3)))
|
||||
|
||||
result = {}
|
||||
for stock, pieces in by_stock.items():
|
||||
pieces = _ordered(pieces, key=lambda x: x[1], how=order)
|
||||
sticks: list[dict] = []
|
||||
for pid, ln in pieces:
|
||||
for st in sticks:
|
||||
need = ln + (kerf if st["pieces"] else 0)
|
||||
if st["used"] + need <= stick_len + 1e-6:
|
||||
st["pieces"].append((pid, ln))
|
||||
st["used"] += need
|
||||
break
|
||||
else:
|
||||
sticks.append({"pieces": [(pid, ln)], "used": ln})
|
||||
for st in sticks:
|
||||
st["offcut"] = round(stick_len - st["used"], 3)
|
||||
result[stock] = sticks
|
||||
return result
|
||||
|
||||
|
||||
def nest_plywood(scene, sheet_w=SHEET_WIDTH_IN, sheet_l=SHEET_LENGTH_IN,
|
||||
kerf=KERF, order="decreasing") -> dict:
|
||||
"""{stock: [ {placements:[(id,x,y,w,h)], sheet:(w,l)}, ... ]}.
|
||||
x runs along the sheet length, y across its width. Shelf packing (no rotation)."""
|
||||
by_stock: dict[str, list] = defaultdict(list)
|
||||
for p in scene.parts:
|
||||
if is_plywood(p.stock):
|
||||
by_stock[p.stock].append((p.id, round(cut_length(p), 3), round(p.section_in[1], 3)))
|
||||
|
||||
result = {}
|
||||
for stock, panels in by_stock.items():
|
||||
panels = _ordered(panels, key=lambda x: x[2], how=order) # tallest (width) first
|
||||
rest = list(panels)
|
||||
sheets = []
|
||||
while rest:
|
||||
placed, rest = _pack_sheet(rest, sheet_w, sheet_l, kerf)
|
||||
if not placed: # a single panel bigger than a sheet
|
||||
pid, pl, pw = rest.pop(0)
|
||||
placed = [(pid, 0.0, 0.0, min(pl, sheet_l), min(pw, sheet_w))]
|
||||
sheets.append({"placements": placed, "sheet": (sheet_w, sheet_l)})
|
||||
result[stock] = sheets
|
||||
return result
|
||||
|
||||
|
||||
def _pack_sheet(panels, sw, sl, kerf):
|
||||
"""Place as many panels as fit on one sheet via shelves; return (placed, rest)."""
|
||||
placed, shelves, rest = [], [], [] # shelves: dicts {y, height, x}
|
||||
for pid, pl, pw in panels:
|
||||
done = False
|
||||
for sh in shelves: # fit into an existing shelf
|
||||
x = sh["x"] + (kerf if sh["x"] else 0)
|
||||
if pw <= sh["height"] + 1e-6 and x + pl <= sl + 1e-6:
|
||||
placed.append((pid, x, sh["y"], pl, pw))
|
||||
sh["x"] = x + pl
|
||||
done = True
|
||||
break
|
||||
if not done: # start a new shelf if width remains
|
||||
y = (shelves[-1]["y"] + shelves[-1]["height"] + kerf) if shelves else 0.0
|
||||
if y + pw <= sw + 1e-6 and pl <= sl + 1e-6:
|
||||
shelves.append({"y": y, "height": pw, "x": pl})
|
||||
placed.append((pid, 0.0, y, pl, pw))
|
||||
done = True
|
||||
if not done:
|
||||
rest.append((pid, pl, pw))
|
||||
return placed, rest
|
||||
|
||||
|
||||
def _ordered(items, key, how):
|
||||
if how == "increasing":
|
||||
return sorted(items, key=key)
|
||||
if how == "shuffle": # deterministic-ish alternative without RNG state
|
||||
return sorted(items, key=lambda x: (hash(x[0]) & 0xffff))
|
||||
return sorted(items, key=key, reverse=True) # decreasing (default)
|
||||
|
||||
|
||||
def stock_counts(scene, **kw) -> dict:
|
||||
"""Pieces-of-stock to buy, from the actual nesting (more accurate than
|
||||
length/area ÷ stock)."""
|
||||
counts = {s: len(sticks) for s, sticks in nest_lumber(scene, **kw).items()}
|
||||
counts.update({s: len(sheets) for s, sheets in nest_plywood(scene, **kw).items()})
|
||||
return counts
|
||||
|
||||
|
||||
def waste_summary(scene, **kw) -> dict:
|
||||
"""Per-stock {bought, used_in, offcut_in / area} for a rough yield report."""
|
||||
out = {}
|
||||
for stock, sticks in nest_lumber(scene, **kw).items():
|
||||
used = sum(s["used"] for s in sticks)
|
||||
out[stock] = {"bought": len(sticks), "used": round(used, 1),
|
||||
"capacity": round(len(sticks) * STICK_LEN, 1)}
|
||||
for stock, sheets in nest_plywood(scene, **kw).items():
|
||||
used = sum(w * h for sh in sheets for (_id, _x, _y, w, h) in sh["placements"])
|
||||
cap = len(sheets) * SHEET_WIDTH_IN * SHEET_LENGTH_IN
|
||||
out[stock] = {"bought": len(sheets), "used": round(used / 144, 1),
|
||||
"capacity": round(cap / 144, 1)}
|
||||
def nest_lumber(scene, order="decreasing", **_) -> dict:
|
||||
plan = build_cut_plan(scene, strategy=order)
|
||||
out: dict[str, list] = {}
|
||||
for sp in plan.stock_pieces:
|
||||
if sp.is_sheet:
|
||||
continue
|
||||
pieces = [(plan.item(p.item_id).part_id, p.len_in) for p in sp.placements]
|
||||
used = sum(p.len_in for p in sp.placements) + plan.settings.kerf_in * max(len(pieces) - 1, 0)
|
||||
offcut = sp.waste[0].length_in if sp.waste else round(sp.length_in - used, 3)
|
||||
out.setdefault(sp.stock, []).append(
|
||||
{"pieces": pieces, "used": round(used, 3), "offcut": round(offcut, 3)})
|
||||
return out
|
||||
|
||||
|
||||
def nest_plywood(scene, order="decreasing", **_) -> dict:
|
||||
plan = build_cut_plan(scene, strategy=order)
|
||||
out: dict[str, list] = {}
|
||||
for sp in plan.stock_pieces:
|
||||
if not sp.is_sheet:
|
||||
continue
|
||||
placements = [(plan.item(p.item_id).part_id, p.x_in, p.y_in, p.len_in, p.wid_in)
|
||||
for p in sp.placements]
|
||||
out.setdefault(sp.stock, []).append(
|
||||
{"placements": placements, "sheet": (sp.width_in, sp.length_in)})
|
||||
return out
|
||||
|
||||
|
||||
def stock_counts(scene, **_) -> dict:
|
||||
return dict(Counter(sp.stock for sp in build_cut_plan(scene).stock_pieces))
|
||||
|
||||
|
||||
def waste_summary(scene, **_) -> dict:
|
||||
plan = build_cut_plan(scene)
|
||||
out: dict[str, dict] = {}
|
||||
for sp in plan.stock_pieces:
|
||||
d = out.setdefault(sp.stock, {"bought": 0, "used": 0.0, "capacity": 0.0})
|
||||
d["bought"] += 1
|
||||
if sp.is_sheet:
|
||||
d["used"] += sum(p.len_in * p.wid_in for p in sp.placements) / 144
|
||||
d["capacity"] += sp.length_in * sp.width_in / 144
|
||||
else:
|
||||
d["used"] += sum(p.len_in for p in sp.placements)
|
||||
d["capacity"] += sp.length_in
|
||||
for d in out.values():
|
||||
d["used"] = round(d["used"], 1)
|
||||
d["capacity"] = round(d["capacity"], 1)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""Phase 0 tests for the CutPlan model."""
|
||||
import json
|
||||
|
||||
from woodshop.cutplan import CutPlan, ShopSettings, build_cut_plan, validate_cut_plan
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def test_lumber_plan_packs_and_validates():
|
||||
s = Scene()
|
||||
for _ in range(3):
|
||||
s.place("2x4", 40)
|
||||
plan = build_cut_plan(s)
|
||||
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
|
||||
assert len(sticks) == 2
|
||||
assert sum(len(sp.placements) for sp in sticks) == 3
|
||||
assert plan.score["stock_count"] == 2
|
||||
assert validate_cut_plan(plan) == []
|
||||
|
||||
|
||||
def test_kerf_prevents_two_48_in_one_stick():
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 48)
|
||||
assert build_cut_plan(s).score["stock_count"] == 2
|
||||
|
||||
|
||||
def test_tenon_extends_cut_item_length():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.add_feature("p1", "tenon", face="end_b", depth_in=2)
|
||||
item = build_cut_plan(s).items[0]
|
||||
assert item.length_in == 26 and "tenon" in item.note
|
||||
|
||||
|
||||
def test_plywood_plan_and_validate():
|
||||
s = Scene()
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
plan = build_cut_plan(s)
|
||||
sheets = [sp for sp in plan.stock_pieces if sp.is_sheet]
|
||||
assert len(sheets) == 1 and len(sheets[0].placements) == 2
|
||||
assert validate_cut_plan(plan) == []
|
||||
|
||||
|
||||
def test_oversize_lumber_warns_and_is_unplaced():
|
||||
s = Scene()
|
||||
s.place("2x4", 120) # longer than a 96" stick
|
||||
plan = build_cut_plan(s)
|
||||
assert plan.unplaced and plan.warnings
|
||||
assert validate_cut_plan(plan) == [] # flagged, so still valid
|
||||
|
||||
|
||||
def test_stable_ids_present():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
plan = build_cut_plan(s)
|
||||
assert all(it.id for it in plan.items)
|
||||
assert all(sp.id for sp in plan.stock_pieces)
|
||||
assert all(p.id for sp in plan.stock_pieces for p in sp.placements)
|
||||
|
||||
|
||||
def test_json_roundtrip():
|
||||
s = Scene()
|
||||
s.place("2x4", 40)
|
||||
s.place("ply-3/4", 40, width_in=20)
|
||||
plan = build_cut_plan(s)
|
||||
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
|
||||
assert plan2.settings.kerf_in == plan.settings.kerf_in
|
||||
assert [sp.id for sp in plan2.stock_pieces] == [sp.id for sp in plan.stock_pieces]
|
||||
assert plan2.score["stock_count"] == plan.score["stock_count"]
|
||||
assert validate_cut_plan(plan2) == []
|
||||
|
||||
|
||||
def test_custom_settings_kerf():
|
||||
s = Scene()
|
||||
s.place("2x4", 48)
|
||||
s.place("2x4", 48)
|
||||
# zero kerf -> both 48" fit in one 96" stick
|
||||
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1
|
||||
Loading…
Reference in New Issue