//! Das geparste Blender-Modell: Ergebnis des Loaders (engine::gltf) und der //! Vertrag, auf den sich seine Konsumenten stützen — collision, trigger, //! audio, render::props und session::load_props arbeiten alle auf dieser //! einen Struktur, statt selbst glTF zu lesen. Eine Entkopplungsgrenze //! zwischen dem einen Loader und seinen Konsumenten, keine Format-Abstraktion: //! glTF ist der einzige 3D-Pfad (die OBJ- und Map-Loader-Pfade sind //! Geschichte), `gltf` der einzige Produzent. //! //! Texturen kommen als Pixel mit — der Loader zieht sie aus der `.glb` //! (baseColorTexture, PNG-Chunk im BIN), siehe [`Texture`]. Keine externe //! Namenskonvention mehr. //! //! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine //! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe //! [`apply_props`]: //! `collide = true` → sichtbar **und** Collider (konvexe Hülle des //! Meshes, siehe collision::object_planes) //! `collide = "proxy"` → unsichtbarer Collider (reines Kollisionsvolumen) //! `signal = ` → Trigger; mit Collider Klick-Ziel, ohne Collider //! unsichtbare Betretens-Zone (engine::trigger) //! nichts davon → nur sichtbar //! //! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe //! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier — //! der Loader bleibt frei von Signal-Wissen. use std::collections::HashMap; use crate::engine::image::Image; /// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`), /// Werte zu Strings vereinheitlicht. pub type Props = HashMap; pub struct Model { pub objects: Vec, /// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für /// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten. pub empties: Vec, /// Distinkte Texturen in Auftrittsreihenfolge; `Object::tri_mats` /// indiziert hierhin. pub textures: Vec, } /// Eine baseColorTexture aus der `.glb` (bzw. ein Material ohne Bild). pub struct Texture { /// Dedup-/Log-Schlüssel: der glTF-Bildname (Blender: Name des /// Bild-Datablocks), sonst der Materialname, sonst `""`. Texturen mit /// gleichem Schlüssel — auch über mehrere `.glb` — teilen sich auf der /// GPU eine Textur. pub key: String, /// Dekodierte Pixel (RGBA8, Ursprung oben links). `None`: Material ohne /// baseColorTexture oder unlesbares Bild → der Konsument nimmt seine /// Platzhalter-Textur. pub image: Option, } pub struct Object { pub name: String, /// Positionen in Engine-Koords (Y-up, Blick −Z), Welt-Raum (Transforms /// vom Loader eingebacken), Maßstab 1:1 (Blender-Meter = Engine-Unit). pub verts: Vec<[f32; 3]>, /// UVs parallel zu `verts`, V-Ursprung oben links — glTF- und zugleich /// `tga::Image`-Konvention, direkt sampelbar ohne Flip. pub uvs: Vec<[f32; 2]>, /// Normalen parallel zu `verts`, normiert, Welt-Raum. Fehlen sie im /// Modell, rechnet der Loader sie aus den Flächen. pub normals: Vec<[f32; 3]>, pub tris: Vec<[usize; 3]>, /// Textur-Index je Dreieck, parallel zu `tris` (→ `Model::textures`). pub tri_mats: Vec, pub visible: bool, pub collider: bool, pub props: Props, } pub struct Empty { pub name: String, /// Welt-Position (Engine-Koords). pub pos: [f32; 3], pub props: Props, } /// Custom-Property-Schlüssel, der ein Objekt zum Collider macht (siehe /// Modul-Doc): truthy (`true`, `1`, …) = sichtbar + Collider, `"proxy"` = /// unsichtbarer Collider. `0`/`false`/`no`/leer zählen als aus, damit die /// Autorin die Property abschalten kann, ohne sie zu löschen. pub const COLLIDE_PROP: &str = "collide"; /// Custom-Property-Schlüssel, der ein Objekt zum Trigger macht (Wert = /// Signal-Name für `signals::dispatch`). Die Collider-Frage entscheidet die /// Semantik — anfassbar = klickbar, durchlaufbar = Betretens-Zone: /// `collide` + `signal` → solide **und** klickbar (Point-and-Click) /// nur `signal` → unsichtbare Trigger-Zone, feuert beim Betreten /// (einmal je Eintritt; erneut erst nach Verlassen) pub const SIGNAL_PROP: &str = "signal"; /// Custom-Property-Schlüssel für Empty-Rollen (der Entity-Kanal): /// `role = "spawn"` markiert den Spieler-Startpunkt (Fußpunkt = /// Empty-Position). Weitere Rollen geben künftige Konsumenten. pub const ROLE_PROP: &str = "role"; pub const ROLE_SPAWN: &str = "spawn"; /// Auf Länge 1 bringen; der Nullvektor bleibt der Nullvektor (Sentinel für /// „noch keine Normale" in [`fill_missing_normals`]). pub fn norm(v: [f32; 3]) -> [f32; 3] { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); if l > 0.0 { [v[0] / l, v[1] / l, v[2] / l] } else { v } } /// Fehlende Normalen (Nullvektoren) aus den angrenzenden Flächen füllen — /// glTF erlaubt Meshes ohne `NORMAL`. Die Flächennormalen werden je Vertex /// aufsummiert, was an Kanten weich mittelt; Alternative wäre, die Vertices /// je Fläche aufzuspalten (hartes Flat-Shading) — dafür ist der Look hier /// nicht empfindlich genug. pub fn fill_missing_normals(o: &mut Object) { if !o.normals.contains(&[0.0; 3]) { return; } let mut acc = vec![[0.0f32; 3]; o.verts.len()]; for t in &o.tris { let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]); let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; let n = [ u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0], ]; for &i in t { for k in 0..3 { acc[i][k] += n[k]; } } } for (i, n) in o.normals.iter_mut().enumerate() { if *n == [0.0; 3] { *n = norm(acc[i]); } } } /// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte /// starten sichtbar und ohne Collider): `collide` schaltet den Collider, /// `"proxy"` und Betretens-Zonen sind reine Volumen und werden nie gerendert. pub fn apply_props(o: &mut Object) { if let Some(v) = o.props.get(COLLIDE_PROP) { let v = v.to_ascii_lowercase(); if !matches!(v.as_str(), "" | "0" | "false" | "no") { o.collider = true; if v == "proxy" { o.visible = false; } } } if !o.collider && o.props.contains_key(SIGNAL_PROP) { o.visible = false; } } /// Test-Helfer: Einheitswürfel-Objekt bei `min..min+1` (Quads CCW von außen, /// Fan-trianguliert wie der Loader) mit optionalen `collide`-/`signal`- /// Properties — geteilt von trigger-/session-/collision-Tests. #[cfg(test)] pub(crate) fn test_cube(name: &str, min: [f32; 3], collide: Option<&str>, signal: Option<&str>) -> Object { let v = |dx: f32, dy: f32, dz: f32| [min[0] + dx, min[1] + dy, min[2] + dz]; let verts = vec![ v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0), v(0.0, 0.0, 1.0), v(1.0, 0.0, 1.0), v(1.0, 1.0, 1.0), v(0.0, 1.0, 1.0), ]; let quads = [ [4, 5, 6, 7], [0, 3, 2, 1], [1, 2, 6, 5], [0, 4, 7, 3], [3, 7, 6, 2], [0, 1, 5, 4], ]; let mut tris = Vec::new(); for q in quads { tris.push([q[0], q[1], q[2]]); tris.push([q[0], q[2], q[3]]); } let n = tris.len(); let mut props = Props::new(); if let Some(c) = collide { props.insert(COLLIDE_PROP.into(), c.into()); } if let Some(s) = signal { props.insert(SIGNAL_PROP.into(), s.into()); } let mut o = Object { name: name.into(), uvs: vec![[0.0, 0.0]; verts.len()], // Vom Mittelpunkt nach außen — die Collider-Tests rendern nie, aber // so ist die Richtung wenigstens plausibel. normals: verts.iter() .map(|v| norm([v[0] - min[0] - 0.5, v[1] - min[1] - 0.5, v[2] - min[2] - 0.5])) .collect(), verts, tris, tri_mats: vec![0; n], visible: true, collider: false, props, }; apply_props(&mut o); o } #[cfg(test)] mod tests { use super::*; #[test] fn collide_prop_rules() { // truthy → sichtbar + Collider; "proxy" → unsichtbarer Collider. let o = test_cube("Kiste", [0.0; 3], Some("true"), None); assert!(o.visible && o.collider); let o = test_cube("Wand", [0.0; 3], Some("proxy"), None); assert!(!o.visible && o.collider); // Abschaltwerte zählen als aus; ohne Property nur sichtbar. let o = test_cube("Aus", [0.0; 3], Some("0"), None); assert!(o.visible && !o.collider); let o = test_cube("Deko", [0.0; 3], None, None); assert!(o.visible && !o.collider); } #[test] fn signal_without_collider_is_invisible_zone() { let o = test_cube("Zone", [0.0; 3], None, Some("betreten")); assert!(!o.visible && !o.collider); // Mit Collider bleibt das Objekt sichtbar (Klick-Ziel). let o = test_cube("Kiste", [0.0; 3], Some("1"), Some("kiste")); assert!(o.visible && o.collider); } }