//! GPU-Canvas-Renderpipeline mit wgpu und Low-Latency-Verwaltung. //! //! Features: //! - Zero-Latency-Pufferung via `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`. //! - Strikte Pipeline-Trennung: DIN-A4-Hintergrund, permanente Striche und temporäre Live-Geometrie. //! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms. //! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`). use std::ops::Range; use std::sync::Arc; use winit::dpi::PhysicalSize; use winit::window::Window; use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme}; use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; /// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color). #[repr(C)] #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] pub struct InkVertex { pub position: [f32; 2], pub color: [f32; 4], } impl InkVertex { pub fn new(x: f32, y: f32, color: [f32; 4]) -> Self { Self { position: [x, y], color, } } pub fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ wgpu::VertexAttribute { offset: 0, shader_location: 0, format: wgpu::VertexFormat::Float32x2, }, wgpu::VertexAttribute { offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, shader_location: 1, format: wgpu::VertexFormat::Float32x4, }, ], } } } /// Uniform-Daten für die 2D-Kamera (Scale und Offset im NDC-Raum). #[repr(C)] #[derive(Copy, Clone, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] pub struct CameraUniforms { pub transform_scale: [f32; 2], pub transform_offset: [f32; 2], /// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung) pub ink_params: [f32; 4], } /// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0]. #[inline] pub fn pixel_to_ndc(x: f32, y: f32, width: f32, height: f32) -> [f32; 2] { let w = width.max(1.0); let h = height.max(1.0); let x_ndc = (x / w) * 2.0 - 1.0; let y_ndc = 1.0 - (y / h) * 2.0; [x_ndc, y_ndc] } /// Transformiert eine Folge von Spline-Vertices aus dem Pixelraum in NDC-`InkVertex`-Elemente. pub fn spline_vertices_to_ndc( vertices: &[spline::Vertex], width: f32, height: f32, color: [f32; 4], ) -> Vec { vertices .iter() .map(|v| { let ndc = pixel_to_ndc(v.position[0], v.position[1], width, height); InkVertex::new(ndc[0], ndc[1], color) }) .collect() } /// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking. /// Meldung, wenn kein Grafikadapter das Fenster darstellen kann. pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\ Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\ Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\ und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\ oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`."; /// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten /// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo. pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option { let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok(); let preferred: &[wgpu::PresentMode] = if force_fifo { &[wgpu::PresentMode::Fifo] } else { &[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo] }; preferred .iter() .copied() .find(|m| available.contains(m)) .or_else(|| available.first().copied()) } /// Erkennt, ob die GPU-Daten einer Nachbarseite noch zur Seite passen. Änderungen an der /// aktuellen Seite verwerfen deren Eintrag ohnehin (siehe [`CanvasState::sync_page`]). #[derive(Debug, Clone, PartialEq)] struct NeighborFingerprint { page_id: u64, background: PageBackground, paper_theme: PaperTheme, vertex_count: usize, stroke_count: usize, last_stroke_id: Option, } impl NeighborFingerprint { fn of(page: &Page, paper_theme: PaperTheme) -> Self { Self { page_id: page.id, background: page.background, paper_theme, vertex_count: page.vertex_cache.len(), stroke_count: page.strokes.len(), last_stroke_id: page.strokes.last().map(|s| s.id), } } } /// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite. struct NeighborPage { fingerprint: NeighborFingerprint, /// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber) offset: i64, camera_buffer: wgpu::Buffer, camera_bind_group: wgpu::BindGroup, background_buffer: Option, background_vertex_count: u32, vertex_buffer: Option, stroke_ranges: Vec>, } pub struct CanvasState { pub surface: wgpu::Surface<'static>, /// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung) adapter: wgpu::Adapter, /// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben presentation_warned: bool, pub device: wgpu::Device, pub queue: wgpu::Queue, pub config: wgpu::SurfaceConfiguration, pub size: PhysicalSize, pub viewport: PageViewport, /// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln ui_inset_left: f32, ui_inset_right: f32, pub camera: crate::viewport::ViewportState, // Pipelines pub strip_pipeline: wgpu::RenderPipeline, pub triangle_pipeline: wgpu::RenderPipeline, // 2D-Kamera Uniforms & Bind Group (gilt einheitlich für alle Vertizes) pub camera_buffer: wgpu::Buffer, pub camera_bind_group: wgpu::BindGroup, pub current_ink_color: [f32; 4], pub current_stroke_width_mm: f32, // Statische Hintergrund-Geometrie pub paper_buffer: wgpu::Buffer, pub background_buffer: wgpu::Buffer, pub background_vertex_capacity: usize, pub background_vertex_count: u32, pub current_background: PageBackground, /// Papierfarbe der Anzeige pub paper_theme: PaperTheme, /// Dunkle Tinte auf dunklem Papier aufhellen pub adapt_ink: bool, // Pipeline-Trennung: Live-Strich (aktiver Stroke) pub live_vertex_buffer: wgpu::Buffer, pub live_vertex_capacity: usize, pub live_vertex_count: u32, pub live_samples: Vec, pub live_control_points: Vec, pub live_vertices: Vec, pub filter: OneEuroFilter, pub spline_config: SplineConfig, // Pipeline-Trennung: Abgeschlossene Striche der aktuellen Seite pub committed_vertex_buffer: wgpu::Buffer, pub committed_vertex_capacity: usize, pub committed_vertex_count: u32, pub committed_strokes: Vec>, // Eingefügte Bilder der aktuellen Seite image_layer: crate::image_layer::ImageLayer, /// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite neighbors: Vec, camera_layout: wgpu::BindGroupLayout, /// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein) pub ruler: Option, /// Kante, an der der aktuelle Live-Strich eingerastet ist ruler_lock: Option, // egui UI-Renderer pub egui_renderer: egui_wgpu::Renderer, } impl CanvasState { const INITIAL_LIVE_CAPACITY: usize = 4096; const INITIAL_COMMITTED_CAPACITY: usize = 65536; const INITIAL_BACKGROUND_CAPACITY: usize = 16384; pub const VIEWPORT_MARGIN: f32 = 24.0; /// Initialisiert den CanvasState für das übergebene Winit-Fenster. pub async fn new(window: Arc) -> Result> { let size = window.inner_size(); let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN); // 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan") let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()), ..Default::default() }); let surface = instance.create_surface(window.clone())?; // 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe). // Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi // anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer. let mut chosen = None; for force_fallback_adapter in [false, true] { let Some(adapter) = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, compatible_surface: Some(&surface), force_fallback_adapter, }) .await else { continue; }; let info = adapter.get_info(); let caps = surface.get_capabilities(&adapter); match choose_present_mode(&caps.present_modes) { Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => { eprintln!( "[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}", info.name, info.backend, mode ); chosen = Some((adapter, caps, mode)); break; } _ => eprintln!( "[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.", info.name, info.backend ), } } let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?; // 3. Device & Queue erstellen let (device, queue) = adapter .request_device( &wgpu::DeviceDescriptor { label: Some("Ink Device"), required_features: wgpu::Features::empty(), required_limits: wgpu::Limits::default(), }, None, ) .await?; // 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1 let surface_format = surface_caps .formats .iter() .copied() .find(|f| f.is_srgb()) .unwrap_or(surface_caps.formats[0]); let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: surface_format, width: size.width.max(1), height: size.height.max(1), present_mode, alpha_mode: surface_caps.alpha_modes[0], view_formats: vec![], desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame }; surface.configure(&device, &config); // 5. Shader & Bind Group Layout let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Ink WGSL Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), }); let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Stroke Bind Group Layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }], }); let (scale, offset) = viewport.ndc_transform(); let default_ink_color = [0.1, 0.1, 0.15, 1.0]; let camera_uniforms = CameraUniforms { transform_scale: scale, transform_offset: offset, ink_params: [0.0; 4], }; let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Camera Uniform Buffer"), size: std::mem::size_of::() as wgpu::BufferAddress, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms])); let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Camera Bind Group"), layout: &bind_group_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: camera_buffer.as_entire_binding(), }], }); // Pipeline Layout let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Ink Pipeline Layout"), bind_group_layouts: &[&bind_group_layout], push_constant_ranges: &[], }); // TriangleStrip-Pipeline (für Inking-Striche) let strip_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Ink Strip Render Pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[InkVertex::desc()], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_ink", targets: &[Some(wgpu::ColorTargetState { format: config.format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleStrip, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, }); // TriangleList-Pipeline (für Hintergrundpapier und Muster) let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Background TriangleList Pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[InkVertex::desc()], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format: config.format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, }); // 6. Statische Buffer let paper_theme = PaperTheme::default(); let paper_quad = generate_paper_quad(paper_theme.paper_color()); let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Paper Quad Buffer"), size: (paper_quad.len() * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); let current_background = PageBackground::Grid5mm; let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color()); let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Background Mesh Buffer"), size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh)); // 7. GPU-Puffer für Live- und Committed-Geometrie let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Live Stroke Vertex Buffer"), size: (Self::INITIAL_LIVE_CAPACITY * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let committed_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Committed Strokes Vertex Buffer"), size: (Self::INITIAL_COMMITTED_CAPACITY * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let image_layer = crate::image_layer::ImageLayer::new( &device, &shader, &bind_group_layout, config.format, ); // 8. egui Renderer für das schwebende UI initialisieren let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); Ok(Self { surface, adapter, presentation_warned: false, device, queue, config, size, viewport, ui_inset_left: 0.0, ui_inset_right: 0.0, strip_pipeline, triangle_pipeline, camera_buffer, camera_bind_group, current_ink_color: default_ink_color, current_stroke_width_mm: 0.5, paper_buffer, background_buffer, background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY, background_vertex_count: bg_mesh.len() as u32, current_background, paper_theme, adapt_ink: false, live_vertex_buffer, live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY, live_vertex_count: 0, live_samples: Vec::with_capacity(256), live_control_points: Vec::with_capacity(256), live_vertices: Vec::with_capacity(1024), filter: OneEuroFilter::default(), spline_config: SplineConfig { min_radius: 1.25, // 0.125 mm bei 10 Einheiten/mm (Standard 0.5 mm Strichbreite) max_radius: 3.75, // 0.375 mm bei vollem Druck samples_per_segment: 16, }, committed_vertex_buffer, committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY, committed_vertex_count: 0, committed_strokes: Vec::with_capacity(128), image_layer, neighbors: Vec::new(), camera_layout: bind_group_layout, ruler: None, ruler_lock: None, egui_renderer, camera: crate::viewport::ViewportState::new(), }) } /// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera. pub fn update_uniforms(&mut self) { let camera_uniforms = self.camera_uniforms(0); self.queue.write_buffer( &self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms]), ); for neighbor in &self.neighbors { self.queue.write_buffer( &neighbor.camera_buffer, 0, bytemuck::cast_slice(&[self.camera_uniforms(neighbor.offset)]), ); } } /// Kamera für die Seite `page_offset` Seiten unter (negativ: über) der aktuellen. fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms { let (scale, mut offset) = self.camera.ndc_transform(&self.viewport); offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1]; CameraUniforms { transform_scale: scale, transform_offset: offset, ink_params: [ if self.adapt_ink && self.paper_theme.is_dark() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0, ], } } /// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite /// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat. pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool { if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 { return false; } self.ui_inset_left = left; self.ui_inset_right = right; self.recalculate_viewport(); true } fn recalculate_viewport(&mut self) { self.viewport = PageViewport::calculate_with_insets( self.size.width as f32, self.size.height as f32, Self::VIEWPORT_MARGIN, self.ui_inset_left, self.ui_inset_right, ); self.update_uniforms(); } /// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an. pub fn resize(&mut self, new_size: PhysicalSize) { if new_size.width > 0 && new_size.height > 0 { self.size = new_size; self.config.width = new_size.width; self.config.height = new_size.height; // Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz let caps = self.surface.get_capabilities(&self.adapter); if !caps.present_modes.contains(&self.config.present_mode) { match choose_present_mode(&caps.present_modes) { Some(mode) => self.config.present_mode = mode, None => { if !self.presentation_warned { eprintln!( "[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}", NO_USABLE_GPU_MESSAGE ); self.presentation_warned = true; } return; } } } self.surface.configure(&self.device, &self.config); self.recalculate_viewport(); } } /// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta. pub fn pan(&mut self, delta_x: f32, delta_y: f32) { self.camera .pan_by_screen_delta(delta_x, delta_y, &self.viewport); self.update_uniforms(); } /// Zoomt zentriert auf eine Cursor-Bildschirmposition. pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) { self.camera .zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport); self.update_uniforms(); } /// Setzt Zoom und Pan auf 100% zentriert zurück. pub fn reset_view(&mut self) { self.camera.reset(); self.update_uniforms(); } /// Transformiert Fenster-Pixel in Seitenkoordinaten. pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option { self.camera .screen_to_page(screen_x, screen_y, &self.viewport) } /// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite. pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 { self.camera .screen_to_page_clamped(screen_x, screen_y, &self.viewport) } /// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten. pub fn page_to_screen(&self, page_pt: glam::Vec2) -> [f32; 2] { self.camera.page_to_screen_vec2(page_pt, &self.viewport) } /// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt, /// `None` neben den Seiten und zwischen zwei Seiten. pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option { let p = self .camera .screen_to_page_unclamped(screen_x, screen_y, &self.viewport); if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) { return None; } crate::viewport::page_offset_at(p.y) } /// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters. pub fn page_offset_at_view_center(&self) -> Option { let p = self.camera.screen_to_page_unclamped( 0.0, self.size.height as f32 / 2.0, &self.viewport, ); crate::viewport::page_offset_at(p.y) } /// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite. pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec { let top = self .camera .screen_to_page_unclamped(0.0, 0.0, &self.viewport); let bottom = self.camera .screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport); crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count) } /// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der /// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit /// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde. pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool { let center = [ self.viewport.page_x + self.viewport.page_width / 2.0, self.size.height as f32 / 2.0, ]; let changed = self.camera .clamp_continuous_center(center, current, page_count, &self.viewport); if changed { self.update_uniforms(); } changed } /// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich /// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen. pub fn shift_reference_page(&mut self, k: i64) { self.camera.shift_reference_page(k); self.update_uniforms(); } /// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold). pub fn set_live_shape_mesh(&mut self, samples: Vec, vertices: Vec) { self.live_samples = samples; self.live_vertices = vertices; self.live_vertex_count = self.live_vertices.len() as u32; if self.live_vertices.len() > self.live_vertex_capacity { self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY); self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Live Stroke Vertex Buffer (Reallocated)"), size: (self.live_vertex_capacity * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); } if !self.live_vertices.is_empty() { self.queue.write_buffer( &self.live_vertex_buffer, 0, bytemuck::cast_slice(&self.live_vertices), ); } } /// Setzt die Strichfarbe (RGB + Alpha) für neue Striche. pub fn set_stroke_color(&mut self, color: [f32; 4]) { self.current_ink_color = color; } /// Konfiguriert die Strichbreite in Millimetern. pub fn set_stroke_width_mm(&mut self, width_mm: f32) { self.current_stroke_width_mm = width_mm; self.spline_config = SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM); } /// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird. pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) { self.adapt_ink = adapt_ink; if self.paper_theme != theme { self.paper_theme = theme; self.queue.write_buffer( &self.paper_buffer, 0, bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())), ); self.upload_background_mesh(); } self.update_uniforms(); } /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. pub fn set_background(&mut self, background: PageBackground) { if self.current_background == background { return; } self.current_background = background; self.upload_background_mesh(); } /// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU. fn upload_background_mesh(&mut self) { let mesh = generate_background_mesh(self.current_background, self.paper_theme.pattern_color()); self.background_vertex_count = mesh.len() as u32; if mesh.len() > self.background_vertex_capacity { self.background_vertex_capacity = mesh.len() * 2; self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Background Mesh Buffer (Reallocated)"), size: (self.background_vertex_capacity * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); } if !mesh.is_empty() { self.queue .write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh)); } } /// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite. pub fn sync_page(&mut self, page: &mut Page) { page.rebuild_cache_if_dirty(); // Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen self.neighbors.retain(|n| n.fingerprint.page_id != page.id); self.set_background(page.background); self.image_layer.set_quads(page_image_quads(page)); self.committed_vertex_count = page.vertex_cache.len() as u32; self.committed_strokes = page.stroke_ranges.clone(); if page.vertex_cache.is_empty() { return; } if page.vertex_cache.len() > self.committed_vertex_capacity { self.committed_vertex_capacity = (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY); self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Committed Strokes Vertex Buffer (Reallocated)"), size: (self.committed_vertex_capacity * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); } self.queue.write_buffer( &self.committed_vertex_buffer, 0, bytemuck::cast_slice(&page.vertex_cache), ); } /// Durchgehende Ansicht: übernimmt die sichtbaren Nachbarseiten mit ihrem Versatz zur /// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein /// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen. /// Eine leere Liste schaltet die Nachbarseiten ab. pub fn set_neighbor_pages(&mut self, pages: &[(i64, &Page)]) { let mut old = std::mem::take(&mut self.neighbors); let mut image_groups = Vec::with_capacity(pages.len()); for &(offset, page) in pages { let fingerprint = NeighborFingerprint::of(page, self.paper_theme); let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) { Some(i) => old.swap_remove(i), None => self.upload_neighbor(page, fingerprint), }; neighbor.offset = offset; self.queue.write_buffer( &neighbor.camera_buffer, 0, bytemuck::cast_slice(&[self.camera_uniforms(offset)]), ); self.neighbors.push(neighbor); image_groups.push(page_image_quads(page)); } self.image_layer.set_neighbor_quads(image_groups); } /// Lädt Raster und Striche einer Nachbarseite auf die GPU. fn upload_neighbor(&self, page: &Page, fingerprint: NeighborFingerprint) -> NeighborPage { let camera_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Neighbor Camera Uniform Buffer"), size: std::mem::size_of::() as wgpu::BufferAddress, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let camera_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Neighbor Camera Bind Group"), layout: &self.camera_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: camera_buffer.as_entire_binding(), }], }); let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| { (!vertices.is_empty()).then(|| { let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size: std::mem::size_of_val(vertices) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); self.queue .write_buffer(&buffer, 0, bytemuck::cast_slice(vertices)); buffer }) }; let background = generate_background_mesh(page.background, self.paper_theme.pattern_color()); NeighborPage { fingerprint, offset: 0, camera_buffer, camera_bind_group, background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background), background_vertex_count: background.len() as u32, vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache), stroke_ranges: page.stroke_ranges.clone(), } } /// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen. /// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts. pub fn prepare_images( &mut self, load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource, ) { self.image_layer.prepare(&self.device, &self.queue, load); } /// Wird noch eine PDF-Seite im Hintergrund gerendert? pub fn is_waiting_for_images(&self) -> bool { self.image_layer.is_waiting() } /// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten. pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { self.filter.reset(); self.live_samples.clear(); self.live_control_points.clear(); self.live_vertices.clear(); self.live_vertex_count = 0; // Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0; self.ruler_lock = self .ruler .and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width)); let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y); // 1. Zwingend durch den 1€-Filter leiten let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let pt = ControlPoint::new(fx, fy, fp); self.live_control_points.push(pt); let sample = StylusSample { position: glam::Vec2::new(fx, fy), pressure: fp, timestamp, }; self.live_samples.push(sample); self.update_live_mesh(); } /// Fügt dem aktuellen Live-Strich einen neuen Bewegungspunkt hinzu. pub fn extend_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { if self.live_control_points.is_empty() { self.start_stroke(page_x, page_y, pressure, timestamp); return; } // 1. Zwingend durch den 1€-Filter leiten let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y); let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let new_pt = ControlPoint::new(fx, fy, fp); // 2. Mindestabstand zu vorherigem Kontrollpunkt prüfen (0.3 Einheiten = 0.03 mm für hohe Pfadpräzision) if let Some(last) = self.live_control_points.last() { let dist_sq = (new_pt.position - last.position).length_squared(); if dist_sq < 0.09 { return; } } // 3. Gefilterten Punkt synchron in Samples und Kontrollpunkten ablegen let sample = StylusSample { position: glam::Vec2::new(fx, fy), pressure: fp, timestamp, }; self.live_samples.push(sample); self.live_control_points.push(new_pt); self.update_live_mesh(); } /// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist. fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) { match self.ruler_lock { Some(lock) => lock.project(glam::Vec2::new(x, y)).into(), None => (x, y), } } /// Ist der aktuelle Live-Strich an einer Linealkante eingerastet? pub fn is_ruler_locked(&self) -> bool { self.ruler_lock.is_some() } /// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück. pub fn cancel_stroke(&mut self) { self.live_samples.clear(); self.live_control_points.clear(); self.live_vertices.clear(); self.live_vertex_count = 0; self.filter.reset(); } /// Schließt den aktuellen Live-Strich ab und erzeugt das fertige `Stroke`-Objekt nach dem Zwei-Stufen-Modell. pub fn finish_stroke(&mut self, stroke_id: u64) -> Option { if self.live_samples.is_empty() { self.live_samples.clear(); self.live_control_points.clear(); self.live_vertices.clear(); self.live_vertex_count = 0; self.filter.reset(); return None; } // Post-Stroke Finalisierung (Corner Preservation, Druckglättung, RDP, runde Kappen) let stroke = spline::finalize_stroke_with_config( stroke_id, &self.live_samples, self.current_ink_color, &self.spline_config, ); let mut stroke = stroke; stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM; stroke.finalized = true; // Temporären GPU-Live-Buffer vollständig leeren self.live_samples.clear(); self.live_control_points.clear(); self.live_vertices.clear(); self.live_vertex_count = 0; self.filter.reset(); Some(stroke) } /// Baut das Mesh des Live-Strichs in Seitenkoordinaten neu auf. fn update_live_mesh(&mut self) { if self.live_control_points.is_empty() { self.live_vertex_count = 0; self.live_vertices.clear(); return; } let color = self.current_ink_color; let raw_strip = spline::generate_triangle_strip(&self.live_control_points, &self.spline_config); self.live_vertices = raw_strip .into_iter() .map(|v| InkVertex::new(v.position[0], v.position[1], color)) .collect(); self.live_vertex_count = self.live_vertices.len() as u32; if self.live_vertex_count == 0 { return; } if self.live_vertices.len() > self.live_vertex_capacity { self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY); self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Live Stroke Vertex Buffer (Reallocated)"), size: (self.live_vertex_capacity * std::mem::size_of::()) as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); } self.queue.write_buffer( &self.live_vertex_buffer, 0, bytemuck::cast_slice(&self.live_vertices), ); } /// Rendert den gesamten Viewport: /// - Pass 1: Schreibtisch, DIN-A4-Papier, Hintergrundraster, permanente Striche und Live-Strich. /// - Pass 2: egui UI (Toolbar, Menüs, Toast) über `LoadOp::Load` auf dieselbe Surface-Textur. pub fn render( &mut self, clipped_primitives: &[egui::ClippedPrimitive], screen_descriptor: &egui_wgpu::ScreenDescriptor, textures_delta: &egui::TexturesDelta, ) -> Result<(), wgpu::SurfaceError> { // Vor dem Renderpass: egui-Texturen aktualisieren for (id, image_delta) in &textures_delta.set { self.egui_renderer .update_texture(&self.device, &self.queue, *id, image_delta); } let output = self.surface.get_current_texture()?; let view = output .texture .create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Ink & UI Render Encoder"), }); // egui Vertex- und Index-Puffer aktualisieren self.egui_renderer.update_buffers( &self.device, &self.queue, &mut encoder, clipped_primitives, screen_descriptor, ); // PASS 1: Canvas (Hintergrund, Papierquadrat, Gittermuster, Striche) { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Ink Canvas Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); // 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera) for (i, neighbor) in self.neighbors.iter().enumerate() { render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]); render_pass.set_pipeline(&self.triangle_pipeline); render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); render_pass.draw(0..6, 0..1); if let Some(buffer) = &neighbor.background_buffer { render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.draw(0..neighbor.background_vertex_count, 0..1); } self.image_layer.draw(&mut render_pass, i + 1); if let Some(buffer) = &neighbor.vertex_buffer { render_pass.set_pipeline(&self.strip_pipeline); render_pass.set_vertex_buffer(0, buffer.slice(..)); for range in &neighbor.stroke_ranges { if range.end > range.start { render_pass.draw(range.clone(), 0..1); } } } } // 2D-Kamera Uniforms (Scale & Offset) binden render_pass.set_bind_group(0, &self.camera_bind_group, &[]); // 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt) render_pass.set_pipeline(&self.triangle_pipeline); render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); render_pass.draw(0..6, 0..1); // 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe) if self.background_vertex_count > 0 { render_pass.set_vertex_buffer(0, self.background_buffer.slice(..)); render_pass.draw(0..self.background_vertex_count, 0..1); } // 3. Eingefügte Bilder (unter der Tinte) self.image_layer.draw(&mut render_pass, 0); // 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben) render_pass.set_pipeline(&self.strip_pipeline); if self.committed_vertex_count > 0 { render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..)); for range in &self.committed_strokes { if range.end > range.start { render_pass.draw(range.clone(), 0..1); } } } // 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency) if self.live_vertex_count > 0 { render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); render_pass.draw(0..self.live_vertex_count, 0..1); } } // PASS 2: egui UI (LoadOp::Load auf dieselbe Surface-Textur!) if !clipped_primitives.is_empty() { let mut egui_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("egui UI Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); self.egui_renderer .render(&mut egui_pass, clipped_primitives, screen_descriptor); } self.queue.submit(std::iter::once(encoder.finish())); output.present(); // egui Texturen freigeben for id in &textures_delta.free { self.egui_renderer.free_texture(id); } Ok(()) } /// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests). pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> { let dummy_delta = egui::TexturesDelta::default(); let dummy_screen = egui_wgpu::ScreenDescriptor { size_in_pixels: [self.size.width, self.size.height], pixels_per_point: 1.0, }; self.render(&[], &dummy_screen, &dummy_delta) } } /// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten Bilder. fn page_image_quads(page: &Page) -> Vec<(String, [glam::Vec2; 4])> { page.pdf_background .iter() .map(|pdf| (pdf.key(), pdf.corners())) .chain( page.strokes .iter() .filter_map(|s| Some((s.image.clone()?, s.image_corners()?))), ) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_choose_present_mode() { use wgpu::PresentMode::{Fifo, Immediate, Mailbox}; assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox)); assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo)); assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate)); // Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None assert_eq!(choose_present_mode(&[]), None); } #[test] fn test_pixel_to_ndc_mapping() { let width = 800.0; let height = 600.0; let top_left = pixel_to_ndc(0.0, 0.0, width, height); assert!((top_left[0] - (-1.0)).abs() < 1e-5); assert!((top_left[1] - 1.0).abs() < 1e-5); let center = pixel_to_ndc(400.0, 300.0, width, height); assert!(center[0].abs() < 1e-5); assert!(center[1].abs() < 1e-5); } #[test] fn test_ink_vertex_layout() { assert_eq!(std::mem::size_of::(), 24); let v = InkVertex::new(0.5, -0.5, [1.0, 0.0, 0.0, 1.0]); assert_eq!(v.position, [0.5, -0.5]); assert_eq!(v.color, [1.0, 0.0, 0.0, 1.0]); } #[test] fn test_camera_uniforms() { let _u = CameraUniforms::default(); // Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4 assert_eq!(std::mem::size_of::(), 32); } #[test] fn test_shader_is_valid_and_matches_uniforms() { // Ein Shaderfehler fiele sonst erst beim Programmstart auf let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl")) .unwrap_or_else(|e| panic!("WGSL-Fehler: {e}")); naga::valid::Validator::new( naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty(), ) .validate(&module) .unwrap_or_else(|e| panic!("Shader ungültig: {e:?}")); for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] { assert!( module.entry_points.iter().any(|ep| ep.name == name), "Einstiegspunkt {name} fehlt" ); } let camera = module .types .iter() .find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms")) .expect("CameraUniforms fehlt im Shader") .1; match camera.inner { naga::TypeInner::Struct { span, .. } => { assert_eq!(span as usize, std::mem::size_of::()) } ref other => panic!("CameraUniforms ist kein Struct: {other:?}"), } } #[test] fn test_viewport_aspect_ratio_ndc_transform() { let vp = PageViewport::calculate(1920.0, 1080.0, 20.0); let (scale, offset) = vp.ndc_transform(); // Zentrum der DIN-A4-Seite muss im NDC-Zentrum (0, 0) landen let center_x = 1050.0; // PAGE_WIDTH / 2 let center_y = 1485.0; // PAGE_HEIGHT / 2 let ndc_x = center_x * scale[0] + offset[0]; let ndc_y = center_y * scale[1] + offset[1]; assert!(ndc_x.abs() < 1e-4, "NDC X ({ndc_x}) muss nahe 0.0 sein"); assert!(ndc_y.abs() < 1e-4, "NDC Y ({ndc_y}) muss nahe 0.0 sein"); } }