Skip to main content

brep_render/
view.rs

1//! The interactive viewer camera (R21/R25): orthographic default + perspective
2//! toggle with state-preserving switch, zoom-to-fit, dynamic depth-range fit,
3//! world-per-pixel and world→screen queries. Pure f64 math — shared verbatim by
4//! the wasm canvas shell and the winit desktop shell (dual-target directive).
5//!
6//! Screen coordinates throughout are CSS pixels with the origin at the canvas
7//! top-left, y down (what browser pointer events deliver); the DPR only matters at
8//! surface-size time, never in camera math (matching the retired viewer, whose
9//! thresholds were CSS-pixel based).
10
11use 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
49/// Rotate `v` around unit `axis` by `angle` (Rodrigues).
50pub 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
61/// Inverse of a column-major 4×4 matrix (index = `col*4 + row`
62/// layout). Returns `None` if singular. Used to invert the view-projection for
63/// the host overlays' screen→world path.
64pub 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/// The projection kind (R21): orthographic is the default; the toggle keeps the
109/// apparent size at the target plane.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum Projection {
112    /// `half_height` is half the vertical world span at the target plane.
113    Orthographic { half_height: f64 },
114    Perspective { fov_y_deg: f64 },
115}
116
117/// A world-space ray for picking.
118#[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    /// Viewport CSS size.
131    pub width: f64,
132    pub height: f64,
133    /// View-space depth window (positive distances along the view direction);
134    /// maintained by [`ViewCamera::fit_depth_range`]. Ortho near may go
135    /// negative (scene behind the eye plane is still projectable).
136    pub near: f64,
137    pub far: f64,
138}
139
140impl Default for ViewCamera {
141    fn default() -> Self {
142        // The retired viewer's startup vantage: eye (15,12,15) → origin, Y-up,
143        // ortho half-height 10 ("viewSize").
144        Self {
145            eye: [15.0, 12.0, 15.0],
146            target: [0.0, 0.0, 0.0],
147            up: [0.0, 1.0, 0.0],
148            projection: Projection::Orthographic { half_height: 10.0 },
149            width: 800.0,
150            height: 600.0,
151            near: -100000.0,
152            far: 100000.0,
153        }
154    }
155}
156
157impl ViewCamera {
158    pub fn aspect(&self) -> f64 {
159        (self.width / self.height.max(1.0)).max(1e-6)
160    }
161
162    /// Camera basis: (right, true-up, forward) with forward pointing INTO the
163    /// scene (eye → target).
164    pub fn basis(&self) -> ([f64; 3], [f64; 3], [f64; 3]) {
165        let forward = norm3(sub3(self.target, self.eye));
166        let right = norm3(cross3(forward, self.up));
167        let up = cross3(right, forward);
168        (right, up, forward)
169    }
170
171    pub fn distance(&self) -> f64 {
172        len3(sub3(self.eye, self.target)).max(1e-9)
173    }
174
175    /// World units per CSS pixel at the target plane (R21/R25 — the query the
176    /// pickers, gizmos and sketch glyph sizing key off).
177    pub fn world_per_pixel(&self) -> f64 {
178        match self.projection {
179            Projection::Orthographic { half_height } => 2.0 * half_height / self.height.max(1.0),
180            Projection::Perspective { fov_y_deg } => {
181                let fov = fov_y_deg.to_radians();
182                2.0 * (fov * 0.5).tan() * self.distance() / self.height.max(1.0)
183            }
184        }
185    }
186
187    /// The world→clip view-projection as column-major `[col][row]` in f64. This
188    /// is the exact matrix [`resolve`] feeds the GPU, kept in f64 so the
189    /// CSS-pixel projection the host overlays derive from it matches [`project`]
190    /// to sub-pixel precision. wgpu clip space: x,y in −1..1, z in 0..1.
191    pub fn view_proj_cols(&self) -> [[f64; 4]; 4] {
192        let (right, up, forward) = self.basis();
193        let half_h = match self.projection {
194            Projection::Orthographic { half_height } => half_height,
195            Projection::Perspective { fov_y_deg } => (fov_y_deg.to_radians() * 0.5).tan(),
196        };
197        let half_w = half_h * self.aspect();
198
199        // View matrix rows from the basis (world → view; view looks down -Z).
200        let ex = -dot3(right, self.eye);
201        let ey = -dot3(up, self.eye);
202        let ez = dot3(forward, self.eye);
203        let view = [
204            [right[0], up[0], -forward[0], 0.0],
205            [right[1], up[1], -forward[1], 0.0],
206            [right[2], up[2], -forward[2], 0.0],
207            [ex, ey, ez, 1.0],
208        ];
209
210        let proj = match self.projection {
211            Projection::Orthographic { .. } => {
212                // wgpu clip space: z in 0..1.
213                let sx = 1.0 / half_w;
214                let sy = 1.0 / half_h;
215                let sz = -1.0 / (self.far - self.near);
216                [
217                    [sx, 0.0, 0.0, 0.0],
218                    [0.0, sy, 0.0, 0.0],
219                    [0.0, 0.0, sz, 0.0],
220                    [0.0, 0.0, -self.near / (self.far - self.near), 1.0],
221                ]
222            }
223            Projection::Perspective { .. } => {
224                let near = self.near.max(1e-6);
225                let far = self.far.max(near * 1.0001);
226                let f = 1.0 / half_h;
227                [
228                    [f / self.aspect(), 0.0, 0.0, 0.0],
229                    [0.0, f, 0.0, 0.0],
230                    [0.0, 0.0, far / (near - far), -1.0],
231                    [0.0, 0.0, near * far / (near - far), 0.0],
232                ]
233            }
234        };
235
236        let mut view_proj = [[0.0f64; 4]; 4];
237        for col in 0..4 {
238            for row in 0..4 {
239                let mut sum = 0.0;
240                for k in 0..4 {
241                    sum += proj[k][row] * view[col][k];
242                }
243                view_proj[col][row] = sum;
244            }
245        }
246        view_proj
247    }
248
249    /// Resolve to the GPU camera (column-major view-proj, f32).
250    pub fn resolve(&self) -> Camera {
251        let cols = self.view_proj_cols();
252        let mut view_proj = [[0.0f32; 4]; 4];
253        for col in 0..4 {
254            for row in 0..4 {
255                view_proj[col][row] = cols[col][row] as f32;
256            }
257        }
258        let fwd = norm3(sub3(self.target, self.eye));
259        Camera {
260            view_proj,
261            forward: [fwd[0] as f32, fwd[1] as f32, fwd[2] as f32],
262        }
263    }
264
265    /// The view-projection flattened column-major (index = `col*4 + row`) — the
266    /// world→clip matrix for the host overlays'
267    /// per-frame world→screen hot path (dimensions + sketch), letting them drop
268    /// the compat mirror camera. Pair with the CSS `viewport` for NDC→pixel.
269    pub fn view_proj_flat(&self) -> [f64; 16] {
270        let cols = self.view_proj_cols();
271        let mut out = [0.0f64; 16];
272        for col in 0..4 {
273            for row in 0..4 {
274                out[col * 4 + row] = cols[col][row];
275            }
276        }
277        out
278    }
279
280    /// Inverse of [`view_proj_flat`] (clip→world), column-major, for the host
281    /// overlays' screen→world / screen→ray path. Falls back to the identity if
282    /// the matrix is singular (never in practice for a valid camera).
283    pub fn view_proj_inverse_flat(&self) -> [f64; 16] {
284        invert4_columns(&self.view_proj_flat())
285            .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])
286    }
287
288    /// Project a world point to CSS-pixel screen coordinates (origin top-left,
289    /// y down). Returns `(x, y, view_depth)`; `view_depth` is the distance
290    /// along the view direction (positive in front of the eye plane).
291    pub fn project(&self, world: [f64; 3]) -> (f64, f64, f64) {
292        let (right, up, forward) = self.basis();
293        let rel = sub3(world, self.eye);
294        let vx = dot3(rel, right);
295        let vy = dot3(rel, up);
296        let depth = dot3(rel, forward);
297        match self.projection {
298            Projection::Orthographic { half_height } => {
299                let half_w = half_height * self.aspect();
300                let sx = (vx / half_w * 0.5 + 0.5) * self.width;
301                let sy = (0.5 - vy / half_height * 0.5) * self.height;
302                (sx, sy, depth)
303            }
304            Projection::Perspective { fov_y_deg } => {
305                let half_h = (fov_y_deg.to_radians() * 0.5).tan();
306                let half_w = half_h * self.aspect();
307                let d = depth.max(1e-9);
308                let sx = (vx / (half_w * d) * 0.5 + 0.5) * self.width;
309                let sy = (0.5 - vy / (half_h * d) * 0.5) * self.height;
310                (sx, sy, depth)
311            }
312        }
313    }
314
315    /// A world-space picking ray through CSS-pixel `(x, y)`. Ortho rays start
316    /// far behind the eye plane so huge scenes are always in front (the retired
317    /// picker pushed its ray origin back the same way).
318    pub fn pick_ray(&self, x: f64, y: f64) -> Ray {
319        let (right, up, forward) = self.basis();
320        let ndc_x = (x / self.width.max(1.0)) * 2.0 - 1.0;
321        let ndc_y = -((y / self.height.max(1.0)) * 2.0 - 1.0);
322        match self.projection {
323            Projection::Orthographic { half_height } => {
324                let half_w = half_height * self.aspect();
325                let span = self.far.abs().max(self.near.abs()).max(half_height * 40.0).max(1.0);
326                let on_plane = add3(
327                    self.eye,
328                    add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_height)),
329                );
330                Ray {
331                    origin: sub3(on_plane, scale3(forward, span)),
332                    dir: forward,
333                }
334            }
335            Projection::Perspective { fov_y_deg } => {
336                let half_h = (fov_y_deg.to_radians() * 0.5).tan();
337                let half_w = half_h * self.aspect();
338                let dir = norm3(add3(
339                    forward,
340                    add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_h)),
341                ));
342                Ray {
343                    origin: self.eye,
344                    dir,
345                }
346            }
347        }
348    }
349
350    /// Fit the depth window to the scene (the `_updateDepthRange` port): the
351    /// whole bbox lands inside `[near, far]` with generous padding.
352    pub fn fit_depth_range(&mut self, bbox: &Aabb) {
353        if bbox.is_empty() {
354            return;
355        }
356        let (_, _, forward) = self.basis();
357        let mut min_d = f64::INFINITY;
358        let mut max_d = f64::NEG_INFINITY;
359        for i in 0..8 {
360            let corner = [
361                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
362                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
363                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
364            ];
365            let d = dot3(sub3(corner, self.eye), forward);
366            min_d = min_d.min(d);
367            max_d = max_d.max(d);
368        }
369        let diag = len3(sub3(bbox.max, bbox.min));
370        let pad = ((max_d - min_d) * 0.1).max(diag * 0.1).max(0.5);
371        match self.projection {
372            Projection::Orthographic { .. } => {
373                self.near = min_d - pad;
374                self.far = max_d + pad;
375            }
376            Projection::Perspective { .. } => {
377                let far = (max_d + pad).max(1.0);
378                self.near = (far * 0.001).clamp(1e-4, 1.0).min((min_d - pad).max(1e-4));
379                self.far = far;
380            }
381        }
382    }
383
384    /// Zoom-to-fit (R21): recenters the target on the bbox and scales the
385    /// frustum/distance so the whole bbox fits with `margin`, preserving the
386    /// view direction (the ArcballControls `focus` behavior).
387    pub fn zoom_to_fit(&mut self, bbox: &Aabb, margin: f64) {
388        if bbox.is_empty() {
389            return;
390        }
391        let margin = margin.max(1.0);
392        let (right, up, forward) = self.basis();
393        let center = bbox.center();
394        let mut half_w = 0.0f64;
395        let mut half_h = 0.0f64;
396        for i in 0..8 {
397            let corner = [
398                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
399                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
400                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
401            ];
402            let rel = sub3(corner, center);
403            half_w = half_w.max(dot3(rel, right).abs());
404            half_h = half_h.max(dot3(rel, up).abs());
405        }
406        half_w = (half_w * margin).max(1e-6);
407        half_h = (half_h * margin).max(1e-6);
408
409        let dist = self.distance();
410        let aspect = self.aspect();
411        self.target = center;
412        match self.projection {
413            Projection::Orthographic { ref mut half_height } => {
414                *half_height = half_h.max(half_w / aspect);
415                self.eye = sub3(center, scale3(forward, dist));
416            }
417            Projection::Perspective { fov_y_deg } => {
418                let fov = fov_y_deg.to_radians();
419                let dist_h = half_h / (fov * 0.5).tan().max(1e-6);
420                let tan_half_h_fov = (fov * 0.5).tan() * aspect;
421                let dist_w = half_w / tan_half_h_fov.max(1e-6);
422                let target_dist = dist_h.max(dist_w).max(1e-3);
423                self.eye = sub3(center, scale3(forward, target_dist));
424            }
425        }
426        self.fit_depth_range(bbox);
427    }
428
429    /// Toggle ortho ↔ perspective preserving the apparent size at the target
430    /// plane (the `toggleCameraProjection` port). Returns the new kind name.
431    pub fn toggle_projection(&mut self) -> &'static str {
432        const FOV: f64 = 50.0;
433        let forward = norm3(sub3(self.target, self.eye));
434        match self.projection {
435            Projection::Orthographic { half_height } => {
436                let denom = (FOV.to_radians() * 0.5).tan();
437                let mut distance = half_height / denom.max(1e-9);
438                if !distance.is_finite() || distance < 1e-4 {
439                    distance = 10.0;
440                }
441                self.eye = sub3(self.target, scale3(forward, distance));
442                self.projection = Projection::Perspective { fov_y_deg: FOV };
443                "perspective"
444            }
445            Projection::Perspective { fov_y_deg } => {
446                let dist = self.distance();
447                let half_height = ((fov_y_deg.to_radians() * 0.5).tan() * dist).max(1e-6);
448                self.projection = Projection::Orthographic { half_height };
449                "orthographic"
450            }
451        }
452    }
453
454    /// Snap to a standard view (future ViewCube seam), preserving distance and
455    /// frustum scale. Directions are world-axis views with sensible ups.
456    pub fn standard_view(&mut self, name: &str) -> bool {
457        let dist = self.distance();
458        let iso = norm3([1.0, 1.0, 1.0]);
459        let (dir, up): ([f64; 3], [f64; 3]) = match name.to_ascii_uppercase().as_str() {
460            "FRONT" => ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
461            "BACK" => ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
462            "RIGHT" => ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
463            "LEFT" => ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
464            "TOP" => ([0.0, 1.0, 0.0], [0.0, 0.0, -1.0]),
465            "BOTTOM" => ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
466            "ISO" => (iso, [0.0, 1.0, 0.0]),
467            _ => return false,
468        };
469        self.eye = add3(self.target, scale3(dir, dist));
470        self.up = up;
471        true
472    }
473
474    /// Serialize the full camera state (R3: the host holds plain JSON only).
475    pub fn state_json(&self) -> String {
476        let (kind, scale) = match self.projection {
477            Projection::Orthographic { half_height } => ("orthographic", half_height),
478            Projection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
479        };
480        serde_json::json!({
481            "kind": kind,
482            "eye": self.eye,
483            "target": self.target,
484            "up": self.up,
485            // half_height for ortho, fov_y_deg for perspective.
486            "scale": scale,
487            "near": self.near,
488            "far": self.far,
489            "width": self.width,
490            "height": self.height,
491            "worldPerPixel": self.world_per_pixel(),
492        })
493        .to_string()
494    }
495
496    /// Restore from [`ViewCamera::state_json`] output (viewport size is NOT
497    /// restored — it belongs to the canvas).
498    pub fn apply_state_json(&mut self, json: &str) -> Result<(), String> {
499        let value: serde_json::Value =
500            serde_json::from_str(json).map_err(|error| format!("camera state parse: {error}"))?;
501        let vec3 = |key: &str| -> Option<[f64; 3]> {
502            let arr = value.get(key)?.as_array()?;
503            Some([arr.first()?.as_f64()?, arr.get(1)?.as_f64()?, arr.get(2)?.as_f64()?])
504        };
505        if let Some(eye) = vec3("eye") {
506            self.eye = eye;
507        }
508        if let Some(target) = vec3("target") {
509            self.target = target;
510        }
511        if let Some(up) = vec3("up") {
512            self.up = up;
513        }
514        let scale = value.get("scale").and_then(|v| v.as_f64());
515        match value.get("kind").and_then(|v| v.as_str()) {
516            Some("perspective") => {
517                self.projection = Projection::Perspective {
518                    fov_y_deg: scale.unwrap_or(50.0),
519                }
520            }
521            Some("orthographic") => {
522                self.projection = Projection::Orthographic {
523                    half_height: scale.unwrap_or(10.0).max(1e-9),
524                }
525            }
526            _ => {}
527        }
528        if let Some(near) = value.get("near").and_then(|v| v.as_f64()) {
529            self.near = near;
530        }
531        if let Some(far) = value.get("far").and_then(|v| v.as_f64()) {
532            self.far = far;
533        }
534        Ok(())
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn unit_bbox() -> Aabb {
543        Aabb {
544            min: [-5.0, -5.0, -5.0],
545            max: [5.0, 5.0, 5.0],
546        }
547    }
548
549    #[test]
550    fn camera_state_roundtrip() {
551        let mut camera = ViewCamera::default();
552        camera.eye = [3.0, 4.0, 5.0];
553        camera.target = [1.0, 1.0, 1.0];
554        camera.projection = Projection::Orthographic { half_height: 7.25 };
555        let json = camera.state_json();
556        let mut restored = ViewCamera::default();
557        restored.apply_state_json(&json).unwrap();
558        assert_eq!(restored.eye, camera.eye);
559        assert_eq!(restored.target, camera.target);
560        assert_eq!(restored.projection, camera.projection);
561    }
562
563    #[test]
564    fn projection_toggle_preserves_apparent_size() {
565        let mut camera = ViewCamera {
566            width: 800.0,
567            height: 600.0,
568            ..ViewCamera::default()
569        };
570        camera.zoom_to_fit(&unit_bbox(), 1.1);
571        let wpp_ortho = camera.world_per_pixel();
572        assert_eq!(camera.toggle_projection(), "perspective");
573        let wpp_persp = camera.world_per_pixel();
574        assert!(
575            (wpp_ortho - wpp_persp).abs() < wpp_ortho * 1e-9,
576            "wpp {wpp_ortho} vs {wpp_persp}"
577        );
578        assert_eq!(camera.toggle_projection(), "orthographic");
579        let wpp_back = camera.world_per_pixel();
580        assert!((wpp_ortho - wpp_back).abs() < wpp_ortho * 1e-9);
581    }
582
583    #[test]
584    fn zoom_to_fit_centers_and_contains_bbox() {
585        let bbox = Aabb {
586            min: [10.0, -2.0, 3.0],
587            max: [16.0, 6.0, 9.0],
588        };
589        let mut camera = ViewCamera {
590            width: 640.0,
591            height: 480.0,
592            ..ViewCamera::default()
593        };
594        camera.zoom_to_fit(&bbox, 1.1);
595        let center = bbox.center();
596        let (sx, sy, depth) = camera.project(center);
597        assert!((sx - 320.0).abs() < 1e-6, "sx {sx}");
598        assert!((sy - 240.0).abs() < 1e-6, "sy {sy}");
599        assert!(depth > 0.0);
600        for i in 0..8 {
601            let corner = [
602                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
603                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
604                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
605            ];
606            let (sx, sy, _) = camera.project(corner);
607            assert!((-1.0..=641.0).contains(&sx), "corner sx {sx}");
608            assert!((-1.0..=481.0).contains(&sy), "corner sy {sy}");
609        }
610    }
611
612    #[test]
613    fn project_and_pick_ray_are_consistent() {
614        let mut camera = ViewCamera::default();
615        camera.zoom_to_fit(&unit_bbox(), 1.1);
616        let world = [1.25, -0.5, 2.0];
617        let (sx, sy, _) = camera.project(world);
618        let ray = camera.pick_ray(sx, sy);
619        // The ray must pass within numerical tolerance of the world point.
620        let rel = sub3(world, ray.origin);
621        let along = dot3(rel, ray.dir);
622        let closest = add3(ray.origin, scale3(ray.dir, along));
623        assert!(len3(sub3(world, closest)) < 1e-9);
624    }
625
626    #[test]
627    fn depth_range_contains_scene() {
628        let mut camera = ViewCamera::default();
629        let bbox = unit_bbox();
630        camera.fit_depth_range(&bbox);
631        let (_, _, forward) = camera.basis();
632        for i in 0..8 {
633            let corner = [
634                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
635                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
636                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
637            ];
638            let d = dot3(sub3(corner, camera.eye), forward);
639            assert!(d >= camera.near && d <= camera.far);
640        }
641    }
642
643    #[test]
644    fn standard_views_look_at_target() {
645        let mut camera = ViewCamera::default();
646        camera.target = [2.0, 3.0, 4.0];
647        let dist = camera.distance();
648        for name in ["FRONT", "BACK", "LEFT", "RIGHT", "TOP", "BOTTOM", "ISO"] {
649            assert!(camera.standard_view(name), "{name}");
650            assert!((camera.distance() - dist).abs() < 1e-9);
651        }
652        assert!(!camera.standard_view("DIAGONAL"));
653    }
654
655    /// Apply a column-major 4×4 (index = `col*4+row`) to a point with the
656    /// perspective divide — the exact math the host overlays run.
657    fn apply4(m: &[f64; 16], x: f64, y: f64, z: f64) -> [f64; 3] {
658        let w = 1.0 / (m[3] * x + m[7] * y + m[11] * z + m[15]);
659        [
660            (m[0] * x + m[4] * y + m[8] * z + m[12]) * w,
661            (m[1] * x + m[5] * y + m[9] * z + m[13]) * w,
662            (m[2] * x + m[6] * y + m[10] * z + m[14]) * w,
663        ]
664    }
665
666    /// The CSS-pixel projection the host overlays build from `view_proj_flat`
667    /// (NDC→pixel with the same y-down convention) must match `project` — this
668    /// is what lets dimensions/sketch drop the mirror camera without drift.
669    #[test]
670    fn view_proj_flat_matches_project() {
671        for persp in [false, true] {
672            let mut camera = ViewCamera { width: 800.0, height: 600.0, ..ViewCamera::default() };
673            camera.zoom_to_fit(&unit_bbox(), 1.1);
674            if persp {
675                camera.toggle_projection();
676            }
677            let vp = camera.view_proj_flat();
678            for world in [[1.25, -0.5, 2.0], [-3.0, 4.0, -1.5], [0.0, 0.0, 0.0]] {
679                let clip = apply4(&vp, world[0], world[1], world[2]);
680                let sx = (clip[0] * 0.5 + 0.5) * camera.width;
681                let sy = (0.5 - clip[1] * 0.5) * camera.height;
682                let (px, py, _) = camera.project(world);
683                assert!((sx - px).abs() < 1e-6, "persp={persp} sx {sx} vs {px}");
684                assert!((sy - py).abs() < 1e-6, "persp={persp} sy {sy} vs {py}");
685            }
686        }
687    }
688
689    /// `view_proj_inverse_flat` must invert `view_proj_flat`, and unprojecting a
690    /// screen point at two clip depths must yield a ray hitting the world point
691    /// (the sketch screen→ray path).
692    #[test]
693    fn view_proj_inverse_round_trips_and_rays() {
694        for persp in [false, true] {
695            let mut camera = ViewCamera { width: 640.0, height: 480.0, ..ViewCamera::default() };
696            camera.zoom_to_fit(&unit_bbox(), 1.1);
697            if persp {
698                camera.toggle_projection();
699            }
700            let vp = camera.view_proj_flat();
701            let inv = camera.view_proj_inverse_flat();
702            let world = [1.25, -0.5, 2.0];
703            let clip = apply4(&vp, world[0], world[1], world[2]);
704            let back = apply4(&inv, clip[0], clip[1], clip[2]);
705            for k in 0..3 {
706                assert!((back[k] - world[k]).abs() < 1e-6, "persp={persp} roundtrip {back:?}");
707            }
708            // screen→ray: unproject NDC at wgpu near (z=0) and far (z=1).
709            let (sx, sy, _) = camera.project(world);
710            let ndc_x = (sx / camera.width) * 2.0 - 1.0;
711            let ndc_y = -((sy / camera.height) * 2.0 - 1.0);
712            let near = apply4(&inv, ndc_x, ndc_y, 0.0);
713            let far = apply4(&inv, ndc_x, ndc_y, 1.0);
714            let dir = norm3(sub3(far, near));
715            let rel = sub3(world, near);
716            let along = dot3(rel, dir);
717            let closest = add3(near, scale3(dir, along));
718            assert!(len3(sub3(world, closest)) < 1e-6, "persp={persp} ray miss");
719        }
720    }
721}