1use crate::camera::MainCamera;
6use crate::mesh::{BatchedMesh, TriangleEntityMapping};
7use crate::storage::{save_selection, SelectionStorage};
8use bevy::math::Affine3A;
9use bevy::prelude::*;
10use bevy::window::PrimaryWindow;
11use rustc_hash::FxHashSet;
12
13pub struct PickingPlugin;
15
16impl Plugin for PickingPlugin {
17 fn build(&self, app: &mut App) {
18 app.init_resource::<SelectionState>()
19 .init_resource::<PickingSettings>()
20 .init_resource::<MeasurementState>()
21 .add_systems(
23 Update,
24 (
25 poll_active_tool,
26 measure_system.after(poll_active_tool),
29 picking_system.after(measure_system),
30 hover_system,
31 draw_measurements,
32 )
33 .after(crate::camera::CameraPlugin::input_system_set()),
34 );
35 }
36}
37
38#[derive(Resource, Default)]
40pub struct MeasurementState {
41 pub measurements: Vec<(Vec3, Vec3)>,
43 pub pending: Option<Vec3>,
45 pub active: bool,
47}
48
49#[derive(Resource, Default)]
51pub struct SelectionState {
52 pub selected: FxHashSet<u64>,
54 pub hovered: Option<u64>,
56}
57
58impl SelectionState {
59 pub fn is_selected(&self, id: u64) -> bool {
61 self.selected.contains(&id)
62 }
63
64 pub fn select(&mut self, id: u64) {
66 self.selected.clear();
67 self.selected.insert(id);
68 self.save();
69 }
70
71 pub fn toggle(&mut self, id: u64) {
73 if self.selected.contains(&id) {
74 self.selected.remove(&id);
75 } else {
76 self.selected.insert(id);
77 }
78 self.save();
79 }
80
81 pub fn add(&mut self, id: u64) {
83 self.selected.insert(id);
84 self.save();
85 }
86
87 pub fn remove(&mut self, id: u64) {
89 self.selected.remove(&id);
90 self.save();
91 }
92
93 pub fn clear(&mut self) {
95 self.selected.clear();
96 self.save();
97 }
98
99 fn save(&self) {
101 let storage = SelectionStorage {
102 selected_ids: self.selected.iter().copied().collect(),
103 hovered_id: self.hovered,
104 };
105 save_selection(&storage);
106 }
107}
108
109#[derive(Resource)]
111pub struct PickingSettings {
112 pub enabled: bool,
114 pub hover_throttle: u32,
116}
117
118impl Default for PickingSettings {
119 fn default() -> Self {
120 Self {
121 enabled: true,
122 hover_throttle: 3, }
124 }
125}
126
127#[allow(clippy::too_many_arguments)]
129fn picking_system(
130 keyboard: Res<ButtonInput<KeyCode>>,
131 cameras: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
132 batched_meshes: Query<(&BatchedMesh, &GlobalTransform, &Mesh3d)>,
133 triangle_mapping: Res<TriangleEntityMapping>,
134 meshes: Res<Assets<Mesh>>,
135 mut selection: ResMut<SelectionState>,
136 settings: Res<PickingSettings>,
137 mut camera_controller: ResMut<crate::camera::CameraController>,
138) {
139 if !settings.enabled {
140 return;
141 }
142
143 if !camera_controller.just_clicked {
145 return;
146 }
147
148 camera_controller.just_clicked = false;
150
151 let Ok((camera, camera_transform)) = cameras.single() else {
152 return;
153 };
154
155 let click_pos = camera_controller.drag_start_pos;
157
158 let Ok(ray) = camera.viewport_to_world(camera_transform, click_pos) else {
160 return;
161 };
162
163 let mut closest: Option<(u64, f32, Vec3)> = None;
165
166 for (batched_mesh, transform, mesh_handle) in batched_meshes.iter() {
167 if let Some(mesh) = meshes.get(&mesh_handle.0) {
168 if let Some((distance, triangle_index, hit_point)) =
169 ray_mesh_intersection_with_triangle(&ray, mesh, transform)
170 {
171 if let Some(entity_id) =
173 triangle_mapping.get_entity(batched_mesh.is_transparent, triangle_index)
174 {
175 if closest.map(|(_, d, _)| distance < d).unwrap_or(true) {
176 closest = Some((entity_id, distance, hit_point));
177 }
178 }
179 }
180 }
181 }
182
183 if let Some((entity_id, _, _)) = closest {
185 let ctrl_pressed = keyboard.pressed(KeyCode::ControlLeft)
186 || keyboard.pressed(KeyCode::ControlRight)
187 || keyboard.pressed(KeyCode::SuperLeft)
188 || keyboard.pressed(KeyCode::SuperRight);
189
190 if ctrl_pressed {
191 selection.toggle(entity_id);
192 } else {
193 selection.select(entity_id);
194 }
195 } else {
196 if !keyboard.pressed(KeyCode::ControlLeft) && !keyboard.pressed(KeyCode::ControlRight) {
198 selection.clear();
199 }
200 }
201}
202
203#[allow(clippy::too_many_arguments)]
205fn hover_system(
206 windows: Query<&Window, With<PrimaryWindow>>,
207 cameras: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
208 batched_meshes: Query<(&BatchedMesh, &GlobalTransform, &Mesh3d)>,
209 triangle_mapping: Res<TriangleEntityMapping>,
210 meshes: Res<Assets<Mesh>>,
211 mut selection: ResMut<SelectionState>,
212 settings: Res<PickingSettings>,
213 mut frame_counter: Local<u32>,
214) {
215 if !settings.enabled {
216 return;
217 }
218
219 *frame_counter += 1;
221 if !(*frame_counter).is_multiple_of(settings.hover_throttle) {
222 return;
223 }
224
225 let Ok(window) = windows.single() else { return };
226 let Some(cursor_pos) = window.cursor_position() else {
227 if selection.hovered.is_some() {
228 selection.hovered = None;
229 }
230 return;
231 };
232 let Ok((camera, camera_transform)) = cameras.single() else {
233 return;
234 };
235
236 let Ok(ray) = camera.viewport_to_world(camera_transform, cursor_pos) else {
238 return;
239 };
240
241 let mut closest: Option<(u64, f32)> = None;
243
244 for (batched_mesh, transform, mesh_handle) in batched_meshes.iter() {
245 if let Some(mesh) = meshes.get(&mesh_handle.0) {
246 if let Some((distance, triangle_index, _hit)) =
247 ray_mesh_intersection_with_triangle(&ray, mesh, transform)
248 {
249 if let Some(entity_id) =
251 triangle_mapping.get_entity(batched_mesh.is_transparent, triangle_index)
252 {
253 if closest.map(|(_, d)| distance < d).unwrap_or(true) {
254 closest = Some((entity_id, distance));
255 }
256 }
257 }
258 }
259 }
260
261 let new_hovered = closest.map(|(id, _)| id);
263 if selection.hovered != new_hovered {
264 selection.hovered = new_hovered;
265 }
266}
267
268fn ray_mesh_intersection_with_triangle(
271 ray: &Ray3d,
272 mesh: &Mesh,
273 transform: &GlobalTransform,
274) -> Option<(f32, usize, Vec3)> {
275 let positions = mesh.attribute(Mesh::ATTRIBUTE_POSITION)?.as_float3()?;
277
278 let transform_matrix = transform.affine();
280 let (min, max) = compute_world_aabb(positions, &transform_matrix);
281
282 if !ray_aabb_intersects(ray, min, max) {
284 return None;
285 }
286
287 let indices = mesh.indices()?;
289 let indices: Vec<usize> = indices.iter().collect();
290
291 let mut closest: Option<(f32, usize, Vec3)> = None;
292
293 for (tri_idx, chunk) in indices.chunks(3).enumerate() {
295 if chunk.len() < 3 {
296 continue;
297 }
298 let v0 = transform_matrix.transform_point3(Vec3::from(positions[chunk[0]]));
299 let v1 = transform_matrix.transform_point3(Vec3::from(positions[chunk[1]]));
300 let v2 = transform_matrix.transform_point3(Vec3::from(positions[chunk[2]]));
301
302 if let Some(t) = ray_triangle_intersection(ray, v0, v1, v2) {
303 if t > 0.0 && closest.map(|(d, _, _)| t < d).unwrap_or(true) {
304 let hit_point = ray.origin + *ray.direction * t;
305 closest = Some((t, tri_idx, hit_point));
306 }
307 }
308 }
309
310 closest
311}
312
313fn compute_world_aabb(positions: &[[f32; 3]], transform: &Affine3A) -> (Vec3, Vec3) {
315 let mut min = Vec3::splat(f32::MAX);
316 let mut max = Vec3::splat(f32::MIN);
317
318 for pos in positions {
319 let world_pos = transform.transform_point3(Vec3::from(*pos));
320 min = min.min(world_pos);
321 max = max.max(world_pos);
322 }
323
324 (min, max)
325}
326
327fn ray_triangle_intersection(ray: &Ray3d, v0: Vec3, v1: Vec3, v2: Vec3) -> Option<f32> {
329 const EPSILON: f32 = 1e-7;
330
331 let edge1 = v1 - v0;
332 let edge2 = v2 - v0;
333 let h = ray.direction.cross(edge2);
334 let a = edge1.dot(h);
335
336 if a.abs() < EPSILON {
338 return None;
339 }
340
341 let f = 1.0 / a;
342 let s = ray.origin - v0;
343 let u = f * s.dot(h);
344
345 if !(0.0..=1.0).contains(&u) {
346 return None;
347 }
348
349 let q = s.cross(edge1);
350 let v = f * ray.direction.dot(q);
351
352 if v < 0.0 || u + v > 1.0 {
353 return None;
354 }
355
356 let t = f * edge2.dot(q);
357 if t > EPSILON {
358 Some(t)
359 } else {
360 None
361 }
362}
363
364fn poll_active_tool(mut measurement: ResMut<MeasurementState>, mut frame: Local<u32>) {
366 *frame += 1;
367 if !(*frame).is_multiple_of(10) {
368 return;
369 }
370 let is_measure = crate::storage::load_active_tool()
371 .map(|t| t == "measure")
372 .unwrap_or(false);
373 measurement.active = is_measure;
374}
375
376#[allow(clippy::too_many_arguments)]
378fn measure_system(
379 cameras: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
380 batched_meshes: Query<(&BatchedMesh, &GlobalTransform, &Mesh3d)>,
381 meshes: Res<Assets<Mesh>>,
382 mut measurement: ResMut<MeasurementState>,
383 mut camera_controller: ResMut<crate::camera::CameraController>,
384 keyboard: Res<ButtonInput<KeyCode>>,
385) {
386 if !measurement.active {
387 return;
388 }
389
390 if keyboard.just_pressed(KeyCode::Escape) {
392 measurement.pending = None;
393 measurement.measurements.clear();
394 crate::log_info("[Measure] Cleared all measurements");
395 return;
396 }
397
398 if !camera_controller.just_clicked {
399 return;
400 }
401
402 camera_controller.just_clicked = false;
404
405 let Ok((camera, camera_transform)) = cameras.single() else {
406 return;
407 };
408 let click_pos = camera_controller.drag_start_pos;
409 let Ok(ray) = camera.viewport_to_world(camera_transform, click_pos) else {
410 return;
411 };
412
413 let mut closest: Option<(f32, Vec3)> = None;
415 for (_batched_mesh, transform, mesh_handle) in batched_meshes.iter() {
416 if let Some(mesh) = meshes.get(&mesh_handle.0) {
417 if let Some((distance, _tri, hit_point)) =
418 ray_mesh_intersection_with_triangle(&ray, mesh, transform)
419 {
420 if closest.map(|(d, _)| distance < d).unwrap_or(true) {
421 closest = Some((distance, hit_point));
422 }
423 }
424 }
425 }
426
427 if let Some((_, hit_point)) = closest {
428 crate::storage::save_measure_point(&crate::storage::MeasurePointStorage {
430 x: hit_point.x,
431 y: hit_point.y,
432 z: hit_point.z,
433 });
434
435 if let Some(start) = measurement.pending.take() {
436 let dist = (hit_point - start).length();
438 measurement.measurements.push((start, hit_point));
439 crate::log_info(&format!("[Measure] Distance: {:.3}m", dist));
440 } else {
441 measurement.pending = Some(hit_point);
443 crate::log_info(&format!(
444 "[Measure] Point 1 set ({:.2}, {:.2}, {:.2}) — click point 2",
445 hit_point.x, hit_point.y, hit_point.z,
446 ));
447 }
448 }
449}
450
451fn draw_measurements(measurement: Res<MeasurementState>, mut gizmos: Gizmos) {
453 if !measurement.active && measurement.measurements.is_empty() && measurement.pending.is_none() {
454 return;
455 }
456
457 let yellow = Color::srgb(1.0, 0.85, 0.0);
458 let red = Color::srgb(1.0, 0.3, 0.3);
459 let cyan = Color::srgb(0.0, 0.9, 1.0);
460
461 for (start, end) in &measurement.measurements {
463 gizmos.line(*start, *end, yellow);
465 let dir = (*end - *start).normalize();
467 let offset = if dir.cross(Vec3::Y).length() > 0.1 {
468 dir.cross(Vec3::Y).normalize() * 0.02
469 } else {
470 dir.cross(Vec3::X).normalize() * 0.02
471 };
472 gizmos.line(*start + offset, *end + offset, yellow);
473 gizmos.line(*start - offset, *end - offset, yellow);
474
475 let sphere_size = 0.06;
477 gizmos.sphere(Isometry3d::from_translation(*start), sphere_size, red);
478 gizmos.sphere(Isometry3d::from_translation(*end), sphere_size, red);
479
480 let mid = (*start + *end) / 2.0;
482 gizmos.sphere(Isometry3d::from_translation(mid), 0.03, yellow);
483 }
484
485 if let Some(start) = measurement.pending {
487 let size = 0.12;
488 gizmos.sphere(Isometry3d::from_translation(start), 0.08, cyan);
489 gizmos.line(start - Vec3::X * size, start + Vec3::X * size, cyan);
490 gizmos.line(start - Vec3::Y * size, start + Vec3::Y * size, cyan);
491 gizmos.line(start - Vec3::Z * size, start + Vec3::Z * size, cyan);
492 }
493}
494
495fn ray_aabb_intersects(ray: &Ray3d, min: Vec3, max: Vec3) -> bool {
497 let inv_dir = Vec3::new(
498 1.0 / ray.direction.x,
499 1.0 / ray.direction.y,
500 1.0 / ray.direction.z,
501 );
502
503 let t1 = (min - ray.origin) * inv_dir;
504 let t2 = (max - ray.origin) * inv_dir;
505
506 let tmin = t1.min(t2);
507 let tmax = t1.max(t2);
508
509 let t_enter = tmin.x.max(tmin.y).max(tmin.z);
510 let t_exit = tmax.x.min(tmax.y).min(tmax.z);
511
512 t_enter <= t_exit && t_exit >= 0.0
513}