M8: exports hold what stacked layer effects paint together

Layer blurs and texture apply one after another and drop shadows are cast from what they leave, but an export grew the node's box by each effect on its own. Stacked blurs came out cut at the image's edges (eight blurs of 10: alpha 10 at the edge), and a sharp shadow under a blurred layer lost the blur around it (alpha 126). Blurs now combine as one whose squared reach is the sum of theirs, texture adds on, shadows grow from that, and paint_reach follows the same rule. Reaches are clamped so adding them stays finite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samhyun
2026-10-30 09:10:14 +09:00
co-authored by Claude Opus 5.5
parent 974ab39503
commit 9426eff1ff
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");