From aabf2895626b5ff412d5ac4f010042f3318f1c87 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 16:04:20 -0300 Subject: [PATCH] Cut list accounts for protruding tenons A tenon adds length beyond the board's end, so the real piece you cut is longer than length_in. cutlist.cut_length() now adds end-tenon protrusions to the cut length used by the cut list, board-feet, and the buy-list (subtractive features like mortises/holes don't change the stock you buy, so they're ignored). The cut list notes when lengths include tenons. 70 tests pass (end-tenon extends cut length; cut features don't). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/cutlist.py | 17 ++++++++++++++--- tests/test_cutlist.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/woodshop/cutlist.py b/src/woodshop/cutlist.py index 8692344..ade7dc6 100644 --- a/src/woodshop/cutlist.py +++ b/src/woodshop/cutlist.py @@ -29,6 +29,15 @@ def board_feet(stock: str, length_in: float) -> float: return t * w * length_in / 144.0 +def cut_length(part) -> float: + """The length to cut the board to, including any tenon that protrudes past an + end. Subtractive features (mortise/hole/slot/chamfer) don't change the stock + length you buy, so they're ignored here.""" + extra = sum(f.depth_in for f in part.features + if f.kind == "tenon" and f.face in ("end_a", "end_b")) + return part.length_in + extra + + def _fmt_len(inches: float) -> str: feet, rem = divmod(round(inches, 2), 12) if feet and rem: @@ -39,10 +48,10 @@ def _fmt_len(inches: float) -> str: def cut_rows(scene: Scene) -> list[dict]: - """One row per distinct (stock, length), with a count.""" + """One row per distinct (stock, cut-length), with a count.""" groups: dict[tuple[str, float], int] = defaultdict(int) for p in scene.parts: - groups[(p.stock, round(p.length_in, 2))] += 1 + groups[(p.stock, round(cut_length(p), 2))] += 1 rows = [] for (stock, length), count in sorted(groups.items()): rows.append({ @@ -56,7 +65,7 @@ def shopping(scene: Scene) -> dict[str, int]: """Sticks of standard length to buy per stock (by total length, +10% waste).""" total: dict[str, float] = defaultdict(float) for p in scene.parts: - total[p.stock] += p.length_in + total[p.stock] += cut_length(p) return {stock: math.ceil(length * 1.10 / STICK_LENGTH_IN) for stock, length in sorted(total.items())} @@ -71,6 +80,8 @@ def format_cutlist(scene: Scene) -> str: f" ({r['board_feet']:.1f} bd-ft)") total_bf = sum(r["board_feet"] for r in rows) lines.append(f" Total: {len(scene.parts)} board(s), {total_bf:.1f} board-feet") + if any(cut_length(p) > p.length_in for p in scene.parts): + lines.append(" (cut lengths include protruding tenons)") lines.append("SHOPPING (8' sticks, +10% waste)") for stock, sticks in shopping(scene).items(): lines.append(f" {sticks} × {stock}") diff --git a/tests/test_cutlist.py b/tests/test_cutlist.py index ab32f43..a42ffb0 100644 --- a/tests/test_cutlist.py +++ b/tests/test_cutlist.py @@ -36,3 +36,23 @@ def test_shopping_rounds_up_with_waste(): def test_empty_shopping(): assert shopping(Scene()) == {} + + +def test_end_tenon_extends_cut_length(): + from woodshop.cutlist import cut_length + s = Scene() + s.place("2x4", 24) + assert cut_length(s.get_part("p1")) == 24 + s.add_feature("p1", "tenon", face="end_b", depth_in=1.5) # protrudes 1.5" + assert cut_length(s.get_part("p1")) == 25.5 + # the cut row and board-feet reflect the longer piece + assert cut_rows(s)[0]["length_in"] == 25.5 + assert cut_rows(s)[0]["board_feet"] == pytest.approx(board_feet("2x4", 25.5)) + + +def test_cut_feature_does_not_change_cut_length(): + from woodshop.cutlist import cut_length + s = Scene() + s.place("2x4", 24) + 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