use crate::Mesh;
pub const GLB_MAGIC: u32 = 0x4654_6C67;
const CHUNK_JSON: u32 = 0x4E4F_534A;
const CHUNK_BIN: u32 = 0x004E_4942;
const TARGET_ARRAY_BUFFER: u32 = 34962;
const TARGET_ELEMENT_ARRAY_BUFFER: u32 = 34963;
const COMPONENT_FLOAT: u32 = 5126;
const COMPONENT_UNSIGNED_INT: u32 = 5125;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlbUpAxis {
YUp,
ZUp,
}
impl GlbUpAxis {
pub fn node_rotation(self) -> [f64; 4] {
match self {
GlbUpAxis::YUp => [0.0, 0.0, 0.0, 1.0],
GlbUpAxis::ZUp => [
-std::f64::consts::FRAC_1_SQRT_2,
0.0,
0.0,
std::f64::consts::FRAC_1_SQRT_2,
],
}
}
}
#[derive(Clone, Debug, Default)]
pub struct GlbSolid {
pub name: String,
pub mesh: Mesh,
pub color: Option<[u8; 3]>,
pub face_colors: std::collections::BTreeMap<u32, [u8; 3]>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GlbReport {
pub meshes: usize,
pub primitives: usize,
pub materials: usize,
pub triangles: usize,
pub bytes: usize,
}
fn srgb_byte_to_linear(byte: u8) -> f64 {
let c = f64::from(byte) / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn json_number(value: f64) -> Result<String, String> {
if !value.is_finite() {
return Err("write_glb: non-finite number".into());
}
Ok(format!("{value}"))
}
fn json_numbers(values: &[f64]) -> Result<String, String> {
let parts: Result<Vec<String>, String> = values.iter().map(|v| json_number(*v)).collect();
Ok(format!("[{}]", parts?.join(",")))
}
fn push_u32(bytes: &mut Vec<u8>, value: u32) {
bytes.extend_from_slice(&value.to_le_bytes());
}
fn triangle_color(solid: &GlbSolid, triangle: usize) -> Option<[u8; 3]> {
solid
.mesh
.face_ids
.get(triangle)
.and_then(|face| solid.face_colors.get(face))
.copied()
.or(solid.color)
}
pub fn write_glb(
solids: &[GlbSolid],
document_name: &str,
up: GlbUpAxis,
unit_scale: f64,
) -> Result<(Vec<u8>, GlbReport), String> {
let mut bin: Vec<u8> = Vec::new();
let mut buffer_views: Vec<String> = Vec::new();
let mut accessors: Vec<String> = Vec::new();
let mut meshes: Vec<String> = Vec::new();
let mut nodes: Vec<String> = Vec::new();
let mut palette: Vec<[u8; 3]> = Vec::new();
let mut report = GlbReport::default();
for solid in solids {
solid.mesh.validate()?;
let triangles = solid.mesh.indices.len() / 3;
if triangles == 0 {
continue;
}
let vertices = solid.mesh.positions.len() / 3;
let positions: Vec<f32> = solid.mesh.positions.iter().map(|v| *v as f32).collect();
let normals: Vec<f32> = solid.mesh.normals.iter().map(|v| *v as f32).collect();
if !positions.iter().all(|v| v.is_finite()) || !normals.iter().all(|v| v.is_finite()) {
return Err(format!(
"write_glb: solid '{}' has a non-finite position or normal",
solid.name
));
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for point in positions.chunks_exact(3) {
for axis in 0..3 {
let value = f64::from(point[axis]);
min[axis] = min[axis].min(value);
max[axis] = max[axis].max(value);
}
}
let position_view = buffer_views.len();
buffer_views.push(format!(
r#"{{"buffer":0,"byteOffset":{},"byteLength":{},"target":{TARGET_ARRAY_BUFFER}}}"#,
bin.len(),
positions.len() * 4
));
for value in &positions {
bin.extend_from_slice(&value.to_le_bytes());
}
let position_accessor = accessors.len();
accessors.push(format!(
r#"{{"bufferView":{position_view},"componentType":{COMPONENT_FLOAT},"count":{vertices},"type":"VEC3","min":{},"max":{}}}"#,
json_numbers(&min)?,
json_numbers(&max)?
));
let normal_view = buffer_views.len();
buffer_views.push(format!(
r#"{{"buffer":0,"byteOffset":{},"byteLength":{},"target":{TARGET_ARRAY_BUFFER}}}"#,
bin.len(),
normals.len() * 4
));
for value in &normals {
bin.extend_from_slice(&value.to_le_bytes());
}
let normal_accessor = accessors.len();
accessors.push(format!(
r#"{{"bufferView":{normal_view},"componentType":{COMPONENT_FLOAT},"count":{vertices},"type":"VEC3"}}"#
));
let mut groups: Vec<(Option<[u8; 3]>, Vec<u32>)> = Vec::new();
for triangle in 0..triangles {
let color = triangle_color(solid, triangle);
let slot = match groups.iter().position(|(key, _)| *key == color) {
Some(slot) => slot,
None => {
groups.push((color, Vec::new()));
groups.len() - 1
}
};
groups[slot]
.1
.extend_from_slice(&solid.mesh.indices[triangle * 3..triangle * 3 + 3]);
}
let mut primitives: Vec<String> = Vec::new();
for (color, indices) in &groups {
let index_view = buffer_views.len();
buffer_views.push(format!(
r#"{{"buffer":0,"byteOffset":{},"byteLength":{},"target":{TARGET_ELEMENT_ARRAY_BUFFER}}}"#,
bin.len(),
indices.len() * 4
));
for index in indices {
push_u32(&mut bin, *index);
}
let index_accessor = accessors.len();
accessors.push(format!(
r#"{{"bufferView":{index_view},"componentType":{COMPONENT_UNSIGNED_INT},"count":{},"type":"SCALAR"}}"#,
indices.len()
));
let material = color.map(|rgb| match palette.iter().position(|c| *c == rgb) {
Some(slot) => slot,
None => {
palette.push(rgb);
palette.len() - 1
}
});
let material_key = match material {
Some(slot) => format!(r#","material":{slot}"#),
None => String::new(),
};
primitives.push(format!(
r#"{{"attributes":{{"POSITION":{position_accessor},"NORMAL":{normal_accessor}}},"indices":{index_accessor}{material_key},"mode":4}}"#
));
}
report.primitives += primitives.len();
report.triangles += triangles;
let mesh_index = meshes.len();
meshes.push(format!(
r#"{{"name":{},"primitives":[{}]}}"#,
json_string(&solid.name),
primitives.join(",")
));
nodes.push(format!(
r#"{{"name":{},"mesh":{mesh_index}}}"#,
json_string(&solid.name)
));
}
if meshes.is_empty() {
return Err("nothing to export: the scene has no triangles".into());
}
report.meshes = meshes.len();
report.materials = palette.len();
let materials: Vec<String> = palette
.iter()
.map(|rgb| {
let linear = rgb.map(srgb_byte_to_linear);
Ok(format!(
r#"{{"name":{},"pbrMetallicRoughness":{{"baseColorFactor":[{},{},{},1],"metallicFactor":0,"roughnessFactor":0.5}}}}"#,
json_string(&format!("color_{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])),
json_number(linear[0])?,
json_number(linear[1])?,
json_number(linear[2])?
))
})
.collect::<Result<Vec<String>, String>>()?;
let children: Vec<String> = (1..=nodes.len()).map(|index| index.to_string()).collect();
let root = format!(
r#"{{"name":{},"rotation":{},"scale":[{s},{s},{s}],"children":[{}]}}"#,
json_string(document_name),
json_numbers(&up.node_rotation())?,
children.join(","),
s = json_number(unit_scale)?
);
nodes.insert(0, root);
let mut json = String::new();
json.push_str(r#"{"asset":{"version":"2.0","generator":"BREP glTF 2.0 writer"}"#);
json.push_str(&format!(
r#","scene":0,"scenes":[{{"name":{},"nodes":[0]}}]"#,
json_string(document_name)
));
json.push_str(&format!(r#","nodes":[{}]"#, nodes.join(",")));
json.push_str(&format!(r#","meshes":[{}]"#, meshes.join(",")));
if !materials.is_empty() {
json.push_str(&format!(r#","materials":[{}]"#, materials.join(",")));
}
json.push_str(&format!(r#","accessors":[{}]"#, accessors.join(",")));
json.push_str(&format!(r#","bufferViews":[{}]"#, buffer_views.join(",")));
json.push_str(&format!(r#","buffers":[{{"byteLength":{}}}]"#, bin.len()));
json.push('}');
let mut json_bytes = json.into_bytes();
while json_bytes.len() % 4 != 0 {
json_bytes.push(b' ');
}
while bin.len() % 4 != 0 {
bin.push(0);
}
let total = 12 + 8 + json_bytes.len() + 8 + bin.len();
let mut out = Vec::with_capacity(total);
push_u32(&mut out, GLB_MAGIC);
push_u32(&mut out, 2);
push_u32(&mut out, total as u32);
push_u32(&mut out, json_bytes.len() as u32);
push_u32(&mut out, CHUNK_JSON);
out.extend_from_slice(&json_bytes);
push_u32(&mut out, bin.len() as u32);
push_u32(&mut out, CHUNK_BIN);
out.extend_from_slice(&bin);
report.bytes = out.len();
Ok((out, report))
}