Compare commits

...

2 Commits

Author SHA1 Message Date
Brandon Thomas 19ccaa164f Render tests: probe the outer shadow where the narrower blur still reaches
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:08:52 -04:00
Brandon Thomas 16ac6ac991 Render: shadows, inner shadows and glows spread like PowerPoint's
Measured on PowerPoint's export of probe shapes:
- outer and inner shadows are blurred by a third of the blur radius (was
  half), so their profile matches pixel for pixel;
- an inner shadow falls inside along the edges its direction points to (45
  degrees: bottom and right); DeckCraft put it on the opposite edges;
- a glow of radius r is half strength about r/2 out and gone by r (was a
  wider, softer halo).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:06:34 -04:00
2 changed files with 77 additions and 8 deletions

View File

@ -793,11 +793,14 @@ impl Renderer {
ctx.push_clip_layer(&outline);
let c = rctx.color(&sh.color, fx_ph);
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m))));
// As PowerPoint draws it (measured on its export): the shadow falls inside along the
// edges it points to (45 degrees: the bottom and right), blurred by a third of the
// blur radius.
ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m))));
ctx.set_paint(color(c, 1.0));
let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1);
let mut hole = outline.clone();
hole.apply_affine(Affine::translate(d));
hole.apply_affine(Affine::translate(-d));
ring.extend(hole.iter());
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&ring);
@ -868,7 +871,8 @@ impl Renderer {
// Shadow offset is in slide space (doesn't rotate with the shape unless asked).
let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local };
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k)));
// Blurred by a third of the blur radius, as PowerPoint's export shows.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
ctx.set_transform(sm);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
@ -877,10 +881,12 @@ impl Renderer {
if let Some(g) = &e.glow {
let c = rctx.color(&g.color, ph);
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k)));
// Measured on PowerPoint's export: a glow of radius r is half strength about half of r
// out from the outline and gone by r.
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k)));
ctx.set_transform(m);
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round));
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
ctx.stroke_path(&silhouette);
ctx.fill_path(&silhouette);
ctx.pop_layer();

View File

@ -119,9 +119,10 @@ fn effects_tables_charts_render() {
all.extend(charts);
let p = deck_with(all);
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
// Shadow darkens below-right of the shape.
let px = img.pixel(258, 156);
assert!(px[0] < 250, "{px:?}");
// Shadow darkens below-right of the shape (grey, past the glow), blurred by a third of its
// 8 pt blur radius.
let px = img.pixel(255, 155);
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
}
#[test]
@ -931,3 +932,65 @@ fn a_table_fill_shows_behind_cells_without_their_own() {
assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]);
assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]);
}
/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt.
fn effect_box(effects: deckcraft_model::Effects) -> Image {
use deckcraft_model::style::{Fill, Line};
let sh = Shape {
id: ShapeId(800),
xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)),
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))),
line: Some(Line { fill: Some(Fill::None), ..Default::default() }),
effects: Some(effects),
..Default::default()
};
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
}
fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow {
deckcraft_model::style::Shadow {
color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)),
blur,
dist,
dir,
inner: false,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: String::new(),
rotate_with_shape: false,
}
}
#[test]
fn shadows_and_glows_spread_like_powerpoints() {
let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32;
// Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5
// px outside the left edge.
let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() });
for (d, want) in [(1, 144), (3, 204), (5, 238)] {
assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120));
}
// Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right
// edges, not the top and left.
let mut sh = shadow(0.0, 10.0, 45.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120));
assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216));
assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear");
// Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside.
let mut sh = shadow(9.0, 0.0, 0.0);
sh.inner = true;
let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() });
for (d, want) in [(0, 144), (2, 204), (4, 238)] {
assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120));
}
// Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10.
let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 };
let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() });
assert!(red(&img, 19, 120) < 20);
assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120));
assert!(red(&img, 10, 120) > 245);
}