Collider via collide-Property + Quickhull, OBJ-Pfad entfernt
col_*-Namenspräfix raus: Rollen steuern nur noch Custom Properties (collide=1 sichtbar+Collider, "proxy" unsichtbar, signal wie gehabt). Collider sind die konvexe Hülle der Vertices — konkave Meshes erlaubt. glb ist der einzige Modell-Pfad; suzanne.obj → suzanne.glb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
# Blender 5.0.0 MTL File: 'None'
|
|
||||||
# www.blender.org
|
|
||||||
File diff suppressed because it is too large
Load Diff
+233
-71
@@ -14,20 +14,21 @@
|
|||||||
//! sofort wieder im Kontakt steckt.
|
//! sofort wieder im Kontakt steckt.
|
||||||
//!
|
//!
|
||||||
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
|
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
|
||||||
//! und konvexe `col_*`-Proxies aus Blender-OBJ-Exporten ([`add_model`]).
|
//! und Collider-Objekte (`collide`-Property) aus Blender-Modellen
|
||||||
//! Beide enden als dasselbe Ebenen-Set — die Trace kennt keine Herkunft.
|
//! ([`add_model`]). Beide enden als dasselbe Ebenen-Set — die Trace kennt
|
||||||
//! Proxy-Ebenen werden aus den Dreiecks-Flächen abgeleitet (koplanare
|
//! keine Herkunft. Modell-Ebenen sind die **konvexe Hülle** der Vertices
|
||||||
//! dedupliziert) und um die sechs achsparallelen AABB-Ebenen ergänzt
|
//! ([`hull_planes`], inkrementeller Quickhull) — das Mesh selbst darf also
|
||||||
//! (Quake-„Bevel planes"): die `|n|·half`-Aufblasung ist nur eine
|
//! konkav sein, kollidiert wird gegen seine Hülle. Ergänzt um die sechs
|
||||||
//! konservative Näherung der Minkowski-Summe; die Axial-Ebenen ziehen sie
|
//! achsparallelen AABB-Ebenen (Quake-„Bevel planes"): die `|n|·half`-
|
||||||
//! an Kanten schräger Proxies wieder stramm.
|
//! Aufblasung ist nur eine konservative Näherung der Minkowski-Summe; die
|
||||||
|
//! Axial-Ebenen ziehen sie an Kanten schräger Hüllen wieder stramm.
|
||||||
//!
|
//!
|
||||||
//! Headless wie der Rest von `engine`: hängt nur an `map`/`obj` (Quellen
|
//! Headless wie der Rest von `engine`: hängt nur an `map`/`model` (Quellen
|
||||||
//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
//! der Ebenen). Der Player ruft `trace` in `player::step`; gebaut wird die
|
||||||
//! Welt einmal vom Frontend aus Map und Modellen.
|
//! Welt einmal vom Frontend aus Map und Modellen.
|
||||||
|
|
||||||
use crate::engine::map::{self, Map};
|
use crate::engine::map::{self, Map};
|
||||||
use crate::engine::model::Model;
|
use crate::engine::model::{Model, Object};
|
||||||
|
|
||||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||||
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
||||||
@@ -76,14 +77,15 @@ impl CollisionWorld {
|
|||||||
Self { brushes: Vec::new() }
|
Self { brushes: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collision-Proxies eines Blender-Modells hinzufügen: jedes Objekt mit
|
/// Collider eines Blender-Modells hinzufügen: jedes Objekt mit
|
||||||
/// `collider = true` wird als konvexer Brush übernommen (siehe
|
/// `collider = true` (aus der `collide`-Property) wird als konvexer
|
||||||
/// [`object_planes`]).
|
/// Brush übernommen (siehe [`object_planes`]).
|
||||||
pub fn add_model(&mut self, model: &Model) {
|
pub fn add_model(&mut self, model: &Model) {
|
||||||
for o in &model.objects {
|
for o in &model.objects {
|
||||||
if !o.collider { continue; }
|
if !o.collider { continue; }
|
||||||
if let Some(planes) = object_planes(o) {
|
match object_planes(o) {
|
||||||
self.brushes.push(ConvexBrush { planes });
|
Some(planes) => self.brushes.push(ConvexBrush { planes }),
|
||||||
|
None => eprintln!("[collision] {}: kein Volumen — ignoriert", o.name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,47 +104,173 @@ impl CollisionWorld {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Das **konvexe** Ebenen-Set eines Modell-Objekts: Ebenen aus den Dreiecks-
|
/// Das konvexe Ebenen-Set eines Modell-Objekts: die **konvexe Hülle** seiner
|
||||||
/// Flächen (koplanare dedupliziert) plus die sechs achsparallelen AABB-Ebenen
|
/// Vertices ([`hull_planes`]) plus die sechs achsparallelen AABB-Ebenen
|
||||||
/// (Bevel-Ebenen, siehe Modul-Doc). `None`, wenn kein geschlossenes Volumen
|
/// (Bevel-Ebenen, siehe Modul-Doc) — das Mesh darf also konkav sein. Flache
|
||||||
/// entsteht. Konkave Objekte sind Autorinnen-Fehler und ergeben zu große
|
/// Objekte (kein Hüll-Volumen) fallen auf ihre Face-Ebenen zurück, mit den
|
||||||
/// Volumen — die Konvention heißt deshalb `col_*`-*Boxen/Prismen*.
|
/// Bevel-Ebenen ergibt das die dünne AABB-Platte (brauchbar für flächige
|
||||||
pub(crate) fn object_planes(o: &crate::engine::model::Object) -> Option<Vec<Plane>> {
|
/// Trigger-Zonen). `None`, wenn gar kein geschlossenes Volumen entsteht.
|
||||||
let mut planes: Vec<Plane> = Vec::new();
|
pub(crate) fn object_planes(o: &Object) -> Option<Vec<Plane>> {
|
||||||
let (mut bb_min, mut bb_max) = ([f32::MAX; 3], [f32::MIN; 3]);
|
if o.verts.is_empty() { return None; }
|
||||||
|
let mut planes = hull_planes(&o.verts).unwrap_or_else(|| face_planes(o));
|
||||||
|
|
||||||
|
// Axiale Bevel-Ebenen aus der AABB (push_plane dedupt achsparallele
|
||||||
|
// Duplikate weg).
|
||||||
|
let (mut bb_min, mut bb_max) = (o.verts[0], o.verts[0]);
|
||||||
for v in &o.verts {
|
for v in &o.verts {
|
||||||
for k in 0..3 {
|
for k in 0..3 {
|
||||||
bb_min[k] = bb_min[k].min(v[k]);
|
bb_min[k] = bb_min[k].min(v[k]);
|
||||||
bb_max[k] = bb_max[k].max(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(b−a, c−a) 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 {
|
for k in 0..3 {
|
||||||
let mut n = [0.0f32; 3];
|
let mut n = [0.0f32; 3];
|
||||||
n[k] = 1.0;
|
n[k] = 1.0;
|
||||||
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - bb_max[k]).abs() < 1e-4) {
|
push_plane(&mut planes, n, bb_max[k]);
|
||||||
planes.push(Plane { n, d: bb_max[k] });
|
|
||||||
}
|
|
||||||
n[k] = -1.0;
|
n[k] = -1.0;
|
||||||
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d + bb_min[k]).abs() < 1e-4) {
|
push_plane(&mut planes, n, -bb_min[k]);
|
||||||
planes.push(Plane { n, d: -bb_min[k] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen
|
(planes.len() >= 4).then_some(planes) // sonst kein geschlossenes Volumen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ebene anfügen, wenn keine (nahezu) gleiche schon da ist — Koplanar-Dedupe
|
||||||
|
/// für Hüll-Dreiecke und Bevel-Ebenen.
|
||||||
|
fn push_plane(planes: &mut Vec<Plane>, n: [f32; 3], d: f32) {
|
||||||
|
if !planes.iter().any(|p| dot(p.n, n) > 0.999 && (p.d - d).abs() < 1e-4) {
|
||||||
|
planes.push(Plane { n, d });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback für degenerierte (flache) Objekte: Ebenen direkt aus den
|
||||||
|
/// Dreiecks-Flächen (koplanare dedupliziert). OBJ-/glTF-Winding ist CCW von
|
||||||
|
/// außen → cross(b−a, c−a) zeigt nach außen.
|
||||||
|
fn face_planes(o: &Object) -> Vec<Plane> {
|
||||||
|
let mut planes: Vec<Plane> = Vec::new();
|
||||||
|
for t in &o.tris {
|
||||||
|
let (a, b, c) = (o.verts[t[0]], o.verts[t[1]], o.verts[t[2]]);
|
||||||
|
let n = cross(sub(b, a), sub(c, a));
|
||||||
|
if dot(n, n) < 1e-12 { continue; } // degeneriert (Länge ~0)
|
||||||
|
let n = normalize(n);
|
||||||
|
push_plane(&mut planes, n, dot(n, a));
|
||||||
|
}
|
||||||
|
planes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toleranz der Hüllen-Konstruktion (units): Punkte näher als das an einer
|
||||||
|
/// Ebene gelten als koplanar. Absolut statt relativ — Maßstab ist fix
|
||||||
|
/// (1 unit = 1 Blender-Meter), und die Trace arbeitet mit ~SKIN-Auflösung.
|
||||||
|
const HULL_EPS: f32 = 1e-4;
|
||||||
|
|
||||||
|
/// Ein Hüll-Dreieck während der Konstruktion; `alive = false` = durch einen
|
||||||
|
/// später eingefügten Punkt ersetzt (Tombstone statt teurem Entfernen).
|
||||||
|
struct HullFace {
|
||||||
|
v: [usize; 3],
|
||||||
|
n: [f32; 3],
|
||||||
|
d: f32,
|
||||||
|
alive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hüll-Dreieck `(x, y, z)`, so orientiert, dass der Referenzpunkt `w`
|
||||||
|
/// (ein Punkt im Inneren des Start-Simplex) hinter der Ebene liegt.
|
||||||
|
fn hull_face(pts: &[[f32; 3]], x: usize, y: usize, z: usize, w: usize) -> HullFace {
|
||||||
|
let n = normalize(cross(sub(pts[y], pts[x]), sub(pts[z], pts[x])));
|
||||||
|
let d = dot(n, pts[x]);
|
||||||
|
if dot(n, pts[w]) > d {
|
||||||
|
HullFace { v: [x, z, y], n: [-n[0], -n[1], -n[2]], d: -d, alive: true }
|
||||||
|
} else {
|
||||||
|
HullFace { v: [x, y, z], n, d, alive: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Konvexe Hülle einer Punktwolke als nach außen orientierte Ebenen
|
||||||
|
/// (inkrementeller Quickhull: Start-Tetraeder aus Extrempunkten, dann jeden
|
||||||
|
/// Außenpunkt einfügen — sichtbare Faces sterben, der Horizont wird mit
|
||||||
|
/// neuen Dreiecken zum Punkt geschlossen). `None` bei degenerierter Eingabe
|
||||||
|
/// (alle Punkte ~kollinear/koplanar) — kein Volumen zum Aufspannen.
|
||||||
|
fn hull_planes(pts: &[[f32; 3]]) -> Option<Vec<Plane>> {
|
||||||
|
if pts.len() < 4 { return None; }
|
||||||
|
|
||||||
|
// Start-Simplex: das fernste Paar unter den sechs Achsen-Extrema, dazu
|
||||||
|
// der fernste Punkt zur Kante und der fernste zur aufgespannten Ebene.
|
||||||
|
let mut ext = [0usize; 6];
|
||||||
|
for (i, p) in pts.iter().enumerate() {
|
||||||
|
for k in 0..3 {
|
||||||
|
if p[k] < pts[ext[k]][k] { ext[k] = i; }
|
||||||
|
if p[k] > pts[ext[3 + k]][k] { ext[3 + k] = i; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (mut a, mut b, mut best) = (0, 0, -1.0);
|
||||||
|
for &i in &ext {
|
||||||
|
for &j in &ext {
|
||||||
|
let d = sub(pts[i], pts[j]);
|
||||||
|
let d = dot(d, d);
|
||||||
|
if d > best { best = d; (a, b) = (i, j); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < HULL_EPS * HULL_EPS { return None; } // alle Punkte ~identisch
|
||||||
|
|
||||||
|
let ab = sub(pts[b], pts[a]);
|
||||||
|
let (mut c, mut best) = (0, -1.0);
|
||||||
|
for (i, p) in pts.iter().enumerate() {
|
||||||
|
let x = cross(ab, sub(*p, pts[a]));
|
||||||
|
let x = dot(x, x);
|
||||||
|
if x > best { best = x; c = i; }
|
||||||
|
}
|
||||||
|
// |cross|² = Abstand² · |ab|² → Vergleich gegen EPS²·|ab|².
|
||||||
|
if best < HULL_EPS * HULL_EPS * dot(ab, ab) { return None; } // kollinear
|
||||||
|
|
||||||
|
let n = normalize(cross(ab, sub(pts[c], pts[a])));
|
||||||
|
let (mut e, mut best) = (0, -1.0);
|
||||||
|
for (i, p) in pts.iter().enumerate() {
|
||||||
|
let x = (dot(n, *p) - dot(n, pts[a])).abs();
|
||||||
|
if x > best { best = x; e = i; }
|
||||||
|
}
|
||||||
|
if best < HULL_EPS { return None; } // koplanar
|
||||||
|
|
||||||
|
// Tetraeder-Faces; als Innen-Referenz dient je der vierte Eckpunkt.
|
||||||
|
let mut faces = vec![
|
||||||
|
hull_face(pts, a, b, c, e),
|
||||||
|
hull_face(pts, a, b, e, c),
|
||||||
|
hull_face(pts, a, c, e, b),
|
||||||
|
hull_face(pts, b, c, e, a),
|
||||||
|
];
|
||||||
|
|
||||||
|
for p in 0..pts.len() {
|
||||||
|
// Faces, die den Punkt sehen (Punkt liegt vor ihrer Ebene).
|
||||||
|
let visible: Vec<usize> = faces.iter().enumerate()
|
||||||
|
.filter(|(_, f)| f.alive && dot(f.n, pts[p]) > f.d + HULL_EPS)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
if visible.is_empty() { continue; } // schon in der Hülle
|
||||||
|
|
||||||
|
// Horizont: gerichtete Kanten sichtbarer Faces, deren Gegenkante
|
||||||
|
// nicht selbst zu einem sichtbaren Face gehört. Weil die Windings
|
||||||
|
// konsistent sind, taucht jede innere Kante genau einmal je Richtung
|
||||||
|
// auf — der Horizont ist der Rest.
|
||||||
|
let mut edges: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for &fi in &visible {
|
||||||
|
let [x, y, z] = faces[fi].v;
|
||||||
|
edges.extend([(x, y), (y, z), (z, x)]);
|
||||||
|
faces[fi].alive = false;
|
||||||
|
}
|
||||||
|
for &(x, y) in &edges {
|
||||||
|
if edges.contains(&(y, x)) { continue; } // innere Kante
|
||||||
|
// Horizont-Kante behält ihre Richtung → (x, y, p) zeigt nach
|
||||||
|
// außen. Degenerierte Dreiecke (p ~auf der Kante) überspringen.
|
||||||
|
let nn = cross(sub(pts[y], pts[x]), sub(pts[p], pts[x]));
|
||||||
|
if dot(nn, nn) < 1e-12 { continue; }
|
||||||
|
let n = normalize(nn);
|
||||||
|
faces.push(HullFace { v: [x, y, p], n, d: dot(n, pts[x]), alive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut planes: Vec<Plane> = Vec::new();
|
||||||
|
for f in faces.iter().filter(|f| f.alive) {
|
||||||
|
push_plane(&mut planes, f.n, f.d); // koplanare Hüll-Dreiecke dedupen
|
||||||
|
}
|
||||||
|
Some(planes)
|
||||||
|
}
|
||||||
|
|
||||||
/// Überlappt eine AABB (Mittelpunkt/Halbmaße) das konvexe Volumen? Gleiche
|
/// Überlappt eine AABB (Mittelpunkt/Halbmaße) das konvexe Volumen? Gleiche
|
||||||
/// Minkowski-Aufblasung wie die Trace — „berühren" zählt als drin.
|
/// 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 {
|
pub(crate) fn box_touches(planes: &[Plane], center: [f32; 3], half: [f32; 3]) -> bool {
|
||||||
@@ -278,40 +406,22 @@ mod tests {
|
|||||||
assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac);
|
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]
|
#[test]
|
||||||
fn obj_proxy_becomes_convex_brush() {
|
fn collider_object_becomes_convex_brush() {
|
||||||
use crate::engine::obj;
|
use crate::engine::model::{test_cube, Model};
|
||||||
let model = obj::parse(PROXY_OBJ);
|
let model = Model {
|
||||||
|
objects: vec![
|
||||||
|
test_cube("Box", [0.0, 0.0, 0.0], Some("proxy"), None),
|
||||||
|
test_cube("Deko", [5.0, 5.0, 5.0], None, None),
|
||||||
|
],
|
||||||
|
empties: Vec::new(),
|
||||||
|
materials: vec![String::new()],
|
||||||
|
};
|
||||||
let mut w = CollisionWorld::empty();
|
let mut w = CollisionWorld::empty();
|
||||||
w.add_model(&model);
|
w.add_model(&model);
|
||||||
|
|
||||||
// Nur col_Box (vis_ trägt nicht bei); 12 Dreiecks-Ebenen dedupen zu 6,
|
// Nur Box (Deko hat kein collide); die Hülle des Würfels dedupt auf
|
||||||
// die axialen Bevel-Ebenen sind mit ihnen identisch (achsparallel).
|
// 6 Ebenen, die axialen Bevel-Ebenen sind mit ihnen identisch.
|
||||||
assert_eq!(w.brushes.len(), 1);
|
assert_eq!(w.brushes.len(), 1);
|
||||||
assert_eq!(w.brushes[0].planes.len(), 6);
|
assert_eq!(w.brushes[0].planes.len(), 6);
|
||||||
|
|
||||||
@@ -321,6 +431,58 @@ f 9 10 11
|
|||||||
assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal);
|
assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hull_of_concave_mesh_spans_the_notch() {
|
||||||
|
// Konkaves „L": zwei Würfel-Objekte liefern die Punktwolke eines
|
||||||
|
// L-Prismas — [0,2]×[0,1]×[0,1] plus [0,1]×[1,2]×[0,1]. Die Hülle
|
||||||
|
// muss die Kerbe (x>1, y>1) mit der Schräge x+y=3 überspannen.
|
||||||
|
let mut verts = Vec::new();
|
||||||
|
for (min, max) in [([0.0f32, 0.0, 0.0], [2.0f32, 1.0, 1.0]),
|
||||||
|
([0.0, 1.0, 0.0], [1.0, 2.0, 1.0])] {
|
||||||
|
for &x in &[min[0], max[0]] {
|
||||||
|
for &y in &[min[1], max[1]] {
|
||||||
|
for &z in &[min[2], max[2]] { verts.push([x, y, z]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let planes = hull_planes(&verts).unwrap();
|
||||||
|
// 6 AABB-Seiten der Gesamtbox + 1 Schräge über der Kerbe.
|
||||||
|
assert_eq!(planes.len(), 7);
|
||||||
|
let diag = planes.iter()
|
||||||
|
.find(|p| p.n[0] > 0.5 && p.n[1] > 0.5)
|
||||||
|
.expect("Schräge über der Kerbe fehlt");
|
||||||
|
assert!((dot(diag.n, [2.0, 1.0, 0.5]) - diag.d).abs() < 1e-3,
|
||||||
|
"Schräge sollte durch (2,1,·) laufen");
|
||||||
|
|
||||||
|
// Ein Punkt in der Kerbe, unter der Schräge, liegt in der Hülle.
|
||||||
|
assert!(box_touches(&planes, [1.2, 1.2, 0.5], PT));
|
||||||
|
// Die Ecke der Kerbe (über der Schräge) liegt außerhalb.
|
||||||
|
assert!(!box_touches(&planes, [1.9, 1.9, 0.5], PT));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_objects_fall_back_or_are_rejected() {
|
||||||
|
use crate::engine::model::{Object, Props};
|
||||||
|
// Flaches Quad in der XY-Ebene: keine Hülle, aber der Face-Ebenen-
|
||||||
|
// Fallback + Bevels ergeben die dünne Platte.
|
||||||
|
let flat = Object {
|
||||||
|
name: "Zone".into(),
|
||||||
|
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
|
||||||
|
uvs: vec![[0.0, 0.0]; 4],
|
||||||
|
tris: vec![[0, 1, 2], [0, 2, 3]],
|
||||||
|
tri_mats: vec![0; 2],
|
||||||
|
visible: true, collider: false, props: Props::new(),
|
||||||
|
};
|
||||||
|
let planes = object_planes(&flat).unwrap();
|
||||||
|
assert!(box_touches(&planes, [0.5, 0.5, 0.0], PT));
|
||||||
|
assert!(!box_touches(&planes, [0.5, 0.5, 1.0], PT));
|
||||||
|
|
||||||
|
// Ohne Vertices gibt es nichts.
|
||||||
|
let empty = Object { verts: Vec::new(), uvs: Vec::new(), tris: Vec::new(),
|
||||||
|
tri_mats: Vec::new(), ..flat };
|
||||||
|
assert!(object_planes(&empty).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_brushes_from_map() {
|
fn builds_brushes_from_map() {
|
||||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||||
|
|||||||
+8
-7
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::engine::model::{apply_signal_rule, classify, Empty, Model, Object, Props};
|
use crate::engine::model::{apply_props, Empty, Model, Object, Props};
|
||||||
|
|
||||||
pub fn load(path: &str) -> Result<Model, String> {
|
pub fn load(path: &str) -> Result<Model, String> {
|
||||||
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
||||||
@@ -81,14 +81,14 @@ fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model)
|
|||||||
|
|
||||||
match node["mesh"].as_u64() {
|
match node["mesh"].as_u64() {
|
||||||
Some(mesh) => {
|
Some(mesh) => {
|
||||||
let (visible, collider) = classify(&name);
|
|
||||||
let mut o = Object {
|
let mut o = Object {
|
||||||
name, visible, collider, props,
|
name, props,
|
||||||
|
visible: true, collider: false, // bis apply_props entscheidet
|
||||||
verts: Vec::new(), uvs: Vec::new(),
|
verts: Vec::new(), uvs: Vec::new(),
|
||||||
tris: Vec::new(), tri_mats: Vec::new(),
|
tris: Vec::new(), tri_mats: Vec::new(),
|
||||||
};
|
};
|
||||||
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
||||||
apply_signal_rule(&mut o); // Betretens-Zonen werden nie gerendert
|
apply_props(&mut o); // collide-/signal-Regeln (siehe engine::model)
|
||||||
if !o.tris.is_empty() { model.objects.push(o); }
|
if !o.tris.is_empty() { model.objects.push(o); }
|
||||||
}
|
}
|
||||||
// Node ohne Mesh = Empty (Entity-Marker); Position aus der
|
// Node ohne Mesh = Empty (Entity-Marker); Position aus der
|
||||||
@@ -352,7 +352,7 @@ mod tests {
|
|||||||
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
||||||
"extras": {"signal": "tiffany", "hp": 3},
|
"extras": {"signal": "tiffany", "hp": 3},
|
||||||
"children": [1]},
|
"children": [1]},
|
||||||
{"name": "col_Kind", "mesh": 0},
|
{"name": "Kind", "mesh": 0, "extras": {"collide": "proxy"}},
|
||||||
{"name": "spawn", "translation": [1, 2, 3]}
|
{"name": "spawn", "translation": [1, 2, 3]}
|
||||||
],
|
],
|
||||||
"meshes": [{"primitives": [{
|
"meshes": [{"primitives": [{
|
||||||
@@ -388,9 +388,10 @@ mod tests {
|
|||||||
assert!(!d.visible && !d.collider);
|
assert!(!d.visible && !d.collider);
|
||||||
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
||||||
|
|
||||||
// Kind erbt die Parent-Transform (10, 0, 0).
|
// Kind erbt die Parent-Transform (10, 0, 0);
|
||||||
|
// collide="proxy" → unsichtbarer Collider.
|
||||||
let k = &m.objects[1];
|
let k = &m.objects[1];
|
||||||
assert_eq!(k.name, "col_Kind");
|
assert_eq!(k.name, "Kind");
|
||||||
assert!(!k.visible && k.collider);
|
assert!(!k.visible && k.collider);
|
||||||
assert_eq!(k.verts[0], [10.0, 0.0, 0.0]);
|
assert_eq!(k.verts[0], [10.0, 0.0, 0.0]);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -16,13 +16,12 @@
|
|||||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
||||||
//! signals::dispatch zu rufen).
|
//! signals::dispatch zu rufen).
|
||||||
//!
|
//!
|
||||||
//! `map`, `obj`, `gltf` und `tga` sind reine Decoder (Bytes → owned Daten,
|
//! `map`, `gltf` und `tga` sind reine Decoder (Bytes → owned Daten, hängen
|
||||||
//! hängen an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
//! an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
||||||
//! Frontend per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
//! Frontend per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
||||||
//! Datenmodells (Phase 2 des Renderer-Plans); `obj` und `gltf` sind der
|
//! Datenmodells (Phase 2 des Renderer-Plans); `gltf` ist der Blender-Pfad
|
||||||
//! Blender-Pfad (Sichtgeometrie + `col_*`-Collision-Proxies + Custom
|
//! (Sichtgeometrie, Collider/Trigger via Custom Properties, Empties aus
|
||||||
//! Properties aus einem Export) und produzieren beide dasselbe neutrale
|
//! einem Export) und produziert das neutrale `model::Model`.
|
||||||
//! `model::Model`.
|
|
||||||
//!
|
//!
|
||||||
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
||||||
//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt
|
//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt
|
||||||
@@ -36,7 +35,6 @@ pub mod ink;
|
|||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod obj;
|
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod signals;
|
pub mod signals;
|
||||||
pub mod story_ctrl;
|
pub mod story_ctrl;
|
||||||
|
|||||||
+58
-36
@@ -1,18 +1,19 @@
|
|||||||
//! Neutrales 3D-Modell — das gemeinsame Ergebnis der Blender-Loader
|
//! Neutrales 3D-Modell — das gemeinsame Ergebnis des Blender-Loaders
|
||||||
//! (engine::obj, engine::gltf). Konsumenten (render::props, collision)
|
//! (engine::gltf). Konsumenten (render::props, collision, trigger) kennen
|
||||||
//! kennen nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen
|
//! nur diese Struktur; welcher Parser sie erzeugt hat, ist ihnen egal — so
|
||||||
//! egal — so bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
|
//! bleibt ein Format-Wechsel ein lokaler Loader-Tausch.
|
||||||
//!
|
//!
|
||||||
//! Naming-Konvention (Objekt-Präfix, case-insensitiv):
|
//! Was ein Objekt *ist*, steuern seine Blender-Custom-Properties (keine
|
||||||
//! `col_*` → unsichtbar, collidierbar (Collision-Proxy, konvex!)
|
//! Namens-Präfixe — Namen bleiben frei für die Autorin), siehe
|
||||||
//! sonst → nur sichtbar (`vis_*` sagt dasselbe explizit)
|
//! [`apply_props`]:
|
||||||
|
//! `collide = true` → sichtbar **und** Collider (konvexe Hülle des
|
||||||
|
//! Meshes, siehe collision::object_planes)
|
||||||
|
//! `collide = "proxy"` → unsichtbarer Collider (reines Kollisionsvolumen)
|
||||||
|
//! `signal = <name>` → Trigger; mit Collider Klick-Ziel, ohne Collider
|
||||||
|
//! unsichtbare Betretens-Zone (engine::trigger)
|
||||||
|
//! nichts davon → nur sichtbar
|
||||||
//!
|
//!
|
||||||
//! Anders als irl3d („ohne Präfix = beides"): dessen Physik kollidierte per
|
//! Objektnamen sind zugleich die Interact-Keys: Blender-Suffixe
|
||||||
//! 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 —
|
//! (`Thing.001`) strippt `signals::signal_key` beim Dispatch, nicht hier —
|
||||||
//! die Loader bleiben frei von Signal-Wissen.
|
//! die Loader bleiben frei von Signal-Wissen.
|
||||||
|
|
||||||
@@ -56,34 +57,41 @@ pub struct Empty {
|
|||||||
pub props: Props,
|
pub props: Props,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Präfix-Klassifikation (case-insensitiv): `col_` = nur Collider, alles
|
/// Custom-Property-Schlüssel, der ein Objekt zum Collider macht (siehe
|
||||||
/// andere nur sichtbar (siehe Modul-Doc, Abweichung von irl3d).
|
/// Modul-Doc): truthy (`true`, `1`, …) = sichtbar + Collider, `"proxy"` =
|
||||||
pub fn classify(name: &str) -> (bool, bool) {
|
/// unsichtbarer Collider. `0`/`false`/`no`/leer zählen als aus, damit die
|
||||||
let is_col = name.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("col_"));
|
/// Autorin die Property abschalten kann, ohne sie zu löschen.
|
||||||
(!is_col, is_col)
|
pub const COLLIDE_PROP: &str = "collide";
|
||||||
}
|
|
||||||
|
|
||||||
/// Custom-Property-Schlüssel, der ein Objekt zum Trigger macht (Wert =
|
/// Custom-Property-Schlüssel, der ein Objekt zum Trigger macht (Wert =
|
||||||
/// Signal-Name für `signals::dispatch`). Die Collider-Frage entscheidet die
|
/// Signal-Name für `signals::dispatch`). Die Collider-Frage entscheidet die
|
||||||
/// Semantik — anfassbar = klickbar, durchlaufbar = Betretens-Zone:
|
/// Semantik — anfassbar = klickbar, durchlaufbar = Betretens-Zone:
|
||||||
/// `col_*` + `signal` → solide **und** klickbar (Point-and-Click)
|
/// `collide` + `signal` → solide **und** klickbar (Point-and-Click)
|
||||||
/// sonst + `signal` → unsichtbare Trigger-Zone, feuert beim Betreten
|
/// nur `signal` → unsichtbare Trigger-Zone, feuert beim Betreten
|
||||||
/// (einmal je Eintritt; erneut erst nach Verlassen)
|
/// (einmal je Eintritt; erneut erst nach Verlassen)
|
||||||
pub const SIGNAL_PROP: &str = "signal";
|
pub const SIGNAL_PROP: &str = "signal";
|
||||||
|
|
||||||
/// Die `signal`-Regel anwenden (nach `classify`, wenn `props` bekannt sind):
|
/// Die Property-Regeln anwenden, sobald `props` gelesen sind (Objekte
|
||||||
/// eine Betretens-Zone ist ein reines Trigger-Volumen und wird nie gerendert.
|
/// starten sichtbar und ohne Collider): `collide` schaltet den Collider,
|
||||||
pub fn apply_signal_rule(o: &mut Object) {
|
/// `"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) {
|
if !o.collider && o.props.contains_key(SIGNAL_PROP) {
|
||||||
o.visible = false;
|
o.visible = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-Helfer: Einheitswürfel-Objekt bei `min..min+1` (Quads CCW von außen,
|
/// Test-Helfer: Einheitswürfel-Objekt bei `min..min+1` (Quads CCW von außen,
|
||||||
/// Fan-trianguliert wie die Loader) mit optionaler `signal`-Property —
|
/// Fan-trianguliert wie der Loader) mit optionalen `collide`-/`signal`-
|
||||||
/// geteilt von trigger-/session-Tests.
|
/// Properties — geteilt von trigger-/session-/collision-Tests.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn test_cube(name: &str, min: [f32; 3], signal: Option<&str>) -> Object {
|
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 v = |dx: f32, dy: f32, dz: f32| [min[0] + dx, min[1] + dy, min[2] + dz];
|
||||||
let verts = vec![
|
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, 0.0), v(1.0, 0.0, 0.0), v(1.0, 1.0, 0.0), v(0.0, 1.0, 0.0),
|
||||||
@@ -99,17 +107,17 @@ pub(crate) fn test_cube(name: &str, min: [f32; 3], signal: Option<&str>) -> Obje
|
|||||||
tris.push([q[0], q[2], q[3]]);
|
tris.push([q[0], q[2], q[3]]);
|
||||||
}
|
}
|
||||||
let n = tris.len();
|
let n = tris.len();
|
||||||
let (visible, collider) = classify(name);
|
|
||||||
let mut props = Props::new();
|
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()); }
|
if let Some(s) = signal { props.insert(SIGNAL_PROP.into(), s.into()); }
|
||||||
let mut o = Object {
|
let mut o = Object {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
uvs: vec![[0.0, 0.0]; verts.len()],
|
uvs: vec![[0.0, 0.0]; verts.len()],
|
||||||
verts, tris,
|
verts, tris,
|
||||||
tri_mats: vec![0; n],
|
tri_mats: vec![0; n],
|
||||||
visible, collider, props,
|
visible: true, collider: false, props,
|
||||||
};
|
};
|
||||||
apply_signal_rule(&mut o);
|
apply_props(&mut o);
|
||||||
o
|
o
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +126,25 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_is_prefix_and_case_insensitive() {
|
fn collide_prop_rules() {
|
||||||
assert_eq!(classify("col_Wand"), (false, true));
|
// truthy → sichtbar + Collider; "proxy" → unsichtbarer Collider.
|
||||||
assert_eq!(classify("COL_Wand"), (false, true));
|
let o = test_cube("Kiste", [0.0; 3], Some("true"), None);
|
||||||
assert_eq!(classify("vis_Deko"), (true, false));
|
assert!(o.visible && o.collider);
|
||||||
assert_eq!(classify("Suzanne"), (true, false));
|
let o = test_cube("Wand", [0.0; 3], Some("proxy"), None);
|
||||||
assert_eq!(classify(""), (true, false));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
//! Wavefront-OBJ-Loader (Blender-Export) → neutrales [`Model`].
|
|
||||||
//!
|
|
||||||
//! Eingebettete Fassung des irl3d-Loaders (../irl3d/src/obj.rs), auf die
|
|
||||||
//! WDS-Loader-Regeln gebracht: reiner Decoder (Datei → CPU-Struct, kein
|
|
||||||
//! Textur-/Material-/GPU-Zugriff — Konsumenten laden selbst, wie bei
|
|
||||||
//! map/tga), f32 statt Fixed-Point, `Result` statt Panic (Blender-Exporte
|
|
||||||
//! sind Autorinnen-Content), und mit `vt`-UVs (irl3d ignorierte die).
|
|
||||||
//!
|
|
||||||
//! Subset: `v`, `vt`, `f`, `o`/`g`, `usemtl`. Alles andere ignoriert.
|
|
||||||
//! OBJ-Indizes sind 1-basiert und über die ganze Datei global; pro
|
|
||||||
//! `o`-Block wird lokal re-indexiert (Schlüssel ist das Paar Position/UV,
|
|
||||||
//! da dieselbe Position je Face verschiedene UVs tragen kann). N-gons
|
|
||||||
//! werden Fan-trianguliert. Kaputte Zeilen werden übersprungen (gemeldet),
|
|
||||||
//! nicht fatal.
|
|
||||||
//!
|
|
||||||
//! Achsen: Blenders OBJ-Exporter konvertiert per Default auf Forward=-Z,
|
|
||||||
//! Up=Y — das ist bereits unser Engine-System, kein Swap, Maßstab 1:1
|
|
||||||
//! (Blender-Meter = Engine-Unit; `MAP_SCALE` gilt nur für Quake-Maps).
|
|
||||||
//!
|
|
||||||
//! Ergebnis ist das format-neutrale [`Model`] (siehe engine::model, dort
|
|
||||||
//! auch die `col_*`-Namenskonvention). OBJ kann keine Custom Properties und
|
|
||||||
//! keine Empties tragen — `props` bleiben leer, `empties` gibt es nur im
|
|
||||||
//! glTF-Pfad (engine::gltf). Faces ohne `vt` bekommen UV [0, 0].
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs::read_to_string;
|
|
||||||
|
|
||||||
use crate::engine::model::{classify, Model, Object, Props};
|
|
||||||
|
|
||||||
pub fn load(path: &str) -> Result<Model, String> {
|
|
||||||
let src = read_to_string(path).map_err(|e| format!("obj load {path}: {e}"))?;
|
|
||||||
Ok(parse(&src))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(src: &str) -> Model {
|
|
||||||
// Globale Pools (Datei-weite 1-basierte Indizes).
|
|
||||||
let mut positions: Vec<[f32; 3]> = Vec::new();
|
|
||||||
let mut texcoords: Vec<[f32; 2]> = Vec::new();
|
|
||||||
|
|
||||||
let mut materials: Vec<String> = Vec::new();
|
|
||||||
let mut mat_by_name: HashMap<String, usize> = HashMap::new();
|
|
||||||
let mut objects: Vec<Object> = Vec::new();
|
|
||||||
|
|
||||||
// Aktueller o-Block. `cur_mat` persistiert über Objektgrenzen (OBJ ist
|
|
||||||
// zustandsbehaftet); `remap` bildet globale (pos, uv)-Paare auf lokale
|
|
||||||
// Vertex-Indizes ab.
|
|
||||||
let mut cur = empty_object();
|
|
||||||
let mut cur_mat: Option<usize> = None;
|
|
||||||
let mut remap: HashMap<(usize, Option<usize>), usize> = HashMap::new();
|
|
||||||
|
|
||||||
for line in src.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() || line.starts_with('#') { continue; }
|
|
||||||
let mut it = line.split_whitespace();
|
|
||||||
let Some(kw) = it.next() else { continue; };
|
|
||||||
match kw {
|
|
||||||
"v" => {
|
|
||||||
if let Some(p) = parse_floats::<3>(&mut it) {
|
|
||||||
positions.push(p);
|
|
||||||
} else {
|
|
||||||
eprintln!("[obj] kaputte v-Zeile übersprungen: {line:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"vt" => {
|
|
||||||
if let Some(t) = parse_floats::<2>(&mut it) {
|
|
||||||
texcoords.push(t);
|
|
||||||
} else {
|
|
||||||
eprintln!("[obj] kaputte vt-Zeile übersprungen: {line:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"o" | "g" => {
|
|
||||||
flush(&mut objects, &mut cur, &mut remap);
|
|
||||||
cur.name = it.collect::<Vec<_>>().join(" ");
|
|
||||||
}
|
|
||||||
"usemtl" => {
|
|
||||||
let name = it.next().unwrap_or("").to_string();
|
|
||||||
cur_mat = Some(*mat_by_name.entry(name.clone()).or_insert_with(|| {
|
|
||||||
materials.push(name);
|
|
||||||
materials.len() - 1
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"f" => {
|
|
||||||
// Token-Formen: "i", "i/uv", "i//n", "i/uv/n". Wir nehmen
|
|
||||||
// Position und (falls da) UV. Negative (relative) Indizes
|
|
||||||
// schreibt Blender nicht — außerhalb des Subsets.
|
|
||||||
let idxs: Option<Vec<usize>> = it.map(|tok| {
|
|
||||||
let mut parts = tok.split('/');
|
|
||||||
let vi = parts.next()?.parse::<usize>().ok()?.checked_sub(1)?;
|
|
||||||
if vi >= positions.len() { return None; }
|
|
||||||
let ti = match parts.next().filter(|s| !s.is_empty()) {
|
|
||||||
Some(s) => {
|
|
||||||
let t = s.parse::<usize>().ok()?.checked_sub(1)?;
|
|
||||||
if t >= texcoords.len() { return None; }
|
|
||||||
Some(t)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
Some(*remap.entry((vi, ti)).or_insert_with(|| {
|
|
||||||
cur.verts.push(positions[vi]);
|
|
||||||
cur.uvs.push(ti.map_or([0.0, 0.0], |t| texcoords[t]));
|
|
||||||
cur.verts.len() - 1
|
|
||||||
}))
|
|
||||||
}).collect();
|
|
||||||
let Some(idxs) = idxs else {
|
|
||||||
eprintln!("[obj] kaputte f-Zeile übersprungen: {line:?}");
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Material erst bei der ersten Face festnageln: Faces ohne
|
|
||||||
// jedes usemtl laufen unter dem ""-Material.
|
|
||||||
let m = *cur_mat.get_or_insert_with(|| {
|
|
||||||
*mat_by_name.entry(String::new()).or_insert_with(|| {
|
|
||||||
materials.push(String::new());
|
|
||||||
materials.len() - 1
|
|
||||||
})
|
|
||||||
});
|
|
||||||
// Fan-Triangulierung: (0,1,2), (0,2,3), …
|
|
||||||
for k in 1..idxs.len().saturating_sub(1) {
|
|
||||||
cur.tris.push([idxs[0], idxs[k], idxs[k + 1]]);
|
|
||||||
cur.tri_mats.push(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // vn, s, mtllib, … ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush(&mut objects, &mut cur, &mut remap);
|
|
||||||
|
|
||||||
Model { objects, empties: Vec::new(), materials }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_object() -> Object {
|
|
||||||
Object {
|
|
||||||
name: String::new(),
|
|
||||||
verts: Vec::new(), uvs: Vec::new(),
|
|
||||||
tris: Vec::new(), tri_mats: Vec::new(),
|
|
||||||
visible: true, collider: false,
|
|
||||||
props: Props::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fertigen o-Block als Objekt ablegen (leere verwerfen) und den Zustand
|
|
||||||
/// für den nächsten Block zurücksetzen.
|
|
||||||
fn flush(objects: &mut Vec<Object>, cur: &mut Object, remap: &mut HashMap<(usize, Option<usize>), usize>) {
|
|
||||||
let done = std::mem::replace(cur, empty_object());
|
|
||||||
remap.clear();
|
|
||||||
if done.tris.is_empty() { return; }
|
|
||||||
let (visible, collider) = classify(&done.name);
|
|
||||||
objects.push(Object { visible, collider, ..done });
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_floats<const N: usize>(it: &mut std::str::SplitWhitespace) -> Option<[f32; N]> {
|
|
||||||
let mut out = [0.0; N];
|
|
||||||
for v in &mut out {
|
|
||||||
*v = it.next()?.parse().ok()?;
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const SAMPLE: &str = "\
|
|
||||||
# Kommentar
|
|
||||||
o Suzanne
|
|
||||||
v 0 0 0
|
|
||||||
v 1 0 0
|
|
||||||
v 1 1 0
|
|
||||||
v 0 1 0
|
|
||||||
vt 0 0
|
|
||||||
vt 1 0
|
|
||||||
vt 1 1
|
|
||||||
vt 0 1
|
|
||||||
usemtl skin
|
|
||||||
f 1/1 2/2 3/3 4/4
|
|
||||||
o col_Wand.001
|
|
||||||
v 5 0 0
|
|
||||||
v 6 0 0
|
|
||||||
v 6 1 0
|
|
||||||
f 5 6 7
|
|
||||||
o VIS_Deko
|
|
||||||
v 7 7 7
|
|
||||||
v 8 7 7
|
|
||||||
v 8 8 7
|
|
||||||
f 8/1 9/2 10/3
|
|
||||||
";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_objects_with_local_reindex_and_fan() {
|
|
||||||
let m = parse(SAMPLE);
|
|
||||||
assert_eq!(m.objects.len(), 3);
|
|
||||||
|
|
||||||
let s = &m.objects[0];
|
|
||||||
assert_eq!(s.name, "Suzanne");
|
|
||||||
assert_eq!(s.verts.len(), 4); // lokal re-indexiert
|
|
||||||
assert_eq!(s.tris, vec![[0, 1, 2], [0, 2, 3]]); // Quad → 2 Dreiecke (Fan)
|
|
||||||
assert_eq!(s.uvs[2], [1.0, 1.0]); // vt übernommen
|
|
||||||
assert!(s.visible && !s.collider, "ohne Präfix: nur sichtbar");
|
|
||||||
|
|
||||||
let w = &m.objects[1];
|
|
||||||
assert_eq!(w.name, "col_Wand.001");
|
|
||||||
assert_eq!(w.verts[0], [5.0, 0.0, 0.0]); // globale Indizes → lokal
|
|
||||||
assert_eq!(w.uvs[0], [0.0, 0.0]); // kein vt → Default
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prefix_classification_is_case_insensitive() {
|
|
||||||
let m = parse(SAMPLE);
|
|
||||||
let w = &m.objects[1];
|
|
||||||
assert!(!w.visible && w.collider, "col_ = nur Collider");
|
|
||||||
let d = &m.objects[2];
|
|
||||||
assert!(d.visible && !d.collider, "VIS_ = nur sichtbar");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn materials_are_shared_and_persist_across_objects() {
|
|
||||||
let m = parse(SAMPLE);
|
|
||||||
// "skin" (usemtl) — der Zustand persistiert über o-Grenzen, also
|
|
||||||
// laufen auch Wand und Deko unter "skin"; kein ""-Material nötig.
|
|
||||||
assert_eq!(m.materials, vec!["skin".to_string()]);
|
|
||||||
assert!(m.objects.iter().all(|o| o.tri_mats.iter().all(|&i| i == 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn faces_without_usemtl_get_empty_material() {
|
|
||||||
let m = parse("o A\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n");
|
|
||||||
assert_eq!(m.materials, vec![String::new()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn broken_lines_are_skipped_not_fatal() {
|
|
||||||
// f verweist auf nicht existierende Indizes, v ist unvollständig —
|
|
||||||
// beides wird übersprungen, der Rest bleibt nutzbar.
|
|
||||||
let m = parse("o A\nv 0 0\nv 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 99\nf 1 2 3\n");
|
|
||||||
assert_eq!(m.objects.len(), 1);
|
|
||||||
assert_eq!(m.objects[0].tris.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_missing_file_is_err() {
|
|
||||||
assert!(load("/nonexistent/x.obj").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-10
@@ -2,10 +2,10 @@
|
|||||||
//! (siehe [`model::SIGNAL_PROP`]), aufgeteilt nach Collider-Frage —
|
//! (siehe [`model::SIGNAL_PROP`]), aufgeteilt nach Collider-Frage —
|
||||||
//! **anfassbar = klickbar, durchlaufbar = Betretens-Zone**:
|
//! **anfassbar = klickbar, durchlaufbar = Betretens-Zone**:
|
||||||
//!
|
//!
|
||||||
//! - `col_*` + `signal` → *Klick-Ziel*: das solide Volumen ist zugleich
|
//! - `collide` + `signal` → *Klick-Ziel*: das solide Volumen ist zugleich
|
||||||
//! das Ziel des Point-and-Click-Raycasts (siehe [`Triggers::pick`]).
|
//! das Ziel des Point-and-Click-Raycasts (siehe [`Triggers::pick`]).
|
||||||
//! Feuern läuft über den `use <name>`-Trichter in `Session::exec`.
|
//! Feuern läuft über den `use <name>`-Trichter in `Session::exec`.
|
||||||
//! - sonst + `signal` → *Zone*: unsichtbares konvexes Volumen; Betreten
|
//! - nur `signal` → *Zone*: unsichtbares konvexes Volumen; Betreten
|
||||||
//! feuert das Signal genau einmal je Eintritt ([`Triggers::enter_events`],
|
//! feuert das Signal genau einmal je Eintritt ([`Triggers::enter_events`],
|
||||||
//! Flanken-Semantik: erneut erst nach Verlassen). „Einmal für immer"
|
//! Flanken-Semantik: erneut erst nach Verlassen). „Einmal für immer"
|
||||||
//! baut die Autorin über KV-Flags/Ink, nicht hier.
|
//! baut die Autorin über KV-Flags/Ink, nicht hier.
|
||||||
@@ -106,9 +106,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn splits_by_collider_and_zone_is_invisible() {
|
fn splits_by_collider_and_zone_is_invisible() {
|
||||||
let m = model(vec![
|
let m = model(vec![
|
||||||
cube("col_Kiste", [0.0, 0.0, 0.0], Some("kiste")),
|
cube("Kiste", [0.0, 0.0, 0.0], Some("1"), Some("kiste")),
|
||||||
cube("Zone.001", [5.0, 0.0, 0.0], Some("betreten")),
|
cube("Zone.001", [5.0, 0.0, 0.0], None, Some("betreten")),
|
||||||
cube("Deko", [9.0, 0.0, 0.0], None),
|
cube("Deko", [9.0, 0.0, 0.0], None, None),
|
||||||
]);
|
]);
|
||||||
assert!(!m.objects[1].visible, "Zone darf nicht gerendert werden");
|
assert!(!m.objects[1].visible, "Zone darf nicht gerendert werden");
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ mod tests {
|
|||||||
t.add_model(&m);
|
t.add_model(&m);
|
||||||
assert_eq!(t.clickables.len(), 1);
|
assert_eq!(t.clickables.len(), 1);
|
||||||
assert_eq!(t.zones.len(), 1);
|
assert_eq!(t.zones.len(), 1);
|
||||||
assert_eq!(t.signal_for("col_Kiste"), Some("kiste"));
|
assert_eq!(t.signal_for("Kiste"), Some("kiste"));
|
||||||
assert_eq!(t.signal_for("Deko"), None, "ohne signal-Property kein Klick-Ziel");
|
assert_eq!(t.signal_for("Deko"), None, "ohne signal-Property kein Klick-Ziel");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,12 +124,12 @@ mod tests {
|
|||||||
fn pick_finds_nearest_clickable() {
|
fn pick_finds_nearest_clickable() {
|
||||||
let mut t = Triggers::new();
|
let mut t = Triggers::new();
|
||||||
t.add_model(&model(vec![
|
t.add_model(&model(vec![
|
||||||
cube("col_Fern", [4.0, -0.5, -0.5], Some("f")),
|
cube("Fern", [4.0, -0.5, -0.5], Some("1"), Some("f")),
|
||||||
cube("col_Nah", [1.0, -0.5, -0.5], Some("n")),
|
cube("Nah", [1.0, -0.5, -0.5], Some("1"), Some("n")),
|
||||||
]));
|
]));
|
||||||
// Strahl entlang +X durch beide Würfel → der nahe gewinnt.
|
// Strahl entlang +X durch beide Würfel → der nahe gewinnt.
|
||||||
let (name, frac) = t.pick([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]).unwrap();
|
let (name, frac) = t.pick([0.0, 0.0, 0.0], [10.0, 0.0, 0.0]).unwrap();
|
||||||
assert_eq!(name, "col_Nah");
|
assert_eq!(name, "Nah");
|
||||||
assert!((frac - 0.1).abs() < 0.01, "frac={frac}");
|
assert!((frac - 0.1).abs() < 0.01, "frac={frac}");
|
||||||
// Strahl daneben → nichts.
|
// Strahl daneben → nichts.
|
||||||
assert!(t.pick([0.0, 5.0, 0.0], [10.0, 5.0, 0.0]).is_none());
|
assert!(t.pick([0.0, 5.0, 0.0], [10.0, 5.0, 0.0]).is_none());
|
||||||
@@ -138,7 +138,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn zone_fires_on_entry_edge_and_rearms_after_exit() {
|
fn zone_fires_on_entry_edge_and_rearms_after_exit() {
|
||||||
let mut t = Triggers::new();
|
let mut t = Triggers::new();
|
||||||
t.add_model(&model(vec![cube("Zone", [0.0, 0.0, 0.0], Some("betreten"))]));
|
t.add_model(&model(vec![cube("Zone", [0.0, 0.0, 0.0], None, Some("betreten"))]));
|
||||||
let half = [0.3, 0.9, 0.3];
|
let half = [0.3, 0.9, 0.3];
|
||||||
|
|
||||||
// Außerhalb: nichts.
|
// Außerhalb: nichts.
|
||||||
|
|||||||
+11
-15
@@ -36,7 +36,7 @@ use winit::window::{CursorGrabMode, Window, WindowId};
|
|||||||
|
|
||||||
use crate::engine::player;
|
use crate::engine::player;
|
||||||
use crate::engine::tga::Image;
|
use crate::engine::tga::Image;
|
||||||
use crate::engine::{assets, gltf, map, model, obj, tga};
|
use crate::engine::{assets, gltf, map, model, tga};
|
||||||
use crate::session::{FrameInput, Mode, Session};
|
use crate::session::{FrameInput, Mode, Session};
|
||||||
use camera::Camera;
|
use camera::Camera;
|
||||||
use gpu::Gpu;
|
use gpu::Gpu;
|
||||||
@@ -64,7 +64,7 @@ pub fn run(mut session: Session) {
|
|||||||
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
||||||
// getrennt.
|
// getrennt.
|
||||||
let world = map::load(&assets::path("assets/maps/test.map"));
|
let world = map::load(&assets::path("assets/maps/test.map"));
|
||||||
// Blender-Modelle: alle .glb/.obj unter assets/maps/props/. Autorinnen-
|
// Blender-Modelle: alle .glb unter assets/maps/props/. Autorinnen-
|
||||||
// Content — ein kaputtes File wird gemeldet und übersprungen, nie fatal.
|
// Content — ein kaputtes File wird gemeldet und übersprungen, nie fatal.
|
||||||
let models = load_models(&assets::path("assets/maps/props"));
|
let models = load_models(&assets::path("assets/maps/props"));
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@ pub fn run(mut session: Session) {
|
|||||||
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
for (_, m) in &models { mesh.append(props::build(m, &tex_names)); }
|
||||||
report_map(&world, &tex_names, &mesh);
|
report_map(&world, &tex_names, &mesh);
|
||||||
|
|
||||||
// Sim-Welt (Collision-Brushes + Spawn, dann col_*-Proxies der Modelle) in
|
// Sim-Welt (Collision-Brushes + Spawn, dann Collider/Trigger der
|
||||||
// die Session einspielen — die Simulation gehört der Session, nicht dem
|
// Modelle) in die Session einspielen — die Simulation gehört der
|
||||||
// Render-Frontend.
|
// Session, nicht dem Render-Frontend.
|
||||||
session.load_world(&world);
|
session.load_world(&world);
|
||||||
for (_, m) in &models { session.load_props(m); }
|
for (_, m) in &models { session.load_props(m); }
|
||||||
|
|
||||||
@@ -134,25 +134,21 @@ pub fn run(mut session: Session) {
|
|||||||
event_loop.run_app(&mut app).expect("winit: run");
|
event_loop.run_app(&mut app).expect("winit: run");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle Blender-Modelle (`.glb` und `.obj`) eines Verzeichnisses laden
|
/// Alle Blender-Modelle (`.glb`) eines Verzeichnisses laden (sortiert →
|
||||||
/// (sortiert → deterministische Reihenfolge). Fehlende Verzeichnisse sind
|
/// deterministische Reihenfolge). Fehlende Verzeichnisse sind kein Fehler
|
||||||
/// kein Fehler (kein Props-Ordner = keine Props); kaputte Dateien werden
|
/// (kein Props-Ordner = keine Props); kaputte Dateien werden gemeldet und
|
||||||
/// gemeldet und übersprungen.
|
/// übersprungen.
|
||||||
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
|
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
|
||||||
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
|
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
.filter(|p| matches!(p.extension().and_then(|s| s.to_str()), Some("obj" | "glb")))
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("glb"))
|
||||||
.collect();
|
.collect();
|
||||||
paths.sort();
|
paths.sort();
|
||||||
let mut models = Vec::new();
|
let mut models = Vec::new();
|
||||||
for p in paths {
|
for p in paths {
|
||||||
let path = p.to_string_lossy();
|
let path = p.to_string_lossy();
|
||||||
let loaded = match p.extension().and_then(|s| s.to_str()) {
|
match gltf::load(&path) {
|
||||||
Some("glb") => gltf::load(&path),
|
|
||||||
_ => obj::load(&path),
|
|
||||||
};
|
|
||||||
match loaded {
|
|
||||||
Ok(m) => {
|
Ok(m) => {
|
||||||
let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
|
let name = p.file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned());
|
||||||
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
let tris: usize = m.objects.iter().map(|o| o.tris.len()).sum();
|
||||||
|
|||||||
+36
-31
@@ -1,18 +1,19 @@
|
|||||||
//! Blender-Modell → Render-Geometrie.
|
//! Blender-Modell → Render-Geometrie.
|
||||||
//!
|
//!
|
||||||
//! Gegenstück zu render::brush für den OBJ-Pfad: nimmt das neutrale
|
//! Gegenstück zu render::brush für den Modell-Pfad: nimmt das neutrale
|
||||||
//! [`Model`] aus engine::obj und baut daraus ein [`Mesh`] (Vertices + nach
|
//! [`Model`] aus engine::gltf und baut daraus ein [`Mesh`] (Vertices + nach
|
||||||
//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem
|
//! Textur gruppierte Index-Batches), das mit der Brush-Geometrie zu einem
|
||||||
//! Buffer verschmilzt (siehe `Mesh::append`).
|
//! Buffer verschmilzt (siehe `Mesh::append`).
|
||||||
//!
|
//!
|
||||||
//! Texturen: der `usemtl`-Materialname *ist* der Texturname — aufgelöst wie
|
//! Texturen: der Blender-Materialname *ist* der Texturname — aufgelöst wie
|
||||||
//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces
|
//! bei Brushes zu `assets/textures/{name}.tga` (macht der Aufrufer). Faces
|
||||||
//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
//! ohne Material (`""`) laufen unter [`FALLBACK_TEXTURE`].
|
||||||
//!
|
//!
|
||||||
//! UVs: OBJ hat den V-Ursprung unten links, unsere `tga::Image`s oben links
|
//! UVs: das Modell trägt den V-Ursprung unten links, unsere `tga::Image`s
|
||||||
//! — V wird hier gespiegelt (Achsen-Konvention ist Sache des Konsumenten,
|
//! oben links — V wird hier gespiegelt (Achsen-Konvention ist Sache des
|
||||||
//! siehe engine::tga). Nur `visible`-Objekte werden ausgegeben; reine
|
//! Konsumenten, siehe engine::tga). Nur `visible`-Objekte werden
|
||||||
//! `col_*`-Proxies sind Sache von engine::collision.
|
//! ausgegeben; Collider-/Zonen-Volumen sind Sache von engine::collision
|
||||||
|
//! und engine::trigger.
|
||||||
|
|
||||||
use crate::engine::model::Model;
|
use crate::engine::model::Model;
|
||||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
use crate::render::scene::{Batch, Mesh, Vertex};
|
||||||
@@ -21,7 +22,7 @@ use crate::render::scene::{Batch, Mesh, Vertex};
|
|||||||
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
pub const FALLBACK_TEXTURE: &str = "placeholder";
|
||||||
|
|
||||||
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
/// Distinkte Texturnamen des Modells (sortiert), `""` bereits auf den
|
||||||
/// Fallback gemappt — dasselbe Kontrakt wie `brush::texture_names`.
|
/// Fallback gemappt — derselbe Kontrakt wie `brush::texture_names`.
|
||||||
pub fn texture_names(model: &Model) -> Vec<String> {
|
pub fn texture_names(model: &Model) -> Vec<String> {
|
||||||
let mut names: Vec<String> = model.materials.iter()
|
let mut names: Vec<String> = model.materials.iter()
|
||||||
.map(|m| resolve(m).to_string())
|
.map(|m| resolve(m).to_string())
|
||||||
@@ -69,43 +70,48 @@ pub fn build(model: &Model, tex_names: &[String]) -> Mesh {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::engine::obj;
|
use crate::engine::model::{Model, Object, Props};
|
||||||
|
|
||||||
const SAMPLE: &str = "\
|
/// Ein Dreieck mit UVs; `visible` steuert, ob es gerendert werden darf.
|
||||||
o Ding
|
fn tri(name: &str, visible: bool) -> Object {
|
||||||
v 0 0 0
|
Object {
|
||||||
v 1 0 0
|
name: name.into(),
|
||||||
v 1 1 0
|
verts: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
|
||||||
vt 0 0
|
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]],
|
||||||
vt 1 0
|
tris: vec![[0, 1, 2]],
|
||||||
vt 1 1
|
tri_mats: vec![0],
|
||||||
usemtl carpet
|
visible, collider: !visible,
|
||||||
f 1/1 2/2 3/3
|
props: Props::new(),
|
||||||
o col_Proxy
|
}
|
||||||
v 5 0 0
|
}
|
||||||
v 6 0 0
|
|
||||||
v 6 1 0
|
fn sample() -> Model {
|
||||||
f 4 5 6
|
Model {
|
||||||
";
|
objects: vec![tri("Ding", true), tri("Proxy", false)],
|
||||||
|
empties: Vec::new(),
|
||||||
|
materials: vec!["carpet".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builds_only_visible_objects_with_v_flip() {
|
fn builds_only_visible_objects_with_v_flip() {
|
||||||
let m = obj::parse(SAMPLE);
|
let m = sample();
|
||||||
let names = texture_names(&m);
|
let names = texture_names(&m);
|
||||||
assert_eq!(names, vec!["carpet".to_string()]);
|
assert_eq!(names, vec!["carpet".to_string()]);
|
||||||
let mesh = build(&m, &names);
|
let mesh = build(&m, &names);
|
||||||
|
|
||||||
// Nur „Ding" (col_Proxy ist unsichtbar): 3 Vertices, 1 Dreieck.
|
// Nur „Ding" (der Proxy ist unsichtbar): 3 Vertices, 1 Dreieck.
|
||||||
assert_eq!(mesh.verts.len(), 3);
|
assert_eq!(mesh.verts.len(), 3);
|
||||||
assert_eq!(mesh.indices, vec![0, 1, 2]);
|
assert_eq!(mesh.indices, vec![0, 1, 2]);
|
||||||
assert_eq!(mesh.batches.len(), 1);
|
assert_eq!(mesh.batches.len(), 1);
|
||||||
// vt (1,1) → V-Flip → uv (1,0).
|
// uv (1,1) → V-Flip → (1,0).
|
||||||
assert_eq!(mesh.verts[2].uv, [1.0, 0.0]);
|
assert_eq!(mesh.verts[2].uv, [1.0, 0.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_material_maps_to_fallback() {
|
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");
|
let mut m = sample();
|
||||||
|
m.materials = vec![String::new()]; // Faces ohne Material
|
||||||
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
assert_eq!(texture_names(&m), vec![FALLBACK_TEXTURE.to_string()]);
|
||||||
let mesh = build(&m, &texture_names(&m));
|
let mesh = build(&m, &texture_names(&m));
|
||||||
assert_eq!(mesh.batches.len(), 1);
|
assert_eq!(mesh.batches.len(), 1);
|
||||||
@@ -115,9 +121,8 @@ f 4 5 6
|
|||||||
fn shares_texture_index_space_with_superset_list() {
|
fn shares_texture_index_space_with_superset_list() {
|
||||||
// Obermengen-Liste (wie beim Merge mit Brush-Texturen): der Batch
|
// Obermengen-Liste (wie beim Merge mit Brush-Texturen): der Batch
|
||||||
// zeigt auf den richtigen Index in der geteilten Liste.
|
// 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 names = vec!["aaa".to_string(), "carpet".to_string(), "zzz".to_string()];
|
||||||
let mesh = build(&m, &names);
|
let mesh = build(&sample(), &names);
|
||||||
assert_eq!(mesh.batches[0].texture, 1);
|
assert_eq!(mesh.batches[0].texture, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -104,10 +104,10 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sim-Seite eines Blender-Modells in die bestehende Welt einfügen —
|
/// Sim-Seite eines Blender-Modells in die bestehende Welt einfügen —
|
||||||
/// nach `load_world` rufen, einmal pro Modell: Collision-Proxies
|
/// nach `load_world` rufen, einmal pro Modell: Collider (`collide`-
|
||||||
/// (`col_*`) und Trigger (`signal`-Property → Klick-Ziele/Zonen). Die
|
/// Property → konvexe Hülle) und Trigger (`signal`-Property →
|
||||||
/// Sichtgeometrie desselben Modells ist Sache des Renderers
|
/// Klick-Ziele/Zonen). Die Sichtgeometrie desselben Modells ist Sache
|
||||||
/// (render::props).
|
/// des Renderers (render::props).
|
||||||
pub fn load_props(&mut self, model: &model::Model) {
|
pub fn load_props(&mut self, model: &model::Model) {
|
||||||
self.collision.add_model(model);
|
self.collision.add_model(model);
|
||||||
self.triggers.add_model(model);
|
self.triggers.add_model(model);
|
||||||
@@ -526,9 +526,9 @@ mod tests {
|
|||||||
crate::engine::model::Model {
|
crate::engine::model::Model {
|
||||||
objects: vec![
|
objects: vec![
|
||||||
crate::engine::model::test_cube(
|
crate::engine::model::test_cube(
|
||||||
"Zone.001", [-0.5, 0.0, -3.0], Some("set zone_hit true")),
|
"Zone.001", [-0.5, 0.0, -3.0], None, Some("set zone_hit true")),
|
||||||
crate::engine::model::test_cube(
|
crate::engine::model::test_cube(
|
||||||
"col_Kiste", [5.0, 0.0, 5.0], Some("set kiste_geklickt true")),
|
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
|
||||||
],
|
],
|
||||||
empties: Vec::new(),
|
empties: Vec::new(),
|
||||||
materials: vec![String::new()],
|
materials: vec![String::new()],
|
||||||
@@ -558,8 +558,8 @@ mod tests {
|
|||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
s.load_props(&trigger_model());
|
s.load_props(&trigger_model());
|
||||||
s.mode = Mode::FirstPerson;
|
s.mode = Mode::FirstPerson;
|
||||||
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("col_Kiste").
|
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
|
||||||
s.exec("use col_Kiste");
|
s.exec("use Kiste");
|
||||||
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
||||||
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
||||||
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
||||||
@@ -571,9 +571,9 @@ mod tests {
|
|||||||
fn pick_hits_clickable_within_reach() {
|
fn pick_hits_clickable_within_reach() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
s.load_props(&trigger_model());
|
s.load_props(&trigger_model());
|
||||||
// col_Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor.
|
// Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor.
|
||||||
let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH);
|
let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH);
|
||||||
assert_eq!(hit, Some("col_Kiste"));
|
assert_eq!(hit, Some("Kiste"));
|
||||||
// Außer Reichweite → nichts.
|
// Außer Reichweite → nichts.
|
||||||
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
|
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user