concinnity-asset 0.18.66

User-facing asset schema for the Concinnity engine
Documentation
// World-level physics configuration schema.

use crate::{AssetId, de_opt_asset_ref};
use alloc::string::String;
use alloc::vec::Vec;

/// Configures the world's physics floor / terrain.
///
/// Optional: a world with physics bodies but no `PhysicsConfig` simulates over a
/// flat floor at Y = 0, and the build injects one carrying these values so the
/// settings are visible in `world-lock.json`. Physics runs whenever the world
/// declares a `PhysicsConfig`, a [RigidBody](#rigidbody), a
/// [PropBody](#propbody), a [TriggerVolume](#triggervolume), or a
/// [SkinnedMesh](#skinnedmesh) with a `capsule`. Declare a `PhysicsConfig` to
/// put bodies on terrain or a non-zero floor.
///
/// For terrain-based outdoor scenes the terrain parameters must match the
/// terrain mesh exactly.
///
/// ```rust
/// # use concinnity_asset::PhysicsConfig;
/// PhysicsConfig {
///     terrain_offset_y: -0.5,
///     ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PhysicsConfig {
    /// Y coordinate of the floor. When left at 0.0 it is auto-detected from the
    /// camera; set it explicitly to override.
    pub floor_y: f32,
    /// Half-width of the terrain mesh along X. Must match the terrain mesh.
    /// Leave at 0.0 (with `terrain_subdivisions` = 0) for flat-floor scenes.
    pub terrain_half_width: f32,
    /// Half-depth of the terrain mesh along Z. Must match the terrain mesh.
    pub terrain_half_depth: f32,
    /// Subdivision count of the terrain mesh. When 0, a flat floor at Y = 0 is
    /// used instead of a heightfield.
    pub terrain_subdivisions: u32,
    /// Height variation of the terrain mesh. Must match the terrain mesh.
    pub terrain_amplitude: f32,
    /// World-space Y offset of the terrain: the height of the prop that renders
    /// the terrain mesh. Leave at 0.0 when the terrain sits at the origin.
    pub terrain_offset_y: f32,
    /// Name of a [ProceduralMesh](#proceduralmesh) with `generator:
    /// "heightfield"`. When set, the physics surface is built from that mesh's
    /// source image so props rest on the visible terrain. Takes precedence over
    /// the `terrain_*` values above.
    #[serde(default, deserialize_with = "de_opt_asset_ref")]
    pub terrain_mesh: Option<AssetId>,
    /// Extra collision layer names beyond the built-ins (`world`, `prop`,
    /// `character`, `trigger`). At most 28; referenced by collider `layer`
    /// fields and `no_collide` pairs.
    pub layers: Vec<String>,
    /// Unordered layer-name pairs that do not collide. Everything collides by
    /// default; each pair here disables collision (and contact solving) between
    /// its two layers symmetrically. Pairs naming `character` also filter the
    /// character controller's movement.
    pub no_collide: Vec<[String; 2]>,
    /// Minimum contact impulse (mass times velocity change) for a collision to
    /// publish a contact event. Resting contact stays below it; raise to hear
    /// only hard impacts.
    pub contact_min_impulse: f32,
    /// Extra physics bodies reserved for props created while the world runs
    /// (by a [Spawner](#spawner), a [Behavior](#behavior) `spawn` node, or the
    /// host). Physics reserves every body it will ever need when the world
    /// loads and never grows: once the declared bodies plus this many are
    /// live, a further spawn gets no physics body and is reported as an error.
    ///
    /// This is a floor beneath what the build reserves on its own, not the
    /// whole reservation. Every [Spawner](#spawner) whose `interval` and
    /// `lifetime` bound how many copies can be alive at once is already
    /// reserved for, and the larger of the two numbers wins. Set a value here
    /// for the sources the build cannot count: a `Spawner` with `lifetime: 0`
    /// (its copies live forever), a `spawn` node in a behavior, and spawns the
    /// host drives itself.
    pub spawn_headroom: u32,
}

impl Default for PhysicsConfig {
    fn default() -> Self {
        Self {
            floor_y: 0.0,
            terrain_half_width: 0.0,
            terrain_half_depth: 0.0,
            terrain_subdivisions: 0,
            terrain_amplitude: 0.0,
            terrain_offset_y: 0.0,
            terrain_mesh: None,
            layers: Vec::new(),
            no_collide: Vec::new(),
            contact_min_impulse: 1.0,
            spawn_headroom: 0,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use alloc::string::ToString;
    use alloc::vec;

    #[test]
    fn a_blank_config_is_a_flat_floor_at_the_origin() {
        let p = PhysicsConfig::default();
        assert_eq!(p.floor_y, 0.0);
        assert_eq!(p.terrain_amplitude, 0.0);
        assert_eq!(p.terrain_subdivisions, 0);
        assert_eq!(p.terrain_offset_y, 0.0);
        // No mesh named means the generated terrain values are what is used.
        assert!(p.terrain_mesh.is_none());
        // Everything collides by default; light impacts stay silent.
        assert!(p.layers.is_empty());
        assert!(p.no_collide.is_empty());
        assert_eq!(p.contact_min_impulse, 1.0);
        // Nothing is held back for runtime spawns unless a world asks for it.
        assert_eq!(p.spawn_headroom, 0);
    }

    #[test]
    fn authored_spawn_headroom_round_trips_through_postcard() {
        let p: PhysicsConfig = serde_json::from_str(r#"{"spawn_headroom":64}"#).unwrap();
        assert_eq!(p.spawn_headroom, 64);

        // The runtime reads the headroom off the baked component, so it has to
        // survive the wire, not just the JSON parse.
        let bytes = postcard::to_allocvec(&p).unwrap();
        let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(back.spawn_headroom, 64);
    }

    #[test]
    fn layers_and_no_collide_parse_and_round_trip_through_postcard() {
        let p: PhysicsConfig = serde_json::from_str(
            r#"{"layers":["debris"],"no_collide":[["debris","character"]],
                "contact_min_impulse":2.5}"#,
        )
        .unwrap();
        assert_eq!(p.layers, vec!["debris".to_string()]);
        assert_eq!(
            p.no_collide,
            vec![["debris".to_string(), "character".to_string()]]
        );

        let bytes = postcard::to_allocvec(&p).unwrap();
        let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(back.layers, p.layers);
        assert_eq!(back.no_collide, p.no_collide);
        assert_eq!(back.contact_min_impulse, 2.5);
    }

    #[test]
    fn a_named_terrain_mesh_parses_and_round_trips_through_postcard() {
        crate::test_support::install_resolvers();
        let p: PhysicsConfig = serde_json::from_str(
            r#"{"floor_y":-1.5,"terrain_half_width":128,"terrain_half_depth":128,
                "terrain_subdivisions":64,"terrain_amplitude":12,"terrain_offset_y":2,
                "terrain_mesh":"ground"}"#,
        )
        .unwrap();
        assert_eq!(p.terrain_mesh, Some(AssetId(6)));

        let bytes = postcard::to_allocvec(&p).unwrap();
        let back: PhysicsConfig = postcard::from_bytes(&bytes).unwrap();
        assert_eq!(back.floor_y, -1.5);
        assert_eq!(back.terrain_half_width, 128.0);
        assert_eq!(back.terrain_half_depth, 128.0);
        assert_eq!(back.terrain_subdivisions, 64);
        assert_eq!(back.terrain_amplitude, 12.0);
        assert_eq!(back.terrain_offset_y, 2.0);
        assert_eq!(back.terrain_mesh, Some(AssetId(6)));
    }
}