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