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:
parent
882b0ec959
commit
7adb7e27fc
|
|
@ -36,7 +36,7 @@ class ShopSettings:
|
||||||
grain_direction: bool = False # honor grain (future; disables rotation)
|
grain_direction: bool = False # honor grain (future; disables rotation)
|
||||||
# tolerances — defaults present from day one even before they're in the UI
|
# tolerances — defaults present from day one even before they're in the UI
|
||||||
mortise_tenon_clearance_in: float = 1 / 32
|
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
|
reveal_in: float = 0.0
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
|
|
@ -53,10 +53,24 @@ class CutItem:
|
||||||
id: str
|
id: str
|
||||||
part_id: str
|
part_id: str
|
||||||
stock: str
|
stock: str
|
||||||
length_in: float
|
length_in: float # ROUGH cut size (what you cut from stock)
|
||||||
width_in: float
|
width_in: float
|
||||||
is_sheet: bool
|
is_sheet: bool
|
||||||
note: str = "" # e.g. "incl. tenon"
|
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
|
@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 = []
|
items = []
|
||||||
for n, p in enumerate(scene.parts, 1):
|
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(
|
items.append(CutItem(
|
||||||
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
||||||
length_in=round(ln, 3), width_in=round(p.section_in[1], 3),
|
length_in=rough_len, width_in=rough_wid,
|
||||||
is_sheet=is_plywood(p.stock),
|
final_length_in=final_len, final_width_in=final_wid,
|
||||||
note="incl. tenon" if ln > p.length_in + _EPS else ""))
|
is_sheet=sheet, note=note))
|
||||||
return items
|
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,
|
def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
strategy: str = "decreasing") -> CutPlan:
|
strategy: str = "decreasing") -> CutPlan:
|
||||||
s = settings or ShopSettings()
|
s = settings or ShopSettings()
|
||||||
items = _cut_items(scene)
|
items = _cut_items(scene, s)
|
||||||
by_id = {it.id: it for it in items}
|
by_id = {it.id: it for it in items}
|
||||||
|
|
||||||
counter = {"n": 0}
|
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
|
are packed into the free space around locked ones first (free segments on
|
||||||
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
|
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
|
||||||
s = base_plan.settings
|
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 = [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}
|
locked_ids = {p.item_id for p in locked}
|
||||||
counter = {"n": 0}
|
counter = {"n": 0}
|
||||||
|
|
|
||||||
|
|
@ -126,16 +126,25 @@ class BomWindow(QDialog):
|
||||||
|
|
||||||
def _cut_text(self) -> str:
|
def _cut_text(self) -> str:
|
||||||
plan = self._plan
|
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)
|
for it in plan.items)
|
||||||
lines = ["CUT LIST", ""]
|
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:
|
if sheet:
|
||||||
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
|
size = f"{_fmt_len(wd)} × {_fmt_len(ln)}"
|
||||||
f" ({wd * ln / 144 * n:.1f} sq ft)")
|
extra = f"({wd * ln / 144 * n:.1f} sq ft)"
|
||||||
else:
|
else:
|
||||||
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
|
size = f"@ {_fmt_len(ln)}"
|
||||||
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
|
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:
|
if not plan.items:
|
||||||
lines.append(" (nothing to cut yet)")
|
lines.append(" (nothing to cut yet)")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -290,3 +290,41 @@ def test_custom_settings_kerf():
|
||||||
s.place("2x4", 48)
|
s.place("2x4", 48)
|
||||||
# zero kerf -> both 48" fit in one 96" stick
|
# zero kerf -> both 48" fit in one 96" stick
|
||||||
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue