Skip to main content

brep_render/engine_state/
camera_widgets.rs

1use super::*;
2
3impl EngineState {
4    /// Per-frame overlay upkeep — the ONE call the app viewport makes each frame
5    /// (see `BREP_app/src/viewport/interaction.rs`).
6    ///
7    /// Everything fed through the general `set_overlay` channel is pre-expanded
8    /// into GPU vertices AT FEED TIME (see the "Why not one uniform feed" note in
9    /// [`crate::widgets`]), so — unlike the specialized widgets (transform gizmo,
10    /// datums, ViewCube), which are rebuilt against the LIVE camera every frame —
11    /// a baked overlay group keeps whatever screen-constant sizing it was baked
12    /// with. A ZOOM changes `world_per_pixel` and nothing else re-bakes them, so
13    /// the draggable gizmos keep their old pixel size — and where a handle's world
14    /// position is ITSELF `px × world_per_pixel` (the angular arc), their old
15    /// POSITION too, drifting away from the live-computed grab region.
16    ///
17    /// So: re-bake on a MATERIAL `world_per_pixel` change, keyed on that ONE
18    /// quantity rather than on any particular gesture. Every zoom path moves it —
19    /// the wheel, [`Self::zoom_to_fit`], [`Self::standard_view`] (which fits), a
20    /// viewport [`Self::resize`] — so they are all covered without a per-path
21    /// hook. What does NOT move it needs no re-bake, and correctly gets none:
22    /// pan and orbit hold the eye→target distance, the ViewCube (face, corner AND
23    /// navigation arrow) is a fixed-pivot reorient, and
24    /// [`Self::toggle_projection`] preserves apparent size by construction
25    /// (`ViewCamera::toggle_projection` solves for the distance/half-height that
26    /// keeps `world_per_pixel` — see `projection_toggle_preserves_apparent_size`).
27    /// The baked buffers are world-space, so the GPU re-projects them for free.
28    /// Re-bakes only on actual change, so a quiet frame stays quiet (no per-frame
29    /// dirty loop).
30    pub fn ensure_overlays_current(&mut self) {
31        // Assembly-constraint leaders + grabbable distance/angle handles (§8.4).
32        self.ensure_constraint_overlay_current();
33        // The ◎ feature-DIMENSION gizmo: draggable leaders/arrowheads + the
34        // angular sweep handle, whose arc radius is itself px × world_per_pixel.
35        self.ensure_feature_dimension_overlay_current();
36        // Live sketch mode: draggable dimension leaders, constraint glyphs and
37        // construction dashes, all sized in pixels at bake time.
38        self.ensure_sketch_overlay_current();
39    }
40
41    /// Frame the whole scene (used right after the first history feed).
42    pub fn zoom_to_fit(&mut self) {
43        self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
44        self.dirty = true;
45    }
46
47    // --- Sizing -----------------------------------------------------------
48
49    /// Update the CSS viewport size (used by all camera math). The physical
50    /// framebuffer size + DPR are the presentation shell's concern.
51    pub fn resize(&mut self, css_width: f64, css_height: f64) {
52        self.camera.width = css_width.max(1.0);
53        self.camera.height = css_height.max(1.0);
54        self.dirty = true;
55    }
56
57    // --- Pointer / wheel ingestion (R22) ----------------------------------
58
59    pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
60        // Sketch camera lock: while locked, the view is held flat-on to the sketch
61        // plane, so a LEFT press must NOT drive the camera at all — neither orbit
62        // (which would tilt off the plane) NOR pan. Suppressing it keeps left-drag
63        // free for sketch interaction and leaves pan on right/middle. Modeling mode
64        // and the UNLOCKED sketch view (where left orbits) are unaffected.
65        if self.sketch_mode()
66            && self.sketch_camera_locked
67            && button == crate::controls::BUTTON_LEFT
68        {
69            return false;
70        }
71        self.controls.pointer_down(x, y, button)
72    }
73
74    pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
75        let changed = self.controls.pointer_move(&mut self.camera, x, y);
76        if changed {
77            self.dirty = true;
78        }
79        changed
80    }
81
82    pub fn pointer_up(&mut self) -> bool {
83        self.controls.pointer_up()
84    }
85
86    pub fn wheel(&mut self, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
87        let changed = self.controls.wheel(&mut self.camera, delta_y, cursor);
88        if changed {
89            self.dirty = true;
90        }
91        changed
92    }
93
94    pub fn set_controls_enabled(&mut self, enabled: bool) {
95        self.controls.enabled = enabled;
96    }
97
98    // --- Camera commands (R21) --------------------------------------------
99
100    pub fn toggle_projection(&mut self) -> &'static str {
101        let kind = self.camera.toggle_projection();
102        self.dirty = true;
103        kind
104    }
105
106    pub fn set_projection(&mut self, kind: &str) {
107        let is_persp = matches!(self.camera.projection, crate::view::Projection::Perspective { .. });
108        let want_persp = kind.to_ascii_lowercase().starts_with("pers");
109        if is_persp != want_persp {
110            self.camera.toggle_projection();
111            self.dirty = true;
112        }
113    }
114
115    pub fn standard_view(&mut self, name: &str) -> bool {
116        let ok = self.camera.standard_view(name);
117        if ok {
118            self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
119            self.dirty = true;
120        }
121        ok
122    }
123
124    pub fn camera_state_json(&self) -> String {
125        self.camera.state_json()
126    }
127
128    pub fn apply_camera_state_json(&mut self, json: &str) -> Result<(), String> {
129        self.camera.apply_state_json(json)?;
130        self.dirty = true;
131        Ok(())
132    }
133
134    pub fn world_per_pixel(&self) -> f64 {
135        self.camera.world_per_pixel()
136    }
137
138    // --- World → screen (R25) ---------------------------------------------
139
140    /// Project world points to CSS-pixel screen coords for host anchoring. Input
141    /// is `[[x,y,z], …]`; output `[[sx, sy, depth, inFront], …]` where inFront
142    /// is 1 when a LABEL anchored at the point should draw
143    /// ([`crate::view::ViewCamera::label_anchor_visible`], THE one label policy:
144    /// the point is projectable — ortho always, perspective unless at/behind the
145    /// eye plane, near/far NEVER cull — AND its projection lands inside the
146    /// viewport, so an off-screen anchor's chip vanishes instead of clamping to
147    /// the viewport edge). Every app label pass (sketch dims, feature dims,
148    /// constraint chips, gizmo axis text) keys its skip off THIS flag, so the
149    /// policy lives in exactly one place.
150    pub fn world_to_screen_json(&self, points_json: &str) -> Result<String, String> {
151        let points: Vec<[f64; 3]> = serde_json::from_str(points_json)
152            .map_err(|error| format!("world_to_screen points parse: {error}"))?;
153        let out: Vec<[f64; 4]> = points
154            .into_iter()
155            .map(|p| {
156                let (sx, sy, depth) = self.camera.project(p);
157                let visible = self.camera.label_anchor_visible(p);
158                [sx, sy, depth, if visible { 1.0 } else { 0.0 }]
159            })
160            .collect();
161        Ok(serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()))
162    }
163
164    /// The camera matrices for the host overlays' per-frame world→screen /
165    /// screen→world hot path: `{ viewProj:[16], viewProjInverse:[16],
166    /// viewport:[w,h] }`. Both matrices are column-major (index =
167    /// `col*4 + row`); `viewProj` maps world → wgpu clip
168    /// (x,y in −1..1, z in 0..1) and `viewport` is the CSS-pixel size. This lets
169    /// dimensions + sketch drop the compat mirror camera and read the engine's
170    /// own view-projection directly (see `world_to_screen_json` for one-shots).
171    pub fn camera_matrices_json(&self) -> String {
172        let view_proj = self.camera.view_proj_flat();
173        let view_proj_inverse = self.camera.view_proj_inverse_flat();
174        serde_json::json!({
175            "viewProj": view_proj,
176            "viewProjInverse": view_proj_inverse,
177            "viewport": [self.camera.width, self.camera.height],
178        })
179        .to_string()
180    }
181
182    // --- Picking (R23/R24) ------------------------------------------------
183
184}
185
186impl EngineState {
187    /// Build this frame's overlay-widget geometry, or None when nothing is
188    /// enabled (skips the overlay passes entirely).
189    pub fn build_widget_overlay(&self) -> Option<WidgetOverlay> {
190        if !self.widgets.any_visible() {
191            return None;
192        }
193        Some(self.widgets.build_overlay(&gizmo_camera(&self.camera)))
194    }
195
196    /// Fit the per-frame depth window to EVERYTHING drawn, then resolve the GPU
197    /// camera — the ONE path both frame loops (wasm `Engine::render`, desktop
198    /// `redraw`) use so they can't drift. The overlay is built FIRST, then its
199    /// WORLD bounds are folded into the fit: near/far never affect the overlay
200    /// geometry (it depends only on view direction + `world_per_pixel`), so
201    /// building it before the fit lets construction geometry — datum planes,
202    /// world axes, frames, the transform gizmo — be bracketed by the depth
203    /// window instead of clipping against the solids-only bounds. The world
204    /// ORIGIN is always folded in too, so the origin triad stays bracketed even
205    /// when every geometry channel is momentarily empty (an all-empty frame then
206    /// yields a tiny origin-centred window — harmless, re-fit next frame). The
207    /// ViewCube is excluded (it draws with its own mini-camera; see
208    /// [`WidgetOverlay::world_bbox`]). Returns the resolved camera + the built
209    /// overlay for the frame to hand to the render core.
210    pub fn fit_camera_and_overlay(&mut self) -> (crate::camera::Camera, Option<WidgetOverlay>) {
211        let overlay = self.build_widget_overlay();
212        let mut depth_bbox = self.depth_range_bbox();
213        if let Some(overlay) = &overlay {
214            depth_bbox.union(&overlay.world_bbox());
215        }
216        depth_bbox.expand([0.0, 0.0, 0.0]);
217        self.camera.fit_depth_range(&depth_bbox);
218        (self.camera.resolve(), overlay)
219    }
220
221    pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
222        self.widgets.set_datums_json(json)?;
223        self.dirty = true;
224        Ok(())
225    }
226
227    /// Feed the general overlay geometry channel (`set_overlay`): arbitrary named
228    /// tri/line/point groups (feature-dialog previews and other display-only
229    /// geometry), drawn in the widget overlay pass.
230    pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
231        self.widgets.set_overlay_json(json)?;
232        self.dirty = true;
233        Ok(())
234    }
235
236    pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
237        self.widgets.set_dimensions_json(json)?;
238        self.dirty = true;
239        Ok(())
240    }
241
242    pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
243        self.widgets.set_transform_json(json)?;
244        self.dirty = true;
245        Ok(())
246    }
247
248    pub fn set_viewcube_enabled(&mut self, enabled: bool) {
249        self.widgets.set_viewcube_enabled(enabled);
250        self.dirty = true;
251    }
252
253    /// The ViewCube corner rect `{x,y,w,h}` (CSS px) so the host can decide
254    /// whether to forward a pointer event.
255    pub fn viewcube_rect_json(&self) -> String {
256        let r = self.widgets.viewcube_rect(&gizmo_camera(&self.camera));
257        serde_json::json!({ "x": r[0], "y": r[1], "w": r[2], "h": r[3] }).to_string()
258    }
259
260    /// Update the ViewCube hover from cube-local pixels; returns whether it
261    /// changed (a hover-out is `(None)` with local coords outside).
262    pub fn viewcube_hover(&mut self, local_x: f64, local_y: f64) -> bool {
263        let cam = gizmo_camera(&self.camera);
264        let handle = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32);
265        let changed = self.widgets.set_viewcube_hover(handle);
266        if changed {
267            self.dirty = true;
268        }
269        changed
270    }
271
272    pub fn viewcube_clear_hover(&mut self) -> bool {
273        let changed = self.widgets.set_viewcube_hover(None);
274        if changed {
275            self.dirty = true;
276        }
277        changed
278    }
279
280    /// Click the ViewCube at cube-local pixels: snap the shared camera to the
281    /// region's standard view (keeping the current pivot distance). Returns
282    /// true if a region was hit.
283    pub fn viewcube_click(&mut self, local_x: f64, local_y: f64) -> bool {
284        let cam = gizmo_camera(&self.camera);
285        let Some(handle) = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32) else {
286            return false;
287        };
288        // Navigation arrows apply a RELATIVE camera rotation (orbit / roll) to
289        // the current view instead of snapping to an absolute standard view.
290        if brep_gizmos::view_cube::ViewCube::is_arrow(handle) {
291            self.apply_viewcube_arrow(handle);
292            self.dirty = true;
293            return true;
294        }
295        let (dir, fallback_up) = self.widgets.viewcube_target(handle);
296        // Minimal-rotation snap with a LEVELLED roll: the view direction snaps to
297        // the region, and the up is snapped to the nearest member of a discrete
298        // per-kind set (see [`snap_view_up`]) so a face lands flat-on with its
299        // bottom edge horizontal and a corner lands on a proper top-vertex-up
300        // isometric — always the orientation that rotates the camera the least.
301        let kind = brep_gizmos::view_cube::ViewCube::region_kind(handle);
302        let dirf = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
303        let up = snap_view_up(kind, dirf, self.camera.up, fallback_up);
304        self.apply_look_direction(dir, up);
305        self.dirty = true;
306        true
307    }
308
309    /// Reorient the camera to look along `dir` (world eye→target) with `up`,
310    /// preserving the current pivot distance.
311    pub(super) fn apply_look_direction(&mut self, dir: [f32; 3], up: [f32; 3]) {
312        let dir = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
313        let dist = self.camera.distance();
314        self.camera.eye = [
315            self.camera.target[0] - dir[0] * dist,
316            self.camera.target[1] - dir[1] * dist,
317            self.camera.target[2] - dir[2] * dist,
318        ];
319        self.camera.up = [up[0] as f64, up[1] as f64, up[2] as f64];
320    }
321
322    /// Apply a ViewCube navigation-arrow rotation to the CURRENT camera (a
323    /// relative 90° orbit / roll), keeping the pivot (`target`) fixed. The
324    /// rotation axes are the camera's own screen axes so the motion follows the
325    /// on-screen arrow direction: the eye moves toward the pan arrow it points
326    /// at, and the roll arcs spin the up vector about the view direction.
327    fn apply_viewcube_arrow(&mut self, handle: u32) {
328        use crate::view::{add3, rotate3, sub3};
329        use brep_gizmos::view_cube::ViewCube;
330        // World-space screen axes of the current view: right, up, forward(eye→target).
331        let (right, up_axis, fwd) = self.camera.basis();
332        let target = self.camera.target;
333        let rel = sub3(self.camera.eye, target); // eye relative to pivot
334        let q = std::f64::consts::FRAC_PI_2; // 90° per click
335        match handle {
336            // Orbit about the screen-up axis; eye moves toward the arrow side.
337            ViewCube::ARROW_RIGHT => {
338                self.camera.eye = add3(target, rotate3(rel, up_axis, q));
339            }
340            ViewCube::ARROW_LEFT => {
341                self.camera.eye = add3(target, rotate3(rel, up_axis, -q));
342            }
343            // Orbit about the screen-right axis; carry the up vector along so the
344            // view stays upright (eye moves toward the arrow side).
345            ViewCube::ARROW_UP => {
346                self.camera.eye = add3(target, rotate3(rel, right, -q));
347                self.camera.up = rotate3(self.camera.up, right, -q);
348            }
349            ViewCube::ARROW_DOWN => {
350                self.camera.eye = add3(target, rotate3(rel, right, q));
351                self.camera.up = rotate3(self.camera.up, right, q);
352            }
353            // Roll about the view direction; only the up vector changes.
354            ViewCube::ROLL_CCW => {
355                self.camera.up = rotate3(self.camera.up, fwd, q);
356            }
357            ViewCube::ROLL_CW => {
358                self.camera.up = rotate3(self.camera.up, fwd, -q);
359            }
360            _ => {}
361        }
362    }
363
364    /// Pick the datum plane/axis under a screen pixel; returns its name (empty
365    /// when none).
366    ///
367    /// NOT the selection path any more: construction planes are ordinary pick
368    /// candidates ([`Self::pick_candidates_at`] → the widget's `datum_plane_hits`,
369    /// which reports EVERY card the ray crosses rather than the first), so the
370    /// viewport click router no longer calls this. It survives as the AXIS-aware
371    /// second line of defense inside [`Self::ref_select_click`]'s total-miss arm.
372    pub fn datum_pick(&self, x: f64, y: f64) -> String {
373        self.widgets
374            .datum_pick(&gizmo_camera(&self.camera), x as f32, y as f32)
375            .unwrap_or_default()
376    }
377
378    /// Update the transform-gizmo hover from a screen pixel; returns the handle
379    /// under the pointer (0 = none). Marks dirty when the highlight changed.
380    pub fn transform_hover(&mut self, x: f64, y: f64) -> u32 {
381        let cam = gizmo_camera(&self.camera);
382        let handle = self.widgets.transform_hit(&cam, x as f32, y as f32);
383        if self.widgets.set_transform_hover(handle) {
384            self.dirty = true;
385        }
386        handle
387    }
388
389    /// The transform-gizmo handle under a screen pixel (0 = none) — the host
390    /// echoes it back to start a drag.
391    pub fn transform_pick(&self, x: f64, y: f64) -> u32 {
392        self.widgets.transform_hit(&gizmo_camera(&self.camera), x as f32, y as f32)
393    }
394
395    /// Compute a transform drag (frame-space + world delta) as JSON for the
396    /// feature-edit commit. Marks the handle active for the highlight.
397    pub fn transform_drag(
398        &mut self,
399        handle: u32,
400        sx: f64,
401        sy: f64,
402        cx: f64,
403        cy: f64,
404    ) -> String {
405        let cam = gizmo_camera(&self.camera);
406        self.widgets.set_transform_active(handle);
407        self.dirty = true;
408        self.widgets
409            .transform_drag_json(&cam, handle, sx as f32, sy as f32, cx as f32, cy as f32)
410    }
411
412    pub fn transform_drag_end(&mut self) {
413        self.widgets.set_transform_active(0);
414        self.dirty = true;
415    }
416
417    /// Per-dimension label placement: `[{id, anchor:[x,y,z],
418    /// screen:[sx,sy,inFront]}]` — the host pins each text label at `screen`.
419    pub fn dimension_anchors_json(&self) -> String {
420        let anchors = self.widgets.dimension_anchors(&gizmo_camera(&self.camera));
421        let out: Vec<serde_json::Value> = anchors
422            .into_iter()
423            .map(|(id, p)| {
424                let world = [p[0] as f64, p[1] as f64, p[2] as f64];
425                let (sx, sy, _) = self.camera.project(world);
426                // Same ONE label policy as `world_to_screen_json` — near/far
427                // never cull; off-viewport anchors hide their label.
428                let visible = if self.camera.label_anchor_visible(world) { 1.0 } else { 0.0 };
429                serde_json::json!({
430                    "id": id,
431                    "anchor": p,
432                    "screen": [sx, sy, visible],
433                })
434            })
435            .collect();
436        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
437    }
438
439    // --- Undo / redo (engine-owned) ---------------------------------------
440    //
441    // The model is engine-owned, so its undo history lives in the engine core
442    // too: `History` holds the stacks and snapshots itself BEFORE each model
443    // mutation (edit / add / delete / reorder), while roll-to-step is view state
444    // and is NOT snapshotted. The UI only TRIGGERS these; it never holds a stack.
445
446}
447
448/// Choose the camera `up` when the ViewCube snaps to a face / edge / corner.
449///
450/// `dir` is the world eye→target direction the view snaps to
451/// ([`brep_gizmos::view_cube::ViewCube::target_view`]): axis-aligned for a face,
452/// a 45° blend for an edge, a body-diagonal for a corner. `current_up` is the
453/// live camera up; `fallback` the region's canonical up.
454///
455/// The roll is snapped to a DISCRETE set and the member closest to the current
456/// up wins (max dot ⇒ least roll), so the reorient rotates by the smallest angle
457/// while still landing "level":
458/// - **face** (`kind == 1`): the 4 signed world axes lying in the face plane
459///   (the two axes `dir` is perpendicular to) — the face ends flat-on with its
460///   bottom edge horizontal, choosing whichever of the 4 edges was already
461///   nearest the top.
462/// - **corner** (`kind == 3`): the 3 cube axes that point up for this corner
463///   (top-vertex-up isometric), 120° apart — the axis whose sign opposes each
464///   component of `dir` (so the near vertex reads upright, not inverted).
465/// - **edge / other**: no discrete set — the current up is projected onto the
466///   plane ⟂ `dir` (free roll preserved), falling back to `fallback` when that
467///   projection degenerates (up nearly parallel to `dir`).
468pub(super) fn snap_view_up(
469    kind: u8,
470    dir: [f64; 3],
471    current_up: [f64; 3],
472    fallback: [f32; 3],
473) -> [f32; 3] {
474    let mut candidates: Vec<[f64; 3]> = Vec::new();
475    match kind {
476        // Face: both signs of each axis the (axis-aligned) view dir is ⟂ to.
477        1 => {
478            for a in 0..3 {
479                if dir[a].abs() < 0.5 {
480                    let mut p = [0.0; 3];
481                    p[a] = 1.0;
482                    candidates.push(p);
483                    p[a] = -1.0;
484                    candidates.push(p);
485                }
486            }
487        }
488        // Corner: the axis direction opposite each component of the view dir
489        // (dir = -normalize(signs), so -sign(dir[a]) recovers the corner's sign).
490        3 => {
491            for a in 0..3 {
492                let mut p = [0.0; 3];
493                p[a] = -dir[a].signum();
494                candidates.push(p);
495            }
496        }
497        _ => {}
498    }
499
500    let dot = |v: &[f64; 3]| v[0] * current_up[0] + v[1] * current_up[1] + v[2] * current_up[2];
501    if let Some(best) = candidates
502        .into_iter()
503        .max_by(|x, y| dot(x).partial_cmp(&dot(y)).unwrap_or(std::cmp::Ordering::Equal))
504    {
505        return [best[0] as f32, best[1] as f32, best[2] as f32];
506    }
507
508    // Edge / fallback: project the current up onto the plane ⟂ dir (keep roll).
509    let d = dot(&dir);
510    let proj = [
511        current_up[0] - dir[0] * d,
512        current_up[1] - dir[1] * d,
513        current_up[2] - dir[2] * d,
514    ];
515    let len = (proj[0] * proj[0] + proj[1] * proj[1] + proj[2] * proj[2]).sqrt();
516    if len > 1e-4 {
517        [
518            (proj[0] / len) as f32,
519            (proj[1] / len) as f32,
520            (proj[2] / len) as f32,
521        ]
522    } else {
523        fallback
524    }
525}
526
527/// Serialize a screen-space [`brep_gizmos::hit_region::HitShape`] to the gizmo
528/// hit-area JSON schema the app strokes: `{ kind:"capsule", a:[x,y], b:[x,y], r }`
529/// or `{ kind:"circle", c:[x,y], r }` — viewport-local px, so the app only offsets
530/// by `rect.min`. The engine hit-tests these SAME shapes, so the drawn outline can
531/// never drift from the pickable region. Shared by both gizmo exposers.
532pub(super) fn hit_shape_json(shape: &brep_gizmos::hit_region::HitShape) -> serde_json::Value {
533    use brep_gizmos::hit_region::HitShape;
534    match *shape {
535        HitShape::Capsule { a, b, r } => {
536            serde_json::json!({ "kind": "capsule", "a": a, "b": b, "r": r })
537        }
538        HitShape::Circle { c, r } => serde_json::json!({ "kind": "circle", "c": c, "r": r }),
539    }
540}
541
542/// Serialize an iterator of [`brep_gizmos::hit_region::HitShape`]s to the app's
543/// gizmo hit-area JSON array (see [`hit_shape_json`]); `"[]"` on failure.
544pub(super) fn hit_shapes_json<'a>(
545    shapes: impl Iterator<Item = &'a brep_gizmos::hit_region::HitShape>,
546) -> String {
547    let out: Vec<serde_json::Value> = shapes.map(hit_shape_json).collect();
548    serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
549}
550
551// ViewCube roll-snap tests — kept in their OWN module (appended) so concurrent
552// edits to the primary block above don't conflict.
553#[cfg(test)]
554mod snap_tests {
555    use super::snap_view_up;
556    use brep_gizmos::view_cube::ViewCube;
557
558    // Region kinds used by snap_view_up (mirrors ViewCube::region_kind).
559    const FACE: u8 = 1;
560    const EDGE: u8 = 2;
561    const CORNER: u8 = 3;
562
563    fn approx(a: [f32; 3], b: [f32; 3]) -> bool {
564        (0..3).all(|i| (a[i] - b[i]).abs() < 1e-5)
565    }
566
567    /// FRONT face (look -Z): the 4 candidate ups are ±X, ±Y; the nearest to the
568    /// current up wins, so a slightly-tilted up snaps to the exact axis and the
569    /// face lands flat-on with a horizontal bottom edge.
570    #[test]
571    fn face_snaps_roll_to_nearest_axis() {
572        let dir = [0.0, 0.0, -1.0]; // FRONT
573        // Up mostly +Y (tilted) → snaps to +Y.
574        assert!(approx(snap_view_up(FACE, dir, [0.12, 0.99, 0.0], [0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]));
575        // Up mostly +X → snaps to +X (bottom edge still horizontal, 90° roll).
576        assert!(approx(snap_view_up(FACE, dir, [0.97, 0.20, 0.0], [0.0, 1.0, 0.0]), [1.0, 0.0, 0.0]));
577        // Up mostly -Y (upside down) → snaps to -Y, not flipped back to +Y.
578        assert!(approx(snap_view_up(FACE, dir, [-0.15, -0.98, 0.0], [0.0, 1.0, 0.0]), [0.0, -1.0, 0.0]));
579    }
580
581    /// TOP face (look -Y): candidates are ±X, ±Z; an up near -Z snaps to -Z —
582    /// exactly the canonical TOP-button up, reached here by minimal rotation.
583    #[test]
584    fn top_face_snaps_to_z_axis() {
585        let dir = [0.0, -1.0, 0.0];
586        assert!(approx(snap_view_up(FACE, dir, [0.1, 0.0, -0.95], [0.0, 0.0, -1.0]), [0.0, 0.0, -1.0]));
587        assert!(approx(snap_view_up(FACE, dir, [0.95, 0.0, 0.1], [0.0, 0.0, -1.0]), [1.0, 0.0, 0.0]));
588    }
589
590    /// Corner (+X+Y+Z) iso: candidates are the 3 positive axes (top-vertex-up);
591    /// the nearest to the current up wins. Up near +Y → the classic ISO up.
592    #[test]
593    fn corner_snaps_to_nearest_cube_axis() {
594        let s = 1.0 / 3.0_f64.sqrt();
595        let dir = [-s, -s, -s]; // look toward the +X+Y+Z corner
596        assert!(approx(snap_view_up(CORNER, dir, [0.1, 0.9, 0.2], [0.0, 1.0, 0.0]), [0.0, 1.0, 0.0]));
597        assert!(approx(snap_view_up(CORNER, dir, [0.9, 0.1, 0.1], [0.0, 1.0, 0.0]), [1.0, 0.0, 0.0]));
598        // -Y is NOT a candidate (would be vertex-down): an up leaning -Y still
599        // lands on one of the three POSITIVE axes, never inverting the iso.
600        let up = snap_view_up(CORNER, dir, [0.2, -0.6, 0.75], [0.0, 1.0, 0.0]);
601        assert!(up[1] >= 0.0, "corner up never points down for a +++ corner: {up:?}");
602    }
603
604    /// Bottom corner (+X−Y+Z): the −Y axis becomes the up candidate (its sign
605    /// opposes the view dir's +Y), so the near vertex still reads upright.
606    #[test]
607    fn bottom_corner_uses_negative_y_candidate() {
608        let s = 1.0 / 3.0_f64.sqrt();
609        let dir = [-s, s, -s]; // look toward the +X−Y+Z corner
610        let up = snap_view_up(CORNER, dir, [0.1, -0.95, 0.2], [0.0, 1.0, 0.0]);
611        assert!(approx(up, [0.0, -1.0, 0.0]), "bottom-corner iso up is -Y: {up:?}");
612    }
613
614    /// Edge regions keep the old free-roll projection (no discrete snap): the
615    /// current up is projected onto the plane ⟂ dir and stays there.
616    #[test]
617    fn edge_keeps_free_projection() {
618        let s = 1.0 / 2.0_f64.sqrt();
619        let dir = [0.0, -s, -s]; // top-front edge look direction
620        let up = snap_view_up(EDGE, dir, [0.0, 1.0, 0.0], [0.0, 1.0, 0.0]);
621        // Projection of +Y onto the plane ⟂ dir is normalize([0, 0.5, -0.5]).
622        assert!(approx(up, [0.0, s as f32, -s as f32]), "edge free projection: {up:?}");
623        // And it is perpendicular to the view direction.
624        let d = up[0] as f64 * dir[0] + up[1] as f64 * dir[1] + up[2] as f64 * dir[2];
625        assert!(d.abs() < 1e-5, "edge up ⟂ dir: {d}");
626    }
627
628    /// The kinds this helper branches on match ViewCube::region_kind for real
629    /// handles, so the engine passes the right kind through.
630    #[test]
631    fn kinds_match_region_kind() {
632        assert_eq!(ViewCube::region_kind(ViewCube::FRONT), FACE);
633        assert_eq!(ViewCube::region_kind(ViewCube::region_id(0, 1, 1)), EDGE);
634        assert_eq!(ViewCube::region_kind(ViewCube::region_id(1, 1, 1)), CORNER);
635    }
636}
637
638// The depth-fit wiring test — kept in its OWN module (appended) so concurrent
639// edits to the primary block above don't conflict.
640#[cfg(test)]
641mod depth_fit_tests {
642    use super::*;
643
644    /// The whole-fix integration check: a CONSTRUCTION-ONLY scene (no solids,
645    /// just a datum plane offset from the origin) still gets a depth window that
646    /// brackets it. `fit_camera_and_overlay` folds the full widget overlay's
647    /// world bounds into the fit, so every corner of the datum plane lands inside
648    /// [near, far] instead of clipping against the empty solids-only bounds.
649    #[test]
650    fn fit_camera_and_overlay_brackets_datum_only_scene() {
651        let mut engine = EngineState::new();
652        // No solids at all: one world-sized datum plane offset well off origin.
653        engine
654            .set_datums_json(
655                r#"{"planes":[{"name":"P1","origin":[40,10,-20],"x":[1,0,0],"y":[0,1,0],"size":30.0}]}"#,
656            )
657            .unwrap();
658        assert!(engine.scene.bbox().is_empty(), "the scene has no solids");
659
660        let (_camera, overlay) = engine.fit_camera_and_overlay();
661        let bbox = overlay.expect("datum plane -> overlay built").world_bbox();
662        assert!(!bbox.is_empty(), "datum plane -> non-empty world bbox");
663
664        // Every corner of the plane's world bbox sits inside [near, far].
665        let (_, _, forward) = engine.camera.basis();
666        for i in 0..8 {
667            let corner = [
668                if i & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
669                if i & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
670                if i & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
671            ];
672            let d = crate::view::dot3(crate::view::sub3(corner, engine.camera.eye), forward);
673            assert!(
674                d >= engine.camera.near && d <= engine.camera.far,
675                "corner {corner:?} depth {d} outside [{}, {}]",
676                engine.camera.near,
677                engine.camera.far
678            );
679        }
680    }
681}