Skip to main content

brep_render/
pick.rs

1//! Picking in Rust (R23/R24), replacing the retired raycaster +
2//! `selectionMethods._pickAtEvent`: returns KERNEL NAMES with the candidate
3//! priority order VERTEX > EDGE > FACE > … > SOLID, CSS-pixel thresholds for
4//! lines/points (the earlier picker's `worldPerPixel * 6` is exactly 6 CSS px), a
5//! double-sided face toggle, and a ranked candidate list feeding the host's
6//! multi-candidate popup. Selection-filter *filtering* stays in the UI layer where the
7//! filter state lives — the engine reports everything under the cursor.
8//!
9//! CPU ray/screen-space testing over the scene's display buffers: exact,
10//! deterministic, identical on native and wasm, and cheap at CAD face counts
11//! (per-face triangle ranges give a bbox reject before any triangle test).
12
13use crate::scene::{RenderScene, SolidDisplay};
14use crate::view::{add3, cross3, dot3, scale3, sub3, Projection, Ray, ViewCamera};
15
16/// Pick thresholds in CSS pixels (ported from `selectionMethods`).
17pub const EDGE_PICK_PX: f64 = 6.0;
18pub const VERTEX_PICK_PX: f64 = 6.0;
19const MAX_CANDIDATES: usize = 16;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum PickKind {
23    Vertex = 0,
24    Edge = 1,
25    Face = 2,
26    /// A construction PLANE / DATUM base plane (the pick-list category right
27    /// AFTER faces). Never produced by the ray tests here — the plane cards live
28    /// in the widget registry, so the engine appends one candidate per drawn card
29    /// the pointer ray crosses (`EngineState::pick_candidates_at`); `name` is the
30    /// datum FRAME name (`Pl`, `Datum:XY`), `solid` is empty.
31    ///
32    /// Ranking below FACE is deliberate: a plane competes for a pick by KIND, not
33    /// by depth, so a face under the cursor always out-priorities the (large,
34    /// unshaded) plane card — the plane is still listed, one row down, instead of
35    /// swallowing the click. See `EngineState::pick_candidates_at`.
36    Plane = 3,
37    Solid = 4,
38    /// An assembly COMPONENT entry (the pick-list category after solids). Never
39    /// produced by the ray tests here — the engine appends one per owning
40    /// component when the selection filter's COMPONENT lane is on
41    /// (`candidates_filtered_at`); `name` is the component id, `solid` is empty.
42    Component = 5,
43}
44
45impl PickKind {
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            PickKind::Vertex => "VERTEX",
49            PickKind::Edge => "EDGE",
50            PickKind::Face => "FACE",
51            PickKind::Plane => "PLANE",
52            PickKind::Solid => "SOLID",
53            PickKind::Component => "COMPONENT",
54        }
55    }
56}
57
58#[derive(Debug, Clone)]
59pub struct PickCandidate {
60    pub kind: PickKind,
61    /// Kernel name (faces/edges; empty for unnamed). For vertices this is
62    /// empty — vertices resolve by `position` within `solid`.
63    pub name: String,
64    /// Owning solid's scene name.
65    pub solid: String,
66    /// View-space depth of the hit (for ranking within a kind).
67    pub depth: f64,
68    /// Cursor→hit distance in CSS px (0 for face hits).
69    pub screen_dist: f64,
70    /// World-space hit position (face hit point / edge closest point / vertex).
71    pub position: [f64; 3],
72}
73
74#[derive(Debug, Clone, Copy)]
75pub struct PickOptions {
76    pub double_sided: bool,
77    pub edge_px: f64,
78    pub vertex_px: f64,
79}
80
81impl Default for PickOptions {
82    fn default() -> Self {
83        Self {
84            // The retired picker force-flipped FrontSide materials to DoubleSide
85            // for every pick, so double-sided is the parity default.
86            double_sided: true,
87            edge_px: EDGE_PICK_PX,
88            vertex_px: VERTEX_PICK_PX,
89        }
90    }
91}
92
93/// Rank candidates under CSS-pixel `(x, y)`. Ordered by the priority chain
94/// then depth; a SOLID entry per hit solid is appended at the end (the earlier
95/// "extras" behavior).
96pub fn pick(
97    scene: &RenderScene,
98    camera: &ViewCamera,
99    x: f64,
100    y: f64,
101    options: &PickOptions,
102) -> Vec<PickCandidate> {
103    let ray = camera.pick_ray(x, y);
104    let (_, _, forward) = camera.basis();
105    let persp = matches!(camera.projection, Projection::Perspective { .. });
106
107    let mut hits: Vec<PickCandidate> = Vec::new();
108    for solid in scene.solids() {
109        if !solid.visible {
110            continue;
111        }
112        pick_faces(solid, camera, &ray, forward, options, &mut hits);
113        pick_edges(solid, camera, x, y, forward, persp, options, &mut hits);
114        pick_vertices(solid, camera, x, y, forward, persp, options, &mut hits);
115    }
116
117    hits.sort_by(|a, b| {
118        (a.kind as u8)
119            .cmp(&(b.kind as u8))
120            .then(a.depth.total_cmp(&b.depth))
121            .then(a.screen_dist.total_cmp(&b.screen_dist))
122    });
123    hits.truncate(MAX_CANDIDATES);
124
125    // Append one SOLID candidate per hit solid, ordered by first (best) hit.
126    let mut solids_seen: Vec<String> = Vec::new();
127    let mut solid_entries: Vec<PickCandidate> = Vec::new();
128    for hit in &hits {
129        if solids_seen.iter().any(|name| name == &hit.solid) {
130            continue;
131        }
132        solids_seen.push(hit.solid.clone());
133        solid_entries.push(PickCandidate {
134            kind: PickKind::Solid,
135            name: hit.solid.clone(),
136            solid: hit.solid.clone(),
137            depth: hit.depth,
138            screen_dist: hit.screen_dist,
139            position: hit.position,
140        });
141    }
142    hits.extend(solid_entries);
143    hits
144}
145
146/// The nearest hit of an ALLOWED KIND under CSS-pixel `(x, y)`, using the same
147/// ranking as [`pick`]. `filter` is a set of kind strings (`"SOLID"`, `"FACE"`,
148/// `"EDGE"`, `"VERTEX"`, case-insensitive); an empty filter means any kind. This
149/// is the type-constrained pick the reference-selection widget uses so a
150/// `targetSolid` field resolves the SOLID under the cursor (candidates rank
151/// faces first, then the trailing SOLID entry — `find` walks that order and
152/// returns the first candidate whose kind is allowed). Returns the full
153/// candidate (name + owning solid + position) or `None` on a miss.
154pub fn pick_filtered(
155    scene: &RenderScene,
156    camera: &ViewCamera,
157    x: f64,
158    y: f64,
159    options: &PickOptions,
160    filter: &[String],
161) -> Option<PickCandidate> {
162    pick(scene, camera, x, y, options).into_iter().find(|c| {
163        filter.is_empty() || filter.iter().any(|f| f.eq_ignore_ascii_case(c.kind.as_str()))
164    })
165}
166
167fn view_depth(camera: &ViewCamera, forward: [f64; 3], point: [f64; 3]) -> f64 {
168    dot3(sub3(point, camera.eye), forward)
169}
170
171fn pick_faces(
172    solid: &SolidDisplay,
173    camera: &ViewCamera,
174    ray: &Ray,
175    forward: [f64; 3],
176    options: &PickOptions,
177    out: &mut Vec<PickCandidate>,
178) {
179    if solid.mesh.indices.is_empty() {
180        return;
181    }
182    if !ray_hits_aabb(ray, &solid.bbox) {
183        return;
184    }
185    let positions = &solid.mesh.positions;
186    let indices = &solid.mesh.indices;
187    for (index, face) in solid.faces.iter().enumerate() {
188        // Per-entity visibility (the scene tree's face checkboxes): a hidden
189        // face isn't rendered, so it must not be hoverable/pickable either.
190        if !solid.visibility.is_face_visible(index) {
191            continue;
192        }
193        if face.tri_count == 0 {
194            continue;
195        }
196        let mut best: Option<(f64, [f64; 3])> = None;
197        let start = face.tri_start as usize;
198        let end = start + face.tri_count as usize;
199        for tri in start..end.min(indices.len() / 3) {
200            let i0 = indices[tri * 3] as usize;
201            let i1 = indices[tri * 3 + 1] as usize;
202            let i2 = indices[tri * 3 + 2] as usize;
203            let a = to_f64(positions[i0]);
204            let b = to_f64(positions[i1]);
205            let c = to_f64(positions[i2]);
206            if let Some(t) = ray_triangle(ray, a, b, c, options.double_sided) {
207                let point = add3(ray.origin, scale3(ray.dir, t));
208                if best.map(|(bt, _)| t < bt).unwrap_or(true) {
209                    best = Some((t, point));
210                }
211            }
212        }
213        if let Some((_, point)) = best {
214            out.push(PickCandidate {
215                kind: PickKind::Face,
216                name: face.name.clone(),
217                solid: solid.name.clone(),
218                depth: view_depth(camera, forward, point),
219                screen_dist: 0.0,
220                position: point,
221            });
222        }
223    }
224}
225
226#[allow(clippy::too_many_arguments)]
227fn pick_edges(
228    solid: &SolidDisplay,
229    camera: &ViewCamera,
230    x: f64,
231    y: f64,
232    forward: [f64; 3],
233    persp: bool,
234    options: &PickOptions,
235    out: &mut Vec<PickCandidate>,
236) {
237    for (index, edge) in solid.edges.iter().enumerate() {
238        // Hidden edges (scene-tree checkboxes) are not rendered → not pickable.
239        if !solid.visibility.is_edge_visible(index) {
240            continue;
241        }
242        let mut best: Option<(f64, f64, [f64; 3])> = None; // (screen_dist, depth, world)
243        for pair in edge.polyline.windows(2) {
244            let mut a = to_f64(pair[0]);
245            let mut b = to_f64(pair[1]);
246            if persp {
247                let da = view_depth(camera, forward, a);
248                let db = view_depth(camera, forward, b);
249                const EPS: f64 = 1e-6;
250                if da <= EPS && db <= EPS {
251                    continue;
252                }
253                if da <= EPS || db <= EPS {
254                    // Clip the behind-eye endpoint to just in front.
255                    let t = (EPS - da) / (db - da);
256                    let clip = add3(a, scale3(sub3(b, a), t));
257                    if da <= EPS {
258                        a = clip;
259                    } else {
260                        b = clip;
261                    }
262                }
263            }
264            let (ax, ay, _) = camera.project(a);
265            let (bx, by, _) = camera.project(b);
266            let (dist, t) = point_segment_distance_2d(x, y, ax, ay, bx, by);
267            if dist <= options.edge_px {
268                let world = add3(a, scale3(sub3(b, a), t));
269                let depth = view_depth(camera, forward, world);
270                if best
271                    .map(|(bd, bdepth, _)| dist < bd || (dist == bd && depth < bdepth))
272                    .unwrap_or(true)
273                {
274                    best = Some((dist, depth, world));
275                }
276            }
277        }
278        if let Some((screen_dist, depth, position)) = best {
279            out.push(PickCandidate {
280                kind: PickKind::Edge,
281                name: edge.name.clone(),
282                solid: solid.name.clone(),
283                depth,
284                screen_dist,
285                position,
286            });
287        }
288    }
289}
290
291#[allow(clippy::too_many_arguments)]
292fn pick_vertices(
293    solid: &SolidDisplay,
294    camera: &ViewCamera,
295    x: f64,
296    y: f64,
297    forward: [f64; 3],
298    persp: bool,
299    options: &PickOptions,
300    out: &mut Vec<PickCandidate>,
301) {
302    for (index, vertex) in solid.vertices.iter().enumerate() {
303        // Hidden vertices (scene-tree checkboxes) are not rendered → not pickable.
304        if !solid.visibility.is_vertex_visible(index) {
305            continue;
306        }
307        let depth = view_depth(camera, forward, vertex.position);
308        if persp && depth <= 1e-6 {
309            continue;
310        }
311        let (sx, sy, _) = camera.project(vertex.position);
312        let dist = ((sx - x).powi(2) + (sy - y).powi(2)).sqrt();
313        if dist <= options.vertex_px {
314            out.push(PickCandidate {
315                kind: PickKind::Vertex,
316                name: String::new(),
317                solid: solid.name.clone(),
318                depth,
319                screen_dist: dist,
320                position: vertex.position,
321            });
322        }
323    }
324}
325
326fn to_f64(p: [f32; 3]) -> [f64; 3] {
327    [p[0] as f64, p[1] as f64, p[2] as f64]
328}
329
330/// Distance from point to segment in 2D + the segment parameter of the
331/// closest point.
332fn point_segment_distance_2d(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> (f64, f64) {
333    let abx = bx - ax;
334    let aby = by - ay;
335    let len2 = abx * abx + aby * aby;
336    let t = if len2 <= 1e-18 {
337        0.0
338    } else {
339        (((px - ax) * abx + (py - ay) * aby) / len2).clamp(0.0, 1.0)
340    };
341    let cx = ax + abx * t;
342    let cy = ay + aby * t;
343    (((px - cx).powi(2) + (py - cy).powi(2)).sqrt(), t)
344}
345
346/// Möller–Trumbore; returns the ray parameter t of the hit.
347fn ray_triangle(ray: &Ray, a: [f64; 3], b: [f64; 3], c: [f64; 3], double_sided: bool) -> Option<f64> {
348    let e1 = sub3(b, a);
349    let e2 = sub3(c, a);
350    let pvec = cross3(ray.dir, e2);
351    let det = dot3(e1, pvec);
352    const EPS: f64 = 1e-14;
353    if double_sided {
354        if det.abs() < EPS {
355            return None;
356        }
357    } else if det < EPS {
358        return None;
359    }
360    let inv_det = 1.0 / det;
361    let tvec = sub3(ray.origin, a);
362    let u = dot3(tvec, pvec) * inv_det;
363    if !(-1e-9..=1.0 + 1e-9).contains(&u) {
364        return None;
365    }
366    let qvec = cross3(tvec, e1);
367    let v = dot3(ray.dir, qvec) * inv_det;
368    if v < -1e-9 || u + v > 1.0 + 1e-9 {
369        return None;
370    }
371    let t = dot3(e2, qvec) * inv_det;
372    if t <= 0.0 {
373        return None;
374    }
375    Some(t)
376}
377
378fn ray_hits_aabb(ray: &Ray, bbox: &crate::camera::Aabb) -> bool {
379    if bbox.is_empty() {
380        return false;
381    }
382    let mut t_min = f64::NEG_INFINITY;
383    let mut t_max = f64::INFINITY;
384    for axis in 0..3 {
385        let dir = ray.dir[axis];
386        let origin = ray.origin[axis];
387        if dir.abs() < 1e-15 {
388            if origin < bbox.min[axis] - 1e-9 || origin > bbox.max[axis] + 1e-9 {
389                return false;
390            }
391            continue;
392        }
393        let inv = 1.0 / dir;
394        let t0 = (bbox.min[axis] - origin) * inv;
395        let t1 = (bbox.max[axis] - origin) * inv;
396        let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
397        t_min = t_min.max(lo);
398        t_max = t_max.min(hi);
399        if t_min > t_max {
400            return false;
401        }
402    }
403    t_max > 0.0
404}
405
406/// Serialize candidates for the R3 JSON boundary.
407pub fn candidates_to_json(candidates: &[PickCandidate]) -> String {
408    let list: Vec<serde_json::Value> = candidates
409        .iter()
410        .map(|c| {
411            serde_json::json!({
412                "kind": c.kind.as_str(),
413                "name": c.name,
414                "solid": c.solid,
415                "depth": c.depth,
416                "screenDist": c.screen_dist,
417                "position": c.position,
418            })
419        })
420        .collect();
421    serde_json::Value::Array(list).to_string()
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::pipeline::scene_from_history_json;
428
429    fn cube_scene() -> RenderScene {
430        let request = serde_json::json!({
431            "expressions": "",
432            "configurator": {},
433            "features": [{
434                "type": "P.CU",
435                "inputParams": {
436                    "id": "PickCube",
437                    "sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
438                    "transform": {
439                        "position": [0.0, 0.0, 0.0],
440                        "rotationEuler": [0.0, 0.0, 0.0],
441                        "scale": [1.0, 1.0, 1.0]
442                    },
443                    "boolean": { "targets": [], "operation": "NONE" }
444                },
445                "persistentData": {}
446            }]
447        })
448        .to_string();
449        let (scene, report) = scene_from_history_json(&request).unwrap();
450        assert!(report.feature_errors.is_empty(), "{:?}", report.feature_errors);
451        scene
452    }
453
454    fn front_camera() -> ViewCamera {
455        // Straight down -Z at the cube (cube spans 0..10 in x,y,z).
456        ViewCamera {
457            eye: [5.0, 5.0, 50.0],
458            target: [5.0, 5.0, 5.0],
459            up: [0.0, 1.0, 0.0],
460            projection: Projection::Orthographic { half_height: 10.0 },
461            width: 800.0,
462            height: 600.0,
463            near: -1000.0,
464            far: 1000.0,
465        }
466    }
467
468    #[test]
469    fn face_pick_at_center_is_deterministic() {
470        let scene = cube_scene();
471        let camera = front_camera();
472        let first = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
473        let second = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
474        assert!(!first.is_empty());
475        assert_eq!(first.len(), second.len());
476        for (a, b) in first.iter().zip(second.iter()) {
477            assert_eq!(a.kind, b.kind);
478            assert_eq!(a.name, b.name);
479            assert_eq!(a.depth.to_bits(), b.depth.to_bits(), "deterministic depth");
480        }
481        // Center of the face: nothing but faces (both sides of the cube) and
482        // the trailing SOLID entry.
483        assert_eq!(first[0].kind, PickKind::Face);
484        assert!(!first[0].name.is_empty(), "face has a kernel name");
485        assert_eq!(first.last().unwrap().kind, PickKind::Solid);
486        assert_eq!(first.last().unwrap().name, "PickCube");
487        // Nearest face first: the +Z face (z = 10) is closer than z = 0.
488        assert!(first[0].depth < first[1].depth);
489        assert!((first[0].position[2] - 10.0).abs() < 1e-9);
490    }
491
492    #[test]
493    fn edge_and_vertex_priority_ranking() {
494        let scene = cube_scene();
495        let camera = front_camera();
496        // The cube's top-right-front corner (10, 10, 10) in screen space.
497        let (cx, cy, _) = camera.project([10.0, 10.0, 10.0]);
498        let hits = pick(&scene, &camera, cx, cy, &PickOptions::default());
499        assert!(!hits.is_empty());
500        // Priority: VERTEX first even though faces/edges are hit too.
501        assert_eq!(hits[0].kind, PickKind::Vertex);
502        assert!(crate::view::len3(crate::view::sub3(hits[0].position, [10.0, 10.0, 10.0])) < 1e-9);
503        assert!(hits.iter().any(|h| h.kind == PickKind::Edge));
504        assert!(hits.iter().any(|h| h.kind == PickKind::Face));
505        assert_eq!(hits.last().unwrap().kind, PickKind::Solid);
506
507        // A point on the top edge midway (5, 10, 10): edge above face.
508        let (ex, ey, _) = camera.project([5.0, 10.0, 10.0]);
509        let hits = pick(&scene, &camera, ex, ey, &PickOptions::default());
510        assert_eq!(hits[0].kind, PickKind::Edge);
511        assert!(!hits[0].name.is_empty(), "edge has a kernel name");
512    }
513
514    #[test]
515    fn filtered_pick_constrains_to_kind() {
516        let scene = cube_scene();
517        let camera = front_camera();
518        // Center of a face: unconstrained best is a FACE; a SOLID filter walks
519        // past the faces to the trailing SOLID entry.
520        let face = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &["FACE".into()]);
521        assert_eq!(face.as_ref().unwrap().kind, PickKind::Face);
522        let solid = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &["SOLID".into()]);
523        assert_eq!(solid.as_ref().unwrap().kind, PickKind::Solid);
524        assert_eq!(solid.unwrap().name, "PickCube");
525        // Case-insensitive filter + empty filter = any (the top-ranked candidate).
526        let any = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &[]);
527        assert_eq!(any.unwrap().kind, PickKind::Face);
528        let lower = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &["solid".into()]);
529        assert_eq!(lower.unwrap().kind, PickKind::Solid);
530        // A miss yields nothing under any filter.
531        assert!(pick_filtered(&scene, &camera, 10.0, 10.0, &PickOptions::default(), &["SOLID".into()]).is_none());
532    }
533
534    #[test]
535    fn miss_returns_empty() {
536        let scene = cube_scene();
537        let camera = front_camera();
538        let hits = pick(&scene, &camera, 10.0, 10.0, &PickOptions::default());
539        assert!(hits.is_empty(), "{hits:?}");
540    }
541
542    /// Per-ENTITY visibility (the scene tree's face/edge/vertex checkboxes)
543    /// must gate picking exactly like rendering: a hidden face isn't drawn, so
544    /// hovering/clicking must fall through to what IS visible behind it.
545    #[test]
546    fn hidden_entities_are_not_pickable() {
547        use crate::visibility::EntityKind;
548        let mut scene = cube_scene();
549        let camera = front_camera();
550
551        // Hide the front (+Z) face: the top-ranked face pick must become the
552        // BACK face (z = 0), and the front face must vanish from candidates.
553        let before = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
554        assert_eq!(before[0].kind, PickKind::Face);
555        let front_name = before[0].name.clone();
556        let front_index = scene
557            .solids()
558            .iter()
559            .find(|s| s.name == "PickCube")
560            .unwrap()
561            .faces
562            .iter()
563            .position(|f| f.name == front_name)
564            .unwrap();
565        scene
566            .solid_mut("PickCube")
567            .unwrap()
568            .visibility
569            .set_visible(EntityKind::Face, front_index, false);
570        let after = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
571        assert!(
572            after
573                .iter()
574                .all(|c| !(c.kind == PickKind::Face && c.name == front_name)),
575            "hidden face still picked: {after:?}"
576        );
577        assert_eq!(after[0].kind, PickKind::Face, "the back face is still pickable");
578        assert!((after[0].position[2] - 0.0).abs() < 1e-9, "back face at z=0");
579
580        // Hide every vertex: a corner pick no longer yields VERTEX candidates
581        // (the corner's edges take priority instead).
582        let vertex_count = scene.solids()[0].vertices.len();
583        scene
584            .solid_mut("PickCube")
585            .unwrap()
586            .visibility
587            .set_group_visible(EntityKind::Vertex, vertex_count, false);
588        let (cx, cy, _) = camera.project([10.0, 10.0, 10.0]);
589        let corner = pick(&scene, &camera, cx, cy, &PickOptions::default());
590        assert!(
591            corner.iter().all(|c| c.kind != PickKind::Vertex),
592            "hidden vertices still picked: {corner:?}"
593        );
594        assert_eq!(corner[0].kind, PickKind::Edge, "edges now outrank the hidden vertex");
595
596        // Hide every edge too: only faces (and the trailing solid) remain.
597        let edge_count = scene.solids()[0].edges.len();
598        scene
599            .solid_mut("PickCube")
600            .unwrap()
601            .visibility
602            .set_group_visible(EntityKind::Edge, edge_count, false);
603        let corner = pick(&scene, &camera, cx, cy, &PickOptions::default());
604        assert!(corner.iter().all(|c| c.kind != PickKind::Edge));
605        assert!(corner.iter().any(|c| c.kind == PickKind::Face));
606    }
607
608    #[test]
609    fn hidden_solid_is_not_pickable() {
610        let mut scene = cube_scene();
611        scene.solid_mut("PickCube").unwrap().visible = false;
612        let camera = front_camera();
613        let hits = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
614        assert!(hits.is_empty());
615    }
616}