From 6d8f26a6beff4c0c5e63e5acf5b5678baefdd69d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:48:49 -0800 Subject: [PATCH] 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}"); + } + } + } }