From ecce86ddb59119061c4ee9fdf2680868b41df94a Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 14:36:50 -0300 Subject: [PATCH] Phase 2: structured build instructions (deterministic + optional AI polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/woodshop/gui/bom_window.py | 46 ++++++++++++++++++- src/woodshop/instructions.py | 82 ++++++++++++++++++++++++++++++++++ tests/test_instructions.py | 41 +++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/woodshop/instructions.py create mode 100644 tests/test_instructions.py diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index 3b1cbbe..338ccfc 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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() diff --git a/src/woodshop/instructions.py b/src/woodshop/instructions.py new file mode 100644 index 0000000..f6aea9c --- /dev/null +++ b/src/woodshop/instructions.py @@ -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)) diff --git a/tests/test_instructions.py b/tests/test_instructions.py new file mode 100644 index 0000000..ae86944 --- /dev/null +++ b/tests/test_instructions.py @@ -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()))