//! Editor selection treatment: active/secondary AABB shells, x-ray, and corner brackets. use avian3d::prelude::ColliderConstructor; use bevy::asset::{load_internal_asset, uuid_handle}; use bevy::camera::visibility::RenderLayers; use bevy::pbr::{Material, MaterialPlugin}; use bevy::prelude::*; use bevy::render::render_resource::{ AsBindGroup, BlendState, CompareFunction, Face, RenderPipelineDescriptor, ShaderType, }; use bevy::shader::ShaderRef; use shared::{ ColliderDesc, ColliderShapeDesc, LevelObject, PhysicsBody, Primitive, RaytracingExcluded, }; use crate::infra::EditorOnly; use crate::state::scene_tools_active; use crate::ui::UiState; use crate::viewport::ViewportDisplayMode; const SELECTION_OUTLINE_SHADER: Handle = uuid_handle!("c4e8f1a2-9b3d-4e7c-a1f5-6d2b8e0c4a91"); const PRIMARY_VISIBLE_COLOR: Color = Color::srgba(1.0, 0.72, 0.28, 0.56); const PRIMARY_OCCLUDED_COLOR: Color = Color::srgba(1.0, 0.66, 0.22, 0.28); const SECONDARY_VISIBLE_COLOR: Color = Color::srgba(0.31, 0.75, 0.88, 0.42); const SECONDARY_OCCLUDED_COLOR: Color = Color::srgba(0.31, 0.68, 0.82, 0.2); const BOUNDS_PADDING: f32 = 1.06; #[derive(Clone, Copy, ShaderType)] struct OutlineUniform { color: LinearRgba, rim_power: f32, rim_mix: f32, pass_kind: u32, } #[derive(Asset, TypePath, AsBindGroup, Clone)] pub struct SelectionOutlineVisibleMaterial { #[uniform(0)] uniform: OutlineUniform, } #[derive(Asset, TypePath, AsBindGroup, Clone)] pub struct SelectionOutlineOccludedMaterial { #[uniform(0)] uniform: OutlineUniform, } fn outline_uniform(color: Color, pass_kind: u32) -> OutlineUniform { OutlineUniform { color: color.into(), rim_power: 5.5, rim_mix: 0.9, pass_kind, } } macro_rules! impl_outline_material { ($t:ty, $depth:expr) => { impl Material for $t { fn fragment_shader() -> ShaderRef { SELECTION_OUTLINE_SHADER.into() } fn alpha_mode(&self) -> AlphaMode { AlphaMode::Blend } fn enable_prepass() -> bool { false } fn enable_shadows() -> bool { false } fn specialize( _pipeline: &bevy::pbr::MaterialPipeline, descriptor: &mut RenderPipelineDescriptor, _layout: &bevy::mesh::MeshVertexBufferLayoutRef, _key: bevy::pbr::MaterialPipelineKey, ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { descriptor.primitive.cull_mode = Some(Face::Front); if let Some(depth) = &mut descriptor.depth_stencil { depth.depth_write_enabled = Some(false); depth.depth_compare = Some($depth); } if let Some(fragment) = &mut descriptor.fragment { if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) { target.blend = Some(BlendState::ALPHA_BLENDING); } } Ok(()) } } }; } impl_outline_material!(SelectionOutlineVisibleMaterial, CompareFunction::LessEqual); impl_outline_material!(SelectionOutlineOccludedMaterial, CompareFunction::Greater); #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] enum SelectionOutlinePass { Visible, Occluded, } #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] enum SelectionOutlineRole { Primary, Secondary, } #[derive(Component)] pub(crate) struct SelectionOutlineShell { root: Entity, pass: SelectionOutlinePass, role: SelectionOutlineRole, } #[derive(Resource)] struct SelectionOutlineMaterials { primary_occluded: Handle, primary_visible: Handle, secondary_occluded: Handle, secondary_visible: Handle, } #[derive(Resource)] struct SelectionOutlineUnitBox(Handle); #[derive(Clone, Copy, Debug)] struct SelectionBounds { center: Vec3, half_extents: Vec3, } pub struct SelectionOutlinePlugin; impl Plugin for SelectionOutlinePlugin { fn build(&self, app: &mut App) { load_internal_asset!( app, SELECTION_OUTLINE_SHADER, "../../assets/shaders/selection_outline.wgsl", Shader::from_wgsl ); app.add_plugins(( MaterialPlugin::::default(), MaterialPlugin::::default(), )) .add_systems( Startup, ( init_selection_outline_assets, init_selection_outline_materials, ) .chain(), ) .add_systems( Update, ( sync_selection_outline_meshes, draw_selection_corner_brackets, ) .chain() .run_if(scene_tools_active), ); } } fn init_selection_outline_assets(mut commands: Commands, mut meshes: ResMut>) { commands.insert_resource(SelectionOutlineUnitBox( meshes.add(Mesh::from(Cuboid::new(1.0, 1.0, 1.0))), )); } fn init_selection_outline_materials( mut visible: ResMut>, mut occluded: ResMut>, mut commands: Commands, ) { commands.insert_resource(SelectionOutlineMaterials { primary_visible: visible.add(SelectionOutlineVisibleMaterial { uniform: outline_uniform(PRIMARY_VISIBLE_COLOR, 0), }), primary_occluded: occluded.add(SelectionOutlineOccludedMaterial { uniform: outline_uniform(PRIMARY_OCCLUDED_COLOR, 1), }), secondary_visible: visible.add(SelectionOutlineVisibleMaterial { uniform: outline_uniform(SECONDARY_VISIBLE_COLOR, 0), }), secondary_occluded: occluded.add(SelectionOutlineOccludedMaterial { uniform: outline_uniform(SECONDARY_OCCLUDED_COLOR, 1), }), }); } #[allow(clippy::too_many_arguments)] fn sync_selection_outline_meshes( ui_state: Res, display: Res, materials: Res, unit_box: Res, mut commands: Commands, mut shells: Query<(Entity, &SelectionOutlineShell, &mut Transform)>, children: Query<&Children>, mesh_entities: Query<(Entity, &Mesh3d), With>, globals: Query<&GlobalTransform>, mesh_assets: Res>, primitives: Query<&Primitive>, physics: Query<&PhysicsBody>, colliders: Query<&ColliderDesc>, level_objects: Query<(), (With, Without)>, ) { let selected: Vec = ui_state.selected_entities.iter().collect(); let mut desired: std::collections::HashMap< (Entity, SelectionOutlinePass, SelectionOutlineRole), SelectionBounds, > = std::collections::HashMap::new(); if !display.clean_game_view { for (index, &root) in selected.iter().enumerate() { if !level_objects.contains(root) { continue; } let Some(bounds) = selection_bounds( root, &children, &mesh_entities, &globals, &mesh_assets, &primitives, &physics, &colliders, ) else { continue; }; if bounds.half_extents.max_element() <= f32::EPSILON { continue; } let role = if index == 0 { SelectionOutlineRole::Primary } else { SelectionOutlineRole::Secondary }; desired.insert((root, SelectionOutlinePass::Occluded, role), bounds); desired.insert((root, SelectionOutlinePass::Visible, role), bounds); } } let mut existing = std::collections::HashSet::new(); for (shell_entity, shell, mut transform) in &mut shells { let key = (shell.root, shell.pass, shell.role); if let Some(bounds) = desired.get(&key) { transform.translation = bounds.center; transform.scale = bounds.half_extents * 2.0; existing.insert(key); } else { commands.entity(shell_entity).despawn(); } } for ((root, pass, role), bounds) in desired { if existing.contains(&(root, pass, role)) { continue; } let mut entity_commands = commands.spawn(( EditorOnly, RaytracingExcluded, Name::new("Selection Outline"), SelectionOutlineShell { root, pass, role }, Mesh3d(unit_box.0.clone()), Transform { translation: bounds.center, scale: bounds.half_extents * 2.0, ..default() }, RenderLayers::default(), ChildOf(root), )); match (role, pass) { (SelectionOutlineRole::Primary, SelectionOutlinePass::Occluded) => { entity_commands.insert(MeshMaterial3d(materials.primary_occluded.clone())); } (SelectionOutlineRole::Primary, SelectionOutlinePass::Visible) => { entity_commands.insert(MeshMaterial3d(materials.primary_visible.clone())); } (SelectionOutlineRole::Secondary, SelectionOutlinePass::Occluded) => { entity_commands.insert(MeshMaterial3d(materials.secondary_occluded.clone())); } (SelectionOutlineRole::Secondary, SelectionOutlinePass::Visible) => { entity_commands.insert(MeshMaterial3d(materials.secondary_visible.clone())); } } } } fn draw_selection_corner_brackets( display: Res, mut gizmos: Gizmos, shells: Query<(&SelectionOutlineShell, &GlobalTransform)>, ) { if display.clean_game_view { return; } for (shell, global) in &shells { if shell.pass != SelectionOutlinePass::Visible { continue; } let (scale, rotation, center) = global.to_scale_rotation_translation(); let half = scale.abs() * 0.5; if half.max_element() <= f32::EPSILON { continue; } let color = match shell.role { SelectionOutlineRole::Primary => Color::srgba(1.0, 0.76, 0.34, 0.98), SelectionOutlineRole::Secondary => Color::srgba(0.38, 0.78, 0.9, 0.78), }; draw_corner_brackets(&mut gizmos, center, rotation, half, color); } } fn draw_corner_brackets( gizmos: &mut Gizmos, center: Vec3, rotation: Quat, half: Vec3, color: Color, ) { let lengths = (half * 0.28).clamp(Vec3::splat(0.06), Vec3::splat(0.55)); for signs in [ Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, -1.0, -1.0), Vec3::new(-1.0, 1.0, -1.0), Vec3::new(1.0, 1.0, -1.0), Vec3::new(-1.0, -1.0, 1.0), Vec3::new(1.0, -1.0, 1.0), Vec3::new(-1.0, 1.0, 1.0), Vec3::new(1.0, 1.0, 1.0), ] { let corner = center + rotation * (half * signs); for axis in [Vec3::X, Vec3::Y, Vec3::Z] { let length = lengths.dot(axis); let direction = rotation * (axis * -signs.dot(axis)); gizmos.line(corner, corner + direction * length, color); } } } #[expect( clippy::too_many_arguments, reason = "selection bounds combine distinct authored geometry and physics sources" )] fn selection_bounds( root: Entity, children: &Query<&Children>, mesh_entities: &Query<(Entity, &Mesh3d), With>, globals: &Query<&GlobalTransform>, mesh_assets: &Assets, primitives: &Query<&Primitive>, physics: &Query<&PhysicsBody>, colliders: &Query<&ColliderDesc>, ) -> Option { if let Ok(primitive) = primitives.get(root) { return Some(SelectionBounds { center: Vec3::ZERO, half_extents: primitive.size * 0.5 * BOUNDS_PADDING, }); } if let Ok(body) = physics.get(root) { if let Some(bounds) = bounds_from_collider(&body.collider) { return Some(bounds); } } if let Ok(collider) = colliders.get(root) { if let Some(bounds) = bounds_from_collider_desc(collider) { return Some(bounds); } } union_mesh_bounds(root, children, mesh_entities, globals, mesh_assets) } fn bounds_from_collider(collider: &ColliderConstructor) -> Option { match collider { ColliderConstructor::Cuboid { x_length, y_length, z_length, } => Some(SelectionBounds { center: Vec3::ZERO, half_extents: Vec3::new(*x_length, *y_length, *z_length) * 0.5 * BOUNDS_PADDING, }), ColliderConstructor::Sphere { radius } => Some(SelectionBounds { center: Vec3::ZERO, half_extents: Vec3::splat(*radius) * BOUNDS_PADDING, }), _ => None, } } fn bounds_from_collider_desc(collider: &ColliderDesc) -> Option { if !collider.enabled { return None; } match &collider.shape { ColliderShapeDesc::Cuboid { x_length, y_length, z_length, } => Some(SelectionBounds { center: Vec3::ZERO, half_extents: Vec3::new(*x_length, *y_length, *z_length) * 0.5 * BOUNDS_PADDING, }), ColliderShapeDesc::Sphere { radius } => Some(SelectionBounds { center: Vec3::ZERO, half_extents: Vec3::splat(*radius) * BOUNDS_PADDING, }), ColliderShapeDesc::Capsule { radius, height } => Some(SelectionBounds { center: Vec3::ZERO, half_extents: Vec3::new(*radius, *height * 0.5, *radius) * BOUNDS_PADDING, }), ColliderShapeDesc::StaticMesh { .. } => None, } } fn union_mesh_bounds( root: Entity, children: &Query<&Children>, mesh_entities: &Query<(Entity, &Mesh3d), With>, globals: &Query<&GlobalTransform>, mesh_assets: &Assets, ) -> Option { let root_global = globals.get(root).ok()?; let root_to_world = root_global.affine(); let world_to_root = root_to_world.inverse(); let mut min = Vec3::splat(f32::INFINITY); let mut max = Vec3::splat(f32::NEG_INFINITY); let mut any = false; for entity in collect_mesh_entities(root, children, mesh_entities) { let Ok((_, mesh3d)) = mesh_entities.get(entity) else { continue; }; let Some(mesh) = mesh_assets.get(&mesh3d.0) else { continue; }; let Some(aabb) = mesh.final_aabb else { continue; }; let entity_global = globals.get(entity).ok()?; let entity_to_world = entity_global.affine(); let mesh_min = Vec3::from(aabb.min); let mesh_max = Vec3::from(aabb.max); for corner in corner_iter(mesh_min, mesh_max) { let world = entity_to_world.transform_point3(corner); let local = world_to_root.transform_point3(world); min = min.min(local); max = max.max(local); any = true; } } if !any { return None; } let center = (min + max) * 0.5; let half_extents = (max - min) * 0.5 * BOUNDS_PADDING; Some(SelectionBounds { center, half_extents, }) } fn corner_iter(min: Vec3, max: Vec3) -> impl Iterator { [ Vec3::new(min.x, min.y, min.z), Vec3::new(max.x, min.y, min.z), Vec3::new(min.x, max.y, min.z), Vec3::new(max.x, max.y, min.z), Vec3::new(min.x, min.y, max.z), Vec3::new(max.x, min.y, max.z), Vec3::new(min.x, max.y, max.z), Vec3::new(max.x, max.y, max.z), ] .into_iter() } fn collect_mesh_entities( root: Entity, children: &Query<&Children>, meshes: &Query<(Entity, &Mesh3d), With>, ) -> Vec { let mut found = Vec::new(); if meshes.get(root).is_ok() { found.push(root); } collect_mesh_descendants(root, children, meshes, &mut found); found } fn collect_mesh_descendants( entity: Entity, children: &Query<&Children>, meshes: &Query<(Entity, &Mesh3d), With>, found: &mut Vec, ) { let Ok(kids) = children.get(entity) else { return; }; for child in kids.iter() { if meshes.get(child).is_ok() { found.push(child); } collect_mesh_descendants(child, children, meshes, found); } }