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