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:
parent
ecce86ddb5
commit
ee00ec7ce5
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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([])
|
||||
Loading…
Reference in New Issue