Files
DOSSIER-STANDALONE/src-tauri/render3d/src/lib.rs
T
karim 68a0459d0e Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie
  (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im
  Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht,
  unsichtbare Ebenen blenden ihre Führungslinie aus
- Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/
  Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste
  (AppMenuBar) neben der Wortmarke
- Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert,
  flackerfrei vor erstem Render gesetzt
- SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene
- UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access-
  Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/
  Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt
- Neues Dachziegel-Material (RoofingTiles013A)
2026-07-20 10:51:37 +02:00

1227 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Nativer wgpu-3D-Renderer fuer die CAD-Modellsicht.
//
// Aufbau in Schichten (bewusst getrennt, siehe Feature-Flags in Cargo.toml):
// - `types` : serde-only Eingabe (geflachte Waende, Kamera) + Mesh-Ausgabe.
// - `mesh` : Wand-Extrusion (Band aus Achse+Dicke, hochgezogen auf Hoehe).
// GPU-frei, headless per `cargo test` pruefbar. Kern-Port der
// three.js-`ExtrudeGeometry`-Wanderzeugung.
// - `math` : Mat4 + Kamera (View/Projektion, Perspektive + Orthografie,
// die fuenf Presets) + Orbit-Helfer. Handgerechnet, testbar.
// - `shaders` : WGSL-Quelle (View-Projektion + Directional-Light).
// - `gpu` : wgpu-Pipeline mit Tiefenpuffer + Backface-Culling (Feature "render").
// - `bin/spike3d` : winit-Fenster mit Orbit-Kamera (Feature "window").
//
// Standard-Build (`cargo test`/`cargo build` ohne Features) enthaelt nur die
// GPU-freien Schichten und ist damit unabhaengig von einer Display-Session.
pub mod edges;
pub mod grid;
pub mod math;
pub mod mesh;
pub(crate) mod openings;
pub mod section;
pub mod section_boolean;
pub mod section_fill;
pub mod shaders;
pub mod types;
#[cfg(feature = "render")]
pub mod gpu;
// Browser-Bindings (wasm32 + WebGPU), nur mit Feature "web". Setzt "render" voraus
// (dieselbe GPU-Schicht wie das native Fenster), fuegt aber KEINE winit-Abhaengigkeit
// hinzu — die Canvas dient direkt als wgpu-Surface. Muster: render2d/src/web.rs.
#[cfg(feature = "web")]
pub mod web;
pub use math::{
look_at, orbit_eye, orthographic, perspective, preset_camera, projection_matrix,
view_matrix, view_projection, Mat4,
};
pub use mesh::{
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
};
pub use section::{
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
};
pub use types::{
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
};
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
#[cfg(test)]
mod tests {
use super::mesh::{
build_free_wall_end_caps, build_scene_mesh_textured, build_walls_mesh,
build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
};
use super::section_fill::CAP_FLOATS_PER_VERTEX;
use super::types::{
Hatch, Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX,
};
/// Bequemer Bau einer achsparallelen Wand entlang +X.
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
WallInput {
start: [0.0, 0.0],
end: [len, 0.0],
thickness,
height,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}
}
/// Liest Position + Normale eines Vertex (index i) aus dem interleaved Puffer.
fn vert(mesh: &Mesh, i: usize) -> ([f32; 3], [f32; 3]) {
let b = i * FLOATS_PER_VERTEX;
(
[mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]],
[mesh.verts[b + 3], mesh.verts[b + 4], mesh.verts[b + 5]],
)
}
#[test]
fn texturierter_pfad_ist_geometrisch_deckungsgleich_mit_shaded() {
// Der texturierte Wand-Pfad (`build_walls_mesh_textured`, [pos,normal,uv])
// muss GEOMETRISCH bitgleich zum Alt-Pfad (`build_walls_mesh`,
// [pos,normal,color]) sein: gleiche Vertex-/Index-Zahl und -Reihenfolge,
// identische Positionen und Normalen. Nur der letzte Kanal (Farbe -> UV)
// unterscheidet sich. So ist belegt, dass der additive UV-Kanal den
// Alt-Pfad nicht beruehrt.
let walls = [wall_x(3.0, 0.2, 2.5)];
let shaded = build_walls_mesh(&walls);
let tex = build_walls_mesh_textured(&walls);
assert_eq!(tex.indices, shaded.indices, "Index-Puffer identisch");
assert_eq!(
tex.vertex_count(),
shaded.vertex_count(),
"gleiche Vertex-Zahl"
);
for i in 0..shaded.vertex_count() {
let sb = i * FLOATS_PER_VERTEX;
let tb = i * TEXTURED_FLOATS_PER_VERTEX;
// Position (0..3) und Normale (3..6) bitgleich uebernommen.
for k in 0..6 {
assert_eq!(
shaded.verts[sb + k],
tex.verts[tb + k],
"Vertex {i} Kanal {k}: pos/normal muss identisch sein"
);
}
}
}
#[test]
fn texturierte_uv_ist_weltmassstaeblich() {
// UV-Massstab: `u`/`v` in Metern (1 Kachel = 1 m). Fuer eine Wand entlang +X
// liegt die +Z-Mantelflaeche bei Normale (0,0,1); dort ist die Tangente
// t=(-n.z,0,n.x)=(-1,0,0), also u = -x und v = Hoehe. Die Deckflaeche
// (Normale +Y) projiziert auf u=x, v=z. Wir pruefen, dass es UEBERHAUPT
// Vertices mit v == Wandhoehe (2.5) bzw. v == 0 gibt (Hoehe wandert als v
// mit) — belegt den weltmassstaeblichen Hoehen-Kanal.
let tex = build_walls_mesh_textured(&[wall_x(3.0, 0.2, 2.5)]);
let n = tex.vertex_count();
let mut saw_top = false;
let mut saw_bottom = false;
for i in 0..n {
let b = i * TEXTURED_FLOATS_PER_VERTEX;
let ny = tex.verts[b + 4];
let v = tex.verts[b + 7];
// Nur Mantelflaechen (ny ~ 0) tragen die Hoehe als v.
if ny.abs() < 0.5 {
if (v - 2.5).abs() < 1e-5 {
saw_top = true;
}
if v.abs() < 1e-5 {
saw_bottom = true;
}
}
}
assert!(saw_top, "Mantel-Vertex mit v == Wandhoehe (2.5 m) erwartet");
assert!(saw_bottom, "Mantel-Vertex mit v == 0 (Sockel) erwartet");
}
#[test]
fn eine_wand_hat_quader_zaehlung() {
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX, "24 Vertices je Quader");
assert_eq!(mesh.indices.len(), INDICES_PER_BOX, "36 Indizes je Quader");
assert_eq!(mesh.triangle_count(), 12, "12 Dreiecke je Quader");
// Kein Index zeigt ausserhalb des Puffers.
let max_idx = *mesh.indices.iter().max().unwrap();
assert!((max_idx as usize) < mesh.vertex_count());
}
/// `wall_x` mit gesetzter Bauteil-Schraffur (Voraussetzung fuer die Kappen).
fn wall_x_hatched(len: f32, thickness: f32, height: f32) -> WallInput {
WallInput {
hatch: Some(Hatch { pattern: 3, angle: 45.0, scale: 1.0, line_weight: 0.13 }),
..wall_x(len, thickness, height)
}
}
#[test]
fn isolierte_wand_ohne_hatch_bekommt_keine_kappen() {
// Bestehende Test-Fixtures (kein `hatch`) bleiben unangetastet: keine
// Kappen-Geometrie, unabhaengig davon, dass beide Enden frei sind.
let caps = build_free_wall_end_caps(&[wall_x(3.0, 0.2, 2.5)]);
assert!(caps.is_empty());
}
#[test]
fn isolierte_wand_mit_hatch_bekommt_kappen_an_beiden_freien_enden() {
// Eine einzelne Wand hat KEINE Nachbarn -> beide Achsenenden sind frei ->
// je Ende eine Schraffur-Kappe (1 Schicht x 2 Dreiecke x 3 Vertices = 6
// Vertices je Ende, 12 insgesamt).
let caps = build_free_wall_end_caps(&[wall_x_hatched(3.0, 0.2, 2.5)]);
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12);
// Jeder Kappen-Vertex traegt die Schraffur-Parameter (Muster 3 = crosshatch).
for v in caps.chunks_exact(CAP_FLOATS_PER_VERTEX) {
assert_eq!(v[5], 3.0, "pattern");
assert!((v[6] - 45.0_f32.to_radians()).abs() < 1e-5, "angle_rad");
}
}
#[test]
fn verbundene_wandenden_bekommen_keine_kappe_dort() {
// Zwei Waende treffen sich rechtwinklig bei (3,0) -> DIESES Ende ist bei
// beiden Waenden NICHT frei (Gehrung/Anschluss), die beiden anderen
// (freien) Enden bekommen weiterhin je eine Kappe -> 2 Kappen gesamt
// (nicht 4), macht 12 Vertices statt 24.
let a = wall_x_hatched(3.0, 0.2, 2.5); // (0,0) frei .. (3,0) verbunden
let b = WallInput {
start: [3.0, 0.0],
end: [3.0, 4.0],
..wall_x_hatched(4.0, 0.2, 2.5) // (3,0) verbunden .. (3,4) frei
};
let caps = build_free_wall_end_caps(&[a, b]);
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12, "nur die 2 freien Enden -> 2 Kappen");
}
#[test]
fn t_stoss_bekommt_ebenfalls_keine_kappe_am_knoten() {
// Drei Waende treffen sich am selben Punkt (T-Stoss, Gehrung bleibt
// rechtwinklig, s. compute_wall_miters) -> trotzdem gilt der Knoten als
// "beruehrt", KEINE der drei dortigen Enden bekommt eine Kappe.
let a = wall_x_hatched(3.0, 0.2, 2.5); // endet bei (3,0)
let b = WallInput { start: [3.0, 0.0], end: [3.0, 4.0], ..wall_x_hatched(4.0, 0.2, 2.5) };
let c = WallInput { start: [3.0, 0.0], end: [6.0, 0.0], ..wall_x_hatched(3.0, 0.2, 2.5) };
let caps = build_free_wall_end_caps(&[a, b, c]);
// Freie Enden: a.start(0,0), b.end(3,4), c.end(6,0) -> 3 Kappen (nicht 6).
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 18);
}
#[test]
fn mehrere_waende_addieren_sich() {
let mesh = build_walls_mesh(&[
wall_x(3.0, 0.2, 2.5),
WallInput {
start: [3.0, 0.0],
end: [3.0, 4.0],
thickness: 0.2,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
},
]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
assert_eq!(mesh.indices.len(), 2 * INDICES_PER_BOX);
}
#[test]
fn bounding_box_deckt_dicke_laenge_hoehe_ab() {
// Wand entlang +X, Laenge 3, Dicke 0.2 -> Band in Z von -0.1..0.1;
// Hoehe 2.5 ab Basis 0. world: (x, y=hoehe, z=grundriss.y).
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
let (min, max) = mesh.bounds();
// X: 0..3 (Achsenlaenge).
assert!((min[0] - 0.0).abs() < 1e-5, "min x == 0");
assert!((max[0] - 3.0).abs() < 1e-5, "max x == 3");
// Y: 0..2.5 (Hoehe).
assert!((min[1] - 0.0).abs() < 1e-5, "min y == 0");
assert!((max[1] - 2.5).abs() < 1e-5, "max y == 2.5");
// Z: -0.1..0.1 (halbe Dicke je Seite).
assert!((min[2] + 0.1).abs() < 1e-5, "min z == -0.1");
assert!((max[2] - 0.1).abs() < 1e-5, "max z == 0.1");
}
#[test]
fn basis_hoehe_verschiebt_in_y() {
let mut w = wall_x(3.0, 0.2, 2.5);
w.base_elevation = 3.0; // Obergeschoss.
let mesh = build_walls_mesh(&[w]);
let (min, max) = mesh.bounds();
assert!((min[1] - 3.0).abs() < 1e-5, "Unterkante auf Basis 3.0");
assert!((max[1] - 5.5).abs() < 1e-5, "Oberkante 3.0 + 2.5");
}
#[test]
fn deckel_normale_zeigt_nach_oben() {
// Der Deckel wird als erstes Quad angehaengt (Vertices 0..3): Normale +Y.
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
let (_, n0) = vert(&mesh, 0);
assert!((n0[0]).abs() < 1e-6);
assert!((n0[1] - 1.0).abs() < 1e-6, "Deckel-Normale +Y");
assert!((n0[2]).abs() < 1e-6);
}
#[test]
fn mantel_normalen_zeigen_nach_aussen() {
// Wand entlang +X: die +n-Seite liegt bei z=+0.1 (n = leftNormal von +X =
// (0,1) im Grundriss -> world +Z), die -n-Seite bei z=-0.1. Fuer JEDE
// Mantelflaechen-Normale muss gelten: sie zeigt vom Wand-Zentrum weg.
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
// Wand-Zentrum in world.
let center = [1.5f32, 1.25, 0.0];
// Alle Vertices durchgehen; fuer jede Flaeche muss die Normale eine
// positive Komponente in Richtung (Vertex - Zentrum) haben (zeigt raus).
let n = mesh.vertex_count();
for i in 0..n {
let (p, nor) = vert(&mesh, i);
let out = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
let d = out[0] * nor[0] + out[1] * nor[1] + out[2] * nor[2];
// >= 0: die Normale weist nie ins Innere (Backface-Culling korrekt).
assert!(
d >= -1e-5,
"Vertex {i}: Normale zeigt nach innen (dot={d})"
);
}
}
#[test]
fn diagonale_wand_hat_gleiche_zaehlung() {
// Auch nicht-achsparallele Waende extrudieren korrekt (nur andere Ecken).
let w = WallInput {
start: [0.0, 0.0],
end: [2.0, 2.0],
thickness: 0.3,
height: 3.0,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w);
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX);
// Hoehe deckt Y 0..3 ab.
let (min, max) = mesh.bounds();
assert!((min[1]).abs() < 1e-5 && (max[1] - 3.0).abs() < 1e-5);
}
#[test]
fn degenerierte_wand_erzeugt_nichts() {
// Start == Ende -> keine Richtung -> uebersprungen (kein Absturz).
let w = WallInput {
start: [1.0, 1.0],
end: [1.0, 1.0],
thickness: 0.2,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0);
assert!(mesh.indices.is_empty());
}
// --- Oeffnungen (Fenster/Tueren) in der Vollkoerper-Extrusion -------------
#[test]
fn fenster_erzeugt_pfeiler_bruestung_sturz_und_sichtbare_luecke() {
// Wand entlang +X, Laenge 4, ein Fenster bei x in [1.5,2.5], Bruestung
// 0.9, Kopfhoehe 2.1 (< Wandhoehe 2.5) -> vier Teil-Quader: Pfeiler
// 0..1.5 (voll), Bruestung 1.5..2.5 (0..0.9), Sturz 1.5..2.5 (2.1..2.5),
// Pfeiler 2.5..4 (voll). Kein Vollkoerper mehr ueber die Oeffnung.
let mut w = wall_x(4.0, 0.2, 2.5);
w.openings = vec![Opening {
from: 1.5,
to: 2.5,
sill: 0.9,
height: 1.2,
}];
let mesh = build_walls_mesh(&[w]);
assert_eq!(
mesh.vertex_count(),
4 * VERTS_PER_BOX,
"vier Teil-Quader (2 Pfeiler + Bruestung + Sturz)"
);
assert_eq!(mesh.indices.len(), 4 * INDICES_PER_BOX);
let max_idx = *mesh.indices.iter().max().unwrap();
assert!((max_idx as usize) < mesh.vertex_count());
// Aussenmasse bleiben unveraendert (Pfeiler+Bruestung+Sturz decken
// zusammen wieder die volle Bounding-Box ab).
let (min, max) = mesh.bounds();
assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5);
assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5);
// Sichtbare Luecke: kein Vertex liegt (mit Sicherheitsabstand) im
// Oeffnungs-Loch selbst (x in (1.5,2.5) UND y in (0.9,2.1)) — dort darf
// keine Mesh-Flaeche mehr existieren.
let margin = 0.05;
let n = mesh.vertex_count();
for i in 0..n {
let (p, _) = vert(&mesh, i);
let inside_hole = p[0] > 1.5 + margin
&& p[0] < 2.5 - margin
&& p[1] > 0.9 + margin
&& p[1] < 2.1 - margin;
assert!(!inside_hole, "Vertex {i} bei {p:?} liegt im Fensterloch");
}
}
#[test]
fn tuer_erreicht_den_boden_keine_bruestung() {
// Tuer (sill == 0.0) reicht bis zum Boden -> keine Bruestung, nur zwei
// volle Pfeiler + ein Sturz-Quader ueber der Tuer (drei Teil-Quader).
let mut w = wall_x(4.0, 0.2, 2.5);
w.openings = vec![Opening {
from: 1.0,
to: 1.8,
sill: 0.0,
height: 2.1,
}];
let mesh = build_walls_mesh(&[w]);
assert_eq!(
mesh.vertex_count(),
3 * VERTS_PER_BOX,
"zwei Pfeiler + ein Sturz, keine Bruestung"
);
assert_eq!(mesh.indices.len(), 3 * INDICES_PER_BOX);
// Im Tuerbereich (x in (1.0,1.8)) darf unterhalb der Kopfhoehe (2.1)
// keine Geometrie liegen -> jeder Vertex dort liegt auf/ueber dem Sturz.
let margin = 0.05;
let n = mesh.vertex_count();
for i in 0..n {
let (p, _) = vert(&mesh, i);
let in_door_span = p[0] > 1.0 + margin && p[0] < 1.8 - margin;
if in_door_span {
assert!(
p[1] > 2.1 - 1e-4,
"Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe"
);
}
}
}
#[test]
fn oeffnung_ausserhalb_der_wandlaenge_erzeugt_vollkoerper() {
// Oeffnung komplett ausserhalb [0,length] -> Regressionsfall: ein
// einzelner Vollkoerper-Quader wie ohne Oeffnungen.
let mut w = wall_x(4.0, 0.2, 2.5);
w.openings = vec![Opening {
from: 5.0,
to: 6.0,
sill: 0.9,
height: 1.2,
}];
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX);
assert_eq!(mesh.indices.len(), INDICES_PER_BOX);
}
// --- Echte Loecher in EINEM Wandkoerper (WallInput::holes) -----------------
#[test]
fn zwei_versetzte_ueberlappende_loecher_in_einem_koerper() {
// KERNFALL: EINE Wand (Laenge 4, Hoehe 2.5) mit ZWEI Loechern, deren
// Achsen-Intervalle sich UEBERLAPPEN, aber auf UNTERSCHIEDLICHER Hoehe
// liegen — Fenster A u=[1.0,2.0] z=[0.9,2.1], Fenster B u=[1.5,2.5]
// z=[0.3,0.7]. Das kann die Segment-Zerlegung nicht abbilden; als Loecher
// in EINEM Koerper muss es sauber zerfallen (keine Geometrie in den
// Loechern, Aussenmasse erhalten, beide Laibungen vollstaendig).
let mut w = wall_x(4.0, 0.2, 2.5);
w.holes = vec![
Hole { from: 1.0, to: 2.0, z_bottom: 0.9, z_top: 2.1 },
Hole { from: 1.5, to: 2.5, z_bottom: 0.3, z_top: 0.7 },
];
let mesh = build_walls_mesh(&[w]);
// Mehr als ein Vollkoerper (Loecher fuegen Teilrechtecke + Laibungen hinzu).
assert!(mesh.vertex_count() > VERTS_PER_BOX);
// Index-Integritaet.
let max_idx = *mesh.indices.iter().max().unwrap();
assert!((max_idx as usize) < mesh.vertex_count());
// Aussenmasse unveraendert (die soliden Teilrechtecke decken den Rand ab).
let (min, max) = mesh.bounds();
assert!((min[0]).abs() < 1e-5 && (max[0] - 4.0).abs() < 1e-5, "u 0..4");
assert!((min[1]).abs() < 1e-5 && (max[1] - 2.5).abs() < 1e-5, "z 0..2.5");
assert!((min[2] + 0.1).abs() < 1e-5 && (max[2] - 0.1).abs() < 1e-5, "Dicke +/-0.1");
// KEIN Vertex liegt (mit Sicherheitsabstand) im Inneren eines der Loecher.
let holes = [(1.0f32, 2.0f32, 0.9f32, 2.1f32), (1.5, 2.5, 0.3, 0.7)];
let m = 0.05;
let n = mesh.vertex_count();
for i in 0..n {
let (p, _) = vert(&mesh, i);
for (a0, a1, zb, zt) in holes {
let inside =
p[0] > a0 + m && p[0] < a1 - m && p[1] > zb + m && p[1] < zt - m;
assert!(!inside, "Vertex {i} bei {p:?} liegt im Loch [{a0},{a1}]x[{zb},{zt}]");
}
}
// Beide Loecher besitzen ihre vier inneren Laibungen: fuer jede Loch-Kante
// muss mindestens ein Vertex GENAU auf der Kante (in u bzw. z) UND im
// jeweils anderen Loch-Intervall liegen (die Laibungsflaeche).
let has_vert = |pred: &dyn Fn([f32; 3]) -> bool| (0..n).any(|i| pred(vert(&mesh, i).0));
for (a0, a1, zb, zt) in holes {
// linke/rechte Laibung: Vertex bei u==a0 bzw. u==a1, z im Loch.
assert!(
has_vert(&|p| (p[0] - a0).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
"linke Laibung fehlt fuer Loch [{a0},{a1}]"
);
assert!(
has_vert(&|p| (p[0] - a1).abs() < 1e-4 && p[1] > zb - 1e-4 && p[1] < zt + 1e-4),
"rechte Laibung fehlt fuer Loch [{a0},{a1}]"
);
// untere/obere Laibung: Vertex bei z==zb bzw. z==zt, u im Loch.
assert!(
has_vert(&|p| (p[1] - zb).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
"untere Laibung fehlt fuer Loch [{a0},{a1}]"
);
assert!(
has_vert(&|p| (p[1] - zt).abs() < 1e-4 && p[0] > a0 - 1e-4 && p[0] < a1 + 1e-4),
"obere Laibung fehlt fuer Loch [{a0},{a1}]"
);
}
}
#[test]
fn tuer_loch_bis_zum_boden_ohne_untere_laibung() {
// Tuer-Loch reicht bis zum Boden (z_bottom == base_elevation 0): die
// untere Kante liegt am Wandrand -> KEINE untere Laibung, stattdessen eine
// Luecke im Boden-Streifen. Unter der Kopfhoehe (z_top 2.1) darf im
// Tuerbereich keine Geometrie liegen.
let mut w = wall_x(4.0, 0.2, 2.5);
w.holes = vec![Hole { from: 1.0, to: 1.8, z_bottom: 0.0, z_top: 2.1 }];
let mesh = build_walls_mesh(&[w]);
let n = mesh.vertex_count();
let m = 0.05;
for i in 0..n {
let (p, _) = vert(&mesh, i);
let in_span = p[0] > 1.0 + m && p[0] < 1.8 - m;
if in_span {
assert!(
p[1] < m || p[1] > 2.1 - 1e-4,
"Vertex {i} bei {p:?} liegt unter der Tuer-Kopfhoehe im Loch"
);
}
}
}
// --- Ecken-Verschneidung (Gehrung) -----------------------------------------
#[test]
fn rechtwinklige_ecke_wird_gehrt_kein_ueberstand_kein_spalt() {
// Zwei Waende entlang +X bzw. +Y, die sich exakt im Ursprung treffen
// (L-Ecke, Dicke 0.4 -> Halbdicke 0.2). Ohne Gehrung ueberlappen sich die
// Baender im Eckbereich (jede Wand ragt um die Halbdicke der jeweils
// anderen ueber den Knoten hinaus); MIT Gehrung muessen alle Vertices IM
// Eckbereich exakt auf der 45-Grad-Gehrungslinie |x| == |z| liegen —
// weder darueber hinaus (Ueberstand) noch darunter (Spalt).
let a = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let b = WallInput {
start: [0.0, 0.0],
end: [0.0, 3.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
// Jeder Vertex nahe dem Knoten (innerhalb der doppelten Halbdicke) muss
// auf der Gehrungslinie liegen: world.x und world.z (== Grundriss-y)
// betragsgleich (45-Grad-Winkelhalbierende der beiden Achsen).
let near_corner_margin = 0.5; // deutlich groesser als die Halbdicke 0.2
let n = mesh.vertex_count();
let mut checked_any = false;
for i in 0..n {
let (p, _) = vert(&mesh, i);
if p[0].abs() < near_corner_margin && p[2].abs() < near_corner_margin {
checked_any = true;
assert!(
(p[0].abs() - p[2].abs()).abs() < 1e-4,
"Eck-Vertex {i} bei {p:?} liegt nicht auf der 45-Grad-Gehrungslinie"
);
}
}
assert!(checked_any, "Test hat keinen Eck-Vertex gefunden (Setup falsch)");
// Kein Vertex ragt ueber die Gehrungslinie hinaus: fuer Punkte mit x>0
// UND z>0 (im "inneren" Quadranten der Ecke) muss min(x,z) <= Halbdicke
// gelten -- sonst wuerde die Wand ueber die andere hinausragen.
for i in 0..n {
let (p, _) = vert(&mesh, i);
if p[0] > 1e-4 && p[2] > 1e-4 && p[0] < near_corner_margin && p[2] < near_corner_margin {
assert!(
p[0].min(p[2]) <= 0.2 + 1e-4,
"Vertex {i} bei {p:?} ragt ueber die Gehrung hinaus"
);
}
}
}
#[test]
fn freies_wandende_bleibt_rechtwinklig() {
// Eine einzelne Wand ohne Nachbarn: keine Gehrung, Enden bleiben exakt
// senkrecht zur Achse -> identisch zum Einzel-Wand-Regressionsfall.
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
let (min, max) = mesh.bounds();
assert!((min[0]).abs() < 1e-5 && (max[0] - 3.0).abs() < 1e-5);
}
#[test]
fn t_stoss_bleibt_rechtwinklig() {
// Drei Wandenden treffen sich im selben Knoten (T-Stoss) -> gemaess
// Moduldoc/`joins.ts` bewusst NICHT gehrt (mehrdeutig), alle drei bleiben
// rechtwinklig. Regressions-Absicherung: keine Panik, stabile Zaehlung.
let mk = |s: [f32; 2], e: [f32; 2]| WallInput {
start: s,
end: e,
thickness: 0.2,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]),
mk([0.0, 0.0], [0.0, 3.0]),
mk([0.0, 0.0], [-3.0, 0.0]),
];
let mesh = build_walls_mesh(&walls);
assert_eq!(mesh.vertex_count(), 3 * VERTS_PER_BOX);
}
#[test]
fn zwei_geschosse_mit_gleichem_grundriss_verschneiden_sich_nicht_ueber_geschosse() {
// EG (Hoehe 0..2.5) und OG (Basis 2.5, Hoehe 2.5..5.0) mit identischem
// L-Grundriss: die Hoehenbereiche BERUEHREN sich nur (bei 2.5), ueber-
// lappen aber nicht -> jedes Geschoss wird NUR gegen sein eigenes
// Pendant gehrt, nicht ueber die Geschossgrenze hinweg. Absicherung:
// beide Geschosse zusammen liefern exakt dieselbe Gehrung wie ein
// einzelnes Geschoss fuer sich (Vertex-Zaehlung + Eck-Vertices je
// Geschoss weiterhin auf der 45-Grad-Linie relativ zu SEINER Basis).
let mk = |s: [f32; 2], e: [f32; 2], base: f32| WallInput {
start: s,
end: e,
thickness: 0.4,
height: 2.5,
base_elevation: base,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0),
mk([0.0, 0.0], [0.0, 3.0], 0.0),
mk([0.0, 0.0], [3.0, 0.0], 2.5),
mk([0.0, 0.0], [0.0, 3.0], 2.5),
];
let mesh = build_walls_mesh(&walls);
assert_eq!(mesh.vertex_count(), 4 * VERTS_PER_BOX);
let n = mesh.vertex_count();
for i in 0..n {
let (p, _) = vert(&mesh, i);
if p[0].abs() < 0.5 && p[2].abs() < 0.5 {
assert!(
(p[0].abs() - p[2].abs()).abs() < 1e-4,
"Eck-Vertex {i} bei {p:?} (Geschoss-Basis {}) nicht auf der Gehrung",
p[1]
);
}
}
}
// --- Schichten (WallInput::layers) -----------------------------------------
#[test]
fn ohne_layers_entsteht_weiterhin_ein_vollkoerper() {
// Regressionsfall: `layers: None` extrudiert exakt wie zuvor.
let mesh = build_walls_mesh(&[wall_x(3.0, 0.2, 2.5)]);
assert_eq!(mesh.vertex_count(), VERTS_PER_BOX);
}
#[test]
fn layer_wand_liefert_ein_prisma_je_schicht() {
// Drei Schichten (Putz/Daemmung/Mauerwerk) -> drei eigenstaendige
// Quader, je VERTS_PER_BOX Vertices, ohne Oeffnungen.
let mut w = wall_x(3.0, 0.3, 2.5);
w.layers = Some(vec![
WallLayer { thickness: 0.02, color: [1.0, 1.0, 1.0] },
WallLayer { thickness: 0.18, color: [0.9, 0.85, 0.7] },
WallLayer { thickness: 0.10, color: [0.6, 0.3, 0.2] },
]);
let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 3 * VERTS_PER_BOX);
assert_eq!(mesh.indices.len(), 3 * INDICES_PER_BOX);
// Die Schichten decken zusammen exakt die Gesamtdicke (0.3, Z in
// -0.15..0.15) lueckenlos und ueberlappungsfrei ab.
let (min, max) = mesh.bounds();
assert!((min[2] + 0.15).abs() < 1e-4, "min z == -0.15");
assert!((max[2] - 0.15).abs() < 1e-4, "max z == 0.15");
// Jede Schicht-Farbe taucht in den Vertex-Farben auf (Layer wurden
// tatsaechlich mit ihrer eigenen Farbe extrudiert, nicht der Wandfarbe).
let n = mesh.vertex_count();
let has_color = |target: [f32; 3]| {
(0..n).any(|i| {
let b = i * FLOATS_PER_VERTEX;
let c = [mesh.verts[b + 6], mesh.verts[b + 7], mesh.verts[b + 8]];
(c[0] - target[0]).abs() < 1e-6
&& (c[1] - target[1]).abs() < 1e-6
&& (c[2] - target[2]).abs() < 1e-6
})
};
assert!(has_color([1.0, 1.0, 1.0]), "Putz-Farbe fehlt");
assert!(has_color([0.9, 0.85, 0.7]), "Daemmungs-Farbe fehlt");
assert!(has_color([0.6, 0.3, 0.2]), "Mauerwerk-Farbe fehlt");
}
#[test]
fn layer_ecke_wird_ebenfalls_gehrt() {
// Zwei mehrschichtige Waende an einer Ecke: JEDE Schicht nutzt dasselbe
// gehrte Fussabdruck-Band -> auch hier landen alle Eck-Vertices exakt
// auf der 45-Grad-Gehrungslinie (analog zum Vollkoerper-Test oben).
let layers = || {
Some(vec![
WallLayer { thickness: 0.15, color: [0.9, 0.85, 0.7] },
WallLayer { thickness: 0.15, color: [0.6, 0.3, 0.2] },
])
};
let a = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: layers(),
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let mut b = a.clone();
b.end = [0.0, 3.0];
let mesh = build_walls_mesh(&[a, b]);
let n = mesh.vertex_count();
assert_eq!(n, 4 * VERTS_PER_BOX, "2 Waende x 2 Schichten");
for i in 0..n {
let (p, _) = vert(&mesh, i);
if p[0].abs() < 0.4 && p[2].abs() < 0.4 {
assert!(
(p[0].abs() - p[2].abs()).abs() < 1e-4,
"Layer-Eck-Vertex {i} bei {p:?} nicht auf der Gehrung"
);
}
}
}
// --- Deckenplatten (extrudierte Polygone) ---------------------------------
use super::mesh::{build_model_mesh, extrude_slab, triangulate};
use super::types::SlabInput;
/// Quadratischer Decken-Umriss (CCW) mit Kantenlaenge `s`, Ecke im Ursprung.
fn square_slab(s: f32, z_bottom: f32, z_top: f32) -> SlabInput {
SlabInput {
outline: vec![[0.0, 0.0], [s, 0.0], [s, s], [0.0, s]],
z_bottom,
z_top,
color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}
}
#[test]
fn triangulate_quadrat_gibt_zwei_dreiecke() {
let tris = triangulate(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
assert_eq!(tris.len(), 6, "Quadrat -> 2 Dreiecke = 6 Indizes");
}
#[test]
fn triangulate_cw_umriss_ebenfalls_zwei_dreiecke() {
// CW-Umriss (negative Flaeche) muss genauso trianguliert werden.
let tris = triangulate(&[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
assert_eq!(tris.len(), 6);
}
#[test]
fn slab_deckel_und_boden_und_mantel() {
// Quadratische Platte: Deckel (2 Tri) + Boden (2 Tri) + 4 Mantel-Quads
// (je 2 Tri) = 4 + 8 = 12 Dreiecke = 36 Vertices (je Tri eigene Vertices).
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &square_slab(4.0, 2.4, 2.65));
assert_eq!(mesh.triangle_count(), 12, "2+2 Kappen + 8 Mantel");
assert_eq!(mesh.vertex_count(), 36);
// Z-Ausdehnung deckt die Deckendicke ab.
let (min, max) = mesh.bounds();
assert!((min[1] - 2.4).abs() < 1e-5, "UK 2.4");
assert!((max[1] - 2.65).abs() < 1e-5, "OK 2.65");
}
#[test]
fn slab_deckel_normale_zeigt_nach_oben_boden_nach_unten() {
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &square_slab(4.0, 0.0, 0.25));
// Fuer jedes Vertex mit y==0.25 (Deckel) muss die Normale +Y sein, fuer
// y==0.0 (Boden) Y. Mantel-Vertices haben n.y ≈ 0.
let n = mesh.vertex_count();
for i in 0..n {
let b = i * FLOATS_PER_VERTEX;
let py = mesh.verts[b + 1];
let ny = mesh.verts[b + 4];
if (py - 0.25).abs() < 1e-6 && (mesh.verts[b + 3]).abs() < 1e-6 && (mesh.verts[b + 5]).abs() < 1e-6 {
assert!((ny - 1.0).abs() < 1e-5, "Deckel-Normale +Y");
}
}
}
#[test]
fn slab_mantel_normalen_zeigen_nach_aussen() {
// Zentriertes Quadrat um den Ursprung: jede Mantel-Normale muss vom
// Zentrum weg zeigen.
let slab = SlabInput {
outline: vec![[-2.0, -2.0], [2.0, -2.0], [2.0, 2.0], [-2.0, 2.0]],
z_bottom: 0.0,
z_top: 0.3,
color: [0.8, 0.8, 0.8],
hatch: None,
cut: None,
};
let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &slab);
let center = [0.0f32, 0.15, 0.0];
let n = mesh.vertex_count();
for i in 0..n {
let b = i * FLOATS_PER_VERTEX;
let p = [mesh.verts[b], mesh.verts[b + 1], mesh.verts[b + 2]];
let nor = [mesh.verts[b + 3], mesh.verts[b + 4], mesh.verts[b + 5]];
let out = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
let d = out[0] * nor[0] + out[1] * nor[1] + out[2] * nor[2];
assert!(d >= -1e-4, "Vertex {i}: Normale zeigt nach innen (dot={d})");
}
}
#[test]
fn build_model_mesh_haengt_slabs_an_waende() {
let wall = build_walls_mesh(&[wall_x(4.0, 0.2, 2.6)]);
let model = build_model_mesh(&[wall_x(4.0, 0.2, 2.6)], &[square_slab(4.0, 2.6, 2.85)]);
// Modell = Wand-Vertices + Slab-Vertices.
assert_eq!(model.vertex_count(), wall.vertex_count() + 36);
assert!(model.indices.len() > wall.indices.len());
}
#[test]
fn entarteter_slab_erzeugt_nichts() {
let mut mesh = Mesh::default();
// <3 Ecken.
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
assert_eq!(mesh.vertex_count(), 0);
// Nullhoehe.
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
assert_eq!(mesh.vertex_count(), 0);
}
// --- Rohe Kontext-Meshes (Terrain / importierte Volumen) ------------------
use super::mesh::{append_context_mesh, build_scene_mesh};
use super::types::{MeshInput, MeshKind};
/// Ein flaches Dreieck auf Modell-Hoehe `z` (Modell x,y,z=Hoehe).
fn flat_context_tri(z: f32, kind: MeshKind, color: Option<[f32; 3]>) -> MeshInput {
MeshInput {
positions: vec![0.0, 0.0, z, 1.0, 0.0, z, 0.0, 1.0, z],
indices: vec![0, 1, 2],
kind,
color,
material_index: None,
vertex_colors: Vec::new(),
}
}
#[test]
fn context_mesh_ist_doppelseitig_und_mappt_hoehe_nach_y() {
let mut mesh = Mesh::default();
append_context_mesh(&mut mesh, &flat_context_tri(5.0, MeshKind::Terrain, None));
// Ein Quell-Dreieck -> zwei (Vorder-/Rueckseite) = 6 Vertices.
assert_eq!(mesh.triangle_count(), 2);
assert_eq!(mesh.vertex_count(), 6);
// model (x,y,z) -> world (x, z, y): die Hoehe z=5 liegt auf world.y=5.
let (min, max) = mesh.bounds();
assert!((min[1] - 5.0).abs() < 1e-5 && (max[1] - 5.0).abs() < 1e-5, "flach auf y=5");
// Doppelseitig: eine Normale +Y, eine Y.
let mut saw_up = false;
let mut saw_down = false;
for i in 0..mesh.vertex_count() {
let ny = mesh.verts[i * FLOATS_PER_VERTEX + 4];
if ny > 0.5 {
saw_up = true;
}
if ny < -0.5 {
saw_down = true;
}
}
assert!(saw_up && saw_down, "Vorder- UND Rueckseite (±Y)");
}
#[test]
fn context_mesh_default_farbe_je_kind() {
// Ohne explizite Farbe greift die kind-Default-Farbe (three.js mats.*).
let mut mesh = Mesh::default();
append_context_mesh(&mut mesh, &flat_context_tri(0.0, MeshKind::Terrain, None));
let c = [mesh.verts[6], mesh.verts[7], mesh.verts[8]];
assert_eq!(c, MeshKind::Terrain.default_color());
// Explizite Farbe schlaegt den Default.
let mut mesh2 = Mesh::default();
append_context_mesh(&mut mesh2, &flat_context_tri(0.0, MeshKind::Imported, Some([0.1, 0.2, 0.3])));
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
}
#[test]
fn context_mesh_pro_vertex_farbe_wird_uebernommen() {
// Luftbild-Drape: jeder Vertex traegt eine eigene Farbe. Wird 1:1 an die
// Ausgabe-Vertices durchgereicht (statt der Einzelfarbe).
let mut mesh = Mesh::default();
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Terrain,
color: None,
material_index: None,
vertex_colors: vec![
1.0, 0.0, 0.0, // Vertex 0 rot
0.0, 1.0, 0.0, // Vertex 1 gruen
0.0, 0.0, 1.0, // Vertex 2 blau
],
},
);
// Vorderseite = erste 3 Vertices (Winding a,b,c): Farben 0,1,2.
assert_eq!([mesh.verts[6], mesh.verts[7], mesh.verts[8]], [1.0, 0.0, 0.0]);
assert_eq!([mesh.verts[15], mesh.verts[16], mesh.verts[17]], [0.0, 1.0, 0.0]);
assert_eq!([mesh.verts[24], mesh.verts[25], mesh.verts[26]], [0.0, 0.0, 1.0]);
// Zu kurzes vertex_colors-Array -> Einzelfarbe (kein Panic, kein Teilbezug).
let mut mesh2 = Mesh::default();
append_context_mesh(
&mut mesh2,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Terrain,
color: None,
material_index: None,
vertex_colors: vec![1.0, 0.0, 0.0], // nur 1 Vertex -> ignoriert
},
);
assert_eq!(
[mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]],
MeshKind::Terrain.default_color()
);
}
#[test]
fn kontext_mesh_materialindex_wandert_in_die_textured_layer() {
// Daecher (und potenziell andere Kontext-Meshes) koennen jetzt einen
// Material-Textur-Index tragen (`MeshInput::material_index`) — vorher
// war er fuer ALLE Kontext-Meshes hart auf -1 (Schachbrett) gesetzt, der
// Stil „Textured" konnte also nie eine echte Dachziegel-Textur zeigen.
let m = MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Extrusion,
color: None,
material_index: Some(2),
vertex_colors: Vec::new(),
};
let tex = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m));
assert!(!tex.layers.is_empty());
assert!(tex.layers.iter().all(|&l| l == 2.0), "alle Vertices Ebene 2");
// Ohne material_index bleibt der Fallback (-1 -> Schachbrett).
let m2 = MeshInput { material_index: None, ..m };
let tex2 = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m2));
assert!(tex2.layers.iter().all(|&l| l == -1.0));
}
#[test]
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
let mut mesh = Mesh::default();
// Index 9 out-of-range (nur 3 Vertices) -> Dreieck verworfen, kein Panic.
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
indices: vec![0, 1, 9],
kind: MeshKind::Imported,
color: None,
material_index: None,
vertex_colors: Vec::new(),
},
);
assert_eq!(mesh.vertex_count(), 0);
// Kollineares (entartetes) Dreieck -> keine Normale, verworfen.
append_context_mesh(
&mut mesh,
&MeshInput {
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.0, 0.0],
indices: vec![0, 1, 2],
kind: MeshKind::Imported,
color: None,
material_index: None,
vertex_colors: Vec::new(),
},
);
assert_eq!(mesh.vertex_count(), 0);
}
#[test]
fn build_scene_mesh_haengt_kontext_meshes_hinten_an() {
let model = build_model_mesh(&[wall_x(4.0, 0.2, 2.6)], &[square_slab(4.0, 2.6, 2.85)]);
let scene = build_scene_mesh(
&[wall_x(4.0, 0.2, 2.6)],
&[square_slab(4.0, 2.6, 2.85)],
&[flat_context_tri(0.0, MeshKind::Terrain, None)],
);
// Szene = Modell (Wand+Slab) + 6 Kontext-Vertices (doppelseitiges Dreieck).
assert_eq!(scene.vertex_count(), model.vertex_count() + 6);
}
// --- Kamera / Matrizen ----------------------------------------------------
#[test]
fn look_at_setzt_kamera_ins_zentrum() {
use super::math::{transform_point, view_matrix};
use super::types::Camera;
let cam = Camera {
eye: [0.0, 0.0, 5.0],
target: [0.0, 0.0, 0.0],
up: [0.0, 1.0, 0.0],
..Camera::default()
};
let v = view_matrix(&cam);
// Das Blickziel (Ursprung) liegt im View-Raum vor der Kamera bei z=-5
// (Kamera schaut entlang -Z).
let p = transform_point(&v, [0.0, 0.0, 0.0]);
assert!((p[0]).abs() < 1e-5 && (p[1]).abs() < 1e-5);
assert!((p[2] + 5.0).abs() < 1e-5, "Ziel bei view-z = -5");
}
#[test]
fn perspektive_klemmt_z_in_null_bis_eins() {
use super::math::{perspective, transform_point};
let m = perspective(60.0_f32.to_radians(), 1.0, 1.0, 100.0);
// Punkt auf der Nah-Ebene (view-z = -near) -> clip-z/w == 0.
let near = transform_point(&m, [0.0, 0.0, -1.0]);
assert!((near[2] / near[3]).abs() < 1e-4, "Nah-Ebene -> z=0");
// Punkt auf der Fern-Ebene (view-z = -far) -> clip-z/w == 1.
let far = transform_point(&m, [0.0, 0.0, -100.0]);
assert!(((far[2] / far[3]) - 1.0).abs() < 1e-4, "Fern-Ebene -> z=1");
}
#[test]
fn orbit_eye_haelt_abstand_ein() {
use super::math::orbit_eye;
let target = [1.0, 0.0, 2.0];
let e = orbit_eye(target, 0.7, 0.4, 10.0);
let d = ((e[0] - target[0]).powi(2)
+ (e[1] - target[1]).powi(2)
+ (e[2] - target[2]).powi(2))
.sqrt();
assert!((d - 10.0).abs() < 1e-4, "Abstand == dist");
}
#[test]
fn presets_setzen_projektionsart() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
assert_eq!(
preset_camera(CameraPreset::Top, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Front, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Side, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Iso, t, 10.0).projection,
Projection::Orthographic
);
assert_eq!(
preset_camera(CameraPreset::Persp, t, 10.0).projection,
Projection::Perspective
);
// Top blickt von oben herab: eye.y > target.y.
let top = preset_camera(CameraPreset::Top, t, 10.0);
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
}
#[test]
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let front = preset_camera(CameraPreset::Front, t, 10.0);
let back = preset_camera(CameraPreset::Back, t, 10.0);
let side = preset_camera(CameraPreset::Side, t, 10.0);
let left = preset_camera(CameraPreset::Left, t, 10.0);
assert_eq!(back.projection, Projection::Orthographic);
assert_eq!(left.projection, Projection::Orthographic);
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
}
#[test]
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let dist = 10.0;
let iso = preset_camera(CameraPreset::Iso, t, dist);
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
for cam in [&iso, &fl, &br, &bl] {
assert_eq!(cam.projection, Projection::Orthographic);
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
// (Modell erscheint in jedem Oktanten gleich gross).
let d = ((cam.eye[0] - t[0]).powi(2)
+ (cam.eye[1] - t[1]).powi(2)
+ (cam.eye[2] - t[2]).powi(2))
.sqrt();
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
}
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
}
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
/// sonst nicht mitgebaut wird (Muster: render2d).
#[cfg(feature = "render")]
#[test]
fn wgsl_quelle_ist_valide() {
use naga::valid::{Capabilities, ValidationFlags, Validator};
let src = super::shaders::MESH_WGSL;
let module = naga::front::wgsl::parse_str(src)
.unwrap_or_else(|e| panic!("mesh: WGSL-Parse-Fehler: {e:?}"));
let mut validator = Validator::new(ValidationFlags::all(), Capabilities::all());
validator
.validate(&module)
.unwrap_or_else(|e| panic!("mesh: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Bodengitter-Shader ebenso headless validieren.
let grid_src = super::shaders::GRID_WGSL;
let grid_module = naga::front::wgsl::parse_str(grid_src)
.unwrap_or_else(|e| panic!("grid: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&grid_module)
.unwrap_or_else(|e| panic!("grid: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Schnittflaechen-Kappen-Shader (Live-Schnitt-Schraffur) ebenso.
let cap_src = super::shaders::CAP_WGSL;
let cap_module = naga::front::wgsl::parse_str(cap_src)
.unwrap_or_else(|e| panic!("cap: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&cap_module)
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Texturierter Wand-Shader (RenderStyle::Textured, Bild-Textur in group 1)
// ebenso headless validieren — faengt Textur-/Sampler-Bindungsfehler ab.
let tex_src = super::shaders::MESH_TEXTURED_WGSL;
let tex_module = naga::front::wgsl::parse_str(tex_src)
.unwrap_or_else(|e| panic!("textured: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&tex_module)
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
// Luftbild-Drape-Shader (Terrain-Overlay, Bild-Textur + Bbox-Uniform in
// group 1) ebenso headless validieren.
let aerial_src = super::shaders::MESH_AERIAL_WGSL;
let aerial_module = naga::front::wgsl::parse_str(aerial_src)
.unwrap_or_else(|e| panic!("aerial: WGSL-Parse-Fehler: {e:?}"));
Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&aerial_module)
.unwrap_or_else(|e| panic!("aerial: WGSL-Validierung fehlgeschlagen: {e:?}"));
}
}