use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvatarSpec {
pub id: String,
#[serde(default)]
pub name: String,
pub fbx_path: String,
#[serde(default)]
pub animation_stack: Option<String>,
#[serde(default)]
pub presentation: PresentationMode,
#[serde(default)]
pub spawn_position: [f32; 3],
#[serde(default = "default_capsule_radius")]
pub capsule_radius: f32,
#[serde(default = "default_capsule_height")]
pub capsule_height: f32,
#[serde(default = "default_move_speed")]
pub move_speed: f32,
#[serde(default = "default_dreamlet_density")]
pub dreamlet_density: f32,
#[serde(default = "default_dreamlet_color")]
pub dreamlet_color: [f32; 4],
}
fn default_capsule_radius() -> f32 {
0.3
}
fn default_capsule_height() -> f32 {
1.8
}
fn default_move_speed() -> f32 {
5.0
}
fn default_dreamlet_density() -> f32 {
64.0
}
fn default_dreamlet_color() -> [f32; 4] {
[0.8, 0.85, 0.9, 1.0]
}
impl Default for AvatarSpec {
fn default() -> Self {
Self {
id: "avatar".into(),
name: "Default Avatar".into(),
fbx_path: String::new(),
animation_stack: None,
presentation: PresentationMode::default(),
spawn_position: [0.0; 3],
capsule_radius: default_capsule_radius(),
capsule_height: default_capsule_height(),
move_speed: default_move_speed(),
dreamlet_density: default_dreamlet_density(),
dreamlet_color: default_dreamlet_color(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PresentationMode {
#[default]
Particle,
PointCloud,
Skin,
Wireframe,
Voxel,
}
impl PresentationMode {
pub const ALL: &'static [PresentationMode] = &[
PresentationMode::Particle,
PresentationMode::PointCloud,
PresentationMode::Skin,
PresentationMode::Wireframe,
PresentationMode::Voxel,
];
pub fn label(&self) -> &'static str {
match self {
PresentationMode::Particle => "Particle",
PresentationMode::PointCloud => "Point Cloud",
PresentationMode::Skin => "Skin",
PresentationMode::Wireframe => "Wireframe",
PresentationMode::Voxel => "Voxel",
}
}
pub fn next(self) -> Self {
let all = Self::ALL;
let idx = all.iter().position(|&m| m == self).unwrap_or(0);
all[(idx + 1) % all.len()]
}
}
#[derive(Debug, Clone)]
pub struct AvatarImportValidation {
pub compatible: bool,
pub vertex_count: usize,
pub bone_count: usize,
pub animation_count: usize,
pub animation_duration: f32,
pub has_skin_weights: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
pub fn reality_check_avatar(import: &crate::fbx::FbxImportResult) -> AvatarImportValidation {
let mut v = AvatarImportValidation {
compatible: true,
vertex_count: import.vertices.len(),
bone_count: import.bones.len(),
animation_count: import.animations.len(),
animation_duration: import.animations.first().map(|a| a.duration_seconds).unwrap_or(0.0),
has_skin_weights: import.skin_data.is_some(),
errors: Vec::new(),
warnings: Vec::new(),
};
if import.vertices.is_empty() {
v.errors
.push("avatar_rc:no_vertices — FBX contains no mesh data".into());
v.compatible = false;
}
if import.bones.is_empty() {
v.warnings
.push("avatar_rc:no_bones — FBX has no skeleton (static mesh only)".into());
}
if import.bones.len() > crate::fbx::MAX_FBX_BONES {
v.errors.push(format!(
"avatar_rc:bone_limit — {} bones exceeds limit of {}",
import.bones.len(),
crate::fbx::MAX_FBX_BONES
));
v.compatible = false;
}
if import.animations.is_empty() {
v.warnings
.push("avatar_rc:no_animations — FBX has no animation clips".into());
}
if import.skin_data.is_none() && !import.bones.is_empty() {
v.warnings
.push("avatar_rc:no_skin_weights — skeleton present but no skin weights".into());
}
for (i, vert) in import.vertices.iter().enumerate() {
if !vert.position.iter().all(|f| f.is_finite()) {
v.errors.push(format!("avatar_rc:nan_vertex:{i}"));
v.compatible = false;
break;
}
}
v
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FootstepConfig {
#[serde(default = "default_step_width")]
pub step_width: f32,
#[serde(default = "default_step_length")]
pub step_length: f32,
#[serde(default = "default_step_height")]
pub step_height: f32,
#[serde(default = "default_step_dreamlets")]
pub dreamlets_per_step: u32,
#[serde(default = "default_step_fade_time")]
pub fade_time: f32,
#[serde(default = "default_step_velocity_threshold")]
pub velocity_threshold: f32,
}
fn default_step_width() -> f32 {
0.4
}
fn default_step_length() -> f32 {
0.6
}
fn default_step_height() -> f32 {
0.08
}
fn default_step_dreamlets() -> u32 {
16
}
fn default_step_fade_time() -> f32 {
1.5
}
fn default_step_velocity_threshold() -> f32 {
0.1
}
impl Default for FootstepConfig {
fn default() -> Self {
Self {
step_width: default_step_width(),
step_length: default_step_length(),
step_height: default_step_height(),
dreamlets_per_step: default_step_dreamlets(),
fade_time: default_step_fade_time(),
velocity_threshold: default_step_velocity_threshold(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn avatar_spec_serde_roundtrip() {
let spec = AvatarSpec {
id: "hero".into(),
fbx_path: "assets/hero.fbx".into(),
presentation: PresentationMode::PointCloud,
..Default::default()
};
let json = serde_json::to_string(&spec).unwrap();
let back: AvatarSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "hero");
assert_eq!(back.presentation, PresentationMode::PointCloud);
}
#[test]
fn presentation_mode_cycle() {
let mut mode = PresentationMode::Particle;
let labels: Vec<&str> = (0..5)
.map(|_| {
let l = mode.label();
mode = mode.next();
l
})
.collect();
assert_eq!(labels, vec!["Particle", "Point Cloud", "Skin", "Wireframe", "Voxel"]);
assert_eq!(mode, PresentationMode::Particle);
}
#[test]
fn reality_check_empty_fbx() {
let import = crate::fbx::FbxImportResult::default();
let v = reality_check_avatar(&import);
assert!(!v.compatible);
assert!(v.errors.iter().any(|e| e.contains("no_vertices")));
}
#[test]
fn reality_check_valid_fbx() {
let import = crate::fbx::FbxImportResult {
vertices: vec![crate::fbx::fbx_vertex([0.0; 3], [0.0, 1.0, 0.0]); 100],
indices: (0..300).map(|i| i % 100).collect(),
bones: vec![crate::fbx::FbxBone {
name: "root".into(),
parent_index: None,
bind_pose: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
}],
animations: vec![crate::fbx::FbxAnimation {
name: "walk".into(),
duration_seconds: 1.0,
keyframes: Vec::new(),
}],
..Default::default()
};
let v = reality_check_avatar(&import);
assert!(v.compatible);
assert_eq!(v.vertex_count, 100);
assert_eq!(v.bone_count, 1);
}
#[test]
fn footstep_config_defaults() {
let fc = FootstepConfig::default();
assert!((fc.step_width - 0.4).abs() < 0.001);
assert!((fc.fade_time - 1.5).abs() < 0.001);
}
}