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)
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:

View File

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

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