//! 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, LineCap, LineJoin, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView}; 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::{Beside, shape_node}; use super::*; /// Name of an Image Trace group. pub const TRACE_NAME: &str = "Image Trace"; pub fn specs() -> Vec { vec![ cmd!( "shapeBuilder.merge", "Shape Builder", [], None, "{ids?|id?: [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?|id?} → {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?|id?} 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?|id?} 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?, ids?: [id…] (instead of 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?, ids?: [id…] (instead of 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?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} 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), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}", has_doc, lp_stroke_edge ), cmd!( query "livePaint.info", "Live Paint Info", [], None, "{group, id?: object id (default: the selection)} → {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\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {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, "{…as imageTrace.make} 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?, ids?: [id…] (instead of 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?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}", has_selection_or_ids, trace_expand ), cmd!( "imageTrace.setView", "Image Trace View", [], None, "{id?|ids?: Image Trace objects (default: selection), view: \"tracingResult\"|\"tracingResultWithOutlines\"|\"outlines\"|\"outlinesWithSourceImage\"|\"sourceImage\"} how they draw on screen; exports, printing and Expand keep the tracing result → {ids, view}", has_selection_or_ids, trace_set_view ), 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::>() })) }), ] } 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> { 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 = 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> { 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::>()) } 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> { 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 { 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 = 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 = vec![]; let mut merged_id = None; for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { let pieces: Vec = 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 = 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) -> Result> { let Some((&top, rest)) = roots.split_last() else { return Err(EngineError::Other("nothing selected".into())) }; let ids = nodes.iter().map(|n| n.id).collect(); let mut replaced = Beside::replacing(d, roots); for r in rest { if d.node(*r).is_some() { replaced.put(d, *r, vec![])?; } } replaced.put(d, top, nodes)?; replaced.finish(d)?; Ok(ids) } fn sb_regions(s: &mut Session, p: &Value) -> Result { 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 = 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::>(), }) }) .collect(); let pieces = |list: &[(po::Shape, Rect)]| -> Vec { 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> { 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>) -> Result<(Vec>, 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> = 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 { 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 { 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.into(); } } let mut merged = Beside::replacing(d, &roots); for r in roots.iter().filter(|r| **r != target) { if d.node(*r).is_some() { merged.put(d, *r, vec![])?; } } merged.put(d, target, vec![g])?; merged.finish(d)?; sel.set([target]); Ok((target, nf, ne)) })?; Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } fn live_targets(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let ids = root_ids(s, p)?; let v: Vec = 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 { let targets = live_targets(s, p)?; let ids = s.edit("Release Live Paint", |d, sel| { let mut out = vec![]; let mut released = Beside::replacing(d, &targets); for g in &targets { let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; let srcs: Vec = source_nodes(&n).iter().map(|c| (**c).clone()).collect(); out.extend(srcs.iter().map(|c| c.id)); released.put(d, *g, srcs)?; } released.finish(d)?; sel.set(out.iter().copied()); Ok(out) })?; Ok(json!({ "ids": ids_json(&ids) })) } fn lp_expand(s: &mut Session, p: &Value) -> Result { 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.extras_mut().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 { 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 { 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 { 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 = 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 = 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(_)))) } /// The `view` param ([`TraceView`] id), if given. fn view_param(cmd: &str, p: &Value) -> Result> { let Some(v) = p.get("view").filter(|v| !v.is_null()) else { return Ok(None) }; let ids = || TraceView::ALL.map(TraceView::id).join(", "); v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) } fn trace_params(p: &Value) -> Result { 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()); } let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?; if !params.fills && !params.strokes { return Err(bad("imageTrace.make", "Create needs fills, strokes or both")); } if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels")); } Ok(params) } /// Document Library in Color mode: look the library's colours up into `params.swatches` → each /// one as traced (sRGB) with the colour to fill with. fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result> { const C: &str = "imageTrace.make"; if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); } let key = params.library.as_str(); let colors = super::swatchlib::library_colors(s, key) .ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?; if colors.is_empty() { return Err(bad(C, format!("`{key}` has no colours"))); } let library: Vec<([u8; 3], Color)> = colors .into_iter() .map(|c| { let [r, g, b, _] = c.to_rgba8(1.0); ([r, g, b], c) }) .collect(); params.swatches = library.iter().map(|l| l.0).collect(); Ok(library) } /// 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; /// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`. fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape { use vectorcraft_geom::Affine; let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians()); let mut live = match shape { tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle { w: width, h: height, radii: [radius; 4], kinds: Default::default(), xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), }, tr::TracedShape::Ellipse { center, rx, ry, rotation } => { LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } } tr::TracedShape::Polygon { center, radius, sides, rotation } => { LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] } } }; // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) let _ = live.transform(xf); live } /// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the /// document by `xf`. fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); Paint::Gradient(Box::new(paint)) } /// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others /// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is /// inside it. Without parents, the nodes as they are. fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec> { let n = nodes.len(); let mut inside: Vec> = vec![vec![]; n]; let mut roots = vec![]; for i in 0..n { match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) { Some(of) => of.push(i), None => roots.push(i), } } // Outermost first, so that backwards each path comes after those inside it. let mut order = roots.clone(); let mut at = 0; while let Some(&i) = order.get(at) { order.extend(inside.get(i).into_iter().flatten()); at += 1; } let mut nodes: Vec> = nodes.into_iter().map(Some).collect(); let mut built: Vec>> = vec![None; n]; for &i in order.iter().rev() { let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue }; let within: Vec> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect(); let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) }; if let Some(slot) = built.get_mut(i) { *slot = Some(Arc::new(made)); } } // (Paths that are inside each other in a ring are reached from no root: the tracer makes none, // and they are kept all the same.) let ring = nodes.into_iter().flatten().map(Arc::new).collect::>(); roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect() } fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { const C: &str = "imageTrace.make"; let mut params = trace_params(p)?; let view = view_param(C, p)?; let params_json = serde_json::to_value(¶ms).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(¶ms_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)))?; // An image over the tracer's size is traced scaled down (the paths are scaled back below). let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?; let library = trace_library(s, &mut params)?; let res = tr::trace_within(&raster, ¶ms, 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); // Document units per raster pixel (stroke widths). let unit = xf.determinant().abs().sqrt(); 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> = vec![]; if !expand { let mut hidden = image.clone(); hidden.visible = false; children.push(Arc::new(hidden)); } let mut traced = Vec::with_capacity(res.paths.len()); for tp in res.paths { let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); // Shapes: a path the tracer found to be a shape is that live shape. if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) { let shape = traced_shape(shape, xf); *path = shape.to_path(); *live = Some(Box::new(shape)); } // A library colour fills with the swatch's own colour (its model kept). let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1); // Gradients: a path the tracer found to be a linear gradient is filled with it. let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf)); n.appearance = match tp.stroke { // Traced lines: round ends and corners, as drawn lines have. Some(width) => Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..StrokeLayer::new(paint, width * unit) })], ..Default::default() } .into(), None => fill_only(paint).into(), }; traced.push(n); } children.extend(grouped(d, traced, &res.parents)); let gid = d.alloc_id(); let mut g = Node::group(gid, children); g.name = if expand { None } else { Some(TRACE_NAME.into()) }; if !expand { // Tracing again keeps the object's view unless another is asked for. let view = view.unwrap_or_else(|| src.trace_view()); g.extras_mut().trace = Some(Box::new(json!({ "preset": preset_label, "params": params_json, "view": view.id() }))); } 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> { let d = &s.doc()?.doc; let v: Vec = 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_set_view(s: &mut Session, p: &Value) -> Result { const C: &str = "imageTrace.setView"; let view = view_param(C, p)?.ok_or_else(|| bad(C, "view is required"))?; let targets = trace_targets(s, p)?; let d = &s.doc()?.doc; if targets.iter().all(|g| d.node(*g).is_some_and(|n| n.trace_view() == view)) { return Ok(json!({ "ids": ids_json(&targets), "view": view.id() })); } s.edit("Image Trace View", |d, _| { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; if let Some(t) = n.extras_if_any().and_then(|e| e.trace.as_deref_mut()).and_then(Value::as_object_mut) { t.insert("view".into(), json!(view.id())); } else { n.extras_mut().trace = Some(Box::new(json!({ "preset": "Custom", "view": view.id() }))); } } Ok(()) })?; Ok(json!({ "ids": ids_json(&targets), "view": view.id() })) } fn trace_release(s: &mut Session, p: &Value) -> Result { let targets = trace_targets(s, p)?; let ids = s.edit("Release Image Trace", |d, sel| { let mut out = vec![]; let mut released = Beside::replacing(d, &targets); 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.push(img.id); released.put(d, *g, vec![img])?; } released.finish(d)?; sel.set(out.iter().copied()); Ok(out) })?; Ok(json!({ "ids": ids_json(&ids) })) } fn trace_expand(s: &mut Session, p: &Value) -> Result { 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.extras_mut().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) })) }