Render: 3-D cameras turn shapes (affine approximation)
A shape seen through a 3-D camera preset (isometric, perspective...) or turned with a:camera/a:rot drew flat and front-on. PowerPoint turns the shape's plane in space and projects it. Fitted black-box to its exports (a picture with coloured corner markers under every preset and several rotations; every fit within half a point on a 100 x 75 pt shape): points about the centre are turned Rz(-rev) Rx(lat) Ry(lon), perspective cameras then dividing by D / (D - z) with D about 5-16 shape sizes; a:rot turns the other way about the horizontal and vertical axes. DeckCraft now draws the shape under the affine transform that best fits its turned corners: exact for orthographic cameras, within 2% of the size for perspective ones (an affine map can't foreshorten). Oblique cameras leave a flat shape as it is; depth (extrusion) is not drawn. Wild synthetic set: camera presets 15.02 -> 6.35, 3-D picture styles 16.00 -> 7.72, extruded cameras 21.41 -> 19.74. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e2cbaed79
commit
238157d98e
|
|
@ -0,0 +1,202 @@
|
|||
//! 3-D cameras (`a:scene3d/a:camera`) turning a flat shape, approximated in 2-D.
|
||||
//!
|
||||
//! A shape seen through a 3-D camera preset (isometric, perspective…) or rotated with
|
||||
//! `a:camera/a:rot` is drawn by PowerPoint as its plane turned in space and projected. DeckCraft
|
||||
//! turns the shape's corners the same way and draws the shape under the affine transform that best
|
||||
//! fits them: exact for orthographic cameras, within about 2% of the shape's size for the
|
||||
//! perspective ones (whose foreshortening an affine transform can't show). Depth, bevels' sides and
|
||||
//! lighting are not drawn.
|
||||
//!
|
||||
//! The presets' turns were fitted to PowerPoint's exports (black-box: a picture with coloured
|
||||
//! corner markers under each preset, all fits within half a point on a 100 × 75 pt shape):
|
||||
//! points relative to the shape's centre are turned `p = Rz(-rev) · Rx(lat) · Ry(lon) · (x, y, 0)`
|
||||
//! and, for perspective cameras, scaled by `D / (D - p.z)` with `D = dist · max(w, h)`.
|
||||
|
||||
use kurbo::{Affine, Point};
|
||||
|
||||
/// (preset, lat, lon, rev in degrees, perspective distance in shape sizes or 0 for orthographic).
|
||||
const PRESETS: &[(&str, f64, f64, f64, f64)] = &[
|
||||
("orthographicFront", 0.0, 0.0, 0.0, 0.0),
|
||||
("isometricTopUp", 215.52, 134.75, 240.17, 0.0),
|
||||
("isometricTopDown", 324.69, 45.30, 299.88, 0.0),
|
||||
("isometricBottomUp", 215.52, 134.75, 240.17, 0.0),
|
||||
("isometricBottomDown", 215.36, 225.28, 119.86, 0.0),
|
||||
("isometricLeftUp", 324.94, 44.77, 0.03, 0.0),
|
||||
("isometricLeftDown", 144.84, 224.73, 179.95, 0.0),
|
||||
("isometricRightUp", 324.81, 44.75, 359.98, 0.0),
|
||||
("isometricRightDown", 144.84, 224.73, 179.95, 0.0),
|
||||
("isometricOffAxis1Left", 17.94, 64.06, 359.94, 0.0),
|
||||
("isometricOffAxis1Right", 341.80, 25.84, 0.0, 0.0),
|
||||
("isometricOffAxis1Top", 238.56, 127.22, 237.03, 0.0),
|
||||
("isometricOffAxis2Left", 161.88, 205.84, 179.98, 0.0),
|
||||
("isometricOffAxis2Right", 197.91, 244.06, 180.06, 0.0),
|
||||
("isometricOffAxis2Top", 301.42, 52.80, 302.97, 0.0),
|
||||
("isometricOffAxis3Left", 197.95, 244.05, 180.06, 0.0),
|
||||
("isometricOffAxis3Right", 161.88, 205.84, 179.98, 0.0),
|
||||
("isometricOffAxis3Bottom", 301.42, 52.78, 302.98, 0.0),
|
||||
("isometricOffAxis4Left", 198.00, 205.84, 180.05, 0.0),
|
||||
("isometricOffAxis4Right", 17.94, 64.06, 359.94, 0.0),
|
||||
("isometricOffAxis4Bottom", 121.42, 232.75, 237.00, 0.0),
|
||||
("perspectiveLeft", 359.91, 341.75, 0.02, 12.14),
|
||||
("perspectiveRight", 180.27, 198.23, 179.94, 8.02),
|
||||
("perspectiveAbove", 18.92, 0.38, 0.09, 16.61),
|
||||
("perspectiveBelow", 198.83, 180.39, 179.89, 9.31),
|
||||
("perspectiveAboveLeftFacing", 320.78, 346.02, 340.95, 11.54),
|
||||
("perspectiveAboveRightFacing", 219.25, 194.14, 198.98, 9.28),
|
||||
("perspectiveContrastingLeftFacing", 190.42, 136.41, 176.42, 12.21),
|
||||
("perspectiveContrastingRightFacing", 190.52, 223.59, 183.56, 9.53),
|
||||
("perspectiveHeroicLeftFacing", 10.89, 346.28, 2.75, 13.22),
|
||||
("perspectiveHeroicRightFacing", 169.53, 194.00, 177.22, 8.45),
|
||||
("perspectiveHeroicExtremeLeftFacing", 351.98, 325.89, 357.00, 5.98),
|
||||
("perspectiveHeroicExtremeRightFacing", 188.39, 214.22, 182.81, 5.26),
|
||||
("perspectiveRelaxed", 50.02, 0.03, 0.0, 11.32),
|
||||
("perspectiveRelaxedModerately", 34.42, 0.33, 0.16, 11.14),
|
||||
];
|
||||
|
||||
/// Perspective distance for a perspective camera given its own rotation (fitted on
|
||||
/// `perspectiveFront` with `a:rot`, about 11 shape sizes).
|
||||
const PERSPECTIVE_DIST: f64 = 11.0;
|
||||
|
||||
/// A camera's turn of the shape's plane.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Camera {
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
pub rev: f64,
|
||||
/// Perspective distance in shape sizes; 0 for an orthographic camera.
|
||||
pub dist: f64,
|
||||
}
|
||||
|
||||
/// The value of attribute `name` in the first `tag` element of `raw` (XML as kept from the file).
|
||||
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 camera of 3-D XML kept from the file, when it turns the shape.
|
||||
pub fn from_raw(raw: &str) -> Option<Camera> {
|
||||
let i = raw.find("camera ")?;
|
||||
let rest = raw.get(i..)?;
|
||||
let open_end = rest.find('>')?;
|
||||
let open = rest.get(..open_end)?;
|
||||
let prst = attr(open, "prst").unwrap_or("orthographicFront");
|
||||
let preset = PRESETS.iter().find(|p| p.0 == prst);
|
||||
let perspective = prst.starts_with("perspective");
|
||||
// Its own `a:rot` (inside the camera element, not the light rig's).
|
||||
let self_closing = open.ends_with('/');
|
||||
let own_rot = if self_closing {
|
||||
None
|
||||
} else {
|
||||
let body = rest.get(open_end..rest.find("camera>").unwrap_or(rest.len()))?;
|
||||
body.find("rot ").and_then(|j| {
|
||||
let t = body.get(j..)?;
|
||||
let t = t.get(..t.find('>')?)?;
|
||||
let deg = |n: &str| attr(t, n).and_then(|v| v.parse::<f64>().ok()).map(|v| v / 60000.0).unwrap_or(0.0);
|
||||
Some((deg("lat"), deg("lon"), deg("rev")))
|
||||
})
|
||||
};
|
||||
let cam = match (own_rot, preset) {
|
||||
// PowerPoint turns the other way about the horizontal and vertical axes for `a:rot`.
|
||||
(Some((lat, lon, rev)), p) => {
|
||||
let dist = match p {
|
||||
Some(p) if p.4 > 0.0 => p.4,
|
||||
_ if perspective => PERSPECTIVE_DIST,
|
||||
_ => 0.0,
|
||||
};
|
||||
Camera { lat: -lat, lon: -lon, rev, dist }
|
||||
}
|
||||
(None, Some(p)) => Camera { lat: p.1, lon: p.2, rev: p.3, dist: p.4 },
|
||||
(None, None) => return None,
|
||||
};
|
||||
let flat = [cam.lat, cam.lon, cam.rev].iter().all(|a| (a.rem_euclid(360.0)).min(360.0 - a.rem_euclid(360.0)) < 1e-6);
|
||||
(!flat).then_some(cam)
|
||||
}
|
||||
|
||||
/// Where a point of the shape (relative to its centre) lands.
|
||||
fn project(c: &Camera, p: Point, size: f64) -> Point {
|
||||
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 (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 {
|
||||
let d = c.dist * size;
|
||||
let s = d / (d - z2);
|
||||
if s.is_finite() && s > 0.0 {
|
||||
return Point::new(x3 * s, y3 * s);
|
||||
}
|
||||
}
|
||||
Point::new(x3, y3)
|
||||
}
|
||||
|
||||
/// The transform (shape space to shape space) that turns a `w` × `h` shape as camera `c` does:
|
||||
/// the affine map best fitting its turned corners.
|
||||
pub fn affine(c: &Camera, w: f64, h: f64) -> Option<Affine> {
|
||||
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let size = w.max(h);
|
||||
let src = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)];
|
||||
let dst: Vec<Point> = src.iter().map(|(x, y)| project(c, Point::new(*x, *y), size)).collect();
|
||||
// Least squares over the four corners. The corners are symmetric about the centre, so the
|
||||
// normal equations are diagonal: x' = a x + b y + e with a = Σ x x' / Σ x², and so on.
|
||||
let (sxx, syy) = (src.iter().map(|p| p.0 * p.0).sum::<f64>(), src.iter().map(|p| p.1 * p.1).sum::<f64>());
|
||||
let dot = |f: &dyn Fn(&(f64, f64), &Point) -> f64| src.iter().zip(&dst).map(|(s, d)| f(s, d)).sum::<f64>();
|
||||
let a = dot(&|s, d| s.0 * d.x) / sxx;
|
||||
let b = dot(&|s, d| s.1 * d.x) / syy;
|
||||
let cc = dot(&|s, d| s.0 * d.y) / sxx;
|
||||
let dd = dot(&|s, d| s.1 * d.y) / syy;
|
||||
let e = dot(&|_, d| d.x) / 4.0;
|
||||
let f = dot(&|_, d| d.y) / 4.0;
|
||||
let local = Affine::new([a, cc, b, dd, e, f]);
|
||||
let m = Affine::translate((w / 2.0, h / 2.0)) * local * Affine::translate((-w / 2.0, -h / 2.0));
|
||||
(m.is_finite() && m.determinant().abs() > 1e-9).then_some(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn corners(m: Affine, w: f64, h: f64) -> Vec<Point> {
|
||||
[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].iter().map(|(x, y)| m * Point::new(*x, *y)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_about_the_vertical_axis_narrows_the_shape() {
|
||||
// orthographicFront with lon 60: half as wide, as tall (PowerPoint: 0.506 of the width).
|
||||
let c = from_raw(r#"<a:scene3d><a:camera prst="orthographicFront"><a:rot lat="0" lon="3600000" rev="0"/></a:camera><a:lightRig rig="threePt" dir="t"><a:rot lat="0" lon="0" rev="1200000"/></a:lightRig></a:scene3d>"#).unwrap();
|
||||
let m = affine(&c, 100.0, 75.0).unwrap();
|
||||
let k = corners(m, 100.0, 75.0);
|
||||
assert!((k[1].x - k[0].x - 50.0).abs() < 0.01 && (k[3].y - k[0].y - 75.0).abs() < 0.01, "{k:?}");
|
||||
assert!(((k[0].x + k[1].x) / 2.0 - 50.0).abs() < 1e-9, "about the centre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_turn_the_shape_as_powerpoint_does() {
|
||||
// isometricTopUp, 100 × 75: PowerPoint's corners at (-1.247, 0.127), (0.170, -0.955),
|
||||
// (1.237, -0.141), (-0.181, 0.941) of the half sizes from the centre.
|
||||
let c = from_raw(r#"<a:scene3d><a:camera prst="isometricTopUp"/><a:lightRig rig="threePt" dir="t"/></a:scene3d>"#).unwrap();
|
||||
let k = corners(affine(&c, 100.0, 75.0).unwrap(), 100.0, 75.0);
|
||||
for (p, (u, v)) in k.iter().zip([(-1.247, 0.127), (0.170, -0.955), (1.237, -0.141), (-0.181, 0.941)]) {
|
||||
let (x, y) = ((p.x - 50.0) / 50.0, (p.y - 37.5) / 37.5);
|
||||
assert!((x - u).abs() < 0.03 && (y - v).abs() < 0.03, "({x:.3}, {y:.3}) vs ({u}, {v})");
|
||||
}
|
||||
// Front-facing cameras and the oblique ones leave a flat shape as it is.
|
||||
for raw in [r#"<a:scene3d><a:camera prst="orthographicFront"/></a:scene3d>"#, r#"<a:scene3d><a:camera prst="obliqueTopLeft"/></a:scene3d>"#] {
|
||||
assert_eq!(from_raw(raw), None);
|
||||
}
|
||||
assert_eq!(from_raw("<a:sp3d/>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_perspective_camera_is_approximated_closely() {
|
||||
// perspectiveContrastingLeftFacing on 100 × 75: PowerPoint's corners within 3% of the size.
|
||||
let c = from_raw(r#"<a:scene3d><a:camera prst="perspectiveContrastingLeftFacing"/></a:scene3d>"#).unwrap();
|
||||
let k = corners(affine(&c, 100.0, 75.0).unwrap(), 100.0, 75.0);
|
||||
for (p, (u, v)) in k.iter().zip([(-0.657, -1.189), (0.791, -0.795), (0.685, 1.244), (-0.742, 0.729)]) {
|
||||
let (dx, dy) = (p.x - 50.0 - u * 50.0, p.y - 37.5 - v * 37.5);
|
||||
assert!(dx.hypot(dy) < 3.0, "{p:?} vs ({u}, {v})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bevel;
|
||||
mod camera;
|
||||
mod chart;
|
||||
mod colr;
|
||||
|
||||
|
|
@ -773,6 +774,17 @@ impl Renderer {
|
|||
* Affine::translate(-c.to_vec2());
|
||||
m = parent * Affine::translate(st.offset) * x.affine() * around;
|
||||
}
|
||||
// A 3-D camera turns the shape (approximated by the affine transform that fits it best).
|
||||
if !matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_))
|
||||
&& let Some(a) = resolve::effects(rctx, s)
|
||||
.0
|
||||
.as_ref()
|
||||
.and_then(|e| e.raw3d.as_deref())
|
||||
.and_then(camera::from_raw)
|
||||
.and_then(|c| camera::affine(&c, x.w, x.h))
|
||||
{
|
||||
m *= a;
|
||||
}
|
||||
let layered = st.opacity < 0.999 || st.clip.is_some();
|
||||
if layered {
|
||||
ctx.set_transform(m);
|
||||
|
|
|
|||
Loading…
Reference in New Issue