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    /// Number of LEADING `main.tris` vertices belonging to the datum/construction
388    /// planes (drawn with a no-depth-write pipeline so they never occlude the
389    /// gizmos/dimensions that follow). The remaining tris are the gizmo geometry.
390    pub plane_tri_verts: usize,
391    pub viewcube: Option<ViewCubeFrame>,
392}
393
394impl WidgetOverlay {
395    /// Nothing to draw (skip the overlay passes entirely).
396    pub fn is_empty(&self) -> bool {
397        self.main.lines.is_empty() && self.main.tris.is_empty() && self.viewcube.is_none()
398    }
399
400    /// The world-space AABB of the MAIN overlay (datums, axes, frames, curves,
401    /// dimensions, transform gizmo, general groups) — folded into the camera
402    /// depth-range fit so construction geometry beyond the solids never clips.
403    /// EXCLUDES the ViewCube: it lives in the separate `viewcube` field and is
404    /// drawn with its own mini-camera, so its coords are NOT world space —
405    /// bboxing them would corrupt the fit. Point groups are already expanded
406    /// into `main.tris` (`push_point_quad`), so tris + lines cover everything.
407    /// Empty when the main overlay is empty. A single vertex absurdly far out
408    /// (|coord| > 1e6) is skipped rather than unioned, so a pathological
409    /// "infinite" helper can't blow the depth window open (world axes are
410    /// screen-constant and finite, but be defensive).
411    pub fn world_bbox(&self) -> crate::camera::Aabb {
412        let mut bbox = crate::camera::Aabb::empty();
413        let mut fold = |pos: &[f32; 3]| {
414            if pos.iter().any(|c| c.abs() > 1.0e6) {
415                return;
416            }
417            bbox.expand([pos[0] as f64, pos[1] as f64, pos[2] as f64]);
418        };
419        for v in &self.main.tris {
420            fold(&v.pos);
421        }
422        for v in &self.main.lines {
423            fold(&v.pos);
424        }
425        bbox
426    }
427}
428
429impl WidgetRegistry {
430    pub fn new() -> Self {
431        Self::default()
432    }
433
434    /// Any main-overlay widget geometry present (excludes the ViewCube, which
435    /// draws in its own pass).
436    pub fn has_main_overlay(&self) -> bool {
437        !self.planes.is_empty()
438            || !self.axes.is_empty()
439            || !self.frames.is_empty()
440            || !self.curves.is_empty()
441            || !self.dims.is_empty()
442            || !self.overlay_groups.is_empty()
443            || self.transform.is_some()
444    }
445
446    /// World-space AABB of the pushed overlay GROUPS — the `set_overlay` geometry
447    /// (the sketch curves + points, dimension leaders, constraint glyphs, datums,
448    /// curves). EXCLUDES the screen-constant transform gizmo + ViewCube (own passes).
449    /// Folded into the camera depth-range fit so orbiting an editing sketch doesn't
450    /// clip the overlay against the SOLIDS-ONLY scene bounds (the reported
451    /// sketch-clipping bug when "Lock to sketch" is off). Empty when no groups.
452    pub fn overlay_groups_bbox(&self) -> crate::camera::Aabb {
453        let mut bbox = crate::camera::Aabb::empty();
454        for group in &self.overlay_groups {
455            for v in &group.tris {
456                bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
457            }
458            for v in &group.lines {
459                bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
460            }
461            for point in &group.points {
462                let c: [f32; 3] = point.center.into();
463                bbox.expand([c[0] as f64, c[1] as f64, c[2] as f64]);
464            }
465        }
466        bbox
467    }
468
469    // --- JSON feed --------------------------------------------------------
470
471    /// Replace the datum / curve display set. Shape:
472    /// `{planes:[{name,origin,x,y,size?,color?,selected?,hovered?}],
473    ///   axes:[{name,point,direction,length,color?,selected?,hovered?}],
474    ///   frames:[{origin,x,y,z,px?}],
475    ///   curves:[{points:[[x,y,z]...],closed?,color?,selected?,hovered?}]}`.
476    pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
477        let value: Value =
478            serde_json::from_str(json).map_err(|e| format!("datums parse: {e}"))?;
479        self.planes.clear();
480        self.axes.clear();
481        self.frames.clear();
482        self.curves.clear();
483
484        if let Some(list) = value.get("planes").and_then(|v| v.as_array()) {
485            for p in list {
486                let (Some(origin), Some(x), Some(y)) = (
487                    p.get("origin").and_then(vec3_of),
488                    p.get("x").and_then(vec3_of),
489                    p.get("y").and_then(vec3_of),
490                ) else {
491                    continue;
492                };
493                self.planes.push(PlaneW {
494                    name: p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
495                    origin,
496                    x,
497                    y,
498                    size: p.get("size").and_then(|v| v.as_f64()).map(|s| s as f32),
499                    color: color_of(p.get("color"), datum::PLANE_COLOR),
500                    hot: flag(p, "selected") || flag(p, "hovered"),
501                });
502            }
503        }
504        if let Some(list) = value.get("axes").and_then(|v| v.as_array()) {
505            for a in list {
506                let (Some(point), Some(direction)) = (
507                    a.get("point").and_then(vec3_of),
508                    a.get("direction").and_then(vec3_of),
509                ) else {
510                    continue;
511                };
512                self.axes.push(AxisW {
513                    name: a.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
514                    point,
515                    direction,
516                    length: a.get("length").and_then(|v| v.as_f64()).unwrap_or(10.0) as f32,
517                    color: color_of(a.get("color"), DEFAULT_AXIS_COLOR),
518                    hot: flag(a, "selected") || flag(a, "hovered"),
519                });
520            }
521        }
522        if let Some(list) = value.get("frames").and_then(|v| v.as_array()) {
523            for f in list {
524                let (Some(origin), Some(x), Some(y), Some(z)) = (
525                    f.get("origin").and_then(vec3_of),
526                    f.get("x").and_then(vec3_of),
527                    f.get("y").and_then(vec3_of),
528                    f.get("z").and_then(vec3_of),
529                ) else {
530                    continue;
531                };
532                self.frames.push(FrameW {
533                    origin,
534                    x,
535                    y,
536                    z,
537                    px: f.get("px").and_then(|v| v.as_f64()).unwrap_or(datum::DEFAULT_FRAME_PX as f64)
538                        as f32,
539                });
540            }
541        }
542        if let Some(list) = value.get("curves").and_then(|v| v.as_array()) {
543            for c in list {
544                let points: Vec<Vec3> = c
545                    .get("points")
546                    .and_then(|v| v.as_array())
547                    .map(|arr| arr.iter().filter_map(vec3_of).collect())
548                    .unwrap_or_default();
549                if points.len() < 2 {
550                    continue;
551                }
552                let mut color = color_of(c.get("color"), curve_display::CURVE_COLOR);
553                if flag(c, "selected") || flag(c, "hovered") {
554                    color = brighten(color);
555                }
556                self.curves.push(CurveW {
557                    points,
558                    closed: flag(c, "closed"),
559                    color,
560                });
561            }
562        }
563        Ok(())
564    }
565
566    /// Replace/upsert the GENERAL overlay geometry channel (`set_overlay`).
567    /// Shape: `{groups:[{name, renderOrder?, tris:{positions,colors,normals?},
568    /// lines:{positions,colors}, points?:{positions,colors,size?}}]}`. Each group
569    /// UPSERTS by `name`; a group whose geometry is entirely empty REMOVES that
570    /// name; an empty (or missing) `groups` array CLEARS every group.
571    pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
572        let value: Value =
573            serde_json::from_str(json).map_err(|e| format!("overlay parse: {e}"))?;
574        let Some(list) = value.get("groups").and_then(|v| v.as_array()) else {
575            self.overlay_groups.clear();
576            return Ok(());
577        };
578        if list.is_empty() {
579            self.overlay_groups.clear();
580            return Ok(());
581        }
582        for g in list {
583            let group = OverlayGroupW::from_json(g);
584            // Upsert by name (a duplicate name replaces the prior group).
585            self.overlay_groups.retain(|x| x.name != group.name);
586            if !group.is_empty() {
587                self.overlay_groups.push(group);
588            }
589        }
590        Ok(())
591    }
592
593    /// The names of the currently-loaded (non-empty) general overlay groups — a read
594    /// accessor for tests / verification. An empty group is auto-removed on upsert, so
595    /// a name present here always carries geometry.
596    pub fn overlay_group_names(&self) -> Vec<&str> {
597        self.overlay_groups.iter().map(|g| g.name.as_str()).collect()
598    }
599
600    /// The currently-fed datum PLANES as `(name, emphasized)` — a read accessor for
601    /// tests / verification (the datum planes replace their set wholesale each
602    /// `set_datums_json`, so this is exactly the current construction-datum feed).
603    /// `emphasized` is the selected/hovered `hot` flag (a selected datum reads true).
604    pub fn datum_plane_names(&self) -> Vec<(&str, bool)> {
605        self.planes.iter().map(|p| (p.name.as_str(), p.hot)).collect()
606    }
607
608    /// Replace the feature-dimension set. Shape: an array of
609    /// `{id, type:"linear"|"angular"|"radial", ...}` — linear: `a,b,offsetDir,
610    /// offset`; angular: `vertex,dirA,dirB,radius`; radial: `center,pointOnCircle`.
611    pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
612        let value: Value =
613            serde_json::from_str(json).map_err(|e| format!("dimensions parse: {e}"))?;
614        self.dims.clear();
615        let Some(list) = value.as_array() else {
616            return Ok(());
617        };
618        for d in list {
619            let id = d.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
620            let color = color_of(d.get("color"), dimension::DIMENSION_COLOR);
621            match d.get("type").and_then(|v| v.as_str()) {
622                Some("linear") => {
623                    if let (Some(a), Some(b), Some(offset_dir)) = (
624                        d.get("a").and_then(vec3_of),
625                        d.get("b").and_then(vec3_of),
626                        d.get("offsetDir").and_then(vec3_of),
627                    ) {
628                        self.dims.push(DimW::Linear {
629                            id,
630                            a,
631                            b,
632                            offset_dir,
633                            offset: d.get("offset").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
634                            color,
635                        });
636                    }
637                }
638                Some("angular") => {
639                    if let (Some(vertex), Some(dir_a), Some(dir_b)) = (
640                        d.get("vertex").and_then(vec3_of),
641                        d.get("dirA").and_then(vec3_of),
642                        d.get("dirB").and_then(vec3_of),
643                    ) {
644                        self.dims.push(DimW::Angular {
645                            id,
646                            vertex,
647                            dir_a,
648                            dir_b,
649                            radius: d.get("radius").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
650                            color,
651                        });
652                    }
653                }
654                Some("radial") => {
655                    if let (Some(center), Some(point)) = (
656                        d.get("center").and_then(vec3_of),
657                        d.get("pointOnCircle").and_then(vec3_of),
658                    ) {
659                        self.dims.push(DimW::Radial {
660                            id,
661                            center,
662                            point,
663                            color,
664                        });
665                    }
666                }
667                _ => {}
668            }
669        }
670        Ok(())
671    }
672
673    /// Set (or clear, when `json == "null"`) the transform gizmo. Shape:
674    /// `{origin,x,y,z,showCenter?,showAxes?,showRings?}` — the selected
675    /// feature's frame (R28); the optional show flags (default true) carve the
676    /// translate-only / rotate-only variants the component Move toggle cycles.
677    pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
678        let value: Value =
679            serde_json::from_str(json).map_err(|e| format!("transform parse: {e}"))?;
680        if value.is_null() {
681            self.transform = None;
682            self.transform_hover = None;
683            self.transform_active = None;
684            return Ok(());
685        }
686        let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
687        let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
688        let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
689        let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
690        let mut gz = TransformGizmo::default();
691        gz.set_frame(origin, x, y, z);
692        let flag = |key: &str| value.get(key).and_then(|v| v.as_bool()).unwrap_or(true);
693        gz.show_center = flag("showCenter");
694        gz.show_axes = flag("showAxes");
695        gz.show_rings = flag("showRings");
696        self.transform = Some(gz);
697        Ok(())
698    }
699
700    pub fn set_viewcube_enabled(&mut self, enabled: bool) {
701        self.viewcube_enabled = enabled;
702        if !enabled {
703            self.viewcube_hover = None;
704        }
705    }
706
707    /// Set the ViewCube's on-screen edge length (CSS px). ONE size field feeds both
708    /// the rendered mini-camera viewport (`build_viewcube`) and the hit-test corner
709    /// rect (`viewcube_rect`), so the drawn cube and its clickable region always
710    /// scale together. Driven from `RenderSettings::viewcube_size_px` on every
711    /// settings apply. Guarded to a >= 1px positive size so a bad feed can't
712    /// collapse the rect.
713    pub fn set_viewcube_size(&mut self, size_px: f32) {
714        if size_px.is_finite() {
715            self.viewcube.size = size_px.max(1.0);
716        }
717    }
718
719    // --- geometry ---------------------------------------------------------
720
721    /// Build the main-overlay geometry (everything but the ViewCube).
722    /// Build the main overlay geometry AND the count of leading tri vertices that
723    /// belong to the datum/construction PLANES (always emitted FIRST). The render
724    /// core draws those with a NO-depth-write pipeline so a translucent plane can
725    /// never occlude the gizmos/dimensions that follow.
726    pub fn build_main_overlay(&self, cam: &GizmoCamera) -> (Overlay, usize) {
727        let mut ov = Overlay::new();
728
729        for p in &self.planes {
730            let color = if p.hot { brighten(p.color) } else { p.color };
731            let plane = match p.size {
732                Some(size) => datum::datum_plane(p.origin, p.x, p.y, size, color),
733                None => datum::datum_plane_screen(
734                    p.origin,
735                    p.x,
736                    p.y,
737                    datum::DEFAULT_PLANE_SCREEN_PX,
738                    color,
739                    cam,
740                ),
741            };
742            ov.extend(&plane);
743        }
744        // Everything after this point (axes, frames, curves, dimensions, gizmo,
745        // groups) is drawn with the depth-writing pipeline so it self-occludes.
746        let plane_tri_verts = ov.tris.len();
747        for a in &self.axes {
748            let color = if a.hot { brighten(a.color) } else { a.color };
749            ov.extend(&datum::datum_axis(a.point, a.direction, a.length, color));
750        }
751        for f in &self.frames {
752            ov.extend(&datum::datum_frame(f.origin, f.x, f.y, f.z, f.px, cam));
753        }
754        for c in &self.curves {
755            ov.extend(&curve_display::polyline_display(&c.points, c.color, c.closed));
756        }
757        for d in &self.dims {
758            ov.extend(&self.build_dim(d, cam).overlay);
759        }
760        if let Some(gz) = &self.transform {
761            ov.extend(&gz.geometry(cam, self.transform_hover, self.transform_active));
762        }
763        // General overlay groups (set_overlay), in render_order (stable within
764        // equal orders). Tris/lines are pre-expanded; point quads are built here
765        // camera-facing + screen-constant since the overlay pass has no point
766        // pipeline of its own.
767        let mut order: Vec<&OverlayGroupW> = self.overlay_groups.iter().collect();
768        order.sort_by_key(|g| g.render_order);
769        for g in order {
770            ov.tris.extend_from_slice(&g.tris);
771            ov.lines.extend_from_slice(&g.lines);
772            for p in &g.points {
773                push_point_quad(&mut ov, p.center, p.color, g.point_size, cam);
774            }
775        }
776        (ov, plane_tri_verts)
777    }
778
779    fn build_dim(&self, d: &DimW, cam: &GizmoCamera) -> dimension::DimensionAnnotation {
780        match d {
781            DimW::Linear {
782                a,
783                b,
784                offset_dir,
785                offset,
786                color,
787                ..
788            } => dimension::linear_dimension_colored(*a, *b, *offset_dir, *offset, cam, *color),
789            DimW::Angular {
790                vertex,
791                dir_a,
792                dir_b,
793                radius,
794                color,
795                ..
796            } => dimension::angular_dimension_colored(*vertex, *dir_a, *dir_b, *radius, cam, *color),
797            DimW::Radial {
798                center,
799                point,
800                color,
801                ..
802            } => dimension::radial_dimension_colored(*center, *point, cam, *color),
803        }
804    }
805
806    /// `(id, world label anchor)` for every dimension — the host projects
807    /// each with `world_to_screen` to place its text label (R29).
808    pub fn dimension_anchors(&self, cam: &GizmoCamera) -> Vec<(String, [f32; 3])> {
809        self.dims
810            .iter()
811            .map(|d| {
812                let id = match d {
813                    DimW::Linear { id, .. } | DimW::Angular { id, .. } | DimW::Radial { id, .. } => {
814                        id.clone()
815                    }
816                };
817                (id, self.build_dim(d, cam).label_anchor.into())
818            })
819            .collect()
820    }
821
822    /// Any overlay widget is present (main geometry or the ViewCube).
823    pub fn any_visible(&self) -> bool {
824        self.has_main_overlay() || self.viewcube_enabled
825    }
826
827    /// Build a whole frame's overlay geometry from the live camera.
828    pub fn build_overlay(&self, cam: &GizmoCamera) -> WidgetOverlay {
829        let (main, plane_tri_verts) = self.build_main_overlay(cam);
830        WidgetOverlay {
831            main,
832            plane_tri_verts,
833            viewcube: self.build_viewcube(cam),
834        }
835    }
836
837    /// The ViewCube render frame (overlay + mini-camera + corner rect), or None
838    /// when disabled.
839    pub fn build_viewcube(&self, cam: &GizmoCamera) -> Option<ViewCubeFrame> {
840        if !self.viewcube_enabled {
841            return None;
842        }
843        let overlay = self.viewcube.geometry(cam, self.viewcube_hover, None);
844        let mini = self.viewcube.mini_camera(cam);
845        Some(ViewCubeFrame {
846            overlay,
847            view_proj: mini.view_proj,
848            forward: mini.forward.into(),
849            rect_css: self.viewcube.sub_rect(cam.viewport),
850        })
851    }
852
853    // --- hit testing / interaction ---------------------------------------
854
855    /// The ViewCube corner rect `[x, y, w, h]` (CSS px) — the host decides
856    /// whether to forward a pointer event and offsets it into cube-local coords.
857    pub fn viewcube_rect(&self, cam: &GizmoCamera) -> [f32; 4] {
858        self.viewcube.sub_rect(cam.viewport)
859    }
860
861    /// Hit-test the ViewCube at cube-local pixels; returns the region handle.
862    pub fn viewcube_hit(&self, cam: &GizmoCamera, local_x: f32, local_y: f32) -> Option<HandleId> {
863        if !self.viewcube_enabled {
864            return None;
865        }
866        self.viewcube.hit(cam, [local_x, local_y])
867    }
868
869    /// Set the ViewCube hover region (drives the highlight). Returns whether it
870    /// changed.
871    pub fn set_viewcube_hover(&mut self, handle: Option<HandleId>) -> bool {
872        if self.viewcube_hover != handle {
873            self.viewcube_hover = handle;
874            true
875        } else {
876            false
877        }
878    }
879
880    /// The world eye→target look direction + up hint for a ViewCube region.
881    pub fn viewcube_target(&self, handle: HandleId) -> ([f32; 3], [f32; 3]) {
882        (
883            ViewCube::target_view(handle).into(),
884            ViewCube::target_up(handle).into(),
885        )
886    }
887
888    /// EVERY fed datum PLANE whose DRAWN card the pointer ray crosses, as
889    /// `(name, world hit point)` — the multi-hit sibling of [`datum_pick`], which
890    /// stops at the first hit and also considers axes.
891    ///
892    /// The bound is the rectangle the renderer draws, not the infinite plane:
893    /// [`DatumPlane::hit_point`] is the same `half()` extent [`build_main_overlay`]
894    /// draws with, evaluated against the LIVE camera on every call — so a
895    /// screen-constant card's pickable region tracks its drawn size across a zoom
896    /// (nothing is baked). Either face of the card hits. Unnamed planes are
897    /// skipped (nothing could be selected by them).
898    ///
899    /// The engine turns these into `PickKind::Plane` candidates so a construction
900    /// plane competes in the ordinary pick list instead of only on a geometry
901    /// miss (see `EngineState::pick_candidates_at`).
902    ///
903    /// [`datum_pick`]: Self::datum_pick
904    /// [`build_main_overlay`]: Self::build_main_overlay
905    pub fn datum_plane_hits(&self, cam: &GizmoCamera, x: f32, y: f32) -> Vec<(String, [f32; 3])> {
906        self.planes
907            .iter()
908            .filter(|p| !p.name.is_empty())
909            .filter_map(|p| {
910                let gz = DatumPlane {
911                    origin: p.origin,
912                    x_axis: p.x,
913                    y_axis: p.y,
914                    size: p.size,
915                    color: p.color,
916                    handle: 1,
917                };
918                let point = gz.hit_point(cam, [x, y])?;
919                Some((p.name.clone(), [point.x, point.y, point.z]))
920            })
921            .collect()
922    }
923
924    /// Pick the datum plane/axis under a screen pixel; returns its name.
925    pub fn datum_pick(&self, cam: &GizmoCamera, x: f32, y: f32) -> Option<String> {
926        // Nearest wins by depth of the hit; planes and axes both tested. We keep
927        // it simple: axes first (thin, priority), then planes.
928        for a in &self.axes {
929            let gz = DatumAxis {
930                point: a.point,
931                direction: a.direction,
932                length: a.length,
933                color: a.color,
934                handle: 1,
935            };
936            if gz.hit(cam, [x, y]).is_some() && !a.name.is_empty() {
937                return Some(a.name.clone());
938            }
939        }
940        for p in &self.planes {
941            let gz = DatumPlane {
942                origin: p.origin,
943                x_axis: p.x,
944                y_axis: p.y,
945                size: p.size,
946                color: p.color,
947                handle: 1,
948            };
949            if gz.hit(cam, [x, y]).is_some() && !p.name.is_empty() {
950                return Some(p.name.clone());
951            }
952        }
953        None
954    }
955
956    pub fn has_transform(&self) -> bool {
957        self.transform.is_some()
958    }
959
960    /// The VISIBLE transform gizmo's current frame origin in world space, or `None`
961    /// when the gizmo is hidden. Reflects the last [`set_transform_json`] feed, so
962    /// it tracks the live-follow re-sync during a drag (Fix 3) — distinct from a
963    /// params-derived anchor, this proves the drawn widget actually moved.
964    pub fn transform_origin(&self) -> Option<[f32; 3]> {
965        self.transform.as_ref().map(|gz| [gz.origin.x, gz.origin.y, gz.origin.z])
966    }
967
968    /// Hit-test the transform gizmo; returns the handle (0 = none).
969    pub fn transform_hit(&self, cam: &GizmoCamera, x: f32, y: f32) -> HandleId {
970        self.transform
971            .as_ref()
972            .and_then(|gz| gz.hit(cam, [x, y]))
973            .unwrap_or(0)
974    }
975
976    /// The world-space `(shaft-start, tip)` endpoints of axis arrow `i` on the
977    /// LIVE transform gizmo — the SAME instance + `axis_seg` the hit test
978    /// (`transform_hit` → `TransformGizmo::hit`) measures against — so a debug
979    /// overlay can outline the exact pickable region without re-deriving it.
980    /// `None` when the gizmo is hidden.
981    pub fn transform_axis_seg(&self, cam: &GizmoCamera, i: usize) -> Option<(Vec3, Vec3)> {
982        self.transform.as_ref().map(|gz| gz.axis_seg(cam, i))
983    }
984
985    /// The world-space center free-move / origin ball point of the LIVE transform
986    /// gizmo, or `None` when hidden / the center handle is off. For the debug
987    /// hit-area outline (radius [`brep_gizmos::transform::PX_CENTER_RAD`]).
988    pub fn transform_center_grab(&self) -> Option<Vec3> {
989        self.transform.as_ref().and_then(|gz| gz.center_grab_point())
990    }
991
992    /// The three rotation grab-sphere world points of the LIVE transform gizmo (in
993    /// `ARCS` order), or `None` when hidden. For the debug hit-area outline (radius
994    /// [`brep_gizmos::transform::PX_RING_GRAB_RAD`]).
995    pub fn transform_ring_grabs(&self, cam: &GizmoCamera) -> Option<[Vec3; 3]> {
996        self.transform.as_ref().map(|gz| gz.ring_grab_points(cam))
997    }
998
999    /// The authoritative screen-space pickable regions of the LIVE transform gizmo
1000    /// — the SAME `hit_regions` [`TransformGizmo::hit`] consumes — each paired with
1001    /// its handle. `[]` when the gizmo is hidden. The debug-outline exposer
1002    /// serializes these, so the drawn region IS exactly the pickable region.
1003    pub fn transform_hit_regions(
1004        &self,
1005        cam: &GizmoCamera,
1006    ) -> Vec<(HandleId, brep_gizmos::hit_region::HitShape)> {
1007        self.transform
1008            .as_ref()
1009            .map(|gz| gz.hit_regions(cam))
1010            .unwrap_or_default()
1011    }
1012
1013    pub fn set_transform_hover(&mut self, handle: HandleId) -> bool {
1014        let handle = (handle != 0).then_some(handle);
1015        if self.transform_hover != handle {
1016            self.transform_hover = handle;
1017            true
1018        } else {
1019            false
1020        }
1021    }
1022
1023    pub fn set_transform_active(&mut self, handle: HandleId) {
1024        self.transform_active = (handle != 0).then_some(handle);
1025    }
1026
1027    /// Compute a transform drag: frame-space delta + its world resolution, as
1028    /// JSON for the host's feature-edit commit (R28). Resolved against the LIVE gizmo
1029    /// frame (`self.transform`).
1030    pub fn transform_drag_json(
1031        &self,
1032        cam: &GizmoCamera,
1033        handle: HandleId,
1034        sx: f32,
1035        sy: f32,
1036        cx: f32,
1037        cy: f32,
1038    ) -> String {
1039        let Some(gz) = &self.transform else {
1040            return "{\"kind\":\"none\"}".to_string();
1041        };
1042        drag_delta_json(gz, cam, handle, sx, sy, cx, cy)
1043    }
1044
1045    /// Like [`transform_drag_json`] but resolved against an EXPLICIT frozen frame
1046    /// (`{origin,x,y,z}`, the grab-time feature frame) instead of the live widget
1047    /// gizmo. This lets the engine re-sync the VISIBLE gizmo to the moving feature
1048    /// pose every drag frame (Fix 3 live-follow) while the delta stays anchored to
1049    /// the grab frame, so the visual sync can't feed back into the drag math.
1050    pub fn transform_drag_json_with_frame(
1051        &self,
1052        cam: &GizmoCamera,
1053        frame_json: &str,
1054        handle: HandleId,
1055        sx: f32,
1056        sy: f32,
1057        cx: f32,
1058        cy: f32,
1059    ) -> String {
1060        let value: Value = match serde_json::from_str(frame_json) {
1061            Ok(v) => v,
1062            Err(_) => return "{\"kind\":\"none\"}".to_string(),
1063        };
1064        let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
1065        let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
1066        let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
1067        let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
1068        let mut gz = TransformGizmo::default();
1069        gz.set_frame(origin, x, y, z);
1070        drag_delta_json(&gz, cam, handle, sx, sy, cx, cy)
1071    }
1072}
1073
1074/// The shared body of [`WidgetRegistry::transform_drag_json`] + its frozen-frame
1075/// twin: resolve `gz`'s frame-space drag delta into the feature-edit-commit JSON.
1076fn drag_delta_json(
1077    gz: &TransformGizmo,
1078    cam: &GizmoCamera,
1079    handle: HandleId,
1080    sx: f32,
1081    sy: f32,
1082    cx: f32,
1083    cy: f32,
1084) -> String {
1085    let start = cam.ray_from_screen(sx, sy);
1086    let current = cam.ray_from_screen(cx, cy);
1087    match gz.drag_delta(cam, handle, start, current) {
1088        DragDelta::Translate(v) => {
1089            let world = gz.ex.scale(v.x).add(gz.ey.scale(v.y)).add(gz.ez.scale(v.z));
1090            serde_json::json!({
1091                "kind": "translate",
1092                "local": [v.x, v.y, v.z],
1093                "world": [world.x, world.y, world.z],
1094            })
1095            .to_string()
1096        }
1097        DragDelta::Rotate { axis_index, radians } => {
1098            let axis = gz.axis(axis_index);
1099            serde_json::json!({
1100                "kind": "rotate",
1101                "axisIndex": axis_index,
1102                "axisWorld": [axis.x, axis.y, axis.z],
1103                "radians": radians,
1104            })
1105            .to_string()
1106        }
1107        DragDelta::None => "{\"kind\":\"none\"}".to_string(),
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use crate::view::ViewCamera;
1115
1116    fn cam() -> ViewCamera {
1117        let mut c = ViewCamera::default();
1118        c.width = 800.0;
1119        c.height = 600.0;
1120        c.eye = [0.0, 0.0, 40.0];
1121        c.target = [0.0, 0.0, 0.0];
1122        c.up = [0.0, 1.0, 0.0];
1123        c.projection = Projection::Orthographic { half_height: 20.0 };
1124        c
1125    }
1126
1127    #[test]
1128    fn datums_build_overlay_and_pick() {
1129        let mut reg = WidgetRegistry::new();
1130        reg.set_datums_json(
1131            r#"{"planes":[{"name":"P1","origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"size":10.0}],
1132                "axes":[{"name":"A1","point":[-5,0,0],"direction":[1,0,0],"length":10.0}]}"#,
1133        )
1134        .unwrap();
1135        assert!(reg.has_main_overlay());
1136        let gc = gizmo_camera(&cam());
1137        let (ov, _) = reg.build_main_overlay(&gc);
1138        assert!(!ov.tris.is_empty(), "plane fill tris");
1139        assert!(!ov.lines.is_empty(), "plane border + axis lines");
1140        // A ray through the viewport center hits the plane at the origin.
1141        let hit = reg.datum_pick(&gc, 400.0, 300.0);
1142        assert!(hit.is_some(), "center pixel picks a datum");
1143    }
1144
1145    // The datum PLANE fill tris are emitted FIRST; `plane_tri_verts` counts exactly
1146    // that prefix so the render core draws it with the no-depth-write pipeline (a
1147    // translucent plane never occludes the gizmos/dimensions drawn after it).
1148    #[test]
1149    fn plane_tri_verts_is_the_no_depth_prefix() {
1150        let mut reg = WidgetRegistry::new();
1151        reg.set_datums_json(
1152            r#"{"planes":[{"name":"P1","origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"size":10.0}]}"#,
1153        )
1154        .unwrap();
1155        let gc = gizmo_camera(&cam());
1156        let (plane_only, verts) = reg.build_main_overlay(&gc);
1157        assert!(verts > 0, "the plane contributes tris");
1158        assert_eq!(verts, plane_only.tris.len(), "with only a plane, every tri is the plane's");
1159
1160        // A linear dimension's arrowhead TRIS append AFTER the plane prefix: the
1161        // prefix count is unchanged and the total grows (the split has a gizmo tail).
1162        reg.set_dimensions_json(
1163            r#"[{"id":"d1","type":"linear","a":[0,0,0],"b":[0,0,5],"offsetDir":[0,-1,0],"offset":2.0}]"#,
1164        )
1165        .unwrap();
1166        let (with_dim, verts2) = reg.build_main_overlay(&gc);
1167        assert_eq!(verts2, verts, "the plane prefix is unchanged by adding a dimension");
1168        assert!(with_dim.tris.len() > verts, "dimension tris follow the plane prefix");
1169    }
1170
1171    #[test]
1172    fn transform_feeds_and_drags() {
1173        let mut reg = WidgetRegistry::new();
1174        reg.set_transform_json(r#"{"origin":[0,0,0],"x":[1,0,0],"y":[0,1,0],"z":[0,0,1]}"#)
1175            .unwrap();
1176        assert!(reg.has_transform());
1177        let gc = gizmo_camera(&cam());
1178        // A +X-axis drag returns a +X world translate.
1179        let out = reg.transform_drag_json(
1180            &gc,
1181            brep_gizmos::transform::HANDLE_AXIS_X,
1182            400.0,
1183            300.0,
1184            440.0,
1185            300.0,
1186        );
1187        let v: Value = serde_json::from_str(&out).unwrap();
1188        assert_eq!(v["kind"], "translate");
1189        assert!(v["world"][0].as_f64().unwrap() > 0.0, "world dx > 0: {out}");
1190        // Clearing removes it.
1191        reg.set_transform_json("null").unwrap();
1192        assert!(!reg.has_transform());
1193    }
1194
1195    #[test]
1196    fn viewcube_frame_and_snap() {
1197        let mut reg = WidgetRegistry::new();
1198        assert!(reg.build_viewcube(&gizmo_camera(&cam())).is_none());
1199        reg.set_viewcube_enabled(true);
1200        let gc = gizmo_camera(&cam());
1201        let frame = reg.build_viewcube(&gc).expect("enabled");
1202        assert!(!frame.overlay.tris.is_empty());
1203        let [x, _y, w, h] = reg.viewcube_rect(&gc);
1204        // Bottom-right corner of an 800-wide viewport.
1205        assert!(x > 600.0 && (w - 135.0).abs() < 1e-3 && (h - 135.0).abs() < 1e-3);
1206        // A click at the cube center hits the front-facing region and yields a
1207        // look direction.
1208        if let Some(handle) = reg.viewcube_hit(&gc, w / 2.0, h / 2.0) {
1209            let (dir, _up) = reg.viewcube_target(handle);
1210            assert!((dir[0].powi(2) + dir[1].powi(2) + dir[2].powi(2) - 1.0).abs() < 1e-3);
1211        }
1212    }
1213
1214    /// THE view-cube orientation contract: for every camera pose, each world
1215    /// axis must project to the SAME on-screen direction through the cube's
1216    /// mini-camera as through the main camera. This is exactly "the cube shows
1217    /// the orientation the model shows", roll included. Poses: the FRONT / TOP /
1218    /// RIGHT / ISO toolbar buttons (ground truth from `standard_view` — TOP's
1219    /// up is -Z, which the old forward-only heuristic got 180° wrong) plus an
1220    /// arcball-ROLLED camera (up rotated 30° about the view axis — the free
1221    /// arcball's everyday state, which a forward-only heuristic cannot follow).
1222    #[test]
1223    fn viewcube_orientation_matches_camera_for_standard_and_rolled_poses() {
1224        use brep_gizmos::view_cube::ViewCube;
1225        let mut poses: Vec<(String, ViewCamera)> = Vec::new();
1226        for name in ["FRONT", "TOP", "RIGHT", "ISO"] {
1227            let mut c = cam();
1228            assert!(c.standard_view(name), "{name}");
1229            poses.push((name.to_string(), c));
1230        }
1231        // FRONT rolled 30° about the view direction (what an arcball drag or
1232        // the cube's roll arrows produce).
1233        let mut rolled = cam();
1234        rolled.standard_view("FRONT");
1235        let (_, _, fwd) = rolled.basis();
1236        rolled.up = crate::view::rotate3(rolled.up, fwd, 30f64.to_radians());
1237        poses.push(("FRONT+30°roll".to_string(), rolled));
1238
1239        let cube = ViewCube::new();
1240        for (label, view) in &poses {
1241            let gc = gizmo_camera(view);
1242            let mini = cube.mini_camera(&gc);
1243            for (axis, world) in [
1244                ("X", [1.0f64, 0.0, 0.0]),
1245                ("Y", [0.0, 1.0, 0.0]),
1246                ("Z", [0.0, 0.0, 1.0]),
1247            ] {
1248                // Main-camera screen direction of this axis (CSS px, y down).
1249                let (ox, oy, _) = view.project([0.0, 0.0, 0.0]);
1250                let (ax, ay, _) = view.project(world);
1251                let dm = [ax - ox, ay - oy];
1252                // Mini-camera screen direction of the same axis.
1253                let c0 = mini.world_to_screen(Vec3::ZERO).expect("origin visible");
1254                let c1 = mini
1255                    .world_to_screen(Vec3::new(world[0] as f32 * 0.4, world[1] as f32 * 0.4, world[2] as f32 * 0.4))
1256                    .expect("axis tip visible");
1257                let dc = [(c1[0] - c0[0]) as f64, (c1[1] - c0[1]) as f64];
1258                let lm = (dm[0] * dm[0] + dm[1] * dm[1]).sqrt();
1259                let lc = (dc[0] * dc[0] + dc[1] * dc[1]).sqrt();
1260                if lm < 1e-3 || lc < 1e-3 {
1261                    // Axis ~parallel to the view direction: no screen direction
1262                    // to compare — BOTH cameras must agree it vanishes.
1263                    assert!(
1264                        lm < 1.0 && lc < 1.0,
1265                        "{label}/{axis}: axis vanishes in one camera only (main {lm}, cube {lc})"
1266                    );
1267                    continue;
1268                }
1269                let dot = (dm[0] * dc[0] + dm[1] * dc[1]) / (lm * lc);
1270                assert!(
1271                    dot > 0.999,
1272                    "{label}: world {axis} axis points a different way on the cube \
1273                     (main dir {dm:?}, cube dir {dc:?}, dot {dot})"
1274                );
1275            }
1276        }
1277    }
1278
1279    /// Cube click targets round-trip the toolbar buttons: with the camera on a
1280    /// standard view, the cube's CENTER pixel hits the same-named face region,
1281    /// and snapping to that region reproduces the button's view direction + up.
1282    /// Also pins the inverse mapping under the TOP button's -Z up: a click
1283    /// BELOW center must land on the region toward world +Z (screen-down in the
1284    /// TOP view is +Z precisely because up = -Z; the old heuristic had it
1285    /// backwards).
1286    #[test]
1287    fn viewcube_click_targets_round_trip_standard_views() {
1288        use brep_gizmos::view_cube::ViewCube;
1289        let mut reg = WidgetRegistry::new();
1290        reg.set_viewcube_enabled(true);
1291        let cube_faces = [
1292            ("FRONT", ViewCube::FRONT),
1293            ("BACK", ViewCube::BACK),
1294            ("RIGHT", ViewCube::RIGHT),
1295            ("LEFT", ViewCube::LEFT),
1296            ("TOP", ViewCube::TOP),
1297            ("BOTTOM", ViewCube::BOTTOM),
1298        ];
1299        for (name, want) in cube_faces {
1300            let mut view = cam();
1301            assert!(view.standard_view(name));
1302            let gc = gizmo_camera(&view);
1303            let [_, _, w, h] = reg.viewcube_rect(&gc);
1304            let hit = reg.viewcube_hit(&gc, w / 2.0, h / 2.0);
1305            assert_eq!(hit, Some(want), "{name}: center hit");
1306            // Snapping to the hit region reproduces this button's pose.
1307            let (dir, up) = reg.viewcube_target(want);
1308            let (_, _, fwd) = view.basis();
1309            for k in 0..3 {
1310                assert!((dir[k] as f64 - fwd[k]).abs() < 1e-6, "{name}: dir");
1311                assert!((up[k] as f64 - view.up[k]).abs() < 1e-6, "{name}: up");
1312            }
1313        }
1314        // TOP view, click below center (still on the cube, inside the edge
1315        // band — further down is the nav arrow) → the +Z (FRONT-side) region.
1316        let mut view = cam();
1317        view.standard_view("TOP");
1318        let gc = gizmo_camera(&view);
1319        let [_, _, w, h] = reg.viewcube_rect(&gc);
1320        let hit = reg.viewcube_hit(&gc, w / 2.0, h * 0.66).expect("hit the cube");
1321        assert!(
1322            ViewCube::region_name(hit).contains("FRONT"),
1323            "TOP view: below-center click must reach toward +Z/FRONT, got {}",
1324            ViewCube::region_name(hit)
1325        );
1326    }
1327
1328    #[test]
1329    fn overlay_groups_build_upsert_and_clear() {
1330        let mut reg = WidgetRegistry::new();
1331        // A group with one triangle, one line segment, and one point.
1332        reg.set_overlay_json(
1333            r#"{"groups":[{"name":"prev","renderOrder":2,
1334                "tris":{"positions":[0,0,0, 1,0,0, 0,1,0],"colors":[1,0,0, 0,1,0, 0,0,1]},
1335                "lines":{"positions":[0,0,0, 2,2,2],"colors":[1,1,0, 1,1,0]},
1336                "points":{"positions":[3,3,3],"colors":[0,1,1],"size":8}}]}"#,
1337        )
1338        .unwrap();
1339        assert!(reg.has_main_overlay());
1340        let gc = gizmo_camera(&cam());
1341        let (ov, _) = reg.build_main_overlay(&gc);
1342        // 3 tri verts from the triangle + 6 from the point quad (2 tris).
1343        assert_eq!(ov.tris.len(), 3 + 6, "tri verts (triangle + point quad)");
1344        assert_eq!(ov.lines.len(), 2, "one line segment (a pair)");
1345        // Per-vertex tri color survives the marshal.
1346        assert_eq!(ov.tris[0].color, [1.0, 0.0, 0.0, 1.0]);
1347
1348        // Upsert the same name with new geometry (only lines now).
1349        reg.set_overlay_json(
1350            r#"{"groups":[{"name":"prev","lines":{"positions":[0,0,0, 1,1,1],"colors":[1,1,1, 1,1,1]}}]}"#,
1351        )
1352        .unwrap();
1353        let (ov, _) = reg.build_main_overlay(&gc);
1354        assert!(ov.tris.is_empty(), "upsert replaced geometry (no tris)");
1355        assert_eq!(ov.lines.len(), 2);
1356
1357        // An empty group removes that name.
1358        reg.set_overlay_json(r#"{"groups":[{"name":"prev"}]}"#).unwrap();
1359        assert!(!reg.has_main_overlay(), "empty group removed the only overlay");
1360
1361        // Empty groups list clears everything.
1362        reg.set_overlay_json(r#"{"groups":[{"name":"a","lines":{"positions":[0,0,0,1,0,0],"colors":[1,1,1,1,1,1]}}]}"#).unwrap();
1363        assert!(reg.has_main_overlay());
1364        reg.set_overlay_json(r#"{"groups":[]}"#).unwrap();
1365        assert!(!reg.has_main_overlay(), "empty groups cleared all");
1366    }
1367
1368    #[test]
1369    fn overlay_groups_bbox_covers_pushed_geometry() {
1370        let mut reg = WidgetRegistry::new();
1371        assert!(reg.overlay_groups_bbox().is_empty(), "no groups -> empty bbox");
1372        // A line from (-5,-5,-5) to (10,20,30) plus a far point at (100,0,-2).
1373        reg.set_overlay_json(
1374            r#"{"groups":[{"name":"sk",
1375                "lines":{"positions":[-5,-5,-5, 10,20,30],"colors":[1,1,1, 1,1,1]},
1376                "points":{"positions":[100,0,-2],"colors":[0,1,0],"size":8}}]}"#,
1377        )
1378        .unwrap();
1379        let bbox = reg.overlay_groups_bbox();
1380        // Must cover BOTH line endpoints AND the far point, so folding it into the
1381        // depth-range fit stops orbiting from clipping the overlay.
1382        assert!(bbox.min[0] <= -5.0 && bbox.min[1] <= -5.0 && bbox.min[2] <= -5.0, "min {:?}", bbox.min);
1383        assert!(bbox.max[0] >= 100.0 && bbox.max[1] >= 20.0 && bbox.max[2] >= 30.0, "max {:?}", bbox.max);
1384    }
1385
1386    /// The CRITICAL ViewCube-exclusion fence: with ONLY the ViewCube enabled
1387    /// (nothing in the main overlay), the world bbox folded into the depth fit
1388    /// must be EMPTY — the cube draws with its own mini-camera, so its coords are
1389    /// NOT world space and unioning them would corrupt the depth window.
1390    #[test]
1391    fn world_bbox_excludes_viewcube() {
1392        let mut reg = WidgetRegistry::new();
1393        reg.set_viewcube_enabled(true);
1394        let overlay = reg.build_overlay(&gizmo_camera(&cam()));
1395        // The cube is present (its own pass) …
1396        assert!(overlay.viewcube.is_some(), "viewcube built");
1397        // … but contributes NOTHING to the world depth-fit bbox.
1398        assert!(
1399            overlay.world_bbox().is_empty(),
1400            "viewcube-only overlay must have an empty WORLD bbox"
1401        );
1402    }
1403
1404    /// The world bbox covers the MAIN overlay's world geometry (a datum plane +
1405    /// axis here), so folding it into the depth fit brackets construction
1406    /// geometry beyond the solids.
1407    #[test]
1408    fn world_bbox_covers_main_overlay_geometry() {
1409        let mut reg = WidgetRegistry::new();
1410        reg.set_datums_json(
1411            r#"{"planes":[{"name":"P1","origin":[20,0,0],"x":[1,0,0],"y":[0,1,0],"size":10.0}],
1412                "axes":[{"name":"A1","point":[-5,-5,-5],"direction":[1,0,0],"length":10.0}]}"#,
1413        )
1414        .unwrap();
1415        let overlay = reg.build_overlay(&gizmo_camera(&cam()));
1416        let bbox = overlay.world_bbox();
1417        assert!(!bbox.is_empty(), "datum geometry -> non-empty world bbox");
1418        // Brackets the plane (centred at x=20, ±5 span) and the axis endpoints.
1419        assert!(bbox.min[0] <= -5.0, "min {:?}", bbox.min);
1420        assert!(bbox.max[0] >= 25.0, "max {:?}", bbox.max);
1421    }
1422
1423    #[test]
1424    fn dimensions_build_and_expose_anchors() {
1425        let mut reg = WidgetRegistry::new();
1426        reg.set_dimensions_json(
1427            r#"[{"id":"d1","type":"linear","a":[-3,0,0],"b":[3,0,0],"offsetDir":[0,-1,0],"offset":2.0}]"#,
1428        )
1429        .unwrap();
1430        let gc = gizmo_camera(&cam());
1431        let (ov, _) = reg.build_main_overlay(&gc);
1432        assert!(!ov.lines.is_empty() && !ov.tris.is_empty());
1433        let anchors = reg.dimension_anchors(&gc);
1434        assert_eq!(anchors.len(), 1);
1435        assert_eq!(anchors[0].0, "d1");
1436    }
1437}