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