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:
Generated
+1
@@ -2147,6 +2147,7 @@ dependencies = [
|
|||||||
"bladeink",
|
"bladeink",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"pollster",
|
"pollster",
|
||||||
|
"serde_json",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ edition = "2024"
|
|||||||
bladeink = "1.2.5"
|
bladeink = "1.2.5"
|
||||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||||
pollster = "0.4.0"
|
pollster = "0.4.0"
|
||||||
|
serde_json = "1"
|
||||||
wgpu = "29.0.3"
|
wgpu = "29.0.3"
|
||||||
winit = "0.30.13"
|
winit = "0.30.13"
|
||||||
|
|||||||
+124
-9
@@ -13,11 +13,21 @@
|
|||||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||||
//! sofort wieder im Kontakt steckt.
|
//! sofort wieder im Kontakt steckt.
|
||||||
//!
|
//!
|
||||||
//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen
|
//! Zwei Quellen füllen die Welt: Quake-Brushes aus der `.map` ([`build`])
|
||||||
//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in
|
//! und konvexe `col_*`-Proxies aus Blender-OBJ-Exporten ([`add_model`]).
|
||||||
//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`.
|
//! 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::map::{self, Map};
|
||||||
|
use crate::engine::model::Model;
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -66,12 +76,24 @@ impl CollisionWorld {
|
|||||||
Self { brushes: Vec::new() }
|
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)
|
/// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte)
|
||||||
/// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`.
|
/// 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> {
|
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||||
let mut nearest: Option<Hit> = None;
|
let mut nearest: Option<Hit> = None;
|
||||||
for b in &self.brushes {
|
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.as_ref().is_none_or(|n| h.frac < n.frac) {
|
||||||
nearest = Some(h);
|
nearest = Some(h);
|
||||||
}
|
}
|
||||||
@@ -80,17 +102,67 @@ impl CollisionWorld {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen.
|
/// Das **konvexe** Ebenen-Set eines Modell-Objekts: Ebenen aus den Dreiecks-
|
||||||
/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt
|
/// Flächen (koplanare dedupliziert) plus die sechs achsparallelen AABB-Ebenen
|
||||||
/// (dann nicht blocken — sonst bliebe der Player hängen).
|
/// (Bevel-Ebenen, siehe Modul-Doc). `None`, wenn kein geschlossenes Volumen
|
||||||
fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
/// 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(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 {
|
||||||
|
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 enter = f32::NEG_INFINITY; // größter Eintritts-Bruch
|
||||||
let mut leave = 1.0f32; // kleinster Austritts-Bruch
|
let mut leave = 1.0f32; // kleinster Austritts-Bruch
|
||||||
let mut normal = [0.0f32; 3];
|
let mut normal = [0.0f32; 3];
|
||||||
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
|
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
|
||||||
let mut started_outside = false;
|
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).
|
// 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 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;
|
let ds = dot(p.n, start) - d;
|
||||||
@@ -206,6 +278,49 @@ 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]
|
||||||
|
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]
|
#[test]
|
||||||
fn builds_brushes_from_map() {
|
fn builds_brushes_from_map() {
|
||||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
//! glTF-Binary-Loader (.glb, Blender-Export) → neutrales [`Model`].
|
||||||
|
//!
|
||||||
|
//! Der Aufrüst-Pfad gegenüber engine::obj: glTF trägt, was OBJ nicht kann —
|
||||||
|
//! **Custom Properties** (Blender: Objekt-Eigenschaften, beim Export
|
||||||
|
//! „Include → Custom Properties" anhaken → landen in `node.extras`) und
|
||||||
|
//! **Empties** (Nodes ohne Mesh) als Entity-Marker für Spawn/Trigger/…
|
||||||
|
//! Beide kommen als `props`/`empties` im Modell an; Bedeutung geben die
|
||||||
|
//! Konsumenten.
|
||||||
|
//!
|
||||||
|
//! Subset: GLB-Container (JSON- + BIN-Chunk; reine `.gltf` mit externen
|
||||||
|
//! Buffern sind außerhalb — in Blender „glTF Binary (.glb)" exportieren),
|
||||||
|
//! Dreiecks-Primitives (Mode 4, Default) mit `POSITION`/`TEXCOORD_0`
|
||||||
|
//! (float) und optionalen Indizes (u8/u16/u32). Node-Transforms (Matrix
|
||||||
|
//! oder T·R·S, samt Hierarchie) werden in die Vertices eingebacken —
|
||||||
|
//! Welt-Raum wie beim OBJ-Pfad. Material-*Name* = Texturname (derselbe
|
||||||
|
//! Kontrakt wie `usemtl`); glTF-eigene Texturen/PBR werden ignoriert.
|
||||||
|
//!
|
||||||
|
//! Achsen: glTF ist per Spezifikation Y-up/−Z-forward — unser System,
|
||||||
|
//! Blenders Exporter konvertiert selbst. Maßstab 1:1.
|
||||||
|
//!
|
||||||
|
//! JSON via `serde_json`: steckt über bladeink ohnehin im Dependency-Baum,
|
||||||
|
//! ein handgerollter Parser wäre Redundanz ohne Dependency-Gewinn (anders
|
||||||
|
//! als beim trivialen signals-TOML-Subset).
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::engine::model::{apply_signal_rule, classify, Empty, Model, Object, Props};
|
||||||
|
|
||||||
|
pub fn load(path: &str) -> Result<Model, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| format!("gltf load {path}: {e}"))?;
|
||||||
|
parse_glb(&bytes).map_err(|e| format!("gltf {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLB-Container: 12-Byte-Header (`glTF`, Version 2, Gesamtlänge), dann
|
||||||
|
/// Chunks aus Länge/Typ/Daten. Wir brauchen JSON (`JSON`) und BIN (`BIN\0`).
|
||||||
|
pub fn parse_glb(bytes: &[u8]) -> Result<Model, String> {
|
||||||
|
if bytes.len() < 12 || &bytes[0..4] != b"glTF" {
|
||||||
|
return Err("kein GLB (Magic fehlt) — in Blender als „glTF Binary (.glb)“ exportieren".into());
|
||||||
|
}
|
||||||
|
let version = u32_at(bytes, 4)?;
|
||||||
|
if version != 2 { return Err(format!("glTF-Version {version}, unterstützt ist 2")); }
|
||||||
|
|
||||||
|
let (mut json, mut bin): (Option<&[u8]>, &[u8]) = (None, &[]);
|
||||||
|
let mut off = 12;
|
||||||
|
while off + 8 <= bytes.len() {
|
||||||
|
let len = u32_at(bytes, off)? as usize;
|
||||||
|
let ty = &bytes[off + 4..off + 8];
|
||||||
|
let data = bytes.get(off + 8..off + 8 + len).ok_or("Chunk länger als Datei")?;
|
||||||
|
match ty {
|
||||||
|
b"JSON" => json = Some(data),
|
||||||
|
b"BIN\0" => bin = data,
|
||||||
|
_ => {} // unbekannte Chunks per Spec ignorieren
|
||||||
|
}
|
||||||
|
off += 8 + len.next_multiple_of(4); // Chunks sind 4-Byte-aligned
|
||||||
|
}
|
||||||
|
let json = json.ok_or("kein JSON-Chunk")?;
|
||||||
|
let doc: Value = serde_json::from_slice(json).map_err(|e| format!("JSON: {e}"))?;
|
||||||
|
build(&doc, bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(doc: &Value, bin: &[u8]) -> Result<Model, String> {
|
||||||
|
let mut model = Model { objects: Vec::new(), empties: Vec::new(), materials: Vec::new() };
|
||||||
|
|
||||||
|
// Szenen-Wurzeln (Default-Szene, sonst 0) rekursiv ablaufen; die
|
||||||
|
// Welt-Transform wächst dabei Parent → Kind.
|
||||||
|
let scene = doc["scene"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let roots = doc["scenes"][scene]["nodes"].as_array().cloned().unwrap_or_default();
|
||||||
|
for r in roots {
|
||||||
|
let Some(i) = r.as_u64() else { continue; };
|
||||||
|
walk_node(doc, bin, i as usize, IDENTITY, &mut model)?;
|
||||||
|
}
|
||||||
|
Ok(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_node(doc: &Value, bin: &[u8], idx: usize, parent: M4, model: &mut Model) -> Result<(), String> {
|
||||||
|
let node = &doc["nodes"][idx];
|
||||||
|
if node.is_null() { return Err(format!("Node {idx} fehlt")); }
|
||||||
|
let world = mul(parent, node_local(node));
|
||||||
|
let name = node["name"].as_str().map_or_else(|| format!("node{idx}"), str::to_string);
|
||||||
|
let props = extras_props(&node["extras"]);
|
||||||
|
|
||||||
|
match node["mesh"].as_u64() {
|
||||||
|
Some(mesh) => {
|
||||||
|
let (visible, collider) = classify(&name);
|
||||||
|
let mut o = Object {
|
||||||
|
name, visible, collider, props,
|
||||||
|
verts: Vec::new(), uvs: Vec::new(),
|
||||||
|
tris: Vec::new(), tri_mats: Vec::new(),
|
||||||
|
};
|
||||||
|
append_mesh(doc, bin, mesh as usize, world, &mut o, &mut model.materials)?;
|
||||||
|
apply_signal_rule(&mut o); // Betretens-Zonen werden nie gerendert
|
||||||
|
if !o.tris.is_empty() { model.objects.push(o); }
|
||||||
|
}
|
||||||
|
// Node ohne Mesh = Empty (Entity-Marker); Position aus der
|
||||||
|
// Welt-Transform (Translationsspalte).
|
||||||
|
None => model.empties.push(Empty {
|
||||||
|
name, props,
|
||||||
|
pos: [world[12], world[13], world[14]],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for c in node["children"].as_array().into_iter().flatten() {
|
||||||
|
if let Some(ci) = c.as_u64() {
|
||||||
|
walk_node(doc, bin, ci as usize, world, model)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Dreiecks-Primitives eines glTF-Meshes (Welt-transformiert) an ein
|
||||||
|
/// Objekt anhängen. Nicht-Dreiecks-Modes werden gemeldet und übersprungen.
|
||||||
|
fn append_mesh(
|
||||||
|
doc: &Value, bin: &[u8], mesh: usize, world: M4,
|
||||||
|
o: &mut Object, materials: &mut Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for prim in doc["meshes"][mesh]["primitives"].as_array().into_iter().flatten() {
|
||||||
|
if prim["mode"].as_u64().unwrap_or(4) != 4 {
|
||||||
|
eprintln!("[gltf] {}: Primitive-Mode ≠ Dreiecke — übersprungen", o.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(pos_acc) = prim["attributes"]["POSITION"].as_u64() else { continue; };
|
||||||
|
let positions = read_floats::<3>(doc, bin, pos_acc as usize)?;
|
||||||
|
let uvs = match prim["attributes"]["TEXCOORD_0"].as_u64() {
|
||||||
|
Some(a) => read_floats::<2>(doc, bin, a as usize)?,
|
||||||
|
None => vec![[0.0, 0.0]; positions.len()],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Material-Name → geteilte Namensliste (wie usemtl); ohne Material "".
|
||||||
|
let mat_name = prim["material"].as_u64()
|
||||||
|
.and_then(|m| doc["materials"][m as usize]["name"].as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let mi = materials.iter().position(|m| m == mat_name).unwrap_or_else(|| {
|
||||||
|
materials.push(mat_name.to_string());
|
||||||
|
materials.len() - 1
|
||||||
|
});
|
||||||
|
|
||||||
|
let base = o.verts.len();
|
||||||
|
for (p, uv) in positions.iter().zip(&uvs) {
|
||||||
|
o.verts.push(transform(world, *p));
|
||||||
|
o.uvs.push(*uv);
|
||||||
|
}
|
||||||
|
// glTF-UVs haben den Ursprung oben links — auf die Modell-Konvention
|
||||||
|
// (unten links, wie OBJ) spiegeln, damit der Render-Konsument beide
|
||||||
|
// Pfade gleich behandelt.
|
||||||
|
for uv in &mut o.uvs[base..] { uv[1] = 1.0 - uv[1]; }
|
||||||
|
|
||||||
|
let idxs: Vec<usize> = match prim["indices"].as_u64() {
|
||||||
|
Some(a) => read_indices(doc, bin, a as usize)?,
|
||||||
|
None => (0..positions.len()).collect(), // non-indexed: sequenziell
|
||||||
|
};
|
||||||
|
for t in idxs.chunks_exact(3) {
|
||||||
|
if t.iter().any(|&i| i >= positions.len()) {
|
||||||
|
return Err(format!("{}: Index außerhalb der Positionen", o.name));
|
||||||
|
}
|
||||||
|
o.tris.push([base + t[0], base + t[1], base + t[2]]);
|
||||||
|
o.tri_mats.push(mi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `node.extras` (Objekt) → Props; Skalare werden zu Strings vereinheitlicht,
|
||||||
|
/// verschachtelte Werte als kompaktes JSON durchgereicht.
|
||||||
|
fn extras_props(extras: &Value) -> Props {
|
||||||
|
let mut props = Props::new();
|
||||||
|
if let Some(map) = extras.as_object() {
|
||||||
|
for (k, v) in map {
|
||||||
|
let s = match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
props.insert(k.clone(), s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accessor-Dekodierung ----------------------------------------------------
|
||||||
|
|
||||||
|
/// Bytes eines Accessors samt Element-Stride auflösen (BufferView-Offset,
|
||||||
|
/// Accessor-Offset, optionaler byteStride — sonst dicht gepackt).
|
||||||
|
fn accessor_bytes<'a>(doc: &Value, bin: &'a [u8], acc: usize, elem_size: usize)
|
||||||
|
-> Result<(&'a [u8], usize, usize), String>
|
||||||
|
{
|
||||||
|
let a = &doc["accessors"][acc];
|
||||||
|
let count = a["count"].as_u64().ok_or("Accessor ohne count")? as usize;
|
||||||
|
let view = a["bufferView"].as_u64().ok_or("Accessor ohne bufferView")? as usize;
|
||||||
|
let v = &doc["bufferViews"][view];
|
||||||
|
let v_off = v["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let a_off = a["byteOffset"].as_u64().unwrap_or(0) as usize;
|
||||||
|
let stride = v["byteStride"].as_u64().map_or(elem_size, |s| s as usize);
|
||||||
|
let start = v_off + a_off;
|
||||||
|
let need = start + stride * count.saturating_sub(1) + elem_size;
|
||||||
|
if need > bin.len() { return Err("Accessor ragt aus dem BIN-Chunk".into()); }
|
||||||
|
Ok((&bin[start..], stride, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N-Komponenten-float-Accessor (POSITION: N=3, TEXCOORD: N=2) lesen.
|
||||||
|
fn read_floats<const N: usize>(doc: &Value, bin: &[u8], acc: usize) -> Result<Vec<[f32; N]>, String> {
|
||||||
|
let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0);
|
||||||
|
if ctype != 5126 {
|
||||||
|
return Err(format!("Accessor {acc}: componentType {ctype}, erwartet float (5126)"));
|
||||||
|
}
|
||||||
|
let (bytes, stride, count) = accessor_bytes(doc, bin, acc, N * 4)?;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let mut e = [0.0f32; N];
|
||||||
|
for (k, v) in e.iter_mut().enumerate() {
|
||||||
|
let o = i * stride + k * 4;
|
||||||
|
*v = f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
|
||||||
|
}
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index-Accessor lesen (u8/u16/u32 → usize).
|
||||||
|
fn read_indices(doc: &Value, bin: &[u8], acc: usize) -> Result<Vec<usize>, String> {
|
||||||
|
let ctype = doc["accessors"][acc]["componentType"].as_u64().unwrap_or(0);
|
||||||
|
let size = match ctype {
|
||||||
|
5121 => 1, // u8
|
||||||
|
5123 => 2, // u16
|
||||||
|
5125 => 4, // u32
|
||||||
|
_ => return Err(format!("Index-Accessor {acc}: componentType {ctype}")),
|
||||||
|
};
|
||||||
|
let (bytes, stride, count) = accessor_bytes(doc, bin, acc, size)?;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let o = i * stride;
|
||||||
|
out.push(match size {
|
||||||
|
1 => bytes[o] as usize,
|
||||||
|
2 => u16::from_le_bytes(bytes[o..o + 2].try_into().unwrap()) as usize,
|
||||||
|
_ => u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()) as usize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Transform-Helfer (4×4 column-major, wie glTFs `matrix`) -----------------
|
||||||
|
|
||||||
|
type M4 = [f32; 16];
|
||||||
|
|
||||||
|
const IDENTITY: M4 = [
|
||||||
|
1.0, 0.0, 0.0, 0.0,
|
||||||
|
0.0, 1.0, 0.0, 0.0,
|
||||||
|
0.0, 0.0, 1.0, 0.0,
|
||||||
|
0.0, 0.0, 0.0, 1.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Lokale Transform eines Nodes: explizite `matrix` oder T·R·S
|
||||||
|
/// (glTF-Reihenfolge; fehlende Anteile sind Identität).
|
||||||
|
fn node_local(node: &Value) -> M4 {
|
||||||
|
if let Some(m) = node["matrix"].as_array() {
|
||||||
|
let mut out = IDENTITY;
|
||||||
|
for (i, v) in m.iter().take(16).enumerate() {
|
||||||
|
out[i] = v.as_f64().unwrap_or(0.0) as f32;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let t = vecn::<3>(&node["translation"], [0.0, 0.0, 0.0]);
|
||||||
|
let q = vecn::<4>(&node["rotation"], [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
let s = vecn::<3>(&node["scale"], [1.0, 1.0, 1.0]);
|
||||||
|
|
||||||
|
// Rotationsmatrix aus dem Quaternion (x, y, z, w), Spalten skaliert,
|
||||||
|
// Translation in die vierte Spalte — direkt komponiertes T·R·S.
|
||||||
|
let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
|
||||||
|
let r = [
|
||||||
|
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y + z * w), 2.0 * (x * z - y * w)],
|
||||||
|
[2.0 * (x * y - z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z + x * w)],
|
||||||
|
[2.0 * (x * z + y * w), 2.0 * (y * z - x * w), 1.0 - 2.0 * (x * x + y * y)],
|
||||||
|
]; // r[spalte][zeile]
|
||||||
|
let mut out = IDENTITY;
|
||||||
|
for c in 0..3 {
|
||||||
|
for row in 0..3 {
|
||||||
|
out[c * 4 + row] = r[c][row] * s[c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[12] = t[0]; out[13] = t[1]; out[14] = t[2];
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vecn<const N: usize>(v: &Value, default: [f32; N]) -> [f32; N] {
|
||||||
|
let Some(arr) = v.as_array() else { return default; };
|
||||||
|
let mut out = default;
|
||||||
|
for (o, x) in out.iter_mut().zip(arr) {
|
||||||
|
if let Some(f) = x.as_f64() { *o = f as f32; }
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mul(a: M4, b: M4) -> M4 {
|
||||||
|
let mut out = [0.0; 16];
|
||||||
|
for c in 0..4 {
|
||||||
|
for r in 0..4 {
|
||||||
|
out[c * 4 + r] = (0..4).map(|k| a[k * 4 + r] * b[c * 4 + k]).sum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform(m: M4, p: [f32; 3]) -> [f32; 3] {
|
||||||
|
let mut out = [0.0; 3];
|
||||||
|
for (r, o) in out.iter_mut().enumerate() {
|
||||||
|
*o = m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u32_at(bytes: &[u8], off: usize) -> Result<u32, String> {
|
||||||
|
bytes.get(off..off + 4)
|
||||||
|
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||||
|
.ok_or_else(|| "Datei zu kurz".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// GLB aus JSON-Text und BIN-Daten zusammensetzen (mit 4-Byte-Padding),
|
||||||
|
/// wie es ein Exporter täte.
|
||||||
|
fn glb(json: &str, bin: &[u8]) -> Vec<u8> {
|
||||||
|
let mut j = json.as_bytes().to_vec();
|
||||||
|
while !j.len().is_multiple_of(4) { j.push(b' '); }
|
||||||
|
let mut b = bin.to_vec();
|
||||||
|
while !b.len().is_multiple_of(4) { b.push(0); }
|
||||||
|
let total = 12 + 8 + j.len() + 8 + b.len();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend(b"glTF");
|
||||||
|
out.extend(2u32.to_le_bytes());
|
||||||
|
out.extend((total as u32).to_le_bytes());
|
||||||
|
out.extend((j.len() as u32).to_le_bytes());
|
||||||
|
out.extend(b"JSON");
|
||||||
|
out.extend(j);
|
||||||
|
out.extend((b.len() as u32).to_le_bytes());
|
||||||
|
out.extend(b"BIN\0");
|
||||||
|
out.extend(b);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Dreieck (Positionen + UVs + u16-Indizes) im BIN-Chunk; ein
|
||||||
|
/// Mesh-Node mit Translation und extras, ein Empty mit extras.
|
||||||
|
fn sample_glb() -> Vec<u8> {
|
||||||
|
let mut bin: Vec<u8> = Vec::new();
|
||||||
|
for f in [0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0] { bin.extend(f.to_le_bytes()); }
|
||||||
|
for f in [0.0f32, 0.0, 1.0, 0.0, 1.0, 1.0] { bin.extend(f.to_le_bytes()); }
|
||||||
|
for i in [0u16, 1, 2] { bin.extend(i.to_le_bytes()); }
|
||||||
|
let json = r#"{
|
||||||
|
"asset": {"version": "2.0"},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [{"nodes": [0, 2]}],
|
||||||
|
"nodes": [
|
||||||
|
{"name": "Ding", "mesh": 0, "translation": [10, 0, 0],
|
||||||
|
"extras": {"signal": "tiffany", "hp": 3},
|
||||||
|
"children": [1]},
|
||||||
|
{"name": "col_Kind", "mesh": 0},
|
||||||
|
{"name": "spawn", "translation": [1, 2, 3]}
|
||||||
|
],
|
||||||
|
"meshes": [{"primitives": [{
|
||||||
|
"attributes": {"POSITION": 0, "TEXCOORD_0": 1},
|
||||||
|
"indices": 2, "material": 0
|
||||||
|
}]}],
|
||||||
|
"materials": [{"name": "carpet"}],
|
||||||
|
"accessors": [
|
||||||
|
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"},
|
||||||
|
{"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC2"},
|
||||||
|
{"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"}
|
||||||
|
],
|
||||||
|
"bufferViews": [
|
||||||
|
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
|
||||||
|
{"buffer": 0, "byteOffset": 36, "byteLength": 24},
|
||||||
|
{"buffer": 0, "byteOffset": 60, "byteLength": 6}
|
||||||
|
],
|
||||||
|
"buffers": [{"byteLength": 66}]
|
||||||
|
}"#;
|
||||||
|
glb(json, &bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_geometry_with_baked_transforms() {
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
assert_eq!(m.objects.len(), 2);
|
||||||
|
|
||||||
|
let d = &m.objects[0];
|
||||||
|
assert_eq!(d.name, "Ding");
|
||||||
|
assert_eq!(d.tris, vec![[0, 1, 2]]);
|
||||||
|
assert_eq!(d.verts[1], [11.0, 0.0, 0.0]); // Translation eingebacken
|
||||||
|
// signal-Property ohne Collider → Betretens-Zone, wird nie gerendert.
|
||||||
|
assert!(!d.visible && !d.collider);
|
||||||
|
assert_eq!(m.materials, vec!["carpet".to_string()]);
|
||||||
|
|
||||||
|
// Kind erbt die Parent-Transform (10, 0, 0).
|
||||||
|
let k = &m.objects[1];
|
||||||
|
assert_eq!(k.name, "col_Kind");
|
||||||
|
assert!(!k.visible && k.collider);
|
||||||
|
assert_eq!(k.verts[0], [10.0, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extras_become_props_and_empties_are_captured() {
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
let d = &m.objects[0];
|
||||||
|
assert_eq!(d.props.get("signal").map(String::as_str), Some("tiffany"));
|
||||||
|
assert_eq!(d.props.get("hp").map(String::as_str), Some("3")); // Zahl → String
|
||||||
|
|
||||||
|
assert_eq!(m.empties.len(), 1);
|
||||||
|
let e = &m.empties[0];
|
||||||
|
assert_eq!(e.name, "spawn");
|
||||||
|
assert_eq!(e.pos, [1.0, 2.0, 3.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uv_origin_is_flipped_to_model_convention() {
|
||||||
|
// glTF-UV (1,1) (oben-links-Ursprung) → Modell-Konvention (1,0).
|
||||||
|
let m = parse_glb(&sample_glb()).unwrap();
|
||||||
|
assert_eq!(m.objects[0].uvs[2], [1.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_glb_and_wrong_version() {
|
||||||
|
assert!(parse_glb(b"PNG...whatever").is_err());
|
||||||
|
let mut v1 = sample_glb();
|
||||||
|
v1[4] = 1; // Version patchen
|
||||||
|
assert!(parse_glb(&v1).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-6
@@ -16,22 +16,29 @@
|
|||||||
//! 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` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an
|
//! `map`, `obj`, `gltf` und `tga` sind reine Decoder (Bytes → owned Daten,
|
||||||
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend
|
//! hängen an nichts) — die geteilte Heimat für Format-Dekodierung, die jedes
|
||||||
//! 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).
|
//! Datenmodells (Phase 2 des Renderer-Plans); `obj` und `gltf` sind der
|
||||||
|
//! Blender-Pfad (Sichtgeometrie + `col_*`-Collision-Proxies + Custom
|
||||||
|
//! Properties aus einem Export) und produzieren beide dasselbe neutrale
|
||||||
|
//! `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`) liefert ihr die Brush-Welt für den
|
//! ableitet; `collision` (hängt an `map` und `model`) liefert ihr die Welt
|
||||||
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
//! für den Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod collision;
|
pub mod collision;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
|
pub mod gltf;
|
||||||
pub mod ink;
|
pub mod ink;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
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;
|
||||||
pub mod tga;
|
pub mod tga;
|
||||||
|
pub mod trigger;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -36,7 +36,8 @@ const JUMP_SPEED: f32 = 7.0;
|
|||||||
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
||||||
const PITCH_LIMIT: f32 = 1.55;
|
const PITCH_LIMIT: f32 = 1.55;
|
||||||
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
||||||
const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
/// Öffentlich, weil die Session dieselbe Box gegen Trigger-Zonen prüft.
|
||||||
|
pub const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
||||||
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok).
|
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok).
|
||||||
const GROUND_NORMAL_Y: f32 = 0.7;
|
const GROUND_NORMAL_Y: f32 = 0.7;
|
||||||
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
||||||
@@ -70,6 +71,15 @@ impl Player {
|
|||||||
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Blickrichtung inkl. Pitch — dieselbe Formel wie `camera::forward`/
|
||||||
|
/// `math::view` (yaw=0 → −Z, positiver Pitch hebt). Für den
|
||||||
|
/// Point-and-Click-Strahl aus der Bildmitte.
|
||||||
|
pub fn look_dir(&self) -> [f32; 3] {
|
||||||
|
let (sy, cy) = self.yaw.sin_cos();
|
||||||
|
let (sp, cp) = self.pitch.sin_cos();
|
||||||
|
[-sy * cp, sp, -cy * cp]
|
||||||
|
}
|
||||||
|
|
||||||
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
||||||
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
||||||
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Trigger aus Blender-Modellen: Objekte mit `signal`-Custom-Property
|
||||||
|
//! (siehe [`model::SIGNAL_PROP`]), aufgeteilt nach Collider-Frage —
|
||||||
|
//! **anfassbar = klickbar, durchlaufbar = Betretens-Zone**:
|
||||||
|
//!
|
||||||
|
//! - `col_*` + `signal` → *Klick-Ziel*: das solide Volumen ist zugleich
|
||||||
|
//! das Ziel des Point-and-Click-Raycasts (siehe [`Triggers::pick`]).
|
||||||
|
//! Feuern läuft über den `use <name>`-Trichter in `Session::exec`.
|
||||||
|
//! - sonst + `signal` → *Zone*: unsichtbares konvexes Volumen; Betreten
|
||||||
|
//! feuert das Signal genau einmal je Eintritt ([`Triggers::enter_events`],
|
||||||
|
//! Flanken-Semantik: erneut erst nach Verlassen). „Einmal für immer"
|
||||||
|
//! baut die Autorin über KV-Flags/Ink, nicht hier.
|
||||||
|
//!
|
||||||
|
//! In beiden Fällen ist der Signal-Name der Property-*Wert*, `$self` der
|
||||||
|
//! Objektname. Volumen-Mathe kommt aus `collision` (dieselben konvexen
|
||||||
|
//! Ebenen-Sets wie die Proxies); gefeuert wird hier nichts — die Session
|
||||||
|
//! zieht Events/Treffer und schickt sie durch ihren Dispatch, damit der
|
||||||
|
//! Eingabe-Trichter der einzige Weg in den State bleibt.
|
||||||
|
|
||||||
|
use crate::engine::collision::{self, Plane};
|
||||||
|
use crate::engine::model::{Model, SIGNAL_PROP};
|
||||||
|
|
||||||
|
struct Clickable {
|
||||||
|
name: String,
|
||||||
|
signal: String,
|
||||||
|
planes: Vec<Plane>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Zone {
|
||||||
|
name: String,
|
||||||
|
signal: String,
|
||||||
|
planes: Vec<Plane>,
|
||||||
|
/// Flanken-Zustand: war der Spieler im letzten Check im Volumen?
|
||||||
|
inside: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Triggers {
|
||||||
|
clickables: Vec<Clickable>,
|
||||||
|
zones: Vec<Zone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Triggers {
|
||||||
|
pub fn new() -> Self { Self::default() }
|
||||||
|
|
||||||
|
/// Trigger eines Modells übernehmen: alle Objekte mit `signal`-Property.
|
||||||
|
pub fn add_model(&mut self, model: &Model) {
|
||||||
|
for o in &model.objects {
|
||||||
|
let Some(signal) = o.props.get(SIGNAL_PROP) else { continue; };
|
||||||
|
let Some(planes) = collision::object_planes(o) else {
|
||||||
|
eprintln!("[trigger] {}: kein geschlossenes konvexes Volumen — ignoriert", o.name);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (name, signal) = (o.name.clone(), signal.clone());
|
||||||
|
if o.collider {
|
||||||
|
self.clickables.push(Clickable { name, signal, planes });
|
||||||
|
} else {
|
||||||
|
self.zones.push(Zone { name, signal, planes, inside: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal eines Klick-Ziels (für die `use <name>`-Auflösung der Session).
|
||||||
|
pub fn signal_for(&self, name: &str) -> Option<&str> {
|
||||||
|
self.clickables.iter()
|
||||||
|
.find(|c| c.name == name)
|
||||||
|
.map(|c| c.signal.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nächstes Klick-Ziel entlang `start→end` (Punktstrahl): Name und
|
||||||
|
/// Trefferbruch. Ob Weltgeometrie davor liegt, prüft der Aufrufer.
|
||||||
|
pub fn pick(&self, start: [f32; 3], end: [f32; 3]) -> Option<(&str, f32)> {
|
||||||
|
let mut nearest: Option<(&str, f32)> = None;
|
||||||
|
for c in &self.clickables {
|
||||||
|
if let Some(hit) = collision::trace_planes(&c.planes, start, end, [0.0; 3])
|
||||||
|
&& nearest.is_none_or(|(_, f)| hit.frac < f) {
|
||||||
|
nearest = Some((&c.name, hit.frac));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nearest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zonen gegen die Spieler-AABB prüfen und die Eintritts-Flanken melden:
|
||||||
|
/// `(Objektname, Signal)` je Zone, die diesen Check betreten wurde.
|
||||||
|
pub fn enter_events(&mut self, center: [f32; 3], half: [f32; 3]) -> Vec<(String, String)> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for z in &mut self.zones {
|
||||||
|
let inside = collision::box_touches(&z.planes, center, half);
|
||||||
|
if inside && !z.inside {
|
||||||
|
events.push((z.name.clone(), z.signal.clone()));
|
||||||
|
}
|
||||||
|
z.inside = inside;
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::engine::model::{test_cube as cube, Object};
|
||||||
|
|
||||||
|
fn model(objects: Vec<Object>) -> Model {
|
||||||
|
Model { objects, empties: Vec::new(), materials: vec![String::new()] }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splits_by_collider_and_zone_is_invisible() {
|
||||||
|
let m = model(vec![
|
||||||
|
cube("col_Kiste", [0.0, 0.0, 0.0], Some("kiste")),
|
||||||
|
cube("Zone.001", [5.0, 0.0, 0.0], Some("betreten")),
|
||||||
|
cube("Deko", [9.0, 0.0, 0.0], None),
|
||||||
|
]);
|
||||||
|
assert!(!m.objects[1].visible, "Zone darf nicht gerendert werden");
|
||||||
|
|
||||||
|
let mut t = Triggers::new();
|
||||||
|
t.add_model(&m);
|
||||||
|
assert_eq!(t.clickables.len(), 1);
|
||||||
|
assert_eq!(t.zones.len(), 1);
|
||||||
|
assert_eq!(t.signal_for("col_Kiste"), Some("kiste"));
|
||||||
|
assert_eq!(t.signal_for("Deko"), None, "ohne signal-Property kein Klick-Ziel");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_finds_nearest_clickable() {
|
||||||
|
let mut t = Triggers::new();
|
||||||
|
t.add_model(&model(vec![
|
||||||
|
cube("col_Fern", [4.0, -0.5, -0.5], Some("f")),
|
||||||
|
cube("col_Nah", [1.0, -0.5, -0.5], Some("n")),
|
||||||
|
]));
|
||||||
|
// 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();
|
||||||
|
assert_eq!(name, "col_Nah");
|
||||||
|
assert!((frac - 0.1).abs() < 0.01, "frac={frac}");
|
||||||
|
// Strahl daneben → nichts.
|
||||||
|
assert!(t.pick([0.0, 5.0, 0.0], [10.0, 5.0, 0.0]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zone_fires_on_entry_edge_and_rearms_after_exit() {
|
||||||
|
let mut t = Triggers::new();
|
||||||
|
t.add_model(&model(vec![cube("Zone", [0.0, 0.0, 0.0], Some("betreten"))]));
|
||||||
|
let half = [0.3, 0.9, 0.3];
|
||||||
|
|
||||||
|
// Außerhalb: nichts.
|
||||||
|
assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty());
|
||||||
|
// Betreten: genau ein Event …
|
||||||
|
let ev = t.enter_events([0.5, 0.5, 0.5], half);
|
||||||
|
assert_eq!(ev, vec![("Zone".to_string(), "betreten".to_string())]);
|
||||||
|
// … und beim Verweilen keins.
|
||||||
|
assert!(t.enter_events([0.6, 0.5, 0.5], half).is_empty());
|
||||||
|
// Verlassen und wieder betreten: feuert erneut.
|
||||||
|
assert!(t.enter_events([5.0, 0.5, 0.5], half).is_empty());
|
||||||
|
assert_eq!(t.enter_events([0.5, 0.5, 0.5], half).len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-13
@@ -19,6 +19,7 @@ mod camera;
|
|||||||
mod font;
|
mod font;
|
||||||
mod gpu;
|
mod gpu;
|
||||||
mod math;
|
mod math;
|
||||||
|
mod props;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod sprite;
|
mod sprite;
|
||||||
mod ui;
|
mod ui;
|
||||||
@@ -35,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, map, tga};
|
use crate::engine::{assets, gltf, map, model, obj, 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;
|
||||||
@@ -63,17 +64,28 @@ 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"));
|
||||||
let tex_names = brush::texture_names(&world);
|
// Blender-Modelle: alle .glb/.obj unter assets/maps/props/. Autorinnen-
|
||||||
let images: Vec<Image> = tex_names.iter()
|
// Content — ein kaputtes File wird gemeldet und übersprungen, nie fatal.
|
||||||
.map(|n| tga::load(&assets::path(&format!("assets/textures/{n}.tga"))))
|
let models = load_models(&assets::path("assets/maps/props"));
|
||||||
.collect();
|
|
||||||
|
// Eine geteilte Texturliste für Brush- und Modell-Geometrie, damit beide
|
||||||
|
// Meshes denselben Index-Raum benutzen und verschmelzen können.
|
||||||
|
let mut tex_names = brush::texture_names(&world);
|
||||||
|
for (_, m) in &models { tex_names.extend(props::texture_names(m)); }
|
||||||
|
tex_names.sort_unstable();
|
||||||
|
tex_names.dedup();
|
||||||
|
let images: Vec<Image> = tex_names.iter().map(|n| load_texture(n)).collect();
|
||||||
let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect();
|
let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect();
|
||||||
let mesh = brush::build(&world, &tex_names, &dims);
|
|
||||||
|
let mut mesh = brush::build(&world, &tex_names, &dims);
|
||||||
|
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) in die Session einspielen — die
|
// Sim-Welt (Collision-Brushes + Spawn, dann col_*-Proxies der Modelle) in
|
||||||
// Simulation gehört der Session, nicht dem Render-Frontend.
|
// die Session einspielen — die Simulation gehört der Session, nicht dem
|
||||||
|
// Render-Frontend.
|
||||||
session.load_world(&world);
|
session.load_world(&world);
|
||||||
|
for (_, m) in &models { session.load_props(m); }
|
||||||
|
|
||||||
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||||||
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
||||||
@@ -122,6 +134,60 @@ 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
|
||||||
|
/// (sortiert → deterministische Reihenfolge). Fehlende Verzeichnisse sind
|
||||||
|
/// kein Fehler (kein Props-Ordner = keine Props); kaputte Dateien werden
|
||||||
|
/// gemeldet und übersprungen.
|
||||||
|
fn load_models(dir: &str) -> Vec<(String, model::Model)> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); };
|
||||||
|
let mut paths: Vec<std::path::PathBuf> = entries.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| matches!(p.extension().and_then(|s| s.to_str()), Some("obj" | "glb")))
|
||||||
|
.collect();
|
||||||
|
paths.sort();
|
||||||
|
let mut models = Vec::new();
|
||||||
|
for p in paths {
|
||||||
|
let path = p.to_string_lossy();
|
||||||
|
let loaded = match p.extension().and_then(|s| s.to_str()) {
|
||||||
|
Some("glb") => gltf::load(&path),
|
||||||
|
_ => obj::load(&path),
|
||||||
|
};
|
||||||
|
match loaded {
|
||||||
|
Ok(m) => {
|
||||||
|
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();
|
||||||
|
println!("[model] {name}: {} Objekte, {tris} Dreiecke, {} Empties",
|
||||||
|
m.objects.len(), m.empties.len());
|
||||||
|
// Custom Properties sichtbar machen — noch konsumiert sie
|
||||||
|
// niemand, aber die Autorin sieht so, dass sie ankommen.
|
||||||
|
for o in &m.objects {
|
||||||
|
for (k, v) in &o.props { println!("[model] {} · {k} = {v}", o.name); }
|
||||||
|
}
|
||||||
|
for e in &m.empties {
|
||||||
|
println!("[model] Empty {} @ {:?}", e.name, e.pos);
|
||||||
|
for (k, v) in &e.props { println!("[model] {} · {k} = {v}", e.name); }
|
||||||
|
}
|
||||||
|
models.push((name, m));
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("[model] {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Welt-Textur laden: `assets/textures/{name}.tga`. Fehlt die Datei
|
||||||
|
/// (Material ohne Textur, Tippfehler im Blender-Materialnamen), gibt es den
|
||||||
|
/// Platzhalter statt eines Boot-Panics — Texturnamen sind Autorinnen-Content.
|
||||||
|
fn load_texture(name: &str) -> Image {
|
||||||
|
let path = assets::path(&format!("assets/textures/{name}.tga"));
|
||||||
|
if std::path::Path::new(&path).exists() {
|
||||||
|
tga::load(&path)
|
||||||
|
} else {
|
||||||
|
eprintln!("[tex] {name}: keine Datei unter {path} — Platzhalter");
|
||||||
|
tga::load(&assets::path(&format!("assets/textures/{}.tga", props::FALLBACK_TEXTURE)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße.
|
/// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße.
|
||||||
fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) {
|
fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) {
|
||||||
let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum();
|
let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum();
|
||||||
@@ -359,7 +425,8 @@ impl App {
|
|||||||
look_dx: self.input.mouse_dx,
|
look_dx: self.input.mouse_dx,
|
||||||
look_dy: self.input.mouse_dy,
|
look_dy: self.input.mouse_dy,
|
||||||
};
|
};
|
||||||
self.session.tick(&input, dt);
|
// Betretens-Zonen melden ihre Signal-Ausgaben wie exec-Befehle.
|
||||||
|
for out in self.session.tick(&input, dt) { println!("{out}"); }
|
||||||
}
|
}
|
||||||
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||||||
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
||||||
@@ -391,12 +458,25 @@ impl App {
|
|||||||
let p = self.session.player.pos;
|
let p = self.session.player.pos;
|
||||||
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
|
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
|
||||||
};
|
};
|
||||||
|
// Fadenkreuz-Pick im Spielmodus: Blickstrahl aus Augenhöhe auf
|
||||||
|
// Klick-Ziele (`signal`-Property). Steuert den Interakt-Cursor und
|
||||||
|
// löst beim Klick unten das `use` aus.
|
||||||
|
let aim: Option<String> = if play {
|
||||||
|
let p = self.session.player.pos;
|
||||||
|
let eye = [p[0], p[1] + player::EYE_HEIGHT, p[2]];
|
||||||
|
let dir = self.session.player.look_dir();
|
||||||
|
self.session.pick(eye, dir, crate::session::INTERACT_REACH).map(str::to_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Mausposition über die Letterbox in interne Pixel mappen.
|
// Mausposition über die Letterbox in interne Pixel mappen.
|
||||||
let cursor = ui::Cursor {
|
let cursor = ui::Cursor {
|
||||||
pos: self.gpu.as_ref()
|
pos: self.gpu.as_ref()
|
||||||
.map(|g| g.map_cursor(self.cursor_win))
|
.map(|g| g.map_cursor(self.cursor_win))
|
||||||
.unwrap_or([0.0, 0.0]),
|
.unwrap_or([0.0, 0.0]),
|
||||||
grabbed: self.input.grabbed,
|
grabbed: self.input.grabbed,
|
||||||
|
hud_interact: aim.is_some(),
|
||||||
};
|
};
|
||||||
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
||||||
// Hover-Aktion kommt aus der Maus-Position.
|
// Hover-Aktion kommt aus der Maus-Position.
|
||||||
@@ -404,12 +484,14 @@ impl App {
|
|||||||
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion
|
// Vorgemerkten Klick auflösen — beides durch denselben exec-Trichter
|
||||||
// durch denselben exec-Trichter wie die Konsole. Klick in der freien
|
// wie die Konsole: über einem UI-Klickziel dessen Aktion, sonst im
|
||||||
// Welt tut (noch) nichts — hier käme später der `use`-Raycast.
|
// Spielmodus das Fadenkreuz-Ziel als `use <name>`.
|
||||||
if self.pending_click {
|
if self.pending_click {
|
||||||
self.pending_click = false;
|
self.pending_click = false;
|
||||||
if let Some(action) = screen.hover_action.clone() {
|
let action = screen.hover_action.clone()
|
||||||
|
.or_else(|| aim.map(|name| format!("use {name}")));
|
||||||
|
if let Some(action) = action {
|
||||||
let r = self.session.exec(&action);
|
let r = self.session.exec(&action);
|
||||||
for out in r.output { println!("{out}"); }
|
for out in r.output { println!("{out}"); }
|
||||||
if r.quit { event_loop.exit(); }
|
if r.quit { event_loop.exit(); }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,24 @@ pub(crate) struct Mesh {
|
|||||||
pub(crate) batches: Vec<Batch>,
|
pub(crate) batches: Vec<Batch>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Mesh {
|
||||||
|
/// Ein zweites Mesh anhängen (ein Vertex-/Index-Buffer für alles).
|
||||||
|
/// Voraussetzung: beide wurden gegen *dieselbe* Texturliste gebaut —
|
||||||
|
/// die Batch-Texturindizes bleiben dann unverändert gültig; nur die
|
||||||
|
/// Buffer-Offsets verschieben sich.
|
||||||
|
pub(crate) fn append(&mut self, other: Mesh) {
|
||||||
|
let vbase = self.verts.len() as u32;
|
||||||
|
let ibase = self.indices.len() as u32;
|
||||||
|
self.verts.extend(other.verts);
|
||||||
|
self.indices.extend(other.indices.iter().map(|i| i + vbase));
|
||||||
|
self.batches.extend(other.batches.iter().map(|b| Batch {
|
||||||
|
texture: b.texture,
|
||||||
|
start: b.start + ibase,
|
||||||
|
count: b.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
|
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
|
||||||
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
|
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
|
||||||
/// von WGSL verlangen.
|
/// von WGSL verlangen.
|
||||||
|
|||||||
+6
-3
@@ -30,8 +30,7 @@ pub(crate) const ORN: usize = 4;
|
|||||||
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
||||||
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
||||||
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
||||||
// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast.
|
pub(crate) const CUR_HUD_INTERACT: usize = 3; // dito, über einem Klick-Ziel
|
||||||
#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3;
|
|
||||||
|
|
||||||
const CURSOR_PX: f32 = 16.0;
|
const CURSOR_PX: f32 = 16.0;
|
||||||
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
|
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
|
||||||
@@ -61,6 +60,9 @@ pub(crate) struct Cursor {
|
|||||||
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
||||||
/// Maus-Cursor an `pos`.
|
/// Maus-Cursor an `pos`.
|
||||||
pub(crate) grabbed: bool,
|
pub(crate) grabbed: bool,
|
||||||
|
/// Fadenkreuz zielt auf ein Klick-Ziel (`Session::pick`) → Interakt-
|
||||||
|
/// Variante des HUD-Pointers.
|
||||||
|
pub(crate) hud_interact: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
|
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
|
||||||
@@ -237,7 +239,8 @@ pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur:
|
|||||||
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
||||||
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
||||||
if cur.grabbed {
|
if cur.grabbed {
|
||||||
ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
let idx = if cur.hud_interact { CUR_HUD_INTERACT } else { CUR_HUD };
|
||||||
|
ui.cursor(idx, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
||||||
} else {
|
} else {
|
||||||
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
||||||
ui.cursor(idx, cur.pos, [1.0; 4]);
|
ui.cursor(idx, cur.pos, [1.0; 4]);
|
||||||
|
|||||||
+123
-11
@@ -2,8 +2,9 @@ use crate::engine::collision::CollisionWorld;
|
|||||||
use crate::engine::game::{Action, Game, ModeTarget};
|
use crate::engine::game::{Action, Game, ModeTarget};
|
||||||
use crate::engine::ink::StoryState;
|
use crate::engine::ink::StoryState;
|
||||||
use crate::engine::map::{self, Map};
|
use crate::engine::map::{self, Map};
|
||||||
use crate::engine::player::Player;
|
use crate::engine::player::{self, Player};
|
||||||
use crate::engine::{kv, signals, story_ctrl};
|
use crate::engine::trigger::Triggers;
|
||||||
|
use crate::engine::{kv, model, signals, story_ctrl};
|
||||||
|
|
||||||
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
|
||||||
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
|
||||||
@@ -20,6 +21,9 @@ const FIXED_DT: f32 = 1.0 / 60.0;
|
|||||||
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
|
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
|
||||||
const MAX_ACCUM: f32 = 0.25;
|
const MAX_ACCUM: f32 = 0.25;
|
||||||
|
|
||||||
|
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
|
||||||
|
pub const INTERACT_REACH: f32 = 3.0;
|
||||||
|
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
FirstPerson,
|
FirstPerson,
|
||||||
Free,
|
Free,
|
||||||
@@ -28,9 +32,6 @@ pub enum Mode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Dialog {
|
pub struct Dialog {
|
||||||
// Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der
|
|
||||||
// Dialogtext über die Konsolen-Ausgabe (siehe `step`).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub choices: Vec<String>,
|
pub choices: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,9 @@ pub struct Session {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
pub collision: CollisionWorld,
|
pub collision: CollisionWorld,
|
||||||
|
/// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
|
||||||
|
/// (`signal`-Property, siehe engine::trigger).
|
||||||
|
triggers: Triggers,
|
||||||
signals_path: String,
|
signals_path: String,
|
||||||
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
|
||||||
sim_accum: f32,
|
sim_accum: f32,
|
||||||
@@ -83,6 +87,7 @@ impl Session {
|
|||||||
// braucht keine — sie tickt nie).
|
// braucht keine — sie tickt nie).
|
||||||
player: Player::new([0.0, 1.0, 0.0]),
|
player: Player::new([0.0, 1.0, 0.0]),
|
||||||
collision: CollisionWorld::empty(),
|
collision: CollisionWorld::empty(),
|
||||||
|
triggers: Triggers::new(),
|
||||||
signals_path,
|
signals_path,
|
||||||
sim_accum: 0.0,
|
sim_accum: 0.0,
|
||||||
pending_jump: false,
|
pending_jump: false,
|
||||||
@@ -94,14 +99,27 @@ impl Session {
|
|||||||
/// Start gerufen.
|
/// Start gerufen.
|
||||||
pub fn load_world(&mut self, map: &Map) {
|
pub fn load_world(&mut self, map: &Map) {
|
||||||
self.collision = CollisionWorld::build(map);
|
self.collision = CollisionWorld::build(map);
|
||||||
|
self.triggers = Triggers::new();
|
||||||
self.player = Player::new(player_spawn(map));
|
self.player = Player::new(player_spawn(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sim-Seite eines Blender-Modells in die bestehende Welt einfügen —
|
||||||
|
/// nach `load_world` rufen, einmal pro Modell: Collision-Proxies
|
||||||
|
/// (`col_*`) und Trigger (`signal`-Property → Klick-Ziele/Zonen). Die
|
||||||
|
/// Sichtgeometrie desselben Modells ist Sache des Renderers
|
||||||
|
/// (render::props).
|
||||||
|
pub fn load_props(&mut self, model: &model::Model) {
|
||||||
|
self.collision.add_model(model);
|
||||||
|
self.triggers.add_model(model);
|
||||||
|
}
|
||||||
|
|
||||||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||||||
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
|
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
|
||||||
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht).
|
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
|
||||||
pub fn tick(&mut self, input: &FrameInput, dt: f32) {
|
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
|
||||||
if !matches!(self.mode, Mode::FirstPerson) { return; }
|
/// zeigt sie wie `exec`-Ausgaben an.
|
||||||
|
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
|
||||||
|
if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); }
|
||||||
|
|
||||||
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
|
||||||
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
|
||||||
@@ -119,6 +137,38 @@ impl Session {
|
|||||||
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
|
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
|
||||||
self.sim_accum -= FIXED_DT;
|
self.sim_accum -= FIXED_DT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Betretens-Zonen nach der Bewegung prüfen (Flanke: einmal je
|
||||||
|
// Eintritt). Erst alle Events einsammeln, dann feuern — `fire` kann
|
||||||
|
// den Modus wechseln (z.B. start_ink → Dialog), die Sim dieses Frames
|
||||||
|
// ist da schon abgeschlossen.
|
||||||
|
let center = [
|
||||||
|
self.player.pos[0],
|
||||||
|
self.player.pos[1] + player::HALF_EXTENTS[1],
|
||||||
|
self.player.pos[2],
|
||||||
|
];
|
||||||
|
let events = self.triggers.enter_events(center, player::HALF_EXTENTS);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (name, signal) in events {
|
||||||
|
out.extend(self.fire(&signal, Some(name)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Klick-Ziel unterm Fadenkreuz: nächstes `signal`-Klick-Volumen entlang
|
||||||
|
/// des Blickstrahls (`origin` + `dir·reach`), sofern keine andere
|
||||||
|
/// Weltgeometrie davor liegt. Liefert den Objektnamen für `use <name>`.
|
||||||
|
pub fn pick(&self, origin: [f32; 3], dir: [f32; 3], reach: f32) -> Option<&str> {
|
||||||
|
let end = [
|
||||||
|
origin[0] + dir[0] * reach,
|
||||||
|
origin[1] + dir[1] * reach,
|
||||||
|
origin[2] + dir[2] * reach,
|
||||||
|
];
|
||||||
|
let (name, frac) = self.triggers.pick(origin, end)?;
|
||||||
|
// Das Klick-Volumen ist selbst Teil der Kollisionswelt → bei freier
|
||||||
|
// Sicht treffen beide Traces dieselbe Fläche (Toleranz für FP/SKIN).
|
||||||
|
let world = self.collision.trace(origin, end, [0.0; 3]).map_or(1.0, |h| h.frac);
|
||||||
|
(frac <= world + 1e-3).then_some(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
|
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
|
||||||
@@ -190,10 +240,14 @@ impl Session {
|
|||||||
} else if let Some(sig) = line.strip_prefix("signal ") {
|
} else if let Some(sig) = line.strip_prefix("signal ") {
|
||||||
ExecResult::lines(self.fire(sig.trim(), None))
|
ExecResult::lines(self.fire(sig.trim(), None))
|
||||||
} else if let Some(name) = line.strip_prefix("use ") {
|
} else if let Some(name) = line.strip_prefix("use ") {
|
||||||
// Objekt-Interaktion simulieren: Signal ist der gestrippte
|
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
|
||||||
// Name, $self der volle — wie der LMB-Klick-Pfad in irl3d.
|
// hier durch): Klick-Ziele mit `signal`-Property feuern
|
||||||
|
// dieses Signal; sonst den vom Blender-Suffix befreiten
|
||||||
|
// Objektnamen. $self ist immer der volle Name.
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
let key = signals::signal_key(name).to_string();
|
let key = self.triggers.signal_for(name)
|
||||||
|
.unwrap_or_else(|| signals::signal_key(name))
|
||||||
|
.to_string();
|
||||||
ExecResult::lines(self.fire(&key, Some(name.to_string())))
|
ExecResult::lines(self.fire(&key, Some(name.to_string())))
|
||||||
} else {
|
} else {
|
||||||
ExecResult::lines(vec![
|
ExecResult::lines(vec![
|
||||||
@@ -466,6 +520,64 @@ mod tests {
|
|||||||
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
|
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
|
||||||
|
/// deren Signale als Builtin-Fall-Through direkt KV-Flags setzen.
|
||||||
|
fn trigger_model() -> crate::engine::model::Model {
|
||||||
|
crate::engine::model::Model {
|
||||||
|
objects: vec![
|
||||||
|
crate::engine::model::test_cube(
|
||||||
|
"Zone.001", [-0.5, 0.0, -3.0], Some("set zone_hit true")),
|
||||||
|
crate::engine::model::test_cube(
|
||||||
|
"col_Kiste", [5.0, 0.0, 5.0], Some("set kiste_geklickt true")),
|
||||||
|
],
|
||||||
|
empties: Vec::new(),
|
||||||
|
materials: vec![String::new()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walking_into_zone_fires_signal_once() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.load_props(&trigger_model());
|
||||||
|
s.mode = Mode::FirstPerson;
|
||||||
|
s.player.grounded = true;
|
||||||
|
|
||||||
|
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
|
||||||
|
// Flanke genau einmal (danach: drin = still).
|
||||||
|
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
||||||
|
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");
|
||||||
|
|
||||||
|
// Weiterlaufen in der Zone darf nicht erneut feuern.
|
||||||
|
s.game.kv.remove("zone_hit");
|
||||||
|
for _ in 0..5 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
|
||||||
|
assert!(!s.game.kv.contains_key("zone_hit"), "Flanke: nur einmal je Eintritt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn use_resolves_signal_property_before_name() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.load_props(&trigger_model());
|
||||||
|
s.mode = Mode::FirstPerson;
|
||||||
|
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("col_Kiste").
|
||||||
|
s.exec("use col_Kiste");
|
||||||
|
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
|
||||||
|
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
|
||||||
|
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
|
||||||
|
let r = s.exec("use Irgendwas.003");
|
||||||
|
assert!(!r.quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_hits_clickable_within_reach() {
|
||||||
|
let mut s = empty_session();
|
||||||
|
s.load_props(&trigger_model());
|
||||||
|
// col_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);
|
||||||
|
assert_eq!(hit, Some("col_Kiste"));
|
||||||
|
// Außer Reichweite → nichts.
|
||||||
|
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_command_reports_and_stays_free() {
|
fn unknown_command_reports_and_stays_free() {
|
||||||
let mut s = empty_session();
|
let mut s = empty_session();
|
||||||
|
|||||||
Reference in New Issue
Block a user