diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs index 83c6f869..770b9445 100644 --- a/crates/render/src/camera.rs +++ b/crates/render/src/camera.rs @@ -116,8 +116,15 @@ pub fn from_raw(raw: &str) -> Option { /// Where a point of the shape (relative to its centre) lands. fn project(c: &Camera, p: Point, size: f64) -> Point { + project3(c, p.x, p.y, 0.0, size) +} + +/// 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 { + let p = Point::new(x, y); let (a, b, r) = (c.lat.to_radians(), c.lon.to_radians(), -c.rev.to_radians()); - let (x1, y1, z1) = (p.x * b.cos(), p.y, -p.x * b.sin()); + let (x1, y1, z1) = (p.x * b.cos() + z * b.sin(), p.y, -p.x * b.sin() + z * b.cos()); let (x2, y2, z2) = (x1, y1 * a.cos() - z1 * a.sin(), y1 * a.sin() + z1 * a.cos()); let (x3, y3) = (x2 * r.cos() - y2 * r.sin(), x2 * r.sin() + y2 * r.cos()); if c.dist > 0.0 { diff --git a/crates/render/src/extrude.rs b/crates/render/src/extrude.rs new file mode 100644 index 00000000..4fdde0d5 --- /dev/null +++ b/crates/render/src/extrude.rs @@ -0,0 +1,119 @@ +//! 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. +//! +//! 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. + +use deckcraft_color::{Rgba, SchemeSlot}; +use deckcraft_model::ColorRef; +use deckcraft_model::resolve::Ctx; +use kurbo::{Affine, BezPath, Vec2}; +use vello_cpu::RenderContext; + +use crate::camera::{self, Camera}; + +/// 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; + +/// An extrusion: its depth in points and its own colour, if set. +#[derive(Clone, Debug, PartialEq)] +pub struct Extrusion { + pub depth: f64, + pub color: Option, +} + +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let s = tag.find(&key)? + key.len(); + let rest = tag.get(s..)?; + rest.get(..rest.find('"')?) +} + +/// The extrusion in 3-D XML kept from the file (`a:sp3d extrusionH="…"` with an `a:scene3d`). +pub fn from_raw(raw: &str) -> Option { + if !raw.contains("scene3d") { + return None; + } + let i = raw.find("sp3d ")?; + let tag = raw.get(i..)?; + let tag = tag.get(..tag.find('>')?)?; + let depth = attr(tag, "extrusionH")?.parse::().ok()? / 12700.0; + if !(depth.is_finite() && depth > 0.01) { + return None; + } + let color = raw.find("extrusionClr").and_then(|j| { + let body = raw.get(j..)?; + let body = body.get(..body.find("/a:extrusionClr").or_else(|| body.find("/extrusionClr")).unwrap_or(body.len()))?; + if let Some(k) = body.find("srgbClr ") { + let v = attr(body.get(k..)?, "val")?; + let n = u32::from_str_radix(v, 16).ok()?; + Some(ColorRef::rgb(Rgba::rgb((n >> 16) as u8, (n >> 8) as u8, n as u8))) + } else if let Some(k) = body.find("schemeClr ") { + SchemeSlot::from_xml(attr(body.get(k..)?, "val")?).map(ColorRef::scheme) + } else { + None + } + }); + Some(Extrusion { depth: depth.min(4000.0), color }) +} + +/// The camera's view of the depth axis: where the back face sits relative to the front face, in +/// the turned shape's space (the space `camera::affine` maps into). +pub fn back_offset(c: &Camera, depth: f64, w: f64, h: f64) -> Vec2 { + let size = w.max(h); + 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. +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 } +} + +/// 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) { + 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); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_depth_and_colour() { + let raw = r#""#; + let e = from_raw(raw).unwrap(); + assert!((e.depth - 30.0).abs() < 1e-9); + assert_eq!(e.color, Some(ColorRef::rgb(Rgba::rgb(0xC0, 0, 0)))); + assert!(from_raw(r#""#).is_none(), "no scene: drawn flat"); + assert!(from_raw(r#""#).is_none()); + } + + #[test] + fn the_back_face_goes_away_from_the_viewer() { + // Turned left about the vertical axis, the back face shows to the right... of the turned + // face for a positive longitude, and nothing for a front camera. + let c = Camera { lat: 0.0, lon: 30.0, rev: 0.0, dist: 0.0 }; + let o = back_offset(&c, 20.0, 100.0, 50.0); + assert!((o.x.abs() - 10.0).abs() < 1e-9 && o.y.abs() < 1e-9, "{o:?}"); + let front = Camera { lat: 0.0, lon: 0.0, rev: 0.0, dist: 0.0 }; + assert!(back_offset(&front, 20.0, 100.0, 50.0).hypot() < 1e-9); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..2eddf178 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ mod bevel; mod camera; mod chart; mod colr; +mod extrude; mod compound; mod extent; @@ -926,6 +927,20 @@ impl Renderer { ctx.push_layer(None, None, None, None, None); let _ = r; } + // A 3-D extrusion under a turned camera: the sides, swept behind the face. + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(ex) = extrude::from_raw(raw) + && let Some(cam) = camera::from_raw(raw) + && let Some(turn) = camera::affine(&cam, w, h) + && turn.determinant().abs() > 1e-9 + { + let face = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => 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)); + } // Fill. match &s.kind { ShapeKind::Picture { fill: pf } => { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..4df0a6ea 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,69 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +/// Shapes with depth under turned cameras (an `extrusionH` and, on two, an `extrusionClr`). +fn extruded_deck() -> Presentation { + let mk = |id: u32, x: f64, preset: &str, raw: &str| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 170.0, 170.0, 130.0)), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + effects: Some(deckcraft_model::Effects { raw3d: Some(raw.into()), ..Default::default() }), + ..Default::default() + }; + deck_with(vec![ + mk( + 700, + 40.0, + "rect", + r#""#, + ), + mk( + 701, + 280.0, + "ellipse", + r#""#, + ), + mk( + 702, + 520.0, + "star5", + r#""#, + ), + mk( + 703, + 760.0, + "rect", + r#""#, + ), + ]) +} + +#[test] +fn extrusions_show_their_sides_under_a_turned_camera() { + let p = extruded_deck(); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(path) = std::env::var("DECKCRAFT_PNG_OUT") { + let _ = std::fs::write(path, img.to_png()); + } + let count = |x0: u32, x1: u32, f: &dyn Fn([u8; 4]) -> bool| { + (x0..x1).flat_map(|x| (100..400).map(move |y| (x, y))).filter(|(x, y)| f(img.pixel(*x, *y))).count() + }; + // The red sides of the ellipse; the dark accent-1 sides of the cube; none for a front camera. + assert!(count(250, 500, &|c| c[0] > 100 && c[1] < 40 && c[2] < 40) > 300, "red extrusion"); + let flat = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let ink = |img: &Image, x0: u32, x1: u32| { + (x0..x1).flat_map(|x| (100..400).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y) != [255, 255, 255, 255]).count() + }; + let mut no_depth = extruded_deck(); + for sh in &mut std::sync::Arc::make_mut(&mut no_depth.slides[0]).shapes { + if let Some(fx) = sh.effects.as_mut() { + fx.raw3d = fx.raw3d.as_ref().map(|r| r.replace(" extrusionH=", " x=")); + } + } + let thin = render_slide(&no_depth, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + assert!(ink(&img, 0, 260) > ink(&thin, 0, 260) + 1000, "the cube's sides add ink"); + assert_eq!(ink(&img, 740, 960), ink(&thin, 740, 960), "a front camera shows no sides"); + assert_eq!(ink(&flat, 0, 960), 0); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..7a453409 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -169,8 +169,8 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting - render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, - only the three-point rig, and no command or pane to apply 3-D). + 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). @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 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-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |