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) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-29 16:04:20 -03:00
parent 70f8e9f0a2
commit aabf289562
2 changed files with 34 additions and 3 deletions

View File

@ -29,6 +29,15 @@ def board_feet(stock: str, length_in: float) -> float:
return t * w * length_in / 144.0 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: def _fmt_len(inches: float) -> str:
feet, rem = divmod(round(inches, 2), 12) feet, rem = divmod(round(inches, 2), 12)
if feet and rem: if feet and rem:
@ -39,10 +48,10 @@ def _fmt_len(inches: float) -> str:
def cut_rows(scene: Scene) -> list[dict]: 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) groups: dict[tuple[str, float], int] = defaultdict(int)
for p in scene.parts: for p in scene.parts:
groups[(p.stock, round(p.length_in, 2))] += 1 groups[(p.stock, round(cut_length(p), 2))] += 1
rows = [] rows = []
for (stock, length), count in sorted(groups.items()): for (stock, length), count in sorted(groups.items()):
rows.append({ 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).""" """Sticks of standard length to buy per stock (by total length, +10% waste)."""
total: dict[str, float] = defaultdict(float) total: dict[str, float] = defaultdict(float)
for p in scene.parts: 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) return {stock: math.ceil(length * 1.10 / STICK_LENGTH_IN)
for stock, length in sorted(total.items())} for stock, length in sorted(total.items())}
@ -71,6 +80,8 @@ def format_cutlist(scene: Scene) -> str:
f" ({r['board_feet']:.1f} bd-ft)") f" ({r['board_feet']:.1f} bd-ft)")
total_bf = sum(r["board_feet"] for r in rows) total_bf = sum(r["board_feet"] for r in rows)
lines.append(f" Total: {len(scene.parts)} board(s), {total_bf:.1f} board-feet") 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)") lines.append("SHOPPING (8' sticks, +10% waste)")
for stock, sticks in shopping(scene).items(): for stock, sticks in shopping(scene).items():
lines.append(f" {sticks} × {stock}") lines.append(f" {sticks} × {stock}")

View File

@ -36,3 +36,23 @@ def test_shopping_rounds_up_with_waste():
def test_empty_shopping(): def test_empty_shopping():
assert shopping(Scene()) == {} 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