Render 3-D extrusions: sides swept behind the face under turned cameras

sp3d extrusionH (with extrusionClr, srgb or scheme) under a camera that
turns the shape now draws the shape's sides: its outline swept from the
back face to the front along the camera's view of the depth axis, in the
side colour shaded, before the face. camera::project3 projects points
off the shape's plane.
This commit is contained in:
Nikita Rakitin
2026-10-14 01:19:18 +03:00
parent 457406a7a5
commit 27988aaa24
5 changed files with 211 additions and 3 deletions
+8 -1
View File
@@ -116,8 +116,15 @@ pub fn from_raw(raw: &str) -> Option<Camera> {
/// 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 {
+119
View File
@@ -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<ColorRef>,
}
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<Extrusion> {
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::<f64>().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>) -> 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#"<a:scene3d><a:camera prst="isometricOffAxis2Left"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000"><a:extrusionClr><a:srgbClr val="C00000"/></a:extrusionClr></a:sp3d>"#;
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#"<a:sp3d extrusionH="381000"/>"#).is_none(), "no scene: drawn flat");
assert!(from_raw(r#"<a:scene3d/><a:sp3d/>"#).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);
}
}
+15
View File
@@ -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 } => {
+66
View File
@@ -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#"<a:scene3d><a:camera prst="isometricOffAxis2Left"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000"/>"#,
),
mk(
701,
280.0,
"ellipse",
r#"<a:scene3d><a:camera prst="isometricTopUp"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="254000"><a:extrusionClr><a:srgbClr val="C00000"/></a:extrusionClr></a:sp3d>"#,
),
mk(
702,
520.0,
"star5",
r#"<a:scene3d><a:camera prst="perspectiveContrastingLeftFacing"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="508000"><a:extrusionClr><a:schemeClr val="accent2"/></a:extrusionClr></a:sp3d>"#,
),
mk(
703,
760.0,
"rect",
r#"<a:scene3d><a:camera prst="orthographicFront"/><a:lightRig rig="threePt" dir="t"/></a:scene3d><a:sp3d extrusionH="381000"/>"#,
),
])
}
#[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);
}
+3 -2
View File
@@ -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 |