mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge pull request #1626 from samhyun/fix/scaled-effect-reach
M8: effects reach as far under a scaled layer
This commit is contained in:
+67
-6
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user