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