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:
parent
93d1b186e3
commit
7256b54719
|
|
@ -5,9 +5,17 @@ A living plan for turning the BOM into a **shop-packet generator**. Adjust as we
|
|||
**Status:** Phases 0–4 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**
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
Loading…
Reference in New Issue