vectorcraft/crates/engine/src/cmd/pathops.rs

983 lines
40 KiB
Rust

//! Pathfinder panel (`object.pathfinder.*`) and Object → Path commands backed by
//! `vectorcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid,
//! Clean Up and Divide Objects Below.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, PathfinderOp};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.pathfinder.unite",
"Unite",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Unite)
),
cmd!(
"object.pathfinder.minusFront",
"Minus Front",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::MinusFront)
),
cmd!(
"object.pathfinder.intersect",
"Intersect",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Intersect)
),
cmd!(
"object.pathfinder.exclude",
"Exclude",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Exclude)
),
cmd!(
"object.pathfinder.divide",
"Divide",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Divide)
),
cmd!(
"object.pathfinder.trim",
"Trim",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Trim)
),
cmd!(
"object.pathfinder.merge",
"Merge",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Merge)
),
cmd!(
"object.pathfinder.crop",
"Crop",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_multi,
|s, _| run_pf(s, PathfinderOp::Crop)
),
cmd!(
"object.pathfinder.outline",
"Outline",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Outline)
),
cmd!(
"object.pathfinder.minusBack",
"Minus Back",
["Window", "Pathfinder"],
None,
"{} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::MinusBack)
),
cmd!(
"object.path.outlineStroke",
"Outline Stroke",
["Object", "Path"],
None,
"{} replace stroked paths with filled outlines → {ids}",
has_selection,
outline_stroke
),
cmd!(
"object.path.offsetPath",
"Offset Path…",
["Object", "Path"],
None,
"{offset: pt (negative insets), joins?: \"miter\"|\"round\"|\"bevel\", miterLimit?: 4} → {ids} (new objects above the originals)",
has_selection,
offset_path
),
cmd!(
"object.path.simplify",
"Simplify…",
["Object", "Path"],
None,
"{tolerance?: pt (1), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts",
has_selection,
simplify
),
cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points),
cmd!(
"object.path.divideObjectsBelow",
"Divide Objects Below",
["Object", "Path"],
None,
"{} cut the objects below with the selected path (which is removed) → {ids}",
has_selection,
divide_objects_below
),
cmd!(
"object.path.splitIntoGrid",
"Split Into Grid…",
["Object", "Path"],
None,
"{rows?: 2, columns?: 2, gutter?: pt (12; rowGutter?, columnGutter?: one of the two), totalHeight?, totalWidth?: pt (the grid's size; the selected paths' when left out), height?, width?: pt (each row's and column's size, which then sets the total), addGuides?: false (ruler guides along the row and column edges)} one grid of rectangles over the selected paths replaces them, painted as the topmost one → {ids, guides: count}",
has_selection,
split_into_grid
),
cmd!(
"object.path.cleanUp",
"Clean Up…",
["Object", "Path"],
None,
"{strayPoints?: true, unpaintedObjects?: true, emptyTextPaths?: true} → {removed}",
has_doc,
clean_up
),
]
}
// ---------- helpers ----------
/// Number param that also accepts strings like `"10 pt"`.
pub(crate) fn num_param(p: &Value, key: &str) -> Option<f64> {
match p.get(key)? {
Value::Number(n) => n.as_f64(),
Value::String(s) => {
let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect();
t.parse().ok()
}
_ => None,
}
.filter(|v: &f64| v.is_finite())
}
/// A distance param in points: a number, or a string with a unit (`"5 mm"`; bare numbers are
/// points).
pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
match p.get(key)? {
Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| num_param(p, key)),
_ => num_param(p, key),
}
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Paint identity carried through a Pathfinder operation.
#[derive(Clone, Debug, PartialEq)]
struct Style {
appearance: Appearance,
opacity: f32,
blend: BlendMode,
}
impl Style {
fn of(n: &Node) -> Self {
Style { appearance: (*n.appearance).clone(), opacity: n.opacity, blend: n.blend }
}
fn apply(&self, n: &mut Node) {
n.appearance = self.appearance.clone().into();
n.opacity = self.opacity;
n.blend = self.blend;
}
}
/// All subpaths of a path or compound path node, in document coordinates, with its fill rule.
pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
let mut subs = vec![];
for c in children {
if let Some((p, _)) = node_path(c) {
subs.extend(p.subpaths);
}
}
Some((PathData::new(subs), *rule))
}
NodeKind::Text(t) => Some((text_outline(t), FillRule::NonZero)),
_ => None,
}
}
/// Glyph outlines of a text object in document coordinates.
pub(crate) fn text_outline(t: &TextObject) -> PathData {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf)
}
fn style_of(n: &Node) -> Style {
match &n.kind {
NodeKind::Text(t) => Style { appearance: t.first_style().appearance(), opacity: n.opacity, blend: n.blend },
_ => Style::of(n),
}
}
/// Leaf shapes (paths, compound paths, text) of a subtree in paint order; guides and clipping
/// paths are skipped.
fn leaves(n: &Node, out: &mut Vec<Node>) {
match &n.kind {
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => out.push(n.clone()),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children {
leaves(c, out);
}
}
_ => {}
}
}
/// A node for `path`: a plain path for one subpath, else a compound path.
pub(crate) fn shape_node(d: &mut Document, path: PathData, style_src: Option<&Node>) -> Node {
let id = d.alloc_id();
let mut n = if path.subpaths.len() <= 1 {
Node::path(id, path, Appearance::default())
} else {
let children = path
.subpaths
.into_iter()
.map(|sp| {
let cid = d.alloc_id();
Arc::new(Node::path(cid, PathData::single(sp), Appearance::default()))
})
.collect();
Node::new(id, NodeKind::Compound { children, rule: FillRule::NonZero })
};
if let Some(src) = style_src {
Style::of(src).apply(&mut n);
}
n
}
fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node {
let mut n = shape_node(d, path, None);
style.apply(&mut n);
n
}
/// Whether `ids` are separate objects, as [`Document::remove_many`] and [`Document::insert_beside`]
/// take them: each one is in the tree and listed once, and none lies inside another listed one.
pub(crate) fn separate(d: &Document, ids: &[NodeId]) -> bool {
let paths = d.index_paths(ids.iter().copied());
let mut found: Vec<_> = ids.iter().filter_map(|id| paths.get(id)).collect();
found.sort_unstable();
found.len() == ids.len() && found.windows(2).all(|w| !w[1].starts_with(w[0]))
}
/// Lists of nodes that go in place of objects, or directly above them; an empty list in place of
/// an object removes it. When the objects are [`separate`], [`Beside::put`] holds each list and
/// leaves the tree as it is, and [`Beside::finish`] inserts them all in one pass over the children
/// of each parent ([`Document::insert_beside`]). Otherwise [`Beside::put`] inserts each list at
/// once, where its object is at that moment.
pub(crate) struct Beside {
replace: bool,
waiting: Option<Vec<(NodeId, Vec<Node>)>>,
}
impl Beside {
/// Lists that go in place of objects among `ids`.
pub(crate) fn replacing(d: &Document, ids: &[NodeId]) -> Self {
Beside { replace: true, waiting: separate(d, ids).then(Vec::new) }
}
/// Lists that go directly above objects among `ids`.
pub(crate) fn above(d: &Document, ids: &[NodeId]) -> Self {
Beside { replace: false, waiting: separate(d, ids).then(Vec::new) }
}
/// Put `nodes`, in their order, in place of object `id` or directly above it.
pub(crate) fn put(&mut self, d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<()> {
if let Some(waiting) = &mut self.waiting {
waiting.push((id, nodes));
return Ok(());
}
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let at = if self.replace {
d.remove(id)?;
idx
} else {
idx + 1
};
for (k, n) in nodes.into_iter().enumerate() {
d.insert(parent, at + k, n)?;
}
Ok(())
}
/// Insert the lists that [`Beside::put`] held.
pub(crate) fn finish(self, d: &mut Document) -> Result<()> {
match self.waiting {
Some(waiting) if !waiting.is_empty() => Ok(d.insert_beside(waiting, self.replace)?),
_ => Ok(()),
}
}
}
/// Remove `ids` through a [`Beside`], which removes them in one pass when they are [`separate`].
pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> {
let mut gone = Beside::replacing(d, ids);
for id in ids {
gone.put(d, *id, vec![])?;
}
gone.finish(d)
}
// ---------- Pathfinder ----------
fn pf_label(op: PathfinderOp) -> &'static str {
match op {
PathfinderOp::Unite => "Unite",
PathfinderOp::MinusFront => "Minus Front",
PathfinderOp::Intersect => "Intersect",
PathfinderOp::Exclude => "Exclude",
PathfinderOp::Divide => "Divide",
PathfinderOp::Trim => "Trim",
PathfinderOp::Merge => "Merge",
PathfinderOp::Crop => "Crop",
PathfinderOp::Outline => "Outline",
PathfinderOp::MinusBack => "Minus Back",
}
}
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let roots = selected_roots(s)?;
if roots.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let shape_mode =
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let label = pf_label(op);
let ids = s.edit(label, |d, sel| {
// Build the stack (back → front); names are separate from paint/style identity.
let mut styles: Vec<Style> = vec![];
let mut shapes: Vec<po::Shape> = vec![];
let mut names: Vec<Option<String>> = vec![];
let mut push = |mut path: PathData, rule: FillRule, mut st: Style, name: Option<String>| {
if path.is_empty() {
return;
}
// Trim, Merge and Crop remove any strokes (and Merge then merges by fill alone), as the
// public Pathfinder documentation describes.
if matches!(op, PathfinderOp::Trim | PathfinderOp::Merge | PathfinderOp::Crop) {
st.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
}
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
// Filled open contours keep their implicit closing edge. A zero-area line
// stays a cutter; normalised area also catches zero-signed-area self crossings.
for sub in path.subpaths.iter_mut().filter(|sp| !sp.closed) {
sub.closed = po::encloses_area(sub) || po::area(&PathData::single(sub.clone()), rule) > 0.0;
}
}
let key = match styles.iter().position(|x| *x == st) {
Some(k) => k,
None => {
styles.push(st);
styles.len() - 1
}
};
shapes.push(po::Shape::new(path, rule, key as u64));
names.push(name);
};
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
let mut lv = vec![];
leaves(n, &mut lv);
if lv.is_empty() {
continue;
}
if shape_mode
&& roots.len() > 1
&& lv.len() > 1
&& let Some(top) = lv.last()
{
// Among other objects, a group acts as one shape (the union of its leaves) with
// its top leaf's style. Selected alone, its members are the shapes (#1450).
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone()));
push(po::unite_all(&refs), FillRule::NonZero, style_of(top), name);
} else {
for l in &lv {
if let Some((p, r)) = node_path(l) {
push(p, r, style_of(l), n.name.clone().or_else(|| l.name.clone()));
}
}
}
}
if shapes.is_empty() {
return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text")));
}
if shapes.iter().any(|s| s.path.subpaths.iter().any(|sp| sp.anchors.iter().any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())))) {
return Err(EngineError::Other("paths contain invalid coordinates".into()));
}
let results = po::pathfinder(op, &shapes);
if results.iter().all(|r| r.path.is_empty()) {
return Err(EngineError::Other(format!("{label} produced an empty result")));
}
let new_node = if shape_mode {
let r =
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
let style = styles.get(r.key as usize).ok_or_else(|| EngineError::Other(format!("{label}: lost track of a shape's style")))?;
let mut n = shape_node_styled(d, r.path, style);
n.name = match op {
// A subtraction keeps its surviving source's identity, even when unnamed.
PathfinderOp::MinusFront => names.first().cloned().flatten(),
PathfinderOp::MinusBack => names.last().cloned().flatten(),
_ => names.into_iter().flatten().next(),
};
n
} else {
let mut children = vec![];
for r in results {
if r.path.is_empty() {
continue;
}
let st = &styles[r.key as usize];
let mut n = shape_node_styled(d, r.path, st);
if op == PathfinderOp::Outline {
// Outline: open edges stroked with the face's former fill.
let fill = st.appearance.fill_paint();
let paint = if fill.is_none() { st.appearance.stroke_paint() } else { fill };
n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() }.into();
}
children.push(Arc::new(n));
}
let gid = d.alloc_id();
Node::group(gid, children)
};
let nid = d.wrap(&roots, |_| new_node)?;
sel.set([nid]);
Ok(vec![nid])
})?;
Ok(ids_json(&ids))
}
// ---------- Offset Path ----------
fn join_param(p: &Value, key: &str) -> po::Join {
match str_param(p, key).map(str::to_ascii_lowercase).as_deref() {
Some("round") => po::Join::Round,
Some("bevel") => po::Join::Bevel,
_ => po::Join::Miter,
}
}
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let delta = len_param(p, "offset").unwrap_or(10.0);
if delta.abs() > 1e5 {
return Err(bad("object.path.offsetPath", "offset out of range"));
}
let join = join_param(p, "joins");
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
let roots = selected_roots(s)?;
let ids = s.edit("Offset Path", |d, sel| {
let (mut out, mut above) = (vec![], vec![]);
let mut paths = false;
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let mut lv = vec![];
leaves(&n, &mut lv);
let mut made = vec![];
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
let Some((path, rule)) = node_path(l) else { continue };
paths = true;
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
let off = po::offset_path(&base, delta, join, miter);
if off.is_empty() {
continue;
}
made.push(shape_node(d, off, Some(l)));
}
if made.is_empty() {
continue;
}
let new = if made.len() == 1
&& !matches!(n.kind, NodeKind::Group { .. })
&& let Some(one) = made.pop()
{
one
} else {
let gid = d.alloc_id();
Node::group(gid, made.into_iter().map(Arc::new).collect())
};
out.push(new.id);
above.push((*id, vec![new]));
}
if out.is_empty() && paths {
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
}
if out.is_empty() {
return Err(EngineError::Other("Offset Path: select paths".into()));
}
d.insert_beside(above, false)?;
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}
// ---------- Outline Stroke ----------
/// Can Outline Stroke turn `st` into filled art?
fn outlinable(st: &StrokeLayer) -> bool {
st.visible && !st.paint.is_none() && st.width > 0.0
}
/// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a
/// group around it.
pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node {
if opacity >= 1.0 && blend == BlendMode::Normal {
return n;
}
if n.opacity < 1.0 || n.blend != BlendMode::Normal {
n = Node::group(d.alloc_id(), vec![Arc::new(n)]);
}
n.opacity = opacity;
n.blend = blend;
n
}
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
/// and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped
/// like the outline). `None` when it paints nothing.
pub(crate) fn outlined_stroke(
d: &mut Document,
brushes: &[vectorcraft_brush::Brush],
path: &PathData,
rule: FillRule,
st: &StrokeLayer,
) -> Option<Node> {
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
let art = match pieces.len() {
0 => return None,
1 => pieces.remove(0),
_ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()),
};
let art = d.reid(&art);
return Some(with_transparency(d, art, st.opacity, st.blend));
}
let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st);
if outline.is_empty() {
return None;
}
if let Some(g) = st.path_gradient() {
// A gradient along or across the stroke: gradient meshes clipped to its outline.
let meshes = vectorcraft_render::effects::stroke::gradient_meshes(&path.to_bezpath(), rule, st, &g.gradient);
let mut clip = shape_node(d, outline, None);
super::object::as_clipping_path(&mut clip).ok()?;
let meshes: Vec<Node> = meshes.into_iter().map(|m| Node::new(d.alloc_id(), NodeKind::Mesh(m))).collect();
let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect();
let group = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true });
let mut art = with_transparency(d, group, st.opacity, st.blend);
art.appearance.effects = st.effects.clone();
return Some(art);
}
let mut n = shape_node(d, outline, None);
let fill = vectorcraft_doc::appearance::FillLayer {
opacity: st.opacity,
blend: st.blend,
effects: st.effects.clone(),
overprint: st.overprint,
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone())
};
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into();
Some(n)
}
/// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of
/// the path, each stroke becomes its outline (or brush art), grouped when there are several.
/// `None` when `l` has no stroke to outline.
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
return None;
}
let (path, rule) = node_path(l)?;
let mut parts: Vec<Node> = vec![];
let mut fills: Vec<AppearanceItem> = vec![];
for item in &l.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
AppearanceItem::Stroke(st) if outlinable(st) => {
if !fills.is_empty() {
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
}
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
}
_ => {}
}
}
if !fills.is_empty() {
parts.push(styled_copy(d, l, fills));
}
assemble(d, l, parts)
}
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
let mut f = d.reid(l);
f.appearance = Appearance { items, ..Default::default() }.into();
f.opacity = 1.0;
f.blend = BlendMode::Normal;
f.name = None;
f.extras_mut().mask = None;
if let NodeKind::Path { live, .. } = &mut f.kind {
*live = None;
}
f
}
/// What stands for object `l` once its appearance became `parts` (paint order): the one part or a
/// group of them, with `l`'s transparency, name, opacity mask and effects. `None` without parts.
pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> {
let root = match parts.len() {
0 => return None,
1 => parts.remove(0),
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
};
let mut root = with_transparency(d, root, l.opacity, l.blend);
root.name = l.name.clone();
root.extras_mut().mask = l.extras().mask.clone();
root.appearance.effects = l.appearance.effects.clone();
Some(root)
}
/// Outline the strokes of every path under `root` in place: → (the id now standing for `root`,
/// how many paths changed).
pub(crate) fn outline_strokes_under(d: &mut Document, brushes: &[vectorcraft_brush::Brush], root: NodeId) -> Result<(NodeId, usize)> {
replace_leaves(d, root, |d, l| if matches!(l.kind, NodeKind::Text(_)) { None } else { outline_strokes(d, brushes, l) })
}
/// Replace each leaf shape under `root` ([`leaves`]) by what `f` makes of it (`None` keeps it):
/// → (the id now standing for `root`, how many leaves changed).
pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&mut Document, &Node) -> Option<Node>) -> Result<(NodeId, usize)> {
let Some(n) = d.node(root).cloned() else { return Ok((root, 0)) };
let mut lv = vec![];
leaves(&n, &mut lv);
let (mut replacement, mut changed) = (root, 0);
for l in lv {
let Some(new) = f(d, &l) else { continue };
let nid = new.id;
*d.node_mut(l.id).ok_or(EngineError::NoNode(l.id))? = new;
changed += 1;
if l.id == root {
replacement = nid;
}
}
Ok((replacement, changed))
}
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Outline Stroke", |d, sel| {
let brushes = vectorcraft_brush::library(d);
let mut new_sel = vec![];
let mut changed = 0;
for root in &roots {
let (id, n) = outline_strokes_under(d, &brushes, *root)?;
new_sel.push(id);
changed += n;
}
if changed == 0 {
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
}
sel.set(new_sel.iter().copied());
Ok(new_sel)
})?;
Ok(ids_json(&ids))
}
// ---------- Simplify / Add Anchor Points ----------
/// Ids of every plain path node under the selected roots (compound children included).
fn path_ids(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
out.push(c.id)
}
});
}
}
out
}
/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it: → (their ids, how
/// many anchors they have before and after). A path that `f` would empty or returns unchanged is
/// left as it is, with its live shape and its direct-selected anchors; a replaced path loses both.
fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -> Result<(Vec<NodeId>, usize, usize)> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let ids = path_ids(d, &roots);
if ids.is_empty() {
return Err(EngineError::Other(format!("{label}: select paths")));
}
let (mut before, mut after, mut changed) = (0, 0, vec![]);
for id in &ids {
let Some(NodeKind::Path { path, .. }) = d.node(*id).map(|n| &n.kind) else { continue };
before += path.anchor_count();
let np = f(path);
if (np.is_empty() && !path.is_empty()) || np == *path {
after += path.anchor_count();
continue;
}
after += np.anchor_count();
changed.push((*id, np));
}
// When no path changes, the document and its undo history stay as they are.
if !changed.is_empty() {
s.edit(label, |d, sel| {
for (id, np) in changed {
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
// Refit and subdivision change what the old component indexes refer to.
// Keep the object selected whole so its new points remain editable.
sel.anchors.remove(&id);
*path = np;
*live = None;
}
}
Ok(())
})?;
}
Ok((ids, before, after))
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let opts = po::SimplifyOptions {
tolerance: len_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
straight_lines: bool_or(p, "straightLines", false),
};
let (ids, before, after) = edit_paths(s, "Simplify", |path| po::simplify_with(path, &opts))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
}
fn add_anchor_points(s: &mut Session, _: &Value) -> Result<Value> {
let (ids, before, after) = edit_paths(s, "Add Anchor Points", po::add_anchor_points)?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
}
// ---------- Split Into Grid ----------
/// One direction of the grid: the (gutter, total size) of `n` rows or columns. A `size` for each
/// makes the total; otherwise it is `total`, or `extent` (the selection's) when neither is given.
fn grid_axis(p: &Value, n: f64, [size, gutter, total]: [&str; 3], extent: f64) -> (f64, f64) {
let gutter = len_param(p, gutter).or_else(|| len_param(p, "gutter")).unwrap_or(12.0).max(0.0);
let total = match len_param(p, size) {
Some(size) => n * size + (n - 1.0) * gutter,
None => len_param(p, total).unwrap_or(extent),
};
(gutter, total)
}
fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
let rows = num_param(p, "rows").unwrap_or(2.0).round();
let cols = num_param(p, "columns").unwrap_or(2.0).round();
if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) {
return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500"));
}
let too_small = || EngineError::Other("Split Into Grid: select paths large enough for the grid".into());
// The selected paths, back to front: one grid over them all replaces them, where the topmost
// one was and painted as it is.
let d = &s.doc()?.doc;
let roots = selected_roots(s)?;
let paths: Vec<&Node> =
roots.iter().filter_map(|id| d.node(*id)).filter(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })).collect();
let Some((top, below)) = paths.split_last().map(|(top, below)| (Node::clone(top), below.iter().map(|n| n.id).collect::<Vec<_>>())) else {
return Err(too_small());
};
let Some(b) = paths.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)) else { return Err(too_small()) };
let (row_gutter, height) = grid_axis(p, rows, ["height", "rowGutter", "totalHeight"], b.height());
let (col_gutter, width) = grid_axis(p, cols, ["width", "columnGutter", "totalWidth"], b.width());
let (rows, cols) = (rows as usize, cols as usize);
let cells = po::split_into_grid(vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + width, b.y0 + height), rows, cols, (row_gutter, col_gutter));
if cells.is_empty() {
return Err(too_small());
}
let mut edges: Vec<(bool, f64)> = Vec::new();
if bool_or(p, "addGuides", false) {
// Row-major cells: the first row has the column edges, each row's first cell the row edges.
let bounds = cells.iter().filter_map(PathData::bounds);
edges.extend(bounds.clone().take(cols).flat_map(|c| [(true, c.x0), (true, c.x1)]));
edges.extend(bounds.step_by(cols).flat_map(|c| [(false, c.y0), (false, c.y1)]));
// Without a gutter, neighbours share an edge.
edges.dedup_by(|a, b| a.0 == b.0 && (a.1 - b.1).abs() < 1e-9);
}
let ids = s.edit("Split Into Grid", |d, sel| {
d.remove_many(&below)?;
let (par, idx, _) = d.position(top.id).ok_or(EngineError::NoNode(top.id))?;
let layer = d.layer_containing(top.id);
d.remove(top.id)?;
let mut out = Vec::with_capacity(cells.len());
for (k, c) in cells.into_iter().enumerate() {
let node = shape_node(d, c, Some(&top));
out.push(d.insert(par, idx + k, node)?);
}
d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) }));
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "guides": edges.len() }))
}
// ---------- Clean Up ----------
fn is_painted(a: &Appearance) -> bool {
a.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
})
}
fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
let stray = bool_or(p, "strayPoints", true);
let unpainted = bool_or(p, "unpaintedObjects", true);
let empty_text = bool_or(p, "emptyTextPaths", true);
let found = {
let d = &s.doc()?.doc;
let mut v = vec![];
fn visit(n: &Node, parent_compound: bool, parent_clip: bool, first: bool, v: &mut Vec<NodeId>, opts: (bool, bool, bool)) {
let (stray, unpainted, empty_text) = opts;
match &n.kind {
NodeKind::Path { path, clipping, guide, .. } => {
if *guide || *clipping || (parent_clip && first) {
return;
}
if (stray && path.anchor_count() <= 1) || (unpainted && !parent_compound && !is_painted(&n.appearance)) {
v.push(n.id);
}
}
NodeKind::Compound { children, .. } => {
if unpainted && !(parent_clip && first) && !is_painted(&n.appearance) {
v.push(n.id);
return;
}
for c in children {
visit(c, true, false, false, v, opts);
}
}
NodeKind::Text(t) if empty_text && t.plain_text().trim().is_empty() => v.push(n.id),
NodeKind::Layer { children, .. } => {
for c in children {
visit(c, false, false, false, v, opts);
}
}
NodeKind::Group { children, clip } => {
for (i, c) in children.iter().enumerate() {
visit(c, false, *clip, i == 0, v, opts);
}
}
_ => {}
}
}
for l in &d.layers {
visit(l, false, false, false, &mut v, (stray, unpainted, empty_text));
}
v.retain(|id| d.is_editable(*id));
v
};
if found.is_empty() {
return Ok(json!({ "removed": 0 }));
}
s.edit("Clean Up", |d, _| {
let mut gone = Beside::replacing(d, &found);
for id in &found {
if d.node(*id).is_some() {
gone.put(d, *id, vec![])?;
}
}
gone.finish(d)
})?;
Ok(json!({ "removed": found.len() }))
}
// ---------- Divide Objects Below ----------
fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let [cutter_id] = roots[..] else { return Err(EngineError::Other("Divide Objects Below: select exactly one path".into())) };
let ids = s.edit("Divide Objects Below", |d, sel| {
let cutter = d.node(cutter_id).cloned().ok_or(EngineError::NoNode(cutter_id))?;
let Some((cpath, crule)) = node_path(&cutter).filter(|_| !matches!(cutter.kind, NodeKind::Text(_))) else {
return Err(EngineError::Other("Divide Objects Below: select a path".into()));
};
let Some(cb) = cpath.bounds() else { return Err(EngineError::Other("empty cutter".into())) };
let cutter_pos = d.index_path(cutter_id).unwrap_or_default();
let mut targets = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
&& path.is_closed()
{
targets.push(n.id);
}
if matches!(n.kind, NodeKind::Compound { .. }) {
targets.push(n.id);
}
});
}
// Compound children are handled via their compound.
targets.retain(|id| !matches!(d.parent_of(*id).and_then(|p| d.node(p)).map(|p| &p.kind), Some(NodeKind::Compound { .. })));
targets.retain(|id| {
*id != cutter_id
&& d.is_editable(*id)
&& d.is_visible(*id)
&& d.index_path(*id).is_some_and(|ip| ip < cutter_pos)
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
});
let mut out = vec![];
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
for t in targets {
let Some(n) = d.node(t).cloned() else { continue };
let Some((tp, tr)) = node_path(&n) else { continue };
let inside = po::boolean(&tp, tr, &cpath, crule, BoolOp::Intersect);
let outside = po::boolean(&tp, tr, &cpath, crule, BoolOp::Difference);
if inside.is_empty() || outside.is_empty() {
continue;
}
let pieces: Vec<Node> = [outside, inside].into_iter().map(|piece| shape_node(d, piece, Some(&n))).collect();
out.extend(pieces.iter().map(|p| p.id));
cut.put(d, t, pieces)?;
}
cut.put(d, cutter_id, vec![])?;
cut.finish(d)?;
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(ids_json(&ids))
}