Another project
0

Configure Feed

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

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}