//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`]. //! //! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann — //! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export //! „Include → Custom Properties" anhaken → landen in `node.extras`) und //! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/… //! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die //! Konsumenten. //! //! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen //! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren), //! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0` //! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix //! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken — //! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe //! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert. //! //! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System, //! Blenders Exporter konvertiert selbst. Maßstab 1:1. //! //! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum, //! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders //! als beim trivialen signals-TOML-Subset). use serde_json::Value; use crate::engine::model::{apply_props, Empty, Model, Object, Props}; pub fn load(path: &str) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?; parse_glb(&bytes).map_err(|e| format!("gltf {path}: {e}")) } /// GLB-Container: 12-Byte-Header (`glTF`, Version 2, Gesamtlänge), dann /// Chunks aus Länge/Typ/Daten. Wir brauchen JSON (`JSON`) und BIN (`BIN\0`). pub fn parse_glb(bytes: &[u8]) -> Result { if bytes.len() < 12 || &bytes[0..4] != b"glTF" { return Err("kein GLB (Magic fehlt) — in Blender als „glTF Binary (.glb)“ exportieren".into()); } let version = u32_at(bytes, 4)?; if version != 2 { return Err(format!("glTF-Version {version}, unterstützt ist 2")); } let (mut json, mut bin): (Option<&[u8]>, &[u8]) = (None, &[]); let mut off = 12; while off + 8 <= bytes.len() { let len = u32_at(bytes, off)? as usize; let ty = &bytes[off + 4..off + 8]; let data = bytes.get(off + 8..off + 8 + len).ok_or("Chunk länger als Datei")?; match ty { b"JSON" => json = Some(data), b"BIN\0" => bin = data, _ => {} // unbekannte Chunks per Spec ignorieren } off += 8 + len.next_multiple_of(4); // Chunks sind 4-Byte-aligned } let json = json.ok_or("kein JSON-Chunk")?; let doc: Value = serde_json::from_slice(json).map_err(|e| format!("JSON: {e}"))?; build(&doc, bin) } fn build(doc: &Value, bin: &[u8]) -> Result { let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() }; // Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die // Welt-Transform wächst dabei Parent → Kind. let scene = doc["scene"].as_u64().unwrap_or(0) as usize; let roots = doc["scenes"][scene]["nodes"].as_array().cloned().unwrap_or_default(); for r in roots { let Some(i) = r.as_u64() else { continue; }; walk_node(doc, bin, i as usize, IDENTITY, &mut model)?; } Ok(model) } fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> { let node = &doc["nodes"][idx]; if node.is_null() { return Err(format!("Node {idx} fehlt")); } let world = mul(parent, node_local(node)); let name = node["name"].as_str().map_or_else(|| format!("node{idx}"), str::to_string); let props = extras_props(&node["extras"]); match node["mesh"].as_u64() { Some(mesh) => { let mut o = Object { name, props, visible: true, collider: false, // bis apply_props entscheidet verts: Vec::new(), uvs: Vec::new(), tris: Vec::new(), tri_mats: Vec::new(), }; append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?; apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model) if !o.tris.is_empty() { model.objects.push(o); } } // Node ohne Mesh = Empty (Entity-Marker); Position aus der // Welt-Transform (Translationsspalte). None => model.empties.push(Empty { name, props, pos: [world[12], world[13], world[14]], }), } for c in node["children"].as_array().into_iter().flatten() { if let Some(ci) = c.as_u64() { walk_node(doc, bin, ci as usize, world, model)?; } } Ok(()) } /// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein /// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen. fn append_mesh( doc: &Value, bin: &[u8], mesh: usize, world: M4, o: &mut Object, materials: &mut Vec, ) -> Result<(), String> { for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() { if prim["mode"].as_u64().unwrap_or(4) != 4 { eprintln!("[gltf] {}: Primitive-Mode ≠ Dreiecke — übersprungen", o.name); continue; } let Some(pos_acc) = prim["attributes"]["POSITION"].as_u64() else { continue; }; let positions = read_floats::<3>(doc, bin, pos_acc as usize)?; let uvs = match prim["attributes"]["TEXCOORD_0"].as_u64() { Some(a) => read_floats::<2>(doc, bin, a as usize)?, None => vec![[0.0, 0.0]; positions.len()], }; // Material-Name → geteilte Namensliste (wie usemtl); ohne Material "". let mat_name = prim["material"].as_u64() .and_then(|m| doc["materials"][m as usize]["name"].as_str()) .unwrap_or(""); let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| { materials.push(mat_name.to_string()); materials.len() - 1 }); let base = o.verts.len(); for (p, uv) in positions.iter().zip(&uvs) { o.verts.push(transform(world, *p)); o.uvs.push(*uv); } // glTF-UVs haben den Ursprung oben links — auf die Modell-Konvention // (unten links, wie OBJ) spiegeln, damit der Render-Konsument beide // Pfade gleich behandelt. for uv in &mut o.uvs[base..] { uv[1] = 1.0 - uv[1]; } let idxs: Vec = match prim["indices"].as_u64() { Some(a) => read_indices(doc, bin, a as usize)?, None => (0..positions.len()).collect(), // non-indexed: sequenziell }; for t in idxs.chunks_exact(3) { if t.iter().any(|&i| i >= positions.len()) { return Err(format!("{}: Index außerhalb der Positionen", o.name)); } o.tris.push([base + t[0], base + t[1], base + t[2]]); o.tri_mats.push(mi); } } Ok(()) } /// `node.extras` (Objekt) → Props; Skalare werden zu Strings vereinheitlicht, /// verschachtelte Werte als kompaktes JSON durchgereicht. fn extras_props(extras: &Value) -> Props { let mut props = Props::new(); if let Some(map) = extras.as_object() { for (k, v) in map { let s = match v { Value::String(s) => s.clone(), other => other.to_string(), }; props.insert(k.clone(), s); } } props } // --- Accessor-Dekodierung ---------------------------------------------------- /// Bytes eines Accessors samt Element-Stride auflösen (BufferView-Offset, /// Accessor-Offset, optionaler byteStride — sonst dicht gepackt). fn accessor_bytes<'a>(doc: &Value, bin: &'a [u8], acc: usize, elem_size: usize) -> Result<(&'a [u8], usize, usize), String> { let a = &doc["accessors"][acc]; let count = a["count"].as_u64().ok_or("Accessor ohne count")? as usize; let view = a["bufferView"].as_u64().ok_or("Accessor ohne bufferView")? as usize; let v = &doc["bufferViews"][view]; let v_off = v["byteOffset"].as_u64().unwrap_or(0) as usize; let a_off = a["byteOffset"].as_u64().unwrap_or(0) as usize; let stride = v["byteStride"].as_u64().map_or(elem_size, |s| s as usize); let start = v_off + a_off; let need = start + stride * count.saturating_sub(1) + elem_size; if need > bin.len() { return Err("Accessor ragt aus dem BIN-Chunk".into()); } Ok((&bin[start..], stride, count)) } /// N-Komponenten-float-Accessor (POSITION: N=3, TEXCOORD: N=2) lesen. fn read_floats(doc: &Value, bin: &[u8], acc: usize) -> Result, String> { let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0); if ctype != 5126 { return Err(format!("Accessor {acc}: componentType {ctype}, erwartet float (5126)")); } let (bytes, stride, count) = accessor_bytes(doc, bin, acc, N * 4)?; let mut out = Vec::with_capacity(count); for i in 0..count { let mut e = [0.0f32; N]; for (k, v) in e.iter_mut().enumerate() { let o = i * stride + k * 4; *v = f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()); } out.push(e); } Ok(out) } /// Index-Accessor lesen (u8/u16/u32 → usize). fn read_indices(doc: &Value, bin: &[u8], acc: usize) -> Result, String> { let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0); let size = match ctype { 5121 => 1, // u8 5123 => 2, // u16 5125 => 4, // u32 _ => return Err(format!("Index-Accessor {acc}: componentType {ctype}")), }; let (bytes, stride, count) = accessor_bytes(doc, bin, acc, size)?; let mut out = Vec::with_capacity(count); for i in 0..count { let o = i * stride; out.push(match size { 1 => bytes[o] as usize, 2 => u16::from_le_bytes(bytes[o..o + 2].try_into().unwrap()) as usize, _ => u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as usize, }); } Ok(out) } // --- Transform-Helfer (4×4 column-major, wie glTFs `matrix`) ----------------- type M4 = [f32; 16]; const IDENTITY: M4 = [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ]; /// Lokale Transform eines Nodes: explizite `matrix` oder T·R·S /// (glTF-Reihenfolge; fehlende Anteile sind Identität). fn node_local(node: &Value) -> M4 { if let Some(m) = node["matrix"].as_array() { let mut out = IDENTITY; for (i, v) in m.iter().take(16).enumerate() { out[i] = v.as_f64().unwrap_or(0.0) as f32; } return out; } let t = vecn::<3>(&node["translation"], [0.0, 0.0, 0.0]); let q = vecn::<4>(&node["rotation"], [0.0, 0.0, 0.0, 1.0]); let s = vecn::<3>(&node["scale"], [1.0, 1.0, 1.0]); // Rotationsmatrix aus dem Quaternion (x, y, z, w), Spalten skaliert, // Translation in die vierte Spalte — direkt komponiertes T·R·S. let (x, y, z, w) = (q[0], q[1], q[2], q[3]); let r = [ [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)], [2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)], [2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)], ]; // r[spalte][zeile] let mut out = IDENTITY; for c in 0..3 { for row in 0..3 { out[c * 4 + row] = r[c][row] * s[c]; } } out[12] = t[0]; out[13] = t[1]; out[14] = t[2]; out } fn vecn(v: &Value, default: [f32; N]) -> [f32; N] { let Some(arr) = v.as_array() else { return default; }; let mut out = default; for (o, x) in out.iter_mut().zip(arr) { if let Some(f) = x.as_f64() { *o = f as f32; } } out } fn mul(a: M4, b: M4) -> M4 { let mut out = [0.0; 16]; for c in 0..4 { for r in 0..4 { out[c * 4 + r] = (0..4).map(|k| a[k * 4 + r] * b[c * 4 + k]).sum(); } } out } fn transform(m: M4, p: [f32; 3]) -> [f32; 3] { let mut out = [0.0; 3]; for (r, o) in out.iter_mut().enumerate() { *o = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]; } out } fn u32_at(bytes: &[u8], off: usize) -> Result { bytes.get(off..off + 4) .map(|b| u32::from_le_bytes(b.try_into().unwrap())) .ok_or_else(|| "Datei zu kurz".into()) } #[cfg(test)] mod tests { use super::*; /// GLB aus JSON-Text und BIN-Daten zusammensetzen (mit 4-Byte-Padding), /// wie es ein Exporter täte. fn glb(json: &str, bin: &[u8]) -> Vec { let mut j = json.as_bytes().to_vec(); while !j.len().is_multiple_of(4) { j.push(b' '); } let mut b = bin.to_vec(); while !b.len().is_multiple_of(4) { b.push(0); } let total = 12 + 8 + j.len() + 8 + b.len(); let mut out = Vec::new(); out.extend(b"glTF"); out.extend(2u32.to_le_bytes()); out.extend((total as u32).to_le_bytes()); out.extend((j.len() as u32).to_le_bytes()); out.extend(b"JSON"); out.extend(j); out.extend((b.len() as u32).to_le_bytes()); out.extend(b"BIN\0"); out.extend(b); out } /// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein /// Mesh-Node mit Translation und extras, ein Empty mit extras. fn sample_glb() -> Vec { let mut bin: Vec = Vec::new(); for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); } for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); } for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); } let json = r#"{ "asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0, 2]}], "nodes": [ {"name": "Ding", "mesh": 0, "translation": [10, 0, 0], "extras": {"signal": "tiffany", "hp": 3}, "children": [1]}, {"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}}, {"name": "spawn", "translation": [1, 2, 3]} ], "meshes": [{"primitives": [{ "attributes": {"POSITION": 0, "TEXCOORD_0": 1}, "indices": 2, "material": 0 }]}], "materials": [{"name": "carpet"}], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"}, {"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"} ], "bufferViews": [ {"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"buffer": 0, "byteOffset": 36, "byteLength": 24}, {"buffer": 0, "byteOffset": 60, "byteLength": 6} ], "buffers": [{"byteLength": 66}] }"#; glb(json, &bin) } #[test] fn parses_geometry_with_baked_transforms() { let m = parse_glb(&sample_glb()).unwrap(); assert_eq!(m.objects.len(), 2); let d = &m.objects[0]; assert_eq!(d.name, "Ding"); assert_eq!(d.tris, vec![[0, 1, 2]]); assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken // signal-Property ohne Collider → Betretens-Zone, wird nie gerendert. assert!(!d.visible && !d.collider); assert_eq!(m.materials, vec!["carpet".to_string()]); // Kind erbt die Parent-Transform (10, 0, 0); // collide="proxy" → unsichtbarer Collider. let k = &m.objects[1]; assert_eq!(k.name, "Kind"); assert!(!k.visible && k.collider); assert_eq!(k.verts[0], [10.0, 0.0, 0.0]); } #[test] fn extras_become_props_and_empties_are_captured() { let m = parse_glb(&sample_glb()).unwrap(); let d = &m.objects[0]; assert_eq!(d.props.get("signal").map(String::as_str), Some("tiffany")); assert_eq!(d.props.get("hp").map(String::as_str), Some("3")); // Zahl → String assert_eq!(m.empties.len(), 1); let e = &m.empties[0]; assert_eq!(e.name, "spawn"); assert_eq!(e.pos, [1.0, 2.0, 3.0]); } #[test] fn uv_origin_is_flipped_to_model_convention() { // glTF-UV (1,1) (oben-links-Ursprung) → Modell-Konvention (1,0). let m = parse_glb(&sample_glb()).unwrap(); assert_eq!(m.objects[0].uvs[2], [1.0, 0.0]); } #[test] fn rejects_non_glb_and_wrong_version() { assert!(parse_glb(b"PNG...whatever").is_err()); let mut v1 = sample_glb(); v1[4] = 1; // Version patchen assert!(parse_glb(&v1).is_err()); } }