Skip to main content

bimifc_bevy/
picking.rs

1//! Picking and selection system
2//!
3//! Handles raycasting for object selection and hover detection.
4
5use 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
13/// Picking plugin
14pub 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            // Run picking after camera input so we can see just_clicked flag
22            .add_systems(
23                Update,
24                (
25                    poll_active_tool,
26                    // measure_system must run before picking_system
27                    // because both consume just_clicked
28                    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/// Active measurements in the scene
39#[derive(Resource, Default)]
40pub struct MeasurementState {
41    /// Completed measurements: (start, end)
42    pub measurements: Vec<(Vec3, Vec3)>,
43    /// First point of in-progress measurement
44    pub pending: Option<Vec3>,
45    /// Whether measure tool is active (polled from localStorage periodically)
46    pub active: bool,
47}
48
49/// Current selection state
50#[derive(Resource, Default)]
51pub struct SelectionState {
52    /// Currently selected entity IDs
53    pub selected: FxHashSet<u64>,
54    /// Currently hovered entity ID
55    pub hovered: Option<u64>,
56}
57
58impl SelectionState {
59    /// Check if entity is selected
60    pub fn is_selected(&self, id: u64) -> bool {
61        self.selected.contains(&id)
62    }
63
64    /// Select single entity (clears previous selection)
65    pub fn select(&mut self, id: u64) {
66        self.selected.clear();
67        self.selected.insert(id);
68        self.save();
69    }
70
71    /// Toggle selection for entity
72    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    /// Add to selection
82    pub fn add(&mut self, id: u64) {
83        self.selected.insert(id);
84        self.save();
85    }
86
87    /// Remove from selection
88    pub fn remove(&mut self, id: u64) {
89        self.selected.remove(&id);
90        self.save();
91    }
92
93    /// Clear all selection
94    pub fn clear(&mut self) {
95        self.selected.clear();
96        self.save();
97    }
98
99    /// Save to localStorage
100    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/// Picking settings
110#[derive(Resource)]
111pub struct PickingSettings {
112    /// Whether picking is enabled
113    pub enabled: bool,
114    /// Hover detection throttle (frames)
115    pub hover_throttle: u32,
116}
117
118impl Default for PickingSettings {
119    fn default() -> Self {
120        Self {
121            enabled: true,
122            hover_throttle: 3, // Check every 3 frames
123        }
124    }
125}
126
127/// Picking system - handles click selection on batched meshes
128#[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    // Use camera controller's click detection (click = press+release without drag)
144    if !camera_controller.just_clicked {
145        return;
146    }
147
148    // Reset the flag so we only process once
149    camera_controller.just_clicked = false;
150
151    let Ok((camera, camera_transform)) = cameras.single() else {
152        return;
153    };
154
155    // Use the position where the click started
156    let click_pos = camera_controller.drag_start_pos;
157
158    // Create ray from camera through click position
159    let Ok(ray) = camera.viewport_to_world(camera_transform, click_pos) else {
160        return;
161    };
162
163    // Find closest intersection in batched meshes
164    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                // Look up which entity this triangle belongs to
172                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    // Update selection based on result
184    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        // Clicked on empty space - clear selection
197        if !keyboard.pressed(KeyCode::ControlLeft) && !keyboard.pressed(KeyCode::ControlRight) {
198            selection.clear();
199        }
200    }
201}
202
203/// Hover system - detects entity under cursor using batched meshes
204#[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    // Throttle hover detection
220    *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    // Create ray from camera through cursor
237    let Ok(ray) = camera.viewport_to_world(camera_transform, cursor_pos) else {
238        return;
239    };
240
241    // Find closest intersection in batched meshes
242    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                // Look up which entity this triangle belongs to
250                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    // Update hover state
262    let new_hovered = closest.map(|(id, _)| id);
263    if selection.hovered != new_hovered {
264        selection.hovered = new_hovered;
265    }
266}
267
268/// Ray-mesh intersection with triangle index for batched mesh picking
269/// Returns (distance, triangle_index, hit_point) of the closest hit
270fn ray_mesh_intersection_with_triangle(
271    ray: &Ray3d,
272    mesh: &Mesh,
273    transform: &GlobalTransform,
274) -> Option<(f32, usize, Vec3)> {
275    // Get vertex positions
276    let positions = mesh.attribute(Mesh::ATTRIBUTE_POSITION)?.as_float3()?;
277
278    // First do a quick AABB check from vertex positions
279    let transform_matrix = transform.affine();
280    let (min, max) = compute_world_aabb(positions, &transform_matrix);
281
282    // Quick AABB rejection test
283    if !ray_aabb_intersects(ray, min, max) {
284        return None;
285    }
286
287    // Get indices
288    let indices = mesh.indices()?;
289    let indices: Vec<usize> = indices.iter().collect();
290
291    let mut closest: Option<(f32, usize, Vec3)> = None;
292
293    // Iterate through triangles
294    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
313/// Compute world-space AABB from vertex positions
314fn 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
327/// Möller–Trumbore ray-triangle intersection algorithm
328fn 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    // Ray is parallel to triangle
337    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
364/// Poll active tool from localStorage (every ~10 frames to avoid DOM thrashing)
365fn 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/// Measurement system — when "measure" tool is active, clicks add measurement points
377#[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    // Escape clears pending + all measurements
391    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    // Consume the click so picking_system doesn't also process it
403    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    // Find closest hit point
414    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        // Save point to bridge for Leptos
429        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            // Complete measurement
437            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            // Start new measurement
442            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
451/// Draw measurement lines and distance labels as gizmos
452fn 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    // Draw completed measurements
462    for (start, end) in &measurement.measurements {
463        // Main measurement line
464        gizmos.line(*start, *end, yellow);
465        // Parallel offset lines for visibility
466        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        // Endpoint markers — small spheres via circle gizmos
476        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        // Distance text via midpoint marker (larger sphere shows there's a measurement)
481        let mid = (*start + *end) / 2.0;
482        gizmos.sphere(Isometry3d::from_translation(mid), 0.03, yellow);
483    }
484
485    // Draw pending measurement (first point — pulsing crosshair)
486    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
495/// Quick ray-AABB intersection test
496fn 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}