Export: symbol instances in opacity masks draw their art's live effects

PDF, EPS, print and DXF export place each symbol instance whose art has
live effects as that art, baked. The bake followed only an object's
children, so an instance in the art of an enabled opacity mask stayed an
instance, and the mask was written with the symbol's stored art and
without its effects. A symbol whose art used such a symbol only inside a
mask was not placed either.

The bake now also looks into the art of enabled opacity masks: that art
is baked one level deeper and written back to the mask, and symbols that
use a placed symbol only inside a mask are placed too. Disabled masks
and the masks of objects the export leaves out stay as they are.
This commit is contained in:
dotCooCoo
2026-11-14 13:46:23 -08:00
parent 107078ad10
commit 500138365b
2 changed files with 117 additions and 12 deletions
+49 -12
View File
@@ -199,8 +199,19 @@ fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option<u64> {
}
/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art
/// holds an instance of one of them at any depth.
/// holds an instance of one of them at any depth, in the art of enabled opacity masks too.
fn drawn_symbols(d: &Document) -> HashSet<String> {
fn used(n: &Node, names: &mut Vec<String>) {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
names.push(symbol.clone());
}
if let Some(m) = n.extras().mask.as_ref().filter(|m| !m.disabled) {
used(&m.art, names);
}
for c in n.children().into_iter().flatten() {
used(c, names);
}
}
let mut out: HashSet<String> = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect();
if out.is_empty() {
return out;
@@ -211,11 +222,7 @@ fn drawn_symbols(d: &Document) -> HashSet<String> {
.filter(|s| !out.contains(&s.name))
.map(|s| {
let mut names = vec![];
s.art.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
names.push(symbol.clone());
}
});
used(&s.art, &mut names);
(s.name.as_str(), names)
})
.collect();
@@ -233,14 +240,22 @@ fn drawn_symbols(d: &Document) -> HashSet<String> {
}
/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces
/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]).
/// by its placed art, in the art of its enabled opacity masks too? False outside it, and for
/// objects the export does not write ([`unwritten`]).
fn holds_placed_symbol(n: &Node) -> bool {
fn walk(n: &Node, symbols: &HashSet<String>) -> bool {
placed_symbol_in(n, true)
}
/// [`holds_placed_symbol`], with the art of `n`'s own opacity mask left out unless `own_mask`
/// (the art of the masks inside `n` is always looked into).
fn placed_symbol_in(n: &Node, own_mask: bool) -> bool {
fn walk(n: &Node, symbols: &HashSet<String>, own_mask: bool) -> bool {
!unwritten(n)
&& (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol))
|| n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols))))
|| (own_mask && !n.is_ruler_guide() && n.extras().mask.as_ref().is_some_and(|m| !m.disabled && walk(&m.art, symbols, true)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols, true))))
}
!UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols)))
!UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols, own_mask)))
}
/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it
@@ -278,8 +293,29 @@ fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> {
Some((g, most))
}
/// `n` with its geometry effects evaluated (`None` = unchanged).
/// `n` with its geometry effects evaluated (`None` = unchanged), and the art of its enabled opacity
/// mask baked one level deeper when it holds instances that [`bake_document_placing_symbols`]
/// places ([`holds_placed_symbol`]), so the mask masks with the art the instances draw. The art is
/// baked only while the object baked from `n` still carries `n`'s mask art; art already baked
/// with `n`'s recolored or placed form is not baked again. A ruler guide's mask stays as it is.
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let out = bake_object(d, n);
let Some(mask) = n.extras().mask.as_ref().filter(|m| !m.disabled && !n.is_ruler_guide() && !unwritten(n) && holds_placed_symbol(&m.art)) else {
return out;
};
let changed = out.is_some();
let mut out = out.unwrap_or_else(|| n.clone());
let Some(slot) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|s| Arc::ptr_eq(&s.art, &mask.art)) else {
return changed.then_some(out);
};
let Some(art) = one_deeper(|| bake_node(d, &mask.art)) else { return changed.then_some(out) };
slot.art = Arc::new(art);
Some(out)
}
/// `n` with its geometry effects evaluated (`None` = unchanged); its opacity mask is left to
/// [`bake_node`].
fn bake_object(d: &mut Document, n: &Node) -> Option<Node> {
// Inside an object the export leaves out, effects are baked but no instance is placed.
if !UNWRITTEN.with(|c| c.get()) && unwritten(n) {
UNWRITTEN.with(|c| c.set(true));
@@ -289,7 +325,8 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
}
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
// A ruler guide draws nothing and stays as it is, last in its layer.
if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() {
// An object whose own mask's art alone holds placed instances stays as it is here.
if n.is_ruler_guide() || !(needs_bake(n) || placed_symbol_in(n, false)) || vectorcraft_doc::cancel::is_cancelled() {
return None;
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
+68
View File
@@ -1169,6 +1169,74 @@ fn placing_symbols_reaches_the_art_of_nested_instances() {
assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900));
}
/// A 10 pt square with id `id`, masked by `art` (the mask enabled unless `disabled`).
fn masked_square(id: u64, art: Node, disabled: bool) -> Node {
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut mask = vectorcraft_doc::OpacityMask::new(art, false);
mask.disabled = disabled;
n.extras_mut().mask = Some(Box::new(mask));
n
}
/// Whether the art of `n`'s opacity mask is still a symbol instance, and its bounds when it is not.
fn mask_art(n: &Node) -> (bool, Option<Rect>) {
let art = &n.extras().mask.as_ref().unwrap_or_else(|| panic!("node {:?} has a mask", n.id)).art;
(matches!(art.kind, NodeKind::SymbolInstance { .. }), art.geometric_bounds())
}
/// An instance in the art of an enabled opacity mask is placed and baked like any other, so the
/// mask masks with the art the instance draws, and the masked object stays as it is (a clipping
/// path stays one); one in a disabled mask, in the mask of an object the export leaves out, or in
/// a ruler guide's mask, stays as it is.
#[test]
fn placing_symbols_reaches_instances_in_opacity_masks() {
use vectorcraft_geom::Affine;
let mut d = doc_with(masked_square(700, instance(900, "S", Affine::IDENTITY), false));
let layer = d.layers[0].id;
d.insert(Some(layer), 1, masked_square(701, instance(901, "S", Affine::IDENTITY), true)).unwrap();
let mut hidden = masked_square(702, instance(902, "S", Affine::IDENTITY), false);
hidden.visible = false;
d.insert(Some(layer), 2, hidden).unwrap();
let mut clipping = masked_square(703, instance(903, "S", Affine::IDENTITY), false);
if let NodeKind::Path { clipping: c, .. } = &mut clipping.kind {
*c = true;
}
d.insert(Some(layer), 3, Node::group(NodeId(704), vec![Arc::new(clipping)])).unwrap();
let mut guide = Node::new(NodeId(705), NodeKind::RulerGuide { vertical: false, pos: 5.0, artboard: None });
guide.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(instance(905, "S", Affine::IDENTITY), false)));
d.insert(Some(layer), 4, guide).unwrap();
add_symbol(&mut d, "S", moved_square(800));
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
for id in [700, 703] {
let (still, b) = mask_art(out.node(NodeId(id)).unwrap());
assert!(!still && b.is_some_and(|b| near_rect(b, Rect::new(110.0, 40.0, 120.0, 50.0))), "{id}: {b:?}");
}
assert!(matches!(out.node(NodeId(703)).unwrap().kind, NodeKind::Path { clipping: true, .. }), "the clipping path stays one");
assert!(mask_art(out.node(NodeId(701)).unwrap()).0, "a disabled mask's instance stays");
assert!(mask_art(out.node(NodeId(702)).unwrap()).0, "a hidden object's mask's instance stays");
assert!(mask_art(out.node(NodeId(705)).unwrap()).0, "a ruler guide's mask's instance stays");
}
/// A symbol whose art holds an instance only in an opacity mask's art is placed too, and the
/// instance in its mask is placed with it.
#[test]
fn placing_symbols_reaches_instances_in_the_masks_of_symbol_art() {
use vectorcraft_geom::Affine;
let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY));
add_symbol(&mut d, "Inner", moved_square(800));
add_symbol(&mut d, "Outer", masked_square(803, instance(802, "Inner", Affine::IDENTITY), false));
let out = bake_document_placing_symbols(&d, true, ALL).unwrap();
assert!(!is_instance(&out, 900), "the outer instance is placed");
let mut found = None;
out.node(NodeId(900)).unwrap().walk(&mut |n| {
if n.id == NodeId(803) {
found = Some(mask_art(n));
}
});
let (still, b) = found.unwrap_or_else(|| panic!("the masked square is in the placed art"));
assert!(!still && b.is_some_and(|b| near_rect(b, Rect::new(110.0, 40.0, 120.0, 50.0))), "{b:?}");
}
/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export
/// ends.
#[test]