Merge pull request #1626 from samhyun/fix/scaled-effect-reach

M8: effects reach as far under a scaled layer
This commit is contained in:
BFlat
2026-10-30 13:01:40 +07:00
committed by GitHub
4 changed files with 214 additions and 21 deletions
+67 -6
View File
@@ -826,12 +826,38 @@ impl Node {
self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0
}
/// Maximum distance (in local units) painting can reach outside the node box.
/// Maximum distance painting can reach outside the node box: strokes and glyphs (local units)
/// and effects (page units, see [`Node::effect_paint_reach`]), the same when the node's
/// transform doesn't scale.
pub fn paint_reach(&self) -> f64 {
self.outline_reach() + self.effects_reach() + self.spread_miter_reach()
self.outline_reach() + self.effect_paint_reach()
}
/// How far (local units) this node's effects paint past what they apply to: the layer effects
/// How far (page units: effects are drawn at the same size however a layer is scaled) this
/// node's effects paint outside its box, drop shadows' spread at sharp corners included.
pub fn effect_paint_reach(&self) -> f64 {
self.effects_reach() + effect_reach(self.spread_miter_reach())
}
/// Each visible drop shadow with a spread, as (local units, page units): a filled outline is
/// grown by the spread (sharp corners' mitre included) before it is transformed, so that part
/// follows the node's scale, and the shadow then reaches as far again as its offset and blur,
/// from the layer its layer effects moved. Layers that aren't one outline spread in page units
/// instead, as counted in [`Node::effect_paint_reach`].
pub fn outline_spreads(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
let corners = if self.grows_by_exactly_its_spread() { 1.0 } else { SPREAD_MITER_LIMIT };
let layer = self.layer_effects_reach();
let outlined = self.may_spread_as_an_outline();
self.effects.iter().filter(|e| e.visible).filter_map(move |e| match &e.kind {
EffectKind::DropShadow(s) if outlined && s.spread.is_finite() && s.spread > 0.0 => {
let page = s.radius.abs() * 1.5 + s.offset.0.abs().max(s.offset.1.abs());
Some((effect_reach(s.spread) * corners, layer + effect_reach(page)))
}
_ => None,
})
}
/// How far (page 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);
@@ -853,7 +879,30 @@ impl Node {
/// grown by its strokes and glyphs, then by its effects (see [`Node::grow_by_effects`]).
pub fn own_paint_bounds(&self, world: Affine) -> figcraft_geom::Rect {
let o = self.outline_reach();
self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, self.local_rect().inflate(o, o)))
let base = self.local_rect().inflate(o, o);
let grown = self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, base));
// A filled outline's spread grows in local units (see [`Node::outline_spreads`]): a box by
// its spread all round, sharp corners' mitres anywhere within a circle of it.
let [a, b, c, d, _, _] = world.as_coeffs();
let (exact, layer, outlined) = (self.grows_by_exactly_its_spread(), self.layer_effects_reach(), self.may_spread_as_an_outline());
self.effects.iter().filter(|e| e.visible).fold(grown, |r, e| match &e.kind {
EffectKind::DropShadow(s) if outlined && s.spread.is_finite() && s.spread > 0.0 => {
let spread = effect_reach(s.spread);
let outline = if exact {
figcraft_geom::transform_rect_bbox(world, base.inflate(spread, spread))
} else {
let m = spread * SPREAD_MITER_LIMIT;
figcraft_geom::transform_rect_bbox(world, base).inflate(m * a.hypot(c), m * b.hypot(d))
};
let (g, (dx, dy)) = (layer + s.radius.abs() * 1.5, s.offset);
if g.is_finite() && dx.is_finite() && dy.is_finite() && outline.is_finite() {
r.union(outline.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy))
} else {
r
}
}
_ => r,
})
}
/// Page-space area `b` grown by what this node's visible effects paint around it: blurs all
@@ -880,7 +929,7 @@ impl Node {
}
/// How far (local units) strokes and glyphs reach outside the box.
fn outline_reach(&self) -> f64 {
pub fn outline_reach(&self) -> f64 {
let stroke = if self.has_visible_stroke() {
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight());
let base = match self.stroke.align {
@@ -904,11 +953,23 @@ impl Node {
stroke + glyphs
}
/// Whether a drop shadow's spread can grow this node's outline (see [`Node::outline_spreads`]):
/// not a group's or stroked text's, which the renderer spreads as pixels, in page units. Others
/// can still turn out to be drawn that way (children, images with alpha): counted both ways.
fn may_spread_as_an_outline(&self) -> bool {
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
}
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners).
fn grows_by_exactly_its_spread(&self) -> bool {
matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like()
}
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
/// spread moves the edges out in parallel and they meet in a mitre (see
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread.
fn spread_miter_reach(&self) -> f64 {
if matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() {
if self.grows_by_exactly_its_spread() {
return 0.0;
}
self.effects
+3 -2
View File
@@ -24,8 +24,9 @@ fn kept_paint_bounds_are_the_bounds_worked_out_afresh() {
for id in &order {
let n = d.node(*id).unwrap();
for depth in [0, 20] {
let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0;
assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name);
let world = d.world_transform(*id);
let kept = draw::bounds_within(&d, n, world, depth, None, Some(&memo)).0;
assert_eq!(kept, draw::local_paint_bounds(&d, n, world, depth, None), "{} at depth {depth}", n.name);
}
}
}
+49 -13
View File
@@ -414,17 +414,41 @@ fn text_box_bounds(n: &Node) -> Rect {
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
/// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
bounds_within(doc, n, depth, greek_scale, None).0
/// `to_page` is the node's local → page map, for how far its effects reach (see [`local_reach`]).
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, to_page: Affine, depth: usize, greek_scale: Option<f64>) -> Rect {
bounds_within(doc, n, to_page, depth, greek_scale, None).0
}
/// How far `reach` page units go in a layer's local units along x and y, under `to_page` (its
/// local → page map): effects are drawn at their size in page units however the layer is scaled,
/// so they reach further in a layer scaled down. A page square of half-size `reach` comes back as
/// the rows of the inverse map, however small the scale (capped where the sums would overflow); a
/// map that collapses the layer keeps `reach`.
fn local_reach(to_page: Affine, reach: f64) -> (f64, f64) {
let [a, b, c, d, _, _] = to_page.as_coeffs();
let det = (a * d - b * c).abs();
if reach <= 0.0 || !reach.is_finite() || !det.is_finite() || det == 0.0 {
return (reach, reach);
}
let along = |u: f64, v: f64| {
let r = (u.abs() + v.abs()) / det * reach;
if r.is_nan() { reach } else { r.min(MAX_LOCAL_REACH) }
};
(along(d, c), along(b, a))
}
/// The most [`local_reach`] makes of a reach (local units): bounds that large are cut to the view,
/// and grown by it they stay finite.
const MAX_LOCAL_REACH: f64 = 1e150;
/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many
/// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on
/// the depth they were asked at and aren't kept, and kept bounds are read only where their levels
/// still fit under the cap.
/// still fit under the cap. They are kept by node: one render meets a node under one `to_page`.
pub(crate) fn bounds_within<'a>(
doc: &'a Document,
n: &Node,
to_page: Affine,
depth: usize,
greek_scale: Option<f64>,
memo: Option<&Bounds<'a>>,
@@ -437,8 +461,14 @@ pub(crate) fn bounds_within<'a>(
{
return (r, Some(levels));
}
let reach = n.paint_reach();
let mut r = n.local_rect().inflate(reach, reach);
// Effects in page units, but a filled outline's spread in local units: as far as either goes.
let (fx, fy) = n.outline_spreads().fold(local_reach(to_page, n.effect_paint_reach()), |(x, y), (spread, page)| {
let (px, py) = local_reach(to_page, page);
(x.max(spread + px), y.max(spread + py))
});
let outline = n.outline_reach();
let grow = |r: Rect| r.inflate(outline + fx, outline + fy);
let mut r = grow(n.local_rect());
let mut levels = Some(0);
let clips = n.clip && n.kind.is_frame_like();
if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() {
@@ -449,15 +479,15 @@ pub(crate) fn bounds_within<'a>(
if !c.visible {
continue;
}
let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo);
let (cb, c_levels) = bounds_within(doc, c, to_page * c.transform, depth + 1, greek_scale, memo);
below = below.zip(c_levels).map(|(b, l)| b.max(l));
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 });
let (ex, ey) = local_reach(to_page, n.effects_reach());
r = r.union(if ex > 0.0 || ey > 0.0 { k.inflate(ex, ey) } else { k });
}
levels = below.map(|b| b + 1);
} else if !n.children.is_empty() {
@@ -466,7 +496,7 @@ pub(crate) fn bounds_within<'a>(
}
// Text can overflow its box.
if let NodeKind::Text(_) = n.kind {
r = r.union(text_box_bounds(n).inflate(reach, reach));
r = r.union(grow(text_box_bounds(n)));
if !greek_scale.is_some_and(|s| lod::greeked(n, s))
&& let Some((l, dy)) = figcraft_text::layout_node(n)
&& let Some(ink) = l.ink_bounds()
@@ -525,8 +555,14 @@ pub fn effect_margin(n: &Node) -> f64 {
pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
let m = effect_margin(n).abs();
let local = local_paint_bounds(doc, n, 0, None);
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m))
let world = doc.world_transform(id);
let local = local_paint_bounds(doc, n, world, 0, None);
Some(figcraft_geom::transform_rect_bbox(world, local).inflate(m, m))
}
/// The local → page map of a layer drawn at `xf` (local → device): the view's scale taken out.
fn to_page(ctx: &Ctx, xf: Affine) -> Affine {
if ctx.scale.is_finite() && ctx.scale > 0.0 { Affine::scale(1.0 / ctx.scale) * xf } else { xf }
}
fn needs_layer(n: &Node) -> bool {
@@ -622,7 +658,7 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u
}
let xf = parent_xf * n.transform;
let (pw, ph) = (pix.width(), pix.height());
let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
let local = bounds_within(ctx.doc, n, to_page(ctx, xf), 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
let dev = device_rect(xf, local);
// Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or
// blur of millions of pixels) keep a sane layer: what lies further off reads as empty.
@@ -974,7 +1010,7 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf:
}
let reach = |id: &NodeId| {
let n = ctx.bounds.node(ctx.doc, *id)?;
let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
let (local, levels) = bounds_within(ctx.doc, n, to_page(ctx, xf * n.transform), 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
// Bounds the nesting cap cut short could miss a layer: anywhere then.
if levels.is_none() {
return Some(whole);
@@ -272,6 +272,101 @@ fn a_containers_effects_reach_past_what_its_children_paint() {
}
}
#[test]
fn effects_reach_as_far_under_a_scaled_or_turned_transform() {
use figcraft_geom::kurbo::Affine;
let shadow = |d: (f64, f64)| fx(EffectKind::DropShadow(sharp(d, 0.0)));
let spread = |s: f64| fx(EffectKind::DropShadow(sharp((0.0, 0.0), s)));
let turned = Affine::translate((150.0, 50.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4);
// (why, kind, transform, effects, a child's effects): effects are page units however a layer
// is transformed, as the renderer draws them, but for a shadow's spread of a filled outline,
// which grows the outline before it is transformed.
let rows = [
("a halved rectangle's shadow", NodeKind::Rectangle, Affine::scale(0.5), vec![shadow((0.0, 80.0))], None),
("a turned rectangle's shadow", NodeKind::Rectangle, turned, vec![shadow((40.0, 40.0))], None),
("a halved group's shadow", NodeKind::Group, Affine::scale(0.5), vec![shadow((0.0, 80.0))], Some(vec![])),
("a shadow in a halved group", NodeKind::Group, Affine::scale(0.5), vec![], Some(vec![shadow((0.0, 80.0))])),
("a flattened rectangle's shadow", NodeKind::Rectangle, Affine::scale_non_uniform(1.0, 0.25), vec![shadow((0.0, 80.0))], None),
("a doubled rectangle's shadow", NodeKind::Rectangle, Affine::scale(2.0), vec![shadow((0.0, 80.0))], None),
("a doubled rectangle's spread shadow", NodeKind::Rectangle, Affine::scale(2.0), vec![spread(20.0)], None),
("a doubled star's spread shadow", NodeKind::Star { count: 5, inner_ratio: 0.4 }, Affine::scale(2.0), vec![spread(10.0)], None),
("a doubled group's spread shadow", NodeKind::Group, Affine::scale(2.0), vec![spread(20.0)], Some(vec![])),
("a turned star's spread shadow", NodeKind::Star { count: 5, inner_ratio: 0.4 }, turned, vec![spread(20.0)], None),
];
for (why, kind, xf, effects, child_fx) in rows {
// `room`: a clear drop shadow wider than the view on each layer, so none can be cut: what
// they should draw.
let scene = |room: bool| {
let clear = fx(EffectKind::DropShadow(Shadow { color: Color::TRANSPARENT, radius: 400.0, ..sharp((0.0, 0.0), 0.0) }));
let with_room = |mut e: Vec<Effect>| {
if room {
e.push(clear.clone());
}
e
};
let (mut d, p) = page();
let n = d.create(p, kind.clone(), "N", 0.0, 0.0, 100.0, 100.0).unwrap();
if let Some(cfx) = &child_fx {
let r = rect(&mut d, n, (0.0, 0.0, 100.0, 100.0), vec![Paint::solid(hex("FF0000"))]);
d.get_mut(r).unwrap().effects = with_room(cfx.clone());
}
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::solid(hex("FF0000"))];
node.transform = xf;
node.effects = with_room(effects.clone());
(d, n)
};
let ((d, n), (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);
let ink = |p: Pixmap| p.pixels().iter().map(|c| u64::from(c.alpha())).sum::<u64>();
let (got, all) = (ink(render_node(&d, n, 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");
}
}
#[test]
fn effects_reach_as_far_under_a_tiny_scale() {
// A rectangle `w` units wide drawn 100 × 100 on the page casts the shadow a 100 × 100 one does:
// shadows are page units.
let scene = |w: f64| {
let (mut d, p) = page();
let r = rect(&mut d, p, (0.0, 0.0, w, w), vec![Paint::solid(hex("FF0000"))]);
let n = d.get_mut(r).unwrap();
n.transform = figcraft_geom::kurbo::Affine::scale(100.0 / w);
n.effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 80.0), 0.0)))];
d
};
let v = View { origin: (-100.0, -100.0), scale: 1.0, width: 400, height: 400 };
let truth = scene(100.0);
let b = render_view_single(&truth, page_of(&truth), &v, &Options::default()).unwrap();
for w in [1e6, 1e8, 1e9] {
let d = scene(w);
let a = render_view_single(&d, page_of(&d), &v, &Options::default()).unwrap();
let worst = a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= 1, "{w} units wide: the canvas draws {worst} levels off");
}
}
#[test]
fn a_spread_counts_once_in_an_export() {
// A 100 × 100 rectangle with a shadow spread by 20 all round, unscaled and doubled.
for (scale, side) in [(1.0, 140), (2.0, 280)] {
let (mut d, p) = page();
let r = rect(&mut d, p, (0.0, 0.0, 100.0, 100.0), vec![Paint::solid(hex("FF0000"))]);
let n = d.get_mut(r).unwrap();
n.transform = figcraft_geom::kurbo::Affine::scale(scale);
n.effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 0.0), 20.0)))];
let px = render_node(&d, r, 1.0).unwrap();
assert_eq!((px.width(), px.height()), (side, side), "at scale {scale}");
}
}
fn page_of(d: &Document) -> NodeId {
d.first_page().unwrap()
}