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    ///
137    /// DEPTH-BUFFER WINDOW ONLY. `near`/`far` exist to map the GPU depth buffer
138    /// over everything drawn (re-fitted each frame from the render path's
139    /// depth bbox = `depth_range_bbox` ∪ the full widget overlay's world bounds
140    /// ∪ the world origin, so construction geometry — datums / axes / gizmos —
141    /// is always bracketed) — they are NEVER a visibility decision. No label, anchor, chip, or hit-region may consult them: the
142    /// ONE screen-visibility rule for all of those is
143    /// [`ViewCamera::projectable`]. Anything gating on `near`/`far` (or a bare
144    /// `depth > 0` in ortho) is a bug — labels would vanish while their
145    /// geometry still renders.
146    pub near: f64,
147    pub far: f64,
148}
149
150impl Default for ViewCamera {
151    fn default() -> Self {
152        // The retired viewer's startup vantage: eye (15,12,15) → origin, Y-up,
153        // ortho half-height 10 ("viewSize").
154        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    /// Camera basis: (right, true-up, forward) with forward pointing INTO the
173    /// scene (eye → target).
174    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    /// World units per CSS pixel at the target plane (R21/R25 — the query the
186    /// pickers, gizmos and sketch glyph sizing key off).
187    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    /// The view-space depth of a world point: the signed distance along the
198    /// forward view axis from the eye (identical to `project`'s third return).
199    /// Positive in front of the eye plane. The screen-space region builder
200    /// ([`brep_gizmos::hit_region`]) keys the perspective front-clip off this.
201    pub fn view_depth(&self, world: [f64; 3]) -> f64 {
202        let (_, _, forward) = self.basis();
203        dot3(sub3(world, self.eye), forward)
204    }
205
206    /// Whether a world point is PROJECTABLE to a usable screen position — THE
207    /// screen-visibility policy for every label / anchor / chip / hit-region
208    /// consumer, in ONE place so no consumer can re-invent a depth cull:
209    ///
210    /// * ORTHOGRAPHIC (the app default): always `true`. Behind-eye-plane
211    ///   geometry still renders in ortho, so its labels must too.
212    /// * PERSPECTIVE: `false` only for a point at/behind the eye plane, where
213    ///   the projection itself is mathematically undefined — the same
214    ///   `FRONT_EPS` rule the region builder ([`brep_gizmos::hit_region`])
215    ///   applies.
216    ///
217    /// The `near`/`far` fields NEVER factor in — they are the GPU depth-buffer
218    /// window (see their field doc), not visibility. Route ANY new "should this
219    /// world-anchored UI draw?" question through here.
220    pub fn projectable(&self, world: [f64; 3]) -> bool {
221        matches!(self.projection, Projection::Orthographic { .. })
222            || self.view_depth(world) > 1e-6
223    }
224
225    /// Whether a world-anchored TEXT LABEL should draw: [`Self::projectable`]
226    /// AND the anchor projects INSIDE the viewport rect. The second half is a
227    /// screen-BOUNDS test, never a depth test — `near`/`far` still cull nothing
228    /// (see [`Self::projectable`]) — so a chip whose 3D anchor scrolled out of
229    /// view disappears instead of piling up clamped at the viewport edge (egui
230    /// Areas constrain themselves on-screen). This is the `inFront` flag every
231    /// app label pass keys its skip off (`world_to_screen_json`); hit-testable
232    /// ANCHORS (gizmo handles, hit regions) intentionally stay on the pure
233    /// `projectable` policy — an off-screen handle just can't be clicked.
234    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    /// The world→clip view-projection as column-major `[col][row]` in f64. This
243    /// is the exact matrix [`resolve`] feeds the GPU, kept in f64 so the
244    /// CSS-pixel projection the host overlays derive from it matches [`project`]
245    /// to sub-pixel precision. wgpu clip space: x,y in −1..1, z in 0..1.
246    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        // View matrix rows from the basis (world → view; view looks down -Z).
255        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                // wgpu clip space: z in 0..1.
268                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                // Finite-far wgpu perspective (z in 0..1, forward-Z: near→0,
280                // far→1). TODO(depth): an INFINITE-FAR limit (col2 → [0,0,-1,-1],
281                // col3 → [0,0,-near,0]) would stop anything clipping at `far` in
282                // perspective, but is DEFERRED: the screen→ray unproject in
283                // `GizmoCamera::ray_from_screen` (datum pick, transform drag,
284                // ViewCube — all in `brep-gizmos`) reconstructs rays from NDC
285                // z=0 AND z=1, and at z=1 the infinite-far inverse's w passes
286                // through zero as the camera orbits → intermittently backward
287                // pick rays. It is also redundant now: the render path folds the
288                // FULL overlay (+origin) into the depth fit, so construction
289                // geometry is bracketed regardless. Revisit alongside a
290                // reversed-Z depth precision pass (would fix the unproject too).
291                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    /// Resolve to the GPU camera (column-major view-proj, f32).
317    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    /// The view-projection flattened column-major (index = `col*4 + row`) — the
333    /// world→clip matrix for the host overlays'
334    /// per-frame world→screen hot path (dimensions + sketch), letting them drop
335    /// the compat mirror camera. Pair with the CSS `viewport` for NDC→pixel.
336    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    /// Inverse of [`view_proj_flat`] (clip→world), column-major, for the host
348    /// overlays' screen→world / screen→ray path. Falls back to the identity if
349    /// the matrix is singular (never in practice for a valid camera).
350    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    /// Project a world point to CSS-pixel screen coordinates (origin top-left,
356    /// y down). Returns `(x, y, view_depth)`; `view_depth` is the distance
357    /// along the view direction (positive in front of the eye plane).
358    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    /// A world-space picking ray through CSS-pixel `(x, y)`. Ortho rays start
383    /// far behind the eye plane so huge scenes are always in front (the retired
384    /// picker pushed its ray origin back the same way).
385    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    /// Fit the depth window to the scene (the `_updateDepthRange` port): the
418    /// whole bbox lands inside `[near, far]` with generous padding.
419    pub fn fit_depth_range(&mut self, bbox: &Aabb) {
420        if bbox.is_empty() {
421            // An EMPTY input (no solids, no overlay geometry) must NOT ride the
422            // stale near/far from an earlier populated frame — that stale-tight
423            // window would CLIP a newly-shown construction-only scene (datum
424            // planes / world axes / gizmos). Reset to the SAME generous window
425            // the camera constructs with (see `Default`: ortho ±100000;
426            // perspective a sane 0.1 / 1e5) so an empty scene never clips. This
427            // is a depth-WINDOW choice only — near/far are the depth-buffer
428            // range, never a visibility decision (see their field doc).
429            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    /// Zoom-to-fit (R21): recenters the target on the bbox and scales the
470    /// frustum/distance so the whole bbox fits with `margin`, preserving the
471    /// view direction (the ArcballControls `focus` behavior).
472    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    /// Toggle ortho ↔ perspective preserving the apparent size at the target
515    /// plane (the `toggleCameraProjection` port). Returns the new kind name.
516    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    /// Snap to a standard view (future ViewCube seam), preserving distance and
540    /// frustum scale. Directions are world-axis views with sensible ups.
541    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    /// Serialize the full camera state (R3: the host holds plain JSON only).
560    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            // half_height for ortho, fov_y_deg for perspective.
571            "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    /// Restore from [`ViewCamera::state_json`] output (viewport size is NOT
582    /// restored — it belongs to the canvas).
583    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
623/// The render camera projects dimension-gizmo handle points to viewport-local px
624/// for the shared screen-space region builder, so a gizmo's hit-test + its debug
625/// outline share ONE projection (see [`brep_gizmos::hit_region`]).
626impl 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        // ONE policy: [`ViewCamera::projectable`] (ortho always projects;
635        // perspective omits only at/behind the eye plane).
636        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    /// THE near/far regression fence: `projectable` — the one screen-visibility
656    /// policy every label/anchor/chip flag derives from — must NEVER cull by the
657    /// depth window. Ortho projects EVERYTHING (behind the eye plane, beyond
658    /// `far`, before `near` — ortho renders all of it); perspective refuses only
659    /// at/behind the eye plane, no matter how tight `near`/`far` are. If this
660    /// test breaks, labels are vanishing while their geometry still renders.
661    #[test]
662    fn projectable_ignores_near_far_and_ortho_never_culls() {
663        let mut camera = ViewCamera::default(); // ortho, eye (15,12,15) → origin
664        // A hostile depth window: nothing may consult it.
665        camera.near = 0.5;
666        camera.far = 1.0;
667
668        // Ortho: in front, far behind the eye plane, and light-years out — all
669        // projectable (and `project` yields finite coords for each).
670        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        // Perspective: the SAME hostile near/far still never cull — only the
680        // eye plane does (projection is undefined at/behind it).
681        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        // And the RegionCamera view agrees (hit outlines share the policy).
692        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    /// The label layer on top of `projectable`: a chip draws ONLY when its 3D
698    /// anchor projects inside the viewport — an off-screen anchor hides its
699    /// label (instead of the egui Area clamping it to the edge) — while depth /
700    /// near / far still cull nothing (an ortho behind-eye anchor that lands
701    /// on-viewport keeps its label, matching its still-rendered geometry).
702    #[test]
703    fn label_anchor_visible_requires_on_viewport_projection() {
704        let mut camera = ViewCamera::default(); // ortho 800×600, eye → origin
705        let (right, _, fwd) = camera.basis();
706
707        // The target projects to the viewport center → label shown.
708        assert!(camera.label_anchor_visible(camera.target));
709        // Way off to the side (world units ≫ the ortho half-width) → the anchor
710        // projects outside the viewport → label hidden, even though the point
711        // is perfectly projectable.
712        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        // Ortho behind the eye plane but projecting on-viewport → label SHOWN
716        // (its geometry renders; the depth policy never culls).
717        let behind_on_screen = sub3(camera.target, scale3(fwd, 500.0));
718        assert!(camera.label_anchor_visible(behind_on_screen));
719        // Perspective behind the eye stays hidden (not projectable at all).
720        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        // The ray must pass within numerical tolerance of the world point.
796        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    /// The staleness reset (the construction-clipping root cause): after fitting
820    /// to a small non-empty scene — which TIGHTENS near/far well inside the
821    /// default — fitting to an EMPTY scene must RESET to the generous default,
822    /// never ride the stale-tight window (that stale window is exactly what
823    /// clipped a newly-shown datum-only scene). Both projections.
824    #[test]
825    fn fit_depth_range_empty_resets_to_generous_default_not_stale() {
826        // A ±1 box tightens the window far inside the ±100000 default.
827        let small = Aabb {
828            min: [-1.0, -1.0, -1.0],
829            max: [1.0, 1.0, 1.0],
830        };
831        // Ortho: a small box tightens the window…
832        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        // …then an EMPTY scene resets to the generous ortho default, NOT the
841        // stale-tight values (which would clip construction geometry).
842        camera.fit_depth_range(&Aabb::empty());
843        assert_eq!(camera.near, -100000.0);
844        assert_eq!(camera.far, 100000.0);
845
846        // Perspective: same policy, the sane perspective default.
847        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    /// A datum-only / overlay-only scene (no solids) still gets a depth window
859    /// that brackets it: fitting to an overlay bbox spanning the origin encloses
860    /// the origin and every corner between near and far, so construction
861    /// geometry never clips at the projection stage.
862    #[test]
863    fn fit_depth_range_brackets_overlay_only_bbox() {
864        let mut camera = ViewCamera::default();
865        // A world-sized datum plane spanning ±50 about the origin — the scene has
866        // no solids, so this overlay bbox is the ONLY depth-fit input.
867        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        // The origin and every corner sit inside [near, far].
874        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    /// Apply a column-major 4×4 (index = `col*4+row`) to a point with the
906    /// perspective divide — the exact math the host overlays run.
907    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    /// The CSS-pixel projection the host overlays build from `view_proj_flat`
917    /// (NDC→pixel with the same y-down convention) must match `project` — this
918    /// is what lets dimensions/sketch drop the mirror camera without drift.
919    #[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    /// `view_proj_inverse_flat` must invert `view_proj_flat`, and unprojecting a
940    /// screen point at two clip depths must yield a ray hitting the world point
941    /// (the sketch screen→ray path).
942    #[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            // screen→ray: unproject NDC at wgpu near (z=0) and far (z=1).
959            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}