Another project
1use bone_types::{Length, Vec2};
2use uom::si::length::millimeter;
3
4#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
5pub struct ViewportPx(u32);
6
7impl ViewportPx {
8 #[must_use]
9 pub const fn new(value: u32) -> Self {
10 Self(value)
11 }
12
13 #[must_use]
14 pub const fn value(self) -> u32 {
15 self.0
16 }
17}
18
19impl core::fmt::Display for ViewportPx {
20 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
21 write!(f, "{}px", self.0)
22 }
23}
24
25#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
26pub struct ViewportExtent {
27 width: ViewportPx,
28 height: ViewportPx,
29}
30
31impl ViewportExtent {
32 #[must_use]
33 pub const fn new(width: ViewportPx, height: ViewportPx) -> Self {
34 Self { width, height }
35 }
36
37 #[must_use]
38 pub const fn square(side: ViewportPx) -> Self {
39 Self {
40 width: side,
41 height: side,
42 }
43 }
44
45 #[must_use]
46 pub const fn width(self) -> ViewportPx {
47 self.width
48 }
49
50 #[must_use]
51 pub const fn height(self) -> ViewportPx {
52 self.height
53 }
54
55 #[must_use]
56 pub fn pixel_count(self) -> u64 {
57 u64::from(self.width.value()) * u64::from(self.height.value())
58 }
59}
60
61impl core::fmt::Display for ViewportExtent {
62 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63 write!(f, "{}x{}", self.width, self.height)
64 }
65}
66
67#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
68pub struct PixelsPerMm(f64);
69
70impl PixelsPerMm {
71 pub const DEFAULT: Self = Self(10.0);
72
73 #[must_use]
74 pub fn new(value: f64) -> Self {
75 assert!(
76 value.is_finite() && value > 0.0,
77 "PixelsPerMm requires a positive, finite value: got {value}",
78 );
79 Self(value)
80 }
81
82 #[must_use]
83 pub const fn value(self) -> f64 {
84 self.0
85 }
86}
87
88impl core::fmt::Display for PixelsPerMm {
89 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90 write!(f, "{} px/mm", self.0)
91 }
92}
93
94#[derive(Copy, Clone, Debug, PartialEq)]
95pub struct Camera2 {
96 extent: ViewportExtent,
97 pan_mm: Vec2,
98 zoom: PixelsPerMm,
99}
100
101impl Camera2 {
102 #[must_use]
103 pub fn new(extent: ViewportExtent) -> Self {
104 Self {
105 extent,
106 pan_mm: Vec2::zero(),
107 zoom: PixelsPerMm::DEFAULT,
108 }
109 }
110
111 #[must_use]
112 pub const fn extent(self) -> ViewportExtent {
113 self.extent
114 }
115
116 #[must_use]
117 pub const fn pan_mm(self) -> Vec2 {
118 self.pan_mm
119 }
120
121 #[must_use]
122 pub const fn zoom(self) -> PixelsPerMm {
123 self.zoom
124 }
125
126 #[must_use]
127 pub fn with_extent(self, extent: ViewportExtent) -> Self {
128 Self { extent, ..self }
129 }
130
131 #[must_use]
132 pub fn with_pan(self, pan_mm: Vec2) -> Self {
133 Self { pan_mm, ..self }
134 }
135
136 #[must_use]
137 pub fn with_zoom(self, zoom: PixelsPerMm) -> Self {
138 Self { zoom, ..self }
139 }
140
141 #[must_use]
142 pub fn world_mm_per_pixel(self) -> f64 {
143 1.0 / self.zoom.value()
144 }
145
146 #[must_use]
147 #[allow(clippy::cast_possible_truncation)]
148 pub fn clip_from_world_mm(self) -> [f32; 16] {
149 let (sx, sy) = self.scale();
150 let (px, py) = self.pan_mm.coords_mm();
151 let tx = -px * sx;
152 let ty = -py * sy;
153 column_major(sx as f32, sy as f32, tx as f32, ty as f32)
154 }
155
156 #[must_use]
157 #[allow(clippy::cast_possible_truncation)]
158 pub fn world_mm_from_clip(self) -> [f32; 16] {
159 let (sx, sy) = self.scale();
160 let inv_x = 1.0 / sx;
161 let inv_y = 1.0 / sy;
162 let (px, py) = self.pan_mm.coords_mm();
163 column_major(inv_x as f32, inv_y as f32, px as f32, py as f32)
164 }
165
166 fn scale(self) -> (f64, f64) {
167 let w = f64::from(self.extent.width.value());
168 let h = f64::from(self.extent.height.value());
169 let z = self.zoom.value();
170 (2.0 * z / w, 2.0 * z / h)
171 }
172}
173
174fn column_major(sx: f32, sy: f32, tx: f32, ty: f32) -> [f32; 16] {
175 [
176 sx, 0.0, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, tx, ty, 0.0, 1.0,
177 ]
178}
179
180#[derive(Copy, Clone, Debug, PartialEq)]
181pub struct GridSpacing {
182 minor: Length,
183 major_every: u32,
184}
185
186impl GridSpacing {
187 #[must_use]
188 pub fn minor(self) -> Length {
189 self.minor
190 }
191
192 #[must_use]
193 pub const fn major_every(self) -> u32 {
194 self.major_every
195 }
196
197 #[must_use]
198 pub fn major(self) -> Length {
199 self.minor * f64::from(self.major_every)
200 }
201
202 #[must_use]
203 pub fn from_zoom(zoom: PixelsPerMm, target_minor_px: f32) -> Self {
204 assert!(
205 target_minor_px.is_finite() && target_minor_px > 0.0,
206 "GridSpacing::from_zoom requires a positive, finite target: got {target_minor_px}",
207 );
208 let desired_mm = f64::from(target_minor_px) / zoom.value();
209 let k = desired_mm.log10().floor();
210 let frac = desired_mm / 10f64.powf(k);
211 let (multiplier, major_every, k_bump) = if frac < f64::sqrt(2.0) {
212 (1.0, 10, 0.0)
213 } else if frac < f64::sqrt(10.0) {
214 (2.0, 5, 0.0)
215 } else if frac < f64::sqrt(50.0) {
216 (5.0, 2, 0.0)
217 } else {
218 (1.0, 10, 1.0)
219 };
220 let minor_mm = multiplier * 10f64.powf(k + k_bump);
221 Self {
222 minor: Length::new::<millimeter>(minor_mm),
223 major_every,
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn extent(w: u32, h: u32) -> ViewportExtent {
233 ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h))
234 }
235
236 fn approx_eq(a: f32, b: f32) -> bool {
237 (a - b).abs() < 1e-6
238 }
239
240 #[test]
241 fn default_camera_maps_origin_to_clip_center() {
242 let cam = Camera2::new(extent(200, 100));
243 let m = cam.clip_from_world_mm();
244 assert!(approx_eq(m[12], 0.0));
245 assert!(approx_eq(m[13], 0.0));
246 }
247
248 #[test]
249 fn clip_from_world_is_column_major() {
250 let cam = Camera2::new(extent(100, 100)).with_pan(Vec2::from_mm(2.0, 3.0));
251 let m = cam.clip_from_world_mm();
252 assert!(approx_eq(m[3], 0.0));
253 assert!(approx_eq(m[7], 0.0));
254 assert!(approx_eq(m[11], 0.0));
255 assert!(approx_eq(m[15], 1.0));
256 }
257
258 #[test]
259 fn inverse_round_trips_origin() {
260 let cam = Camera2::new(extent(256, 128))
261 .with_pan(Vec2::from_mm(-5.0, 4.0))
262 .with_zoom(PixelsPerMm::new(8.0));
263 let fwd = cam.clip_from_world_mm();
264 let inv = cam.world_mm_from_clip();
265 let rt = mul4(&inv, &fwd);
266 (0..16).for_each(|idx| {
267 let (col, row) = (idx / 4, idx % 4);
268 let want = if col == row { 1.0 } else { 0.0 };
269 assert!(
270 approx_eq(rt[idx], want),
271 "round trip broke at col={col},row={row}: {}",
272 rt[idx]
273 );
274 });
275 }
276
277 fn mul4(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
278 core::array::from_fn(|idx| {
279 let (col, row) = (idx / 4, idx % 4);
280 (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum()
281 })
282 }
283
284 #[test]
285 fn grid_spacing_selects_one_two_five_in_decade() {
286 [1.0_f64, 10.0, 100.0]
287 .into_iter()
288 .map(PixelsPerMm::new)
289 .map(|z| GridSpacing::from_zoom(z, 16.0))
290 .for_each(|s| {
291 let minor_mm = s.minor().get::<millimeter>();
292 let k = minor_mm.log10().floor();
293 let decade = 10f64.powf(k);
294 let mult = minor_mm / decade;
295 let snapped = [1.0, 2.0, 5.0]
296 .into_iter()
297 .any(|m: f64| (mult - m).abs() < 1e-9);
298 assert!(snapped, "minor {minor_mm} mm not in {{1, 2, 5}} x decade");
299 assert!([2, 5, 10].contains(&s.major_every()));
300 });
301 }
302
303 #[test]
304 fn grid_spacing_rolls_over_decade_past_sqrt_fifty() {
305 let zoom = PixelsPerMm::new(2.0);
306 let s = GridSpacing::from_zoom(zoom, 16.0);
307 let minor_mm = s.minor().get::<millimeter>();
308 assert!(
309 (minor_mm - 10.0).abs() < 1e-9,
310 "expected rollover to 10 mm, got {minor_mm}",
311 );
312 assert_eq!(s.major_every(), 10);
313 }
314
315 #[test]
316 fn clip_x_of_pan_point_is_zero() {
317 let cam = Camera2::new(extent(320, 240))
318 .with_pan(Vec2::from_mm(7.0, -3.0))
319 .with_zoom(PixelsPerMm::new(5.0));
320 let m = cam.clip_from_world_mm();
321 let world = [7.0_f32, -3.0_f32, 0.0_f32, 1.0_f32];
322 let clip = apply(&m, world);
323 assert!(approx_eq(clip[0], 0.0));
324 assert!(approx_eq(clip[1], 0.0));
325 }
326
327 fn apply(m: &[f32; 16], v: [f32; 4]) -> [f32; 4] {
328 core::array::from_fn(|row| (0..4).map(|col| m[col * 4 + row] * v[col]).sum())
329 }
330}