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