bevy_symbios_shape 0.5.0

Bevy integration for Symbios Shape.
Documentation
//! Texture-config mutation surface for shape-grammar materials.
//!
//! This module is gated behind the `mutation` Cargo feature. It packages the
//! "evolve a procedural texture, then re-derive the building" pattern that
//! existed only as per-app boilerplate in the `detailed_villa` and
//! `medieval_castle` examples. The surface is composed of four pieces:
//!
//! - [`TextureConfigStore`] — a [`Resource`] mapping
//!   `material_id → bevy_symbios_texture::TextureConfig`. Apps register the
//!   procedural-texture configs they want evolvable.
//! - [`MutateMaterialsRequest`] — an [`Event`] (Bevy 0.18 observer-style)
//!   triggering one mutation pass with a fixed seed and Gaussian-jitter
//!   strength. The seed makes the mutation reproducible across peers and
//!   replays.
//! - [`MaterialTextureMutated`] — emitted once per affected material so
//!   downstream observers can spawn fresh
//!   [`bevy_symbios_texture::async_gen::PendingTexture`] tasks and patch
//!   `StandardMaterial::base_color` while the new image generates.
//! - [`MaterialMutationPlugin`] — wires the resource + observer for you.
//!
//! ## Example
//!
//! ```ignore
//! use bevy::prelude::*;
//! use bevy_symbios_shape::prelude::*;
//! use bevy_symbios_shape::mutation::{
//!     MaterialMutationPlugin, MaterialTextureMutated, MutateMaterialsRequest,
//!     TextureConfigStore,
//! };
//! use bevy_symbios_texture::{TextureConfig, brick::BrickConfig};
//!
//! App::new()
//!     .add_plugins((BevySymbiosShapePlugin, MaterialMutationPlugin))
//!     .add_systems(Startup, |mut store: ResMut<TextureConfigStore>| {
//!         store.insert("Brick", TextureConfig::Brick(BrickConfig::default()));
//!     })
//!     .add_observer(|trigger: On<MaterialTextureMutated>| {
//!         info!("Mutated {}: {:?}", trigger.event().material_id, trigger.event().config);
//!         // (spawn PendingTexture + update StandardMaterial::base_color here)
//!     })
//!     // Anywhere that wants to drive a generation forward:
//!     .add_systems(Update, |mut commands: Commands| {
//!         commands.trigger(MutateMaterialsRequest { seed: 42, strength: 0.5 });
//!     });
//! ```

use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy_symbios_texture::TextureConfig;
use rand::SeedableRng;
use rand::rngs::StdRng;
use symbios_genetics::Genotype;

/// Bevy [`Resource`] mapping `material_id` strings to
/// [`bevy_symbios_texture::TextureConfig`] instances. Populated by the host
/// app during setup; consulted (and mutated in place) by the
/// [`MutateMaterialsRequest`] observer.
///
/// `material_id` here is the same string that appears in shape grammar
/// `Mat("…")` ops and that [`crate::ShapeRegistry`] uses to resolve a
/// `Handle<StandardMaterial>`. Keeping the registry and the config store
/// keyed by the same string lets observers correlate mutations to handles
/// without an extra mapping table.
#[derive(Resource, Default, Debug)]
pub struct TextureConfigStore {
    configs: HashMap<String, TextureConfig>,
}

impl TextureConfigStore {
    /// Insert (or replace) the config for `material_id`. Returns the previous
    /// config if one was already registered.
    pub fn insert(
        &mut self,
        material_id: impl Into<String>,
        config: TextureConfig,
    ) -> Option<TextureConfig> {
        self.configs.insert(material_id.into(), config)
    }

    /// Look up the current config for `material_id`.
    pub fn get(&self, material_id: &str) -> Option<&TextureConfig> {
        self.configs.get(material_id)
    }

    /// Mutable lookup. Useful for one-off tweaks without going through the
    /// mutation event path.
    pub fn get_mut(&mut self, material_id: &str) -> Option<&mut TextureConfig> {
        self.configs.get_mut(material_id)
    }

    /// Remove the config for `material_id`. Returns the removed config if any.
    pub fn remove(&mut self, material_id: &str) -> Option<TextureConfig> {
        self.configs.remove(material_id)
    }

    /// Number of registered materials.
    pub fn len(&self) -> usize {
        self.configs.len()
    }

    /// True if no materials are registered.
    pub fn is_empty(&self) -> bool {
        self.configs.is_empty()
    }

    /// Mutate every registered config in place, using `rng` and `strength`
    /// to drive each variant's [`Genotype::mutate`] impl from
    /// `bevy_symbios_texture::genetics`. Returns one
    /// `(material_id, mutated_config)` pair per affected material so the
    /// caller can re-emit them as events. Iteration order is sorted by
    /// `material_id` for reproducibility across runs.
    pub fn mutate<R: rand::Rng>(
        &mut self,
        rng: &mut R,
        strength: f32,
    ) -> Vec<(String, TextureConfig)> {
        let mut ids: Vec<&String> = self.configs.keys().collect();
        ids.sort();
        let ordered_ids: Vec<String> = ids.into_iter().cloned().collect();

        let mut out = Vec::with_capacity(ordered_ids.len());
        for id in ordered_ids {
            if let Some(cfg) = self.configs.get_mut(&id) {
                mutate_texture_config(cfg, rng, strength);
                out.push((id, cfg.clone()));
            }
        }
        out
    }
}

/// Dispatch a single mutation pass over a `TextureConfig`.
///
/// `bevy_symbios_texture` implements [`Genotype`] on the `TextureConfig`
/// enum itself (delegating to the wrapped config; `TextureConfig::None` is
/// a no-op), so this forwards directly and automatically covers every
/// generator variant, including ones added in future releases.
pub fn mutate_texture_config<R: rand::Rng>(cfg: &mut TextureConfig, rng: &mut R, strength: f32) {
    cfg.mutate(rng, strength);
}

/// Event triggered to drive one round of texture-config mutation.
///
/// `seed` makes the resulting jitter deterministic; pass a generation
/// counter, world tick, or hash of the originating user action to keep
/// peer-side replays consistent. `strength` is forwarded verbatim to each
/// variant's `Genotype::mutate(strength)` — values in `0.1..=1.0` are
/// typical; `0.0` is a no-op; values above `1.0` produce visibly chaotic
/// drift on each step.
#[derive(Event, Clone, Copy, Debug)]
pub struct MutateMaterialsRequest {
    pub seed: u64,
    pub strength: f32,
}

/// Event emitted by [`handle_mutate_request`] once per material whose
/// config changed. Consumers observe this to spawn the matching
/// [`bevy_symbios_texture::async_gen::PendingTexture`] task and to update
/// the bound [`StandardMaterial::base_color`] (the new texture takes a
/// frame to generate; updating the base color hides the latency).
#[derive(Event, Clone, Debug)]
pub struct MaterialTextureMutated {
    pub material_id: String,
    pub config: TextureConfig,
}

/// Observer system that reacts to [`MutateMaterialsRequest`] by mutating
/// every entry in [`TextureConfigStore`] and triggering one
/// [`MaterialTextureMutated`] event per affected material.
///
/// Registered automatically by [`MaterialMutationPlugin`].
pub fn handle_mutate_request(
    request: On<MutateMaterialsRequest>,
    mut commands: Commands,
    mut store: ResMut<TextureConfigStore>,
) {
    let req = request.event();
    let mut rng = StdRng::seed_from_u64(req.seed);
    let mutated = store.mutate(&mut rng, req.strength);
    for (material_id, config) in mutated {
        commands.trigger(MaterialTextureMutated {
            material_id,
            config,
        });
    }
}

/// Plugin that installs [`TextureConfigStore`] as a default resource and
/// registers [`handle_mutate_request`] as an observer. Add this alongside
/// [`crate::BevySymbiosShapePlugin`] when you want texture-config evolution
/// without writing the mutation/fan-out plumbing yourself.
pub struct MaterialMutationPlugin;

impl Plugin for MaterialMutationPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<TextureConfigStore>()
            .add_observer(handle_mutate_request);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bevy_symbios_texture::brick::BrickConfig;
    use bevy_symbios_texture::stucco::StuccoConfig;
    use std::sync::{Arc, Mutex};

    fn store_with_two_materials() -> TextureConfigStore {
        let mut store = TextureConfigStore::default();
        store.insert(
            "Brick",
            TextureConfig::Brick(BrickConfig {
                aspect_ratio: 3.0,
                color_brick: [0.45, 0.22, 0.15],
                ..Default::default()
            }),
        );
        store.insert(
            "Stucco",
            TextureConfig::Stucco(StuccoConfig {
                roughness: 0.35,
                color_base: [0.87, 0.83, 0.77],
                ..Default::default()
            }),
        );
        store
    }

    #[test]
    fn store_round_trips_inserts_and_lookups() {
        let store = store_with_two_materials();
        assert_eq!(store.len(), 2);
        assert!(matches!(store.get("Brick"), Some(TextureConfig::Brick(_))));
        assert!(matches!(
            store.get("Stucco"),
            Some(TextureConfig::Stucco(_))
        ));
        assert!(store.get("Missing").is_none());
    }

    #[test]
    fn mutate_returns_one_entry_per_material_in_sorted_order() {
        let mut store = store_with_two_materials();
        let mut rng = StdRng::seed_from_u64(7);
        let result = store.mutate(&mut rng, 0.5);

        assert_eq!(result.len(), 2);
        assert_eq!(result[0].0, "Brick", "ordering must be sorted by id");
        assert_eq!(result[1].0, "Stucco");
    }

    #[test]
    fn mutate_is_deterministic_for_a_fixed_seed() {
        let mut a = store_with_two_materials();
        let mut b = store_with_two_materials();
        let mut rng_a = StdRng::seed_from_u64(42);
        let mut rng_b = StdRng::seed_from_u64(42);
        let out_a = a.mutate(&mut rng_a, 0.7);
        let out_b = b.mutate(&mut rng_b, 0.7);

        // The same seed must produce the same mutation. `TextureConfig` does
        // not derive `PartialEq` (the inner configs hold `f32` fields), so
        // compare via the `Debug` representation — exercised by the existing
        // `TextureConfig::fingerprint()` path in bevy_symbios_texture.
        let dbg_a = format!("{out_a:?}");
        let dbg_b = format!("{out_b:?}");
        assert_eq!(dbg_a, dbg_b);
    }

    #[test]
    fn observer_emits_one_event_per_mutated_material() {
        let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
        let captured_obs = captured.clone();

        let mut app = App::new();
        app.add_plugins(MaterialMutationPlugin);
        app.world_mut()
            .resource_mut::<TextureConfigStore>()
            .insert("Brick", TextureConfig::Brick(BrickConfig::default()));
        app.world_mut()
            .resource_mut::<TextureConfigStore>()
            .insert("Stucco", TextureConfig::Stucco(StuccoConfig::default()));
        app.add_observer(move |trigger: On<MaterialTextureMutated>| {
            captured_obs
                .lock()
                .unwrap()
                .push(trigger.event().material_id.clone());
        });

        app.world_mut().trigger(MutateMaterialsRequest {
            seed: 1,
            strength: 0.5,
        });
        // Flush observer-issued triggers.
        app.update();

        let ids = captured.lock().unwrap();
        assert_eq!(ids.len(), 2, "expected one event per registered material");
        assert!(ids.contains(&"Brick".to_string()));
        assert!(ids.contains(&"Stucco".to_string()));
    }

    #[test]
    fn empty_store_no_op_no_events() {
        let captured: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
        let captured_obs = captured.clone();

        let mut app = App::new();
        app.add_plugins(MaterialMutationPlugin);
        app.add_observer(move |_: On<MaterialTextureMutated>| {
            *captured_obs.lock().unwrap() += 1;
        });

        app.world_mut().trigger(MutateMaterialsRequest {
            seed: 123,
            strength: 0.5,
        });
        app.update();

        assert_eq!(
            *captured.lock().unwrap(),
            0,
            "empty store must not emit any events"
        );
    }
}