Skip to main content

halley_core/
viewport.rs

1use crate::field::{Rect, Vec2};
2
3#[derive(Clone, Copy, Debug, PartialEq)]
4pub struct Viewport {
5    /// Center position in Field coordinates.
6    pub center: Vec2,
7
8    /// Size of the visible region in Field coordinates.
9    pub size: Vec2,
10
11    /// Home position for Return.
12    pub home: Vec2,
13}
14
15impl Viewport {
16    pub fn new(center: Vec2, size: Vec2) -> Self {
17        Self {
18            center,
19            size,
20            home: center,
21        }
22    }
23
24    /// Axis-aligned view rectangle in Field space.
25    pub fn rect(&self) -> Rect {
26        let half = Vec2 {
27            x: self.size.x * 0.5,
28            y: self.size.y * 0.5,
29        };
30
31        Rect {
32            min: Vec2 {
33                x: self.center.x - half.x,
34                y: self.center.y - half.y,
35            },
36            max: Vec2 {
37                x: self.center.x + half.x,
38                y: self.center.y + half.y,
39            },
40        }
41    }
42
43    /// Move camera to a new center.
44    pub fn move_to(&mut self, center: Vec2) {
45        self.center = center;
46    }
47
48    /// Offset camera by delta.
49    pub fn pan(&mut self, delta: Vec2) {
50        self.center.x += delta.x;
51        self.center.y += delta.y;
52    }
53
54    /// Set current position as home.
55    pub fn set_home(&mut self) {
56        self.home = self.center;
57    }
58
59    /// Return to home position.
60    pub fn return_home(&mut self) {
61        self.center = self.home;
62    }
63}
64
65/// Which focus zone a point is in (relative to a viewport center).
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum FocusZone {
68    Inside,
69    Outside,
70}
71
72/// A focus ring modeled as an axis-aligned ellipse in Field coordinates,
73/// with an offset relative to the viewport center.
74///
75/// We use normalized ellipse distance:
76///   d2 = (x/rx)^2 + (y/ry)^2
77/// If d2 <= 1 => inside.
78#[derive(Clone, Copy, Debug, PartialEq)]
79pub struct FocusRing {
80    pub radius_x: f32,
81    pub radius_y: f32,
82    pub offset_x: f32,
83    pub offset_y: f32,
84}
85
86impl FocusRing {
87    pub fn new(radius_x: f32, radius_y: f32, offset_x: f32, offset_y: f32) -> Self {
88        Self {
89            radius_x,
90            radius_y,
91            offset_x,
92            offset_y,
93        }
94    }
95
96    pub fn contains(&self, center: Vec2, p: Vec2) -> bool {
97        self.normalized_distance2(center, p) <= 1.0
98    }
99
100    pub fn zone(&self, vp_center: Vec2, p: Vec2) -> FocusZone {
101        if self.contains(vp_center, p) {
102            FocusZone::Inside
103        } else {
104            FocusZone::Outside
105        }
106    }
107
108    /// Return normalized squared distance inside this ellipse:
109    /// d2 = (x/rx)^2 + (y/ry)^2
110    /// - d2 <= 1.0: inside/on boundary
111    /// - d2 > 1.0: outside
112    pub fn normalized_distance2(&self, center: Vec2, p: Vec2) -> f32 {
113        let ring_center = Vec2 {
114            x: center.x + self.offset_x,
115            y: center.y + self.offset_y,
116        };
117
118        let dx = p.x - ring_center.x;
119        let dy = p.y - ring_center.y;
120
121        let rx = self.radius_x.max(0.0001);
122        let ry = self.radius_y.max(0.0001);
123
124        let nx = dx / rx;
125        let ny = dy / ry;
126
127        nx * nx + ny * ny
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn rect_is_correct() {
137        let vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 50.0 });
138
139        let r = vp.rect();
140
141        assert_eq!(r.min, Vec2 { x: -50.0, y: -25.0 });
142        assert_eq!(r.max, Vec2 { x: 50.0, y: 25.0 });
143    }
144
145    #[test]
146    fn return_home_works() {
147        let mut vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 50.0 });
148
149        vp.pan(Vec2 { x: 10.0, y: 5.0 });
150        assert_eq!(vp.center, Vec2 { x: 10.0, y: 5.0 });
151
152        vp.return_home();
153        assert_eq!(vp.center, Vec2 { x: 0.0, y: 0.0 });
154    }
155
156    #[test]
157    fn focus_ring_contains_axis_aligned() {
158        let ring = FocusRing::new(10.0, 5.0, 0.0, 0.0);
159        let c = Vec2 { x: 0.0, y: 0.0 };
160
161        assert!(ring.contains(c, Vec2 { x: 0.0, y: 0.0 }));
162        assert!(ring.contains(c, Vec2 { x: 10.0, y: 0.0 }));
163        assert!(ring.contains(c, Vec2 { x: 0.0, y: 5.0 }));
164
165        assert!(!ring.contains(c, Vec2 { x: 10.01, y: 0.0 }));
166        assert!(!ring.contains(c, Vec2 { x: 0.0, y: 5.01 }));
167    }
168
169    #[test]
170    fn focus_ring_respects_offset() {
171        let ring = FocusRing::new(10.0, 5.0, 4.0, -2.0);
172        let c = Vec2 { x: 0.0, y: 0.0 };
173
174        assert!(ring.contains(c, Vec2 { x: 4.0, y: -2.0 }));
175        assert!(ring.contains(c, Vec2 { x: 14.0, y: -2.0 }));
176        assert!(ring.contains(c, Vec2 { x: 4.0, y: 3.0 }));
177
178        assert!(!ring.contains(c, Vec2 { x: 14.01, y: -2.0 }));
179        assert!(!ring.contains(c, Vec2 { x: 4.0, y: 3.01 }));
180    }
181
182    #[test]
183    fn focus_zone_classifies() {
184        let ring = FocusRing::new(10.0, 10.0, 0.0, 0.0);
185        let c = Vec2 { x: 0.0, y: 0.0 };
186
187        assert_eq!(ring.zone(c, Vec2 { x: 0.0, y: 0.0 }), FocusZone::Inside);
188        assert_eq!(ring.zone(c, Vec2 { x: 20.0, y: 0.0 }), FocusZone::Outside);
189    }
190}