Phase 3: jig suggestions (rule-based detection + AI explanation)

jigs.py: detect repeated operations deterministically and propose shop aids with
COMPUTED dimensions — stop block (repeated identical crosscuts, e.g. 10× a length),
drilling template (repeated hole diameters), mortise template (repeated mortises),
rip-fence setting (repeated panel widths). Jigs are shop aids kept SEPARATE from
the project BOM (no silent material adds). explain_prompt() lets the AI describe
build/use without changing any dimension.

BOM window gains a Jigs tab: deterministic suggestions + "Explain jigs (AI)"
(background claude) + Print.

116 tests pass (10× crosscut -> stop block, below-threshold -> none, repeated
holes/mortises -> templates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 14:41:58 -03:00
parent ecce86ddb5
commit ee00ec7ce5
3 changed files with 170 additions and 0 deletions

View File

@ -15,6 +15,7 @@ from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
from ..cutlist import _fmt_len, cut_rows, shopping from ..cutlist import _fmt_len, cut_rows, shopping
from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan
from ..instructions import build_steps, format_steps, polish_prompt from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from ..layout import waste_summary from ..layout import waste_summary
from .workers import run_async 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._text_tab(self._shop_text()), "Shopping List")
tabs.addTab(self._layout_tab(), "Cut Layout") tabs.addTab(self._layout_tab(), "Cut Layout")
tabs.addTab(self._instructions_tab(), "Instructions") tabs.addTab(self._instructions_tab(), "Instructions")
tabs.addTab(self._jigs_tab(), "Jigs")
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget(tabs) root.addWidget(tabs)
@ -127,6 +129,47 @@ class BomWindow(QDialog):
run_async(self.pool, work, on_done=done, on_error=failed) 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 --------------------------------------------------- # ----- layout tab ---------------------------------------------------
def _layout_tab(self) -> QWidget: def _layout_tab(self) -> QWidget:
w = QWidget() w = QWidget()

88
src/woodshop/jigs.py Normal file
View File

@ -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)

39
tests/test_jigs.py Normal file
View File

@ -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([])