Merge pull request #7282 from dotCooCoo/feat/banner-hems-grommets

Cutting Tools: Banner Hems and Grommets marks a banner's hems and grommet holes
This commit is contained in:
BFlat
2026-11-14 05:41:10 +07:00
committed by GitHub
39 changed files with 789 additions and 8 deletions
+1
View File
@@ -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.
+4 -2
View File
@@ -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<CommandSpec> {
let mut v = stencil::specs();
v.extend(weed::specs());
v.extend(banner::specs());
v
}
@@ -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 (<https://en.wikipedia.org/wiki/Hem>), and grommet
//! marks, where rings are set into the material to hang it
//! (<https://en.wikipedia.org/wiki/Grommet>), 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<CommandSpec> {
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<f64> {
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<f64> {
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<Vec<Point>> {
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<Point> = 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<Value> {
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<Rect> = 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::<Vec<_>>(), "hems": hems, "grommets": total }))
}
@@ -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<Point> = 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<NodeId> = 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());
}
+1 -1
View File
@@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> 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<NodeId>) -> Option<NodeId> {
pub(super) fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
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 {
@@ -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",
+5
View File
@@ -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"})),
+11
View File
@@ -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<Item> {
@@ -25,6 +28,14 @@ pub fn menu() -> Vec<Item> {
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]"
}),
),
]
}
@@ -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);
}
+8 -2
View File
@@ -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"],
+6
View File
@@ -6845,3 +6845,9 @@ breakText Glyphs Глифи
Box Рамка
Swatch Образец
Direction Посока
Banner Hems and Grommets… Подгъви и капси за банер…
Banner Hems and Grommets Подгъви и капси за банер
Hem Подгъв
Grommets Капси
Grommet Inset Отстъп на капсите от ръба
Diameter Диаметър
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7405,3 +7405,9 @@ breakText Glyphs Γλύφοι
Margin Περιθώριο
Box Πλαίσιο
Swatch Δείγμα χρώματος
Banner Hems and Grommets… Στριφώματα και τρουκς πανό…
Banner Hems and Grommets Στριφώματα και τρουκς πανό
Hem Στρίφωμα
Grommets Τρουκς
Grommet Inset Απόσταση τρουκς από την άκρη
Diameter Διάμετρος
Can't render this file because it is too large.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -8844,3 +8844,9 @@ theme Appearance अपीयरेंस
Margin मार्जिन
Box बॉक्स
Swatch स्वॉच
Banner Hems and Grommets… बैनर हेम और आईलेट…
Banner Hems and Grommets बैनर हेम और आईलेट
Hem हेम
Grommets आईलेट
Grommet Inset किनारे से आईलेट की दूरी
Diameter व्यास
Can't render this file because it is too large.
+6
View File
@@ -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ő
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2429 and column 5.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7148,3 +7148,9 @@ breakText Glyphs 字形
Margin マージン
Box 枠
Swatch スウォッチ
Banner Hems and Grommets… バナーの折り返しとハトメ…
Banner Hems and Grommets バナーの折り返しとハトメ
Hem 折り返し
Grommets ハトメ
Grommet Inset 端からハトメまでの距離
Diameter 直径
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7566,3 +7566,9 @@ font-script Japanese იაპონური
Margin ზღვარი
Box ჩარჩო
Swatch ფერის ნიმუში
Banner Hems and Grommets… ბანერის ნაკეცები და ლუვერები…
Banner Hems and Grommets ბანერის ნაკეცები და ლუვერები
Hem ნაკეცი
Grommets ლუვერები
Grommet Inset ლუვერების დაშორება კიდიდან
Diameter დიამეტრი
Can't render this file because it is too large.
+6
View File
@@ -7043,3 +7043,9 @@ breakText Glyphs 글리프
Margin 여백
Box 상자
Swatch 견본
Banner Hems and Grommets… 배너 단 접기 및 아일렛…
Banner Hems and Grommets 배너 단 접기 및 아일렛
Hem 단 접기
Grommets 아일렛
Grommet Inset 가장자리에서 아일렛까지 거리
Diameter 지름
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -7541,3 +7541,9 @@ shape Open Открытый
Margin Поле
Box Рамка
Swatch Образец
Banner Hems and Grommets… Подгибы и люверсы баннера…
Banner Hems and Grommets Подгибы и люверсы баннера
Hem Подгиб
Grommets Люверсы
Grommet Inset Отступ люверсов от края
Diameter Диаметр
Can't render this file because it is too large.
+6
View File
@@ -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
Can't render this file because it is too large.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6
View File
@@ -7062,3 +7062,9 @@ breakText Glyphs Гліфи
Margin Поле
Box Рамка
Swatch Зразок
Banner Hems and Grommets… Підгини та люверси банера…
Banner Hems and Grommets Підгини та люверси банера
Hem Підгин
Grommets Люверси
Grommet Inset Відступ люверсів від краю
Diameter Діаметр
Can't render this file because it is too large.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+6
View File
@@ -7291,3 +7291,9 @@ breakText Glyphs 字形
Margin 边距
Box 框
Swatch 色板
Banner Hems and Grommets… 横幅折边和扣眼…
Banner Hems and Grommets 横幅折边和扣眼
Hem 折边
Grommets 扣眼
Grommet Inset 扣眼边距
Diameter 直径
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -7098,3 +7098,9 @@ breakText Glyphs 字符
Margin 邊距
Box 框
Swatch 色票
Banner Hems and Grommets… 橫幅折邊與扣眼…
Banner Hems and Grommets 橫幅折邊與扣眼
Hem 折邊
Grommets 扣眼
Grommet Inset 扣眼邊距
Diameter 直徑
Can't render this file because it contains an unexpected character in line 6 and column 47.
+34 -3
View File
@@ -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
+4
View File
@@ -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.