997 lines
40 KiB
Rust
997 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>) {
|
|
each_leaf(n, &mut |l| out.push(l.clone()));
|
|
}
|
|
|
|
/// Calls `f` with each leaf shape of [`leaves`], in its order.
|
|
fn each_leaf(n: &Node, f: &mut impl FnMut(&Node)) {
|
|
match &n.kind {
|
|
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
|
|
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => f(n),
|
|
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
|
for c in children {
|
|
each_leaf(c, f);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// How many shapes a Pathfinder operation takes from `n` selected alone: its leaf shapes, however
|
|
/// deep its groups nest (an expanded stroke is a group inside the group, #1635).
|
|
pub fn shape_count(n: &Node) -> usize {
|
|
// Counted without copying the shapes: the panel asks every frame.
|
|
let mut count = 0;
|
|
each_leaf(n, &mut |_| count += 1);
|
|
count
|
|
}
|
|
|
|
/// 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))
|
|
}
|