brep_render/engine_state/selection_ux.rs
1use super::*;
2
3impl EngineState {
4 /// Clear the current SELECTION (Esc): drop all selected solids/faces/edges/
5 /// vertices (hover is left untouched). Bumps the emphasis generation + marks
6 /// dirty only when something was actually cleared. Returns whether it changed.
7 pub fn clear_selection(&mut self) -> bool {
8 // The viewport-selected CONSTRAINT (label click) clears with the rest.
9 let had_constraint = self.selected_constraint.is_some();
10 self.constraint_deselect();
11 let had_datums = !self.emphasis.selected_datums.is_empty();
12 let had = had_constraint
13 || !self.emphasis.selected_solids.is_empty()
14 || !self.emphasis.selected_faces.is_empty()
15 || !self.emphasis.selected_edges.is_empty()
16 || !self.emphasis.selected_vertices.is_empty()
17 || had_datums;
18 if had {
19 self.emphasis.selected_solids.clear();
20 self.emphasis.selected_faces.clear();
21 self.emphasis.selected_edges.clear();
22 self.emphasis.selected_vertices.clear();
23 self.emphasis.selected_datums.clear();
24 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
25 self.dirty = true;
26 }
27 // A cleared datum drops its selection accent — re-feed the datum planes so
28 // the highlight disappears immediately (no re-run needed).
29 if had_datums {
30 self.refresh_construction_datums();
31 }
32 had
33 }
34
35 /// REPLACE the whole selection with these named entities — the write twin of
36 /// [`Self::selection_json`], and the only way to put a MULTI-entity selection
37 /// back the way it was (`select_by_name` takes one name and clears the rest).
38 ///
39 /// Names are taken as given: an unknown name simply highlights nothing, the
40 /// same as a stale name after a rebuild. VERTICES are not addressable here —
41 /// they have no kernel name and emphasis keys them by position
42 /// ([`Self::select_vertex_by_position`]) — so a saved selection's vertices do
43 /// not come back through this call.
44 pub fn set_selection(
45 &mut self,
46 solids: &[String],
47 faces: &[String],
48 edges: &[String],
49 datums: &[String],
50 ) {
51 let had_datums = !self.emphasis.selected_datums.is_empty();
52 self.emphasis.selected_solids = solids.iter().cloned().collect();
53 self.emphasis.selected_faces = faces.iter().cloned().collect();
54 self.emphasis.selected_edges = edges.iter().cloned().collect();
55 self.emphasis.selected_datums = datums.iter().cloned().collect();
56 self.emphasis.selected_vertices.clear();
57 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
58 self.dirty = true;
59 if had_datums || !datums.is_empty() {
60 self.refresh_construction_datums();
61 }
62 }
63
64 /// Select the top-priority pick under CSS-pixel `(x, y)` that the SELECTION
65 /// FILTER admits — replacing the current selection (a plain viewport click).
66 /// A miss (or a click when the filter admits nothing) clears the selection.
67 /// Marks dirty when the selection changed; returns whether something was
68 /// selected. The by-kind honoring lives in [`select_filtered_at`] in the
69 /// appended selection-filter impl block (kept separate so concurrent edits to
70 /// this primary block don't conflict).
71 pub fn select_top_at(&mut self, x: f64, y: f64) -> bool {
72 self.select_filtered_at(x, y)
73 }
74
75 /// The current SELECTION (not hover) as JSON
76 /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — lets a UI / the
77 /// headed verifier read selection state (e.g. assert Esc cleared it).
78 pub fn selection_json(&self) -> String {
79 let solids: Vec<&String> = self.emphasis.selected_solids.iter().collect();
80 let faces: Vec<&String> = self.emphasis.selected_faces.iter().collect();
81 let edges: Vec<&String> = self.emphasis.selected_edges.iter().collect();
82 let datums: Vec<&String> = self.emphasis.selected_datums.iter().collect();
83 serde_json::json!({
84 "solids": solids,
85 "faces": faces,
86 "edges": edges,
87 "datums": datums,
88 "vertices": self.emphasis.selected_vertices.len(),
89 })
90 .to_string()
91 }
92
93 // --- Reference-selection widget (the engine-native picker, #42) --------
94 //
95 // A feature-dialog reference field activates this MODAL: the UI hides the
96 // rest of itself and shows only the widget's list + Finish/Cancel; the engine
97 // rolls to the pre-feature "before" state, highlights the running selection
98 // (via `emphasis`), and each click in the viewport type-constrained-picks a
99 // name into the list. Finish writes the names into the feature params (via
100 // the same `update_feature_params` path) and restores; Cancel discards. The
101 // list of names is the whole state — no event-on-object wiring.
102
103 /// True while the reference-selection modal is active (the shell hides the
104 /// rest of the UI and the viewport routes clicks to picking).
105 pub fn ref_select_active(&self) -> bool {
106 self.ref_select.is_some()
107 }
108
109 /// Enter reference-selection mode for feature `feature_id`'s param at `path`.
110 /// Seeds the running list from `seed_names` (the field's current value), rolls
111 /// the model to the pre-feature "before" state (the step just before the
112 /// edited feature ran), and highlights the seeded names. `filter` constrains
113 /// the pick kind (`["SOLID"]`, `["FACE"]`, …); `multiple` allows a list.
114 pub fn begin_ref_select(
115 &mut self,
116 feature_id: &str,
117 path: Vec<String>,
118 label: String,
119 filter: Vec<String>,
120 multiple: bool,
121 seed_names: Vec<String>,
122 ) {
123 let restore_index = self.history.rollback();
124 // "Before" = the step just before the edited feature ran, so the user
125 // picks against the correct geometry. Clamp at 0 for the first feature.
126 let before = self
127 .history
128 .index_of(feature_id)
129 .map(|i| i.saturating_sub(1))
130 .unwrap_or(restore_index);
131 // Constrain the GLOBAL selection filter to exactly the kinds this field
132 // permits: this drives BOTH click-picking (`ref_select_click`) AND
133 // hover-highlighting (`hover_at`, which reads `selection_filter`), so only
134 // the allowed kinds highlight/select while the picker is active. An
135 // absent/construction-only field filter maps to all-enabled (see
136 // `from_ref_filter`). Restored to the all-enabled default on finish/cancel
137 // (`end_ref_select`).
138 self.selection_filter = SelectionFilter::from_ref_filter(&filter);
139 self.ref_select = Some(RefSelectState {
140 feature_id: feature_id.to_string(),
141 path,
142 label,
143 filter,
144 multiple,
145 names: seed_names,
146 restore_index,
147 target: RefSelectTarget::Feature,
148 });
149 // Roll to the before-state (re-runs + marks dirty), then light up the seed.
150 self.history.set_rollback(before);
151 self.rerun_history();
152 self.sync_ref_select_emphasis();
153 }
154
155 /// The running list of picked names (empty when not active) — the modal UI
156 /// reads this back to draw its one-per-line list.
157 pub fn ref_select_names(&self) -> Vec<String> {
158 self.ref_select
159 .as_ref()
160 .map(|r| r.names.clone())
161 .unwrap_or_default()
162 }
163
164 /// The active field's label (for the modal heading), or empty.
165 pub fn ref_select_label(&self) -> String {
166 self.ref_select
167 .as_ref()
168 .map(|r| r.label.clone())
169 .unwrap_or_default()
170 }
171
172 /// A one-line summary of the active field for the modal heading:
173 /// `"Tool solids (SOLID, multiple)"`.
174 pub fn ref_select_prompt(&self) -> String {
175 match &self.ref_select {
176 Some(r) => format!(
177 "{} ({}{})",
178 r.label,
179 r.filter.join("/"),
180 if r.multiple { ", multiple" } else { "" }
181 ),
182 None => String::new(),
183 }
184 }
185
186 /// A viewport click while active: type-constrained-pick the nearest allowed
187 /// hit under CSS-pixel `(x, y)` and add its name to the running list (single
188 /// fields replace; multiple fields append, de-duplicated). Re-lights the
189 /// highlight. No-op on a miss / an empty (unnamed) hit.
190 pub fn ref_select_click(&mut self, x: f64, y: f64) {
191 let Some(state) = self.ref_select.as_ref() else {
192 return;
193 };
194 let filter = state.filter.clone();
195 let multiple = state.multiple;
196 let target = state.target;
197 // The field's RAW kind strings drive the pick (`pick_top_at` reads
198 // `DATUM` as an alias of `PLANE`), so a `["PLANE","FACE"]` sketchPlane
199 // field now picks a construction plane through the ORDINARY candidate
200 // list — including one sitting under a face, which the geometry-miss
201 // fallback below could never reach.
202 // A spline anchor attaches to a PORT: whatever part of a port's drawn
203 // sheet was hit (its line, its base vertex, the sheet itself), the
204 // pick resolves to the OWNING sheet, which is keyed by the port id;
205 // anything that is not a port is refused with a notice.
206 if let RefSelectTarget::SplineAnchor { .. } = target {
207 let Some(hit) = self.pick_top_at(x, y, &filter) else {
208 return;
209 };
210 let owner = if hit.solid.trim().is_empty() { hit.name.clone() } else { hit.solid.clone() };
211 if !self.is_port_id(&owner) {
212 self.push_notice(format!("'{owner}' is not a port — pick a port to attach the anchor"));
213 return;
214 }
215 let state = self.ref_select.as_mut().expect("active by guard above");
216 state.names = vec![owner];
217 self.sync_ref_select_emphasis();
218 return;
219 }
220 let picked = match self.pick_top_at(x, y, &filter) {
221 Some(hit) if hit.kind == pick::PickKind::Plane => {
222 // Accept ONLY a resolved D/P frame name, the same guard the
223 // fallback applies — a stray widget-fed plane never lands in a
224 // reference field.
225 if self.datum_feature_for_name(&hit.name).is_none() {
226 return;
227 }
228 hit.name
229 }
230 Some(hit) if !hit.name.trim().is_empty() => hit.name,
231 Some(hit) => {
232 // A vertex pick carries no kernel name. For an ASSEMBLY CONSTRAINT
233 // field that accepts VERTEX, build the `{solidName}@x,y,z` ref with
234 // COMPONENT-LOCAL coordinates (world pick · owning-component
235 // pose⁻¹ — the lane-E selection contract; the kernel resolver snaps
236 // to the nearest topology vertex). Everything else stays a no-op.
237 if !matches!(hit.kind, pick::PickKind::Vertex) {
238 return;
239 }
240 match target {
241 RefSelectTarget::AssemblyConstraint => {
242 match self.component_vertex_ref(&hit.solid, hit.position) {
243 Some(vertex_ref) => vertex_ref,
244 None => return, // not component geometry — constraints reject it anyway
245 }
246 }
247 // PMI resolves against the world-posed resident solids, so
248 // its vertex refs carry WORLD coordinates.
249 RefSelectTarget::Pmi => super::pmi_ops::world_vertex_ref(&hit.solid, hit.position),
250 _ => return,
251 }
252 }
253 None => {
254 // TOTAL MISS. The plane CARDS are candidates above and they are
255 // the same set `datum_pick` tests, so in the app this arm is
256 // unreachable for a plane field; it is kept as a second line of
257 // defense for the tested `["PLANE","FACE"]` sketchPlane flow (and
258 // it is the ONLY path that would reach a datum AXIS, were one ever
259 // fed). Accept ONLY a resolved D/P frame name
260 // (`datum_feature_for_name`, mirroring `select_datum`'s guard) so an
261 // AXIS name — which `datum_pick` may also return — never lands in a
262 // plane field.
263 let admits_plane = filter
264 .iter()
265 .any(|k| k.eq_ignore_ascii_case("PLANE") || k.eq_ignore_ascii_case("DATUM"));
266 if !admits_plane {
267 return;
268 }
269 let name = self.datum_pick(x, y);
270 if name.is_empty() || self.datum_feature_for_name(&name).is_none() {
271 return;
272 }
273 name
274 }
275 };
276 let state = self.ref_select.as_mut().expect("active by guard above");
277 if multiple {
278 if !state.names.iter().any(|n| n == &picked) {
279 state.names.push(picked);
280 }
281 } else {
282 state.names = vec![picked];
283 }
284 self.sync_ref_select_emphasis();
285 }
286
287 /// Remove the name at `index` from the running list (the modal's per-line X).
288 pub fn ref_select_remove(&mut self, index: usize) {
289 if let Some(state) = self.ref_select.as_mut() {
290 if index < state.names.len() {
291 state.names.remove(index);
292 }
293 }
294 self.sync_ref_select_emphasis();
295 }
296
297 /// Finish: write the running names into the edited feature's params at the
298 /// field path, restore the rolled-to step, clear the highlight, and re-run so
299 /// the feature rebuilds with the chosen references.
300 pub fn finish_ref_select(&mut self) {
301 let Some(state) = self.ref_select.take() else {
302 return;
303 };
304 // An ASSEMBLY CONSTRAINT field commits through the constraint update
305 // lane (kernel session + document fold), not feature params; the shared
306 // end tail below still restores the roll + re-runs (which re-solves).
307 if state.target == RefSelectTarget::AssemblyConstraint {
308 self.assembly_commit_constraint_refs(
309 &state.feature_id,
310 &state.path,
311 &state.names,
312 state.multiple,
313 );
314 self.end_ref_select(state.restore_index);
315 return;
316 }
317 // A PMI annotation field commits into the document's pmi block (no
318 // history feature is involved); the shared tail restores + re-runs,
319 // which resolves the annotation against the fresh scene.
320 if state.target == RefSelectTarget::Pmi {
321 self.pmi_commit_refs(&state.feature_id, &state.path, &state.names, state.multiple);
322 self.end_ref_select(state.restore_index);
323 return;
324 }
325 // A SPLINE ANCHOR attachment commits through the anchor lane (the
326 // persistent spline document), then the shared tail restores + re-runs.
327 if let RefSelectTarget::SplineAnchor { index } = state.target {
328 if let Some(port) = state.names.first() {
329 self.attach_spline_anchor_no_rerun(&state.feature_id, index, port);
330 }
331 self.end_ref_select(state.restore_index);
332 return;
333 }
334 if let Some(index) = self.history.index_of(&state.feature_id) {
335 let mut params = self
336 .history
337 .feature_params(index)
338 .unwrap_or_else(|| serde_json::json!({}));
339 let value = if state.multiple {
340 serde_json::Value::Array(
341 state
342 .names
343 .iter()
344 .cloned()
345 .map(serde_json::Value::String)
346 .collect(),
347 )
348 } else {
349 serde_json::Value::String(state.names.first().cloned().unwrap_or_default())
350 };
351 set_json_at(&mut params, &state.path, value);
352 self.history.set_feature_params(index, params);
353 }
354 self.end_ref_select(state.restore_index);
355 }
356
357 /// Cancel: discard the running selection, clear the highlight, restore the
358 /// rolled-to step, and re-run (no param change).
359 pub fn cancel_ref_select(&mut self) {
360 if let Some(state) = self.ref_select.take() {
361 self.end_ref_select(state.restore_index);
362 }
363 }
364
365 /// Restore the rolled-to step + clear emphasis + re-run + reset the selection
366 /// filter to the all-enabled default (shared Finish/Cancel tail).
367 ///
368 /// Resetting to the DEFAULT (not a saved "prior" filter) is deliberate: the
369 /// spec baseline out of ref-select is "all kinds enabled", and `begin_ref_select`
370 /// overwrites `ref_select` without routing through here, so a stashed prior
371 /// could be a stale already-constrained filter. Living in this shared tail also
372 /// means a stray `finish_ref_select()` while inactive (early return on `take`)
373 /// never clobbers the filter.
374 fn end_ref_select(&mut self, restore_index: usize) {
375 let _ = self.emphasis.apply_json("{}");
376 self.selection_filter = SelectionFilter::default();
377 self.history.set_rollback(restore_index);
378 self.rerun_history();
379 }
380
381 /// Drive the selection highlight (`emphasis`) from the running name list so
382 /// picks light up in the viewport. A field may allow SEVERAL kinds at once
383 /// (e.g. `FACE`/`EDGE`), and a pick can be any of them, so every picked name is
384 /// fed to EVERY name-based bucket the filter permits — a name only ever matches
385 /// its own kind's entities (edge names carry the `|…[n]` topology form, faces do
386 /// not), so the cross-listing is harmless and each pick highlights correctly.
387 /// (The old code bucketed ALL names by `filter.first()` only, so an EDGE pick
388 /// under a `FACE`-first filter landed in `faces`, matched nothing, and never
389 /// showed.) VERTEX picks are position-keyed, not name-keyed, so they can't be
390 /// emphasized from a name list here.
391 pub(crate) fn sync_ref_select_emphasis(&mut self) {
392 let json = match &self.ref_select {
393 Some(state) => {
394 let names = serde_json::json!(state.names);
395 let mut selected = serde_json::Map::new();
396 for kind in &state.filter {
397 // Case-INSENSITIVE match, mirroring `SelectionFilter::set` (which
398 // pick-filtering uses via `from_ref_filter`). Without this, a
399 // schema that spelled a kind non-canonically (e.g. `"Edge"`) would
400 // let the user PICK that kind but silently skip its seed HIGHLIGHT
401 // here — a lenient-pick / strict-highlight split. VERTEX is inert:
402 // vertex picks carry no kernel name, so `ref_select_click` never
403 // records one in `names` (empty-name early-return), so there is
404 // nothing to highlight by name.
405 let bucket = match kind.to_ascii_uppercase().as_str() {
406 "FACE" => "faces",
407 "EDGE" => "edges",
408 "SOLID" => "solids",
409 "PLANE" | "DATUM" => "datums",
410 _ => continue, // VERTEX (never name-seeded) / unknown
411 };
412 selected.entry(bucket.to_string()).or_insert_with(|| names.clone());
413 }
414 // No highlightable kind in the filter → fall back to solids (the
415 // prior default) so at least solid-name picks still light up.
416 if selected.is_empty() {
417 selected.insert("solids".to_string(), names);
418 }
419 serde_json::json!({ "selected": selected }).to_string()
420 }
421 None => "{}".to_string(),
422 };
423 let _ = self.emphasis.apply_json(&json);
424 // A picked construction PLANE/DATUM highlights through the datum-plane
425 // WIDGET, whose accent is baked at feed time (`refresh_construction_datums`
426 // reads `emphasis.selected_datums`) — so a plain `apply_json` does not
427 // re-color it. Re-feed here so a datum pick lights up (and un-lights on
428 // remove) in the modal. Harmless for non-datum fields (no datum selected →
429 // an ordinary calm-color re-feed).
430 self.refresh_construction_datums();
431 self.dirty = true;
432 }
433}
434
435/// Write `value` into `root` at `path` (object-key chain), auto-vivifying
436/// intermediate objects — the engine-side twin of the form's nested setter, used
437/// to commit a reference field's picked names back into the feature params.
438pub(crate) fn set_json_at(root: &mut serde_json::Value, path: &[String], value: serde_json::Value) {
439 if path.is_empty() {
440 *root = value;
441 return;
442 }
443 if !root.is_object() {
444 *root = serde_json::Value::Object(serde_json::Map::new());
445 }
446 let mut cur = root;
447 for seg in &path[..path.len() - 1] {
448 let obj = cur.as_object_mut().expect("object by construction");
449 cur = obj
450 .entry(seg.clone())
451 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
452 if !cur.is_object() {
453 *cur = serde_json::Value::Object(serde_json::Map::new());
454 }
455 }
456 cur.as_object_mut()
457 .expect("object by construction")
458 .insert(path[path.len() - 1].clone(), value);
459}
460
461impl EngineState {
462 /// Hover-highlight the TOP-priority pick under CSS-pixel `(x, y)` whose kind
463 /// the selection filter admits, setting it HOVERED in `emphasis` (the
464 /// renderer tints it). A miss — or a filter admitting nothing — clears the
465 /// hover. No-ops (returns `false`, no dirty) when the hovered entity is
466 /// unchanged, so a stationary pointer over the same face doesn't re-render
467 /// every frame (the `k === prevK` early-out). Returns
468 /// whether the hover state changed.
469 pub fn hover_at(&mut self, x: f64, y: f64) -> bool {
470 let kinds = self.selection_filter.enabled_kinds();
471 if kinds.is_empty() {
472 return self.clear_hover();
473 }
474 match self.pick_top_at(x, y, &kinds) {
475 Some(hit) => {
476 if self.hover_is(&hit) {
477 return false; // unchanged — keep the frame clean.
478 }
479 self.set_hover_to_candidate(&hit);
480 true
481 }
482 None => self.clear_hover(),
483 }
484 }
485
486 /// Clear the hover highlight (pointer moved to empty space / off the
487 /// viewport). Bumps the emphasis generation + marks dirty only when a hover
488 /// was actually lit. Returns whether it changed. (Distinct from
489 /// [`clear_selection`](Self::clear_selection), which leaves hover alone.)
490 pub fn clear_hover(&mut self) -> bool {
491 let had_datums = !self.emphasis.hovered_datums.is_empty();
492 let had = !self.emphasis.hovered_solids.is_empty()
493 || !self.emphasis.hovered_faces.is_empty()
494 || !self.emphasis.hovered_edges.is_empty()
495 || !self.emphasis.hovered_vertices.is_empty()
496 || had_datums;
497 if had {
498 self.emphasis.hovered_solids.clear();
499 self.emphasis.hovered_faces.clear();
500 self.emphasis.hovered_edges.clear();
501 self.emphasis.hovered_vertices.clear();
502 self.emphasis.hovered_datums.clear();
503 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
504 self.dirty = true;
505 }
506 // A hovered plane's accent is baked into the datum feed, so dropping the
507 // hover needs a re-feed to un-light it (the datum twin of the selection
508 // re-feed in `clear_selection`).
509 if had_datums {
510 self.refresh_construction_datums();
511 }
512 had
513 }
514
515 /// The current HOVER (not selection) as JSON
516 /// `{ solids:[..], faces:[..], edges:[..], vertices: n }` — the hover twin of
517 /// [`selection_json`](Self::selection_json) so a UI / the headed verifier can
518 /// assert that moving the pointer over a face lit the hover emphasis.
519 pub fn hovered_json(&self) -> String {
520 let solids: Vec<&String> = self.emphasis.hovered_solids.iter().collect();
521 let faces: Vec<&String> = self.emphasis.hovered_faces.iter().collect();
522 let edges: Vec<&String> = self.emphasis.hovered_edges.iter().collect();
523 let datums: Vec<&String> = self.emphasis.hovered_datums.iter().collect();
524 serde_json::json!({
525 "solids": solids,
526 "faces": faces,
527 "edges": edges,
528 "datums": datums,
529 "vertices": self.emphasis.hovered_vertices.len(),
530 })
531 .to_string()
532 }
533
534 /// TOGGLE the top admitted pick under CSS-pixel `(x, y)` in the current
535 /// selection (a **Ctrl/Cmd+click**): add it if absent, remove it if present,
536 /// leaving the rest of the selection intact (unlike [`select_top_at`], which
537 /// REPLACES). With the COMPONENT filter on, a hit on component geometry
538 /// toggles the whole component (all member solids as one unit). A miss — or
539 /// a filter admitting nothing — leaves the selection untouched (additive
540 /// mode never clears). Returns whether a hit was toggled.
541 pub fn select_toggle_at(&mut self, x: f64, y: f64) -> bool {
542 let kinds = self.selection_filter.enabled_kinds();
543 let component_on = self.selection_filter.component;
544 if kinds.is_empty() && !component_on {
545 return false;
546 }
547 let pick_kinds = if kinds.is_empty() {
548 SelectionFilter::default().enabled_kinds()
549 } else {
550 kinds.clone()
551 };
552 match self.pick_top_at(x, y, &pick_kinds) {
553 Some(hit) => {
554 // COMPONENT promotion (the select_filtered_at rule, additively):
555 // toggle the WHOLE component's member solids as one unit, so a
556 // Ctrl/Cmd+click gathers the multi-component selections the
557 // pair-constraint offers key on.
558 if component_on {
559 if let Some(owner) = self.hit_owning_component(&hit) {
560 self.toggle_component_selection(&owner);
561 return true;
562 }
563 }
564 if kinds.is_empty() {
565 return false; // COMPONENT-only, non-component hit.
566 }
567 self.toggle_candidate(&hit);
568 true
569 }
570 None => false,
571 }
572 }
573
574 /// The RANKED, filter-respecting candidates under CSS-pixel `(x, y)` as JSON
575 /// `[{kind, name, solid, depth}]` — the "candidates under the cursor" list
576 /// (feeds the pick-list popup + the headed verifier).
577 ///
578 /// Sorted category-major in the pick-list order (VERTEX > EDGE > FACE >
579 /// PLANE > SOLID > COMPONENT), nearest (smallest depth) first within each
580 /// category — see [`candidates_filtered_at`](Self::candidates_filtered_at).
581 pub fn candidates_at(&self, x: f64, y: f64) -> String {
582 let list = self.candidates_filtered_at(x, y);
583 let out: Vec<serde_json::Value> = list
584 .iter()
585 .map(|c| {
586 serde_json::json!({
587 "kind": self.candidate_kind_label(c),
588 "name": c.name,
589 "solid": c.solid,
590 "depth": c.depth,
591 })
592 })
593 .collect();
594 serde_json::Value::Array(out).to_string()
595 }
596
597 /// The same ranked, filter-respecting candidate list as typed values (the
598 /// in-process egui pick-list popup consumes these directly, then re-hovers /
599 /// selects a chosen one via [`hover_candidate`](Self::hover_candidate) /
600 /// [`select_candidate`](Self::select_candidate) /
601 /// [`toggle_candidate`](Self::toggle_candidate)). EMPTY when the filter admits
602 /// nothing (not the `pick_filtered` "empty filter = any" case).
603 ///
604 /// The raw list is [`pick_candidates_at`](Self::pick_candidates_at), so
605 /// construction PLANE cards are ordinary entries here — a plane under other
606 /// geometry is listed (right after the faces) instead of being reachable only
607 /// on a geometry miss.
608 ///
609 /// With the filter's COMPONENT lane on, one COMPONENT entry per owning
610 /// assembly component of ANY raw hit is appended (name = component id, depth
611 /// = the component's nearest hit) — a raw hit of a filtered-OFF kind still
612 /// reaches its owning component, mirroring `select_filtered_at`'s
613 /// component-only promotion (a PLANE hit owns no component). The final list is
614 /// sorted category-major in the pick-list order
615 /// (VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest first within
616 /// each category.
617 pub fn candidates_filtered_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
618 let kinds = self.selection_filter.enabled_kinds();
619 let component_on = self.selection_filter.component;
620 if kinds.is_empty() && !component_on {
621 return Vec::new();
622 }
623 let raw = self.pick_candidates_at(x, y);
624 let mut out: Vec<pick::PickCandidate> = raw
625 .iter()
626 .filter(|c| self.candidate_admitted(&kinds, c))
627 .cloned()
628 .collect();
629 if component_on {
630 // One entry per owning component, carrying its NEAREST member hit's
631 // depth/position (raw hits are kind-major, so scan them all).
632 let mut components: Vec<pick::PickCandidate> = Vec::new();
633 for hit in &raw {
634 let Some(owner) = self.hit_owning_component(hit) else {
635 continue;
636 };
637 match components.iter_mut().find(|c| c.name == owner) {
638 Some(entry) => {
639 if hit.depth < entry.depth {
640 entry.depth = hit.depth;
641 entry.position = hit.position;
642 }
643 }
644 None => components.push(pick::PickCandidate {
645 kind: pick::PickKind::Component,
646 name: owner,
647 solid: String::new(),
648 depth: hit.depth,
649 screen_dist: hit.screen_dist,
650 position: hit.position,
651 }),
652 }
653 }
654 out.extend(components);
655 }
656 // Category-major (PickKind's discriminant order IS the pick-list order),
657 // nearest first within a category — the shared ordering every pick path
658 // uses, so the appended COMPONENT rows land in the same sort.
659 super::plane_pick::sort_pick_candidates(&mut out);
660 out
661 }
662
663 /// Whether a candidate is CURRENTLY selected (drives the pick-list popup's
664 /// per-row selected state so click-toggling reads back visually).
665 pub fn candidate_is_selected(&self, candidate: &pick::PickCandidate) -> bool {
666 use crate::pick::PickKind;
667 match candidate.kind {
668 PickKind::Solid => self
669 .emphasis
670 .selected_solids
671 .contains(&self.candidate_solid_name(candidate)),
672 PickKind::Face => self.emphasis.selected_faces.contains(&candidate.name),
673 PickKind::Edge => self.emphasis.selected_edges.contains(&candidate.name),
674 PickKind::Vertex => self
675 .emphasis
676 .selected_vertices
677 .iter()
678 .any(|v| Self::vertex_ref_matches(v, candidate)),
679 PickKind::Plane => self.emphasis.selected_datums.contains(&candidate.name),
680 PickKind::Component => {
681 let members = self.component_member_solids(&candidate.name);
682 !members.is_empty()
683 && members
684 .iter()
685 .all(|m| self.emphasis.selected_solids.contains(m))
686 }
687 }
688 }
689
690 /// Hover a SPECIFIC candidate (the popup entry the pointer is over) — sets it
691 /// HOVERED in `emphasis`, replacing any prior hover.
692 pub fn hover_candidate(&mut self, candidate: &pick::PickCandidate) {
693 self.set_hover_to_candidate(candidate);
694 }
695
696 /// REPLACE the selection with a specific candidate (a plain click on a popup
697 /// entry) — reuses the same bucketing as a plain viewport click.
698 pub fn select_candidate(&mut self, candidate: &pick::PickCandidate) {
699 self.set_selection_to_candidate(candidate);
700 }
701
702 /// TOGGLE a specific candidate in the selection (a Ctrl/Cmd+click on a popup
703 /// entry, or the [`select_toggle_at`](Self::select_toggle_at) hit): add if
704 /// absent, remove if present. Returns whether it is NOW selected (`true` =
705 /// added, `false` = removed). Bumps the emphasis generation + marks dirty.
706 pub fn toggle_candidate(&mut self, candidate: &pick::PickCandidate) -> bool {
707 use crate::pick::PickKind;
708 let now_selected = match candidate.kind {
709 PickKind::Solid => {
710 let name = self.candidate_solid_name(candidate);
711 if self.emphasis.selected_solids.remove(&name) {
712 false
713 } else {
714 self.emphasis.selected_solids.insert(name);
715 true
716 }
717 }
718 PickKind::Face => {
719 if self.emphasis.selected_faces.remove(&candidate.name) {
720 false
721 } else {
722 self.emphasis.selected_faces.insert(candidate.name.clone());
723 true
724 }
725 }
726 PickKind::Edge => {
727 if self.emphasis.selected_edges.remove(&candidate.name) {
728 false
729 } else {
730 self.emphasis.selected_edges.insert(candidate.name.clone());
731 true
732 }
733 }
734 PickKind::Vertex => {
735 if let Some(index) = self
736 .emphasis
737 .selected_vertices
738 .iter()
739 .position(|v| Self::vertex_ref_matches(v, candidate))
740 {
741 self.emphasis.selected_vertices.remove(index);
742 false
743 } else {
744 self.emphasis.selected_vertices.push(crate::style::VertexRef {
745 solid: candidate.solid.clone(),
746 position: candidate.position,
747 });
748 true
749 }
750 }
751 PickKind::Plane => {
752 // A construction PLANE toggles by FRAME NAME, the datum bucket the
753 // Scene-tree row / `select_datum` fill.
754 if self.emphasis.selected_datums.remove(&candidate.name) {
755 false
756 } else {
757 self.emphasis.selected_datums.insert(candidate.name.clone());
758 true
759 }
760 }
761 PickKind::Component => {
762 // The whole component toggles as ONE unit (member solids), the
763 // same rule as the Ctrl/Cmd+click COMPONENT promotion.
764 let was_selected = self.candidate_is_selected(candidate);
765 self.toggle_component_selection(&candidate.name);
766 !was_selected
767 }
768 };
769 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
770 self.dirty = true;
771 // A plane's accent is baked into the datum feed, so a toggled plane only
772 // lights / un-lights after a re-feed.
773 if candidate.kind == PickKind::Plane {
774 self.refresh_construction_datums();
775 }
776 now_selected
777 }
778
779 /// Set the hover emphasis to exactly one candidate (bucketed by kind), the
780 /// hover twin of `set_selection_to_candidate`.
781 fn set_hover_to_candidate(&mut self, candidate: &pick::PickCandidate) {
782 use crate::pick::PickKind;
783 // A hovered PLANE's accent lives in the datum FEED, so the re-feed below
784 // is needed both when a plane becomes hovered and when one stops being.
785 let touches_datums =
786 !self.emphasis.hovered_datums.is_empty() || candidate.kind == PickKind::Plane;
787 self.emphasis.hovered_solids.clear();
788 self.emphasis.hovered_faces.clear();
789 self.emphasis.hovered_edges.clear();
790 self.emphasis.hovered_vertices.clear();
791 self.emphasis.hovered_datums.clear();
792 match candidate.kind {
793 PickKind::Solid => {
794 self.emphasis
795 .hovered_solids
796 .insert(self.candidate_solid_name(candidate));
797 }
798 PickKind::Face => {
799 self.emphasis.hovered_faces.insert(candidate.name.clone());
800 }
801 PickKind::Edge => {
802 self.emphasis.hovered_edges.insert(candidate.name.clone());
803 }
804 PickKind::Vertex => {
805 self.emphasis.hovered_vertices.push(crate::style::VertexRef {
806 solid: candidate.solid.clone(),
807 position: candidate.position,
808 });
809 }
810 PickKind::Plane => {
811 self.emphasis.hovered_datums.insert(candidate.name.clone());
812 }
813 PickKind::Component => {
814 // Hovering a COMPONENT entry lights every member solid.
815 for member in self.component_member_solids(&candidate.name) {
816 self.emphasis.hovered_solids.insert(member);
817 }
818 }
819 }
820 self.emphasis.generation = self.emphasis.generation.wrapping_add(1);
821 self.dirty = true;
822 if touches_datums {
823 self.refresh_construction_datums();
824 }
825 }
826
827 /// Whether the CURRENT hover is exactly this one candidate (the `hover_at`
828 /// early-out) — a single hovered entity that matches `candidate` (for a
829 /// COMPONENT candidate: exactly its member-solid set). `pub(super)` so the
830 /// Scene-tree row hover (`scene_query.rs`) shares the same identity test for
831 /// its dedupe + its "only clear what the tree lit" rule.
832 pub(super) fn hover_is(&self, candidate: &pick::PickCandidate) -> bool {
833 use crate::pick::PickKind;
834 if candidate.kind == PickKind::Component {
835 let members = self.component_member_solids(&candidate.name);
836 return !members.is_empty()
837 && self.emphasis.hovered_faces.is_empty()
838 && self.emphasis.hovered_edges.is_empty()
839 && self.emphasis.hovered_vertices.is_empty()
840 && self.emphasis.hovered_datums.is_empty()
841 && self.emphasis.hovered_solids.len() == members.len()
842 && members.iter().all(|m| self.emphasis.hovered_solids.contains(m));
843 }
844 let total = self.emphasis.hovered_solids.len()
845 + self.emphasis.hovered_faces.len()
846 + self.emphasis.hovered_edges.len()
847 + self.emphasis.hovered_vertices.len()
848 + self.emphasis.hovered_datums.len();
849 if total != 1 {
850 return false;
851 }
852 match candidate.kind {
853 PickKind::Solid => self
854 .emphasis
855 .hovered_solids
856 .contains(&self.candidate_solid_name(candidate)),
857 PickKind::Face => self.emphasis.hovered_faces.contains(&candidate.name),
858 PickKind::Edge => self.emphasis.hovered_edges.contains(&candidate.name),
859 PickKind::Vertex => self
860 .emphasis
861 .hovered_vertices
862 .iter()
863 .any(|v| Self::vertex_ref_matches(v, candidate)),
864 PickKind::Plane => self.emphasis.hovered_datums.contains(&candidate.name),
865 PickKind::Component => false, // handled by the early return above
866 }
867 }
868
869 /// The scene name a SOLID candidate resolves to (its owning `solid`, falling
870 /// back to `name` when the pick didn't carry one) — the same rule
871 /// `set_selection_to_candidate` uses.
872 fn candidate_solid_name(&self, candidate: &pick::PickCandidate) -> String {
873 if candidate.solid.is_empty() {
874 candidate.name.clone()
875 } else {
876 candidate.solid.clone()
877 }
878 }
879
880 /// Vertex identity: same owning solid + position within the emphasis match
881 /// tolerance (vertices carry no kernel name, so they resolve by solid+pos).
882 fn vertex_ref_matches(v: &crate::style::VertexRef, candidate: &pick::PickCandidate) -> bool {
883 const TOL: f64 = 1e-4;
884 v.solid == candidate.solid
885 && (v.position[0] - candidate.position[0]).abs() <= TOL
886 && (v.position[1] - candidate.position[1]).abs() <= TOL
887 && (v.position[2] - candidate.position[2]).abs() <= TOL
888 }
889}
890
891// ---------------------------------------------------------------------------
892// Sketch display (S0) — read-only overlay of a solved SketchSession.
893//
894// Additive, self-contained: a solved sketch is fed to the general `set_overlay`
895// channel as the named groups `sketch-geometry` (lines) and `sketch-points`
896// (billboarded points), colored by solver mobility. No interaction (the tools /
897// picking / dimensions of later slices live elsewhere); this block only pushes /
898// clears the display geometry.
899// ---------------------------------------------------------------------------
900impl EngineState {
901 /// Display a solved [`crate::sketch::SketchSession`] as a read-only overlay.
902 /// The plane geometry is tessellated to world space and pushed via
903 /// [`set_overlay_json`](Self::set_overlay_json); construction dashes are sized
904 /// against the LIVE camera so they stay screen-constant.
905 pub fn set_sketch_overlay(&mut self, session: &crate::sketch::SketchSession) {
906 let world_per_pixel = self.camera.world_per_pixel();
907 let json = session.overlay_json(world_per_pixel);
908 // The overlay channel accepts our exact `{groups:[…]}` shape; a parse
909 // failure would be a programming error in the tessellator, so drop it.
910 let _ = self.set_overlay_json(&json);
911 // The dimension leaders ride in their own `sketch-dim-leaders` group (S5).
912 let _ = self.set_overlay_json(&session.dim_leaders_overlay_json(world_per_pixel));
913 // The geometric-constraint glyphs ride in `sketch-constraint-glyphs` (S6c).
914 let _ = self.set_overlay_json(&session.constraint_glyphs_overlay_json(world_per_pixel));
915 // The zoom this screen-constant sizing was baked at, so the per-frame
916 // `ensure_sketch_overlay_current` re-bakes it when the camera zooms.
917 self.sketch_overlay_wpp = if world_per_pixel > 0.0 {
918 world_per_pixel
919 } else {
920 f64::MIN_POSITIVE
921 };
922 }
923
924 /// Remove the sketch overlay groups (feeding empty same-named groups upserts
925 /// them to empty, which the overlay channel treats as a removal — other
926 /// overlay groups are left untouched).
927 pub fn clear_sketch_overlay(&mut self) {
928 let _ = self.set_overlay_json(
929 "{\"groups\":[{\"name\":\"sketch-geometry\"},{\"name\":\"sketch-points\"},{\"name\":\"sketch-preview\"},{\"name\":\"sketch-dim-leaders\"},{\"name\":\"sketch-constraint-glyphs\"}]}",
930 );
931 self.sketch_overlay_wpp = 0.0;
932 }
933}
934
935// BREP private tests: f9c0d353bee5df0b