From 1eaf421e696698585cc3d09cc56739a74efef5d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:58:00 -0800 Subject: [PATCH] Cutting Tools: Banner Hems and Grommets marks a banner's hems and grommet holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… (`addon.cutting.banner`) marks where a printed banner is hemmed and where its grommets go. The banner is the bounds of the selected art, or each artboard when nothing is selected. On each chosen side the command adds a hem line a set depth inside the edge (1 in by default) and grommet marks a set inset inside it (0.5 in): one at each end of the side and the rest spread evenly, as few as keep them at most a set spacing apart (24 in). A corner that two chosen sides share gets one grommet. Each grommet mark is a circle (27 pt by default) with a cross through its center. The marks are stroked in a chosen swatch ([Registration] by default) with no fill, in one group named Banner Marks per banner on top of the current layer, selected, as one undo step. While a clip group is edited in isolation, the group goes outside the outermost clip group, as Weed Box and Lines does. The command refuses every side off, no hem and no grommets, and bad params with a message. It also refuses a hem or inset that reaches the far edge of the banner when one of two opposite sides is chosen, or that meets the marks of the opposite side when both are chosen, an inset of more than half a chosen side's length, and more than 100000 grommets. The dialog has Preview. The grommets' distance from the edge is `grommetInset`, so the dialog shows it as Grommet Inset with its own catalog rows. The new Diameter catalog rows also translate the Diameter field of the `shape.flare` command dialog, which had no rows. 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 | 6 +- .../engine/src/cmd/addons/cutting/banner.rs | 228 +++++++++++++++ .../src/cmd/addons/cutting/banner/tests.rs | 263 ++++++++++++++++++ crates/engine/src/cmd/addons/cutting/weed.rs | 2 +- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/tests/command_sweep.rs | 5 + crates/ui-egui/src/addons/cutting.rs | 11 + crates/ui-egui/src/addons/cutting/tests.rs | 67 +++++ crates/ui-egui/src/dialogs/command.rs | 10 +- crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/bs.tsv | 6 + crates/ui-egui/src/i18n/ca.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/da.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hi.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ka.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + docs/mcp.md | 37 ++- docs/target-app-parity.md | 4 + 39 files changed, 789 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/addons/cutting/banner.rs create mode 100644 crates/engine/src/cmd/addons/cutting/banner/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index fe325ff849..fe038ec97a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,6 +123,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-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 2734ca10b3..37e9f2fa79 100644 --- a/crates/engine/src/cmd/addons/cutting.rs +++ b/crates/engine/src/cmd/addons/cutting.rs @@ -1,9 +1,10 @@ //! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil //! 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`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are -//! the UI's. +//! 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. +mod banner; mod stencil; mod weed; @@ -12,5 +13,6 @@ use super::super::CommandSpec; pub fn specs() -> Vec { let mut v = stencil::specs(); v.extend(weed::specs()); + v.extend(banner::specs()); v } diff --git a/crates/engine/src/cmd/addons/cutting/banner.rs b/crates/engine/src/cmd/addons/cutting/banner.rs new file mode 100644 index 0000000000..f4ae71ed54 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner.rs @@ -0,0 +1,228 @@ +//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools +//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line, +//! where the edge is folded back and sewn (), and grommet +//! marks, where rings are set into the material to hang it +//! (), spread evenly at most a set spacing apart. The +//! banner is the selected art's bounds, or each artboard. + +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::super::super::edit::selected_roots; +use super::super::super::paint::swatch_solid; +use super::super::super::pathops::length_given; +use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param}; +use super::weed::outside_clips; +use crate::{Result, Session}; + +/// Banner Hems and Grommets' command. +pub const BANNER: &str = "addon.cutting.banner"; +/// The sides of a banner, in the order the grommets go round it. +const SIDES: [&str; 4] = ["top", "right", "bottom", "left"]; +/// The largest hem, inset or spacing, in points. +const MAX_DISTANCE: f64 = 100_000.0; +/// The most grommets one run adds. +const MAX_GROMMETS: usize = 100_000; +/// Two grommet centers closer than this are one grommet (the corner that two sides share). +const SAME: f64 = 1e-6; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.cutting.banner", + "Banner Hems and Grommets…", + ["Object", "Plug-ins", "Cutting Tools"], + None, + "{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}", + has_doc, + banner + )] +} + +/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`. +fn distance(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))), + } +} + +/// The length `p` gives for `key` (0.01..1000), or `default`. +fn size(p: &Value, key: &str, default: f64) -> Result { + match length_given(p, key, BANNER)? { + None => Ok(default), + Some(v) if (0.01..=1000.0).contains(&v) => Ok(v), + Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))), + } +} + +/// Which sides are on, in [`SIDES`] order. +type Sides = [bool; 4]; + +/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside +/// the banner, and when both edges are chosen the two sets stay apart. +fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool { + match (near, far) { + (true, true) => 2.0 * depth < len, + (true, false) | (false, true) => depth < len, + (false, false) => true, + } +} + +/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the +/// other. +fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> { + if hem <= 0.0 { + return vec![]; + } + let lines = [ + (Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)), + (Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)), + (Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)), + (Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)), + ]; + lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect() +} + +/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end +/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them +/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two +/// chosen sides share has one grommet. `None` when there would be more than `limit`. +fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option> { + let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset); + // Each side from its start to its end, clockwise. + let runs = [ + (Point::new(l, t), Point::new(rt, t)), + (Point::new(rt, t), Point::new(rt, b)), + (Point::new(rt, b), Point::new(l, b)), + (Point::new(l, b), Point::new(l, t)), + ]; + let mut out: Vec = vec![]; + for ((a, z), on) in runs.into_iter().zip(sides) { + if !on { + continue; + } + let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0); + if !gaps.is_finite() || gaps > limit as f64 { + return None; + } + let gaps = gaps as usize; + for i in 0..=gaps { + let p = a.lerp(z, i as f64 / gaps as f64); + // A side starts where the one before it ends, and the last one ends where the first + // starts. + let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME); + if !shared(out.last()) && !shared(out.first()) { + out.push(p); + } + if out.len() > limit { + return None; + } + } + } + Some(out) +} + +fn banner(s: &mut Session, p: &Value) -> Result { + let hem = distance(p, "hem", 72.0)?; + let inset = distance(p, "grommetInset", 36.0)?; + let spacing = match length_given(p, "spacing", BANNER)? { + None => 1728.0, + Some(v) if v > 0.0 && v <= MAX_DISTANCE => v, + Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))), + }; + let diameter = size(p, "diameter", 27.0)?; + let weight = size(p, "weight", 0.3)?; + let with_grommets = switch_or(p, "grommets", true, BANNER)?; + let mut sides = [true; 4]; + for (on, key) in sides.iter_mut().zip(SIDES) { + *on = switch_or(p, key, true, BANNER)?; + } + if !sides.contains(&true) { + return Err(bad(BANNER, "turn on at least one side")); + } + if hem == 0.0 && !with_grommets { + return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark")); + } + let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION); + let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?; + let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?; + let banners: Vec = if all { + s.doc()?.doc.artboards.iter().map(|a| a.rect).collect() + } else { + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(bad(BANNER, "select the banner, or mark every artboard")); + } + s.doc()?.doc.bounds_of(&roots, false).into_iter().collect() + }; + if banners.is_empty() { + return Err(bad(BANNER, "there is no banner to mark")); + } + let [top, right, bottom, left] = sides; + let mut marks = vec![]; + let mut total = 0; + for r in &banners { + let (w, h) = (r.width(), r.height()); + if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 { + return Err(bad(BANNER, "the banner has no width or height")); + } + // A hem line must stay inside the banner and apart from the hem line on the far side. + if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) { + return Err(bad(BANNER, "the hem is too deep for the banner")); + } + let centers = if with_grommets { + // The grommets on a side run from `inset` past one end to `inset` before the other, + // stay inside the banner and stay apart from the grommets on the far side. + let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h); + if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) { + return Err(bad(BANNER, "the grommet inset is too large for the banner")); + } + grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total) + .ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))? + } else { + vec![] + }; + total += centers.len(); + marks.push((hem_lines(*r, hem, sides), centers)); + } + let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?); + let ap = Appearance::basic(Paint::None, stroke, weight); + let half = diameter / 2.0; + let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| { + let (mut ids, mut hems) = (vec![], 0); + for (lines, centers) in &marks { + let mut children = vec![]; + for (a, b) in lines { + let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone()); + n.name = Some("Hem".into()); + children.push(Arc::new(n)); + } + hems += lines.len(); + for c in centers { + let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone()); + let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone()); + let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone()); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]); + g.name = Some("Grommet".into()); + children.push(Arc::new(g)); + } + let mut g = Node::group(d.alloc_id(), children); + g.name = Some("Banner Marks".into()); + ids.push(g.id); + d.insert(parent, usize::MAX, g)?; + } + sel.set(ids.iter().copied()); + Ok((ids, hems)) + })?; + let first = ids.first().map(|i| i.0); + Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::>(), "hems": hems, "grommets": total })) +} diff --git a/crates/engine/src/cmd/addons/cutting/banner/tests.rs b/crates/engine/src/cmd/addons/cutting/banner/tests.rs new file mode 100644 index 0000000000..b186720b45 --- /dev/null +++ b/crates/engine/src/cmd/addons/cutting/banner/tests.rs @@ -0,0 +1,263 @@ +//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Appearance, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::{grommet_centers, hem_lines}; +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// Runs Banner Hems and Grommets with `p` and gives the first group it made. +fn banner(s: &mut Session, p: Value) -> (Value, Node) { + let r = s.execute("addon.cutting.banner", &p).unwrap(); + let g = node(s, id_of(&r)); + (r, g) +} + +fn round(p: Point) -> (f64, f64) { + let r = |v: f64| (v * 1000.0).round() / 1000.0; + (r(p.x), r(p.y)) +} + +/// The hem lines in group `g` as their two ends. +fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> { + g.children() + .unwrap() + .iter() + .filter(|c| c.name.as_deref() == Some("Hem")) + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => { + let a: Vec = path.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(a.len(), 2, "a hem line is one straight segment"); + (round(a[0]), round(a[1])) + } + other => panic!("not a path: {other:?}"), + }) + .collect() +} + +/// The grommet marks in group `g` as their centers. +fn grommets(g: &Node) -> Vec<(f64, f64)> { + g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect() +} + +#[test] +fn hems_and_grommets_go_round_a_selected_banner() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 200.0); + let undo = s.doc().unwrap().history.undo.len(); + let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8})); + assert_eq!(g.name.as_deref(), Some("Banner Marks")); + assert_eq!( + hems(&g), + vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))] + ); + // 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the + // corners are shared. + assert_eq!( + grommets(&g), + vec![ + (10.0, 10.0), + (136.667, 10.0), + (263.333, 10.0), + (390.0, 10.0), + (390.0, 100.0), + (390.0, 190.0), + (263.333, 190.0), + (136.667, 190.0), + (10.0, 190.0), + (10.0, 100.0) + ] + ); + assert_eq!(r["hems"], 4); + assert_eq!(r["grommets"], 10); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects, [g.id]); +} + +#[test] +fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5})); + let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap(); + assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0))); + let parts = first.children().unwrap(); + assert_eq!(parts.len(), 3, "a circle and two lines"); + for c in parts.iter() { + assert!(matches!(c.kind, NodeKind::Path { .. }), "a path"); + assert_eq!(c.appearance.fill_paint(), Paint::None); + assert!(c.appearance.stroke_paint().is_registration()); + assert_eq!(c.appearance.stroke_width(), 0.5); + } +} + +#[test] +fn every_artboard_is_marked_when_nothing_is_selected() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + let second = node(&s, ids[1]); + assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt"); + assert!(grommets(&second).is_empty()); + assert_eq!(r["grommets"], 0); +} + +#[test] +fn only_the_chosen_sides_are_marked() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false})); + assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]); + assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]); +} + +#[test] +fn units_are_read_and_bad_params_are_refused() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 400.0, 400.0); + let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0))); + assert_eq!(grommets(&g)[0], (36.0, 36.0)); + let before = s.doc().unwrap().history.undo.len(); + for p in [ + json!({"hem": -1}), + json!({"hem": "x"}), + json!({"spacing": 0}), + json!({"diameter": 0}), + json!({"weight": 1e9}), + json!({"top": "yes"}), + json!({"top": false, "right": false, "bottom": false, "left": false}), + json!({"hem": 0, "grommets": false}), + json!({"hem": 250}), + json!({"grommetInset": 250}), + json!({"spacing": 0.001}), + json!({"swatch": "no such swatch"}), + ] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing"); +} + +#[test] +fn hems_and_grommets_on_one_side_may_go_past_the_middle() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000}); + let with = |extra: Value| { + let mut p = one.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70}))); + assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]); + assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]); + for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends"); + } + // A grommet inset of half the width puts the grommets of a side on one point: one grommet. + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 300.0); + let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10}))); + assert_eq!(grommets(&g), vec![(50.0, 50.0)]); +} + +#[test] +fn hems_and_grommets_on_opposite_sides_stay_apart() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 300.0, 100.0); + let pair = |p: Value| { + let mut q = json!({"right": false, "left": false, "spacing": 1000}); + q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone()); + q + }; + // Half the height would put the top and bottom marks on the same line. + let refused = [ + pair(json!({"hem": 50})), + pair(json!({"hem": 0, "grommetInset": 50})), + json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}), + ]; + for p in refused { + assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused"); + } + let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5}))); + assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]); + assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]); +} + +#[test] +fn the_marks_go_outside_a_clip_group_edited_in_isolation() { + let mut s = session(); + let parent = s.doc().unwrap().target_parent().unwrap(); + let (g, art) = s + .edit("clip group", |d, _| { + let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default())); + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default()); + let art_id = art.id; + let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]); + if let NodeKind::Group { clip, .. } = &mut g.kind { + *clip = true; + } + let id = g.id; + d.insert(parent, usize::MAX, g)?; + Ok((id, art_id)) + }) + .unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false})); + // The clipping path would hide marks inside the clip group. + assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent); + assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner"); +} + +#[test] +fn all_artboards_marks_the_artboards_even_with_a_selection() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 100.0); + let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false})); + assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0))); +} + +#[test] +fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() { + let mut s = session(); + assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err()); +} + +#[test] +fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap(); + assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]); + // Spacing that divides the side exactly does not add a grommet, even when the division + // rounds up (2.1 / 0.3 is 7.000000000000001). + let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap(); + assert_eq!(c.len(), 8); + assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit"); + assert!(hem_lines(r, 0.0, [true; 4]).is_empty()); +} diff --git a/crates/engine/src/cmd/addons/cutting/weed.rs b/crates/engine/src/cmd/addons/cutting/weed.rs index 8a6375a595..183a66ad57 100644 --- a/crates/engine/src/cmd/addons/cutting/weed.rs +++ b/crates/engine/src/cmd/addons/cutting/weed.rs @@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option, spacing: Option) -> Resu /// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping /// sublayer (a group edited in isolation), the container around the outermost of them. A clipping /// path would hide the lines and the box, which lie outside the art. -fn outside_clips(d: &Document, parent: Option) -> Option { +pub(super) fn outside_clips(d: &Document, parent: Option) -> Option { let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })); let (mut at, mut out) = (parent, parent); while let Some(p) = at { diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 21d9d65c3d..b530b21d51 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.cutting.banner", "addon.cutting.bridges", "addon.cutting.weed", "addon.image.applyColorMask", diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index d88d99665f..032ef5954d 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -901,6 +901,11 @@ fn structured_junk() { ("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})), ("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})), ("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})), + ("addon.cutting.banner", json!({"hem": 1e5, "grommetInset": 1e5, "spacing": 1e-300, "diameter": 1000})), + ("addon.cutting.banner", json!({"hem": -1, "spacing": f64::MAX, "weight": 1e308, "top": "no", "allArtboards": 1})), + ("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})), // 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 1fbdcf88c2..15a6790872 100644 --- a/crates/ui-egui/src/addons/cutting.rs +++ b/crates/ui-egui/src/addons/cutting.rs @@ -2,6 +2,7 @@ //! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options //! 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. use serde_json::json; @@ -15,6 +16,8 @@ pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Hor pub const WEED: &str = "addon.cutting.weed"; /// Weed Box and Lines › Direction: what the command's `direction` takes, by name. pub const WEED_DIRECTIONS: &[(&str, &str)] = &[("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both", "both")]; +/// Banner Hems and Grommets' command. +pub const BANNER: &str = "addon.cutting.banner"; /// Object › Plug-ins › Cutting Tools. pub fn menu() -> Vec { @@ -25,6 +28,14 @@ pub fn menu() -> Vec { WEED, json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}), ), + Item::Cmd( + "Banner Hems and Grommets…", + BANNER, + json!({ + "hem": 72, "top": true, "right": true, "bottom": true, "left": true, "grommets": true, "spacing": 1728, "grommetInset": 36, + "diameter": 27, "weight": 0.3, "swatch": "[Registration]" + }), + ), ] } diff --git a/crates/ui-egui/src/addons/cutting/tests.rs b/crates/ui-egui/src/addons/cutting/tests.rs index 2e69eab904..99580b369c 100644 --- a/crates/ui-egui/src/addons/cutting/tests.rs +++ b/crates/ui-egui/src/addons/cutting/tests.rs @@ -143,3 +143,70 @@ fn weed_box_and_lines_previews_cancels_and_runs_on_ok() { app.run("edit.undo", json!({})).unwrap(); } } + +/// Object › Plug-ins › Cutting Tools › Banner Hems and Grommets… opens the command dialog on its +/// defaults with Preview off, previews the marks, takes them away on Cancel and keeps them as one +/// step on OK. With nothing selected it marks the artboard. +#[test] +fn banner_hems_and_grommets_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(); + app.run("select.none", json!({})).unwrap(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let count = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node_count(); + let (start, n, nodes) = (app.session.doc().unwrap().doc.clone(), steps(&app), count(&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 == "Banner Hems and Grommets…").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::BANNER.to_string(), "Banner Hems and Grommets".to_string())); + assert_eq!((d.bool("grommets"), d.bool("top"), d.bool("preview")), (true, true, false)); + let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + for s in [ + "Banner Hems and Grommets", + "Hem", + "Top", + "Right", + "Bottom", + "Left", + "Grommets", + "Spacing", + "Grommet Inset", + "Diameter", + "Weight", + "Swatch", + "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 ["Bannersäume und Ösen", "Saum:", "Ösen:", "Randabstand der Ösen:", "Durchmesser:"] { + assert!(german.contains(s), "{s} in {german}"); + } + assert_eq!(count(&app), nodes, "no preview without the checkbox"); + + // 600 × 400 pt with grommets at most 1728 pt apart: one at each corner. The group holds four + // hem lines and four grommets of three paths each. + let added = 1 + 4 + 4 * 4; + 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_eq!(count(&app), nodes + added, "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, start, "Cancel takes the preview away"); + assert_eq!(steps(&app), n, "and adds no undo step"); + + open(&mut app); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "spacing".into(), json!("100 pt")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), n + 1, "one undo step"); + // 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); +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 948b6f990f..cb23628a4c 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -65,7 +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. (Cancel rolls a preview back whichever dialog started it.) +/// Cutting Tools › Weed Box and Lines and Banner Hems and Grommets. (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"), @@ -73,7 +74,8 @@ fn previews(d: &Dialog) -> bool { | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK - | crate::addons::cutting::WEED => true, + | crate::addons::cutting::WEED + | crate::addons::cutting::BANNER => true, _ => false, } } @@ -120,6 +122,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"], + crate::addons::cutting::BANNER => &["hem", "spacing", "grommetInset", "diameter", "weight"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"], "type.pathOptions" => &["spacing"], @@ -134,6 +137,9 @@ fn lengths(command: &str) -> &'static [&'static str] { fn order(command: &str) -> &'static [&'static str] { match command { crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "weight", "swatch"], + crate::addons::cutting::BANNER => { + &["hem", "top", "right", "bottom", "left", "grommets", "spacing", "grommetInset", "diameter", "weight", "swatch"] + } // 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 4c2785956f..21a1e2d943 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6827,3 +6827,9 @@ breakText Glyphs Глифи Box Рамка Swatch Образец Direction Посока + Banner Hems and Grommets… Подгъви и капси за банер… + Banner Hems and Grommets Подгъви и капси за банер + Hem Подгъв + Grommets Капси + Grommet Inset Отстъп на капсите от ръба + Diameter Диаметър diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 9ccc94d3a4..20e3fded9e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6880,3 +6880,9 @@ shape Open Otvoren Box Okvir Swatch Uzorak boje Direction Smjer + Banner Hems and Grommets… Porubi i ušice za baner… + Banner Hems and Grommets Porubi i ušice za baner + Hem Porub + Grommets Ušice + Grommet Inset Udaljenost ušica od ruba + Diameter Prečnik diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b0159ebc14..a7dbc93ccd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7414,3 +7414,9 @@ breakText Glyphs Glifs Margin Marge Box Caixa Swatch Mostra + Banner Hems and Grommets… Vores i ullets de pancarta… + Banner Hems and Grommets Vores i ullets de pancarta + Hem Vora + Grommets Ullets + Grommet Inset Distància dels ullets a la vora + Diameter Diàmetre diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 27b2d32771..601e95f741 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7433,3 +7433,9 @@ breakText Glyphs Glyfy Margin Okraj Box Rámeček Swatch Vzorek + Banner Hems and Grommets… Lemy a průchodky banneru… + Banner Hems and Grommets Lemy a průchodky banneru + Hem Lem + Grommets Průchodky + Grommet Inset Odstup průchodek od okraje + Diameter Průměr diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3810bb2ac8..54eaa336af 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6842,3 +6842,9 @@ breakText Glyphs Glyffer Box Ramme Swatch Farveprøve Direction Retning + Banner Hems and Grommets… Bannersømme og maljer… + Banner Hems and Grommets Bannersømme og maljer + Hem Søm + Grommets Maljer + Grommet Inset Maljernes afstand fra kanten + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7b6cd3e8a6..13536f7aac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -7029,3 +7029,9 @@ breakText Glyphs Glyphen Margin Rand Box Rahmen Swatch Farbfeld + Banner Hems and Grommets… Bannersäume und Ösen… + Banner Hems and Grommets Bannersäume und Ösen + Hem Saum + Grommets Ösen + Grommet Inset Randabstand der Ösen + Diameter Durchmesser diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8620169f21..b7ce9a8df8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7386,3 +7386,9 @@ breakText Glyphs Γλύφοι Margin Περιθώριο Box Πλαίσιο Swatch Δείγμα χρώματος + Banner Hems and Grommets… Στριφώματα και τρουκς πανό… + Banner Hems and Grommets Στριφώματα και τρουκς πανό + Hem Στρίφωμα + Grommets Τρουκς + Grommet Inset Απόσταση τρουκς από την άκρη + Diameter Διάμετρος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a82db20a99..675132efa7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7377,3 +7377,9 @@ breakText Glyphs Glifos Margin Margen Box Caja Swatch Muestra + Banner Hems and Grommets… Dobladillos y ojales de pancarta… + Banner Hems and Grommets Dobladillos y ojales de pancarta + Hem Dobladillo + Grommets Ojales + Grommet Inset Distancia de los ojales al borde + Diameter Diámetro diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index db90a36316..28edeca855 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7468,3 +7468,9 @@ breakText Glyphs Merkit Margin Marginaali Box Kehys Swatch Värimalli + Banner Hems and Grommets… Bannerin päärmeet ja purjerenkaat… + Banner Hems and Grommets Bannerin päärmeet ja purjerenkaat + Hem Päärme + Grommets Purjerenkaat + Grommet Inset Purjerenkaiden etäisyys reunasta + Diameter Halkaisija diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 938e43dd48..970ac45bb6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7428,3 +7428,9 @@ breakText Glyphs Glyphes Margin Marge Box Cadre Swatch Nuance + Banner Hems and Grommets… Ourlets et œillets de banderole… + Banner Hems and Grommets Ourlets et œillets de banderole + Hem Ourlet + Grommets Œillets + Grommet Inset Distance des œillets au bord + Diameter Diamètre diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 0eacf9629b..6898342992 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8825,3 +8825,9 @@ theme Appearance अपीयरेंस Margin मार्जिन Box बॉक्स Swatch स्वॉच + Banner Hems and Grommets… बैनर हेम और आईलेट… + Banner Hems and Grommets बैनर हेम और आईलेट + Hem हेम + Grommets आईलेट + Grommet Inset किनारे से आईलेट की दूरी + Diameter व्यास diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3dadc4d3b6..734c34c7bd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7417,3 +7417,9 @@ breakText Glyphs Glifák Margin Margó Box Keret Swatch Színminta + Banner Hems and Grommets… Molinó szegései és ringlijei… + Banner Hems and Grommets Molinó szegései és ringlijei + Hem Szegés + Grommets Ringlik + Grommet Inset Ringlik távolsága a széltől + Diameter Átmérő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e670840b2c..6ed3f8bb9b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6842,3 +6842,9 @@ breakText Glyphs Glif Box Kotak Swatch Sampel warna Direction Arah + Banner Hems and Grommets… Lipatan dan Mata Ayam Spanduk… + Banner Hems and Grommets Lipatan dan Mata Ayam Spanduk + Hem Lipatan + Grommets Mata ayam + Grommet Inset Jarak mata ayam dari tepi + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 91d1e24bf7..758dabf3d0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7063,3 +7063,9 @@ breakText Glyphs Glifi Margin Margine Box Riquadro Swatch Campione + Banner Hems and Grommets… Orli e occhielli per striscioni… + Banner Hems and Grommets Orli e occhielli per striscioni + Hem Orlo + Grommets Occhielli + Grommet Inset Distanza degli occhielli dal bordo + Diameter Diametro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 628d1ef3de..dadefa6fab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7129,3 +7129,9 @@ breakText Glyphs 字形 Margin マージン Box 枠 Swatch スウォッチ + Banner Hems and Grommets… バナーの折り返しとハトメ… + Banner Hems and Grommets バナーの折り返しとハトメ + Hem 折り返し + Grommets ハトメ + Grommet Inset 端からハトメまでの距離 + Diameter 直径 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index aa2c27c296..57bc564d51 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7547,3 +7547,9 @@ font-script Japanese იაპონური Margin ზღვარი Box ჩარჩო Swatch ფერის ნიმუში + Banner Hems and Grommets… ბანერის ნაკეცები და ლუვერები… + Banner Hems and Grommets ბანერის ნაკეცები და ლუვერები + Hem ნაკეცი + Grommets ლუვერები + Grommet Inset ლუვერების დაშორება კიდიდან + Diameter დიამეტრი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca5c2ac8d1..85713a83b2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7024,3 +7024,9 @@ breakText Glyphs 글리프 Margin 여백 Box 상자 Swatch 견본 + Banner Hems and Grommets… 배너 단 접기 및 아일렛… + Banner Hems and Grommets 배너 단 접기 및 아일렛 + Hem 단 접기 + Grommets 아일렛 + Grommet Inset 가장자리에서 아일렛까지 거리 + Diameter 지름 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d46d7e8e87..282a3b2621 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7460,3 +7460,9 @@ breakText Glyphs Glyphs Box Kader Swatch Staal Direction Richting + Banner Hems and Grommets… Bannerzomen en zeilringen… + Banner Hems and Grommets Bannerzomen en zeilringen + Hem Zoom + Grommets Zeilringen + Grommet Inset Afstand van zeilringen tot de rand + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 38d5e2f497..c8753c0049 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7290,3 +7290,9 @@ breakText Glyphs Glify Margin Margines Box Ramka Swatch Próbka + Banner Hems and Grommets… Obszycia i oczka banera… + Banner Hems and Grommets Obszycia i oczka banera + Hem Obszycie + Grommets Oczka + Grommet Inset Odległość oczek od krawędzi + Diameter Średnica diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2a45bc8e68..c7f262c1d1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7506,3 +7506,9 @@ swatch Yellow Green Verde-amarelado Margin Margem Box Caixa Swatch Amostra + Banner Hems and Grommets… Bainhas e ilhoses de banner… + Banner Hems and Grommets Bainhas e ilhoses de banner + Hem Bainha + Grommets Ilhoses + Grommet Inset Distância dos ilhoses à borda + Diameter Diâmetro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af93b86974..0b86055e36 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7522,3 +7522,9 @@ shape Open Открытый Margin Поле Box Рамка Swatch Образец + Banner Hems and Grommets… Подгибы и люверсы баннера… + Banner Hems and Grommets Подгибы и люверсы баннера + Hem Подгиб + Grommets Люверсы + Grommet Inset Отступ люверсов от края + Diameter Диаметр diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f26438a201..3c5279013c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8909,3 +8909,9 @@ digits Hindi Hindi Stroke Weight Up Öka konturtjocklek The selected objects have different graphics. De markerade objekten har olika grafik. Word Gap Ordmellanrum + Banner Hems and Grommets… Bannerfållar och öljetter… + Banner Hems and Grommets Bannerfållar och öljetter + Hem Fåll + Grommets Öljetter + Grommet Inset Öljetternas avstånd från kanten + Diameter Diameter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a91aab6f0e..737ba45d1c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7389,3 +7389,9 @@ breakText Glyphs Glifler Margin Kenar Boşluğu Box Kutu Swatch Renk Örneği + Banner Hems and Grommets… Afiş Kenar Kıvırma ve Kuşgözleri… + Banner Hems and Grommets Afiş Kenar Kıvırma ve Kuşgözleri + Hem Kenar Kıvırma + Grommets Kuşgözleri + Grommet Inset Kuşgözlerinin kenara uzaklığı + Diameter Çap diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 17ddeb2086..08f90c2100 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7043,3 +7043,9 @@ breakText Glyphs Гліфи Margin Поле Box Рамка Swatch Зразок + Banner Hems and Grommets… Підгини та люверси банера… + Banner Hems and Grommets Підгини та люверси банера + Hem Підгин + Grommets Люверси + Grommet Inset Відступ люверсів від краю + Diameter Діаметр diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7d3d89a82d..b4937f73f1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7044,3 +7044,9 @@ breakText Glyphs Glyph Margin Lề Box Khung Swatch Mẫu màu + Banner Hems and Grommets… Viền gấp và khoen băng rôn… + Banner Hems and Grommets Viền gấp và khoen băng rôn + Hem Viền gấp + Grommets Khoen + Grommet Inset Khoảng cách từ mép đến khoen + Diameter Đường kính diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 34ffeb5d6b..847fa36536 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7272,3 +7272,9 @@ breakText Glyphs 字形 Margin 边距 Box 框 Swatch 色板 + Banner Hems and Grommets… 横幅折边和扣眼… + Banner Hems and Grommets 横幅折边和扣眼 + Hem 折边 + Grommets 扣眼 + Grommet Inset 扣眼边距 + Diameter 直径 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 309e00346c..390125a2b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7079,3 +7079,9 @@ breakText Glyphs 字符 Margin 邊距 Box 框 Swatch 色票 + Banner Hems and Grommets… 橫幅折邊與扣眼… + Banner Hems and Grommets 橫幅折邊與扣眼 + Hem 折邊 + Grommets 扣眼 + Grommet Inset 扣眼邊距 + Diameter 直徑 diff --git a/docs/mcp.md b/docs/mcp.md index 2b20cfc588..d1dd2cec85 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10728,9 +10728,9 @@ 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` and -`addon.cutting.weed`; in the desktop app they are Object › Plug-ins › Cutting Tools › Stencil Bridges… and Weed Box and -Lines…. +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…. ### Stencil Bridges @@ -10820,6 +10820,37 @@ dialog with Preview; a Spacing above 0 is used instead of the Count. {"name":"run_command","arguments":{"command":"addon.cutting.weed","params":{"margin":"0.25 in","direction":"both","spacing":"2 in","gap":1}}} ``` +### Banner Hems and Grommets + +`addon.cutting.banner {hem?, grommets?, spacing?, grommetInset?, diameter?, top?, right?, bottom?, left?, weight?, +swatch?, allArtboards?}` marks where a printed banner is hemmed, its edge folded back and sewn +([hem](https://en.wikipedia.org/wiki/Hem)), and where grommets, rings set into the material to hang it, are punched +([grommet](https://en.wikipedia.org/wiki/Grommet)). The ranges, defaults and rules below are VectorCraft's own. The banner +is the bounds of the selected art, or each artboard when `allArtboards` is true (the default when nothing is selected). +`top`, `right`, `bottom` and `left` (each true by default) choose the sides that get marks. On each chosen side a hem +line runs the whole length of the edge, `hem` points inside it (0 to 100000, default 72; 0 leaves the hem lines out). +With `grommets` on (the default), each chosen side gets grommet marks `grommetInset` points inside the edge (0 to +100000, default 36): one at each end of the side, `grommetInset` from the edges across it, and the rest spread evenly +between them, as few as keep them at most `spacing` points apart (more than 0 and at most 100000, default 1728, 24 in). A corner that two +chosen sides share has one grommet. Each grommet mark is a circle `diameter` points wide (0.01 to 1000, default 27) with +a cross through its center. The marks are stroked `weight` points wide (0.01 to 1000, default 0.3) in the color swatch +`swatch` (default `"[Registration]"`), with no fill. + +Each banner gets one group named "Banner Marks", holding its hem lines (named "Hem") and its grommets (groups named +"Grommet"), added on top of the current layer and selected, as one undo step. While a clip group, or a group inside one, +is edited in isolation, the groups go on top of the container that holds the outermost clip group around it. The command +fails with every side off, with no hem and no grommets, with nothing selected while `allArtboards` is false, when a +banner has no width or height, when a `hem` above 0 reaches the far edge of the banner (the full height with only one +of the top and bottom chosen, or the full width with only one of the left and right) or meets the hem on the opposite +side (half the height or width with both chosen), when a `grommetInset` does the same for the grommets or is more than half +the length of a chosen side, and when the grommets would be more than 100000. Result: +`{id, ids, hems, grommets}`: the first group's id, every group's id, and the number of hem lines and grommet marks. +Banner Hems and Grommets… opens the command dialog with Preview. + +```json +{"name":"run_command","arguments":{"command":"addon.cutting.banner","params":{"hem":"1 in","grommetInset":"0.5 in","spacing":"2 ft"}}} +``` + ## 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 f7782bdfcd..54cab41bab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2128,6 +2128,10 @@ A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Cutting Tools › Weed Box and Lines… (`addon.cutting.weed`) adds a weed box and weeding lines around the selected art for vinyl cutting ([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The rest is VectorCraft's own: the box is a rectangle at a margin around the art, and the lines run across it horizontally, vertically or both ways, by count or by spacing, and stop at the art's outlines or at a gap from them. The box and the lines are straight paths stroked in a chosen swatch ([Registration] by default), in one group named Weeding on top of the current layer, added as one undo step. The art is measured by the outlines a cutter cuts: compound paths by their fill rule, type by its glyph outlines, clip groups by what shows, images by their frames, placed files by the art they show, symbol instances by their art, and blends and other live objects by the art they draw. The dialog has Preview. Tests: `cmd::addons::cutting::weed::tests` in `vectorcraft-engine`, `weed_box_and_lines_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Weed Box and Lines. +## Cutting Tools add-on: Banner Hems and Grommets (2026-11-13) + +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. + ## 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.