Skip to main content

khora_editor/
picking.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Click-to-select in the viewport.
16//!
17//! Casts the cursor ray against every entity's world-space bounding box and
18//! returns the nearest hit. Bounding boxes, not triangles: an editor pick has
19//! to feel instant and forgiving, and a box is both — clicking slightly off a
20//! thin object still selects it, which is what people expect from a viewport.
21//!
22//! Everything here runs **on click**, never per frame, so computing a mesh's
23//! bounds from its vertex positions on the spot is cheap enough not to need a
24//! cache. If picking ever moves to hover-highlighting, that changes and the
25//! bounds want caching against the mesh handle.
26
27use khora_sdk::khora_core::math::{Aabb, Mat4, Ray, Vec3};
28use khora_sdk::khora_core::renderer::api::scene::mesh::Mesh;
29use khora_sdk::prelude::ecs::{AudioSource, Camera, EntityId, GlobalTransform, Light, Transform};
30use khora_sdk::{GameWorld, HandleComponent};
31
32/// Half-extent of the clickable box given to entities that have no mesh.
33///
34/// A light or a camera is drawn as a gizmo, not geometry, so it has no bounds
35/// of its own — without this they would be unselectable in the viewport, which
36/// is precisely when you want to grab them.
37const GIZMO_PICK_HALF_EXTENT: f32 = 0.35;
38
39/// The nearest entity whose bounds the ray enters, if any.
40pub fn pick_entity(world: &GameWorld, ray: &Ray) -> Option<EntityId> {
41    // Reciprocal once, not per candidate — the slab test takes the inverse
42    // direction so it can multiply rather than divide.
43    let inv_dir = ray.inv_direction();
44
45    let mut best: Option<(f32, EntityId)> = None;
46    for entity in world.iter_entities() {
47        let Some(world_matrix) = entity_matrix(world, entity) else {
48            continue;
49        };
50        let local = local_bounds(world, entity);
51        let bounds = local.transform(&world_matrix);
52        if !bounds.is_valid() {
53            continue;
54        }
55        let Some(distance) = bounds.intersect_ray(ray.origin, inv_dir) else {
56            continue;
57        };
58        // Behind the camera: the slab test reports the entry distance, which
59        // is negative when the ray starts inside or past the box.
60        if distance < 0.0 {
61            continue;
62        }
63        if best.is_none_or(|(closest, _)| distance < closest) {
64            best = Some((distance, entity));
65        }
66    }
67    best.map(|(_, entity)| entity)
68}
69
70/// World matrix for `entity`, preferring the propagated `GlobalTransform` so a
71/// child is picked where it is actually drawn rather than where its local
72/// transform alone would put it.
73fn entity_matrix(world: &GameWorld, entity: EntityId) -> Option<Mat4> {
74    if let Some(global) = world.get_component::<GlobalTransform>(entity) {
75        return Some(global.0 .0);
76    }
77    let transform = world.get_component::<Transform>(entity)?;
78    Some(
79        Mat4::from_translation(transform.translation)
80            * Mat4::from_quat(transform.rotation)
81            * Mat4::from_scale(transform.scale),
82    )
83}
84
85/// Local-space bounds for `entity`: the mesh's own extent when it has one, a
86/// small box otherwise.
87fn local_bounds(world: &GameWorld, entity: EntityId) -> Aabb {
88    if let Some(mesh) = world.get_component::<HandleComponent<Mesh>>(entity) {
89        if let Some(bounds) = Aabb::from_points(&mesh.handle.positions) {
90            return bounds;
91        }
92    }
93    // Lights, cameras, audio sources and empties are drawn as gizmos; give them
94    // a grabbable box so the viewport can select them at all. Empties get one
95    // too — an entity you can see in the hierarchy but never click in the
96    // viewport is a worse surprise than a slightly generous hit box.
97    let _ = (
98        world.get_component::<Light>(entity).is_some(),
99        world.get_component::<Camera>(entity).is_some(),
100        world.get_component::<AudioSource>(entity).is_some(),
101    );
102    Aabb::from_half_extents(Vec3::new(
103        GIZMO_PICK_HALF_EXTENT,
104        GIZMO_PICK_HALF_EXTENT,
105        GIZMO_PICK_HALF_EXTENT,
106    ))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use khora_sdk::prelude::ecs::Name;
113
114    /// Spawns an entity at `pos` carrying only a transform — the gizmo-box
115    /// case, which is what most editor entities are.
116    ///
117    /// `GlobalTransform` is set to match, standing in for the propagation pass
118    /// the engine runs every tick. Picking reads the *global* transform, so a
119    /// fixture that left it at identity would place every entity at the origin
120    /// and test nothing.
121    fn ray_from(origin: Vec3, direction: Vec3) -> Ray {
122        Ray::new(origin, direction)
123    }
124
125    fn spawn_at(world: &mut GameWorld, pos: Vec3, name: &str) -> EntityId {
126        world.spawn((
127            Transform::from_translation(pos),
128            GlobalTransform::new(Mat4::from_translation(pos)),
129            Name::new(name),
130        ))
131    }
132
133    /// A ray down -Z from in front of the origin must hit an entity sitting at
134    /// the origin.
135    #[test]
136    fn ray_hits_an_entity_in_its_path() {
137        let mut world = GameWorld::new();
138        let entity = spawn_at(&mut world, Vec3::ZERO, "target");
139        // `GlobalTransform::identity()` is what a freshly spawned entity has
140        // before propagation runs, which is exactly the state a click sees.
141        let hit = pick_entity(
142            &world,
143            &ray_from(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0)),
144        );
145        assert_eq!(hit, Some(entity));
146    }
147
148    /// A ray pointing away selects nothing rather than the entity behind the
149    /// camera — the slab test reports a negative entry distance there.
150    #[test]
151    fn ray_pointing_away_selects_nothing() {
152        let mut world = GameWorld::new();
153        spawn_at(&mut world, Vec3::ZERO, "behind");
154        let hit = pick_entity(
155            &world,
156            &ray_from(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, 1.0)),
157        );
158        assert_eq!(hit, None);
159    }
160
161    /// A ray that misses every box selects nothing, rather than falling back to
162    /// "whatever was closest to the line".
163    #[test]
164    fn ray_that_misses_selects_nothing() {
165        let mut world = GameWorld::new();
166        spawn_at(&mut world, Vec3::ZERO, "target");
167        let hit = pick_entity(
168            &world,
169            &ray_from(Vec3::new(50.0, 50.0, 5.0), Vec3::new(0.0, 0.0, -1.0)),
170        );
171        assert_eq!(hit, None);
172    }
173
174    /// With two entities on the same line, the near one wins — the whole point
175    /// of tracking the entry distance rather than taking the first hit found.
176    #[test]
177    fn nearest_entity_wins() {
178        let mut world = GameWorld::new();
179        let far = spawn_at(&mut world, Vec3::new(0.0, 0.0, -10.0), "far");
180        let near = spawn_at(&mut world, Vec3::new(0.0, 0.0, 0.0), "near");
181        let hit = pick_entity(
182            &world,
183            &ray_from(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0)),
184        );
185        assert_eq!(hit, Some(near), "expected the near entity, not {far:?}");
186    }
187
188    /// An entity moved aside is picked at its new position: the ray is tested
189    /// against *world* bounds, not local ones.
190    #[test]
191    fn picking_follows_the_transform() {
192        let mut world = GameWorld::new();
193        let entity = spawn_at(&mut world, Vec3::new(3.0, 0.0, 0.0), "moved");
194        if let Some(g) = world.get_component_mut::<GlobalTransform>(entity) {
195            *g = GlobalTransform::new(Mat4::from_translation(Vec3::new(3.0, 0.0, 0.0)));
196        }
197
198        let miss = pick_entity(
199            &world,
200            &ray_from(Vec3::new(0.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0)),
201        );
202        assert_eq!(miss, None, "nothing sits at the origin any more");
203
204        let hit = pick_entity(
205            &world,
206            &ray_from(Vec3::new(3.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0)),
207        );
208        assert_eq!(hit, Some(entity));
209    }
210}