Another project
0

Configure Feed

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

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