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
+124 -9
View File
@@ -13,11 +13,21 @@
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
//! sofort wieder im Kontakt steckt.
//!
//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen
//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in
//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`.
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
//! und konvexe `col_*`-Proxies aus Blender-OBJ-Exporten ([`add_model`]).
//! Beide enden als dasselbe Ebenen-Set — die Trace kennt keine Herkunft.
//! Proxy-Ebenen werden aus den Dreiecks-Flächen abgeleitet (koplanare
//! dedupliziert) und um die sechs achsparallelen AABB-Ebenen ergänzt
//! (Quake-„Bevel planes"): die `|n|·half`-Aufblasung ist nur eine
//! konservative Näherung der Minkowski-Summe; die Axial-Ebenen ziehen sie
//! an Kanten schräger Proxies wieder stramm.
//!
//! Headless wie der Rest von `engine`: hängt nur an `map`/`obj` (Quellen
//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
//! Welt einmal vom Frontend aus Map und Modellen.
use crate::engine::map::{self, Map};
use crate::engine::model::Model;
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
@@ -66,12 +76,24 @@ impl CollisionWorld {
Self { brushes: Vec::new() }
}
/// Collision-Proxies eines Blender-Modells hinzufügen: jedes Objekt mit
/// `collider = true` wird als konvexer Brush übernommen (siehe
/// [`object_planes`]).
pub fn add_model(&mut self, model: &Model) {
for o in &model.objects {
if !o.collider { continue; }
if let Some(planes) = object_planes(o) {
self.brushes.push(ConvexBrush { planes });
}
}
}
/// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte)
/// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`.
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
let mut nearest: Option<Hit> = None;
for b in &self.brushes {
if let Some(h) = trace_brush(b, start, end, half)
if let Some(h) = trace_planes(&b.planes, start, end, half)
&& nearest.as_ref().is_none_or(|n| h.frac < n.frac) {
nearest = Some(h);
}
@@ -80,17 +102,67 @@ impl CollisionWorld {
}
}
/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen.
/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt
/// (dann nicht blocken — sonst bliebe der Player hängen).
fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
/// Das **konvexe** Ebenen-Set eines Modell-Objekts: Ebenen aus den Dreiecks-
/// Flächen (koplanare dedupliziert) plus die sechs achsparallelen AABB-Ebenen
/// (Bevel-Ebenen, siehe Modul-Doc). `None`, wenn kein geschlossenes Volumen
/// entsteht. Konkave Objekte sind Autorinnen-Fehler und ergeben zu große
/// Volumen — die Konvention heißt deshalb `col_*`-*Boxen/Prismen*.
pub(crate) fn object_planes(o: &crate::engine::model::Object) -> Option<Vec<Plane>> {
let mut planes: Vec<Plane> = Vec::new();
let (mut bb_min, mut bb_max) = ([f32::MAX; 3], [f32::MIN; 3]);
for v in &o.verts {
for k in 0..3 {
bb_min[k] = bb_min[k].min(v[k]);
bb_max[k] = bb_max[k].max(v[k]);
}
}
for t in &o.tris {
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
// OBJ-/glTF-Winding ist CCW von außen → cross(ba, ca) zeigt nach
// außen. Degenerierte Dreiecke (Länge ~0) überspringen.
let n = cross(sub(b, a), sub(c, a));
if dot(n, n) < 1e-12 { continue; }
let n = normalize(n);
let d = dot(n, a);
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - d).abs() < 1e-4) {
planes.push(Plane { n, d });
}
}
// Axiale Bevel-Ebenen (dedupe fängt achsparallele Duplikate).
for k in 0..3 {
let mut n = [0.0f32; 3];
n[k] = 1.0;
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - bb_max[k]).abs() < 1e-4) {
planes.push(Plane { n, d: bb_max[k] });
}
n[k] = -1.0;
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d + bb_min[k]).abs() < 1e-4) {
planes.push(Plane { n, d: -bb_min[k] });
}
}
(planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen
}
/// Überlappt eine AABB (Mittelpunkt/Halbmaße) das konvexe Volumen? Gleiche
/// Minkowski-Aufblasung wie die Trace — „berühren" zählt als drin.
pub(crate) fn box_touches(planes: &[Plane], center: [f32; 3], half: [f32; 3]) -> bool {
planes.iter().all(|p| {
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
dot(p.n, center) <= d
})
}
/// Segment `start→end` gegen ein aufgeblasenes konvexes Ebenen-Set clippen.
/// `None`, wenn das Segment das Volumen verfehlt oder der Start schon drin
/// steckt (dann nicht blocken — sonst bliebe der Player hängen).
pub(crate) fn trace_planes(planes: &[Plane], start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch
let mut leave = 1.0f32; // kleinster Austritts-Bruch
let mut normal = [0.0f32; 3];
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
let mut started_outside = false;
for p in &b.planes {
for p in planes {
// Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski).
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
let ds = dot(p.n, start) - d;
@@ -206,6 +278,49 @@ mod tests {
assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac);
}
// Einheitswürfel [0,1]³ als col_-Proxy, Quads CCW von außen (wie ein
// Blender-Export), plus ein vis_-Objekt, das keinen Collider ergeben darf.
const PROXY_OBJ: &str = "\
o col_Box
v 0 0 0
v 1 0 0
v 1 1 0
v 0 1 0
v 0 0 1
v 1 0 1
v 1 1 1
v 0 1 1
f 5 6 7 8
f 1 4 3 2
f 2 3 7 6
f 1 5 8 4
f 4 8 7 3
f 1 2 6 5
o vis_Deko
v 5 5 5
v 6 5 5
v 6 6 5
f 9 10 11
";
#[test]
fn obj_proxy_becomes_convex_brush() {
use crate::engine::obj;
let model = obj::parse(PROXY_OBJ);
let mut w = CollisionWorld::empty();
w.add_model(&model);
// Nur col_Box (vis_ trägt nicht bei); 12 Dreiecks-Ebenen dedupen zu 6,
// die axialen Bevel-Ebenen sind mit ihnen identisch (achsparallel).
assert_eq!(w.brushes.len(), 1);
assert_eq!(w.brushes[0].planes.len(), 6);
// Strahl von X hindurch: Kontakt an x≈0 mit X-Normale.
let h = w.trace([-1.0, 0.5, 0.5], [3.0, 0.5, 0.5], PT).unwrap();
assert!((h.frac - 0.25).abs() < 0.02, "frac={}", h.frac);
assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal);
}
#[test]
fn builds_brushes_from_map() {
// Ein achsenparalleler Quader-Brush (Quake-Koords).