Skip to main content

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_unoccluded(&self.scene, &self.context_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_unoccluded(&self.scene, &self.context_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`](Self::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            // The user's hide or show outlasts the active view's poses and
148            // section: the un-posed original carries it (sheets read it, the
149            // restore writes it back), and the section no longer owns it.
150            if let Some(original) = self.pmi_explode_originals.get_mut(name).filter(|original| original.visible != visible) {
151                original.visible = visible;
152                self.pmi_unposed_visibility += 1;
153            }
154            self.pmi_section_hidden.retain(|hidden| hidden != name);
155            self.dirty = true;
156            // A balloon on this body loses or regains its leader at once.
157            if self.pmi_active_view.is_some() {
158                self.refresh_pmi_overlay();
159            }
160        }
161        ok
162    }
163
164    pub fn scene_listing_json(&self) -> String {
165        self.scene.listing_json()
166    }
167
168    // --- Overlay widgets --------------------------------------------------
169
170    /// The BASE bbox the camera depth-range fit starts from: the visible SOLIDS
171    /// unioned with the pushed OVERLAY groups (sketch curves/points, dimension
172    /// leaders, constraint glyphs — the `set_overlay` channel). Folding in the
173    /// groups stops orbiting an editing sketch from clipping it against the
174    /// solids-only bounds (the reported clipping when "Lock to sketch" is off).
175    /// The render path ([`Self::fit_camera_and_overlay`]) unions the FULL widget
176    /// overlay's world bounds (datum planes, world axes, frames, transform
177    /// gizmo — NOT in this bbox's channels) and the world origin on top of this
178    /// before fitting, so construction geometry never clips. Callers must bind
179    /// this to a local before `camera.fit_depth_range` (which needs
180    /// `&mut self.camera`).
181    pub fn depth_range_bbox(&self) -> crate::camera::Aabb {
182        let mut bbox = self.scene.bbox();
183        bbox.union(&self.context_scene.bbox());
184        bbox.union(&self.widgets.overlay_groups_bbox());
185        bbox
186    }
187
188}
189
190impl EngineState {
191    /// A RICHER scene listing than [`scene_listing_json`](Self::scene_listing_json)
192    /// (which is counts only): per solid the individual face + edge kernel NAMES
193    /// and vertex refs (topo id + world position), plus visibility — the shape the
194    /// engine-native Scene tree lists entities from and the headed verifier asserts
195    /// against. Vertices carry no kernel name, so they are keyed by topo id + world
196    /// position (the same shape the emphasis vertex-ref selection uses).
197    pub fn scene_entities_json(&self) -> String {
198        let solids: Vec<serde_json::Value> = self
199            .scene
200            .solids()
201            .iter()
202            // Committed-sketch SHEETS are scene solids (pickable/measurable) but list
203            // under "Sketches" (`committed_sketches`), not among the real solids.
204            .filter(|solid| !solid.is_sketch)
205            .map(|solid| {
206                let faces: Vec<&str> = solid.faces.iter().map(|f| f.name.as_str()).collect();
207                // Display-only (aux) edges are not entities: a board's copper
208                // outline would list thousands of unnamed ones.
209                let edges: Vec<&str> = solid.edges.iter().filter(|e| !e.aux).map(|e| e.name.as_str()).collect();
210                let vertices: Vec<serde_json::Value> = solid
211                    .vertices
212                    .iter()
213                    .map(|v| serde_json::json!({ "topoId": v.topo_id, "position": v.position }))
214                    .collect();
215                serde_json::json!({
216                    "name": solid.name,
217                    "visible": solid.visible,
218                    "faces": faces,
219                    "edges": edges,
220                    "vertices": vertices,
221                })
222            })
223            .collect();
224        serde_json::Value::Array(solids).to_string()
225    }
226
227    /// Drive the engine SELECTION by kernel NAME from a UI tree (the name-based
228    /// analogue of [`select_top_at`](Self::select_top_at), which picks under the
229    /// cursor). Replaces the current selection with the single named `solid` /
230    /// `face` / `edge` so clicking a Scene-tree row highlights that entity in the
231    /// viewport (the render pass reads `emphasis`). Vertices have no kernel name —
232    /// use [`select_vertex_by_position`](Self::select_vertex_by_position). Returns
233    /// false for an unknown `kind` or an empty `name`.
234    pub fn select_by_name(&mut self, kind: &str, name: &str) -> bool {
235        // A construction datum/plane routes to its own name-keyed selection.
236        if kind == "datum" {
237            return self.select_datum(name);
238        }
239        if name.is_empty() || !matches!(kind, "solid" | "face" | "edge") {
240            return false;
241        }
242        let had_datum = !self.emphasis.selected_datums.is_empty();
243        self.emphasis.selected_solids.clear();
244        self.emphasis.selected_faces.clear();
245        self.emphasis.selected_edges.clear();
246        self.emphasis.selected_vertices.clear();
247        self.emphasis.selected_datums.clear();
248        match kind {
249            "solid" => {
250                self.emphasis.selected_solids.insert(name.to_string());
251            }
252            "face" => {
253                self.emphasis.selected_faces.insert(name.to_string());
254            }
255            "edge" => {
256                self.emphasis.selected_edges.insert(name.to_string());
257            }
258            _ => unreachable!(),
259        }
260        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
261        self.dirty = true;
262        if had_datum {
263            self.refresh_construction_datums();
264        }
265        true
266    }
267
268    /// Select a single vertex by its owning solid + world position — vertices have
269    /// no kernel name, so emphasis keys them by solid + position (matched with a
270    /// tolerance in the render pass). Replaces the current selection. Returns false
271    /// for an empty solid name.
272    pub fn select_vertex_by_position(&mut self, solid: &str, position: [f64; 3]) -> bool {
273        if solid.is_empty() {
274            return false;
275        }
276        let had_datum = !self.emphasis.selected_datums.is_empty();
277        self.emphasis.selected_solids.clear();
278        self.emphasis.selected_faces.clear();
279        self.emphasis.selected_edges.clear();
280        self.emphasis.selected_vertices.clear();
281        self.emphasis.selected_datums.clear();
282        self.emphasis.selected_vertices.push(crate::style::VertexRef {
283            solid: solid.to_string(),
284            position,
285        });
286        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
287        self.dirty = true;
288        if had_datum {
289            self.refresh_construction_datums();
290        }
291        true
292    }
293
294    // -----------------------------------------------------------------------
295    // Scene-tree row HOVER → the viewport hover highlight
296    //
297    // The hover twin of the name-based selection above: mousing over a Scene-tree
298    // row lights the entity EXACTLY as mousing over it in the 3D view does,
299    // because it feeds the SAME `emphasis` hover buckets through the SAME
300    // `hover_candidate` bucketing the viewport's `hover_at` uses (so a hovered
301    // datum plane gets its accent re-feed, a solid lights whole, and the render
302    // pass needs no new concept). The pointer is off the viewport while it is over
303    // a row, so the viewport's pointer-left-the-viewport `clear_hover` would
304    // otherwise wipe it every frame — hence the one-frame yield flag, the twin of
305    // `take_sketch_list_hover` / `take_constraint_label_hover`.
306    // -----------------------------------------------------------------------
307
308    /// Hover-highlight by kernel NAME from a UI tree — the hover twin of
309    /// [`select_by_name`](Self::select_by_name) (`kind` is `"solid"` / `"face"` /
310    /// `"edge"` / `"datum"`; vertices carry no kernel name, so they use
311    /// [`hover_vertex_by_position`](Self::hover_vertex_by_position)).
312    ///
313    /// Deliberately does NOT consult the selection filter, matching the row's
314    /// CLICK: a row selects whatever kind it is, so its hover previews the same
315    /// entity. An unknown `kind`, an empty `name` or an unresolved datum frame is
316    /// refused without touching the current hover.
317    ///
318    /// Call it EVERY frame the row is hovered: it re-arms the one-frame
319    /// [`take_scene_tree_hover`](Self::take_scene_tree_hover) yield flag and
320    /// re-lights nothing when the hover is already this entity. Returns whether
321    /// the hover CHANGED (the [`hover_at`](Self::hover_at) convention) so the
322    /// caller can request a repaint.
323    pub fn hover_by_name(&mut self, kind: &str, name: &str) -> bool {
324        if name.is_empty() {
325            return false;
326        }
327        let pick_kind = match kind {
328            "solid" => pick::PickKind::Solid,
329            "face" => pick::PickKind::Face,
330            "edge" => pick::PickKind::Edge,
331            // A construction datum/plane hovers by FRAME name — the bucket
332            // `select_datum` and the viewport's plane picks fill.
333            "datum" => pick::PickKind::Plane,
334            _ => return false,
335        };
336        let candidate = pick::PickCandidate {
337            kind: pick_kind,
338            name: name.to_string(),
339            // A SOLID candidate resolves through `candidate_solid_name`, which
340            // prefers `solid` — and this row IS the solid.
341            solid: match pick_kind {
342                pick::PickKind::Solid => name.to_string(),
343                _ => String::new(),
344            },
345            depth: 0.0,
346            screen_dist: 0.0,
347            position: [0.0; 3],
348        };
349        self.set_scene_tree_hover(candidate)
350    }
351
352    /// Hover a single vertex by its owning solid + world position — the hover twin
353    /// of [`select_vertex_by_position`](Self::select_vertex_by_position) (vertices
354    /// have no kernel name, so emphasis keys them by solid + position). Same
355    /// per-frame contract and return as [`hover_by_name`](Self::hover_by_name).
356    pub fn hover_vertex_by_position(&mut self, solid: &str, position: [f64; 3]) -> bool {
357        if solid.is_empty() {
358            return false;
359        }
360        self.set_scene_tree_hover(pick::PickCandidate {
361            kind: pick::PickKind::Vertex,
362            // Vertices are unnamed: `solid` + `position` is the identity.
363            name: String::new(),
364            solid: solid.to_string(),
365            depth: 0.0,
366            screen_dist: 0.0,
367            position,
368        })
369    }
370
371    /// The pointer left the Scene tree's rows: drop the row-driven highlight.
372    /// Clears ONLY a hover the tree itself lit and that is STILL lit — if the
373    /// viewport has since hovered something else (it may draw before the panel in
374    /// the dock), that hover is left alone. Safe to call every frame no row is
375    /// hovered: the target is taken, so it is a no-op from the second call on.
376    /// Returns whether the hover changed.
377    pub fn scene_tree_hover_end(&mut self) -> bool {
378        match self.scene_tree_hovered.take() {
379            Some(candidate) if self.hover_is(&candidate) => self.clear_hover(),
380            _ => false,
381        }
382    }
383
384    /// Consume the one-frame "a Scene-tree row is hovering an entity" flag — the
385    /// viewport's modeling hover branch skips its pointer-off-viewport
386    /// `clear_hover` while it is set (mirrors
387    /// [`take_sketch_list_hover`](Self::take_sketch_list_hover)) so the row-driven
388    /// highlight survives the frame instead of being cleared and re-applied.
389    pub fn take_scene_tree_hover(&mut self) -> bool {
390        std::mem::take(&mut self.scene_tree_hover_active)
391    }
392
393    /// Light one Scene-tree row's candidate: arm the yield flag (every frame — the
394    /// viewport consumes it every frame), record the target so
395    /// [`scene_tree_hover_end`](Self::scene_tree_hover_end) knows what the tree
396    /// lit, and re-emphasize only when the hover actually changed (a held row must
397    /// not re-bump the emphasis generation / re-feed the datums every frame).
398    fn set_scene_tree_hover(&mut self, candidate: pick::PickCandidate) -> bool {
399        self.scene_tree_hover_active = true;
400        match self.apply_ui_hover(&candidate) {
401            // Refused (an unresolved datum frame): nothing lit, so the tree records
402            // no target and has nothing to end.
403            None => false,
404            Some(changed) => {
405                // Recorded even when unchanged: the viewport may have lit the
406                // identical entity, and the tree still owns ending it.
407                self.scene_tree_hovered = Some(candidate);
408                changed
409            }
410        }
411    }
412
413    /// Apply ONE UI-driven hover candidate to the emphasis — the shared body
414    /// behind every list→viewport highlight (the Scene tree's rows, the dialog
415    /// rows below). `None` = REFUSED, `Some(changed)` = applied, and `changed`
416    /// says whether the hover actually moved (a held row must not re-bump the
417    /// emphasis generation / re-feed the datums every frame).
418    ///
419    /// Each caller records its OWN target: that is what keeps two panes' `_end`
420    /// calls from ending each other's highlight.
421    fn apply_ui_hover(&mut self, candidate: &pick::PickCandidate) -> Option<bool> {
422        if self.hover_is(candidate) {
423            return Some(false);
424        }
425        // Only a RESOLVED datum frame hovers (the `select_datum` guard); an
426        // unresolved name would light nothing and churn the datum feed. Checked on
427        // the change path only — the dedupe above carries the held frames.
428        if candidate.kind == pick::PickKind::Plane
429            && !self.construction_frames.iter().any(|(n, _)| *n == candidate.name)
430        {
431            return None;
432        }
433        self.hover_candidate(candidate);
434        Some(true)
435    }
436}
437
438// ---------------------------------------------------------------------------
439// DIALOG row HOVER → the viewport hover highlight
440//
441// The Scene tree knows the KIND of every row it draws; a dialog does not. A
442// feature form's reference line, its read-only `Outputs` line and the picker
443// card's picked-name line all carry a bare kernel NAME, so this lane resolves the
444// kind from the scene itself and then feeds the SAME hover buckets the tree and
445// the viewport's `hover_at` fill.
446// ---------------------------------------------------------------------------
447impl EngineState {
448    /// Hover-highlight the entity a DIALOG row names — the kind-less twin of
449    /// [`hover_by_name`](Self::hover_by_name), for the lists that show a bare
450    /// kernel name (a reference line, an `Outputs` line, the picker card).
451    ///
452    /// `owner` is the pane that is hovering (`"history"`, `"constraints"`,
453    /// `"pmi"`, `"refsel"`): [`dialog_hover_end`](Self::dialog_hover_end) acts
454    /// only for the owner that set the hover, so panes that are on screen together
455    /// in a split dock cannot end each other's highlight.
456    ///
457    /// The name is resolved by `resolve_entity_name`,
458    /// which also covers the row whose entity the feature CONSUMED — an open
459    /// fillet form is rolled to the fillet, so its `Edges` rows name edges that no
460    /// longer exist and the blend faces the kernel built from them are lit
461    /// instead. Call it EVERY frame the row is hovered: the resolution is memoized
462    /// on the row text and the emphasis re-feed is deduped, so a held hover costs
463    /// nothing. Returns whether the hover CHANGED (the
464    /// [`hover_at`](Self::hover_at) convention), so the caller can repaint.
465    pub fn hover_entity_by_name(&mut self, owner: &'static str, name: &str) -> bool {
466        if name.is_empty() {
467            return false;
468        }
469        // Armed even for a row that resolves to nothing: the pointer is over the
470        // dialog, so the viewport's pointer-left-the-viewport `clear_hover` must
471        // still yield — otherwise moving along a list of rows would strobe the
472        // highlight off on every unresolvable one.
473        self.dialog_hover_active = true;
474        let memo = match &self.dialog_hovered {
475            Some(held) if held.owner == owner && held.row == name => Some(held.candidate.clone()),
476            _ => None,
477        };
478        let candidate = match memo {
479            Some(cached) => cached,
480            None => self.resolve_entity_name(name),
481        };
482        let applied = match &candidate {
483            Some(c) => self.apply_ui_hover(c),
484            None => None,
485        };
486        let changed = match applied {
487            Some(changed) => changed,
488            // Nothing to light (the scene carries no such name, or the candidate
489            // was refused): end whatever the slot holds — WHOEVER set it. Leaving
490            // the previous row's entity standing while the pointer sits on a
491            // different row reads as "this row is that entity", and the previous
492            // row is not always this owner's: moving from a History line straight
493            // onto an unresolvable Constraints one (a component-local vertex ref)
494            // would otherwise strand the History highlight with nobody left
495            // holding the record that could end it.
496            None => self.take_dialog_hover_slot(),
497        };
498        self.dialog_hovered = Some(super::DialogHover {
499            owner,
500            row: name.to_string(),
501            candidate,
502        });
503        changed
504    }
505
506    /// The pointer left `owner`'s dialog rows: drop the row-driven highlight.
507    /// Clears ONLY a hover THIS owner lit and that is STILL lit — a pane that
508    /// never hovered a row (or whose hover the viewport has since replaced) is a
509    /// no-op, which is what lets every consumer call it unconditionally every
510    /// frame. Returns whether the hover changed.
511    pub fn dialog_hover_end(&mut self, owner: &'static str) -> bool {
512        let mine = matches!(&self.dialog_hovered, Some(held) if held.owner == owner);
513        if mine {
514            self.take_dialog_hover_slot()
515        } else {
516            false
517        }
518    }
519
520    /// Take the dialog-hover slot and drop the highlight it recorded, when that
521    /// highlight is still the live one (the viewport may have replaced it since).
522    /// Owner-AGNOSTIC: [`dialog_hover_end`](Self::dialog_hover_end) checks the
523    /// owner before calling this, while a row that lights nothing calls it
524    /// directly — the pointer is on THAT row now, so whatever the slot still
525    /// records is stale whoever set it.
526    fn take_dialog_hover_slot(&mut self) -> bool {
527        match self.dialog_hovered.take() {
528            Some(held) => match held.candidate {
529                Some(candidate) if self.hover_is(&candidate) => self.clear_hover(),
530                _ => false,
531            },
532            None => false,
533        }
534    }
535
536    /// Consume the one-frame "a dialog row is hovering an entity" flag — the
537    /// viewport's modeling hover branch skips its pointer-off-viewport
538    /// `clear_hover` while it is set (the twin of
539    /// [`take_scene_tree_hover`](Self::take_scene_tree_hover)).
540    pub fn take_dialog_hover(&mut self) -> bool {
541        std::mem::take(&mut self.dialog_hover_active)
542    }
543
544    /// Resolve one bare kernel NAME to the entity it addresses, or `None` when the
545    /// scene carries nothing by that name.
546    ///
547    /// Order — most specific identity first: a SOLID (a shown committed sketch is
548    /// a scene solid keyed by its sketch id — `refresh_committed_sketches` inserts
549    /// the sheet under the feature id an extrude's `profile` stores — so a
550    /// `SKETCH` reference lands here whenever that sheet is shown), a
551    /// construction datum FRAME, a FACE, an EDGE, then a `{solid}@x,y,z` VERTEX
552    /// ref (the position-keyed form assembly constraints and PMI store, accepted
553    /// only when it really is a vertex of that solid — a component-LOCAL ref lights
554    /// nothing rather than lighting the wrong vertex).
555    ///
556    /// # The consumed row
557    ///
558    /// A feature's form is drawn with the model rolled TO that feature, so the
559    /// feature has RUN and the entities its references name are gone: a fillet's
560    /// `Edges` rows name edges the blend replaced. The kernel names what it builds
561    /// after what it consumed — `{featureId}:BLEND:{originatingEdgeName}`, asserted
562    /// by the kernel's own `fillet_by_face_ref_rounds_the_top_ring` /
563    /// `chamfer` naming tests — so a row that resolves to nothing falls back to the
564    /// FACE whose name ends with `:{row}`: the thing the feature made from it.
565    /// Only when EXACTLY ONE face matches, so an ambiguous mapping lights nothing
566    /// rather than an arbitrary half of it. Ends-with, never `contains`: the edges
567    /// bounding that blend are named `BOX_NZ|F1:BLEND:BOX_NX|BOX_NZ[0][0]`, which
568    /// CONTAINS the row but is not what it became.
569    fn resolve_entity_name(&self, name: &str) -> Option<pick::PickCandidate> {
570        let at = |kind: pick::PickKind, entity: &str, solid: &str| pick::PickCandidate {
571            kind,
572            name: entity.to_string(),
573            solid: solid.to_string(),
574            depth: 0.0,
575            screen_dist: 0.0,
576            position: [0.0; 3],
577        };
578        let solids = self.scene.solids();
579        if solids.iter().any(|s| s.name == name) {
580            // A SOLID candidate resolves through `candidate_solid_name`, which
581            // prefers `solid` — and this row IS the solid.
582            return Some(at(pick::PickKind::Solid, name, name));
583        }
584        if self.construction_frames.iter().any(|(n, _)| n.as_str() == name) {
585            return Some(at(pick::PickKind::Plane, name, ""));
586        }
587        for solid in solids {
588            if solid.faces.iter().any(|f| f.name == name) {
589                return Some(at(pick::PickKind::Face, name, &solid.name));
590            }
591        }
592        for solid in solids {
593            if solid.edges.iter().any(|e| e.name == name) {
594                return Some(at(pick::PickKind::Edge, name, &solid.name));
595            }
596        }
597        if let Some(candidate) = self.resolve_vertex_ref(name) {
598            return Some(candidate);
599        }
600        let suffix = format!(":{name}");
601        let mut derived = solids
602            .iter()
603            .flat_map(|solid| solid.faces.iter().map(move |face| (solid, face)))
604            .filter(|(_, face)| face.name.ends_with(&suffix));
605        match (derived.next(), derived.next()) {
606            (Some((solid, face)), None) => {
607                Some(at(pick::PickKind::Face, &face.name, &solid.name))
608            }
609            _ => None,
610        }
611    }
612
613    /// A `{solid}@x,y,z` VERTEX reference → its candidate, when the position
614    /// really is a vertex of that solid within the emphasis match tolerance.
615    /// Vertices carry no kernel name, so this is the only form a dialog can list
616    /// one under; PMI stores WORLD coordinates (which resolve here), assembly
617    /// constraints store component-LOCAL ones (which do not, and so light nothing
618    /// rather than the wrong vertex).
619    fn resolve_vertex_ref(&self, name: &str) -> Option<pick::PickCandidate> {
620        /// The tolerance the render pass matches an emphasis vertex ref at.
621        const TOL: f64 = 1e-4;
622        let (solid_name, coords) = name.rsplit_once('@')?;
623        let mut parts = coords.split(',').map(|p| p.trim().parse::<f64>().ok());
624        let position = [parts.next()??, parts.next()??, parts.next()??];
625        if parts.next().is_some() {
626            return None;
627        }
628        let solid = self.scene.solids().iter().find(|s| s.name == solid_name)?;
629        solid.vertices.iter().find(|v| {
630            (v.position[0] - position[0]).abs() <= TOL
631                && (v.position[1] - position[1]).abs() <= TOL
632                && (v.position[2] - position[2]).abs() <= TOL
633        })?;
634        Some(pick::PickCandidate {
635            kind: pick::PickKind::Vertex,
636            name: String::new(),
637            solid: solid_name.to_string(),
638            depth: 0.0,
639            screen_dist: 0.0,
640            position,
641        })
642    }
643}
644
645
646impl EngineState {
647    /// The resident kernel handle of every solid currently displayed, keyed by
648    /// name. Obtained by replaying the CURRENT rolled-to history prefix through
649    /// [`brep_kernel::execute_history`]: after a build the incremental cache
650    /// holds exactly this prefix, so the replay is a clean cache hit — it
651    /// re-tessellates nothing and hands back the SAME handles the scene was built
652    /// from (mirrors the pipeline's `fold_history`: removals then additions).
653    /// `pub(super)`: the interference check (`engine_state::interference`) reads
654    /// the same warm main-side handle map for its non-destructive booleans.
655    pub(super) fn resident_solid_handles(&self) -> HashMap<String, u32> {
656        let request: HistoryRequest = match serde_json::from_value(self.run_request_value()) {
657            Ok(request) => request,
658            Err(_) => return HashMap::new(),
659        };
660        let _trace = crate::run_trace::span("resident_solid_handles");
661        let result = self.execute_plugin_history(&request);
662        let mut handles: HashMap<String, u32> = HashMap::new();
663        for feature in &result.results {
664            for removed in &feature.removed {
665                handles.remove(removed);
666            }
667            for added in &feature.added {
668                handles.insert(added.name.clone(), added.handle);
669            }
670        }
671        handles
672    }
673
674    /// Mass properties for the Inspector panel, from the kernel's exact
675    /// (divergence-theorem) integrator. `name = Some(solid)` reports that resident
676    /// solid; `None` reports the whole model. `density` (mass units per mm³; the
677    /// kernel length convention is millimetres) scales `mass` and the inertia
678    /// tensor — the centroid and principal axes are density-independent.
679    ///
680    /// Returns JSON:
681    /// ```json
682    /// { "ok": true, "target": "Box", "solidCount": 1, "density": 1.0,
683    ///   "volume": 5738.05, "surfaceArea": 2927.79, "mass": 5738.05,
684    ///   "centroid": [10.0, 10.0, 10.0],
685    ///   "inertia": [[..],[..],[..]] | null,
686    ///   "principalMoments": [a,b,c] | null,
687    ///   "principalAxes": [[..],[..],[..]] | null }
688    /// ```
689    /// A single resolved solid carries the full centroidal inertia tensor +
690    /// principal axes/moments; a multi-solid aggregate reports summed volume /
691    /// area / mass and the volume-weighted centroid, with the tensor fields
692    /// `null` (select one solid for its inertia). `ok:false` with a `message` on
693    /// no solids / an unknown name / an integrator failure.
694    pub fn mass_properties_json(&self, name: Option<&str>, density: f64) -> String {
695        let handles = self.resident_solid_handles();
696
697        // Resolve the target solids: a named solid (must be resident) or, for the
698        // whole model, every scene solid that has resident geometry (draw order).
699        let targets: Vec<String> = match name {
700            Some(name) if handles.contains_key(name) => vec![name.to_string()],
701            Some(name) => {
702                return serde_json::json!({
703                    "ok": false,
704                    "message": format!("solid '{name}' has no resident geometry"),
705                })
706                .to_string();
707            }
708            None => self
709                .scene
710                .solids()
711                .iter()
712                .map(|solid| solid.name.clone())
713                .filter(|name| handles.contains_key(name))
714                .collect(),
715        };
716        if targets.is_empty() {
717            return serde_json::json!({ "ok": false, "message": "no solids" }).to_string();
718        }
719
720        // Per-solid density mass properties straight from the kernel.
721        let mut props = Vec::with_capacity(targets.len());
722        for target in &targets {
723            match brep_kernel::mass_properties_handle_native(handles[target], density) {
724                Ok(properties) => props.push(properties),
725                Err(error) => {
726                    return serde_json::json!({
727                        "ok": false,
728                        "message": format!("{target}: {error}"),
729                    })
730                    .to_string();
731                }
732            }
733        }
734
735        let target_label = if targets.len() == 1 {
736            targets[0].clone()
737        } else {
738            "(whole model)".to_string()
739        };
740
741        if props.len() == 1 {
742            // Single solid: the full tensor + principal frame are meaningful.
743            let p = &props[0];
744            serde_json::json!({
745                "ok": true,
746                "target": target_label,
747                "solidCount": 1,
748                "density": p.density,
749                "volume": p.volume,
750                "surfaceArea": p.surface_area,
751                "mass": p.mass,
752                "centroid": [p.centroid.x, p.centroid.y, p.centroid.z],
753                "inertia": p.inertia,
754                "principalMoments": p.principal_moments,
755                "principalAxes": p.principal_axes,
756            })
757            .to_string()
758        } else {
759            // Aggregate: additive scalars + volume-weighted centroid. Combining
760            // the tensors needs a parallel-axis shift per solid; left to the
761            // single-solid view rather than approximated here.
762            let volume: f64 = props.iter().map(|p| p.volume).sum();
763            let surface_area: f64 = props.iter().map(|p| p.surface_area).sum();
764            let mass: f64 = props.iter().map(|p| p.mass).sum();
765            let centroid = if volume.abs() > f64::EPSILON {
766                let mut acc = [0.0f64; 3];
767                for p in &props {
768                    acc[0] += p.volume * p.centroid.x;
769                    acc[1] += p.volume * p.centroid.y;
770                    acc[2] += p.volume * p.centroid.z;
771                }
772                [acc[0] / volume, acc[1] / volume, acc[2] / volume]
773            } else {
774                [0.0, 0.0, 0.0]
775            };
776            serde_json::json!({
777                "ok": true,
778                "target": target_label,
779                "solidCount": props.len(),
780                "density": density,
781                "volume": volume,
782                "surfaceArea": surface_area,
783                "mass": mass,
784                "centroid": centroid,
785                "inertia": serde_json::Value::Null,
786                "principalMoments": serde_json::Value::Null,
787                "principalAxes": serde_json::Value::Null,
788            })
789            .to_string()
790        }
791    }
792}
793
794