Skip to main content

motion_core/geometry/
perspective.rs

1//! Planar 2.5D perspective: a flat layer rotated in 3D and viewed through a pinhole camera.
2
3use crate::{Path2D, PathVerb, Point, ValidationError};
4
5#[cfg(test)]
6mod tests;
7
8/// Maximum cubic pieces used for each curve verb in [`Perspective::project_path`].
9pub const MAX_PERSPECTIVE_CURVE_PIECES: u32 = 64;
10
11/// Smallest accepted fraction of the camera distance between the camera and a point.
12const NEAR_FRACTION: f64 = 1e-3;
13
14const LABEL: &str = "perspective";
15
16/// Rotates the plane `z = 0` about a pivot and projects it onto the viewport.
17///
18/// The camera sits `distance` units in front of the plane on the `z` axis, looking
19/// toward the plane, and the pivot stays fixed on screen. Rotations apply in the order
20/// z (in-plane), y, then x, in radians. Positive `rotation_x` swings the bottom edge
21/// toward the viewer, and positive `rotation_y` swings the left edge toward the viewer.
22/// A point at depth `z` scales by `distance / (distance - z)`.
23///
24/// Clipping is explicit: a point closer than a thousandth of `distance` to the camera
25/// plane, or behind it, is an error rather than being clipped or wrapped. A plane seen
26/// from behind remains drawable (mirrored); [`Self::faces_camera`] reports the side so
27/// callers can hide it. Only geometry is projected, so the result renders identically
28/// on the CPU and GPU backends.
29///
30/// ```
31/// use motion_core::{Perspective, Point};
32/// let tilt = Perspective::new(Point::new(100.0, 100.0), 0.0, 0.5, 0.0, 800.0)?;
33/// let near = tilt.project(Point::new(0.0, 100.0))?;
34/// let far = tilt.project(Point::new(200.0, 100.0))?;
35/// assert!(100.0 - near.x > far.x - 100.0);
36/// assert!(tilt.faces_camera());
37/// # Ok::<(), motion_core::ValidationError>(())
38/// ```
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct Perspective {
41    pivot: Point,
42    rotation: [f64; 3],
43    distance: f64,
44}
45
46impl Perspective {
47    /// Creates a projection; angles are finite radians and `distance` is finite and positive.
48    pub fn new(
49        pivot: Point,
50        rotation_x: f64,
51        rotation_y: f64,
52        rotation_z: f64,
53        distance: f64,
54    ) -> Result<Self, ValidationError> {
55        let finite = [pivot.x, pivot.y, rotation_x, rotation_y, rotation_z]
56            .iter()
57            .all(|value| value.is_finite());
58        if !finite {
59            return Err(ValidationError::invalid(
60                LABEL,
61                "rotation",
62                "Pivot and angles must be finite",
63            ));
64        }
65        if !distance.is_finite() || distance <= 0.0 {
66            return Err(ValidationError::invalid(
67                LABEL,
68                "distance",
69                "Camera distance must be finite and positive",
70            ));
71        }
72        Ok(Self {
73            pivot,
74            rotation: [rotation_x, rotation_y, rotation_z],
75            distance,
76        })
77    }
78
79    /// The fixed point of the projection.
80    pub fn pivot(&self) -> Point {
81        self.pivot
82    }
83
84    /// Rotation angles about the x, y and z axes, in radians.
85    pub fn rotation(&self) -> [f64; 3] {
86        self.rotation
87    }
88
89    /// Camera distance from the plane, in the same units as path coordinates.
90    pub fn distance(&self) -> f64 {
91        self.distance
92    }
93
94    /// Whether the rotated plane's front side points toward the camera.
95    pub fn faces_camera(&self) -> bool {
96        libm::cos(self.rotation[0]) * libm::cos(self.rotation[1]) > 0.0
97    }
98
99    /// Projects one plane point, failing when it reaches the camera plane.
100    pub fn project(&self, point: Point) -> Result<Point, ValidationError> {
101        let (x, y) = (point.x - self.pivot.x, point.y - self.pivot.y);
102        let [rotation_x, rotation_y, rotation_z] = self.rotation;
103        let (sin, cos) = (libm::sin(rotation_z), libm::cos(rotation_z));
104        let (x, y) = (x * cos - y * sin, x * sin + y * cos);
105        let (sin, cos) = (libm::sin(rotation_y), libm::cos(rotation_y));
106        let (x, z) = (x * cos, -x * sin);
107        let (sin, cos) = (libm::sin(rotation_x), libm::cos(rotation_x));
108        let (y, z) = (y * cos - z * sin, y * sin + z * cos);
109        let depth = self.distance - z;
110        if !depth.is_finite() || depth <= self.distance * NEAR_FRACTION {
111            return Err(ValidationError::invalid(
112                LABEL,
113                "depth",
114                "Point reaches or passes the camera plane",
115            ));
116        }
117        let scale = self.distance / depth;
118        Ok(Point::new(
119            self.pivot.x + x * scale,
120            self.pivot.y + y * scale,
121        ))
122    }
123
124    /// Projects every verb of a path; each curve becomes `pieces` cubics whose controls
125    /// are projected, which approximates the exact rational curve.
126    ///
127    /// Lines stay lines, because projection preserves straight edges. Fails when `pieces`
128    /// is outside `1..=`[`MAX_PERSPECTIVE_CURVE_PIECES`], a point reaches the camera
129    /// plane, or the result exceeds the path verb limit.
130    pub fn project_path(&self, path: &Path2D, pieces: u32) -> Result<Path2D, ValidationError> {
131        if !(1..=MAX_PERSPECTIVE_CURVE_PIECES).contains(&pieces) {
132            return Err(ValidationError::limit(
133                LABEL,
134                "pieces",
135                "Curve pieces must be between 1 and 64",
136            ));
137        }
138        let mut result = Path2D::new();
139        let mut current = Point::ORIGIN;
140        let mut start = Point::ORIGIN;
141        for verb in path.verbs() {
142            match *verb {
143                PathVerb::MoveTo(point) => {
144                    result.try_move_to(self.project(point)?)?;
145                    current = point;
146                    start = point;
147                }
148                PathVerb::LineTo(point) => {
149                    result.try_line_to(self.project(point)?)?;
150                    current = point;
151                }
152                PathVerb::QuadraticTo(control, end) => {
153                    let first = lerp(current, control, 2.0 / 3.0);
154                    let second = lerp(end, control, 2.0 / 3.0);
155                    self.cubic(&mut result, [current, first, second, end], pieces)?;
156                    current = end;
157                }
158                PathVerb::CubicTo(first, second, end) => {
159                    self.cubic(&mut result, [current, first, second, end], pieces)?;
160                    current = end;
161                }
162                PathVerb::Close => {
163                    result.try_close()?;
164                    current = start;
165                }
166            }
167        }
168        Ok(result)
169    }
170
171    fn cubic(
172        &self,
173        result: &mut Path2D,
174        curve: [Point; 4],
175        pieces: u32,
176    ) -> Result<(), ValidationError> {
177        let curve = kurbo::CubicBez::new(
178            (curve[0].x, curve[0].y),
179            (curve[1].x, curve[1].y),
180            (curve[2].x, curve[2].y),
181            (curve[3].x, curve[3].y),
182        );
183        for index in 0..pieces {
184            let piece = kurbo::ParamCurve::subsegment(
185                &curve,
186                f64::from(index) / f64::from(pieces)..f64::from(index + 1) / f64::from(pieces),
187            );
188            let [first, second, end] = [piece.p1, piece.p2, piece.p3];
189            result.try_cubic_to(
190                self.project(Point::new(first.x, first.y))?,
191                self.project(Point::new(second.x, second.y))?,
192                self.project(Point::new(end.x, end.y))?,
193            )?;
194        }
195        Ok(())
196    }
197}
198
199fn lerp(from: Point, to: Point, amount: f64) -> Point {
200    Point::new(
201        from.x + (to.x - from.x) * amount,
202        from.y + (to.y - from.y) * amount,
203    )
204}