diff --git a/src/woodshop/cutlist.py b/src/woodshop/cutlist.py index 7eb88c6..0a8f55d 100644 --- a/src/woodshop/cutlist.py +++ b/src/woodshop/cutlist.py @@ -74,29 +74,72 @@ def shopping(scene: Scene) -> dict[str, int]: def format_cutlist(scene: Scene) -> str: + """The text cut list / shopping summary for the CLI and voice. Rendered from + the SAME CutPlan the BOM window uses, so the two never disagree.""" if not scene.parts: return "Nothing to cut yet — the scene is empty." - rows = cut_rows(scene) + from .cutplan import build_cut_plan + return format_plan_cutlist(build_cut_plan(scene)) + + +def _named(stock: str, material: str) -> str: + from .lumber import default_material + return f"{material} {stock}" if material and material != default_material(stock) else stock + + +def format_plan_cutlist(plan) -> str: + """Render a CutPlan as the text cut list + shopping summary (single source of + truth shared with the BOM window — honours kerf, sanding allowance, species, + batch quantity, and offcut reuse automatically).""" + from collections import Counter + lines = ["CUT LIST"] - for r in rows: - if r["plywood"]: - lines.append(f" {r['count']:>2} × {r['stock']:<7} {_fmt_len(r['width_in'])} × " - f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)") + groups = Counter((it.stock, it.material, it.is_sheet, + round(it.length_in, 2), round(it.width_in, 2), + round(it.final_len, 2), round(it.final_wid, 2)) for it in plan.items) + total_bf = total_sf = 0.0 + has_allow = False + for (stock, mat, sheet, ln, wd, fl, fw), n in sorted(groups.items()): + name = _named(stock, mat) + if sheet: + sf = ln * wd / 144.0 * n + total_sf += sf + row = f" {n:>2} × {name:<9} {_fmt_len(wd)} × {_fmt_len(ln)}" + extra = f"({sf:.1f} sq ft)" else: - lines.append(f" {r['count']:>2} × {r['stock']:<7} @ {_fmt_len(r['length_in']):<8}" - f" ({r['board_feet']:.1f} bd-ft)") - total_bf = sum(r.get("board_feet", 0) for r in rows) - total_sf = sum(r.get("sq_ft", 0) for r in rows) - tot = f"{len(scene.parts)} board(s)" + bf = board_feet(stock, ln) * n + total_bf += bf + row = f" {n:>2} × {name:<9} @ {_fmt_len(ln):<8}" + extra = f"({bf:.1f} bd-ft)" + if (ln, wd) != (fl, fw): # finished oversize — show final + has_allow = True + final = f"{_fmt_len(fw)} × {_fmt_len(fl)}" if sheet else f"@ {_fmt_len(fl)}" + row += f" → final {final}" + lines.append(f"{row} {extra}") + + tot = f"{len(plan.items)} board(s)" if total_bf: tot += f", {total_bf:.1f} board-feet" if total_sf: tot += f", {total_sf:.1f} sq ft plywood" lines.append(" Total: " + tot) - if any(cut_length(p) > p.length_in for p in scene.parts): + if any("tenon" in it.note for it in plan.items): lines.append(" (cut lengths include protruding tenons)") + if has_allow: + lines.append(f" (cut sizes include a {_fmt_len(plan.settings.sanding_allowance_in)}" + " sanding allowance — sand to final)") + lines.append("SHOPPING (8' sticks / 4×8 sheets, kerf-aware nesting)") - for stock, qty in shopping(scene).items(): + bought = [sp for sp in plan.stock_pieces if not getattr(sp, "owned", False)] + for (stock, mat), qty in sorted(Counter((sp.stock, sp.material) for sp in bought).items()): unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)" - lines.append(f" {qty} × {stock} {unit}") + lines.append(f" {qty} × {_named(stock, mat)} {unit}") + owned = [sp for sp in plan.stock_pieces if getattr(sp, "owned", False)] + if owned: + lines.append(f" (+ {len(owned)} offcut(s) from shop inventory)") + if plan.unplaced: + lines.append("WON'T FIT standard stock — source / cut specially:") + for iid in plan.unplaced: + it = plan.item(iid) + lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}") return "\n".join(lines) diff --git a/tests/test_cutlist.py b/tests/test_cutlist.py index 43cb34f..8423809 100644 --- a/tests/test_cutlist.py +++ b/tests/test_cutlist.py @@ -66,3 +66,36 @@ def test_cut_feature_does_not_change_cut_length(): s.place("2x4", 24) s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5) assert cut_length(s.get_part("p1")) == 24 # cuts don't reduce stock you buy + + +def test_cli_cutlist_matches_bom_shopping_counts(): + """The CLI cut list now renders from the same CutPlan the BOM window uses, + so kerf-aware shopping counts agree.""" + from collections import Counter + + from woodshop.cutlist import format_cutlist + from woodshop.cutplan import build_cut_plan + s = Scene() + for _ in range(5): + s.place("2x4", 50) # 50" pieces: kerf prevents 2 per 96" stick + plan = build_cut_plan(s) + counts = Counter(sp.stock for sp in plan.stock_pieces) + assert f"{counts['2x4']} × 2x4 stick(s)" in format_cutlist(s) + + +def test_cli_cutlist_shows_species_and_sanding(): + from woodshop.cutlist import format_cutlist + s = Scene() + s.place("1x4", 24) + s.set_material("p1", "oak") + s.set_finish("p1", "sanded") + text = format_cutlist(s) + assert "oak 1x4" in text # species surfaced + assert "sand to final" in text # sanding allowance surfaced + + +def test_cli_cutlist_flags_unplaced(): + from woodshop.cutlist import format_cutlist + s = Scene() + s.place("2x4", 200) # longer than a stick + assert "WON'T FIT" in format_cutlist(s)