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