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:
parent
970b88bc7b
commit
b9b0871ac3
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue