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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 04:59:32 -04:00
parent 0796325a1f
commit b3a980dcb5
2 changed files with 63 additions and 4 deletions

View File

@ -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
}

View File

@ -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<Fill>, line: Option<Line>, 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));
}