diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 338ccfc..93d340f 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene, from ..cutlist import _fmt_len, cut_rows, shopping from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan 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 @@ -38,6 +39,7 @@ class BomWindow(QDialog): tabs.addTab(self._text_tab(self._shop_text()), "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) @@ -127,6 +129,47 @@ class BomWindow(QDialog): run_async(self.pool, work, on_done=done, on_error=failed) + # ----- jigs tab ----------------------------------------------------- + def _jigs_tab(self) -> QWidget: + w = QWidget() + v = QVBoxLayout(w) + self._jigs = QTextEdit(readOnly=True) + self._jigs.setFont(QFont("monospace")) + self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene))) + v.addWidget(self._jigs) + row = QHBoxLayout() + self._jig_btn = QPushButton("Explain jigs (AI)") + self._jig_btn.clicked.connect(self._explain_jigs) + pr = QPushButton("Print…") + pr.clicked.connect(lambda: self._print_text(self._jigs)) + row.addWidget(self._jig_btn); row.addStretch(); row.addWidget(pr) + v.addLayout(row) + return w + + def _explain_jigs(self) -> None: + jigs = suggest_jigs(self.c.scene) + if not jigs: + return + prompt = explain_prompt(jigs) + self._jig_btn.setEnabled(False) + self._jig_btn.setText("Explaining…") + + def work(): + r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True) + return (r.stdout or "").strip() + + def done(text): + self._jig_btn.setEnabled(True) + self._jig_btn.setText("Explain jigs (AI)") + if text: + self._jigs.setPlainText(format_jigs(jigs) + "\n\n— HOW TO BUILD/USE —\n\n" + text) + + def failed(err): + self._jig_btn.setEnabled(True) + self._jig_btn.setText("Explain jigs (AI)") + + run_async(self.pool, work, on_done=done, on_error=failed) + # ----- layout tab --------------------------------------------------- def _layout_tab(self) -> QWidget: w = QWidget() diff --git a/src/woodshop/jigs.py b/src/woodshop/jigs.py new file mode 100644 index 0000000..d39012c --- /dev/null +++ b/src/woodshop/jigs.py @@ -0,0 +1,88 @@ +"""Jig suggestions: detect REPEATED operations (rule-based, deterministic) and +propose shop aids with computed dimensions. The AI only explains how to build/use +them — it never sets a dimension. + +Jigs are *shop aids*, kept separate from the project BOM (don't silently add jig +material to what the user buys). +""" +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field + +from .cutlist import _fmt_len, cut_length +from .lumber import is_plywood + + +@dataclass +class JigSuggestion: + kind: str + title: str + count: int # how many repeated operations it serves + detail: str # deterministic specifics (dimensions, what to set) + material: list = field(default_factory=list) # optional shop-aid stock + + +def suggest_jigs(scene, min_repeats: int = 3) -> list: + jigs = [] + + # Repeated identical crosscuts -> stop block. + lengths = Counter((p.stock, round(cut_length(p), 2)) + for p in scene.parts if not is_plywood(p.stock)) + for (stock, ln), n in sorted(lengths.items(), key=lambda kv: -kv[1]): + if n >= min_repeats: + jigs.append(JigSuggestion( + "stop-block", f"Stop block — {n}× {stock} @ {_fmt_len(ln)}", n, + f"Clamp a stop block {_fmt_len(ln)} from the blade (or marking edge) and cut all " + 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) + for p in scene.parts for f in p.features if f.kind == "hole") + for 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"])) + + # Repeated mortises -> routing/mortise template. + mort = Counter((round(f.width_in, 2), round(f.height_in, 2), round(f.depth_in, 2)) + for p in scene.parts for f in p.features if f.kind == "mortise") + for (w, h, d), 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"])) + + # 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)) + for wd, n in sorted(widths.items()): + if n >= min_repeats: + jigs.append(JigSuggestion( + "rip-stop", f"Rip-fence setting — {n}× {_fmt_len(wd)}-wide panels", n, + f"Set the rip fence to {_fmt_len(wd)} once and rip all {n} panels without re-measuring.")) + + return jigs + + +def format_jigs(jigs) -> str: + if not jigs: + return "No repeated operations detected yet — no jigs suggested." + out = ["SHOP AIDS / JIGS (optional — not part of the project BOM)", ""] + for j in jigs: + out.append(f"• {j.title} (saves repeating {j.count}×)") + out.append(f" {j.detail}") + if j.material: + out.append(" Build from: " + ", ".join(j.material)) + out.append("") + return "\n".join(out).rstrip() + + +def explain_prompt(jigs) -> str: + listing = "\n".join(f"- {j.title}: {j.detail}" for j in jigs) + return ("Explain, in friendly beginner terms, how to build and use each of these " + "woodworking jigs. KEEP every dimension exactly as given; do not invent numbers. " + "Plain text.\n\n" + listing) diff --git a/tests/test_jigs.py b/tests/test_jigs.py new file mode 100644 index 0000000..14aa2a8 --- /dev/null +++ b/tests/test_jigs.py @@ -0,0 +1,39 @@ +"""Tests for rule-based jig suggestions.""" +from woodshop.jigs import format_jigs, suggest_jigs +from woodshop.scene import Scene + + +def test_repeated_crosscuts_suggest_stop_block(): + s = Scene() + for _ in range(10): # the canonical example: 10 identical cuts + s.place("2x4", 6.5) + sb = [j for j in suggest_jigs(s) if j.kind == "stop-block"] + assert sb and sb[0].count == 10 + assert "6.5" in sb[0].title + + +def test_below_threshold_suggests_nothing(): + s = Scene() + s.place("2x4", 6.5) + s.place("2x4", 6.5) # only 2 < default min_repeats=3 + assert suggest_jigs(s) == [] + + +def test_repeated_holes_suggest_drill_template(): + s = Scene() + for _ in range(4): + p = s.place("2x4", 24) + s.add_feature(p.id, "hole", face="top", diameter_in=0.375) + assert any(j.kind == "drill-template" and j.count == 4 for j in suggest_jigs(s)) + + +def test_repeated_mortises_suggest_template(): + s = Scene() + for _ in range(3): + p = s.place("2x4", 24) + s.add_feature(p.id, "mortise", face="top", width_in=1.5, height_in=1, depth_in=1) + assert any(j.kind == "mortise-template" for j in suggest_jigs(s)) + + +def test_format_empty(): + assert "No repeated" in format_jigs([])