bevy_symbios_shape 0.6.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)]
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>,
    round_materials: HashSet<String>,
    round_meshes: HashSet<String>,
    round_segments: u32,
}

/// Default radial tessellation for round cross-sections.
pub const DEFAULT_ROUND_SEGMENTS: u32 = 24;

impl Default for ShapeRegistry {
    fn default() -> Self {
        Self {
            meshes: HashMap::default(),
            materials: HashMap::default(),
            default_material: None,
            stretch_uv_materials: HashSet::default(),
            stretch_uv_meshes: HashSet::default(),
            round_materials: HashSet::default(),
            round_meshes: HashSet::default(),
            round_segments: DEFAULT_ROUND_SEGMENTS,
        }
    }
}

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))
    }

    /// Flags a mesh ID so terminals emitting it render with a **round**
    /// cross-section — an elliptical prism inscribed in the scope's
    /// footprint instead of a box. `Rectangle` profiles become cylinders and
    /// `Taper(t)` profiles become frusta (a cone at `t = 1`), which is how
    /// columns, silos, chimneys, tanks, and spires are built without leaving
    /// the OBB-pure grammar: the scope stays a box, only its rendering is
    /// round.
    ///
    /// ```ignore
    /// // Grammar:  Column --> Extrude(0.4) Taper(0.12) Mat("Marble") I("Column")
    /// registry.register_round_mesh("Column");
    /// ```
    pub fn register_round_mesh(&mut self, id: impl Into<String>) {
        self.round_meshes.insert(id.into());
    }

    /// Material-keyed twin of [`register_round_mesh`] — every terminal
    /// wearing this material renders round.
    ///
    /// [`register_round_mesh`]: ShapeRegistry::register_round_mesh
    pub fn register_round_material(&mut self, id: impl Into<String>) {
        self.round_materials.insert(id.into());
    }

    /// Radial tessellation used for round cross-sections (default
    /// [`DEFAULT_ROUND_SEGMENTS`]). Clamped to `[3, 256]`.
    ///
    /// This value participates in the mesh-cache key, so changing it
    /// re-bakes affected meshes rather than serving stale tessellation.
    pub fn set_round_segments(&mut self, segments: u32) {
        self.round_segments = segments.clamp(3, 256);
    }

    /// The configured radial tessellation.
    pub fn round_segments(&self) -> u32 {
        self.round_segments
    }

    /// Segment count to build this `(mesh_id, material_id)` terminal with:
    /// the configured tessellation when either ID is registered round, else
    /// `0` for the default box geometry. Consulted by
    /// [`SpawnShapeExt::spawn_shape`][crate::spawner::SpawnShapeExt::spawn_shape].
    pub fn round_segments_for(&self, mesh_id: &str, mat_id: Option<&str>) -> u32 {
        let round = self.round_meshes.contains(mesh_id)
            || mat_id.is_some_and(|m| self.round_materials.contains(m));
        if round { self.round_segments } else { 0 }
    }

    /// 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 round_opt_in_by_mesh_or_material() {
        let mut registry = ShapeRegistry::new();
        assert_eq!(registry.round_segments_for("Column", None), 0);

        registry.register_round_mesh("Column");
        registry.register_round_material("Pipe");
        assert_eq!(
            registry.round_segments_for("Column", None),
            DEFAULT_ROUND_SEGMENTS
        );
        assert_eq!(
            registry.round_segments_for("Anything", Some("Pipe")),
            DEFAULT_ROUND_SEGMENTS
        );
        assert_eq!(registry.round_segments_for("Wall", Some("Brick")), 0);
    }

    #[test]
    fn round_segments_are_clamped() {
        let mut registry = ShapeRegistry::new();
        registry.register_round_mesh("Silo");
        registry.set_round_segments(1);
        assert_eq!(registry.round_segments(), 3, "clamped up to the minimum");
        registry.set_round_segments(9999);
        assert_eq!(registry.round_segments(), 256, "clamped to the ceiling");
        assert_eq!(registry.round_segments_for("Silo", None), 256);
    }

    #[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());
    }
}