Initial commit: ink-notes (latency-optimized Linux inking app in Rust)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
*.pdf
|
||||
export.pdf
|
||||
Generated
+2940
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||
winit = { version = "0.29", features = ["rwh_06"] }
|
||||
|
||||
# Low-Level GPU-Rendering (Vulkan/Direct3D12 via WSLg)
|
||||
wgpu = "0.19"
|
||||
|
||||
# Daten-Casting für GPU-Buffer
|
||||
bytemuck = { version = "1.16", features = ["derive"] }
|
||||
|
||||
# Vektor-Mathematik
|
||||
glam = { version = "0.27", features = ["serde"] }
|
||||
|
||||
# Asynchroner Starter für wgpu-Device-Setup
|
||||
pollster = "0.3"
|
||||
|
||||
# PDF-Vektorexport
|
||||
printpdf = "0.7"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
# Immediate-Mode UI (Toolbar & Menüs)
|
||||
egui = "0.27"
|
||||
egui-wgpu = "0.27"
|
||||
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starter-Skript für ink-notes (konfiguriert X11/Xwayland & xkb-Bibliotheken)
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH}"
|
||||
export WAYLAND_DISPLAY=""
|
||||
|
||||
exec "$SCRIPT_DIR/target/debug/ink-notes" "$@"
|
||||
+275
@@ -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(¬ebook_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(¬ebook_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, ¬ebook.pages[0], strategy);
|
||||
|
||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||
for page in ¬ebook.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(¬ebook, &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(¬ebook, 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(¬ebook, 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
@@ -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
File diff suppressed because it is too large
Load Diff
+241
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 ¬ebook.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(¬ebook_dir)?;
|
||||
|
||||
let notebook = Notebook::new();
|
||||
let mut manifest = NotebookManifest::new(&id, title);
|
||||
|
||||
save_notebook(¬ebook_dir, ¬ebook, &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(¬ebook_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);
|
||||
}
|
||||
}
|
||||
+247
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user