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( 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}