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:
parent
70f8e9f0a2
commit
aabf289562
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue