885 lines
32 KiB
Rust
885 lines
32 KiB
Rust
//! Actor kind inference (migration only) and save-time validation.
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
use bevy::ecs::world::EntityRef;
|
|
use bevy::prelude::*;
|
|
|
|
use crate::{
|
|
animation_clip_source_index, animation_skeleton_source_index, brush_math::validate_brush,
|
|
ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject,
|
|
LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
|
|
ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
|
|
SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
|
AUDIO_CLIP_SUB_ASSET_ID,
|
|
};
|
|
|
|
/// Deterministic presentation/compatibility hint derived from authoring components.
|
|
///
|
|
/// Validation is component-driven; this priority only chooses an icon/default
|
|
/// label when an actor composes several compatible behaviors (for example a
|
|
/// rendered mesh with a light).
|
|
pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
|
|
entity.get::<LevelObject>()?;
|
|
if entity.get::<PrefabRef>().is_some() || entity.get::<PrefabInstance>().is_some() {
|
|
return Some(ActorKind::PrefabAnchor);
|
|
}
|
|
if entity.get::<SkinnedMeshRenderer>().is_some() {
|
|
return Some(ActorKind::SkinnedMesh);
|
|
}
|
|
if entity.get::<BrushDesc>().is_some() {
|
|
return Some(ActorKind::Brush);
|
|
}
|
|
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
|
|
return Some(ActorKind::StaticMesh);
|
|
}
|
|
if entity.get::<ModelRef>().is_some() {
|
|
return Some(ActorKind::ImportedModel);
|
|
}
|
|
if entity.get::<LightDesc>().is_some() {
|
|
return Some(ActorKind::Light);
|
|
}
|
|
if entity.get::<PlayerSpawn>().is_some() {
|
|
return Some(ActorKind::PlayerSpawn);
|
|
}
|
|
if entity.get::<WeaponSpawn>().is_some() {
|
|
return Some(ActorKind::WeaponSpawn);
|
|
}
|
|
if entity.get::<TriggerVolume>().is_some() {
|
|
return Some(ActorKind::TriggerVolume);
|
|
}
|
|
if entity.get::<PostProcessVolumeDesc>().is_some() {
|
|
return Some(ActorKind::PostProcessVolume);
|
|
}
|
|
if entity.get::<TeamSpawn>().is_some() {
|
|
return Some(ActorKind::TeamSpawn);
|
|
}
|
|
if entity.get::<ObjectiveMarker>().is_some() {
|
|
return Some(ActorKind::Objective);
|
|
}
|
|
if entity.get::<AudioSourceDesc>().is_some() {
|
|
return Some(ActorKind::AudioSource);
|
|
}
|
|
if entity.get::<AudioListenerDesc>().is_some() {
|
|
return Some(ActorKind::AudioListener);
|
|
}
|
|
if entity.get::<NavigationBounds>().is_some()
|
|
|| entity.get::<NavigationObstacle>().is_some()
|
|
|| entity.get::<NavigationArea>().is_some()
|
|
|| entity.get::<NavigationLink>().is_some()
|
|
{
|
|
return Some(ActorKind::Navigation);
|
|
}
|
|
Some(ActorKind::Empty)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ActorValidationError {
|
|
MissingActorKind,
|
|
MissingTransform,
|
|
BrushMissingDesc,
|
|
BrushHasPrimitive,
|
|
BrushHasStaticMeshRenderer,
|
|
BrushHasLight,
|
|
BrushHasModelRef,
|
|
InvalidBrushGeometry(String),
|
|
StaticMeshMissingPrimitive,
|
|
StaticMeshHasLight,
|
|
StaticMeshHasModelRef,
|
|
ImportedModelMissingModelRef,
|
|
ImportedModelHasPrimitive,
|
|
ImportedModelHasStaticMeshRenderer,
|
|
SkinnedMeshMissingRenderer,
|
|
SkinnedMeshInvalidRenderer,
|
|
SkinnedMeshHasPrimitive,
|
|
SkinnedMeshHasStaticMeshRenderer,
|
|
SkinnedMeshHasModelRef,
|
|
SkinnedMeshRendererActorKindMismatch,
|
|
ConflictingGeometrySources,
|
|
LightMissingLightDesc,
|
|
LightHasPrimitive,
|
|
LightHasModelRef,
|
|
LightHasStaticMeshRenderer,
|
|
PostProcessVolumeInvalidHalfExtents,
|
|
PostProcessVolumeInvalidBlendDistance,
|
|
PostProcessVolumeInvalidOverrideScalar,
|
|
PostProcessVolumeMissingDesc,
|
|
AudioSourceMissingDesc,
|
|
AudioSourceMissingClip,
|
|
AudioSourceInvalidClipReference,
|
|
AudioSourceInvalidGain,
|
|
AudioSourceInvalidPitch,
|
|
AudioSourceInvalidSpatialBlend,
|
|
AudioSourceInvalidAttenuation,
|
|
AudioSourceMissingBus,
|
|
AudioListenerMissingDesc,
|
|
AudioListenerInvalidEarGap,
|
|
AnimationControllerMissingSkinnedMeshRenderer,
|
|
AnimationControllerMissingSkeleton,
|
|
AnimationControllerInvalidSkeletonReference,
|
|
AnimationControllerEmptyStateId,
|
|
AnimationControllerDuplicateStateId,
|
|
AnimationControllerInvalidClipReference,
|
|
AnimationControllerInvalidStateSpeed,
|
|
AnimationControllerInvalidStateRange,
|
|
AnimationControllerInvalidCrossfade,
|
|
AnimationControllerMissingDefaultState,
|
|
AnimationControllerUnknownDefaultState,
|
|
InvalidNavigation(String),
|
|
}
|
|
|
|
fn is_finite_positive(v: f32) -> bool {
|
|
v.is_finite() && v > 0.0
|
|
}
|
|
|
|
fn is_finite_non_negative(v: f32) -> bool {
|
|
v.is_finite() && v >= 0.0
|
|
}
|
|
|
|
/// Validates authoring component sets for a level object before save or play.
|
|
pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> {
|
|
if entity.get::<LevelObject>().is_none() {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(_stored_kind) = entity.get::<ActorKind>() else {
|
|
return Err(ActorValidationError::MissingActorKind);
|
|
};
|
|
let kind = infer_actor_kind(entity).unwrap_or(ActorKind::Empty);
|
|
|
|
if entity.get::<Transform>().is_none() {
|
|
return Err(ActorValidationError::MissingTransform);
|
|
}
|
|
|
|
if let Some(source) = entity.get::<AudioSourceDesc>() {
|
|
validate_audio_source(source)?;
|
|
}
|
|
if let Some(listener) = entity.get::<AudioListenerDesc>() {
|
|
validate_audio_listener(listener)?;
|
|
}
|
|
if let Some(controller) = entity.get::<AnimationControllerDesc>() {
|
|
if entity.get::<SkinnedMeshRenderer>().is_none() {
|
|
return Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer);
|
|
}
|
|
validate_animation_controller(controller)?;
|
|
}
|
|
let geometry_source_count = usize::from(entity.get::<Primitive>().is_some())
|
|
+ usize::from(entity.get::<BrushDesc>().is_some())
|
|
+ usize::from(entity.get::<StaticMeshRenderer>().is_some())
|
|
+ usize::from(entity.get::<SkinnedMeshRenderer>().is_some())
|
|
+ usize::from(entity.get::<ModelRef>().is_some());
|
|
if geometry_source_count > 1 {
|
|
return Err(ActorValidationError::ConflictingGeometrySources);
|
|
}
|
|
if let Some(brush) = entity.get::<BrushDesc>() {
|
|
let report = validate_brush(brush);
|
|
if !report.is_valid() {
|
|
let message = report
|
|
.diagnostics
|
|
.into_iter()
|
|
.find(|diagnostic| {
|
|
diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error
|
|
})
|
|
.map(|diagnostic| diagnostic.message)
|
|
.unwrap_or_else(|| "Brush geometry is invalid.".to_string());
|
|
return Err(ActorValidationError::InvalidBrushGeometry(message));
|
|
}
|
|
}
|
|
if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() {
|
|
if renderer.path.trim().is_empty() {
|
|
return Err(ActorValidationError::SkinnedMeshInvalidRenderer);
|
|
}
|
|
}
|
|
if let Some(desc) = entity.get::<PostProcessVolumeDesc>() {
|
|
validate_post_process_volume(desc)?;
|
|
}
|
|
if entity.get::<NavigationBounds>().is_some()
|
|
|| entity.get::<NavigationObstacle>().is_some()
|
|
|| entity.get::<NavigationArea>().is_some()
|
|
|| entity.get::<NavigationLink>().is_some()
|
|
{
|
|
validate_navigation_actor(entity)?;
|
|
}
|
|
|
|
match &kind {
|
|
ActorKind::Brush => {
|
|
let Some(brush) = entity.get::<BrushDesc>() else {
|
|
return Err(ActorValidationError::BrushMissingDesc);
|
|
};
|
|
if entity.get::<Primitive>().is_some() {
|
|
return Err(ActorValidationError::BrushHasPrimitive);
|
|
}
|
|
if entity.get::<StaticMeshRenderer>().is_some() {
|
|
return Err(ActorValidationError::BrushHasStaticMeshRenderer);
|
|
}
|
|
if entity.get::<ModelRef>().is_some() {
|
|
return Err(ActorValidationError::BrushHasModelRef);
|
|
}
|
|
let _ = brush;
|
|
}
|
|
ActorKind::StaticMesh => {
|
|
let has_static_mesh_renderer = entity
|
|
.get::<StaticMeshRenderer>()
|
|
.is_some_and(|renderer| renderer.slots.iter().any(|slot| slot.mesh.is_resolved()));
|
|
if entity.get::<Primitive>().is_none() && !has_static_mesh_renderer {
|
|
return Err(ActorValidationError::StaticMeshMissingPrimitive);
|
|
}
|
|
if entity.get::<ModelRef>().is_some() {
|
|
return Err(ActorValidationError::StaticMeshHasModelRef);
|
|
}
|
|
}
|
|
ActorKind::SkinnedMesh => {
|
|
let Some(_renderer) = entity.get::<SkinnedMeshRenderer>() else {
|
|
return Err(ActorValidationError::SkinnedMeshMissingRenderer);
|
|
};
|
|
if entity.get::<Primitive>().is_some() {
|
|
return Err(ActorValidationError::SkinnedMeshHasPrimitive);
|
|
}
|
|
if entity.get::<StaticMeshRenderer>().is_some() {
|
|
return Err(ActorValidationError::SkinnedMeshHasStaticMeshRenderer);
|
|
}
|
|
if entity.get::<ModelRef>().is_some() {
|
|
return Err(ActorValidationError::SkinnedMeshHasModelRef);
|
|
}
|
|
}
|
|
ActorKind::ImportedModel => {
|
|
if entity.get::<ModelRef>().is_none() {
|
|
return Err(ActorValidationError::ImportedModelMissingModelRef);
|
|
}
|
|
if entity.get::<Primitive>().is_some() {
|
|
return Err(ActorValidationError::ImportedModelHasPrimitive);
|
|
}
|
|
if entity.get::<StaticMeshRenderer>().is_some() {
|
|
return Err(ActorValidationError::ImportedModelHasStaticMeshRenderer);
|
|
}
|
|
}
|
|
ActorKind::Light => {
|
|
if entity.get::<LightDesc>().is_none() {
|
|
return Err(ActorValidationError::LightMissingLightDesc);
|
|
}
|
|
// Other compatible authoring components may be composed with a light.
|
|
}
|
|
ActorKind::PostProcessVolume => {
|
|
let Some(desc) = entity.get::<PostProcessVolumeDesc>() else {
|
|
return Err(ActorValidationError::PostProcessVolumeMissingDesc);
|
|
};
|
|
let _ = desc;
|
|
}
|
|
ActorKind::AudioSource => {
|
|
if entity.get::<AudioSourceDesc>().is_none() {
|
|
return Err(ActorValidationError::AudioSourceMissingDesc);
|
|
}
|
|
}
|
|
ActorKind::AudioListener => {
|
|
if entity.get::<AudioListenerDesc>().is_none() {
|
|
return Err(ActorValidationError::AudioListenerMissingDesc);
|
|
}
|
|
}
|
|
ActorKind::Navigation => {}
|
|
ActorKind::Empty
|
|
| ActorKind::PrefabAnchor
|
|
| ActorKind::PlayerSpawn
|
|
| ActorKind::WeaponSpawn
|
|
| ActorKind::TriggerVolume
|
|
| ActorKind::TeamSpawn
|
|
| ActorKind::Objective => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_post_process_volume(desc: &PostProcessVolumeDesc) -> Result<(), ActorValidationError> {
|
|
if !is_finite_positive(desc.half_extents.x)
|
|
|| !is_finite_positive(desc.half_extents.y)
|
|
|| !is_finite_positive(desc.half_extents.z)
|
|
{
|
|
return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents);
|
|
}
|
|
if !is_finite_non_negative(desc.blend_distance) {
|
|
return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance);
|
|
}
|
|
let overrides = &desc.overrides;
|
|
if overrides
|
|
.exposure_ev100
|
|
.is_some_and(|value| !value.is_finite())
|
|
|| overrides
|
|
.fog_density
|
|
.is_some_and(|value| !value.is_finite() || value < 0.0)
|
|
{
|
|
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> {
|
|
let mut component_count = 0;
|
|
if let Some(bounds) = entity.get::<NavigationBounds>() {
|
|
component_count += 1;
|
|
validate_vec3_positive(bounds.half_extents, "navigation bounds")?;
|
|
bounds
|
|
.agent
|
|
.validate()
|
|
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
|
|
crate::navigation_generated_artifact_path(&bounds.artifact_path)
|
|
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
|
|
let mut sample_ids = HashSet::new();
|
|
for sample in &bounds.validation_samples {
|
|
let sample_id = sample.id.trim();
|
|
if sample_id.is_empty() {
|
|
return Err(ActorValidationError::InvalidNavigation(
|
|
"navigation validation sample ID must not be empty".into(),
|
|
));
|
|
}
|
|
if !sample_ids.insert(sample_id) {
|
|
return Err(ActorValidationError::InvalidNavigation(format!(
|
|
"navigation validation sample ID `{sample_id}` must be unique within its bounds"
|
|
)));
|
|
}
|
|
if !sample.start.is_finite() || !sample.end.is_finite() || sample.start == sample.end {
|
|
return Err(ActorValidationError::InvalidNavigation(format!(
|
|
"navigation validation sample `{sample_id}` endpoints must be finite and distinct"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
if let Some(obstacle) = entity.get::<NavigationObstacle>() {
|
|
component_count += 1;
|
|
validate_vec3_positive(obstacle.half_extents, "navigation obstacle")?;
|
|
}
|
|
if let Some(area) = entity.get::<NavigationArea>() {
|
|
component_count += 1;
|
|
validate_vec3_positive(area.half_extents, "navigation area")?;
|
|
if area.id.trim().is_empty() {
|
|
return Err(ActorValidationError::InvalidNavigation(
|
|
"navigation area ID must not be empty".into(),
|
|
));
|
|
}
|
|
if !is_finite_positive(area.cost) {
|
|
return Err(ActorValidationError::InvalidNavigation(
|
|
"navigation area cost must be finite and greater than zero".into(),
|
|
));
|
|
}
|
|
}
|
|
if let Some(link) = entity.get::<NavigationLink>() {
|
|
component_count += 1;
|
|
if !link.start.is_finite() || !link.end.is_finite() || link.start == link.end {
|
|
return Err(ActorValidationError::InvalidNavigation(
|
|
"navigation link endpoints must be finite and distinct".into(),
|
|
));
|
|
}
|
|
if !is_finite_positive(link.cost) {
|
|
return Err(ActorValidationError::InvalidNavigation(
|
|
"navigation link cost must be finite and greater than zero".into(),
|
|
));
|
|
}
|
|
}
|
|
if component_count != 1 {
|
|
return Err(ActorValidationError::InvalidNavigation(
|
|
"navigation actors must own exactly one bounds, obstacle, area, or link component"
|
|
.into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_vec3_positive(value: Vec3, label: &str) -> Result<(), ActorValidationError> {
|
|
if !value.is_finite() || value.min_element() <= 0.0 {
|
|
return Err(ActorValidationError::InvalidNavigation(format!(
|
|
"{label} half extents must be finite and greater than zero"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_audio_source(source: &AudioSourceDesc) -> Result<(), ActorValidationError> {
|
|
let Some(clip) = source.clip.as_ref() else {
|
|
return Err(ActorValidationError::AudioSourceMissingClip);
|
|
};
|
|
if !clip.is_resolved() || clip.sub_asset_id != AUDIO_CLIP_SUB_ASSET_ID {
|
|
return Err(ActorValidationError::AudioSourceInvalidClipReference);
|
|
}
|
|
if !source.gain_db.is_finite()
|
|
|| !(settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX).contains(&source.gain_db)
|
|
{
|
|
return Err(ActorValidationError::AudioSourceInvalidGain);
|
|
}
|
|
if !is_finite_positive(source.pitch) {
|
|
return Err(ActorValidationError::AudioSourceInvalidPitch);
|
|
}
|
|
if !source.spatial_blend.is_finite() || !(0.0..=1.0).contains(&source.spatial_blend) {
|
|
return Err(ActorValidationError::AudioSourceInvalidSpatialBlend);
|
|
}
|
|
let attenuation = source.attenuation;
|
|
if !is_finite_non_negative(attenuation.min_distance)
|
|
|| !attenuation.max_distance.is_finite()
|
|
|| attenuation.max_distance <= attenuation.min_distance
|
|
|| !is_finite_non_negative(attenuation.rolloff_factor)
|
|
{
|
|
return Err(ActorValidationError::AudioSourceInvalidAttenuation);
|
|
}
|
|
if source.bus.trim().is_empty() {
|
|
return Err(ActorValidationError::AudioSourceMissingBus);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_audio_listener(listener: &AudioListenerDesc) -> Result<(), ActorValidationError> {
|
|
if !is_finite_positive(listener.ear_gap) {
|
|
return Err(ActorValidationError::AudioListenerInvalidEarGap);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_animation_controller(
|
|
controller: &AnimationControllerDesc,
|
|
) -> Result<(), ActorValidationError> {
|
|
let Some(skeleton) = controller.skeleton.as_ref() else {
|
|
return Err(ActorValidationError::AnimationControllerMissingSkeleton);
|
|
};
|
|
if !skeleton.is_resolved() || animation_skeleton_source_index(&skeleton.sub_asset_id).is_none()
|
|
{
|
|
return Err(ActorValidationError::AnimationControllerInvalidSkeletonReference);
|
|
}
|
|
|
|
let mut state_ids = HashSet::with_capacity(controller.states.len());
|
|
for state in &controller.states {
|
|
if state.id.trim().is_empty() {
|
|
return Err(ActorValidationError::AnimationControllerEmptyStateId);
|
|
}
|
|
if !state_ids.insert(state.id.as_str()) {
|
|
return Err(ActorValidationError::AnimationControllerDuplicateStateId);
|
|
}
|
|
let clip_source_is_gltf = state
|
|
.clip
|
|
.source_path
|
|
.as_deref()
|
|
.and_then(|path| Path::new(path).extension())
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(|extension| {
|
|
extension.eq_ignore_ascii_case("gltf") || extension.eq_ignore_ascii_case("glb")
|
|
});
|
|
if !state.clip.is_resolved()
|
|
|| animation_clip_source_index(&state.clip.sub_asset_id).is_none()
|
|
|| !clip_source_is_gltf
|
|
{
|
|
return Err(ActorValidationError::AnimationControllerInvalidClipReference);
|
|
}
|
|
if !state.speed.is_finite() || state.speed <= 0.0 {
|
|
return Err(ActorValidationError::AnimationControllerInvalidStateSpeed);
|
|
}
|
|
if !is_finite_non_negative(state.range.start_seconds)
|
|
|| state
|
|
.range
|
|
.end_seconds
|
|
.is_some_and(|end| !end.is_finite() || end < 0.0 || end < state.range.start_seconds)
|
|
{
|
|
return Err(ActorValidationError::AnimationControllerInvalidStateRange);
|
|
}
|
|
}
|
|
|
|
if !is_finite_non_negative(controller.default_crossfade_seconds) {
|
|
return Err(ActorValidationError::AnimationControllerInvalidCrossfade);
|
|
}
|
|
if controller.default_state.trim().is_empty() {
|
|
return Err(ActorValidationError::AnimationControllerMissingDefaultState);
|
|
}
|
|
if !state_ids.contains(controller.default_state.as_str()) {
|
|
return Err(ActorValidationError::AnimationControllerUnknownDefaultState);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{
|
|
animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, AnimationPlaybackRange,
|
|
AnimationStateDesc, EditorAssetRef, PostProcessVolumeDesc,
|
|
};
|
|
|
|
fn level_entity(world: &mut World, bundle: impl Bundle) -> Entity {
|
|
world
|
|
.spawn((LevelObject, Transform::default(), bundle))
|
|
.id()
|
|
}
|
|
|
|
fn audio_source() -> AudioSourceDesc {
|
|
AudioSourceDesc {
|
|
clip: Some(
|
|
crate::EditorAssetRef::new("audio-id", AUDIO_CLIP_SUB_ASSET_ID, "Tone")
|
|
.with_source_path("assets/audio/tone.ogg"),
|
|
),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn animation_controller() -> AnimationControllerDesc {
|
|
AnimationControllerDesc {
|
|
skeleton: Some(
|
|
EditorAssetRef::new("model-id", animation_skeleton_sub_asset_id(0, "Rig"), "Rig")
|
|
.with_source_path("assets/models/animated.glb"),
|
|
),
|
|
states: vec![AnimationStateDesc {
|
|
id: "idle".into(),
|
|
label: "Idle".into(),
|
|
clip: EditorAssetRef::new(
|
|
"model-id",
|
|
animation_clip_sub_asset_id(0, "Idle"),
|
|
"Idle",
|
|
)
|
|
.with_source_path("assets/models/animated.glb"),
|
|
looping: true,
|
|
speed: 1.0,
|
|
range: AnimationPlaybackRange::default(),
|
|
}],
|
|
default_state: "idle".into(),
|
|
default_crossfade_seconds: 0.2,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_mesh_ok() {
|
|
let mut world = World::new();
|
|
let e = level_entity(&mut world, (ActorKind::StaticMesh, Primitive::default()));
|
|
assert!(validate_actor(world.entity(e)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_mesh_renderer_ok() {
|
|
let mut world = World::new();
|
|
let mut renderer = StaticMeshRenderer::default();
|
|
renderer.slots.push(crate::StaticMeshRendererEntry {
|
|
mesh: crate::StaticMeshAssetRef::new("asset-id", "Mesh0/Primitive0", "Crate"),
|
|
..Default::default()
|
|
});
|
|
let e = level_entity(&mut world, (ActorKind::StaticMesh, renderer));
|
|
assert!(validate_actor(world.entity(e)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_static_mesh_composes_with_light_and_uses_derived_hint() {
|
|
let mut world = World::new();
|
|
let e = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::StaticMesh,
|
|
Primitive::default(),
|
|
LightDesc::default(),
|
|
),
|
|
);
|
|
assert!(validate_actor(world.entity(e)).is_ok());
|
|
assert_eq!(
|
|
infer_actor_kind(world.entity(e)),
|
|
Some(ActorKind::StaticMesh)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_brush_ok() {
|
|
let mut world = World::new();
|
|
let e = level_entity(&mut world, (ActorKind::Brush, BrushDesc::default()));
|
|
assert!(validate_actor(world.entity(e)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_brush_rejects_static_mesh_renderer() {
|
|
let mut world = World::new();
|
|
let e = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::Brush,
|
|
BrushDesc::default(),
|
|
StaticMeshRenderer::default(),
|
|
),
|
|
);
|
|
assert_eq!(
|
|
validate_actor(world.entity(e)),
|
|
Err(ActorValidationError::ConflictingGeometrySources)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_brush_rejects_invalid_geometry() {
|
|
let mut world = World::new();
|
|
let mut brush = BrushDesc::default();
|
|
brush.faces[0].vertices.clear();
|
|
let e = level_entity(&mut world, (ActorKind::Brush, brush));
|
|
assert_eq!(
|
|
validate_actor(world.entity(e)),
|
|
Err(ActorValidationError::InvalidBrushGeometry(
|
|
"Face has fewer than three vertices.".into()
|
|
))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_post_process_volume_ok() {
|
|
let mut world = World::new();
|
|
let e = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::PostProcessVolume,
|
|
PostProcessVolumeDesc::default(),
|
|
),
|
|
);
|
|
assert!(validate_actor(world.entity(e)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_post_process_volume_rejects_bad_extents() {
|
|
let mut world = World::new();
|
|
let e = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::PostProcessVolume,
|
|
PostProcessVolumeDesc {
|
|
half_extents: Vec3::ZERO,
|
|
..Default::default()
|
|
},
|
|
),
|
|
);
|
|
assert_eq!(
|
|
validate_actor(world.entity(e)),
|
|
Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn infers_and_validates_audio_source_and_listener_actors() {
|
|
let mut world = World::new();
|
|
let source = level_entity(&mut world, audio_source());
|
|
let listener = level_entity(&mut world, AudioListenerDesc::default());
|
|
|
|
assert_eq!(
|
|
infer_actor_kind(world.entity(source)),
|
|
Some(ActorKind::AudioSource)
|
|
);
|
|
assert_eq!(
|
|
infer_actor_kind(world.entity(listener)),
|
|
Some(ActorKind::AudioListener)
|
|
);
|
|
world.entity_mut(source).insert(ActorKind::AudioSource);
|
|
world.entity_mut(listener).insert(ActorKind::AudioListener);
|
|
assert!(validate_actor(world.entity(source)).is_ok());
|
|
assert!(validate_actor(world.entity(listener)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn audio_source_requires_a_stable_clip_reference() {
|
|
let mut world = World::new();
|
|
let entity = level_entity(
|
|
&mut world,
|
|
(ActorKind::AudioSource, AudioSourceDesc::default()),
|
|
);
|
|
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AudioSourceMissingClip)
|
|
);
|
|
|
|
world.entity_mut(entity).insert(AudioSourceDesc {
|
|
clip: Some(crate::EditorAssetRef::new("audio-id", "mesh:0", "Wrong")),
|
|
..Default::default()
|
|
});
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AudioSourceInvalidClipReference)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_scalar_and_attenuation_ranges_are_validated() {
|
|
let mut world = World::new();
|
|
let entity = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::AudioSource,
|
|
AudioSourceDesc {
|
|
spatial_blend: 1.5,
|
|
..audio_source()
|
|
},
|
|
),
|
|
);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AudioSourceInvalidSpatialBlend)
|
|
);
|
|
|
|
let listener = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::AudioListener,
|
|
AudioListenerDesc {
|
|
ear_gap: 0.0,
|
|
..Default::default()
|
|
},
|
|
),
|
|
);
|
|
assert_eq!(
|
|
validate_actor(world.entity(listener)),
|
|
Err(ActorValidationError::AudioListenerInvalidEarGap)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn animation_controller_requires_skinned_renderer_and_resolved_skeleton() {
|
|
let mut world = World::new();
|
|
let entity = level_entity(&mut world, (ActorKind::SkinnedMesh, animation_controller()));
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer)
|
|
);
|
|
|
|
world
|
|
.entity_mut(entity)
|
|
.insert(SkinnedMeshRenderer::new("assets/models/animated.glb"));
|
|
let mut controller = animation_controller();
|
|
controller.skeleton = None;
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerMissingSkeleton)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.skeleton = Some(EditorAssetRef::new("model-id", "mesh:0", "Wrong"));
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerInvalidSkeletonReference)
|
|
);
|
|
|
|
world.entity_mut(entity).insert(animation_controller());
|
|
assert!(validate_actor(world.entity(entity)).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn animation_controller_validates_state_identity_and_clip_reference() {
|
|
let mut world = World::new();
|
|
let entity = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::SkinnedMesh,
|
|
SkinnedMeshRenderer::new("assets/models/animated.glb"),
|
|
animation_controller(),
|
|
),
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.states[0].id = " ".into();
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerEmptyStateId)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.states.push(controller.states[0].clone());
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerDuplicateStateId)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.states[0].clip =
|
|
EditorAssetRef::new("model-id", animation_clip_sub_asset_id(0, "Idle"), "Idle")
|
|
.with_source_path("assets/models/animated.fbx");
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerInvalidClipReference)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn animation_controller_validates_playback_ranges_and_default_state() {
|
|
let mut world = World::new();
|
|
let entity = level_entity(
|
|
&mut world,
|
|
(
|
|
ActorKind::SkinnedMesh,
|
|
SkinnedMeshRenderer::new("assets/models/animated.glb"),
|
|
animation_controller(),
|
|
),
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.states[0].speed = 0.0;
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerInvalidStateSpeed)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.states[0].range = AnimationPlaybackRange {
|
|
start_seconds: 1.0,
|
|
end_seconds: Some(0.5),
|
|
};
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerInvalidStateRange)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.default_crossfade_seconds = f32::NAN;
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerInvalidCrossfade)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.default_state.clear();
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerMissingDefaultState)
|
|
);
|
|
|
|
let mut controller = animation_controller();
|
|
controller.default_state = "missing".into();
|
|
world.entity_mut(entity).insert(controller);
|
|
assert_eq!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::AnimationControllerUnknownDefaultState)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_bounds_validate_generated_path_and_unique_samples() {
|
|
let mut world = World::new();
|
|
let mut bounds = NavigationBounds::for_actor("bounds-main");
|
|
bounds.validation_samples.push(crate::NavigationPathSample {
|
|
id: "entry-to-exit".into(),
|
|
start: Vec3::new(-2.0, 0.0, 0.0),
|
|
end: Vec3::new(2.0, 0.0, 0.0),
|
|
enabled: true,
|
|
});
|
|
let entity = level_entity(&mut world, (ActorKind::Navigation, bounds.clone()));
|
|
assert!(validate_actor(world.entity(entity)).is_ok());
|
|
|
|
bounds
|
|
.validation_samples
|
|
.push(bounds.validation_samples[0].clone());
|
|
world.entity_mut(entity).insert(bounds.clone());
|
|
assert!(matches!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::InvalidNavigation(message))
|
|
if message.contains("must be unique")
|
|
));
|
|
|
|
bounds.validation_samples.pop();
|
|
bounds.artifact_path = "assets/navigation/manual.nav.ron".into();
|
|
world.entity_mut(entity).insert(bounds);
|
|
assert!(matches!(
|
|
validate_actor(world.entity(entity)),
|
|
Err(ActorValidationError::InvalidNavigation(message))
|
|
if message.contains("assets/navigation/generated/")
|
|
));
|
|
}
|
|
}
|