From 5473e3a5c585a2d5a0f254860085cd9ed5464747 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 09:53:46 -0700 Subject: [PATCH] Effects: type in a group with a geometry effect keeps its transparency, mask, effects and visibility Geometry effects on a group or layer, and effects on a symbol instance, blend or repeat, outline the type inside it. outline_nested_text replaced each type object with a plain group of its outlines, so the type lost its opacity, blend mode, opacity mask, knockout shape option, own effects and perspective, and hidden type was drawn, exported and made visible by Expand Appearance. outline_nested_text now replaces type with the art that as_art makes for it, which is also what reshape makes for type with geometry effects of its own. That art has the type's own geometry effects and perspective applied and keeps its name, transparency, opacity mask and other effects. as_art also keeps the object's visibility and knockout shape option, so hidden type with a geometry effect of its own is left out of exports. Type in a clip group's clipping path still becomes its plain outlines and now keeps its visibility. --- crates/effects/src/group.rs | 34 ++++++++++++++ crates/effects/src/reshape.rs | 35 +++++++++----- crates/engine/src/tests_containers.rs | 38 +++++++++++++++ crates/engine/src/tests_persp_text.rs | 15 ++++++ crates/render/src/tests_container.rs | 68 +++++++++++++++++++++++++++ crates/render/src/tests_objectfx.rs | 23 +++++++++ docs/target-app-parity.md | 1 + 7 files changed, 203 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index f54f34cf0..e224d62ef 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -330,6 +330,40 @@ mod tests { assert!((b.0 - a.0 - 5.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9, "{a:?} {b:?}"); } + /// What [`evaluate_container`] makes of black 40 pt type "IIII" (id 3), changed by `style`, + /// in a group with a square and Offset Path 0, which leaves every path as it is. + fn type_in_group(style: impl FnOnce(&mut Node)) -> Node { + let ts = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", ts); + let mut t = Node::new(NodeId(3), NodeKind::Text(Box::new(text))); + style(&mut t); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); + g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); + let art = evaluate_container(&g).unwrap(); + (*art.children().unwrap()[1]).clone() + } + + #[test] + fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { + use vectorcraft_color::BlendMode; + let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); + assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); + assert_eq!((art.opacity, art.blend), (0.5, BlendMode::Multiply)); + // Its opacity mask, its own shadow, its knockout shape option and its visibility stay. + let mask_art = Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default()); + let mask = vectorcraft_doc::OpacityMask::new(mask_art, true); + assert_eq!(type_in_group(|t| t.extras_mut().mask = Some(Box::new(mask.clone()))).extras().mask.as_deref(), Some(&mask)); + let shadow = Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }; + assert_eq!(type_in_group(|t| t.appearance.effects.push(shadow.clone())).appearance.effects, vec![shadow]); + assert!(type_in_group(|t| t.knockout_shape = true).knockout_shape); + assert!(!type_in_group(|t| t.visible = false).visible); + // Its own Transform moves its outlines 30 pt before the group's effect reshapes them. + let x0 = |n: Node| n.geometric_bounds().unwrap().x0; + let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }; + let moved = x0(type_in_group(|t| t.appearance.effects.push(transform))); + assert!((moved - x0(type_in_group(|_| {})) - 30.0).abs() < 1e-6, "{moved}"); + } + #[test] fn hidden_effect_or_plain_group_is_untouched() { let mut g = group_with("pathfinder.add"); diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 763f30658..c3f07831f 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -86,7 +86,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), _ => return None, }; - outline_nested_text(&mut art); + outline_nested_text(&mut art, false); art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -97,22 +97,33 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { Some(art) } -/// Replace type anywhere inside `n` by its outlines. -fn outline_nested_text(n: &mut Node) { +/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look +/// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes +/// its plain outlines ([`outline_text`]) with its visibility, which is what +/// [`crate::clip_outline`] clips by. +fn outline_nested_text(n: &mut Node, clipping: bool) { + let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; - for c in ch.iter_mut() { - if let Some(o) = outline_text(c) { + for (i, c) in ch.iter_mut().enumerate() { + let clipping = clipping || (clips && i == 0); + let art = match &c.kind { + NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + NodeKind::Text(_) => as_art(c, None), + _ => None, + }; + if let Some(o) = art { *c = Arc::new(o); } else if c.children().is_some() { - outline_nested_text(Arc::make_mut(c)); + outline_nested_text(Arc::make_mut(c), clipping); } } } -/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`] -/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is -/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` -/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). +/// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: +/// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped +/// frame. The result is a group with `n`'s id, name, visibility, transparency, opacity mask and +/// remaining (raster) effects; `None` when `n` has neither a visible geometry effect nor a +/// perspective, or has no art (a symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) { return None; @@ -154,11 +165,13 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { // The art is a group with `n`'s id (a clip group for an image). let mut out = art; out.name = n.name.clone(); + out.visible = n.visible; out.opacity = n.opacity; out.blend = n.blend; out.isolate = n.isolate; // One element: its pieces never knock each other out. out.knockout = vectorcraft_doc::Knockout::Off; + out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); Some(out) @@ -281,7 +294,7 @@ fn image_count(n: &Node) -> usize { pub(crate) fn outlined_members(n: &Node) -> Node { let hook: &dyn Fn(&Node) -> Option = &outline_text; let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook)); - outline_nested_text(&mut art); + outline_nested_text(&mut art, false); art } diff --git a/crates/engine/src/tests_containers.rs b/crates/engine/src/tests_containers.rs index 211bca2e1..b06aae7eb 100644 --- a/crates/engine/src/tests_containers.rs +++ b/crates/engine/src/tests_containers.rs @@ -149,3 +149,41 @@ fn group_appearance_round_trips_and_expands() { // Moved down 50 pt; the outlined 1 pt stroke reaches half a point beyond. assert!((n.geometric_bounds().unwrap().y0 - 69.5).abs() < 1e-6); } + +#[test] +fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() { + let mut s = session(); + let square = id_of(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))); + let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"}))); + run(&mut s, "transparency.set", json!({"ids": [t.0], "opacity": 50})); + run(&mut s, "select.set", json!({"ids": [square.0, t.0]})); + let g = id_of(&run(&mut s, "object.group", json!({}))); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 0}})); + // SVG export writes the type's outlines at its opacity, and none of them while it is hidden. + let svg = |s: &Session| vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); + let shown = svg(&s); + assert!(!shown.contains("