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