Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 11 kB View raw
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}