Skip to main content

shuyi_ui/frame/
cinema.rs

1//! Framing a shot over a [`Backdrop`](crate::frame::glyph_buffer::Backdrop)
2//! (game ADR-0048): where the window has to start in the scene to look at a
3//! point at a zoom. Pure arithmetic; the cinematic's shots (0817) and the
4//! debug menu's "Scene camera" use it.
5
6pub use crate::frame::glyph_buffer::MAX_ZOOM;
7
8/// The `origin_px` (the scene pixel at the window's top-left) that puts
9/// the scene pixel `centre` in the middle of the window.
10///
11/// `scene_px` is the scene's size and `clip_px` the window's, both in
12/// their own pixels; at `zoom` (clamped to 1..=[`MAX_ZOOM`]) the window
13/// shows `clip_px / zoom` scene pixels each way. The window never leaves
14/// the scene: near an edge it stops there. On an axis where the zoomed
15/// scene is smaller than the window, the scene is centred instead (the
16/// origin is then negative). A `centre` that isn't a number counts as 0.
17pub fn view(scene_px: (u32, u32), clip_px: (u32, u32), centre: (f32, f32), zoom: u8) -> (f32, f32) {
18    let zoom = f32::from(zoom.clamp(1, MAX_ZOOM));
19    #[allow(clippy::cast_precision_loss)] // pixel sizes are far below 2^24
20    let axis = |scene: u32, clip: u32, centre: f32| {
21        let shown = clip as f32 / zoom;
22        // How far the window can move. With room, from 0 to `room`;
23        // without (a negative `room`), only to the centred place, `room / 2`.
24        let room = scene as f32 - shown;
25        let (first, last) = (room.min(0.0) / 2.0, room.max(room / 2.0));
26        let centre = if centre.is_finite() { centre } else { 0.0 };
27        (centre - shown / 2.0).clamp(first, last)
28    };
29    (
30        axis(scene_px.0, clip_px.0, centre.0),
31        axis(scene_px.1, clip_px.1, centre.1),
32    )
33}
34
35#[cfg(test)]
36mod tests {
37    use proptest::prelude::*;
38
39    use super::*;
40
41    /// A 224×128 scene (the test map in glyphs) in a 384×224 window.
42    const SCENE: (u32, u32) = (224, 128);
43    const CLIP: (u32, u32) = (384, 224);
44
45    #[test]
46    fn centres_the_window_on_the_point() {
47        // At 4× the window shows 96×56 scene pixels.
48        assert_eq!(view(SCENE, CLIP, (112.0, 64.0), 4), (64.0, 36.0));
49        assert_eq!(view(SCENE, CLIP, (100.5, 40.25), 4), (52.5, 12.25));
50        // At 2× it shows 192×112.
51        assert_eq!(view(SCENE, CLIP, (112.0, 64.0), 2), (16.0, 8.0));
52        // Each axis on its own.
53        assert_eq!(view(SCENE, CLIP, (60.0, 90.0), 4), (12.0, 62.0));
54    }
55
56    #[test]
57    fn stops_at_every_edge() {
58        assert_eq!(view(SCENE, CLIP, (0.0, 0.0), 4), (0.0, 0.0));
59        assert_eq!(view(SCENE, CLIP, (47.0, 27.0), 4), (0.0, 0.0));
60        assert_eq!(view(SCENE, CLIP, (49.0, 29.0), 4), (1.0, 1.0));
61        assert_eq!(view(SCENE, CLIP, (-500.0, 64.0), 4), (0.0, 36.0));
62        assert_eq!(view(SCENE, CLIP, (112.0, -500.0), 4), (64.0, 0.0));
63        // Right and bottom: 224 − 96 and 128 − 56.
64        assert_eq!(view(SCENE, CLIP, (224.0, 128.0), 4), (128.0, 72.0));
65        assert_eq!(view(SCENE, CLIP, (9e9, 64.0), 4), (128.0, 36.0));
66        assert_eq!(view(SCENE, CLIP, (112.0, 9e9), 4), (64.0, 72.0));
67        assert_eq!(view(SCENE, CLIP, (175.0, 99.0), 4), (127.0, 71.0));
68        assert_eq!(
69            view(SCENE, CLIP, (f32::INFINITY, f32::NEG_INFINITY), 4),
70            (0.0, 0.0)
71        );
72        assert_eq!(view(SCENE, CLIP, (f32::NAN, 64.0), 4), (0.0, 36.0));
73    }
74
75    #[test]
76    fn centres_a_scene_smaller_than_the_window() {
77        // At 1× the window shows 384×224: more than the scene both ways.
78        assert_eq!(view(SCENE, CLIP, (0.0, 0.0), 1), (-80.0, -48.0));
79        assert_eq!(view(SCENE, CLIP, (999.0, 999.0), 1), (-80.0, -48.0));
80        // Smaller one way only: 3× shows 128 × 74.67 of a 100×128 scene.
81        let (x, y) = view((100, 128), CLIP, (50.0, 128.0), 3);
82        assert!((x + 14.0).abs() < 1e-4, "{x}");
83        assert!((y - (128.0 - 224.0 / 3.0)).abs() < 1e-4, "{y}");
84        // Exactly the window's size: no room to pan.
85        assert_eq!(view((192, 112), CLIP, (150.0, 100.0), 2), (0.0, 0.0));
86        // Nothing to show.
87        assert_eq!(view((0, 0), (0, 0), (5.0, 5.0), 2), (0.0, 0.0));
88        assert_eq!(view((0, 0), CLIP, (5.0, 5.0), 1), (-192.0, -112.0));
89    }
90
91    #[test]
92    fn a_zoom_outside_the_steps_is_clamped() {
93        let at = |zoom| view(SCENE, CLIP, (112.0, 64.0), zoom);
94        assert_eq!(at(0), at(1));
95        assert_eq!(at(5), at(4));
96        assert_eq!(at(u8::MAX), at(4));
97        assert_ne!(at(3), at(4));
98    }
99
100    proptest! {
101        #[test]
102        fn the_window_stays_inside_a_scene_at_least_its_size(
103            scene_w in 1u32..4000, scene_h in 1u32..2000,
104            clip_w in 1u32..801, clip_h in 1u32..513,
105            cx in prop_oneof![-5000f32..5000.0, any::<f32>()],
106            cy in prop_oneof![-5000f32..5000.0, any::<f32>()],
107            zoom in 1u8..=MAX_ZOOM,
108        ) {
109            let (x, y) = view((scene_w, scene_h), (clip_w, clip_h), (cx, cy), zoom);
110            let check = |origin: f32, scene: u32, clip: u32| {
111                #[allow(clippy::cast_precision_loss)]
112                let (scene, shown) = (scene as f32, clip as f32 / f32::from(zoom));
113                if scene >= shown {
114                    assert!(origin >= 0.0, "{origin}");
115                    assert!(origin + shown <= scene + 1e-3, "{origin} + {shown} > {scene}");
116                } else {
117                    // Centred: the same gap on both sides.
118                    assert!((origin * 2.0 - (scene - shown)).abs() < 1e-3);
119                }
120            };
121            check(x, scene_w, clip_w);
122            check(y, scene_h, clip_h);
123        }
124
125        #[test]
126        fn the_point_is_in_the_middle_when_there_is_room(
127            cx in 48f32..176.0, cy in 28f32..100.0,
128        ) {
129            let (x, y) = view(SCENE, CLIP, (cx, cy), 4);
130            prop_assert!((x + 48.0 - cx).abs() < 1e-3);
131            prop_assert!((y + 28.0 - cy).abs() < 1e-3);
132        }
133    }
134}