Skip to main content

mittens_engine/engine/ecs/system/
editor_system.rs

1use crate::engine::ecs::component::{
2    EditorComponent, EditorInteractionMode, RaycastableComponent, SelectableComponent,
3    SerializeComponent,
4};
5use crate::engine::ecs::system::editor::context::{
6    EditorContextState, ensure_shared_workspace_transform_gizmo_global,
7};
8use crate::engine::ecs::system::editor_scene_hit::resolve_world_scene_hit;
9use crate::engine::ecs::{ComponentId, EventSignal, IntentValue, RxWorld, SignalKind, World};
10use std::collections::HashSet;
11use std::sync::OnceLock;
12use std::sync::{Arc, Mutex};
13
14const EDITOR_SELECT_HANDLER_NAME: &str = "editor_select";
15
16#[derive(Debug, Default)]
17pub struct EditorSystem {
18    installed_editor_roots: HashSet<ComponentId>,
19}
20
21impl EditorSystem {
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Install gesture/picking handlers scoped to a specific editor root subtree.
27    ///
28    /// This allows multiple EditorComponent subtrees, each with independent selection/gizmo state.
29    pub fn install_scoped_handlers_for_editor(
30        &mut self,
31        rx: &mut RxWorld,
32        editor_root: ComponentId,
33        _panel_query_root: ComponentId,
34        editor_context_state: Arc<Mutex<EditorContextState>>,
35    ) {
36        if self.installed_editor_roots.contains(&editor_root) {
37            return;
38        }
39        self.installed_editor_roots.insert(editor_root);
40
41        let scoped_editor_context_state = editor_context_state.clone();
42        rx.add_handler_closure_named(
43            SignalKind::Click,
44            editor_root,
45            Some(EDITOR_SELECT_HANDLER_NAME.to_string()),
46            move |world, emit, env| {
47                let Some(EventSignal::Click { renderable, .. }) = env.event.as_ref() else {
48                    return;
49                };
50
51                let Some(scene_hit) = resolve_world_scene_hit(world, *renderable) else {
52                    if debug_editor_select_enabled() {
53                        eprintln!(
54                            "[editor_select] no scene hit renderable={:?} '{}'",
55                            renderable,
56                            debug_component_label(world, *renderable)
57                        );
58                    }
59                    return;
60                };
61                let editor_context = scoped_editor_context_state
62                    .lock()
63                    .expect("editor context state mutex poisoned")
64                    .clone();
65                let handles_non_editor_hit = scene_hit.editor_root.is_none()
66                    && editor_context.active_editor == Some(editor_root);
67                if scene_hit.editor_root != Some(editor_root) && !handles_non_editor_hit {
68                    if debug_editor_select_enabled() {
69                        eprintln!(
70                            "[editor_select] reject renderable={:?} '{}' scene_editor={:?} active_editor={:?} editor_root={:?}",
71                            renderable,
72                            debug_component_label(world, *renderable),
73                            scene_hit.editor_root,
74                            editor_context.active_editor,
75                            editor_root
76                        );
77                    }
78                    return;
79                }
80                if debug_editor_select_enabled() {
81                    eprintln!(
82                        "[editor_select] select renderable={:?} '{}' target_transform={:?} '{}' mode={:?}",
83                        renderable,
84                        debug_component_label(world, *renderable),
85                        scene_hit.target_transform,
86                        debug_component_label(world, scene_hit.target_transform),
87                        editor_interaction_mode(world, editor_root)
88                    );
89                }
90
91                let interaction_mode = editor_interaction_mode(world, editor_root);
92                match interaction_mode {
93                    EditorInteractionMode::Select => {
94                        select_editor_target(
95                            world,
96                            emit,
97                            editor_root,
98                            scene_hit.target_transform,
99                            true,
100                        );
101                    }
102                    EditorInteractionMode::Cursor3d => {}
103                    EditorInteractionMode::SelectAndCursor => {
104                        select_editor_target(
105                            world,
106                            emit,
107                            editor_root,
108                            scene_hit.target_transform,
109                            true,
110                        );
111                    }
112                }
113            },
114        );
115
116        let global_editor_context_state = editor_context_state.clone();
117        rx.add_global_handler_closure_named(
118            SignalKind::Click,
119            Some(format!("{EDITOR_SELECT_HANDLER_NAME}_global_{editor_root:?}")),
120            move |world, emit, env| {
121                let Some(EventSignal::Click { renderable, .. }) = env.event.as_ref() else {
122                    return;
123                };
124
125                let Some(scene_hit) = resolve_world_scene_hit(world, *renderable) else {
126                    return;
127                };
128                if scene_hit.editor_root.is_some() {
129                    return;
130                }
131
132                let editor_context = global_editor_context_state
133                    .lock()
134                    .expect("editor context state mutex poisoned")
135                    .clone();
136                if editor_context
137                    .active_editor
138                    .is_some_and(|active_editor| active_editor != editor_root)
139                {
140                    return;
141                }
142
143                if debug_editor_select_enabled() {
144                    eprintln!(
145                        "[editor_select] global select renderable={:?} '{}' target_transform={:?} '{}'",
146                        renderable,
147                        debug_component_label(world, *renderable),
148                        scene_hit.target_transform,
149                        debug_component_label(world, scene_hit.target_transform),
150                    );
151                }
152
153                match editor_interaction_mode(world, editor_root) {
154                    EditorInteractionMode::Select | EditorInteractionMode::SelectAndCursor => {
155                        select_editor_target(
156                            world,
157                            emit,
158                            editor_root,
159                            scene_hit.target_transform,
160                            true,
161                        );
162                    }
163                    EditorInteractionMode::Cursor3d => {}
164                }
165            },
166        );
167    }
168
169    /// Materialize editor-default pickability by wrapping each current immediate child of the
170    /// editor root in a single `RaycastableComponent::enabled()` ancestor, unless that subtree
171    /// root already explicitly opts in or out.
172    pub fn materialize_editor_raycastables(
173        &mut self,
174        world: &mut World,
175        emit: &mut dyn crate::engine::ecs::SignalEmitter,
176        editor_root: ComponentId,
177    ) {
178        let children: Vec<ComponentId> = world.children_of(editor_root).to_vec();
179
180        for child in children {
181            if subtree_root_has_explicit_raycastable(world, child)
182                || subtree_root_has_selectable_off(world, child)
183            {
184                continue;
185            }
186
187            let wrapper = world.add_component_boxed_named(
188                "editor_auto_raycastable",
189                Box::new(RaycastableComponent::enabled()),
190            );
191            let wrapper_serialize = world.add_component_boxed_named(
192                "editor_auto_raycastable_serialize",
193                Box::new(SerializeComponent::off()),
194            );
195
196            if world.add_child(editor_root, wrapper).is_err() {
197                continue;
198            }
199            let _ = world.add_child(wrapper, wrapper_serialize);
200            if world.add_child(wrapper, child).is_err() {
201                let _ = world.remove_component_subtree(wrapper);
202                continue;
203            }
204
205            world.init_component_tree(wrapper, emit);
206        }
207    }
208}
209
210fn debug_editor_select_enabled() -> bool {
211    static ENABLED: OnceLock<bool> = OnceLock::new();
212    *ENABLED.get_or_init(|| {
213        let v = std::env::var("CAT_DEBUG_EDITOR_SELECT").unwrap_or_default();
214        matches!(
215            v.trim().to_ascii_lowercase().as_str(),
216            "1" | "true" | "yes" | "on"
217        )
218    })
219}
220
221fn debug_component_label(world: &World, component: ComponentId) -> String {
222    world
223        .get_component_record(component)
224        .map(|n| {
225            if n.name.is_empty() {
226                n.component_type.clone()
227            } else {
228                format!("{}: {}", n.component_type, n.name)
229            }
230        })
231        .unwrap_or_else(|| "<missing>".to_string())
232}
233
234fn subtree_root_has_explicit_raycastable(world: &World, node: ComponentId) -> bool {
235    world
236        .get_component_by_id_as::<RaycastableComponent>(node)
237        .is_some()
238}
239
240fn subtree_root_has_selectable_off(world: &World, node: ComponentId) -> bool {
241    world
242        .get_component_by_id_as::<SelectableComponent>(node)
243        .map(|s| !s.enabled)
244        .unwrap_or(false)
245}
246
247fn editor_interaction_mode(world: &World, editor_root: ComponentId) -> EditorInteractionMode {
248    world
249        .get_component_by_id_as::<EditorComponent>(editor_root)
250        .map(|editor| editor.interaction_mode)
251        .unwrap_or(EditorInteractionMode::Select)
252}
253
254pub(crate) fn select_editor_target(
255    world: &mut World,
256    emit: &mut dyn crate::engine::ecs::SignalEmitter,
257    editor_root: ComponentId,
258    target_transform: ComponentId,
259    update_repl_cwd: bool,
260) {
261    let interaction_mode = world
262        .get_component_by_id_as::<EditorComponent>(editor_root)
263        .map(|editor| editor.interaction_mode)
264        .unwrap_or(EditorInteractionMode::Select);
265    eprintln!(
266        "🧲🛠️🐛 select_editor_target called editor_root={editor_root:?} target_transform={target_transform:?} mode={interaction_mode:?} update_repl_cwd={update_repl_cwd}"
267    );
268
269    let gizmo = ensure_shared_workspace_transform_gizmo_global(world, emit);
270
271    if let Some(gizmo) = gizmo {
272        emit.push_intent_now(
273            editor_root,
274            IntentValue::Attach {
275                parents: vec![target_transform],
276                child: gizmo,
277            },
278        );
279    }
280
281    if let Some(ed) = world.get_component_by_id_as_mut::<EditorComponent>(editor_root) {
282        ed.selected = Some(target_transform);
283    }
284    emit.push_event(
285        editor_root,
286        EventSignal::SelectionChanged {
287            selection_root: editor_root,
288            mode: crate::engine::ecs::component::SelectionMode::Single,
289            selected_entries: vec![crate::engine::ecs::component::SelectionEntry {
290                index: None,
291                component: target_transform,
292            }],
293            selected_component: Some(target_transform),
294            selected_payload: Some(target_transform),
295        },
296    );
297
298    if update_repl_cwd {
299        if let Some(node) = world.get_component_node(target_transform) {
300            emit.push_intent_now(
301                editor_root,
302                IntentValue::ReplExec {
303                    command: format!("cd {}", node.guid),
304                },
305            );
306            emit.push_intent_now(
307                editor_root,
308                IntentValue::ReplExec {
309                    command: "pwd".to_string(),
310                },
311            );
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::EditorSystem;
319    use crate::engine::ecs::command_queue::CommandQueue;
320    use crate::engine::ecs::component::{
321        EditorComponent, EditorInteractionMode, GLTFComponent, RenderableComponent,
322        TransformComponent,
323    };
324    use crate::engine::ecs::system::BvhSystem;
325    use crate::engine::ecs::system::editor::context::EditorContextState;
326    use crate::engine::ecs::{EventSignal, SystemWorld, World};
327    use crate::engine::graphics::{RenderAssets, VisualWorld};
328    use std::sync::{Arc, Mutex};
329
330    #[test]
331    fn select_mode_selects_target_without_moving_cursor() {
332        let mut world = World::default();
333        let mut emit = CommandQueue::new();
334        let mut visuals = VisualWorld::default();
335        let mut render_assets = RenderAssets::new();
336        let mut systems = SystemWorld::new();
337        let mut editor_system = EditorSystem::new();
338
339        let panel_root =
340            world.add_component_boxed_named("panel_root", Box::new(TransformComponent::new()));
341        let editor_root = world.add_component_boxed_named(
342            "editor_root",
343            Box::new(EditorComponent::new().with_interaction_mode(EditorInteractionMode::Select)),
344        );
345        let scene_root =
346            world.add_component_boxed_named("scene_root", Box::new(TransformComponent::new()));
347        let renderable =
348            world.add_component_boxed_named("plane", Box::new(RenderableComponent::plane()));
349        let _ = world.add_child(editor_root, scene_root);
350        let _ = world.add_child(scene_root, renderable);
351
352        let context = Arc::new(Mutex::new(EditorContextState::default()));
353        editor_system.install_scoped_handlers_for_editor(
354            &mut systems.rx,
355            editor_root,
356            panel_root,
357            context.clone(),
358        );
359
360        systems.rx.push_event(
361            renderable,
362            EventSignal::Click {
363                raycaster: renderable,
364                renderable,
365                hit_point: [0.0, 0.0, 0.0],
366                screen_pos_px: None,
367            },
368        );
369
370        let _ = systems.process_signals(
371            &mut world,
372            &mut visuals,
373            &mut render_assets,
374            &mut emit,
375            10_000,
376        );
377
378        let state = context
379            .lock()
380            .expect("editor context mutex poisoned")
381            .clone();
382        assert_eq!(state.active_editor, None);
383        assert_eq!(state.selected_component, None);
384        assert_eq!(state.cursor_translation, None);
385        assert_eq!(
386            world
387                .get_component_by_id_as::<EditorComponent>(editor_root)
388                .expect("editor")
389                .selected,
390            Some(scene_root)
391        );
392    }
393
394    #[test]
395    fn materialize_editor_raycastables_covers_gltf_branches() {
396        let mut world = World::default();
397        let mut emit = CommandQueue::new();
398        let mut editor_system = EditorSystem::new();
399
400        let editor_root = world.add_component_boxed_named(
401            "editor_root",
402            Box::new(EditorComponent::new().with_interaction_mode(EditorInteractionMode::Select)),
403        );
404        let gltf_anchor =
405            world.add_component_boxed_named("gltf_anchor", Box::new(TransformComponent::new()));
406        let gltf = world.add_component_boxed_named(
407            "gltf_component",
408            Box::new(GLTFComponent::new("assets/models/test.glb")),
409        );
410        let spawned_node =
411            world.add_component_boxed_named("spawned_node", Box::new(TransformComponent::new()));
412        let renderable = world
413            .add_component_boxed_named("mesh_renderable", Box::new(RenderableComponent::cube()));
414
415        let _ = world.add_child(editor_root, gltf_anchor);
416        let _ = world.add_child(gltf_anchor, gltf);
417        let _ = world.add_child(gltf_anchor, spawned_node);
418        let _ = world.add_child(spawned_node, renderable);
419
420        editor_system.materialize_editor_raycastables(&mut world, &mut emit, editor_root);
421
422        assert!(
423            BvhSystem::renderable_is_raycastable(&world, renderable),
424            "expected GLTF-backed editor renderables to inherit the editor auto-raycastable wrapper"
425        );
426    }
427}