use crate::scene::{RenderScene, SolidDisplay};
use crate::view::{add3, cross3, dot3, scale3, sub3, Projection, Ray, ViewCamera};
pub const EDGE_PICK_PX: f64 = 6.0;
pub const VERTEX_PICK_PX: f64 = 6.0;
const MAX_CANDIDATES: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum PickKind {
Vertex = 0,
Edge = 1,
Face = 2,
Solid = 3,
}
impl PickKind {
pub fn as_str(&self) -> &'static str {
match self {
PickKind::Vertex => "VERTEX",
PickKind::Edge => "EDGE",
PickKind::Face => "FACE",
PickKind::Solid => "SOLID",
}
}
}
#[derive(Debug, Clone)]
pub struct PickCandidate {
pub kind: PickKind,
pub name: String,
pub solid: String,
pub depth: f64,
pub screen_dist: f64,
pub position: [f64; 3],
}
#[derive(Debug, Clone, Copy)]
pub struct PickOptions {
pub double_sided: bool,
pub edge_px: f64,
pub vertex_px: f64,
}
impl Default for PickOptions {
fn default() -> Self {
Self {
double_sided: true,
edge_px: EDGE_PICK_PX,
vertex_px: VERTEX_PICK_PX,
}
}
}
pub fn pick(
scene: &RenderScene,
camera: &ViewCamera,
x: f64,
y: f64,
options: &PickOptions,
) -> Vec<PickCandidate> {
let ray = camera.pick_ray(x, y);
let (_, _, forward) = camera.basis();
let persp = matches!(camera.projection, Projection::Perspective { .. });
let mut hits: Vec<PickCandidate> = Vec::new();
for solid in scene.solids() {
if !solid.visible {
continue;
}
pick_faces(solid, camera, &ray, forward, options, &mut hits);
pick_edges(solid, camera, x, y, forward, persp, options, &mut hits);
pick_vertices(solid, camera, x, y, forward, persp, options, &mut hits);
}
hits.sort_by(|a, b| {
(a.kind as u8)
.cmp(&(b.kind as u8))
.then(a.depth.total_cmp(&b.depth))
.then(a.screen_dist.total_cmp(&b.screen_dist))
});
hits.truncate(MAX_CANDIDATES);
let mut solids_seen: Vec<String> = Vec::new();
let mut solid_entries: Vec<PickCandidate> = Vec::new();
for hit in &hits {
if solids_seen.iter().any(|name| name == &hit.solid) {
continue;
}
solids_seen.push(hit.solid.clone());
solid_entries.push(PickCandidate {
kind: PickKind::Solid,
name: hit.solid.clone(),
solid: hit.solid.clone(),
depth: hit.depth,
screen_dist: hit.screen_dist,
position: hit.position,
});
}
hits.extend(solid_entries);
hits
}
pub fn pick_filtered(
scene: &RenderScene,
camera: &ViewCamera,
x: f64,
y: f64,
options: &PickOptions,
filter: &[String],
) -> Option<PickCandidate> {
pick(scene, camera, x, y, options).into_iter().find(|c| {
filter.is_empty() || filter.iter().any(|f| f.eq_ignore_ascii_case(c.kind.as_str()))
})
}
fn view_depth(camera: &ViewCamera, forward: [f64; 3], point: [f64; 3]) -> f64 {
dot3(sub3(point, camera.eye), forward)
}
fn pick_faces(
solid: &SolidDisplay,
camera: &ViewCamera,
ray: &Ray,
forward: [f64; 3],
options: &PickOptions,
out: &mut Vec<PickCandidate>,
) {
if solid.mesh.indices.is_empty() {
return;
}
if !ray_hits_aabb(ray, &solid.bbox) {
return;
}
let positions = &solid.mesh.positions;
let indices = &solid.mesh.indices;
for (index, face) in solid.faces.iter().enumerate() {
if !solid.visibility.is_face_visible(index) {
continue;
}
if face.tri_count == 0 {
continue;
}
let mut best: Option<(f64, [f64; 3])> = None;
let start = face.tri_start as usize;
let end = start + face.tri_count as usize;
for tri in start..end.min(indices.len() / 3) {
let i0 = indices[tri * 3] as usize;
let i1 = indices[tri * 3 + 1] as usize;
let i2 = indices[tri * 3 + 2] as usize;
let a = to_f64(positions[i0]);
let b = to_f64(positions[i1]);
let c = to_f64(positions[i2]);
if let Some(t) = ray_triangle(ray, a, b, c, options.double_sided) {
let point = add3(ray.origin, scale3(ray.dir, t));
if best.map(|(bt, _)| t < bt).unwrap_or(true) {
best = Some((t, point));
}
}
}
if let Some((_, point)) = best {
out.push(PickCandidate {
kind: PickKind::Face,
name: face.name.clone(),
solid: solid.name.clone(),
depth: view_depth(camera, forward, point),
screen_dist: 0.0,
position: point,
});
}
}
}
#[allow(clippy::too_many_arguments)]
fn pick_edges(
solid: &SolidDisplay,
camera: &ViewCamera,
x: f64,
y: f64,
forward: [f64; 3],
persp: bool,
options: &PickOptions,
out: &mut Vec<PickCandidate>,
) {
for (index, edge) in solid.edges.iter().enumerate() {
if !solid.visibility.is_edge_visible(index) {
continue;
}
let mut best: Option<(f64, f64, [f64; 3])> = None; for pair in edge.polyline.windows(2) {
let mut a = to_f64(pair[0]);
let mut b = to_f64(pair[1]);
if persp {
let da = view_depth(camera, forward, a);
let db = view_depth(camera, forward, b);
const EPS: f64 = 1e-6;
if da <= EPS && db <= EPS {
continue;
}
if da <= EPS || db <= EPS {
let t = (EPS - da) / (db - da);
let clip = add3(a, scale3(sub3(b, a), t));
if da <= EPS {
a = clip;
} else {
b = clip;
}
}
}
let (ax, ay, _) = camera.project(a);
let (bx, by, _) = camera.project(b);
let (dist, t) = point_segment_distance_2d(x, y, ax, ay, bx, by);
if dist <= options.edge_px {
let world = add3(a, scale3(sub3(b, a), t));
let depth = view_depth(camera, forward, world);
if best
.map(|(bd, bdepth, _)| dist < bd || (dist == bd && depth < bdepth))
.unwrap_or(true)
{
best = Some((dist, depth, world));
}
}
}
if let Some((screen_dist, depth, position)) = best {
out.push(PickCandidate {
kind: PickKind::Edge,
name: edge.name.clone(),
solid: solid.name.clone(),
depth,
screen_dist,
position,
});
}
}
}
#[allow(clippy::too_many_arguments)]
fn pick_vertices(
solid: &SolidDisplay,
camera: &ViewCamera,
x: f64,
y: f64,
forward: [f64; 3],
persp: bool,
options: &PickOptions,
out: &mut Vec<PickCandidate>,
) {
for (index, vertex) in solid.vertices.iter().enumerate() {
if !solid.visibility.is_vertex_visible(index) {
continue;
}
let depth = view_depth(camera, forward, vertex.position);
if persp && depth <= 1e-6 {
continue;
}
let (sx, sy, _) = camera.project(vertex.position);
let dist = ((sx - x).powi(2) + (sy - y).powi(2)).sqrt();
if dist <= options.vertex_px {
out.push(PickCandidate {
kind: PickKind::Vertex,
name: String::new(),
solid: solid.name.clone(),
depth,
screen_dist: dist,
position: vertex.position,
});
}
}
}
fn to_f64(p: [f32; 3]) -> [f64; 3] {
[p[0] as f64, p[1] as f64, p[2] as f64]
}
fn point_segment_distance_2d(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> (f64, f64) {
let abx = bx - ax;
let aby = by - ay;
let len2 = abx * abx + aby * aby;
let t = if len2 <= 1e-18 {
0.0
} else {
(((px - ax) * abx + (py - ay) * aby) / len2).clamp(0.0, 1.0)
};
let cx = ax + abx * t;
let cy = ay + aby * t;
(((px - cx).powi(2) + (py - cy).powi(2)).sqrt(), t)
}
fn ray_triangle(ray: &Ray, a: [f64; 3], b: [f64; 3], c: [f64; 3], double_sided: bool) -> Option<f64> {
let e1 = sub3(b, a);
let e2 = sub3(c, a);
let pvec = cross3(ray.dir, e2);
let det = dot3(e1, pvec);
const EPS: f64 = 1e-14;
if double_sided {
if det.abs() < EPS {
return None;
}
} else if det < EPS {
return None;
}
let inv_det = 1.0 / det;
let tvec = sub3(ray.origin, a);
let u = dot3(tvec, pvec) * inv_det;
if !(-1e-9..=1.0 + 1e-9).contains(&u) {
return None;
}
let qvec = cross3(tvec, e1);
let v = dot3(ray.dir, qvec) * inv_det;
if v < -1e-9 || u + v > 1.0 + 1e-9 {
return None;
}
let t = dot3(e2, qvec) * inv_det;
if t <= 0.0 {
return None;
}
Some(t)
}
fn ray_hits_aabb(ray: &Ray, bbox: &crate::camera::Aabb) -> bool {
if bbox.is_empty() {
return false;
}
let mut t_min = f64::NEG_INFINITY;
let mut t_max = f64::INFINITY;
for axis in 0..3 {
let dir = ray.dir[axis];
let origin = ray.origin[axis];
if dir.abs() < 1e-15 {
if origin < bbox.min[axis] - 1e-9 || origin > bbox.max[axis] + 1e-9 {
return false;
}
continue;
}
let inv = 1.0 / dir;
let t0 = (bbox.min[axis] - origin) * inv;
let t1 = (bbox.max[axis] - origin) * inv;
let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
t_min = t_min.max(lo);
t_max = t_max.min(hi);
if t_min > t_max {
return false;
}
}
t_max > 0.0
}
pub fn candidates_to_json(candidates: &[PickCandidate]) -> String {
let list: Vec<serde_json::Value> = candidates
.iter()
.map(|c| {
serde_json::json!({
"kind": c.kind.as_str(),
"name": c.name,
"solid": c.solid,
"depth": c.depth,
"screenDist": c.screen_dist,
"position": c.position,
})
})
.collect();
serde_json::Value::Array(list).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::scene_from_history_json;
fn cube_scene() -> RenderScene {
let request = serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "PickCube",
"sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
})
.to_string();
let (scene, report) = scene_from_history_json(&request).unwrap();
assert!(report.feature_errors.is_empty(), "{:?}", report.feature_errors);
scene
}
fn front_camera() -> ViewCamera {
ViewCamera {
eye: [5.0, 5.0, 50.0],
target: [5.0, 5.0, 5.0],
up: [0.0, 1.0, 0.0],
projection: Projection::Orthographic { half_height: 10.0 },
width: 800.0,
height: 600.0,
near: -1000.0,
far: 1000.0,
}
}
#[test]
fn face_pick_at_center_is_deterministic() {
let scene = cube_scene();
let camera = front_camera();
let first = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
let second = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
assert!(!first.is_empty());
assert_eq!(first.len(), second.len());
for (a, b) in first.iter().zip(second.iter()) {
assert_eq!(a.kind, b.kind);
assert_eq!(a.name, b.name);
assert_eq!(a.depth.to_bits(), b.depth.to_bits(), "deterministic depth");
}
assert_eq!(first[0].kind, PickKind::Face);
assert!(!first[0].name.is_empty(), "face has a kernel name");
assert_eq!(first.last().unwrap().kind, PickKind::Solid);
assert_eq!(first.last().unwrap().name, "PickCube");
assert!(first[0].depth < first[1].depth);
assert!((first[0].position[2] - 10.0).abs() < 1e-9);
}
#[test]
fn edge_and_vertex_priority_ranking() {
let scene = cube_scene();
let camera = front_camera();
let (cx, cy, _) = camera.project([10.0, 10.0, 10.0]);
let hits = pick(&scene, &camera, cx, cy, &PickOptions::default());
assert!(!hits.is_empty());
assert_eq!(hits[0].kind, PickKind::Vertex);
assert!(crate::view::len3(crate::view::sub3(hits[0].position, [10.0, 10.0, 10.0])) < 1e-9);
assert!(hits.iter().any(|h| h.kind == PickKind::Edge));
assert!(hits.iter().any(|h| h.kind == PickKind::Face));
assert_eq!(hits.last().unwrap().kind, PickKind::Solid);
let (ex, ey, _) = camera.project([5.0, 10.0, 10.0]);
let hits = pick(&scene, &camera, ex, ey, &PickOptions::default());
assert_eq!(hits[0].kind, PickKind::Edge);
assert!(!hits[0].name.is_empty(), "edge has a kernel name");
}
#[test]
fn filtered_pick_constrains_to_kind() {
let scene = cube_scene();
let camera = front_camera();
let face = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &["FACE".into()]);
assert_eq!(face.as_ref().unwrap().kind, PickKind::Face);
let solid = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &["SOLID".into()]);
assert_eq!(solid.as_ref().unwrap().kind, PickKind::Solid);
assert_eq!(solid.unwrap().name, "PickCube");
let any = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &[]);
assert_eq!(any.unwrap().kind, PickKind::Face);
let lower = pick_filtered(&scene, &camera, 400.0, 300.0, &PickOptions::default(), &["solid".into()]);
assert_eq!(lower.unwrap().kind, PickKind::Solid);
assert!(pick_filtered(&scene, &camera, 10.0, 10.0, &PickOptions::default(), &["SOLID".into()]).is_none());
}
#[test]
fn miss_returns_empty() {
let scene = cube_scene();
let camera = front_camera();
let hits = pick(&scene, &camera, 10.0, 10.0, &PickOptions::default());
assert!(hits.is_empty(), "{hits:?}");
}
#[test]
fn hidden_entities_are_not_pickable() {
use crate::visibility::EntityKind;
let mut scene = cube_scene();
let camera = front_camera();
let before = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
assert_eq!(before[0].kind, PickKind::Face);
let front_name = before[0].name.clone();
let front_index = scene
.solids()
.iter()
.find(|s| s.name == "PickCube")
.unwrap()
.faces
.iter()
.position(|f| f.name == front_name)
.unwrap();
scene
.solid_mut("PickCube")
.unwrap()
.visibility
.set_visible(EntityKind::Face, front_index, false);
let after = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
assert!(
after
.iter()
.all(|c| !(c.kind == PickKind::Face && c.name == front_name)),
"hidden face still picked: {after:?}"
);
assert_eq!(after[0].kind, PickKind::Face, "the back face is still pickable");
assert!((after[0].position[2] - 0.0).abs() < 1e-9, "back face at z=0");
let vertex_count = scene.solids()[0].vertices.len();
scene
.solid_mut("PickCube")
.unwrap()
.visibility
.set_group_visible(EntityKind::Vertex, vertex_count, false);
let (cx, cy, _) = camera.project([10.0, 10.0, 10.0]);
let corner = pick(&scene, &camera, cx, cy, &PickOptions::default());
assert!(
corner.iter().all(|c| c.kind != PickKind::Vertex),
"hidden vertices still picked: {corner:?}"
);
assert_eq!(corner[0].kind, PickKind::Edge, "edges now outrank the hidden vertex");
let edge_count = scene.solids()[0].edges.len();
scene
.solid_mut("PickCube")
.unwrap()
.visibility
.set_group_visible(EntityKind::Edge, edge_count, false);
let corner = pick(&scene, &camera, cx, cy, &PickOptions::default());
assert!(corner.iter().all(|c| c.kind != PickKind::Edge));
assert!(corner.iter().any(|c| c.kind == PickKind::Face));
}
#[test]
fn hidden_solid_is_not_pickable() {
let mut scene = cube_scene();
scene.solid_mut("PickCube").unwrap().visible = false;
let camera = front_camera();
let hits = pick(&scene, &camera, 400.0, 300.0, &PickOptions::default());
assert!(hits.is_empty());
}
}