dreamwell-engine 1.0.0

Dreamwell pure-logic engine library — transforms, hierarchy, canon pipeline, spatial math, hashing, tile rules, validation, waymark schema, material/lighting descriptors. No SpacetimeDB dependency.
Documentation
// Avatar core — engine-level avatar definition, FBX validation, and presentation config.
//
// This module defines the data model for avatars independent of GPU rendering.
// It provides:
// - AvatarSpec: authored avatar configuration (part of .dream scene or Waymark pack)
// - AvatarImportResult: validated FBX → avatar pipeline result with reality check
// - Presentation modes for DreamMatter skinning visualization
//
// The GPU layer (dreamwell-gpu/src/avatar.rs) consumes these types to drive
// skeleton instantiation and dreamlet materialization.

use serde::{Deserialize, Serialize};

/// Authored avatar specification — loadable from scene.json or Waymark pack.
/// Defines what FBX to load, how to present it, and physics parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvatarSpec {
    /// Unique avatar ID within the scene.
    pub id: String,
    /// Display name.
    #[serde(default)]
    pub name: String,
    /// Path to FBX file (relative to project root).
    pub fbx_path: String,
    /// Which animation stack to use (None = first).
    #[serde(default)]
    pub animation_stack: Option<String>,
    /// Presentation mode for DreamMatter skinning.
    #[serde(default)]
    pub presentation: PresentationMode,
    /// Spawn position in world space.
    #[serde(default)]
    pub spawn_position: [f32; 3],
    /// Physics capsule radius.
    #[serde(default = "default_capsule_radius")]
    pub capsule_radius: f32,
    /// Physics capsule total height.
    #[serde(default = "default_capsule_height")]
    pub capsule_height: f32,
    /// Movement speed (m/s).
    #[serde(default = "default_move_speed")]
    pub move_speed: f32,
    /// Dreamlet density per unit of mesh surface area.
    #[serde(default = "default_dreamlet_density")]
    pub dreamlet_density: f32,
    /// Base color for dreamlet particles.
    #[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(),
        }
    }
}

/// How DreamMatter particles present the avatar mesh.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PresentationMode {
    /// Particles particle around the mesh surface, forming a cloud silhouette.
    #[default]
    Particle,
    /// Particles converge to mesh vertices, creating a point-cloud skeleton.
    PointCloud,
    /// Particles settle on mesh surface, forming a solid-looking skin.
    Skin,
    /// Wireframe: particles trace mesh edges.
    Wireframe,
    /// Voxelized: particles snap to a 3D grid overlapping the mesh.
    Voxel,
}

impl PresentationMode {
    /// All available presentation modes (for UI cycling).
    pub const ALL: &'static [PresentationMode] = &[
        PresentationMode::Particle,
        PresentationMode::PointCloud,
        PresentationMode::Skin,
        PresentationMode::Wireframe,
        PresentationMode::Voxel,
    ];

    /// Display name for UI.
    pub fn label(&self) -> &'static str {
        match self {
            PresentationMode::Particle => "Particle",
            PresentationMode::PointCloud => "Point Cloud",
            PresentationMode::Skin => "Skin",
            PresentationMode::Wireframe => "Wireframe",
            PresentationMode::Voxel => "Voxel",
        }
    }

    /// Cycle to the next mode (wraps around).
    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()]
    }
}

/// Result of validating an FBX file for avatar use.
#[derive(Debug, Clone)]
pub struct AvatarImportValidation {
    /// True if the FBX is compatible with the Dreamwell avatar pipeline.
    pub compatible: bool,
    /// Number of mesh vertices found.
    pub vertex_count: usize,
    /// Number of skeleton bones found.
    pub bone_count: usize,
    /// Number of animation clips found.
    pub animation_count: usize,
    /// Duration of the primary animation in seconds.
    pub animation_duration: f32,
    /// Whether skin weights were found.
    pub has_skin_weights: bool,
    /// Errors that prevent use.
    pub errors: Vec<String>,
    /// Non-fatal warnings.
    pub warnings: Vec<String>,
}

/// Validate an FBX import result for avatar compatibility.
/// This is the "Reality Check" for FBX avatar imports.
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
}

/// Configuration for the footstep illusion effect.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FootstepConfig {
    /// Width of each step platform.
    #[serde(default = "default_step_width")]
    pub step_width: f32,
    /// Length of each step platform.
    #[serde(default = "default_step_length")]
    pub step_length: f32,
    /// Height of each step platform.
    #[serde(default = "default_step_height")]
    pub step_height: f32,
    /// Number of dreamlets per step.
    #[serde(default = "default_step_dreamlets")]
    pub dreamlets_per_step: u32,
    /// How long each step persists before fading (seconds).
    #[serde(default = "default_step_fade_time")]
    pub fade_time: f32,
    /// Step detection: vertical velocity threshold for foot-ground contact.
    #[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"]);
        // Wraps around
        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);
    }
}