Unify CLI/voice cut list onto the CutPlan (single source of truth)

format_cutlist now renders from build_cut_plan(scene) via a new
format_plan_cutlist(plan), instead of its own board-feet/shopping math. The CLI,
voice, and BOM window all read the same CutPlan now, so they can't disagree —
and kerf, sanding allowance, species, offcut reuse, and unplaced warnings all
surface in the text cut list automatically.

- cut_rows/shopping/board_feet kept (still used elsewhere) but no longer back
  the text renderer.
- Output shape preserved (CUT LIST / Total / SHOPPING) plus species labels,
  rough→final lines, and a WON'T FIT section.
- tests: CLI shopping counts == BOM CutPlan counts (kerf case), species +
  sanding surfaced, unplaced flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 22:06:49 -03:00
parent 970b88bc7b
commit b9b0871ac3
2 changed files with 89 additions and 13 deletions

View File

@ -74,29 +74,72 @@ def shopping(scene: Scene) -> dict[str, int]:
def format_cutlist(scene: Scene) -> str: 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: if not scene.parts:
return "Nothing to cut yet — the scene is empty." 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"] lines = ["CUT LIST"]
for r in rows: groups = Counter((it.stock, it.material, it.is_sheet,
if r["plywood"]: round(it.length_in, 2), round(it.width_in, 2),
lines.append(f" {r['count']:>2} × {r['stock']:<7} {_fmt_len(r['width_in'])} × " round(it.final_len, 2), round(it.final_wid, 2)) for it in plan.items)
f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)") 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: else:
lines.append(f" {r['count']:>2} × {r['stock']:<7} @ {_fmt_len(r['length_in']):<8}" bf = board_feet(stock, ln) * n
f" ({r['board_feet']:.1f} bd-ft)") total_bf += bf
total_bf = sum(r.get("board_feet", 0) for r in rows) row = f" {n:>2} × {name:<9} @ {_fmt_len(ln):<8}"
total_sf = sum(r.get("sq_ft", 0) for r in rows) extra = f"({bf:.1f} bd-ft)"
tot = f"{len(scene.parts)} board(s)" 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: if total_bf:
tot += f", {total_bf:.1f} board-feet" tot += f", {total_bf:.1f} board-feet"
if total_sf: if total_sf:
tot += f", {total_sf:.1f} sq ft plywood" tot += f", {total_sf:.1f} sq ft plywood"
lines.append(" Total: " + tot) 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)") 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)") 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)" 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) return "\n".join(lines)

View File

@ -66,3 +66,36 @@ def test_cut_feature_does_not_change_cut_length():
s.place("2x4", 24) s.place("2x4", 24)
s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5) 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 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)