Merge PR #597 from NikitkaRa: render: 3-D light rigs and materials, per-side extrusion shading

This commit is contained in:
Brandon Thomas
2026-10-14 13:08:58 -04:00
6 changed files with 298 additions and 69 deletions
+25 -42
View File
@@ -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);
}
+10
View File
@@ -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 {
+70 -23
View File
@@ -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);
}
}
+3 -1
View File
@@ -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 {
+184
View File
@@ -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
View File
@@ -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 |