Blacksite/crates/editor/src/viewport/actor_icons.rs
Rbanh f29712d158
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Initial project import
2026-06-05 21:44:45 -04:00

657 lines
24 KiB
Rust

//! Selectable editor-only billboards at actor roots.
use std::collections::{HashMap, HashSet};
use bevy::asset::{load_internal_binary_asset, uuid_handle, RenderAssetUsages};
use bevy::camera::visibility::NoFrustumCulling;
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
use bevy::prelude::*;
use shared::{
ActorKind, AuthoringLightKind, ColliderDesc, LevelObject, LightDesc, ModelRef, ObjectiveMarker,
PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
RaytracingExcluded, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
};
use crate::camera::EditorCamera;
use crate::infra::EditorOnly;
use crate::state::scene_tools_active;
use crate::ui::UiState;
use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizationSettings;
const ICON_TARGET_PIXELS: f32 = 30.0;
const ICON_MIN_WORLD_SIZE: f32 = 0.2;
const ICON_MAX_WORLD_SIZE: f32 = 3.0;
const DEFAULT_PERSPECTIVE_FOV: f32 = std::f32::consts::FRAC_PI_4;
const ICON_01_ACTOR_EMPTY: Handle<Image> = uuid_handle!("d87c7796-b219-4011-8305-4db55422f1c7");
const ICON_02_ACTOR_GENERIC: Handle<Image> = uuid_handle!("c9a1a84f-4dcd-4a2b-a5b6-c365dc7d6795");
const ICON_03_ACTOR_MISSING: Handle<Image> = uuid_handle!("cca7cdf9-0c7d-4546-9bb9-1176e49a635c");
const ICON_04_ACTOR_WARNING: Handle<Image> = uuid_handle!("7dba1d9c-0569-40d2-978e-66014377051d");
const ICON_05_RENDER_MESH: Handle<Image> = uuid_handle!("10c70a1d-3327-458a-92d2-db41f893432e");
const ICON_06_RENDER_DECAL: Handle<Image> = uuid_handle!("8c27ff62-0d94-4184-81cf-977e652a0496");
const ICON_07_RENDER_PARTICLE: Handle<Image> = uuid_handle!("0db519d6-0eca-4389-9393-2e0c38f73119");
const ICON_08_RENDER_BILLBOARD: Handle<Image> =
uuid_handle!("2fcb8e83-8f51-4978-8b29-146e2c44b5e6");
const ICON_09_LIGHT_POINT: Handle<Image> = uuid_handle!("ba2cbf62-c547-4cfe-ba61-4081076da93b");
const ICON_10_LIGHT_SPOT: Handle<Image> = uuid_handle!("200ee881-9cac-41d1-89be-45581642968f");
const ICON_11_LIGHT_DIRECTIONAL: Handle<Image> =
uuid_handle!("d9de4730-2463-41dc-87e9-ed99b013d74a");
const ICON_12_LIGHT_AREA: Handle<Image> = uuid_handle!("d43ea6a8-15da-4792-8d63-962211f94ad6");
const ICON_13_CAMERA_MAIN: Handle<Image> = uuid_handle!("1979cc52-7285-4bb3-88f5-bd82021c10fa");
const ICON_14_CAMERA_CAPTURE: Handle<Image> = uuid_handle!("f1fc47d3-eaf4-4aaf-becc-b51a64f0b070");
const ICON_15_AUDIO_SOURCE: Handle<Image> = uuid_handle!("2dd3091f-2dea-4626-8430-79fa44a53c68");
const ICON_16_AUDIO_LISTENER: Handle<Image> = uuid_handle!("9eb3177f-e3d5-45cb-bd7c-49eecb4eb8e3");
const ICON_17_PHYSICS_COLLIDER: Handle<Image> =
uuid_handle!("69f02ada-e171-484b-9a46-af753971e9c1");
const ICON_18_PHYSICS_RIGIDBODY: Handle<Image> =
uuid_handle!("c30ddfb8-a851-45ab-98a9-97add45f1b49");
const ICON_19_PHYSICS_TRIGGER: Handle<Image> = uuid_handle!("3cf22740-463a-41b0-a1d7-d0cccc910b70");
const ICON_20_PHYSICS_JOINT: Handle<Image> = uuid_handle!("cdb8ef15-d41c-4f34-bc98-4a61f4628508");
const ICON_21_GAME_SPAWN: Handle<Image> = uuid_handle!("56719033-f1db-4749-8637-9b004581ce81");
const ICON_22_GAME_TRIGGER: Handle<Image> = uuid_handle!("98e42034-1c34-40af-8955-12c12cc2e261");
const ICON_23_GAME_MARKER: Handle<Image> = uuid_handle!("7808784d-c117-41a9-84db-fae5d4edbf3a");
const ICON_24_GAME_INTERACTABLE: Handle<Image> =
uuid_handle!("e61fc53a-5201-49a4-9451-88d7c804a5c5");
const ICON_25_NAV_WAYPOINT: Handle<Image> = uuid_handle!("26511234-47bc-4cdc-b24c-39bfec7e46da");
const ICON_26_NAV_LINK: Handle<Image> = uuid_handle!("6f4988e9-b571-45af-a3ad-777d80a3d9b6");
const ICON_27_NAV_REGION: Handle<Image> = uuid_handle!("02b7ae0d-b3d6-4e46-8be0-8ee590ea71cc");
const ICON_28_VOLUME_GENERIC: Handle<Image> = uuid_handle!("670cf8ce-c33e-4f86-ae7e-0311c8917629");
const ICON_29_VOLUME_POST_PROCESS: Handle<Image> =
uuid_handle!("bc9064bd-b6a8-4b00-95d4-3763325bceb9");
const ICON_30_ENVIRONMENT_WIND: Handle<Image> =
uuid_handle!("0d4220a6-7abc-42bc-a0d2-b69847061a92");
const ICON_31_ENVIRONMENT_SKY: Handle<Image> = uuid_handle!("e4f09ad3-380d-4086-9eb4-e60e69ec1765");
const ICON_32_DEBUG_NOTE: Handle<Image> = uuid_handle!("d093e657-6598-4097-8a57-9d445e3a80d7");
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ActorIconProxy {
pub target: Entity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ActorIconImage {
Empty,
Generic,
Missing,
Warning,
Mesh,
LightPoint,
LightSpot,
LightDirectional,
PhysicsCollider,
GameSpawn,
GameTrigger,
GameMarker,
VolumeGeneric,
PostProcessVolume,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ActorIconCategory {
Actor,
Mesh,
Light,
Physics,
Gameplay,
Volume,
PrefabModel,
Debug,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ActorIconSpec {
image: ActorIconImage,
category: ActorIconCategory,
}
#[derive(Default, Debug, Clone, Copy)]
struct ActorIconComponents {
kind: Option<ActorKind>,
light_kind: Option<AuthoringLightKind>,
has_primitive: bool,
has_static_mesh_renderer: bool,
has_model_ref: bool,
has_prefab_ref: bool,
has_prefab_instance: bool,
has_player_spawn: bool,
has_weapon_spawn: bool,
has_trigger_volume: bool,
has_post_process_volume: bool,
has_team_spawn: bool,
has_objective: bool,
has_physics: bool,
}
#[derive(Resource)]
struct ActorIconAssets {
mesh: Handle<Mesh>,
materials: HashMap<ActorIconImage, Handle<StandardMaterial>>,
}
pub struct ActorIconsPlugin;
impl Plugin for ActorIconsPlugin {
fn build(&self, app: &mut App) {
register_actor_icon_images(app);
app.add_systems(Startup, init_actor_icon_assets)
.add_systems(Update, sync_actor_icons.run_if(scene_tools_active));
}
}
fn icon_image_from_png(bytes: &[u8], path: String) -> Image {
Image::from_buffer(
bytes,
ImageType::Extension("png"),
CompressedImageFormats::NONE,
true,
ImageSampler::linear(),
RenderAssetUsages::default(),
)
.unwrap_or_else(|err| panic!("failed to load editor actor icon {path}: {err}"))
}
macro_rules! load_actor_icon {
($app:ident, $handle:expr, $path:literal) => {
load_internal_binary_asset!(
$app,
$handle,
concat!("../../assets/icons/", $path),
icon_image_from_png
);
};
}
fn register_actor_icon_images(app: &mut App) {
load_actor_icon!(app, ICON_01_ACTOR_EMPTY, "01_actor_empty.png");
load_actor_icon!(app, ICON_02_ACTOR_GENERIC, "02_actor_generic.png");
load_actor_icon!(app, ICON_03_ACTOR_MISSING, "03_actor_missing.png");
load_actor_icon!(app, ICON_04_ACTOR_WARNING, "04_actor_warning.png");
load_actor_icon!(app, ICON_05_RENDER_MESH, "05_render_mesh.png");
load_actor_icon!(app, ICON_06_RENDER_DECAL, "06_render_decal.png");
load_actor_icon!(app, ICON_07_RENDER_PARTICLE, "07_render_particle.png");
load_actor_icon!(app, ICON_08_RENDER_BILLBOARD, "08_render_billboard.png");
load_actor_icon!(app, ICON_09_LIGHT_POINT, "09_light_point.png");
load_actor_icon!(app, ICON_10_LIGHT_SPOT, "10_light_spot.png");
load_actor_icon!(app, ICON_11_LIGHT_DIRECTIONAL, "11_light_directional.png");
load_actor_icon!(app, ICON_12_LIGHT_AREA, "12_light_area.png");
load_actor_icon!(app, ICON_13_CAMERA_MAIN, "13_camera_main.png");
load_actor_icon!(app, ICON_14_CAMERA_CAPTURE, "14_camera_capture.png");
load_actor_icon!(app, ICON_15_AUDIO_SOURCE, "15_audio_source.png");
load_actor_icon!(app, ICON_16_AUDIO_LISTENER, "16_audio_listener.png");
load_actor_icon!(app, ICON_17_PHYSICS_COLLIDER, "17_physics_collider.png");
load_actor_icon!(app, ICON_18_PHYSICS_RIGIDBODY, "18_physics_rigidbody.png");
load_actor_icon!(app, ICON_19_PHYSICS_TRIGGER, "19_physics_trigger.png");
load_actor_icon!(app, ICON_20_PHYSICS_JOINT, "20_physics_joint.png");
load_actor_icon!(app, ICON_21_GAME_SPAWN, "21_game_spawn.png");
load_actor_icon!(app, ICON_22_GAME_TRIGGER, "22_game_trigger.png");
load_actor_icon!(app, ICON_23_GAME_MARKER, "23_game_marker.png");
load_actor_icon!(app, ICON_24_GAME_INTERACTABLE, "24_game_interactable.png");
load_actor_icon!(app, ICON_25_NAV_WAYPOINT, "25_nav_waypoint.png");
load_actor_icon!(app, ICON_26_NAV_LINK, "26_nav_link.png");
load_actor_icon!(app, ICON_27_NAV_REGION, "27_nav_region.png");
load_actor_icon!(app, ICON_28_VOLUME_GENERIC, "28_volume_generic.png");
load_actor_icon!(
app,
ICON_29_VOLUME_POST_PROCESS,
"29_volume_post_process.png"
);
load_actor_icon!(app, ICON_30_ENVIRONMENT_WIND, "30_environment_wind.png");
load_actor_icon!(app, ICON_31_ENVIRONMENT_SKY, "31_environment_sky.png");
load_actor_icon!(app, ICON_32_DEBUG_NOTE, "32_debug_note.png");
}
fn init_actor_icon_assets(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let mesh = meshes.add(Rectangle::new(1.0, 1.0));
let mut actor_materials = HashMap::new();
for (image, texture) in [
(ActorIconImage::Empty, ICON_01_ACTOR_EMPTY),
(ActorIconImage::Generic, ICON_02_ACTOR_GENERIC),
(ActorIconImage::Missing, ICON_03_ACTOR_MISSING),
(ActorIconImage::Warning, ICON_04_ACTOR_WARNING),
(ActorIconImage::Mesh, ICON_05_RENDER_MESH),
(ActorIconImage::LightPoint, ICON_09_LIGHT_POINT),
(ActorIconImage::LightSpot, ICON_10_LIGHT_SPOT),
(ActorIconImage::LightDirectional, ICON_11_LIGHT_DIRECTIONAL),
(ActorIconImage::PhysicsCollider, ICON_17_PHYSICS_COLLIDER),
(ActorIconImage::GameSpawn, ICON_21_GAME_SPAWN),
(ActorIconImage::GameTrigger, ICON_22_GAME_TRIGGER),
(ActorIconImage::GameMarker, ICON_23_GAME_MARKER),
(ActorIconImage::VolumeGeneric, ICON_28_VOLUME_GENERIC),
(
ActorIconImage::PostProcessVolume,
ICON_29_VOLUME_POST_PROCESS,
),
] {
actor_materials.insert(
image,
materials.add(StandardMaterial {
base_color_texture: Some(texture.clone()),
alpha_mode: AlphaMode::Blend,
unlit: true,
cull_mode: None,
fog_enabled: false,
depth_bias: -40.0,
..default()
}),
);
}
commands.insert_resource(ActorIconAssets {
mesh,
materials: actor_materials,
});
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn sync_actor_icons(
display: Res<ViewportDisplayMode>,
settings: Res<EditorVisualizationSettings>,
ui_state: Res<UiState>,
assets: Res<ActorIconAssets>,
mut commands: Commands,
mut icons: Query<(
Entity,
&ActorIconProxy,
&MeshMaterial3d<StandardMaterial>,
&mut Transform,
)>,
camera_query: Query<(&Camera, &GlobalTransform, Option<&Projection>), With<EditorCamera>>,
actor_roots: Query<
(
Entity,
&GlobalTransform,
Option<&ActorKind>,
Option<&LightDesc>,
Has<Primitive>,
Option<&StaticMeshRenderer>,
Has<ModelRef>,
Has<PrefabRef>,
Has<PrefabInstance>,
Has<PlayerSpawn>,
Has<WeaponSpawn>,
Has<TriggerVolume>,
Has<PostProcessVolumeDesc>,
Has<TeamSpawn>,
Has<ObjectiveMarker>,
),
(With<LevelObject>, Without<EditorOnly>),
>,
physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>,
) {
let camera = active_editor_camera(&camera_query);
let mut desired = HashMap::new();
if !display.clean_game_view && settings.enabled && settings.show_actor_icons {
for (
target,
global,
kind,
light,
has_primitive,
static_mesh_renderer,
has_model_ref,
has_prefab_ref,
has_prefab_instance,
has_player_spawn,
has_weapon_spawn,
has_trigger_volume,
has_post_process_volume,
has_team_spawn,
has_objective,
) in &actor_roots
{
let components = ActorIconComponents {
kind: kind.copied(),
light_kind: light.map(|light| light.kind),
has_primitive,
has_static_mesh_renderer: static_mesh_renderer
.is_some_and(has_resolved_static_mesh_slot),
has_model_ref,
has_prefab_ref,
has_prefab_instance,
has_player_spawn,
has_weapon_spawn,
has_trigger_volume,
has_post_process_volume,
has_team_spawn,
has_objective,
has_physics: physics.contains(target),
};
let spec = icon_for_actor_components(components);
if !icon_category_enabled(&settings, spec.category) {
continue;
}
let transform = desired_icon_transform(global, camera, ui_state.viewport_rect.height());
desired.insert(target, (spec.image, transform));
}
}
let mut existing_targets = HashSet::new();
for (entity, proxy, material, mut transform) in &mut icons {
let Some((image, desired_transform)) = desired.get(&proxy.target) else {
commands.entity(entity).despawn();
continue;
};
let expected_material = assets.materials.get(image);
if expected_material.is_none_or(|expected| material.0 != *expected) {
commands.entity(entity).despawn();
continue;
}
existing_targets.insert(proxy.target);
*transform = *desired_transform;
}
for (
target,
global,
kind,
light,
has_primitive,
static_mesh_renderer,
has_model_ref,
has_prefab_ref,
has_prefab_instance,
has_player_spawn,
has_weapon_spawn,
has_trigger_volume,
has_post_process_volume,
has_team_spawn,
has_objective,
) in &actor_roots
{
if existing_targets.contains(&target) {
continue;
}
let components = ActorIconComponents {
kind: kind.copied(),
light_kind: light.map(|light| light.kind),
has_primitive,
has_static_mesh_renderer: static_mesh_renderer
.is_some_and(has_resolved_static_mesh_slot),
has_model_ref,
has_prefab_ref,
has_prefab_instance,
has_player_spawn,
has_weapon_spawn,
has_trigger_volume,
has_post_process_volume,
has_team_spawn,
has_objective,
has_physics: physics.contains(target),
};
let spec = icon_for_actor_components(components);
if !desired.contains_key(&target) {
continue;
}
let Some(material) = assets.materials.get(&spec.image).cloned() else {
continue;
};
let transform = desired_icon_transform(global, camera, ui_state.viewport_rect.height());
commands.spawn((
EditorOnly,
RaytracingExcluded,
NoFrustumCulling,
Name::new("Actor Root Icon"),
ActorIconProxy { target },
Mesh3d(assets.mesh.clone()),
MeshMaterial3d(material),
transform,
Visibility::Visible,
ChildOf(target),
));
}
}
fn active_editor_camera<'a>(
camera_query: &'a Query<(&Camera, &GlobalTransform, Option<&Projection>), With<EditorCamera>>,
) -> Option<(&'a Camera, &'a GlobalTransform, Option<&'a Projection>)> {
camera_query
.iter()
.find(|(camera, _, _)| camera.is_active)
.or_else(|| camera_query.iter().next())
}
fn desired_icon_transform(
parent_global: &GlobalTransform,
camera: Option<(&Camera, &GlobalTransform, Option<&Projection>)>,
viewport_height: f32,
) -> Transform {
let translation = parent_global.translation();
let Some((_, camera_global, projection)) = camera else {
return Transform::from_translation(Vec3::ZERO).with_scale(Vec3::splat(0.5));
};
let size = icon_world_size(
translation,
camera_global,
projection,
viewport_height.max(1.0),
);
let desired_global = Transform {
translation,
rotation: camera_global.rotation(),
scale: Vec3::splat(size),
};
let local = parent_global.affine().inverse() * GlobalTransform::from(desired_global).affine();
Transform::from_matrix(Mat4::from(local))
}
fn icon_world_size(
target: Vec3,
camera_global: &GlobalTransform,
projection: Option<&Projection>,
viewport_height: f32,
) -> f32 {
let camera_position = camera_global.translation();
let pixels_to_view = ICON_TARGET_PIXELS / viewport_height;
let world_height = match projection {
Some(Projection::Perspective(projection)) => {
let distance = camera_position.distance(target).max(0.1);
2.0 * distance * (projection.fov * 0.5).tan()
}
Some(Projection::Orthographic(projection)) => projection.area.height().abs().max(0.1),
_ => {
let distance = camera_position.distance(target).max(0.1);
2.0 * distance * (DEFAULT_PERSPECTIVE_FOV * 0.5).tan()
}
};
(world_height * pixels_to_view).clamp(ICON_MIN_WORLD_SIZE, ICON_MAX_WORLD_SIZE)
}
fn icon_category_enabled(
settings: &EditorVisualizationSettings,
category: ActorIconCategory,
) -> bool {
match category {
ActorIconCategory::Actor => settings.show_actor_icon_actor,
ActorIconCategory::Mesh => settings.show_actor_icon_mesh,
ActorIconCategory::Light => settings.show_actor_icon_light,
ActorIconCategory::Physics => settings.show_actor_icon_physics,
ActorIconCategory::Gameplay => settings.show_actor_icon_gameplay,
ActorIconCategory::Volume => settings.show_actor_icon_volume,
ActorIconCategory::PrefabModel => settings.show_actor_icon_prefab_model,
ActorIconCategory::Debug => settings.show_actor_icon_debug,
}
}
fn has_resolved_static_mesh_slot(renderer: &StaticMeshRenderer) -> bool {
renderer.slots.iter().any(|slot| slot.mesh.is_resolved())
}
fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
let warning = ActorIconSpec {
image: ActorIconImage::Warning,
category: ActorIconCategory::Debug,
};
let Some(kind) = components.kind else {
return ActorIconSpec {
image: ActorIconImage::Missing,
category: ActorIconCategory::Debug,
};
};
match kind {
ActorKind::StaticMesh => {
if components.has_primitive || components.has_static_mesh_renderer {
ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
}
} else {
warning
}
}
ActorKind::ImportedModel => {
if components.has_model_ref {
ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::PrefabModel,
}
} else {
warning
}
}
ActorKind::Light => match components.light_kind {
Some(AuthoringLightKind::Point) => ActorIconSpec {
image: ActorIconImage::LightPoint,
category: ActorIconCategory::Light,
},
Some(AuthoringLightKind::Spot) => ActorIconSpec {
image: ActorIconImage::LightSpot,
category: ActorIconCategory::Light,
},
Some(AuthoringLightKind::Directional) => ActorIconSpec {
image: ActorIconImage::LightDirectional,
category: ActorIconCategory::Light,
},
None => warning,
},
ActorKind::PrefabAnchor => {
if components.has_prefab_ref || components.has_prefab_instance {
ActorIconSpec {
image: ActorIconImage::Generic,
category: ActorIconCategory::PrefabModel,
}
} else {
warning
}
}
ActorKind::PlayerSpawn => {
if components.has_player_spawn {
ActorIconSpec {
image: ActorIconImage::GameSpawn,
category: ActorIconCategory::Gameplay,
}
} else {
warning
}
}
ActorKind::WeaponSpawn => {
if components.has_weapon_spawn {
ActorIconSpec {
image: ActorIconImage::GameSpawn,
category: ActorIconCategory::Gameplay,
}
} else {
warning
}
}
ActorKind::TriggerVolume => {
if components.has_trigger_volume {
ActorIconSpec {
image: ActorIconImage::GameTrigger,
category: ActorIconCategory::Gameplay,
}
} else {
warning
}
}
ActorKind::PostProcessVolume => {
if components.has_post_process_volume {
ActorIconSpec {
image: ActorIconImage::PostProcessVolume,
category: ActorIconCategory::Volume,
}
} else {
warning
}
}
ActorKind::TeamSpawn => {
if components.has_team_spawn {
ActorIconSpec {
image: ActorIconImage::GameSpawn,
category: ActorIconCategory::Gameplay,
}
} else {
warning
}
}
ActorKind::Objective => {
if components.has_objective {
ActorIconSpec {
image: ActorIconImage::GameMarker,
category: ActorIconCategory::Gameplay,
}
} else {
warning
}
}
ActorKind::Empty => {
if components.has_physics {
ActorIconSpec {
image: ActorIconImage::PhysicsCollider,
category: ActorIconCategory::Physics,
}
} else {
ActorIconSpec {
image: ActorIconImage::Empty,
category: ActorIconCategory::Actor,
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_directional_light_icon() {
let spec = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::Light),
light_kind: Some(AuthoringLightKind::Directional),
..default()
});
assert_eq!(spec.image, ActorIconImage::LightDirectional);
assert_eq!(spec.category, ActorIconCategory::Light);
}
#[test]
fn unresolved_static_mesh_uses_warning_icon() {
let spec = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::StaticMesh),
..default()
});
assert_eq!(spec.image, ActorIconImage::Warning);
assert_eq!(spec.category, ActorIconCategory::Debug);
}
#[test]
fn empty_actor_with_collider_uses_physics_category() {
let spec = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::Empty),
has_physics: true,
..default()
});
assert_eq!(spec.image, ActorIconImage::PhysicsCollider);
assert_eq!(spec.category, ActorIconCategory::Physics);
}
}