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        if !solid.visibility.is_edge_visible(index) {
242            continue;
243        }
244        let mut best: Option<(f64, f64, [f64; 3])> = None; // (screen_dist, depth, world)
245        for pair in edge.polyline.windows(2) {
246            let mut a = to_f64(pair[0]);
247            let mut b = to_f64(pair[1]);
248            if persp {
249                let da = view_depth(camera, forward, a);
250                let db = view_depth(camera, forward, b);
251                const EPS: f64 = 1e-6;
252                if da <= EPS && db <= EPS {
253                    continue;
254                }
255                if da <= EPS || db <= EPS {
256                    // Clip the behind-eye endpoint to just in front.
257                    let t = (EPS - da) / (db - da);
258                    let clip = add3(a, scale3(sub3(b, a), t));
259                    if da <= EPS {
260                        a = clip;
261                    } else {
262                        b = clip;
263                    }
264                }
265            }
266            let (ax, ay, _) = camera.project(a);
267            let (bx, by, _) = camera.project(b);
268            let (dist, t) = point_segment_distance((x, y), (ax, ay), (bx, by));
269            if dist <= options.edge_px {
270                let world = add3(a, scale3(sub3(b, a), t));
271                let depth = view_depth(camera, forward, world);
272                if best
273                    .map(|(bd, bdepth, _)| dist < bd || (dist == bd && depth < bdepth))
274                    .unwrap_or(true)
275                {
276                    best = Some((dist, depth, world));
277                }
278            }
279        }
280        if let Some((screen_dist, depth, position)) = best {
281            out.push(PickCandidate {
282                kind: PickKind::Edge,
283                name: edge.name.clone(),
284                solid: solid.name.clone(),
285                depth,
286                screen_dist,
287                position,
288            });
289        }
290    }
291}
292
293#[allow(clippy::too_many_arguments)]
294fn pick_vertices(
295    solid: &SolidDisplay,
296    camera: &ViewCamera,
297    x: f64,
298    y: f64,
299    forward: [f64; 3],
300    persp: bool,
301    options: &PickOptions,
302    out: &mut Vec<PickCandidate>,
303) {
304    for (index, vertex) in solid.vertices.iter().enumerate() {
305        // Hidden vertices (scene-tree checkboxes) are not rendered → not pickable.
306        if !solid.visibility.is_vertex_visible(index) {
307            continue;
308        }
309        let depth = view_depth(camera, forward, vertex.position);
310        if persp && depth <= 1e-6 {
311            continue;
312        }
313        let (sx, sy, _) = camera.project(vertex.position);
314        let dist = ((sx - x).powi(2) + (sy - y).powi(2)).sqrt();
315        if dist <= options.vertex_px {
316            out.push(PickCandidate {
317                kind: PickKind::Vertex,
318                name: String::new(),
319                solid: solid.name.clone(),
320                depth,
321                screen_dist: dist,
322                position: vertex.position,
323            });
324        }
325    }
326}
327
328fn to_f64(p: [f32; 3]) -> [f64; 3] {
329    [p[0] as f64, p[1] as f64, p[2] as f64]
330}
331
332/// Möller–Trumbore; returns the ray parameter t of the hit.
333fn ray_triangle(ray: &Ray, a: [f64; 3], b: [f64; 3], c: [f64; 3], double_sided: bool) -> Option<f64> {
334    let e1 = sub3(b, a);
335    let e2 = sub3(c, a);
336    let pvec = cross3(ray.dir, e2);
337    let det = dot3(e1, pvec);
338    const EPS: f64 = 1e-14;
339    if double_sided {
340        if det.abs() < EPS {
341            return None;
342        }
343    } else if det < EPS {
344        return None;
345    }
346    let inv_det = 1.0 / det;
347    let tvec = sub3(ray.origin, a);
348    let u = dot3(tvec, pvec) * inv_det;
349    if !(-1e-9..=1.0 + 1e-9).contains(&u) {
350        return None;
351    }
352    let qvec = cross3(tvec, e1);
353    let v = dot3(ray.dir, qvec) * inv_det;
354    if v < -1e-9 || u + v > 1.0 + 1e-9 {
355        return None;
356    }
357    let t = dot3(e2, qvec) * inv_det;
358    if t <= 0.0 {
359        return None;
360    }
361    Some(t)
362}
363
364fn ray_hits_aabb(ray: &Ray, bbox: &crate::camera::Aabb) -> bool {
365    if bbox.is_empty() {
366        return false;
367    }
368    let mut t_min = f64::NEG_INFINITY;
369    let mut t_max = f64::INFINITY;
370    for axis in 0..3 {
371        let dir = ray.dir[axis];
372        let origin = ray.origin[axis];
373        if dir.abs() < 1e-15 {
374            if origin < bbox.min[axis] - 1e-9 || origin > bbox.max[axis] + 1e-9 {
375                return false;
376            }
377            continue;
378        }
379        let inv = 1.0 / dir;
380        let t0 = (bbox.min[axis] - origin) * inv;
381        let t1 = (bbox.max[axis] - origin) * inv;
382        let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
383        t_min = t_min.max(lo);
384        t_max = t_max.min(hi);
385        if t_min > t_max {
386            return false;
387        }
388    }
389    t_max > 0.0
390}
391
392/// Serialize candidates for the R3 JSON boundary.
393pub fn candidates_to_json(candidates: &[PickCandidate]) -> String {
394    let list: Vec<serde_json::Value> = candidates
395        .iter()
396        .map(|c| {
397            serde_json::json!({
398                "kind": c.kind.as_str(),
399                "name": c.name,
400                "solid": c.solid,
401                "depth": c.depth,
402                "screenDist": c.screen_dist,
403                "position": c.position,
404            })
405        })
406        .collect();
407    serde_json::Value::Array(list).to_string()
408}
409
410// BREP private tests: 6b8c3ac0d3037b0d