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}