dreamwell-matter 1.0.0

DreamMatter benchmark — GPU physics materialization demo and profiler
Documentation
// Scene Loader — loads DreamSceneV1 from .dream binary or scene.json files
// and populates a GameObjectScene for rendering.
//
// Clean Compute: all loading happens at import time, never on the hot path.
// The loader produces pre-sized, GPU-uploadable data structures.

use std::path::Path;

use dreamwell_engine::dream_file::{self, DreamSceneV1};
use dreamwell_engine::game_object::{
    GameObjectScene, GameObject, MeshBinding, PrimitiveKind, Transform,
};

/// Result of loading a .dream scene file. Contains all data needed to
/// initialize the GPU scene, physics world, and POI system.
pub struct LoadedScene {
    pub scene: DreamSceneV1,
    pub game_objects: GameObjectScene,
    pub warnings: Vec<String>,
}

/// Convert a DreamSceneV1 primitive name to PrimitiveKind.
fn parse_primitive(name: &str) -> Option<PrimitiveKind> {
    match name {
        "Cube" => Some(PrimitiveKind::Cube),
        "Sphere" => Some(PrimitiveKind::Sphere),
        "Cylinder" => Some(PrimitiveKind::Cylinder),
        "Cone" => Some(PrimitiveKind::Cone),
        "Torus" => Some(PrimitiveKind::Torus),
        "Capsule" => Some(PrimitiveKind::Capsule),
        "Plane" => Some(PrimitiveKind::Plane),
        "Pyramid" => Some(PrimitiveKind::Pyramid),
        "Wedge" => Some(PrimitiveKind::Wedge),
        _ => None,
    }
}

/// Convert Euler degrees [x, y, z] to quaternion [x, y, z, w].
/// Uses intrinsic ZYX rotation order (yaw-pitch-roll).
fn euler_degrees_to_quat(euler: [f32; 3]) -> [f32; 4] {
    let (rx, ry, rz) = (
        euler[0].to_radians() * 0.5,
        euler[1].to_radians() * 0.5,
        euler[2].to_radians() * 0.5,
    );
    let (sx, cx) = rx.sin_cos();
    let (sy, cy) = ry.sin_cos();
    let (sz, cz) = rz.sin_cos();
    [
        sx * cy * cz - cx * sy * sz,
        cx * sy * cz + sx * cy * sz,
        cx * cy * sz - sx * sy * cz,
        cx * cy * cz + sx * sy * sz,
    ]
}

/// Build a GameObjectScene from a DreamSceneV1.
/// This is the core scene construction — called once at load time.
fn build_game_object_scene(scene: &DreamSceneV1) -> GameObjectScene {
    let mut gos = GameObjectScene::new(scene.name.clone());

    for obj in &scene.objects {
        let color = obj.material.as_ref()
            .map(|m| m.base_color)
            .unwrap_or([0.7, 0.7, 0.7, 1.0]);

        let mesh = if let Some(ref prim_name) = obj.primitive {
            if let Some(kind) = parse_primitive(prim_name) {
                MeshBinding::Primitive { kind, color }
            } else {
                log::warn!("scene_loader: unknown primitive '{}' for object '{}'", prim_name, obj.id);
                continue;
            }
        } else if let Some(ref asset_key) = obj.asset_ref {
            MeshBinding::Custom { asset_key: asset_key.clone() }
        } else {
            // Container object (no visual mesh)
            MeshBinding::None
        };

        let go = GameObject {
            id: gos.objects.len() as u64,
            name: obj.name.clone(),
            transform: Transform {
                position: obj.position,
                rotation: euler_degrees_to_quat(obj.rotation),
                scale: obj.scale,
            },
            mesh,
            visible: true,
            parent_id: None,
            components: Vec::new(),
            property_tags: Vec::new(),
            topology_layer: 9,
        };
        gos.objects.push(go);
    }

    gos
}

/// Load a scene from a .dream binary file.
///
/// Performs reality check after deserialization. Returns error if the
/// scene fails validation (missing id, bad topology, etc.).
pub fn load_scene_from_dream(dream_path: &Path) -> Result<LoadedScene, String> {
    log::info!("Loading scene from {}", dream_path.display());
    let scene = dream_file::read_dream_file(dream_path)?;
    finalize_loaded_scene(scene)
}

/// Load a scene from a scene.json file (human-authored JSON).
/// Compiles to DreamSceneV1 inline — no need to write a .dream first.
pub fn load_scene_from_json(json_path: &Path) -> Result<LoadedScene, String> {
    log::info!("Compiling scene from {}", json_path.display());
    let json_str = std::fs::read_to_string(json_path)
        .map_err(|e| format!("scene_read:{e}"))?;
    let scene = dream_file::compile_scene_json(&json_str)?;
    finalize_loaded_scene(scene)
}

/// Load scene from either .dream or .json based on file extension.
pub fn load_scene_auto(path: &Path) -> Result<LoadedScene, String> {
    match path.extension().and_then(|e| e.to_str()) {
        Some("dream") => load_scene_from_dream(path),
        Some("json") => load_scene_from_json(path),
        _ => Err(format!("scene_loader:unsupported extension for {}", path.display())),
    }
}

/// Reality check + build game objects from a loaded DreamSceneV1.
fn finalize_loaded_scene(scene: DreamSceneV1) -> Result<LoadedScene, String> {
    let (warnings, errors) = dream_file::reality_check(&scene);
    if !errors.is_empty() {
        return Err(format!(
            "Scene '{}' failed reality check: {}",
            scene.scene_id,
            errors.join("; ")
        ));
    }
    for w in &warnings {
        log::warn!("scene_loader:{}: {w}", scene.scene_id);
    }

    log::info!(
        "Scene '{}': {} objects, {} dir lights, {} point lights, {} colliders, {} POIs",
        scene.scene_id,
        scene.objects.len(),
        scene.directional_lights.len(),
        scene.point_lights.len(),
        scene.colliders.len(),
        scene.pois.len(),
    );

    let game_objects = build_game_object_scene(&scene);

    Ok(LoadedScene {
        scene,
        game_objects,
        warnings,
    })
}

/// Resolve the project directory from CLI args or fallback to adjacent benchmark project.
pub fn resolve_project_dir(args: &[String]) -> Option<std::path::PathBuf> {
    // CLI explicit: --project-dir <path>
    if let Some(i) = args.iter().position(|a| a == "--project-dir") {
        if let Some(path_str) = args.get(i + 1) {
            let p = std::path::PathBuf::from(path_str);
            if p.join("tapestry.json").exists() || p.join("tapestry.dream").exists() {
                return Some(p);
            }
            log::warn!("--project-dir '{}' has no tapestry file", p.display());
        }
    }

    // Adjacent benchmark project (workspace sibling)
    let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let workspace = manifest_dir.parent()?.parent()?;
    let benchmark_project = workspace.join("dreamwell-benchmark-project");
    if benchmark_project.join("tapestry.json").exists() {
        return Some(benchmark_project);
    }

    None
}

/// Resolve scene path from project directory: tries .dream then .json.
pub fn resolve_scene_path(project_dir: &Path, scene_name: &str) -> Option<std::path::PathBuf> {
    let dream_path = project_dir.join("scenes").join(scene_name).join("scene.dream");
    if dream_path.exists() {
        return Some(dream_path);
    }
    let json_path = project_dir.join("scenes").join(scene_name).join("scene.json");
    if json_path.exists() {
        return Some(json_path);
    }
    None
}

/// List all available scenes in a project directory.
pub fn list_project_scenes(project_dir: &Path) -> Vec<String> {
    let scenes_dir = project_dir.join("scenes");
    if !scenes_dir.is_dir() {
        return Vec::new();
    }
    let mut scenes = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&scenes_dir) {
        for entry in entries.flatten() {
            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
                let name = entry.file_name().to_string_lossy().to_string();
                let has_scene = entry.path().join("scene.json").exists()
                    || entry.path().join("scene.dream").exists();
                if has_scene {
                    scenes.push(name);
                }
            }
        }
    }
    scenes.sort();
    scenes
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn euler_identity_to_quat() {
        let q = euler_degrees_to_quat([0.0, 0.0, 0.0]);
        assert!((q[0]).abs() < 1e-6);
        assert!((q[1]).abs() < 1e-6);
        assert!((q[2]).abs() < 1e-6);
        assert!((q[3] - 1.0).abs() < 1e-6);
    }

    #[test]
    fn parse_all_primitives() {
        assert!(parse_primitive("Cube").is_some());
        assert!(parse_primitive("Sphere").is_some());
        assert!(parse_primitive("Cylinder").is_some());
        assert!(parse_primitive("Cone").is_some());
        assert!(parse_primitive("Torus").is_some());
        assert!(parse_primitive("Capsule").is_some());
        assert!(parse_primitive("Plane").is_some());
        assert!(parse_primitive("Pyramid").is_some());
        assert!(parse_primitive("Wedge").is_some());
        assert!(parse_primitive("Unknown").is_none());
    }

    #[test]
    fn resolve_project_finds_benchmark() {
        let project = resolve_project_dir(&[]);
        // Should find dreamwell-benchmark-project adjacent to workspace
        if let Some(p) = &project {
            assert!(p.join("tapestry.json").exists() || p.join("tapestry.dream").exists());
        }
    }

    #[test]
    fn list_scenes_from_benchmark_project() {
        if let Some(project) = resolve_project_dir(&[]) {
            let scenes = list_project_scenes(&project);
            assert!(!scenes.is_empty(), "Expected at least 1 scene in benchmark project");
            assert!(scenes.contains(&"keynote".to_string()), "Expected keynote scene");
        }
    }

    #[test]
    fn load_keynote_from_json() {
        if let Some(project) = resolve_project_dir(&[]) {
            let path = project.join("scenes").join("keynote").join("scene.json");
            if path.exists() {
                let loaded = load_scene_from_json(&path).unwrap();
                assert_eq!(loaded.scene.scene_id, "keynote");
                assert!(!loaded.game_objects.objects.is_empty());
                assert!(loaded.warnings.is_empty() || true); // warnings OK
            }
        }
    }

    #[test]
    fn load_all_benchmark_scenes() {
        if let Some(project) = resolve_project_dir(&[]) {
            let scenes = list_project_scenes(&project);
            for name in &scenes {
                if let Some(path) = resolve_scene_path(&project, name) {
                    let result = load_scene_auto(&path);
                    assert!(result.is_ok(), "Failed to load scene '{name}': {:?}", result.err());
                    let loaded = result.unwrap();
                    assert_eq!(loaded.scene.scene_id, *name,
                        "scene_id mismatch for '{name}'");
                }
            }
        }
    }

    #[test]
    fn build_game_objects_from_keynote() {
        if let Some(project) = resolve_project_dir(&[]) {
            let path = project.join("scenes").join("keynote").join("scene.json");
            if path.exists() {
                let loaded = load_scene_from_json(&path).unwrap();
                // Keynote has 18 objects with primitives
                assert!(loaded.game_objects.objects.len() >= 10,
                    "Expected 10+ objects, got {}", loaded.game_objects.objects.len());
                // Check first object (ground) has correct scale
                let ground = &loaded.game_objects.objects[0];
                assert_eq!(ground.transform.scale, [80.0, 1.0, 80.0]);
                // Check it's a Plane primitive
                match &ground.mesh {
                    MeshBinding::Primitive { kind, .. } => {
                        assert_eq!(*kind, PrimitiveKind::Plane);
                    }
                    _ => panic!("Ground should be a Plane primitive"),
                }
            }
        }
    }

    #[test]
    fn dream_roundtrip_preserves_scene() {
        if let Some(project) = resolve_project_dir(&[]) {
            let json_path = project.join("scenes").join("keynote").join("scene.json");
            if !json_path.exists() { return; }

            // Load from JSON
            let loaded_json = load_scene_from_json(&json_path).unwrap();

            // Write to .dream
            let dir = std::env::temp_dir().join("dreamwell_loader_roundtrip");
            std::fs::create_dir_all(&dir).unwrap();
            let dream_path = dir.join("test.dream");
            dream_file::write_dream_file(&dream_path, &loaded_json.scene).unwrap();

            // Load from .dream
            let loaded_dream = load_scene_from_dream(&dream_path).unwrap();

            // Compare
            assert_eq!(loaded_json.scene.scene_id, loaded_dream.scene.scene_id);
            assert_eq!(loaded_json.game_objects.objects.len(),
                       loaded_dream.game_objects.objects.len());
            assert_eq!(loaded_json.scene.directional_lights.len(),
                       loaded_dream.scene.directional_lights.len());

            std::fs::remove_dir_all(&dir).ok();
        }
    }
}