Address shop-packet review (consistency, determinism, validation, jigs)

- One active CutPlan: the BOM window holds self._plan; Cut List, Shopping,
  Instructions, and Cut Layout all render from it (no more "shopping says 3 sticks
  while the optimized layout uses 2"). _set_plan/_refresh_all keep them in sync.
- Unplaced/oversize parts now appear in the Shopping list ("Won't fit standard
  stock — source/cut specially"), not just a layout warning.
- Process-stable shuffle ordering via hashlib (built-in hash() is salted per run).
- Kerf-gap validation: placement_fits/validate now reject pieces closer than a
  kerf (not just overlap beyond a kerf).
- Manual edits: drop checks stock-type compatibility (no 2x4 onto a plywood
  sheet); waste regions + score recompute after every move/rotate/lock; rotation
  respects allow_plywood_rotation/grain and validate flags illegal rotation.
- Jig specificity: holes/mortises grouped by face + position + size (not just
  cutter size), so a registration template is only suggested when the position
  actually repeats.

125 tests pass; verified offscreen that Shopping tracks the optimized plan and
unplaced parts surface. Phase 1 noted as partial in SHOP_PACKET_PLAN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 15:11:02 -03:00
parent 93d1b186e3
commit 7256b54719
6 changed files with 224 additions and 80 deletions

View File

@ -5,9 +5,17 @@ A living plan for turning the BOM into a **shop-packet generator**. Adjust as we
**Status:** Phases 04 implemented (cutplan.py model; multi-strategy auto-layout;
deterministic instructions + AI polish; rule-based jig suggestions; constrained
drag-edit layout). Logic is unit-tested; the drag/print GUI needs a real display to
verify interactively. Known follow-ups: lock-aware re-optimization (locked pieces
preserved through "Find better layout"), grain-direction handling, on-hand offcut
inventory, and opt-in jig material in the BOM.
verify interactively.
Review fixes applied: one active CutPlan rendered by every tab; unplaced parts
surfaced in Shopping; process-stable shuffle (hashlib); kerf-gap validation; drop
stock-type compatibility; waste/score recompute after manual edits; rotation legality
(settings/grain); position-aware jig grouping.
Known follow-ups: **Phase 1 is partial** — bounded exact search, a real "Best of N"
control, guillotine/maxrects plywood strategies, richer scoring. Also: lock-aware
re-optimization (locked pieces preserved through "Find better layout"), grain-direction
in auto-layout, on-hand offcut inventory, opt-in jig material in the BOM.
## Guiding principle
The **math layer is deterministic and inspectable**; AI is used **only for narrative**

View File

@ -10,6 +10,7 @@ The math here is deterministic and inspectable; AI is never used for numbers.
"""
from __future__ import annotations
import hashlib
from dataclasses import asdict, dataclass, field, fields
from .cutlist import cut_length
@ -18,6 +19,11 @@ from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
_EPS = 1e-6
def _stable_hash(text: str) -> int:
"""Process-stable hash (unlike built-in hash(), which is salted per run)."""
return int(hashlib.md5(text.encode()).hexdigest()[:8], 16)
@dataclass
class ShopSettings:
kerf_in: float = 0.125
@ -146,7 +152,7 @@ def _ordered(items, strategy):
return sorted(items, key=key)
if strategy.startswith("shuffle"): # "shuffle", "shuffle1", ... distinct salts
salt = strategy[7:]
return sorted(items, key=lambda it: (hash(it.id + salt) & 0xffffff))
return sorted(items, key=lambda it: _stable_hash(it.id + salt))
return sorted(items, key=key, reverse=True) # decreasing (FFD) & bestfit (BFD)
@ -333,22 +339,21 @@ def find_placement(plan: CutPlan, pid: str):
raise KeyError(pid)
def _too_close(a: Placement, b: Placement, kerf: float) -> bool:
"""True if a and b are closer than a saw kerf in BOTH axes (so a cut can't
separate them) i.e. they overlap or leave less than kerf between them."""
x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in)
y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in)
return x_ov > -kerf + _EPS and y_ov > -kerf + _EPS
def placement_fits(sp: StockPiece, placement: Placement, kerf: float) -> bool:
"""Is `placement` inside `sp` and not overlapping its other placements (kerf-aware)?"""
"""Is `placement` inside `sp` and kerf-clear of its other placements?"""
if placement.x_in < -_EPS or placement.x_in + placement.len_in > sp.length_in + _EPS:
return False
if placement.y_in < -_EPS or placement.y_in + placement.wid_in > sp.width_in + _EPS:
return False
for q in sp.placements:
if q.id == placement.id:
continue
x_ov = (min(placement.x_in + placement.len_in, q.x_in + q.len_in)
- max(placement.x_in, q.x_in))
y_ov = (min(placement.y_in + placement.wid_in, q.y_in + q.wid_in)
- max(placement.y_in, q.y_in))
if x_ov > kerf - _EPS and y_ov > _EPS:
return False
return True
return not any(_too_close(placement, q, kerf) for q in sp.placements if q.id != placement.id)
def snap_x(sp: StockPiece, placement: Placement, x: float, kerf: float, tol: float = 2.0) -> float:
@ -380,11 +385,35 @@ def rotate_placement(plan: CutPlan, pid: str) -> None:
p.rotated = not p.rotated
def recompute(plan: CutPlan) -> None:
"""Rebuild waste regions (incl. gaps left by manual moves) and the score —
call after any manual edit so the diagram and yield stay truthful."""
s = plan.settings
for sp in plan.stock_pieces:
sp.waste = []
if sp.is_sheet:
continue
cursor = 0.0
for p in sorted(sp.placements, key=lambda p: p.x_in):
gap = round(p.x_in - cursor, 3)
if gap > 0.5:
sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=gap,
width_in=sp.width_in, reusable=gap >= s.offcut_usable_in))
cursor = max(cursor, p.x_in + p.len_in)
tail = round(sp.length_in - cursor, 3)
if tail > 0.5:
sp.waste.append(WasteRegion(x_in=round(cursor, 3), length_in=tail,
width_in=sp.width_in, reusable=tail >= s.offcut_usable_in))
plan.score = _score(plan.stock_pieces, s, plan.strategy, plan.warnings)
def validate_cut_plan(plan: CutPlan) -> list:
"""Return a list of problems ([] means valid): pieces inside stock, no
overlaps, kerf respected, every item placed-or-warned."""
problems = []
s = plan.settings
items = {it.id: it for it in plan.items}
rot_ok = s.allow_plywood_rotation and not s.grain_direction
placed_items = set()
for sp in plan.stock_pieces:
for p in sp.placements:
@ -393,15 +422,16 @@ def validate_cut_plan(plan: CutPlan) -> list:
problems.append(f"{p.id} runs off {sp.id} lengthwise")
if p.y_in < -_EPS or p.y_in + p.wid_in > sp.width_in + _EPS:
problems.append(f"{p.id} runs off {sp.id} widthwise")
# pairwise overlap (with kerf) on the same stock piece
it = items.get(p.item_id)
if it and it.stock != sp.stock:
problems.append(f"{p.id} ({it.stock}) is on a {sp.stock} stock piece")
if p.rotated and not rot_ok:
problems.append(f"{p.id} is rotated but rotation isn't allowed")
ps = sp.placements
for i in range(len(ps)):
for j in range(i + 1, len(ps)):
a, b = ps[i], ps[j]
x_ov = min(a.x_in + a.len_in, b.x_in + b.len_in) - max(a.x_in, b.x_in)
y_ov = min(a.y_in + a.wid_in, b.y_in + b.wid_in) - max(a.y_in, b.y_in)
if x_ov > s.kerf_in - _EPS and y_ov > _EPS:
problems.append(f"{a.id} and {b.id} overlap on {sp.id}")
if _too_close(ps[i], ps[j], s.kerf_in):
problems.append(f"{ps[i].id} and {ps[j].id} are closer than a kerf on {sp.id}")
for it in plan.items:
if it.id not in placed_items and it.id not in plan.unplaced:
problems.append(f"{it.part_id} ({it.id}) is neither placed nor flagged unplaced")

View File

@ -12,12 +12,13 @@ from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraph
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from ..cutlist import _fmt_len, cut_rows, shopping
from collections import Counter
from ..cutlist import _fmt_len, board_feet
from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement,
placement_fits, relocate, rotate_placement, snap_x)
placement_fits, recompute, relocate, rotate_placement, snap_x)
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
_PX = 7.0 # pixels per inch in the layout view
@ -67,28 +68,45 @@ class BomWindow(QDialog):
self.resize(820, 640)
self._order = 0
self._optimized = False
self._plan = None # persistent editable plan for the layout tab
self._rebuild = True # regenerate from auto-layout on next draw
self._plan = build_cut_plan(self.c.scene) # the ONE active plan all tabs render
self._px = _PX
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
self.pool = QThreadPool.globalInstance()
self._cut_te = self._mono_te()
self._shop_te = self._mono_te()
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._print_wrap(self._cut_te), "Cut List")
tabs.addTab(self._print_wrap(self._shop_te), "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)
self._refresh_all()
# ----- one active plan; all tabs render from it ---------------------
def _set_plan(self, plan) -> None:
recompute(plan) # keep waste/score truthful after any change
self._plan = plan
self._refresh_all()
def _refresh_all(self) -> None:
self._cut_te.setPlainText(self._cut_text())
self._shop_te.setPlainText(self._shop_text())
self._instr.setPlainText(format_steps(build_steps(self.c.scene, self._plan)))
self._jigs.setPlainText(format_jigs(suggest_jigs(self.c.scene)))
self._draw_layout()
# ----- text tabs ----------------------------------------------------
def _text_tab(self, text: str) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
def _mono_te(self) -> QTextEdit:
te = QTextEdit(readOnly=True)
te.setFont(QFont("monospace"))
te.setPlainText(text)
return te
def _print_wrap(self, te: QTextEdit) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
v.addWidget(te)
btn = QPushButton("Print…")
btn.clicked.connect(lambda: self._print_text(te))
@ -97,32 +115,44 @@ class BomWindow(QDialog):
return w
def _cut_text(self) -> str:
rows = cut_rows(self.c.scene)
plan = self._plan
groups = Counter((it.stock, round(it.length_in, 2), round(it.width_in, 2), it.is_sheet)
for it in plan.items)
lines = ["CUT LIST", ""]
for r in rows:
if r["plywood"]:
lines.append(f" {r['count']:>2} × {r['stock']:<8} {_fmt_len(r['width_in'])} × "
f"{_fmt_len(r['length_in'])} ({r['sq_ft']:.1f} sq ft)")
for (stock, ln, wd, sheet), n in sorted(groups.items()):
if sheet:
lines.append(f" {n:>2} × {stock:<8} {_fmt_len(wd)} × {_fmt_len(ln)}"
f" ({wd * ln / 144 * n:.1f} sq ft)")
else:
lines.append(f" {r['count']:>2} × {r['stock']:<8} @ {_fmt_len(r['length_in']):<9}"
f" ({r['board_feet']:.1f} bd-ft)")
if not rows:
lines.append(f" {n:>2} × {stock:<8} @ {_fmt_len(ln):<9}"
f" ({board_feet(stock, ln) * n:.1f} bd-ft)")
if not plan.items:
lines.append(" (nothing to cut yet)")
return "\n".join(lines)
def _shop_text(self) -> str:
plan = self._plan
lines = ["SHOPPING LIST", "", "Buy:"]
for stock, qty in shopping(self.c.scene).items():
for stock, qty in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
s = "s" if qty != 1 else ""
unit = f"sheet{s} (4×8)" if stock.startswith("ply-") else f"stick{s} (8')"
lines.append(f" {qty} × {stock} {unit}")
lines += ["", "Yield (used / bought):"]
for stock, w in waste_summary(self.c.scene).items():
unit = "sq ft" if stock.startswith("ply-") else "in"
pct = (w["used"] / w["capacity"] * 100) if w["capacity"] else 0
lines.append(f" {stock}: {w['used']:g} / {w['capacity']:g} {unit} ({pct:.0f}% used)")
if len(lines) <= 3:
if not plan.stock_pieces:
lines.append(" (nothing yet)")
if plan.unplaced:
lines += ["", "⚠ Won't fit standard stock — source / cut specially:"]
for iid in plan.unplaced:
it = plan.item(iid)
lines.append(f" {it.part_id}: {_fmt_len(it.length_in)} {it.stock}")
lines += ["", "Yield (used / bought):"]
for stock in sorted({sp.stock for sp in plan.stock_pieces}):
sps = [sp for sp in plan.stock_pieces if sp.stock == stock]
sheet = sps[0].is_sheet
used = sum(p.len_in * p.wid_in for sp in sps for p in sp.placements)
cap = sum(sp.length_in * sp.width_in for sp in sps)
pct = used / cap * 100 if cap else 0
lines.append(f" {stock}: {pct:.0f}% used over {len(sps)} "
f"{'sheet' if sheet else 'stick'}{'s' if len(sps) != 1 else ''}")
return "\n".join(lines)
def _print_text(self, te: QTextEdit) -> None:
@ -148,7 +178,7 @@ class BomWindow(QDialog):
return w
def _polish_instructions(self) -> None:
prompt = polish_prompt(build_steps(self.c.scene))
prompt = polish_prompt(build_steps(self.c.scene, self._plan))
self._polish.setEnabled(False)
self._polish.setText("Rewriting…")
@ -234,20 +264,15 @@ class BomWindow(QDialog):
return w
def _optimize(self) -> None:
self._optimized, self._rebuild = True, True
self._draw_layout()
self._optimized = True
self._set_plan(best_cut_plan(self.c.scene))
def _next_arrangement(self) -> None:
self._optimized = False
self._order = (self._order + 1) % len(STRATEGIES)
self._rebuild = True
self._draw_layout()
self._set_plan(build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order]))
def _draw_layout(self) -> None:
if self._rebuild or self._plan is None:
self._plan = (best_cut_plan(self.c.scene) if self._optimized
else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order]))
self._rebuild = False
plan = self._plan
self.scene.clear()
self._rows = []
@ -303,6 +328,13 @@ class BomWindow(QDialog):
def _row_of(self, sp_id):
return next((y0 for y0, _y1, s in self._rows if s.id == sp_id), 0.0)
def _revert(self, plan, pid, home) -> None:
hsp, hx, hy = home
hrow = self._row_of(hsp)
home_sp = next(s for s in plan.stock_pieces if s.id == hsp)
relocate(plan, pid, hsp, hx / self._px,
(hy - hrow) / self._px if home_sp.is_sheet else 0.0)
def _drop_piece(self, item, home) -> None:
plan, px = self._plan, self._px
cy = item.sceneBoundingRect().center().y()
@ -310,6 +342,12 @@ class BomWindow(QDialog):
sp_cur, p = find_placement(plan, item.pid)
if target is None:
target = sp_cur
# Stock-type compatibility: a 2x4 can't go on a plywood sheet, etc.
if plan.item(item.pid).stock != target.stock:
self._revert(plan, item.pid, home)
self._status.setText(f"{plan.item(item.pid).stock} can't go on {target.stock} — reverted")
recompute(plan); self._refresh_all()
return
row_y0 = self._row_of(target.id)
x_in = max(item.pos().x() / px, 0.0)
y_in = max((item.pos().y() - row_y0) / px, 0.0) if target.is_sheet else 0.0
@ -319,43 +357,41 @@ class BomWindow(QDialog):
if placement_fits(target, p, plan.settings.kerf_in):
self._status.setText("✓ placed")
else:
hsp, hx, hy = home
hrow = self._row_of(hsp)
home_sp = next(s for s in plan.stock_pieces if s.id == hsp)
relocate(plan, item.pid, hsp, hx / px,
(hy - hrow) / px if home_sp.is_sheet else 0.0)
self._revert(plan, item.pid, home)
self._status.setText("✗ overlap / off-stock — move reverted")
self._rebuild = False
self._draw_layout()
recompute(plan) # refresh waste/score after the edit
self._refresh_all()
def _rotate_piece(self, pid) -> None:
plan = self._plan
sp, p = find_placement(plan, pid)
if not sp.is_sheet:
return
if not plan.settings.allow_plywood_rotation or plan.settings.grain_direction:
self._status.setText("✗ rotation isn't allowed (grain / settings)")
return
rotate_placement(plan, pid)
if placement_fits(sp, p, plan.settings.kerf_in):
self._status.setText("✓ rotated")
else:
rotate_placement(plan, pid) # rotate back
self._status.setText("✗ rotation doesn't fit")
self._rebuild = False
self._draw_layout()
recompute(plan)
self._refresh_all()
def _piece_menu(self, pid, screen_pos) -> None:
plan = self._plan
sp, p = find_placement(plan, pid)
menu = QMenu(self)
menu.addAction("Unlock" if p.locked else "Lock", lambda: self._toggle_lock(pid))
if sp.is_sheet:
if sp.is_sheet and plan.settings.allow_plywood_rotation and not plan.settings.grain_direction:
menu.addAction("Rotate", lambda: self._rotate_piece(pid))
menu.exec(screen_pos)
def _toggle_lock(self, pid) -> None:
_sp, p = find_placement(self._plan, pid)
p.locked = not p.locked
self._rebuild = False
self._draw_layout()
self._refresh_all()
def _rect(self, x, y, w, h, color, text) -> None:
item = QGraphicsRectItem(x, y, w, h)

View File

@ -37,25 +37,30 @@ def suggest_jigs(scene, min_repeats: int = 3) -> list:
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)
# Repeated holes at the SAME registered position -> drilling template.
# (Grouping by position, not just diameter — a fixed template only locates
# holes that share a face + offsets.)
holes = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2), 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()):
for (stock, face, along, across, 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"]))
"drill-template", f"Drilling template — {n}×{dia:g}\" holes ({stock}, {face})", n,
f"Make a template with a ⌀{dia:g}\" guide hole and clamp it to register the hole "
f"at the same spot ({_fmt_len(along)} along, {across:g}\" off centre) on all {n} parts.",
["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))
# Repeated mortises at the same position/size -> positioning template.
mort = Counter((p.stock, f.face, round(f.along_in, 2), round(f.across_in, 2),
round(f.width_in, 2), round(f.height_in, 2), round(f.depth_in, 2), round(f.rotation_deg, 1))
for p in scene.parts for f in p.features if f.kind == "mortise")
for (w, h, d), n in sorted(mort.items()):
for (stock, face, along, across, w, h, d, _rot), 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"]))
"mortise-template", f"Mortise template — {n}× {w:g}×{h:g}\" ({stock}, {face})", n,
f"Build a routing template with a {w:g}×{h:g}\" opening; register it at the same spot "
f"({_fmt_len(along)} along) and rout all {n} mortises {_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))

View File

@ -142,6 +142,55 @@ def test_rotate_placement_swaps_footprint():
assert p.len_in == W and p.wid_in == L and p.rotated != rot
def test_kerf_gap_required_not_just_overlap():
from woodshop.cutplan import placement_fits
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
p1, p2 = stick.placements
k = plan.settings.kerf_in
p2.x_in = p1.len_in + 0.01 # closer than a kerf
assert not placement_fits(stick, p2, k)
p2.x_in = p1.len_in + k # exactly a kerf apart
assert placement_fits(stick, p2, k)
def test_validate_flags_wrong_stock_and_illegal_rotation():
from woodshop.cutplan import relocate, rotate_placement
s = Scene()
s.place("2x4", 24)
s.place("ply-3/4", 24, width_in=24)
plan = build_cut_plan(s)
lumber = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
sheet = next(sp for sp in plan.stock_pieces if sp.is_sheet)
relocate(plan, lumber.placements[0].id, sheet.id, 0.0, 0.0)
assert any("stock piece" in p for p in validate_cut_plan(plan))
plan2 = build_cut_plan(s, settings=ShopSettings(allow_plywood_rotation=False))
sh2 = next(sp for sp in plan2.stock_pieces if sp.is_sheet)
rotate_placement(plan2, sh2.placements[0].id)
assert any("rotation" in p for p in validate_cut_plan(plan2))
def test_recompute_updates_waste_after_move():
from woodshop.cutplan import recompute
s = Scene()
s.place("2x4", 30)
s.place("2x4", 30)
plan = build_cut_plan(s)
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
stick.placements[1].x_in = 60.0 # leave a gap after p1
recompute(plan)
assert any(abs(w.x_in - 30) < 1.0 for w in stick.waste) # gap at ~30 now waste
def test_stable_hash_is_deterministic():
from woodshop.cutplan import _stable_hash
assert _stable_hash("ci1x") == _stable_hash("ci1x")
def test_custom_settings_kerf():
s = Scene()
s.place("2x4", 48)

View File

@ -35,5 +35,21 @@ def test_repeated_mortises_suggest_template():
assert any(j.kind == "mortise-template" for j in suggest_jigs(s))
def test_holes_at_same_position_suggest_template():
s = Scene()
for _ in range(3):
p = s.place("2x4", 24)
s.add_feature(p.id, "hole", face="top", along_in=3, diameter_in=0.375)
assert any(j.kind == "drill-template" for j in suggest_jigs(s))
def test_holes_at_different_positions_no_template():
s = Scene()
for along in (3, 9, 15): # same diameter, different spots
p = s.place("2x4", 24)
s.add_feature(p.id, "hole", face="top", along_in=along, diameter_in=0.375)
assert not any(j.kind == "drill-template" for j in suggest_jigs(s))
def test_format_empty():
assert "No repeated" in format_jigs([])