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:
parent
e4f9cedf4a
commit
3643aac50d
|
|
@ -67,20 +67,10 @@ def cut_rows(scene: Scene) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
def shopping(scene: Scene) -> dict[str, int]:
|
def shopping(scene: Scene) -> dict[str, int]:
|
||||||
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets
|
"""How many to buy per stock: lumber in 8' sticks, plywood in 4×8 sheets,
|
||||||
(by total length / area, +10% waste)."""
|
from the actual cutting-stock nesting (kerf-aware)."""
|
||||||
from .lumber import SHEET_LENGTH_IN, SHEET_WIDTH_IN, is_plywood
|
from .layout import stock_counts
|
||||||
lumber: dict[str, float] = defaultdict(float)
|
return dict(sorted(stock_counts(scene).items()))
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def format_cutlist(scene: Scene) -> str:
|
def format_cutlist(scene: Scene) -> str:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -180,7 +180,9 @@ class MainWindow(QMainWindow):
|
||||||
self.command.submit(template.format(**vals))
|
self.command.submit(template.format(**vals))
|
||||||
|
|
||||||
def _show_cutlist(self):
|
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):
|
def _show_help(self):
|
||||||
QMessageBox.information(self, "Commands", _HELP)
|
QMessageBox.information(self, "Commands", _HELP)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue