brep_render/engine_state/camera_widgets.rs
1use super::*;
2
3impl EngineState {
4 /// Per-frame overlay upkeep — the ONE call the app viewport makes each frame
5 /// (see `BREP_app/src/viewport/interaction.rs`).
6 ///
7 /// Everything fed through the general `set_overlay` channel is pre-expanded
8 /// into GPU vertices AT FEED TIME (see the "Why not one uniform feed" note in
9 /// [`crate::widgets`]), so — unlike the specialized widgets (transform gizmo,
10 /// datums, ViewCube), which are rebuilt against the LIVE camera every frame —
11 /// a baked overlay group keeps whatever screen-constant sizing it was baked
12 /// with. A ZOOM changes `world_per_pixel` and nothing else re-bakes them, so
13 /// the draggable gizmos keep their old pixel size — and where a handle's world
14 /// position is ITSELF `px × world_per_pixel` (the angular arc), their old
15 /// POSITION too, drifting away from the live-computed grab region.
16 ///
17 /// So: re-bake on a MATERIAL `world_per_pixel` change, keyed on that ONE
18 /// quantity rather than on any particular gesture. Every zoom path moves it —
19 /// the wheel, [`Self::zoom_to_fit`], [`Self::standard_view`] (which fits), a
20 /// viewport [`Self::resize`] — so they are all covered without a per-path
21 /// hook. What does NOT move it needs no re-bake, and correctly gets none:
22 /// pan and orbit hold the eye→target distance, the ViewCube (face, corner AND
23 /// navigation arrow) is a fixed-pivot reorient, and
24 /// [`Self::toggle_projection`] preserves apparent size by construction
25 /// (`ViewCamera::toggle_projection` solves for the distance/half-height that
26 /// keeps `world_per_pixel` — see `projection_toggle_preserves_apparent_size`).
27 /// The baked buffers are world-space, so the GPU re-projects them for free.
28 /// Re-bakes only on actual change, so a quiet frame stays quiet (no per-frame
29 /// dirty loop).
30 pub fn ensure_overlays_current(&mut self) {
31 // Assembly-constraint leaders + grabbable distance/angle handles (§8.4).
32 self.ensure_constraint_overlay_current();
33 // The ◎ feature-DIMENSION gizmo: draggable leaders/arrowheads + the
34 // angular sweep handle, whose arc radius is itself px × world_per_pixel.
35 self.ensure_feature_dimension_overlay_current();
36 // Live sketch mode: draggable dimension leaders, constraint glyphs and
37 // construction dashes, all sized in pixels at bake time.
38 self.ensure_sketch_overlay_current();
39 // The active PMI view's annotation graphics (screen-constant arrows
40 // that also face the camera).
41 self.ensure_pmi_overlay_current();
42 }
43
44 /// Frame the whole scene (used right after the first history feed).
45 pub fn zoom_to_fit(&mut self) {
46 self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
47 self.dirty = true;
48 }
49
50 // --- Sizing -----------------------------------------------------------
51
52 /// Update the CSS viewport size (used by all camera math). The physical
53 /// framebuffer size + DPR are the presentation shell's concern.
54 pub fn resize(&mut self, css_width: f64, css_height: f64) {
55 self.camera.width = css_width.max(1.0);
56 self.camera.height = css_height.max(1.0);
57 self.dirty = true;
58 }
59
60 // --- Pointer / wheel ingestion (R22) ----------------------------------
61
62 pub fn pointer_down(&mut self, x: f64, y: f64, button: i32) -> bool {
63 // Sketch camera lock: while locked, the view is held flat-on to the sketch
64 // plane, so a LEFT press must NOT drive the camera at all — neither orbit
65 // (which would tilt off the plane) NOR pan. Suppressing it keeps left-drag
66 // free for sketch interaction and leaves pan on right/middle. Modeling mode
67 // and the UNLOCKED sketch view (where left orbits) are unaffected.
68 if self.sketch_mode()
69 && self.sketch_camera_locked
70 && button == crate::controls::BUTTON_LEFT
71 {
72 return false;
73 }
74 self.controls.pointer_down(x, y, button)
75 }
76
77 pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
78 let changed = self.controls.pointer_move(&mut self.camera, x, y);
79 if changed {
80 self.dirty = true;
81 }
82 changed
83 }
84
85 pub fn pointer_up(&mut self) -> bool {
86 self.controls.pointer_up()
87 }
88
89 pub fn wheel(&mut self, delta_y: f64, cursor: Option<[f64; 2]>) -> bool {
90 let changed = self.controls.wheel(&mut self.camera, delta_y, cursor);
91 if changed {
92 self.dirty = true;
93 }
94 changed
95 }
96
97 pub fn set_controls_enabled(&mut self, enabled: bool) {
98 self.controls.enabled = enabled;
99 }
100
101 // --- Camera commands (R21) --------------------------------------------
102
103 pub fn toggle_projection(&mut self) -> &'static str {
104 let kind = self.camera.toggle_projection();
105 self.dirty = true;
106 kind
107 }
108
109 pub fn set_projection(&mut self, kind: &str) {
110 let is_persp = matches!(self.camera.projection, crate::view::Projection::Perspective { .. });
111 let want_persp = kind.to_ascii_lowercase().starts_with("pers");
112 if is_persp != want_persp {
113 self.camera.toggle_projection();
114 self.dirty = true;
115 }
116 }
117
118 pub fn standard_view(&mut self, name: &str) -> bool {
119 let ok = self.camera.standard_view(name);
120 if ok {
121 self.camera.zoom_to_fit(&self.scene.bbox(), 1.15);
122 self.dirty = true;
123 }
124 ok
125 }
126
127 pub fn camera_state_json(&self) -> String {
128 self.camera.state_json()
129 }
130
131 pub fn apply_camera_state_json(&mut self, json: &str) -> Result<(), String> {
132 self.camera.apply_state_json(json)?;
133 self.dirty = true;
134 Ok(())
135 }
136
137 pub fn world_per_pixel(&self) -> f64 {
138 self.camera.world_per_pixel()
139 }
140
141 // --- World → screen (R25) ---------------------------------------------
142
143 /// Project world points to CSS-pixel screen coords for host anchoring. Input
144 /// is `[[x,y,z], …]`; output `[[sx, sy, depth, inFront], …]` where inFront
145 /// is 1 when a LABEL anchored at the point should draw
146 /// ([`crate::view::ViewCamera::label_anchor_visible`], THE one label policy:
147 /// the point is projectable — ortho always, perspective unless at/behind the
148 /// eye plane, near/far NEVER cull — AND its projection lands inside the
149 /// viewport, so an off-screen anchor's chip vanishes instead of clamping to
150 /// the viewport edge). Every app label pass (sketch dims, feature dims,
151 /// constraint chips, gizmo axis text) keys its skip off THIS flag, so the
152 /// policy lives in exactly one place.
153 pub fn world_to_screen_json(&self, points_json: &str) -> Result<String, String> {
154 let points: Vec<[f64; 3]> = serde_json::from_str(points_json)
155 .map_err(|error| format!("world_to_screen points parse: {error}"))?;
156 let out: Vec<[f64; 4]> = points
157 .into_iter()
158 .map(|p| {
159 let (sx, sy, depth) = self.camera.project(p);
160 let visible = self.camera.label_anchor_visible(p);
161 [sx, sy, depth, if visible { 1.0 } else { 0.0 }]
162 })
163 .collect();
164 Ok(serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()))
165 }
166
167 /// The camera matrices for the host overlays' per-frame world→screen /
168 /// screen→world hot path: `{ viewProj:[16], viewProjInverse:[16],
169 /// viewport:[w,h] }`. Both matrices are column-major (index =
170 /// `col*4 + row`); `viewProj` maps world → wgpu clip
171 /// (x,y in −1..1, z in 0..1) and `viewport` is the CSS-pixel size. This lets
172 /// dimensions + sketch drop the compat mirror camera and read the engine's
173 /// own view-projection directly (see `world_to_screen_json` for one-shots).
174 pub fn camera_matrices_json(&self) -> String {
175 let view_proj = self.camera.view_proj_flat();
176 let view_proj_inverse = self.camera.view_proj_inverse_flat();
177 serde_json::json!({
178 "viewProj": view_proj,
179 "viewProjInverse": view_proj_inverse,
180 "viewport": [self.camera.width, self.camera.height],
181 })
182 .to_string()
183 }
184
185 // --- Picking (R23/R24) ------------------------------------------------
186
187}
188
189impl EngineState {
190 /// Build this frame's overlay-widget geometry, or None when nothing is
191 /// enabled (skips the overlay passes entirely).
192 pub fn build_widget_overlay(&self) -> Option<WidgetOverlay> {
193 if !self.widgets.any_visible() {
194 return None;
195 }
196 Some(self.widgets.build_overlay(&gizmo_camera(&self.camera)))
197 }
198
199 /// Fit the per-frame depth window to EVERYTHING drawn, then resolve the GPU
200 /// camera — the ONE path both frame loops (wasm `Engine::render`, desktop
201 /// `redraw`) use so they can't drift. The overlay is built FIRST, then its
202 /// WORLD bounds are folded into the fit: near/far never affect the overlay
203 /// geometry (it depends only on view direction + `world_per_pixel`), so
204 /// building it before the fit lets construction geometry — datum planes,
205 /// world axes, frames, the transform gizmo — be bracketed by the depth
206 /// window instead of clipping against the solids-only bounds. The world
207 /// ORIGIN is always folded in too, so the origin triad stays bracketed even
208 /// when every geometry channel is momentarily empty (an all-empty frame then
209 /// yields a tiny origin-centred window — harmless, re-fit next frame). The
210 /// ViewCube is excluded (it draws with its own mini-camera; see
211 /// [`WidgetOverlay::world_bbox`]). Returns the resolved camera + the built
212 /// overlay for the frame to hand to the render core.
213 pub fn fit_camera_and_overlay(&mut self) -> (crate::camera::Camera, Option<WidgetOverlay>) {
214 let overlay = self.build_widget_overlay();
215 let mut depth_bbox = self.depth_range_bbox();
216 if let Some(overlay) = &overlay {
217 depth_bbox.union(&overlay.world_bbox());
218 }
219 depth_bbox.expand([0.0, 0.0, 0.0]);
220 self.camera.fit_depth_range(&depth_bbox);
221 (self.camera.resolve(), overlay)
222 }
223
224 pub fn set_datums_json(&mut self, json: &str) -> Result<(), String> {
225 self.widgets.set_datums_json(json)?;
226 self.dirty = true;
227 Ok(())
228 }
229
230 /// Feed the general overlay geometry channel (`set_overlay`): arbitrary named
231 /// tri/line/point groups (feature-dialog previews and other display-only
232 /// geometry), drawn in the widget overlay pass.
233 pub fn set_overlay_json(&mut self, json: &str) -> Result<(), String> {
234 self.widgets.set_overlay_json(json)?;
235 self.dirty = true;
236 Ok(())
237 }
238
239 pub fn set_dimensions_json(&mut self, json: &str) -> Result<(), String> {
240 self.widgets.set_dimensions_json(json)?;
241 self.dirty = true;
242 Ok(())
243 }
244
245 pub fn set_transform_json(&mut self, json: &str) -> Result<(), String> {
246 self.widgets.set_transform_json(json)?;
247 self.dirty = true;
248 Ok(())
249 }
250
251 pub fn set_viewcube_enabled(&mut self, enabled: bool) {
252 self.widgets.set_viewcube_enabled(enabled);
253 self.dirty = true;
254 }
255
256 /// The ViewCube corner rect `{x,y,w,h}` (CSS px) so the host can decide
257 /// whether to forward a pointer event.
258 pub fn viewcube_rect_json(&self) -> String {
259 let r = self.widgets.viewcube_rect(&gizmo_camera(&self.camera));
260 serde_json::json!({ "x": r[0], "y": r[1], "w": r[2], "h": r[3] }).to_string()
261 }
262
263 /// Update the ViewCube hover from cube-local pixels; returns whether it
264 /// changed (a hover-out is `(None)` with local coords outside).
265 pub fn viewcube_hover(&mut self, local_x: f64, local_y: f64) -> bool {
266 let cam = gizmo_camera(&self.camera);
267 let handle = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32);
268 let changed = self.widgets.set_viewcube_hover(handle);
269 if changed {
270 self.dirty = true;
271 }
272 changed
273 }
274
275 pub fn viewcube_clear_hover(&mut self) -> bool {
276 let changed = self.widgets.set_viewcube_hover(None);
277 if changed {
278 self.dirty = true;
279 }
280 changed
281 }
282
283 /// Click the ViewCube at cube-local pixels: snap the shared camera to the
284 /// region's standard view (keeping the current pivot distance). Returns
285 /// true if a region was hit.
286 pub fn viewcube_click(&mut self, local_x: f64, local_y: f64) -> bool {
287 let cam = gizmo_camera(&self.camera);
288 let Some(handle) = self.widgets.viewcube_hit(&cam, local_x as f32, local_y as f32) else {
289 return false;
290 };
291 // Navigation arrows apply a RELATIVE camera rotation (orbit / roll) to
292 // the current view instead of snapping to an absolute standard view.
293 if brep_gizmos::view_cube::ViewCube::is_arrow(handle) {
294 self.apply_viewcube_arrow(handle);
295 self.dirty = true;
296 return true;
297 }
298 let (dir, fallback_up) = self.widgets.viewcube_target(handle);
299 // Minimal-rotation snap with a LEVELLED roll: the view direction snaps to
300 // the region, and the up is snapped to the nearest member of a discrete
301 // per-kind set (see [`snap_view_up`]) so a face lands flat-on with its
302 // bottom edge horizontal and a corner lands on a proper top-vertex-up
303 // isometric — always the orientation that rotates the camera the least.
304 let kind = brep_gizmos::view_cube::ViewCube::region_kind(handle);
305 let dirf = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
306 let up = snap_view_up(kind, dirf, self.camera.up, fallback_up);
307 self.apply_look_direction(dir, up);
308 self.dirty = true;
309 true
310 }
311
312 /// Reorient the camera to look along `dir` (world eye→target) with `up`,
313 /// preserving the current pivot distance.
314 pub(super) fn apply_look_direction(&mut self, dir: [f32; 3], up: [f32; 3]) {
315 let dir = [dir[0] as f64, dir[1] as f64, dir[2] as f64];
316 let dist = self.camera.distance();
317 self.camera.eye = [
318 self.camera.target[0] - dir[0] * dist,
319 self.camera.target[1] - dir[1] * dist,
320 self.camera.target[2] - dir[2] * dist,
321 ];
322 self.camera.up = [up[0] as f64, up[1] as f64, up[2] as f64];
323 }
324
325 /// Apply a ViewCube navigation-arrow rotation to the CURRENT camera (a
326 /// relative 90° orbit / roll), keeping the pivot (`target`) fixed. The
327 /// rotation axes are the camera's own screen axes so the motion follows the
328 /// on-screen arrow direction: the eye moves toward the pan arrow it points
329 /// at, and the roll arcs spin the up vector about the view direction.
330 fn apply_viewcube_arrow(&mut self, handle: u32) {
331 use crate::view::{add3, rotate3, sub3};
332 use brep_gizmos::view_cube::ViewCube;
333 // World-space screen axes of the current view: right, up, forward(eye→target).
334 let (right, up_axis, fwd) = self.camera.basis();
335 let target = self.camera.target;
336 let rel = sub3(self.camera.eye, target); // eye relative to pivot
337 let q = std::f64::consts::FRAC_PI_2; // 90° per click
338 match handle {
339 // Orbit about the screen-up axis; eye moves toward the arrow side.
340 ViewCube::ARROW_RIGHT => {
341 self.camera.eye = add3(target, rotate3(rel, up_axis, q));
342 }
343 ViewCube::ARROW_LEFT => {
344 self.camera.eye = add3(target, rotate3(rel, up_axis, -q));
345 }
346 // Orbit about the screen-right axis; carry the up vector along so the
347 // view stays upright (eye moves toward the arrow side).
348 ViewCube::ARROW_UP => {
349 self.camera.eye = add3(target, rotate3(rel, right, -q));
350 self.camera.up = rotate3(self.camera.up, right, -q);
351 }
352 ViewCube::ARROW_DOWN => {
353 self.camera.eye = add3(target, rotate3(rel, right, q));
354 self.camera.up = rotate3(self.camera.up, right, q);
355 }
356 // Roll about the view direction; only the up vector changes.
357 ViewCube::ROLL_CCW => {
358 self.camera.up = rotate3(self.camera.up, fwd, q);
359 }
360 ViewCube::ROLL_CW => {
361 self.camera.up = rotate3(self.camera.up, fwd, -q);
362 }
363 _ => {}
364 }
365 }
366
367 /// Pick the datum plane/axis under a screen pixel; returns its name (empty
368 /// when none).
369 ///
370 /// NOT the selection path any more: construction planes are ordinary pick
371 /// candidates ([`Self::pick_candidates_at`] → the widget's `datum_plane_hits`,
372 /// which reports EVERY card the ray crosses rather than the first), so the
373 /// viewport click router no longer calls this. It survives as the AXIS-aware
374 /// second line of defense inside [`Self::ref_select_click`]'s total-miss arm.
375 pub fn datum_pick(&self, x: f64, y: f64) -> String {
376 self.widgets
377 .datum_pick(&gizmo_camera(&self.camera), x as f32, y as f32)
378 .unwrap_or_default()
379 }
380
381 /// Update the transform-gizmo hover from a screen pixel; returns the handle
382 /// under the pointer (0 = none). Marks dirty when the highlight changed.
383 pub fn transform_hover(&mut self, x: f64, y: f64) -> u32 {
384 let cam = gizmo_camera(&self.camera);
385 let handle = self.widgets.transform_hit(&cam, x as f32, y as f32);
386 if self.widgets.set_transform_hover(handle) {
387 self.dirty = true;
388 }
389 handle
390 }
391
392 /// The transform-gizmo handle under a screen pixel (0 = none) — the host
393 /// echoes it back to start a drag.
394 pub fn transform_pick(&self, x: f64, y: f64) -> u32 {
395 self.widgets.transform_hit(&gizmo_camera(&self.camera), x as f32, y as f32)
396 }
397
398 /// Compute a transform drag (frame-space + world delta) as JSON for the
399 /// feature-edit commit. Marks the handle active for the highlight.
400 pub fn transform_drag(
401 &mut self,
402 handle: u32,
403 sx: f64,
404 sy: f64,
405 cx: f64,
406 cy: f64,
407 ) -> String {
408 let cam = gizmo_camera(&self.camera);
409 self.widgets.set_transform_active(handle);
410 self.dirty = true;
411 self.widgets
412 .transform_drag_json(&cam, handle, sx as f32, sy as f32, cx as f32, cy as f32)
413 }
414
415 pub fn transform_drag_end(&mut self) {
416 self.widgets.set_transform_active(0);
417 self.dirty = true;
418 }
419
420 /// Per-dimension label placement: `[{id, anchor:[x,y,z],
421 /// screen:[sx,sy,inFront]}]` — the host pins each text label at `screen`.
422 pub fn dimension_anchors_json(&self) -> String {
423 let anchors = self.widgets.dimension_anchors(&gizmo_camera(&self.camera));
424 let out: Vec<serde_json::Value> = anchors
425 .into_iter()
426 .map(|(id, p)| {
427 let world = [p[0] as f64, p[1] as f64, p[2] as f64];
428 let (sx, sy, _) = self.camera.project(world);
429 // Same ONE label policy as `world_to_screen_json` — near/far
430 // never cull; off-viewport anchors hide their label.
431 let visible = if self.camera.label_anchor_visible(world) { 1.0 } else { 0.0 };
432 serde_json::json!({
433 "id": id,
434 "anchor": p,
435 "screen": [sx, sy, visible],
436 })
437 })
438 .collect();
439 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
440 }
441
442 // --- Undo / redo (engine-owned) ---------------------------------------
443 //
444 // The model is engine-owned, so its undo history lives in the engine core
445 // too: `History` holds the stacks and snapshots itself BEFORE each model
446 // mutation (edit / add / delete / reorder), while roll-to-step is view state
447 // and is NOT snapshotted. The UI only TRIGGERS these; it never holds a stack.
448
449}
450
451/// Choose the camera `up` when the ViewCube snaps to a face / edge / corner.
452///
453/// `dir` is the world eye→target direction the view snaps to
454/// ([`brep_gizmos::view_cube::ViewCube::target_view`]): axis-aligned for a face,
455/// a 45° blend for an edge, a body-diagonal for a corner. `current_up` is the
456/// live camera up; `fallback` the region's canonical up.
457///
458/// The roll is snapped to a DISCRETE set and the member closest to the current
459/// up wins (max dot ⇒ least roll), so the reorient rotates by the smallest angle
460/// while still landing "level":
461/// - **face** (`kind == 1`): the 4 signed world axes lying in the face plane
462/// (the two axes `dir` is perpendicular to) — the face ends flat-on with its
463/// bottom edge horizontal, choosing whichever of the 4 edges was already
464/// nearest the top.
465/// - **corner** (`kind == 3`): the 3 cube axes that point up for this corner
466/// (top-vertex-up isometric), 120° apart — the axis whose sign opposes each
467/// component of `dir` (so the near vertex reads upright, not inverted).
468/// - **edge / other**: no discrete set — the current up is projected onto the
469/// plane ⟂ `dir` (free roll preserved), falling back to `fallback` when that
470/// projection degenerates (up nearly parallel to `dir`).
471pub(super) fn snap_view_up(
472 kind: u8,
473 dir: [f64; 3],
474 current_up: [f64; 3],
475 fallback: [f32; 3],
476) -> [f32; 3] {
477 let mut candidates: Vec<[f64; 3]> = Vec::new();
478 match kind {
479 // Face: both signs of each axis the (axis-aligned) view dir is ⟂ to.
480 1 => {
481 for a in 0..3 {
482 if dir[a].abs() < 0.5 {
483 let mut p = [0.0; 3];
484 p[a] = 1.0;
485 candidates.push(p);
486 p[a] = -1.0;
487 candidates.push(p);
488 }
489 }
490 }
491 // Corner: the axis direction opposite each component of the view dir
492 // (dir = -normalize(signs), so -sign(dir[a]) recovers the corner's sign).
493 3 => {
494 for a in 0..3 {
495 let mut p = [0.0; 3];
496 p[a] = -dir[a].signum();
497 candidates.push(p);
498 }
499 }
500 _ => {}
501 }
502
503 let dot = |v: &[f64; 3]| v[0] * current_up[0] + v[1] * current_up[1] + v[2] * current_up[2];
504 if let Some(best) = candidates
505 .into_iter()
506 .max_by(|x, y| dot(x).partial_cmp(&dot(y)).unwrap_or(std::cmp::Ordering::Equal))
507 {
508 return [best[0] as f32, best[1] as f32, best[2] as f32];
509 }
510
511 // Edge / fallback: project the current up onto the plane ⟂ dir (keep roll).
512 let d = dot(&dir);
513 let proj = [
514 current_up[0] - dir[0] * d,
515 current_up[1] - dir[1] * d,
516 current_up[2] - dir[2] * d,
517 ];
518 let len = (proj[0] * proj[0] + proj[1] * proj[1] + proj[2] * proj[2]).sqrt();
519 if len > 1e-4 {
520 [
521 (proj[0] / len) as f32,
522 (proj[1] / len) as f32,
523 (proj[2] / len) as f32,
524 ]
525 } else {
526 fallback
527 }
528}
529
530/// Serialize a screen-space [`brep_gizmos::hit_region::HitShape`] to the gizmo
531/// hit-area JSON schema the app strokes: `{ kind:"capsule", a:[x,y], b:[x,y], r }`
532/// or `{ kind:"circle", c:[x,y], r }` — viewport-local px, so the app only offsets
533/// by `rect.min`. The engine hit-tests these SAME shapes, so the drawn outline can
534/// never drift from the pickable region. Shared by both gizmo exposers.
535pub(super) fn hit_shape_json(shape: &brep_gizmos::hit_region::HitShape) -> serde_json::Value {
536 use brep_gizmos::hit_region::HitShape;
537 match *shape {
538 HitShape::Capsule { a, b, r } => {
539 serde_json::json!({ "kind": "capsule", "a": a, "b": b, "r": r })
540 }
541 HitShape::Circle { c, r } => serde_json::json!({ "kind": "circle", "c": c, "r": r }),
542 }
543}
544
545/// Serialize an iterator of [`brep_gizmos::hit_region::HitShape`]s to the app's
546/// gizmo hit-area JSON array (see [`hit_shape_json`]); `"[]"` on failure.
547pub(super) fn hit_shapes_json<'a>(
548 shapes: impl Iterator<Item = &'a brep_gizmos::hit_region::HitShape>,
549) -> String {
550 let out: Vec<serde_json::Value> = shapes.map(hit_shape_json).collect();
551 serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string())
552}
553
554// BREP private tests: 1e0e4b2500a26f9c
555
556// BREP private tests: d1d7e4847b5b6ca5