diff --git a/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs b/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs index dea404b6..b8ec8a94 100644 --- a/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs +++ b/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs @@ -1,6 +1,6 @@ use bevy::ecs::system::SystemParam; use bevy::prelude::*; -use bevy_editor_core::selection::SelectedEntity; +use bevy_editor_core::selection::EditorSelection; use bevy_render::primitives::Aabb; #[derive(SystemParam)] @@ -68,7 +68,7 @@ fn draw_fallback_selection_box(gizmos: &mut Gizmos, global_transform: &GlobalTra pub fn selection_box_system( show: Res, - selected_entity: Res, + selection: Res, mut gizmos: Gizmos, queries: SelectionBoxQueries, meshes: Res>, @@ -77,25 +77,23 @@ pub fn selection_box_system( return; } - let Some(entity) = selected_entity.0 else { - return; - }; - - // Calculate the bounding box for the entity (including children) - if let Some(world_aabb) = calculate_world_aabb( - entity, - &queries.mesh_query, - &queries.sprite_query, - &queries.aabb_query, - &queries.children_query, - &queries.transform_query, - &meshes, - ) { - draw_selection_box(&mut gizmos, &world_aabb); - } else { - // Fallback to simple transform-based selection box - if let Ok(global_transform) = queries.transform_query.get(entity) { - draw_fallback_selection_box(&mut gizmos, global_transform); + for entity in selection.iter() { + // Calculate the bounding box for the entity (including children) + if let Some(world_aabb) = calculate_world_aabb( + entity, + &queries.mesh_query, + &queries.sprite_query, + &queries.aabb_query, + &queries.children_query, + &queries.transform_query, + &meshes, + ) { + draw_selection_box(&mut gizmos, &world_aabb); + } else { + // Fallback to simple transform-based selection box + if let Ok(global_transform) = queries.transform_query.get(entity) { + draw_fallback_selection_box(&mut gizmos, global_transform); + } } } } diff --git a/bevy_editor_panes/bevy_properties_pane/src/lib.rs b/bevy_editor_panes/bevy_properties_pane/src/lib.rs index 68883519..a5e7c00d 100644 --- a/bevy_editor_panes/bevy_properties_pane/src/lib.rs +++ b/bevy_editor_panes/bevy_properties_pane/src/lib.rs @@ -8,7 +8,7 @@ use bevy::{ reflect::*, scene2::{CommandsSpawnScene, Scene, SceneList, bsn}, }; -use bevy_editor_core::prelude::*; +use bevy_editor_core::{prelude::*, selection::common_conditions::primary_selection_changed}; use bevy_pane_layout::prelude::*; /// Plugin for the editor properties pane. @@ -19,8 +19,7 @@ impl Plugin for PropertiesPanePlugin { app.register_pane("Properties", setup_pane).add_systems( Update, update_properties_pane.run_if( - resource_changed:: - .or(any_match_filter::>), + primary_selection_changed.or(any_match_filter::>), ), ); } @@ -50,23 +49,23 @@ fn setup_pane(pane: In, mut commands: Commands) { fn update_properties_pane( pane_bodies: Query>, - selected_entity: Res, + selection: Res, world: &World, mut commands: Commands, ) { for pane_body in &pane_bodies { commands.entity(pane_body).despawn_children(); commands - .spawn_scene(properties_pane(&selected_entity, world)) + .spawn_scene(properties_pane(&selection, world)) .insert(Node::default()) .insert(ChildOf(pane_body)); } } -fn properties_pane(selected_entity: &SelectedEntity, world: &World) -> impl Scene { - match selected_entity.0 { - Some(selected_entity) => bsn! {Node { flex_direction: FlexDirection::Column } [ - {component_list(selected_entity, world)} +fn properties_pane(selection: &EditorSelection, world: &World) -> impl Scene { + match selection.primary() { + Some(selection) => bsn! {Node { flex_direction: FlexDirection::Column } [ + {component_list(selection, world)} ]} .boxed_scene(), None => bsn! { diff --git a/bevy_editor_panes/bevy_scene_tree/src/lib.rs b/bevy_editor_panes/bevy_scene_tree/src/lib.rs index 05067dec..a77bdc24 100644 --- a/bevy_editor_panes/bevy_scene_tree/src/lib.rs +++ b/bevy_editor_panes/bevy_scene_tree/src/lib.rs @@ -1,9 +1,7 @@ //! An interactive, collapsible tree view for hierarchical ECS data in Bevy. use bevy::{app::Plugin, color::palettes::tailwind, prelude::*}; -use bevy_editor_core::selection::{ - SelectedEntity, common_handlers::toggle_select_on_click_for_entity, -}; +use bevy_editor_core::selection::EditorSelection; use bevy_i_cant_believe_its_not_bsn::{Template, TemplateEntityCommandsExt, on, template}; use bevy_pane_layout::prelude::{PaneAppExt, PaneStructure}; @@ -36,8 +34,8 @@ fn setup_pane(pane: In, mut commands: Commands) { BackgroundColor(tailwind::NEUTRAL_600.into()), )) .observe( - |mut trigger: On>, mut selected_entity: ResMut| { - selected_entity.0 = None; + |mut trigger: On>, mut selection: ResMut| { + selection.clear(); trigger.propagate(false); }, ); @@ -46,24 +44,37 @@ fn setup_pane(pane: In, mut commands: Commands) { fn update_scene_tree( scene_trees: Query>, scene_entities: Query<(Entity, &Name)>, - selected_entity: Res, + selection: Res, mut commands: Commands, ) { for scene_tree in &scene_trees { let tree_rows: Template = scene_entities .iter() - .flat_map(|(entity, name)| scene_tree_row_for_entity(entity, name, &selected_entity)) + .flat_map(|(entity, name)| scene_tree_row_for_entity(entity, name, &selection)) .collect(); commands.entity(scene_tree).build_children(tree_rows); } } -fn scene_tree_row_for_entity( - entity: Entity, - name: &Name, - selected_entity: &SelectedEntity, -) -> Template { +fn scene_tree_row_for_entity(entity: Entity, name: &Name, selection: &EditorSelection) -> Template { + let selection_handler = + move |mut trigger: On>, + keyboard_input: Res>, + mut selection: ResMut| { + if trigger.button != PointerButton::Primary { + return; + } + + trigger.propagate(false); + let ctrl = keyboard_input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]); + if ctrl { + selection.toggle(entity); + } else { + selection.set(entity); + } + }; + template! { {entity}: ( Node { @@ -72,9 +83,9 @@ fn scene_tree_row_for_entity( ..Default::default() }, BorderRadius::all(Val::Px(4.0)), - BackgroundColor(if selected_entity.0 == Some(entity) { tailwind::NEUTRAL_700.into() } else { Color::NONE }), + BackgroundColor(if selection.contains(entity) { tailwind::NEUTRAL_700.into() } else { Color::NONE }), ) => [ - on(toggle_select_on_click_for_entity(entity)); + on(selection_handler); ( Text(name.into()), TextFont::from_font_size(11.0), diff --git a/crates/bevy_editor/src/lib.rs b/crates/bevy_editor/src/lib.rs index 4c19c294..f9b1d5f6 100644 --- a/crates/bevy_editor/src/lib.rs +++ b/crates/bevy_editor/src/lib.rs @@ -27,7 +27,7 @@ pub use bevy; use bevy::winit::{UpdateMode, WinitSettings}; use bevy_context_menu::ContextMenuPlugin; use bevy_editor_core::EditorCorePlugin; -use bevy_editor_core::selection::common_handlers::toggle_select_on_click; +use bevy_editor_core::selection::Selectable; use bevy_editor_styles::StylesPlugin; // Panes @@ -130,14 +130,13 @@ fn dummy_setup( Name::new("Circle"), )); - commands - .spawn(( - Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(1.5)))), - MeshMaterial3d(materials_3d.add(Color::WHITE)), - Name::new("Plane"), - Pickable::default(), - )) - .observe(toggle_select_on_click); + commands.spawn(( + Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(1.5)))), + MeshMaterial3d(materials_3d.add(Color::WHITE)), + Name::new("Plane"), + Pickable::default(), + Selectable, + )); commands.spawn(( DirectionalLight { diff --git a/crates/bevy_editor_core/src/lib.rs b/crates/bevy_editor_core/src/lib.rs index d57ab292..25ccc2aa 100644 --- a/crates/bevy_editor_core/src/lib.rs +++ b/crates/bevy_editor_core/src/lib.rs @@ -12,7 +12,7 @@ use crate::{actions::ActionsPlugin, selection::SelectionPlugin, utils::CoreUtils pub mod prelude { pub use crate::{ actions::{ActionAppExt, ActionWorldExt}, - selection::SelectedEntity, + selection::EditorSelection, utils::IntoBoxedScene, }; } diff --git a/crates/bevy_editor_core/src/selection.rs b/crates/bevy_editor_core/src/selection.rs index c35a50f0..d03e2ba1 100644 --- a/crates/bevy_editor_core/src/selection.rs +++ b/crates/bevy_editor_core/src/selection.rs @@ -1,6 +1,11 @@ //! Editor selection module. -use bevy::{ecs::entity::Entities, prelude::*}; +use bevy::{ + ecs::entity::{Entities, EntitySet, EntitySetIterator, FromEntitySetIterator, UniqueEntityVec}, + prelude::*, +}; + +use crate::utils::DragCancelClick; /// Editor selection plugin. #[derive(Default)] @@ -8,84 +13,210 @@ pub struct SelectionPlugin; impl Plugin for SelectionPlugin { fn build(&self, app: &mut App) { - app.init_resource::() - .register_type::() - .add_systems(PostUpdate, reset_selected_entity_if_entity_despawned); + app.init_resource::() + .add_systems(PostUpdate, remove_entity_from_selection_if_despawned) + .add_observer(selection_handler); } } -/// The currently selected entity in the scene. -#[derive(Resource, Default, Reflect)] -#[reflect(Resource, Default)] -pub struct SelectedEntity(pub Option); +fn selection_handler( + mut trigger: On>, + selectable_query: Query<(), With>, + keyboard_input: Res>, + mut selection: ResMut, +) { + if trigger.button != PointerButton::Primary { + return; + } + + let target = trigger.target(); + if selectable_query.contains(target) { + trigger.propagate(false); + let shift = keyboard_input.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + if shift { + selection.toggle(target); + } else { + selection.set(target); + } + } +} -impl SelectedEntity { +/// The currently selected entities in the scene. +#[derive(Resource, Default)] +pub struct EditorSelection(UniqueEntityVec); + +impl EditorSelection { /// Toggle selection for an entity. pub fn toggle(&mut self, entity: Entity) { debug_assert_ne!(entity, Entity::PLACEHOLDER); - if self.0 == Some(entity) { - self.0 = None; - } else { - self.0 = Some(entity); + if !self.remove(entity) { + // SAFETY: The preceding call to self.remove ensures the entity is not present. + #[expect(unsafe_code)] + unsafe { + self.0.push(entity); + } } } - /// Set an entity as selected. + /// Set the selection to an entity, making it the primary selection. pub fn set(&mut self, entity: Entity) { debug_assert_ne!(entity, Entity::PLACEHOLDER); - self.0 = Some(entity); + *self = EditorSelection::from_iter([entity]); + } + + /// Add an entity to the selection, making it the primary selection. + /// + /// If the entity was already part of the selection it will be made the primary selection. + pub fn add(&mut self, entity: Entity) { + debug_assert_ne!(entity, Entity::PLACEHOLDER); + self.remove(entity); + // SAFETY: The preceding call to self.remove ensures the entity is not present. + #[expect(unsafe_code)] + unsafe { + self.0.push(entity); + } + } + + /// Remove an entity from the selection if present. Returns `true` if the entity was removed. + pub fn remove(&mut self, entity: Entity) -> bool { + debug_assert_ne!(entity, Entity::PLACEHOLDER); + if let Some(position) = self.0.iter().position(|selected| *selected == entity) { + self.0.remove(position); + true + } else { + false + } } /// Empty the selection. - pub fn reset(&mut self) { - self.0 = None; + pub fn clear(&mut self) { + self.0.clear(); } /// Check whether the selection contains a given entity. pub fn contains(&self, entity: Entity) -> bool { - self.0.is_some_and(|selected| selected == entity) + self.0.contains(&entity) + } + + /// The last entity in the selection. + pub fn primary(&self) -> Option { + self.0.last().copied() + } + + /// Returns an iterator over all entities in the selection in the order they were selected. + pub fn iter(&self) -> impl EntitySetIterator { + self.0.iter().copied() + } +} + +impl FromIterator for EditorSelection { + fn from_iter>(iter: T) -> Self { + EditorSelection(UniqueEntityVec::from_iter(iter)) + } +} + +impl FromEntitySetIterator for EditorSelection { + fn from_entity_set_iter>(set_iter: T) -> Self { + EditorSelection(UniqueEntityVec::from_entity_set_iter(set_iter)) } } -/// System to reset [`SelectedEntity`] when the entity is despawned. -pub fn reset_selected_entity_if_entity_despawned( - mut selected_entity: ResMut, +/// Marker component for selectable entities. +#[derive(Component, Default, Clone)] +pub struct Selectable; + +/// This system removes entities from the [`EditorSelection`] when they are despawned. +pub fn remove_entity_from_selection_if_despawned( + mut selection: ResMut, entities: &Entities, ) { - if let Some(e) = selected_entity.0 - && !entities.contains(e) - { - selected_entity.reset(); + // Avoid triggering change detection every frame. + if selection.0.iter().any(|entity| !entities.contains(*entity)) { + selection.0.retain(|entity| entities.contains(*entity)); } } -/// Common handler observer systems for entity selection behavior. -pub mod common_handlers { - use crate::utils::DragCancelClick; +/// Common [`EditorSelection`] conditions. +pub mod common_conditions { + use bevy::prelude::*; + + use crate::prelude::EditorSelection; + + /// True if the primary [`EditorSelection`] changed. + pub fn primary_selection_changed( + mut cache: Local>, + selection: Res, + ) -> bool { + let changed = *cache != selection.primary(); + *cache = selection.primary(); + changed + } +} +#[cfg(test)] +mod tests { use super::*; - /// Toggles selection for this entity when it is clicked. - pub fn toggle_select_on_click( - mut trigger: On>, - mut selected_entity: ResMut, - ) { - if trigger.button == PointerButton::Primary { - selected_entity.toggle(trigger.target()); - trigger.propagate(false); - } + fn ids() -> [Entity; L] { + let mut world = World::new(); + std::array::from_fn(|_| world.spawn_empty().id()) } - /// Toggles selection for an entity when this entity is clicked. - pub fn toggle_select_on_click_for_entity( - entity: Entity, - ) -> impl FnMut(On>, ResMut) { - move |mut trigger: On>, - mut selected_entity: ResMut| { - if trigger.button == PointerButton::Primary { - selected_entity.toggle(entity); - trigger.propagate(false); - } - } + #[test] + fn remove() { + let [a, b, c] = ids(); + + let mut selection = EditorSelection::from_iter([a, b, c]); + + assert!(selection.remove(b)); + assert_eq!(selection.0[..], [a, c]); + + assert!(selection.remove(c)); + assert_eq!(selection.0[..], [a]); + + assert!(!selection.remove(b)); + assert_eq!(selection.0[..], [a]); + } + + #[test] + fn add() { + let [a, b, c] = ids(); + + let mut selection = EditorSelection::from_iter([a, b]); + + selection.add(c); + assert_eq!(selection.0[..], [a, b, c]); + + selection.add(a); + assert_eq!(selection.0[..], [b, c, a]); + + selection.add(c); + assert_eq!(selection.0[..], [b, a, c]); + } + + #[test] + fn toggle() { + let [a, b, c] = ids(); + + let mut selection = EditorSelection::from_iter([a, b, c]); + + selection.toggle(a); + assert_eq!(selection.0[..], [b, c]); + + selection.toggle(c); + assert_eq!(selection.0[..], [b]); + + selection.toggle(a); + assert_eq!(selection.0[..], [b, a]); + } + + #[test] + fn set() { + let [a, b] = ids(); + + let mut selection = EditorSelection::from_iter([a, b]); + + selection.set(a); + assert_eq!(selection.0[..], [a]); } } diff --git a/crates/bevy_transform_gizmos/examples/minimal.rs b/crates/bevy_transform_gizmos/examples/minimal.rs index f33f41c8..84c75a7a 100644 --- a/crates/bevy_transform_gizmos/examples/minimal.rs +++ b/crates/bevy_transform_gizmos/examples/minimal.rs @@ -39,7 +39,7 @@ fn setup( bevy_transform_gizmos::GizmoTransformable, )) .id(); - commands.insert_resource(SelectedEntity(Some(id))); + commands.insert_resource(EditorSelection::from_iter([id])); // light commands.spawn(( PointLight { diff --git a/crates/bevy_transform_gizmos/examples/parenting.rs b/crates/bevy_transform_gizmos/examples/parenting.rs index 1b9764b2..8e77dd50 100644 --- a/crates/bevy_transform_gizmos/examples/parenting.rs +++ b/crates/bevy_transform_gizmos/examples/parenting.rs @@ -51,7 +51,7 @@ fn setup( )); }) .id(); - commands.insert_resource(SelectedEntity(Some(id))); + commands.insert_resource(EditorSelection::from_iter([id])); // light commands.spawn(( diff --git a/crates/bevy_transform_gizmos/src/lib.rs b/crates/bevy_transform_gizmos/src/lib.rs index 3387fbf8..f2c214a3 100644 --- a/crates/bevy_transform_gizmos/src/lib.rs +++ b/crates/bevy_transform_gizmos/src/lib.rs @@ -13,7 +13,7 @@ use bevy::picking::{backend::ray::RayMap, pointer::PointerId}; use bevy::{prelude::*, render::camera::Projection, transform::TransformSystems}; -use bevy_editor_core::selection::SelectedEntity; +use bevy_editor_core::selection::EditorSelection; use mesh::{RotationGizmo, ViewTranslateGizmo}; use normalization::*; @@ -202,7 +202,7 @@ fn on_transform_gizmo_pointer_press( trigger: On>, target_query: Query<(&InteractionKind, &ChildOf)>, mut query: Query<(&mut TransformGizmo, &GlobalTransform)>, - selection: Res, + selection: Res, items_query: Query<(&GlobalTransform, Entity, Option<&TransformGizmoOffset>)>, mut commands: Commands, ) { @@ -270,7 +270,7 @@ fn on_transform_gizmo_pointer_release( /// Updates the position of the gizmo and selected meshes while the gizmo is being dragged. fn drag_gizmo( raymap: Res, - selection: Res, + selection: Res, mut transform_query: Query< (Entity, Option<&ChildOf>, &mut Transform, &InitialTransform), Without, @@ -415,7 +415,7 @@ pub struct TransformGizmoOffset(pub Vec3); /// Places the gizmo in space relative to the selected entity(s). fn place_gizmo( plugin_settings: Res, - selection: Res, + selection: Res, mut queries: ParamSet<( Query<(Entity, &GlobalTransform, Option<&TransformGizmoOffset>), With>, Query<(&mut GlobalTransform, &mut Transform, &mut Visibility), With>,