Skip to main content

concinnity_core/components/
procedural_mesh.rs

1//! `ProceduralMesh`'s `Component` impl is generated centrally (see
2//! `cn_impl_components!`); this module keeps the blob-residency helper
3//! `PhysicsSystem` relies on.
4
5use crate::ecs::PayloadLocator;
6use crate::ecs::asset_id::AssetId;
7use alloc::string::String;
8use alloc::vec::Vec;
9
10/// Geometry built by a named generator at compile time. Use for standard shapes.
11///
12/// For custom / hand-authored geometry use [Mesh](#mesh) instead.
13///
14/// **Built-in generators:**
15///
16/// ```rust
17/// # use concinnity_core::components::ProceduralMesh;
18/// ProceduralMesh {
19///     generator: "room".into(),
20///     half_width: 16.0,
21///     half_depth: 20.0,
22///     ceiling_height: 3.5,
23///     ..Default::default()
24/// };
25/// ```
26#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
27#[serde(default)]
28pub struct ProceduralMesh {
29    /// Asset identity; injected via `inject_name`. Not part of `args`.
30    #[serde(skip)]
31    pub asset_id: AssetId,
32    /// Built-in generator name (required), e.g. `room`, `box`, `cylinder`,
33    /// `sphere`, `terrain`, `heightfield`, `skybox`, or `extrude`.
34    pub generator: String,
35
36    // Room / box / plane dimensions
37    /// Half-width along X (room / box / plane / terrain), in world units.
38    pub half_width: f32,
39    /// Half-depth along Z (room / box / plane / terrain), in world units.
40    pub half_depth: f32,
41    /// Ceiling height for the `room` generator, in world units.
42    pub ceiling_height: f32,
43
44    // Box
45    /// Half-extents `[x, y, z]` for the `box` generator.
46    pub half_extents: Option<[f32; 3]>,
47
48    // Cylinder / sphere
49    /// Radius for the `cylinder` and `sphere` generators.
50    pub radius: Option<f32>,
51    /// Height for the `cylinder` and `extrude` generators.
52    pub height: Option<f32>,
53    /// Number of radial segments around the `cylinder` and `sphere` generators.
54    pub segments: Option<u32>,
55
56    // Sphere
57    /// Number of horizontal rings on the `sphere` generator.
58    pub rings: Option<u32>,
59
60    // Terrain
61    /// Grid subdivisions for the `terrain` and `heightfield` generators. Higher
62    /// is more detailed.
63    pub subdivisions: Option<u32>,
64    /// Maximum height variation for the `terrain` generator, in world units.
65    pub amplitude: Option<f32>,
66
67    // Heightfield (grayscale image → height grid)
68    /// Path to a grayscale heightmap image for the `heightfield` generator.
69    pub source: Option<String>,
70    /// Height mapped to black pixels in the `heightfield` source, in world units.
71    pub elevation_min: Option<f32>,
72    /// Height mapped to white pixels in the `heightfield` source, in world units.
73    pub elevation_max: Option<f32>,
74
75    // Skybox
76    /// Half-extent on all axes for the `skybox` generator, in world units.
77    /// Keep it below the camera's `far` plane so the sky is not clipped.
78    pub size: Option<f32>,
79
80    // Extrude
81    /// 2D outline `[[x, z], ...]` extruded by the `extrude` generator.
82    pub profile: Option<Vec<[f32; 2]>>,
83    /// Corner-rounding radius for the `extrude` generator. 0 keeps sharp corners.
84    pub corner_radius: Option<f32>,
85    /// Number of segments used to round each corner in the `extrude` generator.
86    pub corner_segments: Option<u32>,
87
88    /// Number of level-of-detail versions to generate, including the original.
89    /// `1` (the default) generates none; values are clamped to `[1, 8]`.
90    pub lod_levels: u32,
91    /// Camera distances at which to switch to each lower-detail version; length
92    /// should be `lod_levels - 1`. Empty lets the build choose defaults.
93    pub lod_distances: Vec<f32>,
94
95    /// Injected at load time from the compiled blob payload.
96    #[serde(skip)]
97    pub locator: Option<PayloadLocator>,
98}
99
100impl Default for ProceduralMesh {
101    fn default() -> Self {
102        Self {
103            asset_id: AssetId::default(),
104            generator: String::new(),
105            half_width: 8.0,
106            half_depth: 10.0,
107            ceiling_height: 3.5,
108            half_extents: None,
109            radius: None,
110            height: None,
111            segments: None,
112            rings: None,
113            subdivisions: None,
114            amplitude: None,
115            source: None,
116            elevation_min: None,
117            elevation_max: None,
118            size: None,
119            profile: None,
120            corner_radius: None,
121            corner_segments: None,
122            lod_levels: 1,
123            lod_distances: Vec::new(),
124            locator: None,
125        }
126    }
127}
128
129/// Blob indices of heightfield-generator ProceduralMeshes. GraphicsSystem's
130/// init release sweep must spare these blobs: PhysicsSystem inits afterwards and
131/// reads the baked heightfield collider grid from the payload, mirroring the
132/// AudioClip / SdfVolume precedent of holding a blob resident for a later system.
133pub fn heightfield_blob_indices(
134    ctx: &crate::ecs::PipelineContext,
135) -> alloc::collections::BTreeSet<u32> {
136    ctx.query::<ProceduralMesh>()
137        .filter(|m| m.generator == "heightfield")
138        .filter_map(|m| m.locator.as_ref().map(|l| l.blob_index))
139        .collect()
140}
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn every_generator_specific_field_starts_unset() {
147        // The generator decides which fields it reads, so an unset field has to
148        // mean "this generator's own default", not a shared number.
149        let m = ProceduralMesh::default();
150        assert!(m.generator.is_empty());
151        assert_eq!(m.half_extents, None);
152        assert_eq!(m.radius, None);
153        assert_eq!(m.height, None);
154        assert_eq!(m.segments, None);
155        assert_eq!(m.rings, None);
156        assert_eq!(m.subdivisions, None);
157        assert_eq!(m.amplitude, None);
158        assert_eq!(m.source, None);
159        assert_eq!(m.elevation_min, None);
160        assert_eq!(m.elevation_max, None);
161        assert_eq!(m.size, None);
162        assert_eq!(m.profile, None);
163        assert_eq!(m.corner_radius, None);
164        assert_eq!(m.corner_segments, None);
165        // The room dimensions are shared, so they carry real defaults.
166        assert_eq!(m.half_width, 8.0);
167        assert_eq!(m.half_depth, 10.0);
168        assert_eq!(m.ceiling_height, 3.5);
169        assert_eq!(m.lod_levels, 1);
170        assert!(m.lod_distances.is_empty());
171        assert!(m.locator.is_none());
172    }
173
174    #[test]
175    fn a_heightfield_reads_its_own_fields_and_leaves_the_rest_unset() {
176        let m: ProceduralMesh = serde_json::from_str(
177            r#"{"generator":"heightfield","source":"terrain.png","subdivisions":128,
178                "elevation_min":-4,"elevation_max":40,"lod_levels":3,"lod_distances":[20,80]}"#,
179        )
180        .unwrap();
181        assert_eq!(m.generator, "heightfield");
182        assert_eq!(m.source.as_deref(), Some("terrain.png"));
183        assert_eq!(m.subdivisions, Some(128));
184        assert_eq!(m.elevation_min, Some(-4.0));
185        assert_eq!(m.elevation_max, Some(40.0));
186        assert_eq!(m.radius, None);
187        assert_eq!(m.rings, None);
188    }
189
190    #[test]
191    fn an_extruded_profile_round_trips_through_postcard() {
192        let m: ProceduralMesh = serde_json::from_str(
193            r#"{"generator":"extrude","profile":[[0,0],[1,0],[1,1]],"height":2.5,
194                "corner_radius":0.1,"corner_segments":4,"half_extents":[1,2,3],
195                "radius":0.5,"segments":32,"rings":16,"amplitude":3,"size":100}"#,
196        )
197        .unwrap();
198        let bytes = postcard::to_allocvec(&m).unwrap();
199        let back: ProceduralMesh = postcard::from_bytes(&bytes).unwrap();
200        assert_eq!(back, m);
201        assert_eq!(
202            back.profile.as_deref(),
203            Some(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]][..])
204        );
205        assert_eq!(back.corner_segments, Some(4));
206        assert_eq!(back.half_extents, Some([1.0, 2.0, 3.0]));
207        assert_eq!(back.size, Some(100.0));
208        assert_eq!(back.asset_id, AssetId::default());
209    }
210}