mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
Merge PR #597 from NikitkaRa: render: 3-D light rigs and materials, per-side extrusion shading
This commit is contained in:
+25
-42
@@ -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<Bevel> {
|
||||
}
|
||||
// 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<Bevel> {
|
||||
let emu = |name: &str| attr(name).and_then(|v| v.parse::<f64>().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<Point>], 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<Rgba>, 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<Rgba>, path: &B
|
||||
return;
|
||||
}
|
||||
let areas: Vec<f64> = polys.iter().map(|p| p.windows(2).map(|w| w[0].x * w[1].y - w[1].x * w[0].y).sum::<f64>() / 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#"<a:scene3d><a:camera prst="orthographicFront"/></a:scene3d><a:sp3d><a:bevelT w="50800" h="16510" prst="angle"/></a:sp3d>"#;
|
||||
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#"<a:scene3d/><a:sp3d><a:bevelT/></a:sp3d>"#;
|
||||
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#"<a:scene3d><a:lightRig rig="flood" dir="t"/></a:scene3d><a:sp3d prstMaterial="metal"><a:bevelT/></a:sp3d>"#;
|
||||
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#"<a:sp3d><a:bevelT w="50800"/></a:sp3d>"#), None);
|
||||
// A 3-D shape with no bevel: a bevel of no width (its face is lit).
|
||||
assert!(from_raw(r#"<a:scene3d/><a:sp3d extrusionH="10"/>"#).is_some_and(|b| b.w == 0.0 && b.modelled));
|
||||
assert!(from_raw(r#"<a:scene3d/><a:sp3d extrusionH="10"/>"#).is_some_and(|b| b.w == 0.0));
|
||||
assert_eq!(from_raw(r#"<a:scene3d/>"#), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) -> 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::<f64>() / 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<f64>()
|
||||
}
|
||||
|
||||
/// 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::<f64>()
|
||||
* 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#"<a:lightRig rig="threePt" dir="t"/>"#);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user