From 0a8e9a5697e48394a07d19cc79c3e63a6127510b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:56:30 -0800 Subject: [PATCH 1/3] Canvas: moving an object with a drop shadow repaints only its area Damage treated any object with an effect as unbounded, so every drag step of a frame with a drop shadow, outer glow, feather or inner effect rendered the whole canvas again. An object's own effects are now bounded: the drop shadow and outer glow by the reach the renderer draws them with, the inner effects and feathers by the shape. Effects on items nested in a group still repaint everything. Strokes are drawn through the item's transform, so the stroke allowance now scales with it, and the strokes of a group's children count toward the group's area. --- crates/render/src/damage.rs | 135 +++++++++++++++++++++++++++-- crates/ui-egui/src/canvas/tests.rs | 28 ++++++ 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index ff76e0ecb..bacd3864c 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -158,36 +158,52 @@ fn shows_other_text(d: &Document, s: &Story) -> bool { fn item_area(it: &Item) -> Option { 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 is drawn through the item's transform. The item's own drop shadow and outer glow + // reach `fx::outset` past what it paints; its inner effects and feathers stay inside the shape. + let stroke = it.stroke.extent().max(0.0) * max_scale(it.xf)?; + let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset(it); + Some(painted_bounds(it)?.inflate(pad, pad)) +} + +/// The largest factor `m` stretches a length by (its linear part's largest singular value), or +/// None when that is not finite. +fn max_scale(m: designcraft_geom::Affine) -> Option { + 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) } /// 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 { 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 +400,107 @@ mod tests { let spreads: Vec = 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"); + } + // 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 { + 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 + } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 62064ea79..685c1b205 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2176,6 +2176,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 { From f0f609a3ba9c1e5b8ed14dacf634e96500036e7f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 08:55:00 -0800 Subject: [PATCH 2/3] Canvas: a scaled object's hardened shadow or glow stays in its damage The spread of a drop shadow or outer glow is stroked through the item's transform, so on a scaled item it reaches that much farther. Damage now grows the effect outset by the item's largest scale factor, and the multithreaded renderer crops the effect's offscreen image to the scaled reach, so the outer part of the glow is no longer cut off. --- crates/render/src/damage.rs | 42 +++++++++++++++-------- crates/render/src/fx.rs | 68 ++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/crates/render/src/damage.rs b/crates/render/src/damage.rs index bacd3864c..aa1780014 100644 --- a/crates/render/src/damage.rs +++ b/crates/render/src/damage.rs @@ -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> { damage_with(old, new, None) @@ -167,23 +169,18 @@ fn item_area(it: &Item) -> Option { if !ok { return None; } - // The stroke is drawn through the item's transform. The item's own drop shadow and outer glow - // reach `fx::outset` past what it paints; its inner effects and feathers stay inside the shape. - let stroke = it.stroke.extent().max(0.0) * max_scale(it.xf)?; - let pad = stroke + 2.0 + if it.is_text_frame() { 24.0 } else { 0.0 } + crate::fx::outset(it); + // 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)) } -/// The largest factor `m` stretches a length by (its linear part's largest singular value), or -/// None when that is not finite. -fn max_scale(m: designcraft_geom::Affine) -> Option { - 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) -} - /// 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. /// Its children's bounds include their strokes. None when a child's transform is not finite. @@ -427,6 +424,23 @@ mod tests { 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(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c7c91fe1..26a87dacd 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -35,17 +35,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 { + 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) +} + /// (blur σ, growth) in points for an effect `size` with `pct` percent hardened. fn split(size: f64, pct: f64) -> (f64, f64) { let size = bound(size).max(0.0); @@ -104,7 +124,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 @@ -125,7 +147,7 @@ impl Renderer { let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); // 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 { @@ -607,4 +629,42 @@ mod tests { // The transparent half shows the page: no shadow painted there. assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); } + + /// 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}"); + } + } } From 6d8f26a6beff4c0c5e63e5acf5b5678baefdd69d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:48:49 -0800 Subject: [PATCH 3/3] Render: cull a scaled object by its scaled stroke and glow Culling grew an object's box by its unscaled stroke and effect outset, so a view or repaint patch that showed only the outer part of a scaled object's stroke or hardened glow skipped the object. Culling now scales both by the object's largest scale factor, as drawing does. The CMYK ink-pass area grows each object, and each nested child, by its stroke and effect outset under its full transform. --- crates/render/src/blendspace.rs | 45 +++++++++++++++++++++++++++++++-- crates/render/src/draw.rs | 7 +++-- crates/render/src/fx.rs | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 6096b3419..5dd8232a5 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -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(); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 625338eb0..dbaa1d1b3 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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; } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0fc1ba048..89f193d06 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -1576,4 +1576,48 @@ mod tests { 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}"); + } + } + } }