Another project
0

Configure Feed

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

1use nalgebra::{Matrix4, Point3 as NPoint3, Vector3 as NVec3, Vector4 as NVec4}; 2use uom::si::angle::radian; 3use uom::si::f64::Length; 4use uom::si::length::millimeter; 5 6use bone_types::{ 7 Aabb3, Angle, AxisAngle, Camera3, Plane3, Point3, Projection, ProjectionKind, Result, 8 StandardView, Tolerance, TypesError, UnitVec3, Vec3, ZoomFactor, 9}; 10 11use crate::camera::ViewportExtent; 12 13const ORTHO_DEPTH_FACTOR: f64 = 8.0; 14const RAY_TOLERANCE: Tolerance = Tolerance::new(1.0e-12); 15const FOCAL_DENOM_FLOOR: f64 = 1.0e-9; 16const ARCBALL_MIN_AXIS: f64 = 1.0e-9; 17const FRAME_MARGIN: f64 = 1.2; 18const SILHOUETTE_COLLAPSE_FRACTION: f64 = 1.0e-6; 19 20#[derive(Copy, Clone, Debug, PartialEq)] 21pub struct ViewportPoint { 22 x: f64, 23 y: f64, 24} 25 26impl ViewportPoint { 27 pub fn new(x: f64, y: f64) -> Result<Self> { 28 if !x.is_finite() || !y.is_finite() { 29 return Err(TypesError::NonFiniteViewportPixel { x, y }); 30 } 31 Ok(Self { x, y }) 32 } 33 34 #[must_use] 35 pub const fn x(self) -> f64 { 36 self.x 37 } 38 39 #[must_use] 40 pub const fn y(self) -> f64 { 41 self.y 42 } 43} 44 45impl core::fmt::Display for ViewportPoint { 46 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 47 write!(f, "({}, {}) px", self.x, self.y) 48 } 49} 50 51pub fn clip_from_world(camera: Camera3, extent: ViewportExtent) -> Result<[f32; 16]> { 52 Ok(to_gpu(&clip_from_world_mat(camera, extent)?)) 53} 54 55pub fn world_from_clip(camera: Camera3, extent: ViewportExtent) -> Result<[f32; 16]> { 56 Ok(to_gpu(&world_from_clip_mat(camera, extent)?)) 57} 58 59pub fn world_ray( 60 camera: Camera3, 61 extent: ViewportExtent, 62 pixel: ViewportPoint, 63) -> Result<(Point3, UnitVec3)> { 64 let inverse = world_from_clip_mat(camera, extent)?; 65 let near = unproject(&inverse, extent, pixel, 0.0); 66 let far = unproject(&inverse, extent, pixel, 1.0); 67 let direction = (far - near).try_normalize(RAY_TOLERANCE)?; 68 Ok((near, direction)) 69} 70 71pub fn world_on_focal_plane( 72 camera: Camera3, 73 extent: ViewportExtent, 74 pixel: ViewportPoint, 75) -> Result<Point3> { 76 let (origin, direction) = world_ray(camera, extent, pixel)?; 77 let normal = view_direction(camera)?; 78 let denom = direction.dot(normal); 79 if denom.abs() < FOCAL_DENOM_FLOOR { 80 return Err(TypesError::ZeroLengthAxis); 81 } 82 let to_target = camera.target() - origin; 83 let t = dot_vec_unit(to_target, normal) / denom; 84 Ok(origin + direction.into_vec(Length::new::<millimeter>(t))) 85} 86 87pub fn pan_pixels( 88 camera: Camera3, 89 extent: ViewportExtent, 90 from: ViewportPoint, 91 to: ViewportPoint, 92) -> Result<Camera3> { 93 let grabbed = world_on_focal_plane(camera, extent, from)?; 94 let released = world_on_focal_plane(camera, extent, to)?; 95 translate(camera, grabbed - released) 96} 97 98pub fn zoom_about_pixel( 99 camera: Camera3, 100 extent: ViewportExtent, 101 pixel: ViewportPoint, 102 factor: ZoomFactor, 103) -> Result<Camera3> { 104 let pivot = world_on_focal_plane(camera, extent, pixel)?; 105 let zoomed = apply_zoom(camera, factor)?; 106 let drifted = world_on_focal_plane(zoomed, extent, pixel)?; 107 translate(zoomed, pivot - drifted) 108} 109 110pub fn orbit_about_pixel( 111 camera: Camera3, 112 extent: ViewportExtent, 113 pixel: ViewportPoint, 114 rotation: AxisAngle, 115) -> Result<Camera3> { 116 let pivot = world_on_focal_plane(camera, extent, pixel)?; 117 orbit_about_point(camera, pivot, rotation) 118} 119 120pub fn orbit_about_point(camera: Camera3, pivot: Point3, rotation: AxisAngle) -> Result<Camera3> { 121 Camera3::new( 122 camera.eye().rotated_about(pivot, rotation), 123 camera.target().rotated_about(pivot, rotation), 124 camera.up().rotated(rotation), 125 camera.projection(), 126 ) 127} 128 129pub fn arcball_rotation( 130 camera: Camera3, 131 extent: ViewportExtent, 132 from: ViewportPoint, 133 to: ViewportPoint, 134) -> Result<AxisAngle> { 135 let grabbed = arcball_vector(camera, extent, from)?; 136 let dragged = arcball_vector(camera, extent, to)?; 137 let axis = grabbed.cross(&dragged); 138 let axis_norm = axis.norm(); 139 if axis_norm < ARCBALL_MIN_AXIS { 140 return Ok(AxisAngle::new( 141 UnitVec3::z_axis(), 142 Angle::new::<radian>(0.0), 143 )); 144 } 145 let angle = grabbed.dot(&dragged).clamp(-1.0, 1.0).acos(); 146 let unit = axis / axis_norm; 147 Ok(AxisAngle::new( 148 UnitVec3::new_unchecked(unit.x, unit.y, unit.z), 149 Angle::new::<radian>(angle), 150 )) 151} 152 153pub fn roll_about_view( 154 camera: Camera3, 155 extent: ViewportExtent, 156 from: ViewportPoint, 157 to: ViewportPoint, 158) -> Result<Camera3> { 159 let forward = view_direction(camera)?; 160 let cx = f64::from(extent.width().value()) * 0.5; 161 let cy = f64::from(extent.height().value()) * 0.5; 162 let (ax, ay) = (from.x() - cx, from.y() - cy); 163 let (bx, by) = (to.x() - cx, to.y() - cy); 164 let angle = (ax * by - ay * bx).atan2(ax * bx + ay * by); 165 Camera3::new( 166 camera.eye(), 167 camera.target(), 168 camera 169 .up() 170 .rotated(AxisAngle::new(forward, Angle::new::<radian>(angle))), 171 camera.projection(), 172 ) 173} 174 175pub fn frame_isometric(aabb: Aabb3, extent: ViewportExtent) -> Result<Camera3> { 176 let direction = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE)?; 177 frame_along(aabb, extent, direction, UnitVec3::z_axis()) 178} 179 180pub fn frame_standard_view( 181 aabb: Aabb3, 182 extent: ViewportExtent, 183 view: StandardView, 184 normal_to: Option<Plane3>, 185) -> Result<Camera3> { 186 let (from_center_to_eye, up) = match view { 187 StandardView::Front => (UnitVec3::y_axis().reversed(), UnitVec3::z_axis()), 188 StandardView::Back => (UnitVec3::y_axis(), UnitVec3::z_axis()), 189 StandardView::Left => (UnitVec3::x_axis().reversed(), UnitVec3::z_axis()), 190 StandardView::Right => (UnitVec3::x_axis(), UnitVec3::z_axis()), 191 StandardView::Top => (UnitVec3::z_axis(), UnitVec3::y_axis()), 192 StandardView::Bottom => (UnitVec3::z_axis().reversed(), UnitVec3::y_axis().reversed()), 193 StandardView::Isometric => return frame_isometric(aabb, extent), 194 StandardView::NormalTo => { 195 let plane = normal_to.ok_or(TypesError::NormalToRequiresPlane)?; 196 return frame_along(aabb, extent, plane.normal(), plane.y_axis()); 197 } 198 }; 199 frame_along(aabb, extent, from_center_to_eye, up) 200} 201 202fn frame_along( 203 aabb: Aabb3, 204 extent: ViewportExtent, 205 from_center_to_eye: UnitVec3, 206 up: UnitVec3, 207) -> Result<Camera3> { 208 let center = aabb.center(); 209 let radius = 0.5 * aabb.extent().norm_mm(); 210 let eye = center + from_center_to_eye.into_vec(Length::new::<millimeter>(radius * 3.0)); 211 let silhouette = silhouette_half_height(aabb, extent, from_center_to_eye, up); 212 let half_height = if silhouette > radius * SILHOUETTE_COLLAPSE_FRACTION { 213 silhouette 214 } else { 215 FRAME_MARGIN * radius 216 }; 217 Camera3::new( 218 eye, 219 center, 220 up, 221 Projection::orthographic(Length::new::<millimeter>(half_height))?, 222 ) 223} 224 225fn silhouette_half_height( 226 aabb: Aabb3, 227 extent: ViewportExtent, 228 view_axis: UnitVec3, 229 up: UnitVec3, 230) -> f64 { 231 let (sx, sy, sz) = aabb.extent().coords_mm(); 232 let (hx, hy, hz) = (0.5 * sx, 0.5 * sy, 0.5 * sz); 233 let (nx, ny, nz) = view_axis.components(); 234 let normal = NVec3::new(nx, ny, nz); 235 let (ux, uy, uz) = up.components(); 236 let up_vec = NVec3::new(ux, uy, uz); 237 let screen_up = (up_vec - normal * up_vec.dot(&normal)).normalize(); 238 let screen_right = normal.cross(&screen_up).normalize(); 239 let project = |axis: NVec3<f64>| hx * axis.x.abs() + hy * axis.y.abs() + hz * axis.z.abs(); 240 FRAME_MARGIN * project(screen_up).max(project(screen_right) / aspect_ratio(extent)) 241} 242 243fn clip_from_world_mat(camera: Camera3, extent: ViewportExtent) -> Result<Matrix4<f64>> { 244 let (width, height) = (extent.width().value(), extent.height().value()); 245 if width == 0 || height == 0 { 246 return Err(TypesError::ZeroViewportExtent { width, height }); 247 } 248 Ok(projection_matrix(camera, extent) * view_matrix(camera)) 249} 250 251fn world_from_clip_mat(camera: Camera3, extent: ViewportExtent) -> Result<Matrix4<f64>> { 252 clip_from_world_mat(camera, extent)? 253 .try_inverse() 254 .ok_or(TypesError::NonInvertibleViewProjection) 255} 256 257fn view_matrix(camera: Camera3) -> Matrix4<f64> { 258 let (ex, ey, ez) = camera.eye().coords_mm(); 259 let (tx, ty, tz) = camera.target().coords_mm(); 260 let (ux, uy, uz) = camera.up().components(); 261 Matrix4::look_at_rh( 262 &NPoint3::new(ex, ey, ez), 263 &NPoint3::new(tx, ty, tz), 264 &NVec3::new(ux, uy, uz), 265 ) 266} 267 268fn projection_matrix(camera: Camera3, extent: ViewportExtent) -> Matrix4<f64> { 269 let aspect = aspect_ratio(extent); 270 match camera.projection().kind() { 271 ProjectionKind::Perspective { fov, near, far } => perspective_wgpu( 272 aspect, 273 fov.get::<radian>(), 274 near.get::<millimeter>(), 275 far.get::<millimeter>(), 276 ), 277 ProjectionKind::Orthographic { half_height } => { 278 let top = half_height.get::<millimeter>(); 279 let distance = (camera.target() - camera.eye()).norm_mm(); 280 let depth = ORTHO_DEPTH_FACTOR * top.max(distance); 281 orthographic_wgpu(top * aspect, top, distance - depth, distance + depth) 282 } 283 } 284} 285 286fn perspective_wgpu(aspect: f64, fovy: f64, near: f64, far: f64) -> Matrix4<f64> { 287 let focal = 1.0 / (fovy * 0.5).tan(); 288 let mut m = Matrix4::zeros(); 289 m[(0, 0)] = focal / aspect; 290 m[(1, 1)] = focal; 291 m[(2, 2)] = far / (near - far); 292 m[(2, 3)] = near * far / (near - far); 293 m[(3, 2)] = -1.0; 294 m 295} 296 297fn orthographic_wgpu(right: f64, top: f64, near: f64, far: f64) -> Matrix4<f64> { 298 let mut m = Matrix4::identity(); 299 m[(0, 0)] = 1.0 / right; 300 m[(1, 1)] = 1.0 / top; 301 m[(2, 2)] = 1.0 / (near - far); 302 m[(2, 3)] = near / (near - far); 303 m 304} 305 306fn unproject( 307 inverse: &Matrix4<f64>, 308 extent: ViewportExtent, 309 pixel: ViewportPoint, 310 ndc_z: f64, 311) -> Point3 { 312 let (ndc_x, ndc_y) = ndc_of(pixel, extent); 313 let clip = NVec4::new(ndc_x, ndc_y, ndc_z, 1.0); 314 let world = inverse * clip; 315 Point3::from_mm(world.x / world.w, world.y / world.w, world.z / world.w) 316} 317 318fn ndc_of(pixel: ViewportPoint, extent: ViewportExtent) -> (f64, f64) { 319 let width = f64::from(extent.width().value()); 320 let height = f64::from(extent.height().value()); 321 ( 322 2.0 * pixel.x() / width - 1.0, 323 1.0 - 2.0 * pixel.y() / height, 324 ) 325} 326 327fn aspect_ratio(extent: ViewportExtent) -> f64 { 328 f64::from(extent.width().value()) / f64::from(extent.height().value()) 329} 330 331fn view_direction(camera: Camera3) -> Result<UnitVec3> { 332 (camera.target() - camera.eye()).try_normalize(RAY_TOLERANCE) 333} 334 335fn arcball_vector( 336 camera: Camera3, 337 extent: ViewportExtent, 338 pixel: ViewportPoint, 339) -> Result<NVec3<f64>> { 340 let forward = view_direction(camera)?; 341 let (fx, fy, fz) = forward.components(); 342 let f = NVec3::new(fx, fy, fz); 343 let (ux, uy, uz) = camera.up().components(); 344 let right = f.cross(&NVec3::new(ux, uy, uz)).normalize(); 345 let up = right.cross(&f); 346 let (ndc_x, ndc_y) = ndc_of(pixel, extent); 347 let sphere_x = ndc_x * aspect_ratio(extent); 348 let radius_sq = sphere_x * sphere_x + ndc_y * ndc_y; 349 let depth = if radius_sq <= 1.0 { 350 (1.0 - radius_sq).sqrt() 351 } else { 352 0.0 353 }; 354 let local = NVec3::new(sphere_x, ndc_y, depth).normalize(); 355 Ok(right * local.x + up * local.y - f * local.z) 356} 357 358fn translate(camera: Camera3, delta: Vec3) -> Result<Camera3> { 359 Camera3::new( 360 camera.eye() + delta, 361 camera.target() + delta, 362 camera.up(), 363 camera.projection(), 364 ) 365} 366 367fn apply_zoom(camera: Camera3, factor: ZoomFactor) -> Result<Camera3> { 368 match camera.projection().kind() { 369 ProjectionKind::Orthographic { half_height } => { 370 let scaled = 371 Length::new::<millimeter>(half_height.get::<millimeter>() / factor.value()); 372 Camera3::new( 373 camera.eye(), 374 camera.target(), 375 camera.up(), 376 Projection::orthographic(scaled)?, 377 ) 378 } 379 ProjectionKind::Perspective { .. } => { 380 let offset = camera.eye() - camera.target(); 381 Camera3::new( 382 camera.target() + offset * (1.0 / factor.value()), 383 camera.target(), 384 camera.up(), 385 camera.projection(), 386 ) 387 } 388 } 389} 390 391fn dot_vec_unit(a: Vec3, b: UnitVec3) -> f64 { 392 let (ax, ay, az) = a.coords_mm(); 393 let (bx, by, bz) = b.components(); 394 ax * bx + ay * by + az * bz 395} 396 397fn to_gpu(matrix: &Matrix4<f64>) -> [f32; 16] { 398 let slice = matrix.as_slice(); 399 core::array::from_fn(|index| crate::lower_f32(slice[index])) 400} 401 402#[cfg(test)] 403mod tests { 404 use super::*; 405 use bone_types::{Aabb3, Angle}; 406 use uom::si::angle::degree; 407 408 fn extent() -> ViewportExtent { 409 ViewportExtent::square(crate::camera::ViewportPx::new(256)) 410 } 411 412 fn ortho_camera() -> Camera3 { 413 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 414 panic!("half height is positive"); 415 }; 416 let Ok(camera) = Camera3::new( 417 Point3::from_mm(4.0, 3.0, 5.0), 418 Point3::origin(), 419 UnitVec3::z_axis(), 420 projection, 421 ) else { 422 panic!("camera is non-degenerate"); 423 }; 424 camera 425 } 426 427 fn perspective_camera() -> Camera3 { 428 let Ok(projection) = Projection::perspective( 429 Angle::new::<degree>(45.0), 430 Length::new::<millimeter>(0.5), 431 Length::new::<millimeter>(100.0), 432 ) else { 433 panic!("projection is valid"); 434 }; 435 let Ok(camera) = Camera3::new( 436 Point3::from_mm(6.0, 4.0, 8.0), 437 Point3::origin(), 438 UnitVec3::z_axis(), 439 projection, 440 ) else { 441 panic!("camera is non-degenerate"); 442 }; 443 camera 444 } 445 446 fn vp(x: f64, y: f64) -> ViewportPoint { 447 let Ok(point) = ViewportPoint::new(x, y) else { 448 panic!("pixel coordinates are finite"); 449 }; 450 point 451 } 452 453 fn zf(value: f64) -> ZoomFactor { 454 let Ok(factor) = ZoomFactor::new(value) else { 455 panic!("zoom factor is positive and finite"); 456 }; 457 factor 458 } 459 460 fn center() -> ViewportPoint { 461 vp(128.0, 128.0) 462 } 463 464 fn focal(camera: Camera3, pixel: ViewportPoint) -> Point3 { 465 let Ok(point) = world_on_focal_plane(camera, extent(), pixel) else { 466 panic!("pixel has a focal point"); 467 }; 468 point 469 } 470 471 fn close(a: Point3, b: Point3, tol: f64) -> bool { 472 let (ax, ay, az) = a.coords_mm(); 473 let (bx, by, bz) = b.coords_mm(); 474 (ax - bx).abs() < tol && (ay - by).abs() < tol && (az - bz).abs() < tol 475 } 476 477 fn mul(a: &Matrix4<f64>, b: &Matrix4<f64>) -> Matrix4<f64> { 478 a * b 479 } 480 481 fn mul16(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { 482 core::array::from_fn(|index| { 483 let (col, row) = (index / 4, index % 4); 484 (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum() 485 }) 486 } 487 488 #[test] 489 fn ortho_lowering_is_column_major_affine() { 490 let Ok(m) = clip_from_world(ortho_camera(), extent()) else { 491 panic!("a nonzero extent lowers"); 492 }; 493 assert!(m[3].abs() < 1e-6); 494 assert!(m[7].abs() < 1e-6); 495 assert!(m[11].abs() < 1e-6); 496 assert!((m[15] - 1.0).abs() < 1e-6); 497 } 498 499 #[test] 500 fn perspective_lowering_has_projective_w_row() { 501 let Ok(m) = clip_from_world(perspective_camera(), extent()) else { 502 panic!("a nonzero extent lowers"); 503 }; 504 let affine = m[3].abs() < 1e-6 505 && m[7].abs() < 1e-6 506 && m[11].abs() < 1e-6 507 && (m[15] - 1.0).abs() < 1e-6; 508 assert!( 509 !affine, 510 "perspective w-row should be projective, not affine" 511 ); 512 } 513 514 #[test] 515 fn ortho_inverse_round_trips_to_identity() { 516 let camera = ortho_camera(); 517 let Ok(forward) = clip_from_world_mat(camera, extent()) else { 518 panic!("a nonzero extent lowers"); 519 }; 520 let Ok(inverse) = world_from_clip_mat(camera, extent()) else { 521 panic!("orthographic view-projection is invertible"); 522 }; 523 let product = mul(&forward, &inverse); 524 (0..16).for_each(|index| { 525 let (col, row) = (index / 4, index % 4); 526 let want = f64::from(u8::from(col == row)); 527 assert!( 528 (product.as_slice()[index] - want).abs() < 1e-9, 529 "round trip broke at col={col} row={row}" 530 ); 531 }); 532 } 533 534 #[test] 535 fn perspective_inverse_round_trips_to_identity() { 536 let camera = perspective_camera(); 537 let Ok(forward) = clip_from_world_mat(camera, extent()) else { 538 panic!("a nonzero extent lowers"); 539 }; 540 let Ok(inverse) = world_from_clip_mat(camera, extent()) else { 541 panic!("perspective view-projection is invertible"); 542 }; 543 let product = mul(&forward, &inverse); 544 (0..16).for_each(|index| { 545 let (col, row) = (index / 4, index % 4); 546 let want = f64::from(u8::from(col == row)); 547 assert!( 548 (product.as_slice()[index] - want).abs() < 1e-9, 549 "round trip broke at col={col} row={row}" 550 ); 551 }); 552 } 553 554 #[test] 555 fn public_forward_and_inverse_round_trip() { 556 [ortho_camera(), perspective_camera()] 557 .into_iter() 558 .for_each(|camera| { 559 let Ok(forward) = clip_from_world(camera, extent()) else { 560 panic!("a nonzero extent lowers"); 561 }; 562 let Ok(inverse) = world_from_clip(camera, extent()) else { 563 panic!("view-projection is invertible"); 564 }; 565 let product = mul16(&forward, &inverse); 566 (0..16).for_each(|index| { 567 let (col, row) = (index / 4, index % 4); 568 let want = f32::from(u8::from(col == row)); 569 assert!( 570 (product[index] - want).abs() < 1e-3, 571 "public round trip broke at col={col} row={row}: {}", 572 product[index] 573 ); 574 }); 575 }); 576 } 577 578 #[test] 579 fn center_pixel_focuses_on_target() { 580 [ortho_camera(), perspective_camera()] 581 .into_iter() 582 .for_each(|camera| { 583 let focus = focal(camera, center()); 584 assert!( 585 close(focus, camera.target(), 1e-6), 586 "center pixel should focus on the target" 587 ); 588 }); 589 } 590 591 #[test] 592 fn pan_keeps_grabbed_point_under_cursor() { 593 [ortho_camera(), perspective_camera()] 594 .into_iter() 595 .for_each(|camera| { 596 let from = vp(80.0, 96.0); 597 let to = vp(170.0, 150.0); 598 let grabbed = focal(camera, from); 599 let Ok(panned) = pan_pixels(camera, extent(), from, to) else { 600 panic!("pan succeeds"); 601 }; 602 let landed = focal(panned, to); 603 assert!( 604 close(grabbed, landed, 1e-6), 605 "grabbed world point should sit under the release cursor" 606 ); 607 }); 608 } 609 610 #[test] 611 fn zoom_keeps_pivot_under_cursor() { 612 [ortho_camera(), perspective_camera()] 613 .into_iter() 614 .for_each(|camera| { 615 let pixel = vp(60.0, 200.0); 616 let pivot = focal(camera, pixel); 617 let Ok(zoomed) = zoom_about_pixel(camera, extent(), pixel, zf(1.6)) else { 618 panic!("zoom succeeds"); 619 }; 620 let after = focal(zoomed, pixel); 621 assert!( 622 close(pivot, after, 1e-6), 623 "world point under the cursor should survive a zoom" 624 ); 625 }); 626 } 627 628 #[test] 629 fn orbit_keeps_pivot_under_cursor() { 630 [ortho_camera(), perspective_camera()] 631 .into_iter() 632 .for_each(|camera| { 633 let pixel = vp(150.0, 110.0); 634 let pivot = focal(camera, pixel); 635 let rotation = AxisAngle::new(UnitVec3::z_axis(), Angle::new::<degree>(20.0)); 636 let Ok(orbited) = orbit_about_pixel(camera, extent(), pixel, rotation) else { 637 panic!("orbit succeeds"); 638 }; 639 let after = focal(orbited, pixel); 640 assert!( 641 close(pivot, after, 1e-6), 642 "world point under the cursor should survive an orbit" 643 ); 644 }); 645 } 646 647 #[test] 648 fn arcball_rotation_is_identity_when_the_cursor_holds_still() { 649 let pixel = vp(70.0, 90.0); 650 let Ok(rotation) = arcball_rotation(ortho_camera(), extent(), pixel, pixel) else { 651 panic!("a zero-length drag still yields a rotation"); 652 }; 653 assert!( 654 rotation.angle().get::<radian>().abs() < 1e-12, 655 "holding the cursor still produces no rotation" 656 ); 657 } 658 659 #[test] 660 fn arcball_speed_is_isotropic_across_aspect() { 661 let wide = ViewportExtent::new( 662 crate::camera::ViewportPx::new(512), 663 crate::camera::ViewportPx::new(256), 664 ); 665 let (cx, cy) = (256.0, 128.0); 666 let span = 60.0; 667 let Ok(horizontal) = arcball_rotation(ortho_camera(), wide, vp(cx, cy), vp(cx + span, cy)) 668 else { 669 panic!("a horizontal drag rotates"); 670 }; 671 let Ok(vertical) = arcball_rotation(ortho_camera(), wide, vp(cx, cy), vp(cx, cy + span)) 672 else { 673 panic!("a vertical drag rotates"); 674 }; 675 let h = horizontal.angle().get::<radian>(); 676 let v = vertical.angle().get::<radian>(); 677 assert!( 678 (h - v).abs() < 1e-9, 679 "equal pixel drags must rotate equally on a non-square viewport: h={h} v={v}" 680 ); 681 } 682 683 #[test] 684 fn antipodal_single_step_drag_collapses_to_no_rotation() { 685 let Ok(rotation) = 686 arcball_rotation(ortho_camera(), extent(), vp(256.0, 128.0), vp(0.0, 128.0)) 687 else { 688 panic!("the arcball still yields a rotation at the sphere edge"); 689 }; 690 assert!( 691 rotation.angle().get::<radian>().abs() < 1e-9, 692 "a single step between antipodal sphere points is intentionally a no-op, not a pi flip" 693 ); 694 } 695 696 #[test] 697 fn roll_sweep_from_right_to_below_rolls_up_toward_positive_x() { 698 let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 699 panic!("half height is positive"); 700 }; 701 let Ok(front) = Camera3::new( 702 Point3::from_mm(0.0, -10.0, 0.0), 703 Point3::origin(), 704 UnitVec3::z_axis(), 705 projection, 706 ) else { 707 panic!("camera is non-degenerate"); 708 }; 709 let Ok(rolled) = roll_about_view(front, extent(), vp(200.0, 128.0), vp(128.0, 200.0)) 710 else { 711 panic!("a roll drag transforms the camera"); 712 }; 713 let (ux, uy, uz) = rolled.up().components(); 714 assert!( 715 (ux - 1.0).abs() < 1e-9 && uy.abs() < 1e-9 && uz.abs() < 1e-9, 716 "a right-then-below screen sweep rolls up from +z to +x: ({ux}, {uy}, {uz})" 717 ); 718 } 719 720 #[test] 721 fn isometric_frames_unit_cube_centered() { 722 let cube = Aabb3::from_corners( 723 Point3::from_mm(0.0, 0.0, 0.0), 724 Point3::from_mm(1.0, 1.0, 1.0), 725 ); 726 let Ok(camera) = frame_isometric(cube, extent()) else { 727 panic!("cube frames isometrically"); 728 }; 729 let focus = focal(camera, center()); 730 assert!( 731 close(focus, cube.center(), 1e-6), 732 "isometric framing should look at the cube center" 733 ); 734 } 735 736 #[test] 737 fn extreme_camera_inverse_reports_error_not_identity() { 738 let Ok(projection) = Projection::perspective( 739 Angle::new::<degree>(45.0), 740 Length::new::<millimeter>(1.0e-9), 741 Length::new::<millimeter>(1.0e12), 742 ) else { 743 panic!("projection is valid"); 744 }; 745 let Ok(direction) = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE) else { 746 panic!("diagonal is normalizable"); 747 }; 748 let eye = Point3::origin() + direction.into_vec(Length::new::<millimeter>(1.0e9)); 749 let Ok(camera) = Camera3::new(eye, Point3::origin(), UnitVec3::z_axis(), projection) else { 750 panic!("camera is non-degenerate"); 751 }; 752 assert!( 753 matches!( 754 world_from_clip(camera, extent()), 755 Err(TypesError::NonInvertibleViewProjection) 756 ), 757 "an inverse that underflows must surface an error, not a silent identity" 758 ); 759 assert!(world_ray(camera, extent(), center()).is_err()); 760 assert!(world_on_focal_plane(camera, extent(), center()).is_err()); 761 } 762 763 #[test] 764 fn viewport_point_rejects_non_finite() { 765 assert!(ViewportPoint::new(f64::NAN, 0.0).is_err()); 766 assert!(ViewportPoint::new(0.0, f64::INFINITY).is_err()); 767 assert!(ViewportPoint::new(10.0, 20.0).is_ok()); 768 } 769 770 #[test] 771 fn frame_isometric_rejects_degenerate_aabb() { 772 let p = Point3::from_mm(1.0, 2.0, 3.0); 773 let degenerate = Aabb3::from_corners(p, p); 774 assert!(frame_isometric(degenerate, extent()).is_err()); 775 } 776 777 fn box_aabb() -> Aabb3 { 778 Aabb3::from_corners( 779 Point3::from_mm(2.0, 4.0, 6.0), 780 Point3::from_mm(4.0, 8.0, 12.0), 781 ) 782 } 783 784 fn standard(view: bone_types::StandardView) -> Camera3 { 785 let Ok(camera) = frame_standard_view(box_aabb(), extent(), view, None) else { 786 panic!("a fixed standard view frames a non-degenerate box"); 787 }; 788 camera 789 } 790 791 #[test] 792 fn every_fixed_standard_view_looks_at_the_box_center() { 793 use bone_types::StandardView::{Back, Bottom, Front, Isometric, Left, Right, Top}; 794 let box_center = box_aabb().center(); 795 [Front, Back, Left, Right, Top, Bottom, Isometric] 796 .into_iter() 797 .for_each(|view| { 798 let focus = focal(standard(view), center()); 799 assert!( 800 close(focus, box_center, 1e-6), 801 "{view} must look at the box center", 802 ); 803 }); 804 } 805 806 #[test] 807 fn standard_view_eyes_sit_on_the_named_side() { 808 use bone_types::StandardView::{Back, Bottom, Front, Left, Right, Top}; 809 let (cx, cy, cz) = box_aabb().center().coords_mm(); 810 let offset = |view| { 811 let (ex, ey, ez) = standard(view).eye().coords_mm(); 812 (ex - cx, ey - cy, ez - cz) 813 }; 814 let (_, fy, _) = offset(Front); 815 assert!(fy < 0.0, "front looks from -Y"); 816 let (_, by, _) = offset(Back); 817 assert!(by > 0.0, "back looks from +Y"); 818 let (lx, _, _) = offset(Left); 819 assert!(lx < 0.0, "left looks from -X"); 820 let (rx, _, _) = offset(Right); 821 assert!(rx > 0.0, "right looks from +X"); 822 let (_, _, tz) = offset(Top); 823 assert!(tz > 0.0, "top looks from +Z"); 824 let (_, _, bz) = offset(Bottom); 825 assert!(bz < 0.0, "bottom looks from -Z"); 826 } 827 828 #[test] 829 fn normal_to_without_a_plane_is_rejected() { 830 assert!(matches!( 831 frame_standard_view( 832 box_aabb(), 833 extent(), 834 bone_types::StandardView::NormalTo, 835 None 836 ), 837 Err(TypesError::NormalToRequiresPlane) 838 )); 839 } 840 841 #[test] 842 fn normal_to_looks_along_the_plane_normal() { 843 let Ok(plane) = bone_types::Plane3::new( 844 Point3::origin(), 845 UnitVec3::x_axis(), 846 UnitVec3::z_axis(), 847 RAY_TOLERANCE, 848 ) else { 849 panic!("x and z axes are orthonormal"); 850 }; 851 let Ok(camera) = frame_standard_view( 852 box_aabb(), 853 extent(), 854 bone_types::StandardView::NormalTo, 855 Some(plane), 856 ) else { 857 panic!("normal-to frames against the supplied plane"); 858 }; 859 let view = view_direction(camera); 860 let Ok(view) = view else { 861 panic!("the framed camera has a view direction"); 862 }; 863 let along = view.dot(plane.normal()); 864 assert!( 865 (along.abs() - 1.0).abs() < 1e-6, 866 "the view direction must be parallel to the plane normal: dot {along}", 867 ); 868 } 869 870 #[test] 871 fn silhouette_collapse_falls_back_to_sphere_fit() { 872 let segment = Aabb3::from_corners( 873 Point3::from_mm(0.0, 0.0, 0.0), 874 Point3::from_mm(0.0, 5.0, 0.0), 875 ); 876 let Ok(camera) = frame_standard_view(segment, extent(), StandardView::Front, None) else { 877 panic!("a segment parallel to the view axis still frames via the sphere fallback"); 878 }; 879 assert!( 880 close(focal(camera, center()), segment.center(), 1e-6), 881 "the fallback framing still looks at the segment center" 882 ); 883 let ProjectionKind::Orthographic { half_height } = camera.projection().kind() else { 884 panic!("standard views are orthographic"); 885 }; 886 let radius = 0.5 * segment.extent().norm_mm(); 887 assert!( 888 (half_height.get::<millimeter>() - FRAME_MARGIN * radius).abs() < 1e-9, 889 "a collapsed silhouette frames the bounding sphere, not a zero-height view" 890 ); 891 } 892 893 #[test] 894 fn full_silhouette_views_keep_the_tight_fit() { 895 let silhouette = silhouette_half_height( 896 box_aabb(), 897 extent(), 898 UnitVec3::y_axis().reversed(), 899 UnitVec3::z_axis(), 900 ); 901 let radius = 0.5 * box_aabb().extent().norm_mm(); 902 assert!( 903 silhouette > radius * SILHOUETTE_COLLAPSE_FRACTION, 904 "a real view stays on the tight silhouette path, not the fallback" 905 ); 906 let standard_front = standard(StandardView::Front); 907 let ProjectionKind::Orthographic { half_height } = standard_front.projection().kind() 908 else { 909 panic!("standard views are orthographic"); 910 }; 911 assert!( 912 (half_height.get::<millimeter>() - silhouette).abs() < 1e-9, 913 "the front view frames on the silhouette, the clamp does not perturb it" 914 ); 915 } 916 917 #[test] 918 fn orbit_preserves_unit_up() { 919 let Ok(direction) = UnitVec3::try_from_components(0.3, 0.7, 0.64, RAY_TOLERANCE) else { 920 panic!("axis is normalizable"); 921 }; 922 let step = AxisAngle::new(direction, Angle::new::<degree>(13.0)); 923 let final_camera = (0..4096).fold(ortho_camera(), |camera, _| { 924 let Ok(next) = orbit_about_point(camera, Point3::origin(), step) else { 925 panic!("orbit stays valid"); 926 }; 927 next 928 }); 929 let (x, y, z) = final_camera.up().components(); 930 let norm = (x * x + y * y + z * z).sqrt(); 931 assert!( 932 (norm - 1.0).abs() < 1e-12, 933 "up vector should stay unit length across orbits: norm {norm}" 934 ); 935 } 936 937 #[test] 938 fn lowering_rejects_zero_extent() { 939 let zero = ViewportExtent::square(crate::camera::ViewportPx::new(0)); 940 assert!(matches!( 941 clip_from_world(ortho_camera(), zero), 942 Err(TypesError::ZeroViewportExtent { 943 width: 0, 944 height: 0 945 }) 946 )); 947 let no_height = ViewportExtent::new( 948 crate::camera::ViewportPx::new(256), 949 crate::camera::ViewportPx::new(0), 950 ); 951 assert!(world_from_clip(perspective_camera(), no_height).is_err()); 952 assert!(world_ray(perspective_camera(), no_height, center()).is_err()); 953 } 954}