Skip to main content

brep_render/
view.rs

1//! Interactive orthographic and perspective cameras with zoom-to-fit,
2//! depth-range fitting, and world/screen conversion shared by native and web.
3//!
4//! Screen coordinates are CSS pixels: origin at the top left, y increasing down.
5//! Device pixel ratio affects surface sizing, not camera math.
6
7use crate::camera::{Aabb, Camera};
8
9pub use crate::geometry3d::{add3, cross3, dot3, len3, norm3, rotate3, scale3, sub3};
10
11/// Inverse of a column-major 4×4 matrix (index = `col*4 + row`
12/// layout). Returns `None` if singular. Used to invert the view-projection for
13/// the host overlays' screen→world path.
14pub fn invert4_columns(m: &[f64; 16]) -> Option<[f64; 16]> {
15    let a00 = m[0]; let a01 = m[1]; let a02 = m[2]; let a03 = m[3];
16    let a10 = m[4]; let a11 = m[5]; let a12 = m[6]; let a13 = m[7];
17    let a20 = m[8]; let a21 = m[9]; let a22 = m[10]; let a23 = m[11];
18    let a30 = m[12]; let a31 = m[13]; let a32 = m[14]; let a33 = m[15];
19
20    let b00 = a00 * a11 - a01 * a10;
21    let b01 = a00 * a12 - a02 * a10;
22    let b02 = a00 * a13 - a03 * a10;
23    let b03 = a01 * a12 - a02 * a11;
24    let b04 = a01 * a13 - a03 * a11;
25    let b05 = a02 * a13 - a03 * a12;
26    let b06 = a20 * a31 - a21 * a30;
27    let b07 = a20 * a32 - a22 * a30;
28    let b08 = a20 * a33 - a23 * a30;
29    let b09 = a21 * a32 - a22 * a31;
30    let b10 = a21 * a33 - a23 * a31;
31    let b11 = a22 * a33 - a23 * a32;
32
33    let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
34    if det.abs() < 1e-300 {
35        return None;
36    }
37    let inv = 1.0 / det;
38    Some([
39        (a11 * b11 - a12 * b10 + a13 * b09) * inv,
40        (a02 * b10 - a01 * b11 - a03 * b09) * inv,
41        (a31 * b05 - a32 * b04 + a33 * b03) * inv,
42        (a22 * b04 - a21 * b05 - a23 * b03) * inv,
43        (a12 * b08 - a10 * b11 - a13 * b07) * inv,
44        (a00 * b11 - a02 * b08 + a03 * b07) * inv,
45        (a32 * b02 - a30 * b05 - a33 * b01) * inv,
46        (a20 * b05 - a22 * b02 + a23 * b01) * inv,
47        (a10 * b10 - a11 * b08 + a13 * b06) * inv,
48        (a01 * b08 - a00 * b10 - a03 * b06) * inv,
49        (a30 * b04 - a31 * b02 + a33 * b00) * inv,
50        (a21 * b02 - a20 * b04 - a23 * b00) * inv,
51        (a11 * b07 - a10 * b09 - a12 * b06) * inv,
52        (a00 * b09 - a01 * b07 + a02 * b06) * inv,
53        (a31 * b01 - a30 * b03 - a32 * b00) * inv,
54        (a20 * b03 - a21 * b01 + a22 * b00) * inv,
55    ])
56}
57
58/// The projection kind (R21): orthographic is the default; the toggle keeps the
59/// apparent size at the target plane.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub enum Projection {
62    /// `half_height` is half the vertical world span at the target plane.
63    Orthographic { half_height: f64 },
64    Perspective { fov_y_deg: f64 },
65}
66
67/// A world-space ray for picking.
68#[derive(Debug, Clone, Copy)]
69pub struct Ray {
70    pub origin: [f64; 3],
71    pub dir: [f64; 3],
72}
73
74#[derive(Debug, Clone)]
75pub struct ViewCamera {
76    pub eye: [f64; 3],
77    pub target: [f64; 3],
78    pub up: [f64; 3],
79    pub projection: Projection,
80    /// Viewport CSS size.
81    pub width: f64,
82    pub height: f64,
83    /// View-space depth window (positive distances along the view direction);
84    /// maintained by [`ViewCamera::fit_depth_range`]. Ortho near may go
85    /// negative (scene behind the eye plane is still projectable).
86    ///
87    /// DEPTH-BUFFER WINDOW ONLY. `near`/`far` exist to map the GPU depth buffer
88    /// over everything drawn (re-fitted each frame from the render path's
89    /// depth bbox = `depth_range_bbox` ∪ the full widget overlay's world bounds
90    /// ∪ the world origin, so construction geometry — datums / axes / gizmos —
91    /// is always bracketed) — they are NEVER a visibility decision. No label, anchor, chip, or hit-region may consult them: the
92    /// ONE screen-visibility rule for all of those is
93    /// [`ViewCamera::projectable`]. Anything gating on `near`/`far` (or a bare
94    /// `depth > 0` in ortho) is a bug — labels would vanish while their
95    /// geometry still renders.
96    pub near: f64,
97    pub far: f64,
98}
99
100impl Default for ViewCamera {
101    fn default() -> Self {
102        // The retired viewer's startup vantage: eye (15,12,15) → origin, Y-up,
103        // ortho half-height 10 ("viewSize").
104        Self {
105            eye: [15.0, 12.0, 15.0],
106            target: [0.0, 0.0, 0.0],
107            up: [0.0, 1.0, 0.0],
108            projection: Projection::Orthographic { half_height: 10.0 },
109            width: 800.0,
110            height: 600.0,
111            near: -100000.0,
112            far: 100000.0,
113        }
114    }
115}
116
117impl ViewCamera {
118    pub fn aspect(&self) -> f64 {
119        (self.width / self.height.max(1.0)).max(1e-6)
120    }
121
122    /// Camera basis: (right, true-up, forward) with forward pointing INTO the
123    /// scene (eye → target).
124    pub fn basis(&self) -> ([f64; 3], [f64; 3], [f64; 3]) {
125        let forward = norm3(sub3(self.target, self.eye));
126        let right = norm3(cross3(forward, self.up));
127        let up = cross3(right, forward);
128        (right, up, forward)
129    }
130
131    pub fn distance(&self) -> f64 {
132        len3(sub3(self.eye, self.target)).max(1e-9)
133    }
134
135    /// World units per CSS pixel at the target plane (R21/R25 — the query the
136    /// pickers, gizmos and sketch glyph sizing key off).
137    pub fn world_per_pixel(&self) -> f64 {
138        match self.projection {
139            Projection::Orthographic { half_height } => 2.0 * half_height / self.height.max(1.0),
140            Projection::Perspective { fov_y_deg } => {
141                let fov = fov_y_deg.to_radians();
142                2.0 * (fov * 0.5).tan() * self.distance() / self.height.max(1.0)
143            }
144        }
145    }
146
147    /// The view-space depth of a world point: the signed distance along the
148    /// forward view axis from the eye (identical to `project`'s third return).
149    /// Positive in front of the eye plane. The screen-space region builder
150    /// ([`brep_gizmos::hit_region`]) keys the perspective front-clip off this.
151    pub fn view_depth(&self, world: [f64; 3]) -> f64 {
152        let (_, _, forward) = self.basis();
153        dot3(sub3(world, self.eye), forward)
154    }
155
156    /// Whether a world point is PROJECTABLE to a usable screen position — THE
157    /// screen-visibility policy for every label / anchor / chip / hit-region
158    /// consumer, in ONE place so no consumer can re-invent a depth cull:
159    ///
160    /// * ORTHOGRAPHIC (the app default): always `true`. Behind-eye-plane
161    ///   geometry still renders in ortho, so its labels must too.
162    /// * PERSPECTIVE: `false` only for a point at/behind the eye plane, where
163    ///   the projection itself is mathematically undefined — the same
164    ///   `FRONT_EPS` rule the region builder ([`brep_gizmos::hit_region`])
165    ///   applies.
166    ///
167    /// The `near`/`far` fields NEVER factor in — they are the GPU depth-buffer
168    /// window (see their field doc), not visibility. Route ANY new "should this
169    /// world-anchored UI draw?" question through here.
170    pub fn projectable(&self, world: [f64; 3]) -> bool {
171        matches!(self.projection, Projection::Orthographic { .. })
172            || self.view_depth(world) > 1e-6
173    }
174
175    /// Whether a world-anchored TEXT LABEL should draw: [`Self::projectable`]
176    /// AND the anchor projects INSIDE the viewport rect. The second half is a
177    /// screen-BOUNDS test, never a depth test — `near`/`far` still cull nothing
178    /// (see [`Self::projectable`]) — so a chip whose 3D anchor scrolled out of
179    /// view disappears instead of piling up clamped at the viewport edge (egui
180    /// Areas constrain themselves on-screen). This is the `inFront` flag every
181    /// app label pass keys its skip off (`world_to_screen_json`); hit-testable
182    /// ANCHORS (gizmo handles, hit regions) intentionally stay on the pure
183    /// `projectable` policy — an off-screen handle just can't be clicked.
184    pub fn label_anchor_visible(&self, world: [f64; 3]) -> bool {
185        if !self.projectable(world) {
186            return false;
187        }
188        let (sx, sy, _) = self.project(world);
189        sx >= 0.0 && sx <= self.width && sy >= 0.0 && sy <= self.height
190    }
191
192    /// The world→clip view-projection as column-major `[col][row]` in f64. This
193    /// is the exact matrix [`resolve`] feeds the GPU, kept in f64 so the
194    /// CSS-pixel projection the host overlays derive from it matches [`project`]
195    /// to sub-pixel precision. wgpu clip space: x,y in −1..1, z in 0..1.
196    pub fn view_proj_cols(&self) -> [[f64; 4]; 4] {
197        let (right, up, forward) = self.basis();
198        let half_h = match self.projection {
199            Projection::Orthographic { half_height } => half_height,
200            Projection::Perspective { fov_y_deg } => (fov_y_deg.to_radians() * 0.5).tan(),
201        };
202        let half_w = half_h * self.aspect();
203
204        // View matrix rows from the basis (world → view; view looks down -Z).
205        let ex = -dot3(right, self.eye);
206        let ey = -dot3(up, self.eye);
207        let ez = dot3(forward, self.eye);
208        let view = [
209            [right[0], up[0], -forward[0], 0.0],
210            [right[1], up[1], -forward[1], 0.0],
211            [right[2], up[2], -forward[2], 0.0],
212            [ex, ey, ez, 1.0],
213        ];
214
215        let proj = match self.projection {
216            Projection::Orthographic { .. } => {
217                // wgpu clip space: z in 0..1.
218                let sx = 1.0 / half_w;
219                let sy = 1.0 / half_h;
220                let sz = -1.0 / (self.far - self.near);
221                [
222                    [sx, 0.0, 0.0, 0.0],
223                    [0.0, sy, 0.0, 0.0],
224                    [0.0, 0.0, sz, 0.0],
225                    [0.0, 0.0, -self.near / (self.far - self.near), 1.0],
226                ]
227            }
228            Projection::Perspective { .. } => {
229                // Finite-far wgpu perspective (z in 0..1, forward-Z: near→0,
230                // far→1). TODO(depth): an INFINITE-FAR limit (col2 → [0,0,-1,-1],
231                // col3 → [0,0,-near,0]) would stop anything clipping at `far` in
232                // perspective, but is DEFERRED: the screen→ray unproject in
233                // `GizmoCamera::ray_from_screen` (datum pick, transform drag,
234                // ViewCube — all in `brep-gizmos`) reconstructs rays from NDC
235                // z=0 AND z=1, and at z=1 the infinite-far inverse's w passes
236                // through zero as the camera orbits → intermittently backward
237                // pick rays. It is also redundant now: the render path folds the
238                // FULL overlay (+origin) into the depth fit, so construction
239                // geometry is bracketed regardless. Revisit alongside a
240                // reversed-Z depth precision pass (would fix the unproject too).
241                let near = self.near.max(1e-6);
242                let far = self.far.max(near * 1.0001);
243                let f = 1.0 / half_h;
244                [
245                    [f / self.aspect(), 0.0, 0.0, 0.0],
246                    [0.0, f, 0.0, 0.0],
247                    [0.0, 0.0, far / (near - far), -1.0],
248                    [0.0, 0.0, near * far / (near - far), 0.0],
249                ]
250            }
251        };
252
253        let mut view_proj = [[0.0f64; 4]; 4];
254        for col in 0..4 {
255            for row in 0..4 {
256                let mut sum = 0.0;
257                for k in 0..4 {
258                    sum += proj[k][row] * view[col][k];
259                }
260                view_proj[col][row] = sum;
261            }
262        }
263        view_proj
264    }
265
266    /// Resolve to the GPU camera (column-major view-proj, f32).
267    pub fn resolve(&self) -> Camera {
268        let cols = self.view_proj_cols();
269        let mut view_proj = [[0.0f32; 4]; 4];
270        for col in 0..4 {
271            for row in 0..4 {
272                view_proj[col][row] = cols[col][row] as f32;
273            }
274        }
275        let fwd = norm3(sub3(self.target, self.eye));
276        Camera {
277            view_proj,
278            forward: [fwd[0] as f32, fwd[1] as f32, fwd[2] as f32],
279        }
280    }
281
282    /// The view-projection flattened column-major (index = `col*4 + row`) — the
283    /// world→clip matrix for the host overlays'
284    /// per-frame world→screen hot path (dimensions + sketch), letting them drop
285    /// the compat mirror camera. Pair with the CSS `viewport` for NDC→pixel.
286    pub fn view_proj_flat(&self) -> [f64; 16] {
287        let cols = self.view_proj_cols();
288        let mut out = [0.0f64; 16];
289        for col in 0..4 {
290            for row in 0..4 {
291                out[col * 4 + row] = cols[col][row];
292            }
293        }
294        out
295    }
296
297    /// Inverse of [`view_proj_flat`] (clip→world), column-major, for the host
298    /// overlays' screen→world / screen→ray path. Falls back to the identity if
299    /// the matrix is singular (never in practice for a valid camera).
300    pub fn view_proj_inverse_flat(&self) -> [f64; 16] {
301        invert4_columns(&self.view_proj_flat())
302            .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])
303    }
304
305    /// Project a world point to CSS-pixel screen coordinates (origin top-left,
306    /// y down). Returns `(x, y, view_depth)`; `view_depth` is the distance
307    /// along the view direction (positive in front of the eye plane).
308    pub fn project(&self, world: [f64; 3]) -> (f64, f64, f64) {
309        let (right, up, forward) = self.basis();
310        let rel = sub3(world, self.eye);
311        let vx = dot3(rel, right);
312        let vy = dot3(rel, up);
313        let depth = dot3(rel, forward);
314        match self.projection {
315            Projection::Orthographic { half_height } => {
316                let half_w = half_height * self.aspect();
317                let sx = (vx / half_w * 0.5 + 0.5) * self.width;
318                let sy = (0.5 - vy / half_height * 0.5) * self.height;
319                (sx, sy, depth)
320            }
321            Projection::Perspective { fov_y_deg } => {
322                let half_h = (fov_y_deg.to_radians() * 0.5).tan();
323                let half_w = half_h * self.aspect();
324                let d = depth.max(1e-9);
325                let sx = (vx / (half_w * d) * 0.5 + 0.5) * self.width;
326                let sy = (0.5 - vy / (half_h * d) * 0.5) * self.height;
327                (sx, sy, depth)
328            }
329        }
330    }
331
332    /// A world-space picking ray through CSS-pixel `(x, y)`. Ortho rays start
333    /// far behind the eye plane so huge scenes are always in front (the retired
334    /// picker pushed its ray origin back the same way).
335    pub fn pick_ray(&self, x: f64, y: f64) -> Ray {
336        let (right, up, forward) = self.basis();
337        let ndc_x = (x / self.width.max(1.0)) * 2.0 - 1.0;
338        let ndc_y = -((y / self.height.max(1.0)) * 2.0 - 1.0);
339        match self.projection {
340            Projection::Orthographic { half_height } => {
341                let half_w = half_height * self.aspect();
342                let span = self.far.abs().max(self.near.abs()).max(half_height * 40.0).max(1.0);
343                let on_plane = add3(
344                    self.eye,
345                    add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_height)),
346                );
347                Ray {
348                    origin: sub3(on_plane, scale3(forward, span)),
349                    dir: forward,
350                }
351            }
352            Projection::Perspective { fov_y_deg } => {
353                let half_h = (fov_y_deg.to_radians() * 0.5).tan();
354                let half_w = half_h * self.aspect();
355                let dir = norm3(add3(
356                    forward,
357                    add3(scale3(right, ndc_x * half_w), scale3(up, ndc_y * half_h)),
358                ));
359                Ray {
360                    origin: self.eye,
361                    dir,
362                }
363            }
364        }
365    }
366
367    /// Fit the depth window to the scene (the `_updateDepthRange` port): the
368    /// whole bbox lands inside `[near, far]` with generous padding.
369    pub fn fit_depth_range(&mut self, bbox: &Aabb) {
370        if bbox.is_empty() {
371            // An EMPTY input (no solids, no overlay geometry) must NOT ride the
372            // stale near/far from an earlier populated frame — that stale-tight
373            // window would CLIP a newly-shown construction-only scene (datum
374            // planes / world axes / gizmos). Reset to the SAME generous window
375            // the camera constructs with (see `Default`: ortho ±100000;
376            // perspective a sane 0.1 / 1e5) so an empty scene never clips. This
377            // is a depth-WINDOW choice only — near/far are the depth-buffer
378            // range, never a visibility decision (see their field doc).
379            match self.projection {
380                Projection::Orthographic { .. } => {
381                    self.near = -100000.0;
382                    self.far = 100000.0;
383                }
384                Projection::Perspective { .. } => {
385                    self.near = 0.1;
386                    self.far = 1e5;
387                }
388            }
389            return;
390        }
391        let (_, _, forward) = self.basis();
392        let mut min_d = f64::INFINITY;
393        let mut max_d = f64::NEG_INFINITY;
394        for i in 0..8 {
395            let corner = [
396                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
397                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
398                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
399            ];
400            let d = dot3(sub3(corner, self.eye), forward);
401            min_d = min_d.min(d);
402            max_d = max_d.max(d);
403        }
404        let diag = len3(sub3(bbox.max, bbox.min));
405        let pad = ((max_d - min_d) * 0.1).max(diag * 0.1).max(0.5);
406        match self.projection {
407            Projection::Orthographic { .. } => {
408                self.near = min_d - pad;
409                self.far = max_d + pad;
410            }
411            Projection::Perspective { .. } => {
412                let far = (max_d + pad).max(1.0);
413                self.near = (far * 0.001).clamp(1e-4, 1.0).min((min_d - pad).max(1e-4));
414                self.far = far;
415            }
416        }
417    }
418
419    /// Zoom-to-fit (R21): recenters the target on the bbox and scales the
420    /// frustum/distance so the whole bbox fits with `margin`, preserving the
421    /// view direction (the ArcballControls `focus` behavior).
422    pub fn zoom_to_fit(&mut self, bbox: &Aabb, margin: f64) {
423        if bbox.is_empty() {
424            return;
425        }
426        let margin = margin.max(1.0);
427        let (right, up, forward) = self.basis();
428        let center = bbox.center();
429        let mut half_w = 0.0f64;
430        let mut half_h = 0.0f64;
431        for i in 0..8 {
432            let corner = [
433                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
434                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
435                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
436            ];
437            let rel = sub3(corner, center);
438            half_w = half_w.max(dot3(rel, right).abs());
439            half_h = half_h.max(dot3(rel, up).abs());
440        }
441        half_w = (half_w * margin).max(1e-6);
442        half_h = (half_h * margin).max(1e-6);
443
444        let dist = self.distance();
445        let aspect = self.aspect();
446        self.target = center;
447        match self.projection {
448            Projection::Orthographic { ref mut half_height } => {
449                *half_height = half_h.max(half_w / aspect);
450                self.eye = sub3(center, scale3(forward, dist));
451            }
452            Projection::Perspective { fov_y_deg } => {
453                let fov = fov_y_deg.to_radians();
454                let dist_h = half_h / (fov * 0.5).tan().max(1e-6);
455                let tan_half_h_fov = (fov * 0.5).tan() * aspect;
456                let dist_w = half_w / tan_half_h_fov.max(1e-6);
457                let target_dist = dist_h.max(dist_w).max(1e-3);
458                self.eye = sub3(center, scale3(forward, target_dist));
459            }
460        }
461        self.fit_depth_range(bbox);
462    }
463
464    /// Toggle ortho ↔ perspective preserving the apparent size at the target
465    /// plane (the `toggleCameraProjection` port). Returns the new kind name.
466    pub fn toggle_projection(&mut self) -> &'static str {
467        const FOV: f64 = 50.0;
468        let forward = norm3(sub3(self.target, self.eye));
469        match self.projection {
470            Projection::Orthographic { half_height } => {
471                let denom = (FOV.to_radians() * 0.5).tan();
472                let mut distance = half_height / denom.max(1e-9);
473                if !distance.is_finite() || distance < 1e-4 {
474                    distance = 10.0;
475                }
476                self.eye = sub3(self.target, scale3(forward, distance));
477                self.projection = Projection::Perspective { fov_y_deg: FOV };
478                "perspective"
479            }
480            Projection::Perspective { fov_y_deg } => {
481                let dist = self.distance();
482                let half_height = ((fov_y_deg.to_radians() * 0.5).tan() * dist).max(1e-6);
483                self.projection = Projection::Orthographic { half_height };
484                "orthographic"
485            }
486        }
487    }
488
489    /// Snap to a standard view (future ViewCube seam), preserving distance and
490    /// frustum scale. Directions are world-axis views with sensible ups.
491    pub fn standard_view(&mut self, name: &str) -> bool {
492        let dist = self.distance();
493        let iso = norm3([1.0, 1.0, 1.0]);
494        let (dir, up): ([f64; 3], [f64; 3]) = match name.to_ascii_uppercase().as_str() {
495            "FRONT" => ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
496            "BACK" => ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
497            "RIGHT" => ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
498            "LEFT" => ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
499            "TOP" => ([0.0, 1.0, 0.0], [0.0, 0.0, -1.0]),
500            "BOTTOM" => ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
501            "ISO" => (iso, [0.0, 1.0, 0.0]),
502            _ => return false,
503        };
504        self.eye = add3(self.target, scale3(dir, dist));
505        self.up = up;
506        true
507    }
508
509    /// Serialize the full camera state (R3: the host holds plain JSON only).
510    pub fn state_json(&self) -> String {
511        let (kind, scale) = match self.projection {
512            Projection::Orthographic { half_height } => ("orthographic", half_height),
513            Projection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
514        };
515        serde_json::json!({
516            "kind": kind,
517            "eye": self.eye,
518            "target": self.target,
519            "up": self.up,
520            // half_height for ortho, fov_y_deg for perspective.
521            "scale": scale,
522            "near": self.near,
523            "far": self.far,
524            "width": self.width,
525            "height": self.height,
526            "worldPerPixel": self.world_per_pixel(),
527        })
528        .to_string()
529    }
530
531    /// Restore from [`ViewCamera::state_json`] output (viewport size is NOT
532    /// restored — it belongs to the canvas).
533    pub fn apply_state_json(&mut self, json: &str) -> Result<(), String> {
534        let value: serde_json::Value =
535            serde_json::from_str(json).map_err(|error| format!("camera state parse: {error}"))?;
536        let vec3 = |key: &str| -> Option<[f64; 3]> {
537            let arr = value.get(key)?.as_array()?;
538            Some([arr.first()?.as_f64()?, arr.get(1)?.as_f64()?, arr.get(2)?.as_f64()?])
539        };
540        if let Some(eye) = vec3("eye") {
541            self.eye = eye;
542        }
543        if let Some(target) = vec3("target") {
544            self.target = target;
545        }
546        if let Some(up) = vec3("up") {
547            self.up = up;
548        }
549        let scale = value.get("scale").and_then(|v| v.as_f64());
550        match value.get("kind").and_then(|v| v.as_str()) {
551            Some("perspective") => {
552                self.projection = Projection::Perspective {
553                    fov_y_deg: scale.unwrap_or(50.0),
554                }
555            }
556            Some("orthographic") => {
557                self.projection = Projection::Orthographic {
558                    half_height: scale.unwrap_or(10.0).max(1e-9),
559                }
560            }
561            _ => {}
562        }
563        if let Some(near) = value.get("near").and_then(|v| v.as_f64()) {
564            self.near = near;
565        }
566        if let Some(far) = value.get("far").and_then(|v| v.as_f64()) {
567            self.far = far;
568        }
569        Ok(())
570    }
571}
572
573/// The render camera projects dimension-gizmo handle points to viewport-local px
574/// for the shared screen-space region builder, so a gizmo's hit-test + its debug
575/// outline share ONE projection (see [`brep_gizmos::hit_region`]).
576impl brep_gizmos::hit_region::RegionCamera for ViewCamera {
577    fn is_orthographic(&self) -> bool {
578        matches!(self.projection, Projection::Orthographic { .. })
579    }
580    fn depth(&self, p: [f64; 3]) -> f64 {
581        self.view_depth(p)
582    }
583    fn project_px(&self, p: [f64; 3]) -> Option<[f32; 2]> {
584        // ONE policy: [`ViewCamera::projectable`] (ortho always projects;
585        // perspective omits only at/behind the eye plane).
586        if !self.projectable(p) {
587            return None;
588        }
589        let (sx, sy, _) = self.project(p);
590        Some([sx as f32, sy as f32])
591    }
592}
593
594// BREP private tests: f08b8e896ac92341