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 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}