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