From 7adb7e27fc9aeb1c857872105921d68be8982df9 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 19:08:05 -0300 Subject: [PATCH] Phase 3: manufacturing allowance in CutPlan (rough vs final) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/woodshop/cutplan.py | 46 +++++++++++++++++++++++++++------- src/woodshop/gui/bom_window.py | 21 +++++++++++----- tests/test_cutplan.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index f7cb89d..f121339 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -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} diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 4abc597..fa9aae2 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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) diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index e46302e..d30e118 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -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