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::select::pick_mask::PickMask,
35 ) -> Option<crate::interaction::query::picking::PickHit> {
36 use crate::interaction::query::picking::{
37 PickHit, pick_gaussian_splat_cpu, pick_point_cloud_cpu,
38 pick_transparent_volume_mesh_cpu, pick_volume_cpu, screen_to_ray,
39 };
40 use crate::interaction::select::pick_mask::PickMask;
41 use crate::interaction::select::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.content.gaussian_splat_store.get(item.source)
380 else {
381 continue;
382 };
383 if gpu_set.cpu_positions.is_empty() {
384 continue;
385 }
386 let model = glam::Mat4::from_cols_array_2d(&item.model);
387 // Derive pick radius from the mean per-splat scale so that a
388 // click anywhere inside the visible disc registers as a hit.
389 let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
390 0.05
391 } else {
392 gpu_set
393 .cpu_scales
394 .iter()
395 .map(|s| s[0].max(s[1]).max(s[2]))
396 .sum::<f32>()
397 / gpu_set.cpu_scales.len() as f32
398 };
399 let world_radius = mean_max_scale * 3.0;
400 let center_w = model.transform_point3(glam::Vec3::ZERO);
401 let p0_clip = view_proj * center_w.extend(1.0);
402 let p1_clip = view_proj * (center_w + glam::Vec3::X * world_radius).extend(1.0);
403 let radius_px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
404 let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
405 let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
406 ((p1_ndc - p0_ndc).length() * 0.5 * viewport_size.x.max(viewport_size.y))
407 .max(4.0)
408 } else {
409 world_radius * 100.0
410 };
411 if let Some(mut hit) = pick_gaussian_splat_cpu(
412 click_pos,
413 item.settings.pick_id.0,
414 &gpu_set.cpu_positions,
415 model,
416 view_proj,
417 viewport_size,
418 radius_px,
419 ) {
420 // pick_gaussian_splat_cpu returns SubObjectRef::Point; remap to Splat.
421 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
422 if wants_splat {
423 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
424 hit.sub_object = Some(SubObjectRef::Splat(idx));
425 }
426 } else {
427 hit.sub_object = None;
428 }
429 consider(toi, hit);
430 }
431 }
432 }
433
434 // 6. Instance picks (INSTANCE or OBJECT fallback) for glyphs, tensor glyphs, sprites.
435 let wants_instance = mask.intersects(PickMask::INSTANCE);
436 if wants_instance || wants_object {
437 // Convert a world-space radius at a given world position to a pixel threshold.
438 // Using the actual instance centroid rather than the model origin gives a correct
439 // pixel size when instances are offset far from the model's local origin.
440 let instance_radius_px = |world_center: glam::Vec3, world_r: f32| -> f32 {
441 let p0 = view_proj * world_center.extend(1.0);
442 let p1 = view_proj * (world_center + glam::Vec3::X * world_r).extend(1.0);
443 if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
444 let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
445 let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
446 ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
447 } else {
448 (world_r * 100.0_f32).max(4.0)
449 }
450 };
451
452 // Glyphs
453 for item in &self.pick_glyph_items {
454 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
455 continue;
456 }
457 let model = glam::Mat4::from_cols_array_2d(&item.model);
458 let full_len = if item.scale_by_magnitude && !item.vectors.is_empty() {
459 let mean_mag = item
460 .vectors
461 .iter()
462 .map(|v| glam::Vec3::from(*v).length())
463 .sum::<f32>()
464 / item.vectors.len() as f32;
465 (mean_mag * item.scale).max(0.01)
466 } else {
467 item.scale.max(0.01)
468 };
469 // Test against the midpoint of each arrow (base + half-vector) with
470 // world_r = half-length. This prevents the hit circle from extending a full
471 // arrow-length behind the base when the arrow points away from the camera.
472 let has_vecs = item.vectors.len() == item.positions.len();
473 let midpoints: Vec<[f32; 3]> = item
474 .positions
475 .iter()
476 .enumerate()
477 .map(|(i, pos)| {
478 if has_vecs {
479 let p = glam::Vec3::from(*pos);
480 let v = glam::Vec3::from(item.vectors[i]);
481 let len = if item.scale_by_magnitude {
482 v.length() * item.scale
483 } else {
484 item.scale
485 };
486 (p + v.normalize_or_zero() * len * 0.5).to_array()
487 } else {
488 *pos
489 }
490 })
491 .collect();
492 let n = midpoints.len() as f32;
493 let centroid = model.transform_point3(
494 midpoints
495 .iter()
496 .map(|p| glam::Vec3::from(*p))
497 .sum::<glam::Vec3>()
498 / n,
499 );
500 let radius_px = instance_radius_px(centroid, full_len * 0.5);
501 if let Some(mut hit) = pick_gaussian_splat_cpu(
502 click_pos,
503 item.settings.pick_id.0,
504 &midpoints,
505 model,
506 view_proj,
507 viewport_size,
508 radius_px,
509 ) {
510 // Report the base position, not the midpoint.
511 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
512 if let Some(base) = item.positions.get(idx as usize) {
513 hit.world_pos = model.transform_point3(glam::Vec3::from(*base));
514 }
515 if wants_instance {
516 hit.sub_object = Some(SubObjectRef::Instance(idx));
517 } else {
518 hit.sub_object = None;
519 }
520 }
521 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
522 consider(toi, hit);
523 }
524 }
525
526 // Tensor glyphs
527 for item in &self.pick_tensor_glyph_items {
528 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
529 continue;
530 }
531 let model = glam::Mat4::from_cols_array_2d(&item.model);
532 // Use the max eigenvalue across all instances so the largest ellipsoid
533 // is fully covered. Use the centroid of instance positions for an accurate
534 // pixel-size estimate (instances may be far from the model origin).
535 let world_r = if !item.eigenvalues.is_empty() {
536 let max_ev = item
537 .eigenvalues
538 .iter()
539 .map(|ev| ev[0].abs().max(ev[1].abs()).max(ev[2].abs()))
540 .fold(0.0_f32, f32::max);
541 (max_ev * item.scale).max(0.01)
542 } else {
543 item.scale.max(0.01)
544 };
545 let n = item.positions.len() as f32;
546 let centroid = model.transform_point3(
547 item.positions
548 .iter()
549 .map(|p| glam::Vec3::from(*p))
550 .sum::<glam::Vec3>()
551 / n,
552 );
553 let radius_px = instance_radius_px(centroid, world_r);
554 if let Some(mut hit) = pick_gaussian_splat_cpu(
555 click_pos,
556 item.settings.pick_id.0,
557 &item.positions,
558 model,
559 view_proj,
560 viewport_size,
561 radius_px,
562 ) {
563 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
564 if wants_instance {
565 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
566 hit.sub_object = Some(SubObjectRef::Instance(idx));
567 }
568 } else {
569 hit.sub_object = None;
570 }
571 consider(toi, hit);
572 }
573 }
574
575 // Sprites
576 for item in &self.pick_sprite_items {
577 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
578 continue;
579 }
580 let model = glam::Mat4::from_cols_array_2d(&item.model);
581 let radius_px = match item.size_mode {
582 SpriteSizeMode::ScreenSpace => (item.default_size * 0.5).max(4.0),
583 SpriteSizeMode::WorldSpace => {
584 let n = item.positions.len() as f32;
585 let centroid = model.transform_point3(
586 item.positions
587 .iter()
588 .map(|p| glam::Vec3::from(*p))
589 .sum::<glam::Vec3>()
590 / n,
591 );
592 instance_radius_px(centroid, (item.default_size * 0.5).max(0.01))
593 }
594 };
595 if let Some(mut hit) = pick_gaussian_splat_cpu(
596 click_pos,
597 item.settings.pick_id.0,
598 &item.positions,
599 model,
600 view_proj,
601 viewport_size,
602 radius_px,
603 ) {
604 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
605 if wants_instance {
606 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
607 hit.sub_object = Some(SubObjectRef::Instance(idx));
608 }
609 } else {
610 hit.sub_object = None;
611 }
612 consider(toi, hit);
613 }
614 }
615 }
616
617 // 7. Polyline node picks (POLY_NODE, STRIP, or OBJECT fallback).
618 let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
619 let wants_strip = mask.intersects(PickMask::STRIP);
620 if wants_poly_node || wants_strip || wants_object {
621 for item in &self.pick_polyline_items {
622 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
623 continue;
624 }
625 let radius_px = (item.line_width + 4.0).max(8.0);
626 if let Some(mut hit) = pick_gaussian_splat_cpu(
627 click_pos,
628 item.settings.pick_id.0,
629 &item.positions,
630 glam::Mat4::IDENTITY,
631 view_proj,
632 viewport_size,
633 radius_px,
634 ) {
635 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
636 if wants_poly_node {
637 // sub_object is already SubObjectRef::Point(node_index)
638 } else if wants_strip {
639 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
640 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
641 idx,
642 &item.strip_lengths,
643 )));
644 }
645 } else {
646 hit.sub_object = None;
647 }
648 consider(toi, hit);
649 }
650 }
651 }
652
653 // 8. Polyline segment picks (SEGMENT, STRIP, or OBJECT fallback).
654 // Uses screen-space distance from the click to the full segment line so
655 // clicking anywhere along a segment registers, not just near the midpoint.
656 let wants_segment = mask.intersects(PickMask::SEGMENT);
657 if wants_segment || wants_strip || wants_object {
658 for item in &self.pick_polyline_items {
659 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
660 continue;
661 }
662 // Half the visual line width plus a few pixels of slack.
663 let threshold_px = (item.line_width / 2.0 + 4.0).max(4.0);
664 let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
665 click_pos,
666 viewport_size,
667 view_proj,
668 &item.positions,
669 &item.strip_lengths,
670 threshold_px,
671 ) else {
672 continue;
673 };
674 let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
675 let sub_object = if wants_segment {
676 Some(SubObjectRef::Segment(seg_idx))
677 } else if wants_strip {
678 Some(SubObjectRef::Strip(strip_for_segment(
679 seg_idx,
680 &item.strip_lengths,
681 )))
682 } else {
683 None
684 };
685 #[allow(deprecated)]
686 let hit = PickHit {
687 id: item.settings.pick_id.0,
688 sub_object,
689 world_pos,
690 normal: glam::Vec3::Z,
691 triangle_index: u32::MAX,
692 point_index: None,
693 scalar_value: None,
694 };
695 consider(toi, hit);
696 }
697 }
698
699 // 9. Streamtube / tube / ribbon picks (POLY_NODE, SEGMENT, STRIP, or OBJECT).
700 // Streamtube / tube: screen-space closest-segment test against each cylinder
701 // axis (both endpoints projected), not just the midpoint.
702 // Ribbon: ray-triangle intersection against the reconstructed swept quad
703 // using the parallel-transport lateral frame.
704 // POLY_NODE: control points are point-like sub-elements (pick_gaussian_splat_cpu).
705 if wants_poly_node || wants_segment || wants_strip || wants_object {
706 // Convert a world-space radius at a reference point to a screen-pixel threshold.
707 let world_r_to_px = |ref_world: glam::Vec3, world_r: f32| -> f32 {
708 let p0 = view_proj * ref_world.extend(1.0);
709 let p1 = view_proj * (ref_world + glam::Vec3::X * world_r).extend(1.0);
710 if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
711 let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
712 let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
713 ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
714 } else {
715 (world_r * 100.0_f32).max(4.0)
716 }
717 };
718
719 // POLY_NODE pass: nearest control point, promoted to Strip/Object as needed.
720 if wants_poly_node || wants_strip || wants_object {
721 for item in &self.pick_streamtube_items {
722 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
723 continue;
724 }
725 let ref_pos = glam::Vec3::from(item.positions[0]);
726 let radius_px = world_r_to_px(ref_pos, item.radius.max(0.01)).max(8.0);
727 if let Some(mut hit) = pick_gaussian_splat_cpu(
728 click_pos,
729 item.settings.pick_id.0,
730 &item.positions,
731 glam::Mat4::IDENTITY,
732 view_proj,
733 viewport_size,
734 radius_px,
735 ) {
736 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
737 if wants_poly_node {
738 // sub_object is already SubObjectRef::Point(node_index)
739 } else if wants_strip {
740 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
741 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
742 idx,
743 &item.strip_lengths,
744 )));
745 }
746 } else {
747 hit.sub_object = None;
748 }
749 consider(toi, hit);
750 }
751 }
752 for item in &self.pick_tube_items {
753 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
754 continue;
755 }
756 let ref_pos = glam::Vec3::from(item.positions[0]);
757 let max_r = item
758 .radius_attribute
759 .as_ref()
760 .and_then(|ra| ra.iter().copied().reduce(f32::max))
761 .unwrap_or(0.0)
762 .max(item.radius)
763 .max(0.01);
764 let radius_px = world_r_to_px(ref_pos, max_r).max(8.0);
765 if let Some(mut hit) = pick_gaussian_splat_cpu(
766 click_pos,
767 item.settings.pick_id.0,
768 &item.positions,
769 glam::Mat4::IDENTITY,
770 view_proj,
771 viewport_size,
772 radius_px,
773 ) {
774 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
775 if wants_poly_node {
776 // sub_object is already SubObjectRef::Point(node_index)
777 } else if wants_strip {
778 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
779 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
780 idx,
781 &item.strip_lengths,
782 )));
783 }
784 } else {
785 hit.sub_object = None;
786 }
787 consider(toi, hit);
788 }
789 }
790 for item in &self.pick_ribbon_items {
791 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
792 continue;
793 }
794 let ref_pos = glam::Vec3::from(item.positions[0]);
795 let radius_px = world_r_to_px(ref_pos, item.width * 0.5).max(8.0);
796 if let Some(mut hit) = pick_gaussian_splat_cpu(
797 click_pos,
798 item.settings.pick_id.0,
799 &item.positions,
800 glam::Mat4::IDENTITY,
801 view_proj,
802 viewport_size,
803 radius_px,
804 ) {
805 let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
806 if wants_poly_node {
807 // sub_object is already SubObjectRef::Point(node_index)
808 } else if wants_strip {
809 if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
810 hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
811 idx,
812 &item.strip_lengths,
813 )));
814 }
815 } else {
816 hit.sub_object = None;
817 }
818 consider(toi, hit);
819 }
820 }
821 }
822
823 // SEGMENT / STRIP / OBJECT pass using full geometric tests.
824 if wants_segment || wants_strip || wants_object {
825 // Streamtube: project each cylinder axis segment to screen and find the
826 // closest point along the full segment (not just the midpoint).
827 for item in &self.pick_streamtube_items {
828 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
829 continue;
830 }
831 let ref_pos = glam::Vec3::from(item.positions[0]);
832 let threshold_px = world_r_to_px(ref_pos, item.radius.max(0.01));
833 let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
834 click_pos,
835 viewport_size,
836 view_proj,
837 &item.positions,
838 &item.strip_lengths,
839 threshold_px,
840 ) else {
841 continue;
842 };
843 let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
844 let sub_object = if wants_segment {
845 Some(SubObjectRef::Segment(seg_idx))
846 } else if wants_strip {
847 Some(SubObjectRef::Strip(strip_for_segment(
848 seg_idx,
849 &item.strip_lengths,
850 )))
851 } else {
852 None
853 };
854 #[allow(deprecated)]
855 consider(
856 toi,
857 PickHit {
858 id: item.settings.pick_id.0,
859 sub_object,
860 world_pos,
861 normal: glam::Vec3::Z,
862 triangle_index: u32::MAX,
863 point_index: None,
864 scalar_value: None,
865 },
866 );
867 }
868
869 // Tube: same as streamtube; uses the conservative max of uniform and
870 // per-point radii for the screen-space threshold.
871 for item in &self.pick_tube_items {
872 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
873 continue;
874 }
875 let ref_pos = glam::Vec3::from(item.positions[0]);
876 let max_r = item
877 .radius_attribute
878 .as_ref()
879 .and_then(|ra| ra.iter().copied().reduce(f32::max))
880 .unwrap_or(0.0)
881 .max(item.radius)
882 .max(0.01);
883 let threshold_px = world_r_to_px(ref_pos, max_r);
884 let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
885 click_pos,
886 viewport_size,
887 view_proj,
888 &item.positions,
889 &item.strip_lengths,
890 threshold_px,
891 ) else {
892 continue;
893 };
894 let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
895 let sub_object = if wants_segment {
896 Some(SubObjectRef::Segment(seg_idx))
897 } else if wants_strip {
898 Some(SubObjectRef::Strip(strip_for_segment(
899 seg_idx,
900 &item.strip_lengths,
901 )))
902 } else {
903 None
904 };
905 #[allow(deprecated)]
906 consider(
907 toi,
908 PickHit {
909 id: item.settings.pick_id.0,
910 sub_object,
911 world_pos,
912 normal: glam::Vec3::Z,
913 triangle_index: u32::MAX,
914 point_index: None,
915 scalar_value: None,
916 },
917 );
918 }
919
920 // Ribbon: reconstruct the swept quad per segment (parallel-transport
921 // lateral frame) and test the ray against both triangles of each quad.
922 for item in &self.pick_ribbon_items {
923 if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
924 continue;
925 }
926 let frames = ribbon_lateral_frames(
927 &item.positions,
928 &item.strip_lengths,
929 item.width,
930 item.width_attribute.as_deref(),
931 item.twist_attribute.as_deref(),
932 );
933
934 let single;
935 let strips: &[u32] = if item.strip_lengths.is_empty() {
936 single = [item.positions.len() as u32];
937 &single
938 } else {
939 &item.strip_lengths
940 };
941
942 let mut best_t = f32::MAX;
943 let mut best_seg: Option<(u32, glam::Vec3)> = None;
944 let mut node_off = 0usize;
945 let mut seg_off = 0u32;
946
947 for &slen in strips {
948 let slen = slen as usize;
949 for k in 0..slen.saturating_sub(1) {
950 let ia = node_off + k;
951 let ib = node_off + k + 1;
952 let pa = glam::Vec3::from(item.positions[ia]);
953 let pb = glam::Vec3::from(item.positions[ib]);
954 let (ua, wa) = frames[ia];
955 let (ub, wb) = frames[ib];
956 // Quad corners: c0/c1 at segment start, c2/c3 at end.
957 let c0 = pa + ua * wa; // left at a
958 let c1 = pa - ua * wa; // right at a
959 let c2 = pb + ub * wb; // left at b
960 let c3 = pb - ub * wb; // right at b
961 // Test 2 triangles, both front and back faces.
962 let t = ray_triangle(ray_origin, ray_dir, c0, c1, c2)
963 .or_else(|| ray_triangle(ray_origin, ray_dir, c1, c3, c2))
964 .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c1, c0))
965 .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c3, c1));
966 if let Some(t) = t {
967 if t < best_t {
968 best_t = t;
969 best_seg = Some((seg_off + k as u32, ray_origin + ray_dir * t));
970 }
971 }
972 }
973 seg_off += slen.saturating_sub(1) as u32;
974 node_off += slen;
975 }
976
977 if let Some((seg_idx, world_pos)) = best_seg {
978 let sub_object = if wants_segment {
979 Some(SubObjectRef::Segment(seg_idx))
980 } else if wants_strip {
981 Some(SubObjectRef::Strip(strip_for_segment(
982 seg_idx,
983 &item.strip_lengths,
984 )))
985 } else {
986 None
987 };
988 #[allow(deprecated)]
989 consider(
990 best_t,
991 PickHit {
992 id: item.settings.pick_id.0,
993 sub_object,
994 world_pos,
995 normal: glam::Vec3::Z,
996 triangle_index: u32::MAX,
997 point_index: None,
998 scalar_value: None,
999 },
1000 );
1001 }
1002 }
1003 }
1004 }
1005
1006 // 10. Image slice / volume surface slice / screen image object picks (OBJECT only).
1007 if wants_object {
1008 // Image slice: axis-aligned quad ray intersection.
1009 for item in &self.pick_image_slice_items {
1010 if item.settings.pick_id == PickId::NONE {
1011 continue;
1012 }
1013 let [bmin, bmax] = [item.bbox_min, item.bbox_max];
1014 let t = item.offset;
1015 // Plane normal and position along the axis.
1016 let (axis_idx, plane_pos) = match item.axis {
1017 SliceAxis::X => (0usize, bmin[0] + t * (bmax[0] - bmin[0])),
1018 SliceAxis::Y => (1usize, bmin[1] + t * (bmax[1] - bmin[1])),
1019 SliceAxis::Z => (2usize, bmin[2] + t * (bmax[2] - bmin[2])),
1020 };
1021 let plane_n = {
1022 let mut n = glam::Vec3::ZERO;
1023 n[axis_idx] = 1.0;
1024 n
1025 };
1026 let denom = plane_n.dot(ray_dir);
1027 if denom.abs() < 1e-6 {
1028 continue;
1029 }
1030 let toi = (plane_pos - ray_origin[axis_idx]) / denom;
1031 if toi <= 0.0 {
1032 continue;
1033 }
1034 let hit_pos = ray_origin + ray_dir * toi;
1035 // Check that the hit is within the slice quad's other two dimensions.
1036 let in_bounds = (0..3)
1037 .filter(|&i| i != axis_idx)
1038 .all(|i| hit_pos[i] >= bmin[i] - 1e-4 && hit_pos[i] <= bmax[i] + 1e-4);
1039 if in_bounds {
1040 #[allow(deprecated)]
1041 consider(
1042 toi,
1043 PickHit {
1044 id: item.settings.pick_id.0,
1045 sub_object: None,
1046 world_pos: hit_pos,
1047 normal: plane_n,
1048 triangle_index: u32::MAX,
1049 point_index: None,
1050 scalar_value: None,
1051 },
1052 );
1053 }
1054 }
1055
1056 // Volume surface slice: ray/mesh intersection via mesh_store CPU data.
1057 for item in &self.pick_volume_surface_slice_items {
1058 if item.settings.pick_id == PickId::NONE {
1059 continue;
1060 }
1061 let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
1062 continue;
1063 };
1064 let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1065 else {
1066 continue;
1067 };
1068 let model = glam::Mat4::from_cols_array_2d(&item.model);
1069 let verts: Vec<parry3d::math::Vector> = positions
1070 .iter()
1071 .map(|p| {
1072 let wp = model.transform_point3(glam::Vec3::from(*p));
1073 parry3d::math::Vector::new(wp.x, wp.y, wp.z)
1074 })
1075 .collect();
1076 let tri_indices: Vec<[u32; 3]> = indices
1077 .chunks(3)
1078 .filter(|c| c.len() == 3)
1079 .map(|c| [c[0], c[1], c[2]])
1080 .collect();
1081 if tri_indices.is_empty() {
1082 continue;
1083 }
1084 let ray = parry3d::query::Ray::new(
1085 parry3d::math::Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
1086 parry3d::math::Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
1087 );
1088 if let Ok(trimesh) = parry3d::shape::TriMesh::new(verts, tri_indices) {
1089 use parry3d::query::RayCast;
1090 if let Some(hit) = trimesh.cast_ray_and_get_normal(
1091 &parry3d::math::Pose::identity(),
1092 &ray,
1093 f32::MAX,
1094 true,
1095 ) {
1096 let world_pos = ray_origin + ray_dir * hit.time_of_impact;
1097 let n = hit.normal;
1098 #[allow(deprecated)]
1099 consider(
1100 hit.time_of_impact,
1101 PickHit {
1102 id: item.settings.pick_id.0,
1103 sub_object: None,
1104 world_pos,
1105 normal: glam::Vec3::new(n.x, n.y, n.z),
1106 triangle_index: u32::MAX,
1107 point_index: None,
1108 scalar_value: None,
1109 },
1110 );
1111 }
1112 }
1113 }
1114
1115 // Screen image: screen-space rect test. toi=0 so these win over any 3D hit.
1116 for item in &self.pick_screen_image_items {
1117 if item.settings.pick_id == PickId::NONE || item.width == 0 || item.height == 0 {
1118 continue;
1119 }
1120 let img_w = item.width as f32 * item.scale;
1121 let img_h = item.height as f32 * item.scale;
1122 let (sx, sy) = match item.anchor {
1123 ImageAnchor::TopLeft => (0.0, 0.0),
1124 ImageAnchor::TopRight => (viewport_size.x - img_w, 0.0),
1125 ImageAnchor::BottomLeft => (0.0, viewport_size.y - img_h),
1126 ImageAnchor::BottomRight => (viewport_size.x - img_w, viewport_size.y - img_h),
1127 ImageAnchor::Center => (
1128 (viewport_size.x - img_w) * 0.5,
1129 (viewport_size.y - img_h) * 0.5,
1130 ),
1131 };
1132 if click_pos.x >= sx
1133 && click_pos.x <= sx + img_w
1134 && click_pos.y >= sy
1135 && click_pos.y <= sy + img_h
1136 {
1137 // No meaningful 3D position; place the hit at the near-plane.
1138 let world_pos = ray_origin + ray_dir * 0.001;
1139 #[allow(deprecated)]
1140 consider(
1141 0.0,
1142 PickHit {
1143 id: item.settings.pick_id.0,
1144 sub_object: None,
1145 world_pos,
1146 normal: -ray_dir,
1147 triangle_index: u32::MAX,
1148 point_index: None,
1149 scalar_value: None,
1150 },
1151 );
1152 }
1153 }
1154 }
1155
1156 // 11. GPU implicit surface picks (OBJECT only -- no sub-element model).
1157 if wants_object {
1158 for item in &self.pick_implicit_items {
1159 if let Some((toi, world_pos)) = pick_implicit_sdf(ray_origin, ray_dir, item) {
1160 #[allow(deprecated)]
1161 consider(
1162 toi,
1163 PickHit {
1164 id: item.id,
1165 sub_object: None,
1166 world_pos,
1167 normal: glam::Vec3::Z,
1168 triangle_index: u32::MAX,
1169 point_index: None,
1170 scalar_value: None,
1171 },
1172 );
1173 }
1174 }
1175 }
1176
1177 // 12. GPU marching cubes surface picks (OBJECT only).
1178 if wants_object {
1179 for item in &self.pick_mc_items {
1180 if let Some((toi, world_pos)) = pick_mc_volume(ray_origin, ray_dir, item) {
1181 #[allow(deprecated)]
1182 consider(
1183 toi,
1184 PickHit {
1185 id: item.id,
1186 sub_object: None,
1187 world_pos,
1188 normal: glam::Vec3::Z,
1189 triangle_index: u32::MAX,
1190 point_index: None,
1191 scalar_value: None,
1192 },
1193 );
1194 }
1195 }
1196 }
1197
1198 // 13. Decal picks (OBJECT only): ray versus the decal projection box.
1199 // A decal is the unit box [-0.5, 0.5]^3 mapped to world by `transform`.
1200 // The box front face typically hugs the receiver surface, so a decal
1201 // that straddles a surface wins over that surface by `toi`, letting a
1202 // click select the decal itself. A decal whose box floats in empty
1203 // space is still pickable wherever the ray passes through the volume.
1204 if wants_object {
1205 for item in &self.pick_decal_items {
1206 if item.settings.hidden || item.settings.pick_id == PickId::NONE {
1207 continue;
1208 }
1209 let model = glam::Mat4::from_cols_array_2d(&item.transform);
1210 if model.determinant().abs() < 1e-12 {
1211 continue;
1212 }
1213 let inv = model.inverse();
1214 let local_origin = inv.transform_point3(ray_origin);
1215 let local_dir = inv.transform_vector3(ray_dir);
1216 if let Some(toi) = ray_unit_box_toi(local_origin, local_dir) {
1217 let world_pos = ray_origin + ray_dir * toi;
1218 #[allow(deprecated)]
1219 consider(
1220 toi,
1221 PickHit {
1222 id: item.settings.pick_id.0,
1223 sub_object: None,
1224 world_pos,
1225 normal: -ray_dir.normalize_or_zero(),
1226 triangle_index: u32::MAX,
1227 point_index: None,
1228 scalar_value: None,
1229 },
1230 );
1231 }
1232 }
1233 }
1234
1235 // Consult registered item-type plugins after the built-in pickers.
1236 // Each plugin returns its own closest hit; the router compares
1237 // by world-space ray t against the running best.
1238 if !self.item_type_plugins.is_empty() {
1239 let plugin_ray = crate::plugin_api::PickRay {
1240 origin: ray_origin,
1241 direction: ray_dir,
1242 };
1243 for plugin in self.item_type_plugins.values() {
1244 if let Some((t, hit)) = plugin.pick(&plugin_ray) {
1245 consider(t, hit);
1246 }
1247 }
1248 }
1249
1250 best.map(|(_, hit)| hit)
1251 }
1252}