From 77444c546a35e0a9f2814ee5307272309037a414 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 14:25:45 -0300 Subject: [PATCH] Phase 0: CutPlan model (deterministic shop-output artifact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- SHOP_PACKET_PLAN.md | 75 ++++++++ src/woodshop/cutplan.py | 306 +++++++++++++++++++++++++++++++++ src/woodshop/gui/bom_window.py | 61 ++++--- src/woodshop/layout.py | 166 ++++++------------ tests/test_cutplan.py | 79 +++++++++ 5 files changed, 551 insertions(+), 136 deletions(-) create mode 100644 SHOP_PACKET_PLAN.md create mode 100644 src/woodshop/cutplan.py create mode 100644 tests/test_cutplan.py diff --git a/SHOP_PACKET_PLAN.md b/SHOP_PACKET_PLAN.md new file mode 100644 index 0000000..36af3f6 --- /dev/null +++ b/SHOP_PACKET_PLAN.md @@ -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 | diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py new file mode 100644 index 0000000..79ce29c --- /dev/null +++ b/src/woodshop/cutplan.py @@ -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 diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 7173972..42ee6a0 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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: diff --git a/src/woodshop/layout.py b/src/woodshop/layout.py index 927e6af..37dc7f8 100644 --- a/src/woodshop/layout.py +++ b/src/woodshop/layout.py @@ -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 diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py new file mode 100644 index 0000000..5061537 --- /dev/null +++ b/tests/test_cutplan.py @@ -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