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 d01db4969d..5329f23331 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 c0dcccadcd..797ca39f52 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -909,6 +909,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 85bf31792e..7ca9a59b8f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6845,3 +6845,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 4d757344a9..06bb9bbfa6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6898,3 +6898,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 264c24d88b..8b6834743d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7433,3 +7433,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 d449ceaa59..8f538c9950 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7452,3 +7452,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 48a863cbfd..e046bed58c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6860,3 +6860,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 fa2fef505b..b1be23b6a3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -7048,3 +7048,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 3fa46574a5..c664501ec6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7405,3 +7405,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 1aaa2bb26d..a6fd22eeb3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7396,3 +7396,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 ddd752b50a..9892a03e5b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7487,3 +7487,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 0444e5da6a..306431c0c5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7447,3 +7447,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 95dddd6223..19c1e20c77 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8844,3 +8844,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 e980069a84..c503250cb2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7436,3 +7436,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 80b567db6c..b48a949df4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6860,3 +6860,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 8d529f4c73..f9739e4824 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7082,3 +7082,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 23faf1d472..50dda82aaa 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7148,3 +7148,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 2e9e065e6b..1aab681c8f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7566,3 +7566,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 284f4df9f0..6e7fdab5a5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.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/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 6b6140146b..af6e9809a2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7479,3 +7479,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 ff4099ccdf..962a0f9aa0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7309,3 +7309,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 e5c02acdf2..d99e48403e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7525,3 +7525,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 9905db8688..0324ba2763 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7541,3 +7541,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 2397055c39..0ba5bb437c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8928,3 +8928,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 504908c98c..2308fad157 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7408,3 +7408,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 d73328804e..9c88d3b569 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7062,3 +7062,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 ad171fb85c..76caff43b3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7063,3 +7063,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 e33a726c89..a55ed9918c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7291,3 +7291,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 36115421e6..2b6740bdbf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -7098,3 +7098,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 286632ece3..577a265611 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10879,9 +10879,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 @@ -10971,6 +10971,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 7fc0b217b3..fc8c3e7c3d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2158,6 +2158,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.