Phase 1: material + finish fields + color resolver

Parts now carry material/finish/finish_color instead of a finishes list.

- Part: material, finish (raw|sanded|clear|stain|paint), finish_color (hex).
  from_dict migrates the old finishes list; serialization additive.
- scene.set_material / set_finish / paint (finish() kept as alias); colors
  normalized via new colors.py (name->hex, lighten/darken/blend).
- part_color() resolves stock->material->finish->visual; viewer applies it with
  a subtle deterministic per-part tint (same-material boards stay distinct);
  sanded reads lighter, painted shows its color, stain/clear tint.
- lumber.MATERIAL_COLORS + default_material(stock).
- CLI: paint / finish / material subcommands; controller TOOL_CMD wood-paint/
  finish/material + group methods; Parts panel Paint…/Material… buttons +
  inspector shows material/finish.
- Updated instructions (sand + finish coats), cli status, estimate to new model.
- tests: set/paint/migrate/json-roundtrip, color priority + fallback, helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 19:01:28 -03:00
parent 30bfb3a9e0
commit c36ed3407e
12 changed files with 506 additions and 22 deletions

197
MATERIALS_INVENTORY_PLAN.md Normal file
View File

@ -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 57 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).

View File

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

63
src/woodshop/colors.py Normal file
View File

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

View File

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

View File

@ -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):

View File

@ -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"<b>{part.id}</b>{' · ' + part.name if part.name else ''}<br>"
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:

View File

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

View File

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

View File

@ -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", [])]

View File

@ -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",

67
tests/test_materials.py Normal file
View File

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

View File

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