1use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AvatarSpec {
18 pub id: String,
20 #[serde(default)]
22 pub name: String,
23 pub fbx_path: String,
25 #[serde(default)]
27 pub animation_stack: Option<String>,
28 #[serde(default)]
30 pub presentation: PresentationMode,
31 #[serde(default)]
33 pub spawn_position: [f32; 3],
34 #[serde(default = "default_capsule_radius")]
36 pub capsule_radius: f32,
37 #[serde(default = "default_capsule_height")]
39 pub capsule_height: f32,
40 #[serde(default = "default_move_speed")]
42 pub move_speed: f32,
43 #[serde(default = "default_dreamlet_density")]
45 pub dreamlet_density: f32,
46 #[serde(default = "default_dreamlet_color")]
48 pub dreamlet_color: [f32; 4],
49}
50
51fn default_capsule_radius() -> f32 {
52 0.3
53}
54fn default_capsule_height() -> f32 {
55 1.8
56}
57fn default_move_speed() -> f32 {
58 5.0
59}
60fn default_dreamlet_density() -> f32 {
61 64.0
62}
63fn default_dreamlet_color() -> [f32; 4] {
64 [0.8, 0.85, 0.9, 1.0]
65}
66
67impl Default for AvatarSpec {
68 fn default() -> Self {
69 Self {
70 id: "avatar".into(),
71 name: "Default Avatar".into(),
72 fbx_path: String::new(),
73 animation_stack: None,
74 presentation: PresentationMode::default(),
75 spawn_position: [0.0; 3],
76 capsule_radius: default_capsule_radius(),
77 capsule_height: default_capsule_height(),
78 move_speed: default_move_speed(),
79 dreamlet_density: default_dreamlet_density(),
80 dreamlet_color: default_dreamlet_color(),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
87pub enum PresentationMode {
88 #[default]
90 Particle,
91 PointCloud,
93 Skin,
95 Wireframe,
97 Voxel,
99}
100
101impl PresentationMode {
102 pub const ALL: &'static [PresentationMode] = &[
104 PresentationMode::Particle,
105 PresentationMode::PointCloud,
106 PresentationMode::Skin,
107 PresentationMode::Wireframe,
108 PresentationMode::Voxel,
109 ];
110
111 pub fn label(&self) -> &'static str {
113 match self {
114 PresentationMode::Particle => "Particle",
115 PresentationMode::PointCloud => "Point Cloud",
116 PresentationMode::Skin => "Skin",
117 PresentationMode::Wireframe => "Wireframe",
118 PresentationMode::Voxel => "Voxel",
119 }
120 }
121
122 pub fn next(self) -> Self {
124 let all = Self::ALL;
125 let idx = all.iter().position(|&m| m == self).unwrap_or(0);
126 all[(idx + 1) % all.len()]
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct AvatarImportValidation {
133 pub compatible: bool,
135 pub vertex_count: usize,
137 pub bone_count: usize,
139 pub animation_count: usize,
141 pub animation_duration: f32,
143 pub has_skin_weights: bool,
145 pub errors: Vec<String>,
147 pub warnings: Vec<String>,
149}
150
151pub fn reality_check_avatar(import: &crate::fbx::FbxImportResult) -> AvatarImportValidation {
154 let mut v = AvatarImportValidation {
155 compatible: true,
156 vertex_count: import.vertices.len(),
157 bone_count: import.bones.len(),
158 animation_count: import.animations.len(),
159 animation_duration: import.animations.first().map(|a| a.duration_seconds).unwrap_or(0.0),
160 has_skin_weights: import.skin_data.is_some(),
161 errors: Vec::new(),
162 warnings: Vec::new(),
163 };
164
165 if import.vertices.is_empty() {
166 v.errors
167 .push("avatar_rc:no_vertices — FBX contains no mesh data".into());
168 v.compatible = false;
169 }
170
171 if import.bones.is_empty() {
172 v.warnings
173 .push("avatar_rc:no_bones — FBX has no skeleton (static mesh only)".into());
174 }
175
176 if import.bones.len() > crate::fbx::MAX_FBX_BONES {
177 v.errors.push(format!(
178 "avatar_rc:bone_limit — {} bones exceeds limit of {}",
179 import.bones.len(),
180 crate::fbx::MAX_FBX_BONES
181 ));
182 v.compatible = false;
183 }
184
185 if import.animations.is_empty() {
186 v.warnings
187 .push("avatar_rc:no_animations — FBX has no animation clips".into());
188 }
189
190 if import.skin_data.is_none() && !import.bones.is_empty() {
191 v.warnings
192 .push("avatar_rc:no_skin_weights — skeleton present but no skin weights".into());
193 }
194
195 for (i, vert) in import.vertices.iter().enumerate() {
196 if !vert.position.iter().all(|f| f.is_finite()) {
197 v.errors.push(format!("avatar_rc:nan_vertex:{i}"));
198 v.compatible = false;
199 break;
200 }
201 }
202
203 v
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct FootstepConfig {
209 #[serde(default = "default_step_width")]
211 pub step_width: f32,
212 #[serde(default = "default_step_length")]
214 pub step_length: f32,
215 #[serde(default = "default_step_height")]
217 pub step_height: f32,
218 #[serde(default = "default_step_dreamlets")]
220 pub dreamlets_per_step: u32,
221 #[serde(default = "default_step_fade_time")]
223 pub fade_time: f32,
224 #[serde(default = "default_step_velocity_threshold")]
226 pub velocity_threshold: f32,
227}
228
229fn default_step_width() -> f32 {
230 0.4
231}
232fn default_step_length() -> f32 {
233 0.6
234}
235fn default_step_height() -> f32 {
236 0.08
237}
238fn default_step_dreamlets() -> u32 {
239 16
240}
241fn default_step_fade_time() -> f32 {
242 1.5
243}
244fn default_step_velocity_threshold() -> f32 {
245 0.1
246}
247
248impl Default for FootstepConfig {
249 fn default() -> Self {
250 Self {
251 step_width: default_step_width(),
252 step_length: default_step_length(),
253 step_height: default_step_height(),
254 dreamlets_per_step: default_step_dreamlets(),
255 fade_time: default_step_fade_time(),
256 velocity_threshold: default_step_velocity_threshold(),
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn avatar_spec_serde_roundtrip() {
267 let spec = AvatarSpec {
268 id: "hero".into(),
269 fbx_path: "assets/hero.fbx".into(),
270 presentation: PresentationMode::PointCloud,
271 ..Default::default()
272 };
273 let json = serde_json::to_string(&spec).unwrap();
274 let back: AvatarSpec = serde_json::from_str(&json).unwrap();
275 assert_eq!(back.id, "hero");
276 assert_eq!(back.presentation, PresentationMode::PointCloud);
277 }
278
279 #[test]
280 fn presentation_mode_cycle() {
281 let mut mode = PresentationMode::Particle;
282 let labels: Vec<&str> = (0..5)
283 .map(|_| {
284 let l = mode.label();
285 mode = mode.next();
286 l
287 })
288 .collect();
289 assert_eq!(labels, vec!["Particle", "Point Cloud", "Skin", "Wireframe", "Voxel"]);
290 assert_eq!(mode, PresentationMode::Particle);
292 }
293
294 #[test]
295 fn reality_check_empty_fbx() {
296 let import = crate::fbx::FbxImportResult::default();
297 let v = reality_check_avatar(&import);
298 assert!(!v.compatible);
299 assert!(v.errors.iter().any(|e| e.contains("no_vertices")));
300 }
301
302 #[test]
303 fn reality_check_valid_fbx() {
304 let import = crate::fbx::FbxImportResult {
305 vertices: vec![crate::fbx::fbx_vertex([0.0; 3], [0.0, 1.0, 0.0]); 100],
306 indices: (0..300).map(|i| i % 100).collect(),
307 bones: vec![crate::fbx::FbxBone {
308 name: "root".into(),
309 parent_index: None,
310 bind_pose: [
311 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,
312 ],
313 }],
314 animations: vec![crate::fbx::FbxAnimation {
315 name: "walk".into(),
316 duration_seconds: 1.0,
317 keyframes: Vec::new(),
318 }],
319 ..Default::default()
320 };
321 let v = reality_check_avatar(&import);
322 assert!(v.compatible);
323 assert_eq!(v.vertex_count, 100);
324 assert_eq!(v.bone_count, 1);
325 }
326
327 #[test]
328 fn footstep_config_defaults() {
329 let fc = FootstepConfig::default();
330 assert!((fc.step_width - 0.4).abs() < 0.001);
331 assert!((fc.fade_time - 1.5).abs() < 0.001);
332 }
333}