// 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, fill_ibo: Option, line_vbo: Option, line_ibo: Option, /// Instanz-Puffer der analytischen Boegen (je Bogen EINE Instanz); None wenn leer. arc_vbo: Option, /// 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, } /// 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, scene: Option, /// Farbformat der Ziel-Surface (auch Format der MSAA-Textur). format: wgpu::TextureFormat, /// Multisample-Farbtextur (4x), lazily an die Ziel-Groesse gebunden. msaa_view: Option, /// Groesse, fuer die `msaa_view` angelegt wurde. msaa_size: (u32, u32), /// Glyphen-Textpass (lazily beim ersten Frame mit Texten erstellt). text: Option, /// 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>, /// 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::() 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::() 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::() 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::() 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 { 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 { 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 = 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) { 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::() 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 = 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-``-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 = 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::()] .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 = 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())); } }