diff --git a/crates/render/src/bevel.rs b/crates/render/src/bevel.rs index 36de1f05..854d3c77 100644 --- a/crates/render/src/bevel.rs +++ b/crates/render/src/bevel.rs @@ -14,11 +14,10 @@ use deckcraft_color::Rgba; use kurbo::{Affine, BezPath, Point, Rect}; use vello_cpu::{Pixmap, RenderContext, peniko}; +use crate::light::Lighting; + /// 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)] @@ -26,9 +25,8 @@ 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 light rig and material that light it. + pub lighting: Lighting, } /// The top bevel of 3-D XML kept from the file (`scene3d` + `sp3d`), when it has both a scene @@ -39,8 +37,7 @@ pub fn from_raw(raw: &str) -> Option { } // A 3-D shape without a bevel still has its face lit: a bevel of no width. let Some(i) = raw.find("bevelT") else { - let b = Bevel { w: 0.0, h: 0.0, preset: "circle".into(), modelled: false }; - return Some(Bevel { modelled: modelled(raw), ..b }); + return Some(Bevel { w: 0.0, h: 0.0, preset: "circle".into(), lighting: Lighting::from_raw(raw) }); }; let tag = raw.get(i..)?; let tag = tag.get(..tag.find('>')?)?; @@ -53,23 +50,11 @@ pub fn from_raw(raw: &str) -> Option { 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); - (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), modelled: modelled(raw) }) -} - -/// Whether the light rig and material (from anywhere in the XML: `a:lightRig rig=`, -/// `prstMaterial=`) are the ones measured: the three-point rig, matte. -fn modelled(raw: &str) -> bool { - 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"); - rig == "threePt" && matches!(material, "warmMatte" | "matte") + (w > 0.0).then(|| Bevel { w, h, preset: attr("prst").unwrap_or("circle").to_string(), lighting: Lighting::from_raw(raw) }) } /// The light, pointing from the surface towards it (x right, y down, z towards the viewer). +#[cfg(test)] fn light() -> [f64; 3] { let (az, el) = (240f64.to_radians(), 45f64.to_radians()); [el.cos() * az.cos(), el.cos() * az.sin(), el.sin()] @@ -121,19 +106,16 @@ fn nearest_edge(polys: &[Vec], areas: &[f64], p: Point) -> Option<(f64, k /// 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() { + let lit = &bevel.lighting; + if bounds.width() <= 0.0 || bounds.height() <= 0.0 || !bounds.is_finite() { return; } // No bevel: only the face, one lit colour (as PowerPoint lights a 3-D shape's front face, // whichever way the camera turns it, within about 10 levels). if bevel.w <= 0.0 { if let Some(c) = fill { - let f = AMBIENT + DIFFUSE * light()[2]; - let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0) as u8; ctx.set_transform(m); - ctx.set_paint(crate::color(Rgba::rgba(ch(c.r), ch(c.g), ch(c.b), c.a), alpha)); + ctx.set_paint(crate::color(lit.shade(c, [0.0, 0.0, 1.0]), alpha)); ctx.fill_path(path); } return; @@ -148,30 +130,29 @@ pub fn draw(ctx: &mut RenderContext, bevel: &Bevel, fill: Option, path: &B 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 face = lit.factor([0.0, 0.0, 1.0]); 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) { + let n = 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) + [n[0] / len, n[1] / len, n[2] / len] } - _ => face, + _ => [0.0, 0.0, 1.0], }; + let f = lit.factor(n); let px = match fill { Some(c) => { - let ch = |v: u8| (v as f64 * f + SPECULAR).round().clamp(0.0, 255.0); + let lc = lit.shade(c, n); 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 } + let pm = |v: u8| (f64::from(v) * ca).round().clamp(0.0, 255.0) as u8; + vello_cpu::color::PremulRgba8 { r: pm(lc.r), g: pm(lc.g), b: pm(lc.b), a: (ca * 255.0).round() as u8 } } None => { // A veil: white where the band is lighter than the face, black where darker. @@ -204,17 +185,19 @@ mod tests { #[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 })); + let b = from_raw(raw).unwrap(); + assert_eq!((b.w, b.h, b.preset.as_str()), (4.0, 1.3, "angle")); + assert_eq!(b.lighting, Lighting::from_raw(raw)); // 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. + assert!(from_raw(raw).is_some_and(|b| (b.w, b.h, b.preset.as_str()) == (6.0, 6.0, "circle"))); + // Another rig or material is lit by it. let raw = r#""#; - assert!(from_raw(raw).is_some_and(|b| !b.modelled)); + assert!(from_raw(raw).is_some_and(|b| b.lighting.specular > 0.0)); // No scene: flat in PowerPoint. assert_eq!(from_raw(r#""#), None); // A 3-D shape with no bevel: a bevel of no width (its face is lit). - assert!(from_raw(r#""#).is_some_and(|b| b.w == 0.0 && b.modelled)); + assert!(from_raw(r#""#).is_some_and(|b| b.w == 0.0)); assert_eq!(from_raw(r#""#), None); } diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 770b9445..af5187c9 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -119,6 +119,16 @@ fn project(c: &Camera, p: Point, size: f64) -> Point { project3(c, p.x, p.y, 0.0, size) } +/// Direction `v` (shape space: x right, y down, z to the viewer) turned by camera `c`, without +/// perspective: a surface normal as the camera sees it. +pub fn rotate(c: &Camera, v: [f64; 3]) -> [f64; 3] { + let (a, b, r) = (c.lat.to_radians(), c.lon.to_radians(), -c.rev.to_radians()); + let (x, y, z) = (v[0], v[1], v[2]); + let (x1, y1, z1) = (x * b.cos() + z * b.sin(), y, -x * b.sin() + z * b.cos()); + let (x2, y2, z2) = (x1, y1 * a.cos() - z1 * a.sin(), y1 * a.sin() + z1 * a.cos()); + [x2 * r.cos() - y2 * r.sin(), x2 * r.sin() + y2 * r.cos(), z2] +} + /// Where a point in the shape's space (relative to its centre; z towards the viewer, so an /// extrusion runs to negative z) lands. pub fn project3(c: &Camera, x: f64, y: f64, z: f64, size: f64) -> Point { diff --git a/crates/render/src/extrude.rs b/crates/render/src/extrude.rs index 4fdde0d5..9576bf60 100644 --- a/crates/render/src/extrude.rs +++ b/crates/render/src/extrude.rs @@ -1,10 +1,10 @@ //! 3-D extrusion (`a:sp3d extrusionH`, `a:extrusionClr`) seen through a turned camera, drawn in -//! 2-D: the shape's outline is swept from its back face to its front face along the camera's view -//! of the depth axis, in the side colour, and the face is drawn on top as usual. +//! 2-D: quads join the shape's outline on its front face to the same outline on its back face (the +//! camera's view of the depth axis), and the face is drawn on top as usual. //! -//! The sides are one shaded colour (the extrusion colour, or the fill's, darkened as a matte side -//! facing away from the three-point rig's key light); PowerPoint shades each side by its own -//! normal. Without a turned camera an extrusion points at the viewer and nothing of it shows. +//! Each side (one quad per flattened outline edge) is lit for its own normal by the shape's light +//! rig and material, and the sides are drawn farthest first. Without a turned camera an extrusion +//! points at the viewer and nothing of it shows. use deckcraft_color::{Rgba, SchemeSlot}; use deckcraft_model::ColorRef; @@ -13,11 +13,10 @@ use kurbo::{Affine, BezPath, Vec2}; use vello_cpu::RenderContext; use crate::camera::{self, Camera}; +use crate::light::Lighting; -/// How much darker the sides are than the face colour. -const SIDE_SHADE: f64 = 0.62; -/// Most sweep steps (one per output pixel of depth up to this). -const MAX_STEPS: usize = 96; +/// Outlines with more edges than this (after flattening) are not extruded. +const MAX_EDGES: usize = 4096; /// An extrusion: its depth in points and its own colour, if set. #[derive(Clone, Debug, PartialEq)] @@ -68,27 +67,75 @@ pub fn back_offset(c: &Camera, depth: f64, w: f64, h: f64) -> Vec2 { camera::project3(c, 0.0, 0.0, -depth, size) - camera::project3(c, 0.0, 0.0, 0.0, size) } -/// The side colour: the extrusion colour, or the face's, shaded. +/// The side colour before lighting: the extrusion colour, or the face's. pub fn side_color(rctx: &Ctx, e: &Extrusion, face: Option) -> Rgba { - let base = e.color.as_ref().map(|c| rctx.color(c, face)).or(face).unwrap_or(Rgba::rgb(0x80, 0x80, 0x80)); - let ch = |v: u8| (f64::from(v) * SIDE_SHADE).round().clamp(0.0, 255.0) as u8; - Rgba { r: ch(base.r), g: ch(base.g), b: ch(base.b), a: base.a } + e.color.as_ref().map(|c| rctx.color(c, face)).or(face).unwrap_or(Rgba::rgb(0x80, 0x80, 0x80)) } -/// Sweep `outline` (shape space) from its back face to its front face. `pre` maps the turned -/// shape's space to the output and `turn` is the camera's turn, so the shape is drawn under -/// `pre * turn` and its back face under `pre * translate(offset) * turn`. -pub fn draw(ctx: &mut RenderContext, outline: &BezPath, pre: Affine, turn: Affine, offset: Vec2, side: Rgba) { +/// Draw the sides of `outline` (shape space, `w` × `h`): one quad per edge between the front face +/// and the back face, each lit by `lit` for its own normal as camera `cam` sees it, farthest first. +/// `pre` maps the turned shape's space to the output and `turn` is the camera's turn. +#[allow(clippy::too_many_arguments)] +pub fn draw( + ctx: &mut RenderContext, + outline: &BezPath, + w: f64, + h: f64, + cam: &Camera, + depth: f64, + pre: Affine, + turn: Affine, + side: Rgba, + lit: &Lighting, +) { + let offset = back_offset(cam, depth, w, h); let px = (pre * offset.to_point() - pre * kurbo::Point::ZERO).hypot(); if !px.is_finite() || px < 0.25 { return; } - let steps = (px.ceil() as usize).clamp(1, MAX_STEPS); - ctx.set_paint(crate::color(side, 1.0)); - for i in (0..steps).rev() { - let t = (i + 1) as f64 / steps as f64; - ctx.set_transform(pre * Affine::translate(offset * t) * turn); - ctx.fill_path(outline); + let polys = crate::pathgrad::polygons(outline); + let size = w.max(h); + let mut quads: Vec<(f64, BezPath, Rgba)> = vec![]; + for poly in &polys { + if poly.len() > MAX_EDGES { + continue; + } + let area: f64 = poly.windows(2).map(|q| q[0].x * q[1].y - q[1].x * q[0].y).sum::() / 2.0; + for e in poly.windows(2) { + let (a, b) = (e[0], e[1]); + let d = b - a; + let len = d.hypot(); + if len < 1e-9 { + continue; + } + // Outward normal in the shape's plane (as in the bevel), turned by the camera. + let n = kurbo::Vec2::new(d.y, -d.x) / len; + let n = if area > 0.0 { n } else { -n }; + let vn = camera::rotate(cam, [n.x, n.y, 0.0]); + if vn[2] < -0.02 { + continue; + } + let mid = a.midpoint(b); + let z = camera::rotate(cam, [mid.x - w / 2.0, mid.y - h / 2.0, -depth / 2.0])[2]; + let (fa, fb) = (turn * a, turn * b); + let mut q = BezPath::new(); + q.move_to(fa); + q.line_to(fb); + q.line_to(fb + offset); + q.line_to(fa + offset); + q.close_path(); + quads.push((z, q, lit.shade(side, vn))); + } + } + let _ = size; + quads.sort_by(|a, b| a.0.total_cmp(&b.0)); + ctx.set_transform(pre); + for (_, q, c) in &quads { + ctx.set_paint(crate::color(*c, 1.0)); + ctx.fill_path(q); + // Close the hairline seams between neighbouring quads. + ctx.set_stroke(kurbo::Stroke::new(0.6 / crate::scale_of(pre).max(1e-6))); + ctx.stroke_path(q); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c77e8538..af67661c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ mod camera; mod chart; mod colr; mod extrude; +mod light; mod compound; mod extent; @@ -967,7 +968,8 @@ impl Renderer { _ => None, }; let pre = m * turn.inverse(); - extrude::draw(ctx, &outline, pre, turn, extrude::back_offset(&cam, ex.depth, w, h), extrude::side_color(rctx, &ex, face)); + let lit = light::Lighting::from_raw(raw); + extrude::draw(ctx, &outline, w, h, &cam, ex.depth, pre, turn, extrude::side_color(rctx, &ex, face), &lit); } // Fill. match &s.kind { diff --git a/crates/render/src/light.rs b/crates/render/src/light.rs new file mode 100644 index 00000000..2d475dfe --- /dev/null +++ b/crates/render/src/light.rs @@ -0,0 +1,184 @@ +//! Light rigs (`a:lightRig rig= dir=`) and materials (`a:sp3d prstMaterial=`) for 3-D shapes: +//! how bright a surface is for its normal. +//! +//! The three-point rig with the matte material is fitted to PowerPoint's exports (see +//! [`crate::bevel`]): `c · (0.453 + 0.731 · max(0, n · L)) + 14.7` with the key light from the upper +//! left. The other rigs and materials are modelled on it, not measured: each rig is a set of +//! directional lights with an ambient level and a colour tint, each material scales the diffuse +//! and ambient terms and adds a specular highlight. + +use deckcraft_color::Rgba; + +/// A light rig and material: what lights a 3-D surface. +#[derive(Clone, Debug, PartialEq)] +pub struct Lighting { + /// Unit directions from the surface towards each light (x right, y down, z to the viewer) + /// and their strengths. + pub lights: Vec<([f64; 3], f64)>, + pub ambient: f64, + pub diffuse: f64, + /// Added to every channel (the measured lift of the three-point rig). + pub lift: f64, + /// Colour of the light, per channel. + pub tint: [f64; 3], + pub specular: f64, + pub shininess: f64, + /// `flat`: no shading at all. + pub flat: bool, +} + +fn dir(az: f64, el: f64) -> [f64; 3] { + let (az, el) = (az.to_radians(), el.to_radians()); + [el.cos() * az.cos(), el.cos() * az.sin(), el.sin()] +} + +fn value_after<'a>(raw: &'a str, key: &str) -> Option<&'a str> { + let s = raw.find(key)? + key.len(); + let rest = raw.get(s..)?; + rest.get(..rest.find('"')?) +} + +/// The rig's lights as (azimuth, elevation, strength), its ambient level and tint. +fn rig(name: &str) -> (Vec<(f64, f64, f64)>, f64, [f64; 3]) { + const WHITE: [f64; 3] = [1.0, 1.0, 1.0]; + match name { + "balanced" => (vec![(240.0, 40.0, 0.75), (300.0, 40.0, 0.35), (90.0, 30.0, 0.25)], 0.47, WHITE), + "soft" => (vec![(270.0, 60.0, 0.6)], 0.58, WHITE), + "harsh" => (vec![(225.0, 30.0, 1.15)], 0.3, WHITE), + "flood" => (vec![(270.0, 70.0, 0.8), (240.0, 40.0, 0.35)], 0.5, WHITE), + "contrasting" => (vec![(210.0, 30.0, 1.05), (30.0, 20.0, 0.3)], 0.33, WHITE), + "morning" => (vec![(240.0, 35.0, 0.9)], 0.45, [1.0, 0.95, 0.85]), + "sunrise" => (vec![(200.0, 20.0, 0.95)], 0.42, [1.0, 0.88, 0.75]), + "sunset" => (vec![(340.0, 20.0, 0.95)], 0.42, [1.0, 0.82, 0.7]), + "chilly" => (vec![(240.0, 45.0, 0.85)], 0.45, [0.88, 0.94, 1.0]), + "freezing" => (vec![(240.0, 45.0, 0.85)], 0.45, [0.8, 0.9, 1.0]), + "twoPt" => (vec![(225.0, 45.0, 0.8), (315.0, 45.0, 0.45)], 0.42, WHITE), + "glow" => (vec![(270.0, 85.0, 0.55)], 0.68, WHITE), + "brightRoom" => (vec![(270.0, 60.0, 0.45)], 0.75, WHITE), + // threePt and the legacy rigs. + _ => (vec![(240.0, 45.0, 1.0)], 0.453, WHITE), + } +} + +/// The rig direction (`dir`) turns the lights about the view axis. +fn dir_turn(d: &str) -> f64 { + match d { + "tr" => 45.0, + "r" => 90.0, + "br" => 135.0, + "b" => 180.0, + "bl" => 225.0, + "l" => 270.0, + "tl" => 315.0, + _ => 0.0, + } +} + +impl Lighting { + /// The lighting of 3-D XML kept from the file; the three-point rig and warm matte material + /// when unset. + pub fn from_raw(raw: &str) -> Lighting { + let rig_name = value_after(raw, "rig=\"").unwrap_or("threePt"); + let turn = dir_turn(value_after(raw, " dir=\"").unwrap_or("t")); + let material = value_after(raw, "prstMaterial=\"").unwrap_or("warmMatte"); + let (lights, ambient, tint) = rig(rig_name); + let (dk, ak, specular, shininess) = match material { + "plastic" => (1.0, 1.0, 0.45, 24.0), + "metal" => (0.8, 0.9, 0.7, 40.0), + "softmetal" => (0.9, 0.95, 0.35, 12.0), + "powder" | "translucentPowder" => (0.85, 1.12, 0.0, 1.0), + "dkEdge" => (1.12, 0.8, 0.1, 8.0), + "softEdge" => (0.9, 1.08, 0.05, 6.0), + "clear" => (0.9, 1.0, 0.6, 50.0), + _ => (1.0, 1.0, 0.0, 1.0), + }; + Lighting { + lights: lights.into_iter().map(|(az, el, s)| (dir(az + turn, el), s)).collect(), + ambient: ambient * ak, + diffuse: 0.731 * dk, + lift: 14.7, + tint, + specular, + shininess, + flat: rig_name == "flat" || material == "flat", + } + } + + /// The diffuse brightness factor for unit normal `n` (the three-point matte rig: 0.453 + + /// 0.731 · max(0, n · L)). + pub fn factor(&self, n: [f64; 3]) -> f64 { + if self.flat { + return 1.0; + } + let dot = |l: &[f64; 3]| (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + self.ambient + self.diffuse * self.lights.iter().map(|(l, s)| s * dot(l)).sum::() + } + + /// The specular highlight (0–1) for unit normal `n`, seen from the front. + pub fn highlight(&self, n: [f64; 3]) -> f64 { + if self.flat || self.specular <= 0.0 { + return 0.0; + } + self.lights + .iter() + .map(|(l, s)| { + let h = [l[0], l[1], l[2] + 1.0]; + let len = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt().max(1e-9); + let d = ((n[0] * h[0] + n[1] * h[1] + n[2] * h[2]) / len).max(0.0); + s * d.powf(self.shininess) + }) + .sum::() + * self.specular + } + + /// Colour `c` lit on a surface with unit normal `n`. + pub fn shade(&self, c: Rgba, n: [f64; 3]) -> Rgba { + if self.flat { + return c; + } + let f = self.factor(n); + let hi = self.highlight(n) * 255.0; + let ch = |v: u8, t: f64| (f64::from(v) * f * t + self.lift + hi).round().clamp(0.0, 255.0) as u8; + Rgba::rgba(ch(c.r, self.tint[0]), ch(c.g, self.tint[1]), ch(c.b, self.tint[2]), c.a) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn three_point_matte_is_the_measured_formula() { + let l = Lighting::from_raw(r#""#); + let n = [0.0, 0.0, 1.0]; + let expect = 0.453 + 0.731 * 45f64.to_radians().sin(); + assert!((l.factor(n) - expect).abs() < 1e-9); + assert_eq!(l.highlight(n), 0.0); + let c = l.shade(Rgba::rgb(100, 100, 100), n); + assert_eq!(c.r, (100.0 * expect + 14.7_f64).round() as u8); + } + + #[test] + fn rigs_and_materials_differ_as_named() { + let face = [0.0, 0.0, 1.0]; + let f = |raw: &str| Lighting::from_raw(raw).factor(face); + assert!(f(r#"rig="brightRoom""#) > f(r#"rig="threePt""#), "a bright room is brighter"); + assert!(f(r#"rig="harsh""#) < f(r#"rig="soft""#) + 0.2); + assert_eq!(Lighting::from_raw(r#"rig="flat""#).shade(Rgba::rgb(10, 20, 30), face), Rgba::rgb(10, 20, 30)); + // Metal shines where plastic shines less and matte not at all. + let up_left = { + let d = dir(240.0, 45.0); + let h = [d[0], d[1], d[2] + 1.0]; + let l = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt(); + [h[0] / l, h[1] / l, h[2] / l] + }; + let hi = |m: &str| Lighting::from_raw(&format!(r#"prstMaterial="{m}""#)).highlight(up_left); + assert!(hi("metal") > hi("plastic") && hi("plastic") > 0.0 && hi("matte") == 0.0); + // Turning the rig to the right moves the key light: a right-facing surface gets brighter. + let right = [0.7, 0.0, 0.7]; + assert!(Lighting::from_raw(r#"rig="threePt" dir="r""#).factor(right) > Lighting::from_raw(r#"rig="threePt" dir="t""#).factor(right)); + // Sunset is warm. + let warm = Lighting::from_raw(r#"rig="sunset""#).shade(Rgba::rgb(200, 200, 200), face); + assert!(warm.r > warm.b); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index f24c8119..230afd50 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -254,9 +254,11 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * only the three-point rig, and no command or pane to apply 3-D). - **Estimate:** 1 h (edit points: segment types, open/close path), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, render as approximations and `scene3d`/`sp3d` round-trip as raw XML; extrusions draw their - sides, in one shaded colour, under turned cameras; only the three-point rig, and no command or pane to apply 3-D). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, - rigs and materials, Format Shape page). + sides under turned cameras, each lit for its own normal; the preset light rigs, rig directions and + materials shade bevels and sides approximately (fitted to PowerPoint for three-point/matte only); no contours, + and no command or pane to apply 3-D). +- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 2–4 h (3-D contours and + rig/material fidelity), plus the Format Shape page. ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings @@ -353,6 +355,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open | | 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip | | 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | +| 2026-10-14 | trivial | 3-D light rigs and materials; extrusion sides lit per side | | 2026-10-14 | trivial | 3-D extrusions draw their sides under turned cameras | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |