brep_render/engine_state/selection_ux.rs
1use super::*;
2
3impl EngineState {
4 /// Clear the current SELECTION (Esc): drop all selected solids/faces/edges/
5 /// vertices (hover is left untouched). Bumps the emphasis generation + marks
6 /// dirty only when something was actually cleared. Returns whether it changed.
7 pub fn clear_selection(&mut self) -> bool {
8 // The viewport-selected CONSTRAINT (label click) clears with the rest.
9 let had_constraint = self.selected_constraint.is_some();
10 self.constraint_deselect();
11 let had_datums = !self.emphasis.selected_datums.is_empty();
12 let had = had_constraint
13 || !self.emphasis.selected_solids.is_empty()
14 || !self.emphasis.selected_faces.is_empty()
15 || !self.emphasis.selected_edges.is_empty()
16 || !self.emphasis.selected_vertices.is_empty()
17 || had_datums;
18 if had {
19 self.emphasis.selected_solids.clear();
20 self.emphasis.selected_faces.clear();
21 self.emphasis.selected_edges.clear();
22 self.emphasis.selected_vertices.clear();
23 self.emphasis.selected_datums.clear();
24 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
25 self.dirty = true;
26 }
27 // A cleared datum drops its selection accent — re-feed the datum planes so
28 // the highlight disappears immediately (no re-run needed).
29 if had_datums {
30 self.refresh_construction_datums();
31 }
32 had
33 }
34
35 /// Select the top-priority pick under CSS-pixel `(x, y)` that the SELECTION
36 /// FILTER admits — replacing the current selection (a plain viewport click).
37 /// A miss (or a click when the filter admits nothing) clears the selection.
38 /// Marks dirty when the selection changed; returns whether something was
39 /// selected. The by-kind honoring lives in [`select_filtered_at`] in the
40 /// appended selection-filter impl block (kept separate so concurrent edits to
41 /// this primary block don't conflict).
42 pub fn select_top_at(&mut self, x: f64, y: f64) -> bool {
43 self.select_filtered_at(x, y)
44 }
45
46 /// The current SELECTION (not hover) as JSON
47 /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — lets a UI / the
48 /// headed verifier read selection state (e.g. assert Esc cleared it).
49 pub fn selection_json(&self) -> String {
50 let solids: Vec<&String> = self.emphasis.selected_solids.iter().collect();
51 let faces: Vec<&String> = self.emphasis.selected_faces.iter().collect();
52 let edges: Vec<&String> = self.emphasis.selected_edges.iter().collect();
53 let datums: Vec<&String> = self.emphasis.selected_datums.iter().collect();
54 serde_json::json!({
55 "solids": solids,
56 "faces": faces,
57 "edges": edges,
58 "datums": datums,
59 "vertices": self.emphasis.selected_vertices.len(),
60 })
61 .to_string()
62 }
63
64 // --- Reference-selection widget (the engine-native picker, #42) --------
65 //
66 // A feature-dialog reference field activates this MODAL: the UI hides the
67 // rest of itself and shows only the widget's list + Finish/Cancel; the engine
68 // rolls to the pre-feature "before" state, highlights the running selection
69 // (via `emphasis`), and each click in the viewport type-constrained-picks a
70 // name into the list. Finish writes the names into the feature params (via
71 // the same `update_feature_params` path) and restores; Cancel discards. The
72 // list of names is the whole state — no event-on-object wiring.
73
74 /// True while the reference-selection modal is active (the shell hides the
75 /// rest of the UI and the viewport routes clicks to picking).
76 pub fn ref_select_active(&self) -> bool {
77 self.ref_select.is_some()
78 }
79
80 /// Enter reference-selection mode for feature `feature_id`'s param at `path`.
81 /// Seeds the running list from `seed_names` (the field's current value), rolls
82 /// the model to the pre-feature "before" state (the step just before the
83 /// edited feature ran), and highlights the seeded names. `filter` constrains
84 /// the pick kind (`["SOLID"]`, `["FACE"]`, …); `multiple` allows a list.
85 pub fn begin_ref_select(
86 &mut self,
87 feature_id: &str,
88 path: Vec<String>,
89 label: String,
90 filter: Vec<String>,
91 multiple: bool,
92 seed_names: Vec<String>,
93 ) {
94 let restore_index = self.history.rollback();
95 // "Before" = the step just before the edited feature ran, so the user
96 // picks against the correct geometry. Clamp at 0 for the first feature.
97 let before = self
98 .history
99 .index_of(feature_id)
100 .map(|i| i.saturating_sub(1))
101 .unwrap_or(restore_index);
102 // Constrain the GLOBAL selection filter to exactly the kinds this field
103 // permits: this drives BOTH click-picking (`ref_select_click`) AND
104 // hover-highlighting (`hover_at`, which reads `selection_filter`), so only
105 // the allowed kinds highlight/select while the picker is active. An
106 // absent/construction-only field filter maps to all-enabled (see
107 // `from_ref_filter`). Restored to the all-enabled default on finish/cancel
108 // (`end_ref_select`).
109 self.selection_filter = SelectionFilter::from_ref_filter(&filter);
110 self.ref_select = Some(RefSelectState {
111 feature_id: feature_id.to_string(),
112 path,
113 label,
114 filter,
115 multiple,
116 names: seed_names,
117 restore_index,
118 target: RefSelectTarget::Feature,
119 });
120 // Roll to the before-state (re-runs + marks dirty), then light up the seed.
121 self.history.set_rollback(before);
122 self.rerun_history();
123 self.sync_ref_select_emphasis();
124 }
125
126 /// The running list of picked names (empty when not active) — the modal UI
127 /// reads this back to draw its one-per-line list.
128 pub fn ref_select_names(&self) -> Vec<String> {
129 self.ref_select
130 .as_ref()
131 .map(|r| r.names.clone())
132 .unwrap_or_default()
133 }
134
135 /// The active field's label (for the modal heading), or empty.
136 pub fn ref_select_label(&self) -> String {
137 self.ref_select
138 .as_ref()
139 .map(|r| r.label.clone())
140 .unwrap_or_default()
141 }
142
143 /// A one-line summary of the active field for the modal heading:
144 /// `"Tool solids (SOLID, multiple)"`.
145 pub fn ref_select_prompt(&self) -> String {
146 match &self.ref_select {
147 Some(r) => format!(
148 "{} ({}{})",
149 r.label,
150 r.filter.join("/"),
151 if r.multiple { ", multiple" } else { "" }
152 ),
153 None => String::new(),
154 }
155 }
156
157 /// A viewport click while active: type-constrained-pick the nearest allowed
158 /// hit under CSS-pixel `(x, y)` and add its name to the running list (single
159 /// fields replace; multiple fields append, de-duplicated). Re-lights the
160 /// highlight. No-op on a miss / an empty (unnamed) hit.
161 pub fn ref_select_click(&mut self, x: f64, y: f64) {
162 let Some(state) = self.ref_select.as_ref() else {
163 return;
164 };
165 let filter = state.filter.clone();
166 let multiple = state.multiple;
167 let target = state.target;
168 // The field's RAW kind strings drive the pick (`pick_top_at` reads
169 // `DATUM` as an alias of `PLANE`), so a `["PLANE","FACE"]` sketchPlane
170 // field now picks a construction plane through the ORDINARY candidate
171 // list — including one sitting under a face, which the geometry-miss
172 // fallback below could never reach.
173 let picked = match self.pick_top_at(x, y, &filter) {
174 Some(hit) if hit.kind == pick::PickKind::Plane => {
175 // Accept ONLY a resolved D/P frame name, the same guard the
176 // fallback applies — a stray widget-fed plane never lands in a
177 // reference field.
178 if self.datum_feature_for_name(&hit.name).is_none() {
179 return;
180 }
181 hit.name
182 }
183 Some(hit) if !hit.name.trim().is_empty() => hit.name,
184 Some(hit) => {
185 // A vertex pick carries no kernel name. For an ASSEMBLY CONSTRAINT
186 // field that accepts VERTEX, build the `{solidName}@x,y,z` ref with
187 // COMPONENT-LOCAL coordinates (world pick · owning-component
188 // pose⁻¹ — the lane-E selection contract; the kernel resolver snaps
189 // to the nearest topology vertex). Everything else stays a no-op.
190 if target != RefSelectTarget::AssemblyConstraint
191 || !matches!(hit.kind, pick::PickKind::Vertex)
192 {
193 return;
194 }
195 match self.component_vertex_ref(&hit.solid, hit.position) {
196 Some(vertex_ref) => vertex_ref,
197 None => return, // not component geometry — constraints reject it anyway
198 }
199 }
200 None => {
201 // TOTAL MISS. The plane CARDS are candidates above and they are
202 // the same set `datum_pick` tests, so in the app this arm is
203 // unreachable for a plane field; it is kept as a second line of
204 // defense for the tested `["PLANE","FACE"]` sketchPlane flow (and
205 // it is the ONLY path that would reach a datum AXIS, were one ever
206 // fed). Accept ONLY a resolved D/P frame name
207 // (`datum_feature_for_name`, mirroring `select_datum`'s guard) so an
208 // AXIS name — which `datum_pick` may also return — never lands in a
209 // plane field.
210 let admits_plane = filter
211 .iter()
212 .any(|k| k.eq_ignore_ascii_case("PLANE") || k.eq_ignore_ascii_case("DATUM"));
213 if !admits_plane {
214 return;
215 }
216 let name = self.datum_pick(x, y);
217 if name.is_empty() || self.datum_feature_for_name(&name).is_none() {
218 return;
219 }
220 name
221 }
222 };
223 let state = self.ref_select.as_mut().expect("active by guard above");
224 if multiple {
225 if !state.names.iter().any(|n| n == &picked) {
226 state.names.push(picked);
227 }
228 } else {
229 state.names = vec![picked];
230 }
231 self.sync_ref_select_emphasis();
232 }
233
234 /// Remove the name at `index` from the running list (the modal's per-line X).
235 pub fn ref_select_remove(&mut self, index: usize) {
236 if let Some(state) = self.ref_select.as_mut() {
237 if index < state.names.len() {
238 state.names.remove(index);
239 }
240 }
241 self.sync_ref_select_emphasis();
242 }
243
244 /// Finish: write the running names into the edited feature's params at the
245 /// field path, restore the rolled-to step, clear the highlight, and re-run so
246 /// the feature rebuilds with the chosen references.
247 pub fn finish_ref_select(&mut self) {
248 let Some(state) = self.ref_select.take() else {
249 return;
250 };
251 // An ASSEMBLY CONSTRAINT field commits through the constraint update
252 // lane (kernel session + document fold), not feature params; the shared
253 // end tail below still restores the roll + re-runs (which re-solves).
254 if state.target == RefSelectTarget::AssemblyConstraint {
255 self.assembly_commit_constraint_refs(
256 &state.feature_id,
257 &state.path,
258 &state.names,
259 state.multiple,
260 );
261 self.end_ref_select(state.restore_index);
262 return;
263 }
264 if let Some(index) = self.history.index_of(&state.feature_id) {
265 let mut params = self
266 .history
267 .feature_params(index)
268 .unwrap_or_else(|| serde_json::json!({}));
269 let value = if state.multiple {
270 serde_json::Value::Array(
271 state
272 .names
273 .iter()
274 .cloned()
275 .map(serde_json::Value::String)
276 .collect(),
277 )
278 } else {
279 serde_json::Value::String(state.names.first().cloned().unwrap_or_default())
280 };
281 set_json_at(&mut params, &state.path, value);
282 self.history.set_feature_params(index, params);
283 }
284 self.end_ref_select(state.restore_index);
285 }
286
287 /// Cancel: discard the running selection, clear the highlight, restore the
288 /// rolled-to step, and re-run (no param change).
289 pub fn cancel_ref_select(&mut self) {
290 if let Some(state) = self.ref_select.take() {
291 self.end_ref_select(state.restore_index);
292 }
293 }
294
295 /// Restore the rolled-to step + clear emphasis + re-run + reset the selection
296 /// filter to the all-enabled default (shared Finish/Cancel tail).
297 ///
298 /// Resetting to the DEFAULT (not a saved "prior" filter) is deliberate: the
299 /// spec baseline out of ref-select is "all kinds enabled", and `begin_ref_select`
300 /// overwrites `ref_select` without routing through here, so a stashed prior
301 /// could be a stale already-constrained filter. Living in this shared tail also
302 /// means a stray `finish_ref_select()` while inactive (early return on `take`)
303 /// never clobbers the filter.
304 fn end_ref_select(&mut self, restore_index: usize) {
305 let _ = self.emphasis.apply_json("{}");
306 self.selection_filter = SelectionFilter::default();
307 self.history.set_rollback(restore_index);
308 self.rerun_history();
309 }
310
311 /// Drive the selection highlight (`emphasis`) from the running name list so
312 /// picks light up in the viewport. A field may allow SEVERAL kinds at once
313 /// (e.g. `FACE`/`EDGE`), and a pick can be any of them, so every picked name is
314 /// fed to EVERY name-based bucket the filter permits — a name only ever matches
315 /// its own kind's entities (edge names carry the `|…[n]` topology form, faces do
316 /// not), so the cross-listing is harmless and each pick highlights correctly.
317 /// (The old code bucketed ALL names by `filter.first()` only, so an EDGE pick
318 /// under a `FACE`-first filter landed in `faces`, matched nothing, and never
319 /// showed.) VERTEX picks are position-keyed, not name-keyed, so they can't be
320 /// emphasized from a name list here.
321 pub(crate) fn sync_ref_select_emphasis(&mut self) {
322 let json = match &self.ref_select {
323 Some(state) => {
324 let names = serde_json::json!(state.names);
325 let mut selected = serde_json::Map::new();
326 for kind in &state.filter {
327 // Case-INSENSITIVE match, mirroring `SelectionFilter::set` (which
328 // pick-filtering uses via `from_ref_filter`). Without this, a
329 // schema that spelled a kind non-canonically (e.g. `"Edge"`) would
330 // let the user PICK that kind but silently skip its seed HIGHLIGHT
331 // here — a lenient-pick / strict-highlight split. VERTEX is inert:
332 // vertex picks carry no kernel name, so `ref_select_click` never
333 // records one in `names` (empty-name early-return), so there is
334 // nothing to highlight by name.
335 let bucket = match kind.to_ascii_uppercase().as_str() {
336 "FACE" => "faces",
337 "EDGE" => "edges",
338 "SOLID" => "solids",
339 "PLANE" | "DATUM" => "datums",
340 _ => continue, // VERTEX (never name-seeded) / unknown
341 };
342 selected.entry(bucket.to_string()).or_insert_with(|| names.clone());
343 }
344 // No highlightable kind in the filter → fall back to solids (the
345 // prior default) so at least solid-name picks still light up.
346 if selected.is_empty() {
347 selected.insert("solids".to_string(), names);
348 }
349 serde_json::json!({ "selected": selected }).to_string()
350 }
351 None => "{}".to_string(),
352 };
353 let _ = self.emphasis.apply_json(&json);
354 // A picked construction PLANE/DATUM highlights through the datum-plane
355 // WIDGET, whose accent is baked at feed time (`refresh_construction_datums`
356 // reads `emphasis.selected_datums`) — so a plain `apply_json` does not
357 // re-color it. Re-feed here so a datum pick lights up (and un-lights on
358 // remove) in the modal. Harmless for non-datum fields (no datum selected →
359 // an ordinary calm-color re-feed).
360 self.refresh_construction_datums();
361 self.dirty = true;
362 }
363}
364
365/// Write `value` into `root` at `path` (object-key chain), auto-vivifying
366/// intermediate objects — the engine-side twin of the form's nested setter, used
367/// to commit a reference field's picked names back into the feature params.
368pub(crate) fn set_json_at(root: &mut serde_json::Value, path: &[String], value: serde_json::Value) {
369 if path.is_empty() {
370 *root = value;
371 return;
372 }
373 if !root.is_object() {
374 *root = serde_json::Value::Object(serde_json::Map::new());
375 }
376 let mut cur = root;
377 for seg in &path[..path.len() - 1] {
378 let obj = cur.as_object_mut().expect("object by construction");
379 cur = obj
380 .entry(seg.clone())
381 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
382 if !cur.is_object() {
383 *cur = serde_json::Value::Object(serde_json::Map::new());
384 }
385 }
386 cur.as_object_mut()
387 .expect("object by construction")
388 .insert(path[path.len() - 1].clone(), value);
389}
390
391impl EngineState {
392 /// Hover-highlight the TOP-priority pick under CSS-pixel `(x, y)` whose kind
393 /// the selection filter admits, setting it HOVERED in `emphasis` (the
394 /// renderer tints it). A miss — or a filter admitting nothing — clears the
395 /// hover. No-ops (returns `false`, no dirty) when the hovered entity is
396 /// unchanged, so a stationary pointer over the same face doesn't re-render
397 /// every frame (the `k === prevK` early-out). Returns
398 /// whether the hover state changed.
399 pub fn hover_at(&mut self, x: f64, y: f64) -> bool {
400 let kinds = self.selection_filter.enabled_kinds();
401 if kinds.is_empty() {
402 return self.clear_hover();
403 }
404 match self.pick_top_at(x, y, &kinds) {
405 Some(hit) => {
406 if self.hover_is(&hit) {
407 return false; // unchanged — keep the frame clean.
408 }
409 self.set_hover_to_candidate(&hit);
410 true
411 }
412 None => self.clear_hover(),
413 }
414 }
415
416 /// Clear the hover highlight (pointer moved to empty space / off the
417 /// viewport). Bumps the emphasis generation + marks dirty only when a hover
418 /// was actually lit. Returns whether it changed. (Distinct from
419 /// [`clear_selection`](Self::clear_selection), which leaves hover alone.)
420 pub fn clear_hover(&mut self) -> bool {
421 let had_datums = !self.emphasis.hovered_datums.is_empty();
422 let had = !self.emphasis.hovered_solids.is_empty()
423 || !self.emphasis.hovered_faces.is_empty()
424 || !self.emphasis.hovered_edges.is_empty()
425 || !self.emphasis.hovered_vertices.is_empty()
426 || had_datums;
427 if had {
428 self.emphasis.hovered_solids.clear();
429 self.emphasis.hovered_faces.clear();
430 self.emphasis.hovered_edges.clear();
431 self.emphasis.hovered_vertices.clear();
432 self.emphasis.hovered_datums.clear();
433 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
434 self.dirty = true;
435 }
436 // A hovered plane's accent is baked into the datum feed, so dropping the
437 // hover needs a re-feed to un-light it (the datum twin of the selection
438 // re-feed in `clear_selection`).
439 if had_datums {
440 self.refresh_construction_datums();
441 }
442 had
443 }
444
445 /// The current HOVER (not selection) as JSON
446 /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — the hover twin of
447 /// [`selection_json`](Self::selection_json) so a UI / the headed verifier can
448 /// assert that moving the pointer over a face lit the hover emphasis.
449 pub fn hovered_json(&self) -> String {
450 let solids: Vec<&String> = self.emphasis.hovered_solids.iter().collect();
451 let faces: Vec<&String> = self.emphasis.hovered_faces.iter().collect();
452 let edges: Vec<&String> = self.emphasis.hovered_edges.iter().collect();
453 let datums: Vec<&String> = self.emphasis.hovered_datums.iter().collect();
454 serde_json::json!({
455 "solids": solids,
456 "faces": faces,
457 "edges": edges,
458 "datums": datums,
459 "vertices": self.emphasis.hovered_vertices.len(),
460 })
461 .to_string()
462 }
463
464 /// TOGGLE the top admitted pick under CSS-pixel `(x, y)` in the current
465 /// selection (a **Ctrl/Cmd+click**): add it if absent, remove it if present,
466 /// leaving the rest of the selection intact (unlike [`select_top_at`], which
467 /// REPLACES). With the COMPONENT filter on, a hit on component geometry
468 /// toggles the whole component (all member solids as one unit). A miss — or
469 /// a filter admitting nothing — leaves the selection untouched (additive
470 /// mode never clears). Returns whether a hit was toggled.
471 pub fn select_toggle_at(&mut self, x: f64, y: f64) -> bool {
472 let kinds = self.selection_filter.enabled_kinds();
473 let component_on = self.selection_filter.component;
474 if kinds.is_empty() && !component_on {
475 return false;
476 }
477 let pick_kinds = if kinds.is_empty() {
478 SelectionFilter::default().enabled_kinds()
479 } else {
480 kinds.clone()
481 };
482 match self.pick_top_at(x, y, &pick_kinds) {
483 Some(hit) => {
484 // COMPONENT promotion (the select_filtered_at rule, additively):
485 // toggle the WHOLE component's member solids as one unit, so a
486 // Ctrl/Cmd+click gathers the multi-component selections the
487 // pair-constraint offers key on.
488 if component_on {
489 if let Some(owner) = self.hit_owning_component(&hit) {
490 self.toggle_component_selection(&owner);
491 return true;
492 }
493 }
494 if kinds.is_empty() {
495 return false; // COMPONENT-only, non-component hit.
496 }
497 self.toggle_candidate(&hit);
498 true
499 }
500 None => false,
501 }
502 }
503
504 /// The RANKED, filter-respecting candidates under CSS-pixel `(x, y)` as JSON
505 /// `[{kind, name, solid, depth}]` — the "candidates under the cursor" list
506 /// (feeds the pick-list popup + the headed verifier).
507 ///
508 /// Sorted category-major in the pick-list order (VERTEX > EDGE > FACE >
509 /// PLANE > SOLID > COMPONENT), nearest (smallest depth) first within each
510 /// category — see [`candidates_filtered_at`](Self::candidates_filtered_at).
511 pub fn candidates_at(&self, x: f64, y: f64) -> String {
512 let list = self.candidates_filtered_at(x, y);
513 let out: Vec<serde_json::Value> = list
514 .iter()
515 .map(|c| {
516 serde_json::json!({
517 "kind": self.candidate_kind_label(c),
518 "name": c.name,
519 "solid": c.solid,
520 "depth": c.depth,
521 })
522 })
523 .collect();
524 serde_json::Value::Array(out).to_string()
525 }
526
527 /// The same ranked, filter-respecting candidate list as typed values (the
528 /// in-process egui pick-list popup consumes these directly, then re-hovers /
529 /// selects a chosen one via [`hover_candidate`](Self::hover_candidate) /
530 /// [`select_candidate`](Self::select_candidate) /
531 /// [`toggle_candidate`](Self::toggle_candidate)). EMPTY when the filter admits
532 /// nothing (not the `pick_filtered` "empty filter = any" case).
533 ///
534 /// The raw list is [`pick_candidates_at`](Self::pick_candidates_at), so
535 /// construction PLANE cards are ordinary entries here — a plane under other
536 /// geometry is listed (right after the faces) instead of being reachable only
537 /// on a geometry miss.
538 ///
539 /// With the filter's COMPONENT lane on, one COMPONENT entry per owning
540 /// assembly component of ANY raw hit is appended (name = component id, depth
541 /// = the component's nearest hit) — a raw hit of a filtered-OFF kind still
542 /// reaches its owning component, mirroring `select_filtered_at`'s
543 /// component-only promotion (a PLANE hit owns no component). The final list is
544 /// sorted category-major in the pick-list order
545 /// (VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest first within
546 /// each category.
547 pub fn candidates_filtered_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
548 let kinds = self.selection_filter.enabled_kinds();
549 let component_on = self.selection_filter.component;
550 if kinds.is_empty() && !component_on {
551 return Vec::new();
552 }
553 let raw = self.pick_candidates_at(x, y);
554 let mut out: Vec<pick::PickCandidate> = raw
555 .iter()
556 .filter(|c| self.candidate_admitted(&kinds, c))
557 .cloned()
558 .collect();
559 if component_on {
560 // One entry per owning component, carrying its NEAREST member hit's
561 // depth/position (raw hits are kind-major, so scan them all).
562 let mut components: Vec<pick::PickCandidate> = Vec::new();
563 for hit in &raw {
564 let Some(owner) = self.hit_owning_component(hit) else {
565 continue;
566 };
567 match components.iter_mut().find(|c| c.name == owner) {
568 Some(entry) => {
569 if hit.depth < entry.depth {
570 entry.depth = hit.depth;
571 entry.position = hit.position;
572 }
573 }
574 None => components.push(pick::PickCandidate {
575 kind: pick::PickKind::Component,
576 name: owner,
577 solid: String::new(),
578 depth: hit.depth,
579 screen_dist: hit.screen_dist,
580 position: hit.position,
581 }),
582 }
583 }
584 out.extend(components);
585 }
586 // Category-major (PickKind's discriminant order IS the pick-list order),
587 // nearest first within a category — the shared ordering every pick path
588 // uses, so the appended COMPONENT rows land in the same sort.
589 super::plane_pick::sort_pick_candidates(&mut out);
590 out
591 }
592
593 /// Whether a candidate is CURRENTLY selected (drives the pick-list popup's
594 /// per-row selected state so click-toggling reads back visually).
595 pub fn candidate_is_selected(&self, candidate: &pick::PickCandidate) -> bool {
596 use crate::pick::PickKind;
597 match candidate.kind {
598 PickKind::Solid => self
599 .emphasis
600 .selected_solids
601 .contains(&self.candidate_solid_name(candidate)),
602 PickKind::Face => self.emphasis.selected_faces.contains(&candidate.name),
603 PickKind::Edge => self.emphasis.selected_edges.contains(&candidate.name),
604 PickKind::Vertex => self
605 .emphasis
606 .selected_vertices
607 .iter()
608 .any(|v| Self::vertex_ref_matches(v, candidate)),
609 PickKind::Plane => self.emphasis.selected_datums.contains(&candidate.name),
610 PickKind::Component => {
611 let members = self.component_member_solids(&candidate.name);
612 !members.is_empty()
613 && members
614 .iter()
615 .all(|m| self.emphasis.selected_solids.contains(m))
616 }
617 }
618 }
619
620 /// Hover a SPECIFIC candidate (the popup entry the pointer is over) — sets it
621 /// HOVERED in `emphasis`, replacing any prior hover.
622 pub fn hover_candidate(&mut self, candidate: &pick::PickCandidate) {
623 self.set_hover_to_candidate(candidate);
624 }
625
626 /// REPLACE the selection with a specific candidate (a plain click on a popup
627 /// entry) — reuses the same bucketing as a plain viewport click.
628 pub fn select_candidate(&mut self, candidate: &pick::PickCandidate) {
629 self.set_selection_to_candidate(candidate);
630 }
631
632 /// TOGGLE a specific candidate in the selection (a Ctrl/Cmd+click on a popup
633 /// entry, or the [`select_toggle_at`](Self::select_toggle_at) hit): add if
634 /// absent, remove if present. Returns whether it is NOW selected (`true` =
635 /// added, `false` = removed). Bumps the emphasis generation + marks dirty.
636 pub fn toggle_candidate(&mut self, candidate: &pick::PickCandidate) -> bool {
637 use crate::pick::PickKind;
638 let now_selected = match candidate.kind {
639 PickKind::Solid => {
640 let name = self.candidate_solid_name(candidate);
641 if self.emphasis.selected_solids.remove(&name) {
642 false
643 } else {
644 self.emphasis.selected_solids.insert(name);
645 true
646 }
647 }
648 PickKind::Face => {
649 if self.emphasis.selected_faces.remove(&candidate.name) {
650 false
651 } else {
652 self.emphasis.selected_faces.insert(candidate.name.clone());
653 true
654 }
655 }
656 PickKind::Edge => {
657 if self.emphasis.selected_edges.remove(&candidate.name) {
658 false
659 } else {
660 self.emphasis.selected_edges.insert(candidate.name.clone());
661 true
662 }
663 }
664 PickKind::Vertex => {
665 if let Some(index) = self
666 .emphasis
667 .selected_vertices
668 .iter()
669 .position(|v| Self::vertex_ref_matches(v, candidate))
670 {
671 self.emphasis.selected_vertices.remove(index);
672 false
673 } else {
674 self.emphasis.selected_vertices.push(crate::style::VertexRef {
675 solid: candidate.solid.clone(),
676 position: candidate.position,
677 });
678 true
679 }
680 }
681 PickKind::Plane => {
682 // A construction PLANE toggles by FRAME NAME, the datum bucket the
683 // Scene-tree row / `select_datum` fill.
684 if self.emphasis.selected_datums.remove(&candidate.name) {
685 false
686 } else {
687 self.emphasis.selected_datums.insert(candidate.name.clone());
688 true
689 }
690 }
691 PickKind::Component => {
692 // The whole component toggles as ONE unit (member solids), the
693 // same rule as the Ctrl/Cmd+click COMPONENT promotion.
694 let was_selected = self.candidate_is_selected(candidate);
695 self.toggle_component_selection(&candidate.name);
696 !was_selected
697 }
698 };
699 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
700 self.dirty = true;
701 // A plane's accent is baked into the datum feed, so a toggled plane only
702 // lights / un-lights after a re-feed.
703 if candidate.kind == PickKind::Plane {
704 self.refresh_construction_datums();
705 }
706 now_selected
707 }
708
709 /// Set the hover emphasis to exactly one candidate (bucketed by kind), the
710 /// hover twin of `set_selection_to_candidate`.
711 fn set_hover_to_candidate(&mut self, candidate: &pick::PickCandidate) {
712 use crate::pick::PickKind;
713 // A hovered PLANE's accent lives in the datum FEED, so the re-feed below
714 // is needed both when a plane becomes hovered and when one stops being.
715 let touches_datums =
716 !self.emphasis.hovered_datums.is_empty() || candidate.kind == PickKind::Plane;
717 self.emphasis.hovered_solids.clear();
718 self.emphasis.hovered_faces.clear();
719 self.emphasis.hovered_edges.clear();
720 self.emphasis.hovered_vertices.clear();
721 self.emphasis.hovered_datums.clear();
722 match candidate.kind {
723 PickKind::Solid => {
724 self.emphasis
725 .hovered_solids
726 .insert(self.candidate_solid_name(candidate));
727 }
728 PickKind::Face => {
729 self.emphasis.hovered_faces.insert(candidate.name.clone());
730 }
731 PickKind::Edge => {
732 self.emphasis.hovered_edges.insert(candidate.name.clone());
733 }
734 PickKind::Vertex => {
735 self.emphasis.hovered_vertices.push(crate::style::VertexRef {
736 solid: candidate.solid.clone(),
737 position: candidate.position,
738 });
739 }
740 PickKind::Plane => {
741 self.emphasis.hovered_datums.insert(candidate.name.clone());
742 }
743 PickKind::Component => {
744 // Hovering a COMPONENT entry lights every member solid.
745 for member in self.component_member_solids(&candidate.name) {
746 self.emphasis.hovered_solids.insert(member);
747 }
748 }
749 }
750 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
751 self.dirty = true;
752 if touches_datums {
753 self.refresh_construction_datums();
754 }
755 }
756
757 /// Whether the CURRENT hover is exactly this one candidate (the `hover_at`
758 /// early-out) — a single hovered entity that matches `candidate` (for a
759 /// COMPONENT candidate: exactly its member-solid set).
760 fn hover_is(&self, candidate: &pick::PickCandidate) -> bool {
761 use crate::pick::PickKind;
762 if candidate.kind == PickKind::Component {
763 let members = self.component_member_solids(&candidate.name);
764 return !members.is_empty()
765 && self.emphasis.hovered_faces.is_empty()
766 && self.emphasis.hovered_edges.is_empty()
767 && self.emphasis.hovered_vertices.is_empty()
768 && self.emphasis.hovered_datums.is_empty()
769 && self.emphasis.hovered_solids.len() == members.len()
770 && members.iter().all(|m| self.emphasis.hovered_solids.contains(m));
771 }
772 let total = self.emphasis.hovered_solids.len()
773 + self.emphasis.hovered_faces.len()
774 + self.emphasis.hovered_edges.len()
775 + self.emphasis.hovered_vertices.len()
776 + self.emphasis.hovered_datums.len();
777 if total != 1 {
778 return false;
779 }
780 match candidate.kind {
781 PickKind::Solid => self
782 .emphasis
783 .hovered_solids
784 .contains(&self.candidate_solid_name(candidate)),
785 PickKind::Face => self.emphasis.hovered_faces.contains(&candidate.name),
786 PickKind::Edge => self.emphasis.hovered_edges.contains(&candidate.name),
787 PickKind::Vertex => self
788 .emphasis
789 .hovered_vertices
790 .iter()
791 .any(|v| Self::vertex_ref_matches(v, candidate)),
792 PickKind::Plane => self.emphasis.hovered_datums.contains(&candidate.name),
793 PickKind::Component => false, // handled by the early return above
794 }
795 }
796
797 /// The scene name a SOLID candidate resolves to (its owning `solid`, falling
798 /// back to `name` when the pick didn't carry one) — the same rule
799 /// `set_selection_to_candidate` uses.
800 fn candidate_solid_name(&self, candidate: &pick::PickCandidate) -> String {
801 if candidate.solid.is_empty() {
802 candidate.name.clone()
803 } else {
804 candidate.solid.clone()
805 }
806 }
807
808 /// Vertex identity: same owning solid + position within the emphasis match
809 /// tolerance (vertices carry no kernel name, so they resolve by solid+pos).
810 fn vertex_ref_matches(v: &crate::style::VertexRef, candidate: &pick::PickCandidate) -> bool {
811 const TOL: f64 = 1e-4;
812 v.solid == candidate.solid
813 && (v.position[0] - candidate.position[0]).abs() <= TOL
814 && (v.position[1] - candidate.position[1]).abs() <= TOL
815 && (v.position[2] - candidate.position[2]).abs() <= TOL
816 }
817}
818
819// ---------------------------------------------------------------------------
820// Sketch display (S0) — read-only overlay of a solved SketchSession.
821//
822// Additive, self-contained: a solved sketch is fed to the general `set_overlay`
823// channel as the named groups `sketch-geometry` (lines) and `sketch-points`
824// (billboarded points), colored by solver mobility. No interaction (the tools /
825// picking / dimensions of later slices live elsewhere); this block only pushes /
826// clears the display geometry.
827// ---------------------------------------------------------------------------
828impl EngineState {
829 /// Display a solved [`crate::sketch::SketchSession`] as a read-only overlay.
830 /// The plane geometry is tessellated to world space and pushed via
831 /// [`set_overlay_json`](Self::set_overlay_json); construction dashes are sized
832 /// against the LIVE camera so they stay screen-constant.
833 pub fn set_sketch_overlay(&mut self, session: &crate::sketch::SketchSession) {
834 let world_per_pixel = self.camera.world_per_pixel();
835 let json = session.overlay_json(world_per_pixel);
836 // The overlay channel accepts our exact `{groups:[…]}` shape; a parse
837 // failure would be a programming error in the tessellator, so drop it.
838 let _ = self.set_overlay_json(&json);
839 // The dimension leaders ride in their own `sketch-dim-leaders` group (S5).
840 let _ = self.set_overlay_json(&session.dim_leaders_overlay_json(world_per_pixel));
841 // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
842 let _ = self.set_overlay_json(&session.constraint_glyphs_overlay_json(world_per_pixel));
843 // The zoom this screen-constant sizing was baked at, so the per-frame
844 // `ensure_sketch_overlay_current` re-bakes it when the camera zooms.
845 self.sketch_overlay_wpp = if world_per_pixel > 0.0 {
846 world_per_pixel
847 } else {
848 f64::MIN_POSITIVE
849 };
850 }
851
852 /// Remove the sketch overlay groups (feeding empty same-named groups upserts
853 /// them to empty, which the overlay channel treats as a removal — other
854 /// overlay groups are left untouched).
855 pub fn clear_sketch_overlay(&mut self) {
856 let _ = self.set_overlay_json(
857 "{\"groups\":[{\"name\":\"sketch-geometry\"},{\"name\":\"sketch-points\"},{\"name\":\"sketch-preview\"},{\"name\":\"sketch-dim-leaders\"},{\"name\":\"sketch-constraint-glyphs\"}]}",
858 );
859 self.sketch_overlay_wpp = 0.0;
860 }
861}
862
863#[cfg(test)]
864mod selection_ux_tests {
865 use super::*;
866
867 fn cube(name: &str, size: f64) -> String {
868 serde_json::json!({
869 "expressions": "",
870 "configurator": {},
871 "features": [{
872 "type": "P.CU",
873 "inputParams": {
874 "id": name,
875 "sizeX": size, "sizeY": size, "sizeZ": size,
876 "transform": {
877 "position": [0.0, 0.0, 0.0],
878 "rotationEuler": [0.0, 0.0, 0.0],
879 "scale": [1.0, 1.0, 1.0]
880 },
881 "boolean": { "targets": [], "operation": "NONE" }
882 },
883 "persistentData": {}
884 }]
885 })
886 .to_string()
887 }
888
889 /// A cube filling `0..size` framed straight-on down -Z, so the viewport
890 /// centre `(400, 300)` lands on a face centre — a ray that pierces BOTH the
891 /// near (+Z) and far (-Z) faces, i.e. an overlapping spot with two FACE
892 /// candidates under one pixel.
893 fn front_cube(size: f64) -> EngineState {
894 let mut engine = EngineState::new();
895 engine.run_history_json(&cube("UxCube", size)).unwrap();
896 engine.resize(800.0, 600.0);
897 engine.camera.eye = [size / 2.0, size / 2.0, size * 6.0];
898 engine.camera.target = [size / 2.0, size / 2.0, size / 2.0];
899 engine.camera.up = [0.0, 1.0, 0.0];
900 engine.camera.projection = crate::view::Projection::Orthographic { half_height: size };
901 engine
902 }
903
904 fn face_filter(engine: &mut EngineState) {
905 engine.set_selection_filter(SelectionFilter {
906 solid: false,
907 sketch: false,
908 face: true,
909 edge: false,
910 vertex: false,
911 plane: false,
912 component: false,
913 });
914 }
915
916 #[test]
917 fn candidates_at_respect_filter_and_the_kind_then_depth_sort() {
918 let mut engine = front_cube(10.0);
919
920 // FACE-only: the overlapping centre pixel lists BOTH faces (near + far),
921 // both FACE, sorted by ascending depth (the final sort: kind priority
922 // then depth). No SOLID entry — the filter drops it.
923 face_filter(&mut engine);
924 let v: serde_json::Value =
925 serde_json::from_str(&engine.candidates_at(400.0, 300.0)).unwrap();
926 let arr = v.as_array().unwrap();
927 assert!(arr.len() >= 2, "two faces under the pixel: {arr:?}");
928 assert!(arr.iter().all(|c| c["kind"] == "FACE"), "faces only: {arr:?}");
929 let depths: Vec<f64> = arr.iter().map(|c| c["depth"].as_f64().unwrap()).collect();
930 assert!(
931 depths.windows(2).all(|w| w[0] <= w[1]),
932 "sorted by ascending depth (near face first): {depths:?}"
933 );
934
935 // SOLID-only: the SAME pixel lists exactly the owning solid.
936 engine.set_selection_filter(SelectionFilter {
937 solid: true,
938 sketch: true,
939 face: false,
940 edge: false,
941 vertex: false,
942 plane: false,
943 component: false,
944 });
945 let v: serde_json::Value =
946 serde_json::from_str(&engine.candidates_at(400.0, 300.0)).unwrap();
947 let arr = v.as_array().unwrap();
948 assert_eq!(arr.len(), 1, "one solid: {arr:?}");
949 assert_eq!(arr[0]["kind"], "SOLID");
950 assert_eq!(arr[0]["name"], "UxCube");
951
952 // Nothing enabled → an empty candidate list.
953 engine.set_selection_filter(SelectionFilter {
954 solid: false,
955 sketch: false,
956 face: false,
957 edge: false,
958 vertex: false,
959 plane: false,
960 component: false,
961 });
962 let v: serde_json::Value =
963 serde_json::from_str(&engine.candidates_at(400.0, 300.0)).unwrap();
964 assert!(v.as_array().unwrap().is_empty(), "no kind admitted → empty");
965 }
966
967 #[test]
968 fn toggle_candidate_adds_then_removes_and_multi_selects() {
969 let mut engine = front_cube(10.0);
970 face_filter(&mut engine);
971 let cands = engine.candidates_filtered_at(400.0, 300.0);
972 assert!(cands.len() >= 2, "need two overlapping faces");
973 let near = cands[0].clone();
974 let far = cands[1].clone();
975 assert_ne!(near.name, far.name, "distinct faces");
976
977 // Toggling two distinct faces ADDS both → a selection of size 2.
978 assert!(engine.toggle_candidate(&near), "near added");
979 assert!(engine.toggle_candidate(&far), "far added");
980 assert_eq!(engine.emphasis.selected_faces.len(), 2, "both faces selected");
981
982 // Toggling the near face again REMOVES it → back to 1, the far face kept.
983 assert!(!engine.toggle_candidate(&near), "near removed");
984 assert_eq!(engine.emphasis.selected_faces.len(), 1);
985 assert!(engine.emphasis.selected_faces.contains(&far.name));
986 }
987
988 #[test]
989 fn select_toggle_at_adds_then_removes_the_top_hit() {
990 let mut engine = front_cube(10.0);
991 face_filter(&mut engine);
992 // First Ctrl+click at the centre adds the near face.
993 assert!(engine.select_toggle_at(400.0, 300.0));
994 assert_eq!(engine.emphasis.selected_faces.len(), 1);
995 // A second Ctrl+click at the SAME spot toggles that same top hit off.
996 assert!(engine.select_toggle_at(400.0, 300.0));
997 assert_eq!(engine.emphasis.selected_faces.len(), 0);
998 // A Ctrl+click on empty space is a no-op (never clears the selection).
999 engine.set_selection_filter(SelectionFilter {
1000 solid: true,
1001 sketch: true,
1002 face: false,
1003 edge: false,
1004 vertex: false,
1005 plane: false,
1006 component: false,
1007 });
1008 assert!(engine.select_toggle_at(400.0, 300.0), "solid added");
1009 assert!(!engine.select_toggle_at(10.0, 10.0), "miss is a no-op");
1010 assert!(engine.has_selection(), "miss left the selection intact");
1011 }
1012
1013 #[test]
1014 fn hover_at_lights_the_top_face_and_clears() {
1015 let mut engine = front_cube(10.0);
1016 face_filter(&mut engine);
1017 // Moving over the face lights exactly one hovered face.
1018 assert!(engine.hover_at(400.0, 300.0), "hover set");
1019 assert_eq!(engine.emphasis.hovered_faces.len(), 1);
1020 let lit: String = engine.emphasis.hovered_faces.iter().next().unwrap().clone();
1021 // Re-hovering the SAME entity does not churn the frame.
1022 assert!(!engine.hover_at(400.0, 300.0), "unchanged hover → no change");
1023 assert_eq!(engine.emphasis.hovered_faces.iter().next().unwrap(), &lit);
1024 // Moving onto empty space clears the hover.
1025 assert!(engine.hover_at(10.0, 10.0), "miss clears the prior hover");
1026 assert!(engine.emphasis.hovered_faces.is_empty());
1027 // Hover does NOT touch the selection set.
1028 assert!(!engine.has_selection());
1029 }
1030
1031 #[test]
1032 fn candidate_hover_and_select_target_the_exact_entity() {
1033 let mut engine = front_cube(10.0);
1034 face_filter(&mut engine);
1035 let cands = engine.candidates_filtered_at(400.0, 300.0);
1036 let far = cands[1].clone();
1037 // Hovering the SECOND (far) candidate lights that exact face, not the near one.
1038 engine.hover_candidate(&far);
1039 assert!(engine.emphasis.hovered_faces.contains(&far.name));
1040 assert_eq!(engine.emphasis.hovered_faces.len(), 1);
1041 // Selecting it replaces the selection with exactly that face.
1042 engine.select_candidate(&far);
1043 assert_eq!(engine.emphasis.selected_faces.len(), 1);
1044 assert!(engine.emphasis.selected_faces.contains(&far.name));
1045 }
1046
1047 /// COMPONENT entries in the pick list: with the filter's COMPONENT lane on,
1048 /// the candidate list appends one COMPONENT entry per owning component AFTER
1049 /// the solids (the category order points > edges > faces > solids >
1050 /// components), and toggling that entry selects/deselects the whole
1051 /// component's member solids as one unit.
1052 #[test]
1053 fn candidates_include_component_entries_and_toggle_whole_components() {
1054 use crate::engine_state::component_fixtures::two_instance_assembly_json;
1055 let mut engine = EngineState::new();
1056 engine.set_history_json(&two_instance_assembly_json()).unwrap();
1057 engine.resize(800.0, 600.0);
1058 engine.camera.eye = [25.0, 5.0, 45.0];
1059 engine.camera.target = [25.0, 5.0, 5.0];
1060 engine.camera.up = [0.0, 1.0, 0.0];
1061 engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
1062
1063 // Default filter (COMPONENT on): the ACOMP2 click spot lists its faces,
1064 // the owning solid, and ONE trailing COMPONENT entry — category-major
1065 // (kind ranks never decrease down the list).
1066 let cands = engine.candidates_filtered_at(400.0, 300.0);
1067 assert!(cands.len() >= 3, "faces + solid + component: {cands:?}");
1068 let ranks: Vec<u8> = cands.iter().map(|c| c.kind as u8).collect();
1069 assert!(
1070 ranks.windows(2).all(|w| w[0] <= w[1]),
1071 "category-major order: {ranks:?}"
1072 );
1073 let components: Vec<_> = cands
1074 .iter()
1075 .filter(|c| c.kind == pick::PickKind::Component)
1076 .collect();
1077 assert_eq!(components.len(), 1, "one owning component: {cands:?}");
1078 assert_eq!(components[0].name, "ACOMP2");
1079 assert_eq!(
1080 cands.last().unwrap().kind,
1081 pick::PickKind::Component,
1082 "components list last"
1083 );
1084 assert!(
1085 cands.iter().any(|c| c.kind == pick::PickKind::Solid && c.name == "ACOMP2:Part"),
1086 "the member solid still lists under its own category: {cands:?}"
1087 );
1088
1089 // Toggling the COMPONENT entry selects the member solids as one unit…
1090 let comp = components[0].clone();
1091 assert!(!engine.candidate_is_selected(&comp));
1092 assert!(engine.toggle_candidate(&comp), "component added");
1093 assert!(engine.candidate_is_selected(&comp));
1094 let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
1095 assert_eq!(sel["solids"], serde_json::json!(["ACOMP2:Part"]), "{sel}");
1096 // …and toggling again removes the whole unit.
1097 assert!(!engine.toggle_candidate(&comp), "component removed");
1098 assert!(!engine.has_selection());
1099
1100 // Hovering the COMPONENT entry lights every member solid.
1101 engine.hover_candidate(&comp);
1102 let hov: serde_json::Value = serde_json::from_str(&engine.hovered_json()).unwrap();
1103 assert_eq!(hov["solids"], serde_json::json!(["ACOMP2:Part"]), "{hov}");
1104
1105 // COMPONENT lane off → no COMPONENT entries.
1106 let mut f = engine.selection_filter();
1107 f.component = false;
1108 engine.set_selection_filter(f);
1109 let cands = engine.candidates_filtered_at(400.0, 300.0);
1110 assert!(
1111 cands.iter().all(|c| c.kind != pick::PickKind::Component),
1112 "no component entries with the lane off: {cands:?}"
1113 );
1114
1115 // COMPONENT-only: the list is exactly the component entries (sub-entity
1116 // kinds are filtered out, but their raw hits still reach the owner).
1117 engine.set_selection_filter(SelectionFilter {
1118 solid: false,
1119 sketch: false,
1120 face: false,
1121 edge: false,
1122 vertex: false,
1123 plane: false,
1124 component: true,
1125 });
1126 let cands = engine.candidates_filtered_at(400.0, 300.0);
1127 assert!(!cands.is_empty(), "component-only still lists the owner");
1128 assert!(
1129 cands.iter().all(|c| c.kind == pick::PickKind::Component),
1130 "component-only lists only components: {cands:?}"
1131 );
1132 }
1133
1134 /// NAME-based selection re-attaches across a feature re-run: after editing
1135 /// the feature (new geometry, same deterministic kernel names) the selected
1136 /// face name still exists in the rebuilt scene and the emphasis still
1137 /// resolves it — the selection isn't dropped by the rebuild.
1138 #[test]
1139 fn selection_reattaches_across_feature_reruns() {
1140 let mut engine = front_cube(10.0);
1141 face_filter(&mut engine);
1142 assert!(engine.select_top_at(400.0, 300.0), "selected the near face");
1143 let selected: String = engine.emphasis.selected_faces.iter().next().unwrap().clone();
1144
1145 // "Edit the feature": re-run the history with a changed size (the same
1146 // feature id → the same deterministic entity names on new geometry).
1147 engine.run_history_json(&cube("UxCube", 12.0)).unwrap();
1148
1149 assert!(
1150 engine.emphasis.selected_faces.contains(&selected),
1151 "selection survives the re-run"
1152 );
1153 let names: Vec<String> = engine
1154 .scene
1155 .solids()
1156 .iter()
1157 .flat_map(|s| s.faces.iter().map(|f| f.name.clone()))
1158 .collect();
1159 assert!(
1160 names.contains(&selected),
1161 "the selected name re-attaches to the rebuilt scene: {selected} not in {names:?}"
1162 );
1163 // And the render-side emphasis lookup still lights it.
1164 assert_eq!(
1165 engine.emphasis.face_state("UxCube", &selected),
1166 crate::style::EmphasisState::Selected,
1167 "emphasis resolves the re-attached face"
1168 );
1169 }
1170}
1171
1172// ===========================================================================
1173// Sketch mode (S1) — enter / exit / new an engine-native sketch edit.
1174//
1175// A SKETCH feature (`type "S"`) persists its editable state in
1176// `persistentData.sketch` (`{points, geometries, constraints}` — a `SketchDoc`)
1177// and its plane in `persistentData.basis` (a `PlaneFrame`). Entering sketch mode
1178// is fully HEADLESS: it reads that persisted state straight off the history JSON
1179// (no kernel SceneMap needed), rolls the model to the step BEFORE the sketch (the
1180// natural backdrop), orients the camera onto the plane, and holds a live solved
1181// [`crate::sketch::SketchSession`]. Exit writes the (possibly edited) doc back to
1182// `persistentData.sketch` (commit) or discards it — deleting the feature outright
1183// when it was a brand-new, never-committed sketch (cancel). The camera + rolled-to
1184// step are snapshotted on enter and restored on exit.
1185//
1186// This mirrors the reference-selection modal's enter/roll-before/finish/restore
1187// shape; kept in ONE appended block so concurrent edits to the primary impl land
1188// clean.
1189// ===========================================================================
1190