Files
vectorcraft/crates/engine/src/cmd/draw2.rs
T
bflatasticandClaude Opus 5.5 5cf9233d26 Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
Double-clicking a brush in the Brushes panel (or the panel menu's Brush
Options…) opens its options. Calligraphic Brush Options: Angle,
Roundness and Size, each Fixed, Random or Pressure with its Variation,
over live previews of the nib and of a stroke. Bristle Brush Options:
Shape and the bristle sliders. OK runs brush.options; strokes painted
with the brush update.

The Paintbrush records the pen pressure along a stroke (path.freehand
takes [x, y, pressure] points), and a Calligraphic brush with Pressure
variation changes its nib along the stroke, from the value less the
variation at the lightest pressure to the value plus it at the heaviest.
Brushes saved before keep their Random variation. Pen pressure doesn't
reach the app on macOS yet (winit 0.30 reports none there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:15:18 +07:00

1277 lines
48 KiB
Rust

//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
//! blob brush, smooth, path eraser, join).
use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
use super::create::{add_look, add_node};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"path.freehand",
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
has_doc,
freehand
),
cmd!(
"path.curvature",
"Curvature",
[],
None,
"{points: [{x, y, corner?}], closed?, id?} create (or with id: replace) a path curving through the points → {id}",
has_doc,
curvature
),
cmd!(
"path.curvatureEdit",
"Curvature",
[],
None,
"{id, subpath?, op: \"move\"|\"insert\"|\"extend\"|\"close\", anchor?, segment?, t?, end?: \"start\"|\"end\", from?: \"keep\"|\"smooth\"|\"corner\", x?, y?} edit a path as the Curvature tool does, its shape kept elsewhere; the anchor edited becomes the direct-selected one → {anchor}. move: anchor to x, y, re-curving only its two segments; insert: a smooth anchor at t on segment, the shape unchanged (with x, y: then moved there); extend: an open subpath goes on from its end (default) or start to a new end at x, y; close: an open subpath closes from that end. The old end bends into the new segment as from says: keep its curve (default), smooth through its neighbours, or a corner",
has_doc,
curvature_edit
),
cmd!(
"path.removeAnchor",
"Delete Anchor Point",
[],
None,
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
has_doc,
remove_anchor
),
cmd!(
"path.removeAnchors",
"Remove Anchor Points",
["Object", "Path"],
None,
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
has_anchors,
remove_anchors
),
cmd!(
"path.convertAnchor",
"Convert Anchor Point",
[],
None,
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored)",
has_doc,
convert_anchor
),
cmd!(
"path.reshapeSegment",
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)",
has_doc,
reshape_segment
),
cmd!(
"path.split",
"Split Path",
[],
None,
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
has_doc,
split
),
cmd!(
"path.knife",
"Knife",
[],
None,
"{points: [[x,y]…]} cut closed shapes (selected, or all when nothing is selected) along the polyline → {ids}",
has_doc,
knife
),
cmd!(
"path.eraseRegion",
"Eraser",
[],
None,
"{points: [[x,y]…], size?: pt (10)} erase a round-brush stroke from paths (selected, or all) → {ids}",
has_doc,
erase_region
),
cmd!(
"path.eraseSegments",
"Path Eraser",
[],
None,
"{points: [[x,y]…], width?: pt (8)} erase the parts of selected paths under the drag → {ids}",
has_selection,
erase_segments
),
cmd!(
"path.smoothRegion",
"Smooth",
[],
None,
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}",
has_selection,
smooth_region
),
cmd!(
"path.joinScrub",
"Join",
[],
None,
"{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}",
has_doc,
join_scrub
),
cmd!(
"path.blob",
"Blob Brush",
[],
None,
"{points: [[x,y]…], size?: pt (10), merge?: true} filled brush shape, merged with touching same-colour blobs → {id}",
has_doc,
blob
),
]
}
// ---------- helpers ----------
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
})
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
}
Ok(pts)
}
/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of
/// its samples has one; samples without one count as full pressure, as a pen event without one.
fn stroke_pressure(samples: &[(Point, Option<f64>)]) -> Option<PressureProfile> {
if samples.iter().all(|(_, p)| p.is_none()) {
return None;
}
let mut at = 0.0;
let mut prev: Option<Point> = None;
let along: Vec<(f64, f64)> = samples
.iter()
.map(|(pt, p)| {
at += prev.map_or(0.0, |q| q.distance(*pt));
prev = Some(*pt);
(at, p.unwrap_or(1.0))
})
.collect();
PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p)))
}
/// The length of a subpath.
fn subpath_len(sp: &SubPath) -> f64 {
use kurbo::ParamCurveArclen;
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
}
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
Ok(path)
}
_ => Err(EngineError::Other(format!("object {id} is not a path"))),
}
}
fn path_of(d: &Document, id: NodeId) -> Result<(PathData, FillRule)> {
match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Path { path, rule, .. }) => Ok((path.clone(), *rule)),
Some(_) => Err(EngineError::Other(format!("object {id} is not a path"))),
None => Err(EngineError::NoNode(id)),
}
}
fn dist_to_polyline(pts: &[Point], q: Point) -> f64 {
if pts.len() == 1 {
return pts[0].distance(q);
}
pts.windows(2)
.map(|w| {
let (a, b) = (w[0], w[1]);
let ab = b - a;
let l2 = ab.hypot2();
let t = if l2 < 1e-18 { 0.0 } else { ((q - a).dot(ab) / l2).clamp(0.0, 1.0) };
(a + ab * t).distance(q)
})
.fold(f64::INFINITY, f64::min)
}
fn poly_bounds(pts: &[Point], pad: f64) -> Rect {
let r = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p));
r.inflate(pad, pad)
}
/// The filled area painted by a round brush of diameter `size` along `pts`.
fn brush_region(pts: &[Point], size: f64) -> PathData {
let mut pts = pts.to_vec();
if pts.len() == 1 {
pts.push(pts[0] + Vec2::new(0.01, 0.0));
}
po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), size, Cap::Round, Join::Round, 4.0)
}
/// Path leaves the area tools act on: under the selected objects, or every path when nothing is
/// selected. Only editable, non-guide paths whose bounds meet `area`.
fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
let mut out = vec![];
let mut push = |n: &Node| {
if let NodeKind::Path { guide: false, path, .. } = &n.kind
&& path.control_bounds().is_some_and(|b| b.inflate(0.5, 0.5).intersect(area).area() > 0.0 || b.width() == 0.0 || b.height() == 0.0)
&& !out.contains(&n.id)
{
out.push(n.id);
}
};
if sel.is_empty() {
doc.walk(&mut push);
} else {
for id in &sel.objects {
if let Some(n) = doc.node(*id) {
n.walk(&mut |c: &Node| push(c));
}
}
}
out.retain(|id| doc.is_editable(*id));
out
}
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
}
/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members
/// are rebuilt, one per subpath).
pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> {
if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) {
let members = compound_members(d, pd);
if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*children = members;
}
return Ok(());
}
*path_mut(d, id)? = pd;
Ok(())
}
/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`.
fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone();
n.id = d.alloc_id();
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*path = pd;
*live = None;
}
NodeKind::Compound { children, .. } => *children = compound_members(d, pd),
_ => {}
}
Ok(n)
}
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
/// it; none removes it). Returns ids.
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
let mut it = pieces.into_iter();
let Some(first) = it.next() else {
d.remove(id)?;
return Ok(vec![]);
};
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let mut ids = vec![id];
for (k, pd) in it.enumerate() {
let n = sibling_with(d, id, pd)?;
ids.push(d.insert(parent, idx + 1 + k, n)?);
}
set_geometry(d, id, first)?;
Ok(ids)
}
pub(super) fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Split a filled path into connected pieces (each outer contour with its holes).
pub(super) fn components(pd: &PathData) -> Vec<PathData> {
let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect();
if subs.len() <= 1 {
return if pd.is_empty() { vec![] } else { vec![pd.clone()] };
}
let bps: Vec<BezPath> = subs
.iter()
.map(|s| {
let mut b = BezPath::new();
s.to_bezpath_into(&mut b);
b
})
.collect();
let samples: Vec<Point> = subs.iter().map(|s| s.segment(0).eval(0.5)).collect();
let n = subs.len();
let inside = |j: usize, i: usize| fill_contains(&bps[j], FillRule::NonZero, samples[i]);
let depth: Vec<usize> = (0..n).map(|i| (0..n).filter(|&j| j != i && inside(j, i)).count()).collect();
let areas: Vec<f64> = subs.iter().map(|s| s.area().abs()).collect();
let outers: Vec<usize> = (0..n).filter(|&i| depth[i].is_multiple_of(2)).collect();
let mut groups: Vec<Vec<SubPath>> = outers.iter().map(|&o| vec![subs[o].clone()]).collect();
for h in (0..n).filter(|&i| !depth[i].is_multiple_of(2)) {
let best = outers
.iter()
.enumerate()
.filter(|(_, o)| depth[**o] + 1 == depth[h] && inside(**o, h))
.min_by(|a, b| areas[*a.1].total_cmp(&areas[*b.1]));
if let Some((k, _)) = best {
groups[k].push(subs[h].clone());
}
}
groups.into_iter().map(PathData::new).collect()
}
/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed.
fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPath>> {
let nseg = sp.segment_count();
if nseg == 0 {
return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]);
}
const K: usize = 24;
let mut runs: Vec<Vec<(usize, f64, f64)>> = vec![];
let mut cur: Vec<(usize, f64, f64)> = vec![];
let mut any = false;
for i in 0..nseg {
let c = sp.segment(i);
let mut prev_t = 0.0;
let mut prev_rm = remove(c.eval(0.0));
let mut start = if prev_rm { None } else { Some(0.0) };
if prev_rm {
any = true;
if !cur.is_empty() {
runs.push(std::mem::take(&mut cur));
}
}
for k in 1..=K {
let t = k as f64 / K as f64;
let rm = remove(c.eval(t));
if rm != prev_rm {
let (mut lo, mut hi) = (prev_t, t);
for _ in 0..30 {
let m = 0.5 * (lo + hi);
if remove(c.eval(m)) == prev_rm { lo = m } else { hi = m }
}
let b = 0.5 * (lo + hi);
if prev_rm {
start = Some(b);
} else {
any = true;
if let Some(s) = start.take() {
cur.push((i, s, b));
}
runs.push(std::mem::take(&mut cur));
}
}
prev_t = t;
prev_rm = rm;
}
if let Some(s) = start {
cur.push((i, s, 1.0));
}
}
if !cur.is_empty() {
runs.push(cur);
}
runs.retain(|r| !r.is_empty());
if !any {
return None;
}
// A closed path's last and first runs meet at anchor 0.
if sp.closed && runs.len() >= 2 {
let first_starts = runs.first().and_then(|r| r.first()).is_some_and(|s| s.0 == 0 && s.1 == 0.0);
let last_ends = runs.last().and_then(|r| r.last()).is_some_and(|s| s.0 + 1 == nseg && s.2 == 1.0);
if first_starts && last_ends {
let first = runs.remove(0);
if let Some(last) = runs.last_mut() {
last.extend(first);
}
}
}
let out = runs
.iter()
.filter(|r| r.len() > 1 || r[0].2 - r[0].1 > 1e-4)
.map(|r| {
let mut anchors: Vec<Anchor> = vec![];
for &(i, t0, t1) in r {
let c = sp.segment(i).subsegment(t0..t1);
let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) };
match anchors.last_mut() {
Some(a) => a.h_out = h1,
None => anchors.push(Anchor { h_out: h1, ..Anchor::corner(c.p0) }),
}
anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner });
}
for a in &mut anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
SubPath::new(anchors, false)
})
.filter(|s| s.anchors.len() >= 2)
.collect();
Some(out)
}
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
let mut changed = false;
let mut subs = vec![];
for sp in &pd.subpaths {
match cut_subpath(sp, remove) {
Some(pieces) => {
changed = true;
subs.extend(pieces);
}
None => subs.push(sp.clone()),
}
}
if !changed {
return None;
}
Some(if pd.subpaths.len() == 1 {
subs.into_iter().map(PathData::single).collect()
} else if subs.is_empty() {
vec![]
} else {
vec![PathData::new(subs)]
})
}
fn stroke_paint(s: &Session) -> Paint {
if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() }
}
fn path_kind(path: PathData) -> NodeKind {
NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: false }
}
// ---------- Pencil / Paintbrush ----------
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
/// corners) and simplify it to `tol`.
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let min = (tol * 0.3).max(0.05);
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
let mut kept: Vec<Point> = vec![p0];
for &p in pts.iter().skip(1) {
if kept.last().is_none_or(|k| k.distance(p) >= min) {
kept.push(p);
}
}
// The last sample always ends the stroke.
if kept.len() > 1 {
if let Some(k) = kept.last_mut() {
*k = pn;
}
} else if pts.len() > 1 {
kept.push(pn);
}
let n = kept.len();
let marked: Vec<(Point, bool)> = (0..n)
.map(|i| {
let corner = if i == 0 || i + 1 == n {
false
} else {
let (a, b) = (kept[i] - kept[i - 1], kept[i + 1] - kept[i]);
a.dot(b) / (a.hypot() * b.hypot()).max(1e-12) < -0.2 // turn > ~100°
};
(kept[i], corner)
})
.collect();
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed);
if sp.anchors.len() < 3 {
return sp;
}
let fitted = po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false });
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
}
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let samples = samples_param(p, "points", C)?;
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start");
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
let mut sp = pd.subpaths[si].clone();
let old_len = subpath_len(&sp);
if at_start {
sp.reverse();
}
let (Some(first), Some(end)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) else {
return Err(bad(C, "path has no open end"));
};
let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) };
*p0 = end;
if pts.len() < 2 {
return Ok(json!({ "id": id.0 }));
}
let close_tol = (tol * 4.0).max(6.0);
let closing = sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
*l = first;
}
let fit = fit_freehand(&pts, tol, false);
let mut add = fit.anchors;
if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) {
last.h_out = a0.h_out;
}
if !add.is_empty() {
add.remove(0);
}
sp.anchors.extend(add);
if closing
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
sp.closed = true;
}
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
// The path's pressure (in the direction it is continued in) followed by the stroke's.
let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
let joined = (old_pressure.is_some() || pressure.is_some()).then(|| {
let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });
let total = subpath_len(&sp);
PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len)
});
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
let reverse = at_start && !sp.closed;
if reverse {
sp.reverse();
}
path_mut(d, id)?.subpaths[si] = sp;
if let Some(joined) = joined
&& let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())
{
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
}
sel.set([id]);
Ok(())
})?;
return Ok(json!({ "id": id.0 }));
}
if pts.len() < 2 {
return Err(bad(C, "need at least two points"));
}
let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
if let Some(st) = look.appearance.stroke_mut() {
st.pressure = pressure;
}
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
}
// ---------- Curvature ----------
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvature";
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
let pts: Vec<(Point, bool)> = arr
.iter()
.filter_map(|v| {
let pt = match v {
Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?),
_ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),
};
(pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))
})
.collect();
if pts.is_empty() {
return Err(bad(C, "need at least one point"));
}
let sp = vectorcraft_tools::draw2::catmull_rom(&pts, bool_or(p, "closed", false));
if let Some(id) = id_param(p, "id") {
s.edit("Curvature", |d, sel| {
*path_mut(d, id)? = PathData::single(sp);
sel.set([id]);
Ok(())
})?;
return Ok(json!({ "id": id.0 }));
}
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
let ap = &mut look.appearance;
if ap.stroke_paint().is_none() && ap.fill_paint().is_none() {
ap.set_stroke(Paint::solid(Color::BLACK));
}
add_look(s, "Curvature", path_kind(PathData::single(sp)), look, None)
}
/// An edit `path.curvatureEdit` makes.
enum CurvatureOp {
Move(usize, Point),
Insert(usize, f64, Option<Point>),
Extend(Point),
Close,
}
fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
_ => return Err(bad(C, "x and y must be finite numbers")),
};
let to = || at.ok_or_else(|| bad(C, "missing x, y"));
let start = match str_param(p, "end") {
None | Some("end") => false,
Some("start") => true,
Some(e) => return Err(bad(C, format!("unknown end {e:?}"))),
};
let how = match str_param(p, "from") {
None => EndCurve::Keep,
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
};
let op = match str_param(p, "op") {
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
Some("extend") => CurvatureOp::Extend(to()?),
Some("close") => CurvatureOp::Close,
Some(o) => return Err(bad(C, format!("unknown op {o:?}"))),
None => return Err(bad(C, "missing op")),
};
let anchor = s.edit("Curvature", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if matches!(op, CurvatureOp::Extend(_) | CurvatureOp::Close) && sp.closed {
return Err(EngineError::Other("path is already closed".into()));
}
let ai = match op {
CurvatureOp::Move(ai, q) => curvature_move(sp, ai, q).then_some(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?,
CurvatureOp::Insert(seg, t, q) => {
let ai = curvature_insert(sp, seg, t).ok_or_else(|| EngineError::Other("no such segment".into()))?;
if let Some(q) = q {
curvature_move(sp, ai, q);
}
ai
}
CurvatureOp::Extend(q) => {
curvature_extend(sp, start, q, how);
if start { 0 } else { sp.anchors.len() - 1 }
}
CurvatureOp::Close => {
if sp.anchors.len() < 3 {
return Err(EngineError::Other("closing needs three anchors".into()));
}
curvature_close(sp, start, how);
if start { sp.anchors.len() - 1 } else { 0 }
}
};
sel.set([id]);
sel.anchors.insert(id, [(si, ai)].into());
Ok(ai)
})?;
Ok(json!({ "anchor": anchor }))
}
// ---------- anchor tools ----------
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let ai = usize_req(p, "anchor", C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if ai >= sp.anchors.len() {
return Err(EngineError::Other("no such anchor".into()));
}
remove_anchors_of(d, sel, id, [(si, ai)])
})?;
Ok(json!({ "removedObject": removed }))
}
fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st
.selection
.anchors
.iter()
.filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. })))
.map(|(id, set)| (*id, set.iter().copied().collect()))
.collect();
if jobs.is_empty() {
return Err(bad("path.removeAnchors", "direct-select anchor points of a path"));
}
let removed = s.edit("Remove Anchor Points", |d, sel| {
let mut removed = 0;
for (id, anchors) in jobs {
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
}
Ok(removed)
})?;
Ok(json!({ "removedObjects": removed }))
}
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
/// the path went.
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
// The last first, so the indexes still to come stay valid.
anchors.sort_unstable_by(|a, b| b.cmp(a));
anchors.dedup();
let path = path_mut(d, id)?;
for (si, ai) in anchors {
if let Some(sp) = path.subpaths.get_mut(si) {
po::remove_anchor(sp, ai);
}
}
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
let gone = path.is_empty();
if gone {
d.remove(id)?;
sel.remove(id);
} else {
sel.anchors.remove(&id);
}
Ok(gone)
}
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => Some(Point::new(x, y)),
_ => None,
};
s.edit("Convert Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
let a = sp.anchors.get_mut(ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
match target {
_ if !smooth => a.retract(),
Some(t) if t.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, t),
Some(_) => a.retract(),
None => {
sp.smooth_anchor(ai);
}
}
Ok(())
})?;
ok()
}
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(dv.x.is_finite() && dv.y.is_finite()) {
return Err(bad(C, "invalid delta"));
}
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if seg >= sp.segment_count() {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
// smooth: its other handle turns with the moved one.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
if let Some(a) = sp.anchors.get_mut(i0) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = sp.anchors.get_mut(i1) {
b.set_handle(false, b.h_in + v, false);
}
Ok(())
})?;
ok()
}
// ---------- Scissors ----------
fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
if anchor.is_none() && seg.is_none() {
return Err(bad(C, "need `anchor` or `segment` + `t`"));
}
let t = f64_or(p, "t", 0.5);
if !t.is_finite() || p.get("t").is_some_and(|v| !v.is_f64() && !v.is_i64() && !v.is_u64()) {
return Err(bad(C, "invalid t"));
}
let ids = s.edit("Split Path", |d, sel| {
let path = path_mut(d, id)?;
let mut sp = path.subpaths.get(si).cloned().ok_or_else(|| EngineError::Other("no such subpath".into()))?;
let n = sp.anchors.len();
let k = match (anchor, seg) {
(Some(a), _) if a < n => a,
(Some(_), _) => return Err(EngineError::Other("no such anchor".into())),
(None, Some(g)) if g < sp.segment_count() => {
if t <= 1e-6 {
g
} else if t >= 1.0 - 1e-6 {
(g + 1) % n
} else {
sp.insert_anchor(g, t)
}
}
_ => return Err(EngineError::Other("no such segment".into())),
};
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
if sp.closed {
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
sel.set([id]);
return Ok(vec![id]);
}
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
if path.subpaths.len() == 1 {
path.subpaths[0] = left;
let node = sibling_with(d, id, PathData::single(right))?;
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let nid = d.insert(parent, idx + 1, node)?;
sel.set([id, nid]);
Ok(vec![id, nid])
} else {
path.subpaths[si] = left;
path.subpaths.insert(si + 1, right);
sel.set([id]);
Ok(vec![id])
}
})?;
Ok(ids_json(&ids))
}
// ---------- Knife / Eraser ----------
/// Width of the slit the knife leaves between pieces.
const KNIFE_GAP: f64 = 0.05;
fn knife(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.knife";
let pts = points_param(p, "points", C)?;
if pts.len() < 2 {
return Err(bad(C, "need at least two points"));
}
let st = s.doc()?;
let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0);
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) {
let (pd, rule) = path_of(&st.doc, id)?;
if !pd.is_closed() {
continue;
}
let pieces = components(&po::boolean(&pd, rule, &strip, FillRule::NonZero, BoolOp::Difference));
if pieces.len() >= 2 {
plan.push((id, pieces));
}
}
if plan.is_empty() {
return Ok(json!({ "ids": [] }));
}
let ids = s.edit("Knife", |d, sel| {
let mut all = vec![];
for (id, pieces) in plan {
all.extend(replace_with_pieces(d, id, pieces)?);
}
sel.set(all.iter().copied());
Ok(all)
})?;
Ok(ids_json(&ids))
}
fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseRegion";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
let r = size / 2.0;
let st = s.doc()?;
let region = brush_region(&pts, size);
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) {
let (pd, rule) = path_of(&st.doc, id)?;
if pd.is_closed() {
if po::area(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 {
continue;
}
plan.push((id, components(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Difference))));
} else if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= r) {
plan.push((id, pieces));
}
}
apply_plan(s, "Eraser", plan)
}
fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>) -> Result<Value> {
if plan.is_empty() {
return Ok(json!({ "ids": [] }));
}
let ids = s.edit(label, |d, sel| {
let mut all = vec![];
for (id, pieces) in plan {
all.extend(replace_with_pieces(d, id, pieces)?);
}
sel.set(all.iter().copied());
Ok(all)
})?;
Ok(ids_json(&ids))
}
fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.eraseSegments";
let pts = points_param(p, "points", C)?;
let w = f64_or(p, "width", 8.0);
if !(w.is_finite() && w > 0.0) {
return Err(bad(C, "width must be positive"));
}
let st = s.doc()?;
let mut plan = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) {
let (pd, _) = path_of(&st.doc, id)?;
if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= w / 2.0) {
plan.push((id, pieces));
}
}
apply_plan(s, "Path Eraser", plan)
}
// ---------- Smooth ----------
/// Refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles.
fn refit_run(anchors: &[Anchor], tol: f64) -> Vec<Anchor> {
let piece = PathData::single(SubPath::new(anchors.to_vec(), false));
let fitted = po::simplify_with(&piece, &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false });
let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() };
let (f, l) = (anchors[0], anchors[anchors.len() - 1]);
let last = out.len() - 1;
out[0].p = f.p;
out[0].h_in = f.h_in;
out[last].p = l.p;
out[last].h_out = l.h_out;
for a in &mut out {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
out
}
fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Option<SubPath> {
let n = sp.anchors.len();
let marked: Vec<bool> = sp.anchors.iter().map(|a| near(a.p)).collect();
if n < 3 || !marked.iter().any(|m| *m) {
return None;
}
if sp.closed {
let Some(k) = marked.iter().position(|m| !m) else {
// Everything is in range: smooth the whole closed path.
let f =
po::simplify_with(&PathData::single(sp.clone()), &SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false });
return f.subpaths.into_iter().next();
};
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
let mut open = sp.clone();
open.anchors.rotate_left(k);
let mut m = marked.clone();
m.rotate_left(k);
open.anchors.push(open.anchors[0]);
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, tol);
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
}
fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
let n = anchors.len();
let mut runs = vec![];
let mut i = 0;
while i < n {
if marked[i] {
let a = i;
while i + 1 < n && marked[i + 1] {
i += 1;
}
runs.push((a, i));
}
i += 1;
}
let mut out = anchors.to_vec();
for (a, b) in runs.into_iter().rev() {
let lo = a.saturating_sub(1);
let hi = (b + 1).min(n - 1);
if hi - lo < 2 {
continue;
}
let new = refit_run(&out[lo..=hi], tol);
out.splice(lo..=hi, new);
}
out
}
fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smoothRegion";
let pts = points_param(p, "points", C)?;
let radius = f64_or(p, "radius", 12.0).max(0.0);
let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0);
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
let (mut before, mut after) = (0, 0);
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) {
let (pd, _) = path_of(&st.doc, id)?;
let mut changed = false;
let subs: Vec<SubPath> = pd
.subpaths
.iter()
.map(|sp| match smooth_subpath(sp, &near, tol) {
Some(n) if n != *sp && n.anchors.len() >= 2 => {
changed = true;
n
}
_ => sp.clone(),
})
.collect();
if changed {
let np = PathData::new(subs);
before += pd.anchor_count();
after += np.anchor_count();
plan.push((id, np));
}
}
if plan.is_empty() {
return Ok(json!({ "ids": [], "before": 0, "after": 0 }));
}
let ids: Vec<NodeId> = plan.iter().map(|(id, _)| *id).collect();
s.edit("Smooth", |d, _| {
for (id, np) in plan {
*path_mut(d, id)? = np;
}
Ok(())
})?;
let mut v = ids_json(&ids);
v["before"] = json!(before);
v["after"] = json!(after);
Ok(v)
}
// ---------- Join tool ----------
fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.joinScrub";
let pts = points_param(p, "points", C)?;
let tol = f64_or(p, "tolerance", 12.0).max(0.0);
let st = s.doc()?;
// Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start).
let poly = {
let mut bp = BezPath::new();
bp.move_to(pts[0]);
for q in &pts[1..] {
bp.line_to(*q);
}
bp
};
let order = |q: Point| -> f64 {
poly.segments()
.enumerate()
.map(|(i, sg)| (i as f64 + sg.nearest(q, 1e-6).t, sg.nearest(q, 1e-6).distance_sq))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map_or(0.0, |x| x.0)
};
let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) {
let (pd, _) = path_of(&st.doc, id)?;
for (si, sp) in pd.subpaths.iter().enumerate() {
if sp.closed || sp.anchors.len() < 2 {
continue;
}
for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] {
if dist_to_polyline(&pts, a) <= tol {
ends.push((order(a), id, si, start, a));
}
}
}
}
ends.sort_by(|a, b| a.0.total_cmp(&b.0));
if ends.len() < 2 {
return Ok(json!({ "id": null }));
}
let (_, ida, sa, starta, pa) = ends[0];
let (_, idb, sb, startb, pb) = ends[1];
let mid = pa.midpoint(pb);
let merge = pa.distance(pb) <= tol;
s.edit("Join", |d, sel| {
if ida == idb && sa == sb {
let path = path_mut(d, ida)?;
let sp = &mut path.subpaths[sa];
if merge
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
{
let d0 = mid - sp.anchors[0].p;
sp.anchors[0].translate(d0);
sp.anchors[0].h_in = l.h_in + (mid - l.p);
}
sp.closed = true;
sel.set([ida]);
return Ok(());
}
let (pdb, _) = path_of(d, idb)?;
let mut x = path_of(d, ida)?.0.subpaths[sa].clone();
let mut y = pdb.subpaths[sb].clone();
if starta {
x.reverse();
}
if !startb {
y.reverse();
}
if merge
&& !y.anchors.is_empty()
&& let Some(last) = x.anchors.last_mut()
{
let yf = y.anchors.remove(0);
let lp = last.p;
last.translate(mid - lp);
last.h_out = yf.h_out + (mid - yf.p);
*last = Anchor::with_handles(last.p, last.h_in, last.h_out);
}
x.anchors.extend(y.anchors);
if ida == idb {
let path = path_mut(d, ida)?;
path.subpaths[sa] = x;
path.subpaths.remove(sb);
} else {
path_mut(d, ida)?.subpaths[sa] = x;
let pbm = path_mut(d, idb)?;
pbm.subpaths.remove(sb);
if pbm.is_empty() {
d.remove(idb)?;
}
}
sel.set([ida]);
Ok(())
})?;
Ok(json!({ "id": ida.0 }))
}
// ---------- Blob Brush ----------
fn blob(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.blob";
let pts = points_param(p, "points", C)?;
let size = f64_or(p, "size", 10.0);
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
let region = brush_region(&pts, size);
if region.is_empty() {
return Err(EngineError::Other("empty brush stroke".into()));
}
let paint = if !s.paint.stroke.is_none() {
s.paint.stroke.clone()
} else if !s.paint.fill.is_none() {
s.paint.fill.clone()
} else {
Paint::solid(Color::BLACK)
};
let st = s.doc()?;
let mut merge_ids = vec![];
if bool_or(p, "merge", true) {
let all = Selection::default();
for id in target_paths(&st.doc, &all, poly_bounds(&pts, size)) {
let Some(n) = st.doc.node(id) else { continue };
if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() {
continue;
}
let (pd, rule) = path_of(&st.doc, id)?;
if pd.is_closed() && po::area(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) > 1e-6 {
merge_ids.push((id, pd, rule));
}
}
}
let ap = Appearance::basic(paint, Paint::None, 1.0);
if merge_ids.is_empty() {
return add_node(s, "Blob Brush", path_kind(region), ap, None);
}
let mut inputs: Vec<(&PathData, FillRule)> = vec![(&region, FillRule::NonZero)];
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
let merged = po::unite_all(&inputs);
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
let keep = *keep;
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
s.edit("Blob Brush", |d, sel| {
*path_mut(d, keep)? = merged;
for id in others {
d.remove(id)?;
}
sel.set([keep]);
Ok(())
})?;
Ok(json!({ "id": keep.0 }))
}