diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index b4821dd..e3b90c5 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -511,12 +511,13 @@ def build_cut_plan(scene, settings: ShopSettings | None = None, def _score(stock_pieces, s, strategy, warnings) -> dict: - waste_area = used_area = bought_area = 0.0 + waste_area = bought_used = bought_area = 0.0 reusable = 0 bought = [sp for sp in stock_pieces if not sp.owned] for sp in stock_pieces: used = sum(p.len_in * p.wid_in for p in sp.placements) - used_area += used + if not sp.owned: + bought_used += used # yield is about BOUGHT stock only if sp.is_sheet: if not sp.owned: bought_area += sp.length_in * sp.width_in @@ -537,7 +538,7 @@ def _score(stock_pieces, s, strategy, warnings) -> dict: "waste_area": round(waste_area, 1), "reusable_offcuts": reusable, "reusable_in": round(reusable_in, 1), - "yield_pct": round(used_area / bought_area * 100, 1) if bought_area else 0.0, + "yield_pct": round(bought_used / bought_area * 100, 1) if bought_area else 0.0, "warnings": list(warnings), } @@ -587,6 +588,7 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl seeds.setdefault((sp.stock, sp.material), {})[sp.id] = StockPiece( id=sp.id, stock=sp.stock, is_sheet=sp.is_sheet, length_in=sp.length_in, width_in=sp.width_in, material=sp.material, + owned=sp.owned, source=sp.source, placements=[Placement(id=p.id, item_id=p.item_id, x_in=p.x_in, y_in=p.y_in, len_in=p.len_in, wid_in=p.wid_in, rotated=p.rotated, locked=True) for p in kept]) @@ -773,6 +775,8 @@ def validate_cut_plan(plan: CutPlan) -> list: it = items.get(p.item_id) if it and it.stock != sp.stock: problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece") + elif it and it.material and sp.material and it.material != sp.material: + problems.append(f"{p.id} ({it.material}) is on a {sp.material} stock piece") if p.rotated and not rot_ok: problems.append(f"{p.id} is rotated but rotation isn't allowed") ps = sp.placements diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 4984282..ec013aa 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -144,7 +144,7 @@ class BomWindow(QDialog): self._rebuild_base() def _mark_purchased(self) -> None: - bought = Counter(sp.stock for sp in self._plan.stock_pieces + bought = Counter((sp.stock, sp.material) 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.") @@ -153,16 +153,19 @@ class BomWindow(QDialog): if not dlg.exec(): return rows, save = dlg.result() - for stock, qty, price in rows: - self._ledger.purchase(stock, qty, price=price) + mults = prices_mod.load_material_multipliers() + for stock, material, qty, price in rows: + self._ledger.purchase(stock, qty, material=material, price=price) if save: - self._prices[stock] = price + # price book stores the SPF base; back out the species multiplier + mult = mults.get(material, 1.0) or 1.0 + self._prices[stock] = round(price / mult, 2) 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.") + f"Added {sum(q for *_, q, _ in rows)} item(s) to shop inventory.") def _record_build(self) -> None: consumed, offcuts = plan_consumption(self._plan) @@ -261,8 +264,8 @@ class BomWindow(QDialog): it = plan.item(iid) lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}") lines += ["", "Yield (used / bought):"] - for stock in sorted({sp.stock for sp in plan.stock_pieces}): - sps = [sp for sp in plan.stock_pieces if sp.stock == stock] + for stock in sorted({sp.stock for sp in bought}): # bought only — matches score + sps = [sp for sp in bought if sp.stock == stock] sheet = sps[0].is_sheet used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements) cap = sum(sp.length_in * sp.width_in for sp in sps) @@ -500,7 +503,8 @@ class BomWindow(QDialog): self._set_plan(best) self._status.setText("✓ optimized around locked pieces") else: - self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity)) + self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity, + available=self._available())) self._status.setText("✓ optimized") def _best_of_n(self) -> None: @@ -513,7 +517,8 @@ class BomWindow(QDialog): self._set_plan(best) self._status.setText("✓ best of 100 around locked pieces") else: - self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity)) + self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity, + available=self._available())) self._status.setText("✓ best of 100 attempts") def _next_arrangement(self) -> None: @@ -521,7 +526,8 @@ class BomWindow(QDialog): self._order = (self._order + 1) % len(STRATEGIES) st = STRATEGIES[self._order] plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks() - else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity)) + else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity, + available=self._available())) self._set_plan(plan) def _draw_layout(self) -> None: @@ -600,12 +606,20 @@ class BomWindow(QDialog): if target is None: target = sp_cur # Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc. - item_stock = plan.item(p.item_id).stock + cut_item = plan.item(p.item_id) + item_stock = cut_item.stock if item_stock != target.stock: self._revert(plan, item.pid, home) self._status.setText(f"✗ {item_stock} can't go on {target.stock} — reverted") recompute(plan); self._refresh_all() return + # Species compatibility: an oak cut can't go on a spruce stick. + if (cut_item.material and target.material and cut_item.material != target.material): + self._revert(plan, item.pid, home) + self._status.setText(f"✗ {cut_item.material} can't go on " + f"{target.material} stock — reverted") + recompute(plan); self._refresh_all() + return row_y0 = self._row_of(target.id) x_in = max(item.pos().x() / px, 0.0) y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0 @@ -803,8 +817,10 @@ class PurchaseDialog(QDialog): def __init__(self, bought, prices, parent=None): super().__init__(parent) + from ..lumber import default_material self.setWindowTitle("Mark purchased") - self.resize(360, 320) + self.resize(380, 320) + mults = prices_mod.load_material_multipliers() v = QVBoxLayout(self) v.addWidget(QLabel("Add these to your shop inventory?")) rows = sorted(bought.items()) @@ -812,16 +828,18 @@ class PurchaseDialog(QDialog): 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) + for r, ((stock, material), qty) in enumerate(rows): + label = (f"{material} {stock}" if material and material != default_material(stock) + else stock) + name = QTableWidgetItem(label) 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))) + p.setValue(round(float(prices.get(stock, 0.0)) * (mults.get(material, 1.0) or 1.0), 2)) self._table.setCellWidget(r, 1, q) self._table.setCellWidget(r, 2, p) - self._spins.append((stock, q, p)) + self._spins.append((stock, material, q, p)) v.addWidget(self._table) self._save = QCheckBox("Save these prices to my price book") v.addWidget(self._save) @@ -830,8 +848,8 @@ class PurchaseDialog(QDialog): 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] + rows = [(stock, material, q.value(), round(p.value(), 2)) + for stock, material, q, p in self._spins if q.value() > 0] return rows, self._save.isChecked() diff --git a/src/woodshop/gui/inventory_window.py b/src/woodshop/gui/inventory_window.py index 550efec..9b7bf8b 100644 --- a/src/woodshop/gui/inventory_window.py +++ b/src/woodshop/gui/inventory_window.py @@ -46,10 +46,12 @@ class InventoryWindow(QDialog): oh = self._ledger.on_hand() if not oh: return "ON-HAND STOCK\n\n (empty — Mark purchased on the BOM window to add)" + from ..lumber import default_material lines = ["ON-HAND STOCK", ""] - for stock, qty in oh.items(): + for (stock, material), qty in oh.items(): unit = "sheet" if stock.startswith("ply-") else "stick" - lines.append(f" {qty:>3} × {stock:<10} ({unit}s)") + name = f"{material} {stock}" if material and material != default_material(stock) else stock + lines.append(f" {qty:>3} × {name:<14} ({unit}s)") return "\n".join(lines) def _offcuts_text(self) -> str: diff --git a/src/woodshop/inventory.py b/src/woodshop/inventory.py index b8afad1..59c991c 100644 --- a/src/woodshop/inventory.py +++ b/src/woodshop/inventory.py @@ -25,7 +25,8 @@ 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 +from .lumber import (SHEET_LENGTH_IN, SHEET_WIDTH_IN, actual_section, default_material, + is_plywood, normalize_stock) @dataclass @@ -51,7 +52,8 @@ def _data_path() -> Path: 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)) + consumed = Counter((sp.stock, getattr(sp, "material", "") or default_material(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: @@ -97,16 +99,18 @@ class Ledger: return f"b{n}" # ----- primitives (each appends events) ----------------------------- - def purchase(self, stock: str, qty: int, price: float | None = None, + def purchase(self, stock: str, qty: int, material: str = "", 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) + self._emit("purchase", stock=stock, material=material or default_material(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 adjust(self, stock: str, delta: int, material: str = "", reason: str = "", + date: str = "") -> None: + stock = normalize_stock(stock) + self._emit("adjustment", stock=stock, material=material or default_material(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) @@ -122,10 +126,13 @@ class Ledger: 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) + for key, qty in consumed.items(): + if not qty: + continue + stock, material = key if isinstance(key, tuple) else (key, default_material(key)) + self._emit("consume", stock=normalize_stock(stock), + material=material or default_material(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" @@ -142,14 +149,19 @@ class Ledger: # ----- derived state (fold the events) ------------------------------ def on_hand(self) -> dict: + """Full units on hand, keyed by (stock, material).""" c = Counter() + + def key(e): + return (e["stock"], e.get("material") or default_material(e["stock"])) + for e in self.events: if e["type"] == "purchase": - c[e["stock"]] += e["qty"] + c[key(e)] += e["qty"] elif e["type"] == "consume" and "stock" in e: - c[e["stock"]] -= e["qty"] + c[key(e)] -= e["qty"] elif e["type"] == "adjustment": - c[e["stock"]] += e["delta"] + c[key(e)] += e["delta"] return {k: v for k, v in sorted(c.items()) if v} def offcut_bin(self) -> list[Piece]: @@ -172,15 +184,15 @@ class Ledger: """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(): + for (stock, material), 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)) + pieces.append(Piece(id=f"{stock}:{material}#{i + 1}", stock=stock, + length_in=L, width_in=W, is_sheet=sheet, material=material)) return pieces def builds(self) -> list[dict]: diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index 708f1ed..16b9803 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -156,3 +156,22 @@ def test_record_build_writes_ledger(tmp_path, monkeypatch): w._ledger.save() again = I.Ledger.load() assert again.builds() and again.stats()["units_built"] == 1 + + +def test_optimize_preserves_offcut_toggle(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) + w._offcut_chk.setChecked(True) + assert w._plan.score["stock_count"] == 0 + w._optimize() # Codex #1: must NOT lose the offcut + assert w._plan.score["stock_count"] == 0 + w._best_of_n() + assert w._plan.score["stock_count"] == 0 diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index 089e7c0..d471f11 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -357,3 +357,46 @@ def test_batch_estimate_per_unit(): 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 + + +def test_locked_reoptimize_preserves_owned_offcut(): + """Codex #1: a locked offcut must stay owned through reoptimize, not become bought.""" + from woodshop.cutplan import reoptimize + from woodshop.inventory import Piece + s = Scene() + s.place("2x4", 30) + 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 + owned_piece = next(sp for sp in plan.stock_pieces if sp.owned) + owned_piece.placements[0].locked = True + re = reoptimize(s, plan, "decreasing") + assert re.score["stock_count"] == 0 # still bought nothing + assert re.score["owned_count"] == 1 + + +def test_validate_flags_cross_species(): + """Codex #3: an oak cut on a spruce stick is invalid.""" + from woodshop.cutplan import relocate + s = Scene() + s.place("1x4", 24) # spruce + s.place("1x4", 24) + s.set_material("p2", "oak") + plan = build_cut_plan(s) # two species → two pieces + spruce_sp = next(sp for sp in plan.stock_pieces if sp.material == "spruce") + oak_sp = next(sp for sp in plan.stock_pieces if sp.material == "oak") + relocate(plan, oak_sp.placements[0].id, spruce_sp.id, 40.0) + assert any("spruce stock piece" in p for p in validate_cut_plan(plan)) + + +def test_yield_excludes_owned_offcuts(): + """Codex #4: an owned-only plan shouldn't report 0% (div by bought=0) misleadingly.""" + from woodshop.inventory import Piece + s = Scene() + s.place("2x4", 30) + 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["yield_pct"] == 0.0 # nothing bought → no bought-yield + assert plan.score["stock_count"] == 0 diff --git a/tests/test_inventory.py b/tests/test_inventory.py index 0a79d26..0d3e4f8 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -8,16 +8,27 @@ 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} + assert led.on_hand() == {("2x4", "spruce"): 5, ("ply-3/4", "spruce-ply"): 2} led.record_build("table", 1, consumed={"2x4": 2}, offcuts=[]) - assert led.on_hand()["2x4"] == 3 + assert led.on_hand()[("2x4", "spruce")] == 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 + assert led.on_hand()[("2x4", "spruce")] == 4 + + +def test_inventory_is_species_aware(): + led = Ledger() + led.purchase("1x4", 3, material="oak") + led.purchase("1x4", 2, material="spruce") + oh = led.on_hand() + assert oh[("1x4", "oak")] == 3 and oh[("1x4", "spruce")] == 2 + # consuming oak doesn't touch spruce + led.record_build("x", 1, consumed={("1x4", "oak"): 1}, offcuts=[]) + assert led.on_hand()[("1x4", "oak")] == 2 and led.on_hand()[("1x4", "spruce")] == 2 def test_record_build_keeps_and_discards_offcuts(): @@ -46,7 +57,7 @@ def test_plan_consumption_from_cutplan(): 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 consumed.get(("2x4", "spruce")) == 1 assert offcuts and offcuts[0]["stock"] == "2x4" and offcuts[0]["length_in"] > 12 @@ -66,7 +77,7 @@ def test_ledger_save_load_roundtrip(tmp_path, monkeypatch): led.purchase("2x4", 3, price=3.98) led.save() again = Ledger.load() - assert again.on_hand()["2x4"] == 3 + assert again.on_hand()[("2x4", "spruce")] == 3 assert again.stats()["spent"] == round(3 * 3.98, 2) @@ -91,3 +102,15 @@ def test_planner_consumes_offcuts_before_buying(): 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 + + +def test_oak_project_not_satisfied_by_spruce_onhand(): + """Codex #2: a generic spruce 1x4 on-hand must NOT satisfy an oak 1x4 cut.""" + led = Ledger() + led.purchase("1x4", 5, material="spruce") + s = Scene() + s.place("1x4", 30) + s.set_material("p1", "oak") + plan = build_cut_plan(s, available=led.available_stock()) + assert plan.score["stock_count"] == 1 # must buy oak + assert plan.score["owned_count"] == 0