// wgpu-Pipelines des nativen 2D-Renderers (nur mit Feature "render").
//
// Zwei Pipelines analog zum WebGL-Pfad:
//  - Fuell-Pipeline: gefuellte Polygone, Ortho-Matrix-Uniform.
//  - Linien-Pipeline: Quad-Expansion, Breite in echten Papier-mm (Shader).
//
// Der Renderer ist fenster-/surface-agnostisch: er bekommt ein `Device`, eine
// `Queue` und ein `TextureView` (die Surface oder eine Offscreen-Textur) und
// zeichnet dort hinein. Die Fenster-Anbindung (winit) liegt separat im Spike-Bin.
//
// Pro-Batch-Uniforms ueber DYNAMISCHE Offsets: alle Globals-Bloecke (je Fuell-/
// Linien-Batch einer) liegen hintereinander in EINEM Uniform-Puffer (auf die
// GPU-Mindest-Ausrichtung gepolstert). Vor dem Zeichnen wird der ganze Puffer
// EINMAL geschrieben; jeder Draw-Call bindet seinen Block per dynamischem Offset.
// Das umgeht die Regel "kein write_buffer auf gebundenen Puffer im offenen Pass".

use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;

use crate::ortho::{compute_ortho_matrix, corrected_view_box, meet_scale, mm_to_device_px, Mat4};
use crate::shaders::{ARC_WGSL, FILL_WGSL, LINE_WGSL};
use crate::tessellate::{compile_scene, to_screen, ArcInstanceData, BatchKind, GpuGeometry, MAX_ARC_DASH};
use crate::types::{Scene, Text, TextAlign, ViewBox};

/// MSAA-Faktor: 4x Multisampling fuer glatte Linien-/Fuellkanten (wie im Browser).
const SAMPLE_COUNT: u32 = 4;

/// Uniform-Block, 1:1 zum WGSL-`Globals`-Struct. std140-kompatibel ausgelegt
/// (16-Byte-Alignment der mat4/vec4, Skalare am Ende gepackt).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct Globals {
    view_proj: [f32; 16],
    color: [f32; 4],
    viewport_px: [f32; 2],
    stroke_px: f32,
    stroke_scale: f32,
}

impl Default for Globals {
    fn default() -> Self {
        Self {
            view_proj: crate::ortho::identity(),
            color: [0.0, 0.0, 0.0, 1.0],
            viewport_px: [1.0, 1.0],
            stroke_px: 0.0,
            stroke_scale: 1.0,
        }
    }
}

/// Frame-Uniform der analytischen Bogen-Pipeline (1:1 zu `ArcGlobals` in WGSL).
/// EIN Block je Frame (fuer ALLE Boegen gleich) — anders als die per-Batch-
/// `Globals` braucht er keinen dynamischen Offset. std140: mat4 + vec2 + 2 Skalare
/// = 80 Byte (16-Byte-Vielfaches).
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct ArcGlobals {
    view_proj: [f32; 16],
    viewport_px: [f32; 2],
    px_per_screen: f32,
    stroke_scale: f32,
}

/// EINE Bogen-Instanz fuer die GPU (Vertex-Puffer, step_mode Instance), 1:1 zum
/// Instanz-Layout in `ARC_WGSL`. Zoom-invariant: Bildschirm-Raum-Geometrie +
/// Dash in Modell-Metern. 18 f32 = 72 Byte.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct ArcInstance {
    center: [f32; 2],
    /// (r_screen, a0, sweep, r_model)
    geom: [f32; 4],
    color: [f32; 4],
    /// (width_mm, dash_total, dash_count, _pad)
    wdash: [f32; 4],
    /// bis zu MAX_ARC_DASH An/Aus-Laengen (Modell-Meter).
    dash: [f32; 4],
}

impl ArcInstance {
    fn from_data(a: &ArcInstanceData) -> Self {
        // MAX_ARC_DASH ist 4 (== vec4 im Shader); Compile-Time abgesichert.
        const _: () = assert!(MAX_ARC_DASH == 4);
        Self {
            center: a.center,
            geom: [a.r_screen, a.a0, a.sweep, a.r_model],
            color: a.color,
            wdash: [a.width_mm, a.dash_total, a.dash_count as f32, 0.0],
            dash: a.dash,
        }
    }
}

/// Rundet `size` auf das naechste Vielfache von `align` (>=1) auf.
fn align_up(size: u64, align: u64) -> u64 {
    if align <= 1 {
        return size;
    }
    ((size + align - 1) / align) * align
}

/// GPU-seitige Puffer + Batch-Metadaten einer tessellierten Szene.
struct SceneBuffers {
    fill_vbo: Option<wgpu::Buffer>,
    fill_ibo: Option<wgpu::Buffer>,
    line_vbo: Option<wgpu::Buffer>,
    line_ibo: Option<wgpu::Buffer>,
    /// Instanz-Puffer der analytischen Boegen (je Bogen EINE Instanz); None wenn leer.
    arc_vbo: Option<wgpu::Buffer>,
    /// Anzahl Bogen-Instanzen (Draw: 6 Vertices je Instanz).
    arc_count: u32,
    geo: GpuGeometry,
    /// Textzeilen der Szene (Modell-Anker + Papier-mm-Groesse). Werden nicht
    /// tesselliert, sondern pro Frame ueber den Glyphen-Atlas gesetzt (die
    /// Pixelgroesse haengt vom Zoom ab).
    texts: Vec<Text>,
}

/// Zustand des Glyphen-Textpasses (glyphon): Systemfonts + Rasterizer-Cache +
/// GPU-Atlas + Renderer. Lazily beim ersten Frame mit Texten erstellt (braucht
/// die Queue, die `Renderer::new` nicht bekommt — Signatur bleibt stabil).
struct TextState {
    font_system: glyphon::FontSystem,
    swash_cache: glyphon::SwashCache,
    viewport: glyphon::Viewport,
    atlas: glyphon::TextAtlas,
    renderer: glyphon::TextRenderer,
}

/// Schrift des Plans: dieselbe Familie wie der Browser-Text (`--font` in
/// styles.css beginnt mit "Inter"); cosmic-text laedt die Systemfonts und
/// faellt bei fehlender Familie auf sans-serif zurueck. Standardwert des
/// `Renderer.text_family`-Feldes — im Web-Pfad (kein System-Font-Zugriff) wird
/// er auf die Familie der eingebetteten Font-Bytes umgestellt (siehe `load_font`).
const DEFAULT_TEXT_FAMILY: &str = "Inter";

/// Der native 2D-Renderer: haelt beide Pipelines, den (dynamischen) Uniform-
/// Puffer und die aktuell hochgeladene Szene. Pan/Zoom aktualisiert nur die
/// Ortho-Matrix (kein Re-Tessellieren).
pub struct Renderer {
    fill_pipeline: wgpu::RenderPipeline,
    line_pipeline: wgpu::RenderPipeline,
    bind_group_layout: wgpu::BindGroupLayout,
    /// Analytische Bogen-Pipeline (exakter Kreis-Shader, `ARC_WGSL`).
    arc_pipeline: wgpu::RenderPipeline,
    /// Frame-Uniform der Bogen-Pipeline (ein Block, kein dynamischer Offset).
    arc_uniform: wgpu::Buffer,
    arc_bind_group: wgpu::BindGroup,
    /// Ausgerichtete Groesse eines Globals-Blocks im dynamischen Uniform-Puffer.
    uniform_stride: u64,
    /// Aktuell allozierter Uniform-Puffer + zugehoerige Bind-Group (wachsen bei Bedarf).
    uniform: Option<UniformArena>,
    scene: Option<SceneBuffers>,
    /// Farbformat der Ziel-Surface (auch Format der MSAA-Textur).
    format: wgpu::TextureFormat,
    /// Multisample-Farbtextur (4x), lazily an die Ziel-Groesse gebunden.
    msaa_view: Option<wgpu::TextureView>,
    /// Groesse, fuer die `msaa_view` angelegt wurde.
    msaa_size: (u32, u32),
    /// Glyphen-Textpass (lazily beim ersten Frame mit Texten erstellt).
    text: Option<TextState>,
    /// Schriftfamilie fuer die Textzeilen (Default `DEFAULT_TEXT_FAMILY`). Wird im
    /// Web-Pfad auf die Familie der eingebetteten Font-Bytes umgestellt.
    text_family: String,
    /// Noch nicht in die FontSystem-DB geladene Font-Bytes (Web-Pfad: es gibt
    /// keine Systemfonts). Werden beim ersten `ensure_text` eingespielt.
    pending_font: Option<Vec<u8>>,
    /// Loeschfarbe (Zeichenblatt), Default hell wie im WebGL-Pfad (#f0f0f0).
    pub clear_color: wgpu::Color,
    /// Papier-Massstab-Nenner (1:N) fuer echte mm-Strichbreiten.
    pub paper_scale_n: f32,
    /// Papier-Massstab-Nenner (1:N) NUR fuer die Text-Schriftgroesse — bewusst
    /// vom geometrischen `paper_scale_n` ENTKOPPELT: der SVG-Referenzpfad haelt
    /// die Raumstempel-Schriftgroesse im normalen Anzeigemodus auf einer festen
    /// Referenzskala 1:100 (lesbar, unabhaengig vom zufaelligen Einpass-Massstab
    /// der aktuellen Ansicht) und wechselt erst im Druck-Modus auf den echten
    /// gemessenen Massstab; siehe PlanView.tsx `textScaleForGl`/case "text". Ohne
    /// diese Trennung wuerde Text hier mit dem live aus dem Ausschnitt
    /// abgeleiteten `paper_scale_n` skalieren (z.B. 1:32 statt 1:100) und damit
    /// sichtbar kleiner/groesser als im SVG-Pfad erscheinen.
    pub text_scale_n: f32,
}

/// Ein Uniform-Puffer, der `capacity` Globals-Bloecke fasst, plus Bind-Group.
struct UniformArena {
    buffer: wgpu::Buffer,
    bind_group: wgpu::BindGroup,
    capacity: u64,
}

impl Renderer {
    /// Erzeugt Pipelines + Layout fuer ein gegebenes Farbformat (Surface).
    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
        let fill_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("fill.wgsl"),
            source: wgpu::ShaderSource::Wgsl(FILL_WGSL.into()),
        });
        let line_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("line.wgsl"),
            source: wgpu::ShaderSource::Wgsl(LINE_WGSL.into()),
        });

        // Ein Uniform-Block (Globals), fuer beide Pipelines geteilt, mit dynamischem
        // Offset (jeder Batch bekommt seinen eigenen Block-Slice).
        let bind_group_layout =
            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                label: Some("globals.layout"),
                entries: &[wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: true,
                        min_binding_size: wgpu::BufferSize::new(
                            std::mem::size_of::<Globals>() as u64
                        ),
                    },
                    count: None,
                }],
            });

        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("2d.layout"),
            bind_group_layouts: &[Some(&bind_group_layout)],
            immediate_size: 0,
        });

        // Alpha-Blending (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) wie im WebGL-Pfad.
        let blend = Some(wgpu::BlendState::ALPHA_BLENDING);
        let color_target = wgpu::ColorTargetState {
            format: color_format,
            blend,
            write_mask: wgpu::ColorWrites::ALL,
        };

        // Fuell-Pipeline: ein vec2-Attribut (Bildschirm-Position).
        let fill_vertex_layout = wgpu::VertexBufferLayout {
            array_stride: 2 * 4,
            step_mode: wgpu::VertexStepMode::Vertex,
            attributes: &wgpu::vertex_attr_array![0 => Float32x2],
        };
        let fill_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("fill.pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &fill_module,
                entry_point: Some("vs_main"),
                buffers: &[fill_vertex_layout],
                compilation_options: Default::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &fill_module,
                entry_point: Some("fs_main"),
                targets: &[Some(color_target.clone())],
                compilation_options: Default::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                // Ohr-Clipping liefert stets CCW; wir lassen Culling aus (robuster).
                cull_mode: None,
                ..Default::default()
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState {
                count: SAMPLE_COUNT,
                mask: !0,
                alpha_to_coverage_enabled: false,
            },
            multiview_mask: None,
            cache: None,
        });

        // Linien-Pipeline: [pos vec2, bisector vec2, side f32, miter f32], stride 6*4.
        let line_vertex_layout = wgpu::VertexBufferLayout {
            array_stride: 6 * 4,
            step_mode: wgpu::VertexStepMode::Vertex,
            attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32, 3 => Float32],
        };
        let line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("line.pipeline"),
            layout: Some(&pipeline_layout),
            vertex: wgpu::VertexState {
                module: &line_module,
                entry_point: Some("vs_main"),
                buffers: &[line_vertex_layout],
                compilation_options: Default::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &line_module,
                entry_point: Some("fs_main"),
                targets: &[Some(color_target)],
                compilation_options: Default::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                cull_mode: None,
                ..Default::default()
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState {
                count: SAMPLE_COUNT,
                mask: !0,
                alpha_to_coverage_enabled: false,
            },
            multiview_mask: None,
            cache: None,
        });

        // ── Analytische Bogen-Pipeline (exakter Kreis-Shader) ─────────────────
        let arc_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("arc.wgsl"),
            source: wgpu::ShaderSource::Wgsl(ARC_WGSL.into()),
        });
        // Eigenes Layout: EIN Uniform-Block je Frame (kein dynamischer Offset).
        let arc_bind_group_layout =
            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                label: Some("arc.globals.layout"),
                entries: &[wgpu::BindGroupLayoutEntry {
                    binding: 0,
                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
                    ty: wgpu::BindingType::Buffer {
                        ty: wgpu::BufferBindingType::Uniform,
                        has_dynamic_offset: false,
                        min_binding_size: wgpu::BufferSize::new(
                            std::mem::size_of::<ArcGlobals>() as u64,
                        ),
                    },
                    count: None,
                }],
            });
        let arc_pipeline_layout =
            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
                label: Some("arc.pipeline.layout"),
                bind_group_layouts: &[Some(&arc_bind_group_layout)],
                immediate_size: 0,
            });
        // Instanz-Layout: [center vec2, geom vec4, color vec4, wdash vec4, dash vec4],
        // stride 18*4, step_mode Instance. Das Quad kommt aus @builtin(vertex_index).
        let arc_instance_layout = wgpu::VertexBufferLayout {
            array_stride: 18 * 4,
            step_mode: wgpu::VertexStepMode::Instance,
            attributes: &wgpu::vertex_attr_array![
                0 => Float32x2, 1 => Float32x4, 2 => Float32x4, 3 => Float32x4, 4 => Float32x4
            ],
        };
        let arc_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
            label: Some("arc.pipeline"),
            layout: Some(&arc_pipeline_layout),
            vertex: wgpu::VertexState {
                module: &arc_module,
                entry_point: Some("vs_main"),
                buffers: &[arc_instance_layout],
                compilation_options: Default::default(),
            },
            fragment: Some(wgpu::FragmentState {
                module: &arc_module,
                entry_point: Some("fs_main"),
                targets: &[Some(wgpu::ColorTargetState {
                    format: color_format,
                    blend,
                    write_mask: wgpu::ColorWrites::ALL,
                })],
                compilation_options: Default::default(),
            }),
            primitive: wgpu::PrimitiveState {
                topology: wgpu::PrimitiveTopology::TriangleList,
                cull_mode: None,
                ..Default::default()
            },
            depth_stencil: None,
            multisample: wgpu::MultisampleState {
                count: SAMPLE_COUNT,
                mask: !0,
                alpha_to_coverage_enabled: false,
            },
            multiview_mask: None,
            cache: None,
        });
        let arc_uniform = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("arc.globals.buffer"),
            size: std::mem::size_of::<ArcGlobals>() as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let arc_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("arc.globals.bind"),
            layout: &arc_bind_group_layout,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: arc_uniform.as_entire_binding(),
            }],
        });

        // Block-Stride = Globals auf die Dynamic-Offset-Ausrichtung des Geraets gepolstert.
        let min_align = device.limits().min_uniform_buffer_offset_alignment as u64;
        let uniform_stride = align_up(std::mem::size_of::<Globals>() as u64, min_align.max(1));

        Self {
            fill_pipeline,
            line_pipeline,
            bind_group_layout,
            arc_pipeline,
            arc_uniform,
            arc_bind_group,
            uniform_stride,
            uniform: None,
            scene: None,
            format: color_format,
            msaa_view: None,
            msaa_size: (0, 0),
            text: None,
            text_family: DEFAULT_TEXT_FAMILY.to_string(),
            pending_font: None,
            // #f0f0f0 (== --sheet). 0.941 entspricht dem WebGL-clearColor.
            clear_color: wgpu::Color {
                r: 0.941,
                g: 0.941,
                b: 0.941,
                a: 1.0,
            },
            paper_scale_n: 100.0,
            text_scale_n: 100.0,
        }
    }

    /// Kompiliert eine Szene und laedt die GPU-Puffer hoch. Alles ist zoom-
    /// invariant — Papier-mm-Striche wie auch die analytischen Boegen werden erst
    /// im Shader auf Geraete-px abgebildet. Daher KEIN Zoom-Parameter und keine
    /// Neu-Tessellierung bei Zoomaenderung mehr (der frueher noetige Bogen-Re-Tess-
    /// Trigger entfaellt: der Kreis-Shader rendert bei jeder Skala exakt rund).
    pub fn upload_scene(&mut self, device: &wgpu::Device, scene: &Scene) {
        let geo = compile_scene(scene);

        let mk_vbo = |data: &[f32], label: &str| -> Option<wgpu::Buffer> {
            if data.is_empty() {
                return None;
            }
            Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                label: Some(label),
                contents: bytemuck::cast_slice(data),
                usage: wgpu::BufferUsages::VERTEX,
            }))
        };
        let mk_ibo = |data: &[u32], label: &str| -> Option<wgpu::Buffer> {
            if data.is_empty() {
                return None;
            }
            Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                label: Some(label),
                contents: bytemuck::cast_slice(data),
                usage: wgpu::BufferUsages::INDEX,
            }))
        };

        // Bogen-Instanzen (je Bogen EINE) in einen Instanz-Vertexpuffer packen.
        let arc_instances: Vec<ArcInstance> =
            geo.arcs.iter().map(ArcInstance::from_data).collect();
        let arc_count = arc_instances.len() as u32;
        let arc_vbo = if arc_instances.is_empty() {
            None
        } else {
            Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
                label: Some("arc.instances"),
                contents: bytemuck::cast_slice(&arc_instances),
                usage: wgpu::BufferUsages::VERTEX,
            }))
        };

        self.scene = Some(SceneBuffers {
            fill_vbo: mk_vbo(&geo.fill_pos, "fill.vbo"),
            fill_ibo: mk_ibo(&geo.fill_idx, "fill.ibo"),
            line_vbo: mk_vbo(&geo.line_verts, "line.vbo"),
            line_ibo: mk_ibo(&geo.line_idx, "line.ibo"),
            arc_vbo,
            arc_count,
            geo,
            texts: scene.texts.clone(),
        });
    }

    /// Reicht Font-Bytes (TrueType/OpenType) nach, die beim ersten `ensure_text`
    /// in die FontSystem-DB geladen werden. Noetig im Web-Pfad, wo cosmic-text
    /// KEINE Systemfonts findet — ohne das bliebe der Grundriss textlos.
    pub fn load_font(&mut self, bytes: Vec<u8>) {
        self.pending_font = Some(bytes);
    }

    /// Erstellt den Glyphen-Textpass beim ersten Bedarf (FontSystem laedt die
    /// Systemfonts einmalig). Der TextRenderer MUSS mit demselben
    /// MultisampleState wie die Geometrie-Pipelines gebaut werden (MSAA 4x),
    /// sonst passt er nicht in den gemeinsamen Render-Pass.
    fn ensure_text(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
        if self.text.is_some() {
            return;
        }
        let mut font_system = glyphon::FontSystem::new();
        // Nachgereichte Font-Bytes (Web-Pfad) in die DB laden und die tatsaechlich
        // registrierte Familie als Text-Familie uebernehmen (die Inter-Variable-
        // Datei meldet sich z.B. als "Inter Variable", nicht "Inter").
        if let Some(bytes) = self.pending_font.take() {
            let db = font_system.db_mut();
            let before: Vec<_> = db.faces().map(|f| f.id).collect();
            db.load_font_data(bytes);
            if let Some(face) = db.faces().find(|f| !before.contains(&f.id)) {
                if let Some((name, _)) = face.families.first() {
                    self.text_family = name.clone();
                }
            }
        }
        let cache = glyphon::Cache::new(device);
        let viewport = glyphon::Viewport::new(device, &cache);
        // ColorMode::Web statt Default (Accurate): die Surface ist non-sRGB und
        // ALLE Geometrie-Passes schreiben ihre sRGB-Farbwerte unkonvertiert —
        // Accurate wuerde die Textfarbe nach linear wandeln und die Glyphen
        // deutlich dunkler zeichnen als der SVG-Referenzpfad (z.B. #5a7a9a ->
        // (26,50,82) statt (90,122,154)).
        let mut atlas = glyphon::TextAtlas::with_color_mode(
            device,
            queue,
            &cache,
            self.format,
            glyphon::ColorMode::Web,
        );
        let renderer = glyphon::TextRenderer::new(
            &mut atlas,
            device,
            wgpu::MultisampleState {
                count: SAMPLE_COUNT,
                mask: !0,
                alpha_to_coverage_enabled: false,
            },
            None,
        );
        self.text = Some(TextState {
            font_system,
            swash_cache: glyphon::SwashCache::new(),
            viewport,
            atlas,
            renderer,
        });
    }

    /// Stellt sicher, dass der dynamische Uniform-Puffer mindestens `count`
    /// Bloecke fasst; realloziert (Puffer + Bind-Group) bei Bedarf.
    fn ensure_uniform_capacity(&mut self, device: &wgpu::Device, count: u64) {
        let need = count.max(1);
        if let Some(u) = &self.uniform {
            if u.capacity >= need {
                return;
            }
        }
        let size = self.uniform_stride * need;
        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("globals.buffer"),
            size,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("globals.bind"),
            layout: &self.bind_group_layout,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
                    buffer: &buffer,
                    offset: 0,
                    size: wgpu::BufferSize::new(std::mem::size_of::<Globals>() as u64),
                }),
            }],
        });
        self.uniform = Some(UniformArena {
            buffer,
            bind_group,
            capacity: need,
        });
    }

    /// Stellt sicher, dass eine 4x-MSAA-Farbtextur passend zur Ziel-Groesse
    /// existiert; realloziert nur bei Groessenaenderung (analog ensure_depth in 3D).
    fn ensure_msaa(&mut self, device: &wgpu::Device, w: u32, h: u32) {
        let (w, h) = (w.max(1), h.max(1));
        if self.msaa_view.is_some() && self.msaa_size == (w, h) {
            return;
        }
        let texture = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("msaa.color"),
            size: wgpu::Extent3d {
                width: w,
                height: h,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: SAMPLE_COUNT,
            dimension: wgpu::TextureDimension::D2,
            format: self.format,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            view_formats: &[],
        });
        self.msaa_view = Some(texture.create_view(&wgpu::TextureViewDescriptor::default()));
        self.msaa_size = (w, h);
    }

    /// Zeichnet einen Frame in `view` (Surface- oder Offscreen-Textur).
    /// Pan/Zoom kommt ueber `view_box`/`viewport`; nur die Ortho-Matrix aendert sich.
    pub fn render(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        view: &wgpu::TextureView,
        view_box: ViewBox,
        viewport: (u32, u32),
    ) {
        let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
        let proj: Mat4 = compute_ortho_matrix(view_box, vw, vh);
        let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
        // Eigene mm->px-Umrechnung fuer Text (siehe `text_scale_n`-Doku): gleiche
        // Formel, aber am Text-Referenz-Massstab statt am Geometrie-Massstab.
        let text_mm_px = mm_to_device_px(view_box, vw, vh, self.text_scale_n);
        // Geraete-px je Bildschirm-Einheit (== meet-Skala) — der Bogen-Shader braucht
        // beides: px_per_screen fuer Radial-/Kappen-/Dash-AA, stroke_scale fuer die
        // Papier-mm-Breite (identisch zu den Linien).
        let px_per_screen = meet_scale(view_box, vw, vh);

        // Frame-Uniform der Bogen-Pipeline schreiben (ein Block fuer ALLE Boegen).
        queue.write_buffer(
            &self.arc_uniform,
            0,
            bytemuck::bytes_of(&ArcGlobals {
                view_proj: proj,
                viewport_px: [vw, vh],
                px_per_screen,
                stroke_scale: mm_px,
            }),
        );

        // Alle Globals-Bloecke in MALER-Reihenfolge (`draw_sequence`, interleaved
        // Fuell-/Linien-Batches) sammeln, den Puffer einmal schreiben, danach nur
        // noch dynamisch binden — Reihenfolge identisch zur Draw-Schleife unten.
        let mut blocks: Vec<Globals> = Vec::new();
        if let Some(scene) = &self.scene {
            for &(kind, idx) in &scene.geo.draw_sequence {
                match kind {
                    BatchKind::Fill => {
                        let b = &scene.geo.fill_batches[idx as usize];
                        blocks.push(Globals {
                            view_proj: proj,
                            color: b.color,
                            viewport_px: [vw, vh],
                            stroke_px: 0.0,
                            stroke_scale: mm_px,
                        });
                    }
                    BatchKind::Line => {
                        let b = &scene.geo.line_batches[idx as usize];
                        blocks.push(Globals {
                            view_proj: proj,
                            color: b.color,
                            viewport_px: [vw, vh],
                            stroke_px: b.stroke_mm,
                            // Schraffur-Striche (`width_screen`) sind an die
                            // Musterkachel gekoppelt (Bildschirm-Einheiten,
                            // zoom-skalierend wie der SVG-`<pattern>`-Strich) —
                            // Geraete-px = Breite * meet-Skala statt * mm->px.
                            stroke_scale: if b.width_screen { px_per_screen } else { mm_px },
                        });
                    }
                }
            }
        }

        self.ensure_uniform_capacity(device, blocks.len() as u64);

        // ── Textpass vorbereiten (echte Glyphen via Atlas) ────────────────────
        // Pixelgroesse = Papier-mm * mm_px (dieselbe Formel wie die Strich-
        // breiten) -> Text waechst beim Zoomen exakt wie im Browser. Die Anker
        // werden ueber den aspekt-korrigierten viewBox in Fenster-Pixel
        // projiziert; die Ausrichtung nutzt die gemessene Zeilenbreite.
        let has_texts = self
            .scene
            .as_ref()
            .map_or(false, |s| !s.texts.is_empty());
        if has_texts || self.text.is_some() {
            self.ensure_text(device, queue);
            // Familie VOR dem mutablen Borrow von self.text kopieren (ensure_text
            // hat sie ggf. auf die geladene Font-Familie umgestellt).
            let family = self.text_family.clone();
            let ts = self.text.as_mut().expect("ensure_text");
            let cvb = corrected_view_box(view_box, vw, vh);
            let meet = meet_scale(view_box, vw, vh);

            // Je sichtbarer Zeile ein geshapter Buffer + Platzierung.
            struct Placed {
                buffer: glyphon::Buffer,
                left: f32,
                top: f32,
                color: glyphon::Color,
            }
            let mut placed: Vec<Placed> = Vec::new();
            if let Some(scene) = &self.scene {
                let attrs = glyphon::Attrs::new()
                    .family(glyphon::Family::Name(&family));
                for t in &scene.texts {
                    let font_px = t.size_mm * text_mm_px;
                    // Unlesbar kleine Glyphen ueberspringen (spart Shaping/Atlas).
                    if !(font_px >= 1.0) || t.content.is_empty() {
                        continue;
                    }
                    // Anker (Modell-Meter) -> Fenster-Pixel: erst Bildschirm-Raum
                    // (to_screen), dann viewBox-Projektion (meet-Skala).
                    let s = to_screen(t.pos);
                    let ax = (s[0] - cvb.x) * meet;
                    let ay = (s[1] - cvb.y) * meet;
                    // Grober Sichtbarkeits-Test VOR dem Shaping (Buffer sparen):
                    // grosszuegige Marge, da die Zeilenbreite noch unbekannt ist.
                    let margin = font_px * (t.content.len() as f32 + 2.0);
                    if ax < -margin || ax > vw + margin || ay < -2.0 * font_px || ay > vh + 2.0 * font_px {
                        continue;
                    }

                    let mut buffer = glyphon::Buffer::new(
                        &mut ts.font_system,
                        glyphon::Metrics::new(font_px, font_px * 1.2),
                    );
                    // Unbegrenzt: eine Zeile, kein Umbruch (Zeilen sind vorgeflacht).
                    buffer.set_size(&mut ts.font_system, None, None);
                    // Kein Align-Override (None): die Ausrichtung rechnet der
                    // Platzierungscode unten selbst ueber die gemessene Zeilenbreite.
                    buffer.set_text(
                        &mut ts.font_system,
                        &t.content,
                        &attrs,
                        glyphon::Shaping::Advanced,
                        None,
                    );

                    // Gemessene Breite + Baseline fuer Ausrichtung/Anker.
                    let (line_w, baseline) = buffer
                        .layout_runs()
                        .next()
                        .map(|r| (r.line_w, r.line_y))
                        .unwrap_or((0.0, font_px * 0.8));
                    let left = match t.align {
                        TextAlign::Left => ax,
                        TextAlign::Center => ax - line_w / 2.0,
                        TextAlign::Right => ax - line_w,
                    };
                    let top = ay - baseline; // Anker-Y ist die BASELINE der Zeile.
                    // Praeziser Sichtbarkeits-Test mit gemessener Breite.
                    if left > vw || left + line_w < 0.0 || top > vh || top + font_px * 1.4 < 0.0 {
                        continue;
                    }
                    let c = t.color;
                    let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
                    placed.push(Placed {
                        buffer,
                        left,
                        top,
                        color: glyphon::Color::rgba(to8(c[0]), to8(c[1]), to8(c[2]), to8(c[3])),
                    });
                }
            }

            ts.viewport.update(
                queue,
                glyphon::Resolution {
                    width: viewport.0,
                    height: viewport.1,
                },
            );
            let bounds = glyphon::TextBounds {
                left: 0,
                top: 0,
                right: viewport.0 as i32,
                bottom: viewport.1 as i32,
            };
            let areas = placed.iter().map(|p| glyphon::TextArea {
                buffer: &p.buffer,
                left: p.left,
                top: p.top,
                scale: 1.0,
                bounds,
                default_color: p.color,
                custom_glyphs: &[],
            });
            if let Err(e) = ts.renderer.prepare(
                device,
                queue,
                &mut ts.font_system,
                &mut ts.atlas,
                &ts.viewport,
                areas,
                &mut ts.swash_cache,
            ) {
                eprintln!("render2d Text-prepare fehlgeschlagen: {e:?}");
            }
            // Nicht mehr benoetigte Glyphen aus dem Atlas werfen (haelt ihn klein).
            ts.atlas.trim();
        }

        // Alle Bloecke in den (auf uniform_stride gepolsterten) Puffer schreiben.
        if let Some(u) = &self.uniform {
            let stride = self.uniform_stride as usize;
            let mut bytes = vec![0u8; stride * blocks.len().max(1)];
            for (i, g) in blocks.iter().enumerate() {
                let off = i * stride;
                bytes[off..off + std::mem::size_of::<Globals>()]
                    .copy_from_slice(bytemuck::bytes_of(g));
            }
            if !blocks.is_empty() {
                queue.write_buffer(&u.buffer, 0, &bytes);
            }
        }

        // In die 4x-MSAA-Textur zeichnen und in die Surface aufloesen (Resolve):
        // das glaettet alle Fuell-/Linienkanten, die Aufrufer bleiben unveraendert.
        self.ensure_msaa(device, viewport.0, viewport.1);
        let msaa_view = self.msaa_view.as_ref().unwrap();

        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("2d.encoder"),
        });
        {
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("2d.pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: msaa_view,
                    // Ziel ist eine 2D-Textur (kein 3D-Volumen) -> kein Depth-Slice.
                    depth_slice: None,
                    resolve_target: Some(view),
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(self.clear_color),
                        store: wgpu::StoreOp::Store,
                    },
                })],
                depth_stencil_attachment: None,
                timestamp_writes: None,
                occlusion_query_set: None,
                multiview_mask: None,
            });

            if let (Some(scene), Some(u)) = (&self.scene, &self.uniform) {
                let stride = self.uniform_stride as u32;

                // Geometrie in MALER-Reihenfolge (`draw_sequence`): Fuell- und
                // Linien-Batches interleaved, Pipeline-/Puffer-Wechsel nur beim
                // Wechsel der Batch-Art. Nur so deckt eine spaetere Fuellung
                // einen frueheren Strich ab (SVG-Paritaet, Primitive-Reihenfolge).
                let mut bound: Option<BatchKind> = None;
                for (block, &(kind, idx)) in scene.geo.draw_sequence.iter().enumerate() {
                    match kind {
                        BatchKind::Fill => {
                            let (Some(vbo), Some(ibo)) = (&scene.fill_vbo, &scene.fill_ibo)
                            else {
                                continue;
                            };
                            if bound != Some(BatchKind::Fill) {
                                pass.set_pipeline(&self.fill_pipeline);
                                pass.set_vertex_buffer(0, vbo.slice(..));
                                pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
                                bound = Some(BatchKind::Fill);
                            }
                            let b = &scene.geo.fill_batches[idx as usize];
                            pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]);
                            let start = b.start_index;
                            pass.draw_indexed(start..start + b.index_count, 0, 0..1);
                        }
                        BatchKind::Line => {
                            let (Some(vbo), Some(ibo)) = (&scene.line_vbo, &scene.line_ibo)
                            else {
                                continue;
                            };
                            if bound != Some(BatchKind::Line) {
                                pass.set_pipeline(&self.line_pipeline);
                                pass.set_vertex_buffer(0, vbo.slice(..));
                                pass.set_index_buffer(ibo.slice(..), wgpu::IndexFormat::Uint32);
                                bound = Some(BatchKind::Line);
                            }
                            let b = &scene.geo.line_batches[idx as usize];
                            pass.set_bind_group(0, &u.bind_group, &[block as u32 * stride]);
                            let start = b.start_index;
                            pass.draw_indexed(start..start + b.index_count, 0, 0..1);
                        }
                    }
                }
            }

            // 3) Analytische Boegen: eigene Pipeline, EIN Frame-Uniform, je Bogen
            //    EINE Instanz (6 Vertices, Quad aus vertex_index). Mathematisch
            //    exakt rund per SDF — nach den Linien, damit der Schwenkbogen ueber
            //    dem Tuerblatt liegt.
            if let Some(scene) = &self.scene {
                if let Some(arc_vbo) = &scene.arc_vbo {
                    if scene.arc_count > 0 {
                        pass.set_pipeline(&self.arc_pipeline);
                        pass.set_bind_group(0, &self.arc_bind_group, &[]);
                        pass.set_vertex_buffer(0, arc_vbo.slice(..));
                        pass.draw(0..6, 0..scene.arc_count);
                    }
                }
            }

            // 4) Text ZUOBERST im selben MSAA-Pass (der TextRenderer wurde mit
            //    identischem MultisampleState erstellt, siehe ensure_text).
            if let Some(ts) = &self.text {
                if let Err(e) = ts.renderer.render(&ts.atlas, &ts.viewport, &mut pass) {
                    eprintln!("render2d Text-render fehlgeschlagen: {e:?}");
                }
            }
        }
        queue.submit(std::iter::once(encoder.finish()));
    }
}
