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(
148 clippy::cast_possible_truncation,
149 reason = "mm coordinates fit f32 mantissa at CAD scales"
150 )]
151 pub fn clip_from_world_mm(self) -> [f32; 16] {
152 let (sx, sy) = self.scale();
153 let (px, py) = self.pan_mm.coords_mm();
154 let tx = -px * sx;
155 let ty = -py * sy;
156 column_major(sx as f32, sy as f32, tx as f32, ty as f32)
157 }
158
159 #[must_use]
160 #[allow(
161 clippy::cast_possible_truncation,
162 reason = "mm coordinates fit f32 mantissa at CAD scales"
163 )]
164 pub fn world_mm_from_clip(self) -> [f32; 16] {
165 let (sx, sy) = self.scale();
166 let inv_x = 1.0 / sx;
167 let inv_y = 1.0 / sy;
168 let (px, py) = self.pan_mm.coords_mm();
169 column_major(inv_x as f32, inv_y as f32, px as f32, py as f32)
170 }
171
172 fn scale(self) -> (f64, f64) {
173 let w = f64::from(self.extent.width.value());
174 let h = f64::from(self.extent.height.value());
175 let z = self.zoom.value();
176 (2.0 * z / w, 2.0 * z / h)
177 }
178}
179
180fn column_major(sx: f32, sy: f32, tx: f32, ty: f32) -> [f32; 16] {
181 [
182 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,
183 ]
184}
185
186#[derive(Copy, Clone, Debug, PartialEq)]
187pub struct GridSpacing {
188 minor: Length,
189 major_every: u32,
190}
191
192impl GridSpacing {
193 #[must_use]
194 pub fn minor(self) -> Length {
195 self.minor
196 }
197
198 #[must_use]
199 pub const fn major_every(self) -> u32 {
200 self.major_every
201 }
202
203 #[must_use]
204 pub fn major(self) -> Length {
205 self.minor * f64::from(self.major_every)
206 }
207
208 #[must_use]
209 pub fn from_zoom(zoom: PixelsPerMm, target_minor_px: f32) -> Self {
210 assert!(
211 target_minor_px.is_finite() && target_minor_px > 0.0,
212 "GridSpacing::from_zoom requires a positive, finite target: got {target_minor_px}",
213 );
214 let desired_mm = f64::from(target_minor_px) / zoom.value();
215 let k = desired_mm.log10().floor();
216 let frac = desired_mm / 10f64.powf(k);
217 let (multiplier, major_every, k_bump) = if frac < f64::sqrt(2.0) {
218 (1.0, 10, 0.0)
219 } else if frac < f64::sqrt(10.0) {
220 (2.0, 5, 0.0)
221 } else if frac < f64::sqrt(50.0) {
222 (5.0, 2, 0.0)
223 } else {
224 (1.0, 10, 1.0)
225 };
226 let minor_mm = multiplier * 10f64.powf(k + k_bump);
227 Self {
228 minor: Length::new::<millimeter>(minor_mm),
229 major_every,
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn extent(w: u32, h: u32) -> ViewportExtent {
239 ViewportExtent::new(ViewportPx::new(w), ViewportPx::new(h))
240 }
241
242 fn approx_eq(a: f32, b: f32) -> bool {
243 (a - b).abs() < 1e-6
244 }
245
246 #[test]
247 fn default_camera_maps_origin_to_clip_center() {
248 let cam = Camera2::new(extent(200, 100));
249 let m = cam.clip_from_world_mm();
250 assert!(approx_eq(m[12], 0.0));
251 assert!(approx_eq(m[13], 0.0));
252 }
253
254 #[test]
255 fn clip_from_world_is_column_major() {
256 let cam = Camera2::new(extent(100, 100)).with_pan(Vec2::from_mm(2.0, 3.0));
257 let m = cam.clip_from_world_mm();
258 assert!(approx_eq(m[3], 0.0));
259 assert!(approx_eq(m[7], 0.0));
260 assert!(approx_eq(m[11], 0.0));
261 assert!(approx_eq(m[15], 1.0));
262 }
263
264 #[test]
265 fn inverse_round_trips_origin() {
266 let cam = Camera2::new(extent(256, 128))
267 .with_pan(Vec2::from_mm(-5.0, 4.0))
268 .with_zoom(PixelsPerMm::new(8.0));
269 let fwd = cam.clip_from_world_mm();
270 let inv = cam.world_mm_from_clip();
271 let rt = mul4(&inv, &fwd);
272 (0..16).for_each(|idx| {
273 let (col, row) = (idx / 4, idx % 4);
274 let want = if col == row { 1.0 } else { 0.0 };
275 assert!(
276 approx_eq(rt[idx], want),
277 "round trip broke at col={col},row={row}: {}",
278 rt[idx]
279 );
280 });
281 }
282
283 fn mul4(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
284 core::array::from_fn(|idx| {
285 let (col, row) = (idx / 4, idx % 4);
286 (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum()
287 })
288 }
289
290 #[test]
291 fn grid_spacing_selects_one_two_five_in_decade() {
292 [1.0_f64, 10.0, 100.0]
293 .into_iter()
294 .map(PixelsPerMm::new)
295 .map(|z| GridSpacing::from_zoom(z, 16.0))
296 .for_each(|s| {
297 let minor_mm = s.minor().get::<millimeter>();
298 let k = minor_mm.log10().floor();
299 let decade = 10f64.powf(k);
300 let mult = minor_mm / decade;
301 let snapped = [1.0, 2.0, 5.0]
302 .into_iter()
303 .any(|m: f64| (mult - m).abs() < 1e-9);
304 assert!(snapped, "minor {minor_mm} mm not in {{1, 2, 5}} x decade");
305 assert!([2, 5, 10].contains(&s.major_every()));
306 });
307 }
308
309 #[test]
310 fn grid_spacing_rolls_over_decade_past_sqrt_fifty() {
311 let zoom = PixelsPerMm::new(2.0);
312 let s = GridSpacing::from_zoom(zoom, 16.0);
313 let minor_mm = s.minor().get::<millimeter>();
314 assert!(
315 (minor_mm - 10.0).abs() < 1e-9,
316 "expected rollover to 10 mm, got {minor_mm}",
317 );
318 assert_eq!(s.major_every(), 10);
319 }
320
321 #[test]
322 fn clip_x_of_pan_point_is_zero() {
323 let cam = Camera2::new(extent(320, 240))
324 .with_pan(Vec2::from_mm(7.0, -3.0))
325 .with_zoom(PixelsPerMm::new(5.0));
326 let m = cam.clip_from_world_mm();
327 let world = [7.0_f32, -3.0_f32, 0.0_f32, 1.0_f32];
328 let clip = apply(&m, world);
329 assert!(approx_eq(clip[0], 0.0));
330 assert!(approx_eq(clip[1], 0.0));
331 }
332
333 fn apply(m: &[f32; 16], v: [f32; 4]) -> [f32; 4] {
334 core::array::from_fn(|row| (0..4).map(|col| m[col * 4 + row] * v[col]).sum())
335 }
336}