brep_render/engine_state/plane_pick.rs
1use super::*;
2
3// ===========================================================================
4// Construction PLANES as ordinary pick candidates.
5//
6// A construction plane / DATUM base plane is drawn by the datum WIDGET, not by
7// the scene, so `pick::pick` — which walks the render scene's solids — can never
8// see one. Before this module a plane was reachable only through the independent
9// `datum_pick` widget path, and ONLY when the geometry pick missed: any face,
10// edge or vertex under the pointer won outright and the plane was unreachable
11// (the reported "I can't select a plane if there is any other geometry under the
12// pointer"). Here the plane cards are turned into ordinary
13// [`pick::PickCandidate`]s and merged into the SAME ranked list every selection
14// path consumes, so a plane competes for a pick like any other entity and shows
15// up in the pick-list popup like any face.
16//
17// HOW A PLANE COMPETES — by KIND, not by depth. The candidate list is sorted
18// category-major in the pick-list order
19//
20// VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT
21//
22// (the `PickKind` discriminant order), nearest-first within a category. So a
23// plane ranks straight after faces: any face under the cursor still out-priorities
24// it, and the plane cannot swallow a click meant for the model — while it is
25// nonetheless IN the list, one row down, reachable through anything. Pure depth
26// ordering was rejected precisely because it trades one complaint for its mirror
27// image: a plane card in front of the model would win every click near it, and a
28// plane behind the model would still lose at a grazing angle.
29//
30// WHAT COUNTS AS "ON" A PLANE — the drawn rectangle. A construction plane is
31// mathematically infinite, but only the card the renderer draws is pickable
32// ([`crate::widgets::WidgetRegistry::datum_plane_hits`] → `DatumPlane::hit_point`,
33// the same live-camera `half()` extent `build_main_overlay` draws with — never a
34// bake, so the pickable region tracks the drawn one across a zoom). Either face
35// of the card picks: a plane seen from behind is still a legitimate selection.
36//
37// DATUMS RIDE ALONG. `refresh_construction_datums` feeds D (datum) and P (plane)
38// features through the one `{planes:[…]}` channel — a DATUM contributes its three
39// base planes (`{id}:XY|XZ|YZ`), a PLANE its single frame (`{id}`) — so both are
40// `PickKind::Plane` candidates with no extra machinery. Datum AXES are not fed by
41// that path and stay out of the pick list.
42// ===========================================================================
43
44impl EngineState {
45 /// EVERY pick candidate under CSS-pixel `(x, y)`: the scene's
46 /// vertices/edges/faces/solids ([`pick::pick`]) PLUS the construction PLANE
47 /// cards the pointer ray crosses, ranked together category-major
48 /// (VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest first within a
49 /// category.
50 ///
51 /// This is the RAW list (no selection filter); the filter-honoring callers are
52 /// [`Self::candidates_filtered_at`] (the pick-list popup) and
53 /// `Self::pick_top_at` (every single-hit pick).
54 pub fn pick_candidates_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
55 let mut out = pick::pick_unoccluded(&self.scene, &self.context_scene, &self.camera, x, y, &self.pick_options());
56 // An instructions occurrence pick reaches the GHOSTS too: the parts
57 // earlier steps placed are drawn in the context layer under their
58 // own names, their solids hidden, and a callout points at any part.
59 if self.ref_select.as_ref().is_some_and(|state| matches!(state.target, super::RefSelectTarget::Instructions { .. })) {
60 out.extend(
61 pick::pick(&self.context_scene, &self.camera, x, y, &self.pick_options())
62 .into_iter()
63 .filter(|candidate| candidate.kind == pick::PickKind::Solid),
64 );
65 }
66 // Appended AFTER `pick`'s own `MAX_CANDIDATES` truncation, so a plane is
67 // never dropped by a crowd of faces under the same pixel.
68 out.extend(self.plane_candidates_at(x, y));
69 sort_pick_candidates(&mut out);
70 out
71 }
72
73 /// The construction PLANE candidates under CSS-pixel `(x, y)` — one per DRAWN
74 /// datum-plane card the pointer ray crosses, carrying the datum FRAME name
75 /// (`Pl`, `Datum:XY`) and the world hit point. `solid` is empty (a plane has no
76 /// owning solid) and `screen_dist` is 0 (a card hit is exact, like a face hit).
77 ///
78 /// Hidden planes are absent by construction: the widget feed
79 /// ([`Self::refresh_construction_datums`]) only carries the planes that are
80 /// actually drawn, so a Scene-tree-hidden or rolled-back plane cannot be picked.
81 pub(super) fn plane_candidates_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
82 let cam = gizmo_camera(&self.camera);
83 let (_, _, forward) = self.camera.basis();
84 self.widgets
85 .datum_plane_hits(&cam, x as f32, y as f32)
86 .into_iter()
87 .map(|(name, point)| {
88 let position = [point[0] as f64, point[1] as f64, point[2] as f64];
89 pick::PickCandidate {
90 kind: pick::PickKind::Plane,
91 name,
92 solid: String::new(),
93 depth: crate::view::dot3(
94 crate::view::sub3(position, self.camera.eye),
95 forward,
96 ),
97 screen_dist: 0.0,
98 position,
99 }
100 })
101 .collect()
102 }
103
104 /// The TOP-ranked candidate under `(x, y)` whose kind `kinds` admits — the
105 /// planes-aware replacement for [`pick::pick_filtered`] (which only sees the
106 /// render scene). An EMPTY `kinds` means any kind, matching `pick_filtered`.
107 ///
108 /// `DATUM` is accepted as an alias of `PLANE` so a reference field whose schema
109 /// spells its construction kind `["DATUM"]` picks the same plane cards as
110 /// `["PLANE"]` (`ref_select_click` passes the field's RAW strings here).
111 pub(super) fn pick_top_at(
112 &self,
113 x: f64,
114 y: f64,
115 kinds: &[String],
116 ) -> Option<pick::PickCandidate> {
117 self.pick_candidates_at(x, y)
118 .into_iter()
119 // EMPTY = any kind, [`pick::pick_filtered`]'s rule (the callers that
120 // mean "select nothing" guard on an empty list BEFORE calling).
121 .find(|c| kinds.is_empty() || self.candidate_admitted(kinds, c))
122 }
123
124 /// Does `kinds` admit this CANDIDATE? [`kind_admitted`] plus the one
125 /// distinction the picker cannot make on its own: a committed SKETCH is drawn
126 /// as a synthesized sheet solid, so it and a real body arrive with the same
127 /// [`pick::PickKind::Solid`]. `SolidDisplay::is_sketch` splits them, and the
128 /// candidate then answers to `"SKETCH"` or to `"SOLID"` — never to both.
129 ///
130 /// Only the whole-object candidate is reclassified. A sketch's planar FACE and
131 /// its drawn EDGES keep their own kinds, which is what the profile fields
132 /// (`["SKETCH","FACE"]`, picking a sketch through its sheet face) and the path
133 /// fields (`["EDGE"]` on path sweep, picking a sketch curve) rely on.
134 pub(super) fn candidate_admitted(
135 &self,
136 kinds: &[String],
137 candidate: &pick::PickCandidate,
138 ) -> bool {
139 if candidate.kind == pick::PickKind::Solid && self.candidate_is_sketch(candidate) {
140 return kinds.iter().any(|k| k.eq_ignore_ascii_case("SKETCH"));
141 }
142 kind_admitted(kinds, candidate.kind)
143 }
144
145 /// The kind LABEL a candidate should present under — `PickKind::as_str`,
146 /// except that a committed-sketch sheet reads `"SKETCH"` rather than the
147 /// `"SOLID"` its `PickKind` carries. The pick-list popup and the published
148 /// candidate JSON both use it, so what a row calls itself matches the filter
149 /// lane that admitted it.
150 pub fn candidate_kind_label(&self, candidate: &pick::PickCandidate) -> &'static str {
151 if candidate.kind == pick::PickKind::Solid && self.candidate_is_sketch(candidate) {
152 return "SKETCH";
153 }
154 candidate.kind.as_str()
155 }
156
157 /// Whether a candidate's owning scene solid is a committed-sketch SHEET.
158 pub(super) fn candidate_is_sketch(&self, candidate: &pick::PickCandidate) -> bool {
159 let name = if candidate.solid.is_empty() {
160 candidate.name.as_str()
161 } else {
162 candidate.solid.as_str()
163 };
164 self.scene
165 .solid(name)
166 .is_some_and(|solid| solid.is_sketch)
167 }
168}
169
170/// Does `kinds` LIST `kind`? Case-insensitive, with `DATUM` as an alias of
171/// `PLANE`. Kind-only, so it cannot tell a committed-sketch sheet from a real
172/// body — [`EngineState::candidate_admitted`] is the candidate-aware wrapper every
173/// picking path actually calls. STRICT: an empty list admits nothing (the "empty = any kind"
174/// convenience belongs to the single-hit [`EngineState::pick_top_at`] alone — the
175/// pick-LIST builder passes an empty list to mean "no sub-entity kind is
176/// admitted", e.g. a COMPONENT-only filter).
177pub(super) fn kind_admitted(kinds: &[String], kind: pick::PickKind) -> bool {
178 let name = kind.as_str();
179 let datum_alias = kind == pick::PickKind::Plane;
180 kinds
181 .iter()
182 .any(|k| k.eq_ignore_ascii_case(name) || (datum_alias && k.eq_ignore_ascii_case("DATUM")))
183}
184
185/// The ONE candidate ordering every pick path shares: category-major in the
186/// pick-list order (`PickKind`'s discriminant order IS that order —
187/// VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest (smallest depth)
188/// first within a category, then by screen distance. Stable, so equal-depth
189/// entries keep the ray ranker's order.
190pub(super) fn sort_pick_candidates(candidates: &mut [pick::PickCandidate]) {
191 candidates.sort_by(|a, b| {
192 (a.kind as u8)
193 .cmp(&(b.kind as u8))
194 .then(a.depth.total_cmp(&b.depth))
195 .then(a.screen_dist.total_cmp(&b.screen_dist))
196 });
197}
198
199/// The ONE candidate a hover highlight lights and a plain click takes: the
200/// filter-admitted entry FIRST IN THE RAYCAST — nearest the eye — with the
201/// shared kind order ([`sort_pick_candidates`]) breaking ties *inside the
202/// nearest depth layer*. `candidates` must already be in that shared order
203/// (every builder sorts before returning); `band` is the depth window, in world
204/// units, that counts as "the same layer".
205///
206/// Why a LAYER and not the raw minimum depth: an EDGE lies ON the face it bounds
207/// and a VERTEX on the edge, so a strict depth comparison between them is a coin
208/// toss decided by tessellation noise — edges and vertices would stop being
209/// clickable wherever a face happened to round nearer. Inside one layer the
210/// established kind order still holds, so small targets keep their priority on
211/// the surface the user is actually looking at. What the rule DOES demote is
212/// everything BEHIND that surface — the far face of a box, a back edge whose
213/// screen projection falls inside the 6 px threshold, a plane card under the
214/// model — none of which may outrank the visible surface by kind alone any more.
215/// Reaching those is what the pick LIST is for, and the list is unchanged: it
216/// still ranks category-major over every candidate.
217pub(super) fn front_layer_winner(
218 candidates: &[pick::PickCandidate],
219 band: f64,
220) -> Option<&pick::PickCandidate> {
221 let nearest = candidates
222 .iter()
223 .map(|c| c.depth)
224 .fold(f64::INFINITY, f64::min);
225 if !nearest.is_finite() {
226 // No usable depth (empty list, or all-NaN): fall back to the shared
227 // order's own winner rather than inventing one.
228 return candidates.first();
229 }
230 let limit = nearest + band.max(0.0);
231 candidates
232 .iter()
233 .find(|c| c.depth <= limit)
234 .or_else(|| candidates.first())
235}
236