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