Phase 3: manufacturing allowance in CutPlan (rough vs final)

Sanding never shrinks the design model; instead the cut plan distinguishes the
rough size you cut from the final size you sand to.

- CutItem gains final_length_in/final_width_in (+ final_len/final_wid/
  has_allowance helpers); length_in/width_in are the ROUGH cut size.
- _cut_items(scene, settings): a finished (finish != raw) board is cut oversize
  by sanding_allowance_in on dimensions actually CUT — length always, width only
  for sheet goods. Dimensional lumber's section width is the stock as delivered,
  not padded (Rob's point). note gains "sand to final".
- ShopSettings.sanding_allowance_in default 1/32"; serialization additive.
- BOM cut list shows "Cut … → final …" and a sanding-allowance footnote.
- tests: raw = no allowance, sanded lumber pads length only, plywood pads width
  too, allowance roundtrips in plan JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:08:05 -03:00
parent 882b0ec959
commit 7adb7e27fc
3 changed files with 90 additions and 15 deletions

View File

@ -36,7 +36,7 @@ class ShopSettings:
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
sanding_allowance_in: float = 1 / 32 # finished parts cut this much oversize
reveal_in: float = 0.0
def to_dict(self) -> dict:
@ -53,10 +53,24 @@ class CutItem:
id: str
part_id: str
stock: str
length_in: float
length_in: float # ROUGH cut size (what you cut from stock)
width_in: float
is_sheet: bool
note: str = "" # e.g. "incl. tenon"
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
final_width_in: float = 0.0
@property
def final_len(self) -> float:
return self.final_length_in or self.length_in
@property
def final_wid(self) -> float:
return self.final_width_in or self.width_in
@property
def has_allowance(self) -> bool:
return (self.length_in - self.final_len > _EPS) or (self.width_in - self.final_wid > _EPS)
@dataclass
@ -134,15 +148,29 @@ class CutPlan:
# --------------------------------------------------------------------------
def _cut_items(scene) -> list:
def _cut_items(scene, settings: "ShopSettings | None" = None) -> list:
"""Build the cut demand from the scene. A finished (non-raw) board is cut
slightly oversize and sanded to final: the allowance is added to dimensions
we actually CUT length always; width only for sheet goods (a panel we rip).
Dimensional lumber's section width is the stock as delivered — not padded."""
s = settings or ShopSettings()
allow = s.sanding_allowance_in
items = []
for n, p in enumerate(scene.parts, 1):
ln = cut_length(p)
final_len = round(cut_length(p), 3)
final_wid = round(p.section_in[1], 3)
sheet = is_plywood(p.stock)
finished = getattr(p, "finish", "raw") != "raw"
rough_len = round(final_len + allow, 3) if finished else final_len
rough_wid = round(final_wid + allow, 3) if (finished and sheet) else final_wid
note = "incl. tenon" if cut_length(p) > p.length_in + _EPS else ""
if finished and allow > 0:
note = (note + "; " if note else "") + "sand to final"
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 ""))
length_in=rough_len, width_in=rough_wid,
final_length_in=final_len, final_width_in=final_wid,
is_sheet=sheet, note=note))
return items
@ -405,7 +433,7 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
def build_cut_plan(scene, settings: ShopSettings | None = None,
strategy: str = "decreasing") -> CutPlan:
s = settings or ShopSettings()
items = _cut_items(scene)
items = _cut_items(scene, s)
by_id = {it.id: it for it in items}
counter = {"n": 0}
@ -489,7 +517,7 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
are packed into the free space around locked ones first (free segments on
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
s = base_plan.settings
items = _cut_items(scene)
items = _cut_items(scene, s)
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
locked_ids = {p.item_id for p in locked}
counter = {"n": 0}

View File

@ -126,16 +126,25 @@ class BomWindow(QDialog):
def _cut_text(self) -> str:
plan = self._plan
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2), it.is_sheet)
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2),
round(it.final_len, 2), round(it.final_wid, 2), it.is_sheet)
for it in plan.items)
lines = ["CUT LIST", ""]
for (stock, ln, wd, sheet), n in sorted(groups.items()):
for (stock, ln, wd, fl, fw, sheet), n in sorted(groups.items()):
if sheet:
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
f" ({wd * ln / 144 * n:.1f} sq ft)")
size = f"{_fmt_len(wd)} × {_fmt_len(ln)}"
extra = f"({wd * ln / 144 * n:.1f} sq ft)"
else:
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
size = f"@ {_fmt_len(ln)}"
extra = f"({board_feet(stock, ln) * n:.1f} bd-ft)"
row = f" {n:>2} × {stock:<8} {size}"
if (ln, wd) != (fl, fw): # finished oversize — show final
final = f"{_fmt_len(fw)} × {_fmt_len(fl)}" if sheet else f"@ {_fmt_len(fl)}"
row += f" → final {final}"
lines.append(f"{row:<46} {extra}")
if any(it.has_allowance for it in plan.items):
lines += ["", f" (cut sizes include a {_fmt_len(plan.settings.sanding_allowance_in)}"
" sanding allowance — sand to final)"]
if not plan.items:
lines.append(" (nothing to cut yet)")
return "\n".join(lines)

View File

@ -290,3 +290,41 @@ def test_custom_settings_kerf():
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
def test_raw_part_has_no_allowance():
s = Scene()
s.place("2x4", 24)
it = build_cut_plan(s).items[0]
assert it.length_in == 24 and it.final_len == 24 and not it.has_allowance
def test_sanded_lumber_cut_oversize_in_length_only():
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "sanded")
plan = build_cut_plan(s, settings=ShopSettings(sanding_allowance_in=1 / 16))
it = plan.items[0]
assert abs(it.length_in - 24.0625) < 0.01 # length padded ~1/16"
assert it.final_len == 24.0
assert it.width_in == it.final_wid # lumber width NOT padded
assert it.has_allowance
def test_sanded_plywood_pads_width_too():
s = Scene()
s.place("ply-3/4", 24, width_in=12)
s.set_finish("p1", "paint")
plan = build_cut_plan(s, settings=ShopSettings(sanding_allowance_in=1 / 16))
it = plan.items[0]
assert it.length_in > it.final_len and it.width_in > it.final_wid
def test_allowance_roundtrips_in_plan_json():
import json
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "sanded")
plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
assert plan2.items[0].final_len == plan.items[0].final_len