Skip to main content

brep_render/engine_state/
selection_ux.rs

1use super::*;
2
3impl EngineState {
4    /// Clear the current SELECTION (Esc): drop all selected solids/faces/edges/
5    /// vertices (hover is left untouched). Bumps the emphasis generation + marks
6    /// dirty only when something was actually cleared. Returns whether it changed.
7    pub fn clear_selection(&mut self) -> bool {
8        // The viewport-selected CONSTRAINT (label click) clears with the rest.
9        let had_constraint = self.selected_constraint.is_some();
10        self.constraint_deselect();
11        let had_datums = !self.emphasis.selected_datums.is_empty();
12        let had = had_constraint
13            || !self.emphasis.selected_solids.is_empty()
14            || !self.emphasis.selected_faces.is_empty()
15            || !self.emphasis.selected_edges.is_empty()
16            || !self.emphasis.selected_vertices.is_empty()
17            || had_datums;
18        if had {
19            self.emphasis.selected_solids.clear();
20            self.emphasis.selected_faces.clear();
21            self.emphasis.selected_edges.clear();
22            self.emphasis.selected_vertices.clear();
23            self.emphasis.selected_datums.clear();
24            self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
25            self.dirty = true;
26        }
27        // A cleared datum drops its selection accent — re-feed the datum planes so
28        // the highlight disappears immediately (no re-run needed).
29        if had_datums {
30            self.refresh_construction_datums();
31        }
32        had
33    }
34
35    /// REPLACE the whole selection with these named entities — the write twin of
36    /// [`Self::selection_json`], and the only way to put a MULTI-entity selection
37    /// back the way it was (`select_by_name` takes one name and clears the rest).
38    ///
39    /// Names are taken as given: an unknown name simply highlights nothing, the
40    /// same as a stale name after a rebuild. VERTICES are not addressable here —
41    /// they have no kernel name and emphasis keys them by position
42    /// ([`Self::select_vertex_by_position`]) — so a saved selection's vertices do
43    /// not come back through this call.
44    pub fn set_selection(
45        &mut self,
46        solids: &[String],
47        faces: &[String],
48        edges: &[String],
49        datums: &[String],
50    ) {
51        let had_datums = !self.emphasis.selected_datums.is_empty();
52        self.emphasis.selected_solids = solids.iter().cloned().collect();
53        self.emphasis.selected_faces = faces.iter().cloned().collect();
54        self.emphasis.selected_edges = edges.iter().cloned().collect();
55        self.emphasis.selected_datums = datums.iter().cloned().collect();
56        self.emphasis.selected_vertices.clear();
57        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
58        self.dirty = true;
59        if had_datums || !datums.is_empty() {
60            self.refresh_construction_datums();
61        }
62    }
63
64    /// Select the top-priority pick under CSS-pixel `(x, y)` that the SELECTION
65    /// FILTER admits — replacing the current selection (a plain viewport click).
66    /// A miss (or a click when the filter admits nothing) clears the selection.
67    /// Marks dirty when the selection changed; returns whether something was
68    /// selected. The by-kind honoring lives in [`select_filtered_at`] in the
69    /// appended selection-filter impl block (kept separate so concurrent edits to
70    /// this primary block don't conflict).
71    pub fn select_top_at(&mut self, x: f64, y: f64) -> bool {
72        self.select_filtered_at(x, y)
73    }
74
75    /// The current SELECTION (not hover) as JSON
76    /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — lets a UI / the
77    /// headed verifier read selection state (e.g. assert Esc cleared it).
78    pub fn selection_json(&self) -> String {
79        let solids: Vec<&String> = self.emphasis.selected_solids.iter().collect();
80        let faces: Vec<&String> = self.emphasis.selected_faces.iter().collect();
81        let edges: Vec<&String> = self.emphasis.selected_edges.iter().collect();
82        let datums: Vec<&String> = self.emphasis.selected_datums.iter().collect();
83        serde_json::json!({
84            "solids": solids,
85            "faces": faces,
86            "edges": edges,
87            "datums": datums,
88            "vertices": self.emphasis.selected_vertices.len(),
89        })
90        .to_string()
91    }
92
93    // --- Reference-selection widget (the engine-native picker, #42) --------
94    //
95    // A feature-dialog reference field activates this MODAL: the UI hides the
96    // rest of itself and shows only the widget's list + Finish/Cancel; the engine
97    // rolls to the pre-feature "before" state, highlights the running selection
98    // (via `emphasis`), and each click in the viewport type-constrained-picks a
99    // name into the list. Finish writes the names into the feature params (via
100    // the same `update_feature_params` path) and restores; Cancel discards. The
101    // list of names is the whole state — no event-on-object wiring.
102
103    /// True while the reference-selection modal is active (the shell hides the
104    /// rest of the UI and the viewport routes clicks to picking).
105    pub fn ref_select_active(&self) -> bool {
106        self.ref_select.is_some()
107    }
108
109    /// Enter reference-selection mode for feature `feature_id`'s param at `path`.
110    /// Seeds the running list from `seed_names` (the field's current value), rolls
111    /// the model to the pre-feature "before" state (the step just before the
112    /// edited feature ran), and highlights the seeded names. `filter` constrains
113    /// the pick kind (`["SOLID"]`, `["FACE"]`, …); `multiple` allows a list.
114    pub fn begin_ref_select(
115        &mut self,
116        feature_id: &str,
117        path: Vec<String>,
118        label: String,
119        filter: Vec<String>,
120        multiple: bool,
121        seed_names: Vec<String>,
122    ) {
123        let restore_index = self.history.rollback();
124        // "Before" = the step just before the edited feature ran, so the user
125        // picks against the correct geometry. Clamp at 0 for the first feature.
126        let before = self
127            .history
128            .index_of(feature_id)
129            .map(|i| i.saturating_sub(1))
130            .unwrap_or(restore_index);
131        // Constrain the GLOBAL selection filter to exactly the kinds this field
132        // permits: this drives BOTH click-picking (`ref_select_click`) AND
133        // hover-highlighting (`hover_at`, which reads `selection_filter`), so only
134        // the allowed kinds highlight/select while the picker is active. An
135        // absent/construction-only field filter maps to all-enabled (see
136        // `from_ref_filter`). Restored to the all-enabled default on finish/cancel
137        // (`end_ref_select`).
138        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
139        self.ref_select = Some(RefSelectState {
140            feature_id: feature_id.to_string(),
141            path,
142            label,
143            filter,
144            multiple,
145            names: seed_names,
146            restore_index,
147            target: RefSelectTarget::Feature,
148        });
149        // Roll to the before-state (re-runs + marks dirty), then light up the seed.
150        self.history.set_rollback(before);
151        self.rerun_history();
152        self.sync_ref_select_emphasis();
153    }
154
155    /// The running list of picked names (empty when not active) — the modal UI
156    /// reads this back to draw its one-per-line list.
157    pub fn ref_select_names(&self) -> Vec<String> {
158        self.ref_select
159            .as_ref()
160            .map(|r| r.names.clone())
161            .unwrap_or_default()
162    }
163
164    /// The active field's label (for the modal heading), or empty.
165    pub fn ref_select_label(&self) -> String {
166        self.ref_select
167            .as_ref()
168            .map(|r| r.label.clone())
169            .unwrap_or_default()
170    }
171
172    /// A one-line summary of the active field for the modal heading:
173    /// `"Tool solids (SOLID, multiple)"`.
174    pub fn ref_select_prompt(&self) -> String {
175        match &self.ref_select {
176            Some(r) => format!(
177                "{} ({}{})",
178                r.label,
179                r.filter.join("/"),
180                if r.multiple { ", multiple" } else { "" }
181            ),
182            None => String::new(),
183        }
184    }
185
186    /// A viewport click while active: type-constrained-pick the nearest allowed
187    /// hit under CSS-pixel `(x, y)` and add its name to the running list (single
188    /// fields replace; multiple fields append, de-duplicated). Re-lights the
189    /// highlight. No-op on a miss / an empty (unnamed) hit.
190    pub fn ref_select_click(&mut self, x: f64, y: f64) {
191        let Some(state) = self.ref_select.as_ref() else {
192            return;
193        };
194        let filter = state.filter.clone();
195        let multiple = state.multiple;
196        let target = state.target;
197        // The field's RAW kind strings drive the pick (`pick_top_at` reads
198        // `DATUM` as an alias of `PLANE`), so a `["PLANE","FACE"]` sketchPlane
199        // field now picks a construction plane through the ORDINARY candidate
200        // list — including one sitting under a face, which the geometry-miss
201        // fallback below could never reach.
202        // A spline anchor attaches to a PORT: whatever part of a port's drawn
203        // sheet was hit (its line, its base vertex, the sheet itself), the
204        // pick resolves to the OWNING sheet, which is keyed by the port id;
205        // anything that is not a port is refused with a notice.
206        if let RefSelectTarget::SplineAnchor { .. } = target {
207            let Some(hit) = self.pick_top_at(x, y, &filter) else {
208                return;
209            };
210            let owner = if hit.solid.trim().is_empty() { hit.name.clone() } else { hit.solid.clone() };
211            if !self.is_port_id(&owner) {
212                self.push_notice(format!("'{owner}' is not a port — pick a port to attach the anchor"));
213                return;
214            }
215            let state = self.ref_select.as_mut().expect("active by guard above");
216            state.names = vec![owner];
217            self.sync_ref_select_emphasis();
218            return;
219        }
220        let picked = match self.pick_top_at(x, y, &filter) {
221            Some(hit) if hit.kind == pick::PickKind::Plane => {
222                // Accept ONLY a resolved D/P frame name, the same guard the
223                // fallback applies — a stray widget-fed plane never lands in a
224                // reference field.
225                if self.datum_feature_for_name(&hit.name).is_none() {
226                    return;
227                }
228                hit.name
229            }
230            Some(hit) if !hit.name.trim().is_empty() => hit.name,
231            Some(hit) => {
232                // A vertex pick carries no kernel name. For an ASSEMBLY CONSTRAINT
233                // field that accepts VERTEX, build the `{solidName}@x,y,z` ref with
234                // COMPONENT-LOCAL coordinates (world pick · owning-component
235                // pose⁻¹ — the lane-E selection contract; the kernel resolver snaps
236                // to the nearest topology vertex). Everything else stays a no-op.
237                if !matches!(hit.kind, pick::PickKind::Vertex) {
238                    return;
239                }
240                match target {
241                    RefSelectTarget::AssemblyConstraint => {
242                        match self.component_vertex_ref(&hit.solid, hit.position) {
243                            Some(vertex_ref) => vertex_ref,
244                            None => return, // not component geometry — constraints reject it anyway
245                        }
246                    }
247                    // PMI resolves against the world-posed resident solids, so
248                    // its vertex refs carry WORLD coordinates.
249                    RefSelectTarget::Pmi => super::pmi_ops::world_vertex_ref(&hit.solid, hit.position),
250                    _ => return,
251                }
252            }
253            None => {
254                // TOTAL MISS. The plane CARDS are candidates above and they are
255                // the same set `datum_pick` tests, so in the app this arm is
256                // unreachable for a plane field; it is kept as a second line of
257                // defense for the tested `["PLANE","FACE"]` sketchPlane flow (and
258                // it is the ONLY path that would reach a datum AXIS, were one ever
259                // fed). Accept ONLY a resolved D/P frame name
260                // (`datum_feature_for_name`, mirroring `select_datum`'s guard) so an
261                // AXIS name — which `datum_pick` may also return — never lands in a
262                // plane field.
263                let admits_plane = filter
264                    .iter()
265                    .any(|k| k.eq_ignore_ascii_case("PLANE") || k.eq_ignore_ascii_case("DATUM"));
266                if !admits_plane {
267                    return;
268                }
269                let name = self.datum_pick(x, y);
270                if name.is_empty() || self.datum_feature_for_name(&name).is_none() {
271                    return;
272                }
273                name
274            }
275        };
276        let state = self.ref_select.as_mut().expect("active by guard above");
277        if multiple {
278            if !state.names.iter().any(|n| n == &picked) {
279                state.names.push(picked);
280            }
281        } else {
282            state.names = vec![picked];
283        }
284        self.sync_ref_select_emphasis();
285    }
286
287    /// Remove the name at `index` from the running list (the modal's per-line X).
288    pub fn ref_select_remove(&mut self, index: usize) {
289        if let Some(state) = self.ref_select.as_mut() {
290            if index < state.names.len() {
291                state.names.remove(index);
292            }
293        }
294        self.sync_ref_select_emphasis();
295    }
296
297    /// Finish: write the running names into the edited feature's params at the
298    /// field path, restore the rolled-to step, clear the highlight, and re-run so
299    /// the feature rebuilds with the chosen references.
300    pub fn finish_ref_select(&mut self) {
301        let Some(state) = self.ref_select.take() else {
302            return;
303        };
304        // An ASSEMBLY CONSTRAINT field commits through the constraint update
305        // lane (kernel session + document fold), not feature params; the shared
306        // end tail below still restores the roll + re-runs (which re-solves).
307        if state.target == RefSelectTarget::AssemblyConstraint {
308            self.assembly_commit_constraint_refs(
309                &state.feature_id,
310                &state.path,
311                &state.names,
312                state.multiple,
313            );
314            self.end_ref_select(state.restore_index);
315            return;
316        }
317        // A PMI annotation field commits into the document's pmi block (no
318        // history feature is involved); the shared tail restores + re-runs,
319        // which resolves the annotation against the fresh scene.
320        if state.target == RefSelectTarget::Pmi {
321            self.pmi_commit_refs(&state.feature_id, &state.path, &state.names, state.multiple);
322            self.end_ref_select(state.restore_index);
323            return;
324        }
325        // A SPLINE ANCHOR attachment commits through the anchor lane (the
326        // persistent spline document), then the shared tail restores + re-runs.
327        if let RefSelectTarget::SplineAnchor { index } = state.target {
328            if let Some(port) = state.names.first() {
329                self.attach_spline_anchor_no_rerun(&state.feature_id, index, port);
330            }
331            self.end_ref_select(state.restore_index);
332            return;
333        }
334        if let Some(index) = self.history.index_of(&state.feature_id) {
335            let mut params = self
336                .history
337                .feature_params(index)
338                .unwrap_or_else(|| serde_json::json!({}));
339            let value = if state.multiple {
340                serde_json::Value::Array(
341                    state
342                        .names
343                        .iter()
344                        .cloned()
345                        .map(serde_json::Value::String)
346                        .collect(),
347                )
348            } else {
349                serde_json::Value::String(state.names.first().cloned().unwrap_or_default())
350            };
351            set_json_at(&mut params, &state.path, value);
352            self.history.set_feature_params(index, params);
353        }
354        self.end_ref_select(state.restore_index);
355    }
356
357    /// Cancel: discard the running selection, clear the highlight, restore the
358    /// rolled-to step, and re-run (no param change).
359    pub fn cancel_ref_select(&mut self) {
360        if let Some(state) = self.ref_select.take() {
361            self.end_ref_select(state.restore_index);
362        }
363    }
364
365    /// Restore the rolled-to step + clear emphasis + re-run + reset the selection
366    /// filter to the all-enabled default (shared Finish/Cancel tail).
367    ///
368    /// Resetting to the DEFAULT (not a saved "prior" filter) is deliberate: the
369    /// spec baseline out of ref-select is "all kinds enabled", and `begin_ref_select`
370    /// overwrites `ref_select` without routing through here, so a stashed prior
371    /// could be a stale already-constrained filter. Living in this shared tail also
372    /// means a stray `finish_ref_select()` while inactive (early return on `take`)
373    /// never clobbers the filter.
374    fn end_ref_select(&mut self, restore_index: usize) {
375        let _ = self.emphasis.apply_json("{}");
376        self.selection_filter = SelectionFilter::default();
377        self.history.set_rollback(restore_index);
378        self.rerun_history();
379    }
380
381    /// Drive the selection highlight (`emphasis`) from the running name list so
382    /// picks light up in the viewport. A field may allow SEVERAL kinds at once
383    /// (e.g. `FACE`/`EDGE`), and a pick can be any of them, so every picked name is
384    /// fed to EVERY name-based bucket the filter permits — a name only ever matches
385    /// its own kind's entities (edge names carry the `|…[n]` topology form, faces do
386    /// not), so the cross-listing is harmless and each pick highlights correctly.
387    /// (The old code bucketed ALL names by `filter.first()` only, so an EDGE pick
388    /// under a `FACE`-first filter landed in `faces`, matched nothing, and never
389    /// showed.) VERTEX picks are position-keyed, not name-keyed, so they can't be
390    /// emphasized from a name list here.
391    pub(crate) fn sync_ref_select_emphasis(&mut self) {
392        let json = match &self.ref_select {
393            Some(state) => {
394                let names = serde_json::json!(state.names);
395                let mut selected = serde_json::Map::new();
396                for kind in &state.filter {
397                    // Case-INSENSITIVE match, mirroring `SelectionFilter::set` (which
398                    // pick-filtering uses via `from_ref_filter`). Without this, a
399                    // schema that spelled a kind non-canonically (e.g. `"Edge"`) would
400                    // let the user PICK that kind but silently skip its seed HIGHLIGHT
401                    // here — a lenient-pick / strict-highlight split. VERTEX is inert:
402                    // vertex picks carry no kernel name, so `ref_select_click` never
403                    // records one in `names` (empty-name early-return), so there is
404                    // nothing to highlight by name.
405                    let bucket = match kind.to_ascii_uppercase().as_str() {
406                        "FACE" => "faces",
407                        "EDGE" => "edges",
408                        "SOLID" => "solids",
409                        "PLANE" | "DATUM" => "datums",
410                        _ => continue, // VERTEX (never name-seeded) / unknown
411                    };
412                    selected.entry(bucket.to_string()).or_insert_with(|| names.clone());
413                }
414                // No highlightable kind in the filter → fall back to solids (the
415                // prior default) so at least solid-name picks still light up.
416                if selected.is_empty() {
417                    selected.insert("solids".to_string(), names);
418                }
419                serde_json::json!({ "selected": selected }).to_string()
420            }
421            None => "{}".to_string(),
422        };
423        let _ = self.emphasis.apply_json(&json);
424        // A picked construction PLANE/DATUM highlights through the datum-plane
425        // WIDGET, whose accent is baked at feed time (`refresh_construction_datums`
426        // reads `emphasis.selected_datums`) — so a plain `apply_json` does not
427        // re-color it. Re-feed here so a datum pick lights up (and un-lights on
428        // remove) in the modal. Harmless for non-datum fields (no datum selected →
429        // an ordinary calm-color re-feed).
430        self.refresh_construction_datums();
431        self.dirty = true;
432    }
433}
434
435/// Write `value` into `root` at `path` (object-key chain), auto-vivifying
436/// intermediate objects — the engine-side twin of the form's nested setter, used
437/// to commit a reference field's picked names back into the feature params.
438pub(crate) fn set_json_at(root: &mut serde_json::Value, path: &[String], value: serde_json::Value) {
439    if path.is_empty() {
440        *root = value;
441        return;
442    }
443    if !root.is_object() {
444        *root = serde_json::Value::Object(serde_json::Map::new());
445    }
446    let mut cur = root;
447    for seg in &path[..path.len() - 1] {
448        let obj = cur.as_object_mut().expect("object by construction");
449        cur = obj
450            .entry(seg.clone())
451            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
452        if !cur.is_object() {
453            *cur = serde_json::Value::Object(serde_json::Map::new());
454        }
455    }
456    cur.as_object_mut()
457        .expect("object by construction")
458        .insert(path[path.len() - 1].clone(), value);
459}
460
461impl EngineState {
462    /// Hover-highlight the TOP-priority pick under CSS-pixel `(x, y)` whose kind
463    /// the selection filter admits, setting it HOVERED in `emphasis` (the
464    /// renderer tints it). A miss — or a filter admitting nothing — clears the
465    /// hover. No-ops (returns `false`, no dirty) when the hovered entity is
466    /// unchanged, so a stationary pointer over the same face doesn't re-render
467    /// every frame (the `k === prevK` early-out). Returns
468    /// whether the hover state changed.
469    pub fn hover_at(&mut self, x: f64, y: f64) -> bool {
470        let kinds = self.selection_filter.enabled_kinds();
471        if kinds.is_empty() {
472            return self.clear_hover();
473        }
474        match self.pick_top_at(x, y, &kinds) {
475            Some(hit) => {
476                if self.hover_is(&hit) {
477                    return false; // unchanged — keep the frame clean.
478                }
479                self.set_hover_to_candidate(&hit);
480                true
481            }
482            None => self.clear_hover(),
483        }
484    }
485
486    /// Clear the hover highlight (pointer moved to empty space / off the
487    /// viewport). Bumps the emphasis generation + marks dirty only when a hover
488    /// was actually lit. Returns whether it changed. (Distinct from
489    /// [`clear_selection`](Self::clear_selection), which leaves hover alone.)
490    pub fn clear_hover(&mut self) -> bool {
491        let had_datums = !self.emphasis.hovered_datums.is_empty();
492        let had = !self.emphasis.hovered_solids.is_empty()
493            || !self.emphasis.hovered_faces.is_empty()
494            || !self.emphasis.hovered_edges.is_empty()
495            || !self.emphasis.hovered_vertices.is_empty()
496            || had_datums;
497        if had {
498            self.emphasis.hovered_solids.clear();
499            self.emphasis.hovered_faces.clear();
500            self.emphasis.hovered_edges.clear();
501            self.emphasis.hovered_vertices.clear();
502            self.emphasis.hovered_datums.clear();
503            self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
504            self.dirty = true;
505        }
506        // A hovered plane's accent is baked into the datum feed, so dropping the
507        // hover needs a re-feed to un-light it (the datum twin of the selection
508        // re-feed in `clear_selection`).
509        if had_datums {
510            self.refresh_construction_datums();
511        }
512        had
513    }
514
515    /// The current HOVER (not selection) as JSON
516    /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — the hover twin of
517    /// [`selection_json`](Self::selection_json) so a UI / the headed verifier can
518    /// assert that moving the pointer over a face lit the hover emphasis.
519    pub fn hovered_json(&self) -> String {
520        let solids: Vec<&String> = self.emphasis.hovered_solids.iter().collect();
521        let faces: Vec<&String> = self.emphasis.hovered_faces.iter().collect();
522        let edges: Vec<&String> = self.emphasis.hovered_edges.iter().collect();
523        let datums: Vec<&String> = self.emphasis.hovered_datums.iter().collect();
524        serde_json::json!({
525            "solids": solids,
526            "faces": faces,
527            "edges": edges,
528            "datums": datums,
529            "vertices": self.emphasis.hovered_vertices.len(),
530        })
531        .to_string()
532    }
533
534    /// TOGGLE the top admitted pick under CSS-pixel `(x, y)` in the current
535    /// selection (a **Ctrl/Cmd+click**): add it if absent, remove it if present,
536    /// leaving the rest of the selection intact (unlike [`select_top_at`], which
537    /// REPLACES). With the COMPONENT filter on, a hit on component geometry
538    /// toggles the whole component (all member solids as one unit). A miss — or
539    /// a filter admitting nothing — leaves the selection untouched (additive
540    /// mode never clears). Returns whether a hit was toggled.
541    pub fn select_toggle_at(&mut self, x: f64, y: f64) -> bool {
542        let kinds = self.selection_filter.enabled_kinds();
543        let component_on = self.selection_filter.component;
544        if kinds.is_empty() && !component_on {
545            return false;
546        }
547        let pick_kinds = if kinds.is_empty() {
548            SelectionFilter::default().enabled_kinds()
549        } else {
550            kinds.clone()
551        };
552        match self.pick_top_at(x, y, &pick_kinds) {
553            Some(hit) => {
554                // COMPONENT promotion (the select_filtered_at rule, additively):
555                // toggle the WHOLE component's member solids as one unit, so a
556                // Ctrl/Cmd+click gathers the multi-component selections the
557                // pair-constraint offers key on.
558                if component_on {
559                    if let Some(owner) = self.hit_owning_component(&hit) {
560                        self.toggle_component_selection(&owner);
561                        return true;
562                    }
563                }
564                if kinds.is_empty() {
565                    return false; // COMPONENT-only, non-component hit.
566                }
567                self.toggle_candidate(&hit);
568                true
569            }
570            None => false,
571        }
572    }
573
574    /// The RANKED, filter-respecting candidates under CSS-pixel `(x, y)` as JSON
575    /// `[{kind, name, solid, depth}]` — the "candidates under the cursor" list
576    /// (feeds the pick-list popup + the headed verifier).
577    ///
578    /// Sorted category-major in the pick-list order (VERTEX > EDGE > FACE >
579    /// PLANE > SOLID > COMPONENT), nearest (smallest depth) first within each
580    /// category — see [`candidates_filtered_at`](Self::candidates_filtered_at).
581    pub fn candidates_at(&self, x: f64, y: f64) -> String {
582        let list = self.candidates_filtered_at(x, y);
583        let out: Vec<serde_json::Value> = list
584            .iter()
585            .map(|c| {
586                serde_json::json!({
587                    "kind": self.candidate_kind_label(c),
588                    "name": c.name,
589                    "solid": c.solid,
590                    "depth": c.depth,
591                })
592            })
593            .collect();
594        serde_json::Value::Array(out).to_string()
595    }
596
597    /// The same ranked, filter-respecting candidate list as typed values (the
598    /// in-process egui pick-list popup consumes these directly, then re-hovers /
599    /// selects a chosen one via [`hover_candidate`](Self::hover_candidate) /
600    /// [`select_candidate`](Self::select_candidate) /
601    /// [`toggle_candidate`](Self::toggle_candidate)). EMPTY when the filter admits
602    /// nothing (not the `pick_filtered` "empty filter = any" case).
603    ///
604    /// The raw list is [`pick_candidates_at`](Self::pick_candidates_at), so
605    /// construction PLANE cards are ordinary entries here — a plane under other
606    /// geometry is listed (right after the faces) instead of being reachable only
607    /// on a geometry miss.
608    ///
609    /// With the filter's COMPONENT lane on, one COMPONENT entry per owning
610    /// assembly component of ANY raw hit is appended (name = component id, depth
611    /// = the component's nearest hit) — a raw hit of a filtered-OFF kind still
612    /// reaches its owning component, mirroring `select_filtered_at`'s
613    /// component-only promotion (a PLANE hit owns no component). The final list is
614    /// sorted category-major in the pick-list order
615    /// (VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest first within
616    /// each category.
617    pub fn candidates_filtered_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
618        let kinds = self.selection_filter.enabled_kinds();
619        let component_on = self.selection_filter.component;
620        if kinds.is_empty() && !component_on {
621            return Vec::new();
622        }
623        let raw = self.pick_candidates_at(x, y);
624        let mut out: Vec<pick::PickCandidate> = raw
625            .iter()
626            .filter(|c| self.candidate_admitted(&kinds, c))
627            .cloned()
628            .collect();
629        if component_on {
630            // One entry per owning component, carrying its NEAREST member hit's
631            // depth/position (raw hits are kind-major, so scan them all).
632            let mut components: Vec<pick::PickCandidate> = Vec::new();
633            for hit in &raw {
634                let Some(owner) = self.hit_owning_component(hit) else {
635                    continue;
636                };
637                match components.iter_mut().find(|c| c.name == owner) {
638                    Some(entry) => {
639                        if hit.depth < entry.depth {
640                            entry.depth = hit.depth;
641                            entry.position = hit.position;
642                        }
643                    }
644                    None => components.push(pick::PickCandidate {
645                        kind: pick::PickKind::Component,
646                        name: owner,
647                        solid: String::new(),
648                        depth: hit.depth,
649                        screen_dist: hit.screen_dist,
650                        position: hit.position,
651                    }),
652                }
653            }
654            out.extend(components);
655        }
656        // Category-major (PickKind's discriminant order IS the pick-list order),
657        // nearest first within a category — the shared ordering every pick path
658        // uses, so the appended COMPONENT rows land in the same sort.
659        super::plane_pick::sort_pick_candidates(&mut out);
660        out
661    }
662
663    /// Whether a candidate is CURRENTLY selected (drives the pick-list popup's
664    /// per-row selected state so click-toggling reads back visually).
665    pub fn candidate_is_selected(&self, candidate: &pick::PickCandidate) -> bool {
666        use crate::pick::PickKind;
667        match candidate.kind {
668            PickKind::Solid => self
669                .emphasis
670                .selected_solids
671                .contains(&self.candidate_solid_name(candidate)),
672            PickKind::Face => self.emphasis.selected_faces.contains(&candidate.name),
673            PickKind::Edge => self.emphasis.selected_edges.contains(&candidate.name),
674            PickKind::Vertex => self
675                .emphasis
676                .selected_vertices
677                .iter()
678                .any(|v| Self::vertex_ref_matches(v, candidate)),
679            PickKind::Plane => self.emphasis.selected_datums.contains(&candidate.name),
680            PickKind::Component => {
681                let members = self.component_member_solids(&candidate.name);
682                !members.is_empty()
683                    && members
684                        .iter()
685                        .all(|m| self.emphasis.selected_solids.contains(m))
686            }
687        }
688    }
689
690    /// Hover a SPECIFIC candidate (the popup entry the pointer is over) — sets it
691    /// HOVERED in `emphasis`, replacing any prior hover.
692    pub fn hover_candidate(&mut self, candidate: &pick::PickCandidate) {
693        self.set_hover_to_candidate(candidate);
694    }
695
696    /// REPLACE the selection with a specific candidate (a plain click on a popup
697    /// entry) — reuses the same bucketing as a plain viewport click.
698    pub fn select_candidate(&mut self, candidate: &pick::PickCandidate) {
699        self.set_selection_to_candidate(candidate);
700    }
701
702    /// TOGGLE a specific candidate in the selection (a Ctrl/Cmd+click on a popup
703    /// entry, or the [`select_toggle_at`](Self::select_toggle_at) hit): add if
704    /// absent, remove if present. Returns whether it is NOW selected (`true` =
705    /// added, `false` = removed). Bumps the emphasis generation + marks dirty.
706    pub fn toggle_candidate(&mut self, candidate: &pick::PickCandidate) -> bool {
707        use crate::pick::PickKind;
708        let now_selected = match candidate.kind {
709            PickKind::Solid => {
710                let name = self.candidate_solid_name(candidate);
711                if self.emphasis.selected_solids.remove(&name) {
712                    false
713                } else {
714                    self.emphasis.selected_solids.insert(name);
715                    true
716                }
717            }
718            PickKind::Face => {
719                if self.emphasis.selected_faces.remove(&candidate.name) {
720                    false
721                } else {
722                    self.emphasis.selected_faces.insert(candidate.name.clone());
723                    true
724                }
725            }
726            PickKind::Edge => {
727                if self.emphasis.selected_edges.remove(&candidate.name) {
728                    false
729                } else {
730                    self.emphasis.selected_edges.insert(candidate.name.clone());
731                    true
732                }
733            }
734            PickKind::Vertex => {
735                if let Some(index) = self
736                    .emphasis
737                    .selected_vertices
738                    .iter()
739                    .position(|v| Self::vertex_ref_matches(v, candidate))
740                {
741                    self.emphasis.selected_vertices.remove(index);
742                    false
743                } else {
744                    self.emphasis.selected_vertices.push(crate::style::VertexRef {
745                        solid: candidate.solid.clone(),
746                        position: candidate.position,
747                    });
748                    true
749                }
750            }
751            PickKind::Plane => {
752                // A construction PLANE toggles by FRAME NAME, the datum bucket the
753                // Scene-tree row / `select_datum` fill.
754                if self.emphasis.selected_datums.remove(&candidate.name) {
755                    false
756                } else {
757                    self.emphasis.selected_datums.insert(candidate.name.clone());
758                    true
759                }
760            }
761            PickKind::Component => {
762                // The whole component toggles as ONE unit (member solids), the
763                // same rule as the Ctrl/Cmd+click COMPONENT promotion.
764                let was_selected = self.candidate_is_selected(candidate);
765                self.toggle_component_selection(&candidate.name);
766                !was_selected
767            }
768        };
769        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
770        self.dirty = true;
771        // A plane's accent is baked into the datum feed, so a toggled plane only
772        // lights / un-lights after a re-feed.
773        if candidate.kind == PickKind::Plane {
774            self.refresh_construction_datums();
775        }
776        now_selected
777    }
778
779    /// Set the hover emphasis to exactly one candidate (bucketed by kind), the
780    /// hover twin of `set_selection_to_candidate`.
781    fn set_hover_to_candidate(&mut self, candidate: &pick::PickCandidate) {
782        use crate::pick::PickKind;
783        // A hovered PLANE's accent lives in the datum FEED, so the re-feed below
784        // is needed both when a plane becomes hovered and when one stops being.
785        let touches_datums =
786            !self.emphasis.hovered_datums.is_empty() || candidate.kind == PickKind::Plane;
787        self.emphasis.hovered_solids.clear();
788        self.emphasis.hovered_faces.clear();
789        self.emphasis.hovered_edges.clear();
790        self.emphasis.hovered_vertices.clear();
791        self.emphasis.hovered_datums.clear();
792        match candidate.kind {
793            PickKind::Solid => {
794                self.emphasis
795                    .hovered_solids
796                    .insert(self.candidate_solid_name(candidate));
797            }
798            PickKind::Face => {
799                self.emphasis.hovered_faces.insert(candidate.name.clone());
800            }
801            PickKind::Edge => {
802                self.emphasis.hovered_edges.insert(candidate.name.clone());
803            }
804            PickKind::Vertex => {
805                self.emphasis.hovered_vertices.push(crate::style::VertexRef {
806                    solid: candidate.solid.clone(),
807                    position: candidate.position,
808                });
809            }
810            PickKind::Plane => {
811                self.emphasis.hovered_datums.insert(candidate.name.clone());
812            }
813            PickKind::Component => {
814                // Hovering a COMPONENT entry lights every member solid.
815                for member in self.component_member_solids(&candidate.name) {
816                    self.emphasis.hovered_solids.insert(member);
817                }
818            }
819        }
820        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
821        self.dirty = true;
822        if touches_datums {
823            self.refresh_construction_datums();
824        }
825    }
826
827    /// Whether the CURRENT hover is exactly this one candidate (the `hover_at`
828    /// early-out) — a single hovered entity that matches `candidate` (for a
829    /// COMPONENT candidate: exactly its member-solid set). `pub(super)` so the
830    /// Scene-tree row hover (`scene_query.rs`) shares the same identity test for
831    /// its dedupe + its "only clear what the tree lit" rule.
832    pub(super) fn hover_is(&self, candidate: &pick::PickCandidate) -> bool {
833        use crate::pick::PickKind;
834        if candidate.kind == PickKind::Component {
835            let members = self.component_member_solids(&candidate.name);
836            return !members.is_empty()
837                && self.emphasis.hovered_faces.is_empty()
838                && self.emphasis.hovered_edges.is_empty()
839                && self.emphasis.hovered_vertices.is_empty()
840                && self.emphasis.hovered_datums.is_empty()
841                && self.emphasis.hovered_solids.len() == members.len()
842                && members.iter().all(|m| self.emphasis.hovered_solids.contains(m));
843        }
844        let total = self.emphasis.hovered_solids.len()
845            + self.emphasis.hovered_faces.len()
846            + self.emphasis.hovered_edges.len()
847            + self.emphasis.hovered_vertices.len()
848            + self.emphasis.hovered_datums.len();
849        if total != 1 {
850            return false;
851        }
852        match candidate.kind {
853            PickKind::Solid => self
854                .emphasis
855                .hovered_solids
856                .contains(&self.candidate_solid_name(candidate)),
857            PickKind::Face => self.emphasis.hovered_faces.contains(&candidate.name),
858            PickKind::Edge => self.emphasis.hovered_edges.contains(&candidate.name),
859            PickKind::Vertex => self
860                .emphasis
861                .hovered_vertices
862                .iter()
863                .any(|v| Self::vertex_ref_matches(v, candidate)),
864            PickKind::Plane => self.emphasis.hovered_datums.contains(&candidate.name),
865            PickKind::Component => false, // handled by the early return above
866        }
867    }
868
869    /// The scene name a SOLID candidate resolves to (its owning `solid`, falling
870    /// back to `name` when the pick didn't carry one) — the same rule
871    /// `set_selection_to_candidate` uses.
872    fn candidate_solid_name(&self, candidate: &pick::PickCandidate) -> String {
873        if candidate.solid.is_empty() {
874            candidate.name.clone()
875        } else {
876            candidate.solid.clone()
877        }
878    }
879
880    /// Vertex identity: same owning solid + position within the emphasis match
881    /// tolerance (vertices carry no kernel name, so they resolve by solid+pos).
882    fn vertex_ref_matches(v: &crate::style::VertexRef, candidate: &pick::PickCandidate) -> bool {
883        const TOL: f64 = 1e-4;
884        v.solid == candidate.solid
885            && (v.position[0] - candidate.position[0]).abs() <= TOL
886            && (v.position[1] - candidate.position[1]).abs() <= TOL
887            && (v.position[2] - candidate.position[2]).abs() <= TOL
888    }
889}
890
891// ---------------------------------------------------------------------------
892// Sketch display (S0) — read-only overlay of a solved SketchSession.
893//
894// Additive, self-contained: a solved sketch is fed to the general `set_overlay`
895// channel as the named groups `sketch-geometry` (lines) and `sketch-points`
896// (billboarded points), colored by solver mobility. No interaction (the tools /
897// picking / dimensions of later slices live elsewhere); this block only pushes /
898// clears the display geometry.
899// ---------------------------------------------------------------------------
900impl EngineState {
901    /// Display a solved [`crate::sketch::SketchSession`] as a read-only overlay.
902    /// The plane geometry is tessellated to world space and pushed via
903    /// [`set_overlay_json`](Self::set_overlay_json); construction dashes are sized
904    /// against the LIVE camera so they stay screen-constant.
905    pub fn set_sketch_overlay(&mut self, session: &crate::sketch::SketchSession) {
906        let world_per_pixel = self.camera.world_per_pixel();
907        let json = session.overlay_json(world_per_pixel);
908        // The overlay channel accepts our exact `{groups:[…]}` shape; a parse
909        // failure would be a programming error in the tessellator, so drop it.
910        let _ = self.set_overlay_json(&json);
911        // The dimension leaders ride in their own `sketch-dim-leaders` group (S5).
912        let _ = self.set_overlay_json(&session.dim_leaders_overlay_json(world_per_pixel));
913        // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
914        let _ = self.set_overlay_json(&session.constraint_glyphs_overlay_json(world_per_pixel));
915        // The zoom this screen-constant sizing was baked at, so the per-frame
916        // `ensure_sketch_overlay_current` re-bakes it when the camera zooms.
917        self.sketch_overlay_wpp = if world_per_pixel > 0.0 {
918            world_per_pixel
919        } else {
920            f64::MIN_POSITIVE
921        };
922    }
923
924    /// Remove the sketch overlay groups (feeding empty same-named groups upserts
925    /// them to empty, which the overlay channel treats as a removal — other
926    /// overlay groups are left untouched).
927    pub fn clear_sketch_overlay(&mut self) {
928        let _ = self.set_overlay_json(
929            "{\"groups\":[{\"name\":\"sketch-geometry\"},{\"name\":\"sketch-points\"},{\"name\":\"sketch-preview\"},{\"name\":\"sketch-dim-leaders\"},{\"name\":\"sketch-constraint-glyphs\"}]}",
930        );
931        self.sketch_overlay_wpp = 0.0;
932    }
933}
934
935// BREP private tests: f9c0d353bee5df0b