Skip to main content

motion_core/authoring/
camera.rs

1//! Stateless world-to-viewport mapping built from ordinary scene groups.
2
3/// f64 inverse-view parameters; scene time remains caller-owned.
4#[derive(Clone, Copy, Debug, PartialEq)]
5pub struct Camera {
6    /// World point placed at the current design viewport's center.
7    pub center: crate::Point,
8    /// Positive logical units per world unit.
9    pub zoom: f64,
10    /// Clockwise camera orientation in radians; content rotates oppositely.
11    pub rotation: f64,
12}
13
14impl Default for Camera {
15    fn default() -> Self {
16        Self {
17            center: crate::Point::ORIGIN,
18            zoom: 1.0,
19            rotation: 0.0,
20        }
21    }
22}
23
24impl Camera {
25    fn transform(
26        self,
27        context: &crate::SceneContext<'_>,
28    ) -> Result<crate::Transform, crate::ValidationError> {
29        if ![self.center.x, self.center.y, self.zoom, self.rotation]
30            .iter()
31            .all(|value| value.is_finite())
32            || self.zoom <= 0.0
33        {
34            return Err(invalid(
35                "Camera requires finite coordinates, rotation and positive zoom",
36            ));
37        }
38        let center = context.center();
39        let transform = crate::Transform::translate(-self.center.x, -self.center.y)
40            .then(crate::Transform::scale(self.zoom))
41            .then(crate::Transform::rotate(-self.rotation))
42            .then(crate::Transform::translate(center.x, center.y));
43        if transform
44            .coefficients()
45            .iter()
46            .any(|value| !value.is_finite())
47            || transform.inverse().is_none()
48        {
49            return Err(invalid("Camera mapping must be finite and invertible"));
50        }
51        Ok(transform)
52    }
53
54    /// Projects a world point without clipping or including ancestor transforms.
55    pub fn world_to_screen(
56        self,
57        context: &crate::SceneContext<'_>,
58        point: crate::Point,
59    ) -> Result<crate::Point, crate::ValidationError> {
60        map(self.transform(context)?, point)
61    }
62
63    /// Unprojects a design-viewport point, including points outside the viewport.
64    pub fn screen_to_world(
65        self,
66        context: &crate::SceneContext<'_>,
67        point: crate::Point,
68    ) -> Result<crate::Point, crate::ValidationError> {
69        map(
70            self.transform(context)?
71                .inverse()
72                .ok_or_else(|| invalid("Camera inverse is not representable"))?,
73            point,
74        )
75    }
76}
77
78/// Applies a view, retaining invalid camera diagnostics until compilation.
79pub fn camera(
80    context: &crate::SceneContext<'_>,
81    content: impl Into<crate::Node>,
82    view: Camera,
83) -> crate::Node {
84    try_camera(context, content, view).unwrap_or_else(super::scene::failure)
85}
86
87/// Applies the inverse view to a subtree, not to subsequent HUD nodes.
88/// No font discovery, old layers, new node kind or implicit viewport clip is added.
89///
90/// ```
91/// use motion_core::prelude::*;
92/// use motion_core::{
93///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
94///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
95/// };
96/// let cache = SceneCache::new(0);
97/// let context = SceneContext::new(Time::ZERO, FrameRate::from(30), Size::new(8.0, 8.0), &cache);
98/// let view = Camera {
99///     center: Point::new(10.0, 5.0),
100///     zoom: 2.0,
101///     rotation: 0.0,
102/// };
103/// let node = camera(
104///     &context,
105///     Rect::new(9.0, 4.0, 11.0, 6.0).fill(Color::WHITE),
106///     view,
107/// );
108/// assert_eq!(
109///     view.world_to_screen(&context, view.center)?,
110///     context.center()
111/// );
112/// node.compile(
113///     context.size(),
114///     PixelSize::new(8, 8),
115///     ValidationLimits::default(),
116/// )?;
117/// # Ok::<(), motion_core::ValidationError>(())
118/// ```
119pub fn try_camera(
120    context: &crate::SceneContext<'_>,
121    content: impl Into<crate::Node>,
122    view: Camera,
123) -> Result<crate::Node, crate::ValidationError> {
124    use crate::NodeModifiers;
125    view.transform(context)?;
126    Ok(
127        crate::Node::group([content.into().translate(-view.center.x, -view.center.y)])
128            .pivot(crate::Pivot::Point(crate::Point::ORIGIN))
129            .scale(view.zoom)
130            .rotate(-view.rotation)
131            .translate(context.center().x, context.center().y),
132    )
133}
134
135fn invalid(message: &str) -> crate::ValidationError {
136    crate::ValidationError::invalid("camera", "view", message)
137}
138
139/// A 2.5D source plane; larger depth is farther from the camera.
140#[derive(Clone, Debug)]
141pub struct DepthPlane {
142    /// Immutable plane artwork.
143    pub node: crate::Node,
144    /// Positive camera distance in the same units as focal length.
145    pub depth: f64,
146}
147
148/// Thin-lens circle of confusion and optional depth-dependent perspective/parallax.
149#[derive(Clone, Copy, Debug, PartialEq)]
150pub struct DepthOfField {
151    /// Camera-to-focus distance, greater than focal length.
152    pub focus_distance: f64,
153    /// Positive aperture f-number.
154    pub f_stop: f64,
155    /// Positive focal length in scene units.
156    pub focal_length: f64,
157    /// Finite nonnegative maximum blur radius in design units.
158    pub max_radius: f64,
159    /// Apply pinhole perspective and depth-scaled camera translation.
160    pub perspective: bool,
161}
162
163impl DepthOfField {
164    /// Validates physical lens parameters.
165    pub fn validate(self) -> Result<(), crate::ValidationError> {
166        if ![
167            self.focus_distance,
168            self.f_stop,
169            self.focal_length,
170            self.max_radius,
171        ]
172        .iter()
173        .all(|value| value.is_finite())
174            || self.f_stop <= 0.0
175            || self.focal_length <= 0.0
176            || self.focus_distance <= self.focal_length
177            || self.max_radius < 0.0
178        {
179            return Err(invalid(
180                "Require a positive aperture/focal length, focus beyond the lens and a finite blur cap",
181            ));
182        }
183        Ok(())
184    }
185
186    /// Radius is exactly zero at focus; extreme finite depths remain capped.
187    pub fn radius(self, depth: f64) -> Result<f64, crate::ValidationError> {
188        self.validate()?;
189        if !depth.is_finite() || depth <= self.focal_length {
190            return Err(invalid(
191                "Plane depth must be finite and beyond the focal length",
192            ));
193        }
194        let radius = (self.focal_length / self.f_stop)
195            * (self.focal_length / (self.focus_distance - self.focal_length))
196            * ((depth - self.focus_distance).abs() / depth)
197            * 0.5;
198        Ok(radius.min(self.max_radius))
199    }
200
201    /// Orders far planes before near planes, then applies the camera to each plane.
202    pub fn capture(
203        self,
204        context: &crate::SceneContext<'_>,
205        view: Camera,
206        planes: impl IntoIterator<Item = DepthPlane>,
207    ) -> Result<crate::Node, crate::ValidationError> {
208        use crate::NodeModifiers;
209        self.validate()?;
210        view.transform(context)?;
211        let mut planes = planes.into_iter().take(257).collect::<Vec<_>>();
212        if planes.len() > 256 {
213            return Err(invalid("Depth capture supports at most 256 planes"));
214        }
215        for plane in &planes {
216            self.radius(plane.depth)?;
217        }
218        planes.sort_by(|first, last| last.depth.total_cmp(&first.depth));
219        let mut nodes = Vec::with_capacity(planes.len());
220        for plane in planes {
221            let radius = self.radius(plane.depth)?;
222            let factor = if self.perspective {
223                (self.focus_distance - self.focal_length) / (plane.depth - self.focal_length)
224            } else {
225                1.0
226            };
227            let transform = view.transform(context)?;
228            let node = plane
229                .node
230                .transformed(crate::Transform::scale(factor).then(transform));
231            nodes.push(if radius == 0.0 {
232                node
233            } else {
234                node.filter(crate::Filter::Optics(crate::Optics::Bokeh {
235                    radius,
236                    shape: crate::ApertureShape::Disc,
237                    highlight_boost: 0.0,
238                }))
239            });
240        }
241        Ok(crate::Node::group(nodes))
242    }
243}
244
245fn map(
246    transform: crate::Transform,
247    point: crate::Point,
248) -> Result<crate::Point, crate::ValidationError> {
249    let result = transform.apply(point);
250    if [point.x, point.y, result.x, result.y]
251        .iter()
252        .all(|value| value.is_finite())
253    {
254        Ok(result)
255    } else {
256        Err(invalid("Camera points must map to finite coordinates"))
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn thin_lens_focus_is_exact_and_out_of_focus_radius_is_capped() {
266        let lens = super::DepthOfField {
267            focus_distance: 1000.0,
268            f_stop: 2.0,
269            focal_length: 50.0,
270            max_radius: 10.0,
271            perspective: true,
272        };
273        assert_eq!(lens.radius(1000.0).unwrap(), 0.0);
274        assert!(lens.radius(100.0).unwrap() > 0.0);
275        assert!(lens.radius(50.0).is_err());
276        assert!(
277            super::DepthOfField {
278                f_stop: 0.0,
279                ..lens
280            }
281            .validate()
282            .is_err()
283        );
284    }
285
286    #[test]
287    fn camera_invalid_parameters_fail_without_rendering_and_do_not_mutate_views() {
288        let cache = crate::SceneCache::new(0);
289        let context = crate::SceneContext::new(
290            crate::Time::ZERO,
291            crate::FrameRate::from(30),
292            crate::Size::new(8.0, 8.0),
293            &cache,
294        );
295        for view in [
296            Camera {
297                zoom: 0.0,
298                ..Camera::default()
299            },
300            Camera {
301                zoom: -1.0,
302                ..Camera::default()
303            },
304            Camera {
305                rotation: f64::NAN,
306                ..Camera::default()
307            },
308            Camera {
309                center: crate::Point::new(f64::INFINITY, 0.0),
310                ..Camera::default()
311            },
312        ] {
313            assert!(try_camera(&context, crate::Node::empty(), view).is_err());
314            assert!(
315                view.screen_to_world(&context, crate::Point::ORIGIN)
316                    .is_err()
317            );
318        }
319        let view = Camera::default();
320        assert!(
321            view.world_to_screen(&context, crate::Point::new(f64::NAN, 0.0))
322                .is_err()
323        );
324        assert_eq!(view, Camera::default());
325    }
326
327    #[test]
328    fn projection_keeps_fixed_clip_parent_mapping_and_overflow_guards() -> crate::Result {
329        use crate::{DrawCommand, Node, NodeModifiers, Pivot, Point, Rect, Size, Transform};
330        let cache = crate::SceneCache::new(0);
331        let context = crate::SceneContext::new(
332            crate::Time::ZERO,
333            crate::FrameRate::NTSC,
334            Size::new(120.0, 80.0),
335            &cache,
336        );
337        let view = Camera {
338            center: Point::new(30.0, -20.0),
339            zoom: 2.5,
340            rotation: 0.7,
341        };
342        let viewport = Rect::new(20.0, 40.0, 140.0, 120.0);
343        for parent in [
344            Transform::IDENTITY,
345            Transform::scale_non_uniform(-1.3, 1.1)
346                .then(Transform::rotate(0.3))
347                .then(Transform::translate(80.0, 100.0)),
348        ] {
349            let artwork = try_camera(
350                &context,
351                Rect::new(0.0, 0.0, 12.0, 8.0).fill(crate::Color::WHITE),
352                view,
353            )?;
354            let world = Node::group([artwork.translate(20.0, 40.0)])
355                .clip_with(viewport, crate::ClipMode::Geometric);
356            let components = parent.decompose().unwrap();
357            let node = world
358                .pivot(Pivot::Point(Point::ORIGIN))
359                .scale_xy(components.scale.x, components.scale.y)
360                .rotate(components.rotation)
361                .translate(components.translation.x, components.translation.y);
362            let list = node.compile(
363                Size::new(320.0, 180.0),
364                crate::PixelSize::new(320, 180),
365                crate::ValidationLimits::default(),
366            )?;
367            let transform = list
368                .commands
369                .iter()
370                .find_map(|command| {
371                    if let DrawCommand::FillRect { transform, .. } = command {
372                        Some(*transform)
373                    } else {
374                        None
375                    }
376                })
377                .unwrap();
378            let clip = list
379                .commands
380                .iter()
381                .find_map(|command| {
382                    if let DrawCommand::PushRectLayer { rect_clip, .. } = command {
383                        Some(*rect_clip)
384                    } else {
385                        None
386                    }
387                })
388                .unwrap();
389            assert_eq!(clip.bounds, viewport);
390            for point in [
391                Point::new(30.0, -20.0),
392                Point::ORIGIN,
393                Point::new(-10.0, 25.0),
394            ] {
395                let expected = parent.apply(
396                    view.world_to_screen(&context, point)? + crate::Vector2::new(20.0, 40.0),
397                );
398                let actual = transform.apply(point);
399                assert!(
400                    (actual.x - expected.x).abs() < 1e-10 && (actual.y - expected.y).abs() < 1e-10
401                );
402                let recovered =
403                    view.screen_to_world(&context, view.world_to_screen(&context, point)?)?;
404                assert!(
405                    (recovered.x - point.x).abs() < 1e-12 && (recovered.y - point.y).abs() < 1e-12
406                );
407            }
408            assert!(
409                clip.transform
410                    .coefficients()
411                    .iter()
412                    .zip(parent.coefficients())
413                    .all(|(actual, expected)| (actual - expected).abs() < 1e-12)
414            );
415        }
416        for zoom in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::from_bits(1)] {
417            let invalid = Camera { zoom, ..view };
418            assert!(invalid.world_to_screen(&context, Point::ORIGIN).is_err());
419            assert!(invalid.screen_to_world(&context, Point::ORIGIN).is_err());
420            assert!(try_camera(&context, Node::empty(), invalid).is_err());
421        }
422        let zoomed = Camera {
423            zoom: 2.0,
424            ..Camera::default()
425        };
426        assert!(
427            zoomed
428                .world_to_screen(&context, Point::new(f64::MAX, 0.0))
429                .is_err()
430        );
431        let zoomed = Camera {
432            zoom: 0.5,
433            ..Camera::default()
434        };
435        assert!(
436            zoomed
437                .screen_to_world(&context, Point::new(f64::MAX, 0.0))
438                .is_err()
439        );
440        Ok(())
441    }
442}