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:
rob 2026-05-30 12:33:49 -03:00
parent 52be9ee5b1
commit e4f9cedf4a
11 changed files with 150 additions and 42 deletions

View File

@ -75,6 +75,12 @@ operations + interpreter:
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.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
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

View File

@ -27,12 +27,16 @@ def code(body: str) -> str:
TOOLS = {
"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": [
{"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": "--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": {
"description": "Attach one board to another at an angle, optionally offset along the target. Use for 'attach', 'join', 'connect', 'fasten'.",

View File

@ -25,8 +25,10 @@ def _fmt_len(inches: float) -> str:
def cmd_place(scene: Scene, args) -> str:
length = to_inches(args.length, default_unit=args.unit)
part = scene.place(args.stock, length)
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}."
width = to_inches(args.width, default_unit=args.unit) if getattr(args, "width", None) else None
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 = {
@ -275,8 +277,9 @@ def build_parser() -> argparse.ArgumentParser:
sub = p.add_subparsers(dest="command", required=False)
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("--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.set_defaults(func=cmd_place)

View File

@ -48,26 +48,39 @@ def _fmt_len(inches: float) -> str:
def cut_rows(scene: Scene) -> list[dict]:
"""One row per distinct (stock, cut-length), with a count."""
groups: dict[tuple[str, float], int] = defaultdict(int)
"""One row per distinct (stock, length, width), with a count. Lumber rows
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:
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 = []
for (stock, length), count in sorted(groups.items()):
rows.append({
"stock": stock, "length_in": length, "count": count,
"board_feet": board_feet(stock, length) * count,
})
for (stock, length, width), count in sorted(groups.items()):
row = {"stock": stock, "length_in": length, "width_in": width,
"count": count, "plywood": is_plywood(stock)}
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
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)
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets
(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:
total[p.stock] += cut_length(p)
return {stock: math.ceil(length * 1.10 / STICK_LENGTH_IN)
for stock, length in sorted(total.items())}
if is_plywood(p.stock):
ply_area[p.stock] += cut_length(p) * p.section_in[1]
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:
@ -76,13 +89,24 @@ def format_cutlist(scene: Scene) -> str:
rows = cut_rows(scene)
lines = ["CUT LIST"]
for r in rows:
lines.append(f" {r['count']:>2} × {r['stock']:<5} @ {_fmt_len(r['length_in']):<8}"
if r["plywood"]:
lines.append(f" {r['count']:>2} × {r['stock']:<7} {_fmt_len(r['width_in'])} × "
f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)")
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["board_feet"] for r in rows)
lines.append(f" Total: {len(scene.parts)} board(s), {total_bf:.1f} board-feet")
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):
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}")
lines.append("SHOPPING (8' sticks / 4×8 sheets, +10% waste)")
for stock, qty in shopping(scene).items():
unit = "sheet(s)" if stock.startswith("ply-") else "stick(s)"
lines.append(f" {qty} × {stock} {unit}")
return "\n".join(lines)

View File

@ -67,6 +67,9 @@ Rules:
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
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.
- 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".

View File

@ -31,7 +31,8 @@ def _opt(v):
# Map each wood-* tool to (cli command fn, namespace builder). Reusing the CLI
# command functions means voice and CLI share one implementation.
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(
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")),
@ -168,8 +169,8 @@ class Controller(QObject):
n = len(ids)
self._commit(f"{verb} {n} board{'s' if n > 1 else ''}.")
def place(self, stock: str, length_in: float):
self._do(lambda: f"Placed {self.scene.place(stock, length_in).id}.")
def place(self, stock: str, length_in: float, width_in: float | None = None):
self._do(lambda: f"Placed {self.scene.place(stock, length_in, width_in).id}.")
# group-aware (act on the whole selection)
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")

View File

@ -9,7 +9,7 @@ from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox,
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget)
from ..lumber import NOMINAL_TO_ACTUAL
from ..lumber import NOMINAL_TO_ACTUAL, PLYWOOD_FRACTIONS, is_plywood
from .controller import Controller
@ -25,13 +25,19 @@ class PartsPanel(QWidget):
# Quick manual add — no AI needed.
add = QHBoxLayout()
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.currentTextChanged.connect(self._stock_changed)
self.add_len = QDoubleSpinBox(); self.add_len.setRange(0.5, 480)
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.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)
self.tree = QTreeWidget()
@ -140,8 +146,13 @@ class PartsPanel(QWidget):
ids += [it.child(i).data(0, Qt.UserRole) for i in range(it.childCount())]
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:
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:
if self._loading:

View File

@ -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:
"""Canonicalize a spoken/typed stock name, e.g. '2 x 4' or '2X4' -> '2x4'."""
return stock.strip().lower().replace(" ", "").replace("by", "x")
"""Canonicalize a stock name: '2 x 4' -> '2x4'; '3/4 plywood' -> 'ply-3/4'."""
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]:
"""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)
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}")
return NOMINAL_TO_ACTUAL[key]

View File

@ -23,7 +23,7 @@ from contextlib import contextmanager
from dataclasses import dataclass, field, fields, asdict
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
@ -377,9 +377,15 @@ class Scene:
return part
# ----- 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()
stock = normalize_stock(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}"
self._next_part += 1

View File

@ -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))
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():
from woodshop.cutlist import cut_length
s = Scene()

View File

@ -341,6 +341,23 @@ def test_spatial_summary_flags_overlap():
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():
s = Scene()
s.place("2x4", 24)