Add plywood (sheet stock)
Plywood is sheet stock — fixed thickness, width cut per-panel. lumber.py adds ply-1/8…ply-3/4 specs + is_plywood/plywood_thickness and normalizes "3/4 plywood" -> "ply-3/4". scene.place(stock, length, width_in=) requires a width for plywood (lumber ignores it). Cut list reports plywood in sq-ft and buys it in 4×8 sheets (lumber stays board-feet / 8' sticks). Wired through CLI (place --width), voice (wood-place width arg + prompt note), and the Parts-tab manual add (plywood in the dropdown + a width field enabled for plywood). Geometry/export/render work unchanged (section = thickness×width). 91 tests pass; verified a plywood top renders as a thin panel and exports to STEP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
52be9ee5b1
commit
e4f9cedf4a
|
|
@ -75,6 +75,12 @@ operations + interpreter:
|
||||||
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
|
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
|
||||||
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
|
Named projects: `~/.local/share/woodshop/projects/<slug>.json`.
|
||||||
|
|
||||||
|
Stock is dimensional lumber (`lumber.NOMINAL_TO_ACTUAL`, fixed cross-section) or
|
||||||
|
**plywood** sheet stock (`ply-3/4`, `ply-1/2`, …): fixed thickness, but a width
|
||||||
|
given per-panel — `place(stock, length, width_in=)` requires a width for plywood.
|
||||||
|
The cut list reports plywood in sq-ft and buys it in 4×8 sheets (lumber stays
|
||||||
|
board-feet / 8' sticks).
|
||||||
|
|
||||||
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
|
Parts have full 3D orientation (`yaw_deg`/`tilt_deg`/`roll_deg`) so legs and
|
||||||
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
|
uprights stand vertically. Parts can be referred to by id (`p1`) or by a name
|
||||||
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick
|
set with `rename`. The cut list (`cutlist.py`) reports board-feet and an 8'-stick
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,16 @@ def code(body: str) -> str:
|
||||||
|
|
||||||
TOOLS = {
|
TOOLS = {
|
||||||
"wood-place": {
|
"wood-place": {
|
||||||
"description": "Place a new board of dimensional lumber. Use for 'place', 'add', 'put', 'grab', 'cut me a' board.",
|
"description": "Place a new board of dimensional lumber, or a plywood panel. Use for 'place', 'add', 'put', 'grab', 'cut me a' board/panel.",
|
||||||
"arguments": [
|
"arguments": [
|
||||||
{"flag": "--stock", "variable": "stock", "description": "Nominal lumber size, e.g. 2x4, 2x6, 1x4, 4x4"},
|
{"flag": "--stock", "variable": "stock", "description": "Lumber size e.g. 2x4, 2x6, 1x4, 4x4; or plywood e.g. ply-3/4, ply-1/2, ply-1/4"},
|
||||||
{"flag": "--length", "variable": "length", "description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
|
{"flag": "--length", "variable": "length", "description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
|
||||||
|
{"flag": "--width", "variable": "width", "default": "", "description": "Panel width (REQUIRED for plywood, ignored for lumber), e.g. '24 in'"},
|
||||||
],
|
],
|
||||||
"code": code('cmd = [ws, "place", stock, length]'),
|
"code": code(
|
||||||
|
'cmd = [ws, "place", stock, length]\n'
|
||||||
|
'if width != "": cmd += ["--width", str(width)]'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"wood-join": {
|
"wood-join": {
|
||||||
"description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.",
|
"description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.",
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ def _fmt_len(inches: float) -> str:
|
||||||
|
|
||||||
def cmd_place(scene: Scene, args) -> str:
|
def cmd_place(scene: Scene, args) -> str:
|
||||||
length = to_inches(args.length, default_unit=args.unit)
|
length = to_inches(args.length, default_unit=args.unit)
|
||||||
part = scene.place(args.stock, length)
|
width = to_inches(args.width, default_unit=args.unit) if getattr(args, "width", None) else None
|
||||||
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}."
|
part = scene.place(args.stock, length, width_in=width)
|
||||||
|
extra = f" ({_fmt_len(part.section_in[1])} wide)" if width else ""
|
||||||
|
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}{extra}."
|
||||||
|
|
||||||
|
|
||||||
_ANCHOR_ALIASES = {
|
_ANCHOR_ALIASES = {
|
||||||
|
|
@ -275,8 +277,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
sub = p.add_subparsers(dest="command", required=False)
|
sub = p.add_subparsers(dest="command", required=False)
|
||||||
|
|
||||||
sp = sub.add_parser("place", help="Place a new board")
|
sp = sub.add_parser("place", help="Place a new board")
|
||||||
sp.add_argument("stock", help="Nominal stock, e.g. 2x4")
|
sp.add_argument("stock", help="Nominal stock, e.g. 2x4, or plywood like ply-3/4")
|
||||||
sp.add_argument("length", help="Length, e.g. '6 ft' or '72'")
|
sp.add_argument("length", help="Length, e.g. '6 ft' or '72'")
|
||||||
|
sp.add_argument("--width", help="Panel width (required for plywood), e.g. '24 in'")
|
||||||
sp.add_argument("--unit", default="inch", help="Default unit for bare numbers (inch|foot)")
|
sp.add_argument("--unit", default="inch", help="Default unit for bare numbers (inch|foot)")
|
||||||
sp.set_defaults(func=cmd_place)
|
sp.set_defaults(func=cmd_place)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,26 +48,39 @@ 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, cut-length), with a count."""
|
"""One row per distinct (stock, length, width), with a count. Lumber rows
|
||||||
groups: dict[tuple[str, float], int] = defaultdict(int)
|
carry board_feet; plywood rows carry sq_ft (it's a cut panel)."""
|
||||||
|
from .lumber import is_plywood
|
||||||
|
groups: dict[tuple, int] = defaultdict(int)
|
||||||
for p in scene.parts:
|
for p in scene.parts:
|
||||||
groups[(p.stock, round(cut_length(p), 2))] += 1
|
groups[(p.stock, round(cut_length(p), 2), round(p.section_in[1], 2))] += 1
|
||||||
rows = []
|
rows = []
|
||||||
for (stock, length), count in sorted(groups.items()):
|
for (stock, length, width), count in sorted(groups.items()):
|
||||||
rows.append({
|
row = {"stock": stock, "length_in": length, "width_in": width,
|
||||||
"stock": stock, "length_in": length, "count": count,
|
"count": count, "plywood": is_plywood(stock)}
|
||||||
"board_feet": board_feet(stock, length) * count,
|
if row["plywood"]:
|
||||||
})
|
row["sq_ft"] = (length * width / 144.0) * count
|
||||||
|
else:
|
||||||
|
row["board_feet"] = board_feet(stock, length) * count
|
||||||
|
rows.append(row)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def shopping(scene: Scene) -> dict[str, int]:
|
def shopping(scene: Scene) -> dict[str, int]:
|
||||||
"""Sticks of standard length to buy per stock (by total length, +10% waste)."""
|
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets
|
||||||
total: dict[str, float] = defaultdict(float)
|
(by total length / area, +10% waste)."""
|
||||||
|
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
|
||||||
|
lumber: dict[str, float] = defaultdict(float)
|
||||||
|
ply_area: dict[str, float] = defaultdict(float)
|
||||||
for p in scene.parts:
|
for p in scene.parts:
|
||||||
total[p.stock] += cut_length(p)
|
if is_plywood(p.stock):
|
||||||
return {stock: math.ceil(length * 1.10 / STICK_LENGTH_IN)
|
ply_area[p.stock] += cut_length(p) * p.section_in[1]
|
||||||
for stock, length in sorted(total.items())}
|
else:
|
||||||
|
lumber[p.stock] += cut_length(p)
|
||||||
|
out = {s: math.ceil(L * 1.10 / STICK_LENGTH_IN) for s, L in sorted(lumber.items())}
|
||||||
|
sheet = SHEET_WIDTH_IN * SHEET_LENGTH_IN
|
||||||
|
out.update({s: math.ceil(a * 1.10 / sheet) for s, a in sorted(ply_area.items())})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def format_cutlist(scene: Scene) -> str:
|
def format_cutlist(scene: Scene) -> str:
|
||||||
|
|
@ -76,13 +89,24 @@ def format_cutlist(scene: Scene) -> str:
|
||||||
rows = cut_rows(scene)
|
rows = cut_rows(scene)
|
||||||
lines = ["CUT LIST"]
|
lines = ["CUT LIST"]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
lines.append(f" {r['count']:>2} × {r['stock']:<5} @ {_fmt_len(r['length_in']):<8}"
|
if r["plywood"]:
|
||||||
f" ({r['board_feet']:.1f} bd-ft)")
|
lines.append(f" {r['count']:>2} × {r['stock']:<7} {_fmt_len(r['width_in'])} × "
|
||||||
total_bf = sum(r["board_feet"] for r in rows)
|
f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)")
|
||||||
lines.append(f" Total: {len(scene.parts)} board(s), {total_bf:.1f} board-feet")
|
else:
|
||||||
|
lines.append(f" {r['count']:>2} × {r['stock']:<7} @ {_fmt_len(r['length_in']):<8}"
|
||||||
|
f" ({r['board_feet']:.1f} bd-ft)")
|
||||||
|
total_bf = sum(r.get("board_feet", 0) for r in rows)
|
||||||
|
total_sf = sum(r.get("sq_ft", 0) for r in rows)
|
||||||
|
tot = f"{len(scene.parts)} board(s)"
|
||||||
|
if total_bf:
|
||||||
|
tot += f", {total_bf:.1f} board-feet"
|
||||||
|
if total_sf:
|
||||||
|
tot += f", {total_sf:.1f} sq ft plywood"
|
||||||
|
lines.append(" Total: " + tot)
|
||||||
if any(cut_length(p) > p.length_in for p in scene.parts):
|
if any(cut_length(p) > p.length_in for p in scene.parts):
|
||||||
lines.append(" (cut lengths include protruding tenons)")
|
lines.append(" (cut lengths include protruding tenons)")
|
||||||
lines.append("SHOPPING (8' sticks, +10% waste)")
|
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)")
|
||||||
for stock, sticks in shopping(scene).items():
|
for stock, qty in shopping(scene).items():
|
||||||
lines.append(f" {sticks} × {stock}")
|
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
|
||||||
|
lines.append(f" {qty} × {stock} {unit}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,9 @@ Rules:
|
||||||
equals the target board's x-min). Fix any "Interpenetrating" pairs the same way.
|
equals the target board's x-min). Fix any "Interpenetrating" pairs the same way.
|
||||||
- "these" / "them" / "the selected ones" refer to the currently-selected boards
|
- "these" / "them" / "the selected ones" refer to the currently-selected boards
|
||||||
listed under the scene; emit one call per selected board (e.g. wood-move for each).
|
listed under the scene; emit one call per selected board (e.g. wood-move for each).
|
||||||
|
- Plywood is sheet stock named like 'ply-3/4' (¾" thick), 'ply-1/2', 'ply-1/4'.
|
||||||
|
When placing plywood you MUST give wood-place a width as well as a length
|
||||||
|
(e.g. a tabletop or cabinet back). Lumber ignores width.
|
||||||
- Legs and uprights must be stood up: place the board, then wood-stand it.
|
- Legs and uprights must be stood up: place the board, then wood-stand it.
|
||||||
- For wood-join, "part_b" is the board being attached (it gets moved into place);
|
- For wood-join, "part_b" is the board being attached (it gets moved into place);
|
||||||
"to" is the board it attaches to. Anchor is "end" (far end) or "start".
|
"to" is the board it attaches to. Anchor is "end" (far end) or "start".
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ def _opt(v):
|
||||||
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
|
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
|
||||||
# command functions means voice and CLI share one implementation.
|
# command functions means voice and CLI share one implementation.
|
||||||
TOOL_CMD = {
|
TOOL_CMD = {
|
||||||
"wood-place": lambda a: (cli.cmd_place, SimpleNamespace(stock=a["stock"], length=a["length"], unit="inch")),
|
"wood-place": lambda a: (cli.cmd_place, SimpleNamespace(
|
||||||
|
stock=a["stock"], length=a["length"], width=_opt(a.get("width")), unit="inch")),
|
||||||
"wood-join": lambda a: (cli.cmd_join, SimpleNamespace(
|
"wood-join": lambda a: (cli.cmd_join, SimpleNamespace(
|
||||||
part_b=a["part_b"], part_a=_opt(a.get("to")), angle=float(a.get("angle") or 90),
|
part_b=a["part_b"], part_a=_opt(a.get("to")), angle=float(a.get("angle") or 90),
|
||||||
offset=_opt(a.get("offset")), anchor=a.get("anchor") or "end_b", unit="inch")),
|
offset=_opt(a.get("offset")), anchor=a.get("anchor") or "end_b", unit="inch")),
|
||||||
|
|
@ -168,8 +169,8 @@ class Controller(QObject):
|
||||||
n = len(ids)
|
n = len(ids)
|
||||||
self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.")
|
self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.")
|
||||||
|
|
||||||
def place(self, stock: str, length_in: float):
|
def place(self, stock: str, length_in: float, width_in: float | None = None):
|
||||||
self._do(lambda: f"Placed {self.scene.place(stock, length_in).id}.")
|
self._do(lambda: f"Placed {self.scene.place(stock, length_in, width_in).id}.")
|
||||||
|
|
||||||
# group-aware (act on the whole selection)
|
# group-aware (act on the whole selection)
|
||||||
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
|
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox,
|
||||||
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
|
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
|
||||||
QTreeWidgetItem, QVBoxLayout, QWidget)
|
QTreeWidgetItem, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from ..lumber import NOMINAL_TO_ACTUAL
|
from ..lumber import NOMINAL_TO_ACTUAL, PLYWOOD_FRACTIONS, is_plywood
|
||||||
from .controller import Controller
|
from .controller import Controller
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,13 +25,19 @@ class PartsPanel(QWidget):
|
||||||
# Quick manual add — no AI needed.
|
# Quick manual add — no AI needed.
|
||||||
add = QHBoxLayout()
|
add = QHBoxLayout()
|
||||||
self.stock = QComboBox()
|
self.stock = QComboBox()
|
||||||
self.stock.addItems(sorted(NOMINAL_TO_ACTUAL, key=lambda s: (s[0], len(s), s)))
|
self.stock.addItems(sorted(NOMINAL_TO_ACTUAL, key=lambda s: (s[0], len(s), s))
|
||||||
|
+ [f"ply-{f}" for f in PLYWOOD_FRACTIONS])
|
||||||
self.stock.setCurrentText("2x4")
|
self.stock.setCurrentText("2x4")
|
||||||
|
self.stock.currentTextChanged.connect(self._stock_changed)
|
||||||
self.add_len = QDoubleSpinBox(); self.add_len.setRange(0.5, 480)
|
self.add_len = QDoubleSpinBox(); self.add_len.setRange(0.5, 480)
|
||||||
self.add_len.setValue(24); self.add_len.setSuffix(" in")
|
self.add_len.setValue(24); self.add_len.setSuffix(" in")
|
||||||
add_btn = QPushButton("+ Add board")
|
self.add_wid = QDoubleSpinBox(); self.add_wid.setRange(0.5, 96)
|
||||||
|
self.add_wid.setValue(24); self.add_wid.setPrefix("w "); self.add_wid.setSuffix(" in")
|
||||||
|
self.add_wid.setToolTip("Panel width (plywood)"); self.add_wid.setEnabled(False)
|
||||||
|
add_btn = QPushButton("+ Add")
|
||||||
add_btn.clicked.connect(self._add_board)
|
add_btn.clicked.connect(self._add_board)
|
||||||
add.addWidget(self.stock); add.addWidget(self.add_len); add.addWidget(add_btn)
|
add.addWidget(self.stock); add.addWidget(self.add_len)
|
||||||
|
add.addWidget(self.add_wid); add.addWidget(add_btn)
|
||||||
root.addLayout(add)
|
root.addLayout(add)
|
||||||
|
|
||||||
self.tree = QTreeWidget()
|
self.tree = QTreeWidget()
|
||||||
|
|
@ -140,8 +146,13 @@ class PartsPanel(QWidget):
|
||||||
ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())]
|
ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())]
|
||||||
return list(dict.fromkeys(ids))
|
return list(dict.fromkeys(ids))
|
||||||
|
|
||||||
|
def _stock_changed(self, stock: str) -> None:
|
||||||
|
self.add_wid.setEnabled(is_plywood(stock)) # width only matters for plywood
|
||||||
|
|
||||||
def _add_board(self) -> None:
|
def _add_board(self) -> None:
|
||||||
self.c.place(self.stock.currentText(), self.add_len.value())
|
stock = self.stock.currentText()
|
||||||
|
width = self.add_wid.value() if is_plywood(stock) else None
|
||||||
|
self.c.place(stock, self.add_len.value(), width)
|
||||||
|
|
||||||
def _on_row_selected(self) -> None:
|
def _on_row_selected(self) -> None:
|
||||||
if self._loading:
|
if self._loading:
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,41 @@ NOMINAL_TO_ACTUAL: dict[str, tuple[float, float]] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Plywood is sheet stock: a fixed thickness, cut to any width × length. Canonical
|
||||||
|
# stock name is "ply-<fraction>"; standard sheet is 4' × 8'.
|
||||||
|
PLYWOOD_FRACTIONS = ("1/8", "1/4", "3/8", "1/2", "5/8", "3/4")
|
||||||
|
SHEET_WIDTH_IN, SHEET_LENGTH_IN = 48.0, 96.0
|
||||||
|
|
||||||
|
|
||||||
def normalize_stock(stock: str) -> str:
|
def normalize_stock(stock: str) -> str:
|
||||||
"""Canonicalize a spoken/typed stock name, e.g. '2 x 4' or '2X4' -> '2x4'."""
|
"""Canonicalize a stock name: '2 x 4' -> '2x4'; '3/4 plywood' -> 'ply-3/4'."""
|
||||||
return stock.strip().lower().replace(" ", "").replace("by", "x")
|
s = stock.strip().lower()
|
||||||
|
if "ply" in s:
|
||||||
|
for frac in PLYWOOD_FRACTIONS:
|
||||||
|
if frac in s:
|
||||||
|
return f"ply-{frac}"
|
||||||
|
return "ply-3/4" # bare "plywood" defaults to 3/4"
|
||||||
|
return s.replace(" ", "").replace("by", "x")
|
||||||
|
|
||||||
|
|
||||||
|
def is_plywood(stock: str) -> bool:
|
||||||
|
return normalize_stock(stock).startswith("ply-")
|
||||||
|
|
||||||
|
|
||||||
|
def plywood_thickness(stock: str) -> float:
|
||||||
|
num, den = normalize_stock(stock).split("-", 1)[1].split("/")
|
||||||
|
return float(num) / float(den)
|
||||||
|
|
||||||
|
|
||||||
def actual_section(stock: str) -> tuple[float, float]:
|
def actual_section(stock: str) -> tuple[float, float]:
|
||||||
"""Return the (thickness, width) in inches for a nominal stock name.
|
"""Return the (thickness, width) in inches for a nominal lumber stock name.
|
||||||
|
|
||||||
Raises KeyError with the list of known stock if unknown.
|
Raises KeyError with the list of known stock if unknown. (Plywood is handled
|
||||||
|
separately — its width is per-panel, not fixed by the stock.)
|
||||||
"""
|
"""
|
||||||
key = normalize_stock(stock)
|
key = normalize_stock(stock)
|
||||||
if key not in NOMINAL_TO_ACTUAL:
|
if key not in NOMINAL_TO_ACTUAL:
|
||||||
known = ", ".join(sorted(NOMINAL_TO_ACTUAL))
|
known = ", ".join(sorted(NOMINAL_TO_ACTUAL)) + ", " + \
|
||||||
|
", ".join(f"ply-{f}" for f in PLYWOOD_FRACTIONS)
|
||||||
raise KeyError(f"Unknown stock {stock!r}. Known stock: {known}")
|
raise KeyError(f"Unknown stock {stock!r}. Known stock: {known}")
|
||||||
return NOMINAL_TO_ACTUAL[key]
|
return NOMINAL_TO_ACTUAL[key]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field, fields, asdict
|
from dataclasses import dataclass, field, fields, asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .lumber import actual_section, normalize_stock
|
from .lumber import actual_section, is_plywood, normalize_stock, plywood_thickness
|
||||||
|
|
||||||
SCENE_VERSION = 1
|
SCENE_VERSION = 1
|
||||||
|
|
||||||
|
|
@ -377,10 +377,16 @@ class Scene:
|
||||||
return part
|
return part
|
||||||
|
|
||||||
# ----- operations ---------------------------------------------------
|
# ----- operations ---------------------------------------------------
|
||||||
def place(self, stock: str, length_in: float) -> Part:
|
def place(self, stock: str, length_in: float, width_in: float | None = None) -> Part:
|
||||||
self._checkpoint()
|
self._checkpoint()
|
||||||
stock = normalize_stock(stock)
|
stock = normalize_stock(stock)
|
||||||
section = actual_section(stock)
|
if is_plywood(stock):
|
||||||
|
if not width_in:
|
||||||
|
raise SceneError("Plywood is sheet stock — give it a width too "
|
||||||
|
"(e.g. a 24 inch wide panel).")
|
||||||
|
section = (plywood_thickness(stock), float(width_in))
|
||||||
|
else:
|
||||||
|
section = actual_section(stock)
|
||||||
pid = f"p{self._next_part}"
|
pid = f"p{self._next_part}"
|
||||||
self._next_part += 1
|
self._next_part += 1
|
||||||
part = Part(id=pid, stock=stock, length_in=float(length_in), section_in=section)
|
part = Part(id=pid, stock=stock, length_in=float(length_in), section_in=section)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,16 @@ def test_end_tenon_extends_cut_length():
|
||||||
assert cut_rows(s)[0]["board_feet"] == pytest.approx(board_feet("2x4", 25.5))
|
assert cut_rows(s)[0]["board_feet"] == pytest.approx(board_feet("2x4", 25.5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_plywood_uses_sqft_and_sheets():
|
||||||
|
from woodshop.cutlist import cut_rows, shopping, format_cutlist
|
||||||
|
s = Scene()
|
||||||
|
s.place("ply-3/4", 48, width_in=24) # 48 × 24 = 8 sq ft
|
||||||
|
row = cut_rows(s)[0]
|
||||||
|
assert row["plywood"] and row["sq_ft"] == pytest.approx(8.0)
|
||||||
|
assert shopping(s)["ply-3/4"] == 1 # well under a 32 sq-ft sheet
|
||||||
|
assert "sq ft" in format_cutlist(s) and "sheet" in format_cutlist(s)
|
||||||
|
|
||||||
|
|
||||||
def test_cut_feature_does_not_change_cut_length():
|
def test_cut_feature_does_not_change_cut_length():
|
||||||
from woodshop.cutlist import cut_length
|
from woodshop.cutlist import cut_length
|
||||||
s = Scene()
|
s = Scene()
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,23 @@ def test_spatial_summary_flags_overlap():
|
||||||
assert "p1&p2" not in spatial_summary(s)
|
assert "p1&p2" not in spatial_summary(s)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plywood_normalize_and_place():
|
||||||
|
from woodshop.lumber import normalize_stock, is_plywood, plywood_thickness
|
||||||
|
assert normalize_stock("3/4 plywood") == "ply-3/4"
|
||||||
|
assert normalize_stock("plywood") == "ply-3/4"
|
||||||
|
assert is_plywood("ply-1/2") and plywood_thickness("ply-1/2") == 0.5
|
||||||
|
s = Scene()
|
||||||
|
p = s.place("3/4 plywood", 48, width_in=24)
|
||||||
|
assert p.stock == "ply-3/4"
|
||||||
|
assert p.section_in == (0.75, 24.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plywood_requires_width():
|
||||||
|
s = Scene()
|
||||||
|
with pytest.raises(SceneError, match="width"):
|
||||||
|
s.place("ply-1/2", 48)
|
||||||
|
|
||||||
|
|
||||||
def test_clear():
|
def test_clear():
|
||||||
s = Scene()
|
s = Scene()
|
||||||
s.place("2x4", 24)
|
s.place("2x4", 24)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue