Blacksite/crates/shared/src/components.rs
Rbanh 9f95ba3082
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add brush authoring schema
2026-06-06 05:42:19 -04:00

1225 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use avian3d::prelude::ColliderConstructor;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
/// Stable persisted identity for an actor. Bevy [`Entity`] IDs are runtime-only.
#[derive(
Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ActorId(pub String);
impl ActorId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
/// User-facing actor display name, separate from Bevy's runtime [`Name`].
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ActorName(pub String);
impl ActorName {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
}
/// Stable identity for repeatable authoring items such as renderer slots.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ComponentInstanceId(pub String);
impl ComponentInstanceId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn is_empty(&self) -> bool {
self.0.trim().is_empty()
}
}
/// Editor-only component ordering metadata. Runtime systems must not depend on it.
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct InspectorOrder {
#[serde(default)]
pub component_type_names: Vec<String>,
#[serde(default)]
pub component_states: Vec<InspectorComponentState>,
}
/// Per-component inspector metadata that must survive scene save/load.
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct InspectorComponentState {
pub type_name: String,
#[serde(default = "default_true")]
pub active: bool,
}
impl Default for InspectorComponentState {
fn default() -> Self {
Self {
type_name: String::new(),
active: true,
}
}
}
impl InspectorOrder {
pub fn is_component_active(&self, type_name: &str) -> bool {
self.component_states
.iter()
.find(|state| state.type_name == type_name)
.map(|state| state.active)
.unwrap_or(true)
}
pub fn set_component_active(&mut self, type_name: impl Into<String>, active: bool) {
let type_name = type_name.into();
if let Some(state) = self
.component_states
.iter_mut()
.find(|state| state.type_name == type_name)
{
state.active = active;
} else {
self.component_states
.push(InspectorComponentState { type_name, active });
}
}
pub fn ensure_component_order(&mut self, present: &[&str]) {
for type_name in present {
if !self
.component_type_names
.iter()
.any(|existing| existing == type_name)
{
self.component_type_names.push((*type_name).to_string());
}
}
}
pub fn ordered_components<'a>(&self, present: &[&'a str]) -> Vec<&'a str> {
let mut ordered = Vec::new();
for type_name in &self.component_type_names {
if let Some(present_type) = present.iter().find(|present| **present == type_name) {
ordered.push(*present_type);
}
}
for type_name in present {
if !ordered.iter().any(|ordered_type| ordered_type == type_name) {
ordered.push(*type_name);
}
}
ordered
}
pub fn move_component(&mut self, type_name: &str, offset: isize, present: &[&str]) -> bool {
self.ensure_component_order(present);
let Some(index) = self
.component_type_names
.iter()
.position(|existing| existing == type_name)
else {
return false;
};
let target = (index as isize + offset)
.clamp(0, self.component_type_names.len() as isize - 1) as usize;
if target == index {
return false;
}
let item = self.component_type_names.remove(index);
self.component_type_names.insert(target, item);
true
}
}
pub fn inspector_component_active(order: Option<&InspectorOrder>, type_name: &str) -> bool {
order
.map(|order| order.is_component_active(type_name))
.unwrap_or(true)
}
pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive";
pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc";
pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc";
pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride";
pub const COMPONENT_LIGHT_DESC: &str = "shared::components::LightDesc";
pub const COMPONENT_RIGID_BODY_DESC: &str = "shared::components::RigidBodyDesc";
pub const COMPONENT_COLLIDER_DESC: &str = "shared::components::ColliderDesc";
pub const COMPONENT_PHYSICS_BODY: &str = "shared::components::PhysicsBody";
pub const COMPONENT_PLAYER_SPAWN: &str = "shared::components::PlayerSpawn";
pub const COMPONENT_WEAPON_SPAWN: &str = "shared::components::WeaponSpawn";
pub const COMPONENT_TRIGGER_VOLUME: &str = "shared::components::TriggerVolume";
pub const COMPONENT_TEAM_SPAWN: &str = "shared::components::TeamSpawn";
pub const COMPONENT_OBJECTIVE_MARKER: &str = "shared::components::ObjectiveMarker";
pub const COMPONENT_PREFAB_INSTANCE: &str = "shared::components::PrefabInstance";
pub const COMPONENT_POST_PROCESS_VOLUME: &str = "shared::components::PostProcessVolumeDesc";
pub const COMPONENT_PROJECT_SUN: &str = "shared::components::ProjectSun";
/// Reference to an imported content-browser asset or sub-asset.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct EditorAssetRef {
/// Stable project asset registry UUID string.
pub asset_id: String,
/// Stable imported sub-asset ID inside the generated artifact.
#[serde(default, alias = "mesh_label")]
pub sub_asset_id: String,
/// UI label cached for inspectors. The asset registry/artifact remains authoritative.
#[serde(default)]
pub label: String,
}
impl EditorAssetRef {
pub fn new(
asset_id: impl Into<String>,
sub_asset_id: impl Into<String>,
label: impl Into<String>,
) -> Self {
Self {
asset_id: asset_id.into(),
sub_asset_id: sub_asset_id.into(),
label: label.into(),
}
}
pub fn is_resolved(&self) -> bool {
!self.asset_id.trim().is_empty() && !self.sub_asset_id.trim().is_empty()
}
}
/// Marker for entities that belong to editable/savable level data.
#[derive(Component, Reflect, Default, Debug, Clone, Copy, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct LevelObject;
/// Runtime marker for helper meshes that must not enter Solari raytracing.
#[derive(Component, Reflect, Default, Debug, Clone, Copy)]
#[reflect(Component, Default, Debug)]
pub struct RaytracingExcluded;
/// Marks the player's spawn location for editor Play mode.
///
/// Place on a level object and set its `Transform` to choose where Play starts.
/// If none exists, the game default spawn is used.
#[derive(Component, Reflect, Default, Debug, Clone, Copy, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PlayerSpawn;
/// Marks the runtime/project default directional sun.
///
/// Scene-authored directional [`LightDesc`] entities can override this default
/// so levels can own their lighting without duplicating sun contribution.
#[derive(Component, Reflect, Default, Debug, Clone, Copy)]
#[reflect(Component, Default, Debug)]
pub struct ProjectSun;
/// Simple primitive meshes that can be authored without external assets.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum PrimitiveShape {
#[default]
Box,
Sphere,
Ramp,
}
/// Reflectable authoring primitive. Hydration turns this into `Mesh3d`.
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct Primitive {
pub shape: PrimitiveShape,
pub size: Vec3,
}
impl Primitive {
pub fn cuboid(size: Vec3) -> Self {
Self {
shape: PrimitiveShape::Box,
size,
}
}
pub fn sphere(radius: f32) -> Self {
Self {
shape: PrimitiveShape::Sphere,
size: Vec3::splat(radius * 2.0),
}
}
pub fn ramp(size: Vec3) -> Self {
Self {
shape: PrimitiveShape::Ramp,
size,
}
}
}
impl Default for Primitive {
fn default() -> Self {
Self::cuboid(Vec3::ONE)
}
}
/// Brush boolean role. Subtractive brushes are authored now but CSG is applied
/// by later tooling.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum BrushKind {
#[default]
Additive,
SubtractiveMarker,
}
/// Serializable convex brush plane. Plane equation is `normal.dot(point) = distance`.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct BrushPlaneDesc {
pub normal: Vec3,
pub distance: f32,
}
impl Default for BrushPlaneDesc {
fn default() -> Self {
Self {
normal: Vec3::Y,
distance: 0.0,
}
}
}
/// One polygon face on a convex authoring brush.
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct BrushFaceDesc {
#[serde(default)]
pub id: ComponentInstanceId,
pub plane: BrushPlaneDesc,
#[serde(default)]
pub vertices: Vec<Vec3>,
#[serde(default)]
pub material: Option<EditorAssetRef>,
#[serde(default)]
pub texture: Option<EditorAssetRef>,
#[serde(default)]
pub uv_offset: Vec2,
#[serde(default = "default_uv_scale")]
pub uv_scale: Vec2,
#[serde(default)]
pub uv_rotation: f32,
#[serde(default)]
pub smoothing_group: u32,
}
impl Default for BrushFaceDesc {
fn default() -> Self {
Self {
id: ComponentInstanceId::default(),
plane: BrushPlaneDesc::default(),
vertices: Vec::new(),
material: None,
texture: None,
uv_offset: Vec2::ZERO,
uv_scale: default_uv_scale(),
uv_rotation: 0.0,
smoothing_group: 0,
}
}
}
fn default_uv_scale() -> Vec2 {
Vec2::ONE
}
/// Convex brush authoring data. Hydration turns this into generated mesh
/// children while the scene stores only this component.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct BrushDesc {
pub kind: BrushKind,
#[serde(default)]
pub faces: Vec<BrushFaceDesc>,
#[serde(default = "default_true")]
pub cast_shadows: bool,
#[serde(default = "default_true")]
pub receive_shadows: bool,
}
impl BrushDesc {
pub fn cuboid(size: Vec3) -> Self {
let hx = size.x.max(0.001) * 0.5;
let hy = size.y.max(0.001) * 0.5;
let hz = size.z.max(0.001) * 0.5;
let faces = vec![
brush_face(
"face:+x",
Vec3::X,
vec![
Vec3::new(hx, -hy, -hz),
Vec3::new(hx, hy, -hz),
Vec3::new(hx, hy, hz),
Vec3::new(hx, -hy, hz),
],
),
brush_face(
"face:-x",
Vec3::NEG_X,
vec![
Vec3::new(-hx, -hy, hz),
Vec3::new(-hx, hy, hz),
Vec3::new(-hx, hy, -hz),
Vec3::new(-hx, -hy, -hz),
],
),
brush_face(
"face:+y",
Vec3::Y,
vec![
Vec3::new(-hx, hy, -hz),
Vec3::new(-hx, hy, hz),
Vec3::new(hx, hy, hz),
Vec3::new(hx, hy, -hz),
],
),
brush_face(
"face:-y",
Vec3::NEG_Y,
vec![
Vec3::new(-hx, -hy, hz),
Vec3::new(-hx, -hy, -hz),
Vec3::new(hx, -hy, -hz),
Vec3::new(hx, -hy, hz),
],
),
brush_face(
"face:+z",
Vec3::Z,
vec![
Vec3::new(hx, -hy, hz),
Vec3::new(hx, hy, hz),
Vec3::new(-hx, hy, hz),
Vec3::new(-hx, -hy, hz),
],
),
brush_face(
"face:-z",
Vec3::NEG_Z,
vec![
Vec3::new(-hx, -hy, -hz),
Vec3::new(-hx, hy, -hz),
Vec3::new(hx, hy, -hz),
Vec3::new(hx, -hy, -hz),
],
),
];
Self {
kind: BrushKind::Additive,
faces,
cast_shadows: true,
receive_shadows: true,
}
}
}
impl Default for BrushDesc {
fn default() -> Self {
Self::cuboid(Vec3::ONE)
}
}
fn brush_face(id: &str, normal: Vec3, vertices: Vec<Vec3>) -> BrushFaceDesc {
let distance = vertices
.first()
.map(|vertex| normal.dot(*vertex))
.unwrap_or(0.0);
BrushFaceDesc {
id: ComponentInstanceId::new(id),
plane: BrushPlaneDesc { normal, distance },
vertices,
..Default::default()
}
}
/// Legacy static mesh collision setting kept only for loading older scenes.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum StaticMeshColliderMode {
#[default]
None,
/// Generate an Avian triangle mesh collider from the loaded render mesh.
TrimeshFromMesh,
}
/// Backward-compatible alias for the previous static mesh reference name.
pub type StaticMeshAssetRef = EditorAssetRef;
/// One material slot on a static mesh renderer entry.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct StaticMeshMaterialSlot {
pub slot_index: usize,
pub name: String,
/// Bevy sub-asset label for the source material, if the importer provided one.
pub source_material_label: Option<String>,
/// Optional per-instance material override.
pub override_material: Option<MaterialDesc>,
}
/// One mesh draw slot owned by a [`StaticMeshRenderer`].
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshRenderSlot {
#[serde(default)]
pub id: ComponentInstanceId,
pub name: String,
pub mesh: EditorAssetRef,
#[serde(default)]
pub material: Option<EditorAssetRef>,
#[serde(default)]
pub local_transform: Transform,
#[serde(default = "default_true")]
pub visible: bool,
#[serde(default = "default_true")]
pub cast_shadows: bool,
#[serde(default = "default_true")]
pub receive_shadows: bool,
}
impl Default for MeshRenderSlot {
fn default() -> Self {
Self {
id: ComponentInstanceId::default(),
name: "Mesh".into(),
mesh: EditorAssetRef::default(),
material: None,
local_transform: Transform::default(),
visible: true,
cast_shadows: true,
receive_shadows: true,
}
}
}
/// Backward-compatible alias for old code paths while scenes migrate to slots.
pub type StaticMeshRendererEntry = MeshRenderSlot;
/// Reflectable static mesh renderer. Hydration turns entries into child `Mesh3d`
/// entities and source/default materials.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct StaticMeshRenderer {
#[serde(default, alias = "entries")]
pub slots: Vec<MeshRenderSlot>,
}
impl StaticMeshRenderer {
pub fn empty() -> Self {
Self { slots: Vec::new() }
}
pub fn single(slot: MeshRenderSlot) -> Self {
Self { slots: vec![slot] }
}
}
impl Default for StaticMeshRenderer {
fn default() -> Self {
Self::empty()
}
}
fn default_true() -> bool {
true
}
/// How a material should be resolved at runtime.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum MaterialShaderKind {
#[default]
StandardLit,
Unlit,
Custom,
}
/// Reference to a built-in or custom shader schema.
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShaderRefDesc {
pub kind: MaterialShaderKind,
#[serde(default)]
pub schema_path: Option<String>,
#[serde(default)]
pub shader_path: Option<String>,
}
impl Default for ShaderRefDesc {
fn default() -> Self {
Self {
kind: MaterialShaderKind::StandardLit,
schema_path: None,
shader_path: None,
}
}
}
/// Typed material parameter value exposed by a shader schema.
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum MaterialParameterValue {
Bool(bool),
Float(f32),
Vec2(Vec2),
Vec3(Vec3),
Color(ColorDesc),
Enum(String),
}
impl Default for MaterialParameterValue {
fn default() -> Self {
Self::Float(0.0)
}
}
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaterialParameter {
pub name: String,
pub value: MaterialParameterValue,
}
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaterialTextureBinding {
pub name: String,
pub texture: Option<EditorAssetRef>,
}
/// Per-render-slot material override stored on scene actors.
#[derive(Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaterialSlotOverride {
pub slot_id: ComponentInstanceId,
#[serde(default)]
pub base_material: Option<EditorAssetRef>,
pub material: MaterialDesc,
}
/// Actor-level material overrides. Shared material assets are edited by asset
/// inspectors, while actor inspectors write only to this component.
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaterialOverride {
#[serde(default)]
pub slots: Vec<MaterialSlotOverride>,
}
/// Serializable color description. This avoids coupling saved scenes to any
/// internal color representation details.
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, Serialize, Deserialize)]
pub struct ColorDesc {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl ColorDesc {
pub const fn srgb(r: f32, g: f32, b: f32) -> Self {
Self { r, g, b, a: 1.0 }
}
pub fn to_color(self) -> Color {
Color::srgba(self.r, self.g, self.b, self.a)
}
}
impl Default for ColorDesc {
fn default() -> Self {
Self::srgb(0.8, 0.8, 0.8)
}
}
/// Reflectable authoring material. Hydration turns this into `StandardMaterial`.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct MaterialDesc {
#[serde(default)]
pub shader: ShaderRefDesc,
pub base_color: ColorDesc,
pub metallic: f32,
pub roughness: f32,
/// Emissive radiance tint. Values are multiplied by `emissive_intensity`.
#[serde(default = "default_emissive_color")]
pub emissive_color: ColorDesc,
/// Emissive luminance multiplier in nits. `0.0` means non-emissive.
#[serde(default)]
pub emissive_intensity: f32,
/// Optional base color (albedo) texture, path relative to `assets/`.
pub base_color_texture: Option<String>,
/// Optional emissive texture, path relative to `assets/`.
#[serde(default)]
pub emissive_texture: Option<String>,
/// Optional normal map texture, path relative to `assets/`.
pub normal_map_texture: Option<String>,
/// Optional metallic/roughness texture, path relative to `assets/`.
pub metallic_roughness_texture: Option<String>,
/// When set, references a [`crate::MaterialAsset`] RON under `assets/materials/`.
#[serde(default)]
pub material_asset_path: Option<String>,
/// Shader-schema-driven scalar/vector/color values.
#[serde(default)]
pub parameters: Vec<MaterialParameter>,
/// Shader-schema-driven texture references.
#[serde(default)]
pub textures: Vec<MaterialTextureBinding>,
}
impl MaterialDesc {
pub fn new(base_color: ColorDesc, metallic: f32, roughness: f32) -> Self {
Self {
shader: ShaderRefDesc::default(),
base_color,
metallic,
roughness,
emissive_color: default_emissive_color(),
emissive_intensity: 0.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: Vec::new(),
textures: Vec::new(),
}
}
}
fn default_emissive_color() -> ColorDesc {
ColorDesc::srgb(1.0, 1.0, 1.0)
}
impl Default for MaterialDesc {
fn default() -> Self {
Self::new(ColorDesc::default(), 0.0, 0.65)
}
}
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
/// Hydration turns this into a `SceneRoot` on the entity.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct ModelRef {
/// Asset path relative to `assets/`, e.g. `models/foo.glb` or `models/foo.fbx`.
pub path: String,
/// Scene index within the model file (`#SceneN` for FBX; glTF scene index otherwise).
pub scene_index: usize,
}
impl ModelRef {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
scene_index: 0,
}
}
/// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`).
pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String {
format!("{path}#Scene{scene_index}")
}
}
/// Reflectable reference to a saved `.scn.ron` dynamic scene under `assets/`.
/// Hydration turns this into a `DynamicSceneRoot`.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PrefabRef {
/// Asset path relative to `assets/`, e.g. "prefabs/foo.scn.ron".
pub path: String,
}
impl PrefabRef {
pub fn new(path: impl Into<String>) -> Self {
Self { path: path.into() }
}
}
/// Marks a placed prefab instance with stable registry identity.
#[derive(Component, Reflect, Debug, Clone, Default, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PrefabInstance {
/// Stable asset registry UUID string.
pub asset_id: String,
/// Source prefab path relative to `assets/`.
pub source_path: String,
/// Optional per-instance transform override serialized as RON (v1: unused).
#[serde(default)]
pub overrides_ron: Option<String>,
}
impl PrefabInstance {
pub fn new(asset_id: impl Into<String>, source_path: impl Into<String>) -> Self {
Self {
asset_id: asset_id.into(),
source_path: source_path.into(),
overrides_ron: None,
}
}
}
/// Weapon pickup / spawn pad for FPS gameplay authoring.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct WeaponSpawn {
pub weapon_id: String,
}
/// Per-field post-process overrides (`None` = inherit project settings).
#[derive(Reflect, Debug, Clone, Default, Serialize, Deserialize)]
#[reflect(Default, Debug, Serialize, Deserialize)]
pub struct PostProcessVolumeOverrides {
pub exposure_ev100: Option<f32>,
pub fog_color: Option<ColorDesc>,
pub fog_density: Option<f32>,
pub bloom: Option<bool>,
pub taa: Option<bool>,
pub ssao: Option<bool>,
pub tonemapping_aces: Option<bool>,
pub atmosphere: Option<bool>,
pub hdr: Option<bool>,
}
impl PostProcessVolumeOverrides {
pub fn override_count(&self) -> u32 {
let mut count = 0u32;
if self.exposure_ev100.is_some() {
count += 1;
}
if self.fog_color.is_some() {
count += 1;
}
if self.fog_density.is_some() {
count += 1;
}
if self.bloom.is_some() {
count += 1;
}
if self.taa.is_some() {
count += 1;
}
if self.ssao.is_some() {
count += 1;
}
if self.tonemapping_aces.is_some() {
count += 1;
}
if self.atmosphere.is_some() {
count += 1;
}
if self.hdr.is_some() {
count += 1;
}
count
}
}
/// Scene-authored post-process volume (axis-aligned box in local space).
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PostProcessVolumeDesc {
pub half_extents: Vec3,
pub priority: i32,
pub blend_distance: f32,
pub overrides: PostProcessVolumeOverrides,
/// Path to `assets/post_fx/*.ron`, if any.
pub fullscreen_effect: Option<String>,
/// Optional `assets/rendering_profiles/*.ron` preset seed.
pub profile: Option<String>,
/// Editor-friendly label for HUD / Rendering panel.
pub label: Option<String>,
}
impl Default for PostProcessVolumeDesc {
fn default() -> Self {
Self {
half_extents: Vec3::new(4.0, 2.0, 4.0),
priority: 0,
blend_distance: 2.0,
overrides: PostProcessVolumeOverrides::default(),
fullscreen_effect: None,
profile: None,
label: None,
}
}
}
/// Axis-aligned trigger volume for gameplay events.
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct TriggerVolume {
pub half_extents: Vec3,
pub event_name: String,
}
impl Default for TriggerVolume {
fn default() -> Self {
Self {
half_extents: Vec3::new(2.0, 2.0, 2.0),
event_name: "trigger".into(),
}
}
}
/// Team spawn point marker.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct TeamSpawn {
pub team_id: u8,
}
/// Objective / game mode marker.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct ObjectiveMarker {
pub objective_id: String,
}
/// Stable sibling order among entities sharing the same parent in the hierarchy outliner.
#[derive(Component, Reflect, Default, Debug, Clone, Copy, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct HierarchySiblingIndex(pub i32);
/// Authoring visibility saved with the scene; hydration maps this to ECS [`Visibility`].
#[derive(Component, Reflect, Debug, Clone, Copy, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct EditorVisibility {
pub visible: bool,
}
impl Default for EditorVisibility {
fn default() -> Self {
Self { visible: true }
}
}
/// Declared role of a level object for inspector routing and save validation.
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Debug, PartialEq, Serialize, Deserialize)]
pub enum ActorKind {
Empty,
Brush,
StaticMesh,
ImportedModel,
Light,
PrefabAnchor,
PlayerSpawn,
WeaponSpawn,
TriggerVolume,
PostProcessVolume,
TeamSpawn,
Objective,
}
/// Inspector upper bound for point/spot [`LightDesc::intensity`] (lumens).
///
/// Bevy's `PointLight` / `SpotLight` default is `light_consts::lumens::VERY_LARGE_CINEMA_LIGHT`
/// (1M lumens) for outdoor exposure (~1215 EV100). This cap is 2× that reference so authored
/// lights can exceed the old 200k ceiling without unbounded sliders.
pub const AUTHORING_POINT_SPOT_LUMENS_MAX: f32 = 2_000_000.0;
/// Inspector upper bound for directional [`LightDesc::intensity`] (lux).
pub const AUTHORING_DIRECTIONAL_LUX_MAX: f32 = 150_000.0;
/// Serializable light kinds for editor-authored lighting.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum AuthoringLightKind {
#[default]
Point,
Spot,
Directional,
}
/// Reflectable authoring light. Hydration turns this into Bevy light components.
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct LightDesc {
pub kind: AuthoringLightKind,
pub color: ColorDesc,
/// Lumens (point/spot) or lux (directional).
pub intensity: f32,
/// Attenuation range in meters (point/spot).
pub range: f32,
pub shadows: bool,
/// Spot inner cone half-angle in degrees.
#[serde(default = "default_spot_inner_angle_deg")]
pub inner_angle_deg: f32,
/// Spot outer cone half-angle in degrees.
#[serde(default = "default_spot_outer_angle_deg")]
pub outer_angle_deg: f32,
}
fn default_spot_inner_angle_deg() -> f32 {
25.0
}
fn default_spot_outer_angle_deg() -> f32 {
35.0
}
impl Default for LightDesc {
fn default() -> Self {
Self::for_kind(AuthoringLightKind::Point)
}
}
impl LightDesc {
/// Sensible defaults when spawning a builtin light from the asset browser.
pub fn for_kind(kind: AuthoringLightKind) -> Self {
match kind {
AuthoringLightKind::Point => Self {
kind,
color: ColorDesc::srgb(1.0, 0.95, 0.85),
intensity: 25_000.0,
range: 25.0,
shadows: true,
inner_angle_deg: default_spot_inner_angle_deg(),
outer_angle_deg: default_spot_outer_angle_deg(),
},
AuthoringLightKind::Spot => Self {
kind,
color: ColorDesc::srgb(1.0, 0.95, 0.85),
intensity: 30_000.0,
range: 35.0,
shadows: true,
inner_angle_deg: default_spot_inner_angle_deg(),
outer_angle_deg: default_spot_outer_angle_deg(),
},
AuthoringLightKind::Directional => Self {
kind,
color: ColorDesc::srgb(1.0, 0.95, 0.85),
// Matches `ProjectSettings::rendering.sun_illuminance` default; spawn code
// refreshes this from live project settings when placing in the scene.
intensity: 100_000.0,
range: 0.0,
shadows: true,
inner_angle_deg: default_spot_inner_angle_deg(),
outer_angle_deg: default_spot_outer_angle_deg(),
},
}
}
}
/// Runtime body type in a serialized-friendly form.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum AuthoringRigidBody {
#[default]
Static,
Kinematic,
Dynamic,
}
/// Reflectable authoring rigid body separated from collider geometry.
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct RigidBodyDesc {
pub body: AuthoringRigidBody,
}
impl Default for RigidBodyDesc {
fn default() -> Self {
Self {
body: AuthoringRigidBody::Static,
}
}
}
/// Mesh collider cooking options for imported static mesh assets.
#[derive(Reflect, Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum MeshColliderCooking {
#[default]
Default,
}
/// Serializable collider shape independent of rigid body settings.
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
pub enum ColliderShapeDesc {
Cuboid {
x_length: f32,
y_length: f32,
z_length: f32,
},
Sphere {
radius: f32,
},
Capsule {
radius: f32,
height: f32,
},
StaticMesh {
meshes: Vec<EditorAssetRef>,
cooking: MeshColliderCooking,
},
}
impl Default for ColliderShapeDesc {
fn default() -> Self {
Self::Cuboid {
x_length: 1.0,
y_length: 1.0,
z_length: 1.0,
}
}
}
impl ColliderShapeDesc {
pub fn static_mesh(meshes: Vec<EditorAssetRef>) -> Self {
Self::StaticMesh {
meshes,
cooking: MeshColliderCooking::Default,
}
}
pub fn to_constructor(&self) -> Option<ColliderConstructor> {
match self {
ColliderShapeDesc::Cuboid {
x_length,
y_length,
z_length,
} => Some(ColliderConstructor::Cuboid {
x_length: *x_length,
y_length: *y_length,
z_length: *z_length,
}),
ColliderShapeDesc::Sphere { radius } => {
Some(ColliderConstructor::Sphere { radius: *radius })
}
ColliderShapeDesc::Capsule { radius, height } => Some(ColliderConstructor::Capsule {
radius: *radius,
height: *height,
}),
ColliderShapeDesc::StaticMesh { .. } => None,
}
}
}
/// Reflectable authoring collider. Static mesh colliders are hydrated on
/// generated mesh children so they can reuse Bevy's mesh-to-collider path.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct ColliderDesc {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub is_trigger: bool,
pub shape: ColliderShapeDesc,
}
impl ColliderDesc {
pub fn static_cuboid(size: Vec3) -> Self {
Self {
enabled: true,
is_trigger: false,
shape: ColliderShapeDesc::Cuboid {
x_length: size.x,
y_length: size.y,
z_length: size.z,
},
}
}
pub fn static_sphere(radius: f32) -> Self {
Self {
enabled: true,
is_trigger: false,
shape: ColliderShapeDesc::Sphere { radius },
}
}
pub fn static_mesh(meshes: Vec<EditorAssetRef>) -> Self {
Self {
enabled: true,
is_trigger: false,
shape: ColliderShapeDesc::static_mesh(meshes),
}
}
}
impl Default for ColliderDesc {
fn default() -> Self {
Self::static_cuboid(Vec3::ONE)
}
}
/// Reflectable authoring physics. Hydration turns this into `RigidBody` and
/// Avian's reflectable `ColliderConstructor` component.
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PhysicsBody {
pub body: AuthoringRigidBody,
pub collider: ColliderConstructor,
}
impl PhysicsBody {
pub fn static_cuboid(size: Vec3) -> Self {
Self {
body: AuthoringRigidBody::Static,
collider: ColliderConstructor::Cuboid {
x_length: size.x,
y_length: size.y,
z_length: size.z,
},
}
}
pub fn static_sphere(radius: f32) -> Self {
Self {
body: AuthoringRigidBody::Static,
collider: ColliderConstructor::Sphere { radius },
}
}
}
impl Default for PhysicsBody {
fn default() -> Self {
Self::static_cuboid(Vec3::ONE)
}
}
#[cfg(test)]
mod tests {
use super::ModelRef;
use bevy::light::light_consts;
#[test]
fn point_spot_lumen_max_covers_bevy_cinema_reference() {
assert!(
super::AUTHORING_POINT_SPOT_LUMENS_MAX >= light_consts::lumens::VERY_LARGE_CINEMA_LIGHT
);
}
#[test]
fn fbx_scene_asset_path_format() {
assert_eq!(
ModelRef::fbx_scene_asset_path("models/foo.fbx", 0),
"models/foo.fbx#Scene0"
);
assert_eq!(
ModelRef::fbx_scene_asset_path("models/foo.fbx", 2),
"models/foo.fbx#Scene2"
);
}
}