Skip to main content

khora_data/ecs/components/
mesh_ref.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Authored, serialized reference to a mesh.
16//!
17//! `MeshRef` is the *only* serialized mesh form. It is an explicit
18//! discriminator the developer/editor sets:
19//!
20//! - [`MeshRef::Procedural`] carries the kind + generator parameters of a
21//!   primitive (cube, sphere, plane), regenerated on resolution — no
22//!   structural fingerprinting.
23//! - [`MeshRef::Asset`] references an imported mesh file (glTF, OBJ) in the
24//!   VFS by its stable `AssetUUID`.
25//!
26//! A registered resolver `DataSystem` (in `khora-io`) turns a `MeshRef` into
27//! the runtime-only `HandleComponent<Mesh>`, which the GPU mesh projection
28//! consumes. `MeshRef` itself never touches the GPU and never carries a
29//! resolved handle.
30//!
31//! The procedural geometry generators ([`reconstruct_procedural_mesh`] and the
32//! `create_*` builders) live here and are public so the resolver can rebuild a
33//! `Mesh` from a [`MeshRef::Procedural`].
34
35use bincode::{config, Decode, Encode};
36use khora_core::asset::AssetUUID;
37use khora_core::math::{Aabb, Vec2, Vec3};
38use khora_core::renderer::api::{
39    pipeline::{PrimitiveTopology, VertexAttributeDescriptor, VertexFormat},
40    scene::Mesh,
41};
42
43/// Identifies a known procedural mesh primitive.
44#[derive(
45    Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize,
46)]
47pub enum ProceduralMeshKind {
48    /// Axis-aligned cube primitive.
49    Cube,
50    /// UV sphere primitive.
51    Sphere,
52    /// Flat XZ plane primitive.
53    Plane,
54}
55
56/// Authored reference to a mesh — the only serialized mesh form.
57///
58/// The enum *is* the discriminator: `Procedural` carries the primitive kind
59/// and its generator parameters; `Asset` carries the stable UUID of an
60/// imported mesh in the VFS.
61///
62/// Both arms carry an *identity UUID* — the content-derived UUID for
63/// `Procedural`, the stable asset UUID for `Asset`. The resolver compares this
64/// against the resolved handle's UUID every tick: a mismatch (or a missing
65/// handle) means the authored reference changed and must be re-resolved. The
66/// `Procedural` UUID is never serialized; it is recomputed from `kind` +
67/// `params` on deserialize via [`MeshRef::procedural`] so it stays purely
68/// content-derived. Construct `Procedural` through [`MeshRef::procedural`] so
69/// the UUID and the parameters can never disagree.
70#[derive(Debug, Clone, PartialEq)]
71pub enum MeshRef {
72    /// Procedural primitive — rebuilt from `kind` + `params` by the resolver.
73    /// The `uuid` is content-derived; build via [`MeshRef::procedural`].
74    Procedural {
75        /// Which procedural primitive to regenerate.
76        kind: ProceduralMeshKind,
77        /// Generator parameters (kind-specific layout, padded with zeros).
78        params: [f32; 4],
79        /// Content-derived identity UUID. Not serialized — recomputed on load.
80        uuid: AssetUUID,
81    },
82    /// Reference to an imported mesh asset in the VFS, resolved by UUID.
83    Asset(AssetUUID),
84}
85
86impl crate::ecs::Component for MeshRef {}
87
88impl MeshRef {
89    /// Builds a `Procedural` reference, deriving its identity UUID from the
90    /// primitive `kind` discriminant plus the raw parameter bytes. Identical
91    /// procedural meshes get the same UUID, so they dedup to one resolved
92    /// handle (and one `GpuMesh`). This is the SAME content hash the GPU
93    /// projection keys on.
94    pub fn procedural(kind: ProceduralMeshKind, params: [f32; 4]) -> Self {
95        let mut key = Vec::with_capacity(1 + 16);
96        key.push(match kind {
97            ProceduralMeshKind::Cube => 0u8,
98            ProceduralMeshKind::Sphere => 1,
99            ProceduralMeshKind::Plane => 2,
100        });
101        for p in params {
102            key.extend_from_slice(&p.to_le_bytes());
103        }
104        let uuid = AssetUUID::new_v5(&blake3::hash(&key).to_hex());
105        Self::Procedural { kind, params, uuid }
106    }
107
108    /// Returns the identity UUID: the content-derived UUID for `Procedural`,
109    /// the stable asset UUID for `Asset`. The resolver compares this against the
110    /// resolved handle's UUID to detect an authored change.
111    pub fn uuid(&self) -> AssetUUID {
112        match self {
113            Self::Procedural { uuid, .. } => *uuid,
114            Self::Asset(uuid) => *uuid,
115        }
116    }
117
118    /// A unit cube — the default authored mesh.
119    pub fn unit_cube() -> Self {
120        Self::procedural(ProceduralMeshKind::Cube, [1.0, 0.0, 0.0, 0.0])
121    }
122}
123
124/// On-disk form of a [`MeshRef`]. The `Procedural` arm omits the identity
125/// UUID — it is recomputed from `kind` + `params` on load via
126/// [`MeshRef::procedural`] so it always stays content-derived. The `Asset`
127/// arm round-trips its UUID verbatim.
128#[derive(Encode, Decode, serde::Serialize, serde::Deserialize)]
129enum SerializableMeshRef {
130    /// Procedural primitive parameters (UUID recomputed on load).
131    Procedural {
132        /// Which procedural primitive to regenerate.
133        kind: ProceduralMeshKind,
134        /// Generator parameters (kind-specific layout, padded with zeros).
135        params: [f32; 4],
136    },
137    /// VFS asset UUID, round-tripped unchanged.
138    Asset(AssetUUID),
139}
140
141impl From<&MeshRef> for SerializableMeshRef {
142    fn from(mesh_ref: &MeshRef) -> Self {
143        match mesh_ref {
144            MeshRef::Procedural { kind, params, .. } => Self::Procedural {
145                kind: *kind,
146                params: *params,
147            },
148            MeshRef::Asset(uuid) => Self::Asset(*uuid),
149        }
150    }
151}
152
153impl From<SerializableMeshRef> for MeshRef {
154    fn from(on_disk: SerializableMeshRef) -> Self {
155        match on_disk {
156            SerializableMeshRef::Procedural { kind, params } => Self::procedural(kind, params),
157            SerializableMeshRef::Asset(uuid) => Self::Asset(uuid),
158        }
159    }
160}
161
162/// Serializes the entity's `MeshRef` into the recipe byte stream.
163fn serialize_mesh_ref(
164    world: &crate::ecs::World,
165    entity: khora_core::ecs::entity::EntityId,
166) -> Option<Vec<u8>> {
167    let mesh_ref = world.get::<MeshRef>(entity)?;
168    let on_disk = SerializableMeshRef::from(mesh_ref);
169    bincode::encode_to_vec(&on_disk, config::standard()).ok()
170}
171
172/// Reconstructs a `MeshRef` from recipe bytes and attaches it to `entity`.
173fn deserialize_mesh_ref(
174    world: &mut crate::ecs::World,
175    entity: khora_core::ecs::entity::EntityId,
176    data: &[u8],
177) -> Result<(), String> {
178    let (on_disk, _): (SerializableMeshRef, _) =
179        bincode::decode_from_slice(data, config::standard()).map_err(|e| e.to_string())?;
180    world
181        .add_component(entity, MeshRef::from(on_disk))
182        .map_err(|e| format!("{e:?}"))?;
183    Ok(())
184}
185
186/// Editor JSON form — the on-disk `MeshRef` shape (UUID omitted for
187/// `Procedural`, recomputed on parse).
188fn mesh_ref_to_json(
189    world: &crate::ecs::World,
190    entity: khora_core::ecs::entity::EntityId,
191) -> Option<serde_json::Value> {
192    let mesh_ref = world.get::<MeshRef>(entity)?;
193    serde_json::to_value(SerializableMeshRef::from(mesh_ref)).ok()
194}
195
196/// Parses the editor JSON form back into a `MeshRef` and applies it.
197fn mesh_ref_from_json(
198    world: &mut crate::ecs::World,
199    entity: khora_core::ecs::entity::EntityId,
200    value: &serde_json::Value,
201) -> Result<(), String> {
202    let on_disk: SerializableMeshRef =
203        serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
204    let mesh_ref = MeshRef::from(on_disk);
205    if !world.set_component(entity, mesh_ref.clone()) {
206        world
207            .add_component(entity, mesh_ref)
208            .map_err(|e| format!("{e:?}"))?;
209    }
210    Ok(())
211}
212
213inventory::submit! {
214    crate::scene::ComponentRegistration {
215        type_id: std::any::TypeId::of::<MeshRef>(),
216        type_name: "MeshRef",
217        provenance: crate::ecs::ComponentProvenance::Authored,
218        serialize_recipe: serialize_mesh_ref,
219        deserialize_recipe: deserialize_mesh_ref,
220        create_default: |world, entity| {
221            world
222                .add_component(entity, MeshRef::unit_cube())
223                .map_err(|e| format!("{e:?}"))?;
224            Ok(())
225        },
226        to_json: mesh_ref_to_json,
227        from_json: mesh_ref_from_json,
228        remove: |world, entity| {
229            match world.remove_component::<MeshRef>(entity) {
230                Ok(_) => Ok(()),
231                Err(e) => Err(format!("{e:?}")),
232            }
233        },
234    }
235}
236
237// ─── Procedural mesh generation ───
238
239/// Reconstructs a procedural [`Mesh`] from its kind and parameters.
240///
241/// Parameter layout per kind:
242/// - `Cube`: `[size, _, _, _]`
243/// - `Plane`: `[size, y, _, _]`
244/// - `Sphere`: `[radius, segments, rings, _]`
245pub fn reconstruct_procedural_mesh(kind: ProceduralMeshKind, params: [f32; 4]) -> Mesh {
246    match kind {
247        ProceduralMeshKind::Cube => create_cube(params[0]),
248        ProceduralMeshKind::Plane => create_plane(params[0], params[1]),
249        ProceduralMeshKind::Sphere => create_sphere(params[0], params[1] as u32, params[2] as u32),
250    }
251}
252
253fn default_vertex_layout() -> Vec<VertexAttributeDescriptor> {
254    vec![
255        VertexAttributeDescriptor {
256            shader_location: 0,
257            format: VertexFormat::Float32x3,
258            offset: 0,
259        },
260        VertexAttributeDescriptor {
261            shader_location: 1,
262            format: VertexFormat::Float32x3,
263            offset: 12,
264        },
265        VertexAttributeDescriptor {
266            shader_location: 2,
267            format: VertexFormat::Float32x2,
268            offset: 24,
269        },
270    ]
271}
272
273/// Builds a flat XZ plane primitive of the given size at height `y`.
274pub fn create_plane(size: f32, y: f32) -> Mesh {
275    let half = size / 2.0;
276    let positions = vec![
277        Vec3::new(-half, y, -half),
278        Vec3::new(half, y, -half),
279        Vec3::new(half, y, half),
280        Vec3::new(-half, y, half),
281    ];
282    let normals = vec![
283        Vec3::new(0.0, 1.0, 0.0),
284        Vec3::new(0.0, 1.0, 0.0),
285        Vec3::new(0.0, 1.0, 0.0),
286        Vec3::new(0.0, 1.0, 0.0),
287    ];
288    let tex_coords = vec![
289        Vec2::new(0.0, 0.0),
290        Vec2::new(1.0, 0.0),
291        Vec2::new(1.0, 1.0),
292        Vec2::new(0.0, 1.0),
293    ];
294    // CCW winding viewed from above (+Y), so the front face matches the +Y
295    // normal — consistent with the cube and glTF convention (front = CCW =
296    // outward), which back-face culling relies on.
297    let indices = vec![0u32, 2, 1, 0, 3, 2];
298    Mesh {
299        positions,
300        normals: Some(normals),
301        tex_coords: Some(tex_coords),
302        tangents: None,
303        colors: None,
304        indices: Some(indices),
305        primitive_type: PrimitiveTopology::TriangleList,
306        bounding_box: Aabb::from_min_max(Vec3::new(-half, y, -half), Vec3::new(half, y, half)),
307        vertex_layout: default_vertex_layout(),
308    }
309}
310
311/// Builds an axis-aligned cube primitive of the given size, centered at origin.
312pub fn create_cube(size: f32) -> Mesh {
313    let half = size / 2.0;
314    let positions = vec![
315        // Front (+Z)
316        Vec3::new(-half, -half, half),
317        Vec3::new(half, -half, half),
318        Vec3::new(half, half, half),
319        Vec3::new(-half, half, half),
320        // Back (-Z)
321        Vec3::new(half, -half, -half),
322        Vec3::new(-half, -half, -half),
323        Vec3::new(-half, half, -half),
324        Vec3::new(half, half, -half),
325        // Right (+X)
326        Vec3::new(half, -half, half),
327        Vec3::new(half, -half, -half),
328        Vec3::new(half, half, -half),
329        Vec3::new(half, half, half),
330        // Left (-X)
331        Vec3::new(-half, -half, -half),
332        Vec3::new(-half, -half, half),
333        Vec3::new(-half, half, half),
334        Vec3::new(-half, half, -half),
335        // Top (+Y)
336        Vec3::new(-half, half, half),
337        Vec3::new(half, half, half),
338        Vec3::new(half, half, -half),
339        Vec3::new(-half, half, -half),
340        // Bottom (-Y)
341        Vec3::new(-half, -half, -half),
342        Vec3::new(half, -half, -half),
343        Vec3::new(half, -half, half),
344        Vec3::new(-half, -half, half),
345    ];
346    let normals = vec![
347        Vec3::new(0.0, 0.0, 1.0),
348        Vec3::new(0.0, 0.0, 1.0),
349        Vec3::new(0.0, 0.0, 1.0),
350        Vec3::new(0.0, 0.0, 1.0),
351        Vec3::new(0.0, 0.0, -1.0),
352        Vec3::new(0.0, 0.0, -1.0),
353        Vec3::new(0.0, 0.0, -1.0),
354        Vec3::new(0.0, 0.0, -1.0),
355        Vec3::new(1.0, 0.0, 0.0),
356        Vec3::new(1.0, 0.0, 0.0),
357        Vec3::new(1.0, 0.0, 0.0),
358        Vec3::new(1.0, 0.0, 0.0),
359        Vec3::new(-1.0, 0.0, 0.0),
360        Vec3::new(-1.0, 0.0, 0.0),
361        Vec3::new(-1.0, 0.0, 0.0),
362        Vec3::new(-1.0, 0.0, 0.0),
363        Vec3::new(0.0, 1.0, 0.0),
364        Vec3::new(0.0, 1.0, 0.0),
365        Vec3::new(0.0, 1.0, 0.0),
366        Vec3::new(0.0, 1.0, 0.0),
367        Vec3::new(0.0, -1.0, 0.0),
368        Vec3::new(0.0, -1.0, 0.0),
369        Vec3::new(0.0, -1.0, 0.0),
370        Vec3::new(0.0, -1.0, 0.0),
371    ];
372    let tex_coords: Vec<Vec2> = (0..6)
373        .flat_map(|_| {
374            [
375                Vec2::new(0.0, 0.0),
376                Vec2::new(1.0, 0.0),
377                Vec2::new(1.0, 1.0),
378                Vec2::new(0.0, 1.0),
379            ]
380        })
381        .collect();
382    let indices = vec![
383        0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15, 16, 17,
384        18, 16, 18, 19, 20, 21, 22, 20, 22, 23,
385    ];
386    Mesh {
387        positions,
388        normals: Some(normals),
389        tex_coords: Some(tex_coords),
390        tangents: None,
391        colors: None,
392        indices: Some(indices),
393        primitive_type: PrimitiveTopology::TriangleList,
394        bounding_box: Aabb::from_min_max(
395            Vec3::new(-half, -half, -half),
396            Vec3::new(half, half, half),
397        ),
398        vertex_layout: default_vertex_layout(),
399    }
400}
401
402/// Builds a UV sphere primitive of the given radius, segments and rings.
403pub fn create_sphere(radius: f32, segments: u32, rings: u32) -> Mesh {
404    let mut positions = Vec::new();
405    let mut normals = Vec::new();
406    let mut tex_coords = Vec::new();
407
408    for ring in 0..=rings {
409        let phi = std::f32::consts::PI * (ring as f32 / rings as f32);
410        let y = radius * phi.cos();
411        let ring_radius = radius * phi.sin();
412        for segment in 0..=segments {
413            let theta = 2.0 * std::f32::consts::PI * (segment as f32 / segments as f32);
414            let x = ring_radius * theta.cos();
415            let z = ring_radius * theta.sin();
416            positions.push(Vec3::new(x, y, z));
417            normals.push(Vec3::new(x / radius, y / radius, z / radius));
418            tex_coords.push(Vec2::new(
419                segment as f32 / segments as f32,
420                ring as f32 / rings as f32,
421            ));
422        }
423    }
424
425    // CCW winding as seen from outside, so front faces point outward —
426    // consistent with the cube and glTF convention that back-face culling
427    // relies on.
428    let mut indices = Vec::new();
429    for ring in 0..rings {
430        for segment in 0..segments {
431            let current = ring * (segments + 1) + segment;
432            let next = current + segments + 1;
433            indices.push(current);
434            indices.push(current + 1);
435            indices.push(next);
436            indices.push(current + 1);
437            indices.push(next + 1);
438            indices.push(next);
439        }
440    }
441
442    Mesh {
443        positions,
444        normals: Some(normals),
445        tex_coords: Some(tex_coords),
446        tangents: None,
447        colors: None,
448        indices: Some(indices),
449        primitive_type: PrimitiveTopology::TriangleList,
450        bounding_box: Aabb::from_min_max(
451            Vec3::new(-radius, -radius, -radius),
452            Vec3::new(radius, radius, radius),
453        ),
454        vertex_layout: default_vertex_layout(),
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use crate::ecs::World;
462    use crate::scene::registry::ComponentRegistration;
463
464    /// `MeshRef::Procedural` survives a recipe serialize → deserialize cycle.
465    #[test]
466    fn procedural_mesh_ref_recipe_round_trip() {
467        let mut src = World::new();
468        let entity = src.spawn(MeshRef::procedural(
469            ProceduralMeshKind::Sphere,
470            [0.75, 32.0, 16.0, 0.0],
471        ));
472
473        let reg = inventory::iter::<ComponentRegistration>
474            .into_iter()
475            .find(|r| r.type_name == "MeshRef")
476            .expect("MeshRef registration present");
477        let bytes = (reg.serialize_recipe)(&src, entity).expect("serialize");
478
479        let mut dst = World::new();
480        let new_entity = dst.spawn(());
481        (reg.deserialize_recipe)(&mut dst, new_entity, &bytes).expect("deserialize");
482
483        let restored = dst.get::<MeshRef>(new_entity).expect("mesh ref restored");
484        // The recomputed identity UUID matches the original (content-derived).
485        assert_eq!(
486            restored,
487            &MeshRef::procedural(ProceduralMeshKind::Sphere, [0.75, 32.0, 16.0, 0.0])
488        );
489    }
490
491    /// `MeshRef::Asset(uuid)` round-trips with the SAME uuid preserved.
492    #[test]
493    fn asset_mesh_ref_recipe_round_trip_preserves_uuid() {
494        let mut src = World::new();
495        let uuid = AssetUUID::new_v5("meshes/teapot.gltf");
496        let entity = src.spawn(MeshRef::Asset(uuid));
497
498        let reg = inventory::iter::<ComponentRegistration>
499            .into_iter()
500            .find(|r| r.type_name == "MeshRef")
501            .expect("MeshRef registration present");
502        let bytes = (reg.serialize_recipe)(&src, entity).expect("serialize");
503
504        let mut dst = World::new();
505        let new_entity = dst.spawn(());
506        (reg.deserialize_recipe)(&mut dst, new_entity, &bytes).expect("deserialize");
507
508        let restored = dst.get::<MeshRef>(new_entity).expect("mesh ref restored");
509        assert_eq!(restored, &MeshRef::Asset(uuid));
510    }
511
512    /// The cube primitive has the canonical 24 vertices / 36 indices.
513    #[test]
514    fn cube_has_expected_vertex_and_index_counts() {
515        let mesh = reconstruct_procedural_mesh(ProceduralMeshKind::Cube, [2.0, 0.0, 0.0, 0.0]);
516        assert_eq!(mesh.positions.len(), 24);
517        assert_eq!(mesh.indices.as_ref().map_or(0, |i| i.len()), 36);
518    }
519}