From 0df9189750afb50d47677b3b57024f7c4873bf7c Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 12:03:28 +0900 Subject: [PATCH 1/2] M8: effects reach as far under a scaled layer The renderer draws drop shadows, layer blurs and texture at their size in page units however a layer is transformed, but sized a layer from its local box grown by the effects' reach in local units, so under a transform that scales down the effect was cut (245 levels on the canvas, exports holding half to 60% of the paint). bounds_within now takes the layer's local-to-page map and turns the reach into local units per axis. A drop shadow's spread of a filled outline is the exception: the outline is grown before it is transformed, so that part follows the layer's scale. Layer bounds and own_paint_bounds take, per shadow, the larger of the two readings, so an unscaled layer comes out as before; exports of scaled-up spread shadows, which were cut, now hold them, and so do the corners of a turned rectangle's spread. The mitre reach is clamped like the other reaches, so a huge spread no longer makes bounds NaN. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 65 ++++++++++++++++-- crates/render/src/bounds_memo_tests.rs | 5 +- crates/render/src/draw.rs | 54 +++++++++++---- crates/render/src/effects_behaviour_tests.rs | 72 ++++++++++++++++++++ 4 files changed, 175 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 9dc85e6e..924141e4 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -826,12 +826,37 @@ 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 + '_ { + let corners = if self.grows_by_exactly_its_spread() { 1.0 } else { SPREAD_MITER_LIMIT }; + let layer = self.layer_effects_reach(); + self.effects.iter().filter(|e| e.visible).filter_map(move |e| match &e.kind { + EffectKind::DropShadow(s) if 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 +878,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) = (self.grows_by_exactly_its_spread(), self.layer_effects_reach()); + self.effects.iter().filter(|e| e.visible).fold(grown, |r, e| match &e.kind { + EffectKind::DropShadow(s) if 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 +928,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 +952,16 @@ impl Node { stroke + glyphs } + /// 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 diff --git a/crates/render/src/bounds_memo_tests.rs b/crates/render/src/bounds_memo_tests.rs index 4d0b305b..44d7dc42 100644 --- a/crates/render/src/bounds_memo_tests.rs +++ b/crates/render/src/bounds_memo_tests.rs @@ -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); } } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index a2afba14..075278cc 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -414,17 +414,33 @@ 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) -> 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) -> 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; 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 || !det.is_finite() || det < 1e-12 { + return (reach, reach); + } + let (x, y) = ((d.abs() + c.abs()) / det * reach, (b.abs() + a.abs()) / det * reach); + if x.is_finite() && y.is_finite() { (x, y) } else { (reach, reach) } } /// [`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, memo: Option<&Bounds<'a>>, @@ -437,8 +453,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 +471,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 +488,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 +547,14 @@ pub fn effect_margin(n: &Node) -> f64 { pub fn paint_bounds(doc: &Document, id: NodeId) -> Option { 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 { @@ -585,7 +613,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. @@ -937,7 +965,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); diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index 9a91bebc..a06cd453 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -272,6 +272,78 @@ 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| { + 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::(); + 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 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() } From 07c936f560e379678ae628bb166338b5769a27c5 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 14:12:58 +0900 Subject: [PATCH 2/2] M8: PR #1626 review: tiny scales and pixel-spread layers local_reach gave up on transforms whose determinant was under 1e-12 and kept the reach in page units, so a layer scaled to a ten millionth (a billion units drawn 100 wide) had its shadow cut (245 levels). It now converts any finite, non-zero scale, capping the result where the sums would overflow. Groups and stroked text spread their shadows as pixels, in page units: their spread no longer also counts in local units. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 14 +++++++++--- crates/render/src/draw.rs | 16 ++++++++++---- crates/render/src/effects_behaviour_tests.rs | 23 ++++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 924141e4..6ec7e824 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -847,8 +847,9 @@ impl Node { pub fn outline_spreads(&self) -> impl Iterator + '_ { 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 s.spread.is_finite() && s.spread > 0.0 => { + 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))) } @@ -883,9 +884,9 @@ impl Node { // 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) = (self.grows_by_exactly_its_spread(), self.layer_effects_reach()); + 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 s.spread.is_finite() && s.spread > 0.0 => { + 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)) @@ -952,6 +953,13 @@ 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() diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 075278cc..bf02b899 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -422,17 +422,25 @@ pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, to_page: Affine, dept /// 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; a map that collapses the layer keeps `reach`. +/// 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 || !det.is_finite() || det < 1e-12 { + if reach <= 0.0 || !reach.is_finite() || !det.is_finite() || det == 0.0 { return (reach, reach); } - let (x, y) = ((d.abs() + c.abs()) / det * reach, (b.abs() + a.abs()) / det * reach); - if x.is_finite() && y.is_finite() { (x, y) } else { (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 diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index a06cd453..24b66a68 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -330,6 +330,29 @@ fn effects_reach_as_far_under_a_scaled_or_turned_transform() { } } +#[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.