From b3a980dcb5aea38fa453bd82d1157bebc1375979 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:59:32 -0400 Subject: [PATCH] Render: perspective shadows scale and skew about their alignment point A shadow's sx/sy/kx/ky were applied about the shape's centre with the skew unscaled, so a perspective shadow (algn=b, sy 23%, kx -20 deg) floated in the middle of the shape instead of lying along its bottom edge. As PowerPoint draws it, the shadow is skewed then scaled about the point of the box its algn names (b: middle of the bottom edge, tl: top-left...). Wild synthetic set: shadow alignment/scale/skew slide 5.57 -> 0.41, picture frames 13.71 -> 12.71. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 31 ++++++++++++++++++++++++---- crates/render/src/tests_wild.rs | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c9fe024..fe7ca39 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1105,10 +1105,7 @@ impl Renderer { if let Some(sh) = &e.outer_shadow { let c = rctx.color(&sh.color, ph); let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - let c0 = Point::new(w / 2.0, h / 2.0); - let local = Affine::translate(c0.to_vec2()) - * Affine::new([sh.sx, sh.ky.to_radians().tan(), sh.kx.to_radians().tan(), sh.sy, 0.0, 0.0]) - * Affine::translate(-c0.to_vec2()); + let local = shadow_local(sh, w, h); // 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); @@ -1169,6 +1166,32 @@ impl Renderer { } } +/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point +/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the +/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow +/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its +/// middle. +fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine { + let (ax, ay) = match sh.align.as_str() { + "tl" => (0.0, 0.0), + "t" => (0.5, 0.0), + "tr" => (1.0, 0.0), + "l" => (0.0, 0.5), + "r" => (1.0, 0.5), + "bl" => (0.0, 1.0), + "b" => (0.5, 1.0), + "br" => (1.0, 1.0), + _ => (0.5, 0.5), + }; + let a = Vec2::new(w * ax, h * ay); + let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan()); + let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]); + if !k.is_finite() { + return Affine::IDENTITY; + } + Affine::translate(a) * k * Affine::translate(-a) +} + fn pre_translate(m: Affine, d: Vec2) -> Affine { Affine::translate(d) * m } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 5f87c7a..c1ec2ef 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -189,3 +189,39 @@ fn a_radial_gradient_off_centre_ends_on_the_circle_around_the_box() { assert!(img.pixel(300, 200)[0] > 240 && img.pixel(101, 101)[0] < 60); assert!((img.pixel(110, 200)[0] as i32 - img.pixel(490, 200)[0] as i32).abs() <= 2); } + +fn shadow(sx: f64, sy: f64, kx: f64, align: &str) -> deckcraft_model::style::Shadow { + deckcraft_model::style::Shadow { + color: ColorRef::rgb(Rgba::BLACK), + blur: 0.0, + dist: 0.0, + dir: 0.0, + inner: false, + sx, + sy, + kx, + ky: 0.0, + align: align.into(), + rotate_with_shape: false, + } +} + +fn with_effects(fill: Option, line: Option, effects: deckcraft_model::Effects) -> Shape { + Shape { id: ShapeId(14), xfrm: Some(Xfrm::new(100.0, 100.0, 100.0, 100.0)), fill, line, effects: Some(effects), ..Default::default() } +} + +#[test] +fn a_perspective_shadow_lies_along_the_edge_its_alignment_names() { + // Half height, skewed 45° so its top leans right, anchored at the bottom middle: the shadow's + // bottom edge is the shape's (y = 200) and its top (y = 150) is 50 pt to the right. + let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.0, 0.5, -45.0, "b")), ..Default::default() }; + let img = render(vec![with_effects(solid(RED), no_line(), fx)]); + assert!(close(img.pixel(210, 175), Rgba::BLACK, 30), "{:?}", img.pixel(210, 175)); + assert!(close(img.pixel(240, 155), Rgba::BLACK, 30), "{:?}", img.pixel(240, 155)); + assert!(close(img.pixel(240, 180), WHITE, 10), "{:?}", img.pixel(240, 180)); + assert!(close(img.pixel(205, 140), WHITE, 10), "nothing above the half height {:?}", img.pixel(205, 140)); + // Anchored bottom-right and half as big again: it grows up and to the left only. + let fx = deckcraft_model::Effects { outer_shadow: Some(shadow(1.5, 1.5, 0.0, "br")), ..Default::default() }; + let img = render(vec![with_effects(solid(RED), no_line(), fx)]); + assert!(close(img.pixel(70, 70), Rgba::BLACK, 30) && close(img.pixel(210, 210), WHITE, 10)); +}