From 3643aac50df954623c44a536266b6c2021ae6fce Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 May 2026 13:07:49 -0300 Subject: [PATCH] BOM window: tabs + cut-layout nesting + print (phase 1 of shop output) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/woodshop/cutlist.py | 18 +--- src/woodshop/gui/bom_window.py | 158 ++++++++++++++++++++++++++++++++ src/woodshop/gui/main_window.py | 4 +- src/woodshop/layout.py | 122 ++++++++++++++++++++++++ tests/test_layout.py | 45 +++++++++ 5 files changed, 332 insertions(+), 15 deletions(-) create mode 100644 src/woodshop/gui/bom_window.py create mode 100644 src/woodshop/layout.py create mode 100644 tests/test_layout.py diff --git a/src/woodshop/cutlist.py b/src/woodshop/cutlist.py index 86695c3..ce89a52 100644 --- a/src/woodshop/cutlist.py +++ b/src/woodshop/cutlist.py @@ -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: diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py new file mode 100644 index 0000000..7173972 --- /dev/null +++ b/src/woodshop/gui/bom_window.py @@ -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() diff --git a/src/woodshop/gui/main_window.py b/src/woodshop/gui/main_window.py index bf579fd..8af83a4 100644 --- a/src/woodshop/gui/main_window.py +++ b/src/woodshop/gui/main_window.py @@ -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) diff --git a/src/woodshop/layout.py b/src/woodshop/layout.py new file mode 100644 index 0000000..927e6af --- /dev/null +++ b/src/woodshop/layout.py @@ -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 diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..35f2d62 --- /dev/null +++ b/tests/test_layout.py @@ -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