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