Skip to main content

brep_render/engine_state/
selection_ux.rs

1use super::*;
2
3/// The geometry selection [`EngineState::select_feature`] made, kept beside the
4/// feature id so the feature reads as selected exactly as long as that
5/// selection stands.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) struct FeatureSelection {
8    solids: crate::style::OrderedNames,
9    faces: crate::style::OrderedNames,
10    edges: crate::style::OrderedNames,
11    datums: crate::style::OrderedNames,
12}
13
14impl FeatureSelection {
15    fn of(emphasis: &crate::style::Emphasis) -> Self {
16        Self {
17            solids: emphasis.selected_solids.clone(),
18            faces: emphasis.selected_faces.clone(),
19            edges: emphasis.selected_edges.clone(),
20            datums: emphasis.selected_datums.clone(),
21        }
22    }
23}
24
25impl EngineState {
26    /// Clear the current SELECTION (Esc): drop all selected solids/faces/edges/
27    /// vertices (hover is left untouched). Bumps the emphasis generation + marks
28    /// dirty only when something was actually cleared. Returns whether it changed.
29    pub fn clear_selection(&mut self) -> bool {
30        // The viewport-selected CONSTRAINT (label click) clears with the rest.
31        let had_constraint = self.selected_constraint.is_some();
32        self.constraint_deselect();
33        // …and so do the tree selections that have no geometry of their own: a
34        // feature (whose geometry clears below), a PMI annotation and a sheet
35        // object.
36        let had_feature = self.selected_feature.take().is_some();
37        let had_pmi_annotation = self.pmi_selected_annotation.take().is_some();
38        if had_pmi_annotation {
39            self.refresh_pmi_overlay();
40        }
41        let had_pmi = self.pmi_selected_view.take().is_some() || had_pmi_annotation;
42        let had_sheet = self.sheet_selected_object.take().is_some();
43        let had_datums = !self.emphasis.selected_datums.is_empty();
44        let had = had_constraint
45            || had_feature
46            || had_pmi
47            || had_sheet
48            || !self.emphasis.selected_solids.is_empty()
49            || !self.emphasis.selected_faces.is_empty()
50            || !self.emphasis.selected_edges.is_empty()
51            || !self.emphasis.selected_vertices.is_empty()
52            || had_datums;
53        if had {
54            self.emphasis.selected_solids.clear();
55            self.emphasis.selected_faces.clear();
56            self.emphasis.selected_edges.clear();
57            self.emphasis.selected_vertices.clear();
58            self.emphasis.selected_datums.clear();
59            self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
60            self.dirty = true;
61        }
62        // A cleared datum drops its selection accent — re-feed the datum planes so
63        // the highlight disappears immediately (no re-run needed).
64        if had_datums {
65            self.refresh_construction_datums();
66        }
67        had
68    }
69
70    /// REPLACE the whole selection with these named entities — the write twin of
71    /// [`Self::selection_json`], and the only way to put a MULTI-entity selection
72    /// back the way it was (`select_by_name` takes one name and clears the rest).
73    ///
74    /// Names are taken as given, IN THE ORDER GIVEN: this is the write twin of a
75    /// pick order, so a selection saved from `selection_json` and put back here
76    /// seeds exactly what it seeded before. An unknown name simply highlights
77    /// nothing, the same as a stale name after a rebuild. VERTICES are not
78    /// addressable here —
79    /// they have no kernel name and emphasis keys them by position
80    /// ([`Self::select_vertex_by_position`]) — so a saved selection's vertices do
81    /// not come back through this call.
82    pub fn set_selection(
83        &mut self,
84        solids: &[String],
85        faces: &[String],
86        edges: &[String],
87        datums: &[String],
88    ) {
89        let had_datums = !self.emphasis.selected_datums.is_empty();
90        self.emphasis.selected_solids = solids.iter().cloned().collect();
91        self.emphasis.selected_faces = faces.iter().cloned().collect();
92        self.emphasis.selected_edges = edges.iter().cloned().collect();
93        self.emphasis.selected_datums = datums.iter().cloned().collect();
94        self.emphasis.selected_vertices.clear();
95        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
96        self.dirty = true;
97        if had_datums || !datums.is_empty() {
98            self.refresh_construction_datums();
99        }
100    }
101
102    /// SELECT a feature, as the history tree's single click does: the feature
103    /// becomes the selected one and the geometry it contributed to the model on
104    /// screen becomes the viewport selection, replacing whatever was selected.
105    ///
106    /// What a feature "contributed" is read from the last run, first match wins:
107    /// the FACES it created that are still in the scene (a fillet's blends, a
108    /// hole's bore — a feature later rewritten by another keeps its own faces);
109    /// else the SOLIDS it was the last to write (a boolean, which makes no face
110    /// of its own); else its own sketch or datum frames. A feature past the
111    /// rollback has built nothing, so it selects with an empty geometry
112    /// selection — the tree row still shows it.
113    ///
114    /// Returns false, changing nothing, when `id` is not a feature.
115    pub fn select_feature(&mut self, id: &str) -> bool {
116        if self.history.index_of(id).is_none() {
117            return false;
118        }
119        let in_scene_faces: Vec<String> = self
120            .scene
121            .solids()
122            .iter()
123            .flat_map(|solid| solid.faces.iter().map(|face| face.name.clone()))
124            .filter(|name| self.entity_origin.get(name).map(String::as_str) == Some(id))
125            .collect();
126        let mut solids: Vec<String> = Vec::new();
127        let mut datums: Vec<String> = Vec::new();
128        if in_scene_faces.is_empty() {
129            solids = self
130                .scene
131                .solids()
132                .iter()
133                .filter(|solid| {
134                    self.provenance.get(&solid.name).map(String::as_str) == Some(id)
135                        || solid.name == id
136                })
137                .map(|solid| solid.name.clone())
138                .collect();
139        }
140        if in_scene_faces.is_empty() && solids.is_empty() {
141            datums = self
142                .construction_frames
143                .iter()
144                .map(|(name, _)| name)
145                .filter(|name| {
146                    self.datum_feature_for_name(name)
147                        .is_some_and(|(feature, _)| feature == id)
148                })
149                .cloned()
150                .collect();
151        }
152        self.set_selection(&solids, &in_scene_faces, &[], &datums);
153        self.selected_feature = Some((id.to_string(), FeatureSelection::of(&self.emphasis)));
154        true
155    }
156
157    /// The feature selected through [`Self::select_feature`], while the
158    /// geometry selection it made still stands and the feature still exists.
159    /// Any other selection — a viewport pick, Esc, Clear, a Scene-tree row —
160    /// replaces that geometry and so reads as the feature being deselected.
161    pub fn selected_feature(&self) -> Option<&str> {
162        let (id, made) = self.selected_feature.as_ref()?;
163        (self.history.index_of(id).is_some() && *made == FeatureSelection::of(&self.emphasis)
164            && self.emphasis.selected_vertices.is_empty())
165        .then_some(id.as_str())
166    }
167
168    /// Select the top-priority pick under CSS-pixel `(x, y)` that the SELECTION
169    /// FILTER admits — replacing the current selection (a plain viewport click).
170    /// A miss (or a click when the filter admits nothing) clears the selection.
171    /// Marks dirty when the selection changed; returns whether something was
172    /// selected. The by-kind honoring lives in [`select_filtered_at`] in the
173    /// appended selection-filter impl block (kept separate so concurrent edits to
174    /// this primary block don't conflict).
175    pub fn select_top_at(&mut self, x: f64, y: f64) -> bool {
176        self.select_filtered_at(x, y)
177    }
178
179    /// The current SELECTION (not hover) as JSON
180    /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — lets a UI / the
181    /// headed verifier read selection state (e.g. assert Esc cleared it).
182    ///
183    /// Each array is in PICK ORDER, oldest first ([`OrderedNames`](crate::style::
184    /// OrderedNames)): the first name in `faces` is the face that was picked
185    /// first, and a face toggled off and on again is last. That is the order
186    /// every seeder reads — the assembly constraint's `elements` (whose first
187    /// element is the Distance BASE face), the context bar's reference pre-fill,
188    /// a boolean's target/tool, a fillet's edge list — so the same picks seed the
189    /// same feature in every process. Order is per BUCKET: a face and an edge
190    /// picked in either order still list under `faces` and `edges`, and a seeder
191    /// that takes both reads them in ITS OWN kind order (the field's
192    /// `selectionFilter`), which is fixed by the schema.
193    pub fn selection_json(&self) -> String {
194        let solids: Vec<&String> = self.emphasis.selected_solids.iter().collect();
195        let faces: Vec<&String> = self.emphasis.selected_faces.iter().collect();
196        let edges: Vec<&String> = self.emphasis.selected_edges.iter().collect();
197        let datums: Vec<&String> = self.emphasis.selected_datums.iter().collect();
198        serde_json::json!({
199            "solids": solids,
200            "faces": faces,
201            "edges": edges,
202            "datums": datums,
203            "vertices": self.emphasis.selected_vertices.len(),
204        })
205        .to_string()
206    }
207
208    // --- Reference-selection widget (the engine-native picker, #42) --------
209    //
210    // A feature-dialog reference field activates this MODAL: the UI hides the
211    // rest of itself and shows only the widget's list + Finish/Cancel; the engine
212    // rolls to the pre-feature "before" state, highlights the running selection
213    // (via `emphasis`), and each click in the viewport type-constrained-picks a
214    // name into the list. Finish writes the names into the feature params (via
215    // the same `update_feature_params` path) and restores; Cancel discards. The
216    // list of names is the whole state — no event-on-object wiring.
217
218    /// True while the reference-selection modal is active (the shell hides the
219    /// rest of the UI and the viewport routes clicks to picking).
220    pub fn ref_select_active(&self) -> bool {
221        self.ref_select.is_some()
222    }
223
224    /// Enter reference-selection mode for feature `feature_id`'s param at `path`.
225    /// Seeds the running list from `seed_names` (the field's current value), rolls
226    /// the model to the pre-feature "before" state (the step just before the
227    /// edited feature ran), and highlights the seeded names. `filter` constrains
228    /// the pick kind (`["SOLID"]`, `["FACE"]`, …); `multiple` allows a list.
229    pub fn begin_ref_select(
230        &mut self,
231        feature_id: &str,
232        path: Vec<String>,
233        label: String,
234        filter: Vec<String>,
235        multiple: bool,
236        seed_names: Vec<String>,
237    ) {
238        let restore_index = self.history.rollback();
239        // "Before" = the step just before the edited feature ran, so the user
240        // picks against the correct geometry. Clamp at 0 for the first feature.
241        let before = self
242            .history
243            .index_of(feature_id)
244            .map(|i| i.saturating_sub(1))
245            .unwrap_or(restore_index);
246        // Constrain the GLOBAL selection filter to exactly the kinds this field
247        // permits: this drives BOTH click-picking (`ref_select_click`) AND
248        // hover-highlighting (`hover_at`, which reads `selection_filter`), so only
249        // the allowed kinds highlight/select while the picker is active. An
250        // absent/construction-only field filter maps to all-enabled (see
251        // `from_ref_filter`). Restored to the all-enabled default on finish/cancel
252        // (`end_ref_select`).
253        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
254        self.ref_select = Some(RefSelectState {
255            feature_id: feature_id.to_string(),
256            path,
257            label,
258            filter,
259            multiple,
260            names: seed_names,
261            restore_index,
262            target: RefSelectTarget::Feature,
263        });
264        // Roll to the before-state (re-runs + marks dirty), then light up the seed.
265        self.history.set_rollback(before);
266        self.rerun_history();
267        self.sync_ref_select_emphasis();
268    }
269
270    /// The running list of picked names (empty when not active) — the modal UI
271    /// reads this back to draw its one-per-line list.
272    pub fn ref_select_names(&self) -> Vec<String> {
273        self.ref_select
274            .as_ref()
275            .map(|r| r.names.clone())
276            .unwrap_or_default()
277    }
278
279    /// The active field's label (for the modal heading), or empty.
280    pub fn ref_select_label(&self) -> String {
281        self.ref_select
282            .as_ref()
283            .map(|r| r.label.clone())
284            .unwrap_or_default()
285    }
286
287    /// A one-line summary of the active field for the modal heading:
288    /// `"Tool solids (SOLID, multiple)"`.
289    pub fn ref_select_prompt(&self) -> String {
290        match &self.ref_select {
291            Some(r) if r.target == RefSelectTarget::Sheet => format!(
292                "{} (sheet anchor{})",
293                r.label,
294                if r.multiple { "s" } else { "" }
295            ),
296            Some(r) => format!(
297                "{} ({}{})",
298                r.label,
299                r.filter.join("/"),
300                if r.multiple { ", multiple" } else { "" }
301            ),
302            None => String::new(),
303        }
304    }
305
306    /// A viewport click while active: type-constrained-pick the nearest allowed
307    /// hit under CSS-pixel `(x, y)` and add its name to the running list (single
308    /// fields replace; multiple fields append, de-duplicated). Re-lights the
309    /// highlight. No-op on a miss / an empty (unnamed) hit.
310    pub fn ref_select_click(&mut self, x: f64, y: f64) {
311        let Some(state) = self.ref_select.as_ref() else {
312            return;
313        };
314        let filter = state.filter.clone();
315        let multiple = state.multiple;
316        let target = state.target;
317        // A SHEET pick is an anchor on the paper, never a 3D hit: the sheet
318        // viewport feeds `ref_select_pick_sheet_anchor` instead.
319        if target == RefSelectTarget::Sheet {
320            return;
321        }
322        // The field's RAW kind strings drive the pick (`pick_top_at` reads
323        // `DATUM` as an alias of `PLANE`), so a `["PLANE","FACE"]` sketchPlane
324        // field now picks a construction plane through the ORDINARY candidate
325        // list — including one sitting under a face, which the geometry-miss
326        // fallback below could never reach.
327        // A spline anchor attaches to a PORT: whatever part of a port's drawn
328        // sheet was hit (its line, its base vertex, the sheet itself), the
329        // pick resolves to the OWNING sheet, which is keyed by the port id;
330        // anything that is not a port is refused with a notice.
331        if let RefSelectTarget::SplineAnchor { .. } = target {
332            let Some(hit) = self.pick_top_at(x, y, &filter) else {
333                return;
334            };
335            let owner = if hit.solid.trim().is_empty() { hit.name.clone() } else { hit.solid.clone() };
336            if !self.is_port_id(&owner) {
337                self.push_notice(format!("'{owner}' is not a port — pick a port to attach the anchor"));
338                return;
339            }
340            let state = self.ref_select.as_mut().expect("active by guard above");
341            state.names = vec![owner];
342            self.sync_ref_select_emphasis();
343            return;
344        }
345        let picked = match self.pick_top_at(x, y, &filter) {
346            Some(hit) if hit.kind == pick::PickKind::Plane => {
347                // Accept ONLY a resolved D/P frame name, the same guard the
348                // fallback applies — a stray widget-fed plane never lands in a
349                // reference field.
350                if self.datum_feature_for_name(&hit.name).is_none() {
351                    return;
352                }
353                hit.name
354            }
355            Some(hit) if !hit.name.trim().is_empty() => hit.name,
356            Some(hit) => {
357                // A vertex pick carries no kernel name. For an ASSEMBLY CONSTRAINT
358                // field that accepts VERTEX, build the `{solidName}@x,y,z` ref with
359                // COMPONENT-LOCAL coordinates (world pick · owning-component
360                // pose⁻¹ — the lane-E selection contract; the kernel resolver snaps
361                // to the nearest topology vertex). Everything else stays a no-op.
362                if !matches!(hit.kind, pick::PickKind::Vertex) {
363                    return;
364                }
365                match target {
366                    RefSelectTarget::AssemblyConstraint => {
367                        match self.component_vertex_ref(&hit.solid, hit.position) {
368                            Some(vertex_ref) => vertex_ref,
369                            None => return, // not component geometry — constraints reject it anyway
370                        }
371                    }
372                    // PMI resolves against the world-posed resident solids, so
373                    // its vertex refs carry WORLD coordinates.
374                    RefSelectTarget::Pmi => super::pmi_ops::world_vertex_ref(&hit.solid, hit.position),
375                    // A connection point's seat resolves against the finished
376                    // part, which is the same world-posed scene, so its vertex
377                    // refs take the same form and the kernel reads them with
378                    // the same helper.
379                    RefSelectTarget::PortPoint { .. } => {
380                        super::pmi_ops::world_vertex_ref(&hit.solid, hit.position)
381                    }
382                    _ => return,
383                }
384            }
385            None => {
386                // TOTAL MISS. The plane CARDS are candidates above and they are
387                // the same set `datum_pick` tests, so in the app this arm is
388                // unreachable for a plane field; it is kept as a second line of
389                // defense for the tested `["PLANE","FACE"]` sketchPlane flow (and
390                // it is the ONLY path that would reach a datum AXIS, were one ever
391                // fed). Accept ONLY a resolved D/P frame name
392                // (`datum_feature_for_name`, mirroring `select_datum`'s guard) so an
393                // AXIS name — which `datum_pick` may also return — never lands in a
394                // plane field.
395                let admits_plane = filter
396                    .iter()
397                    .any(|k| k.eq_ignore_ascii_case("PLANE") || k.eq_ignore_ascii_case("DATUM"));
398                if !admits_plane {
399                    return;
400                }
401                let name = self.datum_pick(x, y);
402                if name.is_empty() || self.datum_feature_for_name(&name).is_none() {
403                    return;
404                }
405                name
406            }
407        };
408        let state = self.ref_select.as_mut().expect("active by guard above");
409        if multiple {
410            if !state.names.iter().any(|n| n == &picked) {
411                state.names.push(picked);
412            }
413        } else {
414            state.names = vec![picked];
415        }
416        self.sync_ref_select_emphasis();
417    }
418
419    /// Feed a picked NAME into the running list — what [`Self::ref_select_click`]
420    /// does after its pick (single fields replace; multiple fields append,
421    /// de-duplicated), for a caller that already holds the name (a test, an
422    /// automation driver). No-op while no selection is running.
423    pub fn ref_select_add_name(&mut self, name: String) {
424        let Some(state) = self.ref_select.as_mut() else {
425            return;
426        };
427        if state.multiple {
428            if !state.names.iter().any(|n| n == &name) {
429                state.names.push(name);
430            }
431        } else {
432            state.names = vec![name];
433        }
434        self.sync_ref_select_emphasis();
435    }
436
437    /// Remove the name at `index` from the running list (the modal's per-line X).
438    pub fn ref_select_remove(&mut self, index: usize) {
439        if let Some(state) = self.ref_select.as_mut() {
440            if index < state.names.len() {
441                state.names.remove(index);
442            }
443        }
444        self.sync_ref_select_emphasis();
445    }
446
447    /// Finish: write the running names into the edited feature's params at the
448    /// field path, restore the rolled-to step, clear the highlight, and re-run so
449    /// the feature rebuilds with the chosen references.
450    pub fn finish_ref_select(&mut self) {
451        let Some(state) = self.ref_select.take() else {
452            return;
453        };
454        // An ASSEMBLY CONSTRAINT field commits through the constraint update
455        // lane (kernel session + document fold), not feature params; the shared
456        // end tail below still restores the roll + re-runs (which re-solves).
457        if state.target == RefSelectTarget::AssemblyConstraint {
458            self.assembly_commit_constraint_refs(
459                &state.feature_id,
460                &state.path,
461                &state.names,
462                state.multiple,
463            );
464            self.end_ref_select(state.restore_index);
465            return;
466        }
467        // A PMI annotation field commits into the document's pmi block (no
468        // history feature is involved); the shared tail restores + re-runs,
469        // which resolves the annotation against the fresh scene.
470        if state.target == RefSelectTarget::Pmi {
471            if let Some((_, annotation)) = self.pmi_state().find_annotation(&state.feature_id) {
472                if annotation.kind.contains('/') {
473                    let mut params = annotation.params.clone();
474                    let value = if state.multiple { serde_json::json!(state.names) }
475                        else { serde_json::json!(state.names.first().cloned().unwrap_or_default()) };
476                    set_json_at(&mut params, &state.path, value);
477                    // Clear picking state before the transaction captures selection/revision.
478                    // PMI never rolled the history; a second rerun would invalidate the worker reply.
479                    let _ = self.emphasis.apply_json("{}");
480                    self.selection_filter = SelectionFilter::default();
481                    if let Err(error) = self.plugin_update_annotation(&state.feature_id, params) {
482                        self.push_notice(format!("PMI update failed: {error}"));
483                    }
484                    self.dirty = true;
485                    return;
486                }
487            }
488            self.pmi_commit_refs(&state.feature_id, &state.path, &state.names, state.multiple);
489            self.end_ref_select(state.restore_index);
490            return;
491        }
492        // A SHEET object's field commits into the `sheets` block, and a sheet
493        // edit never re-runs the history — so its tail is its own: no roll to
494        // restore, no rebuild, just the modal gone.
495        if state.target == RefSelectTarget::Sheet {
496            if let Err(error) =
497                self.sheet_commit_refs(&state.feature_id, &state.path, &state.names, state.multiple)
498            {
499                self.push_notice(format!("Sheets: {error}"));
500            }
501            self.dirty = true;
502            return;
503        }
504        // A CONNECTION POINT's reference commits into the `ports` block (no
505        // history feature is involved, and nothing was rolled). The shared
506        // tail re-runs, which is what re-seats the point on what was picked.
507        if let RefSelectTarget::PortPoint { field } = state.target {
508            self.ports_commit_ref(
509                &state.feature_id,
510                field,
511                state.names.first().map(String::as_str).unwrap_or_default(),
512            );
513            self.end_ref_select(state.restore_index);
514            return;
515        }
516        // A SPLINE ANCHOR attachment commits through the anchor lane (the
517        // persistent spline document), then the shared tail restores + re-runs.
518        if let RefSelectTarget::SplineAnchor { index } = state.target {
519            if let Some(port) = state.names.first() {
520                self.attach_spline_anchor_no_rerun(&state.feature_id, index, port);
521            }
522            self.end_ref_select(state.restore_index);
523            return;
524        }
525        if let Some(index) = self.history.index_of(&state.feature_id) {
526            let mut params = self
527                .history
528                .feature_params(index)
529                .unwrap_or_else(|| serde_json::json!({}));
530            let value = if state.multiple {
531                serde_json::Value::Array(
532                    state
533                        .names
534                        .iter()
535                        .cloned()
536                        .map(serde_json::Value::String)
537                        .collect(),
538                )
539            } else {
540                serde_json::Value::String(state.names.first().cloned().unwrap_or_default())
541            };
542            set_json_at(&mut params, &state.path, value);
543            self.stamp_face_transform_pivot(index, &mut params);
544            self.history.set_feature_params(index, params);
545        }
546        self.end_ref_select(state.restore_index);
547    }
548
549    /// Cancel: discard the running selection, clear the highlight, restore the
550    /// rolled-to step, and re-run (no param change).
551    pub fn cancel_ref_select(&mut self) {
552        if let Some(state) = self.ref_select.take() {
553            if state.target == RefSelectTarget::Sheet {
554                // Nothing rolled and nothing was highlighted: just close.
555                self.dirty = true;
556                return;
557            }
558            self.end_ref_select(state.restore_index);
559        }
560    }
561
562    /// Whether the active picker is picking ANCHORS on a drawing sheet — the
563    /// sheet viewport routes its clicks to [`Self::ref_select_pick_sheet_anchor`]
564    /// and the modal's hint speaks of the paper rather than the 3D view.
565    pub fn ref_select_is_sheet(&self) -> bool {
566        self.ref_select.as_ref().is_some_and(|state| state.target == RefSelectTarget::Sheet)
567    }
568
569    /// Restore the rolled-to step + clear emphasis + re-run + reset the selection
570    /// filter to the all-enabled default (shared Finish/Cancel tail).
571    ///
572    /// Resetting to the DEFAULT (not a saved "prior" filter) is deliberate: the
573    /// spec baseline out of ref-select is "all kinds enabled", and `begin_ref_select`
574    /// overwrites `ref_select` without routing through here, so a stashed prior
575    /// could be a stale already-constrained filter. Living in this shared tail also
576    /// means a stray `finish_ref_select()` while inactive (early return on `take`)
577    /// never clobbers the filter.
578    fn end_ref_select(&mut self, restore_index: usize) {
579        let _ = self.emphasis.apply_json("{}");
580        self.selection_filter = SelectionFilter::default();
581        self.history.set_rollback(restore_index);
582        self.rerun_history();
583    }
584
585    /// Drive the selection highlight (`emphasis`) from the running name list so
586    /// picks light up in the viewport. A field may allow SEVERAL kinds at once
587    /// (e.g. `FACE`/`EDGE`), and a pick can be any of them, so every picked name is
588    /// fed to EVERY name-based bucket the filter permits — a name only ever matches
589    /// its own kind's entities (edge names carry the `|…[n]` topology form, faces do
590    /// not), so the cross-listing is harmless and each pick highlights correctly.
591    /// (The old code bucketed ALL names by `filter.first()` only, so an EDGE pick
592    /// under a `FACE`-first filter landed in `faces`, matched nothing, and never
593    /// showed.) VERTEX picks are position-keyed, not name-keyed, so they can't be
594    /// emphasized from a name list here.
595    pub(crate) fn sync_ref_select_emphasis(&mut self) {
596        // A sheet pick names paper anchors, not scene entities: the 3D
597        // highlight is not its to drive (the paper draws its own picks).
598        if self.ref_select_is_sheet() {
599            return;
600        }
601        let json = match &self.ref_select {
602            Some(state) => {
603                let names = serde_json::json!(state.names);
604                let mut selected = serde_json::Map::new();
605                for kind in &state.filter {
606                    // Case-INSENSITIVE match, mirroring `SelectionFilter::set` (which
607                    // pick-filtering uses via `from_ref_filter`). Without this, a
608                    // schema that spelled a kind non-canonically (e.g. `"Edge"`) would
609                    // let the user PICK that kind but silently skip its seed HIGHLIGHT
610                    // here — a lenient-pick / strict-highlight split. VERTEX is inert:
611                    // vertex picks carry no kernel name, so `ref_select_click` never
612                    // records one in `names` (empty-name early-return), so there is
613                    // nothing to highlight by name.
614                    let bucket = match kind.to_ascii_uppercase().as_str() {
615                        "FACE" => "faces",
616                        "EDGE" => "edges",
617                        "SOLID" => "solids",
618                        "PLANE" | "DATUM" => "datums",
619                        _ => continue, // VERTEX (never name-seeded) / unknown
620                    };
621                    selected.entry(bucket.to_string()).or_insert_with(|| names.clone());
622                }
623                // No highlightable kind in the filter → fall back to solids (the
624                // prior default) so at least solid-name picks still light up.
625                if selected.is_empty() {
626                    selected.insert("solids".to_string(), names);
627                }
628                serde_json::json!({ "selected": selected }).to_string()
629            }
630            None => "{}".to_string(),
631        };
632        let _ = self.emphasis.apply_json(&json);
633        // A picked construction PLANE/DATUM highlights through the datum-plane
634        // WIDGET, whose accent is baked at feed time (`refresh_construction_datums`
635        // reads `emphasis.selected_datums`) — so a plain `apply_json` does not
636        // re-color it. Re-feed here so a datum pick lights up (and un-lights on
637        // remove) in the modal. Harmless for non-datum fields (no datum selected →
638        // an ordinary calm-color re-feed).
639        self.refresh_construction_datums();
640        self.dirty = true;
641    }
642}
643
644/// Write `value` into `root` at `path` (object-key chain), auto-vivifying
645/// intermediate objects — the engine-side twin of the form's nested setter, used
646/// to commit a reference field's picked names back into the feature params.
647pub(crate) fn set_json_at(root: &mut serde_json::Value, path: &[String], value: serde_json::Value) {
648    if path.is_empty() {
649        *root = value;
650        return;
651    }
652    if !root.is_object() {
653        *root = serde_json::Value::Object(serde_json::Map::new());
654    }
655    let mut cur = root;
656    for seg in &path[..path.len() - 1] {
657        let obj = cur.as_object_mut().expect("object by construction");
658        cur = obj
659            .entry(seg.clone())
660            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
661        if !cur.is_object() {
662            *cur = serde_json::Value::Object(serde_json::Map::new());
663        }
664    }
665    cur.as_object_mut()
666        .expect("object by construction")
667        .insert(path[path.len() - 1].clone(), value);
668}
669
670impl EngineState {
671    /// The ONE candidate under CSS-pixel `(x, y)` that the hover highlight lights
672    /// and a plain click takes: the filter-admitted candidate FIRST IN THE
673    /// RAYCAST by depth, the shared kind order breaking ties inside the nearest
674    /// depth layer ([`front_layer_winner`](super::plane_pick::front_layer_winner)).
675    /// `None` on a miss, or when the filter admits nothing.
676    ///
677    /// Built from [`candidates_filtered_at`](Self::candidates_filtered_at), so
678    /// what the pointer promises is drawn from exactly the set the PICK LIST
679    /// offers — one selection rule over one list, never a second pick with its
680    /// own admission rules. The list's own ordering is untouched.
681    ///
682    /// The layer band is the camera's world-per-pixel times the picker's
683    /// [`EDGE_PICK_PX`](crate::pick::EDGE_PICK_PX): the same 6 px neighbourhood
684    /// the picker already accepts *laterally* for an edge or a vertex, applied in
685    /// depth. It is a screen-space quantity, so it tracks zoom and model scale
686    /// without a unit of its own. (A face seen at a grazing angle changes depth
687    /// faster than that across 6 px, so its bounding edge can fall outside the
688    /// band and lose to the face — click nearer the edge, or dwell for the list.)
689    ///
690    /// COMPONENT promotion mirrors
691    /// [`select_filtered_at`](Self::select_filtered_at): with the filter's
692    /// COMPONENT lane on, a winner that is component geometry answers as its
693    /// owning COMPONENT, so the highlight lights the same whole component the
694    /// click would select.
695    pub fn nearest_candidate_at(&self, x: f64, y: f64) -> Option<pick::PickCandidate> {
696        let candidates = self.candidates_filtered_at(x, y);
697        let band = self.camera.world_per_pixel() * crate::pick::EDGE_PICK_PX;
698        let winner = super::plane_pick::front_layer_winner(&candidates, band)?;
699        if self.selection_filter.component && winner.kind != pick::PickKind::Component {
700            if let Some(owner) = self.hit_owning_component(winner) {
701                // The COMPONENT row for that owner is already in the list (the
702                // builder appends one per owning component), carrying its
703                // nearest member hit — reuse it rather than synthesizing one.
704                if let Some(entry) = candidates
705                    .iter()
706                    .find(|c| c.kind == pick::PickKind::Component && c.name == owner)
707                {
708                    return Some(entry.clone());
709                }
710            }
711        }
712        Some(winner.clone())
713    }
714
715    /// Hover-highlight the candidate under CSS-pixel `(x, y)` that a plain click
716    /// would take — the NEAREST admitted hit by depth
717    /// ([`nearest_candidate_at`](Self::nearest_candidate_at)) — setting it
718    /// HOVERED in `emphasis` (the renderer tints it). A miss — or a filter
719    /// admitting nothing — clears the hover. No-ops (returns `false`, no dirty)
720    /// when the hovered entity is unchanged, so a stationary pointer over the
721    /// same face doesn't re-render every frame. Returns whether the hover state
722    /// changed — the signal the viewport's dwell clock restarts on, so "how long
723    /// has THIS highlight been showing" is answered without a second pick.
724    ///
725    /// The highlight is the promise the click keeps: both resolve through the one
726    /// query above, so what lights up is what a plain click selects.
727    pub fn hover_at(&mut self, x: f64, y: f64) -> bool {
728        match self.nearest_candidate_at(x, y) {
729            Some(hit) => {
730                if self.hover_is(&hit) {
731                    return false; // unchanged — keep the frame clean.
732                }
733                self.set_hover_to_candidate(&hit);
734                true
735            }
736            None => self.clear_hover(),
737        }
738    }
739
740    /// Whether ANY hover highlight is lit right now. The viewport's dwell clock
741    /// runs only over a lit highlight (there is nothing to open a list about over
742    /// empty space), and this is the cheap read of that.
743    pub fn hover_is_lit(&self) -> bool {
744        !self.emphasis.hovered_solids.is_empty()
745            || !self.emphasis.hovered_faces.is_empty()
746            || !self.emphasis.hovered_edges.is_empty()
747            || !self.emphasis.hovered_vertices.is_empty()
748            || !self.emphasis.hovered_datums.is_empty()
749    }
750
751    /// Clear the hover highlight (pointer moved to empty space / off the
752    /// viewport). Bumps the emphasis generation + marks dirty only when a hover
753    /// was actually lit. Returns whether it changed. (Distinct from
754    /// [`clear_selection`](Self::clear_selection), which leaves hover alone.)
755    pub fn clear_hover(&mut self) -> bool {
756        let had_datums = !self.emphasis.hovered_datums.is_empty();
757        let had = !self.emphasis.hovered_solids.is_empty()
758            || !self.emphasis.hovered_faces.is_empty()
759            || !self.emphasis.hovered_edges.is_empty()
760            || !self.emphasis.hovered_vertices.is_empty()
761            || had_datums;
762        if had {
763            self.emphasis.hovered_solids.clear();
764            self.emphasis.hovered_faces.clear();
765            self.emphasis.hovered_edges.clear();
766            self.emphasis.hovered_vertices.clear();
767            self.emphasis.hovered_datums.clear();
768            self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
769            self.dirty = true;
770        }
771        // A hovered plane's accent is baked into the datum feed, so dropping the
772        // hover needs a re-feed to un-light it (the datum twin of the selection
773        // re-feed in `clear_selection`).
774        if had_datums {
775            self.refresh_construction_datums();
776        }
777        had
778    }
779
780    /// The current HOVER (not selection) as JSON
781    /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — the hover twin of
782    /// [`selection_json`](Self::selection_json) so a UI / the headed verifier can
783    /// assert that moving the pointer over a face lit the hover emphasis.
784    pub fn hovered_json(&self) -> String {
785        let solids: Vec<&String> = self.emphasis.hovered_solids.iter().collect();
786        let faces: Vec<&String> = self.emphasis.hovered_faces.iter().collect();
787        let edges: Vec<&String> = self.emphasis.hovered_edges.iter().collect();
788        let datums: Vec<&String> = self.emphasis.hovered_datums.iter().collect();
789        serde_json::json!({
790            "solids": solids,
791            "faces": faces,
792            "edges": edges,
793            "datums": datums,
794            "vertices": self.emphasis.hovered_vertices.len(),
795        })
796        .to_string()
797    }
798
799    /// TOGGLE the admitted pick under CSS-pixel `(x, y)` — the NEAREST by depth,
800    /// the one the hover highlight is showing — in the current
801    /// selection (a **Ctrl/Cmd+click**): add it if absent, remove it if present,
802    /// leaving the rest of the selection intact (unlike [`select_top_at`], which
803    /// REPLACES). With the COMPONENT filter on, a hit on component geometry
804    /// toggles the whole component (all member solids as one unit). A miss — or
805    /// a filter admitting nothing — leaves the selection untouched (additive
806    /// mode never clears). Returns whether a hit was toggled.
807    pub fn select_toggle_at(&mut self, x: f64, y: f64) -> bool {
808        // The SAME hit the hover highlight is showing
809        // ([`nearest_candidate_at`](Self::nearest_candidate_at)) — a modifier
810        // changes whether the pick replaces or adds, never WHICH entity it is,
811        // so the highlight stays the promise. Its COMPONENT promotion carries
812        // the whole component through as one unit, which is what
813        // [`toggle_candidate`](Self::toggle_candidate) toggles for a COMPONENT
814        // row, and it answers `None` when the filter admits nothing.
815        match self.nearest_candidate_at(x, y) {
816            Some(hit) => {
817                self.toggle_candidate(&hit);
818                true
819            }
820            None => false,
821        }
822    }
823
824    /// The RANKED, filter-respecting candidates under CSS-pixel `(x, y)` as JSON
825    /// `[{kind, name, solid, depth}]` — the "candidates under the cursor" list
826    /// (feeds the pick-list popup + the headed verifier).
827    ///
828    /// Sorted category-major in the pick-list order (VERTEX > EDGE > FACE >
829    /// PLANE > SOLID > COMPONENT), nearest (smallest depth) first within each
830    /// category — see [`candidates_filtered_at`](Self::candidates_filtered_at).
831    pub fn candidates_at(&self, x: f64, y: f64) -> String {
832        let list = self.candidates_filtered_at(x, y);
833        let out: Vec<serde_json::Value> = list
834            .iter()
835            .map(|c| {
836                serde_json::json!({
837                    "kind": self.candidate_kind_label(c),
838                    "name": c.name,
839                    "solid": c.solid,
840                    "depth": c.depth,
841                })
842            })
843            .collect();
844        serde_json::Value::Array(out).to_string()
845    }
846
847    /// What a REGULAR (non-dwelling) viewport click at CSS-pixel `(x, y)` takes,
848    /// as one JSON `{kind, name, solid, depth}` — the same shape a row of
849    /// [`candidates_at`](Self::candidates_at) carries — or `null` on a miss.
850    ///
851    /// The automation bridge reads this to answer "would a click here reach the
852    /// entity I named": since the trigger moved, the top ROW of the list and what
853    /// a plain click resolves to are no longer the same question, and a script
854    /// that guesses wrong picks the wrong thing silently.
855    pub fn nearest_candidate_json(&self, x: f64, y: f64) -> String {
856        match self.nearest_candidate_at(x, y) {
857            Some(c) => serde_json::json!({
858                "kind": self.candidate_kind_label(&c),
859                "name": c.name,
860                "solid": c.solid,
861                "depth": c.depth,
862            })
863            .to_string(),
864            None => "null".to_string(),
865        }
866    }
867
868    /// The same ranked, filter-respecting candidate list as typed values (the
869    /// in-process egui pick-list popup consumes these directly, then re-hovers /
870    /// selects a chosen one via [`hover_candidate`](Self::hover_candidate) /
871    /// [`select_candidate`](Self::select_candidate) /
872    /// [`toggle_candidate`](Self::toggle_candidate)). EMPTY when the filter admits
873    /// nothing (not the `pick_filtered` "empty filter = any" case).
874    ///
875    /// The raw list is [`pick_candidates_at`](Self::pick_candidates_at), so
876    /// construction PLANE cards are ordinary entries here — a plane under other
877    /// geometry is listed (right after the faces) instead of being reachable only
878    /// on a geometry miss.
879    ///
880    /// With the filter's COMPONENT lane on, one COMPONENT entry per owning
881    /// assembly component of ANY raw hit is appended (name = component id, depth
882    /// = the component's nearest hit) — a raw hit of a filtered-OFF kind still
883    /// reaches its owning component, mirroring `select_filtered_at`'s
884    /// component-only promotion (a PLANE hit owns no component). The final list is
885    /// sorted category-major in the pick-list order
886    /// (VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest first within
887    /// each category.
888    pub fn candidates_filtered_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
889        let kinds = self.selection_filter.enabled_kinds();
890        let component_on = self.selection_filter.component;
891        if kinds.is_empty() && !component_on {
892            return Vec::new();
893        }
894        let raw = self.pick_candidates_at(x, y);
895        let mut out: Vec<pick::PickCandidate> = raw
896            .iter()
897            .filter(|c| self.candidate_admitted(&kinds, c))
898            .cloned()
899            .collect();
900        if component_on {
901            // One entry per owning component, carrying its NEAREST member hit's
902            // depth/position (raw hits are kind-major, so scan them all).
903            let mut components: Vec<pick::PickCandidate> = Vec::new();
904            for hit in &raw {
905                let Some(owner) = self.hit_owning_component(hit) else {
906                    continue;
907                };
908                match components.iter_mut().find(|c| c.name == owner) {
909                    Some(entry) => {
910                        if hit.depth < entry.depth {
911                            entry.depth = hit.depth;
912                            entry.position = hit.position;
913                        }
914                    }
915                    None => components.push(pick::PickCandidate {
916                        kind: pick::PickKind::Component,
917                        name: owner,
918                        solid: String::new(),
919                        depth: hit.depth,
920                        screen_dist: hit.screen_dist,
921                        position: hit.position,
922                    }),
923                }
924            }
925            out.extend(components);
926        }
927        // Category-major (PickKind's discriminant order IS the pick-list order),
928        // nearest first within a category — the shared ordering every pick path
929        // uses, so the appended COMPONENT rows land in the same sort.
930        super::plane_pick::sort_pick_candidates(&mut out);
931        out
932    }
933
934    /// Whether a candidate is CURRENTLY selected (drives the pick-list popup's
935    /// per-row selected state so click-toggling reads back visually).
936    pub fn candidate_is_selected(&self, candidate: &pick::PickCandidate) -> bool {
937        use crate::pick::PickKind;
938        match candidate.kind {
939            PickKind::Solid => self
940                .emphasis
941                .selected_solids
942                .contains(&self.candidate_solid_name(candidate)),
943            PickKind::Face => self.emphasis.selected_faces.contains(&candidate.name),
944            PickKind::Edge => self.emphasis.selected_edges.contains(&candidate.name),
945            PickKind::Vertex => self
946                .emphasis
947                .selected_vertices
948                .iter()
949                .any(|v| Self::vertex_ref_matches(v, candidate)),
950            PickKind::Plane => self.emphasis.selected_datums.contains(&candidate.name),
951            PickKind::Component => {
952                let members = self.component_member_solids(&candidate.name);
953                !members.is_empty()
954                    && members
955                        .iter()
956                        .all(|m| self.emphasis.selected_solids.contains(m))
957            }
958        }
959    }
960
961    /// Hover a SPECIFIC candidate (the popup entry the pointer is over) — sets it
962    /// HOVERED in `emphasis`, replacing any prior hover.
963    pub fn hover_candidate(&mut self, candidate: &pick::PickCandidate) {
964        self.set_hover_to_candidate(candidate);
965    }
966
967    /// REPLACE the selection with a specific candidate (a plain click on a popup
968    /// entry) — reuses the same bucketing as a plain viewport click.
969    pub fn select_candidate(&mut self, candidate: &pick::PickCandidate) {
970        self.set_selection_to_candidate(candidate);
971    }
972
973    /// TOGGLE a specific candidate in the selection (a Ctrl/Cmd+click on a popup
974    /// entry, or the [`select_toggle_at`](Self::select_toggle_at) hit): add if
975    /// absent, remove if present. Returns whether it is NOW selected (`true` =
976    /// added, `false` = removed). Bumps the emphasis generation + marks dirty.
977    pub fn toggle_candidate(&mut self, candidate: &pick::PickCandidate) -> bool {
978        use crate::pick::PickKind;
979        let now_selected = match candidate.kind {
980            PickKind::Solid => {
981                let name = self.candidate_solid_name(candidate);
982                if self.emphasis.selected_solids.remove(&name) {
983                    false
984                } else {
985                    self.emphasis.selected_solids.insert(name);
986                    true
987                }
988            }
989            PickKind::Face => {
990                if self.emphasis.selected_faces.remove(&candidate.name) {
991                    false
992                } else {
993                    self.emphasis.selected_faces.insert(candidate.name.clone());
994                    true
995                }
996            }
997            PickKind::Edge => {
998                if self.emphasis.selected_edges.remove(&candidate.name) {
999                    false
1000                } else {
1001                    self.emphasis.selected_edges.insert(candidate.name.clone());
1002                    true
1003                }
1004            }
1005            PickKind::Vertex => {
1006                if let Some(index) = self
1007                    .emphasis
1008                    .selected_vertices
1009                    .iter()
1010                    .position(|v| Self::vertex_ref_matches(v, candidate))
1011                {
1012                    self.emphasis.selected_vertices.remove(index);
1013                    false
1014                } else {
1015                    self.emphasis.selected_vertices.push(crate::style::VertexRef {
1016                        solid: candidate.solid.clone(),
1017                        position: candidate.position,
1018                    });
1019                    true
1020                }
1021            }
1022            PickKind::Plane => {
1023                // A construction PLANE toggles by FRAME NAME, the datum bucket the
1024                // Scene-tree row / `select_datum` fill.
1025                if self.emphasis.selected_datums.remove(&candidate.name) {
1026                    false
1027                } else {
1028                    self.emphasis.selected_datums.insert(candidate.name.clone());
1029                    true
1030                }
1031            }
1032            PickKind::Component => {
1033                // The whole component toggles as ONE unit (member solids), the
1034                // same rule as the Ctrl/Cmd+click COMPONENT promotion.
1035                let was_selected = self.candidate_is_selected(candidate);
1036                self.toggle_component_selection(&candidate.name);
1037                !was_selected
1038            }
1039        };
1040        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
1041        self.dirty = true;
1042        // A plane's accent is baked into the datum feed, so a toggled plane only
1043        // lights / un-lights after a re-feed.
1044        if candidate.kind == PickKind::Plane {
1045            self.refresh_construction_datums();
1046        }
1047        now_selected
1048    }
1049
1050    /// Set the hover emphasis to exactly one candidate (bucketed by kind), the
1051    /// hover twin of `set_selection_to_candidate`.
1052    fn set_hover_to_candidate(&mut self, candidate: &pick::PickCandidate) {
1053        use crate::pick::PickKind;
1054        // A hovered PLANE's accent lives in the datum FEED, so the re-feed below
1055        // is needed both when a plane becomes hovered and when one stops being.
1056        let touches_datums =
1057            !self.emphasis.hovered_datums.is_empty() || candidate.kind == PickKind::Plane;
1058        self.emphasis.hovered_solids.clear();
1059        self.emphasis.hovered_faces.clear();
1060        self.emphasis.hovered_edges.clear();
1061        self.emphasis.hovered_vertices.clear();
1062        self.emphasis.hovered_datums.clear();
1063        match candidate.kind {
1064            PickKind::Solid => {
1065                self.emphasis
1066                    .hovered_solids
1067                    .insert(self.candidate_solid_name(candidate));
1068            }
1069            PickKind::Face => {
1070                self.emphasis.hovered_faces.insert(candidate.name.clone());
1071            }
1072            PickKind::Edge => {
1073                self.emphasis.hovered_edges.insert(candidate.name.clone());
1074            }
1075            PickKind::Vertex => {
1076                self.emphasis.hovered_vertices.push(crate::style::VertexRef {
1077                    solid: candidate.solid.clone(),
1078                    position: candidate.position,
1079                });
1080            }
1081            PickKind::Plane => {
1082                self.emphasis.hovered_datums.insert(candidate.name.clone());
1083            }
1084            PickKind::Component => {
1085                // Hovering a COMPONENT entry lights every member solid.
1086                for member in self.component_member_solids(&candidate.name) {
1087                    self.emphasis.hovered_solids.insert(member);
1088                }
1089            }
1090        }
1091        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
1092        self.dirty = true;
1093        if touches_datums {
1094            self.refresh_construction_datums();
1095        }
1096    }
1097
1098    /// Whether the CURRENT hover is exactly this one candidate (the `hover_at`
1099    /// early-out) — a single hovered entity that matches `candidate` (for a
1100    /// COMPONENT candidate: exactly its member-solid set). `pub(super)` so the
1101    /// Scene-tree row hover (`scene_query.rs`) shares the same identity test for
1102    /// its dedupe + its "only clear what the tree lit" rule.
1103    pub(super) fn hover_is(&self, candidate: &pick::PickCandidate) -> bool {
1104        use crate::pick::PickKind;
1105        if candidate.kind == PickKind::Component {
1106            let members = self.component_member_solids(&candidate.name);
1107            return !members.is_empty()
1108                && self.emphasis.hovered_faces.is_empty()
1109                && self.emphasis.hovered_edges.is_empty()
1110                && self.emphasis.hovered_vertices.is_empty()
1111                && self.emphasis.hovered_datums.is_empty()
1112                && self.emphasis.hovered_solids.len() == members.len()
1113                && members.iter().all(|m| self.emphasis.hovered_solids.contains(m));
1114        }
1115        let total = self.emphasis.hovered_solids.len()
1116            + self.emphasis.hovered_faces.len()
1117            + self.emphasis.hovered_edges.len()
1118            + self.emphasis.hovered_vertices.len()
1119            + self.emphasis.hovered_datums.len();
1120        if total != 1 {
1121            return false;
1122        }
1123        match candidate.kind {
1124            PickKind::Solid => self
1125                .emphasis
1126                .hovered_solids
1127                .contains(&self.candidate_solid_name(candidate)),
1128            PickKind::Face => self.emphasis.hovered_faces.contains(&candidate.name),
1129            PickKind::Edge => self.emphasis.hovered_edges.contains(&candidate.name),
1130            PickKind::Vertex => self
1131                .emphasis
1132                .hovered_vertices
1133                .iter()
1134                .any(|v| Self::vertex_ref_matches(v, candidate)),
1135            PickKind::Plane => self.emphasis.hovered_datums.contains(&candidate.name),
1136            PickKind::Component => false, // handled by the early return above
1137        }
1138    }
1139
1140    /// The scene name a SOLID candidate resolves to (its owning `solid`, falling
1141    /// back to `name` when the pick didn't carry one) — the same rule
1142    /// `set_selection_to_candidate` uses.
1143    fn candidate_solid_name(&self, candidate: &pick::PickCandidate) -> String {
1144        if candidate.solid.is_empty() {
1145            candidate.name.clone()
1146        } else {
1147            candidate.solid.clone()
1148        }
1149    }
1150
1151    /// Vertex identity: same owning solid + position within the emphasis match
1152    /// tolerance (vertices carry no kernel name, so they resolve by solid+pos).
1153    fn vertex_ref_matches(v: &crate::style::VertexRef, candidate: &pick::PickCandidate) -> bool {
1154        const TOL: f64 = 1e-4;
1155        v.solid == candidate.solid
1156            && (v.position[0] - candidate.position[0]).abs() <= TOL
1157            && (v.position[1] - candidate.position[1]).abs() <= TOL
1158            && (v.position[2] - candidate.position[2]).abs() <= TOL
1159    }
1160}
1161
1162// ---------------------------------------------------------------------------
1163// Sketch display (S0) — read-only overlay of a solved SketchSession.
1164//
1165// Additive, self-contained: a solved sketch is fed to the general `set_overlay`
1166// channel as the named groups `sketch-geometry` (lines) and `sketch-points`
1167// (billboarded points), colored by solver mobility. No interaction (the tools /
1168// picking / dimensions of later slices live elsewhere); this block only pushes /
1169// clears the display geometry.
1170// ---------------------------------------------------------------------------
1171impl EngineState {
1172    /// Display a solved [`crate::sketch::SketchSession`] as a read-only overlay.
1173    /// The plane geometry is tessellated to world space and pushed via
1174    /// [`set_overlay_json`](Self::set_overlay_json); construction dashes are sized
1175    /// against the LIVE camera so they stay screen-constant.
1176    pub fn set_sketch_overlay(&mut self, session: &crate::sketch::SketchSession) {
1177        let world_per_pixel = self.camera.world_per_pixel();
1178        let json = session.overlay_json(world_per_pixel);
1179        // The overlay channel accepts our exact `{groups:[…]}` shape; a parse
1180        // failure would be a programming error in the tessellator, so drop it.
1181        let _ = self.set_overlay_json(&json);
1182        // The dimension leaders ride in their own `sketch-dim-leaders` group (S5).
1183        let _ = self.set_overlay_json(&session.dim_leaders_overlay_json(world_per_pixel));
1184        // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
1185        let _ = self.set_overlay_json(&session.constraint_glyphs_overlay_json(world_per_pixel));
1186        // The zoom this screen-constant sizing was baked at, so the per-frame
1187        // `ensure_sketch_overlay_current` re-bakes it when the camera zooms.
1188        self.sketch_overlay_wpp = if world_per_pixel > 0.0 {
1189            world_per_pixel
1190        } else {
1191            f64::MIN_POSITIVE
1192        };
1193    }
1194
1195    /// Remove the sketch overlay groups (feeding empty same-named groups upserts
1196    /// them to empty, which the overlay channel treats as a removal — other
1197    /// overlay groups are left untouched).
1198    pub fn clear_sketch_overlay(&mut self) {
1199        let _ = self.set_overlay_json(
1200            "{\"groups\":[{\"name\":\"sketch-geometry\"},{\"name\":\"sketch-points\"},{\"name\":\"sketch-preview\"},{\"name\":\"sketch-dim-leaders\"},{\"name\":\"sketch-constraint-glyphs\"}]}",
1201        );
1202        self.sketch_overlay_wpp = 0.0;
1203    }
1204}
1205