bevy_symbios_shape 0.1.0

Bevy integration for Symbios Shape.
Documentation
//! Ergonomic command extension for spawning CGA shape grammar results as Bevy entities.
//!
//! # Usage
//!
//! ```ignore
//! use bevy::prelude::*;
//! use bevy_symbios_shape::{spawner::SpawnShapeExt, registry::ShapeRegistry};
//! use symbios_shape::{Interpreter, Scope, grammar::parse_ops};
//!
//! fn setup(
//!     mut commands: Commands,
//!     registry: Res<ShapeRegistry>,
//!     mut meshes: ResMut<Assets<Mesh>>,
//!     mut materials: ResMut<Assets<StandardMaterial>>,
//! ) {
//!     let mut interp = Interpreter::new();
//!     interp.add_rule("Lot", parse_ops("Extrude(12) I(\"Building\")").unwrap());
//!
//!     let footprint = Scope::new(
//!         symbios_shape::Vec3::ZERO,
//!         symbios_shape::Quat::IDENTITY,
//!         symbios_shape::Vec3::new(10.0, 0.0, 10.0),
//!     );
//!
//!     commands
//!         .spawn_shape(&interp, footprint, "Lot", &registry, &mut meshes, &mut materials)
//!         .unwrap();
//! }
//! ```

use bevy::color::Color;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use symbios_shape::{FaceProfile, Interpreter, Scope, ShapeError};

use crate::mesh::build_profiled_mesh;
use crate::registry::ShapeRegistry;
use crate::transform::scope_to_transform;

/// Extension trait on [`Commands`] for spawning CGA shape grammar outputs as Bevy entities.
pub trait SpawnShapeExt {
    /// Derives the shape grammar starting from `root_scope` / `root_rule`, then
    /// spawns the resulting [`Terminal`] nodes as a hierarchy of Bevy entities.
    ///
    /// Returns the root [`Entity`] (a spatial grouping node at world origin) on
    /// success, or a [`ShapeError`] if derivation fails.
    ///
    /// # Asset resolution
    ///
    /// For each terminal:
    /// - If `mesh_id` is registered in `registry` → spawn a [`SceneRoot`] child.
    /// - Otherwise → generate a procedural mesh via [`build_profiled_mesh`]
    ///   (supports tapered prisms, triangles, trapezoids, and arbitrary polygons)
    ///   and spawn [`Mesh3d`] + [`MeshMaterial3d`].
    ///
    /// [`build_profiled_mesh`]: crate::mesh::build_profiled_mesh
    ///
    /// For materials:
    /// - If `terminal.material` is registered in `registry` → use that handle.
    /// - Otherwise → use `registry.default_material`, or a generated grey material.
    ///
    /// # Transform
    ///
    /// Each terminal's [`Transform`] is computed by [`scope_to_transform`], which
    /// converts the double-precision scope (corner-anchored) to a Bevy centroid-
    /// anchored `Transform` with proper f64→f32 downcasting.
    ///
    /// [`Terminal`]: symbios_shape::Terminal
    fn spawn_shape(
        &mut self,
        interpreter: &Interpreter,
        root_scope: Scope,
        root_rule: &str,
        registry: &ShapeRegistry,
        meshes: &mut Assets<Mesh>,
        materials: &mut Assets<StandardMaterial>,
    ) -> Result<Entity, ShapeError>;
}

impl SpawnShapeExt for Commands<'_, '_> {
    fn spawn_shape(
        &mut self,
        interpreter: &Interpreter,
        root_scope: Scope,
        root_rule: &str,
        registry: &ShapeRegistry,
        meshes: &mut Assets<Mesh>,
        materials: &mut Assets<StandardMaterial>,
    ) -> Result<Entity, ShapeError> {
        let model = interpreter.derive(root_scope, root_rule)?;

        // Root grouping entity — children use world-space transforms as their local
        // transforms, so the root sits at the world origin with no offset.
        // Visibility is required so that `commands.entity(root).insert(Visibility::Hidden)`
        // propagates through the hierarchy via InheritedVisibility/ViewVisibility.
        let root = self
            .spawn((Transform::default(), Visibility::default()))
            .id();

        // Cache procedural mesh handles within this spawn call so identical geometry
        // (same profile + size) reuses one GPU upload instead of allocating a separate
        // Mesh asset per terminal.  Key is bit-exact f32 values; floating-point
        // equality is intentional here (same grammar → same values).
        let mut mesh_cache: HashMap<(ProfileKey, u32, u32, u32, bool), Handle<Mesh>> =
            HashMap::new();

        for terminal in &model.terminals {
            let transform = scope_to_transform(&terminal.scope);

            if let Some(scene_handle) = registry.get_mesh(&terminal.mesh_id) {
                // Registered asset: spawn as a scene child.
                // The scene is assumed to be unit-scale; scope_to_transform provides scale.
                let child = self
                    .spawn((SceneRoot(scene_handle.clone()), transform))
                    .id();
                self.entity(root).add_child(child);
            } else {
                // Procedural fallback: generate a tapered cuboid mesh.
                let size = Vec3::new(
                    terminal.scope.size.x as f32,
                    terminal.scope.size.y as f32,
                    terminal.scope.size.z as f32,
                );

                let stretch_uvs =
                    registry.should_stretch_uvs(&terminal.mesh_id, terminal.material.as_deref());

                let mesh_key = (
                    ProfileKey::from_profile(&terminal.face_profile),
                    size.x.to_bits(),
                    size.y.to_bits(),
                    size.z.to_bits(),
                    stretch_uvs,
                );

                let mesh_handle = mesh_cache
                    .entry(mesh_key)
                    .or_insert_with(|| {
                        meshes.add(build_profiled_mesh(
                            &terminal.face_profile,
                            size,
                            stretch_uvs,
                        ))
                    })
                    .clone();

                let material_handle = registry
                    .resolve_material(terminal.material.as_deref())
                    .unwrap_or_else(|| {
                        // Derive a stable grey-ish color from the mesh_id string so
                        // different unknown IDs are visually distinguishable.
                        let hue = string_to_hue(&terminal.mesh_id);
                        materials.add(StandardMaterial {
                            base_color: Color::hsl(hue, 0.4, 0.6),
                            ..Default::default()
                        })
                    });

                let child = self
                    .spawn((
                        Mesh3d(mesh_handle),
                        MeshMaterial3d(material_handle),
                        transform,
                    ))
                    .id();
                self.entity(root).add_child(child);
            }
        }

        Ok(root)
    }
}

/// A collision-free hashable key for `FaceProfile`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum ProfileKey {
    Rectangle,
    Taper(u32),
    Triangle(u32),
    Trapezoid(u32, u32),
    Polygon(Vec<(u32, u32)>),
}

impl ProfileKey {
    fn from_profile(profile: &FaceProfile) -> Self {
        match profile {
            FaceProfile::Rectangle => Self::Rectangle,
            FaceProfile::Taper(t) => Self::Taper((*t as f32).to_bits()),
            FaceProfile::Triangle { peak_offset } => {
                Self::Triangle((*peak_offset as f32).to_bits())
            }
            FaceProfile::Trapezoid {
                top_width,
                offset_x,
            } => Self::Trapezoid((*top_width as f32).to_bits(), (*offset_x as f32).to_bits()),
            FaceProfile::Polygon(pts) => Self::Polygon(
                pts.iter()
                    .map(|p| ((p.x as f32).to_bits(), (p.y as f32).to_bits()))
                    .collect(),
            ),
        }
    }
}

/// Maps a string to a hue value in `[0, 360)` via a simple hash,
/// giving visually distinct but stable colors for unknown mesh IDs.
fn string_to_hue(s: &str) -> f32 {
    let hash = s.bytes().fold(5381u32, |acc, b| {
        acc.wrapping_mul(31).wrapping_add(b as u32)
    });
    (hash % 360) as f32
}

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

    #[test]
    fn string_to_hue_is_in_range() {
        for id in ["Window", "Door", "Roof", "Ground", "Wall", ""] {
            let h = string_to_hue(id);
            assert!((0.0..360.0).contains(&h), "hue {h} out of range for '{id}'");
        }
    }

    #[test]
    fn string_to_hue_is_stable() {
        assert_eq!(string_to_hue("Building"), string_to_hue("Building"));
    }
}