From a234216ea3dc66077884523bcac1390cce38b94e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:40:58 -0800 Subject: [PATCH] Cutting Tools: Nest Shapes packs the selected objects onto a sheet or roll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Cutting Tools › Nest Shapes… (`addon.cutting.nest`) moves the selected objects close together onto a sheet or a roll of material. The sheet starts at an artboard's top-left corner and is the artboard's size or a set width and height; a roll is a set width with no end. A margin (18 pt by default) is kept inside the sheet's edges and a spacing (9 pt) between objects. Objects are packed by their bounding boxes, with their strokes and effects (the reach of shadows, glows and blurs included) or by their geometry: the tallest first, each where its bottom edge is highest and then furthest left. With Rotate, an object may turn 90° clockwise where that puts its bottom edge higher or where only the turned object fits; a turned object is measured after the turn. A path of a pixel-aligned compound path is not turned. Objects that do not fit go in a column to the right of the sheet, in stacking order. Objects on the pixel grid move by whole points and take 1 pt more room, so they stay on the grid and keep the margin and spacing. A path of a compound path selected on its own moves alone. Objects without bounds, such as an empty group, are not moved and are listed in the result. The command is one undo step, and its result gives the objects placed, put aside, turned and skipped and the length of material used. The dialog has Preview. The new Nest Shapes and Roll catalog rows are in every catalog. Docs: docs/mcp.md (Cutting Tools add-on), docs/target-app-parity.md and ROADMAP. --- ROADMAP.md | 1 + crates/engine/src/cmd/addons/cutting.rs | 5 +- crates/engine/src/cmd/addons/cutting/nest.rs | 403 ++++++++++++++++ .../src/cmd/addons/cutting/nest/tests.rs | 433 ++++++++++++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/tests/command_sweep.rs | 6 + crates/ui-egui/src/addons/cutting.rs | 8 + crates/ui-egui/src/addons/cutting/tests.rs | 54 +++ crates/ui-egui/src/dialogs/command.rs | 9 +- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/mcp.md | 37 +- docs/target-app-parity.md | 4 + 38 files changed, 1036 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/addons/cutting/nest.rs create mode 100644 crates/engine/src/cmd/addons/cutting/nest/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index fe038ec97a..deb9f50113 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,6 +124,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. +- 2026-11-14: Nest Shapes in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): the selected objects move close together onto an artboard, a sheet of a set size or a roll of a set width, packed by their bounding boxes with a margin inside the edges and a spacing between them, optionally turning objects 90° to fit; objects that do not fit go in a column beside the sheet; a dialog with Preview, one undo step (`addon.cutting.nest`). A VectorCraft addition: headline numbers unchanged. - 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged. - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. diff --git a/crates/engine/src/cmd/addons/cutting.rs b/crates/engine/src/cmd/addons/cutting.rs index 37e9f2fa79..6ad2af043b 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -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 { let mut v = stencil::specs(); v.extend(weed::specs()); v.extend(banner::specs()); + v.extend(nest::specs()); v } diff --git a/crates/engine/src/cmd/addons/cutting/nest.rs b/crates/engine/src/cmd/addons/cutting/nest.rs new file mode 100644 index 0000000000..18d4dd70f2 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/nest.rs @@ -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 +//! (). 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 { + 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 { + 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, + 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 = 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 = 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> { + let mut order: Vec = (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 { + 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 { + 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 = items.iter().map(|(id, _)| *id).collect(); + let grid: Vec = { + 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> = if turn { + let mut t = vectorcraft_doc::Document::clone(&s.doc()?.doc); + let xfs: Vec> = 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 = 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, Vec2)> = vec![]; + let mut aside: Vec<(NodeId, Option, 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) +} diff --git a/crates/engine/src/cmd/addons/cutting/nest/tests.rs b/crates/engine/src/cmd/addons/cutting/nest/tests.rs new file mode 100644 index 0000000000..6ca6548a19 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/nest/tests.rs @@ -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::>()})).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 { + b.iter().map(|&s| (s, None)).collect() +} + +/// Boxes that may turn a quarter. +fn turning(b: &[(f64, f64)]) -> Vec { + 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 = (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 = 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 = (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 = 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 = (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))); +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5329f23331..fc6659174d 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -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", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 797ca39f52..f84bc4784a 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -914,6 +914,12 @@ fn structured_junk() { ("addon.cutting.banner", json!({"hem": 0, "grommetInset": 0, "spacing": 0.01, "allArtboards": true})), ("addon.cutting.banner", json!({"spacing": "1 mm", "grommets": false, "swatch": 5, "left": null})), ("addon.cutting.banner", json!({"top": false, "right": false, "bottom": false, "left": false, "allArtboards": false})), + ("addon.cutting.nest", json!({"width": 1e6, "height": 1e6, "margin": 1e5, "spacing": 1e5, "roll": true})), + ("addon.cutting.nest", json!({"width": 1e-300, "height": f64::MAX, "margin": -1, "spacing": "1 mm", "artboard": u64::MAX})), + ("addon.cutting.nest", json!({"width": 0, "height": 0, "margin": 0, "spacing": 0, "usePreviewBounds": "no", "patterns": 1})), + ("addon.cutting.nest", json!({"roll": null, "artboard": -1, "width": "1 in", "height": [1]})), + ("addon.cutting.nest", json!({"rotate": true, "roll": true, "width": 1e-6, "margin": 0, "spacing": 0, "usePreviewBounds": true})), + ("addon.cutting.nest", json!({"rotate": "yes", "width": f64::NAN, "spacing": -0.0})), // Type Tools add-on. ("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})), ("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})), diff --git a/crates/ui-egui/src/addons/cutting.rs b/crates/ui-egui/src/addons/cutting.rs index 15a6790872..314a25fd0c 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -3,6 +3,7 @@ //! of `addon.cutting.bridges`. Weed Box and Lines… opens it on the options of `addon.cutting.weed`, //! with a Preview, and runs the command on OK. A Spacing above 0 is sent instead of the Count. //! Banner Hems and Grommets… opens it on the options of `addon.cutting.banner`, with a Preview. +//! Nest Shapes… opens it on the options of `addon.cutting.nest`, with a Preview. use serde_json::json; @@ -18,6 +19,8 @@ pub const WEED: &str = "addon.cutting.weed"; pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; /// Banner Hems and Grommets' command. pub const BANNER: &str = "addon.cutting.banner"; +/// Nest Shapes' command. +pub const NEST: &str = "addon.cutting.nest"; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { @@ -36,6 +39,11 @@ pub fn menu() -> Vec { "diameter": 27, "weight": 0.3, "swatch": "[Registration]" }), ), + Item::Cmd( + "Nest Shapes…", + NEST, + json!({"roll": false, "width": 0, "height": 0, "margin": 18, "spacing": 9, "rotate": false, "usePreviewBounds": true}), + ), ] } diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index 99580b369c..7e2787996f 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -210,3 +210,57 @@ fn banner_hems_and_grommets_previews_cancels_and_runs_on_ok() { // 528 pt along the top and bottom need 6 gaps, 328 pt down the sides 4: 20 grommets. assert_eq!(count(&app), nodes + 1 + 4 + 20 * 4); } + +/// Object › Plug-ins › Cutting Tools › Nest Shapes… opens the command dialog on its defaults with +/// Preview off, previews the packed objects, puts them back on Cancel and keeps them as one step +/// on OK. +#[test] +fn nest_shapes_previews_cancels_and_runs_on_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 700, "y": 100, "width": 50, "height": 40})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 800, "y": 100, "width": 60, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let (start, n) = (app.session.doc().unwrap().doc.clone(), steps(&app)); + let open = |app: &mut VectorcraftApp| { + let entries = menus::menu_entries(app); + let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Cutting Tools"] && e.label == "Nest Shapes…").unwrap(); + assert!(item.enabled); + let (cmd, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(app, &cmd, p); + }; + + open(&mut app); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::NEST.to_string(), "Nest Shapes".to_string())); + assert_eq!((d.bool("roll"), d.bool("rotate"), d.bool("usePreviewBounds"), d.bool("preview")), (false, false, true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in ["Nest Shapes", "Roll", "Width", "Height", "Margin", "Spacing", "Rotate", "Use Preview Bounds", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + let german = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::i18n::set_current(crate::i18n::Lang::EN); + for s in ["Formen verschachteln", "Rolle"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(app.session.doc().unwrap().doc, start, "no preview without the checkbox"); + + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true)); + painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + assert_ne!(app.session.doc().unwrap().doc, start, "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel puts the objects back"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + open(&mut app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "margin".into(), json!("0 pt")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), n + 1, "one undo step"); + let doc = &app.session.doc().unwrap().doc; + let left = |id: &serde_json::Value| doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().x0; + // The taller object goes first, its 1 pt stroke at the artboard's corner, and the other 9 pt to + // the right of its stroke. + assert_eq!((left(&a), left(&b)), (0.5, 0.5 + 51.0 + 9.0)); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 6656b45bf5..fb28afaeac 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -65,8 +65,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path /// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask and -/// Cutting Tools › Weed Box and Lines and Banner Hems and Grommets. (Cancel rolls a preview back -/// whichever dialog started it.) +/// Cutting Tools › Weed Box and Lines, Banner Hems and Grommets and Nest Shapes. (Cancel rolls a +/// preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), @@ -75,7 +75,8 @@ fn previews(d: &Dialog) -> bool { | crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK | crate::addons::cutting::WEED - | crate::addons::cutting::BANNER => true, + | crate::addons::cutting::BANNER + | crate::addons::cutting::NEST => true, _ => false, } } @@ -123,6 +124,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "perspective.grid.set" => &["cell", "distance"], crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], crate::addons::cutting::BANNER => &["hem", "spacing", "grommetInset", "diameter", "weight"], + crate::addons::cutting::NEST => &["width", "height", "margin", "spacing"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -150,6 +152,7 @@ fn order(command: &str) -> &'static [&'static str] { crate::addons::cutting::BANNER => { &["hem", "top", "right", "bottom", "left", "grommets", "spacing", "grommetInset", "diameter", "weight", "swatch"] } + crate::addons::cutting::NEST => &["roll", "width", "height", "margin", "spacing", "rotate", "usePreviewBounds"], // The public Trap options' order. "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f469c69c9..280b1c0479 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6881,3 +6881,6 @@ breakText Glyphs Глифи Grommets Капси Grommet Inset Отстъп на капсите от ръба Diameter Диаметър + Nest Shapes… Подреждане на форми… + Nest Shapes Подреждане на форми + Roll Ролка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a23133933a..e9e7ec961b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6934,3 +6934,6 @@ shape Open Otvoren Grommets Ušice Grommet Inset Udaljenost ušica od ruba Diameter Prečnik + Nest Shapes… Slaganje oblika… + Nest Shapes Slaganje oblika + Roll Rolna diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 373eb0b79d..62fc82b4d8 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7473,3 +7473,6 @@ breakText Glyphs Glifs Grommets Ullets Grommet Inset Distància dels ullets a la vora Diameter Diàmetre + Nest Shapes… Encaixa les formes… + Nest Shapes Encaixa les formes + Roll Rotlle diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7178be3143..20ba3290e0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7492,3 +7492,6 @@ breakText Glyphs Glyfy Grommets Průchodky Grommet Inset Odstup průchodek od okraje Diameter Průměr + Nest Shapes… Skládat tvary… + Nest Shapes Skládat tvary + Roll Role diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 433c6af3d6..a3a4ece9e2 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6896,3 +6896,6 @@ breakText Glyphs Glyffer Grommets Maljer Grommet Inset Maljernes afstand fra kanten Diameter Diameter + Nest Shapes… Pak figurer tæt… + Nest Shapes Pak figurer tæt + Roll Rulle diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 951f6f9c37..3a37e3070a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -8893,3 +8893,6 @@ breakText Glyphs Glyphen Grommets Ösen Grommet Inset Randabstand der Ösen Diameter Durchmesser + Nest Shapes… Formen verschachteln… + Nest Shapes Formen verschachteln + Roll Rolle diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 62e3adc491..e14ad5599b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7445,3 +7445,6 @@ breakText Glyphs Γλύφοι Grommets Τρουκς Grommet Inset Απόσταση τρουκς από την άκρη Diameter Διάμετρος + Nest Shapes… Ένθεση σχημάτων… + Nest Shapes Ένθεση σχημάτων + Roll Ρολό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d1c6aa53af..867747ff53 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7436,3 +7436,6 @@ breakText Glyphs Glifos Grommets Ojales Grommet Inset Distancia de los ojales al borde Diameter Diámetro + Nest Shapes… Anidar formas… + Nest Shapes Anidar formas + Roll Rollo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 45e47fd355..9f6ac2e6e4 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7527,3 +7527,6 @@ breakText Glyphs Merkit Grommets Purjerenkaat Grommet Inset Purjerenkaiden etäisyys reunasta Diameter Halkaisija + Nest Shapes… Sijoittele muodot… + Nest Shapes Sijoittele muodot + Roll Rulla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 057dce6872..1cf390fb38 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7487,3 +7487,6 @@ breakText Glyphs Glyphes Grommets Œillets Grommet Inset Distance des œillets au bord Diameter Diamètre + Nest Shapes… Imbriquer les formes… + Nest Shapes Imbriquer les formes + Roll Rouleau diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9d056e085a..e9fa8c5954 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8883,3 +8883,6 @@ theme Appearance अपीयरेंस Grommets आईलेट Grommet Inset किनारे से आईलेट की दूरी Diameter व्यास + Nest Shapes… आकृतियाँ सटाकर रखें… + Nest Shapes आकृतियाँ सटाकर रखें + Roll रोल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2f1a52cd9..580faf7e62 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7476,3 +7476,6 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő + Nest Shapes… Alakzatok elrendezése… + Nest Shapes Alakzatok elrendezése + Roll Tekercs diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a36c629332..7ba6d826f9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6896,3 +6896,6 @@ breakText Glyphs Glif Grommets Mata ayam Grommet Inset Jarak mata ayam dari tepi Diameter Diameter + Nest Shapes… Susun Bentuk… + Nest Shapes Susun Bentuk + Roll Gulungan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0ac2816b07..684dacd550 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8926,3 +8926,6 @@ breakText Glyphs Glifi Grommets Occhielli Grommet Inset Distanza degli occhielli dal bordo Diameter Diametro + Nest Shapes… Annida forme… + Nest Shapes Annida forme + Roll Rotolo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 71de1ce91c..b678bba573 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7188,3 +7188,6 @@ breakText Glyphs 字形 Grommets ハトメ Grommet Inset 端からハトメまでの距離 Diameter 直径 + Nest Shapes… 図形のネスティング… + Nest Shapes 図形のネスティング + Roll ロール diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2cff51d94b..626d1c2270 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7606,3 +7606,6 @@ font-script Japanese იაპონური Grommets ლუვერები Grommet Inset ლუვერების დაშორება კიდიდან Diameter დიამეტრი + Nest Shapes… ფორმების განლაგება… + Nest Shapes ფორმების განლაგება + Roll რულონი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4715a413f4..b6945b35b1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7083,3 +7083,6 @@ breakText Glyphs 글리프 Grommets 아일렛 Grommet Inset 가장자리에서 아일렛까지 거리 Diameter 지름 + Nest Shapes… 도형 네스팅… + Nest Shapes 도형 네스팅 + Roll 롤 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 94957fb782..39d0938033 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7519,3 +7519,6 @@ breakText Glyphs Glyphs Grommets Zeilringen Grommet Inset Afstand van zeilringen tot de rand Diameter Diameter + Nest Shapes… Vormen nesten… + Nest Shapes Vormen nesten + Roll Rol diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 48f2671c2d..a63bb0a792 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -9159,3 +9159,6 @@ breakText Glyphs Glify @msg {what} can't be intertwined {what} nie można przeplatać @msg {what} doesn't fit the paper with its bleed and marks {what} nie mieści się na papierze wraz ze spadem i znacznikami @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: wydruk zająłby ponad {MAX_TILES} kafelków: zmniejsz skalę + Nest Shapes… Rozmieść kształty… + Nest Shapes Rozmieść kształty + Roll Rolka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f6e54552d4..ec16bd6835 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -9377,3 +9377,6 @@ swatch Yellow Green Verde-amarelado @msg turn on at least one side ative pelo menos um lado @msg with no hem and no grommets there is nothing to mark sem bainha e sem ilhoses não há nada para marcar @msg {saving} failed: {e} {saving} falhou: {e} + Nest Shapes… Aninhar formas… + Nest Shapes Aninhar formas + Roll Rolo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8dc8336089..c4501f86f2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7581,3 +7581,6 @@ shape Open Открытый Grommets Люверсы Grommet Inset Отступ люверсов от края Diameter Диаметр + Nest Shapes… Раскладка фигур… + Nest Shapes Раскладка фигур + Roll Рулон diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cfb9d9734b..75c0e828ac 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8967,3 +8967,6 @@ digits Hindi Hindi Grommets Öljetter Grommet Inset Öljetternas avstånd från kanten Diameter Diameter + Nest Shapes… Packa former… + Nest Shapes Packa former + Roll Rulle diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2b3e843ba3..5fecf2e830 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7448,3 +7448,6 @@ breakText Glyphs Glifler Grommets Kuşgözleri Grommet Inset Kuşgözlerinin kenara uzaklığı Diameter Çap + Nest Shapes… Şekilleri yerleştir… + Nest Shapes Şekilleri yerleştir + Roll Rulo diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..4c891bb75c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8913,3 +8913,6 @@ breakText Glyphs Гліфи @msg turn on at least one side увімкніть принаймні одну сторону @msg with no hem and no grommets there is nothing to mark без підгину й люверсів немає чого розмічати @msg {saving} failed: {e} {saving}: не вдалося: {e} + Nest Shapes… Розкладка фігур… + Nest Shapes Розкладка фігур + Roll Рулон diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d8469bce53..44acd8908a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7103,3 +7103,6 @@ breakText Glyphs Glyph Grommets Khoen Grommet Inset Khoảng cách từ mép đến khoen Diameter Đường kính + Nest Shapes… Xếp hình tiết kiệm vật liệu… + Nest Shapes Xếp hình tiết kiệm vật liệu + Roll Cuộn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9d9110276..b0dfb404d8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -9145,3 +9145,6 @@ breakText Glyphs 字形 @msg {what} can't be intertwined {what} 不能交织 @msg {what} doesn't fit the paper with its bleed and marks {what} 连同出血和标记在纸张上放不下 @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} 会打印成超过 {MAX_TILES} 个拼贴:请将其缩小 + Nest Shapes… 形状排料… + Nest Shapes 形状排料 + Roll 卷材 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a0d987fe69..d79a297521 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7138,3 +7138,6 @@ breakText Glyphs 字符 Grommets 扣眼 Grommet Inset 扣眼邊距 Diameter 直徑 + Nest Shapes… 形狀排料… + Nest Shapes 形狀排料 + Roll 捲材 diff --git a/docs/mcp.md b/docs/mcp.md index 84c75a81d0..3784e4d915 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -11010,8 +11010,8 @@ it ends up in front), baked with `effect.expandAppearance`, then `object.pathfin ## Cutting Tools add-on Cutting Tools is a built-in add-on that prepares art for cutting. Its commands are `addon.cutting.bridges`, -`addon.cutting.weed` and `addon.cutting.banner`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil -Bridges…, Weed Box and Lines… and Banner Hems and Grommets…. +`addon.cutting.weed`, `addon.cutting.banner` and `addon.cutting.nest`; in the desktop app they are Object › Plug-ins › +Cutting Tools › Stencil Bridges…, Weed Box and Lines…, Banner Hems and Grommets… and Nest Shapes…. ### Stencil Bridges @@ -11132,6 +11132,39 @@ Banner Hems and Grommets… opens the command dialog with Preview. {"name":"run_command","arguments":{"command":"addon.cutting.banner","params":{"hem":"1 in","grommetInset":"0.5 in","spacing":"2 ft"}}} ``` +### Nest Shapes + +`addon.cutting.nest {roll?, width?, height?, margin?, spacing?, rotate?, usePreviewBounds?, artboard?, patterns?}` moves +the selected objects close together onto a sheet or a roll of material +([packing problems](https://en.wikipedia.org/wiki/Packing_problems)). The ranges, defaults and rules below are +VectorCraft's own. The sheet starts at the top-left corner of artboard `artboard` (0-based; by default the artboard +under the center of the selection, else the first). It is `width` × `height` points (0 to 1000000 each; 0, the default, +takes the artboard's width or height). With `roll` true it is a roll `width` wide with no end, and `height` is not used. +`margin` (0 to 100000, default 18) is kept clear inside each edge of the sheet; on a roll, inside the sides and the +start. `spacing` (0 to 100000, default 9) is kept between two objects. + +Each selected object is packed by its bounding box: with its strokes and effects, including the reach of shadows, glows +and blurs, when `usePreviewBounds` is true (the default), else by its geometry. A group is one object. The tallest +objects go first, each where its bottom edge is highest and then furthest left. With `rotate` true (false by default), +an object may turn 90° clockwise where that puts its bottom edge higher, or where only the turned object fits; the +longest side then decides the order, and a turned object is measured as it is after the turn. A path of a +pixel-aligned compound path is not turned. Objects are moved, and turned with `rotate`, never resized. An object on the +pixel grid moves by whole points and takes 1 pt more room across and down. Objects that do not fit go in a column to +the right of the sheet, from its top, `spacing` from its edge and from each other, in stacking order (back to front). +Objects without bounds, such as an empty group, are not moved. `patterns` moves pattern fills and strokes with the art +(default: General › Transform Pattern Tiles). + +All of it is one undo step, and nothing changes when no object moves. The command fails when nothing selected can be +measured, when the margin leaves no room on the sheet, when no selected object fits, with more than 10000 objects +selected, and with bad params. Result: `{placed, unplaced, rotated, skipped, length}`: the number of objects placed, +the ids of the objects put beside the sheet, the ids of the objects turned, the ids of the objects without bounds, and +the length of material used in points, from the sheet's top edge to the margin below the lowest object placed. Nest +Shapes… opens the command dialog with Preview. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.nest","params":{"roll":true,"width":"24 in","margin":"0.25 in","spacing":"0.125 in","rotate":true}}} +``` + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..c80eeb3374 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2211,6 +2211,10 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… (`addon.cutting.banner`) marks where a printed banner is hemmed ([hem](https://en.wikipedia.org/wiki/Hem)) and where its grommets go ([grommet](https://en.wikipedia.org/wiki/Grommet)). The rest is VectorCraft's own: the banner is the selected art's bounds or each artboard; each chosen side gets a hem line at a set depth inside its edge (1 in by default) and grommet marks at a set inset (0.5 in), one at each end of the side and the rest spread evenly at most a set spacing apart (24 in), with one grommet at a shared corner. Each grommet mark is a circle with a cross through its center. The marks are stroked in a chosen swatch ([Registration] by default), in one group named Banner Marks per banner on top of the current layer, added as one undo step. The dialog has Preview. Tests: `cmd::addons::cutting::banner::tests` in `vectorcraft-engine`, `banner_hems_and_grommets_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Banner Hems and Grommets. +## Cutting Tools add-on: Nest Shapes (2026-11-14) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Nest Shapes… (`addon.cutting.nest`) moves the selected objects close together onto a sheet or a roll of material ([packing problems](https://en.wikipedia.org/wiki/Packing_problems)). The rest is VectorCraft's own: the sheet is an artboard, or a set width and height from its top-left corner, and a roll is a set width with no end; a margin is kept inside the edges and a spacing between objects. Objects are packed by their bounding boxes, with or without strokes and effects (shadows, glows and blurs included), the tallest first, each where its bottom edge is highest and then furthest left; with Rotate an object may turn 90° where that places it higher or where only the turned object fits. Objects that do not fit go in a column to the right of the sheet, in stacking order. The command is one undo step, and its result gives the length of material used. The dialog has Preview. Tests: `cmd::addons::cutting::nest::tests` in `vectorcraft-engine`, `nest_shapes_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Nest Shapes. + ## Path Precision Tools add-on: Divide into Dashes (2026-11-01) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools › Divide into Dashes… cuts the selected paths into separate open paths along the curve (arc length, not straight distance): each `dash` long (0.01 to 1000000 pt, 10 by default) with `gap` between them (0 by default: the pieces touch); the gap pieces are left out unless Remove Gaps is off. With direct-selected segments (or anchors) only those runs are divided, the pattern running on across segments that follow one another, and the rest of such a path stays as pieces of its own. Fit to Corners and Ends gives every run between corners and ends a whole number of dashes, all stretched or squeezed alike so it starts and ends on a dash (a closed path without corners holds whole periods); otherwise the lengths are exact and each run's last piece is what is left. Every piece keeps the path's appearance and is selected; one undo step; at most 100000 pieces (more is refused). The dialog takes lengths in any unit and previews on the canvas. Tests: `dashes::tests` in `vectorcraft-pathops` (exact and fitted lengths, curves measured along them, picked segments on open and closed paths, the piece cap and bad patterns), `divide_into_dashes_*` in `cmd::addons::pathtools::tests` in `vectorcraft-engine` (gaps removed or kept, fitted lengths, the stroke kept, one undo step, selected segments only, bad params and too many pieces refused), `divide_into_dashes_opens_its_dialog_previews_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Path Precision Tools add-on.