//! Schnittflaechen-Geometrie fuer den Live-3D-Schnitt: wandelt die analytischen
//! Cut-Polygone aus `section::cut_section` (achsparallele (u,v)-Rechtecke, siehe
//! `section.rs`-Moduldoc) in kamerataugliche Dreiecks-Geometrie fuer die Cap-
//! Pipeline (`gpu::cap_pipeline`, Schraffur in `shaders::CAP_WGSL`).
//!
//! ## Ruecktransformation (u,v) -> Welt
//!
//! `SectionPlane::project(p) = [dot(p - point, u_axis), p.y]` (nur vertikale
//! Ebenen). Die Inverse fuer einen Punkt AUF der Ebene ist damit
//!
//! ```text
//! world = point + u * u_axis   (horizontale Lage),   world.y = v
//! ```
//!
//! `u_axis` ist horizontal (y = 0), also traegt `u * u_axis` nichts zur Hoehe
//! bei — `world.y = v` gilt exakt, und der Punkt liegt auf der Ebene, weil
//! `u_axis` und die Welt-Hoehenachse [0,1,0] beide senkrecht auf der (horizon-
//! talen) Normale stehen. Wir nutzen `SectionPlane::u_axis()` (pub(crate)),
//! damit die Achsdefinition (inkl. Entartungs-Fallback) mit `section.rs`
//! identisch bleibt.
//!
//! ## Ausgabeformat
//!
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.

use crate::section::{cut_section, CutPolygon, SectionPlane};
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
use crate::types::{SlabInput, WallInput};

/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
/// Breite (mm-Papier -> Welt) je Schraffur.
pub const CAP_FLOATS_PER_VERTEX: usize = 9;

/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;

/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;

/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];

/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
/// ein Band keinen eigenen `line_weight` traegt.
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;

/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
///
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
/// Schlussbericht/Moduldoc).
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;

/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
const CUT_LINE_LIFT: f32 = 0.002;

/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
/// aufgeloeste Bauteil-Schraffur).
const FALLBACK_PATTERN: f32 = 2.0;

/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
/// Welt zurueck. Rueckgabe = interleaved Vertexpuffer (`CAP_FLOATS_PER_VERTEX`),
/// leer, wenn die Ebene das Modell nicht schneidet.
///
/// WINDING: bewusst nicht garantiert kamerakonsistent — die Cap-Pipeline
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
/// nicht von der Normale ab.
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
    let out = cut_section(plane, walls, slabs);
    subtract_dominant_bands(out.cut_polygons)
}

pub fn build_cut_caps(
    plane: &SectionPlane,
    walls: &[WallInput],
    slabs: &[SlabInput],
) -> Vec<f32> {
    let polys = cut_cap_polys(plane, walls, slabs);
    let point = plane.point;
    let u_axis = plane.u_axis();
    // (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
    let to_world = |uv: [f32; 2]| -> [f32; 3] {
        [
            point[0] + uv[0] * u_axis[0],
            uv[1],
            point[2] + uv[0] * u_axis[2],
        ]
    };

    let mut verts: Vec<f32> = Vec::new();
    for poly in &polys {
        let pts = &poly.pts;
        if pts.len() < 3 {
            continue;
        }
        // Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
        // Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
        // cos/sin rechnen kann.
        let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
            Some(h) => (
                h.pattern as f32,
                h.angle.to_radians(),
                h.scale.max(0.05),
                h.line_weight.max(0.0),
            ),
            None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
        };
        let mut push_vertex = |uv: [f32; 2]| {
            let w = to_world(uv);
            verts.extend_from_slice(&[
                w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
            ]);
        };
        // Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
        for i in 1..pts.len() - 1 {
            push_vertex(pts[0]);
            push_vertex(pts[i]);
            push_vertex(pts[i + 1]);
        }
    }
    verts
}

/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
///
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
pub fn build_cut_cap_lines(
    plane: &SectionPlane,
    walls: &[WallInput],
    slabs: &[SlabInput],
) -> Vec<f32> {
    let polys = cut_cap_polys(plane, walls, slabs);
    let point = plane.point;
    let u_axis = plane.u_axis();
    let normal = plane.normal;
    // Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
    let lift = [
        -normal[0] * CUT_LINE_LIFT,
        -normal[1] * CUT_LINE_LIFT,
        -normal[2] * CUT_LINE_LIFT,
    ];
    let to_world = |uv: [f32; 2]| -> [f32; 3] {
        [
            point[0] + uv[0] * u_axis[0] + lift[0],
            uv[1] + lift[1],
            point[2] + uv[0] * u_axis[2] + lift[2],
        ]
    };

    let mut verts: Vec<f32> = Vec::new();
    for poly in &polys {
        let Some(r) = rect_of_poly(poly) else {
            continue;
        };
        // Stil aus den Band-Metadaten (oder Default).
        let (color, weight_mm) = match &poly.cut {
            Some(meta) => (
                meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
                meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
            ),
            None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
        };
        let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);

        // Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
        let corners = [
            [r.u_min, r.v_min],
            [r.u_max, r.v_min],
            [r.u_max, r.v_max],
            [r.u_min, r.v_max],
        ];
        for k in 0..4 {
            let a = to_world(corners[k]);
            let b = to_world(corners[(k + 1) % 4]);
            push_line_ribbon(&mut verts, a, b, normal, half_w, color);
        }
    }
    verts
}

/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
fn push_line_ribbon(
    out: &mut Vec<f32>,
    a: [f32; 3],
    b: [f32; 3],
    normal: [f32; 3],
    half_w: f32,
    color: [f32; 3],
) {
    let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
    let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
    if len < 1e-9 {
        return;
    }
    let d = [dir[0] / len, dir[1] / len, dir[2] / len];
    // In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
    // steht senkrecht auf der Kante.
    let mut perp = [
        normal[1] * d[2] - normal[2] * d[1],
        normal[2] * d[0] - normal[0] * d[2],
        normal[0] * d[1] - normal[1] * d[0],
    ];
    let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
    if plen < 1e-9 {
        return;
    }
    perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
    let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
    let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
    let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
    let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
    let mut push = |p: [f32; 3]| {
        out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
    };
    // Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
    push(p0);
    push(p1);
    push(p2);
    push(p0);
    push(p2);
    push(p3);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::Hatch;

    /// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
    /// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
    /// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
    #[test]
    fn hatch_muster_id_landet_in_den_cap_vertices() {
        let wall = 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: None,
            holes: vec![],
            material_index: None,
            hatch: Some(Hatch {
                pattern: 3, // crosshatch
                angle: 90.0,
                scale: 2.0,
                line_weight: 0.5,
            }),
            cut: None,
        };
        let plane = SectionPlane::looking_plus_x(1.5);
        let verts = build_cut_caps(&plane, &[wall], &[]);

        assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
        assert_eq!(
            verts.len() % CAP_FLOATS_PER_VERTEX,
            0,
            "Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
        );
        let n = verts.len() / CAP_FLOATS_PER_VERTEX;
        for i in 0..n {
            let base = i * CAP_FLOATS_PER_VERTEX;
            assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
            assert!(
                (verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
                "angle in Radiant"
            );
            assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
            assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
        }
    }

    /// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
    /// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
    #[test]
    fn ohne_hatch_fallback_auf_diagonale() {
        let wall = 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: None,
            holes: vec![],
            material_index: None,
            hatch: None,
            cut: None,
        };
        let plane = SectionPlane::looking_plus_x(1.5);
        let verts = build_cut_caps(&plane, &[wall], &[]);
        assert!(!verts.is_empty());
        assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
        assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
        assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
        assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
    }

    use crate::types::CutBandMeta;

    /// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
    /// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
    /// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
    /// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
    /// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
    #[test]
    fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
        // Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
        let wall = 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: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
            cut: Some(CutBandMeta {
                join_priority: Some(50),
                component_id: Some("brick".into()),
                line_color: None,
                line_weight: None,
            }),
        };
        // Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
        // z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
        let slab = SlabInput {
            outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
            z_bottom: 2.0,
            z_top: 2.4,
            color: [0.8, 0.8, 0.8],
            hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
            cut: Some(CutBandMeta {
                join_priority: Some(100),
                component_id: Some("concrete".into()),
                line_color: None,
                line_weight: None,
            }),
        };
        let plane = SectionPlane::looking_plus_x(1.5);
        let verts = build_cut_caps(&plane, &[wall], &[slab]);
        assert!(!verts.is_empty());

        // Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
        // (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
        // Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
        let n = verts.len() / CAP_FLOATS_PER_VERTEX;
        let mut wall_verts = 0;
        let mut concrete_verts = 0;
        for i in 0..n {
            let base = i * CAP_FLOATS_PER_VERTEX;
            let y = verts[base + 1];
            let pattern = verts[base + 5];
            if (pattern - 2.0).abs() < 0.5 {
                wall_verts += 1;
                assert!(
                    y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
                    "Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
                );
            } else if (pattern - 1.0).abs() < 0.5 {
                concrete_verts += 1;
            }
        }
        assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
        assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
    }

    /// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
    /// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
    /// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
    /// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
    /// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
    /// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
    /// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
    #[test]
    fn wand_schneidet_schwaechere_decken_schichten() {
        // Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
        // volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
        let wall = 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: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
            cut: Some(CutBandMeta {
                join_priority: Some(50),
                component_id: Some("brick".into()),
                line_color: None,
                line_weight: None,
            }),
        };
        // Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
        // Wand in u ab (Umriss ueber y in [-1,1]).
        let screed = SlabInput {
            outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
            z_bottom: 1.0,
            z_top: 1.06,
            color: [0.8, 0.8, 0.8],
            hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
            cut: Some(CutBandMeta {
                join_priority: Some(30),
                component_id: Some("screed".into()),
                line_color: None,
                line_weight: None,
            }),
        };
        // Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
        let insulation = SlabInput {
            outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
            z_bottom: 0.96,
            z_top: 1.0,
            color: [0.8, 0.8, 0.8],
            hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
            cut: Some(CutBandMeta {
                join_priority: Some(20),
                component_id: Some("insulation".into()),
                line_color: None,
                line_weight: None,
            }),
        };
        let plane = SectionPlane::looking_plus_x(1.5);
        let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
        assert!(!verts.is_empty());

        // Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
        // Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
        // der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
        let n = verts.len() / CAP_FLOATS_PER_VERTEX;
        let mut weak_verts = 0;
        for i in 0..n {
            let base = i * CAP_FLOATS_PER_VERTEX;
            let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
            let pattern = verts[base + 5];
            if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
                weak_verts += 1;
                assert!(
                    u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
                    "Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
                     [-0.2,0.2] (muesste weggeschnitten sein)"
                );
            }
        }
        assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
    }

    /// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
    /// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
    /// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
    /// die Ribbon-Breite messbar).
    #[test]
    fn build_cut_cap_lines_kantenzahl_und_stil() {
        let color = [0.2, 0.4, 0.9];
        let wall = 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: Some(CutBandMeta {
                join_priority: Some(50),
                component_id: Some("brick".into()),
                line_color: Some(color),
                line_weight: Some(2.0),
            }),
        };
        let plane = SectionPlane::looking_plus_x(1.5);
        let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
        assert!(!verts.is_empty());
        assert_eq!(
            verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
            0,
            "Vielfaches des Cut-Linien-Strides"
        );
        // EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
        assert_eq!(
            verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
            24,
            "4 Kanten * 6 Vertices je Ribbon"
        );
        // Farbe je Vertex aus dem Stil (Index 3/4/5).
        let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
        for i in 0..n {
            let base = i * CUT_LINE_FLOATS_PER_VERTEX;
            assert!((verts[base + 3] - color[0]).abs() < 1e-6);
            assert!((verts[base + 4] - color[1]).abs() < 1e-6);
            assert!((verts[base + 5] - color[2]).abs() < 1e-6);
        }

        // Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
        // Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
        // world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
        // gesamten Geometrie) im Vergleich duenn vs. dick.
        let bbox_y = |vs: &[f32]| -> f32 {
            let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
            let mut lo = f32::INFINITY;
            let mut hi = f32::NEG_INFINITY;
            for i in 0..n {
                let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
                lo = lo.min(y);
                hi = hi.max(y);
            }
            hi - lo
        };
        let mut thin = wall.clone();
        thin.cut = Some(CutBandMeta {
            join_priority: Some(50),
            component_id: Some("brick".into()),
            line_color: Some(color),
            line_weight: Some(0.2),
        });
        let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
        assert!(
            bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
            "dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
        );
    }
}
