Skip to main content

concinnity_asset/
procedural_mesh.rs

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