Blacksite/crates/editor/src/viewport/selection.rs
Rbanh fcd34f9079
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add draw brush tool
2026-06-06 05:52:52 -04:00

410 lines
13 KiB
Rust

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<Entity>);
/// Primary click on the viewport panel (egui), consumed by [`handle_pick_events`].
#[derive(Resource, Default, Debug, Clone, Copy)]
pub struct ViewportClick(pub Option<egui::Pos2>);
/// Overlapping pick targets at the last viewport click; **Tab** cycles forward.
#[derive(Resource, Default, Debug)]
pub struct ViewportPickStack {
pub entities: Vec<Entity>,
pub index: usize,
pub anchor: Option<egui::Pos2>,
}
pub struct EditorSelectionPlugin;
#[derive(SystemParam)]
struct PickTargetQueries<'w, 's> {
level_objects: Query<'w, 's, Entity, (With<LevelObject>, Without<EditorOnly>)>,
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<EditorOnly>>,
}
impl Plugin for EditorSelectionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SelectedEntity>()
.init_resource::<ViewportClick>()
.init_resource::<ViewportPickStack>()
.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::<State<EditorMode>>().get() != EditorMode::Editing {
return;
}
let Some(selected_entity) = world.resource::<SelectedEntity>().0 else {
return;
};
if world.get::<sim::Player>(selected_entity).is_none() {
return;
}
let spawn = ensure_player_spawn_for_edit(world);
world.resource_mut::<SelectedEntity>().0 = Some(spawn);
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
ui_state.selected_entities.select_replace(spawn);
}
}
#[allow(clippy::too_many_arguments)]
fn handle_pick_events(
mut ui_state: ResMut<UiState>,
mut selected: ResMut<SelectedEntity>,
mut viewport_click: ResMut<ViewportClick>,
mut pick_stack: ResMut<ViewportPickStack>,
mut click_events: MessageReader<PointerInput>,
pointers: Query<&PointerInteraction>,
editor_cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut mesh_ray_cast: MeshRayCast,
pick_targets: PickTargetQueries,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
brush_tool: Res<BrushToolState>,
gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>,
) -> 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<UiState>,
mut selected: ResMut<SelectedEntity>,
mut pick_stack: ResMut<ViewportPickStack>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
) -> 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<EditorCamera>>,
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<Entity>,
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<EditorCamera>>,
mesh_ray_cast: &mut MeshRayCast,
pick_targets: &PickTargetQueries,
scene_rect: egui::Rect,
pointer_pos: egui::Pos2,
) -> Vec<Entity> {
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<Entity> {
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<UiState>,
mut selected: ResMut<SelectedEntity>,
mut commands: Commands,
targets: Query<Entity, With<GizmoTarget>>,
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
editor_only: Query<(), With<EditorOnly>>,
transforms: Query<(), With<Transform>>,
hierarchy: Option<Res<HierarchyPanelState>>,
display: Res<ViewportDisplayMode>,
) {
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::<GizmoTarget>();
}
return;
}
for entity in &targets {
if !ui_state.selected_entities.contains(entity) {
commands.entity(entity).remove::<GizmoTarget>();
}
}
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());
}
}
}