bevy_symbios_shape 0.5.0

Bevy integration for Symbios Shape.
Documentation
//! Asset routing table for CGA shape terminals.
//!
//! [`ShapeRegistry`] is a Bevy [`Resource`] that maps string IDs (as produced by
//! the `I("mesh_id")` operation in `symbios-shape`) to pre-loaded Bevy asset handles.
//!
//! # Resolution order
//!
//! When the spawner resolves a [`Terminal`]:
//! 1. If `mesh_id` is registered → use the registered [`Handle<Scene>`].
//! 2. Otherwise → fall back to a procedural mesh via [`build_profiled_mesh`].
//!
//! For materials:
//! 1. If `terminal.material` is `Some(id)` and `id` is registered → use registered handle.
//! 2. Otherwise → fall back to the registry's default material.
//!
//! [`Terminal`]: symbios_shape::Terminal
//! [`build_profiled_mesh`]: crate::mesh::build_profiled_mesh

use bevy::platform::collections::{HashMap, HashSet};
use bevy::prelude::{Handle, Resource, Scene, StandardMaterial};

/// Maps `symbios-shape` string IDs to Bevy asset handles.
///
/// Insert this as a [`Resource`] (it is automatically inserted by [`BevySymbiosShapePlugin`]).
/// Populate it in a startup system before calling [`SpawnShapeExt::spawn_shape`].
///
/// [`BevySymbiosShapePlugin`]: crate::BevySymbiosShapePlugin
/// [`SpawnShapeExt::spawn_shape`]: crate::spawner::SpawnShapeExt::spawn_shape
#[derive(Resource, Default)]
pub struct ShapeRegistry {
    meshes: HashMap<String, Handle<Scene>>,
    materials: HashMap<String, Handle<StandardMaterial>>,
    pub default_material: Option<Handle<StandardMaterial>>,
    stretch_uv_materials: HashSet<String>,
    stretch_uv_meshes: HashSet<String>,
}

impl ShapeRegistry {
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers a GLTF scene handle for the given mesh ID.
    ///
    /// # Example
    /// ```ignore
    /// registry.register_mesh("Window", asset_server.load("window.glb#Scene0"));
    /// ```
    pub fn register_mesh(&mut self, id: impl Into<String>, handle: Handle<Scene>) {
        self.meshes.insert(id.into(), handle);
    }

    /// Registers a [`StandardMaterial`] handle for the given material ID.
    pub fn register_material(&mut self, id: impl Into<String>, handle: Handle<StandardMaterial>) {
        self.materials.insert(id.into(), handle);
    }

    /// Flags a material ID so any terminal using it gets UVs in `[0, 1]`
    /// (stretched across the face) instead of the default world-space tiling
    /// (1 UV unit = 1 world unit).
    ///
    /// Use this for textures that should fit *exactly once* per face —
    /// stained-glass windows, signage, decals — where world-space tiling
    /// would crop or repeat the image arbitrarily.
    pub fn register_stretch_material(&mut self, id: impl Into<String>) {
        self.stretch_uv_materials.insert(id.into());
    }

    /// Flags a mesh ID so any terminal using it gets UVs in `[0, 1]` instead
    /// of world-space tiling. See [`register_stretch_material`] for when to
    /// use this; this overload lets you key the override on `mesh_id` (e.g.
    /// `"Window"`) when the material ID isn't a stable carrier.
    ///
    /// [`register_stretch_material`]: ShapeRegistry::register_stretch_material
    pub fn register_stretch_mesh(&mut self, id: impl Into<String>) {
        self.stretch_uv_meshes.insert(id.into());
    }

    /// Returns `true` if a terminal with this `(mesh_id, material_id)` pair
    /// should have its procedural-mesh UVs stretched into `[0, 1]` rather
    /// than tiled in world space. Consulted by
    /// [`SpawnShapeExt::spawn_shape`][crate::spawner::SpawnShapeExt::spawn_shape]
    /// before building a procedural mesh.
    pub fn should_stretch_uvs(&self, mesh_id: &str, mat_id: Option<&str>) -> bool {
        self.stretch_uv_meshes.contains(mesh_id)
            || mat_id.is_some_and(|m| self.stretch_uv_materials.contains(m))
    }

    /// Returns the scene handle for `id`, if registered.
    pub fn get_mesh(&self, id: &str) -> Option<&Handle<Scene>> {
        self.meshes.get(id)
    }

    /// Returns the material handle for `id`, if registered.
    /// Falls back to [`default_material`] if `id` is `None` or not found.
    ///
    /// [`default_material`]: ShapeRegistry::default_material
    pub fn resolve_material(&self, id: Option<&str>) -> Option<Handle<StandardMaterial>> {
        if let Some(h) = id.and_then(|name| self.materials.get(name)) {
            return Some(h.clone());
        }
        self.default_material.clone()
    }
}

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

    fn make_app() -> App {
        let mut app = App::new();
        app.add_plugins(bevy::asset::AssetPlugin::default());
        app
    }

    #[test]
    fn register_and_retrieve_mesh() {
        let mut app = make_app();
        app.init_resource::<Assets<Scene>>();
        let world = app.world_mut();
        let mut scenes = world.resource_mut::<Assets<Scene>>();
        let handle: Handle<Scene> = scenes.add(Scene::new(World::new()));

        let mut registry = ShapeRegistry::new();
        registry.register_mesh("Window", handle.clone());

        assert!(registry.get_mesh("Window").is_some());
        assert!(registry.get_mesh("Door").is_none());
    }

    #[test]
    fn resolve_material_falls_back_to_default() {
        let mut app = make_app();
        app.init_resource::<Assets<StandardMaterial>>();
        let world = app.world_mut();
        let mut mats = world.resource_mut::<Assets<StandardMaterial>>();
        let default_mat: Handle<StandardMaterial> = mats.add(StandardMaterial::default());

        let mut registry = ShapeRegistry::new();
        registry.default_material = Some(default_mat.clone());

        // Unknown id → fallback
        let resolved = registry.resolve_material(Some("Brick"));
        assert!(resolved.is_some());

        // None id → fallback
        let resolved2 = registry.resolve_material(None);
        assert!(resolved2.is_some());
    }

    #[test]
    fn resolve_material_returns_registered_handle() {
        let mut app = make_app();
        app.init_resource::<Assets<StandardMaterial>>();
        let world = app.world_mut();
        let mut mats = world.resource_mut::<Assets<StandardMaterial>>();
        let brick: Handle<StandardMaterial> = mats.add(StandardMaterial {
            base_color: bevy::color::Color::srgb(0.8, 0.4, 0.2),
            ..Default::default()
        });
        let default_mat: Handle<StandardMaterial> = mats.add(StandardMaterial::default());

        let mut registry = ShapeRegistry::new();
        registry.register_material("Brick", brick.clone());
        registry.default_material = Some(default_mat);

        let resolved = registry.resolve_material(Some("Brick"));
        assert!(resolved.is_some());
        assert_eq!(resolved.unwrap().id(), brick.id());
    }
}