1use crate::camera::{Aabb, Camera};
12
13pub fn norm3(v: [f64; 3]) -> [f64; 3] {
14 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
15 if len <= 0.0 {
16 return [0.0, 0.0, 1.0];
17 }
18 [v[0] / len, v[1] / len, v[2] / len]
19}
20
21pub fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
22 [
23 a[1] * b[2] - a[2] * b[1],
24 a[2] * b[0] - a[0] * b[2],
25 a[0] * b[1] - a[1] * b[0],
26 ]
27}
28
29pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
30 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
31}
32
33pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
34 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
35}
36
37pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
38 [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
39}
40
41pub fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
42 [a[0] * s, a[1] * s, a[2] * s]
43}
44
45pub fn len3(a: [f64; 3]) -> f64 {
46 dot3(a, a).sqrt()
47}
48
49pub fn rotate3(v: [f64; 3], axis: [f64; 3], angle: f64) -> [f64; 3] {
51 let (sin, cos) = angle.sin_cos();
52 let cross = cross3(axis, v);
53 let dot = dot3(axis, v);
54 [
55 v[0] * cos + cross[0] * sin + axis[0] * dot * (1.0 - cos),
56 v[1] * cos + cross[1] * sin + axis[1] * dot * (1.0 - cos),
57 v[2] * cos + cross[2] * sin + axis[2] * dot * (1.0 - cos),
58 ]
59}
60
61pub fn invert4_columns(m: &[f64; 16]) -> Option<[f64; 16]> {
65 let a00 = m[0]; let a01 = m[1]; let a02 = m[2]; let a03 = m[3];
66 let a10 = m[4]; let a11 = m[5]; let a12 = m[6]; let a13 = m[7];
67 let a20 = m[8]; let a21 = m[9]; let a22 = m[10]; let a23 = m[11];
68 let a30 = m[12]; let a31 = m[13]; let a32 = m[14]; let a33 = m[15];
69
70 let b00 = a00 * a11 - a01 * a10;
71 let b01 = a00 * a12 - a02 * a10;
72 let b02 = a00 * a13 - a03 * a10;
73 let b03 = a01 * a12 - a02 * a11;
74 let b04 = a01 * a13 - a03 * a11;
75 let b05 = a02 * a13 - a03 * a12;
76 let b06 = a20 * a31 - a21 * a30;
77 let b07 = a20 * a32 - a22 * a30;
78 let b08 = a20 * a33 - a23 * a30;
79 let b09 = a21 * a32 - a22 * a31;
80 let b10 = a21 * a33 - a23 * a31;
81 let b11 = a22 * a33 - a23 * a32;
82
83 let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
84 if det.abs() < 1e-300 {
85 return None;
86 }
87 let inv = 1.0 / det;
88 Some([
89 (a11 * b11 - a12 * b10 + a13 * b09) * inv,
90 (a02 * b10 - a01 * b11 - a03 * b09) * inv,
91 (a31 * b05 - a32 * b04 + a33 * b03) * inv,
92 (a22 * b04 - a21 * b05 - a23 * b03) * inv,
93 (a12 * b08 - a10 * b11 - a13 * b07) * inv,
94 (a00 * b11 - a02 * b08 + a03 * b07) * inv,
95 (a32 * b02 - a30 * b05 - a33 * b01) * inv,
96 (a20 * b05 - a22 * b02 + a23 * b01) * inv,
97 (a10 * b10 - a11 * b08 + a13 * b06) * inv,
98 (a01 * b08 - a00 * b10 - a03 * b06) * inv,
99 (a30 * b04 - a31 * b02 + a33 * b00) * inv,
100 (a21 * b02 - a20 * b04 - a23 * b00) * inv,
101 (a11 * b07 - a10 * b09 - a12 * b06) * inv,
102 (a00 * b09 - a01 * b07 + a02 * b06) * inv,
103 (a31 * b01 - a30 * b03 - a32 * b00) * inv,
104 (a20 * b03 - a21 * b01 + a22 * b00) * inv,
105 ])
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum Projection {
112 Orthographic { half_height: f64 },
114 Perspective { fov_y_deg: f64 },
115}
116
117#[derive(Debug, Clone, Copy)]
119pub struct Ray {
120 pub origin: [f64; 3],
121 pub dir: [f64; 3],
122}
123
124#[derive(Debug, Clone)]
125pub struct ViewCamera {
126 pub eye: [f64; 3],
127 pub target: [f64; 3],
128 pub up: [f64; 3],
129 pub projection: Projection,
130 pub width: f64,
132 pub height: f64,
133 pub near: f64,
147 pub far: f64,
148}
149
150impl Default for ViewCamera {
151 fn default() -> Self {
152 Self {
155 eye: [15.0, 12.0, 15.0],
156 target: [0.0, 0.0, 0.0],
157 up: [0.0, 1.0, 0.0],
158 projection: Projection::Orthographic { half_height: 10.0 },
159 width: 800.0,
160 height: 600.0,
161 near: -100000.0,
162 far: 100000.0,
163 }
164 }
165}
166
167impl ViewCamera {
168 pub fn aspect(&self) -> f64 {
169 (self.width / self.height.max(1.0)).max(1e-6)
170 }
171
172 pub fn basis(&self) -> ([f64; 3], [f64; 3], [f64; 3]) {
175 let forward = norm3(sub3(self.target, self.eye));
176 let right = norm3(cross3(forward, self.up));
177 let up = cross3(right, forward);
178 (right, up, forward)
179 }
180
181 pub fn distance(&self) -> f64 {
182 len3(sub3(self.eye, self.target)).max(1e-9)
183 }
184
185 pub fn world_per_pixel(&self) -> f64 {
188 match self.projection {
189 Projection::Orthographic { half_height } => 2.0 * half_height / self.height.max(1.0),
190 Projection::Perspective { fov_y_deg } => {
191 let fov = fov_y_deg.to_radians();
192 2.0 * (fov * 0.5).tan() * self.distance() / self.height.max(1.0)
193 }
194 }
195 }
196
197 pub fn view_depth(&self, world: [f64; 3]) -> f64 {
202 let (_, _, forward) = self.basis();
203 dot3(sub3(world, self.eye), forward)
204 }
205
206 pub fn projectable(&self, world: [f64; 3]) -> bool {
221 matches!(self.projection, Projection::Orthographic { .. })
222 || self.view_depth(world) > 1e-6
223 }
224
225 pub fn label_anchor_visible(&self, world: [f64; 3]) -> bool {
235 if !self.projectable(world) {
236 return false;
237 }
238 let (sx, sy, _) = self.project(world);
239 sx >= 0.0 && sx <= self.width && sy >= 0.0 && sy <= self.height
240 }
241
242 pub fn view_proj_cols(&self) -> [[f64; 4]; 4] {
247 let (right, up, forward) = self.basis();
248 let half_h = match self.projection {
249 Projection::Orthographic { half_height } => half_height,
250 Projection::Perspective { fov_y_deg } => (fov_y_deg.to_radians() * 0.5).tan(),
251 };
252 let half_w = half_h * self.aspect();
253
254 let ex = -dot3(right, self.eye);
256 let ey = -dot3(up, self.eye);
257 let ez = dot3(forward, self.eye);
258 let view = [
259 [right[0], up[0], -forward[0], 0.0],
260 [right[1], up[1], -forward[1], 0.0],
261 [right[2], up[2], -forward[2], 0.0],
262 [ex, ey, ez, 1.0],
263 ];
264
265 let proj = match self.projection {
266 Projection::Orthographic { .. } => {
267 let sx = 1.0 / half_w;
269 let sy = 1.0 / half_h;
270 let sz = -1.0 / (self.far - self.near);
271 [
272 [sx, 0.0, 0.0, 0.0],
273 [0.0, sy, 0.0, 0.0],
274 [0.0, 0.0, sz, 0.0],
275 [0.0, 0.0, -self.near / (self.far - self.near), 1.0],
276 ]
277 }
278 Projection::Perspective { .. } => {
279 let near = self.near.max(1e-6);
292 let far = self.far.max(near * 1.0001);
293 let f = 1.0 / half_h;
294 [
295 [f / self.aspect(), 0.0, 0.0, 0.0],
296 [0.0, f, 0.0, 0.0],
297 [0.0, 0.0, far / (near - far), -1.0],
298 [0.0, 0.0, near * far / (near - far), 0.0],
299 ]
300 }
301 };
302
303 let mut view_proj = [[0.0f64; 4]; 4];
304 for col in 0..4 {
305 for row in 0..4 {
306 let mut sum = 0.0;
307 for k in 0..4 {
308 sum += proj[k][row] * view[col][k];
309 }
310 view_proj[col][row] = sum;
311 }
312 }
313 view_proj
314 }
315
316 pub fn resolve(&self) -> Camera {
318 let cols = self.view_proj_cols();
319 let mut view_proj = [[0.0f32; 4]; 4];
320 for col in 0..4 {
321 for row in 0..4 {
322 view_proj[col][row] = cols[col][row] as f32;
323 }
324 }
325 let fwd = norm3(sub3(self.target, self.eye));
326 Camera {
327 view_proj,
328 forward: [fwd[0] as f32, fwd[1] as f32, fwd[2] as f32],
329 }
330 }
331
332 pub fn view_proj_flat(&self) -> [f64; 16] {
337 let cols = self.view_proj_cols();
338 let mut out = [0.0f64; 16];
339 for col in 0..4 {
340 for row in 0..4 {
341 out[col * 4 + row] = cols[col][row];
342 }
343 }
344 out
345 }
346
347 pub fn view_proj_inverse_flat(&self) -> [f64; 16] {
351 invert4_columns(&self.view_proj_flat())
352 .unwrap_or([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0])
353 }
354
355 pub fn project(&self, world: [f64; 3]) -> (f64, f64, f64) {
359 let (right, up, forward) = self.basis();
360 let rel = sub3(world, self.eye);
361 let vx = dot3(rel, right);
362 let vy = dot3(rel, up);
363 let depth = dot3(rel, forward);
364 match self.projection {
365 Projection::Orthographic { half_height } => {
366 let half_w = half_height * self.aspect();
367 let sx = (vx / half_w * 0.5 + 0.5) * self.width;
368 let sy = (0.5 - vy / half_height * 0.5) * self.height;
369 (sx, sy, depth)
370 }
371 Projection::Perspective { fov_y_deg } => {
372 let half_h = (fov_y_deg.to_radians() * 0.5).tan();
373 let half_w = half_h * self.aspect();
374 let d = depth.max(1e-9);
375 let sx = (vx / (half_w * d) * 0.5 + 0.5) * self.width;
376 let sy = (0.5 - vy / (half_h * d) * 0.5) * self.height;
377 (sx, sy, depth)
378 }
379 }
380 }
381
382 pub fn pick_ray(&self, x: f64, y: f64) -> Ray {
386 let (right, up, forward) = self.basis();
387 let ndc_x = (x / self.width.max(1.0)) * 2.0 - 1.0;
388 let ndc_y = -((y / self.height.max(1.0)) * 2.0 - 1.0);
389 match self.projection {
390 Projection::Orthographic { half_height } => {
391 let half_w = half_height * self.aspect();
392 let span = self.far.abs().max(self.near.abs()).max(half_height * 40.0).max(1.0);
393 let on_plane = add3(
394 self.eye,
395 add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_height)),
396 );
397 Ray {
398 origin: sub3(on_plane, scale3(forward, span)),
399 dir: forward,
400 }
401 }
402 Projection::Perspective { fov_y_deg } => {
403 let half_h = (fov_y_deg.to_radians() * 0.5).tan();
404 let half_w = half_h * self.aspect();
405 let dir = norm3(add3(
406 forward,
407 add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_h)),
408 ));
409 Ray {
410 origin: self.eye,
411 dir,
412 }
413 }
414 }
415 }
416
417 pub fn fit_depth_range(&mut self, bbox: &Aabb) {
420 if bbox.is_empty() {
421 match self.projection {
430 Projection::Orthographic { .. } => {
431 self.near = -100000.0;
432 self.far = 100000.0;
433 }
434 Projection::Perspective { .. } => {
435 self.near = 0.1;
436 self.far = 1e5;
437 }
438 }
439 return;
440 }
441 let (_, _, forward) = self.basis();
442 let mut min_d = f64::INFINITY;
443 let mut max_d = f64::NEG_INFINITY;
444 for i in 0..8 {
445 let corner = [
446 if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
447 if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
448 if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
449 ];
450 let d = dot3(sub3(corner, self.eye), forward);
451 min_d = min_d.min(d);
452 max_d = max_d.max(d);
453 }
454 let diag = len3(sub3(bbox.max, bbox.min));
455 let pad = ((max_d - min_d) * 0.1).max(diag * 0.1).max(0.5);
456 match self.projection {
457 Projection::Orthographic { .. } => {
458 self.near = min_d - pad;
459 self.far = max_d + pad;
460 }
461 Projection::Perspective { .. } => {
462 let far = (max_d + pad).max(1.0);
463 self.near = (far * 0.001).clamp(1e-4, 1.0).min((min_d - pad).max(1e-4));
464 self.far = far;
465 }
466 }
467 }
468
469 pub fn zoom_to_fit(&mut self, bbox: &Aabb, margin: f64) {
473 if bbox.is_empty() {
474 return;
475 }
476 let margin = margin.max(1.0);
477 let (right, up, forward) = self.basis();
478 let center = bbox.center();
479 let mut half_w = 0.0f64;
480 let mut half_h = 0.0f64;
481 for i in 0..8 {
482 let corner = [
483 if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
484 if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
485 if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
486 ];
487 let rel = sub3(corner, center);
488 half_w = half_w.max(dot3(rel, right).abs());
489 half_h = half_h.max(dot3(rel, up).abs());
490 }
491 half_w = (half_w * margin).max(1e-6);
492 half_h = (half_h * margin).max(1e-6);
493
494 let dist = self.distance();
495 let aspect = self.aspect();
496 self.target = center;
497 match self.projection {
498 Projection::Orthographic { ref mut half_height } => {
499 *half_height = half_h.max(half_w / aspect);
500 self.eye = sub3(center, scale3(forward, dist));
501 }
502 Projection::Perspective { fov_y_deg } => {
503 let fov = fov_y_deg.to_radians();
504 let dist_h = half_h / (fov * 0.5).tan().max(1e-6);
505 let tan_half_h_fov = (fov * 0.5).tan() * aspect;
506 let dist_w = half_w / tan_half_h_fov.max(1e-6);
507 let target_dist = dist_h.max(dist_w).max(1e-3);
508 self.eye = sub3(center, scale3(forward, target_dist));
509 }
510 }
511 self.fit_depth_range(bbox);
512 }
513
514 pub fn toggle_projection(&mut self) -> &'static str {
517 const FOV: f64 = 50.0;
518 let forward = norm3(sub3(self.target, self.eye));
519 match self.projection {
520 Projection::Orthographic { half_height } => {
521 let denom = (FOV.to_radians() * 0.5).tan();
522 let mut distance = half_height / denom.max(1e-9);
523 if !distance.is_finite() || distance < 1e-4 {
524 distance = 10.0;
525 }
526 self.eye = sub3(self.target, scale3(forward, distance));
527 self.projection = Projection::Perspective { fov_y_deg: FOV };
528 "perspective"
529 }
530 Projection::Perspective { fov_y_deg } => {
531 let dist = self.distance();
532 let half_height = ((fov_y_deg.to_radians() * 0.5).tan() * dist).max(1e-6);
533 self.projection = Projection::Orthographic { half_height };
534 "orthographic"
535 }
536 }
537 }
538
539 pub fn standard_view(&mut self, name: &str) -> bool {
542 let dist = self.distance();
543 let iso = norm3([1.0, 1.0, 1.0]);
544 let (dir, up): ([f64; 3], [f64; 3]) = match name.to_ascii_uppercase().as_str() {
545 "FRONT" => ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
546 "BACK" => ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
547 "RIGHT" => ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
548 "LEFT" => ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
549 "TOP" => ([0.0, 1.0, 0.0], [0.0, 0.0, -1.0]),
550 "BOTTOM" => ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
551 "ISO" => (iso, [0.0, 1.0, 0.0]),
552 _ => return false,
553 };
554 self.eye = add3(self.target, scale3(dir, dist));
555 self.up = up;
556 true
557 }
558
559 pub fn state_json(&self) -> String {
561 let (kind, scale) = match self.projection {
562 Projection::Orthographic { half_height } => ("orthographic", half_height),
563 Projection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
564 };
565 serde_json::json!({
566 "kind": kind,
567 "eye": self.eye,
568 "target": self.target,
569 "up": self.up,
570 "scale": scale,
572 "near": self.near,
573 "far": self.far,
574 "width": self.width,
575 "height": self.height,
576 "worldPerPixel": self.world_per_pixel(),
577 })
578 .to_string()
579 }
580
581 pub fn apply_state_json(&mut self, json: &str) -> Result<(), String> {
584 let value: serde_json::Value =
585 serde_json::from_str(json).map_err(|error| format!("camera state parse: {error}"))?;
586 let vec3 = |key: &str| -> Option<[f64; 3]> {
587 let arr = value.get(key)?.as_array()?;
588 Some([arr.first()?.as_f64()?, arr.get(1)?.as_f64()?, arr.get(2)?.as_f64()?])
589 };
590 if let Some(eye) = vec3("eye") {
591 self.eye = eye;
592 }
593 if let Some(target) = vec3("target") {
594 self.target = target;
595 }
596 if let Some(up) = vec3("up") {
597 self.up = up;
598 }
599 let scale = value.get("scale").and_then(|v| v.as_f64());
600 match value.get("kind").and_then(|v| v.as_str()) {
601 Some("perspective") => {
602 self.projection = Projection::Perspective {
603 fov_y_deg: scale.unwrap_or(50.0),
604 }
605 }
606 Some("orthographic") => {
607 self.projection = Projection::Orthographic {
608 half_height: scale.unwrap_or(10.0).max(1e-9),
609 }
610 }
611 _ => {}
612 }
613 if let Some(near) = value.get("near").and_then(|v| v.as_f64()) {
614 self.near = near;
615 }
616 if let Some(far) = value.get("far").and_then(|v| v.as_f64()) {
617 self.far = far;
618 }
619 Ok(())
620 }
621}
622
623impl brep_gizmos::hit_region::RegionCamera for ViewCamera {
627 fn is_orthographic(&self) -> bool {
628 matches!(self.projection, Projection::Orthographic { .. })
629 }
630 fn depth(&self, p: [f64; 3]) -> f64 {
631 self.view_depth(p)
632 }
633 fn project_px(&self, p: [f64; 3]) -> Option<[f32; 2]> {
634 if !self.projectable(p) {
637 return None;
638 }
639 let (sx, sy, _) = self.project(p);
640 Some([sx as f32, sy as f32])
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 fn unit_bbox() -> Aabb {
649 Aabb {
650 min: [-5.0, -5.0, -5.0],
651 max: [5.0, 5.0, 5.0],
652 }
653 }
654
655 #[test]
662 fn projectable_ignores_near_far_and_ortho_never_culls() {
663 let mut camera = ViewCamera::default(); camera.near = 0.5;
666 camera.far = 1.0;
667
668 let (_, _, fwd) = camera.basis();
671 let behind_eye = sub3(camera.eye, scale3(fwd, 500.0));
672 let beyond_far = add3(camera.eye, scale3(fwd, 90000.0));
673 for p in [[0.0, 0.0, 0.0], behind_eye, beyond_far] {
674 assert!(camera.projectable(p), "ortho must project {p:?}");
675 let (sx, sy, _) = camera.project(p);
676 assert!(sx.is_finite() && sy.is_finite());
677 }
678
679 camera.projection = Projection::Perspective { fov_y_deg: 45.0 };
682 assert!(camera.projectable([0.0, 0.0, 0.0]), "in front projects");
683 assert!(
684 camera.projectable(beyond_far),
685 "beyond `far` still projects in perspective — far never culls"
686 );
687 assert!(
688 !camera.projectable(behind_eye),
689 "behind the eye plane cannot project in perspective"
690 );
691 use brep_gizmos::hit_region::RegionCamera;
693 assert!(camera.project_px(beyond_far).is_some());
694 assert!(camera.project_px(behind_eye).is_none());
695 }
696
697 #[test]
703 fn label_anchor_visible_requires_on_viewport_projection() {
704 let mut camera = ViewCamera::default(); let (right, _, fwd) = camera.basis();
706
707 assert!(camera.label_anchor_visible(camera.target));
709 let far_right = add3(camera.target, scale3(right, 1000.0));
713 assert!(camera.projectable(far_right), "still projectable…");
714 assert!(!camera.label_anchor_visible(far_right), "…but off-screen → no label");
715 let behind_on_screen = sub3(camera.target, scale3(fwd, 500.0));
718 assert!(camera.label_anchor_visible(behind_on_screen));
719 camera.projection = Projection::Perspective { fov_y_deg: 45.0 };
721 let behind_eye = sub3(camera.eye, scale3(fwd, 10.0));
722 assert!(!camera.label_anchor_visible(behind_eye));
723 }
724
725 #[test]
726 fn camera_state_roundtrip() {
727 let mut camera = ViewCamera::default();
728 camera.eye = [3.0, 4.0, 5.0];
729 camera.target = [1.0, 1.0, 1.0];
730 camera.projection = Projection::Orthographic { half_height: 7.25 };
731 let json = camera.state_json();
732 let mut restored = ViewCamera::default();
733 restored.apply_state_json(&json).unwrap();
734 assert_eq!(restored.eye, camera.eye);
735 assert_eq!(restored.target, camera.target);
736 assert_eq!(restored.projection, camera.projection);
737 }
738
739 #[test]
740 fn projection_toggle_preserves_apparent_size() {
741 let mut camera = ViewCamera {
742 width: 800.0,
743 height: 600.0,
744 ..ViewCamera::default()
745 };
746 camera.zoom_to_fit(&unit_bbox(), 1.1);
747 let wpp_ortho = camera.world_per_pixel();
748 assert_eq!(camera.toggle_projection(), "perspective");
749 let wpp_persp = camera.world_per_pixel();
750 assert!(
751 (wpp_ortho - wpp_persp).abs() < wpp_ortho * 1e-9,
752 "wpp {wpp_ortho} vs {wpp_persp}"
753 );
754 assert_eq!(camera.toggle_projection(), "orthographic");
755 let wpp_back = camera.world_per_pixel();
756 assert!((wpp_ortho - wpp_back).abs() < wpp_ortho * 1e-9);
757 }
758
759 #[test]
760 fn zoom_to_fit_centers_and_contains_bbox() {
761 let bbox = Aabb {
762 min: [10.0, -2.0, 3.0],
763 max: [16.0, 6.0, 9.0],
764 };
765 let mut camera = ViewCamera {
766 width: 640.0,
767 height: 480.0,
768 ..ViewCamera::default()
769 };
770 camera.zoom_to_fit(&bbox, 1.1);
771 let center = bbox.center();
772 let (sx, sy, depth) = camera.project(center);
773 assert!((sx - 320.0).abs() < 1e-6, "sx {sx}");
774 assert!((sy - 240.0).abs() < 1e-6, "sy {sy}");
775 assert!(depth > 0.0);
776 for i in 0..8 {
777 let corner = [
778 if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
779 if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
780 if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
781 ];
782 let (sx, sy, _) = camera.project(corner);
783 assert!((-1.0..=641.0).contains(&sx), "corner sx {sx}");
784 assert!((-1.0..=481.0).contains(&sy), "corner sy {sy}");
785 }
786 }
787
788 #[test]
789 fn project_and_pick_ray_are_consistent() {
790 let mut camera = ViewCamera::default();
791 camera.zoom_to_fit(&unit_bbox(), 1.1);
792 let world = [1.25, -0.5, 2.0];
793 let (sx, sy, _) = camera.project(world);
794 let ray = camera.pick_ray(sx, sy);
795 let rel = sub3(world, ray.origin);
797 let along = dot3(rel, ray.dir);
798 let closest = add3(ray.origin, scale3(ray.dir, along));
799 assert!(len3(sub3(world, closest)) < 1e-9);
800 }
801
802 #[test]
803 fn depth_range_contains_scene() {
804 let mut camera = ViewCamera::default();
805 let bbox = unit_bbox();
806 camera.fit_depth_range(&bbox);
807 let (_, _, forward) = camera.basis();
808 for i in 0..8 {
809 let corner = [
810 if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
811 if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
812 if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
813 ];
814 let d = dot3(sub3(corner, camera.eye), forward);
815 assert!(d >= camera.near && d <= camera.far);
816 }
817 }
818
819 #[test]
825 fn fit_depth_range_empty_resets_to_generous_default_not_stale() {
826 let small = Aabb {
828 min: [-1.0, -1.0, -1.0],
829 max: [1.0, 1.0, 1.0],
830 };
831 let mut camera = ViewCamera::default();
833 camera.fit_depth_range(&small);
834 assert!(
835 camera.near > -100000.0 && camera.far < 100000.0,
836 "a small scene must tighten the window: near {} far {}",
837 camera.near,
838 camera.far
839 );
840 camera.fit_depth_range(&Aabb::empty());
843 assert_eq!(camera.near, -100000.0);
844 assert_eq!(camera.far, 100000.0);
845
846 let mut camera = ViewCamera {
848 projection: Projection::Perspective { fov_y_deg: 45.0 },
849 ..ViewCamera::default()
850 };
851 camera.fit_depth_range(&small);
852 assert!(camera.far < 1e5, "small scene tightens far: {}", camera.far);
853 camera.fit_depth_range(&Aabb::empty());
854 assert_eq!(camera.near, 0.1);
855 assert_eq!(camera.far, 1e5);
856 }
857
858 #[test]
863 fn fit_depth_range_brackets_overlay_only_bbox() {
864 let mut camera = ViewCamera::default();
865 let overlay = Aabb {
868 min: [-50.0, -50.0, -50.0],
869 max: [50.0, 50.0, 50.0],
870 };
871 camera.fit_depth_range(&overlay);
872 let (_, _, forward) = camera.basis();
873 let mut points = vec![[0.0, 0.0, 0.0]];
875 for i in 0..8 {
876 points.push([
877 if i & 1 == 0 { overlay.min[0] } else { overlay.max[0] },
878 if i & 2 == 0 { overlay.min[1] } else { overlay.max[1] },
879 if i & 4 == 0 { overlay.min[2] } else { overlay.max[2] },
880 ]);
881 }
882 for p in points {
883 let d = dot3(sub3(p, camera.eye), forward);
884 assert!(
885 d >= camera.near && d <= camera.far,
886 "overlay point {p:?} depth {d} outside [{}, {}]",
887 camera.near,
888 camera.far
889 );
890 }
891 }
892
893 #[test]
894 fn standard_views_look_at_target() {
895 let mut camera = ViewCamera::default();
896 camera.target = [2.0, 3.0, 4.0];
897 let dist = camera.distance();
898 for name in ["FRONT", "BACK", "LEFT", "RIGHT", "TOP", "BOTTOM", "ISO"] {
899 assert!(camera.standard_view(name), "{name}");
900 assert!((camera.distance() - dist).abs() < 1e-9);
901 }
902 assert!(!camera.standard_view("DIAGONAL"));
903 }
904
905 fn apply4(m: &[f64; 16], x: f64, y: f64, z: f64) -> [f64; 3] {
908 let w = 1.0 / (m[3] * x + m[7] * y + m[11] * z + m[15]);
909 [
910 (m[0] * x + m[4] * y + m[8] * z + m[12]) * w,
911 (m[1] * x + m[5] * y + m[9] * z + m[13]) * w,
912 (m[2] * x + m[6] * y + m[10] * z + m[14]) * w,
913 ]
914 }
915
916 #[test]
920 fn view_proj_flat_matches_project() {
921 for persp in [false, true] {
922 let mut camera = ViewCamera { width: 800.0, height: 600.0, ..ViewCamera::default() };
923 camera.zoom_to_fit(&unit_bbox(), 1.1);
924 if persp {
925 camera.toggle_projection();
926 }
927 let vp = camera.view_proj_flat();
928 for world in [[1.25, -0.5, 2.0], [-3.0, 4.0, -1.5], [0.0, 0.0, 0.0]] {
929 let clip = apply4(&vp, world[0], world[1], world[2]);
930 let sx = (clip[0] * 0.5 + 0.5) * camera.width;
931 let sy = (0.5 - clip[1] * 0.5) * camera.height;
932 let (px, py, _) = camera.project(world);
933 assert!((sx - px).abs() < 1e-6, "persp={persp} sx {sx} vs {px}");
934 assert!((sy - py).abs() < 1e-6, "persp={persp} sy {sy} vs {py}");
935 }
936 }
937 }
938
939 #[test]
943 fn view_proj_inverse_round_trips_and_rays() {
944 for persp in [false, true] {
945 let mut camera = ViewCamera { width: 640.0, height: 480.0, ..ViewCamera::default() };
946 camera.zoom_to_fit(&unit_bbox(), 1.1);
947 if persp {
948 camera.toggle_projection();
949 }
950 let vp = camera.view_proj_flat();
951 let inv = camera.view_proj_inverse_flat();
952 let world = [1.25, -0.5, 2.0];
953 let clip = apply4(&vp, world[0], world[1], world[2]);
954 let back = apply4(&inv, clip[0], clip[1], clip[2]);
955 for k in 0..3 {
956 assert!((back[k] - world[k]).abs() < 1e-6, "persp={persp} roundtrip {back:?}");
957 }
958 let (sx, sy, _) = camera.project(world);
960 let ndc_x = (sx / camera.width) * 2.0 - 1.0;
961 let ndc_y = -((sy / camera.height) * 2.0 - 1.0);
962 let near = apply4(&inv, ndc_x, ndc_y, 0.0);
963 let far = apply4(&inv, ndc_x, ndc_y, 1.0);
964 let dir = norm3(sub3(far, near));
965 let rel = sub3(world, near);
966 let along = dot3(rel, dir);
967 let closest = add3(near, scale3(dir, along));
968 assert!(len3(sub3(world, closest)) < 1e-6, "persp={persp} ray miss");
969 }
970 }
971}