// 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_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
    };
    use super::types::{
        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());
    }

    #[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,
        }
    }

    #[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_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,
            },
        );
        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,
            },
        );
        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:?}"));
    }
}
