Resolves the v1 simplification where every species cost the same.
- CutItem/StockPiece gain `material`; build_cut_plan and reoptimize group by
(stock, material) so each bought stick/sheet is a single species; pieces are
stamped with their material (offcut seeds keep theirs).
- prices.estimate groups by (stock, species) and applies a per-species
multiplier (DEFAULT_MATERIAL_MULTIPLIERS: SPF 1.0, oak 3.0, walnut 5.5, …),
persisted to material_multipliers.json. CostLine shows "oak 1x4".
- PriceEditDialog gains a species-multiplier table; BOM Buy list shows species.
- Offcuts carry material so offcut reuse matches species.
- tests: multiplier scales price, default species at base, mixed species on the
same stock priced separately, multiplier save/load.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New shop-packet output: a printable cost estimate driven by the active
CutPlan's buy-counts × a curated, editable price book (HST 15%).
- prices.py: DEFAULT_PRICES seeded with real Kent (New Brunswick) shelf
prices per buy-unit (lumber = 8' stick, plywood = 4x8 sheet); persisted to
$XDG_CONFIG_HOME/woodshop/prices.json (defaults + saved overrides).
estimate() -> CostEstimate (lines/subtotal/tax/total/missing); lumber price
scales with stick length; unknown stock is flagged, never invented.
- BOM window: Cost tab with "Edit prices…" (PriceEditDialog), "Refresh from
Kent…", and Print.
- fetch_kent_prices() + scripts/fetch_kent_prices.py: best-effort refresh.
Kent renders prices client-side (not in HTML), so it tries a static parse
then Playwright if installed — honest that it may need updating.
- tests: estimate math, per-sheet plywood, stick-length scaling, missing-price
flagging, save/load roundtrip, corrupt-file fallback, JSON-LD parse, cost
tab render + price edit persistence. 153 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>