Skip to main content

brep_render/
widgets.rs

1//! In-scene overlay widgets: the engine-side registry that hosts the
2//! `brep-gizmos` gizmos natively. It owns the widget STATE (fed as plain JSON
3//! over the R3 boundary — datum planes/axes/frames, curve display, feature
4//! dimensions, the transform gizmo, the always-on ViewCube), constructs a
5//! [`GizmoCamera`] from the engine's live [`ViewCamera`] each frame, and emits
6//! the `brep-gizmos` [`Overlay`] geometry the render core converts into overlay
7//! GPU buffers. It also answers pointer hit-tests (ViewCube snap target, datum
8//! pick, transform-gizmo hover/pick) and computes transform drag deltas.
9//!
10//! No renderer, no GPU: pure geometry + hit-testing, exactly like the gizmo crate.
11//! The host UI keeps the drag→feature-commit logic and the dimension text labels;
12//! the engine supplies the geometry and the frame-space deltas / label anchors.
13//!
14//! # Overlay-feed API surface (the contract the Rust UI programs against)
15//!
16//! Five feeds set overlay geometry. Each is `wasm Engine.* → EngineState.* →`
17//! the `WidgetRegistry` method named below. Four are SPECIALIZED (they carry
18//! structured state their own pick/anchor/interaction reads); one is GENERAL
19//! (arbitrary geometry, no interaction). The general channel is the ONE uniform
20//! feed; the specialized feeds stay specialized (see "Why not one feed").
21//!
22//! | Feed (wasm)           | Registry setter        | Geometry it carries                                   | Screen-constant? | Specialized query / interaction |
23//! |-----------------------|------------------------|-------------------------------------------------------|------------------|---------------------------------|
24//! | `set_datums`          | `set_datums_json`    | datum planes (world- or screen-sized), axes, frames, curves | frames + screen-sized planes: yes | `datum_pick` → hit plane/axis name |
25//! | `set_dimensions`      | `set_dimensions_json`| linear / angular / radial dimension leaders + arrows  | yes              | `dimension_anchors` → `(id, world label anchor)` (the host projects to place text) |
26//! | `set_overlay`         | `set_overlay_json`   | GENERAL named groups of raw tris / lines / points     | no (points billboard) | none — pure display geometry |
27//! | `set_transform_gizmo` | `set_transform_json` | the move+rotate gizmo at a feature frame               | yes              | `transform_hover` / `transform_pick` / `transform_drag` / `transform_drag_end` |
28//! | `set_viewcube_enabled`| `set_viewcube_enabled`| the always-on ViewCube (own mini-camera + corner rect)| yes              | `viewcube_rect` / `viewcube_hover` / `viewcube_clear_hover` / `viewcube_click` |
29//!
30//! ## Draw path
31//! `build_main_overlay` merges datums + dimensions + transform + the general
32//! `set_overlay` groups into ONE full-viewport [`Overlay`] drawn in the render
33//! core's depth-cleared overlay pass, in a fixed order: planes, axes, frames,
34//! curves, dimensions, transform gizmo, then the general groups sorted by their
35//! `renderOrder`. The ViewCube alone draws in its own pass (`build_viewcube`),
36//! with a mini-camera in a scissored corner rect.
37//!
38//! ## Why not one uniform feed (rewrite-time note)
39//! The general `set_overlay` groups pre-expand their tris/lines into GPU-ready
40//! vertices AT FEED TIME (only point billboards are rebuilt per frame). The
41//! specialized widgets can't: dimension leaders, `datum_frame`s, screen-sized
42//! `datum_plane`s, the transform gizmo, and the ViewCube are all SCREEN-CONSTANT
43//! — sized from `world_per_pixel` against the LIVE camera every frame — so they
44//! must be rebuilt in `build_main_overlay`, not stored pre-expanded. They also
45//! carry structured state their interaction needs (datum names for
46//! `datum_pick`, dimension ids+types for `dimension_anchors`, the feature
47//! frame for `transform_drag`, region handles for the ViewCube). Routing any of
48//! them through the flat `set_overlay` group buffers would drop either the
49//! zoom-invariant sizing or that interaction, so it is NOT done here. The one
50//! genuinely static subset (world-sized planes, axes, curves) is left on
51//! `set_datums` rather than fragmenting a single feed across two paths. Any
52//! deeper unification is deferred to the engine-native UI rewrite.
53
54use crate::style::parse_css_hex;
55use crate::view::{Projection, ViewCamera};
56use brep_gizmos::datum::{DatumAxis, DatumPlane};
57use brep_gizmos::transform::{DragDelta, TransformGizmo};
58use brep_gizmos::view_cube::ViewCube;
59use brep_gizmos::{
60    curve_display, datum, dimension, Gizmo, GizmoCamera, HandleId, LineVertex, Overlay, TriVertex,
61    Vec3,
62};
63use serde_json::Value;
64
65/// Build the gizmo camera the widgets consume from the engine's live camera.
66/// The `view_proj` is byte-identical to the render core's `Camera::view_proj`
67/// (both from [`ViewCamera::resolve`]), so widgets project to exactly the view
68/// the engine renders solids with. `viewport` is CSS pixels (what the gizmo
69/// screen-constant sizing keys off).
70pub fn gizmo_camera(view: &ViewCamera) -> GizmoCamera {
71    let resolved = view.resolve();
72    // The TRUE orthonormal basis the view matrix is built from — `up` is the
73    // camera's actual (arcball-rolled) up, not `view.up` raw, so widgets that
74    // mirror the camera's orientation (the ViewCube) match the render exactly.
75    let (_, up, forward) = view.basis();
76    GizmoCamera {
77        view_proj: resolved.view_proj,
78        eye: v3f(view.eye),
79        forward: v3f(forward),
80        up: v3f(up),
81        viewport: [view.width as f32, view.height as f32],
82        orthographic: matches!(view.projection, Projection::Orthographic { .. }),
83    }
84}
85
86fn v3f(a: [f64; 3]) -> Vec3 {
87    Vec3::new(a[0] as f32, a[1] as f32, a[2] as f32)
88}
89
90fn vec3_of(v: &Value) -> Option<Vec3> {
91    let a = v.as_array()?;
92    Some(Vec3::new(
93        a.first()?.as_f64()? as f32,
94        a.get(1)?.as_f64()? as f32,
95        a.get(2)?.as_f64()? as f32,
96    ))
97}
98
99fn color_of(v: Option<&Value>, default: [f32; 4]) -> [f32; 4] {
100    match v.and_then(|v| v.as_str()).and_then(parse_css_hex) {
101        Some(rgb) => [rgb[0], rgb[1], rgb[2], 1.0],
102        None => default,
103    }
104}
105
106fn flag(v: &Value, key: &str) -> bool {
107    v.get(key).and_then(|b| b.as_bool()).unwrap_or(false)
108}
109
110/// A flat `[f32, …]` array from a JSON field (missing / wrong-typed → empty).
111fn f32_array(v: Option<&Value>) -> Vec<f32> {
112    v.and_then(|v| v.as_array())
113        .map(|a| a.iter().filter_map(|x| x.as_f64().map(|f| f as f32)).collect())
114        .unwrap_or_default()
115}
116
117/// The RGBA of vertex `i` from a flat rgb color array (last color repeats past
118/// the end; white when none), alpha forced to 1.
119fn rgb_at(colors: &[f32], i: usize) -> [f32; 4] {
120    let base = i * 3;
121    if base + 2 < colors.len() {
122        [colors[base], colors[base + 1], colors[base + 2], 1.0]
123    } else if colors.len() >= 3 {
124        let n = colors.len();
125        [colors[n - 3], colors[n - 2], colors[n - 1], 1.0]
126    } else {
127        [1.0, 1.0, 1.0, 1.0]
128    }
129}
130
131/// `[f32;3]` position of index `i` from a flat xyz array (zero past the end).
132fn pos_at(positions: &[f32], i: usize) -> Vec3 {
133    let base = i * 3;
134    if base + 2 < positions.len() {
135        Vec3::new(positions[base], positions[base + 1], positions[base + 2])
136    } else {
137        Vec3::ZERO
138    }
139}
140
141/// Emit a camera-facing, screen-constant-size quad (two tris) for an overlay
142/// point at `center`. Reuses the overlay tri pass — no point pipeline needed.
143fn push_point_quad(ov: &mut Overlay, center: Vec3, color: [f32; 4], size_px: f32, cam: &GizmoCamera) {
144    let half = 0.5 * size_px.max(1.0) * cam.world_per_pixel(center);
145    let right = cam.screen_right(center);
146    let up = right.cross(cam.forward).normalized();
147    let rx = right.scale(half);
148    let uy = up.scale(half);
149    let normal: [f32; 3] = cam.forward.scale(-1.0).into();
150    let a = center.sub(rx).sub(uy);
151    let b = center.add(rx).sub(uy);
152    let c = center.add(rx).add(uy);
153    let d = center.sub(rx).add(uy);
154    for p in [a, b, c, a, c, d] {
155        ov.tris.push(TriVertex { pos: p.into(), normal, color });
156    }
157}
158
159/// Brighten a display color for the selected/hovered emphasis (matches the
160/// gizmo crate's private `brighten`).
161fn brighten(c: [f32; 4]) -> [f32; 4] {
162    [
163        (c[0] * 1.35 + 0.1).min(1.0),
164        (c[1] * 1.35 + 0.1).min(1.0),
165        (c[2] * 1.35 + 0.1).min(1.0),
166        c[3],
167    ]
168}
169
170const DEFAULT_AXIS_COLOR: [f32; 4] = [0.72, 0.74, 0.80, 1.0];
171
172// --- widget state ----------------------------------------------------------
173
174struct PlaneW {
175    name: String,
176    origin: Vec3,
177    x: Vec3,
178    y: Vec3,
179    size: Option<f32>,
180    color: [f32; 4],
181    hot: bool,
182}
183
184struct AxisW {
185    name: String,
186    point: Vec3,
187    direction: Vec3,
188    length: f32,
189    color: [f32; 4],
190    hot: bool,
191}
192
193struct FrameW {
194    origin: Vec3,
195    x: Vec3,
196    y: Vec3,
197    z: Vec3,
198    px: f32,
199}
200
201struct CurveW {
202    points: Vec<Vec3>,
203    closed: bool,
204    color: [f32; 4],
205}
206
207/// One screen-constant-size overlay point (billboarded to a small camera-facing
208/// quad each frame, since the overlay pass has no dedicated point pipeline).
209struct OverlayPointW {
210    center: Vec3,
211    color: [f32; 4],
212}
213
214/// A GENERAL named overlay group (`set_overlay`): arbitrary triangle + line +
215/// point geometry fed straight from the host, drawn in the same depth-cleared overlay
216/// pass as the datum/dimension widgets. Groups are keyed by `name` (upsert) and
217/// ordered by `render_order`. Triangles/lines are pre-expanded at feed time; the
218/// camera-dependent point quads are built per frame.
219struct OverlayGroupW {
220    name: String,
221    render_order: i32,
222    tris: Vec<TriVertex>,
223    lines: Vec<LineVertex>,
224    points: Vec<OverlayPointW>,
225    point_size: f32,
226}
227
228/// Default screen size (CSS px) for an overlay point when unspecified.
229const DEFAULT_OVERLAY_POINT_PX: f32 = 6.0;
230
231impl OverlayGroupW {
232    /// Parse one group object from the `set_overlay` JSON. Flat layout:
233    /// `{name, renderOrder?, tris:{positions,colors,normals?},
234    ///   lines:{positions,colors}, points?:{positions,colors,size?}}`.
235    /// Positions are flat xyz; tri/line/point colors are flat rgb per vertex.
236    fn from_json(g: &Value) -> Self {
237        let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
238        let render_order = g.get("renderOrder").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
239
240        // Triangles: 3 vertices each; per-vertex color; optional per-vertex
241        // normal (else a computed flat face normal).
242        let mut tris = Vec::new();
243        if let Some(t) = g.get("tris") {
244            let positions = f32_array(t.get("positions"));
245            let colors = f32_array(t.get("colors"));
246            let normals = f32_array(t.get("normals"));
247            let vcount = positions.len() / 3;
248            let tri_count = vcount / 3;
249            for tri in 0..tri_count {
250                let vi = [tri * 3, tri * 3 + 1, tri * 3 + 2];
251                let p = [pos_at(&positions, vi[0]), pos_at(&positions, vi[1]), pos_at(&positions, vi[2])];
252                let face_n = p[1].sub(p[0]).cross(p[2].sub(p[0])).normalized();
253                for k in 0..3 {
254                    let idx = vi[k];
255                    let normal: [f32; 3] = if normals.len() >= (idx + 1) * 3 {
256                        [normals[idx * 3], normals[idx * 3 + 1], normals[idx * 3 + 2]]
257                    } else {
258                        face_n.into()
259                    };
260                    tris.push(TriVertex {
261                        pos: p[k].into(),
262                        normal,
263                        color: rgb_at(&colors, idx),
264                    });
265                }
266            }
267        }
268
269        // Lines: consecutive vertex pairs; per-vertex color (segment shading
270        // reads the first endpoint's color, matching the widget line pass).
271        let mut lines = Vec::new();
272        if let Some(l) = g.get("lines") {
273            let positions = f32_array(l.get("positions"));
274            let colors = f32_array(l.get("colors"));
275            let vcount = positions.len() / 3;
276            let seg_count = vcount / 2;
277            for seg in 0..seg_count {
278                let a = seg * 2;
279                let b = seg * 2 + 1;
280                lines.push(LineVertex { pos: pos_at(&positions, a).into(), color: rgb_at(&colors, a) });
281                lines.push(LineVertex { pos: pos_at(&positions, b).into(), color: rgb_at(&colors, b) });
282            }
283        }
284
285        // Points: screen-constant billboarded quads (expanded per frame).
286        let mut points = Vec::new();
287        let mut point_size = DEFAULT_OVERLAY_POINT_PX;
288        if let Some(pt) = g.get("points") {
289            let positions = f32_array(pt.get("positions"));
290            let colors = f32_array(pt.get("colors"));
291            point_size = pt.get("size").and_then(|v| v.as_f64()).unwrap_or(DEFAULT_OVERLAY_POINT_PX as f64) as f32;
292            let count = positions.len() / 3;
293            for i in 0..count {
294                points.push(OverlayPointW { center: pos_at(&positions, i), color: rgb_at(&colors, i) });
295            }
296        }
297
298        Self { name, render_order, tris, lines, points, point_size }
299    }
300
301    fn is_empty(&self) -> bool {
302        self.tris.is_empty() && self.lines.is_empty() && self.points.is_empty()
303    }
304}
305
306enum DimW {
307    Linear {
308        id: String,
309        a: Vec3,
310        b: Vec3,
311        offset_dir: Vec3,
312        offset: f32,
313        color: [f32; 4],
314    },
315    Angular {
316        id: String,
317        vertex: Vec3,
318        dir_a: Vec3,
319        dir_b: Vec3,
320        radius: f32,
321        color: [f32; 4],
322    },
323    Radial {
324        id: String,
325        center: Vec3,
326        point: Vec3,
327        color: [f32; 4],
328    },
329}
330
331/// The engine-side widget registry (one per engine).
332pub struct WidgetRegistry {
333    /// The ViewCube is always-on once enabled by the host UI (its retired
334    /// counterpart is deleted in the same change set).
335    pub viewcube_enabled: bool,
336    viewcube: ViewCube,
337    viewcube_hover: Option<HandleId>,
338    planes: Vec<PlaneW>,
339    axes: Vec<AxisW>,
340    frames: Vec<FrameW>,
341    curves: Vec<CurveW>,
342    dims: Vec<DimW>,
343    /// General overlay geometry channel (`set_overlay`): arbitrary named groups
344    /// of tris/lines/points (e.g. feature-dialog previews), engine-drawn.
345    overlay_groups: Vec<OverlayGroupW>,
346    transform: Option<TransformGizmo>,
347    transform_hover: Option<HandleId>,
348    transform_active: Option<HandleId>,
349}
350
351impl Default for WidgetRegistry {
352    fn default() -> Self {
353        Self {
354            viewcube_enabled: false,
355            viewcube: ViewCube::new(),
356            viewcube_hover: None,
357            planes: Vec::new(),
358            axes: Vec::new(),
359            frames: Vec::new(),
360            curves: Vec::new(),
361            dims: Vec::new(),
362            overlay_groups: Vec::new(),
363            transform: None,
364            transform_hover: None,
365            transform_active: None,
366        }
367    }
368}
369
370/// The ViewCube render frame: its overlay drawn with a mini-camera in a
371/// scissored corner sub-rect.
372pub struct ViewCubeFrame {
373    pub overlay: Overlay,
374    /// Column-major mini-camera world→clip.
375    pub view_proj: [[f32; 4]; 4],
376    pub forward: [f32; 3],
377    /// Corner rect `[x, y, w, h]` in CSS px (top-left origin, y down).
378    pub rect_css: [f32; 4],
379}
380
381/// A whole frame's worth of overlay-widget geometry, ready for the render
382/// core's overlay pass: the main overlay (datums / dimensions / curves /
383/// transform gizmo, full-viewport) + the optional ViewCube (own mini-camera +
384/// corner rect).
385pub struct WidgetOverlay {
386    pub main: Overlay,
387    pub viewcube: Option<ViewCubeFrame>,
388}
389
390impl WidgetOverlay {
391    /// Nothing to draw (skip the overlay passes entirely).
392    pub fn is_empty(&self) -> bool {
393        self.main.lines.is_empty() && self.main.tris.is_empty() && self.viewcube.is_none()
394    }
395}
396
397impl WidgetRegistry {
398    pub fn new() -> Self {
399        Self::default()
400    }
401
402    /// Any main-overlay widget geometry present (excludes the ViewCube, which
403    /// draws in its own pass).
404    pub fn has_main_overlay(&self) -> bool {
405        !self.planes.is_empty()
406            || !self.axes.is_empty()
407            || !self.frames.is_empty()
408            || !self.curves.is_empty()
409            || !self.dims.is_empty()
410            || !self.overlay_groups.is_empty()
411            || self.transform.is_some()
412    }
413
414    /// World-space AABB of the pushed overlay GROUPS — the `set_overlay` geometry
415    /// (the sketch curves + points, dimension leaders, constraint glyphs, datums,
416    /// curves). EXCLUDES the screen-constant transform gizmo + ViewCube (own passes).
417    /// Folded into the camera depth-range fit so orbiting an editing sketch doesn't
418    /// clip the overlay against the SOLIDS-ONLY scene bounds (the reported
419    /// sketch-clipping bug when "Lock to sketch" is off). Empty when no groups.
420    pub fn overlay_groups_bbox(&self) -> crate::camera::Aabb {
421        let mut bbox = crate::camera::Aabb::empty();
422        for group in &self.overlay_groups {
423            for v in &group.tris {
424                bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
425            }
426            for v in &group.lines {
427                bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
428            }
429            for point in &group.points {
430                let c: [f32; 3] = point.center.into();
431                bbox.expand([c[0] as f64, c[1] as f64, c[2] as f64]);
432            }
433        }
434        bbox
435    }
436
437    // --- JSON feed --------------------------------------------------------
438
439    /// Replace the datum / curve display set. Shape:
440    /// `{planes:[{name,origin,x,y,size?,color?,selected?,hovered?}],
441    ///   axes:[{name,point,direction,length,color?,selected?,hovered?}],
442    ///   frames:[{origin,x,y,z,px?}],
443    ///   curves:[{points:[[x,y,z]...],closed?,color?,selected?,hovered?}]}`.
444    pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
445        let value: Value =
446            serde_json::from_str(json).map_err(|e| format!("datums parse: {e}"))?;
447        self.planes.clear();
448        self.axes.clear();
449        self.frames.clear();
450        self.curves.clear();
451
452        if let Some(list) = value.get("planes").and_then(|v| v.as_array()) {
453            for p in list {
454                let (Some(origin), Some(x), Some(y)) = (
455                    p.get("origin").and_then(vec3_of),
456                    p.get("x").and_then(vec3_of),
457                    p.get("y").and_then(vec3_of),
458                ) else {
459                    continue;
460                };
461                self.planes.push(PlaneW {
462                    name: p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
463                    origin,
464                    x,
465                    y,
466                    size: p.get("size").and_then(|v| v.as_f64()).map(|s| s as f32),
467                    color: color_of(p.get("color"), datum::PLANE_COLOR),
468                    hot: flag(p, "selected") || flag(p, "hovered"),
469                });
470            }
471        }
472        if let Some(list) = value.get("axes").and_then(|v| v.as_array()) {
473            for a in list {
474                let (Some(point), Some(direction)) = (
475                    a.get("point").and_then(vec3_of),
476                    a.get("direction").and_then(vec3_of),
477                ) else {
478                    continue;
479                };
480                self.axes.push(AxisW {
481                    name: a.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
482                    point,
483                    direction,
484                    length: a.get("length").and_then(|v| v.as_f64()).unwrap_or(10.0) as f32,
485                    color: color_of(a.get("color"), DEFAULT_AXIS_COLOR),
486                    hot: flag(a, "selected") || flag(a, "hovered"),
487                });
488            }
489        }
490        if let Some(list) = value.get("frames").and_then(|v| v.as_array()) {
491            for f in list {
492                let (Some(origin), Some(x), Some(y), Some(z)) = (
493                    f.get("origin").and_then(vec3_of),
494                    f.get("x").and_then(vec3_of),
495                    f.get("y").and_then(vec3_of),
496                    f.get("z").and_then(vec3_of),
497                ) else {
498                    continue;
499                };
500                self.frames.push(FrameW {
501                    origin,
502                    x,
503                    y,
504                    z,
505                    px: f.get("px").and_then(|v| v.as_f64()).unwrap_or(datum::DEFAULT_FRAME_PX as f64)
506                        as f32,
507                });
508            }
509        }
510        if let Some(list) = value.get("curves").and_then(|v| v.as_array()) {
511            for c in list {
512                let points: Vec<Vec3> = c
513                    .get("points")
514                    .and_then(|v| v.as_array())
515                    .map(|arr| arr.iter().filter_map(vec3_of).collect())
516                    .unwrap_or_default();
517                if points.len() < 2 {
518                    continue;
519                }
520                let mut color = color_of(c.get("color"), curve_display::CURVE_COLOR);
521                if flag(c, "selected") || flag(c, "hovered") {
522                    color = brighten(color);
523                }
524                self.curves.push(CurveW {
525                    points,
526                    closed: flag(c, "closed"),
527                    color,
528                });
529            }
530        }
531        Ok(())
532    }
533
534    /// Replace/upsert the GENERAL overlay geometry channel (`set_overlay`).
535    /// Shape: `{groups:[{name, renderOrder?, tris:{positions,colors,normals?},
536    /// lines:{positions,colors}, points?:{positions,colors,size?}}]}`. Each group
537    /// UPSERTS by `name`; a group whose geometry is entirely empty REMOVES that
538    /// name; an empty (or missing) `groups` array CLEARS every group.
539    pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
540        let value: Value =
541            serde_json::from_str(json).map_err(|e| format!("overlay parse: {e}"))?;
542        let Some(list) = value.get("groups").and_then(|v| v.as_array()) else {
543            self.overlay_groups.clear();
544            return Ok(());
545        };
546        if list.is_empty() {
547            self.overlay_groups.clear();
548            return Ok(());
549        }
550        for g in list {
551            let group = OverlayGroupW::from_json(g);
552            // Upsert by name (a duplicate name replaces the prior group).
553            self.overlay_groups.retain(|x| x.name != group.name);
554            if !group.is_empty() {
555                self.overlay_groups.push(group);
556            }
557        }
558        Ok(())
559    }
560
561    /// The names of the currently-loaded (non-empty) general overlay groups — a read
562    /// accessor for tests / verification. An empty group is auto-removed on upsert, so
563    /// a name present here always carries geometry.
564    pub fn overlay_group_names(&self) -> Vec<&str> {
565        self.overlay_groups.iter().map(|g| g.name.as_str()).collect()
566    }
567
568    /// The currently-fed datum PLANES as `(name, emphasized)` — a read accessor for
569    /// tests / verification (the datum planes replace their set wholesale each
570    /// `set_datums_json`, so this is exactly the current construction-datum feed).
571    /// `emphasized` is the selected/hovered `hot` flag (a selected datum reads true).
572    pub fn datum_plane_names(&self) -> Vec<(&str, bool)> {
573        self.planes.iter().map(|p| (p.name.as_str(), p.hot)).collect()
574    }
575
576    /// Replace the feature-dimension set. Shape: an array of
577    /// `{id, type:"linear"|"angular"|"radial", ...}` — linear: `a,b,offsetDir,
578    /// offset`; angular: `vertex,dirA,dirB,radius`; radial: `center,pointOnCircle`.
579    pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
580        let value: Value =
581            serde_json::from_str(json).map_err(|e| format!("dimensions parse: {e}"))?;
582        self.dims.clear();
583        let Some(list) = value.as_array() else {
584            return Ok(());
585        };
586        for d in list {
587            let id = d.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
588            let color = color_of(d.get("color"), dimension::DIMENSION_COLOR);
589            match d.get("type").and_then(|v| v.as_str()) {
590                Some("linear") => {
591                    if let (Some(a), Some(b), Some(offset_dir)) = (
592                        d.get("a").and_then(vec3_of),
593                        d.get("b").and_then(vec3_of),
594                        d.get("offsetDir").and_then(vec3_of),
595                    ) {
596                        self.dims.push(DimW::Linear {
597                            id,
598                            a,
599                            b,
600                            offset_dir,
601                            offset: d.get("offset").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
602                            color,
603                        });
604                    }
605                }
606                Some("angular") => {
607                    if let (Some(vertex), Some(dir_a), Some(dir_b)) = (
608                        d.get("vertex").and_then(vec3_of),
609                        d.get("dirA").and_then(vec3_of),
610                        d.get("dirB").and_then(vec3_of),
611                    ) {
612                        self.dims.push(DimW::Angular {
613                            id,
614                            vertex,
615                            dir_a,
616                            dir_b,
617                            radius: d.get("radius").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
618                            color,
619                        });
620                    }
621                }
622                Some("radial") => {
623                    if let (Some(center), Some(point)) = (
624                        d.get("center").and_then(vec3_of),
625                        d.get("pointOnCircle").and_then(vec3_of),
626                    ) {
627                        self.dims.push(DimW::Radial {
628                            id,
629                            center,
630                            point,
631                            color,
632                        });
633                    }
634                }
635                _ => {}
636            }
637        }
638        Ok(())
639    }
640
641    /// Set (or clear, when `json == "null"`) the transform gizmo. Shape:
642    /// `{origin,x,y,z,showCenter?}` — the selected feature's frame (R28).
643    pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
644        let value: Value =
645            serde_json::from_str(json).map_err(|e| format!("transform parse: {e}"))?;
646        if value.is_null() {
647            self.transform = None;
648            self.transform_hover = None;
649            self.transform_active = None;
650            return Ok(());
651        }
652        let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
653        let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
654        let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
655        let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
656        let mut gz = TransformGizmo::default();
657        gz.set_frame(origin, x, y, z);
658        gz.show_center = value
659            .get("showCenter")
660            .and_then(|v| v.as_bool())
661            .unwrap_or(true);
662        self.transform = Some(gz);
663        Ok(())
664    }
665
666    pub fn set_viewcube_enabled(&mut self, enabled: bool) {
667        self.viewcube_enabled = enabled;
668        if !enabled {
669            self.viewcube_hover = None;
670        }
671    }
672
673    // --- geometry ---------------------------------------------------------
674
675    /// Build the main-overlay geometry (everything but the ViewCube).
676    pub fn build_main_overlay(&self, cam: &GizmoCamera) -> Overlay {
677        let mut ov = Overlay::new();
678
679        for p in &self.planes {
680            let color = if p.hot { brighten(p.color) } else { p.color };
681            let plane = match p.size {
682                Some(size) => datum::datum_plane(p.origin, p.x, p.y, size, color),
683                None => datum::datum_plane_screen(
684                    p.origin,
685                    p.x,
686                    p.y,
687                    datum::DEFAULT_PLANE_SCREEN_PX,
688                    color,
689                    cam,
690                ),
691            };
692            ov.extend(&plane);
693        }
694        for a in &self.axes {
695            let color = if a.hot { brighten(a.color) } else { a.color };
696            ov.extend(&datum::datum_axis(a.point, a.direction, a.length, color));
697        }
698        for f in &self.frames {
699            ov.extend(&datum::datum_frame(f.origin, f.x, f.y, f.z, f.px, cam));
700        }
701        for c in &self.curves {
702            ov.extend(&curve_display::polyline_display(&c.points, c.color, c.closed));
703        }
704        for d in &self.dims {
705            ov.extend(&self.build_dim(d, cam).overlay);
706        }
707        if let Some(gz) = &self.transform {
708            ov.extend(&gz.geometry(cam, self.transform_hover, self.transform_active));
709        }
710        // General overlay groups (set_overlay), in render_order (stable within
711        // equal orders). Tris/lines are pre-expanded; point quads are built here
712        // camera-facing + screen-constant since the overlay pass has no point
713        // pipeline of its own.
714        let mut order: Vec<&OverlayGroupW> = self.overlay_groups.iter().collect();
715        order.sort_by_key(|g| g.render_order);
716        for g in order {
717            ov.tris.extend_from_slice(&g.tris);
718            ov.lines.extend_from_slice(&g.lines);
719            for p in &g.points {
720                push_point_quad(&mut ov, p.center, p.color, g.point_size, cam);
721            }
722        }
723        ov
724    }
725
726    fn build_dim(&self, d: &DimW, cam: &GizmoCamera) -> dimension::DimensionAnnotation {
727        match d {
728            DimW::Linear {
729                a,
730                b,
731                offset_dir,
732                offset,
733                color,
734                ..
735            } => dimension::linear_dimension_colored(*a, *b, *offset_dir, *offset, cam, *color),
736            DimW::Angular {
737                vertex,
738                dir_a,
739                dir_b,
740                radius,
741                color,
742                ..
743            } => dimension::angular_dimension_colored(*vertex, *dir_a, *dir_b, *radius, cam, *color),
744            DimW::Radial {
745                center,
746                point,
747                color,
748                ..
749            } => dimension::radial_dimension_colored(*center, *point, cam, *color),
750        }
751    }
752
753    /// `(id, world label anchor)` for every dimension — the host projects
754    /// each with `world_to_screen` to place its text label (R29).
755    pub fn dimension_anchors(&self, cam: &GizmoCamera) -> Vec<(String, [f32; 3])> {
756        self.dims
757            .iter()
758            .map(|d| {
759                let id = match d {
760                    DimW::Linear { id, .. } | DimW::Angular { id, .. } | DimW::Radial { id, .. } => {
761                        id.clone()
762                    }
763                };
764                (id, self.build_dim(d, cam).label_anchor.into())
765            })
766            .collect()
767    }
768
769    /// Any overlay widget is present (main geometry or the ViewCube).
770    pub fn any_visible(&self) -> bool {
771        self.has_main_overlay() || self.viewcube_enabled
772    }
773
774    /// Build a whole frame's overlay geometry from the live camera.
775    pub fn build_overlay(&self, cam: &GizmoCamera) -> WidgetOverlay {
776        WidgetOverlay {
777            main: self.build_main_overlay(cam),
778            viewcube: self.build_viewcube(cam),
779        }
780    }
781
782    /// The ViewCube render frame (overlay + mini-camera + corner rect), or None
783    /// when disabled.
784    pub fn build_viewcube(&self, cam: &GizmoCamera) -> Option<ViewCubeFrame> {
785        if !self.viewcube_enabled {
786            return None;
787        }
788        let overlay = self.viewcube.geometry(cam, self.viewcube_hover, None);
789        let mini = self.viewcube.mini_camera(cam);
790        Some(ViewCubeFrame {
791            overlay,
792            view_proj: mini.view_proj,
793            forward: mini.forward.into(),
794            rect_css: self.viewcube.sub_rect(cam.viewport),
795        })
796    }
797
798    // --- hit testing / interaction ---------------------------------------
799
800    /// The ViewCube corner rect `[x, y, w, h]` (CSS px) — the host decides
801    /// whether to forward a pointer event and offsets it into cube-local coords.
802    pub fn viewcube_rect(&self, cam: &GizmoCamera) -> [f32; 4] {
803        self.viewcube.sub_rect(cam.viewport)
804    }
805
806    /// Hit-test the ViewCube at cube-local pixels; returns the region handle.
807    pub fn viewcube_hit(&self, cam: &GizmoCamera, local_x: f32, local_y: f32) -> Option<HandleId> {
808        if !self.viewcube_enabled {
809            return None;
810        }
811        self.viewcube.hit(cam, [local_x, local_y])
812    }
813
814    /// Set the ViewCube hover region (drives the highlight). Returns whether it
815    /// changed.
816    pub fn set_viewcube_hover(&mut self, handle: Option<HandleId>) -> bool {
817        if self.viewcube_hover != handle {
818            self.viewcube_hover = handle;
819            true
820        } else {
821            false
822        }
823    }
824
825    /// The world eye→target look direction + up hint for a ViewCube region.
826    pub fn viewcube_target(&self, handle: HandleId) -> ([f32; 3], [f32; 3]) {
827        (
828            ViewCube::target_view(handle).into(),
829            ViewCube::target_up(handle).into(),
830        )
831    }
832
833    /// Pick the datum plane/axis under a screen pixel; returns its name.
834    pub fn datum_pick(&self, cam: &GizmoCamera, x: f32, y: f32) -> Option<String> {
835        // Nearest wins by depth of the hit; planes and axes both tested. We keep
836        // it simple: axes first (thin, priority), then planes.
837        for a in &self.axes {
838            let gz = DatumAxis {
839                point: a.point,
840                direction: a.direction,
841                length: a.length,
842                color: a.color,
843                handle: 1,
844            };
845            if gz.hit(cam, [x, y]).is_some() && !a.name.is_empty() {
846                return Some(a.name.clone());
847            }
848        }
849        for p in &self.planes {
850            let gz = DatumPlane {
851                origin: p.origin,
852                x_axis: p.x,
853                y_axis: p.y,
854                size: p.size,
855                color: p.color,
856                handle: 1,
857            };
858            if gz.hit(cam, [x, y]).is_some() && !p.name.is_empty() {
859                return Some(p.name.clone());
860            }
861        }
862        None
863    }
864
865    pub fn has_transform(&self) -> bool {
866        self.transform.is_some()
867    }
868
869    /// The VISIBLE transform gizmo's current frame origin in world space, or `None`
870    /// when the gizmo is hidden. Reflects the last [`set_transform_json`] feed, so
871    /// it tracks the live-follow re-sync during a drag (Fix 3) — distinct from a
872    /// params-derived anchor, this proves the drawn widget actually moved.
873    pub fn transform_origin(&self) -> Option<[f32; 3]> {
874        self.transform.as_ref().map(|gz| [gz.origin.x, gz.origin.y, gz.origin.z])
875    }
876
877    /// Hit-test the transform gizmo; returns the handle (0 = none).
878    pub fn transform_hit(&self, cam: &GizmoCamera, x: f32, y: f32) -> HandleId {
879        self.transform
880            .as_ref()
881            .and_then(|gz| gz.hit(cam, [x, y]))
882            .unwrap_or(0)
883    }
884
885    pub fn set_transform_hover(&mut self, handle: HandleId) -> bool {
886        let handle = (handle != 0).then_some(handle);
887        if self.transform_hover != handle {
888            self.transform_hover = handle;
889            true
890        } else {
891            false
892        }
893    }
894
895    pub fn set_transform_active(&mut self, handle: HandleId) {
896        self.transform_active = (handle != 0).then_some(handle);
897    }
898
899    /// Compute a transform drag: frame-space delta + its world resolution, as
900    /// JSON for the host's feature-edit commit (R28). Resolved against the LIVE gizmo
901    /// frame (`self.transform`).
902    pub fn transform_drag_json(
903        &self,
904        cam: &GizmoCamera,
905        handle: HandleId,
906        sx: f32,
907        sy: f32,
908        cx: f32,
909        cy: f32,
910    ) -> String {
911        let Some(gz) = &self.transform else {
912            return "{\"kind\":\"none\"}".to_string();
913        };
914        drag_delta_json(gz, cam, handle, sx, sy, cx, cy)
915    }
916
917    /// Like [`transform_drag_json`] but resolved against an EXPLICIT frozen frame
918    /// (`{origin,x,y,z}`, the grab-time feature frame) instead of the live widget
919    /// gizmo. This lets the engine re-sync the VISIBLE gizmo to the moving feature
920    /// pose every drag frame (Fix 3 live-follow) while the delta stays anchored to
921    /// the grab frame, so the visual sync can't feed back into the drag math.
922    pub fn transform_drag_json_with_frame(
923        &self,
924        cam: &GizmoCamera,
925        frame_json: &str,
926        handle: HandleId,
927        sx: f32,
928        sy: f32,
929        cx: f32,
930        cy: f32,
931    ) -> String {
932        let value: Value = match serde_json::from_str(frame_json) {
933            Ok(v) => v,
934            Err(_) => return "{\"kind\":\"none\"}".to_string(),
935        };
936        let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
937        let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
938        let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
939        let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
940        let mut gz = TransformGizmo::default();
941        gz.set_frame(origin, x, y, z);
942        drag_delta_json(&gz, cam, handle, sx, sy, cx, cy)
943    }
944}
945
946/// The shared body of [`WidgetRegistry::transform_drag_json`] + its frozen-frame
947/// twin: resolve `gz`'s frame-space drag delta into the feature-edit-commit JSON.
948fn drag_delta_json(
949    gz: &TransformGizmo,
950    cam: &GizmoCamera,
951    handle: HandleId,
952    sx: f32,
953    sy: f32,
954    cx: f32,
955    cy: f32,
956) -> String {
957    let start = cam.ray_from_screen(sx, sy);
958    let current = cam.ray_from_screen(cx, cy);
959    match gz.drag_delta(cam, handle, start, current) {
960        DragDelta::Translate(v) => {
961            let world = gz.ex.scale(v.x).add(gz.ey.scale(v.y)).add(gz.ez.scale(v.z));
962            serde_json::json!({
963                "kind": "translate",
964                "local": [v.x, v.y, v.z],
965                "world": [world.x, world.y, world.z],
966            })
967            .to_string()
968        }
969        DragDelta::Rotate { axis_index, radians } => {
970            let axis = gz.axis(axis_index);
971            serde_json::json!({
972                "kind": "rotate",
973                "axisIndex": axis_index,
974                "axisWorld": [axis.x, axis.y, axis.z],
975                "radians": radians,
976            })
977            .to_string()
978        }
979        DragDelta::None => "{\"kind\":\"none\"}".to_string(),
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use crate::view::ViewCamera;
987
988    fn cam() -> ViewCamera {
989        let mut c = ViewCamera::default();
990        c.width = 800.0;
991        c.height = 600.0;
992        c.eye = [0.0, 0.0, 40.0];
993        c.target = [0.0, 0.0, 0.0];
994        c.up = [0.0, 1.0, 0.0];
995        c.projection = Projection::Orthographic { half_height: 20.0 };
996        c
997    }
998
999    #[test]
1000    fn datums_build_overlay_and_pick() {
1001        let mut reg = WidgetRegistry::new();
1002        reg.set_datums_json(
1003            r#"{"planes":[{"name":"P1","origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"size":10.0}],
1004                "axes":[{"name":"A1","point":[-5,0,0],"direction":[1,0,0],"length":10.0}]}"#,
1005        )
1006        .unwrap();
1007        assert!(reg.has_main_overlay());
1008        let gc = gizmo_camera(&cam());
1009        let ov = reg.build_main_overlay(&gc);
1010        assert!(!ov.tris.is_empty(), "plane fill tris");
1011        assert!(!ov.lines.is_empty(), "plane border + axis lines");
1012        // A ray through the viewport center hits the plane at the origin.
1013        let hit = reg.datum_pick(&gc, 400.0, 300.0);
1014        assert!(hit.is_some(), "center pixel picks a datum");
1015    }
1016
1017    #[test]
1018    fn transform_feeds_and_drags() {
1019        let mut reg = WidgetRegistry::new();
1020        reg.set_transform_json(r#"{"origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"z":[0,0,1]}"#)
1021            .unwrap();
1022        assert!(reg.has_transform());
1023        let gc = gizmo_camera(&cam());
1024        // A +X-axis drag returns a +X world translate.
1025        let out = reg.transform_drag_json(
1026            &gc,
1027            brep_gizmos::transform::HANDLE_AXIS_X,
1028            400.0,
1029            300.0,
1030            440.0,
1031            300.0,
1032        );
1033        let v: Value = serde_json::from_str(&out).unwrap();
1034        assert_eq!(v["kind"], "translate");
1035        assert!(v["world"][0].as_f64().unwrap() > 0.0, "world dx > 0: {out}");
1036        // Clearing removes it.
1037        reg.set_transform_json("null").unwrap();
1038        assert!(!reg.has_transform());
1039    }
1040
1041    #[test]
1042    fn viewcube_frame_and_snap() {
1043        let mut reg = WidgetRegistry::new();
1044        assert!(reg.build_viewcube(&gizmo_camera(&cam())).is_none());
1045        reg.set_viewcube_enabled(true);
1046        let gc = gizmo_camera(&cam());
1047        let frame = reg.build_viewcube(&gc).expect("enabled");
1048        assert!(!frame.overlay.tris.is_empty());
1049        let [x, _y, w, h] = reg.viewcube_rect(&gc);
1050        // Bottom-right corner of an 800-wide viewport.
1051        assert!(x > 600.0 && (w - 135.0).abs() < 1e-3 && (h - 135.0).abs() < 1e-3);
1052        // A click at the cube center hits the front-facing region and yields a
1053        // look direction.
1054        if let Some(handle) = reg.viewcube_hit(&gc, w / 2.0, h / 2.0) {
1055            let (dir, _up) = reg.viewcube_target(handle);
1056            assert!((dir[0].powi(2) + dir[1].powi(2) + dir[2].powi(2) - 1.0).abs() < 1e-3);
1057        }
1058    }
1059
1060    /// THE view-cube orientation contract: for every camera pose, each world
1061    /// axis must project to the SAME on-screen direction through the cube's
1062    /// mini-camera as through the main camera. This is exactly "the cube shows
1063    /// the orientation the model shows", roll included. Poses: the FRONT / TOP /
1064    /// RIGHT / ISO toolbar buttons (ground truth from `standard_view` — TOP's
1065    /// up is -Z, which the old forward-only heuristic got 180° wrong) plus an
1066    /// arcball-ROLLED camera (up rotated 30° about the view axis — the free
1067    /// arcball's everyday state, which a forward-only heuristic cannot follow).
1068    #[test]
1069    fn viewcube_orientation_matches_camera_for_standard_and_rolled_poses() {
1070        use brep_gizmos::view_cube::ViewCube;
1071        let mut poses: Vec<(String, ViewCamera)> = Vec::new();
1072        for name in ["FRONT", "TOP", "RIGHT", "ISO"] {
1073            let mut c = cam();
1074            assert!(c.standard_view(name), "{name}");
1075            poses.push((name.to_string(), c));
1076        }
1077        // FRONT rolled 30° about the view direction (what an arcball drag or
1078        // the cube's roll arrows produce).
1079        let mut rolled = cam();
1080        rolled.standard_view("FRONT");
1081        let (_, _, fwd) = rolled.basis();
1082        rolled.up = crate::view::rotate3(rolled.up, fwd, 30f64.to_radians());
1083        poses.push(("FRONT+30°roll".to_string(), rolled));
1084
1085        let cube = ViewCube::new();
1086        for (label, view) in &poses {
1087            let gc = gizmo_camera(view);
1088            let mini = cube.mini_camera(&gc);
1089            for (axis, world) in [
1090                ("X", [1.0f64, 0.0, 0.0]),
1091                ("Y", [0.0, 1.0, 0.0]),
1092                ("Z", [0.0, 0.0, 1.0]),
1093            ] {
1094                // Main-camera screen direction of this axis (CSS px, y down).
1095                let (ox, oy, _) = view.project([0.0, 0.0, 0.0]);
1096                let (ax, ay, _) = view.project(world);
1097                let dm = [ax - ox, ay - oy];
1098                // Mini-camera screen direction of the same axis.
1099                let c0 = mini.world_to_screen(Vec3::ZERO).expect("origin visible");
1100                let c1 = mini
1101                    .world_to_screen(Vec3::new(world[0] as f32 * 0.4, world[1] as f32 * 0.4, world[2] as f32 * 0.4))
1102                    .expect("axis tip visible");
1103                let dc = [(c1[0] - c0[0]) as f64, (c1[1] - c0[1]) as f64];
1104                let lm = (dm[0] * dm[0] + dm[1] * dm[1]).sqrt();
1105                let lc = (dc[0] * dc[0] + dc[1] * dc[1]).sqrt();
1106                if lm < 1e-3 || lc < 1e-3 {
1107                    // Axis ~parallel to the view direction: no screen direction
1108                    // to compare — BOTH cameras must agree it vanishes.
1109                    assert!(
1110                        lm < 1.0 && lc < 1.0,
1111                        "{label}/{axis}: axis vanishes in one camera only (main {lm}, cube {lc})"
1112                    );
1113                    continue;
1114                }
1115                let dot = (dm[0] * dc[0] + dm[1] * dc[1]) / (lm * lc);
1116                assert!(
1117                    dot > 0.999,
1118                    "{label}: world {axis} axis points a different way on the cube \
1119                     (main dir {dm:?}, cube dir {dc:?}, dot {dot})"
1120                );
1121            }
1122        }
1123    }
1124
1125    /// Cube click targets round-trip the toolbar buttons: with the camera on a
1126    /// standard view, the cube's CENTER pixel hits the same-named face region,
1127    /// and snapping to that region reproduces the button's view direction + up.
1128    /// Also pins the inverse mapping under the TOP button's -Z up: a click
1129    /// BELOW center must land on the region toward world +Z (screen-down in the
1130    /// TOP view is +Z precisely because up = -Z; the old heuristic had it
1131    /// backwards).
1132    #[test]
1133    fn viewcube_click_targets_round_trip_standard_views() {
1134        use brep_gizmos::view_cube::ViewCube;
1135        let mut reg = WidgetRegistry::new();
1136        reg.set_viewcube_enabled(true);
1137        let cube_faces = [
1138            ("FRONT", ViewCube::FRONT),
1139            ("BACK", ViewCube::BACK),
1140            ("RIGHT", ViewCube::RIGHT),
1141            ("LEFT", ViewCube::LEFT),
1142            ("TOP", ViewCube::TOP),
1143            ("BOTTOM", ViewCube::BOTTOM),
1144        ];
1145        for (name, want) in cube_faces {
1146            let mut view = cam();
1147            assert!(view.standard_view(name));
1148            let gc = gizmo_camera(&view);
1149            let [_, _, w, h] = reg.viewcube_rect(&gc);
1150            let hit = reg.viewcube_hit(&gc, w / 2.0, h / 2.0);
1151            assert_eq!(hit, Some(want), "{name}: center hit");
1152            // Snapping to the hit region reproduces this button's pose.
1153            let (dir, up) = reg.viewcube_target(want);
1154            let (_, _, fwd) = view.basis();
1155            for k in 0..3 {
1156                assert!((dir[k] as f64 - fwd[k]).abs() < 1e-6, "{name}: dir");
1157                assert!((up[k] as f64 - view.up[k]).abs() < 1e-6, "{name}: up");
1158            }
1159        }
1160        // TOP view, click below center (still on the cube, inside the edge
1161        // band — further down is the nav arrow) → the +Z (FRONT-side) region.
1162        let mut view = cam();
1163        view.standard_view("TOP");
1164        let gc = gizmo_camera(&view);
1165        let [_, _, w, h] = reg.viewcube_rect(&gc);
1166        let hit = reg.viewcube_hit(&gc, w / 2.0, h * 0.66).expect("hit the cube");
1167        assert!(
1168            ViewCube::region_name(hit).contains("FRONT"),
1169            "TOP view: below-center click must reach toward +Z/FRONT, got {}",
1170            ViewCube::region_name(hit)
1171        );
1172    }
1173
1174    #[test]
1175    fn overlay_groups_build_upsert_and_clear() {
1176        let mut reg = WidgetRegistry::new();
1177        // A group with one triangle, one line segment, and one point.
1178        reg.set_overlay_json(
1179            r#"{"groups":[{"name":"prev","renderOrder":2,
1180                "tris":{"positions":[0,0,0, 1,0,0, 0,1,0],"colors":[1,0,0, 0,1,0, 0,0,1]},
1181                "lines":{"positions":[0,0,0, 2,2,2],"colors":[1,1,0, 1,1,0]},
1182                "points":{"positions":[3,3,3],"colors":[0,1,1],"size":8}}]}"#,
1183        )
1184        .unwrap();
1185        assert!(reg.has_main_overlay());
1186        let gc = gizmo_camera(&cam());
1187        let ov = reg.build_main_overlay(&gc);
1188        // 3 tri verts from the triangle + 6 from the point quad (2 tris).
1189        assert_eq!(ov.tris.len(), 3 + 6, "tri verts (triangle + point quad)");
1190        assert_eq!(ov.lines.len(), 2, "one line segment (a pair)");
1191        // Per-vertex tri color survives the marshal.
1192        assert_eq!(ov.tris[0].color, [1.0, 0.0, 0.0, 1.0]);
1193
1194        // Upsert the same name with new geometry (only lines now).
1195        reg.set_overlay_json(
1196            r#"{"groups":[{"name":"prev","lines":{"positions":[0,0,0, 1,1,1],"colors":[1,1,1, 1,1,1]}}]}"#,
1197        )
1198        .unwrap();
1199        let ov = reg.build_main_overlay(&gc);
1200        assert!(ov.tris.is_empty(), "upsert replaced geometry (no tris)");
1201        assert_eq!(ov.lines.len(), 2);
1202
1203        // An empty group removes that name.
1204        reg.set_overlay_json(r#"{"groups":[{"name":"prev"}]}"#).unwrap();
1205        assert!(!reg.has_main_overlay(), "empty group removed the only overlay");
1206
1207        // Empty groups list clears everything.
1208        reg.set_overlay_json(r#"{"groups":[{"name":"a","lines":{"positions":[0,0,0,1,0,0],"colors":[1,1,1,1,1,1]}}]}"#).unwrap();
1209        assert!(reg.has_main_overlay());
1210        reg.set_overlay_json(r#"{"groups":[]}"#).unwrap();
1211        assert!(!reg.has_main_overlay(), "empty groups cleared all");
1212    }
1213
1214    #[test]
1215    fn overlay_groups_bbox_covers_pushed_geometry() {
1216        let mut reg = WidgetRegistry::new();
1217        assert!(reg.overlay_groups_bbox().is_empty(), "no groups -> empty bbox");
1218        // A line from (-5,-5,-5) to (10,20,30) plus a far point at (100,0,-2).
1219        reg.set_overlay_json(
1220            r#"{"groups":[{"name":"sk",
1221                "lines":{"positions":[-5,-5,-5, 10,20,30],"colors":[1,1,1, 1,1,1]},
1222                "points":{"positions":[100,0,-2],"colors":[0,1,0],"size":8}}]}"#,
1223        )
1224        .unwrap();
1225        let bbox = reg.overlay_groups_bbox();
1226        // Must cover BOTH line endpoints AND the far point, so folding it into the
1227        // depth-range fit stops orbiting from clipping the overlay.
1228        assert!(bbox.min[0] <= -5.0 && bbox.min[1] <= -5.0 && bbox.min[2] <= -5.0, "min {:?}", bbox.min);
1229        assert!(bbox.max[0] >= 100.0 && bbox.max[1] >= 20.0 && bbox.max[2] >= 30.0, "max {:?}", bbox.max);
1230    }
1231
1232    #[test]
1233    fn dimensions_build_and_expose_anchors() {
1234        let mut reg = WidgetRegistry::new();
1235        reg.set_dimensions_json(
1236            r#"[{"id":"d1","type":"linear","a":[-3,0,0],"b":[3,0,0],"offsetDir":[0,-1,0],"offset":2.0}]"#,
1237        )
1238        .unwrap();
1239        let gc = gizmo_camera(&cam());
1240        let ov = reg.build_main_overlay(&gc);
1241        assert!(!ov.lines.is_empty() && !ov.tris.is_empty());
1242        let anchors = reg.dimension_anchors(&gc);
1243        assert_eq!(anchors.len(), 1);
1244        assert_eq!(anchors[0].0, "d1");
1245    }
1246}