mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user