Skip to main content

mittens_engine/engine/ecs/system/
raycast_system.rs

1use crate::engine::ecs::ComponentId;
2use crate::engine::ecs::World;
3use crate::engine::ecs::component::{
4    RayCastComponent, RayCastMode, RaycastableComponent, RaycastableShapeComponent,
5    RaycastableShapeType, RenderableComponent,
6};
7use crate::engine::ecs::system::BvhSystem;
8use crate::engine::ecs::system::System;
9use crate::engine::ecs::system::TransformSystem;
10use crate::engine::ecs::{EventSignal, RxWorld};
11use crate::engine::graphics::VisualWorld;
12use crate::engine::graphics::primitives::{CpuMeshHandle, TransformMatrix};
13use crate::engine::user_input::InputState;
14use crate::utils::math;
15use std::collections::{HashMap, HashSet};
16use std::sync::OnceLock;
17use std::time::{Duration, Instant};
18use winit::event::MouseButton;
19
20#[derive(Debug, Default)]
21pub struct RayCastSystem {
22    raycasters: HashSet<ComponentId>,
23    last_hit: HashMap<ComponentId, Option<ComponentId>>,
24
25    /// Renderables eligible for raycasting, maintained incrementally on renderable add/remove.
26    ///
27    /// This is used to avoid scanning `world.all_components()` for brute-force fallback tests.
28    eligible_renderables: HashSet<ComponentId>,
29    profile_frames: u64,
30    profile_rays: u64,
31    profile_bvh_hits: u64,
32    profile_fallbacks: u64,
33    profile_fallback_candidates: u64,
34    profile_query_time: Duration,
35}
36
37#[derive(Debug, Clone, Copy)]
38struct CursorRay {
39    origin: [f32; 3],
40    dir: [f32; 3],
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum RaySourceKind {
45    CursorThroughActiveCamera,
46    ParentForward,
47}
48
49// Topology helpers live in pointer_system; use them directly.
50use crate::engine::ecs::system::pointer_system::pointer_topology_context;
51
52impl RayCastSystem {
53    fn debug_raycast_enabled() -> bool {
54        static ENABLED: OnceLock<bool> = OnceLock::new();
55        *ENABLED.get_or_init(|| {
56            let v = std::env::var("CAT_DEBUG_RAYCAST").unwrap_or_default();
57            matches!(
58                v.trim().to_ascii_lowercase().as_str(),
59                "1" | "true" | "yes" | "on"
60            )
61        })
62    }
63
64    fn profile_spatial_enabled() -> bool {
65        static ENABLED: OnceLock<bool> = OnceLock::new();
66        *ENABLED.get_or_init(|| {
67            matches!(
68                std::env::var("CAT_PROFILE_SPATIAL")
69                    .unwrap_or_default()
70                    .trim()
71                    .to_ascii_lowercase()
72                    .as_str(),
73                "1" | "true" | "yes" | "on"
74            )
75        })
76    }
77
78    fn debug_component_label(world: &World, component: ComponentId) -> String {
79        world
80            .get_component_record(component)
81            .map(|n| {
82                if n.name.is_empty() {
83                    n.component_type.clone()
84                } else {
85                    format!("{}: {}", n.component_type, n.name)
86                }
87            })
88            .unwrap_or_else(|| "<missing>".to_string())
89    }
90
91    fn raycastable_for_renderable(
92        world: &World,
93        renderable: ComponentId,
94    ) -> Option<RaycastableComponent> {
95        BvhSystem::find_raycastable_for_renderable(world, renderable)
96    }
97
98    fn sort_hits_by_priority(world: &World, hits: &mut [(ComponentId, f32)]) {
99        hits.sort_by(|a, b| {
100            let a_pri = Self::raycastable_for_renderable(world, a.0)
101                .map(|rc| rc.interaction_priority)
102                .unwrap_or(0);
103            let b_pri = Self::raycastable_for_renderable(world, b.0)
104                .map(|rc| rc.interaction_priority)
105                .unwrap_or(0);
106
107            b_pri
108                .cmp(&a_pri)
109                .then_with(|| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
110        });
111    }
112
113    fn explicit_shape_on_renderable(
114        world: &World,
115        renderable: ComponentId,
116    ) -> Option<RaycastableShapeType> {
117        world.children_of(renderable).iter().find_map(|&ch| {
118            world
119                .get_component_by_id_as::<RaycastableShapeComponent>(ch)
120                .map(|s| s.shape)
121        })
122    }
123
124    fn infer_shape_from_base_mesh(mesh: CpuMeshHandle) -> RaycastableShapeType {
125        match mesh {
126            CpuMeshHandle::CUBE => RaycastableShapeType::Box,
127            CpuMeshHandle::QUAD_2D => RaycastableShapeType::Quad2D,
128            CpuMeshHandle::TRIANGLE_2D => RaycastableShapeType::Triangle2D,
129            CpuMeshHandle::TETRAHEDRON => RaycastableShapeType::Tetrahedron,
130            CpuMeshHandle::CONE => RaycastableShapeType::Cone,
131            CpuMeshHandle::CIRCLE_2D => RaycastableShapeType::Ring2D,
132            _ => RaycastableShapeType::Aabb,
133        }
134    }
135
136    fn resolved_shape_for_renderable(
137        world: &World,
138        renderable: ComponentId,
139    ) -> RaycastableShapeType {
140        if let Some(shape) = Self::explicit_shape_on_renderable(world, renderable) {
141            if shape != RaycastableShapeType::InferFromBaseMesh {
142                return shape;
143            }
144        }
145
146        let Some(r) = world.get_component_by_id_as::<RenderableComponent>(renderable) else {
147            return RaycastableShapeType::Aabb;
148        };
149        Self::infer_shape_from_base_mesh(r.renderable.base_mesh)
150    }
151
152    fn narrow_phase_accept(
153        world: &World,
154        renderable: ComponentId,
155        origin: [f32; 3],
156        dir: [f32; 3],
157        t_aabb: f32,
158    ) -> Option<f32> {
159        let shape = Self::resolved_shape_for_renderable(world, renderable);
160
161        // If we can't compute a stable local-space transform, fall back to AABB-only.
162        let Some(model) = TransformSystem::world_model(world, renderable) else {
163            return Some(t_aabb);
164        };
165        let Some(inv_model) = math::mat4_inverse(model) else {
166            return Some(t_aabb);
167        };
168
169        // Transform the ray into renderable-local space.
170        let o4 = Self::mat4_mul_vec4(inv_model, [origin[0], origin[1], origin[2], 1.0]);
171        let d4 = Self::mat4_mul_vec4(inv_model, [dir[0], dir[1], dir[2], 0.0]);
172        let o_local = [o4[0], o4[1], o4[2]];
173        let d_local = [d4[0], d4[1], d4[2]];
174
175        fn to_world_t(
176            model: TransformMatrix,
177            origin_world: [f32; 3],
178            dir_world: [f32; 3],
179            p_local: [f32; 3],
180        ) -> Option<f32> {
181            let w = RayCastSystem::mat4_mul_vec4(model, [p_local[0], p_local[1], p_local[2], 1.0]);
182            let p_world = [w[0], w[1], w[2]];
183            let v = RayCastSystem::vec3_sub(p_world, origin_world);
184            let t = RayCastSystem::vec3_dot(v, dir_world);
185            if t.is_finite() && t >= 0.0 {
186                Some(t)
187            } else {
188                None
189            }
190        }
191
192        fn ray_triangle_mt(
193            o: [f32; 3],
194            d: [f32; 3],
195            v0: [f32; 3],
196            v1: [f32; 3],
197            v2: [f32; 3],
198        ) -> Option<f32> {
199            // Möller–Trumbore. Returns t along local ray. Accepts both sides (no backface cull).
200            let eps = 1e-7_f32;
201            let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
202            let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
203            let p = RayCastSystem::vec3_cross(d, e2);
204            let det = RayCastSystem::vec3_dot(e1, p);
205            if det.abs() < eps {
206                return None;
207            }
208            let inv_det = 1.0 / det;
209            let tvec = [o[0] - v0[0], o[1] - v0[1], o[2] - v0[2]];
210            let u = RayCastSystem::vec3_dot(tvec, p) * inv_det;
211            if !(0.0..=1.0).contains(&u) {
212                return None;
213            }
214            let q = RayCastSystem::vec3_cross(tvec, e1);
215            let v = RayCastSystem::vec3_dot(d, q) * inv_det;
216            if v < 0.0 || u + v > 1.0 {
217                return None;
218            }
219            let t = RayCastSystem::vec3_dot(e2, q) * inv_det;
220            if t.is_finite() && t >= 0.0 {
221                Some(t)
222            } else {
223                None
224            }
225        }
226
227        fn best_triangle_hit_world_t(
228            model: TransformMatrix,
229            origin_world: [f32; 3],
230            dir_world: [f32; 3],
231            o_local: [f32; 3],
232            d_local: [f32; 3],
233            tris: &[[[f32; 3]; 3]],
234        ) -> Option<f32> {
235            let mut best: Option<f32> = None;
236            for tri in tris {
237                let t_local = ray_triangle_mt(o_local, d_local, tri[0], tri[1], tri[2]);
238                let Some(t_local) = t_local else {
239                    continue;
240                };
241                let p_local = [
242                    o_local[0] + d_local[0] * t_local,
243                    o_local[1] + d_local[1] * t_local,
244                    o_local[2] + d_local[2] * t_local,
245                ];
246                let Some(t_world) = to_world_t(model, origin_world, dir_world, p_local) else {
247                    continue;
248                };
249                best = match best {
250                    None => Some(t_world),
251                    Some(bt) if t_world < bt => Some(t_world),
252                    _ => best,
253                };
254            }
255            best
256        }
257
258        // Local-space narrow-phase tests.
259        match shape {
260            RaycastableShapeType::Aabb => Some(t_aabb),
261
262            RaycastableShapeType::Box => {
263                let t_local =
264                    Self::ray_aabb(o_local, d_local, [-0.5, -0.5, -0.5], [0.5, 0.5, 0.5])?;
265                let p_local = [
266                    o_local[0] + d_local[0] * t_local,
267                    o_local[1] + d_local[1] * t_local,
268                    o_local[2] + d_local[2] * t_local,
269                ];
270                to_world_t(model, origin, dir, p_local)
271            }
272
273            RaycastableShapeType::Quad2D => {
274                // Keep quad as a slab proxy; triangle is handled precisely below.
275                let thick = 0.02_f32;
276                let t_local =
277                    Self::ray_aabb(o_local, d_local, [-0.5, -0.5, -thick], [0.5, 0.5, thick])?;
278                let p_local = [
279                    o_local[0] + d_local[0] * t_local,
280                    o_local[1] + d_local[1] * t_local,
281                    o_local[2] + d_local[2] * t_local,
282                ];
283                to_world_t(model, origin, dir, p_local)
284            }
285
286            RaycastableShapeType::Triangle2D => {
287                // Exact triangle test matching MeshFactory::triangle_2d().
288                let h = 0.866_025_4_f32;
289                let y_top = 2.0 * h / 3.0;
290                let y_bottom = -h / 3.0;
291                let v0 = [-0.5, y_bottom, 0.0];
292                let v1 = [0.5, y_bottom, 0.0];
293                let v2 = [0.0, y_top, 0.0];
294                let tris: [[[f32; 3]; 3]; 1] = [[v0, v1, v2]];
295                best_triangle_hit_world_t(model, origin, dir, o_local, d_local, &tris[..])
296            }
297
298            RaycastableShapeType::Ring2D => {
299                // Builtin ring mesh is generated as an annulus in the local XY plane.
300                // `RenderAssets` uses MeshFactory::circle_2d(0.45, 0.5, ...).
301                let mut r_in = 0.45_f32;
302                let mut r_out = 0.5_f32;
303
304                // Picking tolerance: slightly widen the clickable band.
305                let tol = 0.03_f32;
306                r_in = (r_in - tol).max(0.0);
307                r_out += tol;
308
309                // Intersect with local plane z=0.
310                let dz = d_local[2];
311                if dz.abs() < 1e-6 {
312                    return None;
313                }
314                let t_local = -o_local[2] / dz;
315                if !t_local.is_finite() || t_local < 0.0 {
316                    return None;
317                }
318
319                let p_local = [
320                    o_local[0] + d_local[0] * t_local,
321                    o_local[1] + d_local[1] * t_local,
322                    0.0,
323                ];
324                let r = (p_local[0] * p_local[0] + p_local[1] * p_local[1]).sqrt();
325                if r < r_in || r > r_out {
326                    return None;
327                }
328                to_world_t(model, origin, dir, p_local)
329            }
330
331            RaycastableShapeType::Cone => {
332                // Practical proxy: treat the cone as a finite cylinder in local space.
333                // This reduces AABB false positives under rotation, even if it's not a perfect cone.
334                let r = 0.5_f32;
335                let zmin = -0.5_f32;
336                let zmax = 0.5_f32;
337
338                let ox = o_local[0];
339                let oy = o_local[1];
340                let oz = o_local[2];
341                let dx = d_local[0];
342                let dy = d_local[1];
343                let dz = d_local[2];
344
345                let mut best_t: Option<f32> = None;
346
347                // Body intersection.
348                let a = dx * dx + dy * dy;
349                let b = 2.0 * (ox * dx + oy * dy);
350                let c = ox * ox + oy * oy - r * r;
351
352                if a.abs() > 1e-8 {
353                    let disc = b * b - 4.0 * a * c;
354                    if disc >= 0.0 {
355                        let s = disc.sqrt();
356                        let t0 = (-b - s) / (2.0 * a);
357                        let t1 = (-b + s) / (2.0 * a);
358                        for t in [t0, t1] {
359                            if !t.is_finite() || t < 0.0 {
360                                continue;
361                            }
362                            let z = oz + dz * t;
363                            if z >= zmin && z <= zmax {
364                                best_t = match best_t {
365                                    None => Some(t),
366                                    Some(bt) if t < bt => Some(t),
367                                    _ => best_t,
368                                };
369                            }
370                        }
371                    }
372                }
373
374                // Caps.
375                if dz.abs() > 1e-8 {
376                    for z_plane in [zmin, zmax] {
377                        let t = (z_plane - oz) / dz;
378                        if !t.is_finite() || t < 0.0 {
379                            continue;
380                        }
381                        let x = ox + dx * t;
382                        let y = oy + dy * t;
383                        if x * x + y * y <= r * r {
384                            best_t = match best_t {
385                                None => Some(t),
386                                Some(bt) if t < bt => Some(t),
387                                _ => best_t,
388                            };
389                        }
390                    }
391                }
392
393                let t_local = best_t?;
394                let p_local = [ox + dx * t_local, oy + dy * t_local, oz + dz * t_local];
395                to_world_t(model, origin, dir, p_local)
396            }
397
398            RaycastableShapeType::Tetrahedron => {
399                // Exact tetrahedron test matching MeshFactory::tetrahedron() indices.
400                let p0 = [0.0, 0.0, 0.6123724];
401                let p1 = [-0.5, -0.2886751, -0.2041241];
402                let p2 = [0.5, -0.2886751, -0.2041241];
403                let p3 = [0.0, 0.5773503, -0.2041241];
404
405                // Faces (CCW as authored):
406                let tris = [[p0, p1, p2], [p0, p3, p1], [p0, p2, p3], [p1, p3, p2]];
407                best_triangle_hit_world_t(model, origin, dir, o_local, d_local, &tris[..])
408            }
409
410            RaycastableShapeType::InferFromBaseMesh => Some(t_aabb),
411        }
412    }
413    pub fn register_raycast(
414        &mut self,
415        world: &mut World,
416        _visuals: &mut VisualWorld,
417        component: ComponentId,
418    ) {
419        if world
420            .get_component_by_id_as::<RayCastComponent>(component)
421            .is_none()
422        {
423            return;
424        }
425        self.raycasters.insert(component);
426    }
427
428    pub fn remove_raycast(
429        &mut self,
430        _world: &mut World,
431        _visuals: &mut VisualWorld,
432        component: ComponentId,
433    ) {
434        self.raycasters.remove(&component);
435        self.last_hit.remove(&component);
436    }
437
438    pub fn notify_renderable_added(&mut self, world: &World, renderable_cid: ComponentId) {
439        if BvhSystem::renderable_is_raycastable(world, renderable_cid) {
440            self.eligible_renderables.insert(renderable_cid);
441        } else {
442            self.eligible_renderables.remove(&renderable_cid);
443        }
444    }
445
446    pub fn notify_renderable_removed(&mut self, renderable_cid: ComponentId) {
447        self.eligible_renderables.remove(&renderable_cid);
448    }
449
450    fn should_cast(
451        mode: RayCastMode,
452        input: &InputState,
453        cast_requested: bool,
454        source: RaySourceKind,
455        pointer_trigger_active: bool,
456    ) -> bool {
457        match mode {
458            RayCastMode::Continuous => true,
459            RayCastMode::EventDriven => {
460                let desktop_mouse_auto_cast = source == RaySourceKind::CursorThroughActiveCamera
461                    && (input.mouse_pressed.contains(&MouseButton::Left)
462                        || (input.mouse_down.contains(&MouseButton::Left)
463                            && input.mouse_dragging()));
464
465                cast_requested || desktop_mouse_auto_cast || pointer_trigger_active
466            }
467        }
468    }
469
470    fn mat4_mul(a: TransformMatrix, b: TransformMatrix) -> TransformMatrix {
471        // Column-major mat4 multiplication: out = a * b.
472        let mut out = [[0.0f32; 4]; 4];
473        for c in 0..4 {
474            for r in 0..4 {
475                out[c][r] =
476                    a[0][r] * b[c][0] + a[1][r] * b[c][1] + a[2][r] * b[c][2] + a[3][r] * b[c][3];
477            }
478        }
479        out
480    }
481
482    fn mat4_mul_vec4(m: TransformMatrix, v: [f32; 4]) -> [f32; 4] {
483        // Column-major mat4 * vec4.
484        [
485            m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3],
486            m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3],
487            m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3],
488            m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3],
489        ]
490    }
491
492    fn vec3_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
493        [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
494    }
495
496    fn vec3_mul_scalar(v: [f32; 3], s: f32) -> [f32; 3] {
497        [v[0] * s, v[1] * s, v[2] * s]
498    }
499
500    fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
501        a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
502    }
503
504    fn vec3_len(v: [f32; 3]) -> f32 {
505        Self::vec3_dot(v, v).sqrt()
506    }
507
508    fn vec3_normalize(v: [f32; 3]) -> [f32; 3] {
509        let len = Self::vec3_len(v);
510        if len > 0.0 {
511            Self::vec3_mul_scalar(v, 1.0 / len)
512        } else {
513            [0.0, 0.0, 1.0]
514        }
515    }
516
517    fn ray_from_cursor(visuals: &VisualWorld, input: &InputState) -> Option<CursorRay> {
518        let vp = visuals.viewport();
519        let w = vp[0];
520        let h = vp[1];
521        if w <= 0.0 || h <= 0.0 {
522            return None;
523        }
524
525        let (cx, cy) = input.cursor_pos.unwrap_or((w * 0.5, h * 0.5));
526
527        // NDC in Vulkan: x in [-1,1], y in [-1,1] with +y up, z in [0,1].
528        let x_ndc = (2.0 * (cx / w)) - 1.0;
529        let y_ndc = 1.0 - (2.0 * (cy / h));
530
531        let view = visuals.camera_view();
532        let proj = visuals.camera_proj();
533        let vp_mat = Self::mat4_mul(proj, view);
534        let inv_vp = math::mat4_inverse(vp_mat)?;
535
536        let near_clip = [x_ndc, y_ndc, 0.0, 1.0];
537        let far_clip = [x_ndc, y_ndc, 1.0, 1.0];
538
539        let near_world4 = Self::mat4_mul_vec4(inv_vp, near_clip);
540        let far_world4 = Self::mat4_mul_vec4(inv_vp, far_clip);
541
542        let near_w = near_world4[3];
543        let far_w = far_world4[3];
544        if near_w == 0.0 || far_w == 0.0 {
545            return None;
546        }
547
548        let near = [
549            near_world4[0] / near_w,
550            near_world4[1] / near_w,
551            near_world4[2] / near_w,
552        ];
553        let far = [
554            far_world4[0] / far_w,
555            far_world4[1] / far_w,
556            far_world4[2] / far_w,
557        ];
558
559        let dir = Self::vec3_normalize(Self::vec3_sub(far, near));
560        Some(CursorRay { origin: near, dir })
561    }
562
563    fn mat4_mul_vec3_dir(m: TransformMatrix, v: [f32; 3]) -> [f32; 3] {
564        // Treat v as a direction (w=0).
565        let w = Self::mat4_mul_vec4(m, [v[0], v[1], v[2], 0.0]);
566        [w[0], w[1], w[2]]
567    }
568
569    fn vec3_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
570        [
571            a[1] * b[2] - a[2] * b[1],
572            a[2] * b[0] - a[0] * b[2],
573            a[0] * b[1] - a[1] * b[0],
574        ]
575    }
576
577    /// Infer ray source behavior from topology.
578    ///
579    /// Current runtime policy remains conservative:
580    /// - desktop-style pointers use cursor-through-active-camera when their pose lineage contains
581    ///   a desktop camera anchor in the same transform lineage
582    /// - everything else still uses parent-forward
583    ///
584    /// We also classify outer driver ancestry here so gesture trigger policy can later prefer a
585    /// stronger enclosing driver without requiring the pointer to move in the authored topology.
586    fn inferred_source_kind(world: &World, raycaster_cid: ComponentId) -> RaySourceKind {
587        let topology = pointer_topology_context(world, raycaster_cid);
588
589        let _future_trigger_policy_hint = (
590            topology.has_desktop_input_driver,
591            topology.has_xr_input_driver,
592            topology.has_controller_driver,
593            topology.has_xr_camera_anchor,
594        );
595
596        if topology.has_desktop_camera_anchor {
597            RaySourceKind::CursorThroughActiveCamera
598        } else {
599            RaySourceKind::ParentForward
600        }
601    }
602
603    fn ray_from_parent_forward(
604        world: &World,
605        raycaster_cid: ComponentId,
606    ) -> Option<([f32; 3], [f32; 3])> {
607        // World model matrix of nearest ancestor TransformComponent.
608        let model = TransformSystem::world_model(world, raycaster_cid)?;
609        let origin = [model[3][0], model[3][1], model[3][2]];
610
611        // Engine forward convention is -Z.
612        let forward_local = [0.0, 0.0, -1.0];
613        let dir_world = Self::vec3_normalize(Self::mat4_mul_vec3_dir(model, forward_local));
614        Some((origin, dir_world))
615    }
616
617    fn aabb_from_world_matrix_for_mesh(
618        mesh: CpuMeshHandle,
619        m: TransformMatrix,
620    ) -> Option<([f32; 3], [f32; 3])> {
621        let (local_pts, thickness) = match mesh {
622            CpuMeshHandle::CUBE => {
623                // Unit cube centered at origin.
624                (
625                    vec![
626                        [-0.5, -0.5, -0.5],
627                        [0.5, -0.5, -0.5],
628                        [-0.5, 0.5, -0.5],
629                        [0.5, 0.5, -0.5],
630                        [-0.5, -0.5, 0.5],
631                        [0.5, -0.5, 0.5],
632                        [-0.5, 0.5, 0.5],
633                        [0.5, 0.5, 0.5],
634                    ],
635                    0.0,
636                )
637            }
638            CpuMeshHandle::TETRAHEDRON => (
639                vec![
640                    [0.0, 0.0, 0.6123724],
641                    [-0.5, -0.2886751, -0.2041241],
642                    [0.5, -0.2886751, -0.2041241],
643                    [0.0, 0.5773503, -0.2041241],
644                ],
645                0.0,
646            ),
647            CpuMeshHandle::CONE => (
648                vec![
649                    // Base circle extremes at z = -0.5 and tip at z = +0.5.
650                    [-0.5, 0.0, -0.5],
651                    [0.5, 0.0, -0.5],
652                    [0.0, -0.5, -0.5],
653                    [0.0, 0.5, -0.5],
654                    [0.0, 0.0, 0.5],
655                ],
656                0.0,
657            ),
658            CpuMeshHandle::QUAD_2D | CpuMeshHandle::TRIANGLE_2D | CpuMeshHandle::CIRCLE_2D => {
659                // Flat meshes live in XY plane. Give them a tiny thickness so AABB tests work.
660                (
661                    vec![
662                        [-0.5, -0.5, 0.0],
663                        [0.5, -0.5, 0.0],
664                        [-0.5, 0.5, 0.0],
665                        [0.5, 0.5, 0.0],
666                    ],
667                    0.01,
668                )
669            }
670            CpuMeshHandle::SPHERE => (
671                vec![
672                    [-0.5, 0.0, 0.0],
673                    [0.5, 0.0, 0.0],
674                    [0.0, -0.5, 0.0],
675                    [0.0, 0.5, 0.0],
676                    [0.0, 0.0, -0.5],
677                    [0.0, 0.0, 0.5],
678                ],
679                0.0,
680            ),
681            _ => return None,
682        };
683
684        let mut min = [f32::INFINITY; 3];
685        let mut max = [f32::NEG_INFINITY; 3];
686
687        for p in local_pts {
688            let v = [p[0], p[1], p[2], 1.0];
689            let w = Self::mat4_mul_vec4(m, v);
690            let wp = [w[0], w[1], w[2]];
691            for i in 0..3 {
692                min[i] = min[i].min(wp[i]);
693                max[i] = max[i].max(wp[i]);
694            }
695        }
696
697        if thickness > 0.0 {
698            min[2] -= thickness;
699            max[2] += thickness;
700        }
701
702        Some((min, max))
703    }
704
705    fn ray_aabb(
706        origin: [f32; 3],
707        dir: [f32; 3],
708        aabb_min: [f32; 3],
709        aabb_max: [f32; 3],
710    ) -> Option<f32> {
711        // Slab test. Returns nearest positive t.
712        let mut tmin = 0.0f32;
713        let mut tmax = f32::INFINITY;
714
715        for axis in 0..3 {
716            let o = origin[axis];
717            let d = dir[axis];
718            let min = aabb_min[axis];
719            let max = aabb_max[axis];
720
721            if d.abs() < 1e-6 {
722                if o < min || o > max {
723                    return None;
724                }
725                continue;
726            }
727
728            let inv_d = 1.0 / d;
729            let mut t0 = (min - o) * inv_d;
730            let mut t1 = (max - o) * inv_d;
731            if t0 > t1 {
732                std::mem::swap(&mut t0, &mut t1);
733            }
734            tmin = tmin.max(t0);
735            tmax = tmax.min(t1);
736            if tmax < tmin {
737                return None;
738            }
739        }
740
741        if tmin >= 0.0 {
742            Some(tmin)
743        } else if tmax >= 0.0 {
744            Some(tmax)
745        } else {
746            None
747        }
748    }
749
750    /// Brute-force all-hits: returns every eligible renderable hit, sorted front-to-back by t.
751    fn cast_against_renderables(
752        &self,
753        world: &World,
754        origin: [f32; 3],
755        dir: [f32; 3],
756        max_distance: f32,
757    ) -> Vec<(ComponentId, f32)> {
758        let mut hits: Vec<(ComponentId, f32)> = Vec::new();
759
760        for &cid in self.eligible_renderables.iter() {
761            let Some(r) = world.get_component_by_id_as::<RenderableComponent>(cid) else {
762                continue;
763            };
764
765            let mesh = r.renderable.base_mesh;
766            let Some(model) = TransformSystem::world_model(world, cid) else {
767                continue;
768            };
769
770            let Some((min, max)) = Self::aabb_from_world_matrix_for_mesh(mesh, model) else {
771                continue;
772            };
773
774            let Some(t) = Self::ray_aabb(origin, dir, min, max) else {
775                continue;
776            };
777
778            if t < 0.0 || t > max_distance {
779                continue;
780            }
781
782            let Some(t2) = Self::narrow_phase_accept(world, cid, origin, dir, t) else {
783                continue;
784            };
785            if t2 < 0.0 || t2 > max_distance {
786                continue;
787            }
788
789            hits.push((cid, t2));
790        }
791
792        Self::sort_hits_by_priority(world, &mut hits);
793        hits
794    }
795
796    /// BVH-accelerated all-hits: returns every BVH candidate that passes narrow phase, sorted
797    /// front-to-back by t.
798    fn cast_against_renderables_bvh(
799        &self,
800        world: &World,
801        bvh: &BvhSystem,
802        origin: [f32; 3],
803        dir: [f32; 3],
804        max_distance: f32,
805    ) -> Vec<(ComponentId, f32)> {
806        let candidates = bvh.raycast_renderables_candidates(origin, dir, max_distance, 64);
807
808        let mut hits: Vec<(ComponentId, f32)> = Vec::new();
809        for (cid, t_aabb) in candidates {
810            if world
811                .get_component_by_id_as::<RenderableComponent>(cid)
812                .is_none()
813            {
814                continue;
815            }
816
817            let Some(t2) = Self::narrow_phase_accept(world, cid, origin, dir, t_aabb) else {
818                continue;
819            };
820            if t2 < 0.0 || t2 > max_distance {
821                continue;
822            }
823
824            hits.push((cid, t2));
825        }
826
827        Self::sort_hits_by_priority(world, &mut hits);
828        hits
829    }
830}
831
832impl System for RayCastSystem {
833    fn tick(
834        &mut self,
835        world: &mut World,
836        visuals: &mut VisualWorld,
837        input: &InputState,
838        _dt_sec: f32,
839    ) {
840        // NOTE: RayCastSystem is normally ticked via `tick_with_queue` from SystemWorld so it can
841        // integrate with RxWorld for events. If this gets called directly, we can still do hit
842        // testing and prints.
843
844        if self.raycasters.is_empty() {
845            return;
846        }
847
848        let Some(ray) = Self::ray_from_cursor(visuals, input) else {
849            return;
850        };
851
852        // Iterate over a stable snapshot so removal during iteration is safe.
853        let raycasters: Vec<ComponentId> = self.raycasters.iter().copied().collect();
854        for rcid in raycasters {
855            let Some(rc) = world.get_component_by_id_as::<RayCastComponent>(rcid) else {
856                self.raycasters.remove(&rcid);
857                self.last_hit.remove(&rcid);
858                continue;
859            };
860
861            let cast_requested = rc.cast_requests > 0;
862
863            let source = Self::inferred_source_kind(world, rcid);
864
865            if !Self::should_cast(rc.mode, input, cast_requested, source, false) {
866                continue;
867            }
868
869            let hits = self.cast_against_renderables(world, ray.origin, ray.dir, rc.max_distance);
870            let best = hits.first().copied();
871
872            match rc.mode {
873                RayCastMode::Continuous => {
874                    let next = best.map(|(cid, _)| cid);
875                    self.last_hit.insert(rcid, next);
876                }
877                RayCastMode::EventDriven => {}
878            }
879
880            if cast_requested {
881                if let Some(rc) = world.get_component_by_id_as_mut::<RayCastComponent>(rcid) {
882                    rc.cast_requests = 0;
883                }
884            }
885        }
886    }
887}
888
889impl RayCastSystem {
890    pub fn tick_with_queue(
891        &mut self,
892        world: &mut World,
893        visuals: &mut VisualWorld,
894        input: &InputState,
895        rx: &mut RxWorld,
896        bvh: &BvhSystem,
897        activations: &crate::engine::ecs::system::pointer_system::PointerActivations,
898        pointer_system: &crate::engine::ecs::system::pointer_system::PointerSystem,
899        _dt_sec: f32,
900    ) {
901        let profile = Self::profile_spatial_enabled();
902        if profile {
903            self.profile_frames += 1;
904        }
905        // Cursor ray (if needed by any raycaster this frame).
906        let cursor_ray = Self::ray_from_cursor(visuals, input);
907
908        if !self.raycasters.is_empty() {
909            // Iterate over a stable snapshot so removal during iteration is safe.
910            let raycasters: Vec<ComponentId> = self.raycasters.iter().copied().collect();
911            for rcid in raycasters {
912                let Some(rc) = world.get_component_by_id_as::<RayCastComponent>(rcid) else {
913                    self.raycasters.remove(&rcid);
914                    self.last_hit.remove(&rcid);
915                    continue;
916                };
917
918                // Copy out what we need so we can mutably borrow `world` later.
919                let mode = rc.mode;
920                let max_distance = rc.max_distance;
921                let cast_requested = rc.cast_requests > 0;
922
923                let source = Self::inferred_source_kind(world, rcid);
924
925                // A pointer's trigger being down should auto-cast the same way mouse-down does.
926                let pointer_trigger_active = pointer_system
927                    .raycast_to_pointer(rcid)
928                    .map(|ptr| {
929                        activations.down.contains(&ptr) || activations.pressed.contains(&ptr)
930                    })
931                    .unwrap_or(false);
932
933                let (origin, dir) = match source {
934                    RaySourceKind::CursorThroughActiveCamera => {
935                        let Some(r) = cursor_ray else {
936                            continue;
937                        };
938                        (r.origin, r.dir)
939                    }
940                    RaySourceKind::ParentForward => {
941                        let Some((o, d)) = Self::ray_from_parent_forward(world, rcid) else {
942                            continue;
943                        };
944                        (o, d)
945                    }
946                };
947
948                if !Self::should_cast(mode, input, cast_requested, source, pointer_trigger_active) {
949                    continue;
950                }
951
952                let query_started = profile.then(Instant::now);
953                let mut hits =
954                    self.cast_against_renderables_bvh(world, bvh, origin, dir, max_distance);
955                if profile {
956                    self.profile_rays += 1;
957                    self.profile_bvh_hits += hits.len() as u64;
958                }
959                if hits.is_empty() && !bvh.has_index() {
960                    hits = self.cast_against_renderables(world, origin, dir, max_distance);
961                    if profile {
962                        self.profile_fallbacks += 1;
963                        self.profile_fallback_candidates += self.eligible_renderables.len() as u64;
964                    }
965                }
966                if let Some(started) = query_started {
967                    self.profile_query_time += started.elapsed();
968                }
969                if Self::debug_raycast_enabled() {
970                    let summary: Vec<String> = hits
971                        .iter()
972                        .take(8)
973                        .map(|(cid, t)| {
974                            let rc = Self::raycastable_for_renderable(world, *cid);
975                            format!(
976                                "{:?} '{}' t={:.3} pri={} pe={:?}",
977                                cid,
978                                Self::debug_component_label(world, *cid),
979                                t,
980                                rc.map(|r| r.interaction_priority).unwrap_or(0),
981                                rc.map(|r| r.pointer_events)
982                                    .unwrap_or(crate::engine::ecs::component::PointerEvents::All)
983                            )
984                        })
985                        .collect();
986                    eprintln!(
987                        "[raycast] rc={:?} source={:?} origin=[{:+.3},{:+.3},{:+.3}] dir=[{:+.3},{:+.3},{:+.3}] hits={}",
988                        rcid,
989                        source,
990                        origin[0],
991                        origin[1],
992                        origin[2],
993                        dir[0],
994                        dir[1],
995                        dir[2],
996                        if summary.is_empty() {
997                            "<none>".to_string()
998                        } else {
999                            summary.join(" | ")
1000                        }
1001                    );
1002                }
1003
1004                // Emit one RayIntersected per hit (front-to-back). GestureSystem accumulates
1005                // all of them within the frame and picks drag/click targets independently.
1006                for &(hit_cid, t) in &hits {
1007                    rx.push_event(
1008                        hit_cid,
1009                        EventSignal::RayIntersected {
1010                            raycaster: rcid,
1011                            renderable: hit_cid,
1012                            t,
1013                            origin,
1014                            dir,
1015                        },
1016                    );
1017                }
1018
1019                let best = hits.first().copied();
1020                match mode {
1021                    RayCastMode::Continuous => {
1022                        let next = best.map(|(cid, _)| cid);
1023                        self.last_hit.insert(rcid, next);
1024                    }
1025                    RayCastMode::EventDriven => {}
1026                }
1027
1028                if cast_requested {
1029                    if let Some(rc) = world.get_component_by_id_as_mut::<RayCastComponent>(rcid) {
1030                        rc.cast_requests = 0;
1031                    }
1032                }
1033            }
1034        }
1035
1036        if profile && self.profile_frames >= 120 {
1037            let line = format!(
1038                "[spatial-profile][raycast] frames={} rays={} bvh_hits={} fallbacks={} fallback_scan_candidates={} query_ms={:.3}",
1039                self.profile_frames,
1040                self.profile_rays,
1041                self.profile_bvh_hits,
1042                self.profile_fallbacks,
1043                self.profile_fallback_candidates,
1044                self.profile_query_time.as_secs_f64() * 1000.0
1045            );
1046            eprintln!("{line}");
1047            crate::utils::profile_log::append("spatial-profile", &line);
1048            self.profile_frames = 0;
1049            self.profile_rays = 0;
1050            self.profile_bvh_hits = 0;
1051            self.profile_fallbacks = 0;
1052            self.profile_fallback_candidates = 0;
1053            self.profile_query_time = Duration::ZERO;
1054        }
1055    }
1056}