diff --git a/SHOP_PACKET_PLAN.md b/SHOP_PACKET_PLAN.md index 1826d28..a4b05ec 100644 --- a/SHOP_PACKET_PLAN.md +++ b/SHOP_PACKET_PLAN.md @@ -5,9 +5,17 @@ A living plan for turning the BOM into a **shop-packet generator**. Adjust as we **Status:** Phases 0–4 implemented (cutplan.py model; multi-strategy auto-layout; deterministic instructions + AI polish; rule-based jig suggestions; constrained drag-edit layout). Logic is unit-tested; the drag/print GUI needs a real display to -verify interactively. Known follow-ups: lock-aware re-optimization (locked pieces -preserved through "Find better layout"), grain-direction handling, on-hand offcut -inventory, and opt-in jig material in the BOM. +verify interactively. + +Review fixes applied: one active CutPlan rendered by every tab; unplaced parts +surfaced in Shopping; process-stable shuffle (hashlib); kerf-gap validation; drop +stock-type compatibility; waste/score recompute after manual edits; rotation legality +(settings/grain); position-aware jig grouping. + +Known follow-ups: **Phase 1 is partial** — bounded exact search, a real "Best of N" +control, guillotine/maxrects plywood strategies, richer scoring. Also: lock-aware +re-optimization (locked pieces preserved through "Find better layout"), grain-direction +in auto-layout, on-hand offcut inventory, opt-in jig material in the BOM. ## Guiding principle The **math layer is deterministic and inspectable**; AI is used **only for narrative** diff --git a/src/woodshop/cutplan.py b/src/woodshop/cutplan.py index 22a8e14..0472136 100644 --- a/src/woodshop/cutplan.py +++ b/src/woodshop/cutplan.py @@ -10,6 +10,7 @@ The math here is deterministic and inspectable; AI is never used for numbers. """ from __future__ import annotations +import hashlib from dataclasses import asdict, dataclass, field, fields from .cutlist import cut_length @@ -18,6 +19,11 @@ from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood _EPS = 1e-6 +def _stable_hash(text: str) -> int: + """Process-stable hash (unlike built-in hash(), which is salted per run).""" + return int(hashlib.md5(text.encode()).hexdigest()[:8], 16) + + @dataclass class ShopSettings: kerf_in: float = 0.125 @@ -146,7 +152,7 @@ def _ordered(items, strategy): return sorted(items, key=key) if strategy.startswith("shuffle"): # "shuffle", "shuffle1", ... distinct salts salt = strategy[7:] - return sorted(items, key=lambda it: (hash(it.id + salt) & 0xffffff)) + return sorted(items, key=lambda it: _stable_hash(it.id + salt)) return sorted(items, key=key, reverse=True) # decreasing (FFD) & bestfit (BFD) @@ -333,22 +339,21 @@ def find_placement(plan: CutPlan, pid: str): raise KeyError(pid) +def _too_close(a: Placement, b: Placement, kerf: float) -> bool: + """True if a and b are closer than a saw kerf in BOTH axes (so a cut can't + separate them) — i.e. they overlap or leave less than kerf between them.""" + x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in) + y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in) + return x_ov > -kerf + _EPS and y_ov > -kerf + _EPS + + def placement_fits(sp: StockPiece, placement: Placement, kerf: float) -> bool: - """Is `placement` inside `sp` and not overlapping its other placements (kerf-aware)?""" + """Is `placement` inside `sp` and kerf-clear of its other placements?""" if placement.x_in < -_EPS or placement.x_in + placement.len_in > sp.length_in + _EPS: return False if placement.y_in < -_EPS or placement.y_in + placement.wid_in > sp.width_in + _EPS: return False - for q in sp.placements: - if q.id == placement.id: - continue - x_ov = (min(placement.x_in + placement.len_in, q.x_in + q.len_in) - - max(placement.x_in, q.x_in)) - y_ov = (min(placement.y_in + placement.wid_in, q.y_in + q.wid_in) - - max(placement.y_in, q.y_in)) - if x_ov > kerf - _EPS and y_ov > _EPS: - return False - return True + return not any(_too_close(placement, q, kerf) for q in sp.placements if q.id != placement.id) def snap_x(sp: StockPiece, placement: Placement, x: float, kerf: float, tol: float = 2.0) -> float: @@ -380,11 +385,35 @@ def rotate_placement(plan: CutPlan, pid: str) -> None: p.rotated = not p.rotated +def recompute(plan: CutPlan) -> None: + """Rebuild waste regions (incl. gaps left by manual moves) and the score — + call after any manual edit so the diagram and yield stay truthful.""" + s = plan.settings + for sp in plan.stock_pieces: + sp.waste = [] + if sp.is_sheet: + continue + cursor = 0.0 + for p in sorted(sp.placements, key=lambda p: p.x_in): + gap = round(p.x_in - cursor, 3) + if gap > 0.5: + sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=gap, + width_in=sp.width_in, reusable=gap >= s.offcut_usable_in)) + cursor = max(cursor, p.x_in + p.len_in) + tail = round(sp.length_in - cursor, 3) + if tail > 0.5: + sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=tail, + width_in=sp.width_in, reusable=tail >= s.offcut_usable_in)) + plan.score = _score(plan.stock_pieces, s, plan.strategy, plan.warnings) + + def validate_cut_plan(plan: CutPlan) -> list: """Return a list of problems ([] means valid): pieces inside stock, no overlaps, kerf respected, every item placed-or-warned.""" problems = [] s = plan.settings + items = {it.id: it for it in plan.items} + rot_ok = s.allow_plywood_rotation and not s.grain_direction placed_items = set() for sp in plan.stock_pieces: for p in sp.placements: @@ -393,15 +422,16 @@ def validate_cut_plan(plan: CutPlan) -> list: problems.append(f"{p.id} runs off {sp.id} lengthwise") if p.y_in < -_EPS or p.y_in + p.wid_in > sp.width_in + _EPS: problems.append(f"{p.id} runs off {sp.id} widthwise") - # pairwise overlap (with kerf) on the same stock piece + 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") + if p.rotated and not rot_ok: + problems.append(f"{p.id} is rotated but rotation isn't allowed") ps = sp.placements for i in range(len(ps)): for j in range(i + 1, len(ps)): - a, b = ps[i], ps[j] - x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in) - y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in) - if x_ov > s.kerf_in - _EPS and y_ov > _EPS: - problems.append(f"{a.id} and {b.id} overlap on {sp.id}") + if _too_close(ps[i], ps[j], s.kerf_in): + problems.append(f"{ps[i].id} and {ps[j].id} are closer than a kerf on {sp.id}") for it in plan.items: if it.id not in placed_items and it.id not in plan.unplaced: problems.append(f"{it.part_id} ({it.id}) is neither placed nor flagged unplaced") diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 87e91e3..7982e16 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -12,12 +12,13 @@ from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraph QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel, QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget) -from ..cutlist import _fmt_len, cut_rows, shopping +from collections import Counter + +from ..cutlist import _fmt_len, board_feet from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement, - placement_fits, relocate, rotate_placement, snap_x) + placement_fits, recompute, relocate, rotate_placement, snap_x) from ..instructions import build_steps, format_steps, polish_prompt from ..jigs import explain_prompt, format_jigs, suggest_jigs -from ..layout import waste_summary from .workers import run_async _PX = 7.0 # pixels per inch in the layout view @@ -67,28 +68,45 @@ class BomWindow(QDialog): self.resize(820, 640) self._order = 0 self._optimized = False - self._plan = None # persistent editable plan for the layout tab - self._rebuild = True # regenerate from auto-layout on next draw + 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 self.pool = QThreadPool.globalInstance() + self._cut_te = self._mono_te() + self._shop_te = self._mono_te() tabs = QTabWidget() - tabs.addTab(self._text_tab(self._cut_text()), "Cut List") - tabs.addTab(self._text_tab(self._shop_text()), "Shopping List") + tabs.addTab(self._print_wrap(self._cut_te), "Cut List") + tabs.addTab(self._print_wrap(self._shop_te), "Shopping List") tabs.addTab(self._layout_tab(), "Cut Layout") tabs.addTab(self._instructions_tab(), "Instructions") tabs.addTab(self._jigs_tab(), "Jigs") root = QVBoxLayout(self) root.addWidget(tabs) + self._refresh_all() + + # ----- one active plan; all tabs render from it --------------------- + def _set_plan(self, plan) -> None: + recompute(plan) # keep waste/score truthful after any change + self._plan = plan + self._refresh_all() + + def _refresh_all(self) -> None: + self._cut_te.setPlainText(self._cut_text()) + self._shop_te.setPlainText(self._shop_text()) + self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan))) + self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene))) + self._draw_layout() # ----- text tabs ---------------------------------------------------- - def _text_tab(self, text: str) -> QWidget: - w = QWidget() - v = QVBoxLayout(w) + def _mono_te(self) -> QTextEdit: te = QTextEdit(readOnly=True) te.setFont(QFont("monospace")) - te.setPlainText(text) + return te + + def _print_wrap(self, te: QTextEdit) -> QWidget: + w = QWidget() + v = QVBoxLayout(w) v.addWidget(te) btn = QPushButton("Print…") btn.clicked.connect(lambda: self._print_text(te)) @@ -97,32 +115,44 @@ class BomWindow(QDialog): return w def _cut_text(self) -> str: - rows = cut_rows(self.c.scene) + plan = self._plan + groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2), it.is_sheet) + for it in plan.items) lines = ["CUT LIST", ""] - for r in rows: - if r["plywood"]: - lines.append(f" {r['count']:>2} × {r['stock']:<8} {_fmt_len(r['width_in'])} × " - f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)") + for (stock, ln, wd, sheet), n in sorted(groups.items()): + if sheet: + lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}" + f" ({wd * ln / 144 * n:.1f} sq ft)") else: - lines.append(f" {r['count']:>2} × {r['stock']:<8} @ {_fmt_len(r['length_in']):<9}" - f" ({r['board_feet']:.1f} bd-ft)") - if not rows: + lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}" + f" ({board_feet(stock, ln) * n:.1f} bd-ft)") + if not plan.items: lines.append(" (nothing to cut yet)") return "\n".join(lines) def _shop_text(self) -> str: + plan = self._plan lines = ["SHOPPING LIST", "", "Buy:"] - for stock, qty in shopping(self.c.scene).items(): + for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()): s = "s" if qty != 1 else "" unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')" lines.append(f" {qty} × {stock} {unit}") - lines += ["", "Yield (used / bought):"] - for stock, w in waste_summary(self.c.scene).items(): - unit = "sq ft" if stock.startswith("ply-") else "in" - pct = (w["used"] / w["capacity"] * 100) if w["capacity"] else 0 - lines.append(f" {stock}: {w['used']:g} / {w['capacity']:g} {unit} ({pct:.0f}% used)") - if len(lines) <= 3: + if not plan.stock_pieces: lines.append(" (nothing yet)") + if plan.unplaced: + lines += ["", "⚠ Won't fit standard stock — source / cut specially:"] + for iid in plan.unplaced: + 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] + 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) + pct = used / cap * 100 if cap else 0 + lines.append(f" {stock}: {pct:.0f}% used over {len(sps)} " + f"{'sheet' if sheet else 'stick'}{'s' if len(sps) != 1 else ''}") return "\n".join(lines) def _print_text(self, te: QTextEdit) -> None: @@ -148,7 +178,7 @@ class BomWindow(QDialog): return w def _polish_instructions(self) -> None: - prompt = polish_prompt(build_steps(self.c.scene)) + prompt = polish_prompt(build_steps(self.c.scene, self._plan)) self._polish.setEnabled(False) self._polish.setText("Rewriting…") @@ -234,20 +264,15 @@ class BomWindow(QDialog): return w def _optimize(self) -> None: - self._optimized, self._rebuild = True, True - self._draw_layout() + self._optimized = True + self._set_plan(best_cut_plan(self.c.scene)) def _next_arrangement(self) -> None: self._optimized = False self._order = (self._order + 1) % len(STRATEGIES) - self._rebuild = True - self._draw_layout() + self._set_plan(build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) def _draw_layout(self) -> None: - if self._rebuild or self._plan is None: - self._plan = (best_cut_plan(self.c.scene) if self._optimized - else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order])) - self._rebuild = False plan = self._plan self.scene.clear() self._rows = [] @@ -303,6 +328,13 @@ class BomWindow(QDialog): def _row_of(self, sp_id): return next((y0 for y0, _y1, s in self._rows if s.id == sp_id), 0.0) + def _revert(self, plan, pid, home) -> None: + hsp, hx, hy = home + hrow = self._row_of(hsp) + home_sp = next(s for s in plan.stock_pieces if s.id == hsp) + relocate(plan, pid, hsp, hx / self._px, + (hy - hrow) / self._px if home_sp.is_sheet else 0.0) + def _drop_piece(self, item, home) -> None: plan, px = self._plan, self._px cy = item.sceneBoundingRect().center().y() @@ -310,6 +342,12 @@ class BomWindow(QDialog): sp_cur, p = find_placement(plan, item.pid) if target is None: target = sp_cur + # Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc. + if plan.item(item.pid).stock != target.stock: + self._revert(plan, item.pid, home) + self._status.setText(f"✗ {plan.item(item.pid).stock} can't go on {target.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 @@ -319,43 +357,41 @@ class BomWindow(QDialog): if placement_fits(target, p, plan.settings.kerf_in): self._status.setText("✓ placed") else: - hsp, hx, hy = home - hrow = self._row_of(hsp) - home_sp = next(s for s in plan.stock_pieces if s.id == hsp) - relocate(plan, item.pid, hsp, hx / px, - (hy - hrow) / px if home_sp.is_sheet else 0.0) + self._revert(plan, item.pid, home) self._status.setText("✗ overlap / off-stock — move reverted") - self._rebuild = False - self._draw_layout() + recompute(plan) # refresh waste/score after the edit + self._refresh_all() def _rotate_piece(self, pid) -> None: plan = self._plan sp, p = find_placement(plan, pid) if not sp.is_sheet: return + if not plan.settings.allow_plywood_rotation or plan.settings.grain_direction: + self._status.setText("✗ rotation isn't allowed (grain / settings)") + return rotate_placement(plan, pid) if placement_fits(sp, p, plan.settings.kerf_in): self._status.setText("✓ rotated") else: rotate_placement(plan, pid) # rotate back self._status.setText("✗ rotation doesn't fit") - self._rebuild = False - self._draw_layout() + recompute(plan) + self._refresh_all() def _piece_menu(self, pid, screen_pos) -> None: plan = self._plan sp, p = find_placement(plan, pid) menu = QMenu(self) menu.addAction("Unlock" if p.locked else "Lock", lambda: self._toggle_lock(pid)) - if sp.is_sheet: + if sp.is_sheet and plan.settings.allow_plywood_rotation and not plan.settings.grain_direction: menu.addAction("Rotate", lambda: self._rotate_piece(pid)) menu.exec(screen_pos) def _toggle_lock(self, pid) -> None: _sp, p = find_placement(self._plan, pid) p.locked = not p.locked - self._rebuild = False - self._draw_layout() + self._refresh_all() def _rect(self, x, y, w, h, color, text) -> None: item = QGraphicsRectItem(x, y, w, h) diff --git a/src/woodshop/jigs.py b/src/woodshop/jigs.py index d39012c..121d094 100644 --- a/src/woodshop/jigs.py +++ b/src/woodshop/jigs.py @@ -37,25 +37,30 @@ def suggest_jigs(scene, min_repeats: int = 3) -> list: f"{n} pieces against it for identical length every time.", [f"a ~3\" {stock} offcut (the stop)", "a straight fence/backer board"])) - # Repeated holes -> drilling template. - holes = Counter(round(f.diameter_in, 3) + # Repeated holes at the SAME registered position -> drilling template. + # (Grouping by position, not just diameter — a fixed template only locates + # holes that share a face + offsets.) + holes = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2), round(f.diameter_in, 3)) for p in scene.parts for f in p.features if f.kind == "hole") - for dia, n in sorted(holes.items()): + for (stock, face, along, across, dia), n in sorted(holes.items()): if n >= min_repeats: jigs.append(JigSuggestion( - "drill-template", f"Drilling template — {n}× ⌀{dia:g}\" holes", n, - f"Make a template with ⌀{dia:g}\" guide holes and clamp it to register all " - f"{n} holes in the same spot.", ["a scrap of ply/hardboard for the template"])) + "drill-template", f"Drilling template — {n}× ⌀{dia:g}\" holes ({stock}, {face})", n, + f"Make a template with a ⌀{dia:g}\" guide hole and clamp it to register the hole " + f"at the same spot ({_fmt_len(along)} along, {across:g}\" off centre) on all {n} parts.", + ["a scrap of ply/hardboard for the template"])) - # Repeated mortises -> routing/mortise template. - mort = Counter((round(f.width_in, 2), round(f.height_in, 2), round(f.depth_in, 2)) + # Repeated mortises at the same position/size -> positioning template. + mort = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2), + round(f.width_in, 2), round(f.height_in, 2), round(f.depth_in, 2), round(f.rotation_deg, 1)) for p in scene.parts for f in p.features if f.kind == "mortise") - for (w, h, d), n in sorted(mort.items()): + for (stock, face, along, across, w, h, d, _rot), n in sorted(mort.items()): if n >= min_repeats: jigs.append(JigSuggestion( - "mortise-template", f"Mortise template — {n}× {w:g}×{h:g}\"", n, - f"Build a routing template with a {w:g}×{h:g}\" opening and rout all {n} mortises " - f"to {_fmt_len(d)} deep with a guide bushing.", ["template stock (ply/MDF)", "guide bushing"])) + "mortise-template", f"Mortise template — {n}× {w:g}×{h:g}\" ({stock}, {face})", n, + f"Build a routing template with a {w:g}×{h:g}\" opening; register it at the same spot " + f"({_fmt_len(along)} along) and rout all {n} mortises {_fmt_len(d)} deep with a guide bushing.", + ["template stock (ply/MDF)", "guide bushing"])) # Repeated panel widths -> set the rip fence once. widths = Counter(round(p.section_in[1], 2) for p in scene.parts if is_plywood(p.stock)) diff --git a/tests/test_cutplan.py b/tests/test_cutplan.py index 4a9f7fc..f343402 100644 --- a/tests/test_cutplan.py +++ b/tests/test_cutplan.py @@ -142,6 +142,55 @@ def test_rotate_placement_swaps_footprint(): assert p.len_in == W and p.wid_in == L and p.rotated != rot +def test_kerf_gap_required_not_just_overlap(): + from woodshop.cutplan import placement_fits + s = Scene() + s.place("2x4", 30) + s.place("2x4", 30) + plan = build_cut_plan(s) + stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet) + p1, p2 = stick.placements + k = plan.settings.kerf_in + p2.x_in = p1.len_in + 0.01 # closer than a kerf + assert not placement_fits(stick, p2, k) + p2.x_in = p1.len_in + k # exactly a kerf apart + assert placement_fits(stick, p2, k) + + +def test_validate_flags_wrong_stock_and_illegal_rotation(): + from woodshop.cutplan import relocate, rotate_placement + s = Scene() + s.place("2x4", 24) + s.place("ply-3/4", 24, width_in=24) + plan = build_cut_plan(s) + lumber = next(sp for sp in plan.stock_pieces if not sp.is_sheet) + sheet = next(sp for sp in plan.stock_pieces if sp.is_sheet) + relocate(plan, lumber.placements[0].id, sheet.id, 0.0, 0.0) + assert any("stock piece" in p for p in validate_cut_plan(plan)) + + plan2 = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False)) + sh2 = next(sp for sp in plan2.stock_pieces if sp.is_sheet) + rotate_placement(plan2, sh2.placements[0].id) + assert any("rotation" in p for p in validate_cut_plan(plan2)) + + +def test_recompute_updates_waste_after_move(): + from woodshop.cutplan import recompute + s = Scene() + s.place("2x4", 30) + s.place("2x4", 30) + plan = build_cut_plan(s) + stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet) + stick.placements[1].x_in = 60.0 # leave a gap after p1 + recompute(plan) + assert any(abs(w.x_in - 30) < 1.0 for w in stick.waste) # gap at ~30 now waste + + +def test_stable_hash_is_deterministic(): + from woodshop.cutplan import _stable_hash + assert _stable_hash("ci1x") == _stable_hash("ci1x") + + def test_custom_settings_kerf(): s = Scene() s.place("2x4", 48) diff --git a/tests/test_jigs.py b/tests/test_jigs.py index 14aa2a8..b22dae7 100644 --- a/tests/test_jigs.py +++ b/tests/test_jigs.py @@ -35,5 +35,21 @@ def test_repeated_mortises_suggest_template(): assert any(j.kind == "mortise-template" for j in suggest_jigs(s)) +def test_holes_at_same_position_suggest_template(): + s = Scene() + for _ in range(3): + p = s.place("2x4", 24) + s.add_feature(p.id, "hole", face="top", along_in=3, diameter_in=0.375) + assert any(j.kind == "drill-template" for j in suggest_jigs(s)) + + +def test_holes_at_different_positions_no_template(): + s = Scene() + for along in (3, 9, 15): # same diameter, different spots + p = s.place("2x4", 24) + s.add_feature(p.id, "hole", face="top", along_in=along, diameter_in=0.375) + assert not any(j.kind == "drill-template" for j in suggest_jigs(s)) + + def test_format_empty(): assert "No repeated" in format_jigs([])