diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fe7ca39..eba31f8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, geo: &Geometry, fill: &Option, + fill_alpha: Option, + outline: Option, 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, + 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, ph: Option) -> Option { + 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::() / 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 diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index c1ec2ef..0e1229c 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -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)); +}