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 ->