// Tessellierung: wandelt Szenen-Primitive in GPU-fertige Vertex-/Index-Puffer.
//  - Polygone -> echtes Ear-Clipping (konkav-faehig), Bildschirm-Raum-Dreiecke
//  - Linien   -> Quad mit Normale + Seiten-Flag (Breite spaeter im Shader)
//
// 1:1-Port von `src/plan/glPlan/glPlanCompile.ts`. Koordinaten: alles wird in
// BILDSCHIRM-Raum abgelegt (wie `to_screen`):
//   sx = mx * PX_PER_M,  sy = -my * PX_PER_M.
// Damit ist die Projektion eine reine viewBox-Orthografie (siehe `ortho`).
//
// WARUM Ear-Clipping selbst portiert (statt earcutr/lyon):
//  - Die Plan-Polygone haben wenige Ecken; O(n^2)-Ear-Clipping ist reichlich
//    schnell und exakt der bereits verifizierte Web-Algorithmus (gleiche Tests).
//  - Keine externe Abhaengigkeit -> die Tessellierungs-Bibliothek bleibt
//    serde-only und headless testbar (entscheidend fuer die saubere Trennung
//    vom GPU-Teil). earcutr/lyon zoegen zusaetzliche Crates in genau die
//    Schicht, die ohne Display gruen bleiben muss.
//  - Ergebnis ist byte-fuer-byte-vergleichbar mit dem WebGL-Referenzpfad.

use crate::types::{Arc, FillPolygon, Line, Point, Rgba, Scene};

/// viewBox-Einheiten je Meter (identisch zu PlanView/toScreen im Web-Pfad).
pub const PX_PER_M: f32 = 90.0;

/// Modell-Meter -> Bildschirm-Raum (identisch zu `toScreen`).
#[inline]
pub fn to_screen(p: Point) -> Point {
    [p[0] * PX_PER_M, -p[1] * PX_PER_M]
}

/// Hoechstzahl Dash-Muster-Eintraege je Bogen (An/Aus-Laengen), die der analytische
/// Bogen-Shader modulo rechnet. Deckt die real vorkommenden Muster (max. EIN An/Aus-
/// Paar, z.B. Tuerschwenk [0.06,0.04]) mit Reserve; laengere Muster werden gekappt.
pub const MAX_ARC_DASH: usize = 4;

/// GPU-fertige Beschreibung EINES analytisch (im Fragment-Shader) gezeichneten
/// Bogens — bereits in BILDSCHIRM-Raum-Parametern, damit ihn die GPU pro Frame
/// mathematisch exakt rund rendert (SDF), ohne Segment-Tessellierung. Alle Felder
/// sind zoom-INVARIANT (haengen nicht von der aktuellen Skala ab): `center`/
/// `r_screen` in Bildschirm-Einheiten, Winkel im Bildschirm-Raum, `dash` in
/// Modell-Metern. Die Strichbreite (`width_mm`) mappt der Shader pro Frame mit
/// derselben Formel wie die Linien (`ortho::mm_to_device_px`) auf Geraete-px.
#[derive(Debug, Clone)]
pub struct ArcInstanceData {
    /// Mittelpunkt in Bildschirm-Raum-Einheiten (`to_screen(center)`).
    pub center: Point,
    /// Radius in Bildschirm-Raum-Einheiten (`r * PX_PER_M`).
    pub r_screen: f32,
    /// Radius in MODELL-Metern (fuer die Dash-Bogenlaenge).
    pub r_model: f32,
    /// Start-Winkel im Bildschirm-Raum (atan2 relativ `center`).
    pub a0: f32,
    /// Vorzeichenbehafteter Sweep (kuerzerer Bogen, |sweep| <= pi).
    pub sweep: f32,
    /// Strichfarbe (RGBA 0..1).
    pub color: Rgba,
    /// Strichbreite in echten Papier-Millimetern.
    pub width_mm: f32,
    /// Dash-Zyklus in MODELL-Metern (An/Aus-Laengen), ggf. verdoppelt bei ungerader
    /// Laenge (Standard-Dash-Semantik, wie `split_dash`). Nur `dash_count` gueltig.
    pub dash: [f32; MAX_ARC_DASH],
    /// Anzahl gueltiger `dash`-Eintraege; 0 = durchgezogen.
    pub dash_count: u32,
    /// Summe der gueltigen `dash`-Eintraege (Zykluslaenge, Modell-Meter).
    pub dash_total: f32,
}

/// Bildschirm-Raum-Parameter eines Bogens: Mittelpunkt + Radius (Bildschirm-
/// Einheiten) sowie Start- und Sweep-Winkel IM BILDSCHIRM-RAUM. Der Sweep ist der
/// KUERZERE Bogen (|sweep| <= pi) — exakt die Winkel-Normalisierung der frueheren
/// `tessellate_arc` (dort im Modell-Raum). Die Bildschirm-Y-Spiegelung (`to_screen`)
/// negiert lediglich beide Winkel; der BETRAG von `sweep` bleibt identisch, sodass
/// weiterhin derselbe (kuerzere) Bogen entsteht.
pub fn arc_screen_params(center: Point, from: Point, to: Point, r: f32) -> (Point, f32, f32, f32) {
    let cs = to_screen(center);
    let fs = to_screen(from);
    let ts = to_screen(to);
    let a0 = (fs[1] - cs[1]).atan2(fs[0] - cs[0]);
    let a1 = (ts[1] - cs[1]).atan2(ts[0] - cs[0]);
    let mut delta = a1 - a0;
    while delta > std::f32::consts::PI {
        delta -= 2.0 * std::f32::consts::PI;
    }
    while delta < -std::f32::consts::PI {
        delta += 2.0 * std::f32::consts::PI;
    }
    (cs, r * PX_PER_M, a0, delta)
}

/// Wandelt ein Papier-mm-Dash-Muster in den Modell-Meter-Zyklus, den der Bogen-
/// Shader modulo rechnet — IDENTISCHE Semantik zu `split_dash` (`DASH_MM_TO_M`-
/// Referenz, Verdopplung bei ungerader Musterlaenge). Auf `MAX_ARC_DASH` Eintraege
/// begrenzt; laengere Muster (kommen real nicht vor) werden gekappt. Rueckgabe:
/// (Zyklus, Anzahl, Gesamtlaenge); Anzahl 0 = durchgezogen.
pub fn prepare_arc_dash(dash: Option<&[f32]>) -> ([f32; MAX_ARC_DASH], u32, f32) {
    let mut out = [0.0f32; MAX_ARC_DASH];
    let Some(d) = dash else {
        return (out, 0, 0.0);
    };
    let pat: Vec<f32> = d
        .iter()
        .copied()
        .map(|v| v.max(0.0) * DASH_MM_TO_M)
        .filter(|v| *v > 0.0)
        .collect();
    if pat.is_empty() {
        return (out, 0, 0.0);
    }
    let cycle: Vec<f32> = if pat.len() % 2 == 0 {
        pat
    } else {
        [pat.as_slice(), pat.as_slice()].concat()
    };
    let n = cycle.len().min(MAX_ARC_DASH);
    let mut total = 0.0f32;
    for (i, v) in cycle.iter().take(n).enumerate() {
        out[i] = *v;
        total += *v;
    }
    (out, n as u32, total)
}

/// Faktor Papier-mm -> Modell-Meter fuer ALLGEMEINE Linien-/Bogen-Striche, bei
/// der Referenz-Skala `paper_scale_n = 100` des Renderers (siehe `gpu::Renderer`
/// und `toRenderScene.ts`s `MM_TO_M`): `N/1000`. Bewusst NICHT die Hatch-
/// spezifische `DASH_MM_TO_M`-Konstante aus `glPlanHatch.ts` (die ist an die
/// Musterkachel gekoppelt, nicht an den Papier-Massstab).
const DASH_MM_TO_M: f32 = 100.0 / 1000.0;

/// Portiert `applyDashRuns` (`src/plan/glPlan/glPlanHatch.ts`) auf Modell-Meter-
/// Punktzuege: zerlegt EINEN zusammenhaengenden Lauf entlang eines Strichmusters
/// (Papier-mm, `DASH_MM_TO_M`-Referenz) in seine durchgezogenen Teilstuecke. Die
/// Phase laeuft ENTLANG DES GESAMTEN LAUFS durch (ueber Stuetzpunkt-Ecken
/// hinweg) — wichtig fuer bereits verkettete Umriss-/Zeichnungszuege UND fuer
/// die neu adaptiv tessellierten Boegen. Ungerade Musterlaenge wird verdoppelt
/// (Standard-Dash-Semantik). Leeres/ungueltiges Muster oder <2 Punkte -> EIN
/// Stueck (unveraendert, durchgezogen).
fn split_dash(pts: &[Point], dash: &[f32]) -> Vec<Vec<Point>> {
    let pat: Vec<f32> = dash
        .iter()
        .copied()
        .map(|d| d.max(0.0) * DASH_MM_TO_M)
        .filter(|d| *d > 0.0)
        .collect();
    if pat.is_empty() || pts.len() < 2 {
        return vec![pts.to_vec()];
    }
    let cycle: Vec<f32> = if pat.len() % 2 == 0 {
        pat.clone()
    } else {
        [pat.as_slice(), pat.as_slice()].concat()
    };
    let total: f32 = cycle.iter().sum();
    if total <= 1e-9 {
        return vec![pts.to_vec()];
    }

    let mut out: Vec<Vec<Point>> = Vec::new();
    let mut idx = 0usize;
    let mut dash_left = cycle[0];
    let mut on = true;
    let mut piece: Option<Vec<Point>> = Some(vec![pts[0]]);

    for w in pts.windows(2) {
        let mut a = w[0];
        let b = w[1];
        let mut seg_len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
        if seg_len < 1e-12 {
            continue;
        }
        let ux = (b[0] - a[0]) / seg_len;
        let uy = (b[1] - a[1]) / seg_len;
        while seg_len > 1e-9 {
            let step = seg_len.min(dash_left);
            let mid = [a[0] + ux * step, a[1] + uy * step];
            if on {
                if piece.is_none() {
                    piece = Some(vec![a]);
                }
                piece.as_mut().unwrap().push(mid);
            }
            a = mid;
            seg_len -= step;
            dash_left -= step;
            if dash_left <= 1e-9 {
                if on {
                    if let Some(p) = piece.take() {
                        if p.len() >= 2 {
                            out.push(p);
                        }
                    }
                }
                on = !on;
                idx += 1;
                dash_left = cycle[idx % cycle.len()];
                if on {
                    piece = Some(vec![a]);
                }
            }
        }
    }
    if let Some(p) = piece.take() {
        if p.len() >= 2 {
            out.push(p);
        }
    }
    out
}

/// Zeichnet einen Linienzug entweder als EINEN gehrten Streifen (kein/leeres
/// Muster) oder — bei gesetztem `dash` — als seine durchgezogenen Teilstuecke
/// (`split_dash`), je einzeln offen gestrichen. Vereinfachung: ein gestrichelter
/// GESCHLOSSENER Umriss (`closed=true`) wird trotzdem als offene Teilstuecke
/// gezeichnet — der Ring-Schluss braucht bei einer Dash-Luecke ohnehin keine
/// durchgehende Gehrung.
fn stroke_dashed_or_solid(
    geo: &mut GpuGeometry,
    pts: &[Point],
    closed: bool,
    color: Rgba,
    stroke_mm: f32,
    width_screen: bool,
    dash: Option<&[f32]>,
    bounds: &mut Bounds,
) {
    match dash {
        Some(d) if !d.is_empty() => {
            for piece in split_dash(pts, d) {
                if piece.len() >= 2 {
                    geo.stroke_polyline(&piece, false, color, stroke_mm, width_screen, bounds);
                }
            }
        }
        _ => geo.stroke_polyline(pts, closed, color, stroke_mm, width_screen, bounds),
    }
}

/// Signierte Flaeche (Shoelace); >0 = CCW (Modell-Y nach oben).
fn signed_area(pts: &[Point]) -> f32 {
    let mut a = 0.0f32;
    let n = pts.len();
    let mut j = n - 1;
    for i in 0..n {
        a += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
        j = i;
    }
    a / 2.0
}

/// Kreuzprodukt (b-a) x (c-a).
#[inline]
fn cross(a: Point, b: Point, c: Point) -> f32 {
    (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
}

/// Einheits-Richtung von `from` nach `to` (Bildschirm-Raum); None bei Nulllaenge.
#[inline]
fn unit_dir(from: Point, to: Point) -> Option<Point> {
    let dx = to[0] - from[0];
    let dy = to[1] - from[1];
    let len = (dx * dx + dy * dy).sqrt();
    if len < 1e-9 {
        None
    } else {
        Some([dx / len, dy / len])
    }
}

/// 90-Grad-Linksdrehung einer Einheits-Richtung -> Einheits-Links-Normale.
#[inline]
fn left_normal_of(d: Point) -> Point {
    [-d[1], d[0]]
}

/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
fn point_in_tri(a: Point, b: Point, c: Point, p: Point) -> bool {
    let d1 = cross(a, b, p);
    let d2 = cross(b, c, p);
    let d3 = cross(c, a, p);
    let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
    let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
    !(has_neg && has_pos)
}

/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Polygons. Robust fuer
/// konvexe UND konkave Ringe. O(n^2) — fuer Plan-Polygone (wenige Ecken) voellig
/// ausreichend. Gibt Dreiecks-Indizes (0-basiert auf `pts`) zurueck; leer bei <3
/// Ecken oder Degeneration.
pub fn triangulate(pts: &[Point]) -> Vec<u32> {
    let n = pts.len();
    if n < 3 {
        return Vec::new();
    }

    // Ohr-Test unten nutzt cross>0 = konvex, was CCW voraussetzt. Bei CW-Polygonen
    // die Index-Reihenfolge umdrehen (Triangulierung ist raum-affin-invariant, die
    // Indizes gelten danach auch fuer die Bildschirm-Raum-Vertices).
    let mut idx: Vec<usize> = (0..n).collect();
    if signed_area(pts) < 0.0 {
        idx.reverse(); // <0 = CW -> auf CCW drehen
    }

    let mut tris: Vec<u32> = Vec::new();
    let mut guard = 0usize;
    let max_guard = n * n + 16;

    while idx.len() > 3 && guard < max_guard {
        guard += 1;
        let mut clipped = false;
        let m = idx.len();
        for i in 0..m {
            let i_prev = idx[(i + m - 1) % m];
            let i_cur = idx[i];
            let i_next = idx[(i + 1) % m];
            let a = pts[i_prev];
            let b = pts[i_cur];
            let c = pts[i_next];

            // Konvexe Ecke? (bei CCW: cross > 0)
            if cross(a, b, c) <= 0.0 {
                continue;
            }

            // Kein anderer Vertex im Ohr?
            let mut contains = false;
            for &vi in &idx {
                if vi == i_prev || vi == i_cur || vi == i_next {
                    continue;
                }
                if point_in_tri(a, b, c, pts[vi]) {
                    contains = true;
                    break;
                }
            }
            if contains {
                continue;
            }

            // Ohr abschneiden.
            tris.push(i_prev as u32);
            tris.push(i_cur as u32);
            tris.push(i_next as u32);
            idx.remove(i);
            clipped = true;
            break;
        }
        if !clipped {
            break; // Degeneriert -> abbrechen (kein Absturz).
        }
    }
    if idx.len() == 3 {
        tris.push(idx[0] as u32);
        tris.push(idx[1] as u32);
        tris.push(idx[2] as u32);
    }
    tris
}

/// Pipeline-Art eines Batches in der Maler-Reihenfolge (`GpuGeometry::draw_sequence`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchKind {
    Fill,
    Line,
}

/// Ein zusammenhaengender Zeichen-Batch (Index-Bereich) mit einer Farbe.
#[derive(Debug, Clone)]
pub struct FillBatch {
    pub start_index: u32,
    pub index_count: u32,
    pub color: Rgba,
}

/// Ein Linien-Batch: Index-Bereich mit Farbe + echter Papier-Strichbreite.
#[derive(Debug, Clone)]
pub struct LineBatch {
    pub start_index: u32,
    pub index_count: u32,
    pub color: Rgba,
    /// ECHTE Strichbreite in Papier-Millimeter — bzw. in Bildschirm-Einheiten
    /// (viewBox-px), wenn `width_screen` gesetzt ist (Schraffur-Muster).
    pub stroke_mm: f32,
    /// true: `stroke_mm` traegt Bildschirm-Einheiten (zoom-skalierend); der
    /// Renderer nutzt dann die meet-Skala statt der mm->px-Umrechnung.
    pub width_screen: bool,
}

/// GPU-fertige Geometrie: getrennte Puffer fuer Flaechen (Poche) und Striche.
/// Einmal je Szene tessellieren; danach treibt nur die Projektionsmatrix Pan/Zoom.
#[derive(Debug, Clone, Default)]
pub struct GpuGeometry {
    /// Fuell-Positionen, interleaved [x,y, x,y, ...] in Bildschirm-Raum.
    pub fill_pos: Vec<f32>,
    pub fill_idx: Vec<u32>,
    pub fill_batches: Vec<FillBatch>,
    /// Linien-Vertices, interleaved [x,y, bx,by, side, miter] in Bildschirm-Raum
    /// (bx,by = Einheits-Versatzrichtung — Segment-Normale bei Quad-Raendern,
    /// Radialrichtung bei Rundkappen-/Rundecken-Faechern; side = 0 im Faecher-
    /// Zentrum, sonst +-1; miter ist stets 1 — die Strichbreite wird vollstaendig
    /// im Vertex-Shader angewandt, s. `shaders.rs`).
    pub line_verts: Vec<f32>,
    pub line_idx: Vec<u32>,
    pub line_batches: Vec<LineBatch>,
    /// Analytisch gezeichnete Boegen (je EINE Instanz, kein Segment-Mesh mehr):
    /// die GPU rendert sie pro Frame exakt rund per SDF-Fragment-Shader.
    pub arcs: Vec<ArcInstanceData>,
    /// Maler-Reihenfolge ueber BEIDE Batch-Arten hinweg: (Art, Index in
    /// `fill_batches` bzw. `line_batches`). Der Renderer zeichnet exakt in dieser
    /// Reihenfolge (Pipeline-Wechsel pro Eintrag) — nur so deckt eine spaetere
    /// Fuellung (Wand-Poche) einen frueheren Strich (Raum-Umriss) ab, wie im
    /// SVG-Referenzpfad (Primitive strikt in Array-Reihenfolge).
    pub draw_sequence: Vec<(BatchKind, u32)>,
    /// Modell-Bounds (Meter) fuer Debug/Einpassen; nicht render-kritisch.
    pub bounds: [f32; 4], // [min_x, min_y, max_x, max_y]
}

#[inline]
fn same_rgba(a: Rgba, b: Rgba) -> bool {
    a[0] == b[0] && a[1] == b[1] && a[2] == b[2] && a[3] == b[3]
}

impl GpuGeometry {
    /// Ordnungserhaltendes Batch-Merging fuer Fuellungen: aufeinanderfolgende
    /// Indizes gleicher Farbe werden zu EINEM Draw-Call zusammengefasst. Gemergt
    /// wird nur, wenn der letzte Eintrag der MALER-Reihenfolge (`draw_sequence`)
    /// derselbe Fuell-Batch ist — sonst wuerde ein dazwischenliegender Linien-
    /// Batch uebersprungen und die Z-Reihenfolge verfaelscht.
    fn add_fill_batch(&mut self, count: u32, color: Rgba) {
        let last_idx = self.fill_batches.len().wrapping_sub(1) as u32;
        if self.draw_sequence.last() == Some(&(BatchKind::Fill, last_idx)) {
            if let Some(last) = self.fill_batches.last_mut() {
                if same_rgba(last.color, color) {
                    last.index_count += count;
                    return;
                }
            }
        }
        self.fill_batches.push(FillBatch {
            start_index: self.fill_idx.len() as u32 - count,
            index_count: count,
            color,
        });
        self.draw_sequence
            .push((BatchKind::Fill, (self.fill_batches.len() - 1) as u32));
    }

    fn add_line_batch(&mut self, count: u32, color: Rgba, stroke_mm: f32, width_screen: bool) {
        let last_idx = self.line_batches.len().wrapping_sub(1) as u32;
        if self.draw_sequence.last() == Some(&(BatchKind::Line, last_idx)) {
            if let Some(last) = self.line_batches.last_mut() {
                if last.stroke_mm == stroke_mm
                    && last.width_screen == width_screen
                    && same_rgba(last.color, color)
                {
                    last.index_count += count;
                    return;
                }
            }
        }
        self.line_batches.push(LineBatch {
            start_index: self.line_idx.len() as u32 - count,
            index_count: count,
            color,
            stroke_mm,
            width_screen,
        });
        self.draw_sequence
            .push((BatchKind::Line, (self.line_batches.len() - 1) as u32));
    }

    /// Zeichnet einen zusammenhaengenden Linienzug (Modell-Meter) mit ECHTEN
    /// RUNDEN Kappen/Ecken (wie SVG `stroke-linecap/linejoin: round` und der
    /// Vektor-PDF-Pfad) statt eckigem Butt-Cap/Gehrung:
    ///  - jedes Segment ist ein eigenstaendiges Quad mit BUTT-Enden (eigene
    ///    Segment-Normale, keine Miter-Verlaengerung);
    ///  - an jedem inneren Stuetzpunkt fuellt ein Dreiecksfaecher (Radius = halbe
    ///    Strichbreite) die Aussenseite der Ecke rund auf (Innenseite ueberlappt
    ///    unsichtbar, wie bei jedem Disjoint-Segment-Linienbreiten-Ansatz);
    ///  - an offenen Enden (nicht `closed`) sitzt ein Halbkreis-Faecher als runde
    ///    Kappe.
    /// Die tatsaechliche Pixel-Breite bleibt bildschirmkonstant: alle Faecher-/
    /// Quad-Vertices tragen nur eine EINHEITS-Richtung (`bx,by`) + `side`-Skalar;
    /// der Vertex-Shader multipliziert im Clip-Raum mit der aktuellen
    /// Strichbreite (`stroke_px*stroke_scale`) — die Tessellierung selbst kennt
    /// keine Pixelmasse. `closed` schliesst den Ring (letzter<->erster Punkt).
    /// Vertex-Layout unveraendert: [x,y, bx,by, side, miter] (miter bleibt hier
    /// immer 1, s. Shader-Vertrag in `shaders.rs`).
    ///
    /// 1:1-Port von `strokePolyline` in `src/plan/glPlan/glPlanCompile.ts`.
    fn stroke_polyline(
        &mut self,
        pts_m: &[Point],
        closed: bool,
        color: Rgba,
        stroke_mm: f32,
        width_screen: bool,
        bounds: &mut Bounds,
    ) {
        // Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
        let mut s: Vec<Point> = Vec::with_capacity(pts_m.len());
        for &p in pts_m {
            let sp = to_screen(p);
            if let Some(last) = s.last() {
                if (last[0] - sp[0]).abs() < 1e-6 && (last[1] - sp[1]).abs() < 1e-6 {
                    continue;
                }
            }
            s.push(sp);
            bounds.track(p);
        }
        if closed && s.len() > 1 {
            let f = s[0];
            let l = s[s.len() - 1];
            if (f[0] - l[0]).abs() < 1e-6 && (f[1] - l[1]).abs() < 1e-6 {
                s.pop();
            }
        }
        let k = s.len();
        if k < 2 {
            return;
        }

        let start_idx_len = self.line_idx.len();

        // Vertex fuer Quad-Rand ODER Faecher (Zentrum bei side=0, Rand bei side=1)
        // anhaengen; gibt den neuen Index zurueck.
        #[inline]
        fn push_vert(verts: &mut Vec<f32>, p: Point, n: Point, side: f32) -> u32 {
            verts.extend_from_slice(&[p[0], p[1], n[0], n[1], side, 1.0]);
            (verts.len() / 6 - 1) as u32
        }

        let segs = if closed { k } else { k - 1 };
        let mut seg_dir: Vec<Option<Point>> = Vec::with_capacity(segs);
        for i in 0..segs {
            seg_dir.push(unit_dir(s[i], s[(i + 1) % k]));
        }

        // Segment-Quads: buttendig, jedes mit seiner EIGENEN Normale (kein
        // gemeinsamer Bisektor mehr — Ecken werden separat durch Faecher geschlossen).
        for i in 0..segs {
            let Some(d) = seg_dir[i] else {
                continue; // entartetes (Laenge-0) Segment
            };
            let n = left_normal_of(d);
            let a = s[i];
            let b = s[(i + 1) % k];
            let i0 = push_vert(&mut self.line_verts, a, n, 1.0);
            let i1 = push_vert(&mut self.line_verts, a, n, -1.0);
            let i2 = push_vert(&mut self.line_verts, b, n, 1.0);
            let i3 = push_vert(&mut self.line_verts, b, n, -1.0);
            self.line_idx
                .extend_from_slice(&[i0, i1, i2, i1, i3, i2]);
        }

        // Kreisbogen-Faecher (Zentrum = Vertex, Radius = halbe Strichbreite, per
        // Shader skaliert): `side=0` am Zentrum (kein Versatz), `side=1` an den
        // Randpunkten (voller Versatz in Richtung (cos(t),sin(t))). Segmentzahl
        // skaliert mit dem ueberstrichenen Winkel (18 Grad je Schritt).
        #[inline]
        fn add_fan_arc(verts: &mut Vec<f32>, idx: &mut Vec<u32>, p: Point, a_from: f32, a_to: f32) {
            let delta = a_to - a_from;
            let steps = ((delta.abs() / (std::f32::consts::PI / 10.0)).ceil() as u32).max(1);
            let i_center = push_vert(verts, p, [1.0, 0.0], 0.0);
            let mut prev = push_vert(verts, p, [a_from.cos(), a_from.sin()], 1.0);
            for step in 1..=steps {
                let t = a_from + delta * (step as f32) / (steps as f32);
                let cur = push_vert(verts, p, [t.cos(), t.sin()], 1.0);
                idx.extend_from_slice(&[i_center, prev, cur]);
                prev = cur;
            }
        }

        // Runder Join an einem inneren Stuetzpunkt: Faecher NUR auf der konvexen
        // (aeusseren) Seite der Ecke — die konkave Seite ueberlappt bereits durch
        // die beiden Segment-Quads (kein Loch, keine zusaetzliche Geometrie noetig).
        #[inline]
        fn add_round_join(verts: &mut Vec<f32>, idx: &mut Vec<u32>, p: Point, d1: Point, d2: Point) {
            let turn = d1[0] * d2[1] - d1[1] * d2[0];
            let dot = d1[0] * d2[0] + d1[1] * d2[1];
            if turn.abs() < 1e-6 && dot > 0.0 {
                return; // praktisch gerade
            }
            let n1 = left_normal_of(d1);
            let n2 = left_normal_of(d2);
            if dot < -0.9999 {
                // ~180-Grad-Umkehr: Aussenseite mehrdeutig -> voller Kreis (robust,
                // entspricht zwei gestapelten Rund-Kappen an derselben Stelle).
                let a0 = n1[1].atan2(n1[0]);
                add_fan_arc(verts, idx, p, a0, a0 + 2.0 * std::f32::consts::PI);
                return;
            }
            let outer = if turn > 0.0 { -1.0 } else { 1.0 };
            let u1 = [outer * n1[0], outer * n1[1]];
            let u2 = [outer * n2[0], outer * n2[1]];
            let a1 = u1[1].atan2(u1[0]);
            let a2 = u2[1].atan2(u2[0]);
            let mut delta = a2 - a1;
            while delta <= -std::f32::consts::PI {
                delta += 2.0 * std::f32::consts::PI;
            }
            while delta > std::f32::consts::PI {
                delta -= 2.0 * std::f32::consts::PI;
            }
            if delta.abs() < 1e-4 {
                return;
            }
            add_fan_arc(verts, idx, p, a1, a1 + delta);
        }

        // Runde Kappe an einem offenen Ende: Halbkreis, der auf der Aussenseite
        // (weg von der Linie) ausbaucht.
        #[inline]
        fn add_round_cap(verts: &mut Vec<f32>, idx: &mut Vec<u32>, p: Point, n: Point, sweep_sign: f32) {
            let a0 = n[1].atan2(n[0]);
            add_fan_arc(verts, idx, p, a0, a0 + sweep_sign * std::f32::consts::PI);
        }

        for v in 0..k {
            let has_in = closed || v > 0;
            let has_out = closed || v < k - 1;
            let d_in = if has_in {
                seg_dir[(v + segs - 1) % segs]
            } else {
                None
            };
            let d_out = if has_out { seg_dir[v % segs] } else { None };
            match (d_in, d_out) {
                (Some(di), Some(do_)) => {
                    add_round_join(&mut self.line_verts, &mut self.line_idx, s[v], di, do_)
                }
                (None, Some(do_)) => {
                    // Start-Kappe: baucht rueckwaerts (weg vom ersten Segment) aus.
                    let n = left_normal_of(do_);
                    add_round_cap(&mut self.line_verts, &mut self.line_idx, s[v], n, 1.0)
                }
                (Some(di), None) => {
                    // End-Kappe: baucht vorwaerts (weg vom letzten Segment) aus.
                    let n = left_normal_of(di);
                    add_round_cap(&mut self.line_verts, &mut self.line_idx, s[v], n, -1.0)
                }
                (None, None) => {}
            }
        }

        self.add_line_batch(
            (self.line_idx.len() - start_idx_len) as u32,
            color,
            stroke_mm,
            width_screen,
        );
    }
}

/// Hilfsstruct zum Verfolgen der Modell-Bounds.
struct Bounds {
    min_x: f32,
    min_y: f32,
    max_x: f32,
    max_y: f32,
}

impl Bounds {
    fn new() -> Self {
        Self {
            min_x: f32::INFINITY,
            min_y: f32::INFINITY,
            max_x: f32::NEG_INFINITY,
            max_y: f32::NEG_INFINITY,
        }
    }
    #[inline]
    fn track(&mut self, p: Point) {
        if p[0] < self.min_x {
            self.min_x = p[0];
        }
        if p[1] < self.min_y {
            self.min_y = p[1];
        }
        if p[0] > self.max_x {
            self.max_x = p[0];
        }
        if p[1] > self.max_y {
            self.max_y = p[1];
        }
    }
    fn finish(self) -> [f32; 4] {
        [
            if self.min_x.is_finite() { self.min_x } else { 0.0 },
            if self.min_y.is_finite() { self.min_y } else { 0.0 },
            if self.max_x.is_finite() { self.max_x } else { 1.0 },
            if self.max_y.is_finite() { self.max_y } else { 1.0 },
        ]
    }
}

/// Kompiliert eine ganze Szene zu GPU-Geometrie: gefuellte Polygone +
/// Papier-mm-Striche (tesselliert) + analytische Boegen (je EINE Instanz).
/// Reihenfolge: Fuellungen -> Umrisse -> offene Polylinien -> freie Linien; die
/// Boegen sammelt `geo.arcs` (eigene Pipeline, gezeichnet nach den Linien). ALLES
/// ist zoom-INVARIANT: Papier-mm-Striche wie auch die Bogen-Kanten werden erst im
/// Shader auf Geraete-px abgebildet — keine Neu-Tessellierung bei Zoom noetig.
pub fn compile_scene(scene: &Scene) -> GpuGeometry {
    let mut geo = GpuGeometry::default();
    let mut bounds = Bounds::new();

    // Maler-Reihenfolge herstellen: alle Elemente (Fuellungen, Umrisse, offene
    // Polylinien, freie Linien) stabil nach ihrem `z` sortieren. Bei gleichem z
    // (insb. Alt-Szenen mit z=0 ueberall) gilt die bisherige Kategorien-
    // Reihenfolge Fuellungen -> Umrisse -> Polylinien -> Linien (`seq`).
    enum Item<'a> {
        Fill(&'a crate::types::FillPolygon),
        Outline(&'a crate::types::Outline),
        Poly(&'a crate::types::Polyline),
        Line(&'a Line),
    }
    let mut items: Vec<(u32, usize, Item)> = Vec::with_capacity(
        scene.fills.len() + scene.outlines.len() + scene.polylines.len() + scene.lines.len(),
    );
    let mut seq = 0usize;
    for f in &scene.fills {
        items.push((f.z, seq, Item::Fill(f)));
        seq += 1;
    }
    for o in &scene.outlines {
        items.push((o.z, seq, Item::Outline(o)));
        seq += 1;
    }
    for pl in &scene.polylines {
        items.push((pl.z, seq, Item::Poly(pl)));
        seq += 1;
    }
    for l in &scene.lines {
        items.push((l.z, seq, Item::Line(l)));
        seq += 1;
    }
    items.sort_by_key(|(z, s, _)| (*z, *s));

    for (_, _, item) in &items {
        match item {
            // Gefuellte Polygone.
            Item::Fill(poly) => compile_fill(&mut geo, poly, &mut bounds),
            // Umrisse (geschlossene Ringe als EIN gehrter Streifen, inkl.
            // Schluss-Kante — ausser bei Dash, siehe `stroke_dashed_or_solid`).
            Item::Outline(o) => {
                if o.width_mm > 0.0 && o.pts.len() >= 2 {
                    stroke_dashed_or_solid(&mut geo, &o.pts, true, o.color, o.width_mm, false, o.dash.as_deref(), &mut bounds);
                }
            }
            // Offene Polylinien (verbundene Umrisskanten / 2D-Zeichnungszuege) als
            // EIN gehrter Streifen — innere Ecken gehrt statt Stumpfkappen.
            Item::Poly(pl) => {
                if pl.width_mm > 0.0 && pl.pts.len() >= 2 {
                    stroke_dashed_or_solid(&mut geo, &pl.pts, false, pl.color, pl.width_mm, pl.width_screen, pl.dash.as_deref(), &mut bounds);
                }
            }
            // Freie Einzel-Linien (Tuerblaetter, Referenzlinien).
            Item::Line(l) => compile_line(&mut geo, l, &mut bounds),
        }
    }
    // Kreisboegen: NICHT in Segmente zerlegt — je Bogen eine analytische Instanz
    // (`ArcInstanceData`), die die GPU per SDF-Fragment-Shader exakt rund rendert
    // (eigene Pipeline, nach der uebrigen Geometrie — Tuerschwenk liegt oben).
    for a in &scene.arcs {
        compile_arc(&mut geo, a, &mut bounds);
    }

    geo.bounds = bounds.finish();
    geo
}

fn compile_fill(geo: &mut GpuGeometry, poly: &FillPolygon, bounds: &mut Bounds) {
    let tris = triangulate(&poly.pts);
    if tris.is_empty() {
        return;
    }
    let base = (geo.fill_pos.len() / 2) as u32;
    for &pt in &poly.pts {
        let s = to_screen(pt);
        geo.fill_pos.push(s[0]);
        geo.fill_pos.push(s[1]);
        bounds.track(pt);
    }
    let count = tris.len() as u32;
    for t in tris {
        geo.fill_idx.push(base + t);
    }
    geo.add_fill_batch(count, poly.color);
}

fn compile_arc(geo: &mut GpuGeometry, a: &Arc, bounds: &mut Bounds) {
    if a.width_mm <= 0.0 || a.r <= 0.0 {
        return;
    }
    let (center, r_screen, a0, sweep) = arc_screen_params(a.center, a.from, a.to, a.r);
    // Degenerierter Bogen (from == to -> Sweep ~ 0): nichts zu zeichnen. Echte
    // Vollkreise kommen als geschlossenes Polygon (siehe generatePlan `circle`).
    if sweep.abs() < 1e-6 {
        return;
    }
    let (dash, dash_count, dash_total) = prepare_arc_dash(a.dash.as_deref());
    geo.arcs.push(ArcInstanceData {
        center,
        r_screen,
        r_model: a.r,
        a0,
        sweep,
        color: a.color,
        width_mm: a.width_mm,
        dash,
        dash_count,
        dash_total,
    });
    // Modell-Bounds: Bounding-Box des vollen Kreises (grosszuegig, aber sicher
    // fuer "Einpassen"; der sichtbare Bogen liegt stets darin).
    bounds.track([a.center[0] - a.r, a.center[1] - a.r]);
    bounds.track([a.center[0] + a.r, a.center[1] + a.r]);
}

fn compile_line(geo: &mut GpuGeometry, l: &Line, bounds: &mut Bounds) {
    let w = if l.width_mm > 0.0 { l.width_mm } else { 0.18 };
    stroke_dashed_or_solid(geo, &[l.a, l.b], false, l.color, w, false, l.dash.as_deref(), bounds);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Arc, Outline};

    // --- arc_screen_params: analytische Bogen-Parameter ----------------------

    /// Modell-Raum-Sweep (kuerzerer Bogen) — Referenz fuer die Winkel-Paritaet
    /// (frueher die Winkellogik der `tessellate_arc`). Die Bildschirm-Y-Spiegelung
    /// aendert nur das Vorzeichen, nicht den Betrag.
    fn model_delta(center: Point, from: Point, to: Point) -> f32 {
        let a0 = (from[1] - center[1]).atan2(from[0] - center[0]);
        let a1 = (to[1] - center[1]).atan2(to[0] - center[0]);
        let mut d = a1 - a0;
        while d > std::f32::consts::PI {
            d -= 2.0 * std::f32::consts::PI;
        }
        while d < -std::f32::consts::PI {
            d += 2.0 * std::f32::consts::PI;
        }
        d
    }

    #[test]
    fn arc_screen_params_endpunkte_treffen_exakt() {
        let center = [1.0, 1.0];
        let from = [2.0, 1.0]; // r=1, Modell-Winkel 0
        let to = [1.0, 2.0]; // Modell-Winkel 90 Grad
        let (cs, r_s, a0, sweep) = arc_screen_params(center, from, to, 1.0);
        // Punkt bei a0 == to_screen(from), Punkt bei a0+sweep == to_screen(to).
        let p0 = [cs[0] + a0.cos() * r_s, cs[1] + a0.sin() * r_s];
        let p1 = [cs[0] + (a0 + sweep).cos() * r_s, cs[1] + (a0 + sweep).sin() * r_s];
        let fs = to_screen(from);
        let ts = to_screen(to);
        assert!((p0[0] - fs[0]).abs() < 1e-3 && (p0[1] - fs[1]).abs() < 1e-3, "Start trifft from");
        assert!((p1[0] - ts[0]).abs() < 1e-3 && (p1[1] - ts[1]).abs() < 1e-3, "Ende trifft to");
        assert!((r_s - PX_PER_M).abs() < 1e-3, "r_screen = r * PX_PER_M");
    }

    #[test]
    fn arc_screen_params_kuerzerer_bogen_paritaet() {
        let center = [0.0, 0.0];
        let from = [1.0, 0.0];
        // Ziel bei Modell-Winkel 200 Grad -> naiv 200 Grad, kuerzer aber -160 Grad.
        let ang = 200.0_f32.to_radians();
        let to = [ang.cos(), ang.sin()];
        let (_, _, _, sweep) = arc_screen_params(center, from, to, 1.0);
        assert!(sweep.abs() <= std::f32::consts::PI + 1e-4, "kuerzerer Bogen (|sweep|<=pi)");
        // Betrag identisch zur Modell-Raum-Winkellogik (Y-Spiegelung nur Vorzeichen).
        assert!(
            (sweep.abs() - model_delta(center, from, to).abs()).abs() < 1e-4,
            "Sweep-Betrag == Modell-Delta-Betrag (Paritaet zur alten tessellate_arc-Logik)"
        );
    }

    // --- prepare_arc_dash: Papier-mm -> Modell-Meter-Zyklus -------------------

    #[test]
    fn prepare_arc_dash_tuerschwenk_mapping() {
        // Tuerschwenk-Muster [0.06, 0.04] mm -> * DASH_MM_TO_M(0.1) = [0.006, 0.004] m.
        let (cyc, n, total) = prepare_arc_dash(Some(&[0.06, 0.04]));
        assert_eq!(n, 2, "ein An/Aus-Paar");
        assert!((cyc[0] - 0.006).abs() < 1e-6 && (cyc[1] - 0.004).abs() < 1e-6);
        assert!((total - 0.010).abs() < 1e-6, "Zykluslaenge 0.01 m");
    }

    #[test]
    fn prepare_arc_dash_ungerade_wird_verdoppelt() {
        // Einzelwert -> Standard-Dash-Semantik: verdoppelt auf [x, x].
        let (cyc, n, total) = prepare_arc_dash(Some(&[10.0]));
        assert_eq!(n, 2, "ungerade Laenge wird verdoppelt");
        assert!((cyc[0] - 1.0).abs() < 1e-6 && (cyc[1] - 1.0).abs() < 1e-6);
        assert!((total - 2.0).abs() < 1e-6);
    }

    #[test]
    fn prepare_arc_dash_leer_ist_durchgezogen() {
        assert_eq!(prepare_arc_dash(None).1, 0);
        assert_eq!(prepare_arc_dash(Some(&[])).1, 0);
    }

    // --- split_dash: geometrische Strichmuster-Zerlegung ---------------------

    #[test]
    fn split_dash_gerade_linie_erwartete_stueckzahl_und_laenge() {
        let pts = [[0.0, 0.0], [10.0, 0.0]];
        // 10mm/10mm bei Referenz-Skala (DASH_MM_TO_M=0.1) -> 1m/1m Zyklus,
        // 10m Gesamtlaenge -> 5 "an"-Stuecke a 1m (50% Tastverhaeltnis).
        let pieces = split_dash(&pts, &[10.0, 10.0]);
        assert_eq!(pieces.len(), 5, "5 an-Stuecke erwartet");
        let total: f32 = pieces
            .iter()
            .map(|p| {
                p.windows(2)
                    .map(|w| ((w[1][0] - w[0][0]).powi(2) + (w[1][1] - w[0][1]).powi(2)).sqrt())
                    .sum::<f32>()
            })
            .sum();
        assert!((total - 5.0).abs() < 1e-3, "Gesamtlaenge der an-Stuecke ~5m, war {total}");
    }

    #[test]
    fn split_dash_leeres_muster_unveraendert() {
        let pts = [[0.0, 0.0], [3.0, 4.0], [10.0, 4.0]];
        let pieces = split_dash(&pts, &[]);
        assert_eq!(pieces.len(), 1);
        assert_eq!(pieces[0], pts.to_vec());
    }

    // --- compile_scene: End-zu-End-Verdrahtung --------------------------------

    #[test]
    fn compile_scene_mit_bogen_erzeugt_arc_instanz() {
        let scene = Scene {
            arcs: vec![Arc {
                center: [0.0, 0.0],
                from: [1.0, 0.0],
                to: [0.0, 1.0],
                r: 1.0,
                color: [0.1, 0.1, 0.1, 1.0],
                width_mm: 0.25,
                dash: None,
            }],
            ..Default::default()
        };
        let geo = compile_scene(&scene);
        // Analytisch: EINE Instanz, KEINE tessellierte Liniengeometrie.
        assert_eq!(geo.arcs.len(), 1, "genau eine Bogen-Instanz");
        assert!(geo.line_verts.is_empty(), "Bogen erzeugt keine Linien-Vertices mehr");
        let a = &geo.arcs[0];
        assert_eq!(a.dash_count, 0, "durchgezogen");
        assert!((a.r_screen - PX_PER_M).abs() < 1e-3);
        // Bounds decken den vollen Kreis (center +- r).
        assert!((geo.bounds[0] + 1.0).abs() < 1e-3 && (geo.bounds[2] - 1.0).abs() < 1e-3);
    }

    #[test]
    fn dash_reduziert_indexanzahl_gegenueber_durchgezogenem_umriss() {
        let ring = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
        let solid = Scene {
            outlines: vec![Outline {
                pts: ring.clone(),
                color: [0.0, 0.0, 0.0, 1.0],
                width_mm: 0.2,
                dash: None,
                z: 0,
            }],
            ..Default::default()
        };
        let dashed = Scene {
            outlines: vec![Outline {
                pts: ring,
                color: [0.0, 0.0, 0.0, 1.0],
                width_mm: 0.2,
                dash: Some(vec![10.0, 10.0]),
                z: 0,
            }],
            ..Default::default()
        };
        let geo_solid = compile_scene(&solid);
        let geo_dashed = compile_scene(&dashed);
        assert!(!geo_solid.line_idx.is_empty());
        assert!(!geo_dashed.line_idx.is_empty());
        assert_ne!(
            geo_solid.line_idx.len(),
            geo_dashed.line_idx.len(),
            "gestrichelter Umriss sollte eine andere (geringere) Indexanzahl als der durchgezogene ergeben"
        );
    }

    #[test]
    fn gestrichelter_tuerschwenk_bogen_erzeugt_dash_instanz() {
        // Spiegelt den echten Anwendungsfall (Tuerschwenk-Boegen, dash 0.06/0.04mm).
        let center = [0.0, 0.0];
        let from = [1.0, 0.0];
        let to = [0.0, 1.0];
        let dash = vec![0.06, 0.04];
        let scene = Scene {
            arcs: vec![Arc {
                center,
                from,
                to,
                r: 1.0,
                color: [0.1, 0.1, 0.1, 1.0],
                width_mm: 0.05,
                dash: Some(dash.clone()),
            }],
            ..Default::default()
        };
        // Rundlauf durch die volle Pipeline: eine analytische, GESTRICHELTE Instanz.
        let geo = compile_scene(&scene);
        assert_eq!(geo.arcs.len(), 1, "genau eine Bogen-Instanz");
        let a = &geo.arcs[0];
        assert_eq!(a.dash_count, 2, "ein An/Aus-Paar (dash-Zyklus)");
        assert!((a.dash_total - 0.010).abs() < 1e-6, "Zykluslaenge 0.01 m");
        // Sweep = Viertelkreis (Betrag pi/2), Radius exakt.
        assert!((a.sweep.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-4);
        assert!((a.r_screen - PX_PER_M).abs() < 1e-3);
    }
}
