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, ResourceStore, 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, fps!(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    use crate::Shape;
264
265    #[test]
266    fn thin_lens_focus_is_exact_and_out_of_focus_radius_is_capped() {
267        let lens = super::DepthOfField {
268            focus_distance: 1000.0,
269            f_stop: 2.0,
270            focal_length: 50.0,
271            max_radius: 10.0,
272            perspective: true,
273        };
274        assert_eq!(lens.radius(1000.0).unwrap(), 0.0);
275        assert!(lens.radius(100.0).unwrap() > 0.0);
276        assert!(lens.radius(50.0).is_err());
277        assert!(
278            super::DepthOfField {
279                f_stop: 0.0,
280                ..lens
281            }
282            .validate()
283            .is_err()
284        );
285    }
286
287    #[test]
288    fn camera_invalid_parameters_fail_without_rendering_and_do_not_mutate_views() {
289        let cache = crate::SceneCache::new(0);
290        let context = crate::SceneContext::new(
291            crate::Time::ZERO,
292            crate::fps!(30),
293            crate::Size::new(8.0, 8.0),
294            &cache,
295        );
296        for view in [
297            Camera {
298                zoom: 0.0,
299                ..Camera::default()
300            },
301            Camera {
302                zoom: -1.0,
303                ..Camera::default()
304            },
305            Camera {
306                rotation: f64::NAN,
307                ..Camera::default()
308            },
309            Camera {
310                center: crate::Point::new(f64::INFINITY, 0.0),
311                ..Camera::default()
312            },
313        ] {
314            assert!(try_camera(&context, crate::Node::empty(), view).is_err());
315            assert!(
316                view.screen_to_world(&context, crate::Point::ORIGIN)
317                    .is_err()
318            );
319        }
320        let view = Camera::default();
321        assert!(
322            view.world_to_screen(&context, crate::Point::new(f64::NAN, 0.0))
323                .is_err()
324        );
325        assert_eq!(view, Camera::default());
326    }
327
328    #[test]
329    fn projection_keeps_fixed_clip_parent_mapping_and_overflow_guards() -> crate::Result {
330        use crate::{DrawCommand, Node, NodeModifiers, Pivot, Point, Rect, Size, Transform};
331        let cache = crate::SceneCache::new(0);
332        let context = crate::SceneContext::new(
333            crate::Time::ZERO,
334            crate::FrameRate::NTSC,
335            Size::new(120.0, 80.0),
336            &cache,
337        );
338        let view = Camera {
339            center: Point::new(30.0, -20.0),
340            zoom: 2.5,
341            rotation: 0.7,
342        };
343        let viewport = Rect::new(20.0, 40.0, 140.0, 120.0);
344        for parent in [
345            Transform::IDENTITY,
346            Transform::scale_non_uniform(-1.3, 1.1)
347                .then(Transform::rotate(0.3))
348                .then(Transform::translate(80.0, 100.0)),
349        ] {
350            let artwork = try_camera(
351                &context,
352                Rect::new(0.0, 0.0, 12.0, 8.0).fill(crate::Color::WHITE),
353                view,
354            )?;
355            let world = Node::group([artwork.translate(20.0, 40.0)])
356                .clip_with(viewport, crate::ClipMode::Geometric);
357            let components = parent.decompose().unwrap();
358            let node = world
359                .pivot(Pivot::Point(Point::ORIGIN))
360                .scale_xy(components.scale.x, components.scale.y)
361                .rotate(components.rotation)
362                .translate(components.translation.x, components.translation.y);
363            let list = node.compile(
364                Size::new(320.0, 180.0),
365                crate::PixelSize::new(320, 180),
366                crate::ValidationLimits::default(),
367            )?;
368            let transform = list
369                .commands
370                .iter()
371                .find_map(|command| {
372                    if let DrawCommand::FillRect { transform, .. } = command {
373                        Some(*transform)
374                    } else {
375                        None
376                    }
377                })
378                .unwrap();
379            let clip = list
380                .commands
381                .iter()
382                .find_map(|command| {
383                    if let DrawCommand::PushRectLayer { rect_clip, .. } = command {
384                        Some(*rect_clip)
385                    } else {
386                        None
387                    }
388                })
389                .unwrap();
390            assert_eq!(clip.bounds, viewport);
391            for point in [
392                Point::new(30.0, -20.0),
393                Point::ORIGIN,
394                Point::new(-10.0, 25.0),
395            ] {
396                let expected = parent.apply(
397                    view.world_to_screen(&context, point)? + crate::Vector2::new(20.0, 40.0),
398                );
399                let actual = transform.apply(point);
400                assert!(
401                    (actual.x - expected.x).abs() < 1e-10 && (actual.y - expected.y).abs() < 1e-10
402                );
403                let recovered =
404                    view.screen_to_world(&context, view.world_to_screen(&context, point)?)?;
405                assert!(
406                    (recovered.x - point.x).abs() < 1e-12 && (recovered.y - point.y).abs() < 1e-12
407                );
408            }
409            assert!(
410                clip.transform
411                    .coefficients()
412                    .iter()
413                    .zip(parent.coefficients())
414                    .all(|(actual, expected)| (actual - expected).abs() < 1e-12)
415            );
416        }
417        for zoom in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::from_bits(1)] {
418            let invalid = Camera { zoom, ..view };
419            assert!(invalid.world_to_screen(&context, Point::ORIGIN).is_err());
420            assert!(invalid.screen_to_world(&context, Point::ORIGIN).is_err());
421            assert!(try_camera(&context, Node::empty(), invalid).is_err());
422        }
423        let zoomed = Camera {
424            zoom: 2.0,
425            ..Camera::default()
426        };
427        assert!(
428            zoomed
429                .world_to_screen(&context, Point::new(f64::MAX, 0.0))
430                .is_err()
431        );
432        let zoomed = Camera {
433            zoom: 0.5,
434            ..Camera::default()
435        };
436        assert!(
437            zoomed
438                .screen_to_world(&context, Point::new(f64::MAX, 0.0))
439                .is_err()
440        );
441        Ok(())
442    }
443}