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        self.ensure_sketch3d_overlay_current();
40        // The active PMI view's annotation graphics (screen-constant arrows
41        // that also face the camera).
42        self.ensure_pmi_overlay_current();
43    }
44
45    /// Frame the whole scene (used right after the first history feed).
46    pub fn zoom_to_fit(&mut self) {
47        self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
48        self.dirty = true;
49    }
50
51    // --- Sizing -----------------------------------------------------------
52
53    /// Update the CSS viewport size (used by all camera math). The physical
54    /// framebuffer size + DPR are the presentation shell's concern.
55    /// The viewport calls this every frame, so it marks the frame dirty only
56    /// when the size really changes: an unconditional mark re-rendered the
57    /// whole scene on every repaint (about 0.34 s a frame for a large CAM stock
58    /// on a machine without a GPU), whether or not anything had changed.
59    pub fn resize(&mut self, css_width: f64, css_height: f64) {
60        let (width, height) = (css_width.max(1.0), css_height.max(1.0));
61        if self.camera.width != width || self.camera.height != height {
62            self.camera.width = width;
63            self.camera.height = height;
64            self.dirty = true;
65        }
66    }
67
68    // --- Pointer / wheel ingestion (R22) ----------------------------------
69
70    pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
71        // Sketch camera lock: while locked, the view is held flat-on to the sketch
72        // plane, so a LEFT press must NOT drive the camera at all — neither orbit
73        // (which would tilt off the plane) NOR pan. Suppressing it keeps left-drag
74        // free for sketch interaction and leaves pan on right/middle. Modeling mode
75        // and the UNLOCKED sketch view (where left orbits) are unaffected.
76        if self.sketch_mode()
77            && self.sketch_camera_locked
78            && button == crate::controls::BUTTON_LEFT
79        {
80            return false;
81        }
82        self.controls.pointer_down(x, y, button)
83    }
84
85    pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
86        let changed = self.controls.pointer_move(&mut self.camera, x, y);
87        if changed {
88            self.dirty = true;
89        }
90        changed
91    }
92
93    pub fn pointer_up(&mut self) -> bool {
94        self.controls.pointer_up()
95    }
96
97    pub fn wheel(&mut self, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
98        let changed = self.controls.wheel(&mut self.camera, delta_y, cursor);
99        if changed {
100            self.dirty = true;
101        }
102        changed
103    }
104
105    pub fn set_controls_enabled(&mut self, enabled: bool) {
106        self.controls.enabled = enabled;
107    }
108
109    /// Two-finger pan/pinch, also available with the sketch camera locked:
110    /// neither operation changes its orientation relative to the sketch plane.
111    pub fn touch_navigation(&mut self, from: [f64; 2], to: [f64; 2], scale: f64) -> bool {
112        let changed = self
113            .controls
114            .touch_navigation(&mut self.camera, from, to, scale);
115        self.dirty |= changed;
116        changed
117    }
118
119    // --- Camera commands (R21) --------------------------------------------
120
121    pub fn toggle_projection(&mut self) -> &'static str {
122        let kind = self.camera.toggle_projection();
123        self.dirty = true;
124        kind
125    }
126
127    pub fn set_projection(&mut self, kind: &str) {
128        let is_persp = matches!(self.camera.projection, crate::view::Projection::Perspective { .. });
129        let want_persp = kind.to_ascii_lowercase().starts_with("pers");
130        if is_persp != want_persp {
131            self.camera.toggle_projection();
132            self.dirty = true;
133        }
134    }
135
136    /// Snap to a named view and fit, the name read in [`Self::view_frame`] —
137    /// so with a board shown TOP looks down on its component side.
138    pub fn standard_view(&mut self, name: &str) -> bool {
139        let ok = self.camera.standard_view_in(self.view_frame(), name);
140        if ok {
141            self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
142            self.dirty = true;
143        }
144        ok
145    }
146
147    /// The frame the named views and the ViewCube are read in: Z-up
148    /// ([`crate::view::ViewFrame::Board`]) while the document shows a board, Y-up otherwise.
149    ///
150    /// Keyed on the DOCUMENT, not the workbench: the board is seen in 3D from
151    /// every workbench that draws the 3D view (Modeling, Assembly…) — the PCB
152    /// workbench itself draws its 2D editor there instead — so a frame the PCB
153    /// workbench switched on would never be in force where the board is looked
154    /// at. A document that has a board is one whose 3D view is about the board.
155    pub fn view_frame(&self) -> crate::view::ViewFrame {
156        if self.shown_board_ids.is_empty() {
157            crate::view::ViewFrame::World
158        } else {
159            crate::view::ViewFrame::Board
160        }
161    }
162
163    /// Put the ViewCube in the frame [`Self::view_frame`] reads now, after the
164    /// board bodies were fed or removed. The first time a board appears the
165    /// camera turns to the board's ISO view and frames it, so a board document
166    /// opens looking down on the component side from the front right, as a
167    /// board viewer does, rather than at the Y-up startup vantage that shows
168    /// the board as a standing wall. Losing the board leaves the camera alone.
169    pub(super) fn sync_view_frame(&mut self) {
170        let frame = self.view_frame();
171        if frame == self.widgets.view_frame() {
172            return;
173        }
174        self.widgets.set_view_frame(frame);
175        if frame == crate::view::ViewFrame::Board {
176            self.camera.standard_view_in(frame, "ISO");
177            self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
178        }
179        self.dirty = true;
180    }
181
182    pub fn camera_state_json(&self) -> String {
183        self.camera.state_json()
184    }
185
186    /// [`Self::camera_state_json`] as a JSON value.
187    pub fn camera_state_value(&self) -> serde_json::Value {
188        self.camera.state_value()
189    }
190
191    pub fn apply_camera_state_json(&mut self, json: &str) -> Result<(), String> {
192        self.camera.apply_state_json(json)?;
193        self.dirty = true;
194        Ok(())
195    }
196
197    pub fn world_per_pixel(&self) -> f64 {
198        self.camera.world_per_pixel()
199    }
200
201    // --- World → screen (R25) ---------------------------------------------
202
203    /// Project world points to CSS-pixel screen coords for host anchoring. Input
204    /// is `[[x,y,z], …]`; output `[[sx, sy, depth, inFront], …]` where inFront
205    /// is 1 when a LABEL anchored at the point should draw
206    /// ([`crate::view::ViewCamera::label_anchor_visible`], THE one label policy:
207    /// the point is projectable — ortho always, perspective unless at/behind the
208    /// eye plane, near/far NEVER cull — AND its projection lands inside the
209    /// viewport, so an off-screen anchor's chip vanishes instead of clamping to
210    /// the viewport edge). Every app label pass (sketch dims, feature dims,
211    /// constraint chips, gizmo axis text) keys its skip off THIS flag, so the
212    /// policy lives in exactly one place.
213    pub fn world_to_screen_json(&self, points_json: &str) -> Result<String, String> {
214        let points: Vec<[f64; 3]> = serde_json::from_str(points_json)
215            .map_err(|error| format!("world_to_screen points parse: {error}"))?;
216        let out = self.world_to_screen_points(&points);
217        Ok(serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()))
218    }
219
220    /// [`Self::world_to_screen_json`] without the JSON: what a native pass
221    /// that projects every frame calls.
222    pub fn world_to_screen_points(&self, points: &[[f64; 3]]) -> Vec<[f64; 4]> {
223        points
224            .iter()
225            .map(|&p| {
226                let (sx, sy, depth) = self.camera.project(p);
227                let visible = self.camera.label_anchor_visible(p);
228                [sx, sy, depth, if visible { 1.0 } else { 0.0 }]
229            })
230            .collect()
231    }
232
233    /// The camera matrices for the host overlays' per-frame world→screen /
234    /// screen→world hot path: `{ viewProj:[16], viewProjInverse:[16],
235    /// viewport:[w,h] }`. Both matrices are column-major (index =
236    /// `col*4 + row`); `viewProj` maps world → wgpu clip
237    /// (x,y in −1..1, z in 0..1) and `viewport` is the CSS-pixel size. This lets
238    /// dimensions + sketch drop the compat mirror camera and read the engine's
239    /// own view-projection directly (see `world_to_screen_json` for one-shots).
240    pub fn camera_matrices_json(&self) -> String {
241        let view_proj = self.camera.view_proj_flat();
242        let view_proj_inverse = self.camera.view_proj_inverse_flat();
243        serde_json::json!({
244            "viewProj": view_proj,
245            "viewProjInverse": view_proj_inverse,
246            "viewport": [self.camera.width, self.camera.height],
247        })
248        .to_string()
249    }
250
251    // --- Picking (R23/R24) ------------------------------------------------
252
253}
254
255impl EngineState {
256    /// One hash per kind of render input, for the app's stale-frame check
257    /// (automation builds): a frame whose inputs hash differently from the last
258    /// rendered frame's but that is not marked dirty would show a stale view.
259    /// Selection and hover highlight are `emphasis`; sketch previews and every
260    /// other `set_overlay` geometry, the gizmos and the ViewCube are `widgets`;
261    /// theme and background colours bump `settings_generation`; tessellation
262    /// arriving later bumps a solid's `revision` in `scene`.
263    pub fn render_fingerprint(&self) -> [(&'static str, u64); 5] {
264        use std::hash::{Hash, Hasher};
265        let hash = |f: &dyn Fn(&mut std::collections::hash_map::DefaultHasher)| {
266            let mut h = std::collections::hash_map::DefaultHasher::new();
267            f(&mut h);
268            h.finish()
269        };
270        let camera = self.camera.resolve();
271        [
272            ("camera", hash(&|h| {
273                for v in camera.view_proj.iter().flatten() {
274                    v.to_bits().hash(h);
275                }
276                self.camera.width.to_bits().hash(h);
277                self.camera.height.to_bits().hash(h);
278                self.camera.world_per_pixel().to_bits().hash(h);
279            })),
280            ("settings", self.settings_generation),
281            ("scene", hash(&|h| {
282                for solid in self.scene.solids().iter().chain(self.context_scene.solids()) {
283                    solid.name.hash(h);
284                    solid.revision.hash(h);
285                }
286            })),
287            ("emphasis", hash(&|h| {
288                format!("{:?}", self.emphasis).hash(h);
289                self.context_scene.highlight.hash(h);
290                self.context_scene.tint.map(|rgba| rgba.map(f32::to_bits)).hash(h);
291                for solid in self.context_scene.solids() {
292                    solid.visible.hash(h);
293                }
294            })),
295            ("widgets", self.widgets.render_fingerprint(&gizmo_camera(&self.camera))),
296        ]
297    }
298
299    /// Build this frame's overlay-widget geometry, or None when nothing is
300    /// enabled (skips the overlay passes entirely).
301    pub fn build_widget_overlay(&self) -> Option<WidgetOverlay> {
302        if !self.widgets.any_visible() {
303            return None;
304        }
305        Some(self.widgets.build_overlay(&gizmo_camera(&self.camera)))
306    }
307
308    /// Fit the per-frame depth window to EVERYTHING drawn, then resolve the GPU
309    /// camera — the ONE path both frame loops (wasm `Engine::render`, desktop
310    /// `redraw`) use so they can't drift. The overlay is built FIRST, then its
311    /// WORLD bounds are folded into the fit: near/far never affect the overlay
312    /// geometry (it depends only on view direction + `world_per_pixel`), so
313    /// building it before the fit lets construction geometry — datum planes,
314    /// world axes, frames, the transform gizmo — be bracketed by the depth
315    /// window instead of clipping against the solids-only bounds. The world
316    /// ORIGIN is always folded in too, so the origin triad stays bracketed even
317    /// when every geometry channel is momentarily empty (an all-empty frame then
318    /// yields a tiny origin-centred window — harmless, re-fit next frame). The
319    /// ViewCube is excluded (it draws with its own mini-camera; see
320    /// [`WidgetOverlay::world_bbox`]). Returns the resolved camera + the built
321    /// overlay for the frame to hand to the render core.
322    pub fn fit_camera_and_overlay(&mut self) -> (crate::camera::Camera, Option<WidgetOverlay>) {
323        let overlay = self.build_widget_overlay();
324        let mut depth_bbox = self.depth_range_bbox();
325        if let Some(overlay) = &overlay {
326            depth_bbox.union(&overlay.world_bbox());
327        }
328        depth_bbox.expand([0.0, 0.0, 0.0]);
329        self.camera.fit_depth_range(&depth_bbox);
330        (self.camera.resolve(), overlay)
331    }
332
333    pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
334        self.widgets.set_datums_json(json)?;
335        self.dirty = true;
336        Ok(())
337    }
338
339    /// Feed the general overlay geometry channel (`set_overlay`): arbitrary named
340    /// tri/line/point groups (feature-dialog previews and other display-only
341    /// geometry), drawn in the widget overlay pass.
342    pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
343        self.widgets.set_overlay_json(json)?;
344        self.dirty = true;
345        Ok(())
346    }
347
348    pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
349        self.widgets.set_dimensions_json(json)?;
350        self.dirty = true;
351        Ok(())
352    }
353
354    pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
355        self.widgets.set_transform_json(json)?;
356        self.dirty = true;
357        Ok(())
358    }
359
360    pub fn set_viewcube_enabled(&mut self, enabled: bool) {
361        self.widgets.set_viewcube_enabled(enabled);
362        self.dirty = true;
363    }
364
365    /// The ViewCube corner rect `{x,y,w,h}` (CSS px) so the host can decide
366    /// whether to forward a pointer event.
367    pub fn viewcube_rect_json(&self) -> String {
368        let r = self.widgets.viewcube_rect(&gizmo_camera(&self.camera));
369        serde_json::json!({ "x": r[0], "y": r[1], "w": r[2], "h": r[3] }).to_string()
370    }
371
372    /// Update the ViewCube hover from cube-local pixels; returns whether it
373    /// changed (a hover-out is `(None)` with local coords outside).
374    pub fn viewcube_hover(&mut self, local_x: f64, local_y: f64) -> bool {
375        let cam = gizmo_camera(&self.camera);
376        let handle = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32);
377        let changed = self.widgets.set_viewcube_hover(handle);
378        if changed {
379            self.dirty = true;
380        }
381        changed
382    }
383
384    pub fn viewcube_clear_hover(&mut self) -> bool {
385        let changed = self.widgets.set_viewcube_hover(None);
386        if changed {
387            self.dirty = true;
388        }
389        changed
390    }
391
392    /// Click the ViewCube at cube-local pixels: snap the shared camera to the
393    /// region's standard view (keeping the current pivot distance). Returns
394    /// true if a region was hit.
395    pub fn viewcube_click(&mut self, local_x: f64, local_y: f64) -> bool {
396        let cam = gizmo_camera(&self.camera);
397        let Some(handle) = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32) else {
398            return false;
399        };
400        // Navigation arrows apply a RELATIVE camera rotation (orbit / roll) to
401        // the current view instead of snapping to an absolute standard view.
402        if brep_gizmos::view_cube::ViewCube::is_arrow(handle) {
403            self.apply_viewcube_arrow(handle);
404            self.dirty = true;
405            return true;
406        }
407        let (dir, fallback_up) = self.widgets.viewcube_target(handle);
408        // Minimal-rotation snap with a LEVELLED roll: the view direction snaps to
409        // the region, and the up is snapped to the nearest member of a discrete
410        // per-kind set (see [`snap_view_up`]) so a face lands flat-on with its
411        // bottom edge horizontal and a corner lands on a proper top-vertex-up
412        // isometric — always the orientation that rotates the camera the least.
413        let kind = brep_gizmos::view_cube::ViewCube::region_kind(handle);
414        let dirf = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
415        let up = snap_view_up(kind, dirf, self.camera.up, fallback_up);
416        self.apply_look_direction(dir, up);
417        self.dirty = true;
418        true
419    }
420
421    /// Reorient the camera to look along `dir` (world eye→target) with `up`,
422    /// preserving the current pivot distance.
423    pub(super) fn apply_look_direction(&mut self, dir: [f32; 3], up: [f32; 3]) {
424        let dir = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
425        let dist = self.camera.distance();
426        self.camera.eye = [
427            self.camera.target[0] - dir[0] * dist,
428            self.camera.target[1] - dir[1] * dist,
429            self.camera.target[2] - dir[2] * dist,
430        ];
431        self.camera.up = [up[0] as f64, up[1] as f64, up[2] as f64];
432    }
433
434    /// Apply a ViewCube navigation-arrow rotation to the CURRENT camera (a
435    /// relative 90° orbit / roll), keeping the pivot (`target`) fixed. The
436    /// rotation axes are the camera's own screen axes so the motion follows the
437    /// on-screen arrow direction: the eye moves toward the pan arrow it points
438    /// at, and the roll arcs spin the up vector about the view direction.
439    fn apply_viewcube_arrow(&mut self, handle: u32) {
440        use crate::view::{add3, rotate3, sub3};
441        use brep_gizmos::view_cube::ViewCube;
442        // World-space screen axes of the current view: right, up, forward(eye→target).
443        let (right, up_axis, fwd) = self.camera.basis();
444        let target = self.camera.target;
445        let rel = sub3(self.camera.eye, target); // eye relative to pivot
446        let q = std::f64::consts::FRAC_PI_2; // 90° per click
447        match handle {
448            // Orbit about the screen-up axis; eye moves toward the arrow side.
449            ViewCube::ARROW_RIGHT => {
450                self.camera.eye = add3(target, rotate3(rel, up_axis, q));
451            }
452            ViewCube::ARROW_LEFT => {
453                self.camera.eye = add3(target, rotate3(rel, up_axis, -q));
454            }
455            // Orbit about the screen-right axis; carry the up vector along so the
456            // view stays upright (eye moves toward the arrow side).
457            ViewCube::ARROW_UP => {
458                self.camera.eye = add3(target, rotate3(rel, right, -q));
459                self.camera.up = rotate3(self.camera.up, right, -q);
460            }
461            ViewCube::ARROW_DOWN => {
462                self.camera.eye = add3(target, rotate3(rel, right, q));
463                self.camera.up = rotate3(self.camera.up, right, q);
464            }
465            // Roll about the view direction; only the up vector changes.
466            ViewCube::ROLL_CCW => {
467                self.camera.up = rotate3(self.camera.up, fwd, q);
468            }
469            ViewCube::ROLL_CW => {
470                self.camera.up = rotate3(self.camera.up, fwd, -q);
471            }
472            _ => {}
473        }
474    }
475
476    /// Pick the datum plane/axis under a screen pixel; returns its name (empty
477    /// when none).
478    ///
479    /// NOT the selection path any more: construction planes are ordinary pick
480    /// candidates ([`Self::pick_candidates_at`] → the widget's `datum_plane_hits`,
481    /// which reports EVERY card the ray crosses rather than the first), so the
482    /// viewport click router no longer calls this. It survives as the AXIS-aware
483    /// second line of defense inside [`Self::ref_select_click`]'s total-miss arm.
484    pub fn datum_pick(&self, x: f64, y: f64) -> String {
485        self.widgets
486            .datum_pick(&gizmo_camera(&self.camera), x as f32, y as f32)
487            .unwrap_or_default()
488    }
489
490    /// Update the transform-gizmo hover from a screen pixel; returns the handle
491    /// under the pointer (0 = none). Marks dirty when the highlight changed.
492    pub fn transform_hover(&mut self, x: f64, y: f64) -> u32 {
493        let cam = gizmo_camera(&self.camera);
494        let handle = self.widgets.transform_hit(&cam, x as f32, y as f32);
495        if self.widgets.set_transform_hover(handle) {
496            self.dirty = true;
497        }
498        handle
499    }
500
501    /// The transform-gizmo handle under a screen pixel (0 = none) — the host
502    /// echoes it back to start a drag.
503    pub fn transform_pick(&self, x: f64, y: f64) -> u32 {
504        self.widgets.transform_hit(&gizmo_camera(&self.camera), x as f32, y as f32)
505    }
506
507    /// Compute a transform drag (frame-space + world delta) as JSON for the
508    /// feature-edit commit. Marks the handle active for the highlight.
509    pub fn transform_drag(
510        &mut self,
511        handle: u32,
512        sx: f64,
513        sy: f64,
514        cx: f64,
515        cy: f64,
516    ) -> String {
517        let cam = gizmo_camera(&self.camera);
518        self.widgets.set_transform_active(handle);
519        self.dirty = true;
520        self.widgets
521            .transform_drag_json(&cam, handle, sx as f32, sy as f32, cx as f32, cy as f32)
522    }
523
524    pub fn transform_drag_end(&mut self) {
525        self.widgets.set_transform_active(0);
526        self.dirty = true;
527    }
528
529    /// Per-dimension label placement: `[{id, anchor:[x,y,z],
530    /// screen:[sx,sy,inFront]}]` — the host pins each text label at `screen`.
531    pub fn dimension_anchors_json(&self) -> String {
532        let anchors = self.widgets.dimension_anchors(&gizmo_camera(&self.camera));
533        let out: Vec<serde_json::Value> = anchors
534            .into_iter()
535            .map(|(id, p)| {
536                let world = [p[0] as f64, p[1] as f64, p[2] as f64];
537                let (sx, sy, _) = self.camera.project(world);
538                // Same ONE label policy as `world_to_screen_json` — near/far
539                // never cull; off-viewport anchors hide their label.
540                let visible = if self.camera.label_anchor_visible(world) { 1.0 } else { 0.0 };
541                serde_json::json!({
542                    "id": id,
543                    "anchor": p,
544                    "screen": [sx, sy, visible],
545                })
546            })
547            .collect();
548        serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
549    }
550
551    // --- Undo / redo (engine-owned) ---------------------------------------
552    //
553    // The model is engine-owned, so its undo history lives in the engine core
554    // too: `History` holds the stacks and snapshots itself BEFORE each model
555    // mutation (edit / add / delete / reorder), while roll-to-step is view state
556    // and is NOT snapshotted. The UI only TRIGGERS these; it never holds a stack.
557
558}
559
560/// Choose the camera `up` when the ViewCube snaps to a face / edge / corner.
561///
562/// `dir` is the world eye→target direction the view snaps to
563/// ([`brep_gizmos::view_cube::ViewCube::target_view`]): axis-aligned for a face,
564/// a 45° blend for an edge, a body-diagonal for a corner. `current_up` is the
565/// live camera up; `fallback` the region's canonical up.
566///
567/// The roll is snapped to a DISCRETE set and the member closest to the current
568/// up wins (max dot ⇒ least roll), so the reorient rotates by the smallest angle
569/// while still landing "level":
570/// - **face** (`kind == 1`): the 4 signed world axes lying in the face plane
571///   (the two axes `dir` is perpendicular to) — the face ends flat-on with its
572///   bottom edge horizontal, choosing whichever of the 4 edges was already
573///   nearest the top.
574/// - **corner** (`kind == 3`): the 3 cube axes that point up for this corner
575///   (top-vertex-up isometric), 120° apart — the axis whose sign opposes each
576///   component of `dir` (so the near vertex reads upright, not inverted).
577/// - **edge / other**: no discrete set — the current up is projected onto the
578///   plane ⟂ `dir` (free roll preserved), falling back to `fallback` when that
579///   projection degenerates (up nearly parallel to `dir`).
580pub(super) fn snap_view_up(
581    kind: u8,
582    dir: [f64; 3],
583    current_up: [f64; 3],
584    fallback: [f32; 3],
585) -> [f32; 3] {
586    let mut candidates: Vec<[f64; 3]> = Vec::new();
587    match kind {
588        // Face: both signs of each axis the (axis-aligned) view dir is ⟂ to.
589        1 => {
590            for a in 0..3 {
591                if dir[a].abs() < 0.5 {
592                    let mut p = [0.0; 3];
593                    p[a] = 1.0;
594                    candidates.push(p);
595                    p[a] = -1.0;
596                    candidates.push(p);
597                }
598            }
599        }
600        // Corner: the axis direction opposite each component of the view dir
601        // (dir = -normalize(signs), so -sign(dir[a]) recovers the corner's sign).
602        3 => {
603            for a in 0..3 {
604                let mut p = [0.0; 3];
605                p[a] = -dir[a].signum();
606                candidates.push(p);
607            }
608        }
609        _ => {}
610    }
611
612    let dot = |v: &[f64; 3]| v[0] * current_up[0] + v[1] * current_up[1] + v[2] * current_up[2];
613    // An EXACT tie — the current up perpendicular to two candidates, as from
614    // an ISO onto its TOP face — goes to the candidate nearest the region's
615    // own up, not to whichever the loop listed last. The listing order only
616    // happened to give TOP's −Z in the Y-up frame; in the board frame it gave
617    // the TOP view upside down (the layout's +Y down the screen).
618    let score = |v: &[f64; 3]| {
619        dot(v) + 1e-6 * (v[0] * fallback[0] as f64 + v[1] * fallback[1] as f64 + v[2] * fallback[2] as f64)
620    };
621    if let Some(best) = candidates
622        .into_iter()
623        .max_by(|x, y| score(x).partial_cmp(&score(y)).unwrap_or(std::cmp::Ordering::Equal))
624    {
625        return [best[0] as f32, best[1] as f32, best[2] as f32];
626    }
627
628    // Edge / fallback: project the current up onto the plane ⟂ dir (keep roll).
629    let d = dot(&dir);
630    let proj = [
631        current_up[0] - dir[0] * d,
632        current_up[1] - dir[1] * d,
633        current_up[2] - dir[2] * d,
634    ];
635    let len = (proj[0] * proj[0] + proj[1] * proj[1] + proj[2] * proj[2]).sqrt();
636    if len > 1e-4 {
637        [
638            (proj[0] / len) as f32,
639            (proj[1] / len) as f32,
640            (proj[2] / len) as f32,
641        ]
642    } else {
643        fallback
644    }
645}
646
647/// Serialize a screen-space [`brep_gizmos::hit_region::HitShape`] to the gizmo
648/// hit-area JSON schema the app strokes: `{ kind:"capsule", a:[x,y], b:[x,y], r }`
649/// or `{ kind:"circle", c:[x,y], r }` — viewport-local px, so the app only offsets
650/// by `rect.min`. The engine hit-tests these SAME shapes, so the drawn outline can
651/// never drift from the pickable region. Shared by both gizmo exposers.
652pub(super) fn hit_shape_json(shape: &brep_gizmos::hit_region::HitShape) -> serde_json::Value {
653    use brep_gizmos::hit_region::HitShape;
654    match *shape {
655        HitShape::Capsule { a, b, r } => {
656            serde_json::json!({ "kind": "capsule", "a": a, "b": b, "r": r })
657        }
658        HitShape::Circle { c, r } => serde_json::json!({ "kind": "circle", "c": c, "r": r }),
659    }
660}
661
662/// Serialize an iterator of [`brep_gizmos::hit_region::HitShape`]s to the app's
663/// gizmo hit-area JSON array (see [`hit_shape_json`]); `"[]"` on failure.
664pub(super) fn hit_shapes_json<'a>(
665    shapes: impl Iterator<Item = &'a brep_gizmos::hit_region::HitShape>,
666) -> String {
667    let out: Vec<serde_json::Value> = shapes.map(hit_shape_json).collect();
668    serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
669}
670
671
672