diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index d648dd9258..5b7adfd80f 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -199,8 +199,19 @@ fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option { } /// 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 { + fn used(n: &Node, names: &mut Vec) { + 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 = 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 { .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 { } /// 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) -> 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, 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 { + 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 { // 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 { } // 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. diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 7b353f78f9..fba4f75e5f 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -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) { + 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]