Render: shadows are cast by what is drawn: no-fill shapes, see-through fills, lines

An outer shadow was always the shape's filled silhouette at the shadow's
opacity: an outline-only shape cast a solid block, a 40% fill cast a full
shadow that darkened it from behind, and lines (and outlines) cast none.
PowerPoint casts the shadow from the drawn shape: the filled part as
see-through as its fill (nothing for no fill) plus the outline, which is
now stroked into the shadow layer with the line's width, dash and opacity.

Wild synthetic set: combined effects slide (no-fill frame, 40% fill,
shadows) 21.88 -> 2.34.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 05:04:02 -04:00
parent b3a980dcb5
commit 862e2175a7
2 changed files with 83 additions and 4 deletions

View File

@ -856,7 +856,17 @@ impl Renderer {
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
_ => None,
};
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
// What casts the shadow: the fill as see-through as it is, and the outline.
let fill_alpha = fill_opacity(rctx, &fill, fill_ph);
let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| {
let a = match lf {
Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0,
_ => 1.0,
};
let (base, rel) = self.line_space(m, depth);
Caster { line: &line, ph: line_ph, alpha: a, base, rel }
});
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), m, w, h);
}
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
if let Some(r) = soft {
@ -1087,6 +1097,8 @@ impl Renderer {
ph: Option<Rgba>,
geo: &Geometry,
fill: &Option<Fill>,
fill_alpha: Option<f64>,
outline: Option<Caster>,
content: Option<&deckcraft_model::style::PictureFill>,
m: Affine,
w: f64,
@ -1107,7 +1119,8 @@ impl Renderer {
let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
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 };
let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) };
let sm = pre * m * own;
ctx.set_transform(Affine::IDENTITY);
// 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)));
@ -1125,9 +1138,27 @@ impl Renderer {
ctx.pop_layer();
ctx.pop_layer();
}
// The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as
// see-through as its fill (none for an outline-only shape) …
None => {
ctx.set_paint(color(c.with_alpha(255), 1.0));
ctx.fill_path(&silhouette);
if let Some(a) = fill_alpha {
let mut filled = BezPath::new();
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
filled.extend(sp.path.iter());
}
ctx.set_paint(color(c.with_alpha(255), a));
ctx.fill_path(&filled);
}
}
}
// … and the outline (a picture's border too).
if let Some(o) = &outline {
let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.clone() };
let lw = o.line.width.unwrap_or(0.75).max(0.0);
for sp in geo.paths.iter().filter(|sp| sp.stroke) {
let mut path = sp.path.clone();
path.apply_affine(o.rel * own);
stroke(ctx, rctx, &cast, o.ph, &path, pre * o.base, lw, o.alpha);
}
}
ctx.pop_layer();
@ -1166,6 +1197,29 @@ impl Renderer {
}
}
/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity,
/// and the space it is stroked in (see `Renderer::line_space`).
struct Caster<'a> {
line: &'a Line,
ph: Option<Rgba>,
alpha: f64,
base: Affine,
rel: Affine,
}
/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a
/// gradient's mean stop alpha, otherwise opaque.
fn fill_opacity(rctx: &Ctx, fill: &Option<Fill>, ph: Option<Rgba>) -> Option<f64> {
match fill {
None | Some(Fill::None) => None,
Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0),
Some(Fill::Gradient(g)) if !g.stops.is_empty() => {
Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::<f64>() / g.stops.len() as f64)
}
Some(_) => Some(1.0),
}
}
/// 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

View File

@ -225,3 +225,28 @@ fn a_perspective_shadow_lies_along_the_edge_its_alignment_names() {
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));
}
fn offset_shadow() -> deckcraft_model::Effects {
let mut sh = shadow(1.0, 1.0, 0.0, "b");
sh.dist = 20.0;
sh.dir = 0.0;
deckcraft_model::Effects { outer_shadow: Some(sh), ..Default::default() }
}
#[test]
fn shadows_are_cast_by_what_is_drawn() {
// An outline-only square: its shadow (20 pt to the right) is a frame, not a solid square.
let outline = Some(Line::solid(ColorRef::rgb(RED), 4.0));
let img = render(vec![with_effects(Some(Fill::None), outline.clone(), offset_shadow())]);
assert!(close(img.pixel(220, 150), Rgba::BLACK, 30), "the frame's shadow {:?}", img.pixel(220, 150));
assert!(close(img.pixel(160, 150), WHITE, 10), "no shadow inside the frame {:?}", img.pixel(160, 150));
// A half see-through fill casts a half see-through shadow.
let img = render(vec![with_effects(solid(Rgba::rgba(255, 0, 0, 128)), no_line(), offset_shadow())]);
let px = img.pixel(210, 150);
assert!(close(px, Rgba::rgb(127, 127, 127), 8), "half shadow {px:?}");
// A line casts a shadow too.
let mut ln = arrow_line("none", 6.0, RED);
ln.effects = Some(offset_shadow());
let img = render(vec![ln]);
assert!(close(img.pixel(310, 100), Rgba::BLACK, 30), "line shadow {:?}", img.pixel(310, 100));
}