mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
A fourth Image Trace mode for flat-colour logos. The existing tracer follows hard pixel edges, so an outline can only be as good as the pixel grid; here each pixel is read as a blend of the logo's flat colours, the blend is interpolated smoothly (cubic B-spline), and every outline is placed where two colours are equally present, to a fraction of a pixel. Outlines are then cleaned the way a person would: - corners the blur rounded are put back (line fits either side of a bend, intersected); - near-straight runs and near-circular arcs become exact lines and arcs (Snap Curves To Lines); - the rest is fitted with as few cubic Beziers as the tolerance allows (Schneider); - touching colours overlap by 0.06 px so no hairline gap opens between independent fits; - shapes are painted biggest first; transparent areas fully enclosed by artwork (letters knocked out of a badge, the counters of an "o") are painted white so the logo reads the same on any background, or become real holes with Ignore White (each hole belongs to the smallest outline containing it); - an outline whose fit strays more than 1.4 px from the raw contour is fitted plainly. Colours are found automatically (and completed from pixels no blend of the known colours explains, so a small accent is not lost) or named with `logoColors: ["#rrggbb", ...]` (white is always its own class; colours closer than 12 count as one; at most ten). Gradients and photos are refused with advice to use Color (a flat-colour score of the solid areas, 0.93). A hard-edged source with no anti-aliasing is softened by under a pixel and fitted with looser snap tolerances. Images over 1024 px on the long side are averaged down first (further, if they are busy enough to make the unmixing expensive) and the result is mapped back exactly. More real outlines than we will fit are refused, not silently truncated. Also: a "Flat Logo" preset, the mode in the Image Trace panel (Method hidden: it always stacks), the command docs and MCP prompt, es/fr/it/ru translations for the two new messages and the mode name, and the ROADMAP. Never-crash: no unwrap/expect/panic, every size and float is bounded and checked, with tests for degenerate and extreme input (NaN/infinite parameters, 0x0 and 1x1 images, sparse outlines, an anchor budget, a 200,000-entry palette) and a 250-run randomised no-panic property test. The B-spline interpolation follows Unser et al. and Thevenaz et al., and the curve fitter follows Schneider (Graphics Gems); both are written from the papers. Measured (local experiment, not in CI): a flat-colour logo's vector original rendered down to a soft 199x100 px PNG and traced. Mean edge error vs the original at 1600 px wide: built-in colour presets 2.5 to 3.4 px, Flat Logo 0.78 px (0.48 px from a crisp source); region overlap 0.88-0.91 vs 0.966. About 0.1 s for a 200 px logo. Tests: 60 new in the trace crate (unmix, field, contours, geometry, Beziers, corners, outline fitting, palette, end-to-end on synthetic shapes made in code) and 3 in the engine. cargo xtask ci passes (fmt, clippy, test, assets, brands, layers, wasm): 4,290 tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
865 lines
39 KiB
Rust
865 lines
39 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, LineCap, LineJoin, StrokeLayer};
|
|
use vectorcraft_doc::{Appearance, Document, 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::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\"|\"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, snapCurvesToLines, 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)} → {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!(
|
|
"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::<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(_))))
|
|
}
|
|
|
|
/// The `view` param ([`TraceView`] id), if given.
|
|
fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
|
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<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());
|
|
}
|
|
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<Vec<([u8; 3], Color)>> {
|
|
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;
|
|
|
|
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
|
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)))?;
|
|
let raster = tr::Raster::decode(&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<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);
|
|
// 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);
|
|
let paint = Paint::solid(color);
|
|
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()
|
|
},
|
|
None => fill_only(paint),
|
|
};
|
|
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 {
|
|
// Tracing again keeps the object's view unless another is asked for.
|
|
let view = view.unwrap_or_else(|| src.trace_view());
|
|
g.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<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_set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
|
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.trace.as_deref_mut().and_then(Value::as_object_mut) {
|
|
t.insert("view".into(), json!(view.id()));
|
|
} else {
|
|
n.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<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) }))
|
|
}
|