effectcraft/crates/engine/src/vector.rs

562 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Vector conversions behind Layer ▸ Create: Create Shapes from Vector Layer (SVG, PDF,
//! Illustrator and EPS footage → shape layer), Create Shapes from Text and Create Masks from
//! Text (glyph outlines), and vector files imported as compositions (one layer per file layer).
use effectcraft_keyframe::{Gradient, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, PropGroup};
use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint};
use kurbo::{BezPath, Point};
fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
effectcraft_path::from_kurbo(path)
.into_iter()
.enumerate()
.map(|(i, sp)| {
let mut g = build::shape_path(ids, sp);
g.name = format!("Path {}", i + 1);
g
})
.collect()
}
fn det_scale(m: Affine) -> f64 {
let c = m.as_coeffs();
(c[0] * c[3] - c[1] * c[2]).abs().sqrt()
}
/// Axis-aligned scale + translation only (basic shapes stay parametric).
fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> {
let c = m.as_coeffs();
(c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5]))
}
fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient {
Gradient {
colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(),
opacities: g.stops.iter().map(|(o, c)| (*o, c[3] as f32)).collect(),
}
}
/// Gradient start/end points in shape-layer space (`m`: shape user space → layer space).
fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect) -> (bool, [f64; 2], [f64; 2]) {
let unit = if g.bbox_units { Affine::new([bbox.width(), 0.0, 0.0, bbox.height(), bbox.x0, bbox.y0]) } else { Affine::IDENTITY };
let full = m * unit * g.transform;
let p = |x: f64, y: f64| {
let q = full * Point::new(x, y);
[q.x, q.y]
};
match g.kind {
GradientKind::Linear { x1, y1, x2, y2 } => (false, p(x1, y1), p(x2, y2)),
GradientKind::Radial { cx, cy, r, .. } => (true, p(cx, cy), p(cx + r, cy)),
}
}
fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<PropGroup> {
use kurbo::Shape as _;
let mut items = vec![];
// Geometry: parametric when the transform keeps rectangles/ellipses axis-aligned.
match (&s.geom, axis_aligned(m)) {
(Geom::Rect { x, y, w, h, rx, ry }, Some((sx, sy, tx, ty))) if (rx - ry).abs() < 1e-9 && (*rx == 0.0 || (sx - sy).abs() < 1e-9) => {
let (w2, h2) = (w * sx, h * sy);
let pos = [tx + (x + w / 2.0) * sx, ty + (y + h / 2.0) * sy];
let mut g = build::shape_rect(ids, [w2, h2], pos, rx * sx);
g.name = "Rectangle Path 1".into();
items.push(g);
}
(Geom::Ellipse { cx, cy, rx, ry }, Some((sx, sy, tx, ty))) => {
items.push(build::shape_ellipse(ids, [2.0 * rx * sx, 2.0 * ry * sy], [tx + cx * sx, ty + cy * sy]));
}
(g, _) => items.extend(shape_paths(ids, &(m * g.to_path()))),
}
let bbox = s.geom.to_path().bounding_box();
if let Some(st) = &s.stroke {
let width = st.width * det_scale(m);
let mut g = match &st.paint {
Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width),
Paint::Gradient(gr) => {
let (radial, a, b) = gradient_points(gr, m, bbox);
build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)
}
};
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
set(&mut g, "cap", Value::Enum(st.cap as u32));
set(&mut g, "join", Value::Enum(st.join as u32));
set(&mut g, "miter", Value::Scalar(st.miter));
if let Some((d, off)) = &st.dash
&& let Some(dg) = g.sub_mut("dashes")
{
let k = det_scale(m);
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
set(dg, "offset", Value::Scalar(off * k));
}
items.push(g);
}
if let Some(f) = &s.fill {
let mut g = match f {
Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]),
Paint::Gradient(gr) => {
let (radial, a, b) = gradient_points(gr, m, bbox);
build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))
}
};
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
items.push(g);
}
items
}
fn set(g: &mut PropGroup, k: &str, v: Value) {
if let Some(p) = g.get_mut(k) {
p.value = v;
}
}
fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> PropGroup {
let mut g = build::shape_group(ids, name, items);
if let Some(p) = g.prop_mut("transform/opacity") {
p.value = Value::Scalar(opacity * 100.0);
}
g
}
/// A Merge Paths item in `mode` (0 Merge, 3 Intersect).
fn merge_item(ids: &mut Ids, mode: u32) -> Option<PropGroup> {
let mut g = build::shape_simple_op(ids, "merge")?;
set(&mut g, "mode", Value::Enum(mode));
Some(g)
}
/// A shape inside clipping groups: its geometry merged into one compound path (an inner group
/// with Merge Paths), the clip paths, and Merge Paths ▸ Intersect before its paint, so the fill
/// is exactly the clipped area. Open stroked paths stay unclipped (an intersection would close
/// them).
fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
let items = shape_items(ids, s, m);
let path = s.geom.to_path();
let closed = path.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) || s.fill.is_some();
if clips.is_empty() || !closed {
return items;
}
let (geom, paint): (Vec<PropGroup>, Vec<PropGroup>) =
items.into_iter().partition(|g| !matches!(g.match_id.as_str(), "fill" | "stroke" | "gfill" | "gstroke"));
let mut inner = geom;
inner.extend(merge_item(ids, 0));
let mut out = vec![build::shape_group(ids, "Clipped Path", inner)];
for (i, c) in clips.iter().enumerate() {
let mut parts = shape_paths(ids, c);
parts.extend(merge_item(ids, 0));
out.push(build::shape_group(ids, &format!("Clip Path {}", i + 1), parts));
}
out.extend(merge_item(ids, 3));
out.extend(paint);
out
}
/// Shape layer contents for an SVG document, in document pixels (transforms are baked into the
/// geometry; group and element opacity go to the group transforms, blend modes to the groups'
/// Blend Mode). Top of the paint order first.
///
/// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with
/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the
/// whole document are layer masks instead ([`vector_clip_masks`], used by
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
/// [`split_at_images`]); soft masks are not converted.
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
let mut out = vec![];
for n in nodes.iter().rev() {
match n {
Node::Group(g) => {
let gm = m * g.transform;
let mut inner_clips = clips.to_vec();
inner_clips.extend(g.clip.iter().map(|(p, _)| gm * p.clone()));
let items = walk(ids, &g.children, gm, &inner_clips);
if !items.is_empty() {
let mut grp = group(ids, &g.name, items, g.opacity);
if g.blend != effectcraft_svg::BlendMode::Normal {
set(&mut grp, "blend", Value::Enum(blend_index(g.blend)));
}
out.push(grp);
}
}
Node::Shape(s) => {
let sm = m * s.transform;
let items = if clips.is_empty() { shape_items(ids, s, sm) } else { clipped_items(ids, s, sm, clips) };
if !items.is_empty() {
out.push(group(ids, &s.name, items, s.opacity));
}
}
Node::Image(_) => {}
}
}
out
}
let root = &doc.root;
let mut clips = vec![];
clips.extend(root.clip.iter().map(|(p, _)| root.transform * p.clone()));
let items = walk(ids, &root.children, root.transform, &clips);
if (root.opacity - 1.0).abs() > 1e-9 { vec![group(ids, "svg", items, root.opacity)] } else { items }
}
/// The shape-layer Blend Mode index of an SVG / PDF blend mode.
fn blend_index(b: effectcraft_svg::BlendMode) -> u32 {
use effectcraft_color::BlendMode as P;
use effectcraft_svg::BlendMode as S;
let p = match b {
S::Normal => P::Normal,
S::Multiply => P::Multiply,
S::Screen => P::Screen,
S::Overlay => P::Overlay,
S::Darken => P::Darken,
S::Lighten => P::Lighten,
S::ColorDodge => P::ColorDodge,
S::ColorBurn => P::ColorBurn,
S::HardLight => P::HardLight,
S::SoftLight => P::SoftLight,
S::Difference => P::Difference,
S::Exclusion => P::Exclusion,
S::Hue => P::Hue,
S::Saturation => P::Saturation,
S::Color => P::Color,
S::Luminosity => P::Luminosity,
};
P::ALL.iter().position(|x| *x == p).unwrap_or(0) as u32
}
/// Clips that enclose the whole document (on a chain of single-child groups from the root, as
/// an Illustrator artboard clip or a PDF page clip), in document pixels, and the document with
/// those clips removed: Create Shapes from Vector Layer makes them layer masks.
pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
let mut out = doc.clone();
let mut masks = vec![];
let mut m = Affine::IDENTITY;
let mut g = &mut out.root;
loop {
m *= g.transform;
for (p, _) in g.clip.drain(..) {
masks.push(m * p);
}
if g.children.len() != 1 {
break;
}
match &mut g.children[0] {
Node::Group(sub) if sub.mask.is_none() && sub.blend == effectcraft_svg::BlendMode::Normal => g = sub,
_ => break,
}
}
(masks, out)
}
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
}
/// The vector document of an SVG / PDF / AI / EPS file (`None` for other formats): page
/// `page` of a PDF, restricted to one layer when `layer` names it.
pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project::SourceLayer>, page: u32) -> Option<Result<Doc, String>> {
if path.to_ascii_lowercase().ends_with(".svg") || effectcraft_svg::looks_like_svg(bytes) {
return Some(effectcraft_svg::parse(bytes).map_err(|e| format!("{path}: {e}")));
}
effectcraft_pdf::sniff(bytes)?;
Some(
effectcraft_pdf::parse_page(bytes, page as usize)
.map(|d| match layer {
Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize),
None => d,
})
.map_err(|e| format!("{path}: {e}")),
)
}
/// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the
/// page: one footage item per file layer (each showing only that layer, continuously
/// rasterisable), top layer first. Returns (comp, folder, items).
pub fn import_vector_comp(
proj: &mut Project,
path: &str,
bytes: &[u8],
name: &str,
page: u32,
rate: effectcraft_time::FrameRate,
duration: effectcraft_time::Tick,
) -> Result<(ItemId, ItemId, Vec<ItemId>), String> {
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer};
let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?;
let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF");
let (w, h) = doc.pixel_size();
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let folder = proj.add_item(&format!("{name} Layers"), Label::Yellow, None, ItemKind::Folder);
let mut comp = Comp::new(w, h, rate, duration);
let mut items = vec![];
let names = effectcraft_pdf::layer_names(&doc);
for (i, lname) in names.iter().enumerate() {
let f = Footage {
path: path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: rate,
has_video: true,
alpha: AlphaMode::Straight,
loop_count: 1,
codec: codec.into(),
layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }),
page,
..Default::default()
};
let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f));
items.push(id);
let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None);
l.name = comp.unique_layer_name(lname);
comp.layers.insert(0, l);
}
let cid = proj.add_item(name, Label::Sandstone, None, ItemKind::Comp(std::sync::Arc::new(comp)));
Ok((cid, folder, items))
}
/// A copy of `src`'s transform group with fresh uids.
fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
let Some(tr) = src.props.sub("transform") else { return };
let mut tr = tr.clone();
let mut n = proj.next_id;
tr.reassign_uids(&mut n);
proj.next_id = n + 1;
if let Some(slot) = dst.props.children.iter_mut().find(|c| c.match_id() == "transform") {
*slot = tr.into();
}
}
fn like_source(dst: &mut Layer, src: &Layer) {
dst.start_time = src.start_time;
dst.in_point = src.in_point;
dst.out_point = src.out_point;
dst.stretch = src.stretch;
dst.parent = src.parent;
dst.switches.three_d = src.switches.three_d;
}
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
/// layer: shape layers have no images).
pub struct VectorImage {
pub name: String,
pub width: u32,
pub height: u32,
/// Straight RGBA8.
pub rgba: std::sync::Arc<Vec<u8>>,
/// Image pixels → document pixels.
pub transform: Affine,
pub opacity: f64,
/// Inside a clipping group or under a soft mask (not carried over).
pub clipped: bool,
}
/// The document's images, bottom first, and how many soft-masked groups it has.
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
let m = m * g.transform;
let opacity = opacity * g.opacity;
let clipped = clipped || !g.clip.is_empty() || g.mask.is_some();
if g.mask.is_some() {
*masks += 1;
}
for c in &g.children {
match c {
Node::Group(sub) => walk(sub, m, opacity, clipped, out, masks),
Node::Image(im) => out.push(VectorImage {
name: im.name.clone(),
width: im.width,
height: im.height,
rgba: im.rgba.clone(),
transform: m * im.transform,
opacity: opacity * im.opacity,
clipped,
}),
Node::Shape(_) => {}
}
}
}
let (_, doc) = vector_clip_masks(doc);
let mut out = vec![];
let mut masks = 0;
walk(&doc.root, Affine::IDENTITY, 1.0, false, &mut out, &mut masks);
(out, masks)
}
/// The document split at its images (paint order): segment `k` keeps the shapes drawn between
/// image `k − 1` and image `k` (groups, clips and blend modes kept, images dropped), so shape
/// layers and image footage layers can stack in the document's order. One more segment than
/// images.
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
fn count(g: &effectcraft_svg::Group) -> usize {
g.children
.iter()
.map(|c| match c {
Node::Group(s) => count(s),
Node::Image(_) => 1,
Node::Shape(_) => 0,
})
.sum()
}
/// Keep the shapes between images `seg − 1` and `seg`; `seen` counts images passed.
fn prune(g: &effectcraft_svg::Group, seg: usize, seen: &mut usize) -> effectcraft_svg::Group {
let mut out = g.clone();
out.children = vec![];
for c in &g.children {
match c {
Node::Image(_) => *seen += 1,
Node::Shape(_) => {
if *seen == seg {
out.children.push(c.clone());
}
}
Node::Group(s) => {
let p = prune(s, seg, seen);
if !p.children.is_empty() {
out.children.push(Node::Group(p));
}
}
}
}
out
}
let n = count(&doc.root);
(0..=n)
.map(|seg| {
let mut d = doc.clone();
d.root = prune(&doc.root, seg, &mut 0);
d
})
.collect()
}
/// A footage layer for an image of a vector document, parented to `parent` (the shape layer,
/// whose layer space is document pixels) and placed by the image's transform (anchor at its
/// top-left corner; position, rotation and scale from the matrix). Returns a note when the
/// placement has a skew, which layer transforms cannot express.
pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorImage, parent: &Layer) -> (Layer, Option<String>) {
let mut l = build::layer(proj, comp, &img.name, LayerSource::Footage { item }, (img.width, img.height), None);
let c = img.transform.as_coeffs();
let theta = c[1].atan2(c[0]);
let (sn, cs) = theta.sin_cos();
let sx = (c[0] * c[0] + c[1] * c[1]).sqrt();
let sy = -c[2] * sn + c[3] * cs;
let skew = c[2] * cs + c[3] * sn;
let set = |l: &mut Layer, path: &str, v: Value| {
if let Some(p) = l.props.prop_mut(path) {
p.value = v;
}
};
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([c[4], c[5], 0.0]));
set(&mut l, "transform/scale", Value::Vec3([sx * 100.0, sy * 100.0, 100.0]));
set(&mut l, "transform/rotation", Value::Scalar(theta.to_degrees()));
set(&mut l, "transform/opacity", Value::Scalar((img.opacity * 100.0).clamp(0.0, 100.0)));
l.start_time = parent.start_time;
l.in_point = parent.in_point;
l.out_point = parent.out_point;
l.stretch = parent.stretch;
l.parent = Some(parent.id);
let note = (skew.abs() > 1e-3 * sy.abs().max(1e-9)).then(|| format!("{}: skewed image placed without its skew", img.name));
(l, note)
}
/// Layer ▸ Create ▸ Create Shapes from Vector Layer: `doc` is the SVG shown by `src`. A clip
/// enclosing the whole document becomes the layer's masks (Add, then Intersect); nested clips
/// are kept with Merge Paths (see [`svg_contents`]).
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
let (clips, doc) = vector_clip_masks(doc);
let mut ids = Ids(&mut proj.next_id);
let items = svg_contents(&mut ids, &doc);
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(items.into_iter().map(Into::into));
}
let mut n = 0;
for (k, clip) in clips.iter().enumerate() {
for sp in effectcraft_path::from_kurbo(clip) {
n += 1;
let mode = if k == 0 { MaskMode::Add } else { MaskMode::Intersect };
let mk = build::mask(&mut ids, &format!("Mask {n}"), sp, mode, [255, 255, 0]);
if let Some(masks) = l.props.sub_mut("masks") {
masks.children.push(mk.into());
}
}
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
l
}
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
/// stroke (as laid out at the comp time `ctx` was made for).
pub fn shapes_from_text(proj: &mut Project, comp: &Comp, ctx_project: &Project, cid: ItemId, src: &Layer, time: effectcraft_time::Tick) -> Option<Layer> {
let ctx = effectcraft_render::EvalCtx::new(ctx_project, cid, comp, time);
let geom = effectcraft_render::text::text_geom(&ctx, src)?;
let chars: Vec<char> = geom.doc.text.chars().filter(|c| !c.is_whitespace()).collect();
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
let mut groups = vec![];
{
let mut ids = Ids(&mut proj.next_id);
for (i, g) in geom.glyphs.iter().enumerate() {
let path = g.path();
if path.elements().is_empty() {
continue;
}
let mut items = shape_paths(&mut ids, &path);
if g.stroke_width > 0.0 && g.stroke[3] > 0.0 {
let c = g.stroke;
items.push(build::shape_stroke(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0], g.stroke_width));
}
if g.apply_fill {
let c = g.fill;
let mut f = build::shape_fill(&mut ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
set(&mut f, "opacity", Value::Scalar(c[3] as f64 * 100.0));
items.push(f);
}
let name = chars.get(i).map(|c| c.to_string()).unwrap_or_else(|| format!("Glyph {}", i + 1));
groups.push(group(&mut ids, &name, items, 1.0));
}
}
// First character on top, as in After Effects.
if let Some(c) = l.props.sub_mut("contents") {
c.children.extend(groups.into_iter().map(Into::into));
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
Some(l)
}
/// Glyph outlines of a text layer in comp space (for Create Masks from Text).
pub fn text_outlines_in_comp(project: &Project, cid: ItemId, comp: &Comp, src: &Layer, time: effectcraft_time::Tick) -> Vec<ShapePath> {
let ctx = effectcraft_render::EvalCtx::new(project, cid, comp, time);
let Some(geom) = effectcraft_render::text::text_geom(&ctx, src) else { return vec![] };
let (m, _) = ctx.layer_to_comp(src);
let mut out = vec![];
for g in &geom.glyphs {
let p = g.path();
let p = effectcraft_path::transform(std::slice::from_ref(&p), &m).remove(0);
out.extend(effectcraft_path::from_kurbo(&p));
}
out
}
/// A comp-sized white solid with one mask per glyph contour (Difference, so counters stay open).
pub fn masks_layer(proj: &mut Project, comp: &Comp, name: &str, outlines: Vec<ShapePath>, solid: ItemId) -> Layer {
let mut l = build::layer(proj, comp, name, LayerSource::Solid { item: solid }, (comp.width, comp.height), None);
let mut ids = Ids(&mut proj.next_id);
if let Some(masks) = l.props.sub_mut("masks") {
for (i, sp) in outlines.into_iter().enumerate() {
masks.children.push(build::mask(&mut ids, &format!("Mask {}", i + 1), sp, MaskMode::Difference, [255, 255, 0]).into());
}
}
l
}