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