brep_render/engine_state/scene_query.rs
1use super::*;
2
3impl EngineState {
4 /// Ranked candidate list under CSS-pixel `(x, y)`, kernel names, priority
5 /// VERTEX > EDGE > FACE > … > SOLID.
6 ///
7 /// SCENE ONLY, deliberately: this R3-boundary accessor (and its
8 /// [`hover_json`](Self::hover_json) sibling) reports kernel-named GEOMETRY, and
9 /// its in-tree consumer is the sketch's external-edge picker, which wants
10 /// edges. The construction PLANE cards join the pick list one level up, in
11 /// [`pick_candidates_at`](Self::pick_candidates_at) — that is what the
12 /// selection paths and the app's pick-list popup consume.
13 pub fn pick_json(&self, x: f64, y: f64) -> String {
14 let candidates = pick::pick(&self.scene, &self.camera, x, y, &self.pick_options());
15 pick::candidates_to_json(&candidates)
16 }
17
18 /// The single best candidate under `(x, y)` (hover), or `null`.
19 pub fn hover_json(&self, x: f64, y: f64) -> String {
20 let candidates = pick::pick(&self.scene, &self.camera, x, y, &self.pick_options());
21 match candidates.first() {
22 Some(best) => pick::candidates_to_json(std::slice::from_ref(best))
23 .strip_prefix('[')
24 .and_then(|s| s.strip_suffix(']'))
25 .map(str::to_string)
26 .unwrap_or_else(|| "null".to_string()),
27 None => "null".to_string(),
28 }
29 }
30
31 // --- Settings / emphasis / visibility (R11/R14/R17) -------------------
32
33 pub fn apply_settings_json(&mut self, json: &str) -> Result<(), String> {
34 let prev_lod = self.settings.lod_factor;
35 let prev_override_model_colors = self.settings.override_model_colors;
36 self.settings.apply_json(json)?;
37 self.settings_generation = self.settings_generation.wrapping_add(1);
38 self.dirty = true;
39 // "Override model colors" is resolved when colours are derived, not when
40 // they are drawn, so flipping it has to re-derive. Guarded on an actual
41 // change: this apply path also runs on every unrelated settings edit.
42 if self.settings.override_model_colors != prev_override_model_colors {
43 self.sync_colors_from_metadata();
44 }
45 // Push the (possibly changed) ViewCube size into the widget so the rendered
46 // cube AND its hit-test rect track the setting. Always re-pushed (idempotent
47 // for an unchanged value) so this ONE choke point covers panel edits, boot
48 // restore, and Reset-to-defaults alike.
49 self.widgets.set_viewcube_size(self.settings.viewcube_size_px);
50 // Projection rides in the settings JSON as `orthographic` (see `settings_json`)
51 // so the toolbar toggle AND a reload both go through this ONE apply path — the
52 // same way wireframe does. It is NOT a `RenderSettings` field: read it straight
53 // off the JSON and drive the camera. A PARTIAL apply (the wireframe toggle's
54 // `{"wireframe":true}`) omits the key and leaves the projection untouched, and
55 // the panel's full-buffer apply carries the live value (so it's a no-op).
56 if let Some(want_ortho) = serde_json::from_str::<serde_json::Value>(json)
57 .ok()
58 .and_then(|v| v.get("orthographic").and_then(|o| o.as_bool()))
59 {
60 let is_ortho = matches!(
61 self.camera.projection,
62 crate::view::Projection::Orthographic { .. }
63 );
64 if want_ortho != is_ortho {
65 self.set_projection(if want_ortho { "orthographic" } else { "perspective" });
66 }
67 }
68 // The LOD factor scales DISPLAY tessellation, so a change must re-run so the
69 // resident meshes re-tessellate at the new chord tolerance (the runner drops
70 // its reuse baseline when the lod differs). Every OTHER setting is pure
71 // render state and needs no re-run. Skip the re-run when there are no
72 // features (e.g. boot restores a saved `lodFactor` before any document is
73 // loaded): the run would be empty, and the real doc load re-runs with the
74 // lod already injected.
75 if self.settings.lod_factor != prev_lod && !self.history.is_empty() {
76 self.rerun_history();
77 }
78 // Sketch colors live in the settings too: when a sketch is being edited, push
79 // the (possibly) new palette into the live session and re-push the overlay so
80 // an edited color takes effect immediately (mirrors how a wireframe/lod change
81 // refreshes the view). Compute the palette first to avoid a split borrow.
82 if self.sketch_edit.is_some() {
83 let colors = self.settings.sketch_colors();
84 if let Some(edit) = self.sketch_edit.as_mut() {
85 edit.session.colors = colors;
86 }
87 self.refresh_sketch_overlay();
88 }
89 Ok(())
90 }
91
92 /// The FULL current settings as JSON (the round-trip counterpart of
93 /// [`apply_settings_json`]): the schema-driven form seeds its widgets from
94 /// this and the storage seam persists it.
95 pub fn settings_json(&self) -> String {
96 // Projection is live CAMERA state surfaced to the settings layer as a boolean
97 // (`orthographic`) so the toolbar toggle persists and the settings panel can
98 // round-trip it without clobbering. DERIVE it from the camera here — it is
99 // never a stored `RenderSettings` field — so it can NEVER drift from the
100 // actual projection no matter which code path last changed it.
101 let mut value: serde_json::Value =
102 serde_json::from_str(&self.settings.to_json()).unwrap_or(serde_json::Value::Null);
103 if let Some(obj) = value.as_object_mut() {
104 obj.insert(
105 "orthographic".into(),
106 serde_json::Value::Bool(matches!(
107 self.camera.projection,
108 crate::view::Projection::Orthographic { .. }
109 )),
110 );
111 }
112 value.to_string()
113 }
114
115 /// The current per-solid metadata color overrides as JSON —
116 /// `[{"name": "...", "override": "#rrggbb" | null}, …]`. Lets a UI list the
117 /// scene's solids with their current override so the picker reflects state.
118 pub fn solid_color_overrides_json(&self) -> String {
119 let solids: Vec<serde_json::Value> = self
120 .scene
121 .solids()
122 .iter()
123 .map(|solid| {
124 let over = solid.color_override.map(|rgb| {
125 let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u32;
126 format!("#{:02x}{:02x}{:02x}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
127 });
128 serde_json::json!({ "name": solid.name, "override": over })
129 })
130 .collect();
131 serde_json::Value::Array(solids).to_string()
132 }
133
134 pub fn apply_emphasis_json(&mut self, json: &str) -> Result<(), String> {
135 self.emphasis.apply_json(json)?;
136 self.dirty = true;
137 Ok(())
138 }
139
140 pub fn set_visible(&mut self, name: &str, visible: bool) -> bool {
141 let ok = self.scene.set_visible(name, visible);
142 if ok {
143 // A derived BOARD body is dropped by every run's scene reconcile
144 // and re-seated from the KEPT display, so the hide has to reach
145 // that copy or it lasts only until the next feature edit.
146 self.remember_board_visibility(name, visible);
147 self.dirty = true;
148 // A balloon on this body loses or regains its leader at once.
149 if self.pmi_active_view.is_some() {
150 self.refresh_pmi_overlay();
151 }
152 }
153 ok
154 }
155
156 pub fn scene_listing_json(&self) -> String {
157 self.scene.listing_json()
158 }
159
160 // --- Overlay widgets --------------------------------------------------
161
162 /// The BASE bbox the camera depth-range fit starts from: the visible SOLIDS
163 /// unioned with the pushed OVERLAY groups (sketch curves/points, dimension
164 /// leaders, constraint glyphs — the `set_overlay` channel). Folding in the
165 /// groups stops orbiting an editing sketch from clipping it against the
166 /// solids-only bounds (the reported clipping when "Lock to sketch" is off).
167 /// The render path ([`Self::fit_camera_and_overlay`]) unions the FULL widget
168 /// overlay's world bounds (datum planes, world axes, frames, transform
169 /// gizmo — NOT in this bbox's channels) and the world origin on top of this
170 /// before fitting, so construction geometry never clips. Callers must bind
171 /// this to a local before `camera.fit_depth_range` (which needs
172 /// `&mut self.camera`).
173 pub fn depth_range_bbox(&self) -> crate::camera::Aabb {
174 let mut bbox = self.scene.bbox();
175 bbox.union(&self.widgets.overlay_groups_bbox());
176 bbox
177 }
178
179}
180
181impl EngineState {
182 /// A RICHER scene listing than [`scene_listing_json`](Self::scene_listing_json)
183 /// (which is counts only): per solid the individual face + edge kernel NAMES
184 /// and vertex refs (topo id + world position), plus visibility — the shape the
185 /// engine-native Scene tree lists entities from and the headed verifier asserts
186 /// against. Vertices carry no kernel name, so they are keyed by topo id + world
187 /// position (the same shape the emphasis vertex-ref selection uses).
188 pub fn scene_entities_json(&self) -> String {
189 let solids: Vec<serde_json::Value> = self
190 .scene
191 .solids()
192 .iter()
193 // Committed-sketch SHEETS are scene solids (pickable/measurable) but list
194 // under "Sketches" (`committed_sketches`), not among the real solids.
195 .filter(|solid| !solid.is_sketch)
196 .map(|solid| {
197 let faces: Vec<&str> = solid.faces.iter().map(|f| f.name.as_str()).collect();
198 // Display-only (aux) edges are not entities: a board's copper
199 // outline would list thousands of unnamed ones.
200 let edges: Vec<&str> = solid.edges.iter().filter(|e| !e.aux).map(|e| e.name.as_str()).collect();
201 let vertices: Vec<serde_json::Value> = solid
202 .vertices
203 .iter()
204 .map(|v| serde_json::json!({ "topoId": v.topo_id, "position": v.position }))
205 .collect();
206 serde_json::json!({
207 "name": solid.name,
208 "visible": solid.visible,
209 "faces": faces,
210 "edges": edges,
211 "vertices": vertices,
212 })
213 })
214 .collect();
215 serde_json::Value::Array(solids).to_string()
216 }
217
218 /// Drive the engine SELECTION by kernel NAME from a UI tree (the name-based
219 /// analogue of [`select_top_at`](Self::select_top_at), which picks under the
220 /// cursor). Replaces the current selection with the single named `solid` /
221 /// `face` / `edge` so clicking a Scene-tree row highlights that entity in the
222 /// viewport (the render pass reads `emphasis`). Vertices have no kernel name —
223 /// use [`select_vertex_by_position`](Self::select_vertex_by_position). Returns
224 /// false for an unknown `kind` or an empty `name`.
225 pub fn select_by_name(&mut self, kind: &str, name: &str) -> bool {
226 // A construction datum/plane routes to its own name-keyed selection.
227 if kind == "datum" {
228 return self.select_datum(name);
229 }
230 if name.is_empty() || !matches!(kind, "solid" | "face" | "edge") {
231 return false;
232 }
233 let had_datum = !self.emphasis.selected_datums.is_empty();
234 self.emphasis.selected_solids.clear();
235 self.emphasis.selected_faces.clear();
236 self.emphasis.selected_edges.clear();
237 self.emphasis.selected_vertices.clear();
238 self.emphasis.selected_datums.clear();
239 match kind {
240 "solid" => {
241 self.emphasis.selected_solids.insert(name.to_string());
242 }
243 "face" => {
244 self.emphasis.selected_faces.insert(name.to_string());
245 }
246 "edge" => {
247 self.emphasis.selected_edges.insert(name.to_string());
248 }
249 _ => unreachable!(),
250 }
251 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
252 self.dirty = true;
253 if had_datum {
254 self.refresh_construction_datums();
255 }
256 true
257 }
258
259 /// Select a single vertex by its owning solid + world position — vertices have
260 /// no kernel name, so emphasis keys them by solid + position (matched with a
261 /// tolerance in the render pass). Replaces the current selection. Returns false
262 /// for an empty solid name.
263 pub fn select_vertex_by_position(&mut self, solid: &str, position: [f64; 3]) -> bool {
264 if solid.is_empty() {
265 return false;
266 }
267 let had_datum = !self.emphasis.selected_datums.is_empty();
268 self.emphasis.selected_solids.clear();
269 self.emphasis.selected_faces.clear();
270 self.emphasis.selected_edges.clear();
271 self.emphasis.selected_vertices.clear();
272 self.emphasis.selected_datums.clear();
273 self.emphasis.selected_vertices.push(crate::style::VertexRef {
274 solid: solid.to_string(),
275 position,
276 });
277 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
278 self.dirty = true;
279 if had_datum {
280 self.refresh_construction_datums();
281 }
282 true
283 }
284
285 // -----------------------------------------------------------------------
286 // Scene-tree row HOVER → the viewport hover highlight
287 //
288 // The hover twin of the name-based selection above: mousing over a Scene-tree
289 // row lights the entity EXACTLY as mousing over it in the 3D view does,
290 // because it feeds the SAME `emphasis` hover buckets through the SAME
291 // `hover_candidate` bucketing the viewport's `hover_at` uses (so a hovered
292 // datum plane gets its accent re-feed, a solid lights whole, and the render
293 // pass needs no new concept). The pointer is off the viewport while it is over
294 // a row, so the viewport's pointer-left-the-viewport `clear_hover` would
295 // otherwise wipe it every frame — hence the one-frame yield flag, the twin of
296 // `take_sketch_list_hover` / `take_constraint_label_hover`.
297 // -----------------------------------------------------------------------
298
299 /// Hover-highlight by kernel NAME from a UI tree — the hover twin of
300 /// [`select_by_name`](Self::select_by_name) (`kind` is `"solid"` / `"face"` /
301 /// `"edge"` / `"datum"`; vertices carry no kernel name, so they use
302 /// [`hover_vertex_by_position`](Self::hover_vertex_by_position)).
303 ///
304 /// Deliberately does NOT consult the selection filter, matching the row's
305 /// CLICK: a row selects whatever kind it is, so its hover previews the same
306 /// entity. An unknown `kind`, an empty `name` or an unresolved datum frame is
307 /// refused without touching the current hover.
308 ///
309 /// Call it EVERY frame the row is hovered: it re-arms the one-frame
310 /// [`take_scene_tree_hover`](Self::take_scene_tree_hover) yield flag and
311 /// re-lights nothing when the hover is already this entity. Returns whether
312 /// the hover CHANGED (the [`hover_at`](Self::hover_at) convention) so the
313 /// caller can request a repaint.
314 pub fn hover_by_name(&mut self, kind: &str, name: &str) -> bool {
315 if name.is_empty() {
316 return false;
317 }
318 let pick_kind = match kind {
319 "solid" => pick::PickKind::Solid,
320 "face" => pick::PickKind::Face,
321 "edge" => pick::PickKind::Edge,
322 // A construction datum/plane hovers by FRAME name — the bucket
323 // `select_datum` and the viewport's plane picks fill.
324 "datum" => pick::PickKind::Plane,
325 _ => return false,
326 };
327 let candidate = pick::PickCandidate {
328 kind: pick_kind,
329 name: name.to_string(),
330 // A SOLID candidate resolves through `candidate_solid_name`, which
331 // prefers `solid` — and this row IS the solid.
332 solid: match pick_kind {
333 pick::PickKind::Solid => name.to_string(),
334 _ => String::new(),
335 },
336 depth: 0.0,
337 screen_dist: 0.0,
338 position: [0.0; 3],
339 };
340 self.set_scene_tree_hover(candidate)
341 }
342
343 /// Hover a single vertex by its owning solid + world position — the hover twin
344 /// of [`select_vertex_by_position`](Self::select_vertex_by_position) (vertices
345 /// have no kernel name, so emphasis keys them by solid + position). Same
346 /// per-frame contract and return as [`hover_by_name`](Self::hover_by_name).
347 pub fn hover_vertex_by_position(&mut self, solid: &str, position: [f64; 3]) -> bool {
348 if solid.is_empty() {
349 return false;
350 }
351 self.set_scene_tree_hover(pick::PickCandidate {
352 kind: pick::PickKind::Vertex,
353 // Vertices are unnamed: `solid` + `position` is the identity.
354 name: String::new(),
355 solid: solid.to_string(),
356 depth: 0.0,
357 screen_dist: 0.0,
358 position,
359 })
360 }
361
362 /// The pointer left the Scene tree's rows: drop the row-driven highlight.
363 /// Clears ONLY a hover the tree itself lit and that is STILL lit — if the
364 /// viewport has since hovered something else (it may draw before the panel in
365 /// the dock), that hover is left alone. Safe to call every frame no row is
366 /// hovered: the target is taken, so it is a no-op from the second call on.
367 /// Returns whether the hover changed.
368 pub fn scene_tree_hover_end(&mut self) -> bool {
369 match self.scene_tree_hovered.take() {
370 Some(candidate) if self.hover_is(&candidate) => self.clear_hover(),
371 _ => false,
372 }
373 }
374
375 /// Consume the one-frame "a Scene-tree row is hovering an entity" flag — the
376 /// viewport's modeling hover branch skips its pointer-off-viewport
377 /// `clear_hover` while it is set (mirrors
378 /// [`take_sketch_list_hover`](Self::take_sketch_list_hover)) so the row-driven
379 /// highlight survives the frame instead of being cleared and re-applied.
380 pub fn take_scene_tree_hover(&mut self) -> bool {
381 std::mem::take(&mut self.scene_tree_hover_active)
382 }
383
384 /// Light one Scene-tree row's candidate: arm the yield flag (every frame — the
385 /// viewport consumes it every frame), record the target so
386 /// [`scene_tree_hover_end`](Self::scene_tree_hover_end) knows what the tree
387 /// lit, and re-emphasize only when the hover actually changed (a held row must
388 /// not re-bump the emphasis generation / re-feed the datums every frame).
389 fn set_scene_tree_hover(&mut self, candidate: pick::PickCandidate) -> bool {
390 self.scene_tree_hover_active = true;
391 match self.apply_ui_hover(&candidate) {
392 // Refused (an unresolved datum frame): nothing lit, so the tree records
393 // no target and has nothing to end.
394 None => false,
395 Some(changed) => {
396 // Recorded even when unchanged: the viewport may have lit the
397 // identical entity, and the tree still owns ending it.
398 self.scene_tree_hovered = Some(candidate);
399 changed
400 }
401 }
402 }
403
404 /// Apply ONE UI-driven hover candidate to the emphasis — the shared body
405 /// behind every list→viewport highlight (the Scene tree's rows, the dialog
406 /// rows below). `None` = REFUSED, `Some(changed)` = applied, and `changed`
407 /// says whether the hover actually moved (a held row must not re-bump the
408 /// emphasis generation / re-feed the datums every frame).
409 ///
410 /// Each caller records its OWN target: that is what keeps two panes' `_end`
411 /// calls from ending each other's highlight.
412 fn apply_ui_hover(&mut self, candidate: &pick::PickCandidate) -> Option<bool> {
413 if self.hover_is(candidate) {
414 return Some(false);
415 }
416 // Only a RESOLVED datum frame hovers (the `select_datum` guard); an
417 // unresolved name would light nothing and churn the datum feed. Checked on
418 // the change path only — the dedupe above carries the held frames.
419 if candidate.kind == pick::PickKind::Plane
420 && !self.construction_frames.iter().any(|(n, _)| *n == candidate.name)
421 {
422 return None;
423 }
424 self.hover_candidate(candidate);
425 Some(true)
426 }
427}
428
429// ---------------------------------------------------------------------------
430// DIALOG row HOVER → the viewport hover highlight
431//
432// The Scene tree knows the KIND of every row it draws; a dialog does not. A
433// feature form's reference line, its read-only `Outputs` line and the picker
434// card's picked-name line all carry a bare kernel NAME, so this lane resolves the
435// kind from the scene itself and then feeds the SAME hover buckets the tree and
436// the viewport's `hover_at` fill.
437// ---------------------------------------------------------------------------
438impl EngineState {
439 /// Hover-highlight the entity a DIALOG row names — the kind-less twin of
440 /// [`hover_by_name`](Self::hover_by_name), for the lists that show a bare
441 /// kernel name (a reference line, an `Outputs` line, the picker card).
442 ///
443 /// `owner` is the pane that is hovering (`"history"`, `"constraints"`,
444 /// `"pmi"`, `"refsel"`): [`dialog_hover_end`](Self::dialog_hover_end) acts
445 /// only for the owner that set the hover, so panes that are on screen together
446 /// in a split dock cannot end each other's highlight.
447 ///
448 /// The name is resolved by [`resolve_entity_name`](Self::resolve_entity_name),
449 /// which also covers the row whose entity the feature CONSUMED — an open
450 /// fillet form is rolled to the fillet, so its `Edges` rows name edges that no
451 /// longer exist and the blend faces the kernel built from them are lit
452 /// instead. Call it EVERY frame the row is hovered: the resolution is memoized
453 /// on the row text and the emphasis re-feed is deduped, so a held hover costs
454 /// nothing. Returns whether the hover CHANGED (the
455 /// [`hover_at`](Self::hover_at) convention), so the caller can repaint.
456 pub fn hover_entity_by_name(&mut self, owner: &'static str, name: &str) -> bool {
457 if name.is_empty() {
458 return false;
459 }
460 // Armed even for a row that resolves to nothing: the pointer is over the
461 // dialog, so the viewport's pointer-left-the-viewport `clear_hover` must
462 // still yield — otherwise moving along a list of rows would strobe the
463 // highlight off on every unresolvable one.
464 self.dialog_hover_active = true;
465 let memo = match &self.dialog_hovered {
466 Some(held) if held.owner == owner && held.row == name => Some(held.candidate.clone()),
467 _ => None,
468 };
469 let candidate = match memo {
470 Some(cached) => cached,
471 None => self.resolve_entity_name(name),
472 };
473 let applied = match &candidate {
474 Some(c) => self.apply_ui_hover(c),
475 None => None,
476 };
477 let changed = match applied {
478 Some(changed) => changed,
479 // Nothing to light (the scene carries no such name, or the candidate
480 // was refused): end whatever the slot holds — WHOEVER set it. Leaving
481 // the previous row's entity standing while the pointer sits on a
482 // different row reads as "this row is that entity", and the previous
483 // row is not always this owner's: moving from a History line straight
484 // onto an unresolvable Constraints one (a component-local vertex ref)
485 // would otherwise strand the History highlight with nobody left
486 // holding the record that could end it.
487 None => self.take_dialog_hover_slot(),
488 };
489 self.dialog_hovered = Some(super::DialogHover {
490 owner,
491 row: name.to_string(),
492 candidate,
493 });
494 changed
495 }
496
497 /// The pointer left `owner`'s dialog rows: drop the row-driven highlight.
498 /// Clears ONLY a hover THIS owner lit and that is STILL lit — a pane that
499 /// never hovered a row (or whose hover the viewport has since replaced) is a
500 /// no-op, which is what lets every consumer call it unconditionally every
501 /// frame. Returns whether the hover changed.
502 pub fn dialog_hover_end(&mut self, owner: &'static str) -> bool {
503 let mine = matches!(&self.dialog_hovered, Some(held) if held.owner == owner);
504 if mine {
505 self.take_dialog_hover_slot()
506 } else {
507 false
508 }
509 }
510
511 /// Take the dialog-hover slot and drop the highlight it recorded, when that
512 /// highlight is still the live one (the viewport may have replaced it since).
513 /// Owner-AGNOSTIC: [`dialog_hover_end`](Self::dialog_hover_end) checks the
514 /// owner before calling this, while a row that lights nothing calls it
515 /// directly — the pointer is on THAT row now, so whatever the slot still
516 /// records is stale whoever set it.
517 fn take_dialog_hover_slot(&mut self) -> bool {
518 match self.dialog_hovered.take() {
519 Some(held) => match held.candidate {
520 Some(candidate) if self.hover_is(&candidate) => self.clear_hover(),
521 _ => false,
522 },
523 None => false,
524 }
525 }
526
527 /// Consume the one-frame "a dialog row is hovering an entity" flag — the
528 /// viewport's modeling hover branch skips its pointer-off-viewport
529 /// `clear_hover` while it is set (the twin of
530 /// [`take_scene_tree_hover`](Self::take_scene_tree_hover)).
531 pub fn take_dialog_hover(&mut self) -> bool {
532 std::mem::take(&mut self.dialog_hover_active)
533 }
534
535 /// Resolve one bare kernel NAME to the entity it addresses, or `None` when the
536 /// scene carries nothing by that name.
537 ///
538 /// Order — most specific identity first: a SOLID (a shown committed sketch is
539 /// a scene solid keyed by its sketch id — `refresh_committed_sketches` inserts
540 /// the sheet under the feature id an extrude's `profile` stores — so a
541 /// `SKETCH` reference lands here whenever that sheet is shown), a
542 /// construction datum FRAME, a FACE, an EDGE, then a `{solid}@x,y,z` VERTEX
543 /// ref (the position-keyed form assembly constraints and PMI store, accepted
544 /// only when it really is a vertex of that solid — a component-LOCAL ref lights
545 /// nothing rather than lighting the wrong vertex).
546 ///
547 /// # The consumed row
548 ///
549 /// A feature's form is drawn with the model rolled TO that feature, so the
550 /// feature has RUN and the entities its references name are gone: a fillet's
551 /// `Edges` rows name edges the blend replaced. The kernel names what it builds
552 /// after what it consumed — `{featureId}:BLEND:{originatingEdgeName}`, asserted
553 /// by the kernel's own `fillet_by_face_ref_rounds_the_top_ring` /
554 /// `chamfer` naming tests — so a row that resolves to nothing falls back to the
555 /// FACE whose name ends with `:{row}`: the thing the feature made from it.
556 /// Only when EXACTLY ONE face matches, so an ambiguous mapping lights nothing
557 /// rather than an arbitrary half of it. Ends-with, never `contains`: the edges
558 /// bounding that blend are named `BOX_NZ|F1:BLEND:BOX_NX|BOX_NZ[0][0]`, which
559 /// CONTAINS the row but is not what it became.
560 fn resolve_entity_name(&self, name: &str) -> Option<pick::PickCandidate> {
561 let at = |kind: pick::PickKind, entity: &str, solid: &str| pick::PickCandidate {
562 kind,
563 name: entity.to_string(),
564 solid: solid.to_string(),
565 depth: 0.0,
566 screen_dist: 0.0,
567 position: [0.0; 3],
568 };
569 let solids = self.scene.solids();
570 if solids.iter().any(|s| s.name == name) {
571 // A SOLID candidate resolves through `candidate_solid_name`, which
572 // prefers `solid` — and this row IS the solid.
573 return Some(at(pick::PickKind::Solid, name, name));
574 }
575 if self.construction_frames.iter().any(|(n, _)| n.as_str() == name) {
576 return Some(at(pick::PickKind::Plane, name, ""));
577 }
578 for solid in solids {
579 if solid.faces.iter().any(|f| f.name == name) {
580 return Some(at(pick::PickKind::Face, name, &solid.name));
581 }
582 }
583 for solid in solids {
584 if solid.edges.iter().any(|e| e.name == name) {
585 return Some(at(pick::PickKind::Edge, name, &solid.name));
586 }
587 }
588 if let Some(candidate) = self.resolve_vertex_ref(name) {
589 return Some(candidate);
590 }
591 let suffix = format!(":{name}");
592 let mut derived = solids
593 .iter()
594 .flat_map(|solid| solid.faces.iter().map(move |face| (solid, face)))
595 .filter(|(_, face)| face.name.ends_with(&suffix));
596 match (derived.next(), derived.next()) {
597 (Some((solid, face)), None) => {
598 Some(at(pick::PickKind::Face, &face.name, &solid.name))
599 }
600 _ => None,
601 }
602 }
603
604 /// A `{solid}@x,y,z` VERTEX reference → its candidate, when the position
605 /// really is a vertex of that solid within the emphasis match tolerance.
606 /// Vertices carry no kernel name, so this is the only form a dialog can list
607 /// one under; PMI stores WORLD coordinates (which resolve here), assembly
608 /// constraints store component-LOCAL ones (which do not, and so light nothing
609 /// rather than the wrong vertex).
610 fn resolve_vertex_ref(&self, name: &str) -> Option<pick::PickCandidate> {
611 /// The tolerance the render pass matches an emphasis vertex ref at.
612 const TOL: f64 = 1e-4;
613 let (solid_name, coords) = name.rsplit_once('@')?;
614 let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
615 let position = [parts.next()??, parts.next()??, parts.next()??];
616 if parts.next().is_some() {
617 return None;
618 }
619 let solid = self.scene.solids().iter().find(|s| s.name == solid_name)?;
620 solid.vertices.iter().find(|v| {
621 (v.position[0] - position[0]).abs() <= TOL
622 && (v.position[1] - position[1]).abs() <= TOL
623 && (v.position[2] - position[2]).abs() <= TOL
624 })?;
625 Some(pick::PickCandidate {
626 kind: pick::PickKind::Vertex,
627 name: String::new(),
628 solid: solid_name.to_string(),
629 depth: 0.0,
630 screen_dist: 0.0,
631 position,
632 })
633 }
634}
635
636
637impl EngineState {
638 /// The resident kernel handle of every solid currently displayed, keyed by
639 /// name. Obtained by replaying the CURRENT rolled-to history prefix through
640 /// [`brep_kernel::execute_history`]: after a build the incremental cache
641 /// holds exactly this prefix, so the replay is a clean cache hit — it
642 /// re-tessellates nothing and hands back the SAME handles the scene was built
643 /// from (mirrors the pipeline's `fold_history`: removals then additions).
644 /// `pub(super)`: the interference check (`engine_state::interference`) reads
645 /// the same warm main-side handle map for its non-destructive booleans.
646 pub(super) fn resident_solid_handles(&self) -> HashMap<String, u32> {
647 let request: HistoryRequest = match serde_json::from_value(self.run_request_value()) {
648 Ok(request) => request,
649 Err(_) => return HashMap::new(),
650 };
651 let _trace = crate::run_trace::span("resident_solid_handles");
652 let result = self.execute_plugin_history(&request);
653 let mut handles: HashMap<String, u32> = HashMap::new();
654 for feature in &result.results {
655 for removed in &feature.removed {
656 handles.remove(removed);
657 }
658 for added in &feature.added {
659 handles.insert(added.name.clone(), added.handle);
660 }
661 }
662 handles
663 }
664
665 /// Mass properties for the Inspector panel, from the kernel's exact
666 /// (divergence-theorem) integrator. `name = Some(solid)` reports that resident
667 /// solid; `None` reports the whole model. `density` (mass units per mm³; the
668 /// kernel length convention is millimetres) scales `mass` and the inertia
669 /// tensor — the centroid and principal axes are density-independent.
670 ///
671 /// Returns JSON:
672 /// ```json
673 /// { "ok": true, "target": "Box", "solidCount": 1, "density": 1.0,
674 /// "volume": 5738.05, "surfaceArea": 2927.79, "mass": 5738.05,
675 /// "centroid": [10.0, 10.0, 10.0],
676 /// "inertia": [[..],[..],[..]] | null,
677 /// "principalMoments": [a,b,c] | null,
678 /// "principalAxes": [[..],[..],[..]] | null }
679 /// ```
680 /// A single resolved solid carries the full centroidal inertia tensor +
681 /// principal axes/moments; a multi-solid aggregate reports summed volume /
682 /// area / mass and the volume-weighted centroid, with the tensor fields
683 /// `null` (select one solid for its inertia). `ok:false` with a `message` on
684 /// no solids / an unknown name / an integrator failure.
685 pub fn mass_properties_json(&self, name: Option<&str>, density: f64) -> String {
686 let handles = self.resident_solid_handles();
687
688 // Resolve the target solids: a named solid (must be resident) or, for the
689 // whole model, every scene solid that has resident geometry (draw order).
690 let targets: Vec<String> = match name {
691 Some(name) if handles.contains_key(name) => vec![name.to_string()],
692 Some(name) => {
693 return serde_json::json!({
694 "ok": false,
695 "message": format!("solid '{name}' has no resident geometry"),
696 })
697 .to_string();
698 }
699 None => self
700 .scene
701 .solids()
702 .iter()
703 .map(|solid| solid.name.clone())
704 .filter(|name| handles.contains_key(name))
705 .collect(),
706 };
707 if targets.is_empty() {
708 return serde_json::json!({ "ok": false, "message": "no solids" }).to_string();
709 }
710
711 // Per-solid density mass properties straight from the kernel.
712 let mut props = Vec::with_capacity(targets.len());
713 for target in &targets {
714 match brep_kernel::mass_properties_handle_native(handles[target], density) {
715 Ok(properties) => props.push(properties),
716 Err(error) => {
717 return serde_json::json!({
718 "ok": false,
719 "message": format!("{target}: {error}"),
720 })
721 .to_string();
722 }
723 }
724 }
725
726 let target_label = if targets.len() == 1 {
727 targets[0].clone()
728 } else {
729 "(whole model)".to_string()
730 };
731
732 if props.len() == 1 {
733 // Single solid: the full tensor + principal frame are meaningful.
734 let p = &props[0];
735 serde_json::json!({
736 "ok": true,
737 "target": target_label,
738 "solidCount": 1,
739 "density": p.density,
740 "volume": p.volume,
741 "surfaceArea": p.surface_area,
742 "mass": p.mass,
743 "centroid": [p.centroid.x, p.centroid.y, p.centroid.z],
744 "inertia": p.inertia,
745 "principalMoments": p.principal_moments,
746 "principalAxes": p.principal_axes,
747 })
748 .to_string()
749 } else {
750 // Aggregate: additive scalars + volume-weighted centroid. Combining
751 // the tensors needs a parallel-axis shift per solid; left to the
752 // single-solid view rather than approximated here.
753 let volume: f64 = props.iter().map(|p| p.volume).sum();
754 let surface_area: f64 = props.iter().map(|p| p.surface_area).sum();
755 let mass: f64 = props.iter().map(|p| p.mass).sum();
756 let centroid = if volume.abs() > f64::EPSILON {
757 let mut acc = [0.0f64; 3];
758 for p in &props {
759 acc[0] += p.volume * p.centroid.x;
760 acc[1] += p.volume * p.centroid.y;
761 acc[2] += p.volume * p.centroid.z;
762 }
763 [acc[0] / volume, acc[1] / volume, acc[2] / volume]
764 } else {
765 [0.0, 0.0, 0.0]
766 };
767 serde_json::json!({
768 "ok": true,
769 "target": target_label,
770 "solidCount": props.len(),
771 "density": density,
772 "volume": volume,
773 "surfaceArea": surface_area,
774 "mass": mass,
775 "centroid": centroid,
776 "inertia": serde_json::Value::Null,
777 "principalMoments": serde_json::Value::Null,
778 "principalAxes": serde_json::Value::Null,
779 })
780 .to_string()
781 }
782 }
783}
784
785