diff --git a/crates/doc/src/effects_tests.rs b/crates/doc/src/effects_tests.rs index 3aa25ed1..0ef39c85 100644 --- a/crates/doc/src/effects_tests.rs +++ b/crates/doc/src/effects_tests.rs @@ -78,3 +78,33 @@ fn spread_reaches_further_at_sharp_corners_only() { assert_eq!(d.node(r).unwrap().paint_reach(), 10.0); assert_eq!(d.node(s).unwrap().paint_reach(), 10.0 * SPREAD_MITER_LIMIT); } + +#[test] +fn stacked_layer_effects_reach_together_and_shadows_from_there() { + let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0); + let blur = |r: f64| Effect { kind: EffectKind::LayerBlur(Blur::uniform(r)), visible: true }; + // One blur reaches as before; four of 10 as one of 20 (their variances add). + n.effects = vec![blur(10.0)]; + assert_eq!(n.layer_effects_reach(), 15.0); + n.effects = vec![blur(10.0), blur(10.0), blur(10.0), blur(10.0)]; + assert!((n.layer_effects_reach() - 30.0).abs() < 1e-9, "{}", n.layer_effects_reach()); + // A sharp shadow below is cast from the blurred layer: it reaches the blur's distance further. + let shadow = + Effect { kind: EffectKind::DropShadow(Shadow { radius: 0.0, offset: (0.0, 50.0), spread: 0.0, ..Shadow::default() }), visible: true }; + n.effects = vec![blur(10.0), shadow]; + let b = n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!((b.y0, b.y1), (-15.0, 75.0), "{b:?}"); + // Hidden effects reach nowhere. + n.effects = vec![Effect { visible: false, ..blur(10.0) }]; + assert_eq!(n.layer_effects_reach(), 0.0); +} + +#[test] +fn reaches_too_large_to_add_stay_finite() { + let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0); + let t = Texture { radius: 1e308, ..Default::default() }; + n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }]; + let (reach, b) = (n.paint_reach(), n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0))); + assert!(reach.is_finite() && reach > 1e6, "{reach}"); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 1e6, "{b:?}"); +} diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ae4ae447..ef6b94a8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -828,8 +828,20 @@ impl Node { /// Maximum distance (in local units) painting can reach outside the node box. pub fn paint_reach(&self) -> f64 { - let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max); - self.outline_reach() + fx + self.spread_miter_reach() + let layer = self.layer_effects_reach(); + 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() + } + + /// How far the layer blurs and texture move the layer's pixels together: they apply one after + /// another, each moving what the one before it moved. Blurs one after another spread as one + /// blur whose radius squared is the sum of theirs (Gaussians add their variances); texture + /// scatters by up to its radius, added on. Drop shadows are cast from what they leave. + pub fn layer_effects_reach(&self) -> f64 { + let moving = || self.effects.iter().filter(|e| e.moves_the_layer()); + let blurs: f64 = moving().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).map(|e| effect_reach(e.reach()).powi(2)).sum(); + let texture: f64 = moving().filter(|e| matches!(e.kind, EffectKind::Texture(_))).map(|e| effect_reach(e.reach())).sum(); + effect_reach(blurs.sqrt()) + texture } /// The page-space area this node paints itself under `world` (its children aside): its box @@ -842,18 +854,23 @@ impl Node { /// Page-space area `b` grown by what this node's visible effects paint around it: blurs all /// round, drop shadows towards their offset (in page units, as the renderer draws them), so a /// shadow falling down adds room below and none above. + /// Layer blurs and texture grow it by what they reach together, and drop shadows are cast from + /// that (see [`Node::layer_effects_reach`]). pub fn grow_by_effects(&self, b: figcraft_geom::Rect) -> figcraft_geom::Rect { let miter = self.spread_miter_reach(); - self.effects.iter().filter(|e| e.visible).fold(b, |r, e| match &e.kind { + let layer = self.layer_effects_reach(); + let moved = if layer > 0.0 { b.inflate(layer, layer) } else { b }; + self.effects.iter().filter(|e| e.visible).fold(moved, |r, e| match &e.kind { EffectKind::DropShadow(s) => { let g = s.radius.abs() * 1.5 + s.spread.max(0.0) + miter; let (dx, dy) = s.offset; - if g.is_finite() && dx.is_finite() && dy.is_finite() { r.union(b.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) } else { r } - } - _ => { - let g = e.reach(); - if g.is_finite() && g > 0.0 { r.union(b.inflate(g, g)) } else { r } + if g.is_finite() && dx.is_finite() && dy.is_finite() { + r.union(moved.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) + } else { + r + } } + _ => r, }) } @@ -899,3 +916,12 @@ impl Node { .fold(0.0, f64::max) } } + +/// The most one effect's reach counts for (document units) when reaches are added up: far past any +/// canvas, and a sum of any number of them (or of their squares) stays finite. +const MAX_EFFECT_REACH: f64 = 1e100; + +/// An effect's reach made safe to add: finite, in 0..[`MAX_EFFECT_REACH`]. +fn effect_reach(r: f64) -> f64 { + if r.is_finite() { r.clamp(0.0, MAX_EFFECT_REACH) } else { 0.0 } +} diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 403620d6..92ec539c 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -869,6 +869,11 @@ impl Effect { _ => 0.0, } } + /// Whether this is a visible layer blur or texture: what moves the layer's own pixels, one + /// after another in list order. + pub fn moves_the_layer(&self) -> bool { + self.visible && matches!(self.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_)) + } /// A layer or background blur's settings. pub fn blur(&self) -> Option<&Blur> { match &self.kind { diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index 5a4b6289..f32463e3 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -378,6 +378,29 @@ fn exports_include_what_effects_paint_outside_the_layer() { assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported"); } +/// Layer blurs and texture apply one after another and drop shadows are cast from what they leave: +/// the export holds what they reach together, so nothing is cut at its edges. +#[test] +fn exports_hold_what_stacked_effects_reach_together() { + let blur = |r: f64| fx(EffectKind::LayerBlur(Blur::uniform(r))); + let rows = [ + ("four blurs of 25", vec![blur(25.0), blur(25.0), blur(25.0), blur(25.0)]), + ("eight blurs of 10", (0..8).map(|_| blur(10.0)).collect()), + ("sixteen blurs of 3", (0..16).map(|_| blur(3.0)).collect()), + ("texture over blurs", vec![blur(15.0), blur(15.0), fx(EffectKind::Texture(figcraft_doc::Texture { radius: 12.0, ..Default::default() }))]), + ("a blur under a shadow", vec![blur(20.0), fx(EffectKind::DropShadow(sharp((0.0, 30.0), 0.0)))]), + ("a shadow then a blur", vec![fx(EffectKind::DropShadow(sharp((0.0, 30.0), 0.0))), blur(20.0)]), + ]; + for (why, effects) in rows { + let (d, r) = square(vec![Paint::solid(hex("FF0000"))], effects); + let p = render_node(&d, r, 1.0).unwrap(); + let (w, h) = (p.width(), p.height()); + let alpha = |x: u32, y: u32| p.pixel(x, y).map_or(0, |c| c.alpha()); + let edge = (0..w).flat_map(|x| [alpha(x, 0), alpha(x, h - 1)]).chain((0..h).flat_map(|y| [alpha(0, y), alpha(w - 1, y)])).max().unwrap_or(0); + assert!(edge <= 1, "{why}: the export's edge has alpha {edge} ({w} × {h})"); + } +} + /// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×. fn blurred_frame(radius: f64) -> Pixmap { let mut d = Document::new("Blur range");