Skip to main content

concinnity_core/components/
instanced_prop.rs

1// Instanced-prop schema.
2
3use crate::ecs::MaterialHandle;
4use crate::ecs::MeshHandle;
5use crate::ecs::TextureHandle;
6use crate::ecs::asset_id::AssetId;
7use crate::ecs::de_opt_material_handle;
8use crate::ecs::de_opt_mesh_handle;
9use crate::ecs::de_opt_texture_handle;
10use alloc::vec::Vec;
11
12/// Per-instance transform within an `InstancedProp`.
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14#[serde(default)]
15pub struct InstanceTransform {
16    /// World-space position `[x, y, z]`.
17    pub position: [f32; 3],
18    /// Euler rotation in degrees `[pitch, yaw, roll]`, applied in YXZ order.
19    pub rotation_deg: [f32; 3],
20    /// Non-uniform scale `[x, y, z]`.
21    pub scale: [f32; 3],
22}
23
24impl Default for InstanceTransform {
25    fn default() -> Self {
26        Self {
27            position: [0.0, 0.0, 0.0],
28            rotation_deg: [0.0, 0.0, 0.0],
29            scale: [1.0, 1.0, 1.0],
30        }
31    }
32}
33
34/// A single mesh + material drawn at many world-space transforms.
35///
36/// Use for foliage, debris, projectiles, or any content that repeats the same
37/// shape with varied placement. Each instance gets its own world transform and
38/// culling without the overhead of declaring many separate [Prop](#prop)s.
39///
40/// Each `instances` entry has the shape `{"position":[x,y,z], "rotation_deg":[p,y,r], "scale":[sx,sy,sz]}`.
41/// `rotation_deg` and `scale` may be omitted (defaults `[0,0,0]` and `[1,1,1]`).
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43#[serde(default)]
44pub struct InstancedProp {
45    /// Asset identity; injected via `inject_name`. Not part of `args`.
46    #[serde(skip)]
47    pub asset_id: AssetId,
48    /// A [Mesh](#mesh), [ProceduralMesh](#proceduralmesh),
49    /// [VoxelChunk](#voxelchunk), or mesh-kind [File](#file) asset.
50    #[serde(deserialize_with = "de_opt_mesh_handle")]
51    pub mesh: Option<MeshHandle>,
52    /// A [Material](#material); takes precedence over `texture` when set.
53    #[serde(deserialize_with = "de_opt_material_handle")]
54    pub material: Option<MaterialHandle>,
55    /// Older texture-only reference; ignored when `material` is set.
56    #[serde(deserialize_with = "de_opt_texture_handle")]
57    pub texture: Option<TextureHandle>,
58    /// Per-instance transforms. Empty list renders nothing.
59    pub instances: Vec<InstanceTransform>,
60    /// View-distance cutoff in world units per instance. 0 = always draw.
61    pub cull_distance: f32,
62}
63
64impl Default for InstancedProp {
65    fn default() -> Self {
66        Self {
67            asset_id: AssetId::default(),
68            mesh: None,
69            material: None,
70            texture: None,
71            instances: Vec::new(),
72            cull_distance: 0.0,
73        }
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn a_blank_instance_is_an_untransformed_copy() {
83        let t = InstanceTransform::default();
84        assert_eq!(t.position, [0.0, 0.0, 0.0]);
85        assert_eq!(t.rotation_deg, [0.0, 0.0, 0.0]);
86        // Unit scale, not zero: an omitted scale must not collapse the copy.
87        assert_eq!(t.scale, [1.0, 1.0, 1.0]);
88    }
89
90    #[test]
91    fn a_blank_prop_draws_nothing_and_never_culls() {
92        let p = InstancedProp::default();
93        assert!(p.instances.is_empty());
94        assert!(p.mesh.is_none());
95        assert!(p.material.is_none());
96        assert!(p.texture.is_none());
97        // Zero means "no distance cull", not "cull everything".
98        assert_eq!(p.cull_distance, 0.0);
99    }
100
101    #[test]
102    fn an_authored_instance_list_parses_and_round_trips_through_postcard() {
103        crate::test_support::install_resolvers();
104        let p: InstancedProp = serde_json::from_str(
105            r#"{"mesh":"tree_mesh","material":"bark","texture":"bark_tex","cull_distance":120,
106                "instances":[{"position":[1,0,2]},{"position":[3,0,4],"scale":[2,2,2]}]}"#,
107        )
108        .unwrap();
109        assert_eq!(p.mesh, Some(MeshHandle(9)));
110        assert_eq!(p.material, Some(MaterialHandle(4)));
111        assert_eq!(p.texture, Some(TextureHandle(8)));
112        assert_eq!(p.instances.len(), 2);
113        // A copy that mentions only its position keeps unit scale.
114        assert_eq!(p.instances[0].scale, [1.0, 1.0, 1.0]);
115        assert_eq!(p.instances[1].scale, [2.0, 2.0, 2.0]);
116
117        let bytes = postcard::to_allocvec(&p).unwrap();
118        let back: InstancedProp = postcard::from_bytes(&bytes).unwrap();
119        assert_eq!(back.instances.len(), 2);
120        assert_eq!(back.instances[1].position, [3.0, 0.0, 4.0]);
121        assert_eq!(back.cull_distance, 120.0);
122        assert_eq!(back.asset_id, AssetId::default());
123    }
124}