use bevy::ecs::system::SystemParam; use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility}; use bevy::picking::pointer::{PointerAction, PointerInput, PointerInteraction}; use bevy::prelude::*; use shared::LevelObject; use transform_gizmo_bevy::prelude::GizmoTarget; use crate::camera::EditorCamera; use crate::infra::EditorOnly; use crate::state::scene_tools_active; use crate::state::EditorMode; use crate::ui::helpers::ensure_player_spawn_for_edit; use crate::ui::hierarchy_ops::is_entity_locked; use crate::ui::hierarchy_state::HierarchyPanelState; use crate::ui::UiState; use crate::viewport::actor_icons::ActorIconProxy; use crate::viewport::brush_tool::BrushToolState; use crate::viewport::ViewportDisplayMode; use crate::visualizers::EditorVisualizerProxy; use bevy_egui::egui; use crate::viewport::scene_view_ray; #[derive(Resource, Default, Debug, Clone, Copy)] pub struct SelectedEntity(pub Option); /// Primary click on the viewport panel (egui), consumed by [`handle_pick_events`]. #[derive(Resource, Default, Debug, Clone, Copy)] pub struct ViewportClick(pub Option); /// Overlapping pick targets at the last viewport click; **Tab** cycles forward. #[derive(Resource, Default, Debug)] pub struct ViewportPickStack { pub entities: Vec, pub index: usize, pub anchor: Option, } pub struct EditorSelectionPlugin; #[derive(SystemParam)] struct PickTargetQueries<'w, 's> { level_objects: Query<'w, 's, Entity, (With, Without)>, proxies: Query<'w, 's, &'static EditorVisualizerProxy>, icon_proxies: Query<'w, 's, &'static ActorIconProxy>, parents: Query<'w, 's, &'static ChildOf>, editor_only: Query<'w, 's, (), With>, } impl Plugin for EditorSelectionPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .init_resource::() .add_systems( Update, ( redirect_runtime_player_selection_to_spawn, sync_gizmo_targets, ) .chain() .run_if(scene_tools_active), ) // After transform-gizmo `update_gizmos` (Last) so clicks hit gizmos first. .add_systems( Last, (handle_pick_events, cycle_overlapping_viewport_pick) .chain() .run_if(scene_tools_active), ); } } fn redirect_runtime_player_selection_to_spawn(world: &mut World) { if *world.resource::>().get() != EditorMode::Editing { return; } let Some(selected_entity) = world.resource::().0 else { return; }; if world.get::(selected_entity).is_none() { return; } let spawn = ensure_player_spawn_for_edit(world); world.resource_mut::().0 = Some(spawn); if let Some(mut ui_state) = world.get_resource_mut::() { ui_state.selected_entities.select_replace(spawn); } } #[allow(clippy::too_many_arguments)] fn handle_pick_events( mut ui_state: ResMut, mut selected: ResMut, mut viewport_click: ResMut, mut pick_stack: ResMut, mut click_events: MessageReader, pointers: Query<&PointerInteraction>, editor_cameras: Query<(&Camera, &GlobalTransform), With>, mut mesh_ray_cast: MeshRayCast, pick_targets: PickTargetQueries, keys: Res>, buttons: Res>, display: Res, brush_tool: Res, gizmo_targets: Query<&GizmoTarget>, hierarchy: Option>, ) -> Result { if ui_state.pointer_in_viewport && !buttons.any_pressed([MouseButton::Right, MouseButton::Middle]) && keys.just_pressed(KeyCode::Escape) { ui_state.selected_entities.clear(); selected.0 = None; pick_stack.entities.clear(); pick_stack.index = 0; pick_stack.anchor = None; return Ok(()); } if display.clean_game_view { viewport_click.0 = None; return Ok(()); } if brush_tool.active { return Ok(()); } if gizmo_targets .iter() .any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g)) { viewport_click.0 = None; return Ok(()); } let additive = keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]); if let Some(pointer_pos) = viewport_click.0.take() { apply_viewport_pick( hierarchy.as_deref(), &mut ui_state, &mut selected, &mut pick_stack, &editor_cameras, &mut mesh_ray_cast, &pick_targets, pointer_pos, additive, ); return Ok(()); } if !ui_state.pointer_in_viewport { return Ok(()); } for event in click_events.read() { if !matches!(event.action, PointerAction::Press(PointerButton::Primary)) { continue; } let mut picked = None; for interaction in &pointers { if let Some(entity) = interaction .as_slice() .iter() .find_map(|(entity, _hit)| pick_target_for_entity(*entity, &pick_targets)) { picked = Some(entity); break; } } if picked.is_none() { let Some(pointer_pos) = ui_state.viewport_pointer_pos else { continue; }; let hits = collect_viewport_pick_hits( &editor_cameras, &mut mesh_ray_cast, &pick_targets, ui_state.viewport_rect, pointer_pos, ); pick_stack.entities = hits; pick_stack.index = 0; pick_stack.anchor = Some(pointer_pos); picked = pick_stack.entities.first().copied(); } else { pick_stack.entities.clear(); pick_stack.index = 0; pick_stack.anchor = ui_state.viewport_pointer_pos; } apply_pick_result( hierarchy.as_deref(), &mut ui_state, &mut selected, picked, additive, ); } Ok(()) } fn cycle_overlapping_viewport_pick( mut ui_state: ResMut, mut selected: ResMut, mut pick_stack: ResMut, keys: Res>, buttons: Res>, display: Res, ) -> Result { if display.clean_game_view { return Ok(()); } if !ui_state.pointer_in_viewport || buttons.any_pressed([MouseButton::Right, MouseButton::Middle]) { return Ok(()); } if !keys.just_pressed(KeyCode::Tab) || pick_stack.entities.len() < 2 { return Ok(()); } if keys.any_pressed([KeyCode::ControlLeft, KeyCode::ShiftLeft]) { return Ok(()); } pick_stack.index = (pick_stack.index + 1) % pick_stack.entities.len(); let entity = pick_stack.entities[pick_stack.index]; ui_state.selected_entities.select_replace(entity); selected.0 = Some(entity); Ok(()) } fn apply_viewport_pick( hierarchy: Option<&HierarchyPanelState>, ui_state: &mut UiState, selected: &mut SelectedEntity, pick_stack: &mut ViewportPickStack, editor_cameras: &Query<(&Camera, &GlobalTransform), With>, mesh_ray_cast: &mut MeshRayCast, pick_targets: &PickTargetQueries, pointer_pos: egui::Pos2, additive: bool, ) { let hits = collect_viewport_pick_hits( editor_cameras, mesh_ray_cast, pick_targets, ui_state.viewport_rect, pointer_pos, ); pick_stack.entities = hits; pick_stack.index = 0; pick_stack.anchor = Some(pointer_pos); let picked = pick_stack.entities.first().copied(); apply_pick_result(hierarchy, ui_state, selected, picked, additive); } fn apply_pick_result( hierarchy: Option<&HierarchyPanelState>, ui_state: &mut UiState, selected: &mut SelectedEntity, picked: Option, additive: bool, ) { if let Some(entity) = picked { if hierarchy.is_some_and(|state| is_entity_locked(&state.locked, entity)) { return; } ui_state .selected_entities .select_maybe_add(entity, additive); selected.0 = Some(entity); } else if !additive { ui_state.selected_entities.clear(); selected.0 = None; } } fn collect_viewport_pick_hits( editor_cameras: &Query<(&Camera, &GlobalTransform), With>, mesh_ray_cast: &mut MeshRayCast, pick_targets: &PickTargetQueries, scene_rect: egui::Rect, pointer_pos: egui::Pos2, ) -> Vec { if !scene_rect.contains(pointer_pos) { return Vec::new(); } let (camera, transform) = match editor_cameras .iter() .find(|(camera, _)| camera.is_active) .or_else(|| editor_cameras.iter().next()) { Some(pair) => pair, None => return Vec::new(), }; let Some(ray) = scene_view_ray(camera, transform, pointer_pos, scene_rect) else { return Vec::new(); }; let filter = |entity: Entity| pick_target_for_entity(entity, pick_targets).is_some(); let settings = MeshRayCastSettings::default() .with_visibility(RayCastVisibility::Any) .with_filter(&filter) .never_early_exit(); let hits = mesh_ray_cast.cast_ray(ray, &settings); let mut seen = std::collections::HashSet::new(); let mut ordered = Vec::new(); for icon_priority in [true, false] { for (entity, _) in hits { let is_icon = pick_targets.icon_proxies.get(*entity).is_ok(); if is_icon != icon_priority { continue; } let Some(target) = pick_target_for_entity(*entity, pick_targets) else { continue; }; if seen.insert(target) { ordered.push(target); } } } ordered } fn pick_target_for_entity(entity: Entity, pick_targets: &PickTargetQueries) -> Option { if let Ok(proxy) = pick_targets.icon_proxies.get(entity) { return pick_targets .level_objects .contains(proxy.target) .then_some(proxy.target); } if let Ok(proxy) = pick_targets.proxies.get(entity) { return Some(proxy.target); } if pick_targets.editor_only.contains(entity) { return None; } if pick_targets.level_objects.contains(entity) { return Some(entity); } let mut current = pick_targets .parents .get(entity) .ok() .map(|parent| parent.parent()); while let Some(parent) = current { if pick_targets.editor_only.contains(parent) { return None; } if pick_targets.level_objects.contains(parent) { return Some(parent); } current = pick_targets .parents .get(parent) .ok() .map(|parent| parent.parent()); } None } fn sync_gizmo_targets( mut ui_state: ResMut, mut selected: ResMut, mut commands: Commands, targets: Query>, level_objects: Query<(), (With, Without)>, editor_only: Query<(), With>, transforms: Query<(), With>, hierarchy: Option>, display: Res, ) { ui_state .selected_entities .retain(|entity| transforms.contains(entity) && !editor_only.contains(entity)); selected.0 = ui_state.selected_entities.as_slice().first().copied(); if display.clean_game_view { for entity in &targets { commands.entity(entity).remove::(); } return; } for entity in &targets { if !ui_state.selected_entities.contains(entity) { commands.entity(entity).remove::(); } } if let Some(entity) = ui_state.selected_entities.as_slice().first().copied() { if level_objects.contains(entity) && transforms.contains(entity) && !targets.contains(entity) && !hierarchy .as_ref() .is_some_and(|state| is_entity_locked(&state.locked, entity)) { commands.entity(entity).insert(GizmoTarget::default()); } } }