Merge pull request #2395 from EricKuck/fix/ai-outer-glow-solid-silhouette

Shadows and glows: each is of the art with the ones before it in the stack, and an Outer Glow blurs a solid silhouette
This commit is contained in:
BFlat
2026-10-18 01:50:04 +07:00
committed by GitHub
4 changed files with 239 additions and 60 deletions
+213 -59
View File
@@ -705,6 +705,8 @@ pub(crate) struct ShadowEntry {
/// Pixel offset of the raster's top-left from the anchor's view position.
rel: (f64, f64),
image: std::sync::Arc<vello_cpu::Pixmap>,
/// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget.
scale: f64,
made: Made,
/// What the silhouette read when the raster was made.
inputs: Inputs,
@@ -714,6 +716,16 @@ pub(crate) struct ShadowEntry {
pub(crate) stamp: u64,
}
/// A shadow or glow raster ready to draw ([`Renderer::draw_shadow`]).
struct Shadow {
image: std::sync::Arc<vello_cpu::Pixmap>,
/// Raster to view pixels.
at: Affine,
mode: vectorcraft_doc::color::BlendMode,
/// Made before its object moved ([`ShadowEntry::moved`]).
moved: bool,
}
impl ShadowEntry {
/// The bytes it holds, for [`FX_RASTER_BUDGET`].
pub(crate) fn bytes(&self) -> usize {
@@ -971,16 +983,15 @@ pub(crate) struct FxGeom {
}
impl Renderer {
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
/// while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are unchanged
/// (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). Returns false when the effect isn't a shadow/glow or the raster
/// would be too large (caller falls back).
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the
/// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU,
/// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are
/// unchanged (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). `None` when the effect isn't a shadow/glow or the raster would be
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
#[allow(clippy::too_many_arguments)]
fn draw_shadow_cached(
fn shadow_raster(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
n: &Node,
i: usize,
@@ -988,11 +999,11 @@ impl Renderer {
reach: Rect,
owner: Option<&Arc<Node>>,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> bool {
) -> Option<Shadow> {
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
_ => return false,
_ => return None,
};
let linear = linear_key(f.view);
let key = (n as *const Node as usize, i, f.ink);
@@ -1006,12 +1017,12 @@ impl Renderer {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
}
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
let (image, x, y) = match hit {
let (image, x, y, scale, moved) = match hit {
Some(e) => {
e.stamp = stamp;
self.stats.moved += usize::from(e.moved);
let p = f.view * e.anchor;
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round())
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
}
None => {
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
@@ -1019,14 +1030,22 @@ impl Renderer {
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
// layer the caller falls back to can't make its silhouette solid.
let scale = if matches!(fx, RasterFx::OuterGlow { .. }) { (w * h / 4.0e6).sqrt().ceil().max(1.0) } else { 1.0 };
if scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
return false;
return None;
}
let (w, h) = (w as u16, h as u16);
// Silhouette (the object's painted alpha, and its colors under Darkness), offset by
// the shadow distance.
let mut off = f.offscreen_context(w, h);
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
let crop = Frame { mt: false, view: Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view, ..*f };
let before = self.stats.moved;
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
// A silhouette drawn over moved rasters makes a moved shadow.
@@ -1038,7 +1057,7 @@ impl Renderer {
let made =
Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
let (wu, hu) = (w as usize, h as usize);
let sigma = blur / 2.0 / f.px;
let sigma = blur / 2.0 / f.px / scale;
if let Some(darkness) = darkness {
// The object's own colors, blurred, with the black Darkness adds (premultiplied).
let keep = 1.0 - shade(f.ink, darkness);
@@ -1058,7 +1077,9 @@ impl Renderer {
*px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 };
}
} else {
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
let solid = matches!(fx, RasterFx::OuterGlow { .. });
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
let [cr, cg, cb] = f.ink.rgb(&color);
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
@@ -1072,23 +1093,71 @@ impl Renderer {
let p = f.view * anchor;
let node = Drawn::of(n, owner);
let rel = (x0 - p.x, y0 - p.y);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), made, inputs, moved, stamp });
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, made, inputs, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0)
(image, x0, y0, scale, moved)
}
};
// The blend mode applies per draw: a blend layer would be a full-viewport compositing pass
// per shadow. The raster sits on whole pixels, so the cheapest sampler is exact.
let (w, h) = (image.width() as f64, image.height() as f64);
ctx.set_transform(Affine::translate((x, y)));
ctx.set_blend_mode(blend_mode(mode));
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved })
}
/// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx`
/// draws in. The blend mode applies per draw: a blend layer would be a full-viewport compositing
/// pass per shadow. A full-size raster drawn as made sits on whole pixels, so the cheapest
/// sampler is exact.
fn draw_shadow(&mut self, ctx: &mut RenderContext, s: &Shadow, to: Affine) {
let (w, h) = (s.image.width() as f64, s.image.height() as f64);
let at = to * s.at;
ctx.set_transform(at);
ctx.set_blend_mode(blend_mode(s.mode));
let [a, b, c, d, _, _] = at.as_coeffs();
let exact = (a, b, c, d) == (1.0, 0.0, 0.0, 1.0) && at.translation().x.fract() == 0.0 && at.translation().y.fract() == 0.0;
let quality = if exact { peniko::ImageQuality::Low } else { peniko::ImageQuality::Medium };
let sampler = peniko::ImageSampler { quality, ..Default::default() };
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(s.image.clone()), sampler });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
ctx.set_transform(Affine::IDENTITY);
true
}
/// Drop shadow / outer glow `fx` of what `paint` draws (bounded by `reach`) through a vello
/// filter layer, when there is no raster for it ([`Self::shadow_raster`]). vello's shadow filter
/// blurs the soft alpha, so an Outer Glow drawn here lacks the raster's solid silhouette.
fn draw_shadow_filter(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
fx: &RasterFx,
reach: Rect,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
_ => return,
};
let filter = match darkness {
Some(_) => shade_filter(blur),
None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)),
};
// The offset moves the content rather than the filter: vello_cpu drops layer content
// that lies entirely outside the viewport before filtering.
let reach = reach + Vec2::new(dx, dy);
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
c.set_transform(fr.view);
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
paint(r, c, &shifted(fr, dx, dy));
// Darkness: black over the object's own colors, keeping their alpha.
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
c.set_transform(fr.view);
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
c.fill_rect(&reach);
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
}
c.pop_layer();
});
}
/// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier
@@ -1124,7 +1193,8 @@ impl Renderer {
&& e.inputs == *inputs
&& e.made.fits(&[fx], size, None, None)
};
let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
let Some((old, rel, w, h, scale)) =
self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height(), e.scale))
else {
return;
};
@@ -1132,7 +1202,7 @@ impl Renderer {
let anchor = reach.origin();
let p = f.view * anchor;
let mut off = f.offscreen_context(w, h);
let crop = Frame { mt: false, view: Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
let crop = Frame { mt: false, view: Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
@@ -1453,39 +1523,43 @@ impl Renderer {
let reach = content.reach;
// The `Arc` drawn: the caller's, or the node `draw_arc` is drawing.
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
// Below the content: shadows and outer glows.
// Below the content: shadows and outer glows, each of the content with the ones before it in the stack, as AI
// files draw a glow round a glow and the SVG export nests them. Their rasters are made first to last, each once
// and drawn into the later ones' silhouettes as an image (so n of them cost n rasters, not 2^n draws), then
// drawn with the last lowest. One without a raster (an object drawn uncached, a glow past the budget) is drawn
// through a filter layer from the content alone, and goes into the later silhouettes that way.
let mut made: Vec<(&RasterFx, Option<Shadow>)> = vec![];
let mut stacked = reach;
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
if let Some(slot) = content.cache
&& self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, owner.as_ref(), paint)
{
continue;
let shadow = {
let earlier = &made;
let view = f.view;
let mut art = |r: &mut Self, c: &mut RenderContext, fr: &Frame| {
let to = fr.view * view.inverse();
for (fx, s) in earlier.iter().rev() {
match s {
Some(s) => {
r.stats.moved += usize::from(s.moved);
r.draw_shadow(c, s, to);
}
None => r.draw_shadow_filter(c, fr, fx, reach, paint),
}
}
paint(r, c, fr);
};
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), &mut art))
};
stacked = match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)),
_ => stacked.inflate(fx.outset(stacked), fx.outset(stacked)),
};
made.push((fx, shadow));
}
for (fx, s) in made.iter().rev() {
match s {
Some(s) => self.draw_shadow(ctx, s, Affine::IDENTITY),
None => self.draw_shadow_filter(ctx, f, fx, reach, paint),
}
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
_ => continue,
};
let filter = match darkness {
Some(_) => shade_filter(blur),
None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)),
};
// The offset moves the content rather than the filter: vello_cpu drops layer content
// that lies entirely outside the viewport before filtering.
let reach = reach + Vec2::new(dx, dy);
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
c.set_transform(fr.view);
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
paint(r, c, &shifted(fr, dx, dy));
// Darkness: black over the object's own colors, keeping their alpha.
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
c.set_transform(fr.view);
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
c.fill_rect(&reach);
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
}
c.pop_layer();
});
}
// The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
@@ -2417,6 +2491,86 @@ mod tests {
assert_eq!(lum(feather.pixel(26, 50)), WHITE, "feather stays inside");
}
#[test]
fn an_outer_glow_blurs_a_solid_silhouette_of_faint_art() {
// GUARDS: the glow's strength comes from where the art is, not how opaque it is (a glow round a glow relies on it).
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let glow = |fill_opacity: f32| {
let mut d =
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity;
render(&d).pixel(29, 50)
};
let (faint, opaque) = (glow(0.2), glow(1.0));
assert_eq!(faint[1], opaque[1], "faint {faint:?}, opaque {opaque:?}");
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
}
#[test]
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let d = doc_with(
r,
Color::BLACK,
vec![
("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 8})),
("stylize.outerGlow", json!({"color": "#0000ff", "mode": "normal", "opacity": 100, "blur": 2})),
],
);
let p = render(&d).pixel(22, 50);
assert!(p[2] > p[0] + 100, "8 pt out, the tight blue glow round the red one shows: {p:?}");
}
#[test]
fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() {
// GUARDS: at 40× the glow's raster passes the 4 Mpx budget and is made coarser instead of falling back to the
// filter layer, which blurs the faint alpha.
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let view = Affine::translate((-1100.0, -1900.0)) * Affine::scale(40.0);
let glow = |fill_opacity: f32| {
let mut d =
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity;
Renderer::new().render(&d, 200, 200, view, &opts).pixel(60, 100)
};
let (faint, opaque) = (glow(0.2), glow(1.0));
assert!(faint[1].abs_diff(opaque[1]) <= 2, "faint {faint:?}, opaque {opaque:?}");
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
}
#[test]
fn thirty_stacked_glows_draw_quickly_cached_or_not() {
// GUARDS: each glow is of the content with the glows before it; drawing those again for every glow costs 2^n.
// A shape draws through the shadow cache, a symbol instance without it (the filter layers).
let glows: Vec<(&str, serde_json::Value)> =
(0..30).map(|k| ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 20, "blur": 1 + k % 4}))).collect();
let shape = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, glows.clone());
let mut instanced = Document::new(100.0, 100.0);
let art = Node::path(
instanced.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
instanced.symbols.push(vectorcraft_doc::Symbol { name: "Square".into(), art: std::sync::Arc::new(art) });
let mut instance = Node::new(
instanced.alloc_id(),
vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) },
);
instance.appearance.effects = glows.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
let l = instanced.layers[0].id;
instanced.insert(Some(l), 0, instance).unwrap();
for d in [&shape, &instanced] {
let start = std::time::Instant::now();
let p = render(d).pixel(25, 50);
assert!(start.elapsed() < std::time::Duration::from_secs(20), "{:?}", start.elapsed());
assert!(p[1] < 250, "the glows show: {p:?}");
}
}
#[test]
fn hidden_effects_are_ignored_and_outline_mode_uses_effected_geometry() {
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
+2 -1
View File
@@ -1691,8 +1691,9 @@ impl Writer<'_> {
self.num(*dy),
flood(color, *opacity)
),
// The glow blurs a solid silhouette: alpha × 255 makes any alpha full.
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
flood(color, *opacity)
),
+3
View File
@@ -229,8 +229,11 @@ fn eval(f: &Filter) -> Option<V> {
[Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r),
_ => None,
};
// Our Outer Glow's solid silhouette (alpha × 255): an object's silhouette as far as the effects go.
let solid = matches!(c.func_a(), TransferFunction::Linear { slope, intercept } if *slope >= 255.0 && *intercept == 0.0);
match (input(c.input())?, keep, scale(c.func_a())) {
(V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }),
(V::Alpha(s), _, _) if identity && solid && s.plain() => V::Alpha(s),
(v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity)?,
_ => return None,
}
+21
View File
@@ -315,6 +315,27 @@ fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() {
assert_similar(&canvas, &exported, 40.0, 0.02);
}
#[test]
fn an_exported_outer_glow_round_faint_art_draws_as_the_canvas_does() {
// GUARDS: the export's glow blurs a solid silhouette like the renderer, not the art's faint alpha.
let mut d = Document::new(200.0, 120.0);
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(70.0, 40.0, 130.0, 80.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
n.appearance.fill_mut().unwrap().opacity = 0.2;
n.appearance.effects = vec![Effect {
id: "stylize.outerGlow".into(),
params: json!({"blur": 12.0, "color": "#ff0000", "opacity": 100.0, "mode": "normal"}),
visible: true,
}];
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let svg = export(&d, &ExportOptions::default());
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 40.0, 0.02);
}
#[test]
fn symbols_and_effects_export_import_export_is_stable() {
// A doc with a symbol instance carrying a shadow: export → import → export is stable.