From fb8cf27b9a4677e80b6cc84dcd13e25ac1d0dcf6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:52:45 -0800 Subject: [PATCH] Effects: clip regions follow fill effects and compound paths in every case Three cases still clipped differently from what the clipping path draws: - A clip group with an appearance of its own (a Drop Shadow, a geometry effect) was measured for culling by its plain clipping path, so a view showing only the part of the clip that an effect moved drew nothing. The renderer now grows that measure by the clip region in this case too. - The geometry effects on the fills and strokes of an unpainted clipping path applied after the effects of a group around it. They now apply first, as for other members. A clipping path that paints keeps them live, so its fill and stroke still draw. - Export baked a compound clipping path with an effect of its own from its raw paths, dropping the effects on those paths. It now bakes the clipping path as the canvas clips by it, and the pieces it bakes into get ids of their own. --- crates/effects/src/bake.rs | 19 ++++++- crates/effects/src/clip.rs | 38 +++++++------ crates/effects/src/tests/clipfx.rs | 86 ++++++++++++++++++++++++++++++ crates/render/src/fx/bounds.rs | 33 +++++++----- crates/render/src/tests/clip.rs | 37 +++++++++++++ 5 files changed, 182 insertions(+), 31 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index adbdf20b48..27d3194eb3 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -39,7 +39,7 @@ pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] } } -fn clear_item_effects(item: &mut AppearanceItem) { +pub(crate) fn clear_item_effects(item: &mut AppearanceItem) { match item { AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), @@ -202,7 +202,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return bake_leaf(d, n); } let mut m = n.clone(); - let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + // A clipping path takes the geometry effects in it as the canvas clips by it, those of a + // compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]); + // the pieces it is made of then get ids of their own. + let clip = usize::from(n.clips()); + let baked: Vec> = ch + .iter() + .enumerate() + .map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() { + Some(a) => { + let mut a = bake_node(d, &a).unwrap_or(a); + fresh_ids(d, &mut a, &mut std::collections::HashSet::new()); + Arc::new(a) + } + None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()), + }) + .collect(); if let Some(ch) = m.children_mut() { *ch = baked; } diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index e39deb32b5..60cc3f2845 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -24,17 +24,21 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { /// effects, as an export bakes it), the paths of a compound path and the visible members of a group /// are reshaped by their own effects before the effects around them, a group's or layer's own /// effects reshape its members as an export evaluates them, and other objects with effects (type) -/// become a path of their reshaped region. The geometry effects of a path's fills and strokes stay -/// live, for the export to bake. `None` when nothing in it has a geometry effect. Effects on the -/// clip group or around it then reshape the result, after its own. +/// become a path of their reshaped region. A path or compound path whose fills or strokes have +/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects +/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their +/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the +/// 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 { - clip_effects_applied(n, false) + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) } -/// [`with_clip_effects_applied`]; with `region` set, a path or compound path whose fills or strokes -/// have geometry effects becomes a group of one unpainted path per fill and stroke, reshaped by the -/// path's effects and then by that fill's or stroke's, whose union [`clip_outline`] clips by. -fn clip_effects_applied(n: &Node, region: bool) -> Option { +/// [`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 { let mut m: Option = None; let members: &[std::sync::Arc] = match &n.kind { NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), @@ -49,33 +53,35 @@ fn clip_effects_applied(n: &Node, region: 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, region && !compound) + if let Some(r) = clip_effects_applied(c, items && !compound) && 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); } } - let items = region + let items = items && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i))); - if items && let Some((g, rule, ctx, p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { let children = n .appearance .items .iter() .zip(&p.appearance.items) .map(|(item, carried)| { - let (ig, _) = crate::bake::apply_item(item, carried, &g, &ctx); - let mut piece = Node::path(n.id, ig, Appearance::default()); + let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx); + crate::bake::clear_item_effects(&mut it); + let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() }); if let NodeKind::Path { rule: r, .. } = &mut piece.kind { *r = rule; } std::sync::Arc::new(piece) }) .collect(); - let mut out = Node::new(n.id, NodeKind::Group { children, clip: false }); - out.visible = n.visible; - return Some(out); + // The path's transparency and raster effects stay on the group of its pieces. + p.appearance.items.clear(); + p.kind = NodeKind::Group { children, clip: false }; + return Some(p); } if !crate::has_geometry(&n.appearance.effects) { return m; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index c513be72c3..370f32fa81 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -131,6 +131,15 @@ fn a_compound_paths_member_keeps_its_effect() { let live = clip_outline(&moved).unwrap(); assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves"); + // With a Transform of the compound path's own moving the whole ring 10 pt down after it. + let mut both = moved.clone(); + both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap(); + let live = clip_outline(&both).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down"); } #[test] @@ -296,6 +305,83 @@ fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0)); } +#[test] +fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { + // Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform + // scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the + // clipping path is the member of a group that clips by its members. + let clip_with = |fill: Paint| { + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + } + } + clip + }; + let scaled = |clip: Node| { + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + outer + }; + fn first_clip(n: &Node) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box(); + let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5; + let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap()); + assert!(close(nested), "nested: {nested:?}"); + let outer = scaled(clip_with(Paint::None)); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()); + assert!(close(canvas), "canvas: {canvas:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); + assert!(close(exported), "exported: {exported:?}"); + // A clipping path that paints its fill keeps it, a path with the fill's effect live. + let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); + assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); +} + +#[test] +fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() { + // An unpainted clipping path whose fill and stroke have Transform effects, in a clip group + // without effects: the export bakes it into a piece for each, every object with its own id. + let mut d = Document::new(400.0, 400.0); + let (group, path) = (d.alloc_id(), d.alloc_id()); + let mut clip = clipping_path(path.0, vec![]); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))), + } + } + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap(); + let baked = bake_document(&d).unwrap(); + let clip = baked_clip(&d); + assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}"); + fn ids(n: &Node, out: &mut Vec) { + out.push(n.id); + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let mut all = vec![]; + for l in &baked.layers { + ids(l, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + #[test] fn a_hidden_member_with_an_effect_adds_nothing() { let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default()); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 7816d3f187..65ce8a91fd 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { - art.visual_bounds_with(symbols) + art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b)) } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) }?; @@ -671,18 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let b = n.visual_bounds_with(symbols)?; let b = match &n.kind { NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), - // The geometry effects in a clipping path move or grow the region it clips to - // ([`effects::clip_outline`]) past its plain shape, and its stroke paints along it. - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { - Some(c) if effects::needs_bake(c) => { - let s = c.appearance.outset(); - let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); - let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; - vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) - } - _ => b, - }, - _ => b, + _ => with_clip_region(symbols, n, b), }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) @@ -690,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b } } +/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping +/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`]) +/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`. +fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect { + match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { + Some(c) if effects::needs_bake(c) => { + let s = c.appearance.outset(); + let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); + let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; + vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) + } + _ => b, + }, + _ => b, + } +} + /// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate), /// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no /// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps diff --git a/crates/render/src/tests/clip.rs b/crates/render/src/tests/clip.rs index cdd2028441..205c2a2583 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -131,6 +131,43 @@ fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { } } +#[test] +fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() { + // As above, with the Transform effect on the clipping path itself or on its fill, and the clip + // group with an effect of its own: a Drop Shadow, or a Transform that moves nothing. + let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }; + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}), + visible: true, + }; + let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true }; + for on_fill in [false, true] { + for own in [shadow.clone(), still.clone()] { + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + match clip.appearance.items.iter_mut().find_map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f), + _ => None, + }) { + Some(f) => f.effects.push(moved()), + None => clip.appearance.effects.push(moved()), + } + let art = + Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + g.appearance.effects.push(own.clone()); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let what = format!("effect on the fill: {on_fill}, group effect {}", own.id); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})"); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})"); + } + } +} + #[test] fn clip_regions_are_cached_per_clipping_path() { let mut d = Document::new(100.0, 100.0);