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

776 lines
34 KiB
Rust

//! Shape Builder (`shapeBuilder.*`), Live Paint (`livePaint.*`) and Image Trace (`imageTrace.*`).
//!
//! **Live Paint groups** need no special node kind: a Live Paint group is a plain group whose name
//! starts with "Live Paint". Its first child is a hidden group named "Live Paint Sources" holding
//! the original paths (the planar map's inputs, used by Release, Merge and re-computation); then
//! come the faces (paths named "Face", fill only) and the edges (open paths named "Edge", stroke
//! only). See `vectorcraft_tools::builder` for the shared helpers.
//!
//! **Image Trace** makes a group named "Image Trace" holding the source image (hidden, bottom)
//! and the traced filled paths above it, mapped with the image's transform. Expand drops the
//! image (a plain group of paths remains); Release puts the image back in place of the group.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
sorted_roots,
};
use vectorcraft_trace as tr;
use super::edit::selected_roots;
use super::paint::paint_from;
use super::pathops::shape_node;
use super::*;
/// Name of an Image Trace group.
pub const TRACE_NAME: &str = "Image Trace";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shapeBuilder.merge",
"Shape Builder",
[],
None,
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
cmd!(
query "shapeBuilder.regions",
"Shape Builder Regions",
[],
None,
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
cmd!(
"livePaint.make",
"Make",
["Object", "Live Paint"],
Some("Cmd+Alt+X"),
"{ids?} build a Live Paint group from the selected paths → {id, faces, edges}",
has_selection_or_ids,
lp_make
),
cmd!(
"livePaint.merge",
"Merge",
["Object", "Live Paint"],
None,
"{ids?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
has_multi_or_ids,
lp_merge
),
cmd!(
"livePaint.release",
"Release",
["Object", "Live Paint"],
None,
"{id?} replace Live Paint groups with their original paths → {ids}",
has_selection_or_ids,
lp_release
),
cmd!(
"livePaint.expand",
"Expand",
["Object", "Live Paint"],
None,
"{id?} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
has_selection_or_ids,
lp_expand
),
cmd!(
"livePaint.fill",
"Live Paint Bucket",
[],
None,
"{group?: id | ids?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill)} fill the face containing point → {group, face}",
has_doc,
lp_fill
),
cmd!(
"livePaint.strokeEdge",
"Live Paint Bucket (Stroke)",
[],
None,
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4)} paint the edge nearest point → {group, edge}",
has_doc,
lp_stroke_edge
),
cmd!(
query "livePaint.info",
"Live Paint Info",
[],
None,
"{group} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
has_doc,
lp_info
),
cmd!(
"imageTrace.make",
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, colors: 2-256, paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines}} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
cmd!(
"imageTrace.makeAndExpand",
"Make and Expand",
["Object", "Image Trace"],
None,
"same as imageTrace.make, then expand → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
cmd!(
"imageTrace.release",
"Release",
["Object", "Image Trace"],
None,
"{id?} put the source image back in place of Image Trace groups → {ids}",
has_selection_or_ids,
trace_release
),
cmd!(
"imageTrace.expand",
"Expand",
["Object", "Image Trace"],
None,
"{id?} keep only the traced paths (drop the source image) → {ids}",
has_selection_or_ids,
trace_expand
),
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
}),
]
}
fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> {
// Agents may pass ids explicitly; menus need a selection. Accept either (params are checked in run).
has_doc(s)
}
fn has_multi_or_ids(s: &Session) -> std::result::Result<(), String> {
has_doc(s)
}
fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let d = &s.doc()?.doc;
if let Some(bad_id) = ids.iter().find(|i| d.node(**i).is_none()) {
return Err(EngineError::NoNode(*bad_id));
}
// Layers are containers, not objects.
let ids: Vec<NodeId> = ids.into_iter().filter(|i| d.node(*i).is_some_and(|n| !n.is_layer())).collect();
if ids.is_empty() {
return Err(EngineError::Other("select objects, not layers".into()));
}
Ok(sorted_roots(d, &ids))
}
fn points_param(p: &Value, key: &str) -> Option<Vec<Point>> {
p.get(key)?.as_array()?.iter().map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()
}
fn ids_json(ids: &[NodeId]) -> Value {
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
}
fn finite(p: Point) -> bool {
p.x.is_finite() && p.y.is_finite() && p.x.abs() <= crate::MAX_COORD && p.y.abs() <= crate::MAX_COORD
}
fn fill_only(paint: Paint) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() }
}
fn stroke_only(paint: Paint, width: f64) -> Appearance {
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() }
}
// ---------- Shape Builder ----------
fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
match p.get("fill") {
None | Some(Value::Null) => Ok(None),
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
paint_from(s, v)
}
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
}
}
fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "shapeBuilder.merge";
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
}
let erase = bool_or(p, "erase", false);
// Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let fill = fill_param(s, p, C)?;
let current_fill = s.paint.fill.clone();
let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::new(d, &roots, erase);
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
}
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
let mut touched = Touched::default();
for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, piece_tol));
}
touched.edges = map.erased_edges(&touched);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
}
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::new(&map.shapes);
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an
// area only lines enclose) of the front-most object and the current fill.
let covered = picked.iter().find_map(|r| r.top());
let style_src = covered.or(map.shapes.len().checked_sub(1));
let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill));
let mut new_nodes: Vec<Node> = vec![];
let mut merged_id = None;
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
if !pieces.is_empty() {
// An open path loses the pieces deleted and those between merged (or deleted) regions.
let keep: Vec<usize> =
pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect();
if keep.len() == pieces.len() {
new_nodes.push(d.reid(leaf));
} else {
for k in keep {
if let Some((l, _)) = map.lines.get(k) {
new_nodes.push(shape_node(d, l.path.clone(), Some(leaf)));
}
}
}
} else {
// A shape loses the regions deleted or merged, then the edges deleted (each one on
// its outline, shared edges from every shape along them).
let in_union = picked.iter().any(|r| r.sources.contains(&i));
let mut rest = if in_union {
// What is left of it: its faces not merged or deleted, united. They share their
// edges with the merged faces exactly, where subtracting the merged shape left
// slivers of the outline along them (#569).
let kept: Vec<&po::Region> = map
.regions
.iter()
.enumerate()
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge(&kept) }
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
let fill_closes = !leaf.appearance.fill_paint().is_none();
let mut cut = false;
for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
rest = r;
cut = true;
}
}
if !in_union && !cut {
new_nodes.push(d.reid(leaf));
} else if !rest.is_empty() {
new_nodes.push(shape_node(d, rest, Some(leaf)));
}
}
if !erase && !union.is_empty() && Some(i) == style_src {
let mut m = shape_node(d, union.clone(), Some(leaf));
if let Some(f) = &merged_fill {
m.appearance.set_fill(f.clone());
}
merged_id = Some(m.id);
new_nodes.push(m);
}
}
let new_ids = replace_roots(d, &roots, new_nodes)?;
sel.set(new_ids.iter().copied());
Ok((new_ids, merged_id, touched))
})?;
Ok(json!({
"ids": ids_json(&ids),
"merged": merged.map(|m| m.0),
"regions": touched.regions.len(),
"lines": touched.lines.len(),
"edges": touched.edges.len(),
}))
}
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.)
fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result<Vec<NodeId>> {
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count();
for r in roots {
if d.node(*r).is_some() {
d.remove(*r)?;
}
}
let at = idx - before;
let mut ids = vec![];
for (k, n) in nodes.into_iter().enumerate() {
ids.push(d.insert(par, at + k, n)?);
}
Ok(ids)
}
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::new(d, &roots, true);
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map
.regions
.iter()
.map(|(r, _, bb)| {
json!({
"bounds": [bb.x0, bb.y0, bb.x1, bb.y1],
"area": po::area(&r.path, FillRule::NonZero),
"sources": r.sources.iter().filter_map(|&i| id_of(i)).collect::<Vec<_>>(),
})
})
.collect();
let pieces = |list: &[(po::Shape, Rect)]| -> Vec<Value> {
list.iter().map(|(l, bb)| json!({ "bounds": [bb.x0, bb.y0, bb.x1, bb.y1], "source": id_of(l.key as usize) })).collect()
};
Ok(json!({ "regions": regions, "lines": pieces(&map.lines), "edges": pieces(&map.edges) }))
}
// ---------- Live Paint ----------
/// The source nodes a root contributes (a Live Paint group contributes its own sources).
fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
}
/// Planar map children (sources group, faces, edges) for `sources`.
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
let mut lv = vec![];
for n in &sources {
b::leaf_shapes(n, &mut lv);
}
let mut shapes = vec![];
let mut styles: Vec<(Paint, Paint, f64)> = vec![];
for l in lv {
if let Some((path, rule)) = b::node_outline(l)
&& !path.is_empty()
{
shapes.push(po::Shape::new(path, rule, styles.len() as u64));
let a = &l.appearance;
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
}
}
if shapes.is_empty() {
return Err(EngineError::Other("Live Paint: select paths or compound paths".into()));
}
let mut children: Vec<Arc<Node>> = vec![];
let gid = d.alloc_id();
let mut src_group = Node::group(gid, sources);
src_group.name = Some(SOURCES_NAME.into());
src_group.visible = false;
children.push(Arc::new(src_group));
// Every path is an edge, open ones included; faces are the areas they enclose.
let (regs, edges) = po::live_paint(&shapes);
if regs.is_empty() && edges.is_empty() {
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
}
let nfaces = regs.len();
for r in regs {
let id = d.alloc_id();
let fill = r.top().and_then(|t| styles.get(t)).map_or(Paint::None, |s| s.0.clone());
let mut n = Node::path(id, r.path, fill_only(fill));
n.name = Some(FACE_NAME.into());
children.push(Arc::new(n));
}
let nedges = edges.len();
for e in edges {
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
let id = d.alloc_id();
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
n.name = Some(EDGE_NAME.into());
children.push(Arc::new(n));
}
Ok((children, nfaces, nedges))
}
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
let mut sources = vec![];
for r in roots {
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
sources.extend(source_nodes(n));
}
let (children, nf, ne) = build_children(d, sources)?;
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = Some(LIVE_PAINT_NAME.into());
replace_roots(d, roots, vec![g])?;
sel.set([gid]);
Ok((gid, nf, ne))
}
fn lp_make(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let (id, faces, edges) = s.edit("Make Live Paint", |d, sel| make_live(d, sel, &roots))?;
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
let roots = root_ids(s, p)?;
let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| {
let target = roots
.iter()
.copied()
.find(|r| d.node(*r).is_some_and(is_live_paint))
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
.filter_map(|e| {
let pd = e.path_data()?;
let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?;
Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), e.appearance.clone()))
})
.collect();
let mut sources = source_nodes(&old);
for r in &roots {
if *r != target {
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
}
}
let (children, nf, ne) = build_children(d, sources)?;
let mut g = Node::group(target, children);
g.name = old.name.clone();
g.opacity = old.opacity;
g.blend = old.blend;
for (pt, paint) in keep_faces {
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
&& let Some(ch) = g.children_mut()
&& let Some(f) = ch.iter_mut().find(|c| c.id == fid)
{
Arc::make_mut(f).appearance.set_fill(paint);
}
}
for (pt, app) in keep_edges {
if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id)
&& let Some(ch) = g.children_mut()
&& let Some(e) = ch.iter_mut().find(|c| c.id == eid)
{
Arc::make_mut(e).appearance = app;
}
}
for r in roots.iter().filter(|r| **r != target) {
if d.node(*r).is_some() {
d.remove(*r)?;
}
}
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
d.remove(target)?;
d.insert(par, idx, g)?;
sel.set([target]);
Ok((target, nf, ne))
})?;
Ok(json!({ "id": id.0, "faces": faces, "edges": edges }))
}
fn live_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let ids = root_ids(s, p)?;
let v: Vec<NodeId> = ids.into_iter().filter(|i| d.node(*i).is_some_and(is_live_paint)).collect();
if v.is_empty() {
return Err(EngineError::Other("select a Live Paint group".into()));
}
Ok(v)
}
fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
let targets = live_targets(s, p)?;
let ids = s.edit("Release Live Paint", |d, sel| {
let mut out = vec![];
for g in &targets {
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
let srcs: Vec<Node> = source_nodes(&n).iter().map(|c| (**c).clone()).collect();
out.extend(replace_roots(d, &[*g], srcs)?);
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
let targets = live_targets(s, p)?;
s.edit("Expand Live Paint", |d, sel| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
n.name = None;
n.trace = None;
if let Some(ch) = n.children_mut() {
ch.retain(|c| match c.name.as_deref() {
Some(SOURCES_NAME) => false,
Some(FACE_NAME) => !c.appearance.fill_paint().is_none(),
Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0,
_ => true,
});
}
}
sel.set(targets.iter().copied());
Ok(())
})?;
Ok(json!({ "ids": ids_json(&targets) }))
}
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.fill";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
let gid = match group {
Some(g) => {
if !d.node(g).is_some_and(is_live_paint) {
return Err(bad(C, format!("{g} is not a Live Paint group")));
}
g
}
None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) {
Some(g) => g,
None => make_live(d, sel, &roots)?.0,
},
};
let fid =
d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?;
d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone());
Ok((gid, fid))
})?;
s.remember_paint(&paint);
Ok(json!({ "group": g.0, "face": face.0 }))
}
fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
if !(0.0..=1000.0).contains(&width) {
return Err(bad(C, "width out of range"));
}
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
let edge = s.edit("Live Paint Bucket", |d, _| {
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
let eid = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?;
e.appearance.set_stroke(paint.clone());
if let Some(st) = e.appearance.stroke_mut() {
st.width = width;
}
Ok(eid)
})?;
s.remember_paint(&paint);
Ok(json!({ "group": g.0, "edge": edge.0 }))
}
fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?;
let d = &s.doc()?.doc;
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?;
let faces: Vec<Value> = b::faces(n)
.iter()
.map(|f| {
let bb = f.geometric_bounds().unwrap_or(Rect::ZERO);
json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] })
})
.collect();
let edges: Vec<Value> = b::edges(n)
.iter()
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
.collect();
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
Ok(json!({ "faces": faces, "edges": edges, "sources": sources }))
}
// ---------- Image Trace ----------
fn is_trace_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. })
&& n.name.as_deref() == Some(TRACE_NAME)
&& n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
}
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
for (k, val) in src {
o.insert(k.clone(), val.clone());
}
serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))
}
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
/// refused with advice instead (#525).
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let params = trace_params(p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
let preset_label = match tr::preset(named) {
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(&params_json) => named.to_string(),
_ => "Custom".to_string(),
};
let roots = root_ids(s, p)?;
// The image (inside a trace group, or selected directly).
let (target, img) = {
let d = &s.doc()?.doc;
roots
.iter()
.find_map(|r| {
let n = d.node(*r)?;
match &n.kind {
NodeKind::Image(i) => Some((*r, i.clone())),
_ if is_trace_group(n) => match &n.children()?.first()?.kind {
NodeKind::Image(i) => Some((*r, i.clone())),
_ => None,
},
_ => None,
}
})
.ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))?
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
let raster = tr::Raster::decode(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
let xf = img.xf * vectorcraft_geom::Affine::scale_non_uniform(sx, sy);
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
let id = s.edit(label, |d, sel| {
let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
let mut image = if is_trace_group(&src) {
src.children()
.and_then(|c| c.first())
.map(|c| (**c).clone())
.ok_or_else(|| EngineError::Other("Image Trace: the traced image is missing".into()))?
} else {
src.clone()
};
image.visible = true;
let mut children: Vec<Arc<Node>> = vec![];
if !expand {
let mut hidden = image.clone();
hidden.visible = false;
children.push(Arc::new(hidden));
}
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
n.appearance = fill_only(Paint::solid(Color::rgb8(tp.color[0], tp.color[1], tp.color[2])));
children.push(Arc::new(n));
}
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
if !expand {
g.trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json })));
}
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
d.remove(target)?;
d.insert(par, idx, g)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors }))
}
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let v: Vec<NodeId> = root_ids(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(is_trace_group)).collect();
if v.is_empty() {
return Err(EngineError::Other("select an Image Trace object".into()));
}
Ok(v)
}
fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
let targets = trace_targets(s, p)?;
let ids = s.edit("Release Image Trace", |d, sel| {
let mut out = vec![];
for g in &targets {
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone();
img.visible = true;
out.extend(replace_roots(d, &[*g], vec![img])?);
}
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
let targets = trace_targets(s, p)?;
s.edit("Expand Image Trace", |d, sel| {
for g in &targets {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
n.name = None;
n.trace = None;
if let Some(ch) = n.children_mut() {
ch.retain(|c| !matches!(c.kind, NodeKind::Image(_)));
}
}
sel.set(targets.iter().copied());
Ok(())
})?;
Ok(json!({ "ids": ids_json(&targets) }))
}