Skip to main content

concinnity_asset/
mesh.rs

1// Raw mesh geometry schema.
2
3use crate::AssetId;
4use crate::PayloadLocator;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8/// A single vertex as supplied in raw Mesh args.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct VertexData {
11    /// Vertex position `[x, y, z]` in model space.
12    pub pos: [f32; 3],
13    /// Vertex colour `[r, g, b]` in [0, 1]. Use `[0.75, 0.74, 0.72]` for a
14    /// neutral surface that takes the material albedo.
15    pub color: [f32; 3],
16    /// Texture coordinates in [0, 1] space.  Defaults to [0, 0] when omitted.
17    #[serde(default)]
18    pub uv: [f32; 2],
19}
20
21/// Raw geometry. Supply `vertices` and `indices` directly, or import them from
22/// a binary glTF file with `source` + `primitive_index`.
23///
24/// Use when you want full control over shape: custom furniture,
25/// architectural details, signage, or any form a generator cannot
26/// produce. For standard shapes use [ProceduralMesh](#proceduralmesh).
27///
28/// Normals and tangents are computed automatically at build time.
29/// **Do not supply normals or tangents.**
30///
31/// **Vertex color:** use `[0.75, 0.74, 0.72]` for a neutral surface that takes
32/// the material albedo, or `[1, 1, 1]` to pass through unmodified.
33///
34/// **Winding:** triangles must be counter-clockwise when viewed from the front.
35/// Reversed winding = invisible face.
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
37#[serde(default)]
38pub struct Mesh {
39    /// Asset identity; injected via `inject_name`. Not part of `args`.
40    #[serde(skip)]
41    pub asset_id: AssetId,
42    /// Optional path to a `.glb` file. When set, the build imports
43    /// `vertices` / `indices` from it; inline geometry leaves this empty.
44    pub source: String,
45    /// Which primitive (counted across all meshes in the file) to import from
46    /// `source`. Ignored when `source` is empty.
47    pub primitive_index: u32,
48    /// Pick a single chunk of an oversized imported primitive. `None` (the
49    /// default) imports the whole primitive, which is fine whenever its vertex
50    /// count fits in 16-bit indices; larger primitives are split into chunks on
51    /// import, one Mesh per chunk.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub chunk_index: Option<u32>,
54    /// Vertex list.  Each vertex: `{"pos":[x,y,z], "color":[r,g,b], "uv":[u,v]}`.
55    pub vertices: Vec<VertexData>,
56    /// Triangle index list (16-bit values).
57    pub indices: Vec<u16>,
58    /// Number of level-of-detail versions to generate, including the original.
59    /// `1` (the default) generates none; values are clamped to `[1, 8]`.
60    #[serde(default = "default_lod_levels")]
61    pub lod_levels: u32,
62    /// Camera distances at which to switch to each lower-detail version. Length
63    /// should be `lod_levels - 1`; empty lets the build derive a default
64    /// sequence. The version for index `i` is used at camera distance ≥
65    /// `lod_distances[i]`.
66    pub lod_distances: Vec<f32>,
67    /// Injected at load time from the compiled blob payload.
68    #[serde(skip)]
69    pub locator: Option<PayloadLocator>,
70}
71
72fn default_lod_levels() -> u32 {
73    1
74}
75
76impl Default for Mesh {
77    fn default() -> Self {
78        Self {
79            asset_id: AssetId::default(),
80            source: String::new(),
81            primitive_index: 0,
82            chunk_index: None,
83            vertices: Vec::new(),
84            indices: Vec::new(),
85            lod_levels: 1,
86            lod_distances: Vec::new(),
87            locator: None,
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn a_vertex_without_uvs_samples_the_texture_origin() {
98        let v: VertexData = serde_json::from_str(r#"{"pos":[1,2,3],"color":[1,0,0]}"#).unwrap();
99        assert_eq!(v.pos, [1.0, 2.0, 3.0]);
100        assert_eq!(v.color, [1.0, 0.0, 0.0]);
101        assert_eq!(v.uv, [0.0, 0.0]);
102        // Position and colour are required: geometry with neither is a mistake,
103        // not a default.
104        assert!(serde_json::from_str::<VertexData>(r#"{"pos":[0,0,0]}"#).is_err());
105    }
106
107    #[test]
108    fn a_blank_mesh_has_one_lod_and_no_geometry() {
109        let m = Mesh::default();
110        assert!(m.vertices.is_empty());
111        assert!(m.indices.is_empty());
112        assert!(m.lod_distances.is_empty());
113        // One level means the mesh is drawn as authored, with no simplification.
114        assert_eq!(m.lod_levels, 1);
115        assert_eq!(m.primitive_index, 0);
116        assert_eq!(m.chunk_index, None);
117        assert!(m.locator.is_none());
118    }
119
120    #[test]
121    fn an_omitted_lod_level_count_still_means_one() {
122        // The field carries its own default fn, so an absent value is 1 rather
123        // than the 0 an integer field would otherwise fall back to.
124        let m: Mesh = serde_json::from_str(r#"{"source":"board.obj"}"#).unwrap();
125        assert_eq!(m.lod_levels, 1);
126        assert_eq!(m.source, "board.obj");
127    }
128
129    #[test]
130    fn an_inline_mesh_round_trips_through_postcard() {
131        let m: Mesh = serde_json::from_str(
132            r#"{"source":"tile.obj","primitive_index":2,"chunk_index":7,"lod_levels":3,
133                "lod_distances":[10,40],
134                "vertices":[{"pos":[0,0,0],"color":[1,1,1],"uv":[0.5,0.5]}],
135                "indices":[0,0,0]}"#,
136        )
137        .unwrap();
138        assert_eq!(m.chunk_index, Some(7));
139
140        let bytes = postcard::to_allocvec(&m).unwrap();
141        let back: Mesh = postcard::from_bytes(&bytes).unwrap();
142        assert_eq!(back.primitive_index, 2);
143        assert_eq!(back.chunk_index, Some(7));
144        assert_eq!(back.lod_levels, 3);
145        assert_eq!(back.lod_distances, [10.0, 40.0]);
146        assert_eq!(back.vertices[0].uv, [0.5, 0.5]);
147        assert_eq!(back.indices, [0, 0, 0]);
148        assert_eq!(back.asset_id, AssetId::default());
149    }
150
151    #[test]
152    fn an_absent_chunk_index_is_omitted_from_the_serialized_args() {
153        let m: Mesh = serde_json::from_str(r#"{"source":"board.obj"}"#).unwrap();
154        let json = serde_json::to_string(&m).unwrap();
155        assert!(!json.contains("chunk_index"), "{json}");
156    }
157}