Another project
0

Configure Feed

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

feat(render): 3d cam

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 5, 2026, 4:01 PM +0300) commit 410a3226 parent 7215588c change-id wmoukomm
+618
+1
Cargo.lock
··· 479 479 "bone-types", 480 480 "bytemuck", 481 481 "lyon_tessellation", 482 + "nalgebra", 482 483 "png", 483 484 "pollster", 484 485 "proptest",
+1
crates/bone-render/Cargo.toml
··· 12 12 bone-types = { workspace = true } 13 13 bytemuck = { workspace = true } 14 14 lyon_tessellation = { workspace = true } 15 + nalgebra = { workspace = true } 15 16 png = { workspace = true } 16 17 slotmap = { workspace = true } 17 18 swash = { workspace = true }
+616
crates/bone-render/src/camera3.rs
··· 1 + use nalgebra::{Matrix4, Point3 as NPoint3, Vector3 as NVec3, Vector4 as NVec4}; 2 + use uom::si::angle::radian; 3 + use uom::si::f64::Length; 4 + use uom::si::length::millimeter; 5 + 6 + use bone_types::{ 7 + AxisAngle, Camera3, Point3, Projection, ProjectionKind, Result, Tolerance, TypesError, 8 + UnitVec3, Vec3, ZoomFactor, 9 + }; 10 + 11 + use crate::camera::ViewportExtent; 12 + 13 + const ORTHO_DEPTH_FACTOR: f64 = 8.0; 14 + const RAY_TOLERANCE: Tolerance = Tolerance::new(1.0e-12); 15 + const FOCAL_DENOM_FLOOR: f64 = 1.0e-9; 16 + 17 + #[derive(Copy, Clone, Debug, PartialEq)] 18 + pub struct ViewportPoint { 19 + x: f64, 20 + y: f64, 21 + } 22 + 23 + impl ViewportPoint { 24 + pub fn new(x: f64, y: f64) -> Result<Self> { 25 + if !x.is_finite() || !y.is_finite() { 26 + return Err(TypesError::NonFiniteViewportPixel { x, y }); 27 + } 28 + Ok(Self { x, y }) 29 + } 30 + 31 + #[must_use] 32 + pub const fn x(self) -> f64 { 33 + self.x 34 + } 35 + 36 + #[must_use] 37 + pub const fn y(self) -> f64 { 38 + self.y 39 + } 40 + } 41 + 42 + impl core::fmt::Display for ViewportPoint { 43 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 44 + write!(f, "({}, {}) px", self.x, self.y) 45 + } 46 + } 47 + 48 + pub fn clip_from_world(camera: Camera3, extent: ViewportExtent) -> Result<[f32; 16]> { 49 + Ok(to_gpu(&clip_from_world_mat(camera, extent)?)) 50 + } 51 + 52 + pub fn world_from_clip(camera: Camera3, extent: ViewportExtent) -> Result<[f32; 16]> { 53 + Ok(to_gpu(&world_from_clip_mat(camera, extent)?)) 54 + } 55 + 56 + pub fn world_ray( 57 + camera: Camera3, 58 + extent: ViewportExtent, 59 + pixel: ViewportPoint, 60 + ) -> Result<(Point3, UnitVec3)> { 61 + let inverse = world_from_clip_mat(camera, extent)?; 62 + let near = unproject(&inverse, extent, pixel, 0.0); 63 + let far = unproject(&inverse, extent, pixel, 1.0); 64 + let direction = (far - near).try_normalize(RAY_TOLERANCE)?; 65 + Ok((near, direction)) 66 + } 67 + 68 + pub fn world_on_focal_plane( 69 + camera: Camera3, 70 + extent: ViewportExtent, 71 + pixel: ViewportPoint, 72 + ) -> Result<Point3> { 73 + let (origin, direction) = world_ray(camera, extent, pixel)?; 74 + let normal = view_direction(camera)?; 75 + let denom = direction.dot(normal); 76 + if denom.abs() < FOCAL_DENOM_FLOOR { 77 + return Err(TypesError::ZeroLengthAxis); 78 + } 79 + let to_target = camera.target() - origin; 80 + let t = dot_vec_unit(to_target, normal) / denom; 81 + Ok(origin + direction.into_vec(Length::new::<millimeter>(t))) 82 + } 83 + 84 + pub fn pan_pixels( 85 + camera: Camera3, 86 + extent: ViewportExtent, 87 + from: ViewportPoint, 88 + to: ViewportPoint, 89 + ) -> Result<Camera3> { 90 + let grabbed = world_on_focal_plane(camera, extent, from)?; 91 + let released = world_on_focal_plane(camera, extent, to)?; 92 + translate(camera, grabbed - released) 93 + } 94 + 95 + pub fn zoom_about_pixel( 96 + camera: Camera3, 97 + extent: ViewportExtent, 98 + pixel: ViewportPoint, 99 + factor: ZoomFactor, 100 + ) -> Result<Camera3> { 101 + let pivot = world_on_focal_plane(camera, extent, pixel)?; 102 + let zoomed = apply_zoom(camera, factor)?; 103 + let drifted = world_on_focal_plane(zoomed, extent, pixel)?; 104 + translate(zoomed, pivot - drifted) 105 + } 106 + 107 + pub fn orbit_about_pixel( 108 + camera: Camera3, 109 + extent: ViewportExtent, 110 + pixel: ViewportPoint, 111 + rotation: AxisAngle, 112 + ) -> Result<Camera3> { 113 + let pivot = world_on_focal_plane(camera, extent, pixel)?; 114 + orbit_about_point(camera, pivot, rotation) 115 + } 116 + 117 + pub fn orbit_about_point(camera: Camera3, pivot: Point3, rotation: AxisAngle) -> Result<Camera3> { 118 + Camera3::new( 119 + camera.eye().rotated_about(pivot, rotation), 120 + camera.target().rotated_about(pivot, rotation), 121 + camera.up().rotated(rotation), 122 + camera.projection(), 123 + ) 124 + } 125 + 126 + pub fn frame_isometric(aabb: bone_types::Aabb3, extent: ViewportExtent) -> Result<Camera3> { 127 + let center = aabb.center(); 128 + let span = 0.5 * aabb.extent().norm_mm(); 129 + let direction = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE)?; 130 + let eye = center + direction.into_vec(Length::new::<millimeter>(span * 3.0)); 131 + let fit = (1.0 / aspect_ratio(extent)).max(1.0); 132 + let half_height = Length::new::<millimeter>(span * 1.2 * fit); 133 + Camera3::new( 134 + eye, 135 + center, 136 + UnitVec3::z_axis(), 137 + Projection::orthographic(half_height)?, 138 + ) 139 + } 140 + 141 + fn clip_from_world_mat(camera: Camera3, extent: ViewportExtent) -> Result<Matrix4<f64>> { 142 + let (width, height) = (extent.width().value(), extent.height().value()); 143 + if width == 0 || height == 0 { 144 + return Err(TypesError::ZeroViewportExtent { width, height }); 145 + } 146 + Ok(projection_matrix(camera, extent) * view_matrix(camera)) 147 + } 148 + 149 + fn world_from_clip_mat(camera: Camera3, extent: ViewportExtent) -> Result<Matrix4<f64>> { 150 + clip_from_world_mat(camera, extent)? 151 + .try_inverse() 152 + .ok_or(TypesError::NonInvertibleViewProjection) 153 + } 154 + 155 + fn view_matrix(camera: Camera3) -> Matrix4<f64> { 156 + let (ex, ey, ez) = camera.eye().coords_mm(); 157 + let (tx, ty, tz) = camera.target().coords_mm(); 158 + let (ux, uy, uz) = camera.up().components(); 159 + Matrix4::look_at_rh( 160 + &NPoint3::new(ex, ey, ez), 161 + &NPoint3::new(tx, ty, tz), 162 + &NVec3::new(ux, uy, uz), 163 + ) 164 + } 165 + 166 + fn projection_matrix(camera: Camera3, extent: ViewportExtent) -> Matrix4<f64> { 167 + let aspect = aspect_ratio(extent); 168 + match camera.projection().kind() { 169 + ProjectionKind::Perspective { fov, near, far } => perspective_wgpu( 170 + aspect, 171 + fov.get::<radian>(), 172 + near.get::<millimeter>(), 173 + far.get::<millimeter>(), 174 + ), 175 + ProjectionKind::Orthographic { half_height } => { 176 + let top = half_height.get::<millimeter>(); 177 + let distance = (camera.target() - camera.eye()).norm_mm(); 178 + let depth = ORTHO_DEPTH_FACTOR * top.max(distance); 179 + orthographic_wgpu(top * aspect, top, distance - depth, distance + depth) 180 + } 181 + } 182 + } 183 + 184 + fn perspective_wgpu(aspect: f64, fovy: f64, near: f64, far: f64) -> Matrix4<f64> { 185 + let focal = 1.0 / (fovy * 0.5).tan(); 186 + let mut m = Matrix4::zeros(); 187 + m[(0, 0)] = focal / aspect; 188 + m[(1, 1)] = focal; 189 + m[(2, 2)] = far / (near - far); 190 + m[(2, 3)] = near * far / (near - far); 191 + m[(3, 2)] = -1.0; 192 + m 193 + } 194 + 195 + fn orthographic_wgpu(right: f64, top: f64, near: f64, far: f64) -> Matrix4<f64> { 196 + let mut m = Matrix4::identity(); 197 + m[(0, 0)] = 1.0 / right; 198 + m[(1, 1)] = 1.0 / top; 199 + m[(2, 2)] = 1.0 / (near - far); 200 + m[(2, 3)] = near / (near - far); 201 + m 202 + } 203 + 204 + fn unproject( 205 + inverse: &Matrix4<f64>, 206 + extent: ViewportExtent, 207 + pixel: ViewportPoint, 208 + ndc_z: f64, 209 + ) -> Point3 { 210 + let (ndc_x, ndc_y) = ndc_of(pixel, extent); 211 + let clip = NVec4::new(ndc_x, ndc_y, ndc_z, 1.0); 212 + let world = inverse * clip; 213 + Point3::from_mm(world.x / world.w, world.y / world.w, world.z / world.w) 214 + } 215 + 216 + fn ndc_of(pixel: ViewportPoint, extent: ViewportExtent) -> (f64, f64) { 217 + let width = f64::from(extent.width().value()); 218 + let height = f64::from(extent.height().value()); 219 + ( 220 + 2.0 * pixel.x() / width - 1.0, 221 + 1.0 - 2.0 * pixel.y() / height, 222 + ) 223 + } 224 + 225 + fn aspect_ratio(extent: ViewportExtent) -> f64 { 226 + f64::from(extent.width().value()) / f64::from(extent.height().value()) 227 + } 228 + 229 + fn view_direction(camera: Camera3) -> Result<UnitVec3> { 230 + (camera.target() - camera.eye()).try_normalize(RAY_TOLERANCE) 231 + } 232 + 233 + fn translate(camera: Camera3, delta: Vec3) -> Result<Camera3> { 234 + Camera3::new( 235 + camera.eye() + delta, 236 + camera.target() + delta, 237 + camera.up(), 238 + camera.projection(), 239 + ) 240 + } 241 + 242 + fn apply_zoom(camera: Camera3, factor: ZoomFactor) -> Result<Camera3> { 243 + match camera.projection().kind() { 244 + ProjectionKind::Orthographic { half_height } => { 245 + let scaled = 246 + Length::new::<millimeter>(half_height.get::<millimeter>() / factor.value()); 247 + Camera3::new( 248 + camera.eye(), 249 + camera.target(), 250 + camera.up(), 251 + Projection::orthographic(scaled)?, 252 + ) 253 + } 254 + ProjectionKind::Perspective { .. } => { 255 + let offset = camera.eye() - camera.target(); 256 + Camera3::new( 257 + camera.target() + offset * (1.0 / factor.value()), 258 + camera.target(), 259 + camera.up(), 260 + camera.projection(), 261 + ) 262 + } 263 + } 264 + } 265 + 266 + fn dot_vec_unit(a: Vec3, b: UnitVec3) -> f64 { 267 + let (ax, ay, az) = a.coords_mm(); 268 + let (bx, by, bz) = b.components(); 269 + ax * bx + ay * by + az * bz 270 + } 271 + 272 + fn to_gpu(matrix: &Matrix4<f64>) -> [f32; 16] { 273 + let slice = matrix.as_slice(); 274 + core::array::from_fn(|index| crate::lower_f32(slice[index])) 275 + } 276 + 277 + #[cfg(test)] 278 + mod tests { 279 + use super::*; 280 + use bone_types::{Aabb3, Angle}; 281 + use uom::si::angle::degree; 282 + 283 + fn extent() -> ViewportExtent { 284 + ViewportExtent::square(crate::camera::ViewportPx::new(256)) 285 + } 286 + 287 + fn ortho_camera() -> Camera3 { 288 + let Ok(projection) = Projection::orthographic(Length::new::<millimeter>(2.0)) else { 289 + panic!("half height is positive"); 290 + }; 291 + let Ok(camera) = Camera3::new( 292 + Point3::from_mm(4.0, 3.0, 5.0), 293 + Point3::origin(), 294 + UnitVec3::z_axis(), 295 + projection, 296 + ) else { 297 + panic!("camera is non-degenerate"); 298 + }; 299 + camera 300 + } 301 + 302 + fn perspective_camera() -> Camera3 { 303 + let Ok(projection) = Projection::perspective( 304 + Angle::new::<degree>(45.0), 305 + Length::new::<millimeter>(0.5), 306 + Length::new::<millimeter>(100.0), 307 + ) else { 308 + panic!("projection is valid"); 309 + }; 310 + let Ok(camera) = Camera3::new( 311 + Point3::from_mm(6.0, 4.0, 8.0), 312 + Point3::origin(), 313 + UnitVec3::z_axis(), 314 + projection, 315 + ) else { 316 + panic!("camera is non-degenerate"); 317 + }; 318 + camera 319 + } 320 + 321 + fn vp(x: f64, y: f64) -> ViewportPoint { 322 + let Ok(point) = ViewportPoint::new(x, y) else { 323 + panic!("pixel coordinates are finite"); 324 + }; 325 + point 326 + } 327 + 328 + fn zf(value: f64) -> ZoomFactor { 329 + let Ok(factor) = ZoomFactor::new(value) else { 330 + panic!("zoom factor is positive and finite"); 331 + }; 332 + factor 333 + } 334 + 335 + fn center() -> ViewportPoint { 336 + vp(128.0, 128.0) 337 + } 338 + 339 + fn focal(camera: Camera3, pixel: ViewportPoint) -> Point3 { 340 + let Ok(point) = world_on_focal_plane(camera, extent(), pixel) else { 341 + panic!("pixel has a focal point"); 342 + }; 343 + point 344 + } 345 + 346 + fn close(a: Point3, b: Point3, tol: f64) -> bool { 347 + let (ax, ay, az) = a.coords_mm(); 348 + let (bx, by, bz) = b.coords_mm(); 349 + (ax - bx).abs() < tol && (ay - by).abs() < tol && (az - bz).abs() < tol 350 + } 351 + 352 + fn mul(a: &Matrix4<f64>, b: &Matrix4<f64>) -> Matrix4<f64> { 353 + a * b 354 + } 355 + 356 + fn mul16(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { 357 + core::array::from_fn(|index| { 358 + let (col, row) = (index / 4, index % 4); 359 + (0..4).map(|k| a[k * 4 + row] * b[col * 4 + k]).sum() 360 + }) 361 + } 362 + 363 + #[test] 364 + fn ortho_lowering_is_column_major_affine() { 365 + let Ok(m) = clip_from_world(ortho_camera(), extent()) else { 366 + panic!("a nonzero extent lowers"); 367 + }; 368 + assert!(m[3].abs() < 1e-6); 369 + assert!(m[7].abs() < 1e-6); 370 + assert!(m[11].abs() < 1e-6); 371 + assert!((m[15] - 1.0).abs() < 1e-6); 372 + } 373 + 374 + #[test] 375 + fn perspective_lowering_has_projective_w_row() { 376 + let Ok(m) = clip_from_world(perspective_camera(), extent()) else { 377 + panic!("a nonzero extent lowers"); 378 + }; 379 + let affine = m[3].abs() < 1e-6 380 + && m[7].abs() < 1e-6 381 + && m[11].abs() < 1e-6 382 + && (m[15] - 1.0).abs() < 1e-6; 383 + assert!( 384 + !affine, 385 + "perspective w-row should be projective, not affine" 386 + ); 387 + } 388 + 389 + #[test] 390 + fn ortho_inverse_round_trips_to_identity() { 391 + let camera = ortho_camera(); 392 + let Ok(forward) = clip_from_world_mat(camera, extent()) else { 393 + panic!("a nonzero extent lowers"); 394 + }; 395 + let Ok(inverse) = world_from_clip_mat(camera, extent()) else { 396 + panic!("orthographic view-projection is invertible"); 397 + }; 398 + let product = mul(&forward, &inverse); 399 + (0..16).for_each(|index| { 400 + let (col, row) = (index / 4, index % 4); 401 + let want = f64::from(u8::from(col == row)); 402 + assert!( 403 + (product.as_slice()[index] - want).abs() < 1e-9, 404 + "round trip broke at col={col} row={row}" 405 + ); 406 + }); 407 + } 408 + 409 + #[test] 410 + fn perspective_inverse_round_trips_to_identity() { 411 + let camera = perspective_camera(); 412 + let Ok(forward) = clip_from_world_mat(camera, extent()) else { 413 + panic!("a nonzero extent lowers"); 414 + }; 415 + let Ok(inverse) = world_from_clip_mat(camera, extent()) else { 416 + panic!("perspective view-projection is invertible"); 417 + }; 418 + let product = mul(&forward, &inverse); 419 + (0..16).for_each(|index| { 420 + let (col, row) = (index / 4, index % 4); 421 + let want = f64::from(u8::from(col == row)); 422 + assert!( 423 + (product.as_slice()[index] - want).abs() < 1e-9, 424 + "round trip broke at col={col} row={row}" 425 + ); 426 + }); 427 + } 428 + 429 + #[test] 430 + fn public_forward_and_inverse_round_trip() { 431 + [ortho_camera(), perspective_camera()] 432 + .into_iter() 433 + .for_each(|camera| { 434 + let Ok(forward) = clip_from_world(camera, extent()) else { 435 + panic!("a nonzero extent lowers"); 436 + }; 437 + let Ok(inverse) = world_from_clip(camera, extent()) else { 438 + panic!("view-projection is invertible"); 439 + }; 440 + let product = mul16(&forward, &inverse); 441 + (0..16).for_each(|index| { 442 + let (col, row) = (index / 4, index % 4); 443 + let want = f32::from(u8::from(col == row)); 444 + assert!( 445 + (product[index] - want).abs() < 1e-3, 446 + "public round trip broke at col={col} row={row}: {}", 447 + product[index] 448 + ); 449 + }); 450 + }); 451 + } 452 + 453 + #[test] 454 + fn center_pixel_focuses_on_target() { 455 + [ortho_camera(), perspective_camera()] 456 + .into_iter() 457 + .for_each(|camera| { 458 + let focus = focal(camera, center()); 459 + assert!( 460 + close(focus, camera.target(), 1e-6), 461 + "center pixel should focus on the target" 462 + ); 463 + }); 464 + } 465 + 466 + #[test] 467 + fn pan_keeps_grabbed_point_under_cursor() { 468 + [ortho_camera(), perspective_camera()] 469 + .into_iter() 470 + .for_each(|camera| { 471 + let from = vp(80.0, 96.0); 472 + let to = vp(170.0, 150.0); 473 + let grabbed = focal(camera, from); 474 + let Ok(panned) = pan_pixels(camera, extent(), from, to) else { 475 + panic!("pan succeeds"); 476 + }; 477 + let landed = focal(panned, to); 478 + assert!( 479 + close(grabbed, landed, 1e-6), 480 + "grabbed world point should sit under the release cursor" 481 + ); 482 + }); 483 + } 484 + 485 + #[test] 486 + fn zoom_keeps_pivot_under_cursor() { 487 + [ortho_camera(), perspective_camera()] 488 + .into_iter() 489 + .for_each(|camera| { 490 + let pixel = vp(60.0, 200.0); 491 + let pivot = focal(camera, pixel); 492 + let Ok(zoomed) = zoom_about_pixel(camera, extent(), pixel, zf(1.6)) else { 493 + panic!("zoom succeeds"); 494 + }; 495 + let after = focal(zoomed, pixel); 496 + assert!( 497 + close(pivot, after, 1e-6), 498 + "world point under the cursor should survive a zoom" 499 + ); 500 + }); 501 + } 502 + 503 + #[test] 504 + fn orbit_keeps_pivot_under_cursor() { 505 + [ortho_camera(), perspective_camera()] 506 + .into_iter() 507 + .for_each(|camera| { 508 + let pixel = vp(150.0, 110.0); 509 + let pivot = focal(camera, pixel); 510 + let rotation = AxisAngle::new(UnitVec3::z_axis(), Angle::new::<degree>(20.0)); 511 + let Ok(orbited) = orbit_about_pixel(camera, extent(), pixel, rotation) else { 512 + panic!("orbit succeeds"); 513 + }; 514 + let after = focal(orbited, pixel); 515 + assert!( 516 + close(pivot, after, 1e-6), 517 + "world point under the cursor should survive an orbit" 518 + ); 519 + }); 520 + } 521 + 522 + #[test] 523 + fn isometric_frames_unit_cube_centered() { 524 + let cube = Aabb3::from_corners( 525 + Point3::from_mm(0.0, 0.0, 0.0), 526 + Point3::from_mm(1.0, 1.0, 1.0), 527 + ); 528 + let Ok(camera) = frame_isometric(cube, extent()) else { 529 + panic!("cube frames isometrically"); 530 + }; 531 + let focus = focal(camera, center()); 532 + assert!( 533 + close(focus, cube.center(), 1e-6), 534 + "isometric framing should look at the cube center" 535 + ); 536 + } 537 + 538 + #[test] 539 + fn extreme_camera_inverse_reports_error_not_identity() { 540 + let Ok(projection) = Projection::perspective( 541 + Angle::new::<degree>(45.0), 542 + Length::new::<millimeter>(1.0e-9), 543 + Length::new::<millimeter>(1.0e12), 544 + ) else { 545 + panic!("projection is valid"); 546 + }; 547 + let Ok(direction) = UnitVec3::try_from_components(1.0, 1.0, 1.0, RAY_TOLERANCE) else { 548 + panic!("diagonal is normalizable"); 549 + }; 550 + let eye = Point3::origin() + direction.into_vec(Length::new::<millimeter>(1.0e9)); 551 + let Ok(camera) = Camera3::new(eye, Point3::origin(), UnitVec3::z_axis(), projection) else { 552 + panic!("camera is non-degenerate"); 553 + }; 554 + assert!( 555 + matches!( 556 + world_from_clip(camera, extent()), 557 + Err(TypesError::NonInvertibleViewProjection) 558 + ), 559 + "an inverse that underflows must surface an error, not a silent identity" 560 + ); 561 + assert!(world_ray(camera, extent(), center()).is_err()); 562 + assert!(world_on_focal_plane(camera, extent(), center()).is_err()); 563 + } 564 + 565 + #[test] 566 + fn viewport_point_rejects_non_finite() { 567 + assert!(ViewportPoint::new(f64::NAN, 0.0).is_err()); 568 + assert!(ViewportPoint::new(0.0, f64::INFINITY).is_err()); 569 + assert!(ViewportPoint::new(10.0, 20.0).is_ok()); 570 + } 571 + 572 + #[test] 573 + fn frame_isometric_rejects_degenerate_aabb() { 574 + let p = Point3::from_mm(1.0, 2.0, 3.0); 575 + let degenerate = Aabb3::from_corners(p, p); 576 + assert!(frame_isometric(degenerate, extent()).is_err()); 577 + } 578 + 579 + #[test] 580 + fn orbit_preserves_unit_up() { 581 + let Ok(direction) = UnitVec3::try_from_components(0.3, 0.7, 0.64, RAY_TOLERANCE) else { 582 + panic!("axis is normalizable"); 583 + }; 584 + let step = AxisAngle::new(direction, Angle::new::<degree>(13.0)); 585 + let final_camera = (0..4096).fold(ortho_camera(), |camera, _| { 586 + let Ok(next) = orbit_about_point(camera, Point3::origin(), step) else { 587 + panic!("orbit stays valid"); 588 + }; 589 + next 590 + }); 591 + let (x, y, z) = final_camera.up().components(); 592 + let norm = (x * x + y * y + z * z).sqrt(); 593 + assert!( 594 + (norm - 1.0).abs() < 1e-12, 595 + "up vector should stay unit length across orbits: norm {norm}" 596 + ); 597 + } 598 + 599 + #[test] 600 + fn lowering_rejects_zero_extent() { 601 + let zero = ViewportExtent::square(crate::camera::ViewportPx::new(0)); 602 + assert!(matches!( 603 + clip_from_world(ortho_camera(), zero), 604 + Err(TypesError::ZeroViewportExtent { 605 + width: 0, 606 + height: 0 607 + }) 608 + )); 609 + let no_height = ViewportExtent::new( 610 + crate::camera::ViewportPx::new(256), 611 + crate::camera::ViewportPx::new(0), 612 + ); 613 + assert!(world_from_clip(perspective_camera(), no_height).is_err()); 614 + assert!(world_ray(perspective_camera(), no_height, center()).is_err()); 615 + } 616 + }