Phase 4: constrained manual layout editing (drag/snap/lock/rotate)
cutplan.py gains deterministic editing helpers: find_placement, placement_fits (bounds + kerf-aware overlap), snap_x (snap to stock edges / neighbour ±kerf), relocate (move a placement to a stock piece / position), rotate_placement. The BOM Cut Layout tab is now editable: it holds a persistent CutPlan; pieces are draggable (_Piece) — on drop they snap, validate, and either commit or revert with a status message; you can drag a piece onto another stick/sheet to reassign it, double-click a panel to rotate it, and right-click to lock (locked pieces can't be dragged). "Find better layout" / "Try alternative" regenerate the auto plan. 119 tests pass (snap, fits/overlap, relocate-between-sticks, rotate). Window + drag/rotate handlers verified offscreen; interactive drag/print needs a display. Known follow-up: lock-aware re-optimization (locked pieces currently protect against drags but aren't yet preserved across a fresh auto-layout). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee00ec7ce5
commit
93d1b186e3
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go.
|
A living plan for turning the BOM into a **shop-packet generator**. Adjust as we go.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## Guiding principle
|
## Guiding principle
|
||||||
The **math layer is deterministic and inspectable**; AI is used **only for narrative**
|
The **math layer is deterministic and inspectable**; AI is used **only for narrative**
|
||||||
(instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig
|
(instruction wording, jig explanations). Cut lengths, kerf, counts, layouts, jig
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,62 @@ def best_cut_plan(scene, settings: ShopSettings | None = None, attempts: int = 2
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# --- manual editing helpers (deterministic; the drag UI builds on these) -----
|
||||||
|
def find_placement(plan: CutPlan, pid: str):
|
||||||
|
for sp in plan.stock_pieces:
|
||||||
|
for p in sp.placements:
|
||||||
|
if p.id == pid:
|
||||||
|
return sp, p
|
||||||
|
raise KeyError(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def placement_fits(sp: StockPiece, placement: Placement, kerf: float) -> bool:
|
||||||
|
"""Is `placement` inside `sp` and not overlapping its other placements (kerf-aware)?"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def snap_x(sp: StockPiece, placement: Placement, x: float, kerf: float, tol: float = 2.0) -> float:
|
||||||
|
"""Snap an x position to stock edges / neighbour edges (+kerf), within `tol`."""
|
||||||
|
cands = [0.0, sp.length_in - placement.len_in]
|
||||||
|
for q in sp.placements:
|
||||||
|
if q.id == placement.id:
|
||||||
|
continue
|
||||||
|
cands.append(q.x_in + q.len_in + kerf) # butt to the right of q (+kerf)
|
||||||
|
cands.append(q.x_in - placement.len_in - kerf) # butt to the left of q
|
||||||
|
best = min(cands, key=lambda c: abs(c - x))
|
||||||
|
return best if abs(best - x) <= tol else x
|
||||||
|
|
||||||
|
|
||||||
|
def relocate(plan: CutPlan, pid: str, target_sp_id: str, x_in: float, y_in: float = 0.0) -> None:
|
||||||
|
"""Move a placement to a stock piece at (x,y). Does not validate (caller checks)."""
|
||||||
|
sp, p = find_placement(plan, pid)
|
||||||
|
target = next(s for s in plan.stock_pieces if s.id == target_sp_id)
|
||||||
|
if target is not sp:
|
||||||
|
sp.placements.remove(p)
|
||||||
|
target.placements.append(p)
|
||||||
|
p.x_in, p.y_in = x_in, y_in
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_placement(plan: CutPlan, pid: str) -> None:
|
||||||
|
"""Swap a placement's footprint (plywood rotation)."""
|
||||||
|
_sp, p = find_placement(plan, pid)
|
||||||
|
p.len_in, p.wid_in = p.wid_in, p.len_in
|
||||||
|
p.rotated = not p.rotated
|
||||||
|
|
||||||
|
|
||||||
def validate_cut_plan(plan: CutPlan) -> list:
|
def validate_cut_plan(plan: CutPlan) -> list:
|
||||||
"""Return a list of problems ([] means valid): pieces inside stock, no
|
"""Return a list of problems ([] means valid): pieces inside stock, no
|
||||||
overlaps, kerf respected, every item placed-or-warned."""
|
overlaps, kerf respected, every item placed-or-warned."""
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ import subprocess
|
||||||
from PySide6.QtCore import Qt, QThreadPool
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||||
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
|
||||||
from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
|
from PySide6.QtWidgets import (QDialog, QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
|
||||||
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
|
QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, QLabel,
|
||||||
QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
QMenu, QPushButton, QTabWidget, QTextEdit, QVBoxLayout, QWidget)
|
||||||
|
|
||||||
from ..cutlist import _fmt_len, cut_rows, shopping
|
from ..cutlist import _fmt_len, cut_rows, shopping
|
||||||
from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan
|
from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement,
|
||||||
|
placement_fits, relocate, rotate_placement, snap_x)
|
||||||
from ..instructions import build_steps, format_steps, polish_prompt
|
from ..instructions import build_steps, format_steps, polish_prompt
|
||||||
from ..jigs import explain_prompt, format_jigs, suggest_jigs
|
from ..jigs import explain_prompt, format_jigs, suggest_jigs
|
||||||
from ..layout import waste_summary
|
from ..layout import waste_summary
|
||||||
|
|
@ -24,6 +25,40 @@ _PIECE = "#c8965a"
|
||||||
_WASTE = "#3a3a3a"
|
_WASTE = "#3a3a3a"
|
||||||
|
|
||||||
|
|
||||||
|
class _Piece(QGraphicsRectItem):
|
||||||
|
"""A draggable cut piece on the layout. Reports drops/rotate/lock back to the
|
||||||
|
window, which snaps + validates against the CutPlan."""
|
||||||
|
|
||||||
|
def __init__(self, win, pid, sp_id, w, h, locked, text):
|
||||||
|
super().__init__(0, 0, w, h)
|
||||||
|
self.win, self.pid, self.sp_id = win, pid, sp_id
|
||||||
|
self.setBrush(QBrush(QColor(_PIECE)))
|
||||||
|
self.setPen(QPen(QColor("#ffd700" if locked else "#111111"), 2 if locked else 1))
|
||||||
|
if not locked:
|
||||||
|
self.setFlag(QGraphicsItem.ItemIsMovable, True)
|
||||||
|
self.setCursor(Qt.OpenHandCursor)
|
||||||
|
if text:
|
||||||
|
t = QGraphicsSimpleTextItem(("🔒 " if locked else "") + text, self)
|
||||||
|
t.setBrush(QBrush(QColor("white")))
|
||||||
|
t.setPos(3, 3)
|
||||||
|
self._home = None
|
||||||
|
|
||||||
|
def mousePressEvent(self, e):
|
||||||
|
self._home = (self.sp_id, self.pos().x(), self.pos().y())
|
||||||
|
super().mousePressEvent(e)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, e):
|
||||||
|
super().mouseReleaseEvent(e)
|
||||||
|
if self._home is not None:
|
||||||
|
self.win._drop_piece(self, self._home)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, e):
|
||||||
|
self.win._rotate_piece(self.pid)
|
||||||
|
|
||||||
|
def contextMenuEvent(self, e):
|
||||||
|
self.win._piece_menu(self.pid, e.screenPos())
|
||||||
|
|
||||||
|
|
||||||
class BomWindow(QDialog):
|
class BomWindow(QDialog):
|
||||||
def __init__(self, controller, parent=None):
|
def __init__(self, controller, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -32,6 +67,10 @@ class BomWindow(QDialog):
|
||||||
self.resize(820, 640)
|
self.resize(820, 640)
|
||||||
self._order = 0
|
self._order = 0
|
||||||
self._optimized = False
|
self._optimized = False
|
||||||
|
self._plan = None # persistent editable plan for the layout tab
|
||||||
|
self._rebuild = True # regenerate from auto-layout on next draw
|
||||||
|
self._px = _PX
|
||||||
|
self._rows = [] # (y0, y1, stock_piece) for drop hit-testing
|
||||||
self.pool = QThreadPool.globalInstance()
|
self.pool = QThreadPool.globalInstance()
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
|
|
@ -170,13 +209,17 @@ class BomWindow(QDialog):
|
||||||
|
|
||||||
run_async(self.pool, work, on_done=done, on_error=failed)
|
run_async(self.pool, work, on_done=done, on_error=failed)
|
||||||
|
|
||||||
# ----- layout tab ---------------------------------------------------
|
# ----- layout tab (editable) ---------------------------------------
|
||||||
def _layout_tab(self) -> QWidget:
|
def _layout_tab(self) -> QWidget:
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
v = QVBoxLayout(w)
|
v = QVBoxLayout(w)
|
||||||
self.scene = QGraphicsScene()
|
self.scene = QGraphicsScene()
|
||||||
self.view = QGraphicsView(self.scene)
|
self.view = QGraphicsView(self.scene)
|
||||||
v.addWidget(self.view)
|
v.addWidget(self.view)
|
||||||
|
self._status = QLabel("Drag a piece to re-place it · double-click a panel to rotate "
|
||||||
|
"· right-click to lock")
|
||||||
|
self._status.setStyleSheet("color:#aaaaaa; font-size:11px;")
|
||||||
|
v.addWidget(self._status)
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
opt = QPushButton("Find better layout")
|
opt = QPushButton("Find better layout")
|
||||||
opt.setToolTip("Try several packing strategies and keep the best-scoring one")
|
opt.setToolTip("Try several packing strategies and keep the best-scoring one")
|
||||||
|
|
@ -191,26 +234,31 @@ class BomWindow(QDialog):
|
||||||
return w
|
return w
|
||||||
|
|
||||||
def _optimize(self) -> None:
|
def _optimize(self) -> None:
|
||||||
self._optimized = True
|
self._optimized, self._rebuild = True, True
|
||||||
self._draw_layout()
|
self._draw_layout()
|
||||||
|
|
||||||
def _next_arrangement(self) -> None:
|
def _next_arrangement(self) -> None:
|
||||||
self._optimized = False
|
self._optimized = False
|
||||||
self._order = (self._order + 1) % len(STRATEGIES)
|
self._order = (self._order + 1) % len(STRATEGIES)
|
||||||
|
self._rebuild = True
|
||||||
self._draw_layout()
|
self._draw_layout()
|
||||||
|
|
||||||
def _draw_layout(self) -> None:
|
def _draw_layout(self) -> None:
|
||||||
self.scene.clear()
|
if self._rebuild or self._plan is None:
|
||||||
plan = (best_cut_plan(self.c.scene) if self._optimized
|
self._plan = (best_cut_plan(self.c.scene) if self._optimized
|
||||||
else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order]))
|
else build_cut_plan(self.c.scene, strategy=STRATEGIES[self._order]))
|
||||||
|
self._rebuild = False
|
||||||
|
plan = self._plan
|
||||||
|
self.scene.clear()
|
||||||
|
self._rows = []
|
||||||
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
names = {p.id: (p.name or p.id) for p in self.c.scene.parts}
|
||||||
part_of = {it.id: it.part_id for it in plan.items}
|
part_of = {it.id: it.part_id for it in plan.items}
|
||||||
label = lambda iid: names.get(part_of.get(iid, ""), iid)
|
label = lambda iid: names.get(part_of.get(iid, ""), iid)
|
||||||
px, y, bar = _PX, 30.0, 34.0
|
px, y, bar = self._px, 30.0, 34.0
|
||||||
|
|
||||||
sc = plan.score
|
sc = plan.score
|
||||||
self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock piece(s) · "
|
self._label(0, 2, f"{sc['strategy_name']} · {sc['stock_count']} stock · "
|
||||||
f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable offcut(s)")
|
f"{sc['yield_pct']:.0f}% used · {sc['reusable_offcuts']} reusable")
|
||||||
|
|
||||||
n = m = 0
|
n = m = 0
|
||||||
for sp in plan.stock_pieces: # lumber sticks first
|
for sp in plan.stock_pieces: # lumber sticks first
|
||||||
|
|
@ -218,31 +266,97 @@ class BomWindow(QDialog):
|
||||||
continue
|
continue
|
||||||
n += 1
|
n += 1
|
||||||
self._label(0, y - 15, f"{sp.stock} stick {n}")
|
self._label(0, y - 15, f"{sp.stock} stick {n}")
|
||||||
for p in sp.placements:
|
self._rows.append((y, y + bar, sp))
|
||||||
self._rect(p.x_in * px, y, p.len_in * px, bar, _PIECE,
|
|
||||||
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
|
|
||||||
for w in sp.waste:
|
for w in sp.waste:
|
||||||
self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE,
|
self._rect(w.x_in * px, y, w.length_in * px, bar, _WASTE,
|
||||||
f"waste {_fmt_len(w.length_in)}")
|
f"waste {_fmt_len(w.length_in)}")
|
||||||
|
for p in sp.placements:
|
||||||
|
self._add_piece(sp, p, p.x_in * px, y, p.len_in * px, bar,
|
||||||
|
f"{label(p.item_id)} · {_fmt_len(p.len_in)}")
|
||||||
y += bar + 24
|
y += bar + 24
|
||||||
|
|
||||||
for sp in plan.stock_pieces: # then plywood sheets
|
for sp in plan.stock_pieces: # then plywood sheets
|
||||||
if not sp.is_sheet:
|
if not sp.is_sheet:
|
||||||
continue
|
continue
|
||||||
m += 1
|
m += 1
|
||||||
|
h = sp.width_in * px
|
||||||
self._label(0, y - 15, f"{sp.stock} sheet {m} "
|
self._label(0, y - 15, f"{sp.stock} sheet {m} "
|
||||||
f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})")
|
f"({_fmt_len(sp.width_in)}×{_fmt_len(sp.length_in)})")
|
||||||
self._rect(0, y, sp.length_in * px, sp.width_in * px, _WASTE, "")
|
self._rect(0, y, sp.length_in * px, h, _WASTE, "")
|
||||||
|
self._rows.append((y, y + h, sp))
|
||||||
for p in sp.placements:
|
for p in sp.placements:
|
||||||
self._rect(p.x_in * px, y + p.y_in * px, p.len_in * px, p.wid_in * px,
|
self._add_piece(sp, p, p.x_in * px, y + p.y_in * px,
|
||||||
_PIECE, label(p.item_id))
|
p.len_in * px, p.wid_in * px, label(p.item_id))
|
||||||
y += sp.width_in * px + 34
|
y += h + 34
|
||||||
|
|
||||||
for warn in plan.warnings:
|
for warn in plan.warnings:
|
||||||
self._label(0, y, "⚠ " + warn)
|
self._label(0, y, "⚠ " + warn)
|
||||||
y += 18
|
y += 18
|
||||||
self.view.setSceneRect(self.scene.itemsBoundingRect())
|
self.view.setSceneRect(self.scene.itemsBoundingRect())
|
||||||
|
|
||||||
|
def _add_piece(self, sp, p, x, y, w, h, text) -> None:
|
||||||
|
item = _Piece(self, p.id, sp.id, w, h, p.locked, text)
|
||||||
|
item.setPos(x, y)
|
||||||
|
self.scene.addItem(item)
|
||||||
|
|
||||||
|
# ----- drag / rotate / lock handlers -------------------------------
|
||||||
|
def _row_of(self, sp_id):
|
||||||
|
return next((y0 for y0, _y1, s in self._rows if s.id == sp_id), 0.0)
|
||||||
|
|
||||||
|
def _drop_piece(self, item, home) -> None:
|
||||||
|
plan, px = self._plan, self._px
|
||||||
|
cy = item.sceneBoundingRect().center().y()
|
||||||
|
target = next((s for y0, y1, s in self._rows if y0 - 2 <= cy <= y1 + 2), None)
|
||||||
|
sp_cur, p = find_placement(plan, item.pid)
|
||||||
|
if target is None:
|
||||||
|
target = sp_cur
|
||||||
|
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
|
||||||
|
relocate(plan, item.pid, target.id, x_in, y_in)
|
||||||
|
if not target.is_sheet:
|
||||||
|
p.x_in = snap_x(target, p, p.x_in, plan.settings.kerf_in)
|
||||||
|
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._status.setText("✗ overlap / off-stock — move reverted")
|
||||||
|
self._rebuild = False
|
||||||
|
self._draw_layout()
|
||||||
|
|
||||||
|
def _rotate_piece(self, pid) -> None:
|
||||||
|
plan = self._plan
|
||||||
|
sp, p = find_placement(plan, pid)
|
||||||
|
if not sp.is_sheet:
|
||||||
|
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()
|
||||||
|
|
||||||
|
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:
|
||||||
|
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()
|
||||||
|
|
||||||
def _rect(self, x, y, w, h, color, text) -> None:
|
def _rect(self, x, y, w, h, color, text) -> None:
|
||||||
item = QGraphicsRectItem(x, y, w, h)
|
item = QGraphicsRectItem(x, y, w, h)
|
||||||
item.setBrush(QBrush(QColor(color)))
|
item.setBrush(QBrush(QColor(color)))
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,47 @@ def test_best_cut_plan_is_no_worse():
|
||||||
assert validate_cut_plan(best) == []
|
assert validate_cut_plan(best) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_snap_and_fits():
|
||||||
|
from woodshop.cutplan import placement_fits, snap_x
|
||||||
|
s = Scene()
|
||||||
|
s.place("2x4", 30)
|
||||||
|
s.place("2x4", 30) # both fit one stick
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
stick = next(sp for sp in plan.stock_pieces if not sp.is_sheet)
|
||||||
|
p1, p2 = stick.placements[0], stick.placements[1]
|
||||||
|
k = plan.settings.kerf_in
|
||||||
|
assert abs(snap_x(stick, p2, 31.0, k) - (p1.x_in + p1.len_in + k)) < 1e-6
|
||||||
|
p2.x_in = 0.0
|
||||||
|
assert not placement_fits(stick, p2, k) # now overlaps p1
|
||||||
|
p2.x_in = p1.len_in + k
|
||||||
|
assert placement_fits(stick, p2, k) # butted clear
|
||||||
|
|
||||||
|
|
||||||
|
def test_relocate_between_sticks():
|
||||||
|
from woodshop.cutplan import relocate
|
||||||
|
s = Scene()
|
||||||
|
for _ in range(3):
|
||||||
|
s.place("2x4", 60) # each needs its own stick
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
sticks = [sp for sp in plan.stock_pieces if not sp.is_sheet]
|
||||||
|
assert len(sticks) == 3
|
||||||
|
pid = sticks[2].placements[0].id
|
||||||
|
relocate(plan, pid, sticks[0].id, 0.0)
|
||||||
|
assert any(p.id == pid for p in sticks[0].placements)
|
||||||
|
assert all(p.id != pid for p in sticks[2].placements)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotate_placement_swaps_footprint():
|
||||||
|
from woodshop.cutplan import rotate_placement
|
||||||
|
s = Scene()
|
||||||
|
s.place("ply-3/4", 40, width_in=20)
|
||||||
|
plan = build_cut_plan(s)
|
||||||
|
p = next(sp for sp in plan.stock_pieces if sp.is_sheet).placements[0]
|
||||||
|
L, W, rot = p.len_in, p.wid_in, p.rotated
|
||||||
|
rotate_placement(plan, p.id)
|
||||||
|
assert p.len_in == W and p.wid_in == L and p.rotated != rot
|
||||||
|
|
||||||
|
|
||||||
def test_custom_settings_kerf():
|
def test_custom_settings_kerf():
|
||||||
s = Scene()
|
s = Scene()
|
||||||
s.place("2x4", 48)
|
s.place("2x4", 48)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue