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/// Rank candidates under CSS-pixel `(x, y)`. Ordered by the priority chain
96/// then depth; a SOLID entry per hit solid is appended at the end (the earlier
97/// "extras" behavior).
98pub fn pick(
99    scene: &RenderScene,
100    camera: &ViewCamera,
101    x: f64,
102    y: f64,
103    options: &PickOptions,
104) -> Vec<PickCandidate> {
105    let ray = camera.pick_ray(x, y);
106    let (_, _, forward) = camera.basis();
107    let persp = matches!(camera.projection, Projection::Perspective { .. });
108
109    let mut hits: Vec<PickCandidate> = Vec::new();
110    let mut triangles = Vec::new();
111    for solid in scene.solids() {
112        if !solid.visible {
113            continue;
114        }
115        if ray_hits_aabb(&ray, &solid.bbox) {
116            // Small component meshes cost less to scan than to index. Dense
117            // copper/silkscreen displays dominate UNO hover time without this
118            // broad phase, even though they contain only a handful of faces.
119            let candidates = if solid.mesh.indices.len() / 3 > 128 {
120                triangles.clear();
121                scene.pick_triangles(solid, &ray, &mut triangles);
122                Some(triangles.as_slice())
123            } else {
124                None
125            };
126            pick_faces(solid, camera, &ray, forward, options, candidates, &mut hits);
127        }
128        pick_edges(solid, camera, x, y, forward, persp, options, &mut hits);
129        pick_vertices(solid, camera, x, y, forward, persp, options, &mut hits);
130    }
131
132    hits.sort_by(|a, b| {
133        (a.kind as u8)
134            .cmp(&(b.kind as u8))
135            .then(a.depth.total_cmp(&b.depth))
136            .then(a.screen_dist.total_cmp(&b.screen_dist))
137    });
138    hits.truncate(MAX_CANDIDATES);
139
140    // Append one SOLID candidate per hit solid, ordered by first (best) hit.
141    let mut solids_seen: Vec<String> = Vec::new();
142    let mut solid_entries: Vec<PickCandidate> = Vec::new();
143    for hit in &hits {
144        if solids_seen.iter().any(|name| name == &hit.solid) {
145            continue;
146        }
147        solids_seen.push(hit.solid.clone());
148        solid_entries.push(PickCandidate {
149            kind: PickKind::Solid,
150            name: hit.solid.clone(),
151            solid: hit.solid.clone(),
152            depth: hit.depth,
153            screen_dist: hit.screen_dist,
154            position: hit.position,
155        });
156    }
157    hits.extend(solid_entries);
158    hits
159}
160
161/// The nearest hit of an ALLOWED KIND under CSS-pixel `(x, y)`, using the same
162/// ranking as [`pick`]. `filter` is a set of kind strings (`"SOLID"`, `"FACE"`,
163/// `"EDGE"`, `"VERTEX"`, case-insensitive); an empty filter means any kind. This
164/// is the type-constrained pick the reference-selection widget uses so a
165/// `targetSolid` field resolves the SOLID under the cursor (candidates rank
166/// faces first, then the trailing SOLID entry — `find` walks that order and
167/// returns the first candidate whose kind is allowed). Returns the full
168/// candidate (name + owning solid + position) or `None` on a miss.
169pub fn pick_filtered(
170    scene: &RenderScene,
171    camera: &ViewCamera,
172    x: f64,
173    y: f64,
174    options: &PickOptions,
175    filter: &[String],
176) -> Option<PickCandidate> {
177    pick(scene, camera, x, y, options).into_iter().find(|c| {
178        filter.is_empty() || filter.iter().any(|f| f.eq_ignore_ascii_case(c.kind.as_str()))
179    })
180}
181
182fn view_depth(camera: &ViewCamera, forward: [f64; 3], point: [f64; 3]) -> f64 {
183    dot3(sub3(point, camera.eye), forward)
184}
185
186#[allow(clippy::too_many_arguments)]
187fn pick_faces(
188    solid: &SolidDisplay,
189    camera: &ViewCamera,
190    ray: &Ray,
191    forward: [f64; 3],
192    options: &PickOptions,
193    candidates: Option<&[usize]>,
194    out: &mut Vec<PickCandidate>,
195) {
196    if solid.mesh.indices.is_empty() {
197        return;
198    }
199    if !ray_hits_aabb(ray, &solid.bbox) {
200        return;
201    }
202    let positions = &solid.mesh.positions;
203    let indices = &solid.mesh.indices;
204    for (index, face) in solid.faces.iter().enumerate() {
205        // Per-entity visibility (the scene tree's face checkboxes): a hidden
206        // face isn't rendered, so it must not be hoverable/pickable either.
207        if !solid.visibility.is_face_visible(index) {
208            continue;
209        }
210        if face.tri_count == 0 {
211            continue;
212        }
213        let mut best: Option<(f64, [f64; 3])> = None;
214        let start = face.tri_start as usize;
215        let end = start + face.tri_count as usize;
216        let end = end.min(indices.len() / 3);
217        let mut test_triangle = |tri: usize| {
218            let i0 = indices[tri * 3] as usize;
219            let i1 = indices[tri * 3 + 1] as usize;
220            let i2 = indices[tri * 3 + 2] as usize;
221            let a = to_f64(positions[i0]);
222            let b = to_f64(positions[i1]);
223            let c = to_f64(positions[i2]);
224            if let Some(t) = ray_triangle(ray, a, b, c, options.double_sided) {
225                let point = add3(ray.origin, scale3(ray.dir, t));
226                if best.map(|(bt, _)| t < bt).unwrap_or(true) {
227                    best = Some((t, point));
228                }
229            }
230        };
231        match candidates {
232            Some(triangles) => {
233                let first = triangles.partition_point(|&tri| tri < start);
234                let last = triangles.partition_point(|&tri| tri < end);
235                for &tri in &triangles[first..last] {
236                    test_triangle(tri);
237                }
238            }
239            None => {
240                for tri in start..end {
241                    test_triangle(tri);
242                }
243            }
244        }
245        if let Some((_, point)) = best {
246            out.push(PickCandidate {
247                kind: PickKind::Face,
248                name: face.name.clone(),
249                solid: solid.name.clone(),
250                depth: view_depth(camera, forward, point),
251                screen_dist: 0.0,
252                position: point,
253            });
254        }
255    }
256}
257
258#[allow(clippy::too_many_arguments)]
259fn pick_edges(
260    solid: &SolidDisplay,
261    camera: &ViewCamera,
262    x: f64,
263    y: f64,
264    forward: [f64; 3],
265    persp: bool,
266    options: &PickOptions,
267    out: &mut Vec<PickCandidate>,
268) {
269    for (index, edge) in solid.edges.iter().enumerate() {
270        // Hidden edges (scene-tree checkboxes) are not rendered → not pickable.
271        // An AUX edge is display-only — a board's copper outline — and has no
272        // entity to hover or select.
273        if edge.aux || !solid.visibility.is_edge_visible(index) {
274            continue;
275        }
276        let mut best: Option<(f64, f64, [f64; 3])> = None; // (screen_dist, depth, world)
277        for pair in edge.polyline.windows(2) {
278            let mut a = to_f64(pair[0]);
279            let mut b = to_f64(pair[1]);
280            if persp {
281                let da = view_depth(camera, forward, a);
282                let db = view_depth(camera, forward, b);
283                const EPS: f64 = 1e-6;
284                if da <= EPS && db <= EPS {
285                    continue;
286                }
287                if da <= EPS || db <= EPS {
288                    // Clip the behind-eye endpoint to just in front.
289                    let t = (EPS - da) / (db - da);
290                    let clip = add3(a, scale3(sub3(b, a), t));
291                    if da <= EPS {
292                        a = clip;
293                    } else {
294                        b = clip;
295                    }
296                }
297            }
298            let (ax, ay, _) = camera.project(a);
299            let (bx, by, _) = camera.project(b);
300            let (dist, t) = point_segment_distance((x, y), (ax, ay), (bx, by));
301            if dist <= options.edge_px {
302                let world = add3(a, scale3(sub3(b, a), t));
303                let depth = view_depth(camera, forward, world);
304                if best
305                    .map(|(bd, bdepth, _)| dist < bd || (dist == bd && depth < bdepth))
306                    .unwrap_or(true)
307                {
308                    best = Some((dist, depth, world));
309                }
310            }
311        }
312        if let Some((screen_dist, depth, position)) = best {
313            out.push(PickCandidate {
314                kind: PickKind::Edge,
315                name: edge.name.clone(),
316                solid: solid.name.clone(),
317                depth,
318                screen_dist,
319                position,
320            });
321        }
322    }
323}
324
325#[allow(clippy::too_many_arguments)]
326fn pick_vertices(
327    solid: &SolidDisplay,
328    camera: &ViewCamera,
329    x: f64,
330    y: f64,
331    forward: [f64; 3],
332    persp: bool,
333    options: &PickOptions,
334    out: &mut Vec<PickCandidate>,
335) {
336    for (index, vertex) in solid.vertices.iter().enumerate() {
337        // Hidden vertices (scene-tree checkboxes) are not rendered → not pickable.
338        if !solid.visibility.is_vertex_visible(index) {
339            continue;
340        }
341        let depth = view_depth(camera, forward, vertex.position);
342        if persp && depth <= 1e-6 {
343            continue;
344        }
345        let (sx, sy, _) = camera.project(vertex.position);
346        let dist = ((sx - x).powi(2) + (sy - y).powi(2)).sqrt();
347        if dist <= options.vertex_px {
348            out.push(PickCandidate {
349                kind: PickKind::Vertex,
350                name: String::new(),
351                solid: solid.name.clone(),
352                depth,
353                screen_dist: dist,
354                position: vertex.position,
355            });
356        }
357    }
358}
359
360fn to_f64(p: [f32; 3]) -> [f64; 3] {
361    [p[0] as f64, p[1] as f64, p[2] as f64]
362}
363
364/// Möller–Trumbore; returns the ray parameter t of the hit.
365fn ray_triangle(ray: &Ray, a: [f64; 3], b: [f64; 3], c: [f64; 3], double_sided: bool) -> Option<f64> {
366    let e1 = sub3(b, a);
367    let e2 = sub3(c, a);
368    let pvec = cross3(ray.dir, e2);
369    let det = dot3(e1, pvec);
370    const EPS: f64 = 1e-14;
371    if double_sided {
372        if det.abs() < EPS {
373            return None;
374        }
375    } else if det < EPS {
376        return None;
377    }
378    let inv_det = 1.0 / det;
379    let tvec = sub3(ray.origin, a);
380    let u = dot3(tvec, pvec) * inv_det;
381    if !(-1e-9..=1.0 + 1e-9).contains(&u) {
382        return None;
383    }
384    let qvec = cross3(tvec, e1);
385    let v = dot3(ray.dir, qvec) * inv_det;
386    if v < -1e-9 || u + v > 1.0 + 1e-9 {
387        return None;
388    }
389    let t = dot3(e2, qvec) * inv_det;
390    if t <= 0.0 {
391        return None;
392    }
393    Some(t)
394}
395
396fn ray_hits_aabb(ray: &Ray, bbox: &crate::camera::Aabb) -> bool {
397    if bbox.is_empty() {
398        return false;
399    }
400    let mut t_min = f64::NEG_INFINITY;
401    let mut t_max = f64::INFINITY;
402    for axis in 0..3 {
403        let dir = ray.dir[axis];
404        let origin = ray.origin[axis];
405        if dir.abs() < 1e-15 {
406            if origin < bbox.min[axis] - 1e-9 || origin > bbox.max[axis] + 1e-9 {
407                return false;
408            }
409            continue;
410        }
411        let inv = 1.0 / dir;
412        let t0 = (bbox.min[axis] - origin) * inv;
413        let t1 = (bbox.max[axis] - origin) * inv;
414        let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
415        t_min = t_min.max(lo);
416        t_max = t_max.min(hi);
417        if t_min > t_max {
418            return false;
419        }
420    }
421    t_max > 0.0
422}
423
424/// Serialize candidates for the R3 JSON boundary.
425pub fn candidates_to_json(candidates: &[PickCandidate]) -> String {
426    let list: Vec<serde_json::Value> = candidates
427        .iter()
428        .map(|c| {
429            serde_json::json!({
430                "kind": c.kind.as_str(),
431                "name": c.name,
432                "solid": c.solid,
433                "depth": c.depth,
434                "screenDist": c.screen_dist,
435                "position": c.position,
436            })
437        })
438        .collect();
439    serde_json::Value::Array(list).to_string()
440}
441