Skip to main content

dreamwell_engine/
avatar.rs

1// Avatar core — engine-level avatar definition, FBX validation, and presentation config.
2//
3// This module defines the data model for avatars independent of GPU rendering.
4// It provides:
5// - AvatarSpec: authored avatar configuration (part of .dream scene or Waymark pack)
6// - AvatarImportResult: validated FBX → avatar pipeline result with reality check
7// - Presentation modes for DreamMatter skinning visualization
8//
9// The GPU layer (dreamwell-gpu/src/avatar.rs) consumes these types to drive
10// skeleton instantiation and dreamlet materialization.
11
12use serde::{Deserialize, Serialize};
13
14/// Authored avatar specification — loadable from scene.json or Waymark pack.
15/// Defines what FBX to load, how to present it, and physics parameters.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AvatarSpec {
18    /// Unique avatar ID within the scene.
19    pub id: String,
20    /// Display name.
21    #[serde(default)]
22    pub name: String,
23    /// Path to FBX file (relative to project root).
24    pub fbx_path: String,
25    /// Which animation stack to use (None = first).
26    #[serde(default)]
27    pub animation_stack: Option<String>,
28    /// Presentation mode for DreamMatter skinning.
29    #[serde(default)]
30    pub presentation: PresentationMode,
31    /// Spawn position in world space.
32    #[serde(default)]
33    pub spawn_position: [f32; 3],
34    /// Physics capsule radius.
35    #[serde(default = "default_capsule_radius")]
36    pub capsule_radius: f32,
37    /// Physics capsule total height.
38    #[serde(default = "default_capsule_height")]
39    pub capsule_height: f32,
40    /// Movement speed (m/s).
41    #[serde(default = "default_move_speed")]
42    pub move_speed: f32,
43    /// Dreamlet density per unit of mesh surface area.
44    #[serde(default = "default_dreamlet_density")]
45    pub dreamlet_density: f32,
46    /// Base color for dreamlet particles.
47    #[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/// How DreamMatter particles present the avatar mesh.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
87pub enum PresentationMode {
88    /// Particles particle around the mesh surface, forming a cloud silhouette.
89    #[default]
90    Particle,
91    /// Particles converge to mesh vertices, creating a point-cloud skeleton.
92    PointCloud,
93    /// Particles settle on mesh surface, forming a solid-looking skin.
94    Skin,
95    /// Wireframe: particles trace mesh edges.
96    Wireframe,
97    /// Voxelized: particles snap to a 3D grid overlapping the mesh.
98    Voxel,
99}
100
101impl PresentationMode {
102    /// All available presentation modes (for UI cycling).
103    pub const ALL: &'static [PresentationMode] = &[
104        PresentationMode::Particle,
105        PresentationMode::PointCloud,
106        PresentationMode::Skin,
107        PresentationMode::Wireframe,
108        PresentationMode::Voxel,
109    ];
110
111    /// Display name for UI.
112    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    /// Cycle to the next mode (wraps around).
123    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/// Result of validating an FBX file for avatar use.
131#[derive(Debug, Clone)]
132pub struct AvatarImportValidation {
133    /// True if the FBX is compatible with the Dreamwell avatar pipeline.
134    pub compatible: bool,
135    /// Number of mesh vertices found.
136    pub vertex_count: usize,
137    /// Number of skeleton bones found.
138    pub bone_count: usize,
139    /// Number of animation clips found.
140    pub animation_count: usize,
141    /// Duration of the primary animation in seconds.
142    pub animation_duration: f32,
143    /// Whether skin weights were found.
144    pub has_skin_weights: bool,
145    /// Errors that prevent use.
146    pub errors: Vec<String>,
147    /// Non-fatal warnings.
148    pub warnings: Vec<String>,
149}
150
151/// Validate an FBX import result for avatar compatibility.
152/// This is the "Reality Check" for FBX avatar imports.
153pub 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/// Configuration for the footstep illusion effect.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct FootstepConfig {
209    /// Width of each step platform.
210    #[serde(default = "default_step_width")]
211    pub step_width: f32,
212    /// Length of each step platform.
213    #[serde(default = "default_step_length")]
214    pub step_length: f32,
215    /// Height of each step platform.
216    #[serde(default = "default_step_height")]
217    pub step_height: f32,
218    /// Number of dreamlets per step.
219    #[serde(default = "default_step_dreamlets")]
220    pub dreamlets_per_step: u32,
221    /// How long each step persists before fading (seconds).
222    #[serde(default = "default_step_fade_time")]
223    pub fade_time: f32,
224    /// Step detection: vertical velocity threshold for foot-ground contact.
225    #[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        // Wraps around
291        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}