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