From 81f89b314bdbe0031202f351ca55c25548c15330 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 10:51:57 +0900 Subject: [PATCH] M8: a group's effects reach past what its children paint A group or an unclipped frame applies its layer blur, drop shadows and texture to everything its children paint, including children reaching past its box. The renderer sized the container's layer from the union of its children without growing it by the container's own effects, so the blur or shadow was cut where a child's paint ended; exports of unclipped frames lost part of the shadow the same way. bounds_within now grows the children's union by the node's effects reach, and render_bounds_at grows an unclipped frame's children by its effects. The retained partial-render test drew this very cut (61 levels in its group child scene); with the shadow counted the damage passes the partial share of its 300 x 220 view, so the view is widened to keep it a partial render. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 6 ++- crates/doc/src/node.rs | 9 +++- crates/render/src/draw.rs | 9 +++- crates/render/src/effects_behaviour_tests.rs | 53 ++++++++++++++++++++ crates/render/src/retained_tests.rs | 5 +- 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 88851ff5..9c581760 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -738,7 +738,8 @@ impl Document { /// Bounds of everything painted by a node, effects and strokes included (page coordinates): /// a clipping frame's own area, else that and what its visible children show (masked - /// children only within their mask). A group is what its children show, grown by its effects. + /// children only within their mask). A group, or an unclipped frame past its box, is what its + /// children show grown by its effects, which apply to all of it. pub fn world_render_bounds(&self, id: NodeId) -> Option { self.render_bounds_at(id, 0) } @@ -751,7 +752,8 @@ impl Document { } Some(match (self.children_render_bounds(n, depth + 1), &n.kind) { (Some(kids), NodeKind::Group) => n.grow_by_effects(kids), - (Some(kids), _) => own.union(kids), + // An unclipped frame's effects apply to its children too, past its box included. + (Some(kids), _) => own.union(n.grow_by_effects(kids)), (None, _) => own, }) } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ef6b94a8..9dc85e6e 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -828,9 +828,14 @@ impl Node { /// Maximum distance (in local units) painting can reach outside the node box. pub fn paint_reach(&self) -> f64 { - let layer = self.layer_effects_reach(); + self.outline_reach() + self.effects_reach() + self.spread_miter_reach() + } + + /// How far (local units) this node's effects paint past what they apply to: the layer effects + /// together (see [`Node::layer_effects_reach`]), then the largest of the others. + pub fn effects_reach(&self) -> f64 { let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max); - self.outline_reach() + layer + fx + self.spread_miter_reach() + self.layer_effects_reach() + fx } /// How far the layer blurs and texture move the layer's pixels together: they apply one after diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 8cc1dfe9..a2afba14 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -444,13 +444,20 @@ pub(crate) fn bounds_within<'a>( if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() { if depth < MAX_BOUNDS_DEPTH { let mut below = Some(0); + let mut kids: Option = None; for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))) { if !c.visible { continue; } let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo); below = below.zip(c_levels).map(|(b, l)| b.max(l)); - r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb)); + let cb = figcraft_geom::transform_rect_bbox(c.transform, cb); + kids = Some(kids.map_or(cb, |k| k.union(cb))); + } + // This layer's effects apply to what its children paint, past its box included. + if let Some(k) = kids { + let e = n.effects_reach(); + r = r.union(if e > 0.0 { k.inflate(e, e) } else { k }); } levels = below.map(|b| b + 1); } else if !n.children.is_empty() { diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index f32463e3..9a91bebc 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -228,6 +228,59 @@ fn effects_on_a_group_apply_to_the_group_as_a_whole() { } } +/// A group's or an unclipped frame's effects apply to what its children paint, children reaching +/// past its box included: its layer holds that too, grown by the effects, so nothing is cut where +/// a child's paint ends; and an unclipped frame's export holds it. +#[test] +fn a_containers_effects_reach_past_what_its_children_paint() { + let blur = || fx(EffectKind::LayerBlur(Blur::uniform(20.0))); + let shadow = |dy: f64| fx(EffectKind::DropShadow(sharp((0.0, dy), 0.0))); + // (why, container, its effects, the child's box, the child's effects) + let rows = [ + ("a blurred group over a child's shadow", NodeKind::Group, vec![blur()], (20.0, 20.0, 60.0, 60.0), vec![shadow(80.0)]), + ("an unclipped frame's shadow of a child sticking out", NodeKind::Frame, vec![shadow(40.0)], (60.0, 60.0, 80.0, 80.0), vec![]), + ("an unclipped frame's blur of a child sticking out", NodeKind::Frame, vec![blur()], (60.0, 60.0, 80.0, 80.0), vec![]), + ]; + for (why, kind, effects, (x, y, w, h), child_fx) in rows { + // `room`: an empty child as large as the view, so the layer can't be cut: what it should draw. + let scene = |room: bool| { + let (mut d, p) = page(); + let c = d.create(p, kind.clone(), "C", 0.0, 0.0, 100.0, 100.0).unwrap(); + let node = d.get_mut(c).unwrap(); + node.clip = false; + node.fills.clear(); + node.effects = effects.clone(); + let r = rect(&mut d, c, (x, y, w, h), vec![Paint::solid(hex("FF0000"))]); + d.get_mut(r).unwrap().effects = child_fx.clone(); + if room { + rect(&mut d, c, (-200.0, -200.0, 600.0, 600.0), vec![]); + } + (d, c) + }; + let ((d, c), (truth, _)) = (scene(false), scene(true)); + let v = View { origin: (-100.0, -100.0), scale: 1.0, width: 400, height: 400 }; + let (a, b) = ( + render_view_single(&d, page_of(&d), &v, &Options::default()).unwrap(), + render_view_single(&truth, page_of(&truth), &v, &Options::default()).unwrap(), + ); + let worst = a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + // The export holds all of it: as much paint as the export with room to spare. + let ink = |p: Pixmap| p.pixels().iter().map(|c| u64::from(c.alpha())).sum::(); + let (got, all) = (ink(render_node(&d, c, 1.0).unwrap()), ink(render_node(&truth, page_child(&truth), 1.0).unwrap())); + assert!(worst <= 1, "{why}: the canvas draws {worst} levels off"); + assert!(got * 1000 >= all * 999, "{why}: the export holds {got} of {all} of the paint"); + } +} + +fn page_of(d: &Document) -> NodeId { + d.first_page().unwrap() +} + +/// The page's first layer. +fn page_child(d: &Document) -> NodeId { + *d.node(page_of(d)).unwrap().children.first().unwrap() +} + #[test] fn inner_shadows_paint_inside_over_the_fills_under_strokes_and_children() { let green = [0, 255, 0]; diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index e6c09ec5..1b1d9445 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -187,9 +187,10 @@ fn edits_under_and_beside_effects_match() { assert!(partial(check(&mut r, &across, s.page, &v))); let beside = edit(&across, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((10.0, 110.0))); assert!(partial(check(&mut r, &beside, s.page, &v))); - // A child of a group with a drop shadow: the shadow moves too. + // A child of a group with a drop shadow: the shadow moves too, past the group's box with the + // child (a view large enough that this stays a part of it). let g1 = s.doc.find_by_name("G1", None)[0]; - let v = View { origin: (260.0, 0.0), scale: 1.0, width: 300, height: 220 }; + let v = View { origin: (260.0, 0.0), scale: 1.0, width: 400, height: 300 }; check(&mut r, &beside, s.page, &v); let shadowed = edit(&beside, |d| d.get_mut(g1).unwrap().transform = Affine::translate((30.0, 20.0))); assert!(partial(check(&mut r, &shadowed, s.page, &v)));