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//! (dense meshes use cached kernel BVHs before exact triangle tests).
12
13use crate::geometry2d::point_segment_distance;
14
15use crate::scene::{RenderScene, SolidDisplay};
16use crate::view::{add3, cross3, dot3, scale3, sub3, Projection, Ray, ViewCamera};
17
18/// Pick thresholds in CSS pixels (ported from `selectionMethods`).
19pub const EDGE_PICK_PX: f64 = 6.0;
20pub const VERTEX_PICK_PX: f64 = 6.0;
21const MAX_CANDIDATES: usize = 16;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum PickKind {
25    Vertex = 0,
26    Edge = 1,
27    Face = 2,
28    /// A construction PLANE / DATUM base plane (the pick-list category right
29    /// AFTER faces). Never produced by the ray tests here — the plane cards live
30    /// in the widget registry, so the engine appends one candidate per drawn card
31    /// the pointer ray crosses (`EngineState::pick_candidates_at`); `name` is the
32    /// datum FRAME name (`Pl`, `Datum:XY`), `solid` is empty.
33    ///
34    /// Ranking below FACE is deliberate: a plane competes for a pick by KIND, not
35    /// by depth, so a face under the cursor always out-priorities the (large,
36    /// unshaded) plane card — the plane is still listed, one row down, instead of
37    /// swallowing the click. See `EngineState::pick_candidates_at`.
38    Plane = 3,
39    Solid = 4,
40    /// An assembly COMPONENT entry (the pick-list category after solids). Never
41    /// produced by the ray tests here — the engine appends one per owning
42    /// component when the selection filter's COMPONENT lane is on
43    /// (`candidates_filtered_at`); `name` is the component id, `solid` is empty.
44    Component = 5,
45}
46
47impl PickKind {
48    pub fn as_str(&self) -> &'static str {
49        match self {
50            PickKind::Vertex => "VERTEX",
51            PickKind::Edge => "EDGE",
52            PickKind::Face => "FACE",
53            PickKind::Plane => "PLANE",
54            PickKind::Solid => "SOLID",
55            PickKind::Component => "COMPONENT",
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct PickCandidate {
62    pub kind: PickKind,
63    /// Kernel name (faces/edges; empty for unnamed). For vertices this is
64    /// empty — vertices resolve by `position` within `solid`.
65    pub name: String,
66    /// Owning solid's scene name.
67    pub solid: String,
68    /// View-space depth of the hit (for ranking within a kind).
69    pub depth: f64,
70    /// Cursor→hit distance in CSS px (0 for face hits).
71    pub screen_dist: f64,
72    /// World-space hit position (face hit point / edge closest point / vertex).
73    pub position: [f64; 3],
74}
75
76#[derive(Debug, Clone, Copy)]
77pub struct PickOptions {
78    pub double_sided: bool,
79    pub edge_px: f64,
80    pub vertex_px: f64,
81}
82
83impl Default for PickOptions {
84    fn default() -> Self {
85        Self {
86            // The retired picker force-flipped FrontSide materials to DoubleSide
87            // for every pick, so double-sided is the parity default.
88            double_sided: true,
89            edge_px: EDGE_PICK_PX,
90            vertex_px: VERTEX_PICK_PX,
91        }
92    }
93}
94
95/// The view depth of the nearest FACE of `scene` under CSS-pixel `(x, y)`, if
96/// any: what a translucent context scene puts in front of the clicked point.
97pub fn nearest_face_depth(scene: &RenderScene, camera: &ViewCamera, x: f64, y: f64, options: &PickOptions) -> Option<f64> {
98    pick(scene, camera, x, y, options)
99        .into_iter()
100        .filter(|candidate| candidate.kind == PickKind::Face)
101        .map(|candidate| candidate.depth)
102        .min_by(f64::total_cmp)
103}
104
105/// [`pick`] in `scene`, without what `context` hides: a candidate deeper than
106/// the nearest context face under the cursor is behind that face. A context
107/// drawn translucent (a Work part's surrounding assembly) is still in front, so
108/// a click on it is not a click on the part behind it. Touching faces (a mating
109/// face flush with the context) stay pickable.
110pub fn pick_unoccluded(
111    scene: &RenderScene,
112    context: &RenderScene,
113    camera: &ViewCamera,
114    x: f64,
115    y: f64,
116    options: &PickOptions,
117) -> Vec<PickCandidate> {
118    let mut hits = pick(scene, camera, x, y, options);
119    if context.solids().is_empty() {
120        return hits;
121    }
122    if let Some(front) = nearest_face_depth(context, camera, x, y, options) {
123        let tolerance = 1e-6 * front.abs().max(1.0) + 1e-4;
124        hits.retain(|candidate| candidate.depth <= front + tolerance);
125    }
126    hits
127}
128
129/// Rank candidates under CSS-pixel `(x, y)`. Ordered by the priority chain
130/// then depth; a SOLID entry per hit solid is appended at the end (the earlier
131/// "extras" behavior).
132pub fn pick(
133    scene: &RenderScene,
134    camera: &ViewCamera,
135    x: f64,
136    y: f64,
137    options: &PickOptions,
138) -> Vec<PickCandidate> {
139    let ray = camera.pick_ray(x, y);
140    let (_, _, forward) = camera.basis();
141    let persp = matches!(camera.projection, Projection::Perspective { .. });
142
143    let mut hits: Vec<PickCandidate> = Vec::new();
144    let mut triangles = Vec::new();
145    for solid in scene.solids() {
146        if !solid.visible {
147            continue;
148        }
149        if ray_hits_aabb(&ray, &solid.bbox) {
150            // Small component meshes cost less to scan than to index. Dense
151            // copper/silkscreen displays dominate UNO hover time without this
152            // broad phase, even though they contain only a handful of faces.
153            let candidates = if solid.mesh.indices.len() / 3 > 128 {
154                triangles.clear();
155                scene.pick_triangles(solid, &ray, &mut triangles);
156                Some(triangles.as_slice())
157            } else {
158                None
159            };
160            pick_faces(solid, camera, &ray, forward, options, candidates, &mut hits);
161        }
162        pick_edges(solid, camera, x, y, forward, persp, options, &mut hits);
163        pick_vertices(solid, camera, x, y, forward, persp, options, &mut hits);
164    }
165
166    hits.sort_by(|a, b| {
167        (a.kind as u8)
168            .cmp(&(b.kind as u8))
169            .then(a.depth.total_cmp(&b.depth))
170            .then(a.screen_dist.total_cmp(&b.screen_dist))
171    });
172    hits.truncate(MAX_CANDIDATES);
173
174    // Append one SOLID candidate per hit solid, ordered by first (best) hit.
175    // A section cap's SOLID hit is one of them, not a second entry.
176    let mut solids_seen: Vec<String> = Vec::new();
177    let mut solid_entries: Vec<PickCandidate> = Vec::new();
178    for hit in &hits {
179        if solids_seen.iter().any(|name| name == &hit.solid) {
180            continue;
181        }
182        solids_seen.push(hit.solid.clone());
183        solid_entries.push(PickCandidate {
184            kind: PickKind::Solid,
185            name: hit.solid.clone(),
186            solid: hit.solid.clone(),
187            depth: hit.depth,
188            screen_dist: hit.screen_dist,
189            position: hit.position,
190        });
191    }
192    hits.retain(|hit| hit.kind != PickKind::Solid);
193    hits.extend(solid_entries);
194    hits
195}
196
197/// The nearest hit of an ALLOWED KIND under CSS-pixel `(x, y)`, using the same
198/// ranking as [`pick`]. `filter` is a set of kind strings (`"SOLID"`, `"FACE"`,
199/// `"EDGE"`, `"VERTEX"`, case-insensitive); an empty filter means any kind. This
200/// is the type-constrained pick the reference-selection widget uses so a
201/// `targetSolid` field resolves the SOLID under the cursor (candidates rank
202/// faces first, then the trailing SOLID entry — `find` walks that order and
203/// returns the first candidate whose kind is allowed). Returns the full
204/// candidate (name + owning solid + position) or `None` on a miss.
205pub fn pick_filtered(
206    scene: &RenderScene,
207    camera: &ViewCamera,
208    x: f64,
209    y: f64,
210    options: &PickOptions,
211    filter: &[String],
212) -> Option<PickCandidate> {
213    pick(scene, camera, x, y, options).into_iter().find(|c| {
214        filter.is_empty() || filter.iter().any(|f| f.eq_ignore_ascii_case(c.kind.as_str()))
215    })
216}
217
218fn view_depth(camera: &ViewCamera, forward: [f64; 3], point: [f64; 3]) -> f64 {
219    dot3(sub3(point, camera.eye), forward)
220}
221
222#[allow(clippy::too_many_arguments)]
223fn pick_faces(
224    solid: &SolidDisplay,
225    camera: &ViewCamera,
226    ray: &Ray,
227    forward: [f64; 3],
228    options: &PickOptions,
229    candidates: Option<&[usize]>,
230    out: &mut Vec<PickCandidate>,
231) {
232    if solid.mesh.indices.is_empty() {
233        return;
234    }
235    if !ray_hits_aabb(ray, &solid.bbox) {
236        return;
237    }
238    let positions = &solid.mesh.positions;
239    let indices = &solid.mesh.indices;
240    let first_hit = out.len();
241    let mut cap_depth: Option<f64> = None;
242    for (index, face) in solid.faces.iter().enumerate() {
243        // Per-entity visibility (the scene tree's face checkboxes): a hidden
244        // face isn't rendered, so it must not be hoverable/pickable either.
245        if !solid.visibility.is_face_visible(index) {
246            continue;
247        }
248        if face.tri_count == 0 {
249            continue;
250        }
251        let mut best: Option<(f64, [f64; 3])> = None;
252        let start = face.tri_start as usize;
253        let end = start + face.tri_count as usize;
254        let end = end.min(indices.len() / 3);
255        let mut test_triangle = |tri: usize| {
256            let i0 = indices[tri * 3] as usize;
257            let i1 = indices[tri * 3 + 1] as usize;
258            let i2 = indices[tri * 3 + 2] as usize;
259            let a = to_f64(positions[i0]);
260            let b = to_f64(positions[i1]);
261            let c = to_f64(positions[i2]);
262            if let Some(t) = ray_triangle(ray, a, b, c, options.double_sided) {
263                let point = add3(ray.origin, scale3(ray.dir, t));
264                if best.map(|(bt, _)| t < bt).unwrap_or(true) {
265                    best = Some((t, point));
266                }
267            }
268        };
269        match candidates {
270            Some(triangles) => {
271                let first = triangles.partition_point(|&tri| tri < start);
272                let last = triangles.partition_point(|&tri| tri < end);
273                for &tri in &triangles[first..last] {
274                    test_triangle(tri);
275                }
276            }
277            None => {
278                for tri in start..end {
279                    test_triangle(tri);
280                }
281            }
282        }
283        if let Some((_, point)) = best {
284            let depth = view_depth(camera, forward, point);
285            // A section cap is the cut of the solid, not a face of it: a
286            // SOLID hit.
287            if face.kind == crate::scene::FaceKind::SectionCap {
288                cap_depth = Some(cap_depth.map_or(depth, |d| d.min(depth)));
289                out.push(PickCandidate {
290                    kind: PickKind::Solid,
291                    name: solid.name.clone(),
292                    solid: solid.name.clone(),
293                    depth,
294                    screen_dist: 0.0,
295                    position: point,
296                });
297                continue;
298            }
299            out.push(PickCandidate {
300                kind: PickKind::Face,
301                name: face.name.clone(),
302                solid: solid.name.clone(),
303                depth,
304                screen_dist: 0.0,
305                position: point,
306            });
307        }
308    }
309    // The cap closes the cut: this solid's faces behind it are not under the
310    // cursor (a click on the cut must not pick the inside of the far wall).
311    if let Some(cap) = cap_depth {
312        let tolerance = 1e-6 * cap.abs().max(1.0);
313        let mut index = first_hit;
314        while index < out.len() {
315            if out[index].kind == PickKind::Face && out[index].depth > cap + tolerance {
316                out.remove(index);
317            } else {
318                index += 1;
319            }
320        }
321    }
322}
323
324#[allow(clippy::too_many_arguments)]
325fn pick_edges(
326    solid: &SolidDisplay,
327    camera: &ViewCamera,
328    x: f64,
329    y: f64,
330    forward: [f64; 3],
331    persp: bool,
332    options: &PickOptions,
333    out: &mut Vec<PickCandidate>,
334) {
335    for (index, edge) in solid.edges.iter().enumerate() {
336        // Hidden edges (scene-tree checkboxes) are not rendered → not pickable.
337        // An AUX edge is display-only — a board's copper outline — and has no
338        // entity to hover or select.
339        if edge.aux || !solid.visibility.is_edge_visible(index) {
340            continue;
341        }
342        let mut best: Option<(f64, f64, [f64; 3])> = None; // (screen_dist, depth, world)
343        for pair in edge.polyline.windows(2) {
344            let mut a = to_f64(pair[0]);
345            let mut b = to_f64(pair[1]);
346            if persp {
347                let da = view_depth(camera, forward, a);
348                let db = view_depth(camera, forward, b);
349                const EPS: f64 = 1e-6;
350                if da <= EPS && db <= EPS {
351                    continue;
352                }
353                if da <= EPS || db <= EPS {
354                    // Clip the behind-eye endpoint to just in front.
355                    let t = (EPS - da) / (db - da);
356                    let clip = add3(a, scale3(sub3(b, a), t));
357                    if da <= EPS {
358                        a = clip;
359                    } else {
360                        b = clip;
361                    }
362                }
363            }
364            let (ax, ay, _) = camera.project(a);
365            let (bx, by, _) = camera.project(b);
366            let (dist, t) = point_segment_distance((x, y), (ax, ay), (bx, by));
367            if dist <= options.edge_px {
368                let world = add3(a, scale3(sub3(b, a), t));
369                let depth = view_depth(camera, forward, world);
370                if best
371                    .map(|(bd, bdepth, _)| dist < bd || (dist == bd && depth < bdepth))
372                    .unwrap_or(true)
373                {
374                    best = Some((dist, depth, world));
375                }
376            }
377        }
378        if let Some((screen_dist, depth, position)) = best {
379            out.push(PickCandidate {
380                kind: PickKind::Edge,
381                name: edge.name.clone(),
382                solid: solid.name.clone(),
383                depth,
384                screen_dist,
385                position,
386            });
387        }
388    }
389}
390
391#[allow(clippy::too_many_arguments)]
392fn pick_vertices(
393    solid: &SolidDisplay,
394    camera: &ViewCamera,
395    x: f64,
396    y: f64,
397    forward: [f64; 3],
398    persp: bool,
399    options: &PickOptions,
400    out: &mut Vec<PickCandidate>,
401) {
402    for (index, vertex) in solid.vertices.iter().enumerate() {
403        // Hidden vertices (scene-tree checkboxes) are not rendered → not pickable.
404        if !solid.visibility.is_vertex_visible(index) {
405            continue;
406        }
407        let depth = view_depth(camera, forward, vertex.position);
408        if persp && depth <= 1e-6 {
409            continue;
410        }
411        let (sx, sy, _) = camera.project(vertex.position);
412        let dist = ((sx - x).powi(2) + (sy - y).powi(2)).sqrt();
413        if dist <= options.vertex_px {
414            out.push(PickCandidate {
415                kind: PickKind::Vertex,
416                name: String::new(),
417                solid: solid.name.clone(),
418                depth,
419                screen_dist: dist,
420                position: vertex.position,
421            });
422        }
423    }
424}
425
426fn to_f64(p: [f32; 3]) -> [f64; 3] {
427    [p[0] as f64, p[1] as f64, p[2] as f64]
428}
429
430/// Möller–Trumbore; returns the ray parameter t of the hit.
431fn ray_triangle(ray: &Ray, a: [f64; 3], b: [f64; 3], c: [f64; 3], double_sided: bool) -> Option<f64> {
432    let e1 = sub3(b, a);
433    let e2 = sub3(c, a);
434    let pvec = cross3(ray.dir, e2);
435    let det = dot3(e1, pvec);
436    const EPS: f64 = 1e-14;
437    if double_sided {
438        if det.abs() < EPS {
439            return None;
440        }
441    } else if det < EPS {
442        return None;
443    }
444    let inv_det = 1.0 / det;
445    let tvec = sub3(ray.origin, a);
446    let u = dot3(tvec, pvec) * inv_det;
447    if !(-1e-9..=1.0 + 1e-9).contains(&u) {
448        return None;
449    }
450    let qvec = cross3(tvec, e1);
451    let v = dot3(ray.dir, qvec) * inv_det;
452    if v < -1e-9 || u + v > 1.0 + 1e-9 {
453        return None;
454    }
455    let t = dot3(e2, qvec) * inv_det;
456    if t <= 0.0 {
457        return None;
458    }
459    Some(t)
460}
461
462fn ray_hits_aabb(ray: &Ray, bbox: &crate::camera::Aabb) -> bool {
463    if bbox.is_empty() {
464        return false;
465    }
466    let mut t_min = f64::NEG_INFINITY;
467    let mut t_max = f64::INFINITY;
468    for axis in 0..3 {
469        let dir = ray.dir[axis];
470        let origin = ray.origin[axis];
471        if dir.abs() < 1e-15 {
472            if origin < bbox.min[axis] - 1e-9 || origin > bbox.max[axis] + 1e-9 {
473                return false;
474            }
475            continue;
476        }
477        let inv = 1.0 / dir;
478        let t0 = (bbox.min[axis] - origin) * inv;
479        let t1 = (bbox.max[axis] - origin) * inv;
480        let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
481        t_min = t_min.max(lo);
482        t_max = t_max.min(hi);
483        if t_min > t_max {
484            return false;
485        }
486    }
487    t_max > 0.0
488}
489
490/// Serialize candidates for the R3 JSON boundary.
491pub fn candidates_to_json(candidates: &[PickCandidate]) -> String {
492    let list: Vec<serde_json::Value> = candidates
493        .iter()
494        .map(|c| {
495            serde_json::json!({
496                "kind": c.kind.as_str(),
497                "name": c.name,
498                "solid": c.solid,
499                "depth": c.depth,
500                "screenDist": c.screen_dist,
501                "position": c.position,
502            })
503        })
504        .collect();
505    serde_json::Value::Array(list).to_string()
506}
507