Merge pull request #2654 from dotCooCoo/fix/effect-damage-bounds

Canvas: moving an object with a drop shadow repaints only its area
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 15:50:44 -05:00
commit 7362fffbcf
5 files changed
+325 -16

No files matched your search

+43 -2
View File
@@ -371,10 +371,18 @@ impl Renderer {
}
}
// What an object can paint on the spread: its bounds with its (and its children's)
// strokes and effects.
// strokes and effects, which are drawn through each one's transform and grow with it.
fn outset(it: &Item, outer: Affine, o: &mut f64) {
let xf = outer * it.xf;
let scale = crate::fx::max_scale(xf).unwrap_or(1.0);
*o = o.max(crate::fx::outset_scaled(it, scale) + it.stroke.extent() * scale);
for c in it.children() {
outset(c, xf, o);
}
}
let reach = |it: &Item| {
let mut o: f64 = 0.0;
it.walk(&mut |c| o = o.max(crate::fx::outset(c) + c.stroke.extent()));
outset(it, Affine::IDENTITY, &mut o);
it.bounds().inflate(o + 2.0, o + 2.0)
};
let items: Vec<(Rect, bool)> = sp.items.iter().filter(|it| visible(it)).map(|it| (reach(it), transparent(it))).collect();
@@ -601,6 +609,39 @@ mod tests {
assert_eq!(draw(&doc, &mut renderer).pixels, rgb.pixels);
}
/// A hardened outer glow is stroked through the object's transform, and through its group's,
/// so the ink-pass area grows by the scaled growth: the square at 30..60 glows out to -30.
#[test]
fn cmyk_area_holds_a_scaled_objects_glow() {
for nested in [false, true] {
let mut doc = document();
let mut item = Item::new(
ItemId(doc.alloc()),
doc.default_layer(),
Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)),
);
item.fill = Fill::swatch("C=100 M=0 Y=0 K=0");
let g = &mut item.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
if nested {
let mut group = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Group, Default::default());
group.xf = Affine::scale(3.0);
group.content = Content::Group { items: vec![Arc::new(item)] };
doc.insert_item(SpreadRef::Doc(0), group, None).unwrap();
} else {
item.xf = Affine::scale(3.0);
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
}
let cache = Cache::new();
let opts = RenderOptions::default();
let f =
Frame { doc: &doc, cache: &cache, view: Affine::IDENTITY, visible: Rect::new(-1e4, -1e4, 1e4, 1e4), px: 1.0, opts: &opts, mt: false };
let area = Renderer::new().transparent_area(&f, SpreadRef::Doc(0)).unwrap();
assert!(area.x0 <= -30.0, "nested {nested}: the area {area:?} holds the glow out to -30");
}
}
#[test]
fn cmyk_pass_count_is_constant_and_unchanged_views_are_cached() {
let mut doc = document();
+141 -8
View File
@@ -11,6 +11,8 @@ use std::sync::Arc;
use designcraft_doc::{Document, Item, ItemId, SpreadRef, Story, StoryId};
use designcraft_geom::Rect;
use crate::fx::max_scale;
/// Changed regions in spread coordinates, or `None` when the whole view must be repainted.
pub fn damage(old: &Document, new: &Document) -> Option<Vec<(SpreadRef, Rect)>> {
damage_with(old, new, None)
@@ -158,36 +160,47 @@ fn shows_other_text(d: &Document, s: &Story) -> bool {
fn item_area(it: &Item) -> Option<Rect> {
let mut ok = true;
it.walk(&mut |i| {
// Effects spill outside; text wrap reflows other frames.
if i.effects.any() || i.has_wrap() {
// Text wrap reflows other frames. A nested item's effects are drawn through its parents'
// transforms, which can scale how far they reach.
if i.has_wrap() || !std::ptr::eq(i, it) && i.effects.any() {
ok = false;
}
});
if !ok {
return None;
}
let pad = it.stroke.extent().max(0.0) + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 };
Some(painted_bounds(it).inflate(pad, pad))
// The stroke and the effects' spread are drawn through the item's transform. The item's own
// drop shadow and outer glow reach `fx::outset_scaled` past what it paints; its inner effects
// and feathers stay inside the shape.
let scale = max_scale(it.xf)?;
let stroke = it.stroke.extent().max(0.0) * scale;
let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset_scaled(it, scale);
if !pad.is_finite() {
return None;
}
Some(painted_bounds(it)?.inflate(pad, pad))
}
/// An item's bounds, grown to its text: text is composed in the box around the frame's path in
/// the frame's own coordinates, which a sheared then rotated frame turns past its path's bounds.
fn painted_bounds(it: &Item) -> Rect {
/// Its children's bounds include their strokes. None when a child's transform is not finite.
fn painted_bounds(it: &Item) -> Option<Rect> {
let mut b = it.bounds();
if it.is_text_frame() {
b = b.union(it.xf.transform_rect_bbox(it.inner_bounds()));
}
for c in it.children() {
b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)));
let stroke = c.stroke.extent().max(0.0) * max_scale(c.xf)?;
b = b.union(it.xf.transform_rect_bbox(painted_bounds(c)?.inflate(stroke, stroke)));
}
b
Some(b)
}
/// A text frame's area: its spread and bounds with room for glyph overhang.
fn frame_area(d: &Document, f: ItemId) -> Option<(SpreadRef, Rect)> {
let loc = d.find(f)?;
let it = d.item_at(&loc)?;
let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it));
let b = d.parent_xf(&loc).transform_rect_bbox(painted_bounds(it)?);
// Overhang of italics, swashes and big initials, and anchored objects pinned to lines.
Some((loc.spread, b.inflate(24.0 + 0.1 * b.width().max(b.height()), 24.0)))
}
@@ -384,4 +397,124 @@ mod tests {
let spreads: Vec<SpreadRef> = dmg.iter().map(|(r, _)| *r).collect();
assert!(spreads.contains(&SpreadRef::Doc(1)), "{dmg:?}");
}
/// Moving an object with a drop shadow or an outer glow damages where its effect was and is,
/// and every pixel the move changes lies in that damage: the canvas repaints those regions,
/// not the whole view.
#[test]
fn moving_an_object_with_a_shadow_or_glow_damages_its_effect_area() {
for glow in [false, true] {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it =
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0)));
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
if glow {
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 30.0, 20.0);
} else {
let s = &mut it.effects.drop_shadow;
(s.on, s.color, s.opacity, s.size, s.distance, s.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 25.0, 30.0);
}
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let old = d.clone();
let it = d.item_mut(id).unwrap();
it.path.transform(designcraft_geom::Affine::translate((60.0, 40.0)));
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| !Rect::new(98.0, 98.0, 312.0, 242.0).contains(*p)), "glow {glow}: the effect paints outside the object");
}
// A hardened glow on a scaled object: its growth is stroked through the transform, so it
// reaches three times as far on the spread as the glow's size says.
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)));
it.xf = designcraft_geom::Affine::scale(3.0);
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let old = d.clone();
let it = d.item_mut(id).unwrap();
it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf;
// The square covers 150..270 before the move; its glow reaches 60 pt past that.
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| p.x < 110.0), "the hard glow grows with the object's scale");
// An effect on an object inside a group is drawn through the group's transform: unbounded.
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let mut child =
Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 9.0, 9.0)));
child.effects.drop_shadow.on = true;
let gid = ItemId(d.alloc());
let mut group = Item::new(gid, lid, designcraft_doc::Shape::Group, Default::default());
group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] };
d.insert_item(SpreadRef::Doc(0), group, None).unwrap();
let old = d.clone();
d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::scale(4.0);
assert!(damage(&old, &d).is_none());
}
/// A stroke is drawn through its item's transform: a frame scaled up paints its stroke that
/// much wider, and moving it damages all of it.
#[test]
fn moving_a_scaled_frame_damages_its_whole_stroke() {
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let lid = d.default_layer();
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 125.0, 100.0)));
it.xf = designcraft_geom::Affine::scale(2.0);
it.stroke.swatch = designcraft_color::swatch::BLACK.into();
it.stroke.weight = 20.0;
it.stroke.align = designcraft_doc::StrokeAlign::Outside;
it.effects.outer_glow.on = true;
it.effects.outer_glow.size = 0.0;
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let old = d.clone();
let it = d.item_mut(id).unwrap();
it.xf = designcraft_geom::Affine::translate((60.0, 40.0)) * it.xf;
// The stroke reaches 40 pt past the frame's 100..250 × 100..200 outline on the spread.
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| p.x < 70.0), "the stroke paints its scaled width");
// The same frame inside a group with a gradient feather: the child's stroke counts too.
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
let mut child = old.spreads[0].items[0].as_ref().clone();
child.effects = Default::default();
child.id = ItemId(d.alloc());
let gid = ItemId(d.alloc());
let outline = designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 250.0, 200.0));
let mut group = Item::new(gid, d.default_layer(), designcraft_doc::Shape::Group, outline);
group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child)] };
let gf = &mut group.effects.gradient_feather;
(gf.on, gf.start, gf.end) = (true, 1.0, 0.5);
d.insert_item(SpreadRef::Doc(0), group, None).unwrap();
let old = d.clone();
d.item_mut(gid).unwrap().xf = designcraft_geom::Affine::translate((60.0, 40.0));
let changed = changed_pixels_in_damage(&old, &d);
assert!(changed.iter().any(|p| p.x < 70.0), "the child's stroke paints its scaled width");
}
/// The pixels a page render changes from `old` to `new`, each checked to lie in the damage.
fn changed_pixels_in_damage(old: &Document, new: &Document) -> Vec<designcraft_geom::Point> {
let dmg = damage(old, new).expect("bounded damage");
let cache = designcraft_compose::Cache::new();
let mut r = crate::Renderer::new();
r.threads = 0;
let opts = crate::RenderOptions::default();
let before = r.render_page(old, &cache, 0, 1.0, false, &opts).unwrap();
let after = r.render_page(new, &cache, 0, 1.0, false, &opts).unwrap();
let mut changed = Vec::new();
for y in 0..before.height {
for x in 0..before.width {
if before.pixel(x, y) == after.pixel(x, y) {
continue;
}
let p = designcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5);
assert!(dmg.iter().any(|(_, r)| r.inflate(1.0, 1.0).contains(p)), "changed pixel {p:?} outside {dmg:?}");
changed.push(p);
}
}
changed
}
}
+5 -2
View File
@@ -179,7 +179,10 @@ impl Renderer {
return;
}
let xf = parent * it.xf;
let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0);
// The stroke and the effects' growth are drawn through `xf`, which stretches them.
let scale = fx::max_scale(xf).unwrap_or(1.0);
let stroke = it.stroke.extent() * scale;
let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(stroke + 2.0, stroke + 2.0);
// A frame with items pasted into it draws like any other object (draw_body: fill, the items
// clipped to the frame, stroke), so its effects apply to all of it.
if !it.children().is_empty() && !it.has_nested_items() {
@@ -193,7 +196,7 @@ impl Renderer {
_ => 0.0,
};
let effects = it.effects.any() && f.opts.quality != DisplayQuality::Fast;
let fx_outset = if effects { fx::outset(it) } else { 0.0 };
let fx_outset = if effects { fx::outset_scaled(it, scale) } else { 0.0 };
if !rect_overlaps(vb.inflate(margin + fx_outset, margin + fx_outset), f.visible) {
return;
}
+108 -4
View File
@@ -40,17 +40,37 @@ pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
/// How far (points) the effects of `it` reach outside its geometry.
pub(crate) fn outset(it: &Item) -> f64 {
outset_scaled(it, 1.0)
}
/// [`outset`] for an item whose transform stretches lengths by up to `scale`: the blur and the
/// shadow's distance are spread lengths, but the spread's growth is stroked through the item's
/// transform and grows with it.
pub(crate) fn outset_scaled(it: &Item, scale: f64) -> f64 {
let e = &it.effects;
let extra = |size: f64, pct: f64| split(size, pct).1 * (scale - 1.0).max(0.0);
let mut o: f64 = 0.0;
if e.drop_shadow.on {
o = o.max(bound(e.drop_shadow.distance).abs() + bound(e.drop_shadow.size).abs() * 1.6 + 1.0);
let s = &e.drop_shadow;
o = o.max(bound(s.distance).abs() + bound(s.size).abs() * 1.6 + 1.0 + extra(s.size, s.spread));
}
if e.outer_glow.on {
o = o.max(bound(e.outer_glow.size).abs() * 1.6 + 1.0);
let g = &e.outer_glow;
o = o.max(bound(g.size).abs() * 1.6 + 1.0 + extra(g.size, g.spread));
}
o
}
/// The largest factor `m` stretches a length by (its linear part's largest singular value), or
/// None when that is not finite.
pub(crate) fn max_scale(m: Affine) -> Option<f64> {
let [a, b, c, d, _, _] = m.as_coeffs();
let s = a * a + b * b + c * c + d * d;
let det = a * d - b * c;
let v = ((s + (s * s - 4.0 * det * det).max(0.0).sqrt()) / 2.0).sqrt();
v.is_finite().then_some(v)
}
/// Whether `it` has a basic feather: a width on a closed shape.
fn basic_feather(it: &Item) -> bool {
it.effects.feather > 0.0 && it.path.is_closed()
@@ -120,7 +140,9 @@ impl Renderer {
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) {
let doc = f.doc;
let e = &it.effects;
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() };
// The stroke and the effects' growth are drawn through `xf`, which stretches them.
let scale = max_scale(xf).unwrap_or(1.0);
let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() * scale };
// Spread-space reach of what the object paints.
let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);
// The silhouette is the shape when the object paints its area (fill / image); text frames
@@ -150,7 +172,7 @@ impl Renderer {
let filter = shadow(sigma, paint);
// The offset moves the geometry, not the filter: vello drops layer content that lies
// entirely outside the viewport before filtering.
let r = (reach + off).inflate(grow, grow);
let r = (reach + off).inflate(grow * scale, grow * scale);
// The effect's blend sees what's behind the object: a layer around the filtered one.
let mix = crate::blend_mode(mode).mix;
if mix != Mix::Normal {
@@ -1516,4 +1538,86 @@ mod tests {
}
}
}
/// A hardened outer glow is stroked through the item's transform, so a scaled item's glow
/// grows with it. The multithreaded renderer draws the glow offscreen, cropped to its reach,
/// and that reach scales too.
#[test]
fn a_scaled_items_hard_glow_is_not_cropped() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, SpreadRef};
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut it = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)),
);
it.xf = Affine::scale(3.0);
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
// max_scale's own cases: a plain scale, a rotation and a shear.
assert_eq!(max_scale(it.xf), Some(3.0));
assert!((max_scale(Affine::rotate(0.7)).unwrap() - 1.0).abs() < 1e-12);
assert!((max_scale(Affine::skew(1.0, 0.0)).unwrap() - (1.0 + 5f64.sqrt()) / 2.0).abs() < 1e-12);
assert_eq!(max_scale(Affine::scale(f64::INFINITY)), None);
// The 20 pt growth is 60 pt on the spread: the glow covers 90..330.
assert_eq!(outset_scaled(&it, 3.0), outset(&it) + 40.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
let cache = crate::Cache::new();
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap();
let p = page.pixel(100, 210);
let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
assert!(lum < 200, "{threads} threads: the glow 50 pt left of the scaled square is drawn, luminance {lum}");
}
}
/// Culling measures a scaled item's stroke and hardened glow as they are drawn, through its
/// transform, so a view (or a repaint patch) that shows only that part still draws it.
#[test]
fn a_scaled_items_hard_glow_and_stroke_are_not_culled() {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, SpreadRef};
let square = |glow: bool| {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let mut it = Item::new(
designcraft_doc::ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(50.0, 50.0, 90.0, 90.0)),
);
it.xf = Affine::scale(3.0);
it.fill = designcraft_doc::Fill::swatch(designcraft_color::swatch::BLACK);
if glow {
let g = &mut it.effects.outer_glow;
(g.on, g.color, g.opacity, g.size, g.spread) = (true, designcraft_color::swatch::BLACK.into(), 1.0, 20.0, 100.0);
} else {
(it.stroke.swatch, it.stroke.weight) = (designcraft_color::swatch::BLACK.into(), 20.0);
}
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
d
};
let cache = crate::Cache::new();
let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
// The square covers 150..270 on the spread. Its glow grows 60 pt (to 90), past the
// unscaled 33 pt reach; its 20 pt stroke is 60 pt wide (to 120), past the unscaled 22 pt.
for (glow, x) in [(true, 95.0), (false, 123.0)] {
let d = square(glow);
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let place = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let image = r.render(&d, &cache, &place, (4, 20), Affine::translate((-x, -200.0)), &opts);
let p = image.pixel(1, 10);
let lum = u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]);
assert!(lum < 200, "glow {glow}, {threads} threads: the view at x {x} shows the scaled square's edge, luminance {lum}");
}
}
}
}
+28
View File
@@ -2420,6 +2420,34 @@ fn edits_patch_the_canvas_only_at_full_resolution() {
}
}
/// Moving a frame with a drop shadow repaints the regions the frame and its shadow cover, as
/// moving one without a shadow does, instead of rendering the whole canvas again on every step.
#[test]
fn moving_a_frame_with_a_drop_shadow_patches_the_canvas() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let frame = |app: &mut DesignApp| {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
};
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [72, 72, 200, 180], "content": "unassigned"})).unwrap();
app.run("object.fill", json!({"swatch": "[Black]"})).unwrap();
app.run("object.dropShadow", json!({"on": true})).unwrap();
for _ in 0..3 {
frame(&mut app);
}
for step in 1..=3 {
app.run("transform.move", json!({"dx": 4, "dy": 2})).unwrap();
frame(&mut app);
assert_eq!(app.canvas.patches, step, "drag step {step} repaints only what changed");
}
}
/// The Type tool's caret at the end of a new text frame holding `text`, the app's first frames
/// drawn; returns the story id.
fn typing_app(app: &mut DesignApp, ctx: &egui::Context, text: &str) -> Value {