Blender-Import-Pipeline: glTF/OBJ, col_*-Proxies, Trigger

Blender wird das Haupt-3D-Tool: zwei Loader (engine/gltf.rs für .glb als
Hauptpfad, engine/obj.rs aus irl3d eingebettet) liefern ein neutrales
engine/model.rs::Model. Loader sind reine Dekoder; das Frontend zieht sie
und schiebt das Ergebnis per Session::load_props in den State, der
Renderer baut sein Mesh (render/props.rs, verschmolzen mit der
Brush-Welt über eine geteilte Texturliste).

glTF trägt Blender-Custom-Properties (node.extras → Object::props) und
Empties (Nodes ohne Mesh) als Entity-Kanal. Namenskonvention: `col_*` =
unsichtbarer Collision-Proxy, aus dem collision::add_model konvexe
Ebenen-Sets baut (konvex-only, daher kein Default-Collider auf
Sichtgeometrie).

Trigger (engine/trigger.rs) hängen an der Custom Property `signal`:
mit Collider = Point-and-Click-Ziel (Session::pick raycastet aus der
Bildmitte, Treffer läuft durch den `use <name>`-Trichter), ohne Collider
= unsichtbare Betretens-Zone, die einmal je Eintritt feuert. Gefeuert
wird nur über Session::exec — der Eingabe-Trichter bleibt der einzige
Weg in den State.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:20:00 +02:00
parent 67c4a366c7
commit f7bfdb4a0b
14 changed files with 1465 additions and 43 deletions
+123
View File
@@ -0,0 +1,123 @@
//! Blender-Modell → Render-Geometrie.
//!
//! Gegenstück zu render::brush für den OBJ-Pfad: nimmt das neutrale
//! [`Model`] aus engine::obj und baut daraus ein [`Mesh`] (Vertices + nach
//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem
//! Buffer verschmilzt (siehe `Mesh::append`).
//!
//! Texturen: der `usemtl`-Materialname *ist* der Texturname — aufgelöst wie
//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces
//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`].
//!
//! UVs: OBJ hat den V-Ursprung unten links, unsere `tga::Image`s oben links
//! — V wird hier gespiegelt (Achsen-Konvention ist Sache des Konsumenten,
//! siehe engine::tga). Nur `visible`-Objekte werden ausgegeben; reine
//! `col_*`-Proxies sind Sache von engine::collision.
use crate::engine::model::Model;
use crate::render::scene::{Batch, Mesh, Vertex};
/// Texturname für Faces ohne `usemtl` (Material `""`).
pub const FALLBACK_TEXTURE: &str = "placeholder";
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
/// Fallback gemappt — dasselbe Kontrakt wie `brush::texture_names`.
pub fn texture_names(model: &Model) -> Vec<String> {
let mut names: Vec<String> = model.materials.iter()
.map(|m| resolve(m).to_string())
.collect();
names.sort_unstable();
names.dedup();
names
}
fn resolve(material: &str) -> &str {
if material.is_empty() { FALLBACK_TEXTURE } else { material }
}
/// Alle sichtbaren Objekte zu einem Mesh. `tex_names` gibt (wie bei
/// `brush::build`) die Textur-Indizes vor — darf eine Obermenge sein, so
/// teilen Brush- und Modell-Geometrie eine Texturliste.
pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
let mut verts = Vec::new();
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
for o in &model.objects {
if !o.visible { continue; }
let base = verts.len() as u32;
for (p, uv) in o.verts.iter().zip(&o.uvs) {
verts.push(Vertex { pos: *p, uv: [uv[0], 1.0 - uv[1]] }); // V-Flip
}
for (t, &m) in o.tris.iter().zip(&o.tri_mats) {
let name = resolve(&model.materials[m]);
let Some(ti) = tex_names.iter().position(|n| n == name) else { continue; };
per_tex[ti].extend(t.map(|i| base + i as u32));
}
}
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen (wie brush).
let mut indices = Vec::new();
let mut batches = Vec::new();
for (ti, list) in per_tex.into_iter().enumerate() {
if list.is_empty() { continue; }
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
indices.extend_from_slice(&list);
}
Mesh { verts, indices, batches }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::obj;
const SAMPLE: &str = "\
o Ding
v 0 0 0
v 1 0 0
v 1 1 0
vt 0 0
vt 1 0
vt 1 1
usemtl carpet
f 1/1 2/2 3/3
o col_Proxy
v 5 0 0
v 6 0 0
v 6 1 0
f 4 5 6
";
#[test]
fn builds_only_visible_objects_with_v_flip() {
let m = obj::parse(SAMPLE);
let names = texture_names(&m);
assert_eq!(names, vec!["carpet".to_string()]);
let mesh = build(&m, &names);
// Nur „Ding" (col_Proxy ist unsichtbar): 3 Vertices, 1 Dreieck.
assert_eq!(mesh.verts.len(), 3);
assert_eq!(mesh.indices, vec![0, 1, 2]);
assert_eq!(mesh.batches.len(), 1);
// vt (1,1) → V-Flip → uv (1,0).
assert_eq!(mesh.verts[2].uv, [1.0, 0.0]);
}
#[test]
fn missing_material_maps_to_fallback() {
let m = obj::parse("o A\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n");
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
let mesh = build(&m, &texture_names(&m));
assert_eq!(mesh.batches.len(), 1);
}
#[test]
fn shares_texture_index_space_with_superset_list() {
// Obermengen-Liste (wie beim Merge mit Brush-Texturen): der Batch
// zeigt auf den richtigen Index in der geteilten Liste.
let m = obj::parse(SAMPLE);
let names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()];
let mesh = build(&m, &names);
assert_eq!(mesh.batches[0].texture, 1);
}
}