Merge pull request #1589 from samhyun/fix/export-stacked-blur-bounds

M8: exports hold what stacked layer effects paint together
This commit is contained in:
BFlat
2026-10-30 07:53:10 +07:00
committed by GitHub
4 changed files with 92 additions and 8 deletions
+30
View File
@@ -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:?}");
}
+34 -8
View File
@@ -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 }
}
+5
View File
@@ -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 {
@@ -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");