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)));