Compare commits
7 Commits
30bfb3a9e0
...
2ee4c56b3a
| Author | SHA1 | Date |
|---|---|---|
|
|
2ee4c56b3a | |
|
|
2b76317a3f | |
|
|
30a10adabc | |
|
|
59fff1cb6d | |
|
|
7adb7e27fc | |
|
|
882b0ec959 | |
|
|
c36ed3407e |
|
|
@ -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 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:
|
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}."
|
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:
|
def cmd_delete(scene: Scene, args) -> str:
|
||||||
return scene.delete(args.part)
|
return scene.delete(args.part)
|
||||||
|
|
||||||
|
|
@ -254,8 +269,8 @@ def _describe_part(p) -> str:
|
||||||
bits.append(f"tilt {p.tilt_deg:g}°")
|
bits.append(f"tilt {p.tilt_deg:g}°")
|
||||||
if p.yaw_deg:
|
if p.yaw_deg:
|
||||||
bits.append(f"yaw {p.yaw_deg:g}°")
|
bits.append(f"yaw {p.yaw_deg:g}°")
|
||||||
if p.finishes:
|
if p.finish != "raw":
|
||||||
bits.append(f"[{', '.join(p.finishes)}]")
|
bits.append(f"[{p.finish}{' ' + p.finish_color if p.finish_color else ''}]")
|
||||||
if p.features:
|
if p.features:
|
||||||
bits.append(f"{{{', '.join(f.kind for f in p.features)}}}")
|
bits.append(f"{{{', '.join(f.kind for f in p.features)}}}")
|
||||||
return f" {p.id}: " + ", ".join(bits)
|
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.add_argument("part", nargs="?", default=None, help="Board id (default: selection)")
|
||||||
sp.set_defaults(func=cmd_sand)
|
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 = sub.add_parser("delete", help="Delete a board")
|
||||||
sp.add_argument("part", nargs="?", default=None)
|
sp.add_argument("part", nargs="?", default=None)
|
||||||
sp.set_defaults(func=cmd_delete)
|
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))
|
||||||
|
|
@ -14,7 +14,7 @@ import hashlib
|
||||||
from dataclasses import asdict, dataclass, field, fields
|
from dataclasses import asdict, dataclass, field, fields
|
||||||
|
|
||||||
from .cutlist import cut_length
|
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
|
_EPS = 1e-6
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ class ShopSettings:
|
||||||
grain_direction: bool = False # honor grain (future; disables rotation)
|
grain_direction: bool = False # honor grain (future; disables rotation)
|
||||||
# tolerances — defaults present from day one even before they're in the UI
|
# tolerances — defaults present from day one even before they're in the UI
|
||||||
mortise_tenon_clearance_in: float = 1 / 32
|
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
|
reveal_in: float = 0.0
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
|
|
@ -53,10 +53,25 @@ class CutItem:
|
||||||
id: str
|
id: str
|
||||||
part_id: str
|
part_id: str
|
||||||
stock: str
|
stock: str
|
||||||
length_in: float
|
length_in: float # ROUGH cut size (what you cut from stock)
|
||||||
width_in: float
|
width_in: float
|
||||||
is_sheet: bool
|
is_sheet: bool
|
||||||
note: str = "" # e.g. "incl. tenon"
|
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
|
@dataclass
|
||||||
|
|
@ -88,6 +103,8 @@ class StockPiece:
|
||||||
width_in: float
|
width_in: float
|
||||||
placements: list = field(default_factory=list) # Placement
|
placements: list = field(default_factory=list) # Placement
|
||||||
waste: list = field(default_factory=list) # WasteRegion
|
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
|
@dataclass
|
||||||
|
|
@ -122,7 +139,8 @@ class CutPlan:
|
||||||
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
|
id=s["id"], stock=s["stock"], is_sheet=s["is_sheet"],
|
||||||
length_in=s["length_in"], width_in=s["width_in"],
|
length_in=s["length_in"], width_in=s["width_in"],
|
||||||
placements=[Placement(**p) for p in s.get("placements", [])],
|
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(
|
return cls(
|
||||||
settings=ShopSettings.from_dict(d.get("settings")),
|
settings=ShopSettings.from_dict(d.get("settings")),
|
||||||
items=[CutItem(**i) for i in d.get("items", [])],
|
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 = []
|
items = []
|
||||||
for n, p in enumerate(scene.parts, 1):
|
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(
|
items.append(CutItem(
|
||||||
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
id=f"ci{n}", part_id=p.id, stock=p.stock,
|
||||||
length_in=round(ln, 3), width_in=round(p.section_in[1], 3),
|
length_in=rough_len, width_in=rough_wid,
|
||||||
is_sheet=is_plywood(p.stock),
|
final_length_in=final_len, final_width_in=final_wid,
|
||||||
note="incl. tenon" if ln > p.length_in + _EPS else ""))
|
is_sheet=sheet, note=note))
|
||||||
return items
|
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)
|
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,
|
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()
|
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}
|
by_id = {it.id: it for it in items}
|
||||||
|
|
||||||
counter = {"n": 0}
|
counter = {"n": 0}
|
||||||
|
|
@ -421,9 +471,15 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
|
|
||||||
stock_pieces, unplaced, warnings = [], [], []
|
stock_pieces, unplaced, warnings = [], [], []
|
||||||
for stock, its in by_stock.items():
|
for stock, its in by_stock.items():
|
||||||
|
seeds = _offcut_seeds(available, stock, ids)
|
||||||
if its[0].is_sheet:
|
if its[0].is_sheet:
|
||||||
sps, un = (_pack_plywood_guillotine(its, stock, s, ids) if strategy == "guillotine"
|
if seeds:
|
||||||
else _pack_plywood(its, stock, s, ids))
|
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":
|
elif strategy == "exact":
|
||||||
sps, un = _pack_lumber_exact(its, stock, s, ids)
|
sps, un = _pack_lumber_exact(its, stock, s, ids)
|
||||||
else:
|
else:
|
||||||
|
|
@ -444,14 +500,17 @@ def build_cut_plan(scene, settings: ShopSettings | None = None,
|
||||||
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||||
waste_area = used_area = bought_area = 0.0
|
waste_area = used_area = bought_area = 0.0
|
||||||
reusable = 0
|
reusable = 0
|
||||||
|
bought = [sp for sp in stock_pieces if not sp.owned]
|
||||||
for sp in stock_pieces:
|
for sp in stock_pieces:
|
||||||
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
used = sum(p.len_in * p.wid_in for p in sp.placements)
|
||||||
used_area += used
|
used_area += used
|
||||||
if sp.is_sheet:
|
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
|
waste_area += sp.length_in * sp.width_in - used
|
||||||
else:
|
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:
|
for w in sp.waste:
|
||||||
waste_area += w.length_in * (w.width_in or sp.width_in)
|
waste_area += w.length_in * (w.width_in or sp.width_in)
|
||||||
if w.reusable:
|
if w.reusable:
|
||||||
|
|
@ -460,7 +519,8 @@ def _score(stock_pieces, s, strategy, warnings) -> dict:
|
||||||
for w in sp.waste if w.reusable)
|
for w in sp.waste if w.reusable)
|
||||||
return {
|
return {
|
||||||
"strategy_name": strategy,
|
"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),
|
"waste_area": round(waste_area, 1),
|
||||||
"reusable_offcuts": reusable,
|
"reusable_offcuts": reusable,
|
||||||
"reusable_in": round(reusable_in, 1),
|
"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
|
"""Re-pack while PRESERVING locked placements where they sit. Unlocked pieces
|
||||||
are packed into the free space around locked ones first (free segments on
|
are packed into the free space around locked ones first (free segments on
|
||||||
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
|
seeded sticks / free rectangles on seeded sheets), then onto new stock."""
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
s = base_plan.settings
|
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 = [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}
|
locked_ids = {p.item_id for p in locked}
|
||||||
counter = {"n": 0}
|
counter = {"n": 0}
|
||||||
|
|
@ -576,14 +642,16 @@ def _plan_key(plan: CutPlan):
|
||||||
STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"]
|
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
|
"""Find a better layout by trying several strategies + shuffle restarts and
|
||||||
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
|
keeping the best-scoring one. (Good and explainable, not provably optimal.)"""
|
||||||
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
|
strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"]
|
||||||
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
|
strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))]
|
||||||
best = None
|
best = None
|
||||||
for st in strategies:
|
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):
|
if best is None or _plan_key(plan) < _plan_key(best):
|
||||||
best = plan
|
best = plan
|
||||||
if best is not None:
|
if best is not None:
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ class EstimateRates:
|
||||||
# --- consumable unit costs ($) ---
|
# --- consumable unit costs ($) ---
|
||||||
screw_unit_cost: float = 0.10
|
screw_unit_cost: float = 0.10
|
||||||
glue_cost_per_oz: float = 0.55
|
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 ---
|
# --- consumable quantities ---
|
||||||
screws_per_butt_joint: float = 2.0
|
screws_per_butt_joint: float = 2.0
|
||||||
glue_oz_per_connection: float = 0.5
|
glue_oz_per_connection: float = 0.5
|
||||||
|
|
@ -44,7 +46,9 @@ class EstimateRates:
|
||||||
min_per_cut: float = 3.0
|
min_per_cut: float = 3.0
|
||||||
min_per_butt_joint: float = 5.0
|
min_per_butt_joint: float = 5.0
|
||||||
min_per_connection: float = 8.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: {
|
min_per_feature: dict = field(default_factory=lambda: {
|
||||||
"tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0,
|
"tenon": 10.0, "mortise": 12.0, "hole": 2.0, "slot": 8.0,
|
||||||
"dado": 6.0, "rabbet": 6.0, "chamfer": 4.0})
|
"dado": 6.0, "rabbet": 6.0, "chamfer": 4.0})
|
||||||
|
|
@ -63,9 +67,11 @@ def load_rates() -> EstimateRates:
|
||||||
saved = json.loads(path.read_text())
|
saved = json.loads(path.read_text())
|
||||||
base = asdict(rates)
|
base = asdict(rates)
|
||||||
for k, v in saved.items():
|
for k, v in saved.items():
|
||||||
if k == "min_per_feature" and isinstance(v, dict):
|
if k not in base:
|
||||||
base["min_per_feature"].update({fk: float(fv) for fk, fv in v.items()})
|
continue
|
||||||
elif k in base and not isinstance(base[k], dict):
|
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)
|
base[k] = float(v)
|
||||||
rates = EstimateRates(**base)
|
rates = EstimateRates(**base)
|
||||||
except (ValueError, OSError, TypeError):
|
except (ValueError, OSError, TypeError):
|
||||||
|
|
@ -79,31 +85,30 @@ def save_rates(rates: EstimateRates) -> None:
|
||||||
path.write_text(json.dumps(asdict(rates), indent=2))
|
path.write_text(json.dumps(asdict(rates), indent=2))
|
||||||
|
|
||||||
|
|
||||||
def count_ops(scene, plan) -> dict:
|
def count_ops(scene, plan, quantity: int = 1) -> dict:
|
||||||
"""Deterministic operation counts off the scene + cut plan."""
|
"""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
|
from collections import Counter
|
||||||
|
|
||||||
|
q = max(1, quantity)
|
||||||
feats = Counter(f.kind for p in scene.parts for f in p.features)
|
feats = Counter(f.kind for p in scene.parts for f in p.features)
|
||||||
return {
|
return {
|
||||||
"parts": len(scene.parts),
|
"parts": len(scene.parts) * q,
|
||||||
"cuts": len(plan.items), # one crosscut per cut item
|
"cuts": len(plan.items), # plan already ×N for a batch
|
||||||
"butt_joints": len(scene.joints),
|
"butt_joints": len(scene.joints) * q,
|
||||||
"connections": len(scene.connections),
|
"connections": len(scene.connections) * q,
|
||||||
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS),
|
"glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS) * q,
|
||||||
"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") * q,
|
||||||
"features": dict(feats),
|
"features": {k: v * q for k, v in feats.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _finished_sqft(scene) -> float:
|
def _part_sqft(part) -> float:
|
||||||
total = 0.0
|
"""Total surface area (all six faces) of a board, in sq ft."""
|
||||||
for p in scene.parts:
|
t, w = part.section_in
|
||||||
if not p.finishes:
|
L = part.length_in
|
||||||
continue
|
return 2 * (L * w + L * t + w * t) / 144.0
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -120,11 +125,20 @@ class ProjectEstimate:
|
||||||
labor_lines: list # list[Line] (time breakdown, cost per group)
|
labor_lines: list # list[Line] (time breakdown, cost per group)
|
||||||
labor_minutes: float
|
labor_minutes: float
|
||||||
rates: EstimateRates
|
rates: EstimateRates
|
||||||
|
quantity: int = 1 # how many units this estimate covers
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def material_cost(self) -> float:
|
def material_cost(self) -> float:
|
||||||
return self.material.total # includes HST — what you pay
|
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
|
@property
|
||||||
def consumable_cost(self) -> float:
|
def consumable_cost(self) -> float:
|
||||||
return round(sum(l.cost for l in self.consumables), 2)
|
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,
|
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()
|
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)
|
material = prices_mod.estimate(plan, prices, hst=hst)
|
||||||
|
|
||||||
# --- consumables ---
|
# --- consumables ---
|
||||||
|
|
@ -174,10 +189,18 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non
|
||||||
if glue_oz:
|
if glue_oz:
|
||||||
consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/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)))
|
round(glue_oz * rates.glue_cost_per_oz, 2)))
|
||||||
sqft = _finished_sqft(scene)
|
# finish material: per part, priced by its finish kind × surface area (×N units)
|
||||||
if sqft:
|
finish_cost, finish_sqft, kinds = 0.0, 0.0, set()
|
||||||
consumables.append(Line("Finish", f"{sqft:.1f} sq ft × ${rates.finish_cost_per_sqft:.2f}",
|
for p in scene.parts:
|
||||||
round(sqft * rates.finish_cost_per_sqft, 2)))
|
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) ---
|
# --- labor (minutes -> cost) ---
|
||||||
def line(label, minutes, n_detail):
|
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)"),
|
f"{ops['butt_joints']} joint(s)"),
|
||||||
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
|
("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection,
|
||||||
f"{ops['connections']} connection(s)"),
|
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)"),
|
f"{ops['finished_parts']} part(s)"),
|
||||||
]
|
]
|
||||||
for kind, n in sorted(ops["features"].items()):
|
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,
|
return ProjectEstimate(material=material, consumables=consumables,
|
||||||
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
|
labor_lines=labor_lines, labor_minutes=round(total_min, 1),
|
||||||
rates=rates)
|
rates=rates, quantity=q)
|
||||||
|
|
||||||
|
|
||||||
def _money(v: float) -> str:
|
def _money(v: float) -> str:
|
||||||
|
|
@ -214,7 +238,8 @@ def _money(v: float) -> str:
|
||||||
|
|
||||||
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
||||||
hrs = est.labor_minutes / 60.0
|
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", ""]
|
"editable estimate — verify before quoting", ""]
|
||||||
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
|
lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}")
|
||||||
for l in est.consumables:
|
for l in est.consumables:
|
||||||
|
|
@ -236,4 +261,8 @@ def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str:
|
||||||
lines += [" " + "=" * 42,
|
lines += [" " + "=" * 42,
|
||||||
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
|
f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}",
|
||||||
f" {'(profit ' + _money(est.profit) + ')':<30}"]
|
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)
|
return "\n".join(lines)
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ import subprocess
|
||||||
from PySide6.QtCore import Qt, QThreadPool
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||||
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
||||||
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
|
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||||
QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
QDoubleSpinBox, QFormLayout, QGraphicsItem, QGraphicsRectItem,
|
||||||
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
|
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView,
|
||||||
QHeaderView, QLabel, QMenu, QPushButton, QScrollArea,
|
QHBoxLayout, QHeaderView, QLabel, QMenu, QMessageBox,
|
||||||
QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit,
|
QPushButton, QScrollArea, QSpinBox, QTableWidget,
|
||||||
QVBoxLayout, QWidget)
|
QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from collections import Counter
|
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 ..jigs import explain_prompt, format_jigs, suggest_jigs
|
||||||
from .. import prices as prices_mod
|
from .. import prices as prices_mod
|
||||||
from .. import estimate as estimate_mod
|
from .. import estimate as estimate_mod
|
||||||
|
from .. import inventory as inventory_mod
|
||||||
|
from ..inventory import plan_consumption
|
||||||
from .workers import run_async
|
from .workers import run_async
|
||||||
|
|
||||||
_PX = 7.0 # pixels per inch in the layout view
|
_PX = 7.0 # pixels per inch in the layout view
|
||||||
|
|
@ -74,6 +76,7 @@ class BomWindow(QDialog):
|
||||||
self.resize(820, 640)
|
self.resize(820, 640)
|
||||||
self._order = 0
|
self._order = 0
|
||||||
self._optimized = False
|
self._optimized = False
|
||||||
|
self._quantity = 1
|
||||||
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
|
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
|
||||||
self._px = _PX
|
self._px = _PX
|
||||||
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
||||||
|
|
@ -81,6 +84,8 @@ class BomWindow(QDialog):
|
||||||
|
|
||||||
self._prices = prices_mod.load_prices()
|
self._prices = prices_mod.load_prices()
|
||||||
self._rates = estimate_mod.load_rates()
|
self._rates = estimate_mod.load_rates()
|
||||||
|
self._ledger = inventory_mod.Ledger.load()
|
||||||
|
self._use_offcuts = False
|
||||||
self._cut_te = self._mono_te()
|
self._cut_te = self._mono_te()
|
||||||
self._shop_te = self._mono_te()
|
self._shop_te = self._mono_te()
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
|
|
@ -90,10 +95,96 @@ class BomWindow(QDialog):
|
||||||
tabs.addTab(self._layout_tab(), "Cut Layout")
|
tabs.addTab(self._layout_tab(), "Cut Layout")
|
||||||
tabs.addTab(self._instructions_tab(), "Instructions")
|
tabs.addTab(self._instructions_tab(), "Instructions")
|
||||||
tabs.addTab(self._jigs_tab(), "Jigs")
|
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 = QVBoxLayout(self)
|
||||||
|
root.addLayout(header)
|
||||||
root.addWidget(tabs)
|
root.addWidget(tabs)
|
||||||
self._refresh_all()
|
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 ---------------------
|
# ----- one active plan; all tabs render from it ---------------------
|
||||||
def _set_plan(self, plan) -> None:
|
def _set_plan(self, plan) -> None:
|
||||||
recompute(plan) # keep waste/score truthful after any change
|
recompute(plan) # keep waste/score truthful after any change
|
||||||
|
|
@ -126,16 +217,25 @@ class BomWindow(QDialog):
|
||||||
|
|
||||||
def _cut_text(self) -> str:
|
def _cut_text(self) -> str:
|
||||||
plan = self._plan
|
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)
|
for it in plan.items)
|
||||||
lines = ["CUT LIST", ""]
|
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:
|
if sheet:
|
||||||
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
|
size = f"{_fmt_len(wd)} × {_fmt_len(ln)}"
|
||||||
f" ({wd * ln / 144 * n:.1f} sq ft)")
|
extra = f"({wd * ln / 144 * n:.1f} sq ft)"
|
||||||
else:
|
else:
|
||||||
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
|
size = f"@ {_fmt_len(ln)}"
|
||||||
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
|
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:
|
if not plan.items:
|
||||||
lines.append(" (nothing to cut yet)")
|
lines.append(" (nothing to cut yet)")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
@ -143,12 +243,16 @@ class BomWindow(QDialog):
|
||||||
def _shop_text(self) -> str:
|
def _shop_text(self) -> str:
|
||||||
plan = self._plan
|
plan = self._plan
|
||||||
lines = ["SHOPPING LIST", "", "Buy:"]
|
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 ""
|
s = "s" if qty != 1 else ""
|
||||||
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
|
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
|
||||||
lines.append(f" {qty} × {stock} {unit}")
|
lines.append(f" {qty} × {stock} {unit}")
|
||||||
if not plan.stock_pieces:
|
if not bought:
|
||||||
lines.append(" (nothing yet)")
|
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:
|
if plan.unplaced:
|
||||||
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
|
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
|
||||||
for iid in plan.unplaced:
|
for iid in plan.unplaced:
|
||||||
|
|
@ -215,7 +319,8 @@ class BomWindow(QDialog):
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _cost_text(self) -> str:
|
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)
|
return estimate_mod.format_estimate(est)
|
||||||
|
|
||||||
def _on_margin_changed(self, value: float) -> None:
|
def _on_margin_changed(self, value: float) -> None:
|
||||||
|
|
@ -391,7 +496,7 @@ class BomWindow(QDialog):
|
||||||
self._set_plan(best)
|
self._set_plan(best)
|
||||||
self._status.setText("✓ optimized around locked pieces")
|
self._status.setText("✓ optimized around locked pieces")
|
||||||
else:
|
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")
|
self._status.setText("✓ optimized")
|
||||||
|
|
||||||
def _best_of_n(self) -> None:
|
def _best_of_n(self) -> None:
|
||||||
|
|
@ -404,7 +509,7 @@ class BomWindow(QDialog):
|
||||||
self._set_plan(best)
|
self._set_plan(best)
|
||||||
self._status.setText("✓ best of 100 around locked pieces")
|
self._status.setText("✓ best of 100 around locked pieces")
|
||||||
else:
|
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")
|
self._status.setText("✓ best of 100 attempts")
|
||||||
|
|
||||||
def _next_arrangement(self) -> None:
|
def _next_arrangement(self) -> None:
|
||||||
|
|
@ -412,7 +517,7 @@ class BomWindow(QDialog):
|
||||||
self._order = (self._order + 1) % len(STRATEGIES)
|
self._order = (self._order + 1) % len(STRATEGIES)
|
||||||
st = STRATEGIES[self._order]
|
st = STRATEGIES[self._order]
|
||||||
plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks()
|
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)
|
self._set_plan(plan)
|
||||||
|
|
||||||
def _draw_layout(self) -> None:
|
def _draw_layout(self) -> None:
|
||||||
|
|
@ -421,7 +526,12 @@ class BomWindow(QDialog):
|
||||||
self._rows = []
|
self._rows = []
|
||||||
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
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}
|
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
|
px, y, bar = self._px, 30.0, 34.0
|
||||||
|
|
||||||
sc = plan.score
|
sc = plan.score
|
||||||
|
|
@ -600,57 +710,148 @@ class PriceEditDialog(QDialog):
|
||||||
|
|
||||||
|
|
||||||
class RatesEditDialog(QDialog):
|
class RatesEditDialog(QDialog):
|
||||||
"""Edit labor rate, per-operation minutes, and consumable costs."""
|
"""Edit labor rate, per-operation minutes, and consumable costs. Renders
|
||||||
|
scalar rate fields as spin boxes and dict fields (per-feature time, finish
|
||||||
# field -> (label, suffix, step)
|
cost/time by kind) as labelled sub-sections — generic over EstimateRates."""
|
||||||
_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),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, rates, parent=None):
|
def __init__(self, rates, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Edit rates")
|
self.setWindowTitle("Edit rates")
|
||||||
self.resize(380, 560)
|
self.resize(400, 600)
|
||||||
self._rates = rates
|
self._rates = rates
|
||||||
self._spins = {}
|
self._spins = {} # field -> spin (scalars)
|
||||||
self._feat_spins = {}
|
self._dict_spins = {} # field -> {key -> spin}
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
area = QScrollArea(); area.setWidgetResizable(True)
|
area = QScrollArea(); area.setWidgetResizable(True)
|
||||||
body = QWidget(); form = QFormLayout(body)
|
body = QWidget(); form = QFormLayout(body)
|
||||||
for field, label, suffix, step in self._SCALARS:
|
for field, val in asdict(rates).items():
|
||||||
sp = QDoubleSpinBox()
|
if isinstance(val, dict):
|
||||||
sp.setRange(0.0, 100000.0); sp.setSingleStep(step); sp.setSuffix(suffix)
|
form.addRow(QLabel(f"— {self._pretty(field)} —"))
|
||||||
sp.setValue(float(getattr(rates, field)))
|
self._dict_spins[field] = {}
|
||||||
self._spins[field] = sp
|
for key, v in sorted(val.items()):
|
||||||
form.addRow(label, sp)
|
sp = self._spin(field, v)
|
||||||
form.addRow(QLabel("— Joinery time (minutes each) —"))
|
self._dict_spins[field][key] = sp
|
||||||
for kind, minutes in sorted(rates.min_per_feature.items()):
|
form.addRow(key, sp)
|
||||||
sp = QDoubleSpinBox()
|
else:
|
||||||
sp.setRange(0.0, 1000.0); sp.setSingleStep(0.5); sp.setSuffix(" min")
|
sp = self._spin(field, val)
|
||||||
sp.setValue(float(minutes))
|
self._spins[field] = sp
|
||||||
self._feat_spins[kind] = sp
|
form.addRow(self._pretty(field), sp)
|
||||||
form.addRow(kind, sp)
|
|
||||||
area.setWidget(body)
|
area.setWidget(body)
|
||||||
outer.addWidget(area)
|
outer.addWidget(area)
|
||||||
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||||
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
|
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
|
||||||
outer.addWidget(bb)
|
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):
|
def rates(self):
|
||||||
for field, sp in self._spins.items():
|
for field, sp in self._spins.items():
|
||||||
setattr(self._rates, field, sp.value())
|
setattr(self._rates, field, sp.value())
|
||||||
for kind, sp in self._feat_spins.items():
|
for field, spins in self._dict_spins.items():
|
||||||
self._rates.min_per_feature[kind] = sp.value()
|
d = getattr(self._rates, field)
|
||||||
|
for key, sp in spins.items():
|
||||||
|
d[key] = sp.value()
|
||||||
return self._rates
|
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]
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ TOOL_CMD = {
|
||||||
"wood-assemble": lambda a: (cli.cmd_assemble, SimpleNamespace()),
|
"wood-assemble": lambda a: (cli.cmd_assemble, SimpleNamespace()),
|
||||||
"wood-disconnect": lambda a: (cli.cmd_disconnect, SimpleNamespace(connection=a["connection"])),
|
"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-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-delete": lambda a: (cli.cmd_delete, SimpleNamespace(part=_opt(a.get("part")))),
|
||||||
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
|
"wood-select": lambda a: (cli.cmd_select, SimpleNamespace(part=a["part"])),
|
||||||
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
|
"wood-undo": lambda a: (cli.cmd_undo, SimpleNamespace()),
|
||||||
|
|
@ -176,7 +181,12 @@ class Controller(QObject):
|
||||||
# group-aware (act on the whole selection)
|
# group-aware (act on the whole selection)
|
||||||
def stand(self): self._do_group(lambda pid: self.scene.stand(pid), "Stood up")
|
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 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 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):
|
def move_selected(self, dx=0.0, dy=0.0, dz=0.0):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -119,6 +119,9 @@ class MainWindow(QMainWindow):
|
||||||
"build a bookshelf side: two {H} 2x4 uprights {W} apart with {N} shelves of 1x8 between them",
|
"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")]))
|
[("Height", "48 in"), ("Width", "12 in"), ("Shelves", "3")]))
|
||||||
|
|
||||||
|
s = mb.addMenu("&Shop")
|
||||||
|
self._act(s, "Inventory…", self._show_inventory)
|
||||||
|
|
||||||
h = mb.addMenu("&Help")
|
h = mb.addMenu("&Help")
|
||||||
self._act(h, "Commands…", self._show_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 = BomWindow(self.controller, self) # keep a ref so it isn't GC'd
|
||||||
self._bom.show()
|
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):
|
def _show_help(self):
|
||||||
QMessageBox.information(self, "Commands", _HELP)
|
QMessageBox.information(self, "Commands", _HELP)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ that solves "delete that" ambiguity)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
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,
|
QFormLayout, QGridLayout, QGroupBox, QHBoxLayout,
|
||||||
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
|
QInputDialog, QLabel, QMenu, QPushButton, QTreeWidget,
|
||||||
QTreeWidgetItem, QVBoxLayout, QWidget)
|
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
|
from .controller import Controller
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ class PartsPanel(QWidget):
|
||||||
actions = [
|
actions = [
|
||||||
("Stand", lambda: self.c.stand()), ("Lay", lambda: self.c.lay()),
|
("Stand", lambda: self.c.stand()), ("Lay", lambda: self.c.lay()),
|
||||||
("Rotate 90°", lambda: self.c.rotate_90()), ("Sand", lambda: self.c.sand()),
|
("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),
|
("Duplicate", lambda: self.c.duplicate()), ("Rename", self._rename),
|
||||||
("Delete", lambda: self.c.delete()),
|
("Delete", lambda: self.c.delete()),
|
||||||
]
|
]
|
||||||
|
|
@ -119,9 +121,10 @@ class PartsPanel(QWidget):
|
||||||
part = self._selected_part()
|
part = self._selected_part()
|
||||||
if part:
|
if part:
|
||||||
ori = "vertical" if part.is_vertical else f"yaw {part.yaw_deg:g}°, tilt {part.tilt_deg:g}°"
|
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>"
|
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.len_spin.setValue(part.length_in)
|
||||||
self.yaw_spin.setValue(part.yaw_deg)
|
self.yaw_spin.setValue(part.yaw_deg)
|
||||||
self.tilt_spin.setValue(part.tilt_deg)
|
self.tilt_spin.setValue(part.tilt_deg)
|
||||||
|
|
@ -183,6 +186,26 @@ class PartsPanel(QWidget):
|
||||||
if ok and name.strip():
|
if ok and name.strip():
|
||||||
self.c.rename(part.id, 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:
|
def _apply_length(self) -> None:
|
||||||
part = self._selected_part()
|
part = self._selected_part()
|
||||||
if part and not self._loading and abs(self.len_spin.value() - part.length_in) > 1e-6:
|
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:
|
if joinery:
|
||||||
sections.append(("Mark and cut the joinery", joinery))
|
sections.append(("Mark and cut the joinery", joinery))
|
||||||
|
|
||||||
sanded = [names[p.id] for p in scene.parts if "sanded" in p.finishes]
|
prepped = [names[p.id] for p in scene.parts if p.finish != "raw"]
|
||||||
sections.append(("Sand", [f"Sand {', '.join(sanded)} smooth." if sanded
|
sections.append(("Sand", [f"Sand {', '.join(prepped)} smooth." if prepped
|
||||||
else "Sand all parts smooth."]))
|
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 = []
|
asm = []
|
||||||
for c in scene.connections:
|
for c in scene.connections:
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,29 @@ def is_plywood(stock: str) -> bool:
|
||||||
return normalize_stock(stock).startswith("ply-")
|
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:
|
def plywood_thickness(stock: str) -> float:
|
||||||
num, den = normalize_stock(stock).split("-", 1)[1].split("/")
|
num, den = normalize_stock(stock).split("-", 1)[1].split("/")
|
||||||
return float(num) / float(den)
|
return float(num) / float(den)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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
|
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 = []
|
lines = []
|
||||||
for stock, qty in sorted(counts.items()):
|
for stock, qty in sorted(counts.items()):
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field, fields, asdict
|
from dataclasses import dataclass, field, fields, asdict
|
||||||
from pathlib import Path
|
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
|
SCENE_VERSION = 1
|
||||||
|
|
||||||
|
|
@ -137,6 +138,10 @@ EDGE_KINDS = {"chamfer"}
|
||||||
FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS
|
FEATURE_KINDS = ADD_KINDS | CUT_KINDS | EDGE_KINDS
|
||||||
FACES = ("end_a", "end_b", "top", "bottom", "left", "right")
|
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
|
@dataclass
|
||||||
class Feature:
|
class Feature:
|
||||||
|
|
@ -204,7 +209,9 @@ class Part:
|
||||||
tilt_deg: float = 0.0 # elevation from horizontal toward +Z (90 = standing up)
|
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
|
roll_deg: float = 0.0 # rotation about the board's own length axis
|
||||||
name: str = "" # optional human alias, e.g. "front-left leg"
|
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)
|
features: list[Feature] = field(default_factory=list)
|
||||||
|
|
||||||
def local_frame(self) -> tuple[tuple, tuple, tuple]:
|
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
|
@dataclass
|
||||||
class Joint:
|
class Joint:
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -394,11 +422,31 @@ class Scene:
|
||||||
self.selection = pid
|
self.selection = pid
|
||||||
return part
|
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()
|
self._checkpoint()
|
||||||
part = self.resolve(ref)
|
part = self.resolve(ref)
|
||||||
if kind not in part.finishes:
|
part.finish = kind
|
||||||
part.finishes.append(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
|
self.selection = part.id
|
||||||
return part
|
return part
|
||||||
|
|
||||||
|
|
@ -515,7 +563,7 @@ class Scene:
|
||||||
position_in=[src.position_in[0] + dx, src.position_in[1] + dy,
|
position_in=[src.position_in[0] + dx, src.position_in[1] + dy,
|
||||||
src.position_in[2] + dz],
|
src.position_in[2] + dz],
|
||||||
yaw_deg=src.yaw_deg, tilt_deg=src.tilt_deg, roll_deg=src.roll_deg,
|
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.parts.append(clone)
|
||||||
self.selection = pid
|
self.selection = pid
|
||||||
return clone
|
return clone
|
||||||
|
|
@ -767,6 +815,8 @@ class Scene:
|
||||||
p = dict(p)
|
p = dict(p)
|
||||||
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
|
if "rotation_deg" in p and "yaw_deg" not in p: # migrate old scenes
|
||||||
p["yaw_deg"] = p.pop("rotation_deg")
|
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["section_in"] = tuple(p["section_in"])
|
||||||
p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields})
|
p["features"] = [Feature(**{k: v for k, v in f.items() if k in feat_fields})
|
||||||
for f in p.get("features", [])]
|
for f in p.get("features", [])]
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,25 @@ import argparse
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
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"]
|
_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):
|
def _featured_mesh(part: Part):
|
||||||
"""Tessellate the true build123d solid (with joinery booleans) for display."""
|
"""Tessellate the true build123d solid (with joinery booleans) for display."""
|
||||||
import pyvista as pv
|
import pyvista as pv
|
||||||
|
|
@ -139,7 +152,7 @@ def _render(plotter, scene: Scene) -> None:
|
||||||
mesh = _part_mesh(part)
|
mesh = _part_mesh(part)
|
||||||
plotter.add_mesh(
|
plotter.add_mesh(
|
||||||
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
|
show_edges=not part.features, # plain boxes: real quad edges
|
||||||
line_width=3 if edge else 1,
|
line_width=3 if edge else 1,
|
||||||
edge_color="black",
|
edge_color="black",
|
||||||
|
|
|
||||||
|
|
@ -125,3 +125,34 @@ def test_valid_move_commits(tmp_path):
|
||||||
second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
|
second.setPos(50 * w._px, second.pos().y()) # slide it right, still clear
|
||||||
w._drop_piece(second, home)
|
w._drop_piece(second, home)
|
||||||
assert "placed" in w._status.text().lower()
|
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
|
||||||
|
|
|
||||||
|
|
@ -290,3 +290,70 @@ def test_custom_settings_kerf():
|
||||||
s.place("2x4", 48)
|
s.place("2x4", 48)
|
||||||
# zero kerf -> both 48" fit in one 96" stick
|
# zero kerf -> both 48" fit in one 96" stick
|
||||||
assert build_cut_plan(s, settings=ShopSettings(kerf_in=0.0)).score["stock_count"] == 1
|
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
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ def test_labor_scales_with_rate_and_ops():
|
||||||
s.place("2x4", 24)
|
s.place("2x4", 24)
|
||||||
plan = build_cut_plan(s)
|
plan = build_cut_plan(s)
|
||||||
rates = E.EstimateRates(labor_rate_per_hr=60.0, setup_min=0, min_per_cut=10,
|
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)
|
est = E.project_estimate(s, plan, prices={"2x4": 4.0}, rates=rates)
|
||||||
# 1 cut × 10 min = 10 min = 1/6 h × $60 = $10
|
# 1 cut × 10 min = 10 min = 1/6 h × $60 = $10
|
||||||
assert est.labor_minutes == 10.0
|
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}))
|
text = E.format_estimate(E.project_estimate(s, plan, prices={"2x4": 4.0}))
|
||||||
for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"):
|
for token in ("Materials", "Labor", "TOTAL COST", "SUGGESTED PRICE"):
|
||||||
assert token in text
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")
|
s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b")
|
||||||
|
|
||||||
p1, p2 = s.get_part("p1"), s.get_part("p2")
|
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)
|
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
|
||||||
assert p2.position_in[0] == pytest.approx(62.0)
|
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 ->
|
# butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide ->
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue