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