mod bake;
mod buffer;
mod container;
mod json;
mod source;
pub(crate) use source::export_world_mesh;
use crate::components::SkeletonJoint;
use crate::gfx::mesh_payload::MorphDelta;
use concinnity_core::components::build_skeleton_from_joint_defs;
use concinnity_core::gfx::transform::{Mat4, mat4_affine_inverse};
pub(crate) struct ExportMesh {
pub name: String,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub colors: Vec<[f32; 3]>,
pub joints: Vec<[u16; 4]>,
pub weights: Vec<[f32; 4]>,
pub indices: Vec<u16>,
pub skeleton: Vec<SkeletonJoint>,
pub morph_target_names: Vec<String>,
pub morph_deltas: Vec<MorphDelta>,
}
impl ExportMesh {
fn validate(&self) -> Result<(), String> {
let n = self.positions.len();
if n == 0 {
return Err("mesh has no vertices".to_string());
}
for (label, len) in [
("normals", self.normals.len()),
("uvs", self.uvs.len()),
("colors", self.colors.len()),
] {
if len != 0 && len != n {
return Err(format!("{label} has {len} entries for {n} vertices"));
}
}
if !self.skeleton.is_empty() && (self.joints.len() != n || self.weights.len() != n) {
return Err(format!(
"joint bindings have {} / {} entries for {} vertices",
self.joints.len(),
self.weights.len(),
n
));
}
if !self.indices.len().is_multiple_of(3) {
return Err(format!(
"{} indices do not form triangles",
self.indices.len()
));
}
if let Some(bad) = self.indices.iter().find(|&&i| i as usize >= n) {
return Err(format!("index {bad} out of range ({n} vertices)"));
}
for (i, j) in self.skeleton.iter().enumerate() {
if j.parent >= i as i32 {
return Err(format!(
"joint {i} ('{}') has parent {} at or after it",
j.name, j.parent
));
}
}
let joint_count = self.skeleton.len();
if !self.skeleton.is_empty()
&& let Some(bad) = self
.joints
.iter()
.flatten()
.find(|&&j| j as usize >= joint_count)
{
return Err(format!(
"vertex joint {bad} out of range ({joint_count} joints)"
));
}
let expected = self.morph_target_names.len() * n;
if self.morph_deltas.len() != expected {
return Err(format!(
"morph_deltas has {} entries; {} target(s) x {} vertices requires {}",
self.morph_deltas.len(),
self.morph_target_names.len(),
n,
expected
));
}
Ok(())
}
}
fn inverse_bind_matrices(skeleton: &[SkeletonJoint]) -> Vec<Mat4> {
let s = build_skeleton_from_joint_defs(skeleton);
let mut world = Vec::new();
s.world_matrices_into(s.bind_locals(), &mut world);
world.iter().map(|m| mat4_affine_inverse(*m)).collect()
}
pub(crate) fn export_glb(mesh: &ExportMesh) -> Result<Vec<u8>, String> {
mesh.validate()?;
let n = mesh.positions.len();
let skinned = !mesh.skeleton.is_empty();
let mut buf = buffer::BinBuffer::default();
let acc = json::MeshAccessors {
position: buf.push_vec3(&mesh.positions, true),
normal: (!mesh.normals.is_empty()).then(|| buf.push_vec3(&mesh.normals, false)),
uv: (!mesh.uvs.is_empty()).then(|| buf.push_vec2(&mesh.uvs)),
color: (!mesh.colors.is_empty()).then(|| buf.push_vec3(&mesh.colors, false)),
joints: skinned.then(|| buf.push_u16_vec4(&mesh.joints)),
weights: skinned.then(|| buf.push_vec4(&mesh.weights)),
indices: buf.push_indices(&mesh.indices),
inverse_bind: skinned.then(|| buf.push_mat4(&inverse_bind_matrices(&mesh.skeleton))),
targets: (0..mesh.morph_target_names.len())
.map(|t| {
let block = &mesh.morph_deltas[t * n..(t + 1) * n];
let positions: Vec<[f32; 3]> = block.iter().map(|d| d.position).collect();
let normals: Vec<[f32; 3]> = block.iter().map(|d| d.normal).collect();
(
buf.push_vec3(&positions, true),
buf.push_vec3(&normals, false),
)
})
.collect(),
};
let doc = json::document(
&mesh.name,
&mesh.skeleton,
&mesh.morph_target_names,
&acc,
&buf,
);
let json_bytes = serde_json::to_vec(&doc).map_err(|e| format!("serialise glTF json: {e}"))?;
Ok(container::wrap_glb(json_bytes, buf.bytes))
}
#[cfg(test)]
pub(crate) mod test_fixtures {
use super::*;
pub(crate) fn quad_mesh() -> ExportMesh {
let positions = vec![
[-0.5, 0.0, 0.0],
[0.5, 0.0, 0.0],
[-0.5, 1.0, 0.0],
[0.5, 1.0, 0.0],
];
let deltas = |t: usize, v: usize| -> MorphDelta {
match (t, v) {
(0, _) => MorphDelta {
position: [0.1, 0.0, 0.0],
normal: [0.0; 3],
},
(1, 2) | (1, 3) => MorphDelta {
position: [0.0, 0.2, 0.0],
normal: [0.0, 0.1, 0.0],
},
_ => MorphDelta::default(),
}
};
ExportMesh {
name: "quad".to_string(),
normals: vec![[0.0, 0.0, 1.0]; 4],
uvs: vec![[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
colors: Vec::new(),
joints: vec![[0, 0, 0, 0], [0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0]],
weights: vec![[1.0, 0.0, 0.0, 0.0]; 4],
indices: vec![0, 1, 2, 2, 1, 3],
skeleton: vec![
SkeletonJoint {
name: "root".to_string(),
parent: -1,
..Default::default()
},
SkeletonJoint {
name: "top".to_string(),
parent: 0,
translation: [0.0, 1.0, 0.0],
..Default::default()
},
],
morph_target_names: vec!["wide".to_string(), "lift+".to_string()],
morph_deltas: (0..2)
.flat_map(|t| (0..4).map(move |v| deltas(t, v)))
.collect(),
positions,
}
}
}
#[cfg(test)]
mod tests {
use super::test_fixtures::quad_mesh;
use super::*;
fn json_chunk(glb: &[u8]) -> serde_json::Value {
let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize;
serde_json::from_slice(&glb[20..20 + json_len]).expect("JSON chunk parses")
}
#[test]
fn a_quad_exports_every_expected_accessor() {
let glb = export_glb(&quad_mesh()).expect("export");
let doc = json_chunk(&glb);
assert_eq!(doc["accessors"].as_array().unwrap().len(), 7 + 4);
assert_eq!(doc["bufferViews"].as_array().unwrap().len(), 7 + 4);
let attrs = &doc["meshes"][0]["primitives"][0]["attributes"];
assert!(attrs.get("COLOR_0").is_none());
assert_eq!(
doc["meshes"][0]["extras"]["targetNames"],
serde_json::json!(["wide", "lift+"])
);
let pos = &doc["accessors"][attrs["POSITION"].as_u64().unwrap() as usize];
assert_eq!(pos["min"], serde_json::json!([-0.5, 0.0, 0.0]));
assert_eq!(pos["max"], serde_json::json!([0.5, 1.0, 0.0]));
for (p, _) in [(7usize, 8usize), (9, 10)] {
assert!(
doc["accessors"][p].get("min").is_some(),
"target accessor {p}"
);
}
}
#[test]
fn inverse_bind_matrices_undo_the_bind_pose() {
let mesh = quad_mesh();
let ibm = inverse_bind_matrices(&mesh.skeleton);
assert_eq!(ibm.len(), 2);
assert_eq!(ibm[0][3][1], 0.0);
assert!((ibm[1][3][1] + 1.0).abs() < 1e-6);
assert!((ibm[1][0][0] - 1.0).abs() < 1e-6);
}
#[test]
fn validation_rejects_inconsistent_data() {
let mut short_deltas = quad_mesh();
short_deltas.morph_deltas.pop();
assert!(
export_glb(&short_deltas)
.unwrap_err()
.contains("morph_deltas")
);
let mut bad_index = quad_mesh();
bad_index.indices[0] = 9;
assert!(export_glb(&bad_index).unwrap_err().contains("out of range"));
let mut child_first = quad_mesh();
child_first.skeleton[0].parent = 1;
assert!(export_glb(&child_first).unwrap_err().contains("parent"));
let mut bad_binding = quad_mesh();
bad_binding.joints[0] = [7, 0, 0, 0];
assert!(
export_glb(&bad_binding)
.unwrap_err()
.contains("vertex joint")
);
let mut ragged = quad_mesh();
ragged.normals.pop();
assert!(export_glb(&ragged).unwrap_err().contains("normals"));
}
#[test]
fn an_export_round_trips_through_the_cook_importer() {
let mesh = quad_mesh();
let glb = export_glb(&mesh).expect("export");
let doc =
concinnity_cook::import::gltf_source::GltfDoc::from_slice(&glb, None, "roundtrip")
.expect("exported GLB parses");
let back = concinnity_cook::import::mesh_reimport::decode_skinned_inline_from_parsed_glb(
&doc,
"roundtrip",
0,
)
.expect("cook importer accepts the export");
assert_eq!(back.vertices.len(), mesh.positions.len());
assert_eq!(back.indices, mesh.indices);
let names: Vec<&str> = back.skeleton.iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["root", "top"]);
assert_eq!(back.skeleton[1].parent, 0);
assert!(
back.skeleton[1]
.translation
.iter()
.zip(&mesh.skeleton[1].translation)
.all(|(a, b)| (a - b).abs() < 1e-6)
);
for (b, (j, w)) in back
.vertices
.iter()
.zip(mesh.joints.iter().zip(&mesh.weights))
{
assert_eq!(
b.joints,
[j[0] as u32, j[1] as u32, j[2] as u32, j[3] as u32]
);
assert_eq!(b.weights, *w);
}
assert_eq!(back.morph_target_names, mesh.morph_target_names);
assert_eq!(back.morph_deltas.len(), mesh.morph_deltas.len());
for (b, d) in back.morph_deltas.iter().zip(&mesh.morph_deltas) {
for c in 0..3 {
assert!((b.position[c] - d.position[c]).abs() < 1e-6);
assert!((b.normal[c] - d.normal[c]).abs() < 1e-6);
}
}
}
#[test]
fn the_container_is_aligned_and_sized() {
let glb = export_glb(&quad_mesh()).expect("export");
assert_eq!(&glb[0..4], b"glTF");
let total = u32::from_le_bytes([glb[8], glb[9], glb[10], glb[11]]) as usize;
assert_eq!(total, glb.len());
assert!(glb.len().is_multiple_of(4));
}
}