From aee9cf8463f99281546d990a15709f795c04ba44 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 c9fe0242..fe7ca390 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 5f87c7ac..c1ec2efc 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)); +}