Skip to main content

mittens_engine/engine/ecs/system/
cursor_3d.rs

1use crate::engine::ecs::component::EditorInteractionMode;
2use crate::engine::ecs::system::editor::context::{
3    EditorContextState, ensure_shared_workspace_cursor_host, sync_editor_cursor_visual,
4};
5use crate::engine::ecs::system::editor_scene_hit::resolve_world_scene_hit;
6use crate::engine::ecs::system::paint_placement::{
7    resolve_surface_aligned_pose_from_frame, resolve_surface_placement_frame,
8};
9use crate::engine::ecs::{ComponentId, EventSignal, RxWorld, SignalKind, World};
10use crate::utils::math;
11use std::collections::HashSet;
12use std::f32::consts::FRAC_PI_2;
13use std::sync::OnceLock;
14use std::sync::{Arc, Mutex};
15
16const EDITOR_CURSOR_HANDLER_NAME: &str = "editor_cursor_3d";
17#[derive(Debug, Default)]
18pub struct Cursor3dSystem {
19    installed_editor_roots: HashSet<ComponentId>,
20}
21
22impl Cursor3dSystem {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    pub fn install_scoped_handlers_for_editor(
28        &mut self,
29        rx: &mut RxWorld,
30        editor_root: ComponentId,
31        panel_query_root: ComponentId,
32        editor_context_state: Arc<Mutex<EditorContextState>>,
33    ) {
34        if self.installed_editor_roots.contains(&editor_root) {
35            return;
36        }
37        self.installed_editor_roots.insert(editor_root);
38
39        for signal_kind in [SignalKind::Click] {
40            let scoped_editor_context_state = editor_context_state.clone();
41            rx.add_handler_closure_named(
42                signal_kind,
43                editor_root,
44                Some(EDITOR_CURSOR_HANDLER_NAME.to_string()),
45                move |world, emit, env| {
46                    handle_cursor_signal(
47                        world,
48                        emit,
49                        env.event.as_ref(),
50                        editor_root,
51                        panel_query_root,
52                        scoped_editor_context_state.clone(),
53                    );
54                },
55            );
56
57            let global_editor_context_state = editor_context_state.clone();
58            rx.add_global_handler_closure_named(
59                signal_kind,
60                Some(format!("{EDITOR_CURSOR_HANDLER_NAME}_global_{editor_root:?}")),
61                move |world, emit, env| {
62                    let Some(EventSignal::Click {
63                        renderable,
64                        hit_point,
65                        ..
66                    }) = env.event.as_ref()
67                    else {
68                        return;
69                    };
70                    let Some(scene_hit) = resolve_world_scene_hit(world, *renderable) else {
71                        if debug_cursor_3d_enabled() {
72                            eprintln!(
73                                "[cursor_3d] no scene hit renderable={:?} '{}'",
74                                renderable,
75                                debug_component_label(world, *renderable)
76                            );
77                        }
78                        return;
79                    };
80                    // Scoped handlers already cover hits within the editor subtree.
81                    // The global bridge is only for world objects outside any editor root.
82                    if scene_hit.editor_root.is_some() {
83                        return;
84                    }
85
86                    let editor_context = global_editor_context_state
87                        .lock()
88                        .expect("editor context state mutex poisoned")
89                        .clone();
90                    if editor_context
91                        .active_editor
92                        .is_some_and(|active_editor| active_editor != editor_root)
93                    {
94                        if debug_cursor_3d_enabled() {
95                            eprintln!(
96                                "[cursor_3d] reject global hit renderable={:?} '{}' scene_editor={:?} active_editor={:?} editor_root={:?}",
97                                renderable,
98                                debug_component_label(world, *renderable),
99                                scene_hit.editor_root,
100                                editor_context.active_editor,
101                                editor_root
102                            );
103                        }
104                        return;
105                    }
106
107                    update_editor_cursor(
108                        world,
109                        emit,
110                        global_editor_context_state.clone(),
111                        editor_root,
112                        panel_query_root,
113                        scene_hit.target_transform,
114                        scene_hit.target_renderable,
115                        *hit_point,
116                    );
117                },
118            );
119        }
120    }
121}
122
123fn handle_cursor_signal(
124    world: &mut World,
125    emit: &mut dyn crate::engine::ecs::SignalEmitter,
126    event: Option<&EventSignal>,
127    editor_root: ComponentId,
128    panel_query_root: ComponentId,
129    editor_context_state: Arc<Mutex<EditorContextState>>,
130) {
131    let editor_context = editor_context_state
132        .lock()
133        .expect("editor context state mutex poisoned")
134        .clone();
135
136    match event {
137        Some(EventSignal::Click {
138            renderable,
139            hit_point,
140            ..
141        }) => {
142            let Some(scene_hit) = resolve_world_scene_hit(world, *renderable) else {
143                if debug_cursor_3d_enabled() {
144                    eprintln!(
145                        "[cursor_3d] scoped no scene hit renderable={:?} '{}'",
146                        renderable,
147                        debug_component_label(world, *renderable)
148                    );
149                }
150                return;
151            };
152            let handles_non_editor_hit = scene_hit.editor_root.is_none()
153                && editor_context.active_editor == Some(editor_root);
154            if scene_hit.editor_root != Some(editor_root) && !handles_non_editor_hit {
155                if debug_cursor_3d_enabled() {
156                    eprintln!(
157                        "[cursor_3d] scoped reject renderable={:?} '{}' scene_editor={:?} active_editor={:?} editor_root={:?}",
158                        renderable,
159                        debug_component_label(world, *renderable),
160                        scene_hit.editor_root,
161                        editor_context.active_editor,
162                        editor_root
163                    );
164                }
165                return;
166            }
167            update_editor_cursor(
168                world,
169                emit,
170                editor_context_state,
171                editor_root,
172                panel_query_root,
173                scene_hit.target_transform,
174                scene_hit.target_renderable,
175                *hit_point,
176            );
177        }
178        _ => {}
179    }
180}
181
182fn update_editor_cursor(
183    world: &mut World,
184    emit: &mut dyn crate::engine::ecs::SignalEmitter,
185    editor_context_state: Arc<Mutex<EditorContextState>>,
186    editor_root: ComponentId,
187    panel_query_root: ComponentId,
188    target_transform: ComponentId,
189    target_renderable: ComponentId,
190    hit_point: [f32; 3],
191) {
192    let interaction_mode = world
193        .get_component_by_id_as::<crate::engine::ecs::component::EditorComponent>(editor_root)
194        .map(|editor| editor.interaction_mode)
195        .unwrap_or(EditorInteractionMode::Select);
196
197    let (translation, rotation, frame) = match interaction_mode {
198        EditorInteractionMode::Select => return,
199        EditorInteractionMode::Cursor3d => {
200            let frame = match resolve_surface_placement_frame(
201                world,
202                target_renderable,
203                hit_point,
204                None,
205            ) {
206                Ok(frame) => frame,
207                Err(err) => {
208                    if debug_cursor_3d_enabled() {
209                        eprintln!(
210                            "[cursor_3d] surface frame failed renderable={:?} '{}' error={err:?}",
211                            target_renderable,
212                            debug_component_label(world, target_renderable),
213                        );
214                    }
215                    return;
216                }
217            };
218            let pose = match resolve_surface_aligned_pose_from_frame(&frame, -0.25) {
219                Ok(pose) => pose,
220                Err(err) => {
221                    if debug_cursor_3d_enabled() {
222                        eprintln!(
223                            "[cursor_3d] surface pose failed renderable={:?} '{}' error={err:?}",
224                            target_renderable,
225                            debug_component_label(world, target_renderable),
226                        );
227                    }
228                    return;
229                }
230            };
231            (
232                pose.translation,
233                remap_cursor_rotation_to_surface_up(pose.rotation),
234                Some(frame),
235            )
236        }
237        EditorInteractionMode::SelectAndCursor => {
238            let Some(model) = authored_world_model(world, target_transform) else {
239                if debug_cursor_3d_enabled() {
240                    eprintln!(
241                        "[cursor_3d] authored world model missing transform={:?} '{}'",
242                        target_transform,
243                        debug_component_label(world, target_transform),
244                    );
245                }
246                return;
247            };
248            (
249                [model[3][0], model[3][1], model[3][2]],
250                math::mat_to_quat(model),
251                None,
252            )
253        }
254    };
255
256    {
257        let mut editor_context = editor_context_state
258            .lock()
259            .expect("editor context state mutex poisoned");
260        editor_context.active_editor = Some(editor_root);
261        editor_context.interaction_mode = interaction_mode;
262        editor_context.cursor_translation = Some(translation);
263        editor_context.cursor_rotation = Some(rotation);
264        editor_context.cursor_frame = frame;
265    }
266    if debug_cursor_3d_enabled() {
267        eprintln!(
268            "[cursor_3d] update editor={:?} target_transform={:?} '{}' target_renderable={:?} '{}' translation=[{:+.3},{:+.3},{:+.3}]",
269            editor_root,
270            target_transform,
271            debug_component_label(world, target_transform),
272            target_renderable,
273            debug_component_label(world, target_renderable),
274            translation[0],
275            translation[1],
276            translation[2]
277        );
278    }
279
280    let cursor_host = ensure_shared_workspace_cursor_host(world, Some(panel_query_root));
281    sync_editor_cursor_visual(world, emit, &editor_context_state, cursor_host);
282}
283
284fn debug_cursor_3d_enabled() -> bool {
285    static ENABLED: OnceLock<bool> = OnceLock::new();
286    *ENABLED.get_or_init(|| {
287        let v = std::env::var("CAT_DEBUG_CURSOR_3D").unwrap_or_default();
288        matches!(
289            v.trim().to_ascii_lowercase().as_str(),
290            "1" | "true" | "yes" | "on"
291        )
292    })
293}
294
295fn debug_component_label(world: &World, component: ComponentId) -> String {
296    world
297        .get_component_record(component)
298        .map(|n| {
299            if n.name.is_empty() {
300                n.component_type.clone()
301            } else {
302                format!("{}: {}", n.component_type, n.name)
303            }
304        })
305        .unwrap_or_else(|| "<missing>".to_string())
306}
307
308fn remap_cursor_rotation_to_surface_up(surface_aligned_rotation: [f32; 4]) -> [f32; 4] {
309    let z_to_y = math::quat_from_axis_angle([1.0, 0.0, 0.0], FRAC_PI_2);
310    math::quat_mul(surface_aligned_rotation, z_to_y)
311}
312
313fn authored_world_model(
314    world: &World,
315    component: ComponentId,
316) -> Option<crate::engine::graphics::primitives::TransformMatrix> {
317    let mut chain = Vec::new();
318
319    if world
320        .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(component)
321        .is_some()
322    {
323        chain.push(component);
324    }
325
326    let mut current = component;
327    while let Some(parent) = world.parent_of(current) {
328        if world
329            .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(parent)
330            .is_some()
331        {
332            chain.push(parent);
333        }
334        current = parent;
335    }
336
337    let mut iter = chain.into_iter().rev();
338    let first = iter.next()?;
339    let mut world_model = world
340        .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(first)?
341        .transform
342        .model;
343
344    for transform_id in iter {
345        let local = world
346            .get_component_by_id_as::<crate::engine::ecs::component::TransformComponent>(
347                transform_id,
348            )?
349            .transform
350            .model;
351        world_model = math::mat4_mul(world_model, local);
352    }
353
354    Some(world_model)
355}
356
357#[cfg(test)]
358mod tests {
359    use super::Cursor3dSystem;
360    use crate::engine::ecs::command_queue::CommandQueue;
361    use crate::engine::ecs::component::{
362        EditorComponent, EditorInteractionMode, RenderableComponent, TransformComponent,
363    };
364    use crate::engine::ecs::system::editor::context::EditorContextSystem;
365    use crate::engine::ecs::system::editor_system::EditorSystem;
366    use crate::engine::ecs::{EventSignal, SystemWorld, World};
367    use crate::engine::graphics::{RenderAssets, VisualWorld};
368    use crate::utils::math::quat_rotate_vec3;
369
370    #[test]
371    fn cursor_mode_places_cursor_without_selecting() {
372        let mut world = World::default();
373        let mut emit = CommandQueue::new();
374        let mut visuals = VisualWorld::default();
375        let mut render_assets = RenderAssets::new();
376        let mut systems = SystemWorld::new();
377        let mut context_system = EditorContextSystem::new();
378        let mut cursor_system = Cursor3dSystem::new();
379
380        let panel_root =
381            world.add_component_boxed_named("panel_root", Box::new(TransformComponent::new()));
382        let editor_root = world.add_component_boxed_named(
383            "editor_root",
384            Box::new(EditorComponent::new().with_interaction_mode(EditorInteractionMode::Cursor3d)),
385        );
386        context_system.install_scoped_handlers_for_editor(
387            &mut systems.rx,
388            &mut world,
389            editor_root,
390            panel_root,
391        );
392        assert!(
393            world
394                .all_components()
395                .all(|id| world.component_label(id) != Some("editor_cursor_marker")),
396            "bootstrap must not create a cursor marker with the no-op emitter"
397        );
398        let context = context_system.shared_state();
399        let scene_root =
400            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
401        let renderable =
402            world.add_component_boxed_named("cube", Box::new(RenderableComponent::cube()));
403        let _ = world.add_child(editor_root, scene_root);
404        let _ = world.add_child(scene_root, renderable);
405        cursor_system.install_scoped_handlers_for_editor(
406            &mut systems.rx,
407            editor_root,
408            panel_root,
409            context.clone(),
410        );
411
412        systems.rx.push_event(
413            renderable,
414            EventSignal::Click {
415                raycaster: renderable,
416                renderable,
417                hit_point: [0.5, 0.0, 0.0],
418                screen_pos_px: None,
419            },
420        );
421        let _ = systems.process_signals(
422            &mut world,
423            &mut visuals,
424            &mut render_assets,
425            &mut emit,
426            10_000,
427        );
428
429        let state = context
430            .lock()
431            .expect("editor context mutex poisoned")
432            .clone();
433        assert_eq!(state.active_editor, Some(editor_root));
434        assert_eq!(state.selected_component, Some(editor_root));
435        assert_eq!(state.interaction_mode, EditorInteractionMode::Cursor3d);
436        assert!(state.cursor_translation.is_some());
437        assert!(state.cursor_rotation.is_some());
438        assert!(
439            world
440                .all_components()
441                .any(|id| world.component_label(id) == Some("editor_cursor_marker")),
442            "first live cursor placement should create the marker"
443        );
444        assert_eq!(
445            world
446                .get_component_by_id_as::<EditorComponent>(editor_root)
447                .expect("editor")
448                .selected,
449            None
450        );
451        let cursor_up = quat_rotate_vec3(
452            state.cursor_rotation.expect("cursor rotation"),
453            [0.0, 1.0, 0.0],
454        );
455        assert!((cursor_up[0] - 1.0).abs() < 1e-5);
456        assert!(cursor_up[1].abs() < 1e-5);
457        assert!(cursor_up[2].abs() < 1e-5);
458    }
459
460    #[test]
461    fn select_and_cursor_mode_performs_both_actions() {
462        let mut world = World::default();
463        let mut emit = CommandQueue::new();
464        let mut visuals = VisualWorld::default();
465        let mut render_assets = RenderAssets::new();
466        let mut systems = SystemWorld::new();
467        let mut context_system = EditorContextSystem::new();
468        let mut cursor_system = Cursor3dSystem::new();
469        let mut editor_system = EditorSystem::new();
470
471        let panel_root =
472            world.add_component_boxed_named("panel_root", Box::new(TransformComponent::new()));
473        let editor_root = world.add_component_boxed_named(
474            "editor_root",
475            Box::new(
476                EditorComponent::new()
477                    .with_interaction_mode(EditorInteractionMode::SelectAndCursor),
478            ),
479        );
480        context_system.install_scoped_handlers_for_editor(
481            &mut systems.rx,
482            &mut world,
483            editor_root,
484            panel_root,
485        );
486        let context = context_system.shared_state();
487        let scene_root = world.add_component_boxed_named(
488            "scene_root",
489            Box::new(
490                TransformComponent::new()
491                    .with_position(1.25, 2.5, -3.75)
492                    .with_rotation_quat([0.0, 0.38268343, 0.0, 0.9238795]),
493            ),
494        );
495        let renderable =
496            world.add_component_boxed_named("plane", Box::new(RenderableComponent::plane()));
497        let _ = world.add_child(editor_root, scene_root);
498        let _ = world.add_child(scene_root, renderable);
499        editor_system.install_scoped_handlers_for_editor(
500            &mut systems.rx,
501            editor_root,
502            panel_root,
503            context.clone(),
504        );
505        cursor_system.install_scoped_handlers_for_editor(
506            &mut systems.rx,
507            editor_root,
508            panel_root,
509            context.clone(),
510        );
511
512        systems.rx.push_event(
513            renderable,
514            EventSignal::Click {
515                raycaster: renderable,
516                renderable,
517                hit_point: [0.0, 0.0, 0.0],
518                screen_pos_px: None,
519            },
520        );
521
522        let _ = systems.process_signals(
523            &mut world,
524            &mut visuals,
525            &mut render_assets,
526            &mut emit,
527            10_000,
528        );
529
530        let state = context
531            .lock()
532            .expect("editor context mutex poisoned")
533            .clone();
534        assert_eq!(state.active_editor, Some(editor_root));
535        assert_eq!(
536            state.interaction_mode,
537            EditorInteractionMode::SelectAndCursor
538        );
539        assert_eq!(state.cursor_translation, Some([1.25, 2.5, -3.75]));
540        let rotation = state.cursor_rotation.expect("cursor rotation");
541        assert!((rotation[0] - 0.0).abs() < 1e-6);
542        assert!((rotation[1] - 0.38268343).abs() < 1e-6);
543        assert!((rotation[2] - 0.0).abs() < 1e-6);
544        assert!((rotation[3] - 0.9238795).abs() < 1e-6);
545        assert_eq!(state.cursor_frame, None);
546    }
547
548    #[test]
549    fn cursor_mode_places_cursor_on_world_cube_outside_editor() {
550        let mut world = World::default();
551        let mut emit = CommandQueue::new();
552        let mut visuals = VisualWorld::default();
553        let mut render_assets = RenderAssets::new();
554        let mut systems = SystemWorld::new();
555        let mut context_system = EditorContextSystem::new();
556        let mut cursor_system = Cursor3dSystem::new();
557
558        let panel_root =
559            world.add_component_boxed_named("panel_root", Box::new(TransformComponent::new()));
560        let editor_root = world.add_component_boxed_named(
561            "editor_root",
562            Box::new(EditorComponent::new().with_interaction_mode(EditorInteractionMode::Cursor3d)),
563        );
564        context_system.install_scoped_handlers_for_editor(
565            &mut systems.rx,
566            &mut world,
567            editor_root,
568            panel_root,
569        );
570        let context = context_system.shared_state();
571        let world_transform =
572            world.add_component_boxed_named("terrain_cube", Box::new(TransformComponent::new()));
573        let renderable = world
574            .add_component_boxed_named("terrain_cube_mesh", Box::new(RenderableComponent::cube()));
575        let _ = world.add_child(world_transform, renderable);
576        cursor_system.install_scoped_handlers_for_editor(
577            &mut systems.rx,
578            editor_root,
579            panel_root,
580            context.clone(),
581        );
582
583        systems.rx.push_event(
584            renderable,
585            EventSignal::Click {
586                raycaster: renderable,
587                renderable,
588                hit_point: [0.5, 0.0, 0.0],
589                screen_pos_px: None,
590            },
591        );
592        let _ = systems.process_signals(
593            &mut world,
594            &mut visuals,
595            &mut render_assets,
596            &mut emit,
597            10_000,
598        );
599
600        let state = context.lock().expect("editor context mutex poisoned");
601        assert_eq!(state.active_editor, Some(editor_root));
602        assert!(state.cursor_translation.is_some());
603        assert!(state.cursor_rotation.is_some());
604        assert!(
605            world
606                .all_components()
607                .any(|id| world.component_label(id) == Some("editor_cursor_marker"))
608        );
609    }
610}