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