mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
Cutting Tools: Weed Box and Lines adds a weed box and weeding lines around art
A Cutting Tools add-on command (Object › Plug-ins › Cutting Tools › Weed Box and Lines…, `addon.cutting.weed`) for vinyl cutting: around the selected art it adds a weed box, a rectangle at a margin outside the art's bounds, and weeding lines across the box, horizontal, vertical or both, by count or by spacing. Each line runs from one side of the box to the other and is left out over the art and within a gap of its outlines, so it stops at the art and the waste lifts off in pieces. The lines and the box are straight paths stroked in a chosen color swatch ([Registration] by default) with no fill, in one group named Weeding on top of the current layer, selected, as one undo step. While a clip group, or a group inside one, is edited in isolation, the group goes on top of the container that holds the outermost clip group around it. The art is measured by the outlines a cutter cuts: closed paths by their fill rule (the hole of an even-odd compound path gets lines too), open paths as lines, type by its glyph outlines, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art, and blends and other live objects by the art they draw. A clip group, and an object selected inside one, is measured by what shows inside the region its clipping paths clip to, and a clipping path selected on its own as its clip group. Art in a hidden group or layer is left out. The command refuses an empty selection, art with no outlines, bad params, and art or lines too complex to measure, with a message. The dialog has Preview; a Spacing above 0 is used instead of the Count. Docs: docs/mcp.md (Cutting Tools add-on) and docs/target-app-parity.md.
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
//! 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`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's.
|
||||
//! ([`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.
|
||||
|
||||
mod stencil;
|
||||
mod weed;
|
||||
|
||||
use super::super::CommandSpec;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
stencil::specs()
|
||||
let mut v = stencil::specs();
|
||||
v.extend(weed::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for
|
||||
//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the
|
||||
//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts
|
||||
//! off in pieces (<https://help.arlon.com/what-is-a-weeding-border>). The art is measured by the
|
||||
//! outlines a cutter cuts ([`geometry`]).
|
||||
|
||||
mod geometry;
|
||||
#[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, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::super::super::edit::selected_roots;
|
||||
use super::super::super::object::choice;
|
||||
use super::super::super::paint::swatch_solid;
|
||||
use super::super::super::pathops::{length_given, number_given};
|
||||
use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Weed Box and Lines' command.
|
||||
pub const WEED: &str = "addon.cutting.weed";
|
||||
/// Which way the weeding lines run.
|
||||
pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"];
|
||||
/// The largest margin, gap or spacing, in points.
|
||||
const MAX_DISTANCE: f64 = 100_000.0;
|
||||
/// The most weeding lines each way.
|
||||
const MAX_COUNT: usize = 1000;
|
||||
/// The most pieces of weeding line one run adds.
|
||||
const MAX_PIECES: usize = 100_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.cutting.weed",
|
||||
"Weed Box and Lines…",
|
||||
["Object", "Plug-ins", "Cutting Tools"],
|
||||
None,
|
||||
"{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}",
|
||||
has_selection,
|
||||
weed
|
||||
)]
|
||||
}
|
||||
|
||||
/// 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, WEED)? {
|
||||
None => Ok(default),
|
||||
Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v),
|
||||
Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing`
|
||||
/// apart.
|
||||
fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> Result<usize> {
|
||||
let n = match spacing {
|
||||
// `length / spacing` strips, rounded up, need one line fewer.
|
||||
Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0),
|
||||
None => return Ok(count.unwrap_or(1)),
|
||||
};
|
||||
if n > MAX_COUNT as f64 {
|
||||
return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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 {
|
||||
let up = d.parent_of(p);
|
||||
if clips(p) {
|
||||
match up {
|
||||
Some(up) if d.is_editable(up) => out = Some(up),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
at = up;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn weed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let margin = distance(p, "margin", 9.0)?;
|
||||
let gap = distance(p, "gap", 0.0)?;
|
||||
let with_box = switch_or(p, "box", true, WEED)?;
|
||||
let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both");
|
||||
let count = match number_given(p, "count", WEED)? {
|
||||
None => None,
|
||||
Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize),
|
||||
Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))),
|
||||
};
|
||||
let spacing = match length_given(p, "spacing", WEED)? {
|
||||
None => None,
|
||||
Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v),
|
||||
Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))),
|
||||
};
|
||||
if count.is_some() && spacing.is_some() {
|
||||
return Err(bad(WEED, "give `count` or `spacing`, not both"));
|
||||
}
|
||||
let weight = match length_given(p, "weight", WEED)? {
|
||||
None => 0.3,
|
||||
Some(v) if (0.01..=1000.0).contains(&v) => v,
|
||||
Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")),
|
||||
};
|
||||
let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION);
|
||||
let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?;
|
||||
let roots = selected_roots(s)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(WEED, "select the art to weed"));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let parent = outside_clips(d, s.doc()?.target_parent()?);
|
||||
// Placed files' art uses their resources (symbols) under names of their own.
|
||||
let placed = d.with_placed_art();
|
||||
let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?;
|
||||
let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?;
|
||||
let outline = region.outline().map_err(|e| bad(WEED, e))?;
|
||||
let art = outline.iter().fold(None, |r: Option<Rect>, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b))));
|
||||
let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin);
|
||||
if !(frame.width().is_finite() && frame.height().is_finite()) {
|
||||
return Err(bad(WEED, "the selected art is too large to measure"));
|
||||
}
|
||||
let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) };
|
||||
let counts =
|
||||
geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? };
|
||||
let pieces = geometry::lines(®ion, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?;
|
||||
if pieces.is_empty() && !with_box {
|
||||
return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into()));
|
||||
}
|
||||
let ap = Appearance::basic(Paint::None, stroke, weight);
|
||||
let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| {
|
||||
let mut children = vec![];
|
||||
let mut weed_box = None;
|
||||
if with_box {
|
||||
let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone());
|
||||
b.name = Some("Weed Box".into());
|
||||
weed_box = Some(b.id);
|
||||
children.push(Arc::new(b));
|
||||
}
|
||||
for (a, b) in &pieces {
|
||||
children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone())));
|
||||
}
|
||||
let mut g = Node::group(d.alloc_id(), children);
|
||||
g.name = Some("Weeding".into());
|
||||
let id = g.id;
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
sel.set([id]);
|
||||
Ok((id, weed_box, pieces.len()))
|
||||
})?;
|
||||
Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines }))
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]),
|
||||
//! and the weeding lines across a box that stop at those outlines or at a gap from them
|
||||
//! ([`lines`]).
|
||||
//!
|
||||
//! A region is measured along one straight line at a time. A closed outline encloses its area by
|
||||
//! its fill rule, an open one is a line with no area, a group is the union of its members and a
|
||||
//! clip group is what its members draw inside its clipping path. Along a weeding line the region
|
||||
//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces
|
||||
//! ([`Region::outline`]) give the stretches within the gap of an outline.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect};
|
||||
|
||||
use super::super::super::super::pathops::node_path;
|
||||
|
||||
/// How closely curves are followed, in points.
|
||||
const TOLERANCE: f64 = 0.02;
|
||||
/// The most points the outlines of one selection are measured by, once curves are followed in
|
||||
/// straight segments (each subpath's points, its first one included).
|
||||
pub(super) const MAX_POINTS: usize = 200_000;
|
||||
/// The most tests of a segment against another one measuring a clip group's outlines takes.
|
||||
const MAX_PAIRS: usize = 40_000_000;
|
||||
/// The deepest groups, symbol instances and live objects are opened inside one another: as deep
|
||||
/// as any object is drawn.
|
||||
pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH;
|
||||
/// The most pairs of an outline segment and a weeding line it comes near that one run measures.
|
||||
const MAX_NEAR: usize = 10_000_000;
|
||||
/// Pieces of weeding line shorter than this, in points, are left out.
|
||||
const MIN_PIECE: f64 = 0.01;
|
||||
|
||||
/// A straight segment.
|
||||
pub(super) type Segment = (Point, Point);
|
||||
|
||||
/// One subpath followed in straight segments.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Poly {
|
||||
points: Vec<Point>,
|
||||
closed: bool,
|
||||
/// The box around `points`.
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
impl Poly {
|
||||
/// The segments, with the closing one of a closed subpath.
|
||||
fn segments(&self) -> impl Iterator<Item = Segment> + '_ {
|
||||
let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied()));
|
||||
self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?))))
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected art as the outlines a cutter cuts.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum Region {
|
||||
/// A path, compound path or type: its subpaths with the rule its closed ones fill by.
|
||||
Shape { polys: Vec<Poly>, rule: FillRule },
|
||||
/// Everything its members draw.
|
||||
Union(Vec<Region>),
|
||||
/// What `content` draws inside `clip`.
|
||||
Clip { clip: Box<Region>, content: Box<Region> },
|
||||
}
|
||||
|
||||
/// Reads the selected objects into a [`Region`], keeping count of the outline points.
|
||||
pub(super) struct Reader<'a> {
|
||||
doc: &'a Document,
|
||||
points: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub(super) fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, points: 0 }
|
||||
}
|
||||
|
||||
/// The region of `nodes` together, `None` when they hold no outlines.
|
||||
pub(super) fn read(&mut self, nodes: &[&Node]) -> Result<Option<Region>, String> {
|
||||
let mut parts = vec![];
|
||||
for n in nodes {
|
||||
parts.extend(self.node(n, 0)?);
|
||||
}
|
||||
Ok(union(parts))
|
||||
}
|
||||
|
||||
fn node(&mut self, n: &Node, depth: usize) -> Result<Option<Region>, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep"));
|
||||
}
|
||||
if !n.visible {
|
||||
return Ok(None);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None),
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n),
|
||||
NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => {
|
||||
let members = n.editing_children();
|
||||
if *clip {
|
||||
let Some((first, rest)) = members.split_first() else { return Ok(None) };
|
||||
let Some(clip) = self.clip_region(first)? else { return Ok(None) };
|
||||
let mut parts = vec![];
|
||||
for c in rest {
|
||||
parts.extend(self.node(c, depth + 1)?);
|
||||
}
|
||||
Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) }))
|
||||
} else {
|
||||
let mut parts = vec![];
|
||||
for c in members {
|
||||
parts.extend(self.node(c, depth + 1)?);
|
||||
}
|
||||
Ok(union(parts))
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
let (w, h) = (f64::from(im.width), f64::from(im.height));
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p);
|
||||
self.polygon(&corners)
|
||||
}
|
||||
// An instance of a symbol the document doesn't have draws nothing.
|
||||
NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) {
|
||||
Some(art) => self.node(&art, depth + 1),
|
||||
None => Ok(None),
|
||||
},
|
||||
NodeKind::PlacedDocument(p) => {
|
||||
// The art of the file it shows; a file that can't be read shows its preview in its
|
||||
// box, which may be rotated.
|
||||
if vectorcraft_doc::placed_document::read_cached(p).is_none() {
|
||||
return self.polygon(&p.corners());
|
||||
}
|
||||
let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n);
|
||||
if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) {
|
||||
self.polygon(&p.corners())
|
||||
} else {
|
||||
self.node(&art, depth + 1)
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
|
||||
// The art a live object draws, as the exporters write it; an object that stays
|
||||
// as it is is measured by its box.
|
||||
let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n);
|
||||
if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The region a clipping path `first` clips to, whatever its own visibility: the region the
|
||||
/// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members,
|
||||
/// an image's frame). `None` when it clips to nothing, and the clipped art is hidden.
|
||||
fn clip_region(&mut self, first: &Node) -> Result<Option<Region>, String> {
|
||||
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) };
|
||||
let mut polys = self.polys(outline)?;
|
||||
// A region is filled: an open subpath clips as if closed.
|
||||
polys.iter_mut().for_each(|p| p.closed = true);
|
||||
Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule }))
|
||||
}
|
||||
|
||||
/// The region of the objects `ids` together, each within the clipping paths of the clip groups
|
||||
/// and layers around it (an object selected inside a clip group shows only within its clipping
|
||||
/// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip
|
||||
/// group), `None` when they show no outlines.
|
||||
pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result<Option<Region>, String> {
|
||||
let doc = self.doc;
|
||||
let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
|
||||
// Every object's place in the tree, found in one walk.
|
||||
let places = doc.index_paths(ids.iter().copied());
|
||||
let mut paths = vec![];
|
||||
for id in ids {
|
||||
let Some(mut path) = places.get(id).cloned() else { continue };
|
||||
// A clipping path selected on its own stands for the art it shows: its clip group.
|
||||
while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) {
|
||||
if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) {
|
||||
break;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
// An object selected twice, or inside another selected object, is measured once, with it.
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
let mut kept: Vec<Vec<usize>> = vec![];
|
||||
for path in paths {
|
||||
if !kept.last().is_some_and(|k| path.starts_with(k)) {
|
||||
kept.push(path);
|
||||
}
|
||||
}
|
||||
let mut parts = vec![];
|
||||
'ids: for path in &kept {
|
||||
let Some(n) = doc.node_at(path) else { continue };
|
||||
let Some(mut region) = self.read(&[n])? else { continue };
|
||||
// The containers around the object, innermost first.
|
||||
let mut child = n.id;
|
||||
for depth in (1..path.len()).rev() {
|
||||
let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break };
|
||||
// Art in a hidden group or layer isn't shown.
|
||||
if !container.visible {
|
||||
continue 'ids;
|
||||
}
|
||||
if clips(container)
|
||||
&& let Some(first) = container.editing_children().first().filter(|f| f.id != child)
|
||||
{
|
||||
let Some(clip) = self.clip_region(first)? else { continue 'ids };
|
||||
region = Region::Clip { clip: Box::new(clip), content: Box::new(region) };
|
||||
}
|
||||
child = container.id;
|
||||
}
|
||||
parts.push(region);
|
||||
}
|
||||
Ok(union(parts))
|
||||
}
|
||||
|
||||
/// A path, compound path or type as its outlines.
|
||||
fn shape(&mut self, n: &Node) -> Result<Option<Region>, String> {
|
||||
let Some((path, rule)) = node_path(n) else { return Ok(None) };
|
||||
let polys = self.polys(path.to_bezpath())?;
|
||||
Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule }))
|
||||
}
|
||||
|
||||
/// The box of an object whose outlines can't be read.
|
||||
fn frame(&mut self, n: &Node) -> Result<Option<Region>, String> {
|
||||
match n.geometric_bounds_with(&self.doc.symbol_boxes()) {
|
||||
Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed polygon through `points`.
|
||||
fn polygon(&mut self, points: &[Point]) -> Result<Option<Region>, String> {
|
||||
let poly = poly(points.to_vec(), true);
|
||||
self.count(poly.iter().map(|p| p.points.len()).sum())?;
|
||||
Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero }))
|
||||
}
|
||||
|
||||
fn count(&mut self, n: usize) -> Result<(), String> {
|
||||
self.points = self.points.saturating_add(n);
|
||||
if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The subpaths of `path` followed in straight segments.
|
||||
fn polys(&mut self, path: BezPath) -> Result<Vec<Poly>, String> {
|
||||
let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize));
|
||||
let left = MAX_POINTS.saturating_sub(self.points);
|
||||
// Past the budget no further curve is followed (one curve gives at most
|
||||
// `MAX_FLATTEN_PER_CURVE` points), and the read fails below.
|
||||
let elements = path.into_iter().take_while(|_| points.get() <= left);
|
||||
vectorcraft_geom::flatten(elements, TOLERANCE, |el| {
|
||||
points.set(points.get() + 1);
|
||||
if points.get() > left {
|
||||
return;
|
||||
}
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
out.extend(poly(std::mem::take(&mut current), false));
|
||||
current.push(p);
|
||||
}
|
||||
PathEl::LineTo(p) => current.push(p),
|
||||
PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)),
|
||||
PathEl::QuadTo(..) | PathEl::CurveTo(..) => {}
|
||||
}
|
||||
});
|
||||
if points.get() > left {
|
||||
return Err(too_many());
|
||||
}
|
||||
out.extend(poly(current, false));
|
||||
self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn too_many() -> String {
|
||||
format!("the selected art has too many outline points to measure (more than {MAX_POINTS})")
|
||||
}
|
||||
|
||||
/// A subpath through `points`, `None` for fewer than two points or one that isn't finite.
|
||||
fn poly(points: Vec<Point>, closed: bool) -> Option<Poly> {
|
||||
if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let first = *points.first()?;
|
||||
let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
|
||||
Some(Poly { points, closed, bounds })
|
||||
}
|
||||
|
||||
/// One region of `parts`, `None` when there are none.
|
||||
fn union(mut parts: Vec<Region>) -> Option<Region> {
|
||||
match parts.len() {
|
||||
0 => None,
|
||||
1 => parts.pop(),
|
||||
_ => Some(Region::Union(parts)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y.
|
||||
fn uv(p: Point, vertical: bool) -> (f64, f64) {
|
||||
if vertical { (p.y, p.x) } else { (p.x, p.y) }
|
||||
}
|
||||
|
||||
/// Sorts `spans` and joins those that overlap or touch.
|
||||
fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
|
||||
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
|
||||
for (a, b) in spans {
|
||||
match out.last_mut() {
|
||||
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||
_ => out.push((a, b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where sorted, joined spans `a` and `b` overlap.
|
||||
fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
let (mut i, mut j, mut out) = (0, 0, vec![]);
|
||||
while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) {
|
||||
let (lo, hi) = (a0.max(b0), a1.min(b1));
|
||||
if lo <= hi {
|
||||
out.push((lo, hi));
|
||||
}
|
||||
if a1 < b1 {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across`
|
||||
/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the
|
||||
/// line counts as past it.
|
||||
fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> {
|
||||
let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical));
|
||||
((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 }))
|
||||
}
|
||||
|
||||
/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted
|
||||
/// along it.
|
||||
fn filled(crossings: impl IntoIterator<Item = (f64, i32)>, rule: FillRule) -> Vec<(f64, f64)> {
|
||||
let (mut winding, mut start, mut out) = (0, 0.0, vec![]);
|
||||
let inside = |w: i32| match rule {
|
||||
FillRule::NonZero => w != 0,
|
||||
FillRule::EvenOdd => w % 2 != 0,
|
||||
};
|
||||
for (u, dir) in crossings {
|
||||
let was = inside(winding);
|
||||
winding += dir;
|
||||
match (was, inside(winding)) {
|
||||
(false, true) => start = u,
|
||||
(true, false) => out.push((start, u)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
merged(out)
|
||||
}
|
||||
|
||||
/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the
|
||||
/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes
|
||||
/// are numbered in the order [`Region::shapes`] lists them.
|
||||
pub(super) type Crossings = Vec<Vec<(usize, f64, i32)>>;
|
||||
|
||||
/// Where the shapes of each part of a region are numbered among its [`Crossings`].
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Layout {
|
||||
Shape,
|
||||
/// The number of each part's first shape, from the union's first, and the parts' layouts.
|
||||
Union {
|
||||
starts: Vec<usize>,
|
||||
parts: Vec<Layout>,
|
||||
},
|
||||
/// The clipping path's shapes come first.
|
||||
Clip {
|
||||
clip_shapes: usize,
|
||||
clip: Box<Layout>,
|
||||
content: Box<Layout>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// The layout of `region` and how many shapes it has.
|
||||
pub(super) fn of(region: &Region) -> (Layout, usize) {
|
||||
match region {
|
||||
Region::Shape { .. } => (Layout::Shape, 1),
|
||||
Region::Union(parts) => {
|
||||
let (mut starts, mut layouts, mut n) = (vec![], vec![], 0);
|
||||
for r in parts {
|
||||
let (l, k) = Layout::of(r);
|
||||
starts.push(n);
|
||||
layouts.push(l);
|
||||
n += k;
|
||||
}
|
||||
(Layout::Union { starts, parts: layouts }, n)
|
||||
}
|
||||
Region::Clip { clip, content } => {
|
||||
let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content));
|
||||
(Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs
|
||||
/// past [`MAX_NEAR`] crossings in all.
|
||||
pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result<Crossings, String> {
|
||||
let mut shapes = vec![];
|
||||
region.shapes(&mut shapes);
|
||||
let at = |k: usize| v0 + step * (k as f64 + 1.0);
|
||||
// The lines a segment may cross: those from just below its lower end to its higher end.
|
||||
let candidates = |(a, b): Segment| {
|
||||
let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1);
|
||||
if step <= 0.0 || va == vb {
|
||||
return 0..0;
|
||||
}
|
||||
let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64);
|
||||
let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64);
|
||||
(first as usize)..(end as usize)
|
||||
};
|
||||
let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::<Vec<_>>();
|
||||
let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len()));
|
||||
if total > MAX_NEAR {
|
||||
return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into());
|
||||
}
|
||||
let mut out: Crossings = vec![vec![]; count];
|
||||
for (i, (polys, _)) in shapes.iter().enumerate() {
|
||||
for (a, b) in closed(polys) {
|
||||
for k in candidates((a, b)) {
|
||||
if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) {
|
||||
line.push((i, u, dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for line in &mut out {
|
||||
line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c,
|
||||
/// along y) inside the region's area, sorted and joined.
|
||||
pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> {
|
||||
match self {
|
||||
Region::Shape { polys, rule } => {
|
||||
let mut crossings: Vec<(f64, i32)> = vec![];
|
||||
for p in polys.iter().filter(|p| p.closed) {
|
||||
let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) };
|
||||
if c >= lo && c <= hi {
|
||||
crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c)));
|
||||
}
|
||||
}
|
||||
crossings.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
filled(crossings, *rule)
|
||||
}
|
||||
Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()),
|
||||
Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The region's shapes with their fill rules, in a fixed order.
|
||||
fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) {
|
||||
match self {
|
||||
Region::Shape { polys, rule } => out.push((polys, *rule)),
|
||||
Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)),
|
||||
Region::Clip { clip, content } => {
|
||||
clip.shapes(out);
|
||||
content.shapes(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the
|
||||
/// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts
|
||||
/// the line crosses are visited: a part it doesn't cross has no stretch inside its area.
|
||||
pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> {
|
||||
if line.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
match (self, layout) {
|
||||
(Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule),
|
||||
(Region::Union(parts), Layout::Union { starts, parts: layouts }) => {
|
||||
let (mut out, mut rest) = (vec![], line);
|
||||
while let Some(&(id, ..)) = rest.first() {
|
||||
// The part whose shapes the next crossing is on, and its crossings.
|
||||
let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1);
|
||||
let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break };
|
||||
let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s);
|
||||
let n = rest.partition_point(|c| c.0 < end);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let (mine, after) = rest.split_at(n);
|
||||
out.extend(r.spans_on(l, mine, base + start));
|
||||
rest = after;
|
||||
}
|
||||
merged(out)
|
||||
}
|
||||
(Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => {
|
||||
let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes));
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes))
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` inside the region's area or on one of `segments`, the outlines it shows?
|
||||
fn contains(&self, p: Point, segments: &[Segment]) -> bool {
|
||||
const EPS: f64 = 1e-7;
|
||||
self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS)
|
||||
}
|
||||
|
||||
/// The pieces of outline the region shows: every outline of a shape and of a group's members,
|
||||
/// and in a clip group the members' outlines inside the clipping path and the clipping path's
|
||||
/// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`].
|
||||
pub(super) fn outline(&self) -> Result<Vec<Segment>, String> {
|
||||
match self {
|
||||
Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()),
|
||||
Region::Union(parts) => {
|
||||
let mut out = vec![];
|
||||
for r in parts {
|
||||
out.extend(r.outline()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Region::Clip { clip, content } => {
|
||||
// Each side's area is bounded by the outlines it shows, so the pieces are cut and
|
||||
// tested against those, never against art a nested clip hides.
|
||||
let (clip_shown, content_shown) = (clip.outline()?, content.outline()?);
|
||||
let mut work = 0;
|
||||
let mut out = within(&content_shown, clip, &clip_shown, &mut work)?;
|
||||
out.extend(within(&clip_shown, content, &content_shown, &mut work)?);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn too_complex_clip() -> String {
|
||||
"a clip group in the selection has too many outline segments to measure".into()
|
||||
}
|
||||
|
||||
/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross
|
||||
/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested
|
||||
/// for being inside, adds the region's segment count to `work`, and the measure errs past
|
||||
/// [`MAX_PAIRS`].
|
||||
fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result<Vec<Segment>, String> {
|
||||
let mut out = vec![];
|
||||
let charge = |work: &mut usize| {
|
||||
*work = work.saturating_add(region_segments.len().max(1));
|
||||
if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) }
|
||||
};
|
||||
for &(a, b) in segments {
|
||||
charge(work)?;
|
||||
let mut cuts = vec![0.0, 1.0];
|
||||
let seg_box = Rect::from_points(a, b);
|
||||
for &(c, d) in region_segments {
|
||||
if touches(seg_box, Rect::from_points(c, d)) {
|
||||
cuts.extend(crossing(a, b, c, d));
|
||||
}
|
||||
}
|
||||
cuts.sort_by(f64::total_cmp);
|
||||
cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
|
||||
let at = |t: f64| a + (b - a) * t;
|
||||
for w in cuts.windows(2) {
|
||||
let (t0, t1) = (w[0], w[1]);
|
||||
if t1 - t0 < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
charge(work)?;
|
||||
if region.contains(at((t0 + t1) / 2.0), region_segments) {
|
||||
out.push((at(t0), at(t1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Do boxes `a` and `b` overlap or touch?
|
||||
fn touches(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the
|
||||
/// shared stretch when the two lie on one line.
|
||||
fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec<f64> {
|
||||
let (r, s) = (b - a, d - c);
|
||||
let denom = r.cross(s);
|
||||
let qp = c - a;
|
||||
let len2 = r.hypot2();
|
||||
if len2 == 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) {
|
||||
// Parallel: the ends of c→d on a→b when the two lie on one line.
|
||||
if qp.cross(r).abs() > 1e-9 * len2.sqrt() {
|
||||
return vec![];
|
||||
}
|
||||
return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect();
|
||||
}
|
||||
let t = qp.cross(s) / denom;
|
||||
let u = qp.cross(r) / denom;
|
||||
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] }
|
||||
}
|
||||
|
||||
/// The distance from `p` to segment a→b.
|
||||
fn distance(p: Point, a: Point, b: Point) -> f64 {
|
||||
let ab = b - a;
|
||||
let len2 = ab.hypot2();
|
||||
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p - (a + ab * t)).hypot()
|
||||
}
|
||||
|
||||
/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line
|
||||
/// passes farther away. With no gap it is where the segment meets the line.
|
||||
fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> {
|
||||
let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical));
|
||||
if gap <= 0.0 {
|
||||
if v0 == v1 {
|
||||
return (v0 == c).then_some((u0.min(u1), u0.max(u1)));
|
||||
}
|
||||
if c < v0.min(v1) || c > v0.max(v1) {
|
||||
return None;
|
||||
}
|
||||
let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0);
|
||||
return Some((u, u));
|
||||
}
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut take = |x: f64, y: f64| {
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(y);
|
||||
};
|
||||
// The round ends.
|
||||
for (u, v) in [(u0, v0), (u1, v1)] {
|
||||
let dv = (c - v).abs();
|
||||
if dv <= gap {
|
||||
let h = (gap * gap - dv * dv).max(0.0).sqrt();
|
||||
take(u - h, u + h);
|
||||
}
|
||||
}
|
||||
// The band along the segment: points whose foot falls on it, within the gap of it.
|
||||
let (du, dv) = (u1 - u0, v1 - v0);
|
||||
let len = du.hypot(dv);
|
||||
if len > 0.0 {
|
||||
// Each condition is linear in the position x along the line: k·x + m ≥ 0.
|
||||
let mut range = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let mut keep = |k: f64, m: f64| {
|
||||
if k == 0.0 {
|
||||
if m < 0.0 {
|
||||
range = (1.0, 0.0);
|
||||
}
|
||||
} else if k > 0.0 {
|
||||
range.0 = range.0.max(-m / k);
|
||||
} else {
|
||||
range.1 = range.1.min(-m / k);
|
||||
}
|
||||
};
|
||||
// The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0.
|
||||
keep(du, -u0 * du + (c - v0) * dv);
|
||||
// The foot at or before b.
|
||||
keep(-du, u0 * du - (c - v0) * dv + len * len);
|
||||
// Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len.
|
||||
let cross0 = -u0 * dv - (c - v0) * du;
|
||||
keep(-dv, -cross0 + gap * len);
|
||||
keep(dv, cross0 + gap * len);
|
||||
if range.0 <= range.1 {
|
||||
take(range.0, range.1);
|
||||
}
|
||||
}
|
||||
(lo <= hi).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// How many weeding lines run each way.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Counts {
|
||||
pub horizontal: usize,
|
||||
pub vertical: usize,
|
||||
}
|
||||
|
||||
/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that
|
||||
/// divide it evenly, each from one side of the frame to the other, left out where it is inside the
|
||||
/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each
|
||||
/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline
|
||||
/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's
|
||||
/// outlines with the lines one way, or past `max_pieces` pieces.
|
||||
pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result<Vec<Segment>, String> {
|
||||
let mut out = vec![];
|
||||
let (layout, _) = Layout::of(region);
|
||||
for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] {
|
||||
let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical));
|
||||
let step = (v1 - v0) / (count as f64 + 1.0);
|
||||
// A frame with no height (for horizontal lines) or no width has no waste to divide.
|
||||
if step <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at
|
||||
// v0 + k·step.
|
||||
let reach = |&(a, b): &Segment| {
|
||||
let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1);
|
||||
let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap);
|
||||
let first = ((lo - v0) / step).ceil().max(1.0);
|
||||
let last = ((hi - v0) / step).floor().min(count as f64);
|
||||
if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 }
|
||||
};
|
||||
// The outlines near each line, found once for all of them, by index into `outline`.
|
||||
let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len()));
|
||||
if pairs > MAX_NEAR {
|
||||
return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into());
|
||||
}
|
||||
let mut near_line: Vec<Vec<usize>> = vec![vec![]; count];
|
||||
for (j, s) in outline.iter().enumerate() {
|
||||
for k in reach(s) {
|
||||
if let Some(list) = near_line.get_mut(k) {
|
||||
list.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
let crossed = crossings(region, vertical, v0, step, count)?;
|
||||
for (i, segments) in near_line.iter().enumerate() {
|
||||
let c = v0 + step * (i as f64 + 1.0);
|
||||
let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0);
|
||||
blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap)));
|
||||
let mut from = u0;
|
||||
let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) };
|
||||
for (a, b) in merged(blocked) {
|
||||
if a - from >= MIN_PIECE && a <= u1 {
|
||||
out.push((point(from), point(a.min(u1))));
|
||||
}
|
||||
from = from.max(b);
|
||||
}
|
||||
if u1 - from >= MIN_PIECE {
|
||||
out.push((point(from), point(u1)));
|
||||
}
|
||||
if out.len() > max_pieces {
|
||||
return Err(format!("the weeding lines would be more than {max_pieces} pieces"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it
|
||||
//! that stop at the art's outlines.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
|
||||
|
||||
use super::super::super::super::pathops::text_outline;
|
||||
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 line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
/// Runs Weed Box and Lines with `p` and gives the group it made.
|
||||
fn weed(s: &mut Session, p: Value) -> (Value, Node) {
|
||||
let r = s.execute("addon.cutting.weed", &p).unwrap();
|
||||
let g = node(s, id_of(&r));
|
||||
(r, g)
|
||||
}
|
||||
|
||||
/// The weed box's rectangle in group `g`, if it has one.
|
||||
fn weed_box(g: &Node) -> Option<Rect> {
|
||||
g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap())
|
||||
}
|
||||
|
||||
/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt.
|
||||
fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> {
|
||||
let r = |v: f64| (v * 1000.0).round() / 1000.0;
|
||||
g.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c.name.is_none())
|
||||
.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 weeding line is one straight segment");
|
||||
((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y)))
|
||||
}
|
||||
other => panic!("not a path: {other:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_weed_box_and_lines_go_around_a_rectangle() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let (r, g) = weed(&mut s, json!({"margin": 10}));
|
||||
assert_eq!(g.name.as_deref(), Some("Weeding"));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0)));
|
||||
// One line each way through the middle, left out over the art.
|
||||
assert_eq!(
|
||||
lines(&g),
|
||||
[((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))]
|
||||
);
|
||||
assert_eq!(r["lines"], json!(4));
|
||||
assert_eq!(r["box"], json!(weed_box_id(&g)));
|
||||
// Stroked in Registration with no fill, selected, as one undo step that takes it all away.
|
||||
for c in g.children().unwrap() {
|
||||
assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill");
|
||||
assert!(c.appearance.stroke_paint().is_registration());
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g.id]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g.id).is_none());
|
||||
}
|
||||
|
||||
fn weed_box_id(g: &Node) -> u64 {
|
||||
g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() {
|
||||
let mut s = session();
|
||||
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0);
|
||||
select(&mut s, &[outer, inner]);
|
||||
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
// Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a
|
||||
// hole.
|
||||
let same_way = |rule: FillRule| {
|
||||
move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> {
|
||||
let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") };
|
||||
*r = rule;
|
||||
let area = |n: &Node| match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.subpaths[0].signed_area(),
|
||||
_ => panic!("not a path"),
|
||||
};
|
||||
let outer = area(&children[0]).signum();
|
||||
for child in children.iter_mut() {
|
||||
if area(child).signum() != outer
|
||||
&& let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind
|
||||
{
|
||||
path.reverse();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap();
|
||||
select(&mut s, &[c]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The waste in the hole is cut too; the ring itself is not.
|
||||
assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]);
|
||||
// With the non-zero rule the inner subpath fills, and no line crosses it.
|
||||
s.edit("non-zero", same_way(FillRule::NonZero)).unwrap();
|
||||
select(&mut s, &[c]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_keeps_the_lines_away_from_the_outline() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0)));
|
||||
assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]);
|
||||
// The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of
|
||||
// 6, stops √11 pt short of the corner's height.
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0);
|
||||
select(&mut s, &[a, b]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0)));
|
||||
assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_with_no_height_gets_no_lines_across_it() {
|
||||
// A horizontal line with no margin: the box is as flat as the line, and no weeding line runs
|
||||
// along it.
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]);
|
||||
let n = s.doc().unwrap().doc.node_count();
|
||||
assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add");
|
||||
select(&mut s, &[l]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0)));
|
||||
assert_eq!(lines(&g), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_path_stops_a_line_where_it_crosses() {
|
||||
let mut s = session();
|
||||
line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2}));
|
||||
assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_measured_by_its_glyph_outlines() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap());
|
||||
let glyphs = match &node(&s, t).kind {
|
||||
NodeKind::Text(t) => text_outline(t),
|
||||
_ => panic!("not type"),
|
||||
};
|
||||
let ink = glyphs.bounds().unwrap();
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"}));
|
||||
let b = weed_box(&g).unwrap();
|
||||
for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] {
|
||||
assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}");
|
||||
}
|
||||
// No piece of line runs over a glyph: each piece's middle is outside the ink.
|
||||
let shape = glyphs.to_bezpath();
|
||||
let pieces = lines(&g);
|
||||
assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}");
|
||||
for ((x0, y0), (x1, y1)) in pieces {
|
||||
let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_group_is_measured_by_what_shows() {
|
||||
let mut s = session();
|
||||
let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0);
|
||||
let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
select(&mut s, &[big, clip]);
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0)));
|
||||
assert_eq!(lines(&g).len(), 4);
|
||||
// Art that shows only partly: the clip path's edge where the art is, and the art's edge
|
||||
// inside the clip.
|
||||
let mut s = session();
|
||||
let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0);
|
||||
let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
select(&mut s, &[art, clip]);
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
|
||||
assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_as_the_clipping_path_clips_by_its_members() {
|
||||
// Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a
|
||||
// group, as this one does.
|
||||
let mut s = session();
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let id = s
|
||||
.edit("clip group", |d, sel| {
|
||||
let mut r = |x: f64, w: f64, y: f64, h: f64| {
|
||||
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default()))
|
||||
};
|
||||
let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0));
|
||||
let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b]));
|
||||
let mut g = Node::group(d.alloc_id(), vec![clip, art]);
|
||||
if let NodeKind::Group { clip, .. } = &mut g.kind {
|
||||
*clip = true;
|
||||
}
|
||||
let id = g.id;
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[id]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The art shows through both rectangles, and the line is cut between them too.
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0)));
|
||||
assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_near_too_many_outline_segments_are_refused() {
|
||||
let mut s = session();
|
||||
// A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near
|
||||
// every segment.
|
||||
let mut zigzag = BezPath::new();
|
||||
zigzag.move_to((100.0, 100.0));
|
||||
for i in 1..=12_000 {
|
||||
zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 }));
|
||||
}
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let id = s
|
||||
.edit("zigzag", |d, sel| {
|
||||
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default());
|
||||
let id = n.id;
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[id]);
|
||||
let n = s.doc().unwrap().doc.node_count();
|
||||
let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err();
|
||||
assert!(e.to_string().contains("too many outline segments"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), n);
|
||||
// Fewer lines measure.
|
||||
s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap();
|
||||
}
|
||||
|
||||
/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it.
|
||||
fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId {
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let id = s
|
||||
.edit("clip group", |d, sel| {
|
||||
let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default()));
|
||||
let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default()));
|
||||
let mut g = Node::group(d.alloc_id(), vec![clip, art]);
|
||||
if let NodeKind::Group { clip, .. } = &mut g.kind {
|
||||
*clip = true;
|
||||
}
|
||||
let id = g.id;
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(s, &[id]);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_clipping_path_clips_as_if_closed() {
|
||||
let mut s = session();
|
||||
let mut v = BezPath::new();
|
||||
v.move_to((100.0, 100.0));
|
||||
v.line_to((150.0, 200.0));
|
||||
v.line_to((200.0, 100.0));
|
||||
clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0)));
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The art shows inside the triangle the V closes; the line stops at its sides.
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0)));
|
||||
assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_a_nested_clip_hides_adds_no_outline() {
|
||||
// A clip group, clipping path A, holding a clip group whose clipping path B shows a small
|
||||
// rectangle and hides a line that runs along A's bottom edge.
|
||||
let mut s = session();
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let id = s
|
||||
.edit("nested clips", |d, sel| {
|
||||
let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default()));
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 200.0));
|
||||
l.line_to((400.0, 200.0));
|
||||
let (b, shown, hidden) = (
|
||||
path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))),
|
||||
path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))),
|
||||
path(PathData::from_bezpath(&l)),
|
||||
);
|
||||
let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0)));
|
||||
let clipped = |mut g: Node| {
|
||||
if let NodeKind::Group { clip, .. } = &mut g.kind {
|
||||
*clip = true;
|
||||
}
|
||||
g
|
||||
};
|
||||
let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden])));
|
||||
let outer = clipped(Node::group(d.alloc_id(), vec![a, inner]));
|
||||
let id = outer.id;
|
||||
d.insert(parent, usize::MAX, outer)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[id]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_selected_inside_a_clip_group_is_measured_by_what_shows() {
|
||||
// The rectangle runs far outside the clipping path; selected on its own, it still shows only
|
||||
// inside it.
|
||||
let mut s = session();
|
||||
let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0));
|
||||
let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)));
|
||||
let art = node(&s, g).children().unwrap()[1].id;
|
||||
select(&mut s, &[art]);
|
||||
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
|
||||
assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]);
|
||||
// The clipping path selected on its own is measured by the art it shows.
|
||||
let clip_path = node(&s, g).children().unwrap()[0].id;
|
||||
select(&mut s, &[clip_path]);
|
||||
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
|
||||
// With the art selected too, or the group, the group is measured once.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len();
|
||||
assert_eq!(outline(&[clip_path, art]), outline(&[g]));
|
||||
assert_eq!(outline(&[g, art, g]), outline(&[g]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_in_a_hidden_group_is_left_out() {
|
||||
let mut s = session();
|
||||
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0);
|
||||
select(&mut s, &[a, b]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
s.edit("hide", |d, _| {
|
||||
if let Some(n) = d.node_mut(g) {
|
||||
n.visible = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[a]);
|
||||
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows");
|
||||
select(&mut s, &[a, shown]);
|
||||
let (_, w) = weed(&mut s, json!({"margin": 10}));
|
||||
assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_outlines_crossing_too_often_are_refused() {
|
||||
// A clipping path whose 700 edges each run across the art, and open art whose 700 segments
|
||||
// each run down across the clipping path: every pair crosses, and each of the 490,000 pieces
|
||||
// of art between crossings would be tested against all 700 edges.
|
||||
let zigzag = |across: bool| {
|
||||
let mut p = BezPath::new();
|
||||
for i in 0..=700 {
|
||||
let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 });
|
||||
let pt = if across { (side, along) } else { (along, side) };
|
||||
if i == 0 { p.move_to(pt) } else { p.line_to(pt) }
|
||||
}
|
||||
p
|
||||
};
|
||||
let mut clip = zigzag(true);
|
||||
clip.close_path();
|
||||
let mut s = session();
|
||||
clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false)));
|
||||
let n = s.doc().unwrap().doc.node_count();
|
||||
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("too many outline segments"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), n);
|
||||
}
|
||||
|
||||
/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the
|
||||
/// lines, match those found by measuring the whole art along that line.
|
||||
#[test]
|
||||
fn indexed_crossings_find_the_same_stretches() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0);
|
||||
select(&mut s, &[outer, inner]);
|
||||
ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()));
|
||||
let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0);
|
||||
let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap());
|
||||
select(&mut s, &[big, window]);
|
||||
ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()));
|
||||
ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap()));
|
||||
ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)]));
|
||||
let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0);
|
||||
let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap());
|
||||
select(&mut s, &[a, b]);
|
||||
ids.push(id_of(&s.execute("object.group", &json!({})).unwrap()));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect();
|
||||
let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap();
|
||||
let (layout, _) = super::geometry::Layout::of(®ion);
|
||||
let count = 97;
|
||||
for vertical in [false, true] {
|
||||
let (v0, v1) = (30.0, 400.0);
|
||||
let step = (v1 - v0) / (count as f64 + 1.0);
|
||||
let crossed = super::geometry::crossings(®ion, vertical, v0, step, count).unwrap();
|
||||
assert_eq!(crossed.len(), count);
|
||||
let mut hit = 0;
|
||||
for (k, line) in crossed.iter().enumerate() {
|
||||
let c = v0 + step * (k as f64 + 1.0);
|
||||
let want = region.spans(vertical, c);
|
||||
hit += usize::from(!want.is_empty());
|
||||
assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}");
|
||||
}
|
||||
assert!(hit > count / 2, "most lines cross the art ({hit})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_nested_too_deep_is_refused() {
|
||||
// A rectangle inside `levels` groups, each holding the next. A document keeps none this deep,
|
||||
// but the art symbol instances and live objects draw nests further.
|
||||
let doc = vectorcraft_doc::Document::new(800.0, 600.0);
|
||||
let nested = |levels: usize| {
|
||||
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default());
|
||||
for i in 0..levels {
|
||||
n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]);
|
||||
}
|
||||
n
|
||||
};
|
||||
let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]);
|
||||
assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some());
|
||||
let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err();
|
||||
assert!(e.contains("nested more than"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_divides_the_box_into_strips_no_wider() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 10.0, 40.0);
|
||||
// The box is 60 pt tall: three strips of 20 pt.
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false}));
|
||||
assert_eq!(weed_box(&g), None);
|
||||
let ys: Vec<f64> = lines(&g).iter().map(|l| l.0.1).collect();
|
||||
assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]);
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 10.0, 40.0);
|
||||
assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lines_take_a_swatch() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1}));
|
||||
for c in g.children().unwrap() {
|
||||
match c.appearance.stroke_paint() {
|
||||
Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")),
|
||||
other => panic!("stroke {other:?}"),
|
||||
}
|
||||
assert_eq!(c.appearance.stroke_width(), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id.
|
||||
fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId {
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let id = s
|
||||
.edit("insert", |d, _| {
|
||||
let n = make(d.alloc_id());
|
||||
let id = n.id;
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(s, &[id]);
|
||||
id
|
||||
}
|
||||
|
||||
/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across.
|
||||
fn diamond_xf() -> Affine {
|
||||
Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0))
|
||||
}
|
||||
|
||||
/// The weeding lines of `g` at height `y` (to 1/1000 pt).
|
||||
fn at(g: &Node, y: f64) -> Vec<(f64, f64)> {
|
||||
lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect()
|
||||
}
|
||||
|
||||
/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the
|
||||
/// first line's left piece ends.
|
||||
fn diamond_weeding(s: &mut Session) -> (Rect, f64) {
|
||||
let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"}));
|
||||
let b = weed_box(&g).unwrap();
|
||||
let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0;
|
||||
let first = at(&g, y);
|
||||
assert_eq!(first.len(), 2, "{first:?}");
|
||||
(b, first[0].1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_is_measured_by_its_frame_as_turned() {
|
||||
let mut s = session();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "image".into(),
|
||||
width: 100,
|
||||
height: 100,
|
||||
xf: diamond_xf(),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im))));
|
||||
let (b, end) = diamond_weeding(&mut s);
|
||||
let half = 50.0 * std::f64::consts::SQRT_2;
|
||||
assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}");
|
||||
// A quarter of the box down, the line meets the diamond's upper left edge, not its bounds.
|
||||
let y = b.y0 + b.height() / 4.0;
|
||||
assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_file_is_measured_by_the_art_it_shows() {
|
||||
let dir = crate::tests_links::Folder::new("weed-placed");
|
||||
// A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when
|
||||
// `hidden`), placed centered on (200, 150) and selected.
|
||||
let place = |s: &mut Session, name: &str, hidden: bool| {
|
||||
let src = dir.file(name);
|
||||
let mut d = vectorcraft_doc::Document::new(60.0, 40.0);
|
||||
let layer = d.layers[0].id;
|
||||
for x in [0.0, 40.0] {
|
||||
let id = d.alloc_id();
|
||||
let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
|
||||
let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look);
|
||||
n.visible = !hidden;
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
}
|
||||
crate::tests_links::write(&src, &vectorcraft_format::save_file(&d));
|
||||
let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap();
|
||||
select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]);
|
||||
};
|
||||
let mut s = session();
|
||||
place(&mut s, "pair.vectorcraft", false);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The rectangles lie at 170..190 and 210..230; the line runs between them too.
|
||||
assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]);
|
||||
// A file read but shown in a box with no size shows nothing, and its box isn't measured.
|
||||
let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap());
|
||||
s.edit("no size", |d, _| {
|
||||
if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
(p.width, p.height) = (0.0, 0.0);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[id]);
|
||||
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("has no outlines"), "{e}");
|
||||
// A file read with nothing showing has no outlines, and its box isn't measured instead.
|
||||
let mut s = session();
|
||||
place(&mut s, "hidden.vectorcraft", true);
|
||||
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("has no outlines"), "{e}");
|
||||
// A file whose art is an instance of a symbol of its own: the instance's art is measured.
|
||||
let src = dir.file("symbol.vectorcraft");
|
||||
let mut d = vectorcraft_doc::Document::new(60.0, 40.0);
|
||||
let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
|
||||
let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(art) });
|
||||
let (layer, id) = (d.layers[0].id, d.alloc_id());
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })).unwrap();
|
||||
crate::tests_links::write(&src, &vectorcraft_format::save_file(&d));
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap();
|
||||
select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10}));
|
||||
let b = weed_box(&g).unwrap();
|
||||
assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() {
|
||||
let mut s = session();
|
||||
let p = vectorcraft_doc::PlacedDocument {
|
||||
link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"),
|
||||
key: "missing".into(),
|
||||
bounding: false,
|
||||
page_box: Default::default(),
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
xf: diamond_xf(),
|
||||
placement: Default::default(),
|
||||
};
|
||||
insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p))));
|
||||
let (b, end) = diamond_weeding(&mut s);
|
||||
let half = 50.0 * std::f64::consts::SQRT_2;
|
||||
let y = b.y0 + b.height() / 4.0;
|
||||
assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instance_is_measured_by_its_art() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0);
|
||||
let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0);
|
||||
select(&mut s, &[a, b]);
|
||||
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
|
||||
select(&mut s, &[inst]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The line runs between the two rectangles too.
|
||||
assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]);
|
||||
// An instance of a symbol the document doesn't have draws nothing, and has no outlines.
|
||||
s.edit("lose the symbol", |d, _| {
|
||||
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) {
|
||||
*symbol = "Missing".into();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[inst]);
|
||||
let n = s.doc().unwrap().doc.node_count();
|
||||
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("has no outlines"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), n);
|
||||
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
|
||||
select(&mut s, &[inst, shown]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blend_is_measured_by_the_art_it_draws() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0);
|
||||
let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0);
|
||||
let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap());
|
||||
select(&mut s, &[blend]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The step halfway between the two rectangles stops the line too.
|
||||
assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() {
|
||||
let mut s = session();
|
||||
let first = s.doc().unwrap().doc.layers[0].id;
|
||||
let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
|
||||
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
|
||||
let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
|
||||
s.edit("hide", |d, _| {
|
||||
if let Some(n) = d.node_mut(first) {
|
||||
n.visible = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[hidden]);
|
||||
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show");
|
||||
select(&mut s, &[hidden, shown]);
|
||||
let (_, g) = weed(&mut s, json!({"margin": 10}));
|
||||
assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.parent_of(g.id), Some(art));
|
||||
assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top");
|
||||
// A hidden object selected on its own shows nothing.
|
||||
s.edit("hide", |d, _| {
|
||||
if let Some(n) = d.node_mut(below) {
|
||||
n.visible = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[below]);
|
||||
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_goes_outside_a_clip_group_edited_in_isolation() {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)));
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
let art = node(&s, g).children().unwrap()[1].id;
|
||||
select(&mut s, &[art]);
|
||||
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
// The clipping path would hide the lines and the box around the art inside it.
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.parent_of(w.id), Some(layer));
|
||||
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
|
||||
// A plain group edited in isolation inside a clip group: the group goes outside the clip group.
|
||||
let mut s = session();
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let (inner, art) = s
|
||||
.edit("clip group around a 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(120.0, 0.0, 220.0, 300.0)), Appearance::default());
|
||||
let art_id = art.id;
|
||||
let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
||||
let inner_id = inner.id;
|
||||
let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]);
|
||||
if let NodeKind::Group { clip, .. } = &mut g.kind {
|
||||
*clip = true;
|
||||
}
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
Ok((inner_id, art_id))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.isolate", &json!({"id": inner.0})).unwrap();
|
||||
select(&mut s, &[art]);
|
||||
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent);
|
||||
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
|
||||
}
|
||||
|
||||
/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200.
|
||||
fn bars(s: &mut Session, count: u32) -> NodeId {
|
||||
let mut p = BezPath::new();
|
||||
for i in 0..count {
|
||||
let x = 100.0 + f64::from(i) * 2.0;
|
||||
p.move_to((x, 100.0));
|
||||
p.line_to((x, 200.0));
|
||||
}
|
||||
insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_the_most_pieces_of_line_is_refused() {
|
||||
// 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds.
|
||||
let mut s = session();
|
||||
bars(&mut s, 101);
|
||||
let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap();
|
||||
assert_eq!(r["lines"], json!(super::MAX_PIECES));
|
||||
// One bar more makes 1,000 pieces more.
|
||||
let mut s = session();
|
||||
bars(&mut s, 102);
|
||||
let n = s.doc().unwrap().doc.node_count();
|
||||
let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err();
|
||||
assert!(e.to_string().contains("more than 100000 pieces"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_with_more_than_the_most_outline_points_is_refused() {
|
||||
// An open zigzag of `points` points: one subpath, measured by its points.
|
||||
let zigzag = |s: &mut Session, points: u32| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((100.0, 100.0));
|
||||
for i in 1..points {
|
||||
p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
|
||||
}
|
||||
insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()));
|
||||
};
|
||||
let mut s = session();
|
||||
zigzag(&mut s, super::geometry::MAX_POINTS as u32);
|
||||
s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap();
|
||||
let mut s = session();
|
||||
zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1);
|
||||
let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err();
|
||||
assert!(e.to_string().contains("too many outline points"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_and_an_empty_selection_are_refused() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
for p in [
|
||||
json!({"margin": -1}),
|
||||
json!({"gap": "x"}),
|
||||
json!({"count": 1.5}),
|
||||
json!({"count": 1001}),
|
||||
json!({"count": 2, "spacing": 10}),
|
||||
json!({"spacing": 0}),
|
||||
json!({"direction": "diagonal"}),
|
||||
json!({"box": "yes"}),
|
||||
json!({"weight": 0}),
|
||||
json!({"swatch": "no such swatch"}),
|
||||
json!({"swatch": 3}),
|
||||
json!({"box": false, "count": 0}),
|
||||
] {
|
||||
let n = s.doc().unwrap().doc.node_count();
|
||||
assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}");
|
||||
}
|
||||
// The same art with good params is weeded; with nothing selected it is refused.
|
||||
s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
assert!(s.execute("addon.cutting.weed", &json!({})).is_err());
|
||||
}
|
||||
@@ -33,6 +33,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.cutting.bridges",
|
||||
"addon.cutting.weed",
|
||||
"addon.image.removeBackground",
|
||||
"addon.pathtools.addPoints",
|
||||
"addon.pathtools.close",
|
||||
|
||||
@@ -796,6 +796,12 @@ fn structured_junk() {
|
||||
("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})),
|
||||
("addon.pen.nextSegment", json!({"curved": "yes"})),
|
||||
("addon.pen.closeJoin", json!({"ids": [u64::MAX]})),
|
||||
// Cutting Tools add-on.
|
||||
("addon.cutting.weed", json!({"margin": 1e5, "count": 1000, "gap": 1e5, "direction": "both"})),
|
||||
("addon.cutting.weed", json!({"margin": -1, "count": 1.5, "spacing": 0, "gap": f64::MAX, "weight": 1e308})),
|
||||
("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})),
|
||||
// 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"})),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Cutting Tools in the interface: its submenu (Object › Plug-ins › Cutting Tools). Stencil
|
||||
//! Bridges… opens the generic command dialog (`ui.paramDialog`, `dialogs::command`) on the options
|
||||
//! of `addon.cutting.bridges`.
|
||||
//! 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.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
@@ -10,10 +11,21 @@ use crate::menus::Item;
|
||||
pub const BRIDGES: &str = "addon.cutting.bridges";
|
||||
/// Stencil Bridges › Direction: what the command's `direction` takes, by name.
|
||||
pub const BRIDGE_DIRECTIONS: &[(&str, &str)] = &[("Vertical", "vertical"), ("Horizontal", "horizontal"), ("Shortest Crossing", "shortest")];
|
||||
/// Weed Box and Lines' command.
|
||||
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")];
|
||||
|
||||
/// Object › Plug-ins › Cutting Tools.
|
||||
pub fn menu() -> Vec<Item> {
|
||||
vec![Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true}))]
|
||||
vec![
|
||||
Item::Cmd("Stencil Bridges…", BRIDGES, json!({"width": 2, "count": 1, "direction": "vertical", "keepOriginal": true})),
|
||||
Item::Cmd(
|
||||
"Weed Box and Lines…",
|
||||
WEED,
|
||||
json!({"margin": 9, "box": true, "direction": "both", "count": 1, "spacing": 0, "gap": 0, "weight": 0.3, "swatch": "[Registration]"}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -52,3 +52,70 @@ fn stencil_bridges_opens_its_dialog_and_runs_on_ok() {
|
||||
assert_eq!(steps(&app), n + 1);
|
||||
assert!(app.ui.status.contains("no islands"), "{}", app.ui.status);
|
||||
}
|
||||
|
||||
/// Object › Plug-ins › Cutting Tools › Weed Box and Lines… opens the command dialog on its
|
||||
/// defaults with Preview off. With Preview on the canvas shows the weed box and lines while the
|
||||
/// dialog is open; Cancel takes them away with no step of history, and OK keeps them as one step.
|
||||
#[test]
|
||||
fn weed_box_and_lines_previews_cancels_and_runs_on_ok() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).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 == "Weed Box and Lines…").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::WEED.to_string(), "Weed Box and Lines".to_string()));
|
||||
assert_eq!((d.str("direction"), d.bool("box"), d.bool("preview")), ("both".to_string(), true, false));
|
||||
let shown = painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||||
for s in ["Weed Box and Lines", "Margin", "Box", "Direction", "Both", "Count", "Spacing", "Gap", "Weight", "Swatch", "Preview"] {
|
||||
assert!(shown.contains(s), "{s} in {shown}");
|
||||
}
|
||||
// Every field label is translated.
|
||||
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 ["Rand:", "Rahmen:", "Richtung:", "Farbfeld:"] {
|
||||
assert!(german.contains(s), "{s} in {german}");
|
||||
}
|
||||
assert_eq!(count(&app), nodes, "no preview without the checkbox");
|
||||
|
||||
// Preview on: the group with the box and four lines is on the canvas.
|
||||
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 + 6, "previewed: a group, the box and four lines");
|
||||
|
||||
// Cancel: as it was, with no step of history.
|
||||
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");
|
||||
|
||||
// OK: one step.
|
||||
open(&mut app);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(steps(&app), n + 1, "one undo step");
|
||||
assert_eq!(count(&app), nodes + 6);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, start);
|
||||
|
||||
// A Spacing above 0 is used instead of the Count: the 68 pt wide box in strips at most 20 pt
|
||||
// wide takes three vertical lines, each cut in two by the rectangle.
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
open(&mut app);
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
crate::dialogs::set_field(d, "direction".into(), json!("vertical"));
|
||||
crate::dialogs::set_field(d, "spacing".into(), json!(20));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(count(&app), nodes + 8, "a group, the box and six pieces of line");
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
/// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the
|
||||
/// 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 does Image Tools › Remove Background. (Cancel rolls a
|
||||
/// preview back whichever dialog started it.)
|
||||
/// works the same way (#5532), and so do Image Tools › Remove Background and Cutting Tools › Weed
|
||||
/// Box and Lines. (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"),
|
||||
"type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true,
|
||||
"type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::cutting::WEED => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,14 @@ fn call(d: &Dialog) -> (String, Value) {
|
||||
{
|
||||
o.remove("rowGutter");
|
||||
}
|
||||
// Weed Box and Lines: a Spacing above 0 divides the box instead of the Count, and only the one
|
||||
// in use is sent.
|
||||
if d.str("__command") == crate::addons::cutting::WEED
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
let by_spacing = o.get("spacing").and_then(Value::as_f64).is_some_and(|s| s > 0.0);
|
||||
o.remove(if by_spacing { "count" } else { "spacing" });
|
||||
}
|
||||
(d.str("__command"), params)
|
||||
}
|
||||
|
||||
@@ -102,6 +110,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"graph.create" => &["width", "height"],
|
||||
"shape.flare" => &["diameter", "pathLength"],
|
||||
"perspective.grid.set" => &["cell", "distance"],
|
||||
crate::addons::cutting::WEED => &["margin", "spacing", "gap", "weight"],
|
||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||
"text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin", "lineGap"],
|
||||
"type.pathOptions" => &["spacing"],
|
||||
@@ -115,6 +124,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
/// names isn't it (the rest follow, by name).
|
||||
fn order(command: &str) -> &'static [&'static str] {
|
||||
match command {
|
||||
crate::addons::cutting::WEED => &["margin", "box", "direction", "count", "spacing", "gap", "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"],
|
||||
@@ -135,6 +145,7 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
("text.areaOptions", "fit") => Some(AREA_FIT),
|
||||
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
|
||||
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
|
||||
(crate::addons::cutting::WEED, "direction") => Some(crate::addons::cutting::WEED_DIRECTIONS),
|
||||
("graph.setType", "type") => Some(GRAPH_KINDS.as_slice()),
|
||||
("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS),
|
||||
("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH),
|
||||
|
||||
@@ -6616,4 +6616,9 @@ breakText Glyphs Глифи
|
||||
Stencil Bridges… Мостчета на шаблона…
|
||||
Stencil Bridges Мостчета на шаблона
|
||||
Shortest Crossing Най-късо пресичане
|
||||
Weed Box and Lines… Рамка и линии за почистване…
|
||||
Weed Box and Lines Рамка и линии за почистване
|
||||
Margin Поле
|
||||
Box Рамка
|
||||
Swatch Образец
|
||||
Direction Посока
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6667,4 +6667,9 @@ shape Open Otvoren
|
||||
Stencil Bridges… Mostići šablona…
|
||||
Stencil Bridges Mostići šablona
|
||||
Shortest Crossing Najkraći prijelaz
|
||||
Weed Box and Lines… Okvir i linije za čišćenje…
|
||||
Weed Box and Lines Okvir i linije za čišćenje
|
||||
Margin Margina
|
||||
Box Okvir
|
||||
Swatch Uzorak boje
|
||||
Direction Smjer
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6900,3 +6900,8 @@ breakText Glyphs Glifs
|
||||
@msg Some islands could not be joined to the area around the art Algunes illes no s’han pogut unir a l’àrea que envolta la il·lustració
|
||||
@msg `width` must be from {lo} to {hi} pt `width` ha d’estar entre {lo} i {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` ha de ser un nombre enter d’1 a {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Caixa i línies de pelat…
|
||||
Weed Box and Lines Caixa i línies de pelat
|
||||
Margin Marge
|
||||
Box Caixa
|
||||
Swatch Mostra
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6919,3 +6919,8 @@ breakText Glyphs Glyfy
|
||||
@msg Some islands could not be joined to the area around the art Některé ostrůvky nebylo možné připojit k oblasti kolem kresby
|
||||
@msg `width` must be from {lo} to {hi} pt `width` musí být od {lo} do {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musí být celé číslo od 1 do {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Rámeček a linie pro vybírání…
|
||||
Weed Box and Lines Rámeček a linie pro vybírání
|
||||
Margin Okraj
|
||||
Box Rámeček
|
||||
Swatch Vzorek
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6630,4 +6630,9 @@ breakText Glyphs Glyffer
|
||||
Stencil Bridges… Stencilbroer…
|
||||
Stencil Bridges Stencilbroer
|
||||
Shortest Crossing Korteste krydsning
|
||||
Weed Box and Lines… Lugeramme og lugelinjer…
|
||||
Weed Box and Lines Lugeramme og lugelinjer
|
||||
Margin Margen
|
||||
Box Ramme
|
||||
Swatch Farveprøve
|
||||
Direction Retning
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6506,3 +6506,8 @@ breakText Glyphs Glyphen
|
||||
@msg Some islands could not be joined to the area around the art Einige Inseln konnten nicht mit der Fläche um das Bildmaterial verbunden werden
|
||||
@msg `width` must be from {lo} to {hi} pt `width` muss zwischen {lo} und {hi} pt liegen
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` muss eine ganze Zahl von 1 bis {MAX_BRIDGES_PER_ISLAND} sein
|
||||
Weed Box and Lines… Entgitterrahmen und -linien…
|
||||
Weed Box and Lines Entgitterrahmen und -linien
|
||||
Margin Rand
|
||||
Box Rahmen
|
||||
Swatch Farbfeld
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6871,3 +6871,8 @@ breakText Glyphs Γλύφοι
|
||||
@msg Some islands could not be joined to the area around the art Ορισμένες νησίδες δεν ήταν δυνατό να συνδεθούν με την περιοχή γύρω από το γραφικό
|
||||
@msg `width` must be from {lo} to {hi} pt Το `width` πρέπει να είναι από {lo} έως {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} Το `count` πρέπει να είναι ακέραιος από 1 έως {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων…
|
||||
Weed Box and Lines Πλαίσιο και γραμμές αφαίρεσης περισσευμάτων
|
||||
Margin Περιθώριο
|
||||
Box Πλαίσιο
|
||||
Swatch Δείγμα χρώματος
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6862,3 +6862,8 @@ breakText Glyphs Glifos
|
||||
@msg Some islands could not be joined to the area around the art Algunas islas no se pudieron unir al área que rodea la ilustración
|
||||
@msg `width` must be from {lo} to {hi} pt `width` debe estar entre {lo} y {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` debe ser un número entero entre 1 y {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Caja y líneas de pelado…
|
||||
Weed Box and Lines Caja y líneas de pelado
|
||||
Margin Margen
|
||||
Box Caja
|
||||
Swatch Muestra
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6954,3 +6954,8 @@ breakText Glyphs Merkit
|
||||
@msg Some islands could not be joined to the area around the art Joitakin saarekkeita ei voitu yhdistää grafiikkaa ympäröivään alueeseen
|
||||
@msg `width` must be from {lo} to {hi} pt `width`-arvon on oltava {lo}–{hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` on oltava kokonaisluku 1–{MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Karsintakehys ja -viivat…
|
||||
Weed Box and Lines Karsintakehys ja -viivat
|
||||
Margin Marginaali
|
||||
Box Kehys
|
||||
Swatch Värimalli
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6914,3 +6914,8 @@ breakText Glyphs Glyphes
|
||||
@msg Some islands could not be joined to the area around the art Certains îlots n’ont pas pu être reliés à la zone autour de l’illustration
|
||||
@msg `width` must be from {lo} to {hi} pt `width` doit être compris entre {lo} et {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` doit être un nombre entier compris entre 1 et {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Cadre et lignes d’échenillage…
|
||||
Weed Box and Lines Cadre et lignes d’échenillage
|
||||
Margin Marge
|
||||
Box Cadre
|
||||
Swatch Nuance
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -8354,3 +8354,8 @@ theme Appearance अपीयरेंस
|
||||
@msg Some islands could not be joined to the area around the art कुछ आइलैंड आर्टवर्क के आसपास के क्षेत्र से नहीं जोड़े जा सके
|
||||
@msg `width` must be from {lo} to {hi} pt `width` {lo} से {hi} pt तक होना चाहिए
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 से {MAX_BRIDGES_PER_ISLAND} तक पूरी संख्या होना चाहिए
|
||||
Weed Box and Lines… वीड बॉक्स और लाइनें…
|
||||
Weed Box and Lines वीड बॉक्स और लाइनें
|
||||
Margin मार्जिन
|
||||
Box बॉक्स
|
||||
Swatch स्वॉच
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6903,3 +6903,8 @@ breakText Glyphs Glifák
|
||||
@msg Some islands could not be joined to the area around the art Egyes szigeteket nem sikerült összekötni a grafikát körülvevő területtel
|
||||
@msg `width` must be from {lo} to {hi} pt A `width` értéke {lo} és {hi} pt között lehet
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`: 1 és {MAX_BRIDGES_PER_ISLAND} közötti egész szám kell
|
||||
Weed Box and Lines… Gyomláló keret és vonalak…
|
||||
Weed Box and Lines Gyomláló keret és vonalak
|
||||
Margin Margó
|
||||
Box Keret
|
||||
Swatch Színminta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6630,4 +6630,9 @@ breakText Glyphs Glif
|
||||
Stencil Bridges… Jembatan Stensil…
|
||||
Stencil Bridges Jembatan Stensil
|
||||
Shortest Crossing Persilangan terpendek
|
||||
Weed Box and Lines… Kotak dan garis pembersih sisa…
|
||||
Weed Box and Lines Kotak dan garis pembersih sisa
|
||||
Margin Margin
|
||||
Box Kotak
|
||||
Swatch Sampel warna
|
||||
Direction Arah
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2364 and column 5.
|
@@ -6540,3 +6540,8 @@ breakText Glyphs Glifi
|
||||
@msg Some islands could not be joined to the area around the art Non è stato possibile collegare alcune isole all’area intorno alla grafica
|
||||
@msg `width` must be from {lo} to {hi} pt `width` deve essere compreso tra {lo} e {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve essere un numero intero compreso tra 1 e {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Riquadro e linee di spellicolatura…
|
||||
Weed Box and Lines Riquadro e linee di spellicolatura
|
||||
Margin Margine
|
||||
Box Riquadro
|
||||
Swatch Campione
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6605,3 +6605,8 @@ breakText Glyphs 字形
|
||||
@msg Some islands could not be joined to the area around the art 一部のアイランドをアートワークの周囲の領域につなげられませんでした
|
||||
@msg `width` must be from {lo} to {hi} pt `width` は {lo}~{hi} pt の範囲で指定してください
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` には 1 から {MAX_BRIDGES_PER_ISLAND} の整数を指定してください
|
||||
Weed Box and Lines… カス取り枠とライン…
|
||||
Weed Box and Lines カス取り枠とライン
|
||||
Margin マージン
|
||||
Box 枠
|
||||
Swatch スウォッチ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -7033,3 +7033,8 @@ font-script Japanese იაპონური
|
||||
@msg Some islands could not be joined to the area around the art ზოგიერთი კუნძული ვერ დაუკავშირდა ნახატის გარშემო არეს
|
||||
@msg `width` must be from {lo} to {hi} pt `width` უნდა იყოს {lo}-დან {hi} pt-მდე
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` უნდა იყოს მთელი რიცხვი 1-დან {MAX_BRIDGES_PER_ISLAND}-მდე
|
||||
Weed Box and Lines… ნარჩენის მოსაცილებელი ჩარჩო და ხაზები…
|
||||
Weed Box and Lines ნარჩენის მოსაცილებელი ჩარჩო და ხაზები
|
||||
Margin ზღვარი
|
||||
Box ჩარჩო
|
||||
Swatch ფერის ნიმუში
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6501,3 +6501,8 @@ breakText Glyphs 글리프
|
||||
@msg Some islands could not be joined to the area around the art 일부 섬을 아트워크 주변 영역에 연결할 수 없습니다
|
||||
@msg `width` must be from {lo} to {hi} pt `width`는 {lo}에서 {hi}pt 사이여야 합니다
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count`는 1에서 {MAX_BRIDGES_PER_ISLAND} 사이의 정수여야 합니다
|
||||
Weed Box and Lines… 위딩 상자 및 선…
|
||||
Weed Box and Lines 위딩 상자 및 선
|
||||
Margin 여백
|
||||
Box 상자
|
||||
Swatch 견본
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6681,4 +6681,9 @@ breakText Glyphs Glyphs
|
||||
Stencil Bridges… Sjabloonbruggen…
|
||||
Stencil Bridges Sjabloonbruggen
|
||||
Shortest Crossing Kortste oversteek
|
||||
Weed Box and Lines… Wiedkader en wiedlijnen…
|
||||
Weed Box and Lines Wiedkader en wiedlijnen
|
||||
Margin Marge
|
||||
Box Kader
|
||||
Swatch Staal
|
||||
Direction Richting
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6775,3 +6775,8 @@ breakText Glyphs Glify
|
||||
@msg Some islands could not be joined to the area around the art Niektórych wysp nie udało się połączyć z obszarem wokół grafiki
|
||||
@msg `width` must be from {lo} to {hi} pt `width` musi mieścić się w zakresie od {lo} do {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` musi być liczbą całkowitą od 1 do {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Ramka i linie do wybierania…
|
||||
Weed Box and Lines Ramka i linie do wybierania
|
||||
Margin Margines
|
||||
Box Ramka
|
||||
Swatch Próbka
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6992,3 +6992,8 @@ swatch Yellow Green Verde-amarelado
|
||||
@msg Some islands could not be joined to the area around the art Algumas ilhas não puderam ser unidas à área ao redor da arte
|
||||
@msg `width` must be from {lo} to {hi} pt `width` deve estar entre {lo} e {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` deve ser um número inteiro de 1 a {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Caixa e Linhas de Descasque…
|
||||
Weed Box and Lines Caixa e Linhas de Descasque
|
||||
Margin Margem
|
||||
Box Caixa
|
||||
Swatch Amostra
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -7008,3 +7008,8 @@ shape Open Открытый
|
||||
@msg Some islands could not be joined to the area around the art Некоторые островки не удалось соединить с областью вокруг графики
|
||||
@msg `width` must be from {lo} to {hi} pt `width` должно быть от {lo} до {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` должно быть целым числом от 1 до {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Рамка и линии выборки…
|
||||
Weed Box and Lines Рамка и линии выборки
|
||||
Margin Поле
|
||||
Box Рамка
|
||||
Swatch Образец
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -8399,3 +8399,8 @@ digits Hindi Hindi
|
||||
@msg Some islands could not be joined to the area around the art Vissa öar kunde inte förbindas med området runt teckningen
|
||||
@msg `width` must be from {lo} to {hi} pt `width` måste vara från {lo} till {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` måste vara ett heltal från 1 till {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Rensningsram och rensningslinjer…
|
||||
Weed Box and Lines Rensningsram och rensningslinjer
|
||||
Margin Marginal
|
||||
Box Ram
|
||||
Swatch Färgruta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -6875,3 +6875,8 @@ breakText Glyphs Glifler
|
||||
@msg Some islands could not be joined to the area around the art Bazı adalar çizimin çevresindeki alana bağlanamadı
|
||||
@msg `width` must be from {lo} to {hi} pt `width` {lo} ile {hi} pt arasında olmalıdır
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 1 ile {MAX_BRIDGES_PER_ISLAND} arasında bir tam sayı olmalıdır
|
||||
Weed Box and Lines… Ayıklama Kutusu ve Çizgileri…
|
||||
Weed Box and Lines Ayıklama Kutusu ve Çizgileri
|
||||
Margin Kenar Boşluğu
|
||||
Box Kutu
|
||||
Swatch Renk Örneği
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -6520,3 +6520,8 @@ breakText Glyphs Гліфи
|
||||
@msg Some islands could not be joined to the area around the art Деякі острівці не вдалося з’єднати з областю навколо графіки
|
||||
@msg `width` must be from {lo} to {hi} pt `width` має бути від {lo} до {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` має бути цілим числом від 1 до {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Рамка й лінії вибірки…
|
||||
Weed Box and Lines Рамка й лінії вибірки
|
||||
Margin Поле
|
||||
Box Рамка
|
||||
Swatch Зразок
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -6521,3 +6521,8 @@ breakText Glyphs Glyph
|
||||
@msg Some islands could not be joined to the area around the art Không thể nối một số đảo với vùng xung quanh hình vẽ
|
||||
@msg `width` must be from {lo} to {hi} pt `width` phải từ {lo} đến {hi} pt
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` phải là số nguyên từ 1 đến {MAX_BRIDGES_PER_ISLAND}
|
||||
Weed Box and Lines… Khung và đường bóc phần thừa…
|
||||
Weed Box and Lines Khung và đường bóc phần thừa
|
||||
Margin Lề
|
||||
Box Khung
|
||||
Swatch Mẫu màu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -6757,3 +6757,8 @@ breakText Glyphs 字形
|
||||
@msg Some islands could not be joined to the area around the art 部分孤岛无法连接到图稿周围的区域
|
||||
@msg `width` must be from {lo} to {hi} pt `width` 必须介于 {lo} 到 {hi} pt 之间
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必须是 1 到 {MAX_BRIDGES_PER_ISLAND} 之间的整数
|
||||
Weed Box and Lines… 除废框和除废线…
|
||||
Weed Box and Lines 除废框和除废线
|
||||
Margin 边距
|
||||
Box 框
|
||||
Swatch 色板
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6555,3 +6555,8 @@ breakText Glyphs 字符
|
||||
@msg Some islands could not be joined to the area around the art 部分孤島無法連接到圖稿周圍的區域
|
||||
@msg `width` must be from {lo} to {hi} pt `width` 必須介於 {lo} 到 {hi} pt 之間
|
||||
@msg `count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND} `count` 必須是介於 1 到 {MAX_BRIDGES_PER_ISLAND} 的整數
|
||||
Weed Box and Lines… 除廢框與除廢線…
|
||||
Weed Box and Lines 除廢框與除廢線
|
||||
Margin 邊距
|
||||
Box 框
|
||||
Swatch 色票
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+40
-2
@@ -9192,8 +9192,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 command is `addon.cutting.bridges`; in the desktop
|
||||
app it is Object › Plug-ins › Cutting Tools › Stencil Bridges….
|
||||
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….
|
||||
|
||||
### Stencil Bridges
|
||||
|
||||
@@ -9244,6 +9245,43 @@ Crossing) and Keep Original.
|
||||
{"name":"run_command","arguments":{"command":"addon.cutting.bridges","params":{"width":3,"count":2,"direction":"shortest","keepOriginal":false}}}
|
||||
```
|
||||
|
||||
### Weed Box and Lines
|
||||
|
||||
`addon.cutting.weed {margin?, box?, direction?, count?, spacing?, gap?, weight?, swatch?}` adds a weed box around the
|
||||
selected art and weeding lines across the box, so the waste vinyl around the art lifts off in pieces
|
||||
([what a weeding border is](https://help.arlon.com/what-is-a-weeding-border)). The ranges, defaults and rules below are
|
||||
VectorCraft's own. The weed box is a rectangle `margin` points (0 to 100000, default 9) outside the art's bounds;
|
||||
`box: false` leaves it out. The weeding lines run `"horizontal"`, `"vertical"` or `"both"` ways (default `"both"`) from
|
||||
one side of the box to the other and divide it evenly: `count` lines each way (a whole number from 0 to 1000, default 1),
|
||||
or with `spacing` instead, as few lines as keep them at most that far apart. A line is left out over the art's area and
|
||||
within `gap` points of its outlines (0 to 100000, default 0: the lines stop at the outlines), so it ends in several
|
||||
pieces, each a straight path. The lines and the box are stroked `weight` points wide (0.01 to 1000, default 0.3) in the
|
||||
color swatch `swatch` (default `"[Registration]"`), with no fill.
|
||||
|
||||
The art is measured by the outlines a cutter cuts. Closed paths enclose their area by their fill rule, so the hole of a
|
||||
compound path filled even-odd gets lines too, and open paths are lines with no area. Type is measured by its glyph
|
||||
outlines, a clip group by what shows inside its clipping path (an open clipping path clips as if closed), an image by
|
||||
its frame, a placed file by the art it shows (by its box when the file can't be read), a symbol instance by its art (an
|
||||
instance of a symbol the document doesn't have adds nothing) and a live object such as a blend by the art it draws. An
|
||||
object selected inside a clip group is measured by what shows of it, and a clipping path selected on its own as its clip
|
||||
group. Strokes, effects, hidden objects and objects in a hidden group or layer are left out.
|
||||
|
||||
The result is one group named "Weeding" (the box is its member "Weed Box"), 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 group goes on top of
|
||||
the container that holds the outermost clip group around it. With nothing selected, or with a selection that has no
|
||||
outlines, the command fails. It also fails when the art's outlines, followed in straight segments, have more than
|
||||
200000 points; when groups, symbol instances and live objects are nested more than 128 levels deep; when measuring a
|
||||
clip group's outlines against each other takes more than 40000000 tests of one segment against another; when, for the
|
||||
lines one way, more than 10000000 pairs of a line and an outline segment within `gap` of it are found, or closed
|
||||
outline segments are checked for crossings with the lines more than 10000000 times; when the lines would be more than
|
||||
100000 pieces; and when no line fits outside the art while `box` is false. Result: `{id, box, lines}`: the group's id,
|
||||
the weed box's id (null without one) and the number of pieces of weeding line. Weed Box and Lines… opens the command
|
||||
dialog with Preview; a Spacing above 0 is used instead of the Count.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"addon.cutting.weed","params":{"margin":"0.25 in","direction":"both","spacing":"2 in","gap":1}}}
|
||||
```
|
||||
|
||||
## Color Effects add-on
|
||||
|
||||
Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is
|
||||
|
||||
@@ -1943,6 +1943,10 @@ A VectorCraft addition, not a feature of the reference app (it has no command fo
|
||||
|
||||
A VectorCraft addition, so no area score moves. Object › Plug-ins › Cutting Tools › Stencil Bridges… cuts strips of a set width (0.01 to 1000 pt, 2 by default) out of the selected art so that a stencil cut from it keeps its islands, such as the inside of an O or a hole in a shape, attached to the sheet ([stencil islands and bridges](https://en.wikipedia.org/wiki/Stencil)). Every island is joined to the sheet, directly or through another island, by the shortest crossings that fit, then gets more bridges spread around it up to the count asked for (1 to 16); bridges run vertically, horizontally or along the shortest crossing among 12 directions 15° apart. The art is measured by the area its outlines enclose (hidden objects left out), type by its glyph outlines without converting the type, and becomes one compound path with the gaps cut; the selected objects that make up the art go to a new hidden layer, Stencil Original, or are replaced, and selected objects with no outlines stay where they are. Art with no islands is left as it is with a message and no undo step, islands the cut result still encloses are counted and reported, and the run is one undo step. Tests: `addons::stencil::tests` in `vectorcraft-pathops` (an O, two holes, nested islands, curved outlines, width, direction, the shortest crossing at an angle, more bridges per island and their spacing, narrow islands, slivers, outlines that touch at a corner, the curve tolerance, open outlines, limits and the work budget), `cmd::addons::cutting::stencil::tests` in `vectorcraft-engine` (the hidden layer and undo, replacing, width with units, type, hidden group members, objects with no outlines, the result's paint, kind and place in the stacking order, no islands, narrow islands, bad params refused), `stencil_bridges_opens_its_dialog_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Cutting Tools add-on › Stencil Bridges.
|
||||
|
||||
## Cutting Tools add-on: Weed Box and Lines (2026-11-12)
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user