mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Object > Expand with Fill expanded gradient fills only. A pattern fill stayed a live pattern, and object.expand.info did not report it, so the dialog's Fill option was disabled for it. In the reference app, Expand with Fill turns a pattern fill into the pattern's tiles, and the shape becomes their clipping mask. The Fill step now turns each visible pattern fill of a path or compound path into a clip group shaped like the object, holding the tiles the pattern lays over the object's box with fresh ids. The group isolates the tiles' blending and takes the fill's opacity, blend mode and other effects. Geometry effects of the fill or the object, such as Offset Path, shape the group's outline and the box the tiles cover. A path in the tile art with a random Transform effect is baked with its own id first, and a path with a brush that varies at random (calligraphic or scatter with Random variation, or bristle) is baked where the pattern defines it first, so every tile matches the canvas. The object's other fills and strokes stay on copies of it in paint order, and Expand stays one undo step. Type with a pattern fill expands the same way once Object outlines it, and object.expand.info reports pattern fills under fill. It also reports gradient fills under a new gradient key, and the dialog enables Expand Gradient To only when the selection has one. A pattern fill stays as it is when it lays no tiles over the box, when a group or type in its art has a random Transform effect, when the pattern is scaled, rotated, reflected, stretched or sheared and its art holds a symbol instance, and when the pattern is rotated, reflected, stretched or sheared, or scaled while its art has geometry effects, and its art has raster or 3D effects, or a geometry effect on a group or type. PatternDef gains instances_in_with, which places the tiles with a given Scaling. Expand has stroke weights, effect distances and the tiles' own pattern paints scale with the pattern's transform. When the pattern is rotated, reflected, stretched or sheared, or scaled while its art has geometry effects such as Roughen, the strokes and geometry effects of the tiles' paths become filled art first (bake_appearance, and Outline Stroke's conversion for brushed strokes), and their live shapes become plain paths, in their opacity masks too. Tiles place their opacity masks with them, unlinked ones included (place_art), which also fixes unlinked masks in pattern tiles in PDF, EPS, EMF and WMF export and in Distort Pattern Fills. Faults in the shared code are fixed for every caller. assemble dropped the effects of a sole part, so Expand lost a lone gradient fill's own effects and Outline Stroke lost those of a stroke with a gradient along or across it. Outline Stroke also dropped a brushed stroke's raster effects from its brush art. Expand replaced an object whose gradient fill could not be expanded with a plain copy; it now leaves it as it was. Expand left the geometry effects of a gradient fill, and with Object off those of the object, on the expanded art, where they reshaped every strip; they now shape the clip group's outline, and the gradient fits it, as for pattern fills. bake_appearance dropped a stroke's raster effects from its filled outline and filled the outline of a stroke with a gradient along or across it with a gradient across the outline's box; it now keeps the effects and makes the gradient meshes clipped to the outline, as Outline Stroke does. Envelope Options > Distort Appearance uses it too. PatternDef::offsets_covering overflowed on a region too large to count in integers, as for a pattern scaled by 1e-20; it now gives no offsets. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
1006 lines
41 KiB
Rust
1006 lines
41 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,
|
|
"{ids?: [id…] (selected first; default: the selection)} 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,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_selection,
|
|
|s, _| run_pf(s, PathfinderOp::MinusFront)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.intersect",
|
|
"Intersect",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_selection,
|
|
|s, _| run_pf(s, PathfinderOp::Intersect)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.exclude",
|
|
"Exclude",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_selection,
|
|
|s, _| run_pf(s, PathfinderOp::Exclude)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.divide",
|
|
"Divide",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_selection,
|
|
|s, _| run_pf(s, PathfinderOp::Divide)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.trim",
|
|
"Trim",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_selection,
|
|
|s, _| run_pf(s, PathfinderOp::Trim)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.merge",
|
|
"Merge",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_selection,
|
|
|s, _| run_pf(s, PathfinderOp::Merge)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.crop",
|
|
"Crop",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
|
|
has_multi,
|
|
|s, _| run_pf(s, PathfinderOp::Crop)
|
|
),
|
|
cmd!(
|
|
"object.pathfinder.outline",
|
|
"Outline",
|
|
["Window", "Pathfinder"],
|
|
None,
|
|
"{ids?: [id…] (selected first; default: the selection)} 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,
|
|
"{ids?: [id…] (selected first; default: the selection)} 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 (with the
|
|
/// stroke's opacity, blend mode and raster effects), 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(d, 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);
|
|
let mut art = with_transparency(d, art, st.opacity, st.blend);
|
|
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
|
|
if !raster.is_empty() {
|
|
if !art.appearance.effects.is_empty() {
|
|
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
|
}
|
|
art.appearance.effects = raster;
|
|
}
|
|
return Some(art);
|
|
}
|
|
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 (in a
|
|
/// group when it has effects of its own) 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 if parts[0].appearance.effects.is_empty() => 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))
|
|
}
|