Compare commits

..

7 Commits

Author SHA1 Message Date
rob 2ee4c56b3a Phase 7: shop Inventory window + stats
- gui/inventory_window.py: read-only management view over the event ledger —
  On-hand / Offcut bin / Build history / Stats tabs (shop-wide), with Refresh.
- Wired into the main window: new Shop ▸ Inventory… menu.
- Plan doc marked complete (all 7 phases; offcut reuse opt-in toggle, purchase
  price-book update opt-in).
- tests: window renders a populated ledger and an empty one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:25:28 -03:00
rob 2b76317a3f Phase 6: inventory workflows (purchase / record build / use offcuts)
Wires the ledger into the BOM window via three workflows (Codex's
workflow-first UX), plus an offcut-consuming planner.

- StockPiece gains owned/source; build_cut_plan(available=) seeds the packer
  from owned offcuts (reusing the seeded-packing) so they're consumed before
  buying. Score reports stock_count = pieces to BUY (offcuts free) + owned_count;
  prices.estimate and the Buy list exclude owned pieces.
- BOM header: "Build units", an opt-in "Use shop offcuts" toggle, and
  "Mark purchased…" / "Record build…" buttons.
- PurchaseDialog: confirm qty + price each, opt-in "save prices to price book".
- RecordBuildDialog: shows consumed stock + each offcut with Keep/Burn/Trash/
  Ignore before committing (the moment to correct reality).
- Ledger.record_build takes per-offcut dispositions; used offcuts are consumed
  from the bin; build cost snapshot logged.
- tests: offcut toggle drops buy-count to 0, record-build writes the ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:23:24 -03:00
rob 30a10adabc Phase 5: event-sourced inventory ledger model
inventory.py — shop-wide, append-only event log; current state derived by
folding (Codex's recommendation). Lean and plumbing-only.

- Events: purchase / consume / create_offcut / discard / adjustment /
  build_recorded. Ledger.load/save to $XDG_DATA_HOME/woodshop/inventory.json.
- Primitives: purchase, adjust, consume_offcut, discard_offcut, record_build
  (deducts consumed stock, keeps/discards each produced offcut).
- Derived: on_hand(), offcut_bin(), available_stock() (one Piece shape for full
  stock + offcuts — the AvailableStock interface), builds(), stats() (spent,
  units built, offcuts kept/burned/trashed, per-project units).
- plan_consumption(plan): derive consumed stock + reusable offcuts from a CutPlan.
- tests: purchase/consume, adjustment, keep/discard offcuts, consume removes
  from bin, plan_consumption, available_stock, save/load, cross-project stats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:16:28 -03:00
rob 59fff1cb6d Phase 4: batch builds (quantity N)
Estimate N identical units, nesting all units together so offcuts carry across
units → realistic per-unit cost.

- build_cut_plan(quantity=N) / best_cut_plan(quantity=N) replicate CutItems
  (not Parts) with a `unit` field; reoptimize infers the batch size from the
  base plan and preserves it.
- project_estimate(quantity=N): materials from the N-unit plan; setup labor once
  per batch, per-op time + consumables ×N; reports per-unit cost & price.
- BOM window: "Build units" spinner in the header drives the active plan; layout
  labels pieces by unit ("U2 left leg"); cost tab shows total + per-unit.
- tests: replication + units, offcut sharing across units, per-unit estimate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:14:23 -03:00
rob 7adb7e27fc Phase 3: manufacturing allowance in CutPlan (rough vs final)
Sanding never shrinks the design model; instead the cut plan distinguishes the
rough size you cut from the final size you sand to.

- CutItem gains final_length_in/final_width_in (+ final_len/final_wid/
  has_allowance helpers); length_in/width_in are the ROUGH cut size.
- _cut_items(scene, settings): a finished (finish != raw) board is cut oversize
  by sanding_allowance_in on dimensions actually CUT — length always, width only
  for sheet goods. Dimensional lumber's section width is the stock as delivered,
  not padded (Rob's point). note gains "sand to final".
- ShopSettings.sanding_allowance_in default 1/32"; serialization additive.
- BOM cut list shows "Cut … → final …" and a sanding-allowance footnote.
- tests: raw = no allowance, sanded lumber pads length only, plywood pads width
  too, allowance roundtrips in plan JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:08:05 -03:00
rob 882b0ec959 Phase 2: finish costs by kind in the estimate
- EstimateRates.finish_cost_per_sqft and min_per_finish are now dicts keyed by
  finish kind (sanded/clear/stain/paint) — paint costs more and takes longer
  than a clear coat; all editable.
- project_estimate prices the finish line per part by its finish kind × surface
  area; finishing labor sums per-part by kind; raw parts cost nothing.
- load_rates generically merges any dict-valued rate field; RatesEditDialog
  rewritten to render scalars + dict sub-sections automatically.
- tests: finish cost varies by kind, raw = no finish line, dict roundtrip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:04:44 -03:00
rob c36ed3407e 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>
2026-05-30 19:01:28 -03:00
23 changed files with 1486 additions and 128 deletions

199
MATERIALS_INVENTORY_PLAN.md Normal file
View File

@ -0,0 +1,199 @@
# Materials, Finish, Batch Builds & Shop Inventory — Plan (v2, reconciled)
Design plan for four related features, reconciled after a Codex review.
**STATUS: all 7 phases implemented (193 tests).** v2 changes vs v1 are marked **[v2]**.
Settled the two trailing questions: offcut reuse is **opt-in** (a toggle), and
recording a purchase **opt-in** saves prices to the price book.
## 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

@ -14,7 +14,7 @@ import hashlib
from dataclasses import asdict, dataclass, field, fields
from .cutlist import cut_length
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood, normalize_stock
_EPS = 1e-6
@ -36,7 +36,7 @@ class ShopSettings:
grain_direction: bool = False # honor grain (future; disables rotation)
# tolerances — defaults present from day one even before they're in the UI
mortise_tenon_clearance_in: float = 1 / 32
sanding_allowance_in: float = 0.0
sanding_allowance_in: float = 1 / 32 # finished parts cut this much oversize
reveal_in: float = 0.0
def to_dict(self) -> dict:
@ -53,10 +53,25 @@ class CutItem:
id: str
part_id: str
stock: str
length_in: float
length_in: float # ROUGH cut size (what you cut from stock)
width_in: float
is_sheet: bool
note: str = "" # e.g. "incl. tenon"
final_length_in: float = 0.0 # finished size after sanding (0 -> same as rough)
final_width_in: float = 0.0
unit: int = 1 # which build unit (batch quantity > 1)
@property
def final_len(self) -> float:
return self.final_length_in or self.length_in
@property
def final_wid(self) -> float:
return self.final_width_in or self.width_in
@property
def has_allowance(self) -> bool:
return (self.length_in - self.final_len > _EPS) or (self.width_in - self.final_wid > _EPS)
@dataclass
@ -88,6 +103,8 @@ class StockPiece:
width_in: float
placements: list = field(default_factory=list) # Placement
waste: list = field(default_factory=list) # WasteRegion
owned: bool = False # True = an offcut you already have (not bought)
source: str = "" # offcut id / origin, when owned
@dataclass
@ -122,7 +139,8 @@ class CutPlan:
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
length_in=s["length_in"], width_in=s["width_in"],
placements=[Placement(**p) for p in s.get("placements", [])],
waste=[WasteRegion(**w) for w in s.get("waste", [])])
waste=[WasteRegion(**w) for w in s.get("waste", [])],
owned=s.get("owned", False), source=s.get("source", ""))
return cls(
settings=ShopSettings.from_dict(d.get("settings")),
items=[CutItem(**i) for i in d.get("items", [])],
@ -134,15 +152,29 @@ class CutPlan:
# --------------------------------------------------------------------------
def _cut_items(scene) -> list:
def _cut_items(scene, settings: "ShopSettings | None" = None) -> list:
"""Build the cut demand from the scene. A finished (non-raw) board is cut
slightly oversize and sanded to final: the allowance is added to dimensions
we actually CUT length always; width only for sheet goods (a panel we rip).
Dimensional lumber's section width is the stock as delivered — not padded."""
s = settings or ShopSettings()
allow = s.sanding_allowance_in
items = []
for n, p in enumerate(scene.parts, 1):
ln = cut_length(p)
final_len = round(cut_length(p), 3)
final_wid = round(p.section_in[1], 3)
sheet = is_plywood(p.stock)
finished = getattr(p, "finish", "raw") != "raw"
rough_len = round(final_len + allow, 3) if finished else final_len
rough_wid = round(final_wid + allow, 3) if (finished and sheet) else final_wid
note = "incl. tenon" if cut_length(p) > p.length_in + _EPS else ""
if finished and allow > 0:
note = (note + "; " if note else "") + "sand to final"
items.append(CutItem(
id=f"ci{n}", part_id=p.id, stock=p.stock,
length_in=round(ln, 3), width_in=round(p.section_in[1], 3),
is_sheet=is_plywood(p.stock),
note="incl. tenon" if ln > p.length_in + _EPS else ""))
length_in=rough_len, width_in=rough_wid,
final_length_in=final_len, final_width_in=final_wid,
is_sheet=sheet, note=note))
return items
@ -402,10 +434,28 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]:
return _guillotine_pack(items, stock, s, ids, seed_sheets)
def _offcut_seeds(available, stock, ids) -> list:
"""StockPieces representing owned offcuts of `stock`, to fill before buying."""
seeds = []
for oc in available or []:
if normalize_stock(getattr(oc, "stock", "")) != stock:
continue
seeds.append(StockPiece(id=ids("oc"), stock=stock, is_sheet=oc.is_sheet,
length_in=oc.length_in, width_in=oc.width_in,
owned=True, source=getattr(oc, "id", "")))
return seeds
def build_cut_plan(scene, settings: ShopSettings | None = None,
strategy: str = "decreasing") -> CutPlan:
strategy: str = "decreasing", quantity: int = 1,
available=None) -> CutPlan:
from dataclasses import replace
s = settings or ShopSettings()
items = _cut_items(scene)
items = _cut_items(scene, s)
if quantity > 1: # batch: replicate cut demand per unit
items = [replace(it, id=f"{it.id}u{u}", unit=u)
for u in range(1, quantity + 1) for it in items]
by_id = {it.id: it for it in items}
counter = {"n": 0}
@ -421,9 +471,15 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
stock_pieces, unplaced, warnings = [], [], []
for stock, its in by_stock.items():
seeds = _offcut_seeds(available, stock, ids)
if its[0].is_sheet:
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
else _pack_plywood(its, stock, s, ids))
if seeds:
sps, un = _pack_plywood_seeded(its, stock, s, ids, seeds)
else:
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
else _pack_plywood(its, stock, s, ids))
elif seeds:
sps, un = _pack_lumber_seeded(its, stock, s, ids, seeds)
elif strategy == "exact":
sps, un = _pack_lumber_exact(its, stock, s, ids)
else:
@ -444,14 +500,17 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
def _score(stock_pieces, s, strategy, warnings) -> dict:
waste_area = used_area = bought_area = 0.0
reusable = 0
bought = [sp for sp in stock_pieces if not sp.owned]
for sp in stock_pieces:
used = sum(p.len_in * p.wid_in for p in sp.placements)
used_area += used
if sp.is_sheet:
bought_area += sp.length_in * sp.width_in
if not sp.owned:
bought_area += sp.length_in * sp.width_in
waste_area += sp.length_in * sp.width_in - used
else:
bought_area += sp.length_in * sp.width_in
if not sp.owned:
bought_area += sp.length_in * sp.width_in
for w in sp.waste:
waste_area += w.length_in * (w.width_in or sp.width_in)
if w.reusable:
@ -460,7 +519,8 @@ def _score(stock_pieces, s, strategy, warnings) -> dict:
for w in sp.waste if w.reusable)
return {
"strategy_name": strategy,
"stock_count": len(stock_pieces),
"stock_count": len(bought), # pieces you must BUY (offcuts free)
"owned_count": len(stock_pieces) - len(bought),
"waste_area": round(waste_area, 1),
"reusable_offcuts": reusable,
"reusable_in": round(reusable_in, 1),
@ -488,8 +548,14 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl
"""Re-pack while PRESERVING locked placements where they sit. Unlocked pieces
are packed into the free space around locked ones first (free segments on
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
from dataclasses import replace
s = base_plan.settings
items = _cut_items(scene)
items = _cut_items(scene, s)
quantity = max((getattr(it, "unit", 1) for it in base_plan.items), default=1)
if quantity > 1: # preserve the batch the base plan covered
items = [replace(it, id=f"{it.id}u{u}", unit=u)
for u in range(1, quantity + 1) for it in items]
locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked]
locked_ids = {p.item_id for p in locked}
counter = {"n": 0}
@ -576,14 +642,16 @@ def _plan_key(plan: CutPlan):
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan:
def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24,
quantity: int = 1, available=None) -> CutPlan:
"""Find a better layout by trying several strategies + shuffle restarts and
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
best = None
for st in strategies:
plan = build_cut_plan(scene, settings, strategy=st)
plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity,
available=available)
if best is None or _plan_key(plan) < _plan_key(best):
best = plan
if best is not None:

View File

@ -34,7 +34,9 @@ class EstimateRates:
# --- consumable unit costs ($) ---
screw_unit_cost: float = 0.10
glue_cost_per_oz: float = 0.55
finish_cost_per_sqft: float = 0.40
# finish material $/sq ft, by finish kind (sanded = abrasives only)
finish_cost_per_sqft: dict = field(default_factory=lambda: {
"sanded": 0.05, "clear": 0.35, "stain": 0.45, "paint": 0.60})
# --- consumable quantities ---
screws_per_butt_joint: float = 2.0
glue_oz_per_connection: float = 0.5
@ -44,7 +46,9 @@ class EstimateRates:
min_per_cut: float = 3.0
min_per_butt_joint: float = 5.0
min_per_connection: float = 8.0
min_per_finish: float = 10.0
# finishing time per part, by finish kind (paint/stain take longer)
min_per_finish: dict = field(default_factory=lambda: {
"sanded": 8.0, "clear": 12.0, "stain": 14.0, "paint": 16.0})
min_per_feature: dict = field(default_factory=lambda: {
"tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0,
"dado": 6.0, "rabbet": 6.0, "chamfer": 4.0})
@ -63,9 +67,11 @@ def load_rates() -> EstimateRates:
saved = json.loads(path.read_text())
base = asdict(rates)
for k, v in saved.items():
if k == "min_per_feature" and isinstance(v, dict):
base["min_per_feature"].update({fk: float(fv) for fk, fv in v.items()})
elif k in base and not isinstance(base[k], dict):
if k not in base:
continue
if isinstance(base[k], dict) and isinstance(v, dict):
base[k].update({fk: float(fv) for fk, fv in v.items()})
elif not isinstance(base[k], dict):
base[k] = float(v)
rates = EstimateRates(**base)
except (ValueError, OSError, TypeError):
@ -79,31 +85,30 @@ def save_rates(rates: EstimateRates) -> None:
path.write_text(json.dumps(asdict(rates), indent=2))
def count_ops(scene, plan) -> dict:
"""Deterministic operation counts off the scene + cut plan."""
def count_ops(scene, plan, quantity: int = 1) -> dict:
"""Deterministic operation counts off the scene + cut plan. For a batch of
N units, scene-derived counts (joints/features/finish) scale by N; `cuts`
comes from the plan, which already reflects N when built with quantity=N."""
from collections import Counter
q = max(1, quantity)
feats = Counter(f.kind for p in scene.parts for f in p.features)
return {
"parts": len(scene.parts),
"cuts": len(plan.items), # one crosscut per cut item
"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),
"features": dict(feats),
"parts": len(scene.parts) * q,
"cuts": len(plan.items), # plan already ×N for a batch
"butt_joints": len(scene.joints) * q,
"connections": len(scene.connections) * q,
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS) * q,
"finished_parts": sum(1 for p in scene.parts if p.finish != "raw") * q,
"features": {k: v * q for k, v in feats.items()},
}
def _finished_sqft(scene) -> float:
total = 0.0
for p in scene.parts:
if not p.finishes:
continue
t, w = p.section_in
L = p.length_in
total += 2 * (L * w + L * t + w * t) / 144.0 # all six faces, sq ft
return total
def _part_sqft(part) -> float:
"""Total surface area (all six faces) of a board, in sq ft."""
t, w = part.section_in
L = part.length_in
return 2 * (L * w + L * t + w * t) / 144.0
@dataclass
@ -120,11 +125,20 @@ class ProjectEstimate:
labor_lines: list # list[Line] (time breakdown, cost per group)
labor_minutes: float
rates: EstimateRates
quantity: int = 1 # how many units this estimate covers
@property
def material_cost(self) -> float:
return self.material.total # includes HST — what you pay
@property
def per_unit_cost(self) -> float:
return round(self.total_cost / max(1, self.quantity), 2)
@property
def per_unit_price(self) -> float:
return round(self.price / max(1, self.quantity), 2)
@property
def consumable_cost(self) -> float:
return round(sum(l.cost for l in self.consumables), 2)
@ -158,9 +172,10 @@ class ProjectEstimate:
def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None,
hst: float = prices_mod.NB_HST) -> ProjectEstimate:
hst: float = prices_mod.NB_HST, quantity: int = 1) -> ProjectEstimate:
rates = rates or load_rates()
ops = count_ops(scene, plan)
q = max(1, quantity)
ops = count_ops(scene, plan, q)
material = prices_mod.estimate(plan, prices, hst=hst)
# --- consumables ---
@ -174,10 +189,18 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
if glue_oz:
consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz",
round(glue_oz * rates.glue_cost_per_oz, 2)))
sqft = _finished_sqft(scene)
if sqft:
consumables.append(Line("Finish", f"{sqft:.1f} sq ft × ${rates.finish_cost_per_sqft:.2f}",
round(sqft * rates.finish_cost_per_sqft, 2)))
# finish material: per part, priced by its finish kind × surface area (×N units)
finish_cost, finish_sqft, kinds = 0.0, 0.0, set()
for p in scene.parts:
if p.finish == "raw":
continue
a = _part_sqft(p) * q
finish_sqft += a
finish_cost += a * rates.finish_cost_per_sqft.get(p.finish, 0.0)
kinds.add(p.finish)
if finish_cost > 0:
consumables.append(Line("Finish", f"{'/'.join(sorted(kinds))} · {finish_sqft:.1f} sq ft",
round(finish_cost, 2)))
# --- labor (minutes -> cost) ---
def line(label, minutes, n_detail):
@ -191,7 +214,8 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
f"{ops['butt_joints']} joint(s)"),
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
f"{ops['connections']} connection(s)"),
("Sanding / finishing", ops["finished_parts"] * rates.min_per_finish,
("Sanding / finishing",
sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw") * q,
f"{ops['finished_parts']} part(s)"),
]
for kind, n in sorted(ops["features"].items()):
@ -205,7 +229,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
return ProjectEstimate(material=material, consumables=consumables,
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
rates=rates)
rates=rates, quantity=q)
def _money(v: float) -> str:
@ -214,7 +238,8 @@ def _money(v: float) -> str:
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
hrs = est.labor_minutes / 60.0
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%)",
batch = f" · batch of {est.quantity}" if est.quantity > 1 else ""
lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%){batch}",
"editable estimate — verify before quoting", ""]
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
for l in est.consumables:
@ -236,4 +261,8 @@ def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
lines += [" " + "=" * 42,
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
f" {'(profit ' + _money(est.profit) + ')':<30}"]
if est.quantity > 1:
lines += [" " + "-" * 42,
f" {'PER UNIT cost':<30}{_money(est.per_unit_cost):>12}",
f" {'PER UNIT price':<30}{_money(est.per_unit_price):>12}"]
return "\n".join(lines)

View File

@ -8,12 +8,12 @@ import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsRectItem,
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
QHBoxLayout, QHeaderView, QLabel, QMenu, QMessageBox,
QPushButton, QScrollArea, QSpinBox, QTableWidget,
QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from collections import Counter
@ -25,6 +25,8 @@ from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from .. import prices as prices_mod
from .. import estimate as estimate_mod
from .. import inventory as inventory_mod
from ..inventory import plan_consumption
from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view
@ -74,6 +76,7 @@ class BomWindow(QDialog):
self.resize(820, 640)
self._order = 0
self._optimized = False
self._quantity = 1
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
self._px = _PX
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
@ -81,6 +84,8 @@ class BomWindow(QDialog):
self._prices = prices_mod.load_prices()
self._rates = estimate_mod.load_rates()
self._ledger = inventory_mod.Ledger.load()
self._use_offcuts = False
self._cut_te = self._mono_te()
self._shop_te = self._mono_te()
tabs = QTabWidget()
@ -90,10 +95,96 @@ class BomWindow(QDialog):
tabs.addTab(self._layout_tab(), "Cut Layout")
tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs")
# header: build quantity (nests all units together → real per-unit cost),
# an opt-in toggle to consume shop offcuts first, and inventory workflows.
header = QHBoxLayout()
header.addWidget(QLabel("Build units:"))
self._qty_spin = QSpinBox()
self._qty_spin.setRange(1, 999)
self._qty_spin.setValue(self._quantity)
self._qty_spin.setToolTip("Nest this many identical units together so offcuts "
"carry across units")
self._qty_spin.valueChanged.connect(self._on_quantity_changed)
header.addWidget(self._qty_spin)
self._offcut_chk = QCheckBox("Use shop offcuts")
self._offcut_chk.setToolTip("Consume offcuts you already have before buying new stock")
self._offcut_chk.toggled.connect(self._on_use_offcuts)
header.addWidget(self._offcut_chk)
header.addStretch()
buy = QPushButton("Mark purchased…")
buy.setToolTip("Add this shopping list to your shop inventory")
buy.clicked.connect(self._mark_purchased)
rec = QPushButton("Record build…")
rec.setToolTip("Deduct stock, keep/discard offcuts, log the build")
rec.clicked.connect(self._record_build)
header.addWidget(buy); header.addWidget(rec)
root = QVBoxLayout(self)
root.addLayout(header)
root.addWidget(tabs)
self._refresh_all()
def _available(self):
if not self._use_offcuts:
return None
return self._ledger.available_stock(self._plan.settings.stick_len_in)
def _rebuild_base(self) -> None:
self._optimized = False
self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity,
available=self._available()))
def _on_quantity_changed(self, value: int) -> None:
self._quantity = max(1, value)
self._rebuild_base()
def _on_use_offcuts(self, on: bool) -> None:
self._use_offcuts = on
self._rebuild_base()
def _mark_purchased(self) -> None:
bought = Counter(sp.stock for sp in self._plan.stock_pieces
if not getattr(sp, "owned", False))
if not bought:
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
return
dlg = PurchaseDialog(bought, self._prices, self)
if not dlg.exec():
return
rows, save = dlg.result()
for stock, qty, price in rows:
self._ledger.purchase(stock, qty, price=price)
if save:
self._prices[stock] = price
self._ledger.save()
if save:
prices_mod.save_prices(self._prices)
self._cost_te.setPlainText(self._cost_text())
QMessageBox.information(self, "Added to inventory",
f"Added {sum(q for _, q, _ in rows)} item(s) to shop inventory.")
def _record_build(self) -> None:
consumed, offcuts = plan_consumption(self._plan)
used = [sp.source for sp in self._plan.stock_pieces
if getattr(sp, "owned", False) and sp.source]
dlg = RecordBuildDialog(consumed, offcuts, self._quantity, self)
if not dlg.exec():
return
for oid in used:
self._ledger.consume_offcut(oid)
cost = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices,
self._rates, quantity=self._quantity).total_cost
project = getattr(self.c, "scene_path", None)
project = project.stem if project else "project"
self._ledger.record_build(project, self._quantity, consumed, offcuts,
dispositions=dlg.dispositions(), cost=cost)
self._ledger.save()
if self._use_offcuts:
self._rebuild_base()
QMessageBox.information(self, "Build recorded",
"Recorded in shop inventory (File ▸ Inventory to view).")
# ----- one active plan; all tabs render from it ---------------------
def _set_plan(self, plan) -> None:
recompute(plan) # keep waste/score truthful after any change
@ -126,16 +217,25 @@ class BomWindow(QDialog):
def _cut_text(self) -> str:
plan = self._plan
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2), it.is_sheet)
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2),
round(it.final_len, 2), round(it.final_wid, 2), it.is_sheet)
for it in plan.items)
lines = ["CUT LIST", ""]
for (stock, ln, wd, sheet), n in sorted(groups.items()):
for (stock, ln, wd, fl, fw, sheet), n in sorted(groups.items()):
if sheet:
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
f" ({wd * ln / 144 * n:.1f} sq ft)")
size = f"{_fmt_len(wd)} × {_fmt_len(ln)}"
extra = f"({wd * ln / 144 * n:.1f} sq ft)"
else:
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
size = f"@ {_fmt_len(ln)}"
extra = f"({board_feet(stock, ln) * n:.1f} bd-ft)"
row = f" {n:>2} × {stock:<8} {size}"
if (ln, wd) != (fl, fw): # finished oversize — show final
final = f"{_fmt_len(fw)} × {_fmt_len(fl)}" if sheet else f"@ {_fmt_len(fl)}"
row += f" → final {final}"
lines.append(f"{row:<46} {extra}")
if any(it.has_allowance for it in plan.items):
lines += ["", f" (cut sizes include a {_fmt_len(plan.settings.sanding_allowance_in)}"
" sanding allowance — sand to final)"]
if not plan.items:
lines.append(" (nothing to cut yet)")
return "\n".join(lines)
@ -143,12 +243,16 @@ class BomWindow(QDialog):
def _shop_text(self) -> str:
plan = self._plan
lines = ["SHOPPING LIST", "", "Buy:"]
for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
bought = [sp for sp in plan.stock_pieces if not getattr(sp, "owned", False)]
for stock, qty in sorted(Counter(sp.stock for sp in bought).items()):
s = "s" if qty != 1 else ""
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
lines.append(f" {qty} × {stock} {unit}")
if not plan.stock_pieces:
lines.append(" (nothing yet)")
if not bought:
lines.append(" (nothing to buy)")
owned = [sp for sp in plan.stock_pieces if getattr(sp, "owned", False)]
if owned:
lines += ["", f"Using {len(owned)} offcut(s) from your shop inventory."]
if plan.unplaced:
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
for iid in plan.unplaced:
@ -215,7 +319,8 @@ class BomWindow(QDialog):
return w
def _cost_text(self) -> str:
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates)
est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices,
self._rates, quantity=self._quantity)
return estimate_mod.format_estimate(est)
def _on_margin_changed(self, value: float) -> None:
@ -391,7 +496,7 @@ class BomWindow(QDialog):
self._set_plan(best)
self._status.setText("✓ optimized around locked pieces")
else:
self._set_plan(best_cut_plan(self.c.scene))
self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity))
self._status.setText("✓ optimized")
def _best_of_n(self) -> None:
@ -404,7 +509,7 @@ class BomWindow(QDialog):
self._set_plan(best)
self._status.setText("✓ best of 100 around locked pieces")
else:
self._set_plan(best_cut_plan(self.c.scene, attempts=100))
self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity))
self._status.setText("✓ best of 100 attempts")
def _next_arrangement(self) -> None:
@ -412,7 +517,7 @@ class BomWindow(QDialog):
self._order = (self._order + 1) % len(STRATEGIES)
st = STRATEGIES[self._order]
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
else build_cut_plan(self.c.scene, strategy=st))
else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity))
self._set_plan(plan)
def _draw_layout(self) -> None:
@ -421,7 +526,12 @@ class BomWindow(QDialog):
self._rows = []
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
part_of = {it.id: it.part_id for it in plan.items}
label = lambda iid: names.get(part_of.get(iid, ""), iid)
unit_of = {it.id: getattr(it, "unit", 1) for it in plan.items}
multi = any(u > 1 for u in unit_of.values())
def label(iid):
base = names.get(part_of.get(iid, ""), iid)
return f"U{unit_of.get(iid, 1)} {base}" if multi else base
px, y, bar = self._px, 30.0, 34.0
sc = plan.score
@ -600,57 +710,148 @@ class PriceEditDialog(QDialog):
class RatesEditDialog(QDialog):
"""Edit labor rate, per-operation minutes, and consumable costs."""
# field -> (label, suffix, step)
_SCALARS = [
("labor_rate_per_hr", "Labor rate", " $/h", 1.0),
("setup_min", "Setup / cleanup", " min", 1.0),
("min_per_cut", "Time per cut", " min", 0.5),
("min_per_butt_joint", "Time per butt joint", " min", 0.5),
("min_per_connection", "Time per assembly (M&T)", " min", 0.5),
("min_per_finish", "Time per part sanded", " min", 0.5),
("screws_per_butt_joint", "Screws per butt joint", "", 1.0),
("screw_unit_cost", "Screw cost", " $", 0.01),
("glue_oz_per_connection", "Glue per assembly", " oz", 0.1),
("glue_oz_per_glued_feature", "Glue per dado/rabbet", " oz", 0.1),
("glue_cost_per_oz", "Glue cost", " $/oz", 0.05),
("finish_cost_per_sqft", "Finish cost", " $/sq ft", 0.05),
]
"""Edit labor rate, per-operation minutes, and consumable costs. Renders
scalar rate fields as spin boxes and dict fields (per-feature time, finish
cost/time by kind) as labelled sub-sections generic over EstimateRates."""
def __init__(self, rates, parent=None):
super().__init__(parent)
self.setWindowTitle("Edit rates")
self.resize(380, 560)
self.resize(400, 600)
self._rates = rates
self._spins = {}
self._feat_spins = {}
self._spins = {} # field -> spin (scalars)
self._dict_spins = {} # field -> {key -> spin}
from dataclasses import asdict
outer = QVBoxLayout(self)
area = QScrollArea(); area.setWidgetResizable(True)
body = QWidget(); form = QFormLayout(body)
for field, label, suffix, step in self._SCALARS:
sp = QDoubleSpinBox()
sp.setRange(0.0, 100000.0); sp.setSingleStep(step); sp.setSuffix(suffix)
sp.setValue(float(getattr(rates, field)))
self._spins[field] = sp
form.addRow(label, sp)
form.addRow(QLabel("— Joinery time (minutes each) —"))
for kind, minutes in sorted(rates.min_per_feature.items()):
sp = QDoubleSpinBox()
sp.setRange(0.0, 1000.0); sp.setSingleStep(0.5); sp.setSuffix(" min")
sp.setValue(float(minutes))
self._feat_spins[kind] = sp
form.addRow(kind, sp)
for field, val in asdict(rates).items():
if isinstance(val, dict):
form.addRow(QLabel(f"{self._pretty(field)}"))
self._dict_spins[field] = {}
for key, v in sorted(val.items()):
sp = self._spin(field, v)
self._dict_spins[field][key] = sp
form.addRow(key, sp)
else:
sp = self._spin(field, val)
self._spins[field] = sp
form.addRow(self._pretty(field), sp)
area.setWidget(body)
outer.addWidget(area)
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
outer.addWidget(bb)
@staticmethod
def _pretty(field: str) -> str:
return field.replace("min_per_", "time: ").replace("_", " ")
@staticmethod
def _suffix(field: str) -> str:
if "pct" in field:
return " %"
if "min_per" in field or field.endswith("_min"):
return " min"
if "oz" in field and "cost" not in field:
return " oz"
if any(t in field for t in ("cost", "rate", "price")):
return " $"
return ""
def _spin(self, field, value):
sp = QDoubleSpinBox()
sp.setRange(0.0, 1_000_000.0)
sp.setDecimals(2)
sp.setSingleStep(0.5 if "min" in field else 0.05)
sp.setSuffix(self._suffix(field))
sp.setValue(float(value))
return sp
def rates(self):
for field, sp in self._spins.items():
setattr(self._rates, field, sp.value())
for kind, sp in self._feat_spins.items():
self._rates.min_per_feature[kind] = sp.value()
for field, spins in self._dict_spins.items():
d = getattr(self._rates, field)
for key, sp in spins.items():
d[key] = sp.value()
return self._rates
class PurchaseDialog(QDialog):
"""Confirm a shopping list before adding it to shop inventory; optionally
save the entered prices back to the price book (opt-in)."""
def __init__(self, bought, prices, parent=None):
super().__init__(parent)
self.setWindowTitle("Mark purchased")
self.resize(360, 320)
v = QVBoxLayout(self)
v.addWidget(QLabel("Add these to your shop inventory?"))
rows = sorted(bought.items())
self._table = QTableWidget(len(rows), 3)
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._spins = []
for r, (stock, qty) in enumerate(rows):
name = QTableWidgetItem(stock)
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
self._table.setItem(r, 0, name)
q = QSpinBox(); q.setRange(0, 9999); q.setValue(int(qty))
p = QDoubleSpinBox(); p.setRange(0.0, 100000.0); p.setDecimals(2)
p.setValue(float(prices.get(stock, 0.0)))
self._table.setCellWidget(r, 1, q)
self._table.setCellWidget(r, 2, p)
self._spins.append((stock, q, p))
v.addWidget(self._table)
self._save = QCheckBox("Save these prices to my price book")
v.addWidget(self._save)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
v.addWidget(bb)
def result(self):
rows = [(stock, q.value(), round(p.value(), 2))
for stock, q, p in self._spins if q.value() > 0]
return rows, self._save.isChecked()
class RecordBuildDialog(QDialog):
"""Confirm what stock was consumed and decide each offcut's fate before the
build is committed to inventory (the moment to correct reality)."""
_FATES = ["keep", "burned", "trashed", "ignore"]
def __init__(self, consumed, offcuts, quantity, parent=None):
super().__init__(parent)
self.setWindowTitle("Record build")
self.resize(420, 420)
v = QVBoxLayout(self)
units = f" ×{quantity}" if quantity > 1 else ""
v.addWidget(QLabel(f"Recording a build{units}.\n\nConsumed from stock:"))
cons = ", ".join(f"{q} × {s}" for s, q in sorted(consumed.items())) or "(none)"
lbl = QLabel(" " + cons); lbl.setWordWrap(True)
v.addWidget(lbl)
v.addWidget(QLabel("Offcuts produced — keep, burn, trash, or ignore each:"))
self._table = QTableWidget(len(offcuts), 2)
self._table.setHorizontalHeaderLabels(["Offcut", "Fate"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._combos = []
for r, oc in enumerate(offcuts):
unit = "sheet" if oc["is_sheet"] else "stick"
desc = (f"{oc['stock']} {oc['length_in']:g}\" × {oc['width_in']:g}\""
if oc["is_sheet"] else f"{oc['stock']} {oc['length_in']:g}\" {unit}")
name = QTableWidgetItem(desc)
name.setFlags(name.flags() & ~Qt.ItemIsEditable)
self._table.setItem(r, 0, name)
combo = QComboBox(); combo.addItems(self._FATES)
self._table.setCellWidget(r, 1, combo)
self._combos.append(combo)
v.addWidget(self._table)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
v.addWidget(bb)
def dispositions(self):
return [c.currentText() for c in self._combos]

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

@ -0,0 +1,95 @@
"""The shop Inventory window — a read-only management view over the event
ledger (on-hand stock, offcut bin, build history, stats). The day-to-day
workflows (mark purchased / record build / use offcuts) live on the BOM window;
this is where you review and audit. Shop-wide across all projects."""
from __future__ import annotations
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QDialog, QHBoxLayout, QPushButton, QTabWidget,
QTextEdit, QVBoxLayout, QWidget)
from .. import inventory as inventory_mod
class InventoryWindow(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Shop Inventory")
self.resize(640, 520)
self._ledger = inventory_mod.Ledger.load()
self._tabs = {}
tabw = QTabWidget()
for name in ("On-hand", "Offcut bin", "Build history", "Stats"):
te = QTextEdit(readOnly=True)
te.setFont(QFont("monospace"))
self._tabs[name] = te
tabw.addTab(te, name)
root = QVBoxLayout(self)
root.addWidget(tabw)
row = QHBoxLayout()
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.reload)
row.addStretch(); row.addWidget(refresh)
root.addLayout(row)
self.reload()
def reload(self) -> None:
self._ledger = inventory_mod.Ledger.load()
self._tabs["On-hand"].setPlainText(self._on_hand_text())
self._tabs["Offcut bin"].setPlainText(self._offcuts_text())
self._tabs["Build history"].setPlainText(self._builds_text())
self._tabs["Stats"].setPlainText(self._stats_text())
def _on_hand_text(self) -> str:
oh = self._ledger.on_hand()
if not oh:
return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)"
lines = ["ON-HAND STOCK", ""]
for stock, qty in oh.items():
unit = "sheet" if stock.startswith("ply-") else "stick"
lines.append(f" {qty:>3} × {stock:<10} ({unit}s)")
return "\n".join(lines)
def _offcuts_text(self) -> str:
bin_ = self._ledger.offcut_bin()
if not bin_:
return "OFFCUT BIN\n\n (empty — kept offcuts from recorded builds land here)"
lines = ["OFFCUT BIN", ""]
for p in sorted(bin_, key=lambda p: (p.stock, -p.length_in)):
if p.is_sheet:
size = f"{p.length_in:g}\" × {p.width_in:g}\""
else:
size = f"{p.length_in:g}\""
src = f" from {p.source_project}" if p.source_project else ""
lines.append(f" {p.stock:<10} {size:<14} [{p.id}]{src}")
return "\n".join(lines)
def _builds_text(self) -> str:
builds = self._ledger.builds()
if not builds:
return "BUILD HISTORY\n\n (no builds recorded yet)"
lines = ["BUILD HISTORY", ""]
for e in builds:
cost = f" ${e['cost']:,.2f}" if e.get("cost") is not None else ""
date = f" {e['date']}" if e.get("date") else ""
lines.append(f" {e['build_id']:<5} {e['project']:<16} ×{e['units']}{cost}{date}")
return "\n".join(lines)
def _stats_text(self) -> str:
s = self._ledger.stats()
lines = ["SHOP STATS", "",
f" Total spent ${s['spent']:,.2f}",
f" Builds recorded {s['builds']}",
f" Units built {s['units_built']}",
f" Offcuts kept {s['offcuts_kept']}",
f" Offcuts burned {s['offcuts_burned']}",
f" Offcuts trashed {s['offcuts_trashed']}",
"", " Units built by project:"]
if s["by_project"]:
for proj, n in sorted(s["by_project"].items()):
lines.append(f" {proj:<18} {n}")
else:
lines.append(" (none)")
return "\n".join(lines)

View File

@ -119,6 +119,9 @@ class MainWindow(QMainWindow):
"build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them",
[("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")]))
s = mb.addMenu("&Shop")
self._act(s, "Inventory…", self._show_inventory)
h = mb.addMenu("&Help")
self._act(h, "Commands…", self._show_help)
@ -184,6 +187,11 @@ class MainWindow(QMainWindow):
self._bom = BomWindow(self.controller, self) # keep a ref so it isn't GC'd
self._bom.show()
def _show_inventory(self):
from .inventory_window import InventoryWindow
self._inventory = InventoryWindow(self) # keep a ref so it isn't GC'd
self._inventory.show()
def _show_help(self):
QMessageBox.information(self, "Commands", _HELP)

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:

204
src/woodshop/inventory.py Normal file
View File

@ -0,0 +1,204 @@
"""Shop-wide inventory as an append-only event ledger.
Per the plan (and Codex's recommendation), the source of truth is a list of
immutable events; current state (on-hand stock, the offcut bin, build history,
stats) is *derived* by folding them. This gives history/audit/stats for free and
avoids "why is my inventory wrong" drift. The ledger is plumbing the GUI only
ever shows the three workflows (purchase / record build / use offcuts) and a
read-only management view.
Events (each a dict with a "type"):
purchase {stock, qty, is_sheet, price, date}
consume {stock, qty} OR {offcut_id}
create_offcut {offcut: {...}}
discard {offcut_id, fate: burned|trashed}
adjustment {stock, delta, reason}
build_recorded {build_id, project, units, cost, date}
Shop-wide, stored in $XDG_DATA_HOME/woodshop/inventory.json.
"""
from __future__ import annotations
import json
import os
from collections import Counter
from dataclasses import asdict, dataclass, field
from pathlib import Path
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, is_plywood, normalize_stock
@dataclass
class Piece:
"""An available piece of stock — a full unit or a reusable offcut. The
planner can consume both through this one shape (AvailableStock)."""
id: str
stock: str
length_in: float
width_in: float
is_sheet: bool
material: str = ""
is_offcut: bool = False
source_project: str = ""
bin: str = ""
def _data_path() -> Path:
base = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() / "woodshop"
return base / "inventory.json"
def plan_consumption(plan) -> tuple[dict, list[dict]]:
"""From a CutPlan, derive (stock consumed as {stock: qty}, reusable offcuts).
Offcuts are the reusable waste regions; ids are assigned later by the ledger."""
consumed = Counter(sp.stock for sp in plan.stock_pieces if not getattr(sp, "owned", False))
offcuts = []
for sp in plan.stock_pieces:
for w in sp.waste:
if not w.reusable:
continue
offcuts.append({
"stock": sp.stock,
"length_in": round(w.length_in, 2),
"width_in": round(w.width_in or sp.width_in, 2),
"is_sheet": sp.is_sheet,
})
return dict(consumed), offcuts
class Ledger:
def __init__(self, events: list | None = None):
self.events = list(events or [])
# ----- persistence --------------------------------------------------
@classmethod
def load(cls, path: Path | None = None) -> "Ledger":
path = path or _data_path()
if path.exists():
try:
return cls(json.loads(path.read_text()).get("events", []))
except (ValueError, OSError):
pass
return cls()
def save(self, path: Path | None = None) -> None:
path = path or _data_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"events": self.events}, indent=2))
def _emit(self, type_: str, **fields) -> dict:
e = {"type": type_, **fields}
self.events.append(e)
return e
def _next_build_id(self) -> str:
n = sum(1 for e in self.events if e["type"] == "build_recorded") + 1
return f"b{n}"
# ----- primitives (each appends events) -----------------------------
def purchase(self, stock: str, qty: int, price: float | None = None,
date: str = "", is_sheet: bool | None = None) -> None:
stock = normalize_stock(stock)
sheet = is_plywood(stock) if is_sheet is None else is_sheet
self._emit("purchase", stock=stock, qty=int(qty), is_sheet=sheet,
price=price, date=date)
def adjust(self, stock: str, delta: int, reason: str = "", date: str = "") -> None:
self._emit("adjustment", stock=normalize_stock(stock), delta=int(delta),
reason=reason, date=date)
def discard_offcut(self, offcut_id: str, fate: str, date: str = "") -> None:
self._emit("discard", offcut_id=offcut_id, fate=fate, date=date)
def consume_offcut(self, offcut_id: str) -> None:
self._emit("consume", offcut_id=offcut_id)
def record_build(self, project: str, units: int, consumed: dict,
offcuts: list[dict], dispositions: list | None = None,
cost: float | None = None, date: str = "") -> str:
"""Record a build: deduct consumed full stock, then for each produced
offcut apply its disposition. `dispositions[i]` is one of "keep" ( offcut
bin), "burned"/"trashed" ( discard), or "ignore" (don't track). Defaults
to keeping every offcut. Returns the build id."""
build_id = self._next_build_id()
for stock, qty in consumed.items():
if qty:
self._emit("consume", stock=normalize_stock(stock), qty=int(qty),
build_id=build_id)
disp = dispositions if dispositions is not None else ["keep"] * len(offcuts)
for i, oc in enumerate(offcuts):
fate = disp[i] if i < len(disp) else "keep"
oid = f"{build_id}-o{i + 1}"
if fate == "keep":
self._emit("create_offcut", offcut={**oc, "id": oid,
"source_project": project, "source_build": build_id})
elif fate in ("burned", "trashed"):
self._emit("discard", offcut_id=oid, fate=fate)
# "ignore" -> not tracked
self._emit("build_recorded", build_id=build_id, project=project,
units=int(units), cost=cost, date=date)
return build_id
# ----- derived state (fold the events) ------------------------------
def on_hand(self) -> dict:
c = Counter()
for e in self.events:
if e["type"] == "purchase":
c[e["stock"]] += e["qty"]
elif e["type"] == "consume" and "stock" in e:
c[e["stock"]] -= e["qty"]
elif e["type"] == "adjustment":
c[e["stock"]] += e["delta"]
return {k: v for k, v in sorted(c.items()) if v}
def offcut_bin(self) -> list[Piece]:
live: dict[str, dict] = {}
for e in self.events:
if e["type"] == "create_offcut":
oc = e["offcut"]
live[oc["id"]] = oc
elif e["type"] in ("discard",) and e.get("offcut_id") in live:
del live[e["offcut_id"]]
elif e["type"] == "consume" and e.get("offcut_id") in live:
del live[e["offcut_id"]]
return [Piece(id=oc["id"], stock=oc["stock"], length_in=oc["length_in"],
width_in=oc["width_in"], is_sheet=oc["is_sheet"], is_offcut=True,
source_project=oc.get("source_project", ""), bin=oc.get("bin", ""))
for oc in live.values()]
def available_stock(self, stick_len_in: float = 96.0) -> list[Piece]:
"""Everything the planner could consume: full on-hand units (given real
dimensions so they pack) + offcuts."""
pieces = list(self.offcut_bin())
for stock, qty in self.on_hand().items():
sheet = is_plywood(stock)
if sheet:
L, W = SHEET_LENGTH_IN, SHEET_WIDTH_IN
else:
L, W = stick_len_in, actual_section(stock)[1]
for i in range(qty):
pieces.append(Piece(id=f"{stock}#{i + 1}", stock=stock,
length_in=L, width_in=W, is_sheet=sheet))
return pieces
def builds(self) -> list[dict]:
return [e for e in self.events if e["type"] == "build_recorded"]
def stats(self) -> dict:
spent = sum((e.get("price") or 0.0) * e["qty"]
for e in self.events if e["type"] == "purchase")
units = sum(e["units"] for e in self.builds())
fates = Counter(e["fate"] for e in self.events if e["type"] == "discard")
kept = sum(1 for e in self.events if e["type"] == "create_offcut")
by_project = Counter()
for e in self.builds():
by_project[e["project"]] += e["units"]
return {
"spent": round(spent, 2),
"units_built": units,
"builds": len(self.builds()),
"offcuts_kept": kept,
"offcuts_burned": fates.get("burned", 0),
"offcuts_trashed": fates.get("trashed", 0),
"by_project": dict(by_project),
}

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

@ -118,7 +118,8 @@ def estimate(plan, prices: dict[str, float] | None = None, hst: float = NB_HST)
prices = prices if prices is not None else load_prices()
stick_in = getattr(plan.settings, "stick_len_in", STD_STICK_IN) or STD_STICK_IN
counts = Counter(normalize_stock(sp.stock) for sp in plan.stock_pieces)
counts = Counter(normalize_stock(sp.stock) for sp in plan.stock_pieces
if not getattr(sp, "owned", False)) # offcuts are free, not bought
lines = []
for stock, qty in sorted(counts.items()):

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

View File

@ -125,3 +125,34 @@ def test_valid_move_commits(tmp_path):
second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
w._drop_piece(second, home)
assert "placed" in w._status.text().lower()
def test_offcut_toggle_uses_inventory(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from woodshop import inventory as I
led = I.Ledger()
led.record_build("seed", 1, consumed={}, offcuts=[
{"stock": "2x4", "length_in": 96, "width_in": 3.5, "is_sheet": False}])
led.save()
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 30)
w = BomWindow(c)
assert w._plan.score["stock_count"] == 1 # buys 1 stick by default
w._offcut_chk.setChecked(True) # use the 96" offcut instead
assert w._plan.score["stock_count"] == 0
assert w._plan.score["owned_count"] == 1
def test_record_build_writes_ledger(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from woodshop import inventory as I
c = Controller(str(tmp_path / "s.json"))
c.place("2x4", 60)
w = BomWindow(c)
consumed, offcuts = I.plan_consumption(w._plan)
w._ledger.record_build("t", 1, consumed, offcuts, dispositions=["keep"] * len(offcuts))
w._ledger.save()
again = I.Ledger.load()
assert again.builds() and again.stats()["units_built"] == 1

View File

@ -290,3 +290,70 @@ def test_custom_settings_kerf():
s.place("2x4", 48)
# zero kerf -> both 48" fit in one 96" stick
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1
def test_raw_part_has_no_allowance():
s = Scene()
s.place("2x4", 24)
it = build_cut_plan(s).items[0]
assert it.length_in == 24 and it.final_len == 24 and not it.has_allowance
def test_sanded_lumber_cut_oversize_in_length_only():
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "sanded")
plan = build_cut_plan(s, settings=ShopSettings(sanding_allowance_in=1 / 16))
it = plan.items[0]
assert abs(it.length_in - 24.0625) < 0.01 # length padded ~1/16"
assert it.final_len == 24.0
assert it.width_in == it.final_wid # lumber width NOT padded
assert it.has_allowance
def test_sanded_plywood_pads_width_too():
s = Scene()
s.place("ply-3/4", 24, width_in=12)
s.set_finish("p1", "paint")
plan = build_cut_plan(s, settings=ShopSettings(sanding_allowance_in=1 / 16))
it = plan.items[0]
assert it.length_in > it.final_len and it.width_in > it.final_wid
def test_allowance_roundtrips_in_plan_json():
import json
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "sanded")
plan = build_cut_plan(s)
plan2 = CutPlan.from_dict(json.loads(json.dumps(plan.to_dict())))
assert plan2.items[0].final_len == plan.items[0].final_len
def test_batch_replicates_cut_items_with_units():
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s, quantity=3)
assert len(plan.items) == 6 # 2 parts × 3 units
assert {it.unit for it in plan.items} == {1, 2, 3}
assert validate_cut_plan(plan) == []
def test_batch_shares_offcuts_across_units():
s = Scene()
s.place("2x4", 30) # 3 fit per 96" stick
one = build_cut_plan(s, quantity=1).score["stock_count"]
three = build_cut_plan(s, quantity=3).score["stock_count"]
assert one == 1 and three == 1 # 3×30" still one stick, not three
def test_batch_estimate_per_unit():
from woodshop import estimate as E
s = Scene()
s.place("2x4", 30)
plan = build_cut_plan(s, quantity=4)
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=E.EstimateRates(), quantity=4)
assert est.quantity == 4
assert abs(est.per_unit_cost - est.total_cost / 4) < 0.01
assert abs(est.per_unit_price - est.price / 4) < 0.01

View File

@ -46,7 +46,7 @@ def test_labor_scales_with_rate_and_ops():
s.place("2x4", 24)
plan = build_cut_plan(s)
rates = E.EstimateRates(labor_rate_per_hr=60.0, setup_min=0, min_per_cut=10,
min_per_butt_joint=0, min_per_connection=0, min_per_finish=0)
min_per_butt_joint=0, min_per_connection=0)
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
# 1 cut × 10 min = 10 min = 1/6 h × $60 = $10
assert est.labor_minutes == 10.0
@ -106,3 +106,41 @@ def test_format_includes_all_sections():
text = E.format_estimate(E.project_estimate(s, plan, prices={"2x4": 4.0}))
for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"):
assert token in text
def test_finish_cost_varies_by_kind():
from woodshop.scene import Scene
s = Scene()
s.place("2x4", 24)
s.set_finish("p1", "paint")
plan = build_cut_plan(s)
rates = E.EstimateRates()
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
fin = next(l for l in est.consumables if l.label == "Finish")
sqft = E._part_sqft(s.get_part("p1"))
assert abs(fin.cost - round(sqft * rates.finish_cost_per_sqft["paint"], 2)) < 0.01
# paint costs more than clear for the same part
s.set_finish("p1", "clear")
est2 = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
fin2 = next(l for l in est2.consumables if l.label == "Finish")
assert fin2.cost < fin.cost
def test_raw_parts_have_no_finish_cost():
from woodshop.scene import Scene
s = Scene()
s.place("2x4", 24) # raw
plan = build_cut_plan(s)
est = E.project_estimate(s, plan, prices={"2x4": 4.0})
assert not any(l.label == "Finish" for l in est.consumables)
def test_finish_rates_dict_roundtrips(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
r = E.EstimateRates()
r.finish_cost_per_sqft["paint"] = 1.23
r.min_per_finish["paint"] = 99.0
E.save_rates(r)
loaded = E.load_rates()
assert loaded.finish_cost_per_sqft["paint"] == 1.23
assert loaded.min_per_finish["paint"] == 99.0

93
tests/test_inventory.py Normal file
View File

@ -0,0 +1,93 @@
"""Phase 5: event-sourced inventory ledger (no GUI)."""
from woodshop.cutplan import build_cut_plan
from woodshop.inventory import Ledger, plan_consumption
from woodshop.scene import Scene
def test_purchase_then_consume_on_hand():
led = Ledger()
led.purchase("2x4", 5)
led.purchase("ply-3/4", 2)
assert led.on_hand() == {"2x4": 5, "ply-3/4": 2}
led.record_build("table", 1, consumed={"2x4": 2}, offcuts=[])
assert led.on_hand()["2x4"] == 3
def test_adjustment_corrects_count():
led = Ledger()
led.purchase("2x4", 5)
led.adjust("2x4", -1, reason="broke one")
assert led.on_hand()["2x4"] == 4
def test_record_build_keeps_and_discards_offcuts():
led = Ledger()
offcuts = [{"stock": "2x4", "length_in": 20, "width_in": 3.5, "is_sheet": False},
{"stock": "2x4", "length_in": 8, "width_in": 3.5, "is_sheet": False}]
bid = led.record_build("shelf", 1, consumed={"2x4": 1}, offcuts=offcuts,
dispositions=["keep", "burned"])
bin_ = led.offcut_bin()
assert len(bin_) == 1 and bin_[0].id == "b1-o1" # kept the 20"
stats = led.stats()
assert stats["offcuts_kept"] == 1 and stats["offcuts_burned"] == 1
def test_consume_offcut_removes_it_from_bin():
led = Ledger()
led.record_build("a", 1, consumed={}, offcuts=[
{"stock": "2x4", "length_in": 20, "width_in": 3.5, "is_sheet": False}])
oid = led.offcut_bin()[0].id
led.consume_offcut(oid)
assert led.offcut_bin() == []
def test_plan_consumption_from_cutplan():
s = Scene()
s.place("2x4", 60) # leaves a reusable ~35" offcut on a 96" stick
plan = build_cut_plan(s)
consumed, offcuts = plan_consumption(plan)
assert consumed.get("2x4") == 1
assert offcuts and offcuts[0]["stock"] == "2x4" and offcuts[0]["length_in"] > 12
def test_available_stock_combines_full_and_offcuts():
led = Ledger()
led.purchase("2x4", 2)
led.record_build("a", 1, consumed={}, offcuts=[
{"stock": "2x4", "length_in": 20, "width_in": 3.5, "is_sheet": False}])
avail = led.available_stock()
assert sum(1 for p in avail if p.is_offcut) == 1
assert sum(1 for p in avail if not p.is_offcut and p.stock == "2x4") == 2
def test_ledger_save_load_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
led = Ledger()
led.purchase("2x4", 3, price=3.98)
led.save()
again = Ledger.load()
assert again.on_hand()["2x4"] == 3
assert again.stats()["spent"] == round(3 * 3.98, 2)
def test_stats_aggregate_across_projects():
led = Ledger()
led.record_build("table", 2, consumed={}, offcuts=[])
led.record_build("shelf", 1, consumed={}, offcuts=[])
st = led.stats()
assert st["units_built"] == 3 and st["builds"] == 2
assert st["by_project"] == {"table": 2, "shelf": 1}
def test_planner_consumes_offcuts_before_buying():
from woodshop.inventory import Piece
s = Scene()
s.place("2x4", 30)
# one 96" offcut on hand → the 30" piece should use it, buying 0 new sticks
offcut = Piece(id="oc1", stock="2x4", length_in=96, width_in=3.5,
is_sheet=False, is_offcut=True)
plan = build_cut_plan(s, available=[offcut])
assert plan.score["stock_count"] == 0 # bought nothing
assert plan.score["owned_count"] == 1
owned = [sp for sp in plan.stock_pieces if sp.owned]
assert owned and owned[0].placements # the 30" sits in the offcut

View File

@ -0,0 +1,37 @@
"""Offscreen tests for the Inventory window (read-only management view)."""
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402
from woodshop import inventory as I # noqa: E402
from woodshop.gui.inventory_window import InventoryWindow # noqa: E402
_app = QApplication.instance() or QApplication([])
def test_window_renders_ledger(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
led = I.Ledger()
led.purchase("2x4", 5, price=3.98)
led.record_build("table", 2, consumed={"2x4": 2}, offcuts=[
{"stock": "2x4", "length_in": 30, "width_in": 3.5, "is_sheet": False}],
dispositions=["keep"])
led.save()
w = InventoryWindow()
assert "2x4" in w._tabs["On-hand"].toPlainText()
assert "OFFCUT" in w._tabs["Offcut bin"].toPlainText()
assert "table" in w._tabs["Build history"].toPlainText()
stats = w._tabs["Stats"].toPlainText()
assert "Units built" in stats and "2" in stats
def test_empty_ledger_renders(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
w = InventoryWindow()
assert "empty" in w._tabs["On-hand"].toPlainText().lower()
assert "no builds" in w._tabs["Build history"].toPlainText().lower()

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