Phase 2: structured build instructions (deterministic + optional AI polish)
instructions.py: build_steps(scene, plan) emits an ordered, DETERMINISTIC step list from the CutPlan + scene — gather stock, cut to size (per cut layout), mark & cut joinery, sand, dry-fit/glue/fasten (per connection), finish. Every number and part name comes from the model. polish_prompt() asks the AI to rephrase into friendly prose while forbidding any change to measurements. BOM window gains an Instructions tab: shows the deterministic steps immediately, "Rewrite in plain English (AI)" runs claude in a background thread to polish, and Print. 111 tests pass (steps cover phases, carry real data, prompt guards numbers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d44d36a773
commit
ecce86ddb5
|
|
@ -3,7 +3,9 @@ printable. The Cut Layout tab draws the cutting-stock nesting and can try
|
|||
alternative arrangements."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
import subprocess
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
||||
from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
|
||||
|
|
@ -12,7 +14,9 @@ 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 ..layout import waste_summary
|
||||
from .workers import run_async
|
||||
|
||||
_PX = 7.0 # pixels per inch in the layout view
|
||||
_PIECE = "#c8965a"
|
||||
|
|
@ -27,11 +31,13 @@ class BomWindow(QDialog):
|
|||
self.resize(820, 640)
|
||||
self._order = 0
|
||||
self._optimized = False
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
|
||||
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._layout_tab(), "Cut Layout")
|
||||
tabs.addTab(self._instructions_tab(), "Instructions")
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(tabs)
|
||||
|
||||
|
|
@ -83,6 +89,44 @@ class BomWindow(QDialog):
|
|||
if QPrintDialog(printer, self).exec():
|
||||
te.print_(printer)
|
||||
|
||||
# ----- instructions tab --------------------------------------------
|
||||
def _instructions_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
self._instr = QTextEdit(readOnly=True)
|
||||
self._instr.setFont(QFont("monospace"))
|
||||
self._instr.setPlainText(format_steps(build_steps(self.c.scene)))
|
||||
v.addWidget(self._instr)
|
||||
row = QHBoxLayout()
|
||||
self._polish = QPushButton("Rewrite in plain English (AI)")
|
||||
self._polish.clicked.connect(self._polish_instructions)
|
||||
pr = QPushButton("Print…")
|
||||
pr.clicked.connect(lambda: self._print_text(self._instr))
|
||||
row.addWidget(self._polish); row.addStretch(); row.addWidget(pr)
|
||||
v.addLayout(row)
|
||||
return w
|
||||
|
||||
def _polish_instructions(self) -> None:
|
||||
prompt = polish_prompt(build_steps(self.c.scene))
|
||||
self._polish.setEnabled(False)
|
||||
self._polish.setText("Rewriting…")
|
||||
|
||||
def work():
|
||||
r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True)
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
def done(text):
|
||||
self._polish.setEnabled(True)
|
||||
self._polish.setText("Rewrite in plain English (AI)")
|
||||
if text:
|
||||
self._instr.setPlainText(text)
|
||||
|
||||
def failed(err):
|
||||
self._polish.setEnabled(True)
|
||||
self._polish.setText("Rewrite in plain English (AI)")
|
||||
|
||||
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||
|
||||
# ----- layout tab ---------------------------------------------------
|
||||
def _layout_tab(self) -> QWidget:
|
||||
w = QWidget()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
"""Build instructions: a DETERMINISTIC ordered step list from the CutPlan + scene.
|
||||
|
||||
Every number/part name comes from the model. The AI is only used (optionally) to
|
||||
rephrase these steps into friendlier prose — `polish_prompt()` builds a prompt
|
||||
that explicitly forbids changing any measurement.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from .cutlist import _fmt_len
|
||||
from .cutplan import build_cut_plan
|
||||
|
||||
|
||||
def build_steps(scene, plan=None) -> list:
|
||||
"""Return [(title, [lines])] in build order. Deterministic."""
|
||||
plan = plan or build_cut_plan(scene)
|
||||
names = {p.id: (p.name or p.id) for p in scene.parts}
|
||||
part_of = {it.id: it.part_id for it in plan.items}
|
||||
sections = []
|
||||
|
||||
buy = []
|
||||
for stock, n in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
|
||||
unit = "sheet" if stock.startswith("ply-") else "8' stick"
|
||||
buy.append(f"{n} × {stock} ({unit}{'s' if n != 1 else ''})")
|
||||
if buy:
|
||||
sections.append(("Gather stock", buy))
|
||||
|
||||
cuts = []
|
||||
for sp in plan.stock_pieces:
|
||||
kind = "sheet" if sp.is_sheet else "stick"
|
||||
pieces = []
|
||||
for p in sp.placements:
|
||||
nm = names.get(part_of.get(p.item_id, ""), p.item_id)
|
||||
dims = (f"{_fmt_len(p.wid_in)}×{_fmt_len(p.len_in)}" if sp.is_sheet
|
||||
else _fmt_len(p.len_in))
|
||||
pieces.append(f"{nm} ({dims})")
|
||||
if pieces:
|
||||
cuts.append(f"From a {sp.stock} {kind}: cut " + ", ".join(pieces))
|
||||
if cuts:
|
||||
sections.append(("Cut pieces to size (see the Cut Layout tab)", cuts))
|
||||
|
||||
joinery = []
|
||||
for p in scene.parts:
|
||||
for f in p.features:
|
||||
dims = (f"⌀{f.diameter_in:g}\"" if f.kind == "hole"
|
||||
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}\"")
|
||||
joinery.append(f"On {names[p.id]} — {f.kind} on the {f.face} face ({dims})")
|
||||
if joinery:
|
||||
sections.append(("Mark and cut the joinery", joinery))
|
||||
|
||||
sanded = [names[p.id] for p in scene.parts if "sanded" in p.finishes]
|
||||
sections.append(("Sand", [f"Sand {', '.join(sanded)} smooth." if sanded
|
||||
else "Sand all parts smooth."]))
|
||||
|
||||
asm = []
|
||||
for c in scene.connections:
|
||||
if not scene._conn_valid(c):
|
||||
continue
|
||||
ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving)
|
||||
asm.append(f"Join {names[mp.id]} to {names[ap.id]} (seat the joint).")
|
||||
if asm:
|
||||
sections.append(("Dry-fit, then glue and fasten", asm))
|
||||
|
||||
sections.append(("Finish", ["Apply your chosen finish."]))
|
||||
return sections
|
||||
|
||||
|
||||
def format_steps(sections) -> str:
|
||||
out = []
|
||||
for n, (title, lines) in enumerate(sections, 1):
|
||||
out.append(f"{n}. {title}")
|
||||
out += [f" • {ln}" for ln in lines]
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip() or "Nothing to build yet."
|
||||
|
||||
|
||||
def polish_prompt(sections) -> str:
|
||||
return ("Rewrite these woodworking build steps as clear, friendly, numbered shop "
|
||||
"instructions a beginner could follow. KEEP every measurement, part name, "
|
||||
"and count EXACTLY as given — do not invent or change any number. Plain text only.\n\n"
|
||||
+ format_steps(sections))
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"""Tests for deterministic build-step generation."""
|
||||
from woodshop.instructions import build_steps, format_steps, polish_prompt
|
||||
from woodshop.scene import Scene
|
||||
|
||||
|
||||
def _scene():
|
||||
s = Scene()
|
||||
s.place("2x4", 24)
|
||||
s.rename("p1", "leg")
|
||||
s.add_feature("p1", "tenon", face="end_b", depth_in=1) # f1
|
||||
s.place("2x4", 48)
|
||||
s.add_feature("p2", "mortise", face="top", along_in=24,
|
||||
width_in=1.5, height_in=1, depth_in=1) # f2
|
||||
s.connect("f2", "f1") # seat the joint
|
||||
return s
|
||||
|
||||
|
||||
def test_steps_cover_the_build_phases():
|
||||
sections = build_steps(_scene())
|
||||
titles = [t for t, _ in sections]
|
||||
assert "Gather stock" in titles
|
||||
assert any("Cut pieces" in t for t in titles)
|
||||
assert any("joinery" in t.lower() for t in titles)
|
||||
assert any("glue" in t.lower() for t in titles) # assembly step (has connections)
|
||||
assert titles[-1] == "Finish"
|
||||
|
||||
|
||||
def test_steps_carry_real_data():
|
||||
text = format_steps(build_steps(_scene()))
|
||||
assert "leg" in text # named part appears
|
||||
assert "tenon" in text and "mortise" in text # joinery listed
|
||||
|
||||
|
||||
def test_polish_prompt_guards_numbers():
|
||||
p = polish_prompt(build_steps(_scene()))
|
||||
assert "do not invent" in p.lower()
|
||||
assert "Gather stock" in p
|
||||
|
||||
|
||||
def test_empty_scene_still_formats():
|
||||
assert format_steps(build_steps(Scene()))
|
||||
Loading…
Reference in New Issue