diff --git a/CLAUDE.md b/CLAUDE.md index 656cfc0..3bc0901 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,12 @@ operations + interpreter: Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`. Named projects: `~/.local/share/woodshop/projects/.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 diff --git a/scripts/gen_wood_tools.py b/scripts/gen_wood_tools.py index 6469eee..f97da55 100644 --- a/scripts/gen_wood_tools.py +++ b/scripts/gen_wood_tools.py @@ -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'.", diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 64bacc3..4f921da 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -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) diff --git a/src/woodshop/cutlist.py b/src/woodshop/cutlist.py index ade7dc6..86695c3 100644 --- a/src/woodshop/cutlist.py +++ b/src/woodshop/cutlist.py @@ -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}" - 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 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.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) diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index a377f48..53d0bce 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -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". diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py index 6e8ad63..b10b1aa 100644 --- a/src/woodshop/gui/controller.py +++ b/src/woodshop/gui/controller.py @@ -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") diff --git a/src/woodshop/gui/panels.py b/src/woodshop/gui/panels.py index cf79364..7c40946 100644 --- a/src/woodshop/gui/panels.py +++ b/src/woodshop/gui/panels.py @@ -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: diff --git a/src/woodshop/lumber.py b/src/woodshop/lumber.py index 83a7a47..3adcc76 100644 --- a/src/woodshop/lumber.py +++ b/src/woodshop/lumber.py @@ -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-"; 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] diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py index a13404b..3a66105 100644 --- a/src/woodshop/scene.py +++ b/src/woodshop/scene.py @@ -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,10 +377,16 @@ 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) - 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}" self._next_part += 1 part = Part(id=pid, stock=stock, length_in=float(length_in), section_in=section) diff --git a/tests/test_cutlist.py b/tests/test_cutlist.py index a42ffb0..43cb34f 100644 --- a/tests/test_cutlist.py +++ b/tests/test_cutlist.py @@ -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() diff --git a/tests/test_scene.py b/tests/test_scene.py index 28f963c..60eb2bb 100644 --- a/tests/test_scene.py +++ b/tests/test_scene.py @@ -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)