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