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}