diff --git a/crates/render/src/bevel.rs b/crates/render/src/bevel.rs new file mode 100644 index 0000000..85dfb3b --- /dev/null +++ b/crates/render/src/bevel.rs @@ -0,0 +1,212 @@ +//! 3-D bevels (`a:sp3d/a:bevelT` seen through a `a:scene3d`), approximated in 2-D: the shape's +//! face and a band inside its outline as wide as the bevel are lit as surfaces, the band's normal +//! tilting out towards the outline by the bevel profile's slope. +//! +//! Fitted to PowerPoint's export of bevel presets on a solid-filled shape with the default +//! three-point light rig and matte material: each channel comes out as +//! `c · (0.453 + 0.731 · max(0, n · L)) + 14.7` for a light from the upper left (azimuth 240°, +//! 45° up), within about 15 grey levels RMS across the band (exact profiles, specular highlights +//! and the other rigs, materials and cameras are not modelled). + +use std::sync::Arc; + +use deckcraft_color::Rgba; +use kurbo::{Affine, BezPath, Point, Rect}; +use vello_cpu::{Pixmap, RenderContext, peniko}; + +/// Longest side of the rasterised lighting, in pixels. +const MAX_SIDE: f64 = 512.0; +const AMBIENT: f64 = 0.453; +const DIFFUSE: f64 = 0.731; +const SPECULAR: f64 = 14.7; + +/// A top bevel: its width and height in points and its preset. +#[derive(Clone, Debug, PartialEq)] +pub struct Bevel { + pub w: f64, + pub h: f64, + pub preset: String, + /// Lit as measured (the three-point rig and matte material); other rigs and materials are not + /// drawn. + pub modelled: bool, +} + +/// The top bevel of 3-D XML kept from the file (`scene3d` + `sp3d`), when it has both a scene +/// (without one PowerPoint draws the shape flat) and a `bevelT`. +pub fn from_raw(raw: &str) -> Option { + if !raw.contains("scene3d") { + return None; + } + let i = raw.find("bevelT")?; + let tag = raw.get(i..)?; + let tag = tag.get(..tag.find('>')?)?; + let attr = |name: &str| -> Option<&str> { + let key = format!(" {name}=\""); + let s = tag.find(&key)? + key.len(); + let rest = tag.get(s..)?; + rest.get(..rest.find('"')?) + }; + let emu = |name: &str| attr(name).and_then(|v| v.parse::().ok()).map(|v| v / 12700.0); + let w = emu("w").unwrap_or(6.0).clamp(0.0, 2000.0); + let h = emu("h").unwrap_or(6.0).clamp(0.0, 2000.0); + // The light rig and material, from anywhere in the XML (`a:lightRig rig=`, `prstMaterial=`). + let value_after = |key: &str| -> Option<&str> { + let s = raw.find(key)? + key.len(); + let rest = raw.get(s..)?; + rest.get(..rest.find('"')?) + }; + let rig = value_after("rig=\"").unwrap_or("threePt"); + let material = value_after("prstMaterial=\"").unwrap_or("warmMatte"); + let modelled = rig == "threePt" && matches!(material, "warmMatte" | "matte"); + (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled }) +} + +/// The light, pointing from the surface towards it (x right, y down, z towards the viewer). +fn light() -> [f64; 3] { + let (az, el) = (240f64.to_radians(), 45f64.to_radians()); + [el.cos() * az.cos(), el.cos() * az.sin(), el.sin()] +} + +/// The bevel surface's rise per unit inwards (in units of h / w) at `u` (0 at the outline, 1 at +/// the inner edge of the band), by preset. +fn slope(preset: &str, u: f64) -> f64 { + let u = u.clamp(1e-3, 1.0); + match preset { + "angle" => 1.0, + // A quarter circle, steep at the outline and flat where it meets the face; the default, + // and used for the presets not modelled. + _ => { + let v = 1.0 - u; + v / (1.0 - v * v).max(1e-6).sqrt() + } + } +} + +/// Lighting factor and outward edge normal at each position, for a polygon set: distance to the +/// nearest edge and that edge's outward normal (edges oriented by each polygon's signed area). +fn nearest_edge(polys: &[Vec], areas: &[f64], p: Point) -> Option<(f64, kurbo::Vec2)> { + let mut best: Option<(f64, kurbo::Vec2)> = None; + for (poly, area) in polys.iter().zip(areas) { + for w in poly.windows(2) { + let (a, b) = (w[0], w[1]); + let e = b - a; + let l2 = e.hypot2(); + if l2 < 1e-12 { + continue; + } + let t = ((p - a).dot(e) / l2).clamp(0.0, 1.0); + let d = (p - (a + e * t)).hypot(); + if best.is_none_or(|(bd, _)| d < bd) { + // Outward normal: for a polygon of positive (clockwise on screen, y down) area the + // outside is to the left of each edge's direction. + let n = kurbo::Vec2::new(e.y, -e.x) / l2.sqrt(); + let n = if *area > 0.0 { n } else { -n }; + best = Some((d, n)); + } + } + } + best +} + +/// Draw the lit face and bevel band over a shape filled with `fill` (a solid colour, or `None` +/// for any other fill, which gets the band's highlights and shading as light and dark veils) +/// inside `path` (shape space, laid out in `bounds`), `m` mapping it to the output. +#[allow(clippy::too_many_arguments)] +pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option, path: &BezPath, bounds: Rect, m: Affine, alpha: f64) { + // Other light rigs and materials light the face and band differently (brighter, darker, + // shinier); rather than guess, they stay flat as before. + if !bevel.modelled || bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() { + return; + } + let k = crate::scale_of(m); + let k = if k.is_finite() && k > 0.0 { k } else { 1.0 }; + let longest = bounds.width().max(bounds.height()) * k; + let s = (MAX_SIDE / longest).min(1.0) * k; + let (pw, ph) = (((bounds.width() * s).ceil() as u32).clamp(1, 2048), ((bounds.height() * s).ceil() as u32).clamp(1, 2048)); + let polys = crate::pathgrad::polygons(path); + if polys.is_empty() { + return; + } + let areas: Vec = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::() / 2.0).collect(); + let l = light(); + let face = AMBIENT + DIFFUSE * l[2]; + let ratio = if bevel.w > 0.0 { bevel.h / bevel.w } else { 0.0 }; + let a = alpha.clamp(0.0, 1.0); + let mut data = Vec::with_capacity((pw * ph) as usize); + for j in 0..ph { + for i in 0..pw { + let p = Point::new(bounds.x0 + (i as f64 + 0.5) / pw as f64 * bounds.width(), bounds.y0 + (j as f64 + 0.5) / ph as f64 * bounds.height()); + let f = match nearest_edge(&polys, &areas, p) { + Some((d, e)) if d < bevel.w => { + let z = slope(&bevel.preset, d / bevel.w) * ratio; + let n = [e.x * z, e.y * z, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let dot = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len; + AMBIENT + DIFFUSE * dot.max(0.0) + } + _ => face, + }; + let px = match fill { + Some(c) => { + let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0); + let ca = c.a as f64 * a / 255.0; + let pm = |v: f64| (v * ca).round().clamp(0.0, 255.0) as u8; + vello_cpu::color::PremulRgba8 { r: pm(ch(c.r)), g: pm(ch(c.g)), b: pm(ch(c.b)), a: (ca * 255.0).round() as u8 } + } + None => { + // A veil: white where the band is lighter than the face, black where darker. + let d = ((f - face) * 1.5).clamp(-1.0, 1.0); + let va = (d.abs() * a * 255.0).round().clamp(0.0, 255.0) as u8; + let v = if d > 0.0 { va } else { 0 }; + vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: va } + } + }; + data.push(px); + } + } + let pm = Arc::new(Pixmap::from_parts(data, pw as u16, ph as u16)); + ctx.set_transform(m); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { quality: peniko::ImageQuality::Medium, ..Default::default() }, + }); + ctx.set_paint_transform( + Affine::translate(bounds.origin().to_vec2()) * Affine::scale_non_uniform(bounds.width() / pw as f64, bounds.height() / ph as f64), + ); + ctx.fill_path(path); + ctx.reset_paint_transform(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bevels_are_read_from_kept_3d_xml() { + let raw = r#""#; + assert_eq!(from_raw(raw), Some(Bevel { w: 4.0, h: 1.3, preset: "angle".into(), modelled: true })); + // Defaults: 6 pt square, circle. + let raw = r#""#; + assert_eq!(from_raw(raw), Some(Bevel { w: 6.0, h: 6.0, preset: "circle".into(), modelled: true })); + // Another rig or material: not modelled. + let raw = r#""#; + assert!(from_raw(raw).is_some_and(|b| !b.modelled)); + // No scene: flat in PowerPoint. + assert_eq!(from_raw(r#""#), None); + assert_eq!(from_raw(r#""#), None); + } + + #[test] + fn the_light_comes_from_the_upper_left() { + let l = light(); + assert!(l[0] < 0.0 && l[1] < 0.0 && l[2] > 0.0); + // An upward-facing edge (outward normal up) is lit more than a downward one. + let lit = |e: [f64; 2]| { + let z = slope("circle", 0.3) * 0.5; + let n = [e[0] * z, e[1] * z, 1.0]; + let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt(); + (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]) / len + }; + assert!(lit([0.0, -1.0]) > lit([-1.0, 0.0]) && lit([-1.0, 0.0]) > lit([1.0, 0.0]) && lit([1.0, 0.0]) > lit([0.0, 1.0])); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8a00a19..09d6ab1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod bevel; mod chart; mod colr; @@ -997,6 +998,24 @@ impl Renderer { ctx.pop_layer(); } } + // A 3-D bevel: the face and a band inside the outline lit as surfaces. + if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { + let solid = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => None, + }; + let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none()); + if filled { + let mut area = BezPath::new(); + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { + area.extend(sp.path.iter()); + } + if is_pic || area.elements().is_empty() { + area = outline.clone(); + } + bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); + } + } // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index 53deada..0581fb2 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -557,3 +557,26 @@ fn patterns_between_whole_pixel_cells_blend_neighbouring_cells() { // The pattern repeats every 6 px (8 cells). assert!(row.iter().zip(row.iter().skip(6)).all(|(a, b)| (*a as i32 - *b as i32).abs() <= 2), "{row:?}"); } + +#[test] +fn a_bevelled_shape_is_lit_from_the_upper_left() { + let base = Rgba::rgb(91, 155, 213); + let bevelled = |raw: &str| Shape { + id: ShapeId(26), + xfrm: Some(Xfrm::new(100.0, 100.0, 130.0, 130.0)), + fill: solid(base), + line: no_line(), + effects: Some(deckcraft_model::Effects { bevel: Some("circle".into()), raw3d: Some(raw.into()), ..Default::default() }), + ..Default::default() + }; + let raw = r#""#; + let img = render(vec![bevelled(raw)]); + // PowerPoint: face (99, 166, 226); top of the band saturates, its bottom and right darken. + let face = img.pixel(165, 165); + assert!(close(face, Rgba::rgb(99, 166, 226), 8), "face {face:?}"); + let (top, bottom, right) = (img.pixel(165, 104)[2], img.pixel(165, 226)[2], img.pixel(226, 165)[2]); + assert!(top > face[2] && bottom < face[2] && right < face[2], "top {top} bottom {bottom} right {right} face {}", face[2]); + // Without a 3-D scene PowerPoint draws it flat. + let img = render(vec![bevelled(r#""#)]); + assert!(close(img.pixel(165, 104), base, 2) && close(img.pixel(165, 165), base, 2)); +}