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:
rob 2026-05-30 14:25:45 -03:00
parent 3643aac50d
commit 77444c546a
5 changed files with 551 additions and 136 deletions

75
SHOP_PACKET_PLAN.md Normal file
View File

@ -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 |

306
src/woodshop/cutplan.py Normal file
View File

@ -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

View File

@ -11,7 +11,8 @@ 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 ..layout import nest_lumber, nest_plywood, waste_summary from ..cutplan import build_cut_plan
from ..layout import waste_summary
_ORDERS = ["decreasing", "increasing", "shuffle"] _ORDERS = ["decreasing", "increasing", "shuffle"]
_PX = 7.0 # pixels per inch in the layout view _PX = 7.0 # pixels per inch in the layout view
@ -105,31 +106,45 @@ class BomWindow(QDialog):
def _draw_layout(self) -> None: def _draw_layout(self) -> None:
self.scene.clear() 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} 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). sc = plan.score
for stock, sticks in nest_lumber(self.c.scene, order=order).items(): self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock piece(s) · "
for i, st in enumerate(sticks): f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable offcut(s)")
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
# 2D plywood: each sheet a rectangle with placed panels. n = m = 0
for stock, sheets in nest_plywood(self.c.scene, order=order).items(): for sp in plan.stock_pieces: # lumber sticks first
for i, sh in enumerate(sheets): if sp.is_sheet:
sw, sl = sh["sheet"] continue
self._label(0, y - 15, f"{stock} sheet {i + 1} ({_fmt_len(sw)}×{_fmt_len(sl)})") n += 1
self._rect(0, y, sl * px, sw * px, _WASTE, "") self._label(0, y - 15, f"{sp.stock} stick {n}")
for pid, x, yy, pl, pw in sh["placements"]: for p in sp.placements:
self._rect(x * px, y + yy * px, pl * px, pw * px, _PIECE, names[pid]) self._rect(p.x_in * px, y, p.len_in * px, bar, _PIECE,
y += sw * px + 34 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()) self.view.setSceneRect(self.scene.itemsBoundingRect())
def _rect(self, x, y, w, h, color, text) -> None: def _rect(self, x, y, w, h, color, text) -> None:

View File

@ -1,122 +1,62 @@
"""Cutting-stock layout: pack the required pieces onto standard stock to estimate """Backwards-compatible nesting helpers — thin wrappers over cutplan.build_cut_plan.
waste and drive the cut-layout view.
- Lumber is 1D: pack piece lengths into 8' sticks (first-fit-decreasing + kerf). The real packing now lives in cutplan.py (the CutPlan artifact). These keep the
- Plywood is 2D: shelf/guillotine packing of panels onto 4'×8' sheets. older dict-shaped APIs working for existing callers/tests.
Heuristics, not provably optimal 2D nesting is NP-hard but good, and the
ordering can be varied ("try another arrangement"). Pure logic, no GUI.
""" """
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import Counter
from .cutlist import cut_length from .cutplan import build_cut_plan
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
STICK_LEN = 96.0 # an 8' stick STICK_LEN = 96.0
KERF = 0.125 # saw kerf between cuts KERF = 0.125
def nest_lumber(scene, stick_len=STICK_LEN, kerf=KERF, order="decreasing") -> dict: def nest_lumber(scene, order="decreasing", **_) -> dict:
"""{stock: [ {pieces:[(part_id,len)], used, offcut}, ... ]} — sticks per stock.""" plan = build_cut_plan(scene, strategy=order)
by_stock: dict[str, list] = defaultdict(list) out: dict[str, list] = {}
for p in scene.parts: for sp in plan.stock_pieces:
if not is_plywood(p.stock): if sp.is_sheet:
by_stock[p.stock].append((p.id, round(cut_length(p), 3))) continue
pieces = [(plan.item(p.item_id).part_id, p.len_in) for p in sp.placements]
result = {} used = sum(p.len_in for p in sp.placements) + plan.settings.kerf_in * max(len(pieces) - 1, 0)
for stock, pieces in by_stock.items(): offcut = sp.waste[0].length_in if sp.waste else round(sp.length_in - used, 3)
pieces = _ordered(pieces, key=lambda x: x[1], how=order) out.setdefault(sp.stock, []).append(
sticks: list[dict] = [] {"pieces": pieces, "used": round(used, 3), "offcut": round(offcut, 3)})
for pid, ln in pieces: return out
for st in sticks:
need = ln + (kerf if st["pieces"] else 0)
if st["used"] + need <= stick_len + 1e-6: def nest_plywood(scene, order="decreasing", **_) -> dict:
st["pieces"].append((pid, ln)) plan = build_cut_plan(scene, strategy=order)
st["used"] += need out: dict[str, list] = {}
break for sp in plan.stock_pieces:
else: if not sp.is_sheet:
sticks.append({"pieces": [(pid, ln)], "used": ln}) continue
for st in sticks: placements = [(plan.item(p.item_id).part_id, p.x_in, p.y_in, p.len_in, p.wid_in)
st["offcut"] = round(stick_len - st["used"], 3) for p in sp.placements]
result[stock] = sticks out.setdefault(sp.stock, []).append(
return result {"placements": placements, "sheet": (sp.width_in, sp.length_in)})
return out
def nest_plywood(scene, sheet_w=SHEET_WIDTH_IN, sheet_l=SHEET_LENGTH_IN,
kerf=KERF, order="decreasing") -> dict: def stock_counts(scene, **_) -> dict:
"""{stock: [ {placements:[(id,x,y,w,h)], sheet:(w,l)}, ... ]}. return dict(Counter(sp.stock for sp in build_cut_plan(scene).stock_pieces))
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: def waste_summary(scene, **_) -> dict:
if is_plywood(p.stock): plan = build_cut_plan(scene)
by_stock[p.stock].append((p.id, round(cut_length(p), 3), round(p.section_in[1], 3))) out: dict[str, dict] = {}
for sp in plan.stock_pieces:
result = {} d = out.setdefault(sp.stock, {"bought": 0, "used": 0.0, "capacity": 0.0})
for stock, panels in by_stock.items(): d["bought"] += 1
panels = _ordered(panels, key=lambda x: x[2], how=order) # tallest (width) first if sp.is_sheet:
rest = list(panels) d["used"] += sum(p.len_in * p.wid_in for p in sp.placements) / 144
sheets = [] d["capacity"] += sp.length_in * sp.width_in / 144
while rest: else:
placed, rest = _pack_sheet(rest, sheet_w, sheet_l, kerf) d["used"] += sum(p.len_in for p in sp.placements)
if not placed: # a single panel bigger than a sheet d["capacity"] += sp.length_in
pid, pl, pw = rest.pop(0) for d in out.values():
placed = [(pid, 0.0, 0.0, min(pl, sheet_l), min(pw, sheet_w))] d["used"] = round(d["used"], 1)
sheets.append({"placements": placed, "sheet": (sheet_w, sheet_l)}) d["capacity"] = round(d["capacity"], 1)
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)}
return out return out

79
tests/test_cutplan.py Normal file
View File

@ -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