Skip to main content

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(&self.scene, &self.camera, x, y, &self.pick_options());
56        // Appended AFTER `pick`'s own `MAX_CANDIDATES` truncation, so a plane is
57        // never dropped by a crowd of faces under the same pixel.
58        out.extend(self.plane_candidates_at(x, y));
59        sort_pick_candidates(&mut out);
60        out
61    }
62
63    /// The construction PLANE candidates under CSS-pixel `(x, y)` — one per DRAWN
64    /// datum-plane card the pointer ray crosses, carrying the datum FRAME name
65    /// (`Pl`, `Datum:XY`) and the world hit point. `solid` is empty (a plane has no
66    /// owning solid) and `screen_dist` is 0 (a card hit is exact, like a face hit).
67    ///
68    /// Hidden planes are absent by construction: the widget feed
69    /// ([`Self::refresh_construction_datums`]) only carries the planes that are
70    /// actually drawn, so a Scene-tree-hidden or rolled-back plane cannot be picked.
71    pub(super) fn plane_candidates_at(&self, x: f64, y: f64) -> Vec<pick::PickCandidate> {
72        let cam = gizmo_camera(&self.camera);
73        let (_, _, forward) = self.camera.basis();
74        self.widgets
75            .datum_plane_hits(&cam, x as f32, y as f32)
76            .into_iter()
77            .map(|(name, point)| {
78                let position = [point[0] as f64, point[1] as f64, point[2] as f64];
79                pick::PickCandidate {
80                    kind: pick::PickKind::Plane,
81                    name,
82                    solid: String::new(),
83                    depth: crate::view::dot3(
84                        crate::view::sub3(position, self.camera.eye),
85                        forward,
86                    ),
87                    screen_dist: 0.0,
88                    position,
89                }
90            })
91            .collect()
92    }
93
94    /// The TOP-ranked candidate under `(x, y)` whose kind `kinds` admits — the
95    /// planes-aware replacement for [`pick::pick_filtered`] (which only sees the
96    /// render scene). An EMPTY `kinds` means any kind, matching `pick_filtered`.
97    ///
98    /// `DATUM` is accepted as an alias of `PLANE` so a reference field whose schema
99    /// spells its construction kind `["DATUM"]` picks the same plane cards as
100    /// `["PLANE"]` (`ref_select_click` passes the field's RAW strings here).
101    pub(super) fn pick_top_at(
102        &self,
103        x: f64,
104        y: f64,
105        kinds: &[String],
106    ) -> Option<pick::PickCandidate> {
107        self.pick_candidates_at(x, y)
108            .into_iter()
109            // EMPTY = any kind, [`pick::pick_filtered`]'s rule (the callers that
110            // mean "select nothing" guard on an empty list BEFORE calling).
111            .find(|c| kinds.is_empty() || self.candidate_admitted(kinds, c))
112    }
113
114    /// Does `kinds` admit this CANDIDATE? [`kind_admitted`] plus the one
115    /// distinction the picker cannot make on its own: a committed SKETCH is drawn
116    /// as a synthesized sheet solid, so it and a real body arrive with the same
117    /// [`pick::PickKind::Solid`]. `SolidDisplay::is_sketch` splits them, and the
118    /// candidate then answers to `"SKETCH"` or to `"SOLID"` — never to both.
119    ///
120    /// Only the whole-object candidate is reclassified. A sketch's planar FACE and
121    /// its drawn EDGES keep their own kinds, which is what the profile fields
122    /// (`["SKETCH","FACE"]`, picking a sketch through its sheet face) and the path
123    /// fields (`["EDGE"]` on path sweep, picking a sketch curve) rely on.
124    pub(super) fn candidate_admitted(
125        &self,
126        kinds: &[String],
127        candidate: &pick::PickCandidate,
128    ) -> bool {
129        if candidate.kind == pick::PickKind::Solid && self.candidate_is_sketch(candidate) {
130            return kinds.iter().any(|k| k.eq_ignore_ascii_case("SKETCH"));
131        }
132        kind_admitted(kinds, candidate.kind)
133    }
134
135    /// The kind LABEL a candidate should present under — `PickKind::as_str`,
136    /// except that a committed-sketch sheet reads `"SKETCH"` rather than the
137    /// `"SOLID"` its `PickKind` carries. The pick-list popup and the published
138    /// candidate JSON both use it, so what a row calls itself matches the filter
139    /// lane that admitted it.
140    pub fn candidate_kind_label(&self, candidate: &pick::PickCandidate) -> &'static str {
141        if candidate.kind == pick::PickKind::Solid && self.candidate_is_sketch(candidate) {
142            return "SKETCH";
143        }
144        candidate.kind.as_str()
145    }
146
147    /// Whether a candidate's owning scene solid is a committed-sketch SHEET.
148    pub(super) fn candidate_is_sketch(&self, candidate: &pick::PickCandidate) -> bool {
149        let name = if candidate.solid.is_empty() {
150            candidate.name.as_str()
151        } else {
152            candidate.solid.as_str()
153        };
154        self.scene
155            .solid(name)
156            .is_some_and(|solid| solid.is_sketch)
157    }
158}
159
160/// Does `kinds` LIST `kind`? Case-insensitive, with `DATUM` as an alias of
161/// `PLANE`. Kind-only, so it cannot tell a committed-sketch sheet from a real
162/// body — [`EngineState::candidate_admitted`] is the candidate-aware wrapper every
163/// picking path actually calls. STRICT: an empty list admits nothing (the "empty = any kind"
164/// convenience belongs to the single-hit [`EngineState::pick_top_at`] alone — the
165/// pick-LIST builder passes an empty list to mean "no sub-entity kind is
166/// admitted", e.g. a COMPONENT-only filter).
167pub(super) fn kind_admitted(kinds: &[String], kind: pick::PickKind) -> bool {
168    let name = kind.as_str();
169    let datum_alias = kind == pick::PickKind::Plane;
170    kinds
171        .iter()
172        .any(|k| k.eq_ignore_ascii_case(name) || (datum_alias && k.eq_ignore_ascii_case("DATUM")))
173}
174
175/// The ONE candidate ordering every pick path shares: category-major in the
176/// pick-list order (`PickKind`'s discriminant order IS that order —
177/// VERTEX > EDGE > FACE > PLANE > SOLID > COMPONENT), nearest (smallest depth)
178/// first within a category, then by screen distance. Stable, so equal-depth
179/// entries keep the ray ranker's order.
180pub(super) fn sort_pick_candidates(candidates: &mut [pick::PickCandidate]) {
181    candidates.sort_by(|a, b| {
182        (a.kind as u8)
183            .cmp(&(b.kind as u8))
184            .then(a.depth.total_cmp(&b.depth))
185            .then(a.screen_dist.total_cmp(&b.screen_dist))
186    });
187}
188
189#[cfg(test)]
190mod plane_pick_tests {
191    use super::*;
192
193    /// A cube spanning 0..10 (index 0) + a DATUM at the world origin (index 1),
194    /// whose three base planes are cards centred on `[0,0,0]` — i.e. at the cube's
195    /// near-bottom-left CORNER, so a pixel can land on a plane card WITH cube faces
196    /// behind it (the reported bug's geometry).
197    fn cube_and_datum_history() -> String {
198        serde_json::json!({
199            "features": [
200                {
201                    "type": "P.CU",
202                    "inputParams": {
203                        "id": "Box",
204                        "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
205                        "transform": {
206                            "position": [0.0, 0.0, 0.0],
207                            "rotationEuler": [0.0, 0.0, 0.0],
208                            "scale": [1.0, 1.0, 1.0]
209                        },
210                        "boolean": { "targets": [], "operation": "NONE" }
211                    },
212                    "persistentData": {}
213                },
214                { "type": "D", "inputParams": { "id": "Datum" }, "persistentData": {} }
215            ]
216        })
217        .to_string()
218    }
219
220    /// The cube+datum framed straight down −Z with the viewport centre aimed at
221    /// `[1.2, 1.2, 5]`: the centre pixel is ~36 CSS px from the datum origin's
222    /// projection (inside the XY card's 70 px half-extent) and ~36 px from the
223    /// cube's nearest edges (well outside the 6 px edge/vertex threshold), so the
224    /// pixel carries cube FACES *and* the datum plane — and nothing else.
225    fn engine_with_a_plane_over_the_cube() -> EngineState {
226        let mut engine = EngineState::new();
227        engine.set_history_json(&cube_and_datum_history()).unwrap();
228        engine.resize(800.0, 600.0);
229        engine.camera.eye = [1.2, 1.2, 60.0];
230        engine.camera.target = [1.2, 1.2, 5.0];
231        engine.camera.up = [0.0, 1.0, 0.0];
232        // half_height 10 over 600 px → 30 px per world unit.
233        engine.camera.projection = crate::view::Projection::Orthographic { half_height: 10.0 };
234        engine
235    }
236
237    /// THE REPORTED BUG: a construction plane with geometry under the pointer.
238    /// The plane is now an ordinary entry in the pick list (right after the faces,
239    /// before the solid), and selecting that entry selects the datum — where before
240    /// the plane was unreachable because the geometry pick won and the `datum_pick`
241    /// fallback only ran on a MISS.
242    #[test]
243    fn a_plane_under_geometry_is_pickable() {
244        let mut engine = engine_with_a_plane_over_the_cube();
245        let cands = engine.candidates_filtered_at(400.0, 300.0);
246
247        // Faces of the cube ARE under this pixel (the bug's precondition: without
248        // the fix the plane loses to them and never appears).
249        assert!(
250            cands.iter().any(|c| c.kind == pick::PickKind::Face),
251            "the pixel must have geometry under it: {cands:?}"
252        );
253        // …and the XY datum plane is listed exactly once (XZ/YZ are edge-on).
254        let planes: Vec<&pick::PickCandidate> = cands
255            .iter()
256            .filter(|c| c.kind == pick::PickKind::Plane)
257            .collect();
258        assert_eq!(planes.len(), 1, "one plane candidate: {cands:?}");
259        assert_eq!(planes[0].name, "Datum:XY");
260
261        // Ordering: category-major, so every FACE precedes the PLANE and the PLANE
262        // precedes the SOLID entry.
263        let ranks: Vec<u8> = cands.iter().map(|c| c.kind as u8).collect();
264        assert!(ranks.windows(2).all(|w| w[0] <= w[1]), "category-major: {ranks:?}");
265        let plane_at = cands
266            .iter()
267            .position(|c| c.kind == pick::PickKind::Plane)
268            .unwrap();
269        assert!(
270            cands[..plane_at].iter().all(|c| c.kind == pick::PickKind::Face),
271            "faces rank ahead of the plane: {cands:?}"
272        );
273        assert!(
274            cands[plane_at + 1..]
275                .iter()
276                .all(|c| c.kind == pick::PickKind::Solid),
277            "the plane ranks ahead of the solid: {cands:?}"
278        );
279        // A plane's frame name has the `A:B` shape of a component-namespaced solid
280        // (`ACOMP2:Part`), so it must NOT be promoted to a phantom COMPONENT row.
281        assert!(
282            cands.iter().all(|c| c.kind != pick::PickKind::Component),
283            "a plane name is not a component: {cands:?}"
284        );
285
286        // Picking the plane entry selects the DATUM (the same selection a
287        // Scene-tree row click makes) and drops the geometry selection.
288        let plane = planes[0].clone();
289        assert!(!engine.candidate_is_selected(&plane));
290        engine.select_candidate(&plane);
291        assert!(engine.candidate_is_selected(&plane));
292        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
293        assert_eq!(sel["datums"], serde_json::json!(["Datum:XY"]), "{sel}");
294        assert!(sel["faces"].as_array().unwrap().is_empty(), "{sel}");
295        // The fed plane carries the selection accent (the widget's `hot` flag).
296        assert!(
297            engine
298                .widgets
299                .datum_plane_names()
300                .iter()
301                .any(|(n, hot)| *n == "Datum:XY" && *hot),
302            "the selected plane is emphasized"
303        );
304    }
305
306    /// The FILTER gates planes exactly like faces: unchecking Plane removes every
307    /// plane entry (and the plane stops being pickable at all), while the geometry
308    /// under the same pixel still picks. Re-checking restores it.
309    #[test]
310    fn the_plane_filter_gates_plane_picking() {
311        let mut engine = engine_with_a_plane_over_the_cube();
312        let mut filter = engine.selection_filter();
313        assert!(filter.plane, "planes are pickable by default");
314
315        // Plane OFF: no plane candidate, and a plane-only filter picks nothing.
316        filter.plane = false;
317        engine.set_selection_filter(filter);
318        let cands = engine.candidates_filtered_at(400.0, 300.0);
319        assert!(
320            cands.iter().all(|c| c.kind != pick::PickKind::Plane),
321            "unchecking Plane must exclude planes: {cands:?}"
322        );
323        assert!(!cands.is_empty(), "the geometry under the pixel still lists");
324        // A click still reaches the face behind the (now unpickable) plane.
325        assert!(engine.select_top_at(400.0, 300.0));
326        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
327        assert_eq!(sel["faces"].as_array().unwrap().len(), 1, "{sel}");
328        assert!(sel["datums"].as_array().unwrap().is_empty(), "{sel}");
329
330        // Plane back ON with EVERYTHING else off: the same pixel now picks the
331        // plane directly (no face out-priorities it any more).
332        engine.set_selection_filter(SelectionFilter {
333            solid: false,
334            sketch: false,
335            face: false,
336            edge: false,
337            vertex: false,
338            plane: true,
339            component: false,
340        });
341        assert!(engine.select_top_at(400.0, 300.0), "plane-only picks the plane");
342        let sel: serde_json::Value = serde_json::from_str(&engine.selection_json()).unwrap();
343        assert_eq!(sel["datums"], serde_json::json!(["Datum:XY"]), "{sel}");
344    }
345
346    /// Only the DRAWN rectangle is pickable, though the plane is mathematically
347    /// infinite: a pixel far from the card — but still on the infinite XY plane —
348    /// yields no plane candidate. And the card is picked from EITHER side.
349    #[test]
350    fn only_the_drawn_card_is_pickable_and_from_both_sides() {
351        let mut engine = engine_with_a_plane_over_the_cube();
352        // The card's half-extent is 70 CSS px; (700, 300) is 300 px right of the
353        // centre, far outside it, yet the ray still crosses the infinite XY plane.
354        assert!(
355            engine.plane_candidates_at(700.0, 300.0).is_empty(),
356            "outside the drawn card is a miss"
357        );
358        let front = engine.plane_candidates_at(400.0, 300.0);
359        assert_eq!(front.len(), 1, "on the card is a hit");
360
361        // Same pixel from the OPPOSITE side (camera swung to −Z): the reverse face
362        // of the card is still pickable.
363        engine.camera.eye = [1.2, 1.2, -50.0];
364        engine.camera.target = [1.2, 1.2, 5.0];
365        let back = engine.plane_candidates_at(400.0, 300.0);
366        assert_eq!(back.len(), 1, "the card picks from behind too");
367        assert_eq!(back[0].name, front[0].name);
368    }
369
370    /// Hover follows the same ranked list: the top admitted candidate lights up,
371    /// so a plane-only filter hover-highlights the plane card (and moving off it
372    /// clears the highlight). The unchanged-hover early-out still holds, so a
373    /// stationary pointer does not churn frames.
374    #[test]
375    fn hover_lights_a_plane_and_clears() {
376        let mut engine = engine_with_a_plane_over_the_cube();
377        engine.set_selection_filter(SelectionFilter {
378            solid: false,
379            sketch: false,
380            face: false,
381            edge: false,
382            vertex: false,
383            plane: true,
384            component: false,
385        });
386        assert!(engine.hover_at(400.0, 300.0), "hover set");
387        assert!(engine.emphasis.hovered_datums.contains("Datum:XY"));
388        assert!(
389            engine
390                .widgets
391                .datum_plane_names()
392                .iter()
393                .any(|(n, hot)| *n == "Datum:XY" && *hot),
394            "the hovered plane is emphasized"
395        );
396        assert!(!engine.hover_at(400.0, 300.0), "unchanged hover → no change");
397        assert!(engine.hover_at(700.0, 300.0), "moving off the card clears");
398        assert!(engine.emphasis.hovered_datums.is_empty());
399        assert!(
400            engine
401                .widgets
402                .datum_plane_names()
403                .iter()
404                .all(|(_, hot)| !*hot),
405            "the accent is dropped on hover-out"
406        );
407        assert!(!engine.has_selection(), "hover never selects");
408    }
409
410    /// The REFERENCE-FIELD flow (a sketch's `["PLANE","FACE"]` `sketchPlane`) now
411    /// runs through the same ranked list, and its precedence is unchanged: a FACE
412    /// under the cursor still wins (the field is not hijacked by the plane card
413    /// over the model), while a pixel on the card alone picks the plane. The modal
414    /// has no pick list, so the top admitted candidate is the pick — exactly what
415    /// the old geometry-miss fallback produced, minus the fallback.
416    #[test]
417    fn ref_select_plane_face_field_keeps_faces_first() {
418        let mut engine = engine_with_a_plane_over_the_cube();
419        engine.begin_ref_select(
420            "Sk",
421            vec!["sketchPlane".to_string()],
422            "Sketch plane".to_string(),
423            vec!["PLANE".to_string(), "FACE".to_string()],
424            false,
425            Vec::new(),
426        );
427        // The constrained filter admits planes AND faces — and nothing else.
428        let f = engine.selection_filter();
429        assert!(f.plane && f.face && !f.solid && !f.edge && !f.vertex, "{f:?}");
430
431        // Over the cube: the FACE wins, as before.
432        engine.ref_select_click(400.0, 300.0);
433        let picked = engine.ref_select_names();
434        assert_eq!(picked.len(), 1, "one pick: {picked:?}");
435        assert!(
436            engine.scene.solids()[0].faces.iter().any(|face| face.name == picked[0]),
437            "a face under the cursor still out-priorities the plane: {picked:?}"
438        );
439
440        // On the plane card with NO geometry behind it (the cube spans 0..10, so a
441        // pixel left of x=0 clears it while staying inside the 70 px card):
442        // the plane is picked, by frame name.
443        engine.ref_select_click(360.0, 300.0);
444        assert_eq!(
445            engine.ref_select_names(),
446            vec!["Datum:XY".to_string()],
447            "the card picks the construction plane"
448        );
449    }
450
451    /// Ctrl/Cmd+click TOGGLES a plane in and out of the selection like any other
452    /// candidate, and a plane pick supersedes a geometry selection (they share the
453    /// one selection).
454    #[test]
455    fn toggle_adds_and_removes_a_plane() {
456        let mut engine = engine_with_a_plane_over_the_cube();
457        let plane = engine
458            .candidates_filtered_at(400.0, 300.0)
459            .into_iter()
460            .find(|c| c.kind == pick::PickKind::Plane)
461            .expect("a plane candidate");
462        assert!(engine.toggle_candidate(&plane), "added");
463        assert!(engine.candidate_is_selected(&plane));
464        assert!(!engine.toggle_candidate(&plane), "removed");
465        assert!(!engine.candidate_is_selected(&plane));
466        assert!(!engine.has_selection());
467    }
468}