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
+128
View File
@@ -0,0 +1,128 @@
//! Neutrales 3D-Modell — das gemeinsame Ergebnis der Blender-Loader
//! (engine::obj, engine::gltf). Konsumenten (render::props, collision)
//! kennen nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen
//! egal — so bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
//!
//! Naming-Konvention (Objekt-Präfix, case-insensitiv):
//! `col_*` → unsichtbar, collidierbar (Collision-Proxy, konvex!)
//! sonst → nur sichtbar (`vis_*` sagt dasselbe explizit)
//!
//! Anders als irl3d („ohne Präfix = beides"): dessen Physik kollidierte per
//! Dreieck, unsere per konvexem Ebenen-Schnitt — konkave Sichtgeometrie als
//! Default-Collider ergäbe hier nur ein sinnloses Hüll-Volumen. Kollision
//! ist deshalb immer ein bewusst gebauter `col_*`-Proxy.
//!
//! Objektnamen sind zugleich die künftigen Interact-Keys: Blender-Suffixe
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
//! die Loader bleiben frei von Signal-Wissen.
use std::collections::HashMap;
/// Blender-Custom-Properties eines Objekts/Empties (glTF-`extras`),
/// Werte zu Strings vereinheitlicht. OBJ kann keine tragen → leer.
pub type Props = HashMap<String, String>;
pub struct Model {
pub objects: Vec<Object>,
/// Nodes ohne Mesh (Blender-Empties) — der Entity-Kanal: Marker für
/// Spawn-Punkte, Trigger o.Ä., Bedeutung geben künftige Konsumenten.
pub empties: Vec<Empty>,
/// Distinkte Materialnamen in Auftrittsreihenfolge; `Object::tri_mats`
/// indiziert hierhin. `""` steht für „Faces ohne Material" — der
/// Konsument wählt dafür seinen Fallback (z.B. Platzhalter-Textur).
pub materials: Vec<String>,
}
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 unten links (OBJ-/glTF-Rohform;
/// den Flip auf Bild-Konvention macht der Render-Konsument).
pub uvs: Vec<[f32; 2]>,
pub tris: Vec<[usize; 3]>,
/// Material-Index je Dreieck, parallel zu `tris` (→ `Model::materials`).
pub tri_mats: Vec<usize>,
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,
}
/// Präfix-Klassifikation (case-insensitiv): `col_` = nur Collider, alles
/// andere nur sichtbar (siehe Modul-Doc, Abweichung von irl3d).
pub fn classify(name: &str) -> (bool, bool) {
let is_col = name.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("col_"));
(!is_col, is_col)
}
/// 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:
/// `col_*` + `signal` → solide **und** klickbar (Point-and-Click)
/// sonst + `signal` → unsichtbare Trigger-Zone, feuert beim Betreten
/// (einmal je Eintritt; erneut erst nach Verlassen)
pub const SIGNAL_PROP: &str = "signal";
/// Die `signal`-Regel anwenden (nach `classify`, wenn `props` bekannt sind):
/// eine Betretens-Zone ist ein reines Trigger-Volumen und wird nie gerendert.
pub fn apply_signal_rule(o: &mut Object) {
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 die Loader) mit optionaler `signal`-Property —
/// geteilt von trigger-/session-Tests.
#[cfg(test)]
pub(crate) fn test_cube(name: &str, min: [f32; 3], 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 (visible, collider) = classify(name);
let mut props = Props::new();
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()],
verts, tris,
tri_mats: vec![0; n],
visible, collider, props,
};
apply_signal_rule(&mut o);
o
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_is_prefix_and_case_insensitive() {
assert_eq!(classify("col_Wand"), (false, true));
assert_eq!(classify("COL_Wand"), (false, true));
assert_eq!(classify("vis_Deko"), (true, false));
assert_eq!(classify("Suzanne"), (true, false));
assert_eq!(classify(""), (true, false));
}
}