From be44f10b6a2d2f26feb132c0c6bdebc4feb36345 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:21:58 -0800 Subject: [PATCH 1/4] Effects: a clipping path's members count toward the member limit of a group's effects When a group's geometry effects evaluate the members of the group, a clip group's clipping path takes the geometry effects in it first. A clipping path that is a group, layer or compound path had each of its members reshaped at every depth without counting them, so a large clipping path was reshaped past the SYMBOL_ART_MAX_NODES members that members_as_drawn documents. Each member of a clipping path now counts as one visited member. Once the limit is reached, that member and the members after it keep their effects, as the other members past the limit do. The canvas, the export bake and clipping type are unchanged: they do not count members. --- crates/effects/src/clip.rs | 26 ++++++++++++++++++-------- crates/effects/src/group.rs | 5 +++-- crates/effects/src/group/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 60cc3f2845..bcb2dd0995 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{BezPath, FillRule, PathData}; /// fills and strokes: the region is then the union of the paths they give, as an export bakes them. /// `None` when there is nothing to clip by (the clipped art is then hidden). pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { - match clip_effects_applied(clip, true) { + match clip_effects_applied(clip, true, || true) { Some(m) => m.clip_outline(Some(&outline_text), &unite), None => clip.clip_outline(Some(&outline_text), &unite), } @@ -31,14 +31,21 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { /// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a /// geometry effect. Effects on the clip group or around it then reshape the result, after its own. pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { - let paint = n.clip_paint(); - clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) + with_clip_effects_applied_while(n, || true) } -/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of -/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of -/// `n`'s own fills and strokes are left out. -fn clip_effects_applied(n: &Node, items: bool) -> Option { +/// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer +/// or compound path in `n`, at every depth: once `take` returns `false`, that member and the +/// members after it stay as they are, with their effects. +pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) +} + +/// [`with_clip_effects_applied_while`]; with `items` unset (a clipping path that paints, and the +/// paths of a compound path, which draw with the compound path's fills and strokes), the geometry +/// effects of `n`'s own fills and strokes are left out. +fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option { let mut m: Option = None; let members: &[std::sync::Arc] = match &n.kind { NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), @@ -53,7 +60,10 @@ fn clip_effects_applied(n: &Node, items: bool) -> Option { // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. let compound = matches!(n.kind, NodeKind::Compound { .. }); for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { - if let Some(r) = clip_effects_applied(c, items && !compound) + if !take() { + break; + } + if let Some(r) = clip_effects_applied(c, items && !compound, take) && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) { *slot = std::sync::Arc::new(r); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 20b4f982a3..34cc98e3bb 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -904,7 +904,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// depth of its groups and layers, by the art it draws ([`evaluate_container`], /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members /// draw. A clip group's clipping path only takes the geometry effects in it into its geometry -/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// ([`crate::clip::with_clip_effects_applied_while`]), and the members of a clipping group or +/// compound path count toward the limit below. Images, symbol instances and the other objects /// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D /// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members @@ -917,7 +918,7 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { // The clipping path's own geometry effects shape it before the effects around it do. if clip == 1 && let Some(first) = ch.first_mut() - && let Some(applied) = crate::clip::with_clip_effects_applied(first) + && let Some(applied) = crate::clip::with_clip_effects_applied_while(first, DrawnFrame::take_member) { *first = Arc::new(applied); } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 877528d0bb..8b8fda5727 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -849,6 +849,28 @@ fn nested_groups_with_effects_count_toward_the_depth_limit() { assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect"); } +/// The members of a clipping path count toward the member limit: with room for two more members, +/// the third member of a clipping group keeps its own effect as it is. +#[test] +fn clipping_path_members_past_the_member_limit_stay_as_they_are() { + let member = |id: u64| { + let mut m = with_effect(square_at_100(), offset_10()); + m.id = NodeId(id); + Arc::new(m) + }; + let clipping = Node::group(NodeId(20), vec![member(2), member(3), member(4)]); + let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true }); + // An enclosing call that has visited all the members it may but two. + let _outer = DrawnFrame::enter().unwrap(); + DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 2))); + members_as_drawn(None, &mut clipped); + let clipping = clipped.children().unwrap()[0].clone(); + let effects: Vec = clipping.children().unwrap().iter().map(|m| crate::reshape::geometry_effects(m).len()).collect(); + assert_eq!(effects, [0, 0, 1], "the first two members take their Offset Path, the third keeps it"); + let widths: Vec = clipping.children().unwrap().iter().map(|m| m.geometric_bounds().unwrap().width()).collect(); + assert!((widths[0] - 120.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5 && (widths[2] - 100.0).abs() < 0.5, "{widths:?}"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. From c3341be03cdb5400051e0badb46c615c7340ecf6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:27:28 -0800 Subject: [PATCH 2/4] Effects: the member limit's doc comment covers clipping paths walked twice A group or layer in a clipping path that has geometry effects of its own is walked once for its members' effects and once for its own, and its members count toward the member limit each time. The doc comment of members_as_drawn now states this. --- crates/effects/src/group.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 34cc98e3bb..1b64971a3a 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -905,7 +905,9 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members /// draw. A clip group's clipping path only takes the geometry effects in it into its geometry /// ([`crate::clip::with_clip_effects_applied_while`]), and the members of a clipping group or -/// compound path count toward the limit below. Images, symbol instances and the other objects +/// compound path count toward the limit below. A group or layer in a clipping path that has +/// geometry effects of its own is walked twice, once for its members' effects and once for its +/// own, and its members count each time. Images, symbol instances and the other objects /// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D /// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members From 40d0b31cbe4de180f99d61c5abc82371aca4c13d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:54:44 -0800 Subject: [PATCH 3/4] Effects: the clipping path doc comment says what the member limit leaves of a compound path When the member limit is reached among the paths of a compound clipping path that has geometry effects of its own, the compound path still becomes one path of its reshaped geometry. The paths that were not visited go into that geometry as they are, and their own effects are not applied. The doc comment of with_clip_effects_applied_while said that they keep their effects, which holds for the members of a group or layer only. New test a_compound_clipping_path_takes_its_own_effect_at_the_member_limit covers the compound path. --- crates/effects/src/clip.rs | 5 ++++- crates/effects/src/group/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index bcb2dd0995..d04a60b70a 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -36,7 +36,10 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { /// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the -/// members after it stay as they are, with their effects. +/// members after it are not visited. The members of a group or layer that are not visited stay as +/// they are, with their effects. A compound path with geometry effects of its own, or of its fills +/// and strokes, still becomes a path of its reshaped geometry: the paths in it that are not visited +/// go into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { let paint = n.clip_paint(); clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take) diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 8b8fda5727..519323e5f0 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -871,6 +871,31 @@ fn clipping_path_members_past_the_member_limit_stay_as_they_are() { assert!((widths[0] - 120.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5 && (widths[2] - 100.0).abs() < 0.5, "{widths:?}"); } +/// A compound clipping path with an effect of its own takes that effect when the member limit is +/// reached among its paths: with room for one more member, the first path (at 100 to 200) takes +/// its Offset Path, the second (at 300 to 400) goes in as it is, and the compound path's Offset +/// Path reshapes both, to 80 to 220 and 290 to 410. +#[test] +fn a_compound_clipping_path_takes_its_own_effect_at_the_member_limit() { + let member = |id: u64, x: f64| { + let path = Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), black()); + Arc::new(with_effect(path, offset_10())) + }; + let kind = NodeKind::Compound { children: vec![member(2, 100.0), member(3, 300.0)], rule: FillRule::NonZero }; + let clipping = with_effect(Node::new(NodeId(20), kind), offset_10()); + let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true }); + // An enclosing call that has visited all the members it may but one. + let _outer = DrawnFrame::enter().unwrap(); + DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 1))); + members_as_drawn(None, &mut clipped); + let clipping = clipped.children().unwrap()[0].clone(); + assert!(crate::reshape::geometry_effects(&clipping).is_empty(), "the compound path's Offset Path is in its geometry"); + let subpaths = clipping.path_data().unwrap().subpaths.clone(); + let bounds: Vec<(f64, f64)> = subpaths.into_iter().map(|sp| PathData::single(sp).bounds().unwrap()).map(|b| (b.x0, b.x1)).collect(); + let near = |(a, b): (f64, f64), (x0, x1): (f64, f64)| (a - x0).abs() < 0.5 && (b - x1).abs() < 0.5; + assert!(bounds.len() == 2 && near(bounds[0], (80.0, 220.0)) && near(bounds[1], (290.0, 410.0)), "{bounds:?}"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. From 66f989c27bb737ea5bc273318aa1b8a91712029b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:09:26 -0800 Subject: [PATCH 4/4] Effects: the member limit's doc comment says a compound path with fill or stroke effects becomes a group of pieces --- crates/effects/src/clip.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index d04a60b70a..c5e11c648f 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -37,9 +37,11 @@ pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { /// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer /// or compound path in `n`, at every depth: once `take` returns `false`, that member and the /// members after it are not visited. The members of a group or layer that are not visited stay as -/// they are, with their effects. A compound path with geometry effects of its own, or of its fills -/// and strokes, still becomes a path of its reshaped geometry: the paths in it that are not visited -/// go into that geometry as they are, and their own effects are not applied. +/// they are, with their effects. A compound path with geometry effects of its own still becomes a +/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or +/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both +/// cases the paths in it that are not visited go +/// into that geometry as they are, and their own effects are not applied. pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option { let paint = n.clip_paint(); clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take)