Skip to main content

brep_render/engine_state/
selection_ux.rs

1use super::*;
2
3impl EngineState {
4    /// Clear the current SELECTION (Esc): drop all selected solids/faces/edges/
5    /// vertices (hover is left untouched). Bumps the emphasis generation + marks
6    /// dirty only when something was actually cleared. Returns whether it changed.
7    pub fn clear_selection(&mut self) -> bool {
8        let had_datums = !self.emphasis.selected_datums.is_empty();
9        let had = !self.emphasis.selected_solids.is_empty()
10            || !self.emphasis.selected_faces.is_empty()
11            || !self.emphasis.selected_edges.is_empty()
12            || !self.emphasis.selected_vertices.is_empty()
13            || had_datums;
14        if had {
15            self.emphasis.selected_solids.clear();
16            self.emphasis.selected_faces.clear();
17            self.emphasis.selected_edges.clear();
18            self.emphasis.selected_vertices.clear();
19            self.emphasis.selected_datums.clear();
20            self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
21            self.dirty = true;
22        }
23        // A cleared datum drops its selection accent — re-feed the datum planes so
24        // the highlight disappears immediately (no re-run needed).
25        if had_datums {
26            self.refresh_construction_datums();
27        }
28        had
29    }
30
31    /// Select the top-priority pick under CSS-pixel `(x, y)` that the SELECTION
32    /// FILTER admits — replacing the current selection (a plain viewport click).
33    /// A miss (or a click when the filter admits nothing) clears the selection.
34    /// Marks dirty when the selection changed; returns whether something was
35    /// selected. The by-kind honoring lives in [`select_filtered_at`] in the
36    /// appended selection-filter impl block (kept separate so concurrent edits to
37    /// this primary block don't conflict).
38    pub fn select_top_at(&mut self, x: f64, y: f64) -> bool {
39        self.select_filtered_at(x, y)
40    }
41
42    /// The current SELECTION (not hover) as JSON
43    /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — lets a UI / the
44    /// headed verifier read selection state (e.g. assert Esc cleared it).
45    pub fn selection_json(&self) -> String {
46        let solids: Vec<&String> = self.emphasis.selected_solids.iter().collect();
47        let faces: Vec<&String> = self.emphasis.selected_faces.iter().collect();
48        let edges: Vec<&String> = self.emphasis.selected_edges.iter().collect();
49        let datums: Vec<&String> = self.emphasis.selected_datums.iter().collect();
50        serde_json::json!({
51            "solids": solids,
52            "faces": faces,
53            "edges": edges,
54            "datums": datums,
55            "vertices": self.emphasis.selected_vertices.len(),
56        })
57        .to_string()
58    }
59
60    // --- Reference-selection widget (the engine-native picker, #42) --------
61    //
62    // A feature-dialog reference field activates this MODAL: the UI hides the
63    // rest of itself and shows only the widget's list + Finish/Cancel; the engine
64    // rolls to the pre-feature "before" state, highlights the running selection
65    // (via `emphasis`), and each click in the viewport type-constrained-picks a
66    // name into the list. Finish writes the names into the feature params (via
67    // the same `update_feature_params` path) and restores; Cancel discards. The
68    // list of names is the whole state — no event-on-object wiring.
69
70    /// True while the reference-selection modal is active (the shell hides the
71    /// rest of the UI and the viewport routes clicks to picking).
72    pub fn ref_select_active(&self) -> bool {
73        self.ref_select.is_some()
74    }
75
76    /// Enter reference-selection mode for feature `feature_id`'s param at `path`.
77    /// Seeds the running list from `seed_names` (the field's current value), rolls
78    /// the model to the pre-feature "before" state (the step just before the
79    /// edited feature ran), and highlights the seeded names. `filter` constrains
80    /// the pick kind (`["SOLID"]`, `["FACE"]`, …); `multiple` allows a list.
81    pub fn begin_ref_select(
82        &mut self,
83        feature_id: &str,
84        path: Vec<String>,
85        label: String,
86        filter: Vec<String>,
87        multiple: bool,
88        seed_names: Vec<String>,
89    ) {
90        let restore_index = self.history.rollback();
91        // "Before" = the step just before the edited feature ran, so the user
92        // picks against the correct geometry. Clamp at 0 for the first feature.
93        let before = self
94            .history
95            .index_of(feature_id)
96            .map(|i| i.saturating_sub(1))
97            .unwrap_or(restore_index);
98        // Constrain the GLOBAL selection filter to exactly the kinds this field
99        // permits: this drives BOTH click-picking (`ref_select_click`) AND
100        // hover-highlighting (`hover_at`, which reads `selection_filter`), so only
101        // the allowed kinds highlight/select while the picker is active. An
102        // absent/construction-only field filter maps to all-enabled (see
103        // `from_ref_filter`). Restored to the all-enabled default on finish/cancel
104        // (`end_ref_select`).
105        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
106        self.ref_select = Some(RefSelectState {
107            feature_id: feature_id.to_string(),
108            path,
109            label,
110            filter,
111            multiple,
112            names: seed_names,
113            restore_index,
114        });
115        // Roll to the before-state (re-runs + marks dirty), then light up the seed.
116        self.history.set_rollback(before);
117        self.rerun_history();
118        self.sync_ref_select_emphasis();
119    }
120
121    /// The running list of picked names (empty when not active) — the modal UI
122    /// reads this back to draw its one-per-line list.
123    pub fn ref_select_names(&self) -> Vec<String> {
124        self.ref_select
125            .as_ref()
126            .map(|r| r.names.clone())
127            .unwrap_or_default()
128    }
129
130    /// The active field's label (for the modal heading), or empty.
131    pub fn ref_select_label(&self) -> String {
132        self.ref_select
133            .as_ref()
134            .map(|r| r.label.clone())
135            .unwrap_or_default()
136    }
137
138    /// A one-line summary of the active field for the modal heading:
139    /// `"Tool solids (SOLID, multiple)"`.
140    pub fn ref_select_prompt(&self) -> String {
141        match &self.ref_select {
142            Some(r) => format!(
143                "{} ({}{})",
144                r.label,
145                r.filter.join("/"),
146                if r.multiple { ", multiple" } else { "" }
147            ),
148            None => String::new(),
149        }
150    }
151
152    /// A viewport click while active: type-constrained-pick the nearest allowed
153    /// hit under CSS-pixel `(x, y)` and add its name to the running list (single
154    /// fields replace; multiple fields append, de-duplicated). Re-lights the
155    /// highlight. No-op on a miss / an empty (unnamed) hit.
156    pub fn ref_select_click(&mut self, x: f64, y: f64) {
157        let Some(state) = self.ref_select.as_ref() else {
158            return;
159        };
160        let filter = state.filter.clone();
161        let multiple = state.multiple;
162        let options = self.pick_options();
163        let Some(hit) = pick::pick_filtered(&self.scene, &self.camera, x, y, &options, &filter)
164        else {
165            return;
166        };
167        if hit.name.trim().is_empty() {
168            return; // e.g. a vertex (no kernel name) — nothing to record by name.
169        }
170        let state = self.ref_select.as_mut().expect("active by guard above");
171        if multiple {
172            if !state.names.iter().any(|n| n == &hit.name) {
173                state.names.push(hit.name);
174            }
175        } else {
176            state.names = vec![hit.name];
177        }
178        self.sync_ref_select_emphasis();
179    }
180
181    /// Remove the name at `index` from the running list (the modal's per-line X).
182    pub fn ref_select_remove(&mut self, index: usize) {
183        if let Some(state) = self.ref_select.as_mut() {
184            if index < state.names.len() {
185                state.names.remove(index);
186            }
187        }
188        self.sync_ref_select_emphasis();
189    }
190
191    /// Finish: write the running names into the edited feature's params at the
192    /// field path, restore the rolled-to step, clear the highlight, and re-run so
193    /// the feature rebuilds with the chosen references.
194    pub fn finish_ref_select(&mut self) {
195        let Some(state) = self.ref_select.take() else {
196            return;
197        };
198        if let Some(index) = self.history.index_of(&state.feature_id) {
199            let mut params = self
200                .history
201                .feature_params(index)
202                .unwrap_or_else(|| serde_json::json!({}));
203            let value = if state.multiple {
204                serde_json::Value::Array(
205                    state
206                        .names
207                        .iter()
208                        .cloned()
209                        .map(serde_json::Value::String)
210                        .collect(),
211                )
212            } else {
213                serde_json::Value::String(state.names.first().cloned().unwrap_or_default())
214            };
215            set_json_at(&mut params, &state.path, value);
216            self.history.set_feature_params(index, params);
217        }
218        self.end_ref_select(state.restore_index);
219    }
220
221    /// Cancel: discard the running selection, clear the highlight, restore the
222    /// rolled-to step, and re-run (no param change).
223    pub fn cancel_ref_select(&mut self) {
224        if let Some(state) = self.ref_select.take() {
225            self.end_ref_select(state.restore_index);
226        }
227    }
228
229    /// Restore the rolled-to step + clear emphasis + re-run + reset the selection
230    /// filter to the all-enabled default (shared Finish/Cancel tail).
231    ///
232    /// Resetting to the DEFAULT (not a saved "prior" filter) is deliberate: the
233    /// spec baseline out of ref-select is "all kinds enabled", and `begin_ref_select`
234    /// overwrites `ref_select` without routing through here, so a stashed prior
235    /// could be a stale already-constrained filter. Living in this shared tail also
236    /// means a stray `finish_ref_select()` while inactive (early return on `take`)
237    /// never clobbers the filter.
238    fn end_ref_select(&mut self, restore_index: usize) {
239        let _ = self.emphasis.apply_json("{}");
240        self.selection_filter = SelectionFilter::default();
241        self.history.set_rollback(restore_index);
242        self.rerun_history();
243    }
244
245    /// Drive the selection highlight (`emphasis`) from the running name list so
246    /// picks light up in the viewport. A field may allow SEVERAL kinds at once
247    /// (e.g. `FACE`/`EDGE`), and a pick can be any of them, so every picked name is
248    /// fed to EVERY name-based bucket the filter permits — a name only ever matches
249    /// its own kind's entities (edge names carry the `|…[n]` topology form, faces do
250    /// not), so the cross-listing is harmless and each pick highlights correctly.
251    /// (The old code bucketed ALL names by `filter.first()` only, so an EDGE pick
252    /// under a `FACE`-first filter landed in `faces`, matched nothing, and never
253    /// showed.) VERTEX picks are position-keyed, not name-keyed, so they can't be
254    /// emphasized from a name list here.
255    fn sync_ref_select_emphasis(&mut self) {
256        let json = match &self.ref_select {
257            Some(state) => {
258                let names = serde_json::json!(state.names);
259                let mut selected = serde_json::Map::new();
260                for kind in &state.filter {
261                    let bucket = match kind.as_str() {
262                        "FACE" => "faces",
263                        "EDGE" => "edges",
264                        "SOLID" => "solids",
265                        "PLANE" | "DATUM" => "datums",
266                        _ => continue, // VERTEX / unknown: no name-based highlight
267                    };
268                    selected.entry(bucket.to_string()).or_insert_with(|| names.clone());
269                }
270                // No highlightable kind in the filter → fall back to solids (the
271                // prior default) so at least solid-name picks still light up.
272                if selected.is_empty() {
273                    selected.insert("solids".to_string(), names);
274                }
275                serde_json::json!({ "selected": selected }).to_string()
276            }
277            None => "{}".to_string(),
278        };
279        let _ = self.emphasis.apply_json(&json);
280        self.dirty = true;
281    }
282}
283
284/// Write `value` into `root` at `path` (object-key chain), auto-vivifying
285/// intermediate objects — the engine-side twin of the form's nested setter, used
286/// to commit a reference field's picked names back into the feature params.
287fn set_json_at(root: &mut serde_json::Value, path: &[String], value: serde_json::Value) {
288    if path.is_empty() {
289        *root = value;
290        return;
291    }
292    if !root.is_object() {
293        *root = serde_json::Value::Object(serde_json::Map::new());
294    }
295    let mut cur = root;
296    for seg in &path[..path.len() - 1] {
297        let obj = cur.as_object_mut().expect("object by construction");
298        cur = obj
299            .entry(seg.clone())
300            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
301        if !cur.is_object() {
302            *cur = serde_json::Value::Object(serde_json::Map::new());
303        }
304    }
305    cur.as_object_mut()
306        .expect("object by construction")
307        .insert(path[path.len() - 1].clone(), value);
308}
309
310impl EngineState {
311    /// Hover-highlight the TOP-priority pick under CSS-pixel `(x, y)` whose kind
312    /// the selection filter admits, setting it HOVERED in `emphasis` (the
313    /// renderer tints it). A miss — or a filter admitting nothing — clears the
314    /// hover. No-ops (returns `false`, no dirty) when the hovered entity is
315    /// unchanged, so a stationary pointer over the same face doesn't re-render
316    /// every frame (the `k === prevK` early-out). Returns
317    /// whether the hover state changed.
318    pub fn hover_at(&mut self, x: f64, y: f64) -> bool {
319        let kinds = self.selection_filter.enabled_kinds();
320        if kinds.is_empty() {
321            return self.clear_hover();
322        }
323        let options = self.pick_options();
324        match pick::pick_filtered(&self.scene, &self.camera, x, y, &options, &kinds) {
325            Some(hit) => {
326                if self.hover_is(&hit) {
327                    return false; // unchanged — keep the frame clean.
328                }
329                self.set_hover_to_candidate(&hit);
330                true
331            }
332            None => self.clear_hover(),
333        }
334    }
335
336    /// Clear the hover highlight (pointer moved to empty space / off the
337    /// viewport). Bumps the emphasis generation + marks dirty only when a hover
338    /// was actually lit. Returns whether it changed. (Distinct from
339    /// [`clear_selection`](Self::clear_selection), which leaves hover alone.)
340    pub fn clear_hover(&mut self) -> bool {
341        let had = !self.emphasis.hovered_solids.is_empty()
342            || !self.emphasis.hovered_faces.is_empty()
343            || !self.emphasis.hovered_edges.is_empty()
344            || !self.emphasis.hovered_vertices.is_empty();
345        if had {
346            self.emphasis.hovered_solids.clear();
347            self.emphasis.hovered_faces.clear();
348            self.emphasis.hovered_edges.clear();
349            self.emphasis.hovered_vertices.clear();
350            self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
351            self.dirty = true;
352        }
353        had
354    }
355
356    /// The current HOVER (not selection) as JSON
357    /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — the hover twin of
358    /// [`selection_json`](Self::selection_json) so a UI / the headed verifier can
359    /// assert that moving the pointer over a face lit the hover emphasis.
360    pub fn hovered_json(&self) -> String {
361        let solids: Vec<&String> = self.emphasis.hovered_solids.iter().collect();
362        let faces: Vec<&String> = self.emphasis.hovered_faces.iter().collect();
363        let edges: Vec<&String> = self.emphasis.hovered_edges.iter().collect();
364        serde_json::json!({
365            "solids": solids,
366            "faces": faces,
367            "edges": edges,
368            "vertices": self.emphasis.hovered_vertices.len(),
369        })
370        .to_string()
371    }
372
373    /// TOGGLE the top admitted pick under CSS-pixel `(x, y)` in the current
374    /// selection (a **Ctrl/Cmd+click**): add it if absent, remove it if present,
375    /// leaving the rest of the selection intact (unlike [`select_top_at`], which
376    /// REPLACES). A miss — or a filter admitting nothing — leaves the selection
377    /// untouched (additive mode never clears). Returns whether a hit was toggled.
378    pub fn select_toggle_at(&mut self, x: f64, y: f64) -> bool {
379        let kinds = self.selection_filter.enabled_kinds();
380        if kinds.is_empty() {
381            return false;
382        }
383        let options = self.pick_options();
384        match pick::pick_filtered(&self.scene, &self.camera, x, y, &options, &kinds) {
385            Some(hit) => {
386                self.toggle_candidate(&hit);
387                true
388            }
389            None => false,
390        }
391    }
392
393    /// The RANKED, filter-respecting candidates under CSS-pixel `(x, y)` as JSON
394    /// `[{kind, name, solid, depth}]` — the "candidates under the cursor" list
395    /// (feeds the disambiguation popup + the headed verifier).
396    ///
397    /// Sorted EXACTLY as the previous app sorted its pick list
398    /// (kind PRIORITY first, then depth) — `pick::pick`
399    /// already ranks by `(kind, depth, screen_dist)` and appends the owning SOLID
400    /// entries at the very end, so filtering by the enabled kinds preserves that
401    /// order.
402    pub fn candidates_at(&self, x: f64, y: f64) -> String {
403        let list = self.candidates_filtered_at(x, y);
404        let out: Vec<serde_json::Value> = list
405            .iter()
406            .map(|c| {
407                serde_json::json!({
408                    "kind": c.kind.as_str(),
409                    "name": c.name,
410                    "solid": c.solid,
411                    "depth": c.depth,
412                })
413            })
414            .collect();
415        serde_json::Value::Array(out).to_string()
416    }
417
418    /// The same ranked, filter-respecting candidate list as typed values (the
419    /// in-process egui popup consumes these directly, then re-hovers / selects a
420    /// chosen one via [`hover_candidate`](Self::hover_candidate) /
421    /// [`select_candidate`](Self::select_candidate) /
422    /// [`toggle_candidate`](Self::toggle_candidate)). EMPTY when the filter admits
423    /// nothing (not the `pick_filtered` "empty filter = any" case).
424    pub fn candidates_filtered_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
425        let kinds = self.selection_filter.enabled_kinds();
426        if kinds.is_empty() {
427            return Vec::new();
428        }
429        pick::pick(&self.scene, &self.camera, x, y, &self.pick_options())
430            .into_iter()
431            .filter(|c| kinds.iter().any(|k| k.eq_ignore_ascii_case(c.kind.as_str())))
432            .collect()
433    }
434
435    /// Hover a SPECIFIC candidate (the popup entry the pointer is over) — sets it
436    /// HOVERED in `emphasis`, replacing any prior hover.
437    pub fn hover_candidate(&mut self, candidate: &pick::PickCandidate) {
438        self.set_hover_to_candidate(candidate);
439    }
440
441    /// REPLACE the selection with a specific candidate (a plain click on a popup
442    /// entry) — reuses the same bucketing as a plain viewport click.
443    pub fn select_candidate(&mut self, candidate: &pick::PickCandidate) {
444        self.set_selection_to_candidate(candidate);
445    }
446
447    /// TOGGLE a specific candidate in the selection (a Ctrl/Cmd+click on a popup
448    /// entry, or the [`select_toggle_at`](Self::select_toggle_at) hit): add if
449    /// absent, remove if present. Returns whether it is NOW selected (`true` =
450    /// added, `false` = removed). Bumps the emphasis generation + marks dirty.
451    pub fn toggle_candidate(&mut self, candidate: &pick::PickCandidate) -> bool {
452        use crate::pick::PickKind;
453        let now_selected = match candidate.kind {
454            PickKind::Solid => {
455                let name = self.candidate_solid_name(candidate);
456                if self.emphasis.selected_solids.remove(&name) {
457                    false
458                } else {
459                    self.emphasis.selected_solids.insert(name);
460                    true
461                }
462            }
463            PickKind::Face => {
464                if self.emphasis.selected_faces.remove(&candidate.name) {
465                    false
466                } else {
467                    self.emphasis.selected_faces.insert(candidate.name.clone());
468                    true
469                }
470            }
471            PickKind::Edge => {
472                if self.emphasis.selected_edges.remove(&candidate.name) {
473                    false
474                } else {
475                    self.emphasis.selected_edges.insert(candidate.name.clone());
476                    true
477                }
478            }
479            PickKind::Vertex => {
480                if let Some(index) = self
481                    .emphasis
482                    .selected_vertices
483                    .iter()
484                    .position(|v| Self::vertex_ref_matches(v, candidate))
485                {
486                    self.emphasis.selected_vertices.remove(index);
487                    false
488                } else {
489                    self.emphasis.selected_vertices.push(crate::style::VertexRef {
490                        solid: candidate.solid.clone(),
491                        position: candidate.position,
492                    });
493                    true
494                }
495            }
496        };
497        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
498        self.dirty = true;
499        now_selected
500    }
501
502    /// Set the hover emphasis to exactly one candidate (bucketed by kind), the
503    /// hover twin of `set_selection_to_candidate`.
504    fn set_hover_to_candidate(&mut self, candidate: &pick::PickCandidate) {
505        use crate::pick::PickKind;
506        self.emphasis.hovered_solids.clear();
507        self.emphasis.hovered_faces.clear();
508        self.emphasis.hovered_edges.clear();
509        self.emphasis.hovered_vertices.clear();
510        match candidate.kind {
511            PickKind::Solid => {
512                self.emphasis
513                    .hovered_solids
514                    .insert(self.candidate_solid_name(candidate));
515            }
516            PickKind::Face => {
517                self.emphasis.hovered_faces.insert(candidate.name.clone());
518            }
519            PickKind::Edge => {
520                self.emphasis.hovered_edges.insert(candidate.name.clone());
521            }
522            PickKind::Vertex => {
523                self.emphasis.hovered_vertices.push(crate::style::VertexRef {
524                    solid: candidate.solid.clone(),
525                    position: candidate.position,
526                });
527            }
528        }
529        self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
530        self.dirty = true;
531    }
532
533    /// Whether the CURRENT hover is exactly this one candidate (the `hover_at`
534    /// early-out) — a single hovered entity that matches `candidate`.
535    fn hover_is(&self, candidate: &pick::PickCandidate) -> bool {
536        use crate::pick::PickKind;
537        let total = self.emphasis.hovered_solids.len()
538            + self.emphasis.hovered_faces.len()
539            + self.emphasis.hovered_edges.len()
540            + self.emphasis.hovered_vertices.len();
541        if total != 1 {
542            return false;
543        }
544        match candidate.kind {
545            PickKind::Solid => self
546                .emphasis
547                .hovered_solids
548                .contains(&self.candidate_solid_name(candidate)),
549            PickKind::Face => self.emphasis.hovered_faces.contains(&candidate.name),
550            PickKind::Edge => self.emphasis.hovered_edges.contains(&candidate.name),
551            PickKind::Vertex => self
552                .emphasis
553                .hovered_vertices
554                .iter()
555                .any(|v| Self::vertex_ref_matches(v, candidate)),
556        }
557    }
558
559    /// The scene name a SOLID candidate resolves to (its owning `solid`, falling
560    /// back to `name` when the pick didn't carry one) — the same rule
561    /// `set_selection_to_candidate` uses.
562    fn candidate_solid_name(&self, candidate: &pick::PickCandidate) -> String {
563        if candidate.solid.is_empty() {
564            candidate.name.clone()
565        } else {
566            candidate.solid.clone()
567        }
568    }
569
570    /// Vertex identity: same owning solid + position within the emphasis match
571    /// tolerance (vertices carry no kernel name, so they resolve by solid+pos).
572    fn vertex_ref_matches(v: &crate::style::VertexRef, candidate: &pick::PickCandidate) -> bool {
573        const TOL: f64 = 1e-4;
574        v.solid == candidate.solid
575            && (v.position[0] - candidate.position[0]).abs() <= TOL
576            && (v.position[1] - candidate.position[1]).abs() <= TOL
577            && (v.position[2] - candidate.position[2]).abs() <= TOL
578    }
579}
580
581// ---------------------------------------------------------------------------
582// Sketch display (S0) — read-only overlay of a solved SketchSession.
583//
584// Additive, self-contained: a solved sketch is fed to the general `set_overlay`
585// channel as the named groups `sketch-geometry` (lines) and `sketch-points`
586// (billboarded points), colored by solver mobility. No interaction (the tools /
587// picking / dimensions of later slices live elsewhere); this block only pushes /
588// clears the display geometry.
589// ---------------------------------------------------------------------------
590impl EngineState {
591    /// Display a solved [`crate::sketch::SketchSession`] as a read-only overlay.
592    /// The plane geometry is tessellated to world space and pushed via
593    /// [`set_overlay_json`](Self::set_overlay_json); construction dashes are sized
594    /// against the LIVE camera so they stay screen-constant.
595    pub fn set_sketch_overlay(&mut self, session: &crate::sketch::SketchSession) {
596        let world_per_pixel = self.camera.world_per_pixel();
597        let json = session.overlay_json(world_per_pixel);
598        // The overlay channel accepts our exact `{groups:[…]}` shape; a parse
599        // failure would be a programming error in the tessellator, so drop it.
600        let _ = self.set_overlay_json(&json);
601        // The dimension leaders ride in their own `sketch-dim-leaders` group (S5).
602        let _ = self.set_overlay_json(&session.dim_leaders_overlay_json(world_per_pixel));
603        // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
604        let _ = self.set_overlay_json(&session.constraint_glyphs_overlay_json(world_per_pixel));
605    }
606
607    /// Remove the sketch overlay groups (feeding empty same-named groups upserts
608    /// them to empty, which the overlay channel treats as a removal — other
609    /// overlay groups are left untouched).
610    pub fn clear_sketch_overlay(&mut self) {
611        let _ = self.set_overlay_json(
612            "{\"groups\":[{\"name\":\"sketch-geometry\"},{\"name\":\"sketch-points\"},{\"name\":\"sketch-preview\"},{\"name\":\"sketch-dim-leaders\"},{\"name\":\"sketch-constraint-glyphs\"}]}",
613        );
614    }
615}
616
617#[cfg(test)]
618mod selection_ux_tests {
619    use super::*;
620
621    fn cube(name: &str, size: f64) -> String {
622        serde_json::json!({
623            "expressions": "",
624            "configurator": {},
625            "features": [{
626                "type": "P.CU",
627                "inputParams": {
628                    "id": name,
629                    "sizeX": size, "sizeY": size, "sizeZ": size,
630                    "transform": {
631                        "position": [0.0, 0.0, 0.0],
632                        "rotationEuler": [0.0, 0.0, 0.0],
633                        "scale": [1.0, 1.0, 1.0]
634                    },
635                    "boolean": { "targets": [], "operation": "NONE" }
636                },
637                "persistentData": {}
638            }]
639        })
640        .to_string()
641    }
642
643    /// A cube filling `0..size` framed straight-on down -Z, so the viewport
644    /// centre `(400, 300)` lands on a face centre — a ray that pierces BOTH the
645    /// near (+Z) and far (-Z) faces, i.e. an overlapping spot with two FACE
646    /// candidates under one pixel.
647    fn front_cube(size: f64) -> EngineState {
648        let mut engine = EngineState::new();
649        engine.run_history_json(&cube("UxCube", size), None).unwrap();
650        engine.resize(800.0, 600.0);
651        engine.camera.eye = [size / 2.0, size / 2.0, size * 6.0];
652        engine.camera.target = [size / 2.0, size / 2.0, size / 2.0];
653        engine.camera.up = [0.0, 1.0, 0.0];
654        engine.camera.projection = crate::view::Projection::Orthographic { half_height: size };
655        engine
656    }
657
658    fn face_filter(engine: &mut EngineState) {
659        engine.set_selection_filter(SelectionFilter {
660            solid: false,
661            face: true,
662            edge: false,
663            vertex: false,
664        });
665    }
666
667    #[test]
668    fn candidates_at_respect_filter_and_the_kind_then_depth_sort() {
669        let mut engine = front_cube(10.0);
670
671        // FACE-only: the overlapping centre pixel lists BOTH faces (near + far),
672        // both FACE, sorted by ascending depth (the final sort: kind priority
673        // then depth). No SOLID entry — the filter drops it.
674        face_filter(&mut engine);
675        let v: serde_json::Value =
676            serde_json::from_str(&engine.candidates_at(400.0, 300.0)).unwrap();
677        let arr = v.as_array().unwrap();
678        assert!(arr.len() >= 2, "two faces under the pixel: {arr:?}");
679        assert!(arr.iter().all(|c| c["kind"] == "FACE"), "faces only: {arr:?}");
680        let depths: Vec<f64> = arr.iter().map(|c| c["depth"].as_f64().unwrap()).collect();
681        assert!(
682            depths.windows(2).all(|w| w[0] <= w[1]),
683            "sorted by ascending depth (near face first): {depths:?}"
684        );
685
686        // SOLID-only: the SAME pixel lists exactly the owning solid.
687        engine.set_selection_filter(SelectionFilter {
688            solid: true,
689            face: false,
690            edge: false,
691            vertex: false,
692        });
693        let v: serde_json::Value =
694            serde_json::from_str(&engine.candidates_at(400.0, 300.0)).unwrap();
695        let arr = v.as_array().unwrap();
696        assert_eq!(arr.len(), 1, "one solid: {arr:?}");
697        assert_eq!(arr[0]["kind"], "SOLID");
698        assert_eq!(arr[0]["name"], "UxCube");
699
700        // Nothing enabled → an empty candidate list.
701        engine.set_selection_filter(SelectionFilter {
702            solid: false,
703            face: false,
704            edge: false,
705            vertex: false,
706        });
707        let v: serde_json::Value =
708            serde_json::from_str(&engine.candidates_at(400.0, 300.0)).unwrap();
709        assert!(v.as_array().unwrap().is_empty(), "no kind admitted → empty");
710    }
711
712    #[test]
713    fn toggle_candidate_adds_then_removes_and_multi_selects() {
714        let mut engine = front_cube(10.0);
715        face_filter(&mut engine);
716        let cands = engine.candidates_filtered_at(400.0, 300.0);
717        assert!(cands.len() >= 2, "need two overlapping faces");
718        let near = cands[0].clone();
719        let far = cands[1].clone();
720        assert_ne!(near.name, far.name, "distinct faces");
721
722        // Toggling two distinct faces ADDS both → a selection of size 2.
723        assert!(engine.toggle_candidate(&near), "near added");
724        assert!(engine.toggle_candidate(&far), "far added");
725        assert_eq!(engine.emphasis.selected_faces.len(), 2, "both faces selected");
726
727        // Toggling the near face again REMOVES it → back to 1, the far face kept.
728        assert!(!engine.toggle_candidate(&near), "near removed");
729        assert_eq!(engine.emphasis.selected_faces.len(), 1);
730        assert!(engine.emphasis.selected_faces.contains(&far.name));
731    }
732
733    #[test]
734    fn select_toggle_at_adds_then_removes_the_top_hit() {
735        let mut engine = front_cube(10.0);
736        face_filter(&mut engine);
737        // First Ctrl+click at the centre adds the near face.
738        assert!(engine.select_toggle_at(400.0, 300.0));
739        assert_eq!(engine.emphasis.selected_faces.len(), 1);
740        // A second Ctrl+click at the SAME spot toggles that same top hit off.
741        assert!(engine.select_toggle_at(400.0, 300.0));
742        assert_eq!(engine.emphasis.selected_faces.len(), 0);
743        // A Ctrl+click on empty space is a no-op (never clears the selection).
744        engine.set_selection_filter(SelectionFilter {
745            solid: true,
746            face: false,
747            edge: false,
748            vertex: false,
749        });
750        assert!(engine.select_toggle_at(400.0, 300.0), "solid added");
751        assert!(!engine.select_toggle_at(10.0, 10.0), "miss is a no-op");
752        assert!(engine.has_selection(), "miss left the selection intact");
753    }
754
755    #[test]
756    fn hover_at_lights_the_top_face_and_clears() {
757        let mut engine = front_cube(10.0);
758        face_filter(&mut engine);
759        // Moving over the face lights exactly one hovered face.
760        assert!(engine.hover_at(400.0, 300.0), "hover set");
761        assert_eq!(engine.emphasis.hovered_faces.len(), 1);
762        let lit: String = engine.emphasis.hovered_faces.iter().next().unwrap().clone();
763        // Re-hovering the SAME entity does not churn the frame.
764        assert!(!engine.hover_at(400.0, 300.0), "unchanged hover → no change");
765        assert_eq!(engine.emphasis.hovered_faces.iter().next().unwrap(), &lit);
766        // Moving onto empty space clears the hover.
767        assert!(engine.hover_at(10.0, 10.0), "miss clears the prior hover");
768        assert!(engine.emphasis.hovered_faces.is_empty());
769        // Hover does NOT touch the selection set.
770        assert!(!engine.has_selection());
771    }
772
773    #[test]
774    fn candidate_hover_and_select_target_the_exact_entity() {
775        let mut engine = front_cube(10.0);
776        face_filter(&mut engine);
777        let cands = engine.candidates_filtered_at(400.0, 300.0);
778        let far = cands[1].clone();
779        // Hovering the SECOND (far) candidate lights that exact face, not the near one.
780        engine.hover_candidate(&far);
781        assert!(engine.emphasis.hovered_faces.contains(&far.name));
782        assert_eq!(engine.emphasis.hovered_faces.len(), 1);
783        // Selecting it replaces the selection with exactly that face.
784        engine.select_candidate(&far);
785        assert_eq!(engine.emphasis.selected_faces.len(), 1);
786        assert!(engine.emphasis.selected_faces.contains(&far.name));
787    }
788
789    /// NAME-based selection re-attaches across a feature re-run: after editing
790    /// the feature (new geometry, same deterministic kernel names) the selected
791    /// face name still exists in the rebuilt scene and the emphasis still
792    /// resolves it — the selection isn't dropped by the rebuild.
793    #[test]
794    fn selection_reattaches_across_feature_reruns() {
795        let mut engine = front_cube(10.0);
796        face_filter(&mut engine);
797        assert!(engine.select_top_at(400.0, 300.0), "selected the near face");
798        let selected: String = engine.emphasis.selected_faces.iter().next().unwrap().clone();
799
800        // "Edit the feature": re-run the history with a changed size (the same
801        // feature id → the same deterministic entity names on new geometry).
802        engine.run_history_json(&cube("UxCube", 12.0), None).unwrap();
803
804        assert!(
805            engine.emphasis.selected_faces.contains(&selected),
806            "selection survives the re-run"
807        );
808        let names: Vec<String> = engine
809            .scene
810            .solids()
811            .iter()
812            .flat_map(|s| s.faces.iter().map(|f| f.name.clone()))
813            .collect();
814        assert!(
815            names.contains(&selected),
816            "the selected name re-attaches to the rebuilt scene: {selected} not in {names:?}"
817        );
818        // And the render-side emphasis lookup still lights it.
819        assert_eq!(
820            engine.emphasis.face_state("UxCube", &selected),
821            crate::style::EmphasisState::Selected,
822            "emphasis resolves the re-attached face"
823        );
824    }
825}
826
827// ===========================================================================
828// Sketch mode (S1) — enter / exit / new an engine-native sketch edit.
829//
830// A SKETCH feature (`type "S"`) persists its editable state in
831// `persistentData.sketch` (`{points, geometries, constraints}` — a `SketchDoc`)
832// and its plane in `persistentData.basis` (a `PlaneFrame`). Entering sketch mode
833// is fully HEADLESS: it reads that persisted state straight off the history JSON
834// (no kernel SceneMap needed), rolls the model to the step BEFORE the sketch (the
835// natural backdrop), orients the camera onto the plane, and holds a live solved
836// [`crate::sketch::SketchSession`]. Exit writes the (possibly edited) doc back to
837// `persistentData.sketch` (commit) or discards it — deleting the feature outright
838// when it was a brand-new, never-committed sketch (cancel). The camera + rolled-to
839// step are snapshotted on enter and restored on exit.
840//
841// This mirrors the reference-selection modal's enter/roll-before/finish/restore
842// shape; kept in ONE appended block so concurrent edits to the primary impl land
843// clean.
844// ===========================================================================
845