Skip to main content

dotloom_render/
view.rs

1//! Camera and coordinate conversions.
2//!
3//! World coordinates are model units (mm, Y up). The view maps them to *device*
4//! pixels (Y down) of a target whose CSS size times `dpr` is its pixel size. All
5//! conversions use `f64`; GPU buffers receive `f32` offsets relative to local
6//! origins, so precision does not depend on the absolute world position.
7
8use dotloom_geometry::{Aabb, Point};
9use serde::{Deserialize, Serialize};
10
11use crate::RenderError;
12
13/// Largest supported zoom range (CSS px per model unit).
14pub const MIN_SCALE: f64 = 1e-9;
15/// See [`MIN_SCALE`].
16pub const MAX_SCALE: f64 = 1e9;
17
18/// Camera state. Not part of the document; hosts may persist it as view state.
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct View {
22    /// World point at the center of the target.
23    pub center: [f64; 2],
24    /// CSS pixels per model unit.
25    pub scale: f64,
26    /// Target width in CSS pixels.
27    pub width: f64,
28    /// Target height in CSS pixels.
29    pub height: f64,
30    /// Device pixels per CSS pixel.
31    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    /// Validate finite, positive values.
42    ///
43    /// # Errors
44    /// [`RenderError::InvalidView`] for NaN/∞, non-positive sizes or out-of-range zoom.
45    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    /// Target size in device pixels (rounded, at least 1×1).
60    #[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    /// Device pixels per model unit.
67    #[must_use]
68    pub fn device_scale(&self) -> f64 {
69        self.scale * self.dpr
70    }
71
72    /// World → device pixels.
73    #[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    /// World → CSS pixels.
81    #[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    /// CSS pixels (relative to the target's top-left corner) → world.
90    #[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    /// Visible world rectangle.
99    #[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    /// Zoom by `factor` keeping the world point under CSS position `(x, y)` fixed.
110    #[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    /// Fit a world box with `margin` CSS pixels on each side.
121    #[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    /// Flatten tolerance for the current zoom, bucketed by powers of two so cached
136    /// tessellations stay valid while zooming within a bucket.
137    #[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/// Flatten tolerance (model units) for a LOD bucket: ¼ device pixel at the
144/// bucket's smallest scale.
145#[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        // Device pixels are exactly dpr × CSS pixels (no double scaling).
162        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}