1use dotloom_geometry::{Aabb, Point};
9use serde::{Deserialize, Serialize};
10
11use crate::RenderError;
12
13pub const MIN_SCALE: f64 = 1e-9;
15pub const MAX_SCALE: f64 = 1e9;
17
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct View {
22 pub center: [f64; 2],
24 pub scale: f64,
26 pub width: f64,
28 pub height: f64,
30 pub dpr: f64,
32}
33
34impl Default for View {
35 fn default() -> Self {
36 Self { center: [0.0, 0.0], scale: 1.0, width: 800.0, height: 600.0, dpr: 1.0 }
37 }
38}
39
40impl View {
41 pub fn validate(&self) -> Result<(), RenderError> {
46 let ok = self.center.iter().all(|v| v.is_finite())
47 && self.scale.is_finite()
48 && (MIN_SCALE..=MAX_SCALE).contains(&self.scale)
49 && self.width.is_finite()
50 && self.width > 0.0
51 && self.height.is_finite()
52 && self.height > 0.0
53 && self.dpr.is_finite()
54 && self.dpr > 0.0
55 && self.dpr <= 16.0;
56 if ok { Ok(()) } else { Err(RenderError::InvalidView(format!("{self:?}"))) }
57 }
58
59 #[must_use]
61 pub fn device_size(&self) -> (u32, u32) {
62 let px = |v: f64| (v * self.dpr).round().clamp(1.0, 16384.0) as u32;
63 (px(self.width), px(self.height))
64 }
65
66 #[must_use]
68 pub fn device_scale(&self) -> f64 {
69 self.scale * self.dpr
70 }
71
72 #[must_use]
74 pub fn world_to_device(&self, p: Point) -> [f64; 2] {
75 let (w, h) = self.device_size();
76 let s = self.device_scale();
77 [(p.x - self.center[0]) * s + f64::from(w) * 0.5, f64::from(h) * 0.5 - (p.y - self.center[1]) * s]
78 }
79
80 #[must_use]
82 pub fn world_to_css(&self, p: Point) -> [f64; 2] {
83 [
84 (p.x - self.center[0]) * self.scale + self.width * 0.5,
85 self.height * 0.5 - (p.y - self.center[1]) * self.scale,
86 ]
87 }
88
89 #[must_use]
91 pub fn css_to_world(&self, x: f64, y: f64) -> Point {
92 Point::new(
93 self.center[0] + (x - self.width * 0.5) / self.scale,
94 self.center[1] - (y - self.height * 0.5) / self.scale,
95 )
96 }
97
98 #[must_use]
100 pub fn visible(&self) -> Aabb {
101 let hw = self.width * 0.5 / self.scale;
102 let hh = self.height * 0.5 / self.scale;
103 Aabb::from_corners(
104 Point::new(self.center[0] - hw, self.center[1] - hh),
105 Point::new(self.center[0] + hw, self.center[1] + hh),
106 )
107 }
108
109 #[must_use]
111 pub fn zoomed_at(&self, x: f64, y: f64, factor: f64) -> Self {
112 let anchor = self.css_to_world(x, y);
113 let scale = (self.scale * factor).clamp(MIN_SCALE, MAX_SCALE);
114 let mut v = Self { scale, ..*self };
115 let moved = v.css_to_world(x, y);
116 v.center = [self.center[0] + anchor.x - moved.x, self.center[1] + anchor.y - moved.y];
117 v
118 }
119
120 #[must_use]
122 pub fn fit(&self, b: Aabb, margin: f64) -> Self {
123 if b.is_empty() || !b.min.is_finite() || !b.max.is_finite() {
124 return *self;
125 }
126 let w = (self.width - 2.0 * margin).max(1.0);
127 let h = (self.height - 2.0 * margin).max(1.0);
128 let bw = b.width().max(1e-9);
129 let bh = b.height().max(1e-9);
130 let scale = (w / bw).min(h / bh).clamp(MIN_SCALE, MAX_SCALE);
131 let c = b.center();
132 Self { center: [c.x, c.y], scale, ..*self }
133 }
134
135 #[must_use]
138 pub fn lod(&self) -> i32 {
139 dotloom_geometry::math::log2(self.device_scale()).floor().clamp(-120.0, 120.0) as i32
140 }
141}
142
143#[must_use]
146pub fn lod_tolerance(lod: i32) -> f64 {
147 0.25 / 2f64.powi(lod)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn roundtrip_and_dpr() {
156 let v = View { center: [100.0, 50.0], scale: 2.0, width: 400.0, height: 300.0, dpr: 2.0 };
157 assert_eq!(v.device_size(), (800, 600));
158 let p = Point::new(110.0, 40.0);
159 let css = v.world_to_css(p);
160 assert_eq!(css, [220.0, 170.0]);
161 let dev = v.world_to_device(p);
163 assert_eq!(dev, [440.0, 340.0]);
164 let back = v.css_to_world(css[0], css[1]);
165 assert!((back.x - p.x).abs() < 1e-12 && (back.y - p.y).abs() < 1e-12);
166 }
167
168 #[test]
169 fn zoom_keeps_anchor() {
170 let v = View { center: [0.0, 0.0], scale: 1.0, width: 200.0, height: 100.0, dpr: 1.5 };
171 let before = v.css_to_world(30.0, 70.0);
172 let z = v.zoomed_at(30.0, 70.0, 3.0);
173 let after = z.css_to_world(30.0, 70.0);
174 assert!((before.x - after.x).abs() < 1e-9 && (before.y - after.y).abs() < 1e-9);
175 assert_eq!(z.scale, 3.0);
176 }
177
178 #[test]
179 fn rejects_invalid() {
180 assert!(View { scale: f64::NAN, ..View::default() }.validate().is_err());
181 assert!(View { dpr: 0.0, ..View::default() }.validate().is_err());
182 assert!(View::default().validate().is_ok());
183 }
184
185 #[test]
186 fn large_coordinates_stay_precise() {
187 let v = View { center: [1.0e9, -1.0e9], scale: 100.0, width: 100.0, height: 100.0, dpr: 1.0 };
188 let d = v.world_to_device(Point::new(1.0e9 + 0.01, -1.0e9));
189 assert!((d[0] - 51.0).abs() < 1e-3);
190 }
191}