SVG export: masked groups span their region only for effects that are written

A fill or stroke that the export leaves out (hidden, unpainted or zero
width) no longer adds the hidden rectangle for an effect on it. An
instance of a dynamic symbol is checked in its overridden art, so an
override that paints a fill or stroke with an effect still adds it.
This commit is contained in:
dotCooCoo
2026-11-14 03:43:24 -08:00
parent 14076169fc
commit 48bbcb115d
2 changed files with 49 additions and 10 deletions
+18 -9
View File
@@ -449,17 +449,24 @@ impl Writer<'_> {
/// Do `n`, its fills and strokes, its members at any depth, its symbols' art or the art its
/// live objects are written as have a visible raster effect (shadow, glow, blur, feather)?
/// Objects that aren't written (hidden ones, unless the options keep them) are skipped. The
/// walk keeps its own stack, and looks at each symbol's art once.
/// Objects that aren't written (hidden ones, unless the options keep them) are skipped, and so
/// are fills and strokes that [`Self::shape`] doesn't write (hidden, unpainted or zero-width).
/// An instance with overrides is looked at in the art it is written as
/// ([`vectorcraft_brush::instance_art`]). The walk keeps its own stack, and looks at each
/// symbol's art once, and at each instance's overridden art once.
fn holds_raster(&self, n: &Node) -> bool {
let mut stack: Vec<std::sync::Arc<Node>> = vec![];
let mut symbols = std::collections::HashSet::new();
let mut symbols: std::collections::HashSet<(String, Option<vectorcraft_doc::NodeId>)> = std::collections::HashSet::new();
let paints = |i: &AppearanceItem| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
};
// Whether `n` itself has one; else its members, symbol art or expanded art go on the stack.
let mut visit = |n: &Node, stack: &mut Vec<std::sync::Arc<Node>>| {
if !(n.visible || self.opts.hidden_layers) {
return false;
}
if has_raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_raster(i.effects())) {
if has_raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| paints(i) && has_raster(i.effects())) {
return true;
}
// A live object is written as its expanded art ([`Self::node_body`]), which holds no
@@ -471,11 +478,13 @@ impl Writer<'_> {
return false;
}
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& symbols.insert(symbol.clone())
&& let Some(s) = self.doc.symbols.iter().find(|s| s.name == *symbol)
{
stack.push(s.art.clone());
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
let custom = !n.extras().symbol_overrides.is_empty();
if symbols.insert((symbol.clone(), custom.then_some(n.id)))
&& let Some(s) = self.doc.symbols.iter().find(|s| s.name == *symbol)
{
stack.push(if custom { std::sync::Arc::new(vectorcraft_brush::instance_art(&s.art, n)) } else { s.art.clone() });
}
}
stack.extend(n.children().into_iter().flatten().cloned());
false
+31 -1
View File
@@ -261,9 +261,12 @@ fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
InGroup,
InNestedGroup,
FillEffect,
HiddenFill,
UnpaintedStroke,
HiddenInGroup,
Brushed,
InSymbol,
OverriddenSymbol,
InBlend,
}
use Case::*;
@@ -275,11 +278,17 @@ fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
(InNestedGroup, true, true),
(FillEffect, true, true),
(FillEffect, false, false),
// A fill or stroke that isn't written doesn't count.
(HiddenFill, true, false),
(UnpaintedStroke, true, false),
(HiddenInGroup, true, false),
(Brushed, false, false),
(Brushed, true, true),
(InSymbol, true, true),
(InSymbol, false, false),
// An unpainted fill in a dynamic symbol's art that the instance paints counts.
(OverriddenSymbol, true, true),
(OverriddenSymbol, false, false),
(InBlend, true, true),
(InBlend, false, false),
] {
@@ -290,15 +299,28 @@ fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
stroke.brush = Some("Bristle Round".into());
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(stroke));
}
if matches!(case, UnpaintedStroke) {
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::None, 8.0)));
}
if shadowed {
match case {
FillEffect => red.appearance.items[0].effects_mut().push(drop_shadow()),
FillEffect | HiddenFill | OverriddenSymbol => red.appearance.items[0].effects_mut().push(drop_shadow()),
UnpaintedStroke => red.appearance.items.last_mut().unwrap().effects_mut().push(drop_shadow()),
_ => red.appearance.effects.push(drop_shadow()),
}
}
if matches!(case, HiddenFill)
&& let vectorcraft_doc::AppearanceItem::Fill(f) = &mut red.appearance.items[0]
{
f.visible = false;
}
if matches!(case, OverriddenSymbol) {
red.appearance.set_fill(Paint::None);
}
let group = |d: &mut Document, n: Node| Node::group(d.alloc_id(), vec![n.into()]);
let mut masked = match case {
Alone | FillEffect => red,
HiddenFill | UnpaintedStroke => group(&mut d, red),
InGroup | Brushed => group(&mut d, red),
InNestedGroup => {
let inner = group(&mut d, red);
@@ -313,6 +335,14 @@ fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
group(&mut d, instance)
}
OverriddenSymbol => {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "Square".into(), art: std::sync::Arc::new(red) });
let mut instance =
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let fill = Some(Paint::solid(Color::rgb8(255, 0, 0)));
instance.extras_mut().symbol_overrides.push(vectorcraft_doc::symbol_overrides::SymbolOverride { fill, ..Default::default() });
group(&mut d, instance)
}
InBlend => {
let mut blue = rect(&mut d, Rect::new(60.0, 10.0, 80.0, 30.0), [0, 0, 255]);
blue.appearance.effects = red.appearance.effects.clone();