BOM window: tabs + cut-layout nesting + print (phase 1 of shop output)

layout.py: cutting-stock nesting — 1D lumber (first-fit-decreasing into 8' sticks,
kerf-aware) and 2D plywood (shelf packing onto 4x8 sheets), plus stock_counts
(now drives the accurate buy-counts) and waste_summary. Tested headlessly.

gui/bom_window.py replaces the cut-list QMessageBox popup with a tabbed window:
- Cut List + Shopping List tabs (printable via QPrinter).
- Cut Layout tab: a QGraphicsScene diagram of pieces packed onto each stick/sheet
  with waste, a "Try another arrangement" button (cycles ordering heuristics),
  and Print. Verified offscreen — the layout renders correctly.

96 tests pass.

Deferred (phase 2): drag-to-rearrange pieces, true-optimal nesting, generated
step-by-step instructions, and jig suggestions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 13:07:49 -03:00
parent e4f9cedf4a
commit 3643aac50d
5 changed files with 332 additions and 15 deletions

View File

@ -67,20 +67,10 @@ def cut_rows(scene: Scene) -> list[dict]:
def shopping(scene: Scene) -> dict[str, int]:
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets
(by total length / area, +10% waste)."""
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
lumber: dict[str, float] = defaultdict(float)
ply_area: dict[str, float] = defaultdict(float)
for p in scene.parts:
if is_plywood(p.stock):
ply_area[p.stock] += cut_length(p) * p.section_in[1]
else:
lumber[p.stock] += cut_length(p)
out = {s: math.ceil(L * 1.10 / STICK_LENGTH_IN) for s, L in sorted(lumber.items())}
sheet = SHEET_WIDTH_IN * SHEET_LENGTH_IN
out.update({s: math.ceil(a * 1.10 / sheet) for s, a in sorted(ply_area.items())})
return out
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets,
from the actual cutting-stock nesting (kerf-aware)."""
from .layout import stock_counts
return dict(sorted(stock_counts(scene).items()))
def format_cutlist(scene: Scene) -> str:

View File

@ -0,0 +1,158 @@
"""The shop-output window: tabbed Cut List / Shopping List / Cut Layout, each
printable. The Cut Layout tab draws the cutting-stock nesting and can try
alternative arrangements."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
from ..cutlist import _fmt_len, cut_rows, shopping
from ..layout import nest_lumber, nest_plywood, waste_summary
_ORDERS = ["decreasing", "increasing", "shuffle"]
_PX = 7.0 # pixels per inch in the layout view
_PIECE = "#c8965a"
_WASTE = "#3a3a3a"
class BomWindow(QDialog):
def __init__(self, controller, parent=None):
super().__init__(parent)
self.c = controller
self.setWindowTitle("Cut List & BOM")
self.resize(820, 640)
self._order = 0
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")
root = QVBoxLayout(self)
root.addWidget(tabs)
# ----- text tabs ----------------------------------------------------
def _text_tab(self, text: str) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
te = QTextEdit(readOnly=True)
te.setFont(QFont("monospace"))
te.setPlainText(text)
v.addWidget(te)
btn = QPushButton("Print…")
btn.clicked.connect(lambda: self._print_text(te))
row = QHBoxLayout(); row.addStretch(); row.addWidget(btn)
v.addLayout(row)
return w
def _cut_text(self) -> str:
rows = cut_rows(self.c.scene)
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)")
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(" (nothing to cut yet)")
return "\n".join(lines)
def _shop_text(self) -> str:
lines = ["SHOPPING LIST", "", "Buy:"]
for stock, qty in shopping(self.c.scene).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:
lines.append(" (nothing yet)")
return "\n".join(lines)
def _print_text(self, te: QTextEdit) -> None:
printer = QPrinter()
if QPrintDialog(printer, self).exec():
te.print_(printer)
# ----- layout tab ---------------------------------------------------
def _layout_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
self.scene = QGraphicsScene()
self.view = QGraphicsView(self.scene)
v.addWidget(self.view)
row = QHBoxLayout()
again = QPushButton("Try another arrangement")
again.clicked.connect(self._next_arrangement)
pr = QPushButton("Print…")
pr.clicked.connect(self._print_layout)
row.addWidget(again); row.addStretch(); row.addWidget(pr)
v.addLayout(row)
self._draw_layout()
return w
def _next_arrangement(self) -> None:
self._order = (self._order + 1) % len(_ORDERS)
self._draw_layout()
def _draw_layout(self) -> None:
self.scene.clear()
order = _ORDERS[self._order]
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
px, y, bar = _PX, 16.0, 34.0
# 1D lumber: each stick a horizontal bar (all coords in pixels).
for stock, sticks in nest_lumber(self.c.scene, order=order).items():
for i, st in enumerate(sticks):
self._label(0, y - 15, f"{stock} stick {i + 1}")
x = 0.0
for pid, ln in st["pieces"]:
self._rect(x * px, y, ln * px, bar, _PIECE, f"{names[pid]} · {_fmt_len(ln)}")
x += ln
if st["offcut"] > 0.5:
self._rect(x * px, y, st["offcut"] * px, bar, _WASTE, f"waste {_fmt_len(st['offcut'])}")
y += bar + 24
# 2D plywood: each sheet a rectangle with placed panels.
for stock, sheets in nest_plywood(self.c.scene, order=order).items():
for i, sh in enumerate(sheets):
sw, sl = sh["sheet"]
self._label(0, y - 15, f"{stock} sheet {i + 1} ({_fmt_len(sw)}×{_fmt_len(sl)})")
self._rect(0, y, sl * px, sw * px, _WASTE, "")
for pid, x, yy, pl, pw in sh["placements"]:
self._rect(x * px, y + yy * px, pl * px, pw * px, _PIECE, names[pid])
y += sw * px + 34
self.view.setSceneRect(self.scene.itemsBoundingRect())
def _rect(self, x, y, w, h, color, text) -> None:
item = QGraphicsRectItem(x, y, w, h)
item.setBrush(QBrush(QColor(color)))
item.setPen(QPen(QColor("#111111")))
self.scene.addItem(item)
if text:
t = QGraphicsSimpleTextItem(text)
t.setBrush(QBrush(QColor("white")))
t.setPos(x + 3, y + 3)
self.scene.addItem(t)
def _label(self, x, y, text) -> None:
t = QGraphicsSimpleTextItem(text)
t.setBrush(QBrush(QColor("#cccccc")))
t.setPos(x, y)
self.scene.addItem(t)
def _print_layout(self) -> None:
printer = QPrinter()
if QPrintDialog(printer, self).exec():
from PySide6.QtGui import QPainter
painter = QPainter(printer)
self.scene.render(painter)
painter.end()

View File

@ -180,7 +180,9 @@ class MainWindow(QMainWindow):
self.command.submit(template.format(**vals))
def _show_cutlist(self):
QMessageBox.information(self, "Cut List", self.controller.cutlist_text())
from .bom_window import BomWindow
self._bom = BomWindow(self.controller, self) # keep a ref so it isn't GC'd
self._bom.show()
def _show_help(self):
QMessageBox.information(self, "Commands", _HELP)

122
src/woodshop/layout.py Normal file
View File

@ -0,0 +1,122 @@
"""Cutting-stock layout: pack the required pieces onto standard stock to estimate
waste and drive the cut-layout view.
- Lumber is 1D: pack piece lengths into 8' sticks (first-fit-decreasing + kerf).
- Plywood is 2D: shelf/guillotine packing of panels onto 4'×8' sheets.
Heuristics, not provably optimal 2D nesting is NP-hard but good, and the
ordering can be varied ("try another arrangement"). Pure logic, no GUI.
"""
from __future__ import annotations
from collections import defaultdict
from .cutlist import cut_length
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
STICK_LEN = 96.0 # an 8' stick
KERF = 0.125 # saw kerf between cuts
def nest_lumber(scene, stick_len=STICK_LEN, kerf=KERF, order="decreasing") -> dict:
"""{stock: [ {pieces:[(part_id,len)], used, offcut}, ... ]} — sticks per stock."""
by_stock: dict[str, list] = defaultdict(list)
for p in scene.parts:
if not is_plywood(p.stock):
by_stock[p.stock].append((p.id, round(cut_length(p), 3)))
result = {}
for stock, pieces in by_stock.items():
pieces = _ordered(pieces, key=lambda x: x[1], how=order)
sticks: list[dict] = []
for pid, ln in pieces:
for st in sticks:
need = ln + (kerf if st["pieces"] else 0)
if st["used"] + need <= stick_len + 1e-6:
st["pieces"].append((pid, ln))
st["used"] += need
break
else:
sticks.append({"pieces": [(pid, ln)], "used": ln})
for st in sticks:
st["offcut"] = round(stick_len - st["used"], 3)
result[stock] = sticks
return result
def nest_plywood(scene, sheet_w=SHEET_WIDTH_IN, sheet_l=SHEET_LENGTH_IN,
kerf=KERF, order="decreasing") -> dict:
"""{stock: [ {placements:[(id,x,y,w,h)], sheet:(w,l)}, ... ]}.
x runs along the sheet length, y across its width. Shelf packing (no rotation)."""
by_stock: dict[str, list] = defaultdict(list)
for p in scene.parts:
if is_plywood(p.stock):
by_stock[p.stock].append((p.id, round(cut_length(p), 3), round(p.section_in[1], 3)))
result = {}
for stock, panels in by_stock.items():
panels = _ordered(panels, key=lambda x: x[2], how=order) # tallest (width) first
rest = list(panels)
sheets = []
while rest:
placed, rest = _pack_sheet(rest, sheet_w, sheet_l, kerf)
if not placed: # a single panel bigger than a sheet
pid, pl, pw = rest.pop(0)
placed = [(pid, 0.0, 0.0, min(pl, sheet_l), min(pw, sheet_w))]
sheets.append({"placements": placed, "sheet": (sheet_w, sheet_l)})
result[stock] = sheets
return result
def _pack_sheet(panels, sw, sl, kerf):
"""Place as many panels as fit on one sheet via shelves; return (placed, rest)."""
placed, shelves, rest = [], [], [] # shelves: dicts {y, height, x}
for pid, pl, pw in panels:
done = False
for sh in shelves: # fit into an existing shelf
x = sh["x"] + (kerf if sh["x"] else 0)
if pw <= sh["height"] + 1e-6 and x + pl <= sl + 1e-6:
placed.append((pid, x, sh["y"], pl, pw))
sh["x"] = x + pl
done = True
break
if not done: # start a new shelf if width remains
y = (shelves[-1]["y"] + shelves[-1]["height"] + kerf) if shelves else 0.0
if y + pw <= sw + 1e-6 and pl <= sl + 1e-6:
shelves.append({"y": y, "height": pw, "x": pl})
placed.append((pid, 0.0, y, pl, pw))
done = True
if not done:
rest.append((pid, pl, pw))
return placed, rest
def _ordered(items, key, how):
if how == "increasing":
return sorted(items, key=key)
if how == "shuffle": # deterministic-ish alternative without RNG state
return sorted(items, key=lambda x: (hash(x[0]) & 0xffff))
return sorted(items, key=key, reverse=True) # decreasing (default)
def stock_counts(scene, **kw) -> dict:
"""Pieces-of-stock to buy, from the actual nesting (more accurate than
length/area ÷ stock)."""
counts = {s: len(sticks) for s, sticks in nest_lumber(scene, **kw).items()}
counts.update({s: len(sheets) for s, sheets in nest_plywood(scene, **kw).items()})
return counts
def waste_summary(scene, **kw) -> dict:
"""Per-stock {bought, used_in, offcut_in / area} for a rough yield report."""
out = {}
for stock, sticks in nest_lumber(scene, **kw).items():
used = sum(s["used"] for s in sticks)
out[stock] = {"bought": len(sticks), "used": round(used, 1),
"capacity": round(len(sticks) * STICK_LEN, 1)}
for stock, sheets in nest_plywood(scene, **kw).items():
used = sum(w * h for sh in sheets for (_id, _x, _y, w, h) in sh["placements"])
cap = len(sheets) * SHEET_WIDTH_IN * SHEET_LENGTH_IN
out[stock] = {"bought": len(sheets), "used": round(used / 144, 1),
"capacity": round(cap / 144, 1)}
return out

45
tests/test_layout.py Normal file
View File

@ -0,0 +1,45 @@
"""Tests for the cutting-stock nesting (lumber 1D, plywood 2D)."""
from woodshop.layout import nest_lumber, nest_plywood, stock_counts
from woodshop.scene import Scene
def test_lumber_packs_into_sticks():
s = Scene()
for _ in range(3):
s.place("2x4", 40) # three 40" pieces -> two fit in a 96" stick (80+kerf)
sticks = nest_lumber(s)["2x4"]
assert len(sticks) == 2 # 2 in the first stick, 1 in the second
assert sum(len(st["pieces"]) for st in sticks) == 3
assert all(st["used"] <= 96 + 1e-6 for st in sticks)
def test_lumber_offcut_reported():
s = Scene()
s.place("2x4", 90)
stick = nest_lumber(s)["2x4"][0]
assert stick["offcut"] == 6.0 # 96 - 90
def test_plywood_packs_panels_on_sheet():
s = Scene()
s.place("ply-3/4", 40, width_in=20) # two panels easily fit on one 48x96 sheet
s.place("ply-3/4", 40, width_in=20)
sheets = nest_plywood(s)["ply-3/4"]
assert len(sheets) == 1
assert len(sheets[0]["placements"]) == 2
def test_oversize_panel_gets_its_own_sheet():
s = Scene()
s.place("ply-3/4", 96, width_in=48) # a full sheet
s.place("ply-3/4", 96, width_in=48) # another full sheet
assert len(nest_plywood(s)["ply-3/4"]) == 2
def test_stock_counts_mixes_lumber_and_plywood():
s = Scene()
s.place("2x4", 40)
s.place("ply-1/2", 24, width_in=24)
counts = stock_counts(s)
assert counts["2x4"] == 1
assert counts["ply-1/2"] == 1