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.
This commit is contained in:
dotCooCoo committed 2026-11-12 12:48:49 -08:00
1 parent 8121bca1fe
commit 6d8f26a6be
3 files changed
+92 -4

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();
+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;
}
+44
View File
@@ -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}");
}
}
}
}