diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index f121339..88694c5 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -59,6 +59,7 @@ class CutItem: 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: @@ -431,9 +432,14 @@ def _pack_plywood_seeded(items, stock, s, ids, seeds) -> tuple[list, list]: def build_cut_plan(scene, settings: ShopSettings | None = None, - strategy: str = "decreasing") -> CutPlan: + strategy: str = "decreasing", quantity: int = 1) -> CutPlan: + from dataclasses import replace + s = settings or ShopSettings() items = _cut_items(scene, s) + if quantity > 1: # batch: replicate cut demand per unit + items = [replace(it, id=f"{it.id}u{u}", unit=u) + for u in range(1, quantity + 1) for it in items] by_id = {it.id: it for it in items} counter = {"n": 0} @@ -516,8 +522,14 @@ def reoptimize(scene, base_plan: CutPlan, strategy: str = "decreasing") -> CutPl """Re-pack while PRESERVING locked placements where they sit. Unlocked pieces are packed into the free space around locked ones first (free segments on seeded sticks / free rectangles on seeded sheets), then onto new stock.""" + from dataclasses import replace + s = base_plan.settings items = _cut_items(scene, s) + quantity = max((getattr(it, "unit", 1) for it in base_plan.items), default=1) + if quantity > 1: # preserve the batch the base plan covered + items = [replace(it, id=f"{it.id}u{u}", unit=u) + for u in range(1, quantity + 1) for it in items] locked = [p for sp in base_plan.stock_pieces for p in sp.placements if p.locked] locked_ids = {p.item_id for p in locked} counter = {"n": 0} @@ -604,14 +616,15 @@ def _plan_key(plan: CutPlan): STRATEGIES = ["decreasing", "bestfit", "exact", "guillotine", "increasing", "shuffle"] -def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24) -> CutPlan: +def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 24, + quantity: int = 1) -> CutPlan: """Find a better layout by trying several strategies + shuffle restarts and keeping the best-scoring one. (Good and explainable, not provably optimal.)""" strategies = ["decreasing", "bestfit", "exact", "guillotine", "increasing"] strategies += [f"shuffle{i}" for i in range(max(attempts - len(strategies), 0))] best = None for st in strategies: - plan = build_cut_plan(scene, settings, strategy=st) + plan = build_cut_plan(scene, settings, strategy=st, quantity=quantity) if best is None or _plan_key(plan) < _plan_key(best): best = plan if best is not None: diff --git a/src/woodshop/estimate.py b/src/woodshop/estimate.py index 1ba4518..d294b27 100644 --- a/src/woodshop/estimate.py +++ b/src/woodshop/estimate.py @@ -85,19 +85,22 @@ def save_rates(rates: EstimateRates) -> None: path.write_text(json.dumps(asdict(rates), indent=2)) -def count_ops(scene, plan) -> dict: - """Deterministic operation counts off the scene + cut plan.""" +def count_ops(scene, plan, quantity: int = 1) -> dict: + """Deterministic operation counts off the scene + cut plan. For a batch of + N units, scene-derived counts (joints/features/finish) scale by N; `cuts` + comes from the plan, which already reflects N when built with quantity=N.""" from collections import Counter + q = max(1, quantity) feats = Counter(f.kind for p in scene.parts for f in p.features) return { - "parts": len(scene.parts), - "cuts": len(plan.items), # one crosscut per cut item - "butt_joints": len(scene.joints), - "connections": len(scene.connections), - "glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS), - "finished_parts": sum(1 for p in scene.parts if p.finish != "raw"), - "features": dict(feats), + "parts": len(scene.parts) * q, + "cuts": len(plan.items), # plan already ×N for a batch + "butt_joints": len(scene.joints) * q, + "connections": len(scene.connections) * q, + "glued_features": sum(feats[k] for k in GLUED_FEATURE_KINDS) * q, + "finished_parts": sum(1 for p in scene.parts if p.finish != "raw") * q, + "features": {k: v * q for k, v in feats.items()}, } @@ -122,11 +125,20 @@ class ProjectEstimate: labor_lines: list # list[Line] (time breakdown, cost per group) labor_minutes: float rates: EstimateRates + quantity: int = 1 # how many units this estimate covers @property def material_cost(self) -> float: return self.material.total # includes HST — what you pay + @property + def per_unit_cost(self) -> float: + return round(self.total_cost / max(1, self.quantity), 2) + + @property + def per_unit_price(self) -> float: + return round(self.price / max(1, self.quantity), 2) + @property def consumable_cost(self) -> float: return round(sum(l.cost for l in self.consumables), 2) @@ -160,9 +172,10 @@ class ProjectEstimate: def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = None, - hst: float = prices_mod.NB_HST) -> ProjectEstimate: + hst: float = prices_mod.NB_HST, quantity: int = 1) -> ProjectEstimate: rates = rates or load_rates() - ops = count_ops(scene, plan) + q = max(1, quantity) + ops = count_ops(scene, plan, q) material = prices_mod.estimate(plan, prices, hst=hst) # --- consumables --- @@ -176,12 +189,12 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non if glue_oz: consumables.append(Line("Glue", f"{glue_oz:g} oz × ${rates.glue_cost_per_oz:.2f}/oz", round(glue_oz * rates.glue_cost_per_oz, 2))) - # finish material: per part, priced by its finish kind × surface area + # finish material: per part, priced by its finish kind × surface area (×N units) finish_cost, finish_sqft, kinds = 0.0, 0.0, set() for p in scene.parts: if p.finish == "raw": continue - a = _part_sqft(p) + 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) @@ -202,7 +215,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non ("Assembly (mortise & tenon)", ops["connections"] * rates.min_per_connection, f"{ops['connections']} connection(s)"), ("Sanding / finishing", - sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw"), + sum(rates.min_per_finish.get(p.finish, 0.0) for p in scene.parts if p.finish != "raw") * q, f"{ops['finished_parts']} part(s)"), ] for kind, n in sorted(ops["features"].items()): @@ -216,7 +229,7 @@ def project_estimate(scene, plan, prices=None, rates: EstimateRates | None = Non return ProjectEstimate(material=material, consumables=consumables, labor_lines=labor_lines, labor_minutes=round(total_min, 1), - rates=rates) + rates=rates, quantity=q) def _money(v: float) -> str: @@ -225,7 +238,8 @@ def _money(v: float) -> str: def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str: hrs = est.labor_minutes / 60.0 - lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%)", + batch = f" · batch of {est.quantity}" if est.quantity > 1 else "" + lines = [f"PROJECT ESTIMATE ({region} · HST {est.material.hst * 100:g}%){batch}", "editable estimate — verify before quoting", ""] lines.append(f" {'Materials (incl HST)':<30}{_money(est.material_cost):>12}") for l in est.consumables: @@ -247,4 +261,8 @@ def format_estimate(est: ProjectEstimate, region: str = "Kent NB") -> str: lines += [" " + "=" * 42, f" {'SUGGESTED PRICE':<30}{_money(est.price):>12}", f" {'(profit ' + _money(est.profit) + ')':<30}"] + if est.quantity > 1: + lines += [" " + "-" * 42, + f" {'PER UNIT cost':<30}{_money(est.per_unit_cost):>12}", + f" {'PER UNIT price':<30}{_money(est.per_unit_price):>12}"] return "\n".join(lines) diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index fa9aae2..485cafa 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -12,8 +12,8 @@ from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QDoubleSpinBox, QFormL QGraphicsItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QHeaderView, QLabel, QMenu, QPushButton, QScrollArea, - QTableWidget, QTableWidgetItem, QTabWidget, QTextEdit, - QVBoxLayout, QWidget) + QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, + QTextEdit, QVBoxLayout, QWidget) from collections import Counter @@ -74,6 +74,7 @@ class BomWindow(QDialog): self.resize(820, 640) self._order = 0 self._optimized = False + self._quantity = 1 self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render self._px = _PX self._rows = [] # (y0, y1, stock_piece) for drop hit-testing @@ -90,10 +91,28 @@ class BomWindow(QDialog): tabs.addTab(self._layout_tab(), "Cut Layout") tabs.addTab(self._instructions_tab(), "Instructions") tabs.addTab(self._jigs_tab(), "Jigs") + + # header: build quantity (nests all units together → real per-unit cost) + 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) + header.addStretch() + root = QVBoxLayout(self) + root.addLayout(header) root.addWidget(tabs) self._refresh_all() + def _on_quantity_changed(self, value: int) -> None: + self._quantity = max(1, value) + self._set_plan(build_cut_plan(self.c.scene, quantity=self._quantity)) + # ----- one active plan; all tabs render from it --------------------- def _set_plan(self, plan) -> None: recompute(plan) # keep waste/score truthful after any change @@ -224,7 +243,8 @@ class BomWindow(QDialog): return w def _cost_text(self) -> str: - est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, self._rates) + est = estimate_mod.project_estimate(self.c.scene, self._plan, self._prices, + self._rates, quantity=self._quantity) return estimate_mod.format_estimate(est) def _on_margin_changed(self, value: float) -> None: @@ -400,7 +420,7 @@ class BomWindow(QDialog): self._set_plan(best) self._status.setText("✓ optimized around locked pieces") else: - self._set_plan(best_cut_plan(self.c.scene)) + self._set_plan(best_cut_plan(self.c.scene, quantity=self._quantity)) self._status.setText("✓ optimized") def _best_of_n(self) -> None: @@ -413,7 +433,7 @@ class BomWindow(QDialog): self._set_plan(best) self._status.setText("✓ best of 100 around locked pieces") else: - self._set_plan(best_cut_plan(self.c.scene, attempts=100)) + self._set_plan(best_cut_plan(self.c.scene, attempts=100, quantity=self._quantity)) self._status.setText("✓ best of 100 attempts") def _next_arrangement(self) -> None: @@ -421,7 +441,7 @@ class BomWindow(QDialog): self._order = (self._order + 1) % len(STRATEGIES) st = STRATEGIES[self._order] plan = (reoptimize(self.c.scene, self._plan, st) if self._has_locks() - else build_cut_plan(self.c.scene, strategy=st)) + else build_cut_plan(self.c.scene, strategy=st, quantity=self._quantity)) self._set_plan(plan) def _draw_layout(self) -> None: @@ -430,7 +450,12 @@ class BomWindow(QDialog): self._rows = [] names = {p.id: (p.name or p.id) for p in self.c.scene.parts} part_of = {it.id: it.part_id for it in plan.items} - label = lambda iid: names.get(part_of.get(iid, ""), iid) + unit_of = {it.id: getattr(it, "unit", 1) for it in plan.items} + multi = any(u > 1 for u in unit_of.values()) + + def label(iid): + base = names.get(part_of.get(iid, ""), iid) + return f"U{unit_of.get(iid, 1)} {base}" if multi else base px, y, bar = self._px, 30.0, 34.0 sc = plan.score diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index d30e118..089e7c0 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -328,3 +328,32 @@ def test_allowance_roundtrips_in_plan_json(): 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