viewport_lib/renderer/picking/point.rs
1//! CPU ray-cast pick: find the nearest item or sub-element under the cursor.
2
3use super::*;
4
5impl ViewportRenderer {
6 // -----------------------------------------------------------------------
7 // Unified CPU pick : renderer.pick()
8 // -----------------------------------------------------------------------
9
10 /// Pick the nearest item or sub-element under `click_pos`.
11 ///
12 /// Dispatches across all item types retained from the last `prepare()` call.
13 /// The `mask` controls which item types and sub-element levels participate.
14 ///
15 /// Returns `None` if nothing matching the mask is under the cursor.
16 ///
17 /// # Arguments
18 /// * `click_pos` - cursor position in viewport pixels (top-left origin)
19 /// * `viewport_size` - viewport width x height in pixels
20 /// * `view_proj` - combined view x projection matrix from the last frame
21 /// * `mask` - which item types and sub-element levels to include
22 ///
23 /// # Example
24 /// ```rust,ignore
25 /// if let Some(hit) = renderer.pick(cursor, vp_size, view_proj, PickMask::FACE) {
26 /// println!("hit face {:?} on object {}", hit.sub_object, hit.id);
27 /// }
28 /// ```
29 pub fn pick(
30 &self,
31 click_pos: glam::Vec2,
32 viewport_size: glam::Vec2,
33 view_proj: glam::Mat4,
34 mask: crate::interaction::pick_mask::PickMask,
35 ) -> Option<crate::interaction::picking::PickHit> {
36 use crate::interaction::pick_mask::PickMask;
37 use crate::interaction::picking::{
38 PickHit, pick_gaussian_splat_cpu, pick_point_cloud_cpu,
39 pick_transparent_volume_mesh_cpu, pick_volume_cpu, screen_to_ray,
40 };
41 use crate::interaction::sub_object::SubObjectRef;
42 use parry3d::math::{Pose, Vector};
43 use parry3d::query::{Ray, RayCast};
44
45 if !self.cpu_pick_cache_enabled {
46 warn_pick_cache_disabled();
47 return None;
48 }
49
50 if viewport_size.x <= 0.0 || viewport_size.y <= 0.0 {
51 return None;
52 }
53
54 let view_proj_inv = view_proj.inverse();
55 let (ray_origin, ray_dir) = screen_to_ray(click_pos, viewport_size, view_proj_inv);
56
57 let wants_face = mask.intersects(PickMask::FACE);
58 let wants_vertex = mask.intersects(PickMask::VERTEX);
59 let wants_cell = mask.intersects(PickMask::CELL);
60 let wants_cloud = mask.intersects(PickMask::CLOUD_POINT);
61 let wants_splat = mask.intersects(PickMask::SPLAT);
62 let wants_object = mask.intersects(PickMask::OBJECT);
63 let wants_mesh_sub = wants_face || wants_vertex || mask.intersects(PickMask::EDGE);
64
65 // (toi, hit) -- nearest hit so far across all types.
66 let mut best: Option<(f32, PickHit)> = None;
67
68 let mut consider = |toi: f32, hit: PickHit| {
69 if best.as_ref().map_or(true, |(bt, _)| toi < *bt) {
70 best = Some((toi, hit));
71 }
72 };
73
74 // Build lookup for opaque volume mesh face_to_cell maps (used in section 1
75 // to convert surface Face hits to Cell hits).
76 let vm_cell_map: std::collections::HashMap<u64, &[u32]> = self
77 .pick_volume_mesh_items
78 .iter()
79 .filter(|item| item.settings.pick_id != PickId::NONE && !item.face_to_cell.is_empty())
80 .map(|item| (item.settings.pick_id.0, item.face_to_cell.as_slice()))
81 .collect();
82
83 // 1. Surface mesh picks (FACE, VERTEX, EDGE, CELL, or OBJECT fallback).
84 if wants_mesh_sub || wants_cell || wants_object {
85 let ray = Ray::new(
86 Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
87 Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
88 );
89 for item in &self.pick_scene_items {
90 if item.settings.hidden || item.settings.pick_id == PickId::NONE {
91 continue;
92 }
93 let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
94 continue;
95 };
96 let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
97 else {
98 continue;
99 };
100
101 let model = glam::Mat4::from_cols_array_2d(&item.model);
102
103 // Bake the full model matrix into vertex positions so that
104 // non-uniform scale is handled correctly.
105 let verts: Vec<Vector> = positions
106 .iter()
107 .map(|p| {
108 let wp = model.transform_point3(glam::Vec3::from(*p));
109 Vector::new(wp.x, wp.y, wp.z)
110 })
111 .collect();
112
113 let tri_indices: Vec<[u32; 3]> = indices
114 .chunks(3)
115 .filter(|c| c.len() == 3)
116 .map(|c| [c[0], c[1], c[2]])
117 .collect();
118
119 if tri_indices.is_empty() {
120 continue;
121 }
122
123 match parry3d::shape::TriMesh::new(verts, tri_indices) {
124 Ok(trimesh) => {
125 // Vertices are already in world space: use identity pose.
126 let identity = Pose::identity();
127 let Some(intersection) =
128 trimesh.cast_ray_and_get_normal(&identity, &ray, f32::MAX, true)
129 else {
130 continue;
131 };
132 let toi = intersection.time_of_impact;
133 let world_pos = ray_origin + ray_dir * toi;
134 let normal = intersection.normal;
135
136 let feature_sub = SubObjectRef::from_feature_id(intersection.feature);
137
138 let sub_object = if wants_face {
139 feature_sub
140 } else if wants_cell {
141 // Convert surface Face hit to originating cell index.
142 if let Some(f2c) = vm_cell_map.get(&item.settings.pick_id.0) {
143 match feature_sub {
144 Some(SubObjectRef::Face(face_raw)) => {
145 let n_tri = indices.len() / 3;
146 let face = if (face_raw as usize) >= n_tri {
147 face_raw as usize - n_tri
148 } else {
149 face_raw as usize
150 };
151 f2c.get(face).map(|&ci| SubObjectRef::Cell(ci))
152 }
153 other => other,
154 }
155 } else if wants_vertex {
156 // No cell map for this item; try vertex picking instead.
157 // Fall through to the vertex branch below by
158 // re-evaluating with the vertex logic inline.
159 match feature_sub {
160 Some(SubObjectRef::Face(face_raw)) => {
161 let n_tri = indices.len() / 3;
162 let face = if (face_raw as usize) >= n_tri {
163 face_raw as usize - n_tri
164 } else {
165 face_raw as usize
166 };
167 if face * 3 + 2 < indices.len() {
168 let vis = [
169 indices[face * 3] as usize,
170 indices[face * 3 + 1] as usize,
171 indices[face * 3 + 2] as usize,
172 ];
173 let (best_vi, _) = vis
174 .iter()
175 .map(|&i| {
176 let p = model.transform_point3(
177 glam::Vec3::from(positions[i]),
178 );
179 (i, p.distance(world_pos))
180 })
181 .fold((vis[0], f32::MAX), |acc, (i, d)| {
182 if d < acc.1 { (i, d) } else { acc }
183 });
184 Some(SubObjectRef::Vertex(best_vi as u32))
185 } else {
186 None
187 }
188 }
189 other => other,
190 }
191 } else {
192 // No cell map and vertex not wanted; no sub-element.
193 None
194 }
195 } else if wants_vertex {
196 // Convert face hit to nearest triangle corner.
197 match feature_sub {
198 Some(SubObjectRef::Face(face_raw)) => {
199 let n_tri = indices.len() / 3;
200 let face = if (face_raw as usize) >= n_tri {
201 face_raw as usize - n_tri
202 } else {
203 face_raw as usize
204 };
205 if face * 3 + 2 < indices.len() {
206 let vis = [
207 indices[face * 3] as usize,
208 indices[face * 3 + 1] as usize,
209 indices[face * 3 + 2] as usize,
210 ];
211 let (best_vi, _) = vis
212 .iter()
213 .map(|&i| {
214 let p = model.transform_point3(glam::Vec3::from(
215 positions[i],
216 ));
217 (i, p.distance(world_pos))
218 })
219 .fold((vis[0], f32::MAX), |acc, (i, d)| {
220 if d < acc.1 { (i, d) } else { acc }
221 });
222 Some(SubObjectRef::Vertex(best_vi as u32))
223 } else {
224 None
225 }
226 }
227 other => other,
228 }
229 } else {
230 // Object-only: no sub-element.
231 None
232 };
233
234 // Only emit the hit if we produced a meaningful sub-element
235 // or the caller explicitly asked for object-level hits.
236 // Without this guard, an EDGE-only mask runs the ray-trimesh
237 // intersection (because wants_mesh_sub is true) but falls through
238 // to sub_object=None, producing a spurious object-level hit.
239 if sub_object.is_some() || wants_object {
240 #[allow(deprecated)]
241 let hit = PickHit {
242 id: item.settings.pick_id.0,
243 sub_object,
244 world_pos,
245 normal,
246 triangle_index: u32::MAX,
247 point_index: None,
248 scalar_value: None,
249 };
250 consider(toi, hit);
251 }
252 }
253 Err(e) => {
254 tracing::warn!(
255 pick_id = item.settings.pick_id.0,
256 error = %e,
257 "TriMesh build failed in renderer.pick()"
258 );
259 }
260 }
261 }
262 }
263
264 // 2. Opaque volume mesh cell picks are handled in section 1 above via
265 // vm_cell_map (face_to_cell conversion on surface Face hits).
266
267 // 2c. Scatter-volume object picks. Ray-vs-shape intersection only;
268 // there is no sub-object level for participating media
269 if wants_object {
270 for item in &self.pick_scatter_volume_items {
271 if item.settings.hidden || item.settings.pick_id == PickId::NONE {
272 continue;
273 }
274 if let Some((t_enter, _)) = crate::scene::scatter_volume::ray_intersect(
275 &item.volume.shape,
276 ray_origin,
277 ray_dir,
278 ) {
279 let world_pos = ray_origin + ray_dir * t_enter;
280 let normal = (world_pos
281 - match item.volume.shape {
282 crate::scene::scatter_volume::ScatterShape::Box(b) => {
283 (b.min + b.max) * 0.5
284 }
285 crate::scene::scatter_volume::ScatterShape::Sphere {
286 center, ..
287 } => glam::Vec3::from(center),
288 })
289 .try_normalize()
290 .unwrap_or(glam::Vec3::Z);
291 consider(
292 t_enter,
293 PickHit::object_hit(item.settings.pick_id.0, world_pos, normal),
294 );
295 }
296 }
297 }
298
299 // 2b. Interior-inclusive cell picks for volume meshes rendering
300 // transparently. Items rendering as opaque are handled in section 1
301 // above via vm_cell_map (face_to_cell on the boundary surface).
302 if wants_cell || wants_object {
303 for item in &self.pick_volume_mesh_items {
304 if item.settings.pick_id == PickId::NONE || item.transparency.is_none() {
305 continue;
306 }
307 let Some(data) = item.volume_mesh_data.as_deref() else {
308 continue;
309 };
310 let model = glam::Mat4::from_cols_array_2d(&item.model);
311 if let Some(mut hit) = pick_transparent_volume_mesh_cpu(
312 ray_origin,
313 ray_dir,
314 item.settings.pick_id.0,
315 model,
316 data,
317 ) {
318 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
319 if !wants_cell {
320 hit.sub_object = None;
321 }
322 consider(toi, hit);
323 }
324 }
325 }
326
327 // 3. Point cloud picks (CLOUD_POINT or OBJECT fallback).
328 if wants_cloud || wants_object {
329 for item in &self.pick_point_cloud_items {
330 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
331 continue;
332 }
333 let radius_px = item.point_size.max(4.0);
334 if let Some(mut hit) = pick_point_cloud_cpu(
335 click_pos,
336 item.settings.pick_id.0,
337 item,
338 view_proj,
339 viewport_size,
340 radius_px,
341 ) {
342 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
343 if !wants_cloud {
344 hit.sub_object = None;
345 }
346 consider(toi, hit);
347 }
348 }
349 }
350
351 // 4. Volume voxel picks (VOXEL or OBJECT fallback).
352 let wants_voxel = mask.intersects(PickMask::VOXEL);
353 if wants_voxel || wants_object {
354 for item in &self.pick_volume_items {
355 if item.settings.pick_id == PickId::NONE {
356 continue;
357 }
358 let Some(vol_data) = item.volume_data.as_deref() else {
359 continue;
360 };
361 if let Some(mut hit) =
362 pick_volume_cpu(ray_origin, ray_dir, item.settings.pick_id.0, item, vol_data)
363 {
364 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
365 if !wants_voxel {
366 hit.sub_object = None;
367 }
368 consider(toi, hit);
369 }
370 }
371 }
372
373 // 5. Gaussian splat picks (SPLAT or OBJECT fallback).
374 if wants_splat || wants_object {
375 for item in &self.pick_splat_items {
376 if item.settings.pick_id == PickId::NONE {
377 continue;
378 }
379 let Some(gpu_set) = self.resources.gaussian_splat_store.get(item.id.0) else {
380 continue;
381 };
382 if gpu_set.cpu_positions.is_empty() {
383 continue;
384 }
385 let model = glam::Mat4::from_cols_array_2d(&item.model);
386 // Derive pick radius from the mean per-splat scale so that a
387 // click anywhere inside the visible disc registers as a hit.
388 let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
389 0.05
390 } else {
391 gpu_set
392 .cpu_scales
393 .iter()
394 .map(|s| s[0].max(s[1]).max(s[2]))
395 .sum::<f32>()
396 / gpu_set.cpu_scales.len() as f32
397 };
398 let world_radius = mean_max_scale * 3.0;
399 let center_w = model.transform_point3(glam::Vec3::ZERO);
400 let p0_clip = view_proj * center_w.extend(1.0);
401 let p1_clip = view_proj * (center_w + glam::Vec3::X * world_radius).extend(1.0);
402 let radius_px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
403 let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
404 let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
405 ((p1_ndc - p0_ndc).length() * 0.5 * viewport_size.x.max(viewport_size.y))
406 .max(4.0)
407 } else {
408 world_radius * 100.0
409 };
410 if let Some(mut hit) = pick_gaussian_splat_cpu(
411 click_pos,
412 item.settings.pick_id.0,
413 &gpu_set.cpu_positions,
414 model,
415 view_proj,
416 viewport_size,
417 radius_px,
418 ) {
419 // pick_gaussian_splat_cpu returns SubObjectRef::Point; remap to Splat.
420 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
421 if wants_splat {
422 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
423 hit.sub_object = Some(SubObjectRef::Splat(idx));
424 }
425 } else {
426 hit.sub_object = None;
427 }
428 consider(toi, hit);
429 }
430 }
431 }
432
433 // 6. Instance picks (INSTANCE or OBJECT fallback) for glyphs, tensor glyphs, sprites.
434 let wants_instance = mask.intersects(PickMask::INSTANCE);
435 if wants_instance || wants_object {
436 // Convert a world-space radius at a given world position to a pixel threshold.
437 // Using the actual instance centroid rather than the model origin gives a correct
438 // pixel size when instances are offset far from the model's local origin.
439 let instance_radius_px = |world_center: glam::Vec3, world_r: f32| -> f32 {
440 let p0 = view_proj * world_center.extend(1.0);
441 let p1 = view_proj * (world_center + glam::Vec3::X * world_r).extend(1.0);
442 if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
443 let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
444 let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
445 ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
446 } else {
447 (world_r * 100.0_f32).max(4.0)
448 }
449 };
450
451 // Glyphs
452 for item in &self.pick_glyph_items {
453 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
454 continue;
455 }
456 let model = glam::Mat4::from_cols_array_2d(&item.model);
457 let full_len = if item.scale_by_magnitude && !item.vectors.is_empty() {
458 let mean_mag = item
459 .vectors
460 .iter()
461 .map(|v| glam::Vec3::from(*v).length())
462 .sum::<f32>()
463 / item.vectors.len() as f32;
464 (mean_mag * item.scale).max(0.01)
465 } else {
466 item.scale.max(0.01)
467 };
468 // Test against the midpoint of each arrow (base + half-vector) with
469 // world_r = half-length. This prevents the hit circle from extending a full
470 // arrow-length behind the base when the arrow points away from the camera.
471 let has_vecs = item.vectors.len() == item.positions.len();
472 let midpoints: Vec<[f32; 3]> = item
473 .positions
474 .iter()
475 .enumerate()
476 .map(|(i, pos)| {
477 if has_vecs {
478 let p = glam::Vec3::from(*pos);
479 let v = glam::Vec3::from(item.vectors[i]);
480 let len = if item.scale_by_magnitude {
481 v.length() * item.scale
482 } else {
483 item.scale
484 };
485 (p + v.normalize_or_zero() * len * 0.5).to_array()
486 } else {
487 *pos
488 }
489 })
490 .collect();
491 let n = midpoints.len() as f32;
492 let centroid = model.transform_point3(
493 midpoints
494 .iter()
495 .map(|p| glam::Vec3::from(*p))
496 .sum::<glam::Vec3>()
497 / n,
498 );
499 let radius_px = instance_radius_px(centroid, full_len * 0.5);
500 if let Some(mut hit) = pick_gaussian_splat_cpu(
501 click_pos,
502 item.settings.pick_id.0,
503 &midpoints,
504 model,
505 view_proj,
506 viewport_size,
507 radius_px,
508 ) {
509 // Report the base position, not the midpoint.
510 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
511 if let Some(base) = item.positions.get(idx as usize) {
512 hit.world_pos = model.transform_point3(glam::Vec3::from(*base));
513 }
514 if wants_instance {
515 hit.sub_object = Some(SubObjectRef::Instance(idx));
516 } else {
517 hit.sub_object = None;
518 }
519 }
520 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
521 consider(toi, hit);
522 }
523 }
524
525 // Tensor glyphs
526 for item in &self.pick_tensor_glyph_items {
527 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
528 continue;
529 }
530 let model = glam::Mat4::from_cols_array_2d(&item.model);
531 // Use the max eigenvalue across all instances so the largest ellipsoid
532 // is fully covered. Use the centroid of instance positions for an accurate
533 // pixel-size estimate (instances may be far from the model origin).
534 let world_r = if !item.eigenvalues.is_empty() {
535 let max_ev = item
536 .eigenvalues
537 .iter()
538 .map(|ev| ev[0].abs().max(ev[1].abs()).max(ev[2].abs()))
539 .fold(0.0_f32, f32::max);
540 (max_ev * item.scale).max(0.01)
541 } else {
542 item.scale.max(0.01)
543 };
544 let n = item.positions.len() as f32;
545 let centroid = model.transform_point3(
546 item.positions
547 .iter()
548 .map(|p| glam::Vec3::from(*p))
549 .sum::<glam::Vec3>()
550 / n,
551 );
552 let radius_px = instance_radius_px(centroid, world_r);
553 if let Some(mut hit) = pick_gaussian_splat_cpu(
554 click_pos,
555 item.settings.pick_id.0,
556 &item.positions,
557 model,
558 view_proj,
559 viewport_size,
560 radius_px,
561 ) {
562 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
563 if wants_instance {
564 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
565 hit.sub_object = Some(SubObjectRef::Instance(idx));
566 }
567 } else {
568 hit.sub_object = None;
569 }
570 consider(toi, hit);
571 }
572 }
573
574 // Sprites
575 for item in &self.pick_sprite_items {
576 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
577 continue;
578 }
579 let model = glam::Mat4::from_cols_array_2d(&item.model);
580 let radius_px = match item.size_mode {
581 SpriteSizeMode::ScreenSpace => (item.default_size * 0.5).max(4.0),
582 SpriteSizeMode::WorldSpace => {
583 let n = item.positions.len() as f32;
584 let centroid = model.transform_point3(
585 item.positions
586 .iter()
587 .map(|p| glam::Vec3::from(*p))
588 .sum::<glam::Vec3>()
589 / n,
590 );
591 instance_radius_px(centroid, (item.default_size * 0.5).max(0.01))
592 }
593 };
594 if let Some(mut hit) = pick_gaussian_splat_cpu(
595 click_pos,
596 item.settings.pick_id.0,
597 &item.positions,
598 model,
599 view_proj,
600 viewport_size,
601 radius_px,
602 ) {
603 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
604 if wants_instance {
605 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
606 hit.sub_object = Some(SubObjectRef::Instance(idx));
607 }
608 } else {
609 hit.sub_object = None;
610 }
611 consider(toi, hit);
612 }
613 }
614 }
615
616 // 7. Polyline node picks (POLY_NODE, STRIP, or OBJECT fallback).
617 let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
618 let wants_strip = mask.intersects(PickMask::STRIP);
619 if wants_poly_node || wants_strip || wants_object {
620 for item in &self.pick_polyline_items {
621 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
622 continue;
623 }
624 let radius_px = (item.line_width + 4.0).max(8.0);
625 if let Some(mut hit) = pick_gaussian_splat_cpu(
626 click_pos,
627 item.settings.pick_id.0,
628 &item.positions,
629 glam::Mat4::IDENTITY,
630 view_proj,
631 viewport_size,
632 radius_px,
633 ) {
634 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
635 if wants_poly_node {
636 // sub_object is already SubObjectRef::Point(node_index)
637 } else if wants_strip {
638 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
639 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
640 idx,
641 &item.strip_lengths,
642 )));
643 }
644 } else {
645 hit.sub_object = None;
646 }
647 consider(toi, hit);
648 }
649 }
650 }
651
652 // 8. Polyline segment picks (SEGMENT, STRIP, or OBJECT fallback).
653 // Uses screen-space distance from the click to the full segment line so
654 // clicking anywhere along a segment registers, not just near the midpoint.
655 let wants_segment = mask.intersects(PickMask::SEGMENT);
656 if wants_segment || wants_strip || wants_object {
657 for item in &self.pick_polyline_items {
658 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
659 continue;
660 }
661 // Half the visual line width plus a few pixels of slack.
662 let threshold_px = (item.line_width / 2.0 + 4.0).max(4.0);
663 let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
664 click_pos,
665 viewport_size,
666 view_proj,
667 &item.positions,
668 &item.strip_lengths,
669 threshold_px,
670 ) else {
671 continue;
672 };
673 let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
674 let sub_object = if wants_segment {
675 Some(SubObjectRef::Segment(seg_idx))
676 } else if wants_strip {
677 Some(SubObjectRef::Strip(strip_for_segment(
678 seg_idx,
679 &item.strip_lengths,
680 )))
681 } else {
682 None
683 };
684 #[allow(deprecated)]
685 let hit = PickHit {
686 id: item.settings.pick_id.0,
687 sub_object,
688 world_pos,
689 normal: glam::Vec3::Z,
690 triangle_index: u32::MAX,
691 point_index: None,
692 scalar_value: None,
693 };
694 consider(toi, hit);
695 }
696 }
697
698 // 9. Streamtube / tube / ribbon picks (POLY_NODE, SEGMENT, STRIP, or OBJECT).
699 // Streamtube / tube: screen-space closest-segment test against each cylinder
700 // axis (both endpoints projected), not just the midpoint.
701 // Ribbon: ray-triangle intersection against the reconstructed swept quad
702 // using the parallel-transport lateral frame.
703 // POLY_NODE: control points are point-like sub-elements (pick_gaussian_splat_cpu).
704 if wants_poly_node || wants_segment || wants_strip || wants_object {
705 // Convert a world-space radius at a reference point to a screen-pixel threshold.
706 let world_r_to_px = |ref_world: glam::Vec3, world_r: f32| -> f32 {
707 let p0 = view_proj * ref_world.extend(1.0);
708 let p1 = view_proj * (ref_world + glam::Vec3::X * world_r).extend(1.0);
709 if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
710 let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
711 let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
712 ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
713 } else {
714 (world_r * 100.0_f32).max(4.0)
715 }
716 };
717
718 // POLY_NODE pass: nearest control point, promoted to Strip/Object as needed.
719 if wants_poly_node || wants_strip || wants_object {
720 for item in &self.pick_streamtube_items {
721 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
722 continue;
723 }
724 let ref_pos = glam::Vec3::from(item.positions[0]);
725 let radius_px = world_r_to_px(ref_pos, item.radius.max(0.01)).max(8.0);
726 if let Some(mut hit) = pick_gaussian_splat_cpu(
727 click_pos,
728 item.settings.pick_id.0,
729 &item.positions,
730 glam::Mat4::IDENTITY,
731 view_proj,
732 viewport_size,
733 radius_px,
734 ) {
735 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
736 if wants_poly_node {
737 // sub_object is already SubObjectRef::Point(node_index)
738 } else if wants_strip {
739 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
740 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
741 idx,
742 &item.strip_lengths,
743 )));
744 }
745 } else {
746 hit.sub_object = None;
747 }
748 consider(toi, hit);
749 }
750 }
751 for item in &self.pick_tube_items {
752 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
753 continue;
754 }
755 let ref_pos = glam::Vec3::from(item.positions[0]);
756 let max_r = item
757 .radius_attribute
758 .as_ref()
759 .and_then(|ra| ra.iter().copied().reduce(f32::max))
760 .unwrap_or(0.0)
761 .max(item.radius)
762 .max(0.01);
763 let radius_px = world_r_to_px(ref_pos, max_r).max(8.0);
764 if let Some(mut hit) = pick_gaussian_splat_cpu(
765 click_pos,
766 item.settings.pick_id.0,
767 &item.positions,
768 glam::Mat4::IDENTITY,
769 view_proj,
770 viewport_size,
771 radius_px,
772 ) {
773 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
774 if wants_poly_node {
775 // sub_object is already SubObjectRef::Point(node_index)
776 } else if wants_strip {
777 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
778 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
779 idx,
780 &item.strip_lengths,
781 )));
782 }
783 } else {
784 hit.sub_object = None;
785 }
786 consider(toi, hit);
787 }
788 }
789 for item in &self.pick_ribbon_items {
790 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
791 continue;
792 }
793 let ref_pos = glam::Vec3::from(item.positions[0]);
794 let radius_px = world_r_to_px(ref_pos, item.width * 0.5).max(8.0);
795 if let Some(mut hit) = pick_gaussian_splat_cpu(
796 click_pos,
797 item.settings.pick_id.0,
798 &item.positions,
799 glam::Mat4::IDENTITY,
800 view_proj,
801 viewport_size,
802 radius_px,
803 ) {
804 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
805 if wants_poly_node {
806 // sub_object is already SubObjectRef::Point(node_index)
807 } else if wants_strip {
808 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
809 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
810 idx,
811 &item.strip_lengths,
812 )));
813 }
814 } else {
815 hit.sub_object = None;
816 }
817 consider(toi, hit);
818 }
819 }
820 }
821
822 // SEGMENT / STRIP / OBJECT pass using full geometric tests.
823 if wants_segment || wants_strip || wants_object {
824 // Streamtube: project each cylinder axis segment to screen and find the
825 // closest point along the full segment (not just the midpoint).
826 for item in &self.pick_streamtube_items {
827 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
828 continue;
829 }
830 let ref_pos = glam::Vec3::from(item.positions[0]);
831 let threshold_px = world_r_to_px(ref_pos, item.radius.max(0.01));
832 let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
833 click_pos,
834 viewport_size,
835 view_proj,
836 &item.positions,
837 &item.strip_lengths,
838 threshold_px,
839 ) else {
840 continue;
841 };
842 let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
843 let sub_object = if wants_segment {
844 Some(SubObjectRef::Segment(seg_idx))
845 } else if wants_strip {
846 Some(SubObjectRef::Strip(strip_for_segment(
847 seg_idx,
848 &item.strip_lengths,
849 )))
850 } else {
851 None
852 };
853 #[allow(deprecated)]
854 consider(
855 toi,
856 PickHit {
857 id: item.settings.pick_id.0,
858 sub_object,
859 world_pos,
860 normal: glam::Vec3::Z,
861 triangle_index: u32::MAX,
862 point_index: None,
863 scalar_value: None,
864 },
865 );
866 }
867
868 // Tube: same as streamtube; uses the conservative max of uniform and
869 // per-point radii for the screen-space threshold.
870 for item in &self.pick_tube_items {
871 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
872 continue;
873 }
874 let ref_pos = glam::Vec3::from(item.positions[0]);
875 let max_r = item
876 .radius_attribute
877 .as_ref()
878 .and_then(|ra| ra.iter().copied().reduce(f32::max))
879 .unwrap_or(0.0)
880 .max(item.radius)
881 .max(0.01);
882 let threshold_px = world_r_to_px(ref_pos, max_r);
883 let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
884 click_pos,
885 viewport_size,
886 view_proj,
887 &item.positions,
888 &item.strip_lengths,
889 threshold_px,
890 ) else {
891 continue;
892 };
893 let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
894 let sub_object = if wants_segment {
895 Some(SubObjectRef::Segment(seg_idx))
896 } else if wants_strip {
897 Some(SubObjectRef::Strip(strip_for_segment(
898 seg_idx,
899 &item.strip_lengths,
900 )))
901 } else {
902 None
903 };
904 #[allow(deprecated)]
905 consider(
906 toi,
907 PickHit {
908 id: item.settings.pick_id.0,
909 sub_object,
910 world_pos,
911 normal: glam::Vec3::Z,
912 triangle_index: u32::MAX,
913 point_index: None,
914 scalar_value: None,
915 },
916 );
917 }
918
919 // Ribbon: reconstruct the swept quad per segment (parallel-transport
920 // lateral frame) and test the ray against both triangles of each quad.
921 for item in &self.pick_ribbon_items {
922 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
923 continue;
924 }
925 let frames = ribbon_lateral_frames(
926 &item.positions,
927 &item.strip_lengths,
928 item.width,
929 item.width_attribute.as_deref(),
930 item.twist_attribute.as_deref(),
931 );
932
933 let single;
934 let strips: &[u32] = if item.strip_lengths.is_empty() {
935 single = [item.positions.len() as u32];
936 &single
937 } else {
938 &item.strip_lengths
939 };
940
941 let mut best_t = f32::MAX;
942 let mut best_seg: Option<(u32, glam::Vec3)> = None;
943 let mut node_off = 0usize;
944 let mut seg_off = 0u32;
945
946 for &slen in strips {
947 let slen = slen as usize;
948 for k in 0..slen.saturating_sub(1) {
949 let ia = node_off + k;
950 let ib = node_off + k + 1;
951 let pa = glam::Vec3::from(item.positions[ia]);
952 let pb = glam::Vec3::from(item.positions[ib]);
953 let (ua, wa) = frames[ia];
954 let (ub, wb) = frames[ib];
955 // Quad corners: c0/c1 at segment start, c2/c3 at end.
956 let c0 = pa + ua * wa; // left at a
957 let c1 = pa - ua * wa; // right at a
958 let c2 = pb + ub * wb; // left at b
959 let c3 = pb - ub * wb; // right at b
960 // Test 2 triangles, both front and back faces.
961 let t = ray_triangle(ray_origin, ray_dir, c0, c1, c2)
962 .or_else(|| ray_triangle(ray_origin, ray_dir, c1, c3, c2))
963 .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c1, c0))
964 .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c3, c1));
965 if let Some(t) = t {
966 if t < best_t {
967 best_t = t;
968 best_seg = Some((seg_off + k as u32, ray_origin + ray_dir * t));
969 }
970 }
971 }
972 seg_off += slen.saturating_sub(1) as u32;
973 node_off += slen;
974 }
975
976 if let Some((seg_idx, world_pos)) = best_seg {
977 let sub_object = if wants_segment {
978 Some(SubObjectRef::Segment(seg_idx))
979 } else if wants_strip {
980 Some(SubObjectRef::Strip(strip_for_segment(
981 seg_idx,
982 &item.strip_lengths,
983 )))
984 } else {
985 None
986 };
987 #[allow(deprecated)]
988 consider(
989 best_t,
990 PickHit {
991 id: item.settings.pick_id.0,
992 sub_object,
993 world_pos,
994 normal: glam::Vec3::Z,
995 triangle_index: u32::MAX,
996 point_index: None,
997 scalar_value: None,
998 },
999 );
1000 }
1001 }
1002 }
1003 }
1004
1005 // 10. Image slice / volume surface slice / screen image object picks (OBJECT only).
1006 if wants_object {
1007 // Image slice: axis-aligned quad ray intersection.
1008 for item in &self.pick_image_slice_items {
1009 if item.settings.pick_id == PickId::NONE {
1010 continue;
1011 }
1012 let [bmin, bmax] = [item.bbox_min, item.bbox_max];
1013 let t = item.offset;
1014 // Plane normal and position along the axis.
1015 let (axis_idx, plane_pos) = match item.axis {
1016 SliceAxis::X => (0usize, bmin[0] + t * (bmax[0] - bmin[0])),
1017 SliceAxis::Y => (1usize, bmin[1] + t * (bmax[1] - bmin[1])),
1018 SliceAxis::Z => (2usize, bmin[2] + t * (bmax[2] - bmin[2])),
1019 };
1020 let plane_n = {
1021 let mut n = glam::Vec3::ZERO;
1022 n[axis_idx] = 1.0;
1023 n
1024 };
1025 let denom = plane_n.dot(ray_dir);
1026 if denom.abs() < 1e-6 {
1027 continue;
1028 }
1029 let toi = (plane_pos - ray_origin[axis_idx]) / denom;
1030 if toi <= 0.0 {
1031 continue;
1032 }
1033 let hit_pos = ray_origin + ray_dir * toi;
1034 // Check that the hit is within the slice quad's other two dimensions.
1035 let in_bounds = (0..3)
1036 .filter(|&i| i != axis_idx)
1037 .all(|i| hit_pos[i] >= bmin[i] - 1e-4 && hit_pos[i] <= bmax[i] + 1e-4);
1038 if in_bounds {
1039 #[allow(deprecated)]
1040 consider(
1041 toi,
1042 PickHit {
1043 id: item.settings.pick_id.0,
1044 sub_object: None,
1045 world_pos: hit_pos,
1046 normal: plane_n,
1047 triangle_index: u32::MAX,
1048 point_index: None,
1049 scalar_value: None,
1050 },
1051 );
1052 }
1053 }
1054
1055 // Volume surface slice: ray/mesh intersection via mesh_store CPU data.
1056 for item in &self.pick_volume_surface_slice_items {
1057 if item.settings.pick_id == PickId::NONE {
1058 continue;
1059 }
1060 let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
1061 continue;
1062 };
1063 let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1064 else {
1065 continue;
1066 };
1067 let model = glam::Mat4::from_cols_array_2d(&item.model);
1068 let verts: Vec<parry3d::math::Vector> = positions
1069 .iter()
1070 .map(|p| {
1071 let wp = model.transform_point3(glam::Vec3::from(*p));
1072 parry3d::math::Vector::new(wp.x, wp.y, wp.z)
1073 })
1074 .collect();
1075 let tri_indices: Vec<[u32; 3]> = indices
1076 .chunks(3)
1077 .filter(|c| c.len() == 3)
1078 .map(|c| [c[0], c[1], c[2]])
1079 .collect();
1080 if tri_indices.is_empty() {
1081 continue;
1082 }
1083 let ray = parry3d::query::Ray::new(
1084 parry3d::math::Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
1085 parry3d::math::Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
1086 );
1087 if let Ok(trimesh) = parry3d::shape::TriMesh::new(verts, tri_indices) {
1088 use parry3d::query::RayCast;
1089 if let Some(hit) = trimesh.cast_ray_and_get_normal(
1090 &parry3d::math::Pose::identity(),
1091 &ray,
1092 f32::MAX,
1093 true,
1094 ) {
1095 let world_pos = ray_origin + ray_dir * hit.time_of_impact;
1096 let n = hit.normal;
1097 #[allow(deprecated)]
1098 consider(
1099 hit.time_of_impact,
1100 PickHit {
1101 id: item.settings.pick_id.0,
1102 sub_object: None,
1103 world_pos,
1104 normal: glam::Vec3::new(n.x, n.y, n.z),
1105 triangle_index: u32::MAX,
1106 point_index: None,
1107 scalar_value: None,
1108 },
1109 );
1110 }
1111 }
1112 }
1113
1114 // Screen image: screen-space rect test. toi=0 so these win over any 3D hit.
1115 for item in &self.pick_screen_image_items {
1116 if item.settings.pick_id == PickId::NONE || item.width == 0 || item.height == 0 {
1117 continue;
1118 }
1119 let img_w = item.width as f32 * item.scale;
1120 let img_h = item.height as f32 * item.scale;
1121 let (sx, sy) = match item.anchor {
1122 ImageAnchor::TopLeft => (0.0, 0.0),
1123 ImageAnchor::TopRight => (viewport_size.x - img_w, 0.0),
1124 ImageAnchor::BottomLeft => (0.0, viewport_size.y - img_h),
1125 ImageAnchor::BottomRight => (viewport_size.x - img_w, viewport_size.y - img_h),
1126 ImageAnchor::Center => (
1127 (viewport_size.x - img_w) * 0.5,
1128 (viewport_size.y - img_h) * 0.5,
1129 ),
1130 };
1131 if click_pos.x >= sx
1132 && click_pos.x <= sx + img_w
1133 && click_pos.y >= sy
1134 && click_pos.y <= sy + img_h
1135 {
1136 // No meaningful 3D position; place the hit at the near-plane.
1137 let world_pos = ray_origin + ray_dir * 0.001;
1138 #[allow(deprecated)]
1139 consider(
1140 0.0,
1141 PickHit {
1142 id: item.settings.pick_id.0,
1143 sub_object: None,
1144 world_pos,
1145 normal: -ray_dir,
1146 triangle_index: u32::MAX,
1147 point_index: None,
1148 scalar_value: None,
1149 },
1150 );
1151 }
1152 }
1153 }
1154
1155 // 11. GPU implicit surface picks (OBJECT only -- no sub-element model).
1156 if wants_object {
1157 for item in &self.pick_implicit_items {
1158 if let Some((toi, world_pos)) = pick_implicit_sdf(ray_origin, ray_dir, item) {
1159 #[allow(deprecated)]
1160 consider(
1161 toi,
1162 PickHit {
1163 id: item.id,
1164 sub_object: None,
1165 world_pos,
1166 normal: glam::Vec3::Z,
1167 triangle_index: u32::MAX,
1168 point_index: None,
1169 scalar_value: None,
1170 },
1171 );
1172 }
1173 }
1174 }
1175
1176 // 12. GPU marching cubes surface picks (OBJECT only).
1177 if wants_object {
1178 for item in &self.pick_mc_items {
1179 if let Some((toi, world_pos)) = pick_mc_volume(ray_origin, ray_dir, item) {
1180 #[allow(deprecated)]
1181 consider(
1182 toi,
1183 PickHit {
1184 id: item.id,
1185 sub_object: None,
1186 world_pos,
1187 normal: glam::Vec3::Z,
1188 triangle_index: u32::MAX,
1189 point_index: None,
1190 scalar_value: None,
1191 },
1192 );
1193 }
1194 }
1195 }
1196
1197 // Consult registered item-type plugins after the built-in pickers.
1198 // Each plugin returns its own closest hit; the router compares
1199 // by world-space ray t against the running best.
1200 if !self.item_type_plugins.is_empty() {
1201 let plugin_ray = crate::plugin_api::PickRay {
1202 origin: ray_origin,
1203 direction: ray_dir,
1204 };
1205 for plugin in self.item_type_plugins.values() {
1206 if let Some((t, hit)) = plugin.pick(&plugin_ray) {
1207 consider(t, hit);
1208 }
1209 }
1210 }
1211
1212 best.map(|(_, hit)| hit)
1213 }
1214}