diff --git a/MATERIALS_INVENTORY_PLAN.md b/MATERIALS_INVENTORY_PLAN.md
new file mode 100644
index 0000000..9268087
--- /dev/null
+++ b/MATERIALS_INVENTORY_PLAN.md
@@ -0,0 +1,197 @@
+# Materials, Finish, Batch Builds & Shop Inventory — Plan (v2, reconciled)
+
+Design plan for four related features, reconciled after a Codex review.
+Nothing built yet. v2 changes vs v1 are marked **[v2]**.
+
+## What changed in v2 (after Codex review)
+- **[v2] Sanding never shrinks the design model.** Part dims = final/intended.
+ Sanding becomes a *manufacturing allowance* in the CutPlan (rough vs final),
+ not a scene mutation. (Codex #1.)
+- **[v2] Rob's stock-reality refinement:** the allowance applies to dimensions we
+ actually **cut** — length always, width only for rips/sheet goods. Dimensional
+ lumber's fixed section (a 2x4 is 1.5×3.5 as delivered) is NOT padded. Final
+ dims remain the truth for fit in v1.
+- **[v2] Finish is first-class** (`material` + `finish` enum + `finish_color`),
+ layered: stock shape → material/species → finish → visual → cost. A painted
+ pine board is still pine for buying/cutting. (Codex #2, #9.) Single enum now,
+ sheen later (simpler than Codex's kind+sheen split).
+- **[v2] Inventory is an append-only event ledger**; current state is derived.
+ (Codex #5.) Kept deliberately lean — minimal events, no undo/branching, raw
+ events never shown to the user.
+- **[v2] Offcuts are real stock pieces** behind one `AvailableStock` interface the
+ planner consumes (reusing the seeded-packing from lock-aware reopt). (Codex #6.)
+- **[v2] Sequence reordered** per Codex: materials/finish first; allowance and
+ batch as CutPlan features; inventory last (model → workflows → window).
+
+## Guiding principles
+- Deterministic math, AI only for narrative. Easy, intuitive, useful — no
+ complexity for its own sake.
+- Additive, backward-compatible serialization (loader filters by known fields).
+- Physical state (stock, offcuts, builds) is **shop-wide**, in the data dir.
+- Reuse existing machinery (`_pack_lumber_seeded`, `_pack_plywood_seeded`,
+ `_free_rects_sheet`).
+
+## Data locations
+- Scenes: `$XDG_DATA_HOME/woodshop/` (per project).
+- Prices + estimate rates: `$XDG_CONFIG_HOME/woodshop/{prices,estimate}.json`.
+- **New** ledger: `$XDG_DATA_HOME/woodshop/inventory.json` (shop-wide, append-only).
+
+---
+
+## Phase 1 — Material + finish fields + color resolver
+
+**Goal:** parts carry a material and a finish; the viewer colors by them; sanded
+raw wood reads lighter, painted boards show their color. Flat colors v1 (PyVista
+textures are a future option).
+
+**Data model (additive on `Part`):**
+- `material: str = ""` — species/sheet: spruce, pine, oak, walnut, maple, birch,
+ mdf, spruce-ply. Default derived from stock (`lumber.py`).
+- `finish: str = "raw"` — one of `raw | sanded | clear | stain | paint`.
+- `finish_color: str = ""` — hex, for paint/stain.
+- **[v2] Migration:** old scenes store `finishes: ["sanded"]`; `from_dict` maps a
+ non-empty list → `finish="sanded"` (or "paint" if a color is present).
+
+**Color resolver (viewer), layered stock→material→finish→visual:**
+- base = `MATERIAL_COLORS[material]` if set else positional palette (fallback).
+- `paint` → `finish_color`; `stain` → blend base toward `finish_color`/darker;
+ `clear` → base slightly richer; `sanded` → base ~15% lighter; `raw` → base.
+- **Per-part subtle tint** (±~5% lightness keyed by `_stable_hash(id)`) so
+ same-material boards stay distinguishable. Selection highlight unchanged.
+
+**Scene ops / UI:** `scene.set_material(ref, m)`, `scene.set_finish(ref, kind,
+color="")`. GUI: Paint button (QColorDialog) + Material & Finish dropdowns in the
+inspector; works on selection/group. CLI/voice: `wood-material`, `wood-finish`/
+`wood-paint`; voice color words → hex via a small name table.
+
+**Tests:** resolver priority; sanded lightening; tint determinism; finish/material
+persist + migrate; group ops.
+
+---
+
+## Phase 2 — Finish costs in the estimate
+
+**Goal:** finish/paint cost folds into the project estimate by **finish kind ×
+surface area**.
+
+**Design:** `EstimateRates` gains per-kind $/sq ft (`sanded` = abrasives only,
+`clear`, `stain`, `paint`). `project_estimate` adds a finish line per part using
+its `finish` + finished surface area. Labor already has a sanding/finishing line;
+extend its time to vary by finish kind (paint/stain take longer) — editable.
+
+**Tests:** paint vs clear vs raw produce expected finish $; editable rates flow
+through; zero for raw.
+
+---
+
+## Phase 3 — Manufacturing allowance in the CutPlan (rough vs final)
+
+**Goal:** the cut plan distinguishes the size you **cut** from the **final** size,
+so sanded/finished parts are cut slightly oversize where applicable.
+
+**Design:**
+- `CutItem` gains `final_length_in`, `final_width_in`; existing `length_in`/
+ `width_in` become the **rough cut** size.
+- For a part whose `finish != raw`: `rough_length = final + sanding_allowance`;
+ `rough_width = final + sanding_allowance` **only** for sheet goods / ripped
+ widths. **[v2]** Dimensional lumber at full nominal width: rough_width = stock
+ width (no add); fixed section thickness unchanged.
+- `ShopSettings.sanding_allowance_in` (already exists) default `1/32"`, editable.
+- Cut list shows both: `Cut 24 1/16" × 3 9/16" → final 24" × 3 1/2"`.
+- Nesting/packing uses the **rough** size (that's what you cut from stock).
+
+**Tests:** finished part's rough length = final + allowance; lumber width not
+padded; raw parts unchanged; cut list shows rough→final; packing uses rough.
+
+**Open Q (deferred):** compensating joinery fit for material lost to sanding
+(e.g. tenon thickness). v1 keeps final dims as the fit truth. Note as future.
+
+---
+
+## Phase 4 — Batch builds (quantity N)
+
+**Goal:** estimate N identical units, nesting all units together so offcuts carry
+across units → real per-unit cost.
+
+**Design:**
+- `build_cut_plan(..., quantity=1)` **[v2 confirmed]**: replicate **CutItems**,
+ not Parts — `ci_p3` → `ci_p3_u1, ci_p3_u2, …`. Label placements with unit #
+ ("Unit 2 – left leg") in the layout.
+- `project_estimate(..., quantity=N)`: materials from the N-unit plan;
+ **setup labor once per batch**, per-operation time and consumables ×N. Report
+ **total + per-unit** (total / N).
+- UI: a quantity spinner in the BOM header; every tab reflects it.
+
+**Tests:** qty=2 uses ≤ 2× sticks (often fewer via shared offcuts); per-unit =
+total/2; consumables/labor scale; setup once.
+
+---
+
+## Phase 5 — Inventory ledger model (event-sourced)
+
+**Goal:** the source of truth for shop stock/offcuts/builds.
+
+**Design [v2]:** append-only event log; derive current state (cache totals for
+speed). Lean event set:
+- `purchase {stock, qty, unit, price?, date}`
+- `consume {stock, qty, build_id}`
+- `create_offcut {offcut, build_id}`
+- `discard {offcut_id, fate: burned|trashed}`
+- `adjustment {…, reason}` (manual correction)
+- `build_recorded {project, units, cost_snapshot, date}`
+
+**`AvailableStock` interface [v2]:** standard stock and offcuts share one shape —
+`{stock, material, length_in, width_in, is_sheet, source, bin?, usable,
+reserved/consumed}`. The planner consumes both through this interface.
+
+**Tests:** fold events → correct on-hand & offcut bin; cache matches fold;
+adjustments apply; derived stats (units built, $ spent, waste split) correct.
+
+---
+
+## Phase 6 — Inventory workflows (the UX that matters)
+
+**Workflow-first [v2], not spreadsheet-first:**
+1. **Mark Purchased** — Shopping tab → "Add these to shop inventory?" → `purchase`
+ events. Optionally record price paid.
+2. **Record Build** — confirmation FIRST: shows *Consumed* (e.g. 2 × 2x4 8') and
+ *Offcuts created* (18", 26", 12×24 ply); each offcut → **Keep / Trash / Burn /
+ Ignore**. On confirm: emit consume/create_offcut/discard/build_recorded.
+3. **Use Shop Inventory** toggle in BOM — "Use available offcuts first" →
+ planner seeds from `AvailableStock` (reuses seeded-packing).
+
+**Tests:** purchase adds on-hand; record-build deducts + creates offcuts + logs;
+dispositions recorded; planner-with-offcuts uses ≤ as many new sticks.
+
+---
+
+## Phase 7 — Inventory window + stats (management view)
+
+A top-level **Inventory** window (menu: *Shop → Inventory*) as a management/review
+view, not the primary workflow. Tabs: On-hand, Offcut bin, Build history, Stats
+(units built per project, $ spent, material used, waste % kept/burned/trashed).
+Shop-wide.
+
+---
+
+## Locked sequence
+1. Material + finish fields + color resolver
+2. Finish costs in the estimate
+3. Manufacturing allowance in CutPlan (rough vs final) — with the lumber-section caveat
+4. Batch quantity in CutPlan
+5. Inventory ledger model (event-sourced)
+6. Purchase / Record-build / Use-offcuts workflows
+7. Inventory window + stats
+
+Each phase committed separately; phases 5–7 are the big block, built model-first.
+
+## Settled decisions (formerly open questions)
+- **Sanding:** rough-vs-final allowance on cut dims only; design dims stay final;
+ lumber section not padded. (Rob + Codex.)
+- **Finish:** single `finish` enum + color now; sheen later.
+- **Pricing:** stock-based in v1; price key designed to extend to (stock,
+ material, grade).
+- **Batch labor:** setup once per batch, per-op ×N.
+- **Inventory:** event-sourced source of truth; workflow-first UX; window second.
+- **Still genuinely open:** auto-update price book from recorded purchase prices
+ (opt-in?); offcut reuse default-on vs opt-in (leaning opt-in toggle).
diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py
index 4f921da..7e0876d 100644
--- a/src/woodshop/cli.py
+++ b/src/woodshop/cli.py
@@ -52,10 +52,25 @@ def cmd_join(scene: Scene, args) -> str:
def cmd_sand(scene: Scene, args) -> str:
- part = scene.finish(args.part, kind="sanded")
+ part = scene.set_finish(args.part, "sanded")
return f"Sanded {part.id}."
+def cmd_paint(scene: Scene, args) -> str:
+ part = scene.paint(args.part, args.color)
+ return f"Painted {part.id} {args.color}."
+
+
+def cmd_finish(scene: Scene, args) -> str:
+ part = scene.set_finish(args.part, args.kind, color=getattr(args, "color", "") or "")
+ return f"Finished {part.id}: {part.finish}."
+
+
+def cmd_material(scene: Scene, args) -> str:
+ part = scene.set_material(args.part, args.material)
+ return f"Set {part.id} material to {part.material}."
+
+
def cmd_delete(scene: Scene, args) -> str:
return scene.delete(args.part)
@@ -254,8 +269,8 @@ def _describe_part(p) -> str:
bits.append(f"tilt {p.tilt_deg:g}°")
if p.yaw_deg:
bits.append(f"yaw {p.yaw_deg:g}°")
- if p.finishes:
- bits.append(f"[{', '.join(p.finishes)}]")
+ if p.finish != "raw":
+ bits.append(f"[{p.finish}{' ' + p.finish_color if p.finish_color else ''}]")
if p.features:
bits.append(f"{{{', '.join(f.kind for f in p.features)}}}")
return f" {p.id}: " + ", ".join(bits)
@@ -297,6 +312,22 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
sp.set_defaults(func=cmd_sand)
+ sp = sub.add_parser("paint", help="Paint a board a color")
+ sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
+ sp.add_argument("--color", required=True, help="Hex or color name, e.g. #3366aa or navy")
+ sp.set_defaults(func=cmd_paint)
+
+ sp = sub.add_parser("finish", help="Set a board's finish")
+ sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
+ sp.add_argument("--kind", required=True, help="raw | sanded | clear | stain | paint")
+ sp.add_argument("--color", default="", help="Hex/name for paint or stain")
+ sp.set_defaults(func=cmd_finish)
+
+ sp = sub.add_parser("material", help="Set a board's material/species")
+ sp.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
+ sp.add_argument("--material", required=True, help="e.g. pine, oak, walnut, mdf")
+ sp.set_defaults(func=cmd_material)
+
sp = sub.add_parser("delete", help="Delete a board")
sp.add_argument("part", nargs="?", default=None)
sp.set_defaults(func=cmd_delete)
diff --git a/src/woodshop/colors.py b/src/woodshop/colors.py
new file mode 100644
index 0000000..584a30b
--- /dev/null
+++ b/src/woodshop/colors.py
@@ -0,0 +1,63 @@
+"""Tiny color helpers: name->hex (so voice/CLI can say "paint it navy") and the
+lightness blends the viewer uses for finishes and per-part tinting. Pure, no deps.
+"""
+from __future__ import annotations
+
+# Common paint color names -> hex. Not exhaustive; unknown names pass through if
+# they already look like a hex code, else default to a mid grey.
+NAMED: dict[str, str] = {
+ "white": "#f5f5f5", "black": "#222222", "grey": "#808080", "gray": "#808080",
+ "red": "#c0392b", "crimson": "#b01030", "maroon": "#7a1f1f",
+ "orange": "#e67e22", "amber": "#f0a020", "yellow": "#f1c40f",
+ "green": "#27ae60", "forest": "#1f6b3b", "olive": "#7a7a30", "mint": "#9fe0c0",
+ "blue": "#2e74c0", "navy": "#1f3a6b", "teal": "#1f8a8a", "sky": "#7fbfe6",
+ "purple": "#7d3c98", "violet": "#8e44ad", "lavender": "#c0a0e0",
+ "pink": "#e08aa8", "rose": "#d06080", "brown": "#6b4a2f", "tan": "#c8a06a",
+ "cream": "#efe6c8", "beige": "#e4d8b8", "charcoal": "#36393b",
+}
+
+
+def normalize_color(value: str) -> str:
+ """A color name or hex string -> a #rrggbb hex string."""
+ if not value:
+ return ""
+ s = value.strip().lower()
+ if s in NAMED:
+ return NAMED[s]
+ if s.startswith("#") and len(s) in (4, 7):
+ return s
+ if len(s) in (6,) and all(c in "0123456789abcdef" for c in s):
+ return "#" + s
+ return "#808080" # unknown name -> neutral grey, better than crashing
+
+
+def _hex_to_rgb(h: str) -> tuple[int, int, int]:
+ h = h.lstrip("#")
+ if len(h) == 3:
+ h = "".join(c * 2 for c in h)
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
+
+
+def _rgb_to_hex(rgb) -> str:
+ return "#" + "".join(f"{max(0, min(255, int(round(c)))):02x}" for c in rgb)
+
+
+def lighten(hex_color: str, amount: float) -> str:
+ """Blend a color toward white by `amount` (0..1)."""
+ r, g, b = _hex_to_rgb(hex_color)
+ return _rgb_to_hex((r + (255 - r) * amount, g + (255 - g) * amount,
+ b + (255 - b) * amount))
+
+
+def darken(hex_color: str, amount: float) -> str:
+ """Blend a color toward black by `amount` (0..1)."""
+ r, g, b = _hex_to_rgb(hex_color)
+ f = 1.0 - amount
+ return _rgb_to_hex((r * f, g * f, b * f))
+
+
+def blend(a: str, b: str, t: float) -> str:
+ """Blend hex color a toward b by t (0..1)."""
+ ra, ga, ba = _hex_to_rgb(a)
+ rb, gb, bb = _hex_to_rgb(b)
+ return _rgb_to_hex((ra + (rb - ra) * t, ga + (gb - ga) * t, ba + (bb - ba) * t))
diff --git a/src/woodshop/estimate.py b/src/woodshop/estimate.py
index e0deaf0..546c02c 100644
--- a/src/woodshop/estimate.py
+++ b/src/woodshop/estimate.py
@@ -90,7 +90,7 @@ def count_ops(scene, plan) -> dict:
"butt_joints": len(scene.joints),
"connections": len(scene.connections),
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS),
- "finished_parts": sum(1 for p in scene.parts if p.finishes),
+ "finished_parts": sum(1 for p in scene.parts if p.finish != "raw"),
"features": dict(feats),
}
@@ -98,7 +98,7 @@ def count_ops(scene, plan) -> dict:
def _finished_sqft(scene) -> float:
total = 0.0
for p in scene.parts:
- if not p.finishes:
+ if p.finish == "raw":
continue
t, w = p.section_in
L = p.length_in
diff --git a/src/woodshop/gui/controller.py b/src/woodshop/gui/controller.py
index b789a73..ec166cc 100644
--- a/src/woodshop/gui/controller.py
+++ b/src/woodshop/gui/controller.py
@@ -58,6 +58,11 @@ TOOL_CMD = {
"wood-assemble": lambda a: (cli.cmd_assemble, SimpleNamespace()),
"wood-disconnect": lambda a: (cli.cmd_disconnect, SimpleNamespace(connection=a["connection"])),
"wood-sand": lambda a: (cli.cmd_sand, SimpleNamespace(part=_opt(a.get("part")))),
+ "wood-paint": lambda a: (cli.cmd_paint, SimpleNamespace(part=_opt(a.get("part")), color=a["color"])),
+ "wood-finish": lambda a: (cli.cmd_finish, SimpleNamespace(
+ part=_opt(a.get("part")), kind=a["kind"], color=a.get("color") or "")),
+ "wood-material": lambda a: (cli.cmd_material, SimpleNamespace(
+ part=_opt(a.get("part")), material=a["material"])),
"wood-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
@@ -176,7 +181,12 @@ class Controller(QObject):
# group-aware (act on the whole selection)
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
def lay(self): self._do_group(lambda pid: self.scene.stand(pid, 0.0), "Laid flat")
- def sand(self): self._do_group(lambda pid: self.scene.finish(pid), "Sanded")
+ def sand(self): self._do_group(lambda pid: self.scene.set_finish(pid, "sanded"), "Sanded")
+ def paint(self, color): self._do_group(lambda pid: self.scene.paint(pid, color), "Painted")
+ def set_finish(self, kind, color=""):
+ self._do_group(lambda pid: self.scene.set_finish(pid, kind, color), "Finished")
+ def set_material(self, material):
+ self._do_group(lambda pid: self.scene.set_material(pid, material), "Material set")
def delete(self): self._do_group(lambda pid: self.scene.delete(pid), "Deleted")
def move_selected(self, dx=0.0, dy=0.0, dz=0.0):
diff --git a/src/woodshop/gui/panels.py b/src/woodshop/gui/panels.py
index 7c40946..845873b 100644
--- a/src/woodshop/gui/panels.py
+++ b/src/woodshop/gui/panels.py
@@ -4,12 +4,13 @@ that solves "delete that" ambiguity)."""
from __future__ import annotations
from PySide6.QtCore import Qt
-from PySide6.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox,
+from PySide6.QtGui import QColor
+from PySide6.QtWidgets import (QAbstractItemView, QColorDialog, QComboBox, QDoubleSpinBox,
QFormLayout, QGridLayout, QGroupBox, QHBoxLayout,
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget)
-from ..lumber import NOMINAL_TO_ACTUAL, PLYWOOD_FRACTIONS, is_plywood
+from ..lumber import MATERIAL_COLORS, NOMINAL_TO_ACTUAL, PLYWOOD_FRACTIONS, is_plywood
from .controller import Controller
@@ -59,6 +60,7 @@ class PartsPanel(QWidget):
actions = [
("Stand", lambda: self.c.stand()), ("Lay", lambda: self.c.lay()),
("Rotate 90°", lambda: self.c.rotate_90()), ("Sand", lambda: self.c.sand()),
+ ("Paint…", self._paint), ("Material…", self._set_material),
("Duplicate", lambda: self.c.duplicate()), ("Rename", self._rename),
("Delete", lambda: self.c.delete()),
]
@@ -119,9 +121,10 @@ class PartsPanel(QWidget):
part = self._selected_part()
if part:
ori = "vertical" if part.is_vertical else f"yaw {part.yaw_deg:g}°, tilt {part.tilt_deg:g}°"
- fin = f" · {', '.join(part.finishes)}" if part.finishes else ""
+ fin = f" · {part.finish}" if part.finish != "raw" else ""
+ mat = f" · {part.material}" if part.material else ""
self.detail.setText(f"{part.id}{' · ' + part.name if part.name else ''}
"
- f"{part.length_in:g}\" {part.stock} · {ori}{fin}")
+ f"{part.length_in:g}\" {part.stock}{mat} · {ori}{fin}")
self.len_spin.setValue(part.length_in)
self.yaw_spin.setValue(part.yaw_deg)
self.tilt_spin.setValue(part.tilt_deg)
@@ -183,6 +186,26 @@ class PartsPanel(QWidget):
if ok and name.strip():
self.c.rename(part.id, name.strip())
+ def _paint(self) -> None:
+ if not self._selected_part():
+ return
+ part = self._selected_part()
+ initial = QColor(part.finish_color) if part.finish_color else QColor("#3366aa")
+ color = QColorDialog.getColor(initial, self, "Paint color")
+ if color.isValid():
+ self.c.paint(color.name())
+
+ def _set_material(self) -> None:
+ part = self._selected_part()
+ if not part:
+ return
+ materials = sorted(MATERIAL_COLORS)
+ cur = part.material if part.material in materials else materials[0]
+ mat, ok = QInputDialog.getItem(self, "Material", "Species / sheet:",
+ materials, materials.index(cur), False)
+ if ok and mat:
+ self.c.set_material(mat)
+
def _apply_length(self) -> None:
part = self._selected_part()
if part and not self._loading and abs(self.len_spin.value() - part.length_in) > 1e-6:
diff --git a/src/woodshop/instructions.py b/src/woodshop/instructions.py
index f6aea9c..c8d7347 100644
--- a/src/woodshop/instructions.py
+++ b/src/woodshop/instructions.py
@@ -49,9 +49,16 @@ def build_steps(scene, plan=None) -> list:
if joinery:
sections.append(("Mark and cut the joinery", joinery))
- sanded = [names[p.id] for p in scene.parts if "sanded" in p.finishes]
- sections.append(("Sand", [f"Sand {', '.join(sanded)} smooth." if sanded
+ prepped = [names[p.id] for p in scene.parts if p.finish != "raw"]
+ sections.append(("Sand", [f"Sand {', '.join(prepped)} smooth." if prepped
else "Sand all parts smooth."]))
+ coats = []
+ for p in scene.parts:
+ if p.finish in ("paint", "stain", "clear"):
+ what = p.finish + (f" ({p.finish_color})" if p.finish_color else "")
+ coats.append(f"Apply {what} to {names[p.id]}.")
+ if coats:
+ sections.append(("Finish", coats))
asm = []
for c in scene.connections:
diff --git a/src/woodshop/lumber.py b/src/woodshop/lumber.py
index 3adcc76..becff8d 100644
--- a/src/woodshop/lumber.py
+++ b/src/woodshop/lumber.py
@@ -52,6 +52,29 @@ def is_plywood(stock: str) -> bool:
return normalize_stock(stock).startswith("ply-")
+# Known materials (species / sheet goods) and their base render colors (hex).
+# Lumber defaults to SPF; plywood to spruce-ply. Cosmetic in v1 (pricing stays
+# stock-based) — see MATERIALS_INVENTORY_PLAN.md.
+MATERIAL_COLORS: dict[str, str] = {
+ "spruce": "#d8b787", # SPF — the pale default
+ "pine": "#e3c896",
+ "fir": "#d2a96b",
+ "oak": "#c79a5b",
+ "maple": "#e6cfa0",
+ "birch": "#e8d6ad",
+ "walnut": "#6b4a2f",
+ "cherry": "#a9603f",
+ "cedar": "#c98a5e",
+ "mdf": "#b6a98f",
+ "spruce-ply": "#d9c08a",
+}
+
+
+def default_material(stock: str) -> str:
+ """The species/sheet a stock is by default: plywood -> spruce-ply, else SPF."""
+ return "spruce-ply" if is_plywood(stock) else "spruce"
+
+
def plywood_thickness(stock: str) -> float:
num, den = normalize_stock(stock).split("-", 1)[1].split("/")
return float(num) / float(den)
diff --git a/src/woodshop/scene.py b/src/woodshop/scene.py
index 3a66105..35899b9 100644
--- a/src/woodshop/scene.py
+++ b/src/woodshop/scene.py
@@ -23,7 +23,8 @@ from contextlib import contextmanager
from dataclasses import dataclass, field, fields, asdict
from pathlib import Path
-from .lumber import actual_section, is_plywood, normalize_stock, plywood_thickness
+from .lumber import (MATERIAL_COLORS, actual_section, default_material, is_plywood,
+ normalize_stock, plywood_thickness)
SCENE_VERSION = 1
@@ -137,6 +138,10 @@ EDGE_KINDS = {"chamfer"}
FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS
FACES = ("end_a", "end_b", "top", "bottom", "left", "right")
+# Surface treatments, in increasing order of work. Anything past "raw" implies
+# the board is sanded (so it gets the sanding allowance + lighter/finished look).
+FINISH_KINDS = ("raw", "sanded", "clear", "stain", "paint")
+
@dataclass
class Feature:
@@ -204,7 +209,9 @@ class Part:
tilt_deg: float = 0.0 # elevation from horizontal toward +Z (90 = standing up)
roll_deg: float = 0.0 # rotation about the board's own length axis
name: str = "" # optional human alias, e.g. "front-left leg"
- finishes: list[str] = field(default_factory=list)
+ material: str = "" # species/sheet; "" means derive from stock
+ finish: str = "raw" # surface treatment: raw | sanded | clear | stain | paint
+ finish_color: str = "" # hex, used by paint/stain
features: list[Feature] = field(default_factory=list)
def local_frame(self) -> tuple[tuple, tuple, tuple]:
@@ -276,6 +283,27 @@ class Part:
]
+def part_color(part: "Part", fallback: str | None = None) -> str:
+ """Resolve a board's render color from material + finish (layered
+ stock -> material -> finish -> visual). `fallback` is used when no material
+ is set (e.g. the viewer's positional palette). Pure; testable without a GUI."""
+ from .colors import blend, darken, lighten
+
+ base = MATERIAL_COLORS.get(part.material or default_material(part.stock),
+ fallback or "#c8965a")
+ fin = part.finish
+ if fin == "paint" and part.finish_color:
+ return part.finish_color
+ if fin == "stain":
+ tint = part.finish_color or "#5a3a1f"
+ return blend(base, tint, 0.5)
+ if fin == "clear":
+ return darken(base, 0.08) # clear coat slightly richer/darker
+ if fin == "sanded":
+ return lighten(base, 0.15) # sanded raw wood reads lighter
+ return base # raw
+
+
@dataclass
class Joint:
id: str
@@ -394,11 +422,31 @@ class Scene:
self.selection = pid
return part
- def finish(self, ref: str | None, kind: str = "sanded") -> Part:
+ def set_finish(self, ref: str | None, kind: str = "sanded", color: str = "") -> Part:
+ kind = kind.lower().strip()
+ if kind not in FINISH_KINDS:
+ raise SceneError(f"Unknown finish {kind!r}. Known: {', '.join(FINISH_KINDS)}")
self._checkpoint()
part = self.resolve(ref)
- if kind not in part.finishes:
- part.finishes.append(kind)
+ part.finish = kind
+ if color:
+ from .colors import normalize_color
+ part.finish_color = normalize_color(color)
+ self.selection = part.id
+ return part
+
+ # back-compat alias: the old API was scene.finish(ref, kind="sanded")
+ def finish(self, ref: str | None, kind: str = "sanded") -> Part:
+ return self.set_finish(ref, kind)
+
+ def paint(self, ref: str | None, color: str) -> Part:
+ """Paint a board a color (a paint finish)."""
+ return self.set_finish(ref, "paint", color=color)
+
+ def set_material(self, ref: str | None, material: str) -> Part:
+ self._checkpoint()
+ part = self.resolve(ref)
+ part.material = material.lower().strip()
self.selection = part.id
return part
@@ -515,7 +563,7 @@ class Scene:
position_in=[src.position_in[0] + dx, src.position_in[1] + dy,
src.position_in[2] + dz],
yaw_deg=src.yaw_deg, tilt_deg=src.tilt_deg, roll_deg=src.roll_deg,
- finishes=list(src.finishes))
+ material=src.material, finish=src.finish, finish_color=src.finish_color)
self.parts.append(clone)
self.selection = pid
return clone
@@ -767,6 +815,8 @@ class Scene:
p = dict(p)
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
p["yaw_deg"] = p.pop("rotation_deg")
+ if "finish" not in p and p.get("finishes"): # migrate finishes list
+ p["finish"] = "paint" if p.get("finish_color") else "sanded"
p["section_in"] = tuple(p["section_in"])
p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields})
for f in p.get("features", [])]
diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py
index 5834ada..c098b4b 100644
--- a/src/woodshop/viewer.py
+++ b/src/woodshop/viewer.py
@@ -15,12 +15,25 @@ import argparse
import time
from pathlib import Path
-from .scene import Part, Scene, default_scene_path
+from .scene import Part, Scene, default_scene_path, part_color
-# Distinct colors so adjacent boards read as separate pieces.
+# Fallback palette so adjacent boards read as separate pieces when no material set.
_PALETTE = ["#c8965a", "#a9744f", "#d6b27c", "#8d5524", "#e0c097", "#b5651d"]
+def _board_color(part: Part, index: int) -> str:
+ """Material/finish color with a subtle deterministic per-part tint so
+ same-material boards stay distinguishable."""
+ import hashlib
+
+ from .colors import darken, lighten
+ base = part_color(part, fallback=_PALETTE[index % len(_PALETTE)])
+ # ±~5% lightness, keyed by id so it's stable across renders
+ h = int(hashlib.md5(part.id.encode()).hexdigest(), 16) % 1000 / 1000.0 # 0..1
+ delta = (h - 0.5) * 0.1 # -0.05..+0.05
+ return lighten(base, delta) if delta >= 0 else darken(base, -delta)
+
+
def _featured_mesh(part: Part):
"""Tessellate the true build123d solid (with joinery booleans) for display."""
import pyvista as pv
@@ -139,7 +152,7 @@ def _render(plotter, scene: Scene) -> None:
mesh = _part_mesh(part)
plotter.add_mesh(
mesh,
- color="#f5d76e" if edge else _PALETTE[i % len(_PALETTE)],
+ color="#f5d76e" if edge else _board_color(part, i),
show_edges=not part.features, # plain boxes: real quad edges
line_width=3 if edge else 1,
edge_color="black",
diff --git a/tests/test_materials.py b/tests/test_materials.py
new file mode 100644
index 0000000..9d9b101
--- /dev/null
+++ b/tests/test_materials.py
@@ -0,0 +1,67 @@
+"""Phase 1: material + finish fields, color resolver, color helpers."""
+import json
+
+from woodshop import colors
+from woodshop.scene import Scene, part_color
+
+
+def test_set_material_and_finish():
+ s = Scene()
+ s.place("2x4", 24)
+ s.set_material("p1", "oak")
+ s.set_finish("p1", "sanded")
+ p = s.get_part("p1")
+ assert p.material == "oak" and p.finish == "sanded"
+
+
+def test_paint_normalizes_color_name():
+ s = Scene()
+ s.place("2x4", 24)
+ s.paint("p1", "navy")
+ p = s.get_part("p1")
+ assert p.finish == "paint" and p.finish_color == colors.NAMED["navy"]
+
+
+def test_finish_roundtrips_through_json():
+ s = Scene()
+ s.place("2x4", 24)
+ s.paint("p1", "#112233")
+ s2 = Scene.from_dict(json.loads(json.dumps(s.to_dict())))
+ p = s2.get_part("p1")
+ assert p.finish == "paint" and p.finish_color == "#112233"
+
+
+def test_legacy_finishes_list_migrates():
+ raw = {"parts": [{"id": "p1", "stock": "2x4", "length_in": 24.0,
+ "section_in": [1.5, 3.5], "finishes": ["sanded"]}]}
+ s = Scene.from_dict(raw)
+ assert s.get_part("p1").finish == "sanded"
+
+
+def test_part_color_priority():
+ s = Scene()
+ s.place("2x4", 24)
+ p = s.get_part("p1")
+ # raw spruce default
+ raw = part_color(p)
+ s.set_finish("p1", "sanded")
+ assert part_color(p) != raw # sanded is lighter
+ s.paint("p1", "#ff0000")
+ assert part_color(p) == "#ff0000" # paint wins
+
+
+def test_part_color_fallback_when_no_material():
+ s = Scene()
+ s.place("2x4", 24)
+ p = s.get_part("p1")
+ p.material = "__unknown__"
+ assert part_color(p, fallback="#abcdef") == "#abcdef"
+
+
+def test_color_helpers():
+ assert colors.normalize_color("navy") == colors.NAMED["navy"]
+ assert colors.normalize_color("#abc") == "#abc"
+ assert colors.normalize_color("aabbcc") == "#aabbcc"
+ assert colors.normalize_color("not-a-color") == "#808080"
+ assert colors.lighten("#000000", 0.5) == "#808080"
+ assert colors.darken("#ffffff", 0.5) == "#808080"
diff --git a/tests/test_scene.py b/tests/test_scene.py
index 60eb2bb..daf6914 100644
--- a/tests/test_scene.py
+++ b/tests/test_scene.py
@@ -57,7 +57,7 @@ def test_the_example_sentence():
s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b")
p1, p2 = s.get_part("p1"), s.get_part("p2")
- assert "sanded" in p1.finishes
+ assert p1.finish == "sanded"
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
assert p2.position_in[0] == pytest.approx(62.0)
# butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide ->