Another project
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}