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:
parent
30bfb3a9e0
commit
c36ed3407e
|
|
@ -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).
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", [])]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
Loading…
Reference in New Issue