Initial commit: ink-notes (latency-optimized Linux inking app in Rust)

This commit is contained in:
2026-10-06 10:15:22 +02:00
commit d7ffef5368
20 changed files with 13549 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
//! Asynchroner Autosave-Mechanismus mit Debounce-Logik und Hintergrund-Worker-Thread.
//!
//! Stellt sicher, dass Datei-I/O niemals den 120/240 Hz Render-Loop blockiert.
//! Schnelle aufeinanderfolgende Änderungen werden gebündelt (debounced).
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use crate::document::Page;
use crate::storage::{self, NotebookManifest, PageData};
/// Aktueller Status des Autosave-Controllers für OSD und UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutosaveStatus {
/// Alle Änderungen sind dauerhaft auf der Festplatte gesichert.
Saved,
/// Es liegen ungespeicherte Änderungen vor (im Debounce-Fenster).
Pending,
/// Ein Hintergrund-Schreibvorgang wird gerade ausgeführt.
Saving,
/// Ein Fehler ist beim Schreiben aufgetreten.
Error,
}
impl AutosaveStatus {
pub fn display_text(&self) -> &'static str {
match self {
AutosaveStatus::Saved => "Gespeichert",
AutosaveStatus::Pending => "Ungespeichert",
AutosaveStatus::Saving => "Speichert...",
AutosaveStatus::Error => "Speicherfehler!",
}
}
}
/// Befehle vom Haupt-Thread an den Hintergrund-Worker.
pub enum AutosaveCommand {
SavePage {
notebook_dir: PathBuf,
page_data: PageData,
},
SaveManifest {
notebook_dir: PathBuf,
manifest: NotebookManifest,
},
Flush(Sender<()>),
Shutdown,
}
/// Antworten des Hintergrund-Workers an den Haupt-Thread.
pub enum AutosaveResponse {
PageSaved {
page_id: u64,
result: Result<(), String>,
},
ManifestSaved {
result: Result<(), String>,
},
}
/// Controller zur Verwaltung des asynchronen Autosave-Ablaufs.
pub struct AutosaveController {
notebook_dir: PathBuf,
debounce_duration: Duration,
last_change_time: Option<Instant>,
pending_pages: HashMap<u64, PageData>,
pending_manifest: Option<NotebookManifest>,
status: AutosaveStatus,
in_flight_count: usize,
total_page_writes: Arc<AtomicUsize>,
cmd_tx: Sender<AutosaveCommand>,
resp_rx: Receiver<AutosaveResponse>,
worker_handle: Option<JoinHandle<()>>,
}
impl AutosaveController {
/// Erzeugt einen neuen Controller und startet den Hintergrund-Worker-Thread.
pub fn new(notebook_dir: PathBuf, debounce_duration: Duration) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel::<AutosaveCommand>();
let (resp_tx, resp_rx) = mpsc::channel::<AutosaveResponse>();
let total_page_writes = Arc::new(AtomicUsize::new(0));
let worker_writes_counter = Arc::clone(&total_page_writes);
let worker_handle = thread::spawn(move || {
while let Ok(cmd) = cmd_rx.recv() {
match cmd {
AutosaveCommand::SavePage {
notebook_dir,
page_data,
} => {
let page_id = page_data.id;
let res = storage::save_page(&notebook_dir, &page_data);
if res.is_ok() {
worker_writes_counter.fetch_add(1, Ordering::SeqCst);
}
let _ = resp_tx.send(AutosaveResponse::PageSaved {
page_id,
result: res.map_err(|e| e.to_string()),
});
}
AutosaveCommand::SaveManifest {
notebook_dir,
manifest,
} => {
let res = storage::save_manifest(&notebook_dir, &manifest);
let _ = resp_tx.send(AutosaveResponse::ManifestSaved {
result: res.map_err(|e| e.to_string()),
});
}
AutosaveCommand::Flush(ack_tx) => {
let _ = ack_tx.send(());
}
AutosaveCommand::Shutdown => {
break;
}
}
}
});
Self {
notebook_dir,
debounce_duration,
last_change_time: None,
pending_pages: HashMap::new(),
pending_manifest: None,
status: AutosaveStatus::Saved,
in_flight_count: 0,
total_page_writes,
cmd_tx,
resp_rx,
worker_handle: Some(worker_handle),
}
}
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
let page_data = PageData::from(page);
self.pending_pages.insert(page.id, page_data);
self.pending_manifest = Some(manifest.clone());
self.last_change_time = Some(Instant::now());
self.status = AutosaveStatus::Pending;
}
/// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf des Debounce-Timers an.
///
/// Sollte regelmäßig im Event-Loop aufgerufen werden.
pub fn update(&mut self) -> AutosaveStatus {
// 1. Antworten des Hintergrund-Workers verarbeiten
while let Ok(response) = self.resp_rx.try_recv() {
if self.in_flight_count > 0 {
self.in_flight_count -= 1;
}
match response {
AutosaveResponse::PageSaved { page_id: _, result } => {
if let Err(e) = result {
eprintln!("[Autosave-Fehler] Seite konnte nicht gespeichert werden: {e}");
self.status = AutosaveStatus::Error;
}
}
AutosaveResponse::ManifestSaved { result } => {
if let Err(e) = result {
eprintln!("[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}");
self.status = AutosaveStatus::Error;
}
}
}
if self.in_flight_count == 0 && self.pending_pages.is_empty() && self.status != AutosaveStatus::Error {
self.status = AutosaveStatus::Saved;
}
}
// 2. Debounce-Timer prüfen: Wenn Zeit abgelaufen, an Worker übergeben
if !self.pending_pages.is_empty() {
if let Some(last_change) = self.last_change_time {
if last_change.elapsed() >= self.debounce_duration {
self.dispatch_pending();
}
}
}
self.status
}
/// Übergibt alle anstehenden Änderungen sofort an den Worker-Kanal.
fn dispatch_pending(&mut self) {
for (_page_id, page_data) in self.pending_pages.drain() {
let cmd = AutosaveCommand::SavePage {
notebook_dir: self.notebook_dir.clone(),
page_data,
};
if self.cmd_tx.send(cmd).is_ok() {
self.in_flight_count += 1;
}
}
if let Some(manifest) = self.pending_manifest.take() {
let cmd = AutosaveCommand::SaveManifest {
notebook_dir: self.notebook_dir.clone(),
manifest,
};
if self.cmd_tx.send(cmd).is_ok() {
self.in_flight_count += 1;
}
}
self.status = AutosaveStatus::Saving;
self.last_change_time = None;
}
/// Führt einen synchronen Flush aller noch ausstehenden Änderungen durch (z. B. vor Beenden).
pub fn flush(&mut self) {
// Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
self.dispatch_pending();
}
let (ack_tx, ack_rx) = mpsc::channel();
if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() {
let _ = ack_rx.recv();
}
// Restliche Antworten leeren
while let Ok(response) = self.resp_rx.try_recv() {
if self.in_flight_count > 0 {
self.in_flight_count -= 1;
}
if let AutosaveResponse::PageSaved { result: Err(e), .. } = response {
eprintln!("[Autosave-Flush] Fehler: {e}");
}
}
self.status = AutosaveStatus::Saved;
}
/// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel),
/// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden.
pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) {
self.flush();
self.notebook_dir = new_notebook_dir;
}
/// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush.
pub fn shutdown(&mut self) {
self.flush();
let _ = self.cmd_tx.send(AutosaveCommand::Shutdown);
if let Some(handle) = self.worker_handle.take() {
let _ = handle.join();
}
}
/// Gibt die Gesamtzahl aller tatsächlich auf die Disk geschriebenen Seiten zurück.
pub fn total_page_writes(&self) -> usize {
self.total_page_writes.load(Ordering::SeqCst)
}
/// Gibt den aktuellen Status zurück.
pub fn status(&self) -> AutosaveStatus {
self.status
}
}
impl Drop for AutosaveController {
fn drop(&mut self) {
if self.worker_handle.is_some() {
self.shutdown();
}
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
pub const LINE_THICKNESS: f32 = 1.5;
/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm).
pub const DOT_SIZE: f32 = 2.5;
/// Rasterabstand 5 mm (50 Einheiten).
pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0;
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
r: 0.16,
g: 0.16,
b: 0.18,
a: 1.0,
};
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
pub fn generate_paper_quad() -> Vec<InkVertex> {
vec![
InkVertex::new(0.0, 0.0, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
]
}
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline]
fn push_quad(
vertices: &mut Vec<InkVertex>,
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
) {
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
}
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
match background {
PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => {
let mut vertices = Vec::with_capacity(1200);
let half_t = LINE_THICKNESS / 2.0;
// Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
x += GRID_5MM_SPACING;
}
// Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
y += GRID_5MM_SPACING;
}
vertices
}
PageBackground::Lined => {
let mut vertices = Vec::with_capacity(600);
let half_t = LINE_THICKNESS / 2.0;
// Oberer und unterer Randabstand (z. B. 15 mm)
let margin_top = 150.0;
let margin_bottom = 150.0;
let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
y += LINED_SPACING;
}
vertices
}
PageBackground::DotGrid => {
let mut vertices = Vec::with_capacity(15000);
let half_d = DOT_SIZE / 2.0;
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_d, y - half_d, x + half_d, y + half_d);
x += GRID_5MM_SPACING;
}
y += GRID_5MM_SPACING;
}
vertices
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paper_quad_generation() {
let quad = generate_paper_quad();
assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
}
#[test]
fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank);
assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm);
assert!(!grid.is_empty());
assert_eq!(grid.len() % 6, 0, "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)");
let lined = generate_background_mesh(PageBackground::Lined);
assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid);
assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0);
}
}
+857
View File
@@ -0,0 +1,857 @@
//! 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, DESK_COLOR,
};
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::<InkVertex>() 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],
}
/// 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<InkVertex> {
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.
pub struct CanvasState {
pub surface: wgpu::Surface<'static>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration,
pub size: PhysicalSize<u32>,
pub viewport: PageViewport,
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],
// 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,
// 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<StylusSample>,
pub live_control_points: Vec<ControlPoint>,
pub live_vertices: Vec<InkVertex>,
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<Range<u32>>,
// 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<Window>) -> Result<Self, Box<dyn std::error::Error>> {
let size = window.inner_size();
let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
let surface = instance.create_surface(window.clone())?;
// 2. Adapter auswählen (hohe Performance bevorzugt)
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.ok_or("Kein passender GPU-Adapter gefunden")?;
// 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 mit Fallback Fifo & Frame-Latency 1
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
wgpu::PresentMode::Fifo
};
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,
};
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Camera Uniform Buffer"),
size: std::mem::size_of::<CameraUniforms>() 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_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::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_quad = generate_paper_quad();
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Paper Quad Buffer"),
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) 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);
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer"),
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) 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::<InkVertex>()) 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::<InkVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(
&device,
config.format,
None,
1,
);
Ok(Self {
surface,
device,
queue,
config,
size,
viewport,
strip_pipeline,
triangle_pipeline,
camera_buffer,
camera_bind_group,
current_ink_color: default_ink_color,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
background_vertex_count: bg_mesh.len() as u32,
current_background,
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),
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 (scale, offset) = self.camera.ndc_transform(&self.viewport);
let camera_uniforms = CameraUniforms {
transform_scale: scale,
transform_offset: offset,
};
self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms]));
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
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;
self.surface.configure(&self.device, &self.config);
self.viewport = PageViewport::calculate(
new_size.width as f32,
new_size.height as f32,
Self::VIEWPORT_MARGIN,
);
self.update_uniforms();
}
}
/// 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<glam::Vec2> {
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)
}
/// 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<StylusSample>, vertices: Vec<InkVertex>) {
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::<InkVertex>()) 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) {
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
let base_radius = (width_mm * 5.0).max(0.25);
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
}
/// 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;
let mesh = generate_background_mesh(background);
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::<InkVertex>()) 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();
self.set_background(page.background);
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::<InkVertex>()) 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),
);
}
/// 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;
// 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 (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();
}
/// 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.
pub fn finish_stroke(&mut self, stroke_id: u64) -> Option<Stroke> {
if self.live_vertex_count == 0 || self.live_vertices.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;
}
let stroke = Stroke::new(
stroke_id,
self.live_samples.clone(),
self.live_vertices.clone(),
self.current_ink_color,
);
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::<InkVertex>()) 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(DESK_COLOR), // Dunkler Tischhintergrund
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
// 2D-Kamera Uniforms (Scale & Offset) binden
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
// 1. Weißes DIN-A4-Blatt 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. 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);
}
}
}
// 4. 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)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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::<InkVertex>(), 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();
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16);
}
#[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");
}
}
+975
View File
@@ -0,0 +1,975 @@
//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell.
use std::ops::Range;
use crate::canvas::InkVertex;
use crate::history::{HistoryAction, HistoryStack};
/// Standardmaße DIN A4 in Millimetern.
pub const A4_WIDTH_MM: f32 = 210.0;
pub const A4_HEIGHT_MM: f32 = 297.0;
/// Seitenverhältnis DIN A4 (1 / sqrt(2) ≈ 0.7071).
pub const A4_ASPECT_RATIO: f32 = A4_WIDTH_MM / A4_HEIGHT_MM;
/// Virtuelle Seitenauflösung in internen Einheiten (10 Einheiten = 1 mm).
pub const PAGE_WIDTH: f32 = 2100.0;
pub const PAGE_HEIGHT: f32 = 2970.0;
pub const UNITS_PER_MM: f32 = 10.0;
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StylusSample {
pub position: glam::Vec2,
pub pressure: f32,
pub timestamp: f64,
}
/// 2D Axis-Aligned Bounding Box (AABB) für schnellen Kollisionsausschluss.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Aabb {
pub min: glam::Vec2,
pub max: glam::Vec2,
}
impl Aabb {
pub fn new(min: glam::Vec2, max: glam::Vec2) -> Self {
Self { min, max }
}
pub fn from_point(p: glam::Vec2) -> Self {
Self { min: p, max: p }
}
pub fn from_points(points: &[glam::Vec2]) -> Self {
if points.is_empty() {
return Self::new(glam::Vec2::ZERO, glam::Vec2::ZERO);
}
let mut min = points[0];
let mut max = points[0];
for &p in &points[1..] {
min = min.min(p);
max = max.max(p);
}
Self { min, max }
}
pub fn expand_by_point(&mut self, p: glam::Vec2) {
self.min = self.min.min(p);
self.max = self.max.max(p);
}
/// Vergrößert die Bounding Box allseitig um einen Radius `r`.
pub fn expanded(&self, radius: f32) -> Self {
let r = glam::Vec2::splat(radius);
Self {
min: self.min - r,
max: self.max + r,
}
}
/// Prüft, ob ein Punkt innerhalb der Bounding Box liegt.
pub fn contains_point(&self, p: glam::Vec2) -> bool {
p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y
}
/// Prüft, ob zwei Bounding Boxes überlappen.
pub fn intersects(&self, other: &Aabb) -> bool {
self.min.x <= other.max.x
&& self.max.x >= other.min.x
&& self.min.y <= other.max.y
&& self.max.y >= other.min.y
}
/// Kombiniert diese Bounding Box mit einer anderen zu einer minimalen Hüllbox.
pub fn union(&self, other: &Aabb) -> Self {
Self {
min: self.min.min(other.min),
max: self.max.max(other.max),
}
}
}
/// Berechnet den minimalen Abstand eines Punktes `p` zu einer Strecke zwischen `a` und `b`.
pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) -> f32 {
let ab = b - a;
let len_sq = ab.length_squared();
if len_sq < 1e-6 {
return (p - a).length();
}
// Orthogonale Projektion t ∈ [0, 1]
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
let projection = a + t * ab;
(p - projection).length()
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub id: u64,
pub points: Vec<StylusSample>,
pub vertices: Vec<InkVertex>,
pub aabb: Aabb,
pub color: [f32; 4],
pub shape: Option<crate::geometry::RecognizedShape>,
}
impl Stroke {
pub fn new(id: u64, points: Vec<StylusSample>, vertices: Vec<InkVertex>, color: [f32; 4]) -> Self {
let aabb = if !points.is_empty() {
let pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
Aabb::from_points(&pts)
} else if !vertices.is_empty() {
let pts: Vec<glam::Vec2> = vertices.iter().map(|v| glam::Vec2::new(v.position[0], v.position[1])).collect();
Aabb::from_points(&pts)
} else {
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
};
Self {
id,
points,
vertices,
aabb,
color,
shape: None,
}
}
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
self.shape = Some(shape);
self
}
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
if self.points.is_empty() {
return f32::INFINITY;
}
if self.points.len() == 1 {
return (p - self.points[0].position).length();
}
let mut min_dist = f32::INFINITY;
for window in self.points.windows(2) {
let d = point_to_segment_distance(p, window[0].position, window[1].position);
if d < min_dist {
min_dist = d;
}
}
min_dist
}
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
pub fn intersects_eraser(&self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> bool {
// 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB?
let total_margin = eraser_radius + max_stroke_radius;
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
return false;
}
// 2. Punkt-zu-Strecke-Distanztest entlang aller Segmente
if self.points.len() < 2 {
if let Some(first) = self.points.first() {
let dist = (eraser_pos - first.position).length();
return dist <= total_margin;
}
return false;
}
for i in 0..self.points.len() - 1 {
let p1 = self.points[i].position;
let p2 = self.points[i + 1].position;
let dist = point_to_segment_distance(eraser_pos, p1, p2);
if dist <= total_margin {
return true;
}
}
false
}
/// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu.
pub fn recompute_aabb(&mut self) {
self.aabb = if !self.points.is_empty() {
let pts: Vec<glam::Vec2> = self.points.iter().map(|s| s.position).collect();
Aabb::from_points(&pts)
} else if !self.vertices.is_empty() {
let pts: Vec<glam::Vec2> = self
.vertices
.iter()
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
.collect();
Aabb::from_points(&pts)
} else {
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
};
}
/// Verschiebt den Strich um `delta` (DIN-A4-Einheiten).
pub fn translate(&mut self, delta: glam::Vec2) {
for pt in &mut self.points {
pt.position += delta;
}
for v in &mut self.vertices {
v.position[0] += delta.x;
v.position[1] += delta.y;
}
if let Some(ref mut shape) = self.shape {
shape.translate(delta);
}
self.recompute_aabb();
}
/// Dreht den Strich um `origin` mit dem Winkel `angle_rad`.
pub fn rotate(&mut self, origin: glam::Vec2, angle_rad: f32) {
let (sin, cos) = angle_rad.sin_cos();
let rot = |pos: glam::Vec2| -> glam::Vec2 {
let diff = pos - origin;
origin + glam::Vec2::new(diff.x * cos - diff.y * sin, diff.x * sin + diff.y * cos)
};
for pt in &mut self.points {
pt.position = rot(pt.position);
}
for v in &mut self.vertices {
let rotated = rot(glam::Vec2::new(v.position[0], v.position[1]));
v.position = [rotated.x, rotated.y];
}
if let Some(ref mut shape) = self.shape {
shape.rotate(origin, angle_rad);
}
self.recompute_aabb();
}
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
let sc = |pos: glam::Vec2| -> glam::Vec2 {
origin + (pos - origin) * factors
};
for pt in &mut self.points {
pt.position = sc(pt.position);
}
for v in &mut self.vertices {
let scaled = sc(glam::Vec2::new(v.position[0], v.position[1]));
v.position = [scaled.x, scaled.y];
}
if let Some(ref mut shape) = self.shape {
shape.scale(origin, factors);
}
self.recompute_aabb();
}
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
pub fn set_color(&mut self, new_color: [f32; 4]) {
self.color = new_color;
for v in &mut self.vertices {
v.color = new_color;
}
}
}
/// Hintergrundtyp für eine DIN-A4-Seite.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PageBackground {
/// Komplett blanko (Weiß)
Blank,
/// 5 mm Karomuster
Grid5mm,
/// Linierte Seite mit 7 mm Zeilenabstand
Lined,
/// Punktraster im 5 mm Abstand
DotGrid,
}
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
#[derive(Debug, Clone)]
pub struct Page {
pub id: u64,
pub strokes: Vec<Stroke>,
pub background: PageBackground,
pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
pub is_loaded: bool,
pub history: HistoryStack,
}
impl Page {
pub fn new(id: u64, background: PageBackground) -> Self {
Self {
id,
strokes: Vec::new(),
background,
vertex_cache: Vec::new(),
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: true,
history: HistoryStack::new(),
}
}
/// Erzeugt einen noch nicht aus dem Speicher geladenen Seiten-Platzhalter (Lazy Loading).
pub fn new_unloaded(id: u64, background: PageBackground) -> Self {
Self {
id,
strokes: Vec::new(),
background,
vertex_cache: Vec::new(),
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: false,
history: HistoryStack::new(),
}
}
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
pub fn add_stroke(&mut self, stroke: Stroke) {
self.history.push(HistoryAction::AddStroke(stroke.clone()));
self.strokes.push(stroke);
self.is_dirty = true;
}
/// Ersetzt einen vorhandenen Strich (z. B. durch eine erkannte geometrische Form)
/// und hinterlegt die Aktion für Undo/Redo im History-Stack.
pub fn replace_stroke(&mut self, old_id: u64, new_stroke: Stroke) -> bool {
if let Some(pos) = self.strokes.iter().position(|s| s.id == old_id) {
let old = self.strokes[pos].clone();
self.history.push(HistoryAction::ReplaceStroke {
old,
new: new_stroke.clone(),
});
self.strokes[pos] = new_stroke;
self.is_dirty = true;
true
} else {
false
}
}
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
let mut best: Option<(&Stroke, f32)> = None;
for s in &self.strokes {
if !s.aabb.expanded(max_dist).contains_point(pos) {
continue;
}
let d = s.distance_to_point(pos);
if d <= max_dist {
if let Some((_, best_d)) = best {
if d < best_d {
best = Some((s, d));
}
} else {
best = Some((s, d));
}
}
}
best.map(|(s, _)| s)
}
/// Macht die letzte Aktion rückgängig (Undo).
pub fn undo(&mut self) -> bool {
if self.history.undo(&mut self.strokes) {
self.is_dirty = true;
true
} else {
false
}
}
/// Stellt die letzte rückgängig gemachte Aktion wieder her (Redo).
pub fn redo(&mut self) -> bool {
if self.history.redo(&mut self.strokes) {
self.is_dirty = true;
true
} else {
false
}
}
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
///
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
pub fn erase_at(&mut self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> Vec<Stroke> {
let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() {
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) {
hit_indices.push(i);
}
}
if hit_indices.is_empty() {
return Vec::new();
}
// Rückwärts entfernen, um Indexverschiebungen zu vermeiden
let mut removed = Vec::with_capacity(hit_indices.len());
for &idx in hit_indices.iter().rev() {
removed.push(self.strokes.remove(idx));
}
removed.reverse();
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
self.is_dirty = true;
removed
}
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
pub fn rebuild_cache_if_dirty(&mut self) {
if !self.is_dirty {
return;
}
self.vertex_cache.clear();
self.stroke_ranges.clear();
for stroke in &self.strokes {
let start = self.vertex_cache.len() as u32;
self.vertex_cache.extend_from_slice(&stroke.vertices);
let end = self.vertex_cache.len() as u32;
self.stroke_ranges.push(start..end);
}
self.is_dirty = false;
}
/// Berechnet die kombinierte AABB einer Menge von Strichen.
pub fn selection_aabb(strokes: &[Stroke]) -> Option<Aabb> {
strokes.first().map(|first| {
let mut combined = first.aabb;
for s in strokes.iter().skip(1) {
combined = combined.union(&s.aabb);
}
combined
})
}
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
let sel_box = Aabb::new(
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
glam::Vec2::new(box_min.x.max(box_max.x), box_min.y.max(box_max.y)),
);
self.strokes
.iter()
.filter(|s| s.aabb.intersects(&sel_box))
.map(|s| s.id)
.collect()
}
/// Liefert Klone der Striche mit den angegebenen IDs.
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect()
}
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
pub fn modify_strokes(&mut self, modified: Vec<Stroke>) {
if modified.is_empty() {
return;
}
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
let before: Vec<Stroke> = self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect();
for m in modified.iter() {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.history.push(HistoryAction::ModifyStrokes {
before,
after: modified,
});
self.is_dirty = true;
}
/// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern.
pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) {
for m in modified {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.is_dirty = true;
}
/// Schließt eine Transformationsoperation transaktional mit vorherigem und finalem Zustand ab.
pub fn commit_modified_strokes(&mut self, before: Vec<Stroke>, after: Vec<Stroke>) {
if before.is_empty() || after.is_empty() {
return;
}
for m in &after {
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
self.strokes[pos] = m.clone();
}
}
self.history.push(HistoryAction::ModifyStrokes { before, after });
self.is_dirty = true;
}
/// Entfernt Striche anhand ihrer IDs und hinterlegt die Aktion im History-Stack.
pub fn remove_strokes_by_ids(&mut self, ids: &[u64]) -> Vec<Stroke> {
let mut removed = Vec::new();
self.strokes.retain(|s| {
if ids.contains(&s.id) {
removed.push(s.clone());
false
} else {
true
}
});
if !removed.is_empty() {
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
self.is_dirty = true;
}
removed
}
/// Fügt mehrere Striche hinzu und hinterlegt die Aktion transaktional im History-Stack.
pub fn add_strokes(&mut self, new_strokes: Vec<Stroke>) {
if new_strokes.is_empty() {
return;
}
self.history.push(HistoryAction::AddStrokes(new_strokes.clone()));
self.strokes.extend(new_strokes);
self.is_dirty = true;
}
}
/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation.
#[derive(Debug, Clone)]
pub struct Notebook {
pub pages: Vec<Page>,
pub current_page_index: usize,
pub next_stroke_id: u64,
pub next_page_id: u64,
}
impl Notebook {
pub fn new() -> Self {
let mut nb = Self {
pages: Vec::new(),
current_page_index: 0,
next_stroke_id: 1,
next_page_id: 1,
};
// Erste Standardseite anlegen (5mm Grid)
nb.add_page(PageBackground::Grid5mm);
nb
}
/// Erzeugt ein Notizbuch aus vorhandenen Seiten und Zählern (für Session-Wiederherstellung).
pub fn from_pages(
pages: Vec<Page>,
current_page_index: usize,
next_stroke_id: u64,
next_page_id: u64,
) -> Self {
let mut nb = Self {
pages,
current_page_index,
next_stroke_id,
next_page_id,
};
if nb.pages.is_empty() {
nb.add_page(PageBackground::Grid5mm);
}
if nb.current_page_index >= nb.pages.len() {
nb.current_page_index = 0;
}
nb
}
/// Gibt die aktuell aktive Seite zurück.
pub fn current_page(&self) -> &Page {
&self.pages[self.current_page_index]
}
/// Gibt die aktuell aktive Seite als veränderliche Referenz zurück.
pub fn current_page_mut(&mut self) -> &mut Page {
&mut self.pages[self.current_page_index]
}
/// Erzeugt eine eindeutige neue Stroke-ID.
pub fn next_stroke_id(&mut self) -> u64 {
let id = self.next_stroke_id;
self.next_stroke_id += 1;
id
}
/// Fügt eine neue Seite mit dem angegebenen Hintergrund hinzu.
pub fn add_page(&mut self, background: PageBackground) -> usize {
let id = self.next_page_id;
self.next_page_id += 1;
self.pages.push(Page::new(id, background));
self.pages.len() - 1
}
/// Wechselt zur nächsten Seite (PageDown / N). Hängt automatisch eine neue Seite an, falls am Ende.
pub fn next_page(&mut self) {
if self.current_page_index + 1 < self.pages.len() {
self.current_page_index += 1;
} else {
// Neue leere Seite mit gleichem Hintergrund anhängen
let current_bg = self.current_page().background;
let new_idx = self.add_page(current_bg);
self.current_page_index = new_idx;
}
}
/// Wechselt zur vorherigen Seite (PageUp / P).
pub fn previous_page(&mut self) {
if self.current_page_index > 0 {
self.current_page_index -= 1;
}
}
/// Springt zur ersten Seite (Pos1 / Home).
pub fn first_page(&mut self) {
self.current_page_index = 0;
}
/// Springt zur letzten Seite (Ende / End).
pub fn last_page(&mut self) {
if !self.pages.is_empty() {
self.current_page_index = self.pages.len() - 1;
}
}
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String {
format!("Seite {} / {}", self.current_page_index + 1, self.pages.len())
}
}
impl Default for Notebook {
fn default() -> Self {
Self::new()
}
}
/// Berechnet die DIN-A4-Darstellung im Fenster mit Letterboxing/Pillarboxing.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageViewport {
pub window_width: f32,
pub window_height: f32,
pub page_x: f32,
pub page_y: f32,
pub page_width: f32,
pub page_height: f32,
}
impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
let avail_w = (window_width - 2.0 * margin).max(10.0);
let avail_h = (window_height - 2.0 * margin).max(10.0);
let window_ratio = avail_w / avail_h;
let page_ratio = A4_ASPECT_RATIO;
let (page_w, page_h) = if window_ratio > page_ratio {
// Fenster ist breiter als DIN A4 -> Pillarboxing (Balken links/rechts)
let h = avail_h;
let w = h * page_ratio;
(w, h)
} else {
// Fenster ist höher als DIN A4 -> Letterboxing (Balken oben/unten)
let w = avail_w;
let h = w / page_ratio;
(w, h)
};
let page_x = (window_width - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0;
Self {
window_width,
window_height,
page_x,
page_y,
page_width: page_w,
page_height: page_h,
}
}
/// Transformiert einen Fenster-Pixelpunkt in DIN-A4-Seitenkoordinaten [0.0, PAGE_WIDTH] x [0.0, PAGE_HEIGHT].
///
/// Liefert `None`, falls der Punkt außerhalb der DIN-A4-Papierfläche liegt.
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
let u = (screen_x - self.page_x) / self.page_width;
let v = (screen_y - self.page_y) / self.page_height;
if (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) {
Some(glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT))
} else {
None
}
}
/// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die DIN-A4-Papierfläche.
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
let u = ((screen_x - self.page_x) / self.page_width).clamp(0.0, 1.0);
let v = ((screen_y - self.page_y) / self.page_height).clamp(0.0, 1.0);
glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT)
}
/// Berechnet die Shader-Transformationsparameter (Scale, Offset),
/// um DIN-A4-Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self) -> ([f32; 2], [f32; 2]) {
let scale_x = (2.0 * self.page_width) / (self.window_width * PAGE_WIDTH);
let scale_y = (-2.0 * self.page_height) / (self.window_height * PAGE_HEIGHT);
let offset_x = (2.0 * self.page_x) / self.window_width - 1.0;
let offset_y = 1.0 - (2.0 * self.page_y) / self.window_height;
([scale_x, scale_y], [offset_x, offset_y])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_point_to_segment_distance() {
let a = glam::Vec2::new(0.0, 0.0);
let b = glam::Vec2::new(10.0, 0.0);
// Punkt liegt genau senkrecht über der Streckenmitte (5, 5)
let p_mid = glam::Vec2::new(5.0, 5.0);
assert!((point_to_segment_distance(p_mid, a, b) - 5.0).abs() < 1e-5);
// Punkt liegt links von a (-5, 0) -> Abstand zu a
let p_left = glam::Vec2::new(-5.0, 0.0);
assert!((point_to_segment_distance(p_left, a, b) - 5.0).abs() < 1e-5);
// Punkt liegt rechts von b (15, 0) -> Abstand zu b
let p_right = glam::Vec2::new(15.0, 0.0);
assert!((point_to_segment_distance(p_right, a, b) - 5.0).abs() < 1e-5);
// Punkt liegt direkt auf der Strecke
let p_on = glam::Vec2::new(3.0, 0.0);
assert!(point_to_segment_distance(p_on, a, b) < 1e-5);
}
#[test]
fn test_stroke_eraser_hit() {
let points = vec![
StylusSample {
position: glam::Vec2::new(100.0, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(200.0, 100.0),
pressure: 0.5,
timestamp: 0.01,
},
];
let stroke = Stroke::new(1, points, Vec::new(), [0.0, 0.0, 0.0, 1.0]);
let eraser_radius = 10.0;
let stroke_radius = 2.0;
// Treffer: Radierer bei (150, 105) (Abstand 5 <= 12)
assert!(stroke.intersects_eraser(glam::Vec2::new(150.0, 105.0), eraser_radius, stroke_radius));
// Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12)
assert!(!stroke.intersects_eraser(glam::Vec2::new(150.0, 150.0), eraser_radius, stroke_radius));
// Kein Treffer: Weit außerhalb der AABB
assert!(!stroke.intersects_eraser(glam::Vec2::new(500.0, 500.0), eraser_radius, stroke_radius));
}
#[test]
fn test_notebook_navigation() {
let mut nb = Notebook::new();
assert_eq!(nb.pages.len(), 1);
assert_eq!(nb.current_page_index, 0);
assert_eq!(nb.osd_text(), "Seite 1 / 1");
// Weiterblättern am Ende erzeugt automatisch neue Seite
nb.next_page();
assert_eq!(nb.pages.len(), 2);
assert_eq!(nb.current_page_index, 1);
assert_eq!(nb.osd_text(), "Seite 2 / 2");
// Zurückblättern
nb.previous_page();
assert_eq!(nb.current_page_index, 0);
// Zurückblättern auf erster Seite bleibt auf Seite 0
nb.previous_page();
assert_eq!(nb.current_page_index, 0);
// Ende und Home
nb.last_page();
assert_eq!(nb.current_page_index, 1);
nb.first_page();
assert_eq!(nb.current_page_index, 0);
}
#[test]
fn test_page_viewport_projection() {
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
assert!(vp.page_width > 0.0);
assert!(vp.page_height > 0.0);
// Aspekt DIN A4 muss exakt erhalten bleiben
let ratio = vp.page_width / vp.page_height;
assert!((ratio - A4_ASPECT_RATIO).abs() < 1e-4);
// Transformation Mitte des Papiers
let center_screen_x = vp.page_x + vp.page_width / 2.0;
let center_screen_y = vp.page_y + vp.page_height / 2.0;
let page_pt = vp.screen_to_page(center_screen_x, center_screen_y).unwrap();
assert!((page_pt.x - PAGE_WIDTH / 2.0).abs() < 1.0);
assert!((page_pt.y - PAGE_HEIGHT / 2.0).abs() < 1.0);
// NDC-Transformation der Mitte muss (0.0, 0.0) ergeben
let (scale, offset) = vp.ndc_transform();
let ndc_x = (PAGE_WIDTH / 2.0) * scale[0] + offset[0];
let ndc_y = (PAGE_HEIGHT / 2.0) * scale[1] + offset[1];
assert!(ndc_x.abs() < 1e-4);
assert!(ndc_y.abs() < 1e-4);
}
#[test]
fn test_page_erase_and_undo_redo() {
let mut page = Page::new(1, PageBackground::Grid5mm);
let s1 = Stroke::new(
1,
vec![
StylusSample {
position: glam::Vec2::new(10.0, 10.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(50.0, 10.0),
pressure: 0.5,
timestamp: 0.01,
},
],
vec![
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
);
let s2 = Stroke::new(
2,
vec![
StylusSample {
position: glam::Vec2::new(10.0, 100.0),
pressure: 0.5,
timestamp: 0.02,
},
StylusSample {
position: glam::Vec2::new(50.0, 100.0),
pressure: 0.5,
timestamp: 0.03,
},
],
vec![
InkVertex::new(10.0, 100.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(50.0, 100.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
);
page.add_stroke(s1);
page.add_stroke(s2);
assert_eq!(page.strokes.len(), 2);
// Radieren bei (30, 12): Trifft nur s1
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0);
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].id, 1);
assert_eq!(page.strokes.len(), 1);
assert_eq!(page.strokes[0].id, 2);
// Undo: s1 wird wiederhergestellt
assert!(page.undo());
assert_eq!(page.strokes.len(), 2);
// Redo: s1 wird wieder gelöscht
assert!(page.redo());
assert_eq!(page.strokes.len(), 1);
assert_eq!(page.strokes[0].id, 2);
}
#[test]
fn test_page_isolation() {
let mut nb = Notebook::new();
let s1 = Stroke::new(
100,
vec![StylusSample {
position: glam::Vec2::new(50.0, 50.0),
pressure: 0.5,
timestamp: 0.0,
}],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
nb.current_page_mut().add_stroke(s1);
assert_eq!(nb.current_page().strokes.len(), 1);
// Neue Seite anlegen
nb.next_page();
assert_eq!(nb.current_page_index, 1);
// Neue Seite muss leer sein
assert_eq!(nb.current_page().strokes.len(), 0);
// Zurück zu Seite 0: Strich muss unverändert da sein
nb.previous_page();
assert_eq!(nb.current_page_index, 0);
assert_eq!(nb.current_page().strokes.len(), 1);
assert_eq!(nb.current_page().strokes[0].id, 100);
}
#[test]
fn test_page_ephemeral_and_commit_manipulations() {
let mut page = Page::new(1, PageBackground::Blank);
let s = Stroke::new(
10,
vec![StylusSample {
position: glam::Vec2::new(10.0, 10.0),
pressure: 0.5,
timestamp: 0.0,
}],
vec![InkVertex::new(10.0, 10.0, [1.0, 0.0, 0.0, 1.0])],
[1.0, 0.0, 0.0, 1.0],
);
page.add_stroke(s.clone());
// Ephemeral modification (simulating in-progress mouse drag)
let mut moved = s.clone();
moved.translate(glam::Vec2::new(20.0, 30.0));
page.update_strokes_ephemeral(&[moved.clone()]);
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
// History should not have been updated yet
assert_eq!(page.history.can_redo(), false);
// Transactional commit (mouse release)
let before = vec![s.clone()];
let after = vec![moved.clone()];
page.commit_modified_strokes(before, after);
// Verify Undo restores original position
assert!(page.undo());
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(10.0, 10.0));
// Verify Redo applies modified position
assert!(page.redo());
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
}
}
+824
View File
@@ -0,0 +1,824 @@
//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs via printpdf.
//!
//! Features:
//! - Hochformat DIN A4 (210 mm x 297 mm)
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink")
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
//! - Zwei Export-Strategien:
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line`
use std::error::Error;
use std::fs::File;
use std::io::BufWriter;
use std::path::Path;
use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
};
/// Wählbare Exportstrategie für Vektorstriche.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ExportStrategy {
/// Gefüllte Polygone aus den Triangle-Strip-Hüllen (ideal für variable Druckstärken).
#[default]
FilledPolygon,
/// Kubische Bézier-Kurven mit Segment-Linienstärke.
BezierCurve,
}
/// Rechnet eine interne Seitenkoordinate (x, y) in PDF-Millimeter-Koordinaten um (mit Y-Inversion).
#[inline]
pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
let x_norm = (x / PAGE_WIDTH).clamp(0.0, 1.0);
let y_norm = (y / PAGE_HEIGHT).clamp(0.0, 1.0);
let x_mm = x_norm * A4_WIDTH_MM;
let y_mm = (1.0 - y_norm) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links
(x_mm, y_mm)
}
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
pub fn export_notebook_to_pdf<P: AsRef<Path>>(
notebook: &Notebook,
path: P,
strategy: ExportStrategy,
) -> Result<(), Box<dyn Error>> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let doc = generate_pdf_document(notebook, strategy)?;
doc.save(&mut writer)?;
Ok(())
}
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und gibt die rohen Bytes zurück.
pub fn export_notebook_to_bytes(
notebook: &Notebook,
strategy: ExportStrategy,
) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy)?;
let bytes = doc.save_to_bytes()?;
Ok(bytes)
}
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
pub fn generate_pdf_document(
notebook: &Notebook,
strategy: ExportStrategy,
) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into());
}
// 1. Erstes Dokument mit Seite 1 erstellen
let title = "ink-notes Notebook";
let (doc, page1_idx, layer1_bg) = PdfDocument::new(
title,
Mm(A4_WIDTH_MM),
Mm(A4_HEIGHT_MM),
"Background",
);
// Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
// 2. Alle nachfolgenden Seiten hinzufügen
for page in &notebook.pages[1..] {
let (page_idx, layer_bg) = doc.add_page(
Mm(A4_WIDTH_MM),
Mm(A4_HEIGHT_MM),
"Background",
);
let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background);
let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy);
}
Ok(doc)
}
/// Rendert das Vektorraster auf Layer 1 ("Background").
fn render_background_layer(
layer: &printpdf::PdfLayerReference,
background: PageBackground,
) {
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
layer.set_fill_color(grid_color);
match background {
PageBackground::Blank => {}
PageBackground::Grid5mm => {
let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm
// Vertikale Linien alle 5 mm
let mut x = spacing_mm;
while x < A4_WIDTH_MM {
let line = Line {
points: vec![
(Point::new(Mm(x), Mm(0.0)), false),
(Point::new(Mm(x), Mm(A4_HEIGHT_MM)), false),
],
is_closed: false,
};
layer.add_line(line);
x += spacing_mm;
}
// Horizontale Linien alle 5 mm
let mut y = spacing_mm;
while y < A4_HEIGHT_MM {
let line = Line {
points: vec![
(Point::new(Mm(0.0), Mm(y)), false),
(Point::new(Mm(A4_WIDTH_MM), Mm(y)), false),
],
is_closed: false,
};
layer.add_line(line);
y += spacing_mm;
}
}
PageBackground::Lined => {
let spacing_mm = LINED_SPACING / 10.0; // 7.5 mm
let margin_top_mm = 15.0;
let margin_bottom_mm = 15.0;
let mut y = margin_bottom_mm;
while y <= A4_HEIGHT_MM - margin_top_mm {
let line = Line {
points: vec![
(Point::new(Mm(0.0), Mm(y)), false),
(Point::new(Mm(A4_WIDTH_MM), Mm(y)), false),
],
is_closed: false,
};
layer.add_line(line);
y += spacing_mm;
}
}
PageBackground::DotGrid => {
let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm
let half_dot = 0.2; // 0.4 mm Kantenlänge
let mut y = spacing_mm;
while y < A4_HEIGHT_MM {
let mut x = spacing_mm;
while x < A4_WIDTH_MM {
let dot_poly = Polygon {
rings: vec![vec![
(Point::new(Mm(x - half_dot), Mm(y - half_dot)), false),
(Point::new(Mm(x + half_dot), Mm(y - half_dot)), false),
(Point::new(Mm(x + half_dot), Mm(y + half_dot)), false),
(Point::new(Mm(x - half_dot), Mm(y + half_dot)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(dot_poly);
x += spacing_mm;
}
y += spacing_mm;
}
}
}
}
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
strategy: ExportStrategy,
) {
for stroke in &page.strokes {
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
} else {
match strategy {
ExportStrategy::FilledPolygon => {
render_stroke_polygon(layer, stroke);
}
ExportStrategy::BezierCurve => {
render_stroke_bezier(layer, stroke);
}
}
}
}
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape(
layer: &printpdf::PdfLayerReference,
shape: &crate::geometry::RecognizedShape,
color: [f32; 4],
) {
use crate::geometry::RecognizedShape;
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
layer.set_outline_color(stroke_color.clone());
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
match shape {
RecognizedShape::Line { start, end } => {
let (x0, y0) = page_to_pdf_mm(start.x, start.y);
let (x1, y1) = page_to_pdf_mm(end.x, end.y);
let line = Line {
points: vec![
(Point::new(Mm(x0), Mm(y0)), false),
(Point::new(Mm(x1), Mm(y1)), false),
],
is_closed: false,
};
layer.add_line(line);
}
RecognizedShape::Circle { center, radius } => {
let (cx, cy) = page_to_pdf_mm(center.x, center.y);
let r_mm = radius * (A4_WIDTH_MM / PAGE_WIDTH);
let segments = 64;
let mut ring = Vec::with_capacity(segments);
for i in 0..segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let px = cx + angle.cos() * r_mm;
let py = cy + angle.sin() * r_mm;
ring.push((Point::new(Mm(px), Mm(py)), false));
}
let poly = Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
RecognizedShape::Rectangle { corners } => {
let mut ring = Vec::with_capacity(4);
for c in corners {
let (x, y) = page_to_pdf_mm(c.x, c.y);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
let poly = Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
RecognizedShape::Triangle { corners } => {
let mut ring = Vec::with_capacity(3);
for c in corners {
let (x, y) = page_to_pdf_mm(c.x, c.y);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
let poly = Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
RecognizedShape::Rhombus { corners } => {
let mut ring = Vec::with_capacity(4);
for c in corners {
let (x, y) = page_to_pdf_mm(c.x, c.y);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
let poly = Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
RecognizedShape::Polygon { vertices } => {
let mut ring = Vec::with_capacity(vertices.len());
for c in vertices {
let (x, y) = page_to_pdf_mm(c.x, c.y);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
let poly = Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth);
for (p1, p2) in edges {
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
let (x2, y2) = page_to_pdf_mm(p2.x, p2.y);
let line = Line {
points: vec![
(Point::new(Mm(x1), Mm(y1)), false),
(Point::new(Mm(x2), Mm(y2)), false),
],
is_closed: false,
};
layer.add_line(line);
}
}
RecognizedShape::CoordinateSystem {
origin,
x_axis_end,
y_axis_end,
with_arrows,
} => {
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
// Achse 1 (Y)
let line_y = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(yx), Mm(yy)), false),
],
is_closed: false,
};
layer.add_line(line_y);
// Achse 2 (X)
let line_x = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(xx), Mm(xy)), false),
],
is_closed: false,
};
layer.add_line(line_x);
// Pfeilspitzen als gefüllte Dreiecke
if *with_arrows {
layer.set_fill_color(stroke_color);
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let arrow_len_page = 25.0;
let arrow_w_page = 16.0;
let base_y = *y_axis_end - y_dir * arrow_len_page;
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
let arrow_poly_y = Polygon {
rings: vec![vec![
(Point::new(Mm(tx), Mm(ty)), false),
(Point::new(Mm(lx), Mm(ly)), false),
(Point::new(Mm(rx), Mm(ry)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_y);
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let base_x = *x_axis_end - x_dir * arrow_len_page;
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
let arrow_poly_x = Polygon {
rings: vec![vec![
(Point::new(Mm(tx_x), Mm(ty_x)), false),
(Point::new(Mm(lx_x), Mm(ly_x)), false),
(Point::new(Mm(rx_x), Mm(ry_x)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_x);
}
}
}
}
/// Strategie A: Gefülltes Polygon aus den Triangle-Strip-Hüllpunkten.
fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
if stroke.vertices.is_empty() {
return;
}
let n = stroke.vertices.len() / 2;
if n < 2 {
// Einzelner Klickpunkt (Dot): Kreisförmiges Polygon rendern
if let Some(first) = stroke.points.first() {
let (cx, cy) = page_to_pdf_mm(first.position.x, first.position.y);
let r_mm = (first.pressure * 0.75 + 0.25).max(0.2); // ca. 0.25 bis 1.0 mm
let segments = 12;
let mut dot_pts = Vec::with_capacity(segments);
for i in 0..segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let px = cx + angle.cos() * r_mm;
let py = cy + angle.sin() * r_mm;
dot_pts.push((Point::new(Mm(px), Mm(py)), false));
}
let fill_color = Color::Rgb(Rgb::new(
stroke.color[0],
stroke.color[1],
stroke.color[2],
None,
));
layer.set_fill_color(fill_color);
let poly = Polygon {
rings: vec![dot_pts],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
return;
}
// Geschlossener Pfad um den Ribbon-Rand:
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
let mut ring = Vec::with_capacity(n * 2);
// 1. Linke Hüllkurve vorwärts
for i in 0..n {
let v = stroke.vertices[i * 2];
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
// 2. Rechte Hüllkurve rückwärts
for i in (0..n).rev() {
let v = stroke.vertices[i * 2 + 1];
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
ring.push((Point::new(Mm(x), Mm(y)), false));
}
let fill_color = Color::Rgb(Rgb::new(
stroke.color[0],
stroke.color[1],
stroke.color[2],
None,
));
layer.set_fill_color(fill_color);
let poly = Polygon {
rings: vec![ring],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(poly);
}
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
if stroke.points.is_empty() {
return;
}
let outline_color = Color::Rgb(Rgb::new(
stroke.color[0],
stroke.color[1],
stroke.color[2],
None,
));
layer.set_outline_color(outline_color);
layer.set_line_cap_style(printpdf::LineCapStyle::Round);
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
// Durchschnittliche Linienstärke aus dem Druck berechnen
let avg_pressure = stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25);
let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
layer.set_outline_thickness(thickness_pt);
if stroke.points.len() == 1 {
// Einpunktiger Stroke
let (x, y) = page_to_pdf_mm(stroke.points[0].position.x, stroke.points[0].position.y);
let line = Line {
points: vec![
(Point::new(Mm(x), Mm(y)), false),
(Point::new(Mm(x + 0.05), Mm(y)), false),
],
is_closed: false,
};
layer.add_line(line);
return;
}
let n = stroke.points.len();
let mut line_points = Vec::new();
// Catmull-Rom zu kubischen Bézier-Segmenten konvertieren
for i in 0..n - 1 {
let p_curr = stroke.points[i].position;
let p_next = stroke.points[i + 1].position;
let p_prev = if i == 0 {
2.0 * p_curr - p_next
} else {
stroke.points[i - 1].position
};
let p_after = if i + 2 < n {
stroke.points[i + 2].position
} else {
2.0 * p_next - p_curr
};
// Knotensequenz mit zentripetalem Catmull-Rom (alpha = 0.5)
let d01 = (p_curr - p_prev).length();
let d12 = (p_next - p_curr).length();
let d23 = (p_after - p_next).length();
let dt01 = if d01 > 1e-6 { d01.sqrt() } else { 1e-4 };
let dt12 = if d12 > 1e-6 { d12.sqrt() } else { 1e-4 };
let dt23 = if d23 > 1e-6 { d23.sqrt() } else { 1e-4 };
// Geschwindigkeitsvektoren (Tangenten an P1 und P2)
let v1 = (p_next - p_prev) / (dt01 + dt12);
let v2 = (p_after - p_curr) / (dt12 + dt23);
// Kubische Bézier-Kontrollpunkte B1 und B2
let b1 = p_curr + (dt12 / 3.0) * v1;
let b2 = p_next - (dt12 / 3.0) * v2;
let (b0_x, b0_y) = page_to_pdf_mm(p_curr.x, p_curr.y);
let (b1_x, b1_y) = page_to_pdf_mm(b1.x, b1.y);
let (b2_x, b2_y) = page_to_pdf_mm(b2.x, b2.y);
let (b3_x, b3_y) = page_to_pdf_mm(p_next.x, p_next.y);
let pt0 = Point::new(Mm(b0_x), Mm(b0_y));
let pt1 = Point::new(Mm(b1_x), Mm(b1_y));
let pt2 = Point::new(Mm(b2_x), Mm(b2_y));
let pt3 = Point::new(Mm(b3_x), Mm(b3_y));
if i == 0 {
line_points.push((pt0, true));
}
line_points.push((pt1, true));
line_points.push((pt2, false));
// Für das nächste Segment als Startpunkt mit true markieren, am Ende mit false
let is_last_segment = i == n - 2;
line_points.push((pt3, !is_last_segment));
}
if !line_points.is_empty() {
let line = Line {
points: line_points,
is_closed: false,
};
layer.add_line(line);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canvas::InkVertex;
use crate::document::{StylusSample, PAGE_HEIGHT, PAGE_WIDTH};
#[test]
fn test_page_to_pdf_mm_mapping() {
// Oben-Links (0, 0) -> PDF (0.0, 297.0)
let (x0, y0) = page_to_pdf_mm(0.0, 0.0);
assert!((x0 - 0.0).abs() < 1e-4);
assert!((y0 - A4_HEIGHT_MM).abs() < 1e-4);
// Unten-Rechts (PAGE_WIDTH, PAGE_HEIGHT) -> PDF (210.0, 0.0)
let (x1, y1) = page_to_pdf_mm(PAGE_WIDTH, PAGE_HEIGHT);
assert!((x1 - A4_WIDTH_MM).abs() < 1e-4);
assert!((y1 - 0.0).abs() < 1e-4);
// Mitte (PAGE_WIDTH / 2, PAGE_HEIGHT / 2) -> PDF (105.0, 148.5)
let (xm, ym) = page_to_pdf_mm(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
assert!((xm - 105.0).abs() < 1e-4);
assert!((ym - 148.5).abs() < 1e-4);
}
#[test]
fn test_pdf_export_generation() {
// 1. Mock-Notebook mit 2 Seiten und unterschiedlichen Hintergründen
let mut notebook = Notebook::new();
notebook.pages[0].background = PageBackground::Grid5mm;
// Stroke auf Seite 1 hinzufügen
let stroke1 = Stroke::new(
1,
vec![
StylusSample {
position: glam::Vec2::new(100.0, 200.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(300.0, 400.0),
pressure: 0.8,
timestamp: 0.01,
},
StylusSample {
position: glam::Vec2::new(500.0, 300.0),
pressure: 0.6,
timestamp: 0.02,
},
],
vec![
InkVertex::new(95.0, 200.0, [0.1, 0.1, 0.15, 1.0]),
InkVertex::new(105.0, 200.0, [0.1, 0.1, 0.15, 1.0]),
InkVertex::new(290.0, 400.0, [0.1, 0.1, 0.15, 1.0]),
InkVertex::new(310.0, 400.0, [0.1, 0.1, 0.15, 1.0]),
InkVertex::new(495.0, 300.0, [0.1, 0.1, 0.15, 1.0]),
InkVertex::new(505.0, 300.0, [0.1, 0.1, 0.15, 1.0]),
],
[0.1, 0.1, 0.15, 1.0],
);
notebook.pages[0].add_stroke(stroke1);
// Seite 2 mit Liniertem Hintergrund
let page2_idx = notebook.add_page(PageBackground::Lined);
let stroke2 = Stroke::new(
2,
vec![
StylusSample {
position: glam::Vec2::new(200.0, 500.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(600.0, 500.0),
pressure: 0.5,
timestamp: 0.01,
},
],
vec![
InkVertex::new(200.0, 498.0, [0.85, 0.2, 0.2, 1.0]),
InkVertex::new(200.0, 502.0, [0.85, 0.2, 0.2, 1.0]),
InkVertex::new(600.0, 498.0, [0.85, 0.2, 0.2, 1.0]),
InkVertex::new(600.0, 502.0, [0.85, 0.2, 0.2, 1.0]),
],
[0.85, 0.2, 0.2, 1.0],
);
notebook.pages[page2_idx].add_stroke(stroke2);
// 2. Export als Datei testen (Strategie A: FilledPolygon)
let temp_dir = std::env::temp_dir();
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon);
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
// Datei-Existenz und Größe prüfen
assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren");
let metadata = std::fs::metadata(&pdf_path).unwrap();
assert!(
metadata.len() > 1024,
"Dateigröße ({}) muss größer als 1 KB sein",
metadata.len()
);
// PDF-Header verifizieren
let content = std::fs::read(&pdf_path).unwrap();
assert!(
content.starts_with(b"%PDF-1."),
"Datei muss mit gültigem PDF-Header (%PDF-1.) beginnen"
);
// 3. Export als Bytes testen (Strategie B: BezierCurve)
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve);
assert!(bytes_res.is_ok());
let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1."));
// Aufräumen
let _ = std::fs::remove_file(pdf_path);
}
#[test]
fn test_pdf_export_recognized_shapes() {
let mut notebook = Notebook::new();
let page = notebook.current_page_mut();
let line_stroke = Stroke::new(1, vec![], vec![], [0.0, 0.0, 0.0, 1.0]).with_shape(
crate::geometry::RecognizedShape::Line {
start: glam::Vec2::new(100.0, 100.0),
end: glam::Vec2::new(500.0, 500.0),
},
);
page.add_stroke(line_stroke);
let circle_stroke = Stroke::new(2, vec![], vec![], [0.1, 0.4, 0.9, 1.0]).with_shape(
crate::geometry::RecognizedShape::Circle {
center: glam::Vec2::new(1000.0, 1000.0),
radius: 200.0,
},
);
page.add_stroke(circle_stroke);
let rect_stroke = Stroke::new(3, vec![], vec![], [0.2, 0.7, 0.3, 1.0]).with_shape(
crate::geometry::RecognizedShape::Rectangle {
corners: [
glam::Vec2::new(200.0, 200.0),
glam::Vec2::new(600.0, 200.0),
glam::Vec2::new(600.0, 400.0),
glam::Vec2::new(200.0, 400.0),
],
},
);
page.add_stroke(rect_stroke);
let coords_stroke = Stroke::new(4, vec![], vec![], [0.8, 0.1, 0.1, 1.0]).with_shape(
crate::geometry::RecognizedShape::CoordinateSystem {
origin: glam::Vec2::new(300.0, 1500.0),
x_axis_end: glam::Vec2::new(800.0, 1500.0),
y_axis_end: glam::Vec2::new(300.0, 1000.0),
with_arrows: true,
},
);
page.add_stroke(coords_stroke);
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
crate::geometry::RecognizedShape::Triangle {
corners: [
glam::Vec2::new(100.0, 500.0),
glam::Vec2::new(400.0, 500.0),
glam::Vec2::new(250.0, 300.0),
],
},
);
page.add_stroke(tri_stroke);
let rhombus_stroke = Stroke::new(6, vec![], vec![], [0.9, 0.5, 0.1, 1.0]).with_shape(
crate::geometry::RecognizedShape::Rhombus {
corners: [
glam::Vec2::new(500.0, 700.0),
glam::Vec2::new(700.0, 850.0),
glam::Vec2::new(500.0, 1000.0),
glam::Vec2::new(300.0, 850.0),
],
},
);
page.add_stroke(rhombus_stroke);
let octagon_stroke = Stroke::new(7, vec![], vec![], [0.1, 0.6, 0.6, 1.0]).with_shape(
crate::geometry::RecognizedShape::Polygon {
vertices: vec![
glam::Vec2::new(1200.0, 600.0),
glam::Vec2::new(1350.0, 650.0),
glam::Vec2::new(1400.0, 800.0),
glam::Vec2::new(1350.0, 950.0),
glam::Vec2::new(1200.0, 1000.0),
glam::Vec2::new(1050.0, 950.0),
glam::Vec2::new(1000.0, 800.0),
glam::Vec2::new(1050.0, 650.0),
],
},
);
page.add_stroke(octagon_stroke);
let octahedron_stroke = Stroke::new(8, vec![], vec![], [0.3, 0.3, 0.8, 1.0]).with_shape(
crate::geometry::RecognizedShape::Octahedron {
center: glam::Vec2::new(800.0, 1500.0),
radius_x: 200.0,
radius_y: 250.0,
depth: 70.0,
},
);
page.add_stroke(octahedron_stroke);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon);
assert!(bytes_res.is_ok(), "Shape export must succeed");
let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1."));
}
}
+369
View File
@@ -0,0 +1,369 @@
//! Adaptiver 1€-Filter (One Euro Filter) für rauschfreie und latenzoptimierte Stylus-Eingaben.
//!
//! Filtert x, y und Druck (pressure p ∈ [0.0, 1.0]).
//! Implementiert numerische Härtung:
//! - dt-Clamping: dt <= 0 wird mit dt = 1e-4 s abgefangen, dt > 1.0 s löst ein reset() aus.
//! - Glättungsfaktor alpha wird strikt auf den Bereich [1e-5, 1.0] begrenzt.
//! - NaN/Inf-Schutz gegen Filtervergiftung.
use std::f32::consts::PI;
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
pub const DEFAULT_FC_MIN: f32 = 4.5;
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen)
pub const DEFAULT_BETA: f32 = 0.05;
/// Standard-Grenzfrequenz für die Ableitung in Hz
pub const DEFAULT_D_CUTOFF: f32 = 1.0;
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
///
/// Numerisch gehärtet: Garantiert Werte im Bereich [1e-5, 1.0] und schützt vor NaN/Inf.
pub fn smoothing_factor(dt: f32, cutoff: f32) -> f32 {
let r = 2.0 * PI * cutoff * dt;
if !r.is_finite() || r <= 0.0 {
1e-5
} else {
let alpha = r / (r + 1.0);
if !alpha.is_finite() {
1e-5
} else {
alpha.clamp(1e-5, 1.0)
}
}
}
/// 1D One-Euro-Filter für skalare Signale.
#[derive(Debug, Clone)]
pub struct OneEuroFilter1D {
pub fc_min: f32,
pub beta: f32,
pub d_cutoff: f32,
x_prev: Option<f32>,
dx_prev: f32,
t_prev: Option<f64>,
}
impl OneEuroFilter1D {
/// Erstellt einen neuen 1D-Filter mit den angegebenen Parametern.
pub fn new(fc_min: f32, beta: f32, d_cutoff: f32) -> Self {
Self {
fc_min,
beta,
d_cutoff,
x_prev: None,
dx_prev: 0.0,
t_prev: None,
}
}
/// Setzt den Filterzustand vollständig zurück.
pub fn reset(&mut self) {
self.x_prev = None;
self.dx_prev = 0.0;
self.t_prev = None;
}
/// Gibt zurück, ob der Filter bereits mindestens einen Messwert empfangen hat.
pub fn is_initialized(&self) -> bool {
self.x_prev.is_some()
}
/// Letzter gefilterter Wert, falls vorhanden.
pub fn last_value(&self) -> Option<f32> {
self.x_prev
}
/// Filtert einen Wert `x` zum Zeitstempel `t` (in Sekunden).
pub fn filter(&mut self, x: f32, t: f64) -> f32 {
// Schutz vor NaN- und Inf-Eingaben
if !x.is_finite() || !t.is_finite() {
return self.x_prev.unwrap_or(0.0);
}
let Some(prev_t) = self.t_prev else {
// Erster Messwert: Initialisierung
self.x_prev = Some(x);
self.dx_prev = 0.0;
self.t_prev = Some(t);
return x;
};
let raw_dt = t - prev_t;
// Numerische Härtung: dt > 1.0 s führt zu reset()
if raw_dt > 1.0 {
self.reset();
self.x_prev = Some(x);
self.dx_prev = 0.0;
self.t_prev = Some(t);
return x;
}
// Numerische Härtung: dt <= 0 wird mit dt = 1e-4 s abgefangen
let dt = if raw_dt <= 0.0 || !raw_dt.is_finite() {
1e-4_f32
} else {
raw_dt as f32
};
self.t_prev = Some(t);
let prev_x = self.x_prev.unwrap_or(x);
// 1. Ableitung (Geschwindigkeit) filtern
let mut dx = (x - prev_x) / dt;
if !dx.is_finite() {
dx = 0.0;
}
let a_d = smoothing_factor(dt, self.d_cutoff);
let mut dx_hat = a_d * dx + (1.0 - a_d) * self.dx_prev;
if !dx_hat.is_finite() {
dx_hat = self.dx_prev;
}
// 2. Adaptive Grenzfrequenz fc berechnen
let mut fc = self.fc_min + self.beta * dx_hat.abs();
if !fc.is_finite() || fc < self.fc_min {
fc = self.fc_min;
}
// 3. Signal mit adaptivem fc filtern
let a = smoothing_factor(dt, fc);
let mut x_hat = a * x + (1.0 - a) * prev_x;
if !x_hat.is_finite() {
x_hat = prev_x;
}
self.x_prev = Some(x_hat);
self.dx_prev = dx_hat;
x_hat
}
}
impl Default for OneEuroFilter1D {
fn default() -> Self {
Self::new(DEFAULT_FC_MIN, DEFAULT_BETA, DEFAULT_D_CUTOFF)
}
}
/// Gefilterter Eingabepunkt mit 2D-Position, Druck und Zeitstempel.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FilteredPoint {
pub position: glam::Vec2,
pub pressure: f32,
pub timestamp: f64,
}
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
#[derive(Debug, Clone)]
pub struct OneEuroFilter {
pub fc_min: f32,
pub beta: f32,
pub d_cutoff: f32,
x_filter: OneEuroFilter1D,
y_filter: OneEuroFilter1D,
p_filter: OneEuroFilter1D,
last_output: Option<(f32, f32, f32)>,
}
impl OneEuroFilter {
/// Erstellt einen neuen OneEuroFilter mit den angegebenen Parametern.
pub fn new(fc_min: f32, beta: f32, d_cutoff: f32) -> Self {
Self {
fc_min,
beta,
d_cutoff,
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
last_output: None,
}
}
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
pub fn reset(&mut self) {
self.x_filter.reset();
self.y_filter.reset();
self.p_filter.reset();
self.last_output = None;
}
/// Filtert x, y und Druck p ∈ [0.0, 1.0] zum Zeitstempel `t` (in Sekunden).
///
/// Gibt das gefilterte Tupel `(x, y, pressure)` zurück.
pub fn filter(&mut self, x: f32, y: f32, pressure: f32, t: f64) -> (f32, f32, f32) {
// Schutz vor NaN- und Inf-Eingaben
if !x.is_finite() || !y.is_finite() || !pressure.is_finite() || !t.is_finite() {
return self.last_output.unwrap_or((0.0, 0.0, 0.0));
}
// Druck immer auf [0.0, 1.0] klemmen
let p_in = pressure.clamp(0.0, 1.0);
let x_out = self.x_filter.filter(x, t);
let y_out = self.y_filter.filter(y, t);
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
let out = (x_out, y_out, p_out);
self.last_output = Some(out);
out
}
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) {
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
(glam::Vec2::new(x, y), p)
}
/// Filtert einen `FilteredPoint` und gibt den geglätteten Punkt zurück.
pub fn filter_stroke_point(&mut self, point: FilteredPoint) -> FilteredPoint {
let (pos, p) = self.filter_point(point.position, point.pressure, point.timestamp);
FilteredPoint {
position: pos,
pressure: p,
timestamp: point.timestamp,
}
}
}
impl Default for OneEuroFilter {
fn default() -> Self {
Self::new(DEFAULT_FC_MIN, DEFAULT_BETA, DEFAULT_D_CUTOFF)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zero_and_negative_dt() {
let mut filter = OneEuroFilter::default();
// 1. Erster gültiger Datenpunkt bei t = 1.0
let (x1, y1, p1) = filter.filter(10.0, 20.0, 0.5, 1.0);
assert_eq!(x1, 10.0);
assert_eq!(y1, 20.0);
assert_eq!(p1, 0.5);
// 2. dt == 0 (gleicher Zeitstempel t = 1.0): Sollte dt = 1e-4 abfangen
let (x2, y2, p2) = filter.filter(10.5, 20.5, 0.6, 1.0);
assert!(x2.is_finite(), "x2 muss endlich sein");
assert!(y2.is_finite(), "y2 muss endlich sein");
assert!(p2.is_finite(), "p2 muss endlich sein");
assert!((0.0..=1.0).contains(&p2), "Druck muss in [0, 1] liegen");
// 3. dt < 0 (Zeitstempel springt zurück auf t = 0.5): Sollte mit dt = 1e-4 abgefangen werden
let (x3, y3, p3) = filter.filter(11.0, 21.0, 0.7, 0.5);
assert!(x3.is_finite(), "x3 muss nach negativem dt endlich sein");
assert!(y3.is_finite(), "y3 muss nach negativem dt endlich sein");
assert!(p3.is_finite(), "p3 muss nach negativem dt endlich sein");
assert!((0.0..=1.0).contains(&p3));
// 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen
let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0);
// Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen
assert_eq!(x4, 50.0, "Nach Reset muss der neue X-Wert übernommen werden");
assert_eq!(y4, 100.0, "Nach Reset muss der neue Y-Wert übernommen werden");
assert_eq!(p4, 0.9, "Nach Reset muss der neue Druckwert übernommen werden");
}
#[test]
fn test_high_frequency_jitter() {
let mut filter = OneEuroFilter::default();
let base_x = 100.0_f32;
let base_y = 50.0_f32;
let base_p = 0.5_f32;
let jitter_amplitude = 2.0_f32;
let num_samples = 120;
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
let mut raw_x_values = Vec::with_capacity(num_samples);
let mut filtered_x_values = Vec::with_capacity(num_samples);
for i in 0..num_samples {
let t = i as f64 * dt;
// Hochfrequentes Rauschen mit Vorzeichenwechsel
let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
let raw_x = base_x + sign * jitter_amplitude;
let raw_y = base_y + sign * jitter_amplitude;
let raw_p = base_p + sign * 0.1;
raw_x_values.push(raw_x);
let (fx, fy, fp) = filter.filter(raw_x, raw_y, raw_p, t);
filtered_x_values.push(fx);
assert!(fx.is_finite());
assert!(fy.is_finite());
assert!(fp.is_finite());
assert!((0.0..=1.0).contains(&fp));
}
// Varianz nach der Einschwingphase (letzte 60 Samples) berechnen
let eval_range = 60..num_samples;
let count = eval_range.len() as f32;
let raw_mean: f32 = raw_x_values[eval_range.clone()].iter().sum::<f32>() / count;
let raw_variance: f32 = raw_x_values[eval_range.clone()]
.iter()
.map(|v| (v - raw_mean).powi(2))
.sum::<f32>()
/ count;
let filtered_mean: f32 = filtered_x_values[eval_range.clone()].iter().sum::<f32>() / count;
let filtered_variance: f32 = filtered_x_values[eval_range]
.iter()
.map(|v| (v - filtered_mean).powi(2))
.sum::<f32>()
/ count;
// Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen
assert!(
filtered_variance < raw_variance * 0.05,
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
);
}
#[test]
fn test_nan_inf_poisoning() {
let mut filter = OneEuroFilter::default();
// 1. Zuerst normale Werte einspeisen
let (x0, y0, p0) = filter.filter(10.0, 20.0, 0.5, 0.0);
assert_eq!((x0, y0, p0), (10.0, 20.0, 0.5));
let (x1, y1, p1) = filter.filter(11.0, 21.0, 0.55, 0.01);
assert!(x1.is_finite());
assert!(y1.is_finite());
assert!(p1.is_finite());
// 2. Vergiftungsversuch mit NaN und Infinity
let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02);
assert!(x_nan.is_finite(), "Filter darf kein NaN bei Vergiftung ausgeben");
assert!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben");
assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben");
let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN);
assert!(x_nan2.is_finite());
assert!(y_nan2.is_finite());
assert!(p_nan2.is_finite());
// 3. Nach der Vergiftung wieder normale Werte einspeisen
let (x2, y2, p2) = filter.filter(13.0, 23.0, 0.65, 0.03);
assert!(x2.is_finite(), "x2 muss nach Vergiftung endlich sein");
assert!(y2.is_finite(), "y2 muss nach Vergiftung endlich sein");
assert!(p2.is_finite(), "p2 muss nach Vergiftung endlich sein");
// Sicherstellen, dass der Filter nicht bei einem fixen Wert 'steckenbleibt'
let (x3, y3, p3) = filter.filter(30.0, 40.0, 0.8, 0.04);
assert!(x3.is_finite());
assert!(y3.is_finite());
assert!(p3.is_finite());
assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen");
}
}
+1600
View File
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
//! Transaktionales Undo / Redo mit History-Stack pro Seite oder Notizbuch.
use crate::document::Stroke;
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
#[derive(Debug, Clone)]
pub enum HistoryAction {
/// Ein Strich wurde neu gezeichnet.
AddStroke(Stroke),
/// Mehrere Striche wurden auf einmal hinzugefügt (z. B. Einfügen/Duplizieren).
AddStrokes(Vec<Stroke>),
/// Ein oder mehrere Striche wurden durch den Radierer oder Selektion gelöscht.
RemoveStrokes(Vec<Stroke>),
/// Ein Strich wurde durch eine geometrische Form ersetzt.
ReplaceStroke { old: Stroke, new: Stroke },
/// Ein oder mehrere Striche wurden modifiziert (z. B. verschoben, gedreht, skaliert, umgefärbt).
ModifyStrokes {
before: Vec<Stroke>,
after: Vec<Stroke>,
},
}
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
#[derive(Debug, Clone, Default)]
pub struct HistoryStack {
pub undo_stack: Vec<HistoryAction>,
pub redo_stack: Vec<HistoryAction>,
}
impl HistoryStack {
pub fn new() -> Self {
Self {
undo_stack: Vec::new(),
redo_stack: Vec::new(),
}
}
/// Registriert eine neue Aktion. Leert den Redo-Stack.
pub fn push(&mut self, action: HistoryAction) {
self.undo_stack.push(action);
self.redo_stack.clear();
}
/// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
/// Gibt zurück, ob Redo verfügbar ist.
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
/// Führt Undo auf der Liste der Striche aus.
///
/// Gibt `true` zurück, wenn sich die Strichliste geändert hat.
pub fn undo(&mut self, strokes: &mut Vec<Stroke>) -> bool {
let Some(action) = self.undo_stack.pop() else {
return false;
};
match action {
HistoryAction::AddStroke(stroke) => {
// Den hinzugefügten Strich aus der Seite entfernen
if let Some(pos) = strokes.iter().position(|s| s.id == stroke.id) {
strokes.remove(pos);
}
self.redo_stack.push(HistoryAction::AddStroke(stroke));
}
HistoryAction::AddStrokes(added) => {
let ids: Vec<u64> = added.iter().map(|s| s.id).collect();
strokes.retain(|s| !ids.contains(&s.id));
self.redo_stack.push(HistoryAction::AddStrokes(added));
}
HistoryAction::RemoveStrokes(removed) => {
// Die gelöschten Striche wiederherstellen
for s in &removed {
strokes.push(s.clone());
}
self.redo_stack.push(HistoryAction::RemoveStrokes(removed));
}
HistoryAction::ReplaceStroke { old, new } => {
if let Some(pos) = strokes.iter().position(|s| s.id == new.id) {
strokes[pos] = old.clone();
}
self.redo_stack.push(HistoryAction::ReplaceStroke { old, new });
}
HistoryAction::ModifyStrokes { before, after } => {
for b in &before {
if let Some(pos) = strokes.iter().position(|s| s.id == b.id) {
strokes[pos] = b.clone();
}
}
self.redo_stack.push(HistoryAction::ModifyStrokes { before, after });
}
}
true
}
/// Führt Redo auf der Liste der Striche aus.
///
/// Gibt `true` zurück, wenn sich die Strichliste geändert hat.
pub fn redo(&mut self, strokes: &mut Vec<Stroke>) -> bool {
let Some(action) = self.redo_stack.pop() else {
return false;
};
match action {
HistoryAction::AddStroke(stroke) => {
// Strich erneut hinzufügen
strokes.push(stroke.clone());
self.undo_stack.push(HistoryAction::AddStroke(stroke));
}
HistoryAction::AddStrokes(added) => {
strokes.extend(added.clone());
self.undo_stack.push(HistoryAction::AddStrokes(added));
}
HistoryAction::RemoveStrokes(removed) => {
// Striche erneut entfernen
let ids: Vec<u64> = removed.iter().map(|s| s.id).collect();
strokes.retain(|s| !ids.contains(&s.id));
self.undo_stack.push(HistoryAction::RemoveStrokes(removed));
}
HistoryAction::ReplaceStroke { old, new } => {
if let Some(pos) = strokes.iter().position(|s| s.id == old.id) {
strokes[pos] = new.clone();
}
self.undo_stack.push(HistoryAction::ReplaceStroke { old, new });
}
HistoryAction::ModifyStrokes { before, after } => {
for a in &after {
if let Some(pos) = strokes.iter().position(|s| s.id == a.id) {
strokes[pos] = a.clone();
}
}
self.undo_stack.push(HistoryAction::ModifyStrokes { before, after });
}
}
true
}
/// Setzt den History-Stack zurück.
pub fn clear(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StylusSample;
fn make_dummy_stroke(id: u64) -> Stroke {
Stroke::new(
id,
vec![StylusSample {
position: glam::Vec2::new(0.0, 0.0),
pressure: 0.5,
timestamp: 0.0,
}],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
)
}
#[test]
fn test_undo_redo_add_stroke() {
let mut history = HistoryStack::new();
let mut strokes = Vec::new();
let s1 = make_dummy_stroke(1);
strokes.push(s1.clone());
history.push(HistoryAction::AddStroke(s1));
assert_eq!(strokes.len(), 1);
assert!(history.can_undo());
assert!(!history.can_redo());
// Undo
assert!(history.undo(&mut strokes));
assert_eq!(strokes.len(), 0);
assert!(!history.can_undo());
assert!(history.can_redo());
// Redo
assert!(history.redo(&mut strokes));
assert_eq!(strokes.len(), 1);
assert_eq!(strokes[0].id, 1);
assert!(history.can_undo());
assert!(!history.can_redo());
}
#[test]
fn test_undo_redo_remove_strokes() {
let mut history = HistoryStack::new();
let mut strokes = vec![make_dummy_stroke(1), make_dummy_stroke(2)];
// Löschen von Stroke 2
let removed = vec![strokes.remove(1)];
history.push(HistoryAction::RemoveStrokes(removed));
assert_eq!(strokes.len(), 1);
// Undo: Stroke 2 wird wiederhergestellt
assert!(history.undo(&mut strokes));
assert_eq!(strokes.len(), 2);
// Redo: Stroke 2 wird wieder entfernt
assert!(history.redo(&mut strokes));
assert_eq!(strokes.len(), 1);
assert_eq!(strokes[0].id, 1);
}
#[test]
fn test_undo_redo_replace_stroke() {
let mut history = HistoryStack::new();
let s_old = make_dummy_stroke(1);
let mut s_new = make_dummy_stroke(1);
s_new.color = [1.0, 0.0, 0.0, 1.0]; // Modifizierter Strich (z. B. erkannte Form)
let mut strokes = vec![s_old.clone()];
strokes[0] = s_new.clone();
history.push(HistoryAction::ReplaceStroke {
old: s_old.clone(),
new: s_new.clone(),
});
assert_eq!(strokes[0].color, [1.0, 0.0, 0.0, 1.0]);
// Undo: stellt den alten Strich wieder her
assert!(history.undo(&mut strokes));
assert_eq!(strokes[0].color, s_old.color);
// Redo: setzt den neuen Strich wieder ein
assert!(history.redo(&mut strokes));
assert_eq!(strokes[0].color, [1.0, 0.0, 0.0, 1.0]);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod autosave;
pub mod background;
pub mod canvas;
pub mod document;
pub mod export;
pub mod filter;
pub mod geometry;
pub mod history;
pub mod selection;
pub mod spline;
pub mod storage;
pub mod ui;
pub mod viewport;
+1678
View File
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
//! Objekt-Auswahl und Manipulation (Selektion, Verschieben, Drehen, Skalieren, Kopieren, Einfügen, Umfärben).
//!
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
use std::f32::consts::FRAC_PI_2;
use glam::Vec2;
use crate::canvas::CanvasState;
use crate::document::{Aabb, Page, Stroke};
/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus.
#[derive(Debug, Clone, PartialEq, Default)]
pub enum DragMode {
#[default]
None,
/// Aufziehen eines Auswahlrahmens (Marquee Box)
BoxSelect {
start_page: Vec2,
current_page: Vec2,
},
/// Verschieben ausgewählter Objekte
Move {
start_page: Vec2,
initial_strokes: Vec<Stroke>,
},
/// Drehen ausgewählter Objekte um das Zentrum der Auswahl
Rotate {
center_page: Vec2,
start_angle: f32,
initial_strokes: Vec<Stroke>,
},
/// Skalieren ausgewählter Objekte relativ zum gegenüberliegenden Eckpunkt
Scale {
fixed_corner_page: Vec2,
start_pos_page: Vec2,
initial_box_size: Vec2,
initial_strokes: Vec<Stroke>,
},
}
/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SelectionHandleHit {
None,
/// Dreh-Griff (oberhalb der Auswahlbox)
RotateHandle {
center_page: Vec2,
},
/// Einer der 4 Eck-Skalierungsgriffe (0: oben-links, 1: oben-rechts, 2: unten-rechts, 3: unten-links)
CornerHandle {
corner_idx: usize,
fixed_corner_page: Vec2,
start_pos_page: Vec2,
initial_box_size: Vec2,
},
/// Klick in den Körper / Innenbereich der Auswahlbox
Body,
}
/// Verwaltet den Zustand der Objektauswahl, des Clipboards und der Manipulationen.
#[derive(Debug, Clone, Default)]
pub struct SelectionState {
pub selected_ids: Vec<u64>,
pub drag_mode: DragMode,
pub clipboard: Vec<Stroke>,
}
impl SelectionState {
pub fn new() -> Self {
Self {
selected_ids: Vec::new(),
drag_mode: DragMode::None,
clipboard: Vec::new(),
}
}
/// Prüft, ob aktuell Objekte ausgewählt sind.
pub fn is_empty(&self) -> bool {
self.selected_ids.is_empty()
}
/// Anzahl der aktuell ausgewählten Objekte.
pub fn count(&self) -> usize {
self.selected_ids.len()
}
/// Leert die aktuelle Auswahl.
pub fn clear(&mut self) {
self.selected_ids.clear();
self.drag_mode = DragMode::None;
}
/// Wählt ein einzelnes Objekt aus.
pub fn select_single(&mut self, id: u64) {
self.selected_ids = vec![id];
}
/// Schaltet die Auswahl eines Objekts um (z. B. bei Shift+Klick).
pub fn toggle_select(&mut self, id: u64) {
if let Some(pos) = self.selected_ids.iter().position(|&x| x == id) {
self.selected_ids.remove(pos);
} else {
self.selected_ids.push(id);
}
}
/// Setzt die Auswahl auf eine Liste von Objekt-IDs.
pub fn select_multiple(&mut self, ids: Vec<u64>) {
self.selected_ids = ids;
}
/// Berechnet die gemeinsame AABB aller ausgewählten Objekte.
pub fn get_selection_aabb(&self, page: &Page) -> Option<Aabb> {
let strokes = page.get_strokes(&self.selected_ids);
Page::selection_aabb(&strokes)
}
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
pub fn hit_test_handles(
&self,
page: &Page,
canvas: &CanvasState,
screen_pos: Vec2,
) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let screen_min = Vec2::new(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = Vec2::new(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
if (screen_pos - rot_screen).length() <= 14.0 {
return SelectionHandleHit::RotateHandle {
center_page: page_center,
};
}
// 2. 4 Eck-Skalierungsgriffe testen
let corners_page = [
aabb.min, // 0: oben-links
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
aabb.max, // 2: unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
];
let fixed_corners_page = [
aabb.max, // gegenüber 0 -> unten-rechts
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
aabb.min, // gegenüber 2 -> oben-links
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
];
let corners_screen = [
screen_min,
Vec2::new(screen_max.x, screen_min.y),
screen_max,
Vec2::new(screen_min.x, screen_max.y),
];
for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 {
return SelectionHandleHit::CornerHandle {
corner_idx: i,
fixed_corner_page: fixed_corners_page[i],
start_pos_page: corners_page[i],
initial_box_size: aabb.max - aabb.min,
};
}
}
// 3. Körper-Test
let margin = 6.0;
if screen_pos.x >= screen_min.x - margin
&& screen_pos.x <= screen_max.x + margin
&& screen_pos.y >= screen_min.y - margin
&& screen_pos.y <= screen_max.y + margin
{
return SelectionHandleHit::Body;
}
SelectionHandleHit::None
}
/// Kopiert die aktuell ausgewählten Objekte in das Clipboard.
pub fn copy_to_clipboard(&mut self, page: &Page) -> usize {
let strokes = page.get_strokes(&self.selected_ids);
let count = strokes.len();
self.clipboard = strokes;
count
}
/// Fügt Objekte aus dem Clipboard mit Versatz (+30 Einheiten) ein, vergibt neue IDs und wählt sie aus.
pub fn paste_from_clipboard(
&mut self,
page: &mut Page,
next_id_gen: &mut impl FnMut() -> u64,
) -> Vec<u64> {
if self.clipboard.is_empty() {
return Vec::new();
}
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
let mut new_ids = Vec::with_capacity(self.clipboard.len());
for stroke in &self.clipboard {
let new_id = next_id_gen();
let mut cloned = stroke.clone();
cloned.id = new_id;
cloned.translate(offset);
new_ids.push(new_id);
new_strokes.push(cloned);
}
page.add_strokes(new_strokes);
self.selected_ids = new_ids.clone();
new_ids
}
/// Löscht die ausgewählten Objekte transaktional aus der Seite.
pub fn delete_selected(&mut self, page: &mut Page) -> Vec<Stroke> {
if self.selected_ids.is_empty() {
return Vec::new();
}
let removed = page.remove_strokes_by_ids(&self.selected_ids);
self.selected_ids.clear();
self.drag_mode = DragMode::None;
removed
}
/// Färbt alle ausgewählten Objekte auf `new_color` um (inkl. Vertex-Buffer).
pub fn recolor_selected(&mut self, page: &mut Page, new_color: [f32; 4]) -> bool {
if self.selected_ids.is_empty() {
return false;
}
let mut strokes = page.get_strokes(&self.selected_ids);
if strokes.is_empty() {
return false;
}
for s in &mut strokes {
s.set_color(new_color);
}
page.modify_strokes(strokes);
true
}
/// Dreht alle ausgewählten Objekte um 90° im Uhrzeigersinn um das Zentrum ihrer AABB.
pub fn rotate_selected_90_deg(&mut self, page: &mut Page) -> bool {
let Some(aabb) = self.get_selection_aabb(page) else {
return false;
};
let center = (aabb.min + aabb.max) * 0.5;
let mut strokes = page.get_strokes(&self.selected_ids);
if strokes.is_empty() {
return false;
}
for s in &mut strokes {
s.rotate(center, FRAC_PI_2);
}
page.modify_strokes(strokes);
true
}
}
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
pub fn render_selection_overlay(
ctx: &egui::Context,
selection: &SelectionState,
page: &Page,
canvas: &CanvasState,
) {
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"),
));
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]);
let max_y = p1[1].max(p2[1]);
let rect = egui::Rect::from_min_max(egui::pos2(min_x, min_y), egui::pos2(max_x, max_y));
// Halbtransparente Füllung & blauer Auswahlrand
painter.rect_filled(
rect,
egui::Rounding::same(4.0_f32),
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
);
painter.rect_stroke(
rect,
egui::Rounding::same(4.0_f32),
egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)),
);
return;
}
// 2. Wenn Objekte ausgewählt sind, Bounding Box und Griffe zeichnen
let Some(aabb) = selection.get_selection_aabb(page) else {
return;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
let rect = egui::Rect::from_min_max(screen_min, screen_max);
// Haupt-Auswahlrahmen (leuchtend blau)
let border_color = egui::Color32::from_rgb(70, 150, 255);
painter.rect_stroke(
rect,
egui::Rounding::same(3.0_f32),
egui::Stroke::new(1.5_f32, border_color),
);
// Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
painter.line_segment(
[top_center, rot_handle],
egui::Stroke::new(1.2_f32, border_color),
);
// Drehgriff-Kreis
painter.circle_filled(rot_handle, 6.0_f32, egui::Color32::WHITE);
painter.circle_stroke(
rot_handle,
6.0_f32,
egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(40, 90, 180)),
);
// 4 Eck-Skalierungsgriffe (kleine weiße Quadrate mit blauem Rand)
let handle_size = 7.0_f32;
let corners = [
rect.min,
egui::pos2(rect.max.x, rect.min.y),
rect.max,
egui::pos2(rect.min.x, rect.max.y),
];
for c in corners {
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
painter.rect_filled(handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE);
painter.rect_stroke(
handle_rect,
egui::Rounding::same(1.5_f32),
egui::Stroke::new(1.2_f32, egui::Color32::from_rgb(30, 80, 170)),
);
}
// Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen")
let badge_text = format!(
"{} {} | [R] Drehen [Entf] Löschen",
selection.count(),
if selection.count() == 1 { "Objekt" } else { "Objekte" }
);
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
painter.text(
badge_pos,
egui::Align2::CENTER_CENTER,
badge_text,
egui::FontId::proportional(11.0),
egui::Color32::from_gray(220),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canvas::InkVertex;
use crate::document::{PageBackground, StylusSample};
fn make_test_stroke(id: u64, x: f32, y: f32, color: [f32; 4]) -> Stroke {
let s1 = StylusSample {
position: Vec2::new(x, y),
pressure: 0.5,
timestamp: 0.0,
};
let s2 = StylusSample {
position: Vec2::new(x + 50.0, y + 50.0),
pressure: 0.5,
timestamp: 0.01,
};
let v1 = InkVertex::new(x, y, color);
let v2 = InkVertex::new(x + 50.0, y + 50.0, color);
Stroke::new(id, vec![s1, s2], vec![v1, v2], color)
}
#[test]
fn test_selection_copy_paste_delete() {
let mut page = Page::new(1, PageBackground::Blank);
let s1 = make_test_stroke(1, 100.0, 100.0, [0.1, 0.1, 0.1, 1.0]);
let s2 = make_test_stroke(2, 300.0, 300.0, [0.8, 0.2, 0.2, 1.0]);
page.add_stroke(s1);
page.add_stroke(s2);
let mut sel = SelectionState::new();
sel.select_single(1);
assert_eq!(sel.count(), 1);
// Copy
let copied = sel.copy_to_clipboard(&page);
assert_eq!(copied, 1);
// Paste
let mut next_id = 100;
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
next_id += 1;
next_id
});
assert_eq!(new_ids.len(), 1);
assert_eq!(page.strokes.len(), 3);
assert_eq!(sel.selected_ids, new_ids);
// Delete
let deleted = sel.delete_selected(&mut page);
assert_eq!(deleted.len(), 1);
assert_eq!(page.strokes.len(), 2);
assert!(sel.is_empty());
}
#[test]
fn test_selection_recolor_and_rotate() {
let mut page = Page::new(1, PageBackground::Blank);
let s1 = make_test_stroke(1, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]);
page.add_stroke(s1);
let mut sel = SelectionState::new();
sel.select_single(1);
// Recolor
let new_color = [0.0, 1.0, 0.0, 1.0];
assert!(sel.recolor_selected(&mut page, new_color));
assert_eq!(page.strokes[0].color, new_color);
assert_eq!(page.strokes[0].vertices[0].color, new_color);
// Undo recolor
assert!(page.undo());
assert_eq!(page.strokes[0].color, [0.1, 0.1, 0.1, 1.0]);
// Rotate 90 deg
let orig_pos = page.strokes[0].points[0].position;
assert!(sel.rotate_selected_90_deg(&mut page));
let rotated_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1);
// Undo rotate
assert!(page.undo());
let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
}
}
+34
View File
@@ -0,0 +1,34 @@
// WGSL Shader für 2D-Vektor-Meshes der Inking-App
// Unterstützt echte Multi-Color-Vertizes und DIN-A4-Seitenkoordinatentransformation
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
};
struct CameraUniforms {
transform_scale: vec2<f32>,
transform_offset: vec2<f32>,
};
@group(0) @binding(0)
var<uniform> camera: CameraUniforms;
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
}
+450
View File
@@ -0,0 +1,450 @@
//! Zentripetale Catmull-Rom-Spline-Interpolation (alpha = 0.5) über De-Boor / Barry-Goldman.
//!
//! Berechnet für jeden Schritt entlang der Kurve:
//! - Position C(t)
//! - Tangente T(t) (analytische Ableitung)
//! - Normale N(t) (senkrecht auf Tangente)
//! - Druckabhängigen Radius r(t)
//! - Linke und rechte Hüllpunkte
//!
//! Generiert daraus Triangle-Strips für das GPU-Rendering mit `wgpu`.
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Vertex {
pub position: [f32; 2],
pub pressure: f32,
pub side: f32, // -1.0 für linken Hüllpunkt, +1.0 für rechten Hüllpunkt
}
impl Vertex {
pub fn new(x: f32, y: f32, pressure: f32, side: f32) -> Self {
Self {
position: [x, y],
pressure,
side,
}
}
pub fn from_vec2(pos: glam::Vec2, pressure: f32, side: f32) -> Self {
Self {
position: [pos.x, pos.y],
pressure,
side,
}
}
pub fn pos(&self) -> glam::Vec2 {
glam::Vec2::new(self.position[0], self.position[1])
}
}
/// Kontrollpunkt für die Spline-Interpolation mit Position und Druck p ∈ [0.0, 1.0].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ControlPoint {
pub position: glam::Vec2,
pub pressure: f32,
}
impl ControlPoint {
pub fn new(x: f32, y: f32, pressure: f32) -> Self {
Self {
position: glam::Vec2::new(x, y),
pressure: pressure.clamp(0.0, 1.0),
}
}
pub fn from_vec2(position: glam::Vec2, pressure: f32) -> Self {
Self {
position,
pressure: pressure.clamp(0.0, 1.0),
}
}
}
/// Ein berechneter Abtastschritt entlang des Splines mit Tangente, Normale und Hüllpunkten.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SplineSample {
pub position: glam::Vec2,
pub tangent: glam::Vec2,
pub normal: glam::Vec2,
pub pressure: f32,
pub radius: f32,
pub left: glam::Vec2,
pub right: glam::Vec2,
}
/// Konfiguration für die Spline-Interpolation und das Meshing.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SplineConfig {
/// Minimaler Pinselradius bei Druck p = 0.0
pub min_radius: f32,
/// Maximaler Pinselradius bei Druck p = 1.0
pub max_radius: f32,
/// Anzahl an Diskretisierungsschritten pro Spline-Segment
pub samples_per_segment: usize,
}
impl Default for SplineConfig {
fn default() -> Self {
Self {
min_radius: 1.5,
max_radius: 4.5,
samples_per_segment: 16,
}
}
}
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
let p = pressure.clamp(0.0, 1.0);
min_radius + p * (max_radius - min_radius)
}
/// Zentripetale Catmull-Rom-Interpolation (alpha = 0.5) über De-Boor / Barry-Goldman.
///
/// Berechnet Position, Tangente, Normale, Radius und linke/rechte Hüllpunkte
/// für den Parameter `u` ∈ [0.0, 1.0] im Segment zwischen `p1` und `p2`.
pub fn evaluate_catmull_rom_segment(
p0: ControlPoint,
p1: ControlPoint,
p2: ControlPoint,
p3: ControlPoint,
u: f32,
config: &SplineConfig,
) -> SplineSample {
let u = u.clamp(0.0, 1.0);
// Knotensequenz mit alpha = 0.5 (zentripetal: sqrt(Distanz))
let d01 = (p1.position - p0.position).length();
let d12 = (p2.position - p1.position).length();
let d23 = (p3.position - p2.position).length();
let dt01 = if d01 > 1e-6 { d01.sqrt() } else { 1e-4 };
let dt12 = if d12 > 1e-6 { d12.sqrt() } else { 1e-4 };
let dt23 = if d23 > 1e-6 { d23.sqrt() } else { 1e-4 };
let t0 = 0.0_f32;
let t1 = t0 + dt01;
let t2 = t1 + dt12;
let t3 = t2 + dt23;
let t = t1 + u * (t2 - t1);
// --- De-Boor / Barry-Goldman Pyramide ---
// Ebene 1:
let a1 = ((t1 - t) * p0.position + (t - t0) * p1.position) / (t1 - t0);
let a2 = ((t2 - t) * p1.position + (t - t1) * p2.position) / (t2 - t1);
let a3 = ((t3 - t) * p2.position + (t - t2) * p3.position) / (t3 - t2);
let pa1 = ((t1 - t) * p0.pressure + (t - t0) * p1.pressure) / (t1 - t0);
let pa2 = ((t2 - t) * p1.pressure + (t - t1) * p2.pressure) / (t2 - t1);
let pa3 = ((t3 - t) * p2.pressure + (t - t2) * p3.pressure) / (t3 - t2);
// Ebene 1 Ableitungen:
let da1 = (p1.position - p0.position) / (t1 - t0);
let da2 = (p2.position - p1.position) / (t2 - t1);
let da3 = (p3.position - p2.position) / (t3 - t2);
// Ebene 2:
let b1 = ((t2 - t) * a1 + (t - t0) * a2) / (t2 - t0);
let b2 = ((t3 - t) * a2 + (t - t1) * a3) / (t3 - t1);
let pb1 = ((t2 - t) * pa1 + (t - t0) * pa2) / (t2 - t0);
let pb2 = ((t3 - t) * pa2 + (t - t1) * pa3) / (t3 - t1);
// Ebene 2 Ableitungen:
let db1 = (a2 - a1) / (t2 - t0) + ((t2 - t) * da1 + (t - t0) * da2) / (t2 - t0);
let db2 = (a3 - a2) / (t3 - t1) + ((t3 - t) * da2 + (t - t1) * da3) / (t3 - t1);
// Ebene 3 (Ergebnis):
let pos = ((t2 - t) * b1 + (t - t1) * b2) / (t2 - t1);
let pressure = (((t2 - t) * pb1 + (t - t1) * pb2) / (t2 - t1)).clamp(0.0, 1.0);
// Ebene 3 Ableitung (analytische Tangente C'(t)):
let dc = (b2 - b1) / (t2 - t1) + ((t2 - t) * db1 + (t - t1) * db2) / (t2 - t1);
// Tangente normieren (oder Fallback bei Stillstand)
let tangent = if dc.length_squared() > 1e-10 {
dc.normalize()
} else {
let fallback = p2.position - p1.position;
if fallback.length_squared() > 1e-10 {
fallback.normalize()
} else {
glam::Vec2::new(1.0, 0.0)
}
};
// Normale senkrecht zur Tangente: (-Ty, Tx)
let normal = glam::Vec2::new(-tangent.y, tangent.x);
// Druckabhängiger Radius r(t)
let radius = calculate_radius(pressure, config.min_radius, config.max_radius);
// Linke und rechte Hüllpunkte
let left = pos + normal * radius;
let right = pos - normal * radius;
SplineSample {
position: pos,
tangent,
normal,
pressure,
radius,
left,
right,
}
}
/// Tastet ein einzelnes Segment zwischen `p1` und `p2` ab.
pub fn sample_segment(
p0: ControlPoint,
p1: ControlPoint,
p2: ControlPoint,
p3: ControlPoint,
config: &SplineConfig,
) -> Vec<SplineSample> {
let samples = config.samples_per_segment.max(1);
let mut result = Vec::with_capacity(samples + 1);
for i in 0..=samples {
let u = i as f32 / samples as f32;
result.push(evaluate_catmull_rom_segment(p0, p1, p2, p3, u, config));
}
result
}
/// Wandelt eine Folge von `SplineSample`s in ein abwechselndes Triangle-Strip-Vertex-Array um:
/// [left_0, right_0, left_1, right_1, left_2, right_2, ...]
pub fn samples_to_triangle_strip(samples: &[SplineSample]) -> Vec<Vertex> {
if samples.is_empty() {
return Vec::new();
}
let mut vertices = Vec::with_capacity(samples.len() * 2);
for s in samples {
vertices.push(Vertex::new(s.left.x, s.left.y, s.pressure, -1.0));
vertices.push(Vertex::new(s.right.x, s.right.y, s.pressure, 1.0));
}
vertices
}
/// Generiert einen zusammenhängenden Triangle-Strip aus einer Liste von Eingabe-Kontrollpunkten.
///
/// Handhabt automatisch Randpunkte (Geisterpunkte / Spiegelung an den Enden),
/// interpoliert mit zentripetalen Catmull-Rom-Splines und erzeugt ein flüssiges Ribbon-Mesh.
pub fn generate_triangle_strip(
control_points: &[ControlPoint],
config: &SplineConfig,
) -> Vec<Vertex> {
match control_points.len() {
0 => Vec::new(),
1 => {
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
let pt = control_points[0];
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
let segments = 8;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
vertices.push(Vertex::from_vec2(pt.position, pt.pressure, 0.0));
vertices.push(Vertex::from_vec2(pt.position + offset, pt.pressure, 1.0));
}
vertices
}
2 => {
let p0 = control_points[0];
let p1 = control_points[1];
// Geisterpunkte durch lineare Extrapolation
let ghost_before = ControlPoint {
position: 2.0 * p0.position - p1.position,
pressure: p0.pressure,
};
let ghost_after = ControlPoint {
position: 2.0 * p1.position - p0.position,
pressure: p1.pressure,
};
let samples = sample_segment(ghost_before, p0, p1, ghost_after, config);
samples_to_triangle_strip(&samples)
}
n => {
let steps = config.samples_per_segment.max(1);
let mut samples = Vec::new();
for i in 0..n - 1 {
let p_curr = control_points[i];
let p_next = control_points[i + 1];
let p_prev = if i == 0 {
ControlPoint {
position: 2.0 * p_curr.position - p_next.position,
pressure: p_curr.pressure,
}
} else {
control_points[i - 1]
};
let p_after = if i + 2 < n {
control_points[i + 2]
} else {
ControlPoint {
position: 2.0 * p_next.position - p_curr.position,
pressure: p_next.pressure,
}
};
// Für das erste Segment sample von u = 0.0 bis 1.0
// Für nachfolgende Segmente bei u > 0 beginnen, um doppelte Vertices zu vermeiden
let start_step = if i == 0 { 0 } else { 1 };
for s in start_step..=steps {
let u = s as f32 / steps as f32;
samples.push(evaluate_catmull_rom_segment(
p_prev, p_curr, p_next, p_after, u, config,
));
}
}
samples_to_triangle_strip(&samples)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_centripetal_interpolation_endpoints() {
let p0 = ControlPoint::new(0.0, 0.0, 0.2);
let p1 = ControlPoint::new(10.0, 2.0, 0.4);
let p2 = ControlPoint::new(20.0, 8.0, 0.8);
let p3 = ControlPoint::new(30.0, 10.0, 1.0);
let config = SplineConfig::default();
// Am Start u = 0.0 muss der Spline exakt bei p1 sein
let start_sample = evaluate_catmull_rom_segment(p0, p1, p2, p3, 0.0, &config);
assert!((start_sample.position.x - p1.position.x).abs() < 1e-4);
assert!((start_sample.position.y - p1.position.y).abs() < 1e-4);
assert!((start_sample.pressure - p1.pressure).abs() < 1e-4);
// Am Ende u = 1.0 muss der Spline exakt bei p2 sein
let end_sample = evaluate_catmull_rom_segment(p0, p1, p2, p3, 1.0, &config);
assert!((end_sample.position.x - p2.position.x).abs() < 1e-4);
assert!((end_sample.position.y - p2.position.y).abs() < 1e-4);
assert!((end_sample.pressure - p2.pressure).abs() < 1e-4);
}
#[test]
fn test_tangent_and_normal_orthogonality() {
let p0 = ControlPoint::new(0.0, 0.0, 0.5);
let p1 = ControlPoint::new(10.0, 10.0, 0.5);
let p2 = ControlPoint::new(20.0, 5.0, 0.5);
let p3 = ControlPoint::new(30.0, 0.0, 0.5);
let config = SplineConfig::default();
let samples = sample_segment(p0, p1, p2, p3, &config);
for s in samples {
// Tangente und Normale müssen normiert sein (Länge ca. 1.0)
assert!((s.tangent.length() - 1.0).abs() < 1e-4);
assert!((s.normal.length() - 1.0).abs() < 1e-4);
// Skalarprodukt aus Tangente und Normale muss 0 sein (orthogonal)
let dot = s.tangent.dot(s.normal);
assert!(
dot.abs() < 1e-4,
"Tangente ({:?}) und Normale ({:?}) müssen orthogonal sein, dot = {}",
s.tangent,
s.normal,
dot
);
// Linker und rechter Punkt müssen im Abstand 'radius' von der Mittellinie liegen
let dist_left = (s.left - s.position).length();
let dist_right = (s.right - s.position).length();
assert!((dist_left - s.radius).abs() < 1e-4);
assert!((dist_right - s.radius).abs() < 1e-4);
}
}
#[test]
fn test_pressure_dependent_radius() {
let min_r = 1.0;
let max_r = 5.0;
assert_eq!(calculate_radius(0.0, min_r, max_r), 1.0);
assert_eq!(calculate_radius(1.0, min_r, max_r), 5.0);
assert_eq!(calculate_radius(0.5, min_r, max_r), 3.0);
// Außerhalb des Intervalls geklemmt
assert_eq!(calculate_radius(-0.5, min_r, max_r), 1.0);
assert_eq!(calculate_radius(1.5, min_r, max_r), 5.0);
}
#[test]
fn test_triangle_strip_generation() {
let points = vec![
ControlPoint::new(0.0, 0.0, 0.2),
ControlPoint::new(10.0, 10.0, 0.5),
ControlPoint::new(20.0, 10.0, 0.8),
ControlPoint::new(30.0, 0.0, 1.0),
];
let config = SplineConfig {
min_radius: 1.0,
max_radius: 4.0,
samples_per_segment: 10,
};
let strip = generate_triangle_strip(&points, &config);
// 3 Segmente, erstes mit 11 Samples, nächste zwei je 10 Samples = 31 Samples
// Jedes Sample liefert 2 Vertices (left, right) -> 62 Vertices
assert_eq!(strip.len(), 31 * 2);
// Vertices müssen abwechselnd side -1.0 und +1.0 haben
for (i, v) in strip.iter().enumerate() {
assert!(v.position[0].is_finite());
assert!(v.position[1].is_finite());
assert!((0.0..=1.0).contains(&v.pressure));
if i % 2 == 0 {
assert_eq!(v.side, -1.0, "Gerader Index muss linker Hüllpunkt sein");
} else {
assert_eq!(v.side, 1.0, "Ungerader Index muss rechter Hüllpunkt sein");
}
}
}
#[test]
fn test_degenerate_points() {
// Zwei identische Punkte dürfen keine Panik, keine Division durch Null und kein NaN erzeugen
let points = vec![
ControlPoint::new(5.0, 5.0, 0.5),
ControlPoint::new(5.0, 5.0, 0.5),
];
let config = SplineConfig::default();
let strip = generate_triangle_strip(&points, &config);
assert!(!strip.is_empty());
for v in strip {
assert!(v.position[0].is_finite());
assert!(v.position[1].is_finite());
assert!(v.pressure.is_finite());
assert!(v.side.is_finite());
}
}
}
+751
View File
@@ -0,0 +1,751 @@
//! Robuste lokale Notizbuch-Persistenz mit atomaren Writes (Crash-Resistenz)
//! und semantischer Datenspeicherung (ohne gecachte GPU-Vertizes).
use std::error::Error;
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
use crate::document::{Notebook, Page, PageBackground, Stroke, StylusSample};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SampleData {
pub x: f32,
pub y: f32,
pub p: f32,
pub t: f64,
}
impl From<&StylusSample> for SampleData {
fn from(s: &StylusSample) -> Self {
Self {
x: s.position.x,
y: s.position.y,
p: s.pressure,
t: s.timestamp,
}
}
}
impl From<SampleData> for StylusSample {
fn from(d: SampleData) -> Self {
Self {
position: glam::Vec2::new(d.x, d.y),
pressure: d.p,
timestamp: d.t,
}
}
}
/// Serialisierbare semantische Strichdaten (ohne GPU-Vertizes).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StrokeData {
pub id: u64,
pub color: [f32; 4],
pub samples: Vec<SampleData>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<crate::geometry::RecognizedShape>,
}
impl From<&Stroke> for StrokeData {
fn from(stroke: &Stroke) -> Self {
Self {
id: stroke.id,
color: stroke.color,
samples: stroke.points.iter().map(SampleData::from).collect(),
shape: stroke.shape.clone(),
}
}
}
impl StrokeData {
/// Wandelt die semantischen Daten deterministisch zurück in einen `Stroke` um,
/// inklusive Wiederherstellung des Triangle-Strip-Vertexpuffers und der AABB.
pub fn to_stroke(&self) -> Stroke {
let stylus_samples: Vec<StylusSample> = self.samples.iter().copied().map(StylusSample::from).collect();
let vertices = if let Some(ref shape) = self.shape {
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color);
shape_verts
} else {
reconstruct_stroke_vertices(&stylus_samples, self.color)
};
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
stroke.shape = self.shape.clone();
stroke
}
}
/// Serialisierbare semantische Seitendaten.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageData {
pub id: u64,
pub background: PageBackground,
pub strokes: Vec<StrokeData>,
}
impl From<&Page> for PageData {
fn from(page: &Page) -> Self {
Self {
id: page.id,
background: page.background,
strokes: page.strokes.iter().map(StrokeData::from).collect(),
}
}
}
impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background);
for stroke_data in &self.strokes {
page.strokes.push(stroke_data.to_stroke());
}
page.is_dirty = true;
page.rebuild_cache_if_dirty();
page.is_loaded = true;
page
}
}
/// Metadaten des gesamten Notizbuchs (`manifest.json`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NotebookManifest {
pub id: String,
pub title: String,
pub created_at: u64,
pub modified_at: u64,
pub current_page_index: usize,
pub page_ids: Vec<u64>,
pub next_stroke_id: u64,
pub next_page_id: u64,
}
impl NotebookManifest {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
id: id.into(),
title: title.into(),
created_at: now,
modified_at: now,
current_page_index: 0,
page_ids: vec![1],
next_stroke_id: 1,
next_page_id: 2,
}
}
}
/// Mögliche Fehler bei Speicher- und Ladeoperationen.
#[derive(Debug)]
pub enum StorageError {
Io(io::Error),
Serialization(serde_json::Error),
InvalidManifest(String),
CorruptedPage(u64, String),
PageNotFound(u64),
}
impl fmt::Display for StorageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"),
StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"),
StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"),
StorageError::CorruptedPage(id, msg) => {
write!(f, "Beschädigte Seite {id}: {msg}")
}
StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"),
}
}
}
impl Error for StorageError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
StorageError::Io(e) => Some(e),
StorageError::Serialization(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for StorageError {
fn from(e: io::Error) -> Self {
StorageError::Io(e)
}
}
impl From<serde_json::Error> for StorageError {
fn from(e: serde_json::Error) -> Self {
StorageError::Serialization(e)
}
}
/// Rekonstruiert deterministisch die GPU-Triangle-Strip-Vertizes aus Stylus-Samples.
pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> Vec<InkVertex> {
if samples.is_empty() {
return Vec::new();
}
if samples.len() == 1 {
let pt = samples[0];
let radius = spline::calculate_radius(pt.pressure, 2.5, 10.0);
let segments = 8;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
vertices.push(InkVertex::new(pt.position.x + offset.x, pt.position.y + offset.y, color));
}
return vertices;
}
let mut filter = OneEuroFilter::default();
let mut control_points: Vec<ControlPoint> = Vec::with_capacity(samples.len());
for s in samples {
let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp);
let pt = ControlPoint::new(fx, fy, fp);
if let Some(last) = control_points.last() {
if (pt.position - last.position).length_squared() < 1.0 {
continue;
}
}
control_points.push(pt);
}
let config = SplineConfig {
min_radius: 2.5,
max_radius: 10.0,
samples_per_segment: 16,
};
let raw_strip = spline::generate_triangle_strip(&control_points, &config);
raw_strip
.into_iter()
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect()
}
/// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`).
pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
let dir = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "Übergeordnetes Verzeichnis nicht gefunden")
})?;
fs::create_dir_all(dir)?;
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let random_suffix: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let temp_filename = format!("{filename}.tmp.{}.{random_suffix}", std::process::id());
let temp_path = dir.join(temp_filename);
// In temporäre Datei schreiben und auf Datenträger flushen
{
let mut file = File::create(&temp_path)?;
file.write_all(content)?;
file.sync_all()?;
}
// Atomares Rename
if let Err(e) = fs::rename(&temp_path, path) {
let _ = fs::remove_file(&temp_path);
return Err(e);
}
Ok(())
}
/// Serialisiert einen Wert als formatiertes JSON und schreibt ihn atomar.
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StorageError> {
let bytes = serde_json::to_vec_pretty(value)?;
atomic_write_file(path, &bytes)?;
Ok(())
}
/// Speichert das Manifest atomar in `manifest.json`.
pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> {
let path = notebook_dir.join("manifest.json");
atomic_write_json(&path, manifest)
}
/// Lädt das Manifest aus `manifest.json`.
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
let path = notebook_dir.join("manifest.json");
if !path.exists() {
return Err(StorageError::InvalidManifest(format!(
"Manifestdatei nicht gefunden: {}",
path.display()
)));
}
let mut file = File::open(&path)?;
let mut json_str = String::new();
file.read_to_string(&mut json_str)?;
let manifest: NotebookManifest = serde_json::from_str(&json_str)?;
Ok(manifest)
}
/// Speichert eine Seite atomar in `page_<id>.json`.
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
let path = notebook_dir.join(format!("page_{}.json", page.id));
atomic_write_json(&path, page)
}
/// Lädt eine Seite aus `page_<id>.json`.
pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result<PageData, StorageError> {
let path = notebook_dir.join(format!("page_{page_id}.json"));
if !path.exists() {
return Err(StorageError::PageNotFound(page_id));
}
let mut file = File::open(&path)?;
let mut json_str = String::new();
file.read_to_string(&mut json_str)?;
match serde_json::from_str::<PageData>(&json_str) {
Ok(data) => Ok(data),
Err(e) => {
eprintln!(
"[Storage-Warnung] Seite {page_id} in '{}' ist beschädigt: {e}",
path.display()
);
Err(StorageError::CorruptedPage(page_id, e.to_string()))
}
}
}
/// Speichert ein gesamtes Notebook synchron auf die Festplatte (z. B. initial oder beim Flush).
pub fn save_notebook(
notebook_dir: &Path,
notebook: &Notebook,
manifest: &mut NotebookManifest,
) -> Result<(), StorageError> {
fs::create_dir_all(notebook_dir)?;
manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect();
manifest.current_page_index = notebook.current_page_index;
manifest.next_stroke_id = notebook.next_stroke_id;
manifest.next_page_id = notebook.next_page_id;
manifest.modified_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Alle bereits geladenen Seiten speichern
for page in &notebook.pages {
if page.is_loaded {
let page_data = PageData::from(page);
save_page(notebook_dir, &page_data)?;
}
}
save_manifest(notebook_dir, manifest)?;
Ok(())
}
/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading).
pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> {
let manifest = load_manifest(notebook_dir)?;
if manifest.page_ids.is_empty() {
let nb = Notebook::new();
return Ok((nb, manifest));
}
let mut pages = Vec::with_capacity(manifest.page_ids.len());
// Erste Seite sofort laden und initialisieren
let first_id = manifest.page_ids[0];
let first_page = match load_page(notebook_dir, first_id) {
Ok(data) => data.to_page(),
Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
Page::new(first_id, PageBackground::Grid5mm)
}
};
pages.push(first_page);
// Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading)
for &page_id in &manifest.page_ids[1..] {
pages.push(Page::new_unloaded(page_id, PageBackground::Grid5mm));
}
let notebook = Notebook::from_pages(
pages,
manifest.current_page_index,
manifest.next_stroke_id,
manifest.next_page_id,
);
Ok((notebook, manifest))
}
/// Stellt sicher, dass die Seite am Index `page_idx` geladen ist (Lazy Loading beim Seitenwechsel).
pub fn ensure_page_loaded(
notebook_dir: &Path,
notebook: &mut Notebook,
page_idx: usize,
) -> Result<(), StorageError> {
if page_idx >= notebook.pages.len() {
return Err(StorageError::PageNotFound(page_idx as u64));
}
if notebook.pages[page_idx].is_loaded {
return Ok(());
}
let page_id = notebook.pages[page_idx].id;
match load_page(notebook_dir, page_id) {
Ok(data) => {
notebook.pages[page_idx] = data.to_page();
Ok(())
}
Err(e) => {
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
// Ersatzseite als geladen markieren, damit die App nicht hängt
notebook.pages[page_idx].is_loaded = true;
notebook.pages[page_idx].rebuild_cache_if_dirty();
Err(e)
}
}
}
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
pub fn default_storage_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("ink-notes").join("notebooks");
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return PathBuf::from(home)
.join(".local")
.join("share")
.join("ink-notes")
.join("notebooks");
}
}
PathBuf::from("./data/notebooks")
}
/// Zusammenfassende Metadaten eines Notizbuchs für die Dokumentenliste.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NotebookSummary {
pub id: String,
pub title: String,
pub page_count: usize,
pub modified_at: u64,
}
/// Durchsucht das Basis-Verzeichnis nach allen vorhandenen Notizbüchern.
pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
let mut list = Vec::new();
let Ok(entries) = fs::read_dir(base_dir) else {
return list;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join("manifest.json");
if manifest_path.exists() {
if let Ok(manifest) = load_manifest(&path) {
list.push(NotebookSummary {
id: manifest.id,
title: manifest.title,
page_count: manifest.page_ids.len(),
modified_at: manifest.modified_at,
});
}
}
}
}
// Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst)
list.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
list
}
/// Erstellt ein neues Notizbuch mit dem angegebenen Titel und initialisiert Seite 1.
pub fn create_new_notebook(
base_dir: &Path,
title: &str,
) -> Result<(Notebook, NotebookManifest, PathBuf), StorageError> {
fs::create_dir_all(base_dir)?;
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let clean_slug: String = title
.chars()
.filter_map(|c| {
if c.is_alphanumeric() {
Some(c.to_ascii_lowercase())
} else if c == ' ' || c == '-' || c == '_' {
Some('_')
} else {
None
}
})
.collect();
let clean_slug = clean_slug.trim_matches('_');
let id = if clean_slug.is_empty() {
format!("nb_{now_ts}")
} else {
format!("{clean_slug}_{now_ts}")
};
let notebook_dir = base_dir.join(&id);
fs::create_dir_all(&notebook_dir)?;
let notebook = Notebook::new();
let mut manifest = NotebookManifest::new(&id, title);
save_notebook(&notebook_dir, &notebook, &mut manifest)?;
Ok((notebook, manifest, notebook_dir))
}
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
pub fn rename_notebook(
notebook_dir: &Path,
manifest: &mut NotebookManifest,
new_title: &str,
) -> Result<(), StorageError> {
manifest.title = new_title.to_string();
manifest.modified_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
save_manifest(notebook_dir, manifest)?;
Ok(())
}
/// Löscht ein Notizbuch vollständig vom Datenträger.
pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> {
if notebook_dir.exists() {
fs::remove_dir_all(notebook_dir)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::autosave::AutosaveController;
use std::time::Duration;
#[test]
fn test_atomic_save_and_reload() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let path = temp_dir.path();
let mut page = Page::new(42, PageBackground::DotGrid);
let sample1 = StylusSample {
position: glam::Vec2::new(120.0, 340.0),
pressure: 0.45,
timestamp: 1.0,
};
let sample2 = StylusSample {
position: glam::Vec2::new(135.0, 360.0),
pressure: 0.85,
timestamp: 1.02,
};
let sample3 = StylusSample {
position: glam::Vec2::new(160.0, 390.0),
pressure: 0.65,
timestamp: 1.05,
};
let stroke = Stroke::new(
101,
vec![sample1, sample2, sample3],
vec![
InkVertex::new(118.0, 340.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(122.0, 340.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(132.0, 360.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(138.0, 360.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(158.0, 390.0, [0.1, 0.2, 0.8, 1.0]),
InkVertex::new(162.0, 390.0, [0.1, 0.2, 0.8, 1.0]),
],
[0.1, 0.2, 0.8, 1.0],
);
page.add_stroke(stroke);
// Speichern
let page_data = PageData::from(&page);
let save_res = save_page(path, &page_data);
assert!(save_res.is_ok(), "Speichern der Seite muss gelingen");
// Datei existiert
let file_path = path.join("page_42.json");
assert!(file_path.exists());
// Neu laden
let load_res = load_page(path, 42);
assert!(load_res.is_ok(), "Laden der Seite muss gelingen");
let loaded_data = load_res.unwrap();
let loaded_page = loaded_data.to_page();
// Überprüfungen
assert_eq!(loaded_page.id, 42);
assert_eq!(loaded_page.background, PageBackground::DotGrid);
assert_eq!(loaded_page.strokes.len(), 1);
let loaded_stroke = &loaded_page.strokes[0];
assert_eq!(loaded_stroke.id, 101);
assert_eq!(loaded_stroke.color, [0.1, 0.2, 0.8, 1.0]);
assert_eq!(loaded_stroke.points.len(), 3);
for (orig, loaded) in page.strokes[0].points.iter().zip(loaded_stroke.points.iter()) {
assert!((orig.position.x - loaded.position.x).abs() < 1e-4);
assert!((orig.position.y - loaded.position.y).abs() < 1e-4);
assert!((orig.pressure - loaded.pressure).abs() < 1e-4);
assert!((orig.timestamp - loaded.timestamp).abs() < 1e-4);
}
// Vertizes wurden deterministisch regeneriert
assert!(!loaded_stroke.vertices.is_empty());
assert!(!loaded_page.vertex_cache.is_empty());
assert!(loaded_page.is_loaded);
}
#[test]
fn test_corrupted_file_recovery() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let path = temp_dir.path();
// Beschädigtes, unvollständiges JSON manuell schreiben
let corrupted_file = path.join("page_99.json");
let truncated_json = br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
fs::write(&corrupted_file, truncated_json).expect("Konnte Datei nicht schreiben");
// Laden muss einen abgefangenen CorruptedPage-Fehler liefern und darf nicht panicken
let load_res = load_page(path, 99);
assert!(load_res.is_err(), "Laden beschädigter Daten muss fehlschlagen");
match load_res.unwrap_err() {
StorageError::CorruptedPage(id, msg) => {
assert_eq!(id, 99);
assert!(!msg.is_empty());
}
other => panic!("Erwartete CorruptedPage-Fehler, erhielt: {other:?}"),
}
}
#[test]
fn test_debounce_timing() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let notebook_dir = temp_dir.path().to_path_buf();
let debounce_duration = Duration::from_millis(150);
let mut controller = AutosaveController::new(notebook_dir.clone(), debounce_duration);
let manifest = NotebookManifest::new("test_nb", "Test Notizbuch");
let mut page = Page::new(1, PageBackground::Grid5mm);
// 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms, deutlich kürzer als 150 ms Debounce)
for i in 0..10 {
let sample = StylusSample {
position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32),
pressure: 0.5,
timestamp: i as f64 * 0.01,
};
let stroke = Stroke::new(
i + 1,
vec![sample],
vec![InkVertex::new(10.0, 20.0, [0.0, 0.0, 0.0, 1.0])],
[0.0, 0.0, 0.0, 1.0],
);
page.add_stroke(stroke);
controller.mark_page_dirty(&page, &manifest);
controller.update();
std::thread::sleep(Duration::from_millis(5));
}
// Während der schnellen Änderungen (ca. 50 ms vergangen) darf noch KEIN Schreibvorgang erfolgt sein!
assert_eq!(
controller.total_page_writes(),
0,
"Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben"
);
// Jetzt warten wir, bis das Debounce-Fenster abgelaufen ist (> 150 ms)
std::thread::sleep(Duration::from_millis(180));
controller.update();
// Flush abwarten
controller.flush();
// Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben
assert_eq!(
controller.total_page_writes(),
1,
"10 schnelle Änderungen müssen in genau 1 gebündelten Schreibvorgang münden"
);
// Verifiziere, dass die Datei existiert und alle 10 Striche enthält
let loaded = load_page(&notebook_dir, 1).expect("Seite muss lesbar sein");
assert_eq!(loaded.strokes.len(), 10);
controller.shutdown();
}
#[test]
fn test_notebook_management() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
let base_dir = temp_dir.path();
// 1. Erstes Notizbuch anlegen
let (nb1, mut m1, dir1) = create_new_notebook(base_dir, "Mathe Vorlesung")
.expect("Erstellen von Notizbuch 1 muss klappen");
assert_eq!(m1.title, "Mathe Vorlesung");
assert_eq!(nb1.pages.len(), 1);
assert!(dir1.exists());
// 2. Zweites Notizbuch anlegen
let (_nb2, m2, dir2) = create_new_notebook(base_dir, "Skizzen & Entwürfe")
.expect("Erstellen von Notizbuch 2 muss klappen");
assert_eq!(m2.title, "Skizzen & Entwürfe");
assert!(dir2.exists());
// 3. Auflisten
let summaries = list_notebooks(base_dir);
assert_eq!(summaries.len(), 2);
assert!(summaries.iter().any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
assert!(summaries.iter().any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
// 4. Umbenennen
rename_notebook(&dir1, &mut m1, "Höhere Mathematik")
.expect("Umbenennen muss klappen");
assert_eq!(m1.title, "Höhere Mathematik");
let updated_summaries = list_notebooks(base_dir);
assert!(updated_summaries.iter().any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
// 5. Löschen
delete_notebook(&dir2).expect("Löschen muss klappen");
assert!(!dir2.exists());
let final_summaries = list_notebooks(base_dir);
assert_eq!(final_summaries.len(), 1);
assert_eq!(final_summaries[0].id, m1.id);
}
}
+1626
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
//! Viewport- und Kameraverwaltung für stufenloses Zoomen und Pannen.
//!
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use glam::{Mat4, Vec2, Vec4};
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
pub const MAX_ZOOM: f32 = 5.0;
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportState {
/// Zoomfaktor (1.0 = 100%, 0.25 bis 5.0).
pub zoom: f32,
/// Verschiebung (Pan) in virtuellen DIN-A4-Seitenkoordinaten.
pub pan_offset: [f32; 2],
}
impl Default for ViewportState {
fn default() -> Self {
Self {
zoom: 1.0,
pan_offset: [0.0, 0.0],
}
}
}
impl ViewportState {
pub fn new() -> Self {
Self::default()
}
/// Setzt Zoom und Pan auf die Ausgangsposition (100% zentriert) zurück.
pub fn reset(&mut self) {
self.zoom = 1.0;
self.pan_offset = [0.0, 0.0];
}
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta(&mut self, delta_x: f32, delta_y: f32, page_viewport: &PageViewport) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
self.pan_offset[1] += delta_y / (base_scale * self.zoom);
}
}
/// Zoomt zentriert auf eine Cursor-Position in Bildschirmkoordinaten.
///
/// Formel: offset_neu = cursor - (cursor - offset_alt) * (zoom_neu / zoom_alt)
pub fn zoom_at_cursor(
&mut self,
cursor_screen: [f32; 2],
zoom_factor: f32,
page_viewport: &PageViewport,
) {
let old_zoom = self.zoom;
let new_zoom = (self.zoom * zoom_factor).clamp(MIN_ZOOM, MAX_ZOOM);
if (new_zoom - old_zoom).abs() < 1e-6 {
return;
}
let base_scale = page_viewport.page_width / PAGE_WIDTH;
// Bildschirmposition des Seitenursprungs (0, 0)
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom;
let old_offset_y = page_viewport.page_y + self.pan_offset[1] * base_scale * old_zoom;
let ratio = new_zoom / old_zoom;
let new_offset_x = cursor_screen[0] - (cursor_screen[0] - old_offset_x) * ratio;
let new_offset_y = cursor_screen[1] - (cursor_screen[1] - old_offset_y) * ratio;
self.zoom = new_zoom;
self.pan_offset[0] = (new_offset_x - page_viewport.page_x) / (base_scale * new_zoom);
self.pan_offset[1] = (new_offset_y - page_viewport.page_y) / (base_scale * new_zoom);
}
/// Transformiert einen Fenster-Pixelpunkt invers in DIN-A4-Seitenkoordinaten (ungeklemmt).
pub fn screen_to_page_unclamped(
&self,
screen_x: f32,
screen_y: f32,
page_viewport: &PageViewport,
) -> Vec2 {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let effective_scale = base_scale * self.zoom;
if effective_scale < 1e-6 {
return Vec2::ZERO;
}
let page_x = (screen_x - page_viewport.page_x) / effective_scale - self.pan_offset[0];
let page_y = (screen_y - page_viewport.page_y) / effective_scale - self.pan_offset[1];
Vec2::new(page_x, page_y)
}
/// Transformiert einen Fenster-Pixelpunkt invers in DIN-A4-Seitenkoordinaten.
///
/// Liefert `None`, falls der Punkt außerhalb der virtuellen Seite liegt.
pub fn screen_to_page(
&self,
screen_x: f32,
screen_y: f32,
page_viewport: &PageViewport,
) -> Option<Vec2> {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) {
Some(pt)
} else {
None
}
}
/// Transformiert einen Fenster-Pixelpunkt invers in DIN-A4-Seitenkoordinaten und klemmt auf die Seite.
pub fn screen_to_page_clamped(
&self,
screen_x: f32,
screen_y: f32,
page_viewport: &PageViewport,
) -> Vec2 {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
Vec2::new(
pt.x.clamp(0.0, PAGE_WIDTH),
pt.y.clamp(0.0, PAGE_HEIGHT),
)
}
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
pub fn page_to_screen_vec2(&self, page_pt: Vec2, page_viewport: &PageViewport) -> [f32; 2] {
self.page_to_screen(page_pt.x, page_pt.y, page_viewport)
}
/// Transformiert einen DIN-A4-Seitenkoordinatenpunkt in Fenster-Bildschirmkoordinaten.
pub fn page_to_screen(
&self,
page_x: f32,
page_y: f32,
page_viewport: &PageViewport,
) -> [f32; 2] {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let effective_scale = base_scale * self.zoom;
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale;
let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale;
[screen_x, screen_y]
}
/// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader,
/// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let effective_scale = base_scale * self.zoom;
// ndc_x = (screen_x / window_width) * 2.0 - 1.0
// screen_x = page_viewport.page_x + (page_x + pan_offset[0]) * effective_scale
// = page_x * effective_scale + (page_viewport.page_x + pan_offset[0] * effective_scale)
let scale_x = (2.0 * effective_scale) / page_viewport.window_width;
let offset_x = (2.0 * (page_viewport.page_x + self.pan_offset[0] * effective_scale))
/ page_viewport.window_width
- 1.0;
// ndc_y = 1.0 - (screen_y / window_height) * 2.0
// screen_y = page_viewport.page_y + (page_y + pan_offset[1]) * effective_scale
// = page_y * effective_scale + (page_viewport.page_y + pan_offset[1] * effective_scale)
let scale_y = (-2.0 * effective_scale) / page_viewport.window_height;
let offset_y = 1.0
- (2.0 * (page_viewport.page_y + self.pan_offset[1] * effective_scale))
/ page_viewport.window_height;
([scale_x, scale_y], [offset_x, offset_y])
}
/// Berechnet die 2D-Projektionsmatrix (Ortho-Projektion kombiniert mit Pan und Zoom).
pub fn projection_matrix(&self, page_viewport: &PageViewport) -> Mat4 {
let (scale, offset) = self.ndc_transform(page_viewport);
Mat4::from_cols(
Vec4::new(scale[0], 0.0, 0.0, 0.0),
Vec4::new(0.0, scale[1], 0.0, 0.0),
Vec4::new(0.0, 0.0, 1.0, 0.0),
Vec4::new(offset[0], offset[1], 0.0, 1.0),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_viewport_zoom_limits() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
vp.zoom_at_cursor([960.0, 540.0], 10.0, &page_vp);
assert!((vp.zoom - MAX_ZOOM).abs() < 1e-4);
vp.zoom_at_cursor([960.0, 540.0], 0.01, &page_vp);
assert!((vp.zoom - MIN_ZOOM).abs() < 1e-4);
}
#[test]
fn test_viewport_zoom_centered_at_cursor() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
let cursor = [960.0, 540.0]; // Mitten im Fenster und auf der Seite
// Punkt vor Zoom berechnen
let page_pt_before = vp.screen_to_page_unclamped(cursor[0], cursor[1], &page_vp);
// Zoom verdoppeln
vp.zoom_at_cursor(cursor, 2.0, &page_vp);
// Nach Zoom muss der Punkt unter dem Cursor unverändert sein
let screen_pt_after = vp.page_to_screen(page_pt_before.x, page_pt_before.y, &page_vp);
assert!((screen_pt_after[0] - cursor[0]).abs() < 0.1);
assert!((screen_pt_after[1] - cursor[1]).abs() < 0.1);
}
#[test]
fn test_viewport_pan() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
let pt0 = vp.screen_to_page_unclamped(960.0, 540.0, &page_vp);
vp.pan_by_screen_delta(50.0, 30.0, &page_vp);
let pt1 = vp.page_to_screen(pt0.x, pt0.y, &page_vp);
assert!((pt1[0] - 1010.0).abs() < 0.1);
assert!((pt1[1] - 570.0).abs() < 0.1);
}
#[test]
fn test_ndc_transform_consistency_at_default() {
let vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
let (scale1, offset1) = vp.ndc_transform(&page_vp);
let (scale2, offset2) = page_vp.ndc_transform();
assert!((scale1[0] - scale2[0]).abs() < 1e-5);
assert!((scale1[1] - scale2[1]).abs() < 1e-5);
assert!((offset1[0] - offset2[0]).abs() < 1e-5);
assert!((offset1[1] - offset2[1]).abs() < 1e-5);
}
}