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, ViewFrame};
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    /// [`fold_words`] of the group's geometry, computed once when it is set;
227    /// [`WidgetRegistry::render_fingerprint`] reads it instead of the vertices.
228    content_hash: u64,
229}
230
231/// Default screen size (CSS px) for an overlay point when unspecified.
232const DEFAULT_OVERLAY_POINT_PX: f32 = 6.0;
233
234impl OverlayGroupW {
235    /// Parse one group object from the `set_overlay` JSON. Flat layout:
236    /// `{name, renderOrder?, tris:{positions,colors,normals?},
237    ///   lines:{positions,colors}, points?:{positions,colors,size?}}`.
238    /// Positions are flat xyz; tri/line/point colors are flat rgb per vertex.
239    fn from_json(g: &Value) -> Self {
240        let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
241        let render_order = g.get("renderOrder").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
242
243        // Triangles: 3 vertices each; per-vertex color; optional per-vertex
244        // normal (else a computed flat face normal).
245        let mut tris = Vec::new();
246        if let Some(t) = g.get("tris") {
247            let positions = f32_array(t.get("positions"));
248            let colors = f32_array(t.get("colors"));
249            let normals = f32_array(t.get("normals"));
250            let vcount = positions.len() / 3;
251            let tri_count = vcount / 3;
252            for tri in 0..tri_count {
253                let vi = [tri * 3, tri * 3 + 1, tri * 3 + 2];
254                let p = [pos_at(&positions, vi[0]), pos_at(&positions, vi[1]), pos_at(&positions, vi[2])];
255                let face_n = p[1].sub(p[0]).cross(p[2].sub(p[0])).normalized();
256                for k in 0..3 {
257                    let idx = vi[k];
258                    let normal: [f32; 3] = if normals.len() >= (idx + 1) * 3 {
259                        [normals[idx * 3], normals[idx * 3 + 1], normals[idx * 3 + 2]]
260                    } else {
261                        face_n.into()
262                    };
263                    tris.push(TriVertex {
264                        pos: p[k].into(),
265                        normal,
266                        color: rgb_at(&colors, idx),
267                    });
268                }
269            }
270        }
271
272        // Lines: consecutive vertex pairs; per-vertex color (segment shading
273        // reads the first endpoint's color, matching the widget line pass).
274        let mut lines = Vec::new();
275        if let Some(l) = g.get("lines") {
276            let positions = f32_array(l.get("positions"));
277            let colors = f32_array(l.get("colors"));
278            let vcount = positions.len() / 3;
279            let seg_count = vcount / 2;
280            for seg in 0..seg_count {
281                let a = seg * 2;
282                let b = seg * 2 + 1;
283                lines.push(LineVertex { pos: pos_at(&positions, a).into(), color: rgb_at(&colors, a) });
284                lines.push(LineVertex { pos: pos_at(&positions, b).into(), color: rgb_at(&colors, b) });
285            }
286        }
287
288        // Points: screen-constant billboarded quads (expanded per frame).
289        let mut points = Vec::new();
290        let mut point_size = DEFAULT_OVERLAY_POINT_PX;
291        if let Some(pt) = g.get("points") {
292            let positions = f32_array(pt.get("positions"));
293            let colors = f32_array(pt.get("colors"));
294            point_size = pt.get("size").and_then(|v| v.as_f64()).unwrap_or(DEFAULT_OVERLAY_POINT_PX as f64) as f32;
295            let count = positions.len() / 3;
296            for i in 0..count {
297                points.push(OverlayPointW { center: pos_at(&positions, i), color: rgb_at(&colors, i) });
298            }
299        }
300
301        let mut content_hash = FOLD_SEED;
302        for v in &tris {
303            content_hash = fold_words(content_hash, v.pos.iter().chain(&v.normal).chain(&v.color).map(|f| f.to_bits()));
304        }
305        for v in &lines {
306            content_hash = fold_words(content_hash, v.pos.iter().chain(&v.color).map(|f| f.to_bits()));
307        }
308        Self { name, render_order, tris, lines, points, point_size, content_hash }
309    }
310
311    fn is_empty(&self) -> bool {
312        self.tris.is_empty() && self.lines.is_empty() && self.points.is_empty()
313    }
314}
315
316const FOLD_SEED: u64 = 0xcbf2_9ce4_8422_2325;
317
318/// FNV-1a over 32-bit words: cheap enough to run over a whole CAM stock group
319/// when it is set, and only used to notice change (not for security).
320fn fold_words(mut hash: u64, words: impl Iterator<Item = u32>) -> u64 {
321    for word in words {
322        hash = (hash ^ word as u64).wrapping_mul(0x0000_0100_0000_01b3);
323    }
324    hash
325}
326
327enum DimW {
328    Linear {
329        id: String,
330        a: Vec3,
331        b: Vec3,
332        offset_dir: Vec3,
333        offset: f32,
334        color: [f32; 4],
335    },
336    Angular {
337        id: String,
338        vertex: Vec3,
339        dir_a: Vec3,
340        dir_b: Vec3,
341        radius: f32,
342        color: [f32; 4],
343    },
344    Radial {
345        id: String,
346        center: Vec3,
347        point: Vec3,
348        color: [f32; 4],
349    },
350}
351
352/// The engine-side widget registry (one per engine).
353pub struct WidgetRegistry {
354    /// The ViewCube is always-on once enabled by the host UI (its retired
355    /// counterpart is deleted in the same change set).
356    pub viewcube_enabled: bool,
357    viewcube: ViewCube,
358    viewcube_hover: Option<HandleId>,
359    /// The frame the cube's labelled faces are read in ([`ViewFrame`]): the
360    /// cube is built Y-up in its own space, and this carries it into the world,
361    /// so with a board shown its T face sits on world +Z.
362    view_frame: ViewFrame,
363    planes: Vec<PlaneW>,
364    axes: Vec<AxisW>,
365    frames: Vec<FrameW>,
366    curves: Vec<CurveW>,
367    dims: Vec<DimW>,
368    /// General overlay geometry channel (`set_overlay`): arbitrary named groups
369    /// of tris/lines/points (e.g. feature-dialog previews), engine-drawn.
370    overlay_groups: Vec<OverlayGroupW>,
371    transform: Option<TransformGizmo>,
372    transform_hover: Option<HandleId>,
373    transform_active: Option<HandleId>,
374}
375
376impl Default for WidgetRegistry {
377    fn default() -> Self {
378        Self {
379            viewcube_enabled: false,
380            viewcube: ViewCube::new(),
381            viewcube_hover: None,
382            view_frame: ViewFrame::World,
383            planes: Vec::new(),
384            axes: Vec::new(),
385            frames: Vec::new(),
386            curves: Vec::new(),
387            dims: Vec::new(),
388            overlay_groups: Vec::new(),
389            transform: None,
390            transform_hover: None,
391            transform_active: None,
392        }
393    }
394}
395
396/// The ViewCube render frame: its overlay drawn with a mini-camera in a
397/// scissored corner sub-rect.
398pub struct ViewCubeFrame {
399    pub overlay: Overlay,
400    /// Column-major mini-camera world→clip.
401    pub view_proj: [[f32; 4]; 4],
402    pub forward: [f32; 3],
403    /// Corner rect `[x, y, w, h]` in CSS px (top-left origin, y down).
404    pub rect_css: [f32; 4],
405}
406
407/// A whole frame's worth of overlay-widget geometry, ready for the render
408/// core's overlay pass: the main overlay (datums / dimensions / curves /
409/// transform gizmo, full-viewport) + the optional ViewCube (own mini-camera +
410/// corner rect).
411pub struct WidgetOverlay {
412    pub main: Overlay,
413    /// Number of LEADING `main.tris` vertices belonging to the datum/construction
414    /// planes (drawn with a no-depth-write pipeline so they never occlude the
415    /// gizmos/dimensions that follow). The remaining tris are the gizmo geometry.
416    pub plane_tri_verts: usize,
417    pub viewcube: Option<ViewCubeFrame>,
418}
419
420impl WidgetOverlay {
421    /// Nothing to draw (skip the overlay passes entirely).
422    pub fn is_empty(&self) -> bool {
423        self.main.lines.is_empty() && self.main.tris.is_empty() && self.viewcube.is_none()
424    }
425
426    /// The world-space AABB of the MAIN overlay (datums, axes, frames, curves,
427    /// dimensions, transform gizmo, general groups) — folded into the camera
428    /// depth-range fit so construction geometry beyond the solids never clips.
429    /// EXCLUDES the ViewCube: it lives in the separate `viewcube` field and is
430    /// drawn with its own mini-camera, so its coords are NOT world space —
431    /// bboxing them would corrupt the fit. Point groups are already expanded
432    /// into `main.tris` (`push_point_quad`), so tris + lines cover everything.
433    /// Empty when the main overlay is empty. A single vertex absurdly far out
434    /// (|coord| > 1e6) is skipped rather than unioned, so a pathological
435    /// "infinite" helper can't blow the depth window open (world axes are
436    /// screen-constant and finite, but be defensive).
437    pub fn world_bbox(&self) -> crate::camera::Aabb {
438        let mut bbox = crate::camera::Aabb::empty();
439        let mut fold = |pos: &[f32; 3]| {
440            if pos.iter().any(|c| c.abs() > 1.0e6) {
441                return;
442            }
443            bbox.expand([pos[0] as f64, pos[1] as f64, pos[2] as f64]);
444        };
445        for v in &self.main.tris {
446            fold(&v.pos);
447        }
448        for v in &self.main.lines {
449            fold(&v.pos);
450        }
451        bbox
452    }
453}
454
455impl WidgetRegistry {
456    pub fn new() -> Self {
457        Self::default()
458    }
459
460    /// Any main-overlay widget geometry present (excludes the ViewCube, which
461    /// draws in its own pass).
462    pub fn has_main_overlay(&self) -> bool {
463        !self.planes.is_empty()
464            || !self.axes.is_empty()
465            || !self.frames.is_empty()
466            || !self.curves.is_empty()
467            || !self.dims.is_empty()
468            || !self.overlay_groups.is_empty()
469            || self.transform.is_some()
470    }
471
472    /// World-space AABB of the pushed overlay GROUPS — the `set_overlay` geometry
473    /// (the sketch curves + points, dimension leaders, constraint glyphs, datums,
474    /// curves). EXCLUDES the screen-constant transform gizmo + ViewCube (own passes).
475    /// Folded into the camera depth-range fit so orbiting an editing sketch doesn't
476    /// clip the overlay against the SOLIDS-ONLY scene bounds (the reported
477    /// sketch-clipping bug when "Lock to sketch" is off). Empty when no groups.
478    pub fn overlay_groups_bbox(&self) -> crate::camera::Aabb {
479        let mut bbox = crate::camera::Aabb::empty();
480        for group in &self.overlay_groups {
481            for v in &group.tris {
482                bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
483            }
484            for v in &group.lines {
485                bbox.expand([v.pos[0] as f64, v.pos[1] as f64, v.pos[2] as f64]);
486            }
487            for point in &group.points {
488                let c: [f32; 3] = point.center.into();
489                bbox.expand([c[0] as f64, c[1] as f64, c[2] as f64]);
490            }
491        }
492        bbox
493    }
494
495    // --- JSON feed --------------------------------------------------------
496
497    /// Replace the datum / curve display set. Shape:
498    /// `{planes:[{name,origin,x,y,size?,color?,selected?,hovered?}],
499    ///   axes:[{name,point,direction,length,color?,selected?,hovered?}],
500    ///   frames:[{origin,x,y,z,px?}],
501    ///   curves:[{points:[[x,y,z]...],closed?,color?,selected?,hovered?}]}`.
502    pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
503        let value: Value =
504            serde_json::from_str(json).map_err(|e| format!("datums parse: {e}"))?;
505        self.planes.clear();
506        self.axes.clear();
507        self.frames.clear();
508        self.curves.clear();
509
510        if let Some(list) = value.get("planes").and_then(|v| v.as_array()) {
511            for p in list {
512                let (Some(origin), Some(x), Some(y)) = (
513                    p.get("origin").and_then(vec3_of),
514                    p.get("x").and_then(vec3_of),
515                    p.get("y").and_then(vec3_of),
516                ) else {
517                    continue;
518                };
519                self.planes.push(PlaneW {
520                    name: p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
521                    origin,
522                    x,
523                    y,
524                    size: p.get("size").and_then(|v| v.as_f64()).map(|s| s as f32),
525                    color: color_of(p.get("color"), datum::PLANE_COLOR),
526                    hot: flag(p, "selected") || flag(p, "hovered"),
527                });
528            }
529        }
530        if let Some(list) = value.get("axes").and_then(|v| v.as_array()) {
531            for a in list {
532                let (Some(point), Some(direction)) = (
533                    a.get("point").and_then(vec3_of),
534                    a.get("direction").and_then(vec3_of),
535                ) else {
536                    continue;
537                };
538                self.axes.push(AxisW {
539                    name: a.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
540                    point,
541                    direction,
542                    length: a.get("length").and_then(|v| v.as_f64()).unwrap_or(10.0) as f32,
543                    color: color_of(a.get("color"), DEFAULT_AXIS_COLOR),
544                    hot: flag(a, "selected") || flag(a, "hovered"),
545                });
546            }
547        }
548        if let Some(list) = value.get("frames").and_then(|v| v.as_array()) {
549            for f in list {
550                let (Some(origin), Some(x), Some(y), Some(z)) = (
551                    f.get("origin").and_then(vec3_of),
552                    f.get("x").and_then(vec3_of),
553                    f.get("y").and_then(vec3_of),
554                    f.get("z").and_then(vec3_of),
555                ) else {
556                    continue;
557                };
558                self.frames.push(FrameW {
559                    origin,
560                    x,
561                    y,
562                    z,
563                    px: f.get("px").and_then(|v| v.as_f64()).unwrap_or(datum::DEFAULT_FRAME_PX as f64)
564                        as f32,
565                });
566            }
567        }
568        if let Some(list) = value.get("curves").and_then(|v| v.as_array()) {
569            for c in list {
570                let points: Vec<Vec3> = c
571                    .get("points")
572                    .and_then(|v| v.as_array())
573                    .map(|arr| arr.iter().filter_map(vec3_of).collect())
574                    .unwrap_or_default();
575                if points.len() < 2 {
576                    continue;
577                }
578                let mut color = color_of(c.get("color"), curve_display::CURVE_COLOR);
579                if flag(c, "selected") || flag(c, "hovered") {
580                    color = brighten(color);
581                }
582                self.curves.push(CurveW {
583                    points,
584                    closed: flag(c, "closed"),
585                    color,
586                });
587            }
588        }
589        Ok(())
590    }
591
592    /// Replace/upsert the GENERAL overlay geometry channel (`set_overlay`).
593    /// Shape: `{groups:[{name, renderOrder?, tris:{positions,colors,normals?},
594    /// lines:{positions,colors}, points?:{positions,colors,size?}}]}`. Each group
595    /// UPSERTS by `name`; a group whose geometry is entirely empty REMOVES that
596    /// name; an empty (or missing) `groups` array CLEARS every group.
597    pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
598        let value: Value =
599            serde_json::from_str(json).map_err(|e| format!("overlay parse: {e}"))?;
600        let Some(list) = value.get("groups").and_then(|v| v.as_array()) else {
601            self.overlay_groups.clear();
602            return Ok(());
603        };
604        if list.is_empty() {
605            self.overlay_groups.clear();
606            return Ok(());
607        }
608        for g in list {
609            let group = OverlayGroupW::from_json(g);
610            // Upsert by name (a duplicate name replaces the prior group).
611            self.overlay_groups.retain(|x| x.name != group.name);
612            if !group.is_empty() {
613                self.overlay_groups.push(group);
614            }
615        }
616        Ok(())
617    }
618
619    /// The names of the currently-loaded (non-empty) general overlay groups — a read
620    /// accessor for tests / verification. An empty group is auto-removed on upsert, so
621    /// a name present here always carries geometry.
622    pub fn overlay_group_names(&self) -> Vec<&str> {
623        self.overlay_groups.iter().map(|g| g.name.as_str()).collect()
624    }
625
626    /// The currently-fed datum PLANES as `(name, emphasized)` — a read accessor for
627    /// tests / verification (the datum planes replace their set wholesale each
628    /// `set_datums_json`, so this is exactly the current construction-datum feed).
629    /// `emphasized` is the selected/hovered `hot` flag (a selected datum reads true).
630    pub fn datum_plane_names(&self) -> Vec<(&str, bool)> {
631        self.planes.iter().map(|p| (p.name.as_str(), p.hot)).collect()
632    }
633
634    /// Replace the feature-dimension set. Shape: an array of
635    /// `{id, type:"linear"|"angular"|"radial", ...}` — linear: `a,b,offsetDir,
636    /// offset`; angular: `vertex,dirA,dirB,radius`; radial: `center,pointOnCircle`.
637    pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
638        let value: Value =
639            serde_json::from_str(json).map_err(|e| format!("dimensions parse: {e}"))?;
640        self.dims.clear();
641        let Some(list) = value.as_array() else {
642            return Ok(());
643        };
644        for d in list {
645            let id = d.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
646            let color = color_of(d.get("color"), dimension::DIMENSION_COLOR);
647            match d.get("type").and_then(|v| v.as_str()) {
648                Some("linear") => {
649                    if let (Some(a), Some(b), Some(offset_dir)) = (
650                        d.get("a").and_then(vec3_of),
651                        d.get("b").and_then(vec3_of),
652                        d.get("offsetDir").and_then(vec3_of),
653                    ) {
654                        self.dims.push(DimW::Linear {
655                            id,
656                            a,
657                            b,
658                            offset_dir,
659                            offset: d.get("offset").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
660                            color,
661                        });
662                    }
663                }
664                Some("angular") => {
665                    if let (Some(vertex), Some(dir_a), Some(dir_b)) = (
666                        d.get("vertex").and_then(vec3_of),
667                        d.get("dirA").and_then(vec3_of),
668                        d.get("dirB").and_then(vec3_of),
669                    ) {
670                        self.dims.push(DimW::Angular {
671                            id,
672                            vertex,
673                            dir_a,
674                            dir_b,
675                            radius: d.get("radius").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32,
676                            color,
677                        });
678                    }
679                }
680                Some("radial") => {
681                    if let (Some(center), Some(point)) = (
682                        d.get("center").and_then(vec3_of),
683                        d.get("pointOnCircle").and_then(vec3_of),
684                    ) {
685                        self.dims.push(DimW::Radial {
686                            id,
687                            center,
688                            point,
689                            color,
690                        });
691                    }
692                }
693                _ => {}
694            }
695        }
696        Ok(())
697    }
698
699    /// Set (or clear, when `json == "null"`) the transform gizmo. Shape:
700    /// `{origin,x,y,z,showCenter?,showAxes?,showRings?}` — the selected
701    /// feature's frame (R28); the optional show flags (default true) carve the
702    /// translate-only / rotate-only variants the component Move toggle cycles.
703    pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
704        let value: Value =
705            serde_json::from_str(json).map_err(|e| format!("transform parse: {e}"))?;
706        if value.is_null() {
707            self.transform = None;
708            self.transform_hover = None;
709            self.transform_active = None;
710            return Ok(());
711        }
712        let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
713        let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
714        let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
715        let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
716        let mut gz = TransformGizmo::default();
717        gz.set_frame(origin, x, y, z);
718        let flag = |key: &str| value.get(key).and_then(|v| v.as_bool()).unwrap_or(true);
719        gz.show_center = flag("showCenter");
720        gz.show_axes = flag("showAxes");
721        gz.show_rings = flag("showRings");
722        self.transform = Some(gz);
723        Ok(())
724    }
725
726    pub fn set_viewcube_enabled(&mut self, enabled: bool) {
727        self.viewcube_enabled = enabled;
728        if !enabled {
729            self.viewcube_hover = None;
730        }
731    }
732
733    /// Set the frame the ViewCube's faces are named in. The engine sets it
734    /// from whether a board is shown, the same test its standard views use.
735    pub fn set_view_frame(&mut self, frame: ViewFrame) {
736        self.view_frame = frame;
737    }
738
739    pub fn view_frame(&self) -> ViewFrame {
740        self.view_frame
741    }
742
743    /// The main camera as the cube sees it: its orientation expressed in the
744    /// view frame. The cube's mini-camera, its geometry and its hit test read
745    /// only the camera's forward and up, so turning those two (and the eye,
746    /// for consistency) is all it takes for the cube to be drawn and picked in
747    /// the frame. The identity for [`ViewFrame::World`].
748    fn cube_camera(&self, cam: &GizmoCamera) -> GizmoCamera {
749        let turn = |v: Vec3| {
750            let t = self.view_frame.from_world([v.x as f64, v.y as f64, v.z as f64]);
751            Vec3::new(t[0] as f32, t[1] as f32, t[2] as f32)
752        };
753        GizmoCamera { eye: turn(cam.eye), forward: turn(cam.forward), up: turn(cam.up), ..*cam }
754    }
755
756    /// Set the ViewCube's on-screen edge length (CSS px). ONE size field feeds both
757    /// the rendered mini-camera viewport (`build_viewcube`) and the hit-test corner
758    /// rect (`viewcube_rect`), so the drawn cube and its clickable region always
759    /// scale together. Driven from `RenderSettings::viewcube_size_px` on every
760    /// settings apply. Guarded to a >= 1px positive size so a bad feed can't
761    /// collapse the rect.
762    pub fn set_viewcube_size(&mut self, size_px: f32) {
763        if size_px.is_finite() {
764            self.viewcube.size = size_px.max(1.0);
765        }
766    }
767
768    // --- geometry ---------------------------------------------------------
769
770    /// Build the main-overlay geometry (everything but the ViewCube).
771    /// Build the main overlay geometry AND the count of leading tri vertices that
772    /// belong to the datum/construction PLANES (always emitted FIRST). The render
773    /// core draws those with a NO-depth-write pipeline so a translucent plane can
774    /// never occlude the gizmos/dimensions that follow.
775    pub fn build_main_overlay(&self, cam: &GizmoCamera) -> (Overlay, usize) {
776        self.build_main_overlay_with(cam, true)
777    }
778
779    /// [`Self::build_main_overlay`], with the general groups left out when
780    /// `groups` is false.
781    fn build_main_overlay_with(&self, cam: &GizmoCamera, groups: bool) -> (Overlay, usize) {
782        let mut ov = Overlay::new();
783
784        for p in &self.planes {
785            let color = if p.hot { brighten(p.color) } else { p.color };
786            let plane = match p.size {
787                Some(size) => datum::datum_plane(p.origin, p.x, p.y, size, color),
788                None => datum::datum_plane_screen(
789                    p.origin,
790                    p.x,
791                    p.y,
792                    datum::DEFAULT_PLANE_SCREEN_PX,
793                    color,
794                    cam,
795                ),
796            };
797            ov.extend(&plane);
798        }
799        // Everything after this point (axes, frames, curves, dimensions, gizmo,
800        // groups) is drawn with the depth-writing pipeline so it self-occludes.
801        let plane_tri_verts = ov.tris.len();
802        for a in &self.axes {
803            let color = if a.hot { brighten(a.color) } else { a.color };
804            ov.extend(&datum::datum_axis(a.point, a.direction, a.length, color));
805        }
806        for f in &self.frames {
807            ov.extend(&datum::datum_frame(f.origin, f.x, f.y, f.z, f.px, cam));
808        }
809        for c in &self.curves {
810            ov.extend(&curve_display::polyline_display(&c.points, c.color, c.closed));
811        }
812        for d in &self.dims {
813            ov.extend(&self.build_dim(d, cam).overlay);
814        }
815        if let Some(gz) = &self.transform {
816            ov.extend(&gz.geometry(cam, self.transform_hover, self.transform_active));
817        }
818        // General overlay groups (set_overlay), in render_order (stable within
819        // equal orders). Tris/lines are pre-expanded; point quads are built here
820        // camera-facing + screen-constant since the overlay pass has no point
821        // pipeline of its own.
822        let mut order: Vec<&OverlayGroupW> = if groups { self.overlay_groups.iter().collect() } else { Vec::new() };
823        order.sort_by_key(|g| g.render_order);
824        for g in order {
825            ov.tris.extend_from_slice(&g.tris);
826            ov.lines.extend_from_slice(&g.lines);
827            for p in &g.points {
828                push_point_quad(&mut ov, p.center, p.color, g.point_size, cam);
829            }
830        }
831        (ov, plane_tri_verts)
832    }
833
834    fn build_dim(&self, d: &DimW, cam: &GizmoCamera) -> dimension::DimensionAnnotation {
835        match d {
836            DimW::Linear {
837                a,
838                b,
839                offset_dir,
840                offset,
841                color,
842                ..
843            } => dimension::linear_dimension_colored(*a, *b, *offset_dir, *offset, cam, *color),
844            DimW::Angular {
845                vertex,
846                dir_a,
847                dir_b,
848                radius,
849                color,
850                ..
851            } => dimension::angular_dimension_colored(*vertex, *dir_a, *dir_b, *radius, cam, *color),
852            DimW::Radial {
853                center,
854                point,
855                color,
856                ..
857            } => dimension::radial_dimension_colored(*center, *point, cam, *color),
858        }
859    }
860
861    /// `(id, world label anchor)` for every dimension — the host projects
862    /// each with `world_to_screen` to place its text label (R29).
863    pub fn dimension_anchors(&self, cam: &GizmoCamera) -> Vec<(String, [f32; 3])> {
864        self.dims
865            .iter()
866            .map(|d| {
867                let id = match d {
868                    DimW::Linear { id, .. } | DimW::Angular { id, .. } | DimW::Radial { id, .. } => {
869                        id.clone()
870                    }
871                };
872                (id, self.build_dim(d, cam).label_anchor.into())
873            })
874            .collect()
875    }
876
877    /// Any overlay widget is present (main geometry or the ViewCube).
878    pub fn any_visible(&self) -> bool {
879        self.has_main_overlay() || self.viewcube_enabled
880    }
881
882    /// A hash of everything this registry would draw with `cam`: the main
883    /// overlay without the groups and the ViewCube, built as a frame builds
884    /// them, plus each group's identity and stored `fold_words` hash. Used by
885    /// the app's stale-frame check (automation builds) to notice a drawn
886    /// change that did not mark the frame dirty.
887    pub fn render_fingerprint(&self, cam: &GizmoCamera) -> u64 {
888        let (main, planes) = self.build_main_overlay_with(cam, false);
889        let tri_words = main.tris.iter().flat_map(|v| v.pos.into_iter().chain(v.normal).chain(v.color)).map(f32::to_bits);
890        let line_words = main.lines.iter().flat_map(|v| v.pos.into_iter().chain(v.color)).map(f32::to_bits);
891        let mut hash = fold_words(FOLD_SEED, tri_words.chain(line_words).chain([planes as u32]));
892        for g in &self.overlay_groups {
893            hash = fold_words(hash, g.name.bytes().map(u32::from).chain([g.render_order as u32, g.point_size.to_bits()]));
894            hash = fold_words(hash, [g.content_hash as u32, (g.content_hash >> 32) as u32].into_iter());
895            for p in &g.points {
896                let c: [f32; 3] = p.center.into();
897                hash = fold_words(hash, c.into_iter().chain(p.color).map(f32::to_bits));
898            }
899        }
900        if let Some(cube) = self.build_viewcube(cam) {
901            let tris = cube.overlay.tris.iter().flat_map(|v| v.pos.into_iter().chain(v.color)).map(f32::to_bits);
902            let lines = cube.overlay.lines.iter().flat_map(|v| v.pos.into_iter().chain(v.color)).map(f32::to_bits);
903            hash = fold_words(hash, tris.chain(lines).chain(cube.rect_css.map(f32::to_bits)));
904        }
905        hash
906    }
907
908    /// Build a whole frame's overlay geometry from the live camera.
909    pub fn build_overlay(&self, cam: &GizmoCamera) -> WidgetOverlay {
910        let (main, plane_tri_verts) = self.build_main_overlay(cam);
911        WidgetOverlay {
912            main,
913            plane_tri_verts,
914            viewcube: self.build_viewcube(cam),
915        }
916    }
917
918    /// The ViewCube render frame (overlay + mini-camera + corner rect), or None
919    /// when disabled.
920    pub fn build_viewcube(&self, cam: &GizmoCamera) -> Option<ViewCubeFrame> {
921        if !self.viewcube_enabled {
922            return None;
923        }
924        let cam = &self.cube_camera(cam);
925        let overlay = self.viewcube.geometry(cam, self.viewcube_hover, None);
926        let mini = self.viewcube.mini_camera(cam);
927        Some(ViewCubeFrame {
928            overlay,
929            view_proj: mini.view_proj,
930            forward: mini.forward.into(),
931            rect_css: self.viewcube.sub_rect(cam.viewport),
932        })
933    }
934
935    // --- hit testing / interaction ---------------------------------------
936
937    /// The ViewCube corner rect `[x, y, w, h]` (CSS px) — the host decides
938    /// whether to forward a pointer event and offsets it into cube-local coords.
939    pub fn viewcube_rect(&self, cam: &GizmoCamera) -> [f32; 4] {
940        self.viewcube.sub_rect(cam.viewport)
941    }
942
943    /// Hit-test the ViewCube at cube-local pixels; returns the region handle.
944    pub fn viewcube_hit(&self, cam: &GizmoCamera, local_x: f32, local_y: f32) -> Option<HandleId> {
945        if !self.viewcube_enabled {
946            return None;
947        }
948        self.viewcube.hit(&self.cube_camera(cam), [local_x, local_y])
949    }
950
951    /// Set the ViewCube hover region (drives the highlight). Returns whether it
952    /// changed.
953    pub fn set_viewcube_hover(&mut self, handle: Option<HandleId>) -> bool {
954        if self.viewcube_hover != handle {
955            self.viewcube_hover = handle;
956            true
957        } else {
958            false
959        }
960    }
961
962    /// The world eye→target look direction + up hint for a ViewCube region —
963    /// the cube's own (frame) direction carried into the world by the frame.
964    pub fn viewcube_target(&self, handle: HandleId) -> ([f32; 3], [f32; 3]) {
965        let world = |v: Vec3| {
966            let w = self.view_frame.to_world([v.x as f64, v.y as f64, v.z as f64]);
967            [w[0] as f32, w[1] as f32, w[2] as f32]
968        };
969        (world(ViewCube::target_view(handle)), world(ViewCube::target_up(handle)))
970    }
971
972    /// EVERY fed datum PLANE whose DRAWN card the pointer ray crosses, as
973    /// `(name, world hit point)` — the multi-hit sibling of [`datum_pick`], which
974    /// stops at the first hit and also considers axes.
975    ///
976    /// The bound is the rectangle the renderer draws, not the infinite plane:
977    /// [`DatumPlane::hit_point`] is the same `half()` extent [`build_main_overlay`]
978    /// draws with, evaluated against the LIVE camera on every call — so a
979    /// screen-constant card's pickable region tracks its drawn size across a zoom
980    /// (nothing is baked). Either face of the card hits. Unnamed planes are
981    /// skipped (nothing could be selected by them).
982    ///
983    /// The engine turns these into `PickKind::Plane` candidates so a construction
984    /// plane competes in the ordinary pick list instead of only on a geometry
985    /// miss (see `EngineState::pick_candidates_at`).
986    ///
987    /// [`datum_pick`]: Self::datum_pick
988    /// [`build_main_overlay`]: Self::build_main_overlay
989    pub fn datum_plane_hits(&self, cam: &GizmoCamera, x: f32, y: f32) -> Vec<(String, [f32; 3])> {
990        self.planes
991            .iter()
992            .filter(|p| !p.name.is_empty())
993            .filter_map(|p| {
994                let gz = DatumPlane {
995                    origin: p.origin,
996                    x_axis: p.x,
997                    y_axis: p.y,
998                    size: p.size,
999                    color: p.color,
1000                    handle: 1,
1001                };
1002                let point = gz.hit_point(cam, [x, y])?;
1003                Some((p.name.clone(), [point.x, point.y, point.z]))
1004            })
1005            .collect()
1006    }
1007
1008    /// Pick the datum plane/axis under a screen pixel; returns its name.
1009    pub fn datum_pick(&self, cam: &GizmoCamera, x: f32, y: f32) -> Option<String> {
1010        // Nearest wins by depth of the hit; planes and axes both tested. We keep
1011        // it simple: axes first (thin, priority), then planes.
1012        for a in &self.axes {
1013            let gz = DatumAxis {
1014                point: a.point,
1015                direction: a.direction,
1016                length: a.length,
1017                color: a.color,
1018                handle: 1,
1019            };
1020            if gz.hit(cam, [x, y]).is_some() && !a.name.is_empty() {
1021                return Some(a.name.clone());
1022            }
1023        }
1024        for p in &self.planes {
1025            let gz = DatumPlane {
1026                origin: p.origin,
1027                x_axis: p.x,
1028                y_axis: p.y,
1029                size: p.size,
1030                color: p.color,
1031                handle: 1,
1032            };
1033            if gz.hit(cam, [x, y]).is_some() && !p.name.is_empty() {
1034                return Some(p.name.clone());
1035            }
1036        }
1037        None
1038    }
1039
1040    pub fn has_transform(&self) -> bool {
1041        self.transform.is_some()
1042    }
1043
1044    /// The VISIBLE transform gizmo's current frame origin in world space, or `None`
1045    /// when the gizmo is hidden. Reflects the last [`set_transform_json`](Self::set_transform_json) feed, so
1046    /// it tracks the live-follow re-sync during a drag (Fix 3) — distinct from a
1047    /// params-derived anchor, this proves the drawn widget actually moved.
1048    pub fn transform_origin(&self) -> Option<[f32; 3]> {
1049        self.transform.as_ref().map(|gz| [gz.origin.x, gz.origin.y, gz.origin.z])
1050    }
1051
1052    /// Hit-test the transform gizmo; returns the handle (0 = none).
1053    pub fn transform_hit(&self, cam: &GizmoCamera, x: f32, y: f32) -> HandleId {
1054        self.transform
1055            .as_ref()
1056            .and_then(|gz| gz.hit(cam, [x, y]))
1057            .unwrap_or(0)
1058    }
1059
1060    /// The world-space `(shaft-start, tip)` endpoints of axis arrow `i` on the
1061    /// LIVE transform gizmo — the SAME instance + `axis_seg` the hit test
1062    /// (`transform_hit` → `TransformGizmo::hit`) measures against — so a debug
1063    /// overlay can outline the exact pickable region without re-deriving it.
1064    /// `None` when the gizmo is hidden.
1065    pub fn transform_axis_seg(&self, cam: &GizmoCamera, i: usize) -> Option<(Vec3, Vec3)> {
1066        self.transform.as_ref().map(|gz| gz.axis_seg(cam, i))
1067    }
1068
1069    /// The world-space center free-move / origin ball point of the LIVE transform
1070    /// gizmo, or `None` when hidden / the center handle is off. For the debug
1071    /// hit-area outline (radius [`brep_gizmos::transform::PX_CENTER_RAD`]).
1072    pub fn transform_center_grab(&self) -> Option<Vec3> {
1073        self.transform.as_ref().and_then(|gz| gz.center_grab_point())
1074    }
1075
1076    /// The three rotation grab-sphere world points of the LIVE transform gizmo (in
1077    /// `ARCS` order), or `None` when hidden. For the debug hit-area outline (radius
1078    /// [`brep_gizmos::transform::PX_RING_GRAB_RAD`]).
1079    pub fn transform_ring_grabs(&self, cam: &GizmoCamera) -> Option<[Vec3; 3]> {
1080        self.transform.as_ref().map(|gz| gz.ring_grab_points(cam))
1081    }
1082
1083    /// The authoritative screen-space pickable regions of the LIVE transform gizmo
1084    /// — the SAME `hit_regions` [`TransformGizmo::hit`] consumes — each paired with
1085    /// its handle. `[]` when the gizmo is hidden. The debug-outline exposer
1086    /// serializes these, so the drawn region IS exactly the pickable region.
1087    pub fn transform_hit_regions(
1088        &self,
1089        cam: &GizmoCamera,
1090    ) -> Vec<(HandleId, brep_gizmos::hit_region::HitShape)> {
1091        self.transform
1092            .as_ref()
1093            .map(|gz| gz.hit_regions(cam))
1094            .unwrap_or_default()
1095    }
1096
1097    pub fn set_transform_hover(&mut self, handle: HandleId) -> bool {
1098        let handle = (handle != 0).then_some(handle);
1099        if self.transform_hover != handle {
1100            self.transform_hover = handle;
1101            true
1102        } else {
1103            false
1104        }
1105    }
1106
1107    pub fn set_transform_active(&mut self, handle: HandleId) {
1108        self.transform_active = (handle != 0).then_some(handle);
1109    }
1110
1111    /// Compute a transform drag: frame-space delta + its world resolution, as
1112    /// JSON for the host's feature-edit commit (R28). Resolved against the LIVE gizmo
1113    /// frame (`self.transform`).
1114    pub fn transform_drag_json(
1115        &self,
1116        cam: &GizmoCamera,
1117        handle: HandleId,
1118        sx: f32,
1119        sy: f32,
1120        cx: f32,
1121        cy: f32,
1122    ) -> String {
1123        let Some(gz) = &self.transform else {
1124            return "{\"kind\":\"none\"}".to_string();
1125        };
1126        drag_delta_json(gz, cam, handle, sx, sy, cx, cy)
1127    }
1128
1129    /// Like [`transform_drag_json`](Self::transform_drag_json) but resolved against an EXPLICIT frozen frame
1130    /// (`{origin,x,y,z}`, the grab-time feature frame) instead of the live widget
1131    /// gizmo. This lets the engine re-sync the VISIBLE gizmo to the moving feature
1132    /// pose every drag frame (Fix 3 live-follow) while the delta stays anchored to
1133    /// the grab frame, so the visual sync can't feed back into the drag math.
1134    pub fn transform_drag_json_with_frame(
1135        &self,
1136        cam: &GizmoCamera,
1137        frame_json: &str,
1138        handle: HandleId,
1139        sx: f32,
1140        sy: f32,
1141        cx: f32,
1142        cy: f32,
1143    ) -> String {
1144        let value: Value = match serde_json::from_str(frame_json) {
1145            Ok(v) => v,
1146            Err(_) => return "{\"kind\":\"none\"}".to_string(),
1147        };
1148        let origin = value.get("origin").and_then(vec3_of).unwrap_or(Vec3::ZERO);
1149        let x = value.get("x").and_then(vec3_of).unwrap_or(Vec3::X);
1150        let y = value.get("y").and_then(vec3_of).unwrap_or(Vec3::Y);
1151        let z = value.get("z").and_then(vec3_of).unwrap_or(Vec3::Z);
1152        let mut gz = TransformGizmo::default();
1153        gz.set_frame(origin, x, y, z);
1154        drag_delta_json(&gz, cam, handle, sx, sy, cx, cy)
1155    }
1156}
1157
1158/// The shared body of [`WidgetRegistry::transform_drag_json`] + its frozen-frame
1159/// twin: resolve `gz`'s frame-space drag delta into the feature-edit-commit JSON.
1160fn drag_delta_json(
1161    gz: &TransformGizmo,
1162    cam: &GizmoCamera,
1163    handle: HandleId,
1164    sx: f32,
1165    sy: f32,
1166    cx: f32,
1167    cy: f32,
1168) -> String {
1169    let start = cam.ray_from_screen(sx, sy);
1170    let current = cam.ray_from_screen(cx, cy);
1171    match gz.drag_delta(cam, handle, start, current) {
1172        DragDelta::Translate(v) => {
1173            let world = gz.ex.scale(v.x).add(gz.ey.scale(v.y)).add(gz.ez.scale(v.z));
1174            serde_json::json!({
1175                "kind": "translate",
1176                "local": [v.x, v.y, v.z],
1177                "world": [world.x, world.y, world.z],
1178            })
1179            .to_string()
1180        }
1181        DragDelta::Rotate { axis_index, radians } => {
1182            let axis = gz.axis(axis_index);
1183            serde_json::json!({
1184                "kind": "rotate",
1185                "axisIndex": axis_index,
1186                "axisWorld": [axis.x, axis.y, axis.z],
1187                "radians": radians,
1188            })
1189            .to_string()
1190        }
1191        DragDelta::None => "{\"kind\":\"none\"}".to_string(),
1192    }
1193}
1194