Merge pull request #7387 from dotCooCoo/feat/arrange-nest

Cutting Tools: Nest Shapes packs the selected objects onto a sheet or roll
This commit is contained in:
BFlat
2026-11-15 04:22:37 +07:00
committed by GitHub
38 changed files with 1037 additions and 6 deletions
+4 -1
View File
@@ -2,9 +2,11 @@
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art
//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner
//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's.
//! ([`banner`]). Nest Shapes packs the selected art onto a sheet or a roll of material ([`nest`]).
//! Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's.
mod banner;
mod nest;
mod stencil;
mod weed;
@@ -14,5 +16,6 @@ pub fn specs() -> Vec<CommandSpec> {
let mut v = stencil::specs();
v.extend(weed::specs());
v.extend(banner::specs());
v.extend(nest::specs());
v
}
@@ -0,0 +1,403 @@
//! Nest Shapes (`addon.cutting.nest`), a command of the built-in Cutting Tools add-on that packs
//! the selected objects onto a sheet or a roll of material by their bounding boxes
//! (<https://en.wikipedia.org/wiki/Packing_problems>). The sheet is an
//! artboard, or a set size from the artboard's top-left corner; a roll is as wide as the sheet
//! and has no end. The objects are moved, and with `rotate` turned a quarter turn when that places
//! them better; they are never scaled or changed otherwise.
#[cfg(test)]
mod tests;
use std::collections::VecDeque;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use vectorcraft_render::effects;
use super::super::super::edit::selected_roots_to_transform;
use super::super::super::object::{keep_compound_aligned, transform_node, transform_patterns};
use super::super::super::pathops::length_given;
use super::super::super::{CommandSpec, bad, check_switches, cmd, has_selection, switch_or};
use crate::{Result, Session};
/// Nest Shapes' command.
pub const NEST: &str = "addon.cutting.nest";
/// The largest sheet width or height, in points.
const MAX_SIZE: f64 = 1_000_000.0;
/// The largest margin or spacing, in points.
const MAX_GAP: f64 = 100_000.0;
/// The most objects one run nests.
const MAX_SHAPES: usize = 10_000;
/// How far a box may reach past the sheet's edge and still fit (rounding).
const EPS: f64 = 1e-9;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.nest",
"Nest Shapes…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{roll?: bool (false: a sheet `width` × `height`; true: a roll `width` wide with no end), width?: pt 0..1000000 (0: the artboard's width), height?: pt 0..1000000 (0: the artboard's height; not used with roll), margin?: pt 0..100000 (18: kept clear inside each edge of the sheet; on a roll, the sides and the start), spacing?: pt 0..100000 (9: kept between two objects), rotate?: bool (false: objects keep their orientation; true: an object may turn 90° clockwise where that puts its bottom edge higher, or where only the turned object fits), usePreviewBounds?: bool (true: objects are measured with their strokes and effects, the reach of shadows, glows and blurs included; false: by their geometry), artboard?: index (0-based; the sheet's artboard; default: the artboard under the center of the selection, else the first), patterns?: bool (Transform Patterns: pattern fills and strokes move with the art; default: prefs transformPatternTiles)} move the selected objects onto the sheet or roll, which starts at the artboard's top-left corner, packed by their bounding boxes: the tallest first (with rotate, the longest side first), each where its bottom edge is highest and then furthest left, without resizing them. An object on the pixel grid moves by whole points and takes 1 pt more room across and down; a path of a pixel-aligned compound path is not turned. Objects that do not fit on the sheet go in a column to the right of it, `spacing` from its edge and from each other, from its top, in stacking order (back to front). Objects without bounds (an empty group) are not moved. One undo step; nothing changes when no object moves → {placed: count, unplaced: [ids of the objects put beside the sheet], rotated: [ids], skipped: [ids of the objects without bounds], length: pt (from the sheet's top edge to the bottom margin below the lowest object placed)}",
has_selection,
nest
)]
}
/// The length `p` gives for `key`, from 0 to `max`, or `default`.
fn length(p: &Value, key: &str, default: f64, max: f64) -> Result<f64> {
match length_given(p, key, NEST)? {
None => Ok(default),
Some(v) if (0.0..=max).contains(&v) => Ok(v),
Some(_) => Err(bad(NEST, format!("`{key}` must be from 0 to {max} pt"))),
}
}
/// One stretch of a skyline: from `x`, `w` wide, filled down to `y`.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Span {
x: f64,
w: f64,
y: f64,
}
/// Bottom-left packing of boxes on a skyline: a strip `width` wide and `height` deep
/// (`f64::INFINITY` for a roll), whose filled part is the spans' outline along its top.
struct Skyline {
spans: Vec<Span>,
width: f64,
height: f64,
}
impl Skyline {
fn new(width: f64, height: f64) -> Self {
Skyline { spans: vec![Span { x: 0.0, w: width, y: 0.0 }], width, height }
}
/// Where a box `w` × `h` goes: at the left edge of a span, as high as the spans under it
/// allow, choosing the place where its bottom edge is highest and then furthest left. `None`
/// when it fits nowhere. The spans under the box at each span are a window whose two ends only
/// move right, and `window` holds the indices in it whose depth no later span in it reaches, so
/// its front is the deepest: one call takes time in proportion to the number of spans.
fn find(&self, w: f64, h: f64) -> Option<(f64, f64)> {
let mut best: Option<(f64, f64)> = None;
let mut window: VecDeque<usize> = VecDeque::new();
let mut end = 0;
for (i, s) in self.spans.iter().enumerate() {
if s.x + w > self.width + EPS {
break;
}
while window.front().is_some_and(|&j| j < i) {
window.pop_front();
}
end = end.max(i);
while let Some(t) = self.spans.get(end)
&& (end == i || t.x < s.x + w - EPS)
{
while window.back().and_then(|&j| self.spans.get(j)).is_some_and(|b| b.y <= t.y) {
window.pop_back();
}
window.push_back(end);
end += 1;
}
let y = window.front().and_then(|&j| self.spans.get(j)).map_or(0.0, |t| t.y.max(0.0));
if y + h > self.height + EPS {
continue;
}
// The box is `h` tall at every place, so the place with the highest bottom edge is the
// place with the highest top edge.
if best.is_none_or(|(bx, by)| y < by - EPS || ((y - by).abs() <= EPS && s.x < bx)) {
best = Some((s.x, y));
}
}
best
}
/// Fills the skyline down to `y + h` from `x` across `w`.
fn place(&mut self, x: f64, y: f64, w: f64, h: f64) {
if w <= 0.0 {
return;
}
let end = x + w;
let mut out = Vec::with_capacity(self.spans.len() + 2);
let mut added = false;
for s in &self.spans {
let s_end = s.x + s.w;
if s_end <= x || s.x >= end {
out.push(*s);
continue;
}
if s.x < x {
out.push(Span { x: s.x, w: x - s.x, y: s.y });
}
if !added {
out.push(Span { x, w, y: y + h });
added = true;
}
if s_end > end {
out.push(Span { x: end, w: s_end - end, y: s.y });
}
}
// Neighbors filled to the same depth are one span.
let mut merged: Vec<Span> = Vec::with_capacity(out.len());
for s in out {
match merged.last_mut() {
Some(l) if l.y == s.y => l.w = s.x + s.w - l.x,
_ => merged.push(s),
}
}
self.spans = merged;
}
}
/// One box to pack: its width and height as it stands, and when it may turn, as it is turned.
type Sizes = ((f64, f64), Option<(f64, f64)>);
/// The side a box is ordered by: its height, or its longest side when it may turn.
fn lead(b: &Sizes) -> (f64, f64) {
let ((w, h), turned) = *b;
if turned.is_some() { (w.max(h), w.min(h)) } else { (h, w) }
}
/// Packs `boxes` onto a strip `width` wide and `height` deep, the tallest first (a box that may
/// turn by its longest side; then the widest, then in their order), each where [`Skyline::find`]
/// puts it, turned when that puts its bottom edge higher (then further left), or when only the
/// turned box fits. Gives each box's top-left corner and whether it is turned, or `None` for a box
/// that does not fit.
fn pack(boxes: &[Sizes], width: f64, height: f64) -> Vec<Option<(f64, f64, bool)>> {
let mut order: Vec<usize> = (0..boxes.len()).collect();
order.sort_by(|&a, &b| {
let (la, lb) = (lead(&boxes[a]), lead(&boxes[b]));
lb.0.total_cmp(&la.0).then(lb.1.total_cmp(&la.1))
});
let mut sky = Skyline::new(width, height);
let mut at = vec![None; boxes.len()];
for i in order {
let Some(&((w, h), turned)) = boxes.get(i) else { continue };
let mut best = sky.find(w, h).map(|(x, y)| (x, y, false, w, h));
if let Some((tw, th)) = turned
&& let Some((x, y)) = sky.find(tw, th)
{
let better = best.is_none_or(|(bx, by, _, _, bh)| {
let (was, now) = (by + bh, y + th);
now < was - EPS || ((now - was).abs() <= EPS && x < bx - EPS)
});
if better {
best = Some((x, y, true, tw, th));
}
}
if let Some((x, y, t, bw, bh)) = best {
sky.place(x, y, bw, bh);
if let Some(slot) = at.get_mut(i) {
*slot = Some((x, y, t));
}
}
}
at
}
/// A quarter turn clockwise on the page about `c`.
fn quarter_turn(c: Point) -> Affine {
Affine::translate(c.to_vec2()) * vectorcraft_geom::rotate_deg(90.0) * Affine::translate(-c.to_vec2())
}
fn finite(r: &Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())
}
/// The box `id` is packed by: with `preview`, its visual bounds with geometry effects
/// ([`effects::preview::bounds_of`]) and the reach of its raster effects, such as shadows, glows
/// and blurs ([`vectorcraft_render::drawn_bounds`]); else its geometric bounds.
fn measure(d: &vectorcraft_doc::Document, id: NodeId, preview: bool) -> Option<Rect> {
let b = effects::preview::bounds_of(d, &[id], preview).filter(finite)?;
if !preview {
return Some(b);
}
match d.node(id).and_then(|n| vectorcraft_render::drawn_bounds(d, n)).filter(finite) {
Some(r) => Some(b.union(r)),
None => Some(b),
}
}
/// Whether `id` is a path of a pixel-aligned compound path.
fn in_aligned_compound(d: &Document, id: NodeId) -> bool {
d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }) && n.extras().pixel_aligned)
}
/// Whether moving `id` snaps it, or an object in it, to the pixel grid: it, an object it holds or
/// the art of a linked opacity mask in it is pixel-aligned, or it is a path of a pixel-aligned
/// compound path.
fn snaps(d: &Document, id: NodeId) -> bool {
if in_aligned_compound(d, id) {
return true;
}
let mut stack: Vec<&Node> = d.node(id).into_iter().collect();
while let Some(n) = stack.pop() {
if n.extras().pixel_aligned {
return true;
}
stack.extend(n.children().map_or(&[][..], Vec::as_slice).iter().map(|c| &**c));
if let Some(m) = n.extras().mask.as_ref()
&& m.linked
{
stack.push(&*m.art);
}
}
false
}
fn nest(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], NEST)?;
let roll = switch_or(p, "roll", false, NEST)?;
let width = length(p, "width", 0.0, MAX_SIZE)?;
let height = length(p, "height", 0.0, MAX_SIZE)?;
let margin = length(p, "margin", 18.0, MAX_GAP)?;
let spacing = length(p, "spacing", 9.0, MAX_GAP)?;
let turn = switch_or(p, "rotate", false, NEST)?;
let preview = switch_or(p, "usePreviewBounds", true, NEST)?;
let named = match p.get("artboard") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(NEST, "`artboard` must be an artboard's index"))?),
};
// A path of a compound path selected on its own moves without the rest of the compound path.
let ids = selected_roots_to_transform(s)?;
if ids.len() > MAX_SHAPES {
return Err(bad(NEST, format!("select at most {MAX_SHAPES} objects to nest")));
}
// Objects without a finite box (an empty group, empty type) are left where they are and
// listed as `skipped`.
let mut skipped = vec![];
let (items, board): (Vec<(NodeId, Rect)>, Rect) = {
let d = &s.doc()?.doc;
let mut items: Vec<(NodeId, Rect)> = Vec::with_capacity(ids.len());
for id in &ids {
match measure(d, *id, preview) {
Some(r) => items.push((*id, r)),
None => skipped.push(id.0),
}
}
let board = match named {
Some(i) => d.artboards.get(i).ok_or_else(|| bad(NEST, format!("there is no artboard {i}")))?.rect,
None => {
let all = items.iter().map(|(_, r)| *r).reduce(|a, b| a.union(b));
let i = all.and_then(|r| d.artboard_at(r.center())).unwrap_or(0);
d.artboards.get(i).ok_or_else(|| bad(NEST, "there is no artboard for the sheet"))?.rect
}
};
(items, board)
};
if items.is_empty() {
return Err(bad(NEST, "the selection has nothing to nest"));
}
let sheet_w = if width > 0.0 { width } else { board.width() };
let sheet_h = if roll {
f64::INFINITY
} else if height > 0.0 {
height
} else {
board.height()
};
// Every box is `spacing` wider and taller, and the strip is `spacing` wider and deeper than
// the room inside the margins, so the spacing falls between boxes and not at the far edges.
let room_w = sheet_w - 2.0 * margin;
let room_h = if roll { f64::INFINITY } else { sheet_h - 2.0 * margin };
if room_w <= 0.0 || room_h <= 0.0 {
return Err(bad(NEST, "the margin leaves no room on the sheet"));
}
let all: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
let grid: Vec<bool> = {
let d = &s.doc()?.doc;
all.iter().map(|id| snaps(d, *id)).collect()
};
let sc = Scaling { patterns: transform_patterns(s, p, &all)?, ..Scaling::default() };
// With `rotate`, each object is turned in a copy of the document and measured there, so the
// box packed for a turned object is the box its turned strokes and effects take. A path of a
// pixel-aligned compound path keeps its orientation: turning it snaps the whole compound path
// again, which would move the compound's other paths away from where they were measured.
let turned: Vec<Option<(Affine, Rect)>> = if turn {
let mut t = vectorcraft_doc::Document::clone(&s.doc()?.doc);
let xfs: Vec<Option<Affine>> = items.iter().map(|(id, r)| (!in_aligned_compound(&t, *id)).then(|| quarter_turn(r.center()))).collect();
for ((id, _), xf) in items.iter().zip(&xfs) {
if let (Some(xf), Some(n)) = (xf, t.node_mut(*id)) {
transform_node(n, *xf, sc, true);
}
}
items.iter().zip(xfs).map(|((id, _), xf)| xf.and_then(|xf| measure(&t, *id, preview).map(|r| (xf, r)))).collect()
} else {
vec![None; items.len()]
};
// An object that snaps to the pixel grid moves by whole points, so it stays on the grid and the
// snap after the move does not shift it; its box is 1 pt wider and taller to hold the rounding.
let slack = |g: bool| if g { 1.0 } else { 0.0 };
let boxes: Vec<Sizes> = items
.iter()
.zip(&turned)
.zip(&grid)
.map(|(((_, r), t), g)| {
let pad = spacing + slack(*g);
((r.width() + pad, r.height() + pad), t.map(|(_, q)| (q.width() + pad, q.height() + pad)))
})
.collect();
let at = pack(&boxes, room_w + spacing, room_h + spacing);
let origin = Vec2::new(board.x0 + margin, board.y0 + margin);
// The move that puts the top-left corner of box `b` at `to`: rounded up to whole points for an
// object on the pixel grid, so it moves at most 1 pt further right and down, into its slack.
let shift = |b: &Rect, to: Vec2, g: bool| {
let v = Vec2::new(to.x - b.x0, to.y - b.y0);
if g { Vec2::new(v.x.ceil(), v.y.ceil()) } else { v }
};
// Each move: the quarter turn first, when the object turns, then the shift.
let mut moves: Vec<(NodeId, Option<Affine>, Vec2)> = vec![];
let mut aside: Vec<(NodeId, Option<Affine>, Vec2)> = vec![];
let mut unplaced = vec![];
let mut rotated = vec![];
let mut bottom = 0.0_f64;
// Objects that do not fit go in a column to the right of the sheet, `spacing` from its edge
// and from each other, so that no object placed on the sheet covers them.
let aside_x = board.x0 + sheet_w + spacing;
let mut aside_y = board.y0;
for ((((id, r), spot), t), g) in items.iter().zip(&at).zip(&turned).zip(&grid) {
match (spot, t) {
(Some((x, y, true)), Some((xf, q))) => {
let dv = shift(q, Vec2::new(origin.x + x, origin.y + y), *g);
moves.push((*id, Some(*xf), dv));
rotated.push(id.0);
bottom = bottom.max(q.y1 + dv.y - origin.y);
}
(Some((x, y, _)), _) => {
let dv = shift(r, Vec2::new(origin.x + x, origin.y + y), *g);
moves.push((*id, None, dv));
bottom = bottom.max(r.y1 + dv.y - origin.y);
}
(None, _) => {
aside.push((*id, None, shift(r, Vec2::new(aside_x, aside_y), *g)));
aside_y += r.height() + spacing + slack(*g);
unplaced.push(id.0);
}
}
}
if moves.is_empty() {
return Err(bad(NEST, "no selected object fits on the sheet"));
}
moves.extend(aside);
let length = 2.0 * margin + bottom;
let placed = moves.len() - unplaced.len();
let result = json!({ "placed": placed, "unplaced": unplaced, "rotated": rotated, "skipped": skipped, "length": length });
if moves.iter().all(|(_, xf, dv)| xf.is_none() && dv.x.abs() <= EPS && dv.y.abs() <= EPS) {
return Ok(result);
}
s.edit("Nest Shapes", |d, _| {
for (id, xf, dv) in &moves {
// The turn is the one the object was measured with in the copy, and the shift after it
// is whole points for an object on the pixel grid.
for step in xf.iter().copied().chain([Affine::translate(*dv)]) {
if let Some(n) = d.node_mut(*id) {
transform_node(n, step, sc, true);
}
keep_compound_aligned(d, *id, sc);
}
}
Ok(())
})?;
Ok(result)
}
@@ -0,0 +1,433 @@
//! Tests for `addon.cutting.nest`: packing the selected objects onto a sheet or a roll.
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use super::{Skyline, pack};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn overlap(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 - 1e-9 && b.x0 < a.x1 - 1e-9 && a.y0 < b.y1 - 1e-9 && b.y0 < a.y1 - 1e-9
}
#[test]
fn boxes_pack_tallest_first_from_the_top_left() {
// 100 wide: the 60 × 50 box goes first at the left and the 40 × 30 one beside it. The 50 × 20
// one is too wide for the 40 pt beside the first box, so it goes below the first box.
let at = pack(&fixed(&[(40.0, 30.0), (60.0, 50.0), (50.0, 20.0)]), 100.0, f64::INFINITY);
assert_eq!(at, vec![Some((60.0, 0.0, false)), Some((0.0, 0.0, false)), Some((0.0, 50.0, false))]);
}
#[test]
fn a_box_that_does_not_fit_is_left_out() {
let at = pack(&fixed(&[(80.0, 80.0), (80.0, 80.0), (120.0, 10.0)]), 100.0, 100.0);
assert_eq!(at, vec![Some((0.0, 0.0, false)), None, None]);
}
/// Boxes that keep their orientation.
fn fixed(b: &[(f64, f64)]) -> Vec<super::Sizes> {
b.iter().map(|&s| (s, None)).collect()
}
/// Boxes that may turn a quarter.
fn turning(b: &[(f64, f64)]) -> Vec<super::Sizes> {
b.iter().map(|&(w, h)| ((w, h), Some((h, w)))).collect()
}
#[test]
fn a_box_turns_where_its_bottom_edge_ends_higher_or_only_turned_it_fits() {
// A 20 × 90 box lies down as 90 × 20 at the top; a 120 × 10 box only fits the 100 pt strip
// turned, as 10 × 120.
let at = pack(&turning(&[(20.0, 90.0), (120.0, 10.0)]), 100.0, f64::INFINITY);
assert_eq!(at, vec![Some((10.0, 0.0, true)), Some((0.0, 0.0, true))]);
// Without turning, the 120 pt wide box is left out and the other stands up.
let at = pack(&fixed(&[(20.0, 90.0), (120.0, 10.0)]), 100.0, f64::INFINITY);
assert_eq!(at, vec![Some((0.0, 0.0, false)), None]);
// A square box is never turned: turned, it is no better.
let at = pack(&turning(&[(30.0, 30.0)]), 100.0, 100.0);
assert_eq!(at, vec![Some((0.0, 0.0, false))]);
}
#[test]
fn the_skyline_fills_and_merges_spans() {
let mut sky = Skyline::new(100.0, f64::INFINITY);
sky.place(0.0, 0.0, 30.0, 10.0);
sky.place(30.0, 0.0, 30.0, 10.0);
assert_eq!(sky.spans.len(), 2, "two spans at the same depth are one: {:?}", sky.spans);
assert_eq!(sky.find(50.0, 5.0), Some((0.0, 10.0)), "50 wide does not fit in the 40 left at the top");
assert_eq!(sky.find(40.0, 5.0), Some((60.0, 0.0)));
}
#[test]
fn selected_objects_are_nested_on_the_artboard_as_one_undo_step() {
let mut s = session();
let ids: Vec<NodeId> = (0..6).map(|i| rect(&mut s, 900.0 + 300.0 * i as f64, 700.0, 150.0, 100.0 + 10.0 * i as f64)).collect();
select(&mut s, &ids);
let n = steps(&s);
let r = s.execute("addon.cutting.nest", &json!({"margin": 10, "spacing": 5, "usePreviewBounds": false})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert_eq!(r["placed"], json!(6));
assert_eq!(r["unplaced"], json!([]));
let boxes: Vec<Rect> = ids.iter().map(|id| bounds(&s, *id)).collect();
for (i, a) in boxes.iter().enumerate() {
assert!(a.x0 >= 10.0 - 1e-9 && a.y0 >= 10.0 - 1e-9 && a.x1 <= 790.0 + 1e-9 && a.y1 <= 590.0 + 1e-9, "{a:?} inside the margins");
assert!((a.width(), a.height()) == (150.0, 100.0 + 10.0 * i as f64), "moved, not resized");
for b in &boxes[i + 1..] {
assert!(!overlap(a.inflate(2.5, 2.5), b.inflate(2.5, 2.5)), "{a:?} and {b:?} are 5 pt apart");
}
}
// The tallest (150 pt) is at the top-left corner inside the margin, and five 150 pt wide
// boxes with 5 pt between them fill the 780 pt inside the margins. The shortest goes 5 pt below
// the 110 pt tall box at the right, where its bottom edge is highest.
assert_eq!((boxes[5].x0, boxes[5].y0), (10.0, 10.0));
assert_eq!(boxes.iter().filter(|b| b.y0 == 10.0).count(), 5);
assert_eq!((boxes[0].x0, boxes[0].y0), (630.0, 125.0));
assert_eq!(r["length"], json!(10.0 + 115.0 + 100.0 + 10.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bounds(&s, ids[0]), Rect::new(900.0, 700.0, 1050.0, 800.0));
}
#[test]
fn objects_that_do_not_fit_go_beside_the_sheet() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 500.0, 400.0);
let b = rect(&mut s, 1500.0, 0.0, 500.0, 400.0);
let big = rect(&mut s, 2100.0, 0.0, 900.0, 100.0);
select(&mut s, &[a, b, big]);
let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 5, "usePreviewBounds": false})).unwrap();
assert_eq!(r["placed"], json!(1));
assert_eq!(r["unplaced"], json!([b.0, big.0]));
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 500.0, 400.0));
// A column 5 pt to the right of the 800 pt sheet, in stacking order, 5 pt apart.
assert_eq!(bounds(&s, b), Rect::new(805.0, 0.0, 1305.0, 400.0));
assert_eq!(bounds(&s, big), Rect::new(805.0, 405.0, 1705.0, 505.0));
}
#[test]
fn the_column_beside_the_sheet_is_in_stacking_order() {
let mut s = session();
let back = rect(&mut s, 900.0, 0.0, 900.0, 100.0);
let front = rect(&mut s, 900.0, 200.0, 900.0, 50.0);
let small = rect(&mut s, 900.0, 400.0, 50.0, 50.0);
// Selected front first: the column still starts with the object at the back.
select(&mut s, &[front, small, back]);
let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 5, "usePreviewBounds": false})).unwrap();
assert_eq!(r["unplaced"], json!([back.0, front.0]));
assert_eq!(bounds(&s, back), Rect::new(805.0, 0.0, 1705.0, 100.0));
assert_eq!(bounds(&s, front), Rect::new(805.0, 105.0, 1705.0, 155.0));
}
#[test]
fn objects_without_bounds_are_skipped_and_listed() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 50.0, 50.0);
let layer = s.doc().unwrap().doc.parent_of(a);
let empty = s
.edit("empty group", |d, _| {
let g = vectorcraft_doc::Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false });
Ok(d.insert(layer, usize::MAX, g).unwrap())
})
.unwrap();
select(&mut s, &[a, empty]);
let r = s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!((r["placed"].clone(), r["unplaced"].clone(), r["skipped"].clone()), (json!(1), json!([]), json!([empty.0])));
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
}
#[test]
fn the_sheet_is_the_artboard_under_the_center_of_the_selection() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 300})).unwrap();
let a = rect(&mut s, 1100.0, 100.0, 50.0, 50.0);
let b = rect(&mut s, 1200.0, 200.0, 40.0, 40.0);
select(&mut s, &[a, b]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(1000.0, 0.0, 1050.0, 50.0), "on the second artboard, not the first");
assert_eq!(bounds(&s, b), Rect::new(1050.0, 0.0, 1090.0, 40.0));
}
#[test]
fn rotate_turns_clockwise() {
let mut s = session();
// A 400 × 100 group with a 20 pt mark at its top-left corner. Turned clockwise on the page, the
// top-left corner goes to the top-right.
let long = rect(&mut s, 900.0, 0.0, 400.0, 100.0);
let mark = rect(&mut s, 900.0, 0.0, 20.0, 20.0);
select(&mut s, &[long, mark]);
s.execute("object.group", &json!({})).unwrap();
let r = s
.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true, "usePreviewBounds": false}))
.unwrap();
assert_eq!(r["rotated"].as_array().map(Vec::len), Some(1));
assert_eq!(bounds(&s, long), Rect::new(0.0, 0.0, 100.0, 400.0));
assert_eq!(bounds(&s, mark), Rect::new(80.0, 0.0, 100.0, 20.0), "the mark is at the top-right");
}
#[test]
fn a_path_of_a_pixel_aligned_compound_path_is_not_turned() {
let mut s = session();
let outer = rect(&mut s, 900.0, 100.0, 500.0, 200.0);
let hole = rect(&mut s, 950.0, 150.0, 400.0, 100.0);
select(&mut s, &[outer, hole]);
s.execute("object.compoundPath.make", &json!({})).unwrap();
s.execute("object.setPixelAligned", &json!({})).unwrap();
select(&mut s, &[hole]);
// The 400 pt wide path fits the 300 pt sheet only turned, and it keeps its orientation.
let r =
s.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true, "usePreviewBounds": false}));
let e = r.unwrap_err().to_string();
assert!(e.contains("no selected object fits"), "{e}");
}
/// [`Skyline::find`] as a search of every span and every span under the box.
fn find_by_search(sky: &Skyline, w: f64, h: f64) -> Option<(f64, f64)> {
let mut best: Option<(f64, f64)> = None;
for (i, s) in sky.spans.iter().enumerate() {
if s.x + w > sky.width + 1e-9 {
break;
}
let y = sky.spans[i..].iter().take_while(|t| t.x == s.x || t.x < s.x + w - 1e-9).fold(0.0_f64, |m, t| m.max(t.y));
if y + h > sky.height + 1e-9 {
continue;
}
if best.is_none_or(|(bx, by)| y < by - 1e-9 || ((y - by).abs() <= 1e-9 && s.x < bx)) {
best = Some((s.x, y));
}
}
best
}
#[test]
fn find_matches_a_search_of_every_span() {
// A fixed pseudo-random sequence of boxes, each placed where `find` puts it.
let mut seed: u64 = 0x2545_f491_4f6c_dd1d;
let mut next = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % n) as f64 + 1.0
};
let mut sky = Skyline::new(200.0, 400.0);
for _ in 0..400 {
let (w, h) = (next(60), next(30));
let got = sky.find(w, h);
assert_eq!(got, find_by_search(&sky, w, h), "{w} × {h} on {:?}", sky.spans);
if let Some((x, y)) = got {
sky.place(x, y, w, h);
}
}
}
#[test]
fn an_object_that_does_not_fit_is_not_covered_by_the_others() {
let mut s = session();
// The wide object sits on the sheet and does not fit; the small one must not land on it.
let wide = rect(&mut s, 0.0, 0.0, 900.0, 100.0);
let small = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
select(&mut s, &[wide, small]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap();
assert!(!overlap(bounds(&s, wide), bounds(&s, small)), "{:?} {:?}", bounds(&s, wide), bounds(&s, small));
}
#[test]
fn objects_on_the_pixel_grid_keep_the_margin_and_spacing() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 1100.0, 0.0, 100.0, 90.0);
select(&mut s, &[a, b]);
s.execute("object.setPixelAligned", &json!({})).unwrap();
let on_grid = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| (v * 2.0).fract() == 0.0);
assert!(on_grid(bounds(&s, a)) && on_grid(bounds(&s, b)));
s.execute("addon.cutting.nest", &json!({"margin": 0.6, "spacing": 0.6, "usePreviewBounds": false})).unwrap();
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert!(on_grid(ba) && on_grid(bb), "still on the grid: {ba:?} {bb:?}");
assert!(ba.x0 >= 0.6 && ba.y0 >= 0.6 && bb.y0 >= 0.6, "inside the margin: {ba:?} {bb:?}");
assert!(bb.x0 >= ba.x1 + 0.6, "0.6 pt apart: {ba:?} {bb:?}");
assert!(ba.x1.max(bb.x1) <= 800.0 - 0.6, "inside the margin: {ba:?} {bb:?}");
}
#[test]
fn preview_bounds_take_in_a_drop_shadow() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 1100.0, 0.0, 100.0, 100.0);
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [a.0], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
select(&mut s, &[a, b]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0})).unwrap();
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
let shadow = vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(a).unwrap()).unwrap();
assert!(shadow.x1 >= ba.x1 + 20.0 - 1e-9, "the shadow reaches 20 pt right: {shadow:?}");
let shadow_b = vectorcraft_render::drawn_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(b).unwrap()).unwrap_or(bb);
assert!(!overlap(shadow, shadow_b.union(bb)), "the shadow {shadow:?} does not reach {bb:?}");
}
#[test]
fn a_roll_has_no_end() {
let mut s = session();
let ids: Vec<NodeId> = (0..5).map(|i| rect(&mut s, 900.0, 300.0 * i as f64, 500.0, 400.0)).collect();
select(&mut s, &ids);
let r = s.execute("addon.cutting.nest", &json!({"roll": true, "margin": 0, "spacing": 10, "usePreviewBounds": false})).unwrap();
assert_eq!((r["placed"].clone(), r["unplaced"].clone()), (json!(5), json!([])));
let mut tops: Vec<f64> = ids.iter().map(|id| bounds(&s, *id).y0).collect();
tops.sort_by(f64::total_cmp);
assert_eq!(tops, vec![0.0, 410.0, 820.0, 1230.0, 1640.0], "one 500 pt wide shape per row on the 800 pt roll");
assert_eq!(r["length"], json!(2040.0));
}
#[test]
fn width_and_height_set_the_sheet_from_the_artboard_corner() {
let mut s = session();
let ids: Vec<NodeId> = (0..3).map(|i| rect(&mut s, 50.0 * i as f64, 300.0, 40.0, 40.0)).collect();
select(&mut s, &ids);
let r = s.execute("addon.cutting.nest", &json!({"width": "1 in", "height": 200, "margin": 0, "spacing": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(r["placed"], json!(3));
for id in &ids {
let b = bounds(&s, *id);
assert!(b.x0 == 0.0 && b.x1 <= 72.0, "one 40 pt shape per row on a 72 pt sheet: {b:?}");
}
}
#[test]
fn preview_bounds_keep_strokes_apart() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 1100.0, 0.0, 100.0, 100.0);
select(&mut s, &[a, b]);
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
s.execute("addon.cutting.nest", &json!({"margin": 0, "spacing": 0})).unwrap();
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert_eq!((ba.x0, ba.y0), (5.0, 5.0), "the stroke's outer half is inside the sheet");
assert_eq!((bb.x0 - ba.x1), 10.0, "the strokes touch and do not overlap");
}
#[test]
fn a_selection_already_nested_adds_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 18.0, 18.0, 100.0, 100.0);
select(&mut s, &[a]);
let n = steps(&s);
let r = s.execute("addon.cutting.nest", &json!({"usePreviewBounds": false})).unwrap();
assert_eq!(r["placed"], json!(1));
assert_eq!(steps(&s), n, "nothing moved");
}
#[test]
fn bad_params_are_refused_and_change_nothing() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 100.0, 100.0);
select(&mut s, &[a]);
let n = steps(&s);
let bad: [(Value, &str); 10] = [
(json!({"rotate": 1}), "`rotate` must be true or false"),
(json!({"width": -1}), "`width` must be from 0"),
(json!({"height": 2e6}), "`height` must be from 0"),
(json!({"margin": "x"}), "`margin` must be a number"),
(json!({"spacing": 1e6}), "`spacing` must be from 0"),
(json!({"roll": "yes"}), "`roll` must be true or false"),
(json!({"usePreviewBounds": 1}), "`usePreviewBounds` must be true or false"),
(json!({"artboard": 3}), "there is no artboard 3"),
(json!({"artboard": -1}), "`artboard` must be"),
(json!({"margin": 400}), "the margin leaves no room"),
];
for (p, want) in bad {
let e = s.execute("addon.cutting.nest", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(want), "{p}: {e}");
}
let e = s.execute("addon.cutting.nest", &json!({"width": 50, "margin": 0})).unwrap_err().to_string();
assert!(e.contains("no selected object fits"), "{e}");
let e = s.execute("addon.cutting.nest", &json!({"patterns": "no"})).unwrap_err().to_string();
assert!(e.contains("`patterns` must be true or false"), "{e}");
assert_eq!(steps(&s), n, "a refused run changes nothing");
assert_eq!(bounds(&s, a), Rect::new(900.0, 0.0, 1000.0, 100.0));
}
#[test]
fn the_named_artboard_is_the_sheet() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 300})).unwrap();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
select(&mut s, &[a]);
s.execute("addon.cutting.nest", &json!({"artboard": 1, "margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(1000.0, 0.0, 1050.0, 50.0));
}
#[test]
fn rotate_turns_objects_a_quarter_to_fit_the_sheet() {
let mut s = session();
// On a 300 × 500 sheet with no margin, a 400 × 100 object only fits standing up.
let long = rect(&mut s, 900.0, 0.0, 400.0, 100.0);
let small = rect(&mut s, 900.0, 300.0, 50.0, 40.0);
select(&mut s, &[long, small]);
let p = json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "usePreviewBounds": false});
let r = s.execute("addon.cutting.nest", &p).unwrap();
assert_eq!((r["unplaced"].clone(), r["rotated"].clone()), (json!([long.0]), json!([])), "without rotate it does not fit");
s.execute("edit.undo", &json!({})).unwrap();
let mut p = p;
p["rotate"] = json!(true);
let n = steps(&s);
let r = s.execute("addon.cutting.nest", &p).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert_eq!((r["placed"].clone(), r["unplaced"].clone(), r["rotated"].clone()), (json!(2), json!([]), json!([long.0])));
assert_eq!(bounds(&s, long), Rect::new(0.0, 0.0, 100.0, 400.0), "turned and at the corner");
// The 50 × 40 object stays as it is: turned, its bottom edge would be lower.
assert_eq!(bounds(&s, small), Rect::new(100.0, 0.0, 150.0, 40.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bounds(&s, long), Rect::new(900.0, 0.0, 1300.0, 100.0), "undo turns it back");
}
#[test]
fn rotate_measures_the_turned_preview_bounds() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 400.0, 100.0);
select(&mut s, &[a]);
s.execute("stroke.set", &json!({"weight": 10})).unwrap();
let r = s.execute("addon.cutting.nest", &json!({"width": 300, "height": 500, "margin": 0, "spacing": 0, "rotate": true})).unwrap();
assert_eq!(r["rotated"], json!([a.0]));
assert_eq!(bounds(&s, a), Rect::new(5.0, 5.0, 105.0, 405.0), "the turned stroke's outer half is inside the sheet");
}
#[test]
fn a_compound_paths_path_selected_alone_moves_alone() {
let mut s = session();
let outer = rect(&mut s, 900.0, 100.0, 40.0, 40.0);
let hole = rect(&mut s, 910.0, 110.0, 10.0, 10.0);
select(&mut s, &[outer, hole]);
s.execute("object.compoundPath.make", &json!({})).unwrap();
select(&mut s, &[hole]);
s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!(bounds(&s, hole), Rect::new(0.0, 0.0, 10.0, 10.0), "the path moved");
assert_eq!(bounds(&s, outer), Rect::new(900.0, 100.0, 940.0, 140.0), "the rest of the compound path stayed");
}
#[test]
fn a_group_is_nested_as_one_object() {
let mut s = session();
let a = rect(&mut s, 900.0, 0.0, 50.0, 50.0);
let b = rect(&mut s, 1000.0, 100.0, 50.0, 50.0);
select(&mut s, &[a, b]);
s.execute("object.group", &json!({})).unwrap();
s.execute("addon.cutting.nest", &json!({"margin": 0, "usePreviewBounds": false})).unwrap();
assert_eq!((bounds(&s, a), bounds(&s, b)), (Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(100.0, 100.0, 150.0, 150.0)));
}
@@ -34,6 +34,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.contour.set",
"addon.cutting.banner",
"addon.cutting.bridges",
"addon.cutting.nest",
"addon.cutting.weed",
"addon.image.applyColorMask",
"addon.image.colorMask",