Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904b36e5f7 | ||
|
|
80d319099f | ||
|
|
ee5257d744 | ||
|
|
fa1ab5ab8f | ||
|
|
47300f2ca2 | ||
|
|
27f84ff1b1 | ||
|
|
ffab2cea7a |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.11.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.11.0"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
|
||||||
|
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
|
||||||
|
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
|
||||||
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
||||||
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
||||||
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
|
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
|
||||||
|
|||||||
+73
-125
@@ -165,115 +165,70 @@ pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex>
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
|
||||||
#[inline]
|
pub const LINED_MARGIN: f32 = 150.0;
|
||||||
fn push_quad(
|
|
||||||
vertices: &mut Vec<InkVertex>,
|
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
|
||||||
color: [f32; 4],
|
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
|
||||||
x_min: f32,
|
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
|
||||||
y_min: f32,
|
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
|
||||||
x_max: f32,
|
#[repr(C)]
|
||||||
y_max: f32,
|
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
) {
|
pub struct PatternVertex {
|
||||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
/// Seitenkoordinate
|
||||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
pub position: [f32; 2],
|
||||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
pub color: [f32; 4],
|
||||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
|
||||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
pub params: [f32; 4],
|
||||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
/// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
|
||||||
|
pub bounds: [f32; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
|
impl PatternVertex {
|
||||||
/// einem Blatt der Größe `size`.
|
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
|
||||||
pub fn generate_background_mesh(
|
0 => Float32x2,
|
||||||
|
1 => Float32x4,
|
||||||
|
2 => Float32x4,
|
||||||
|
3 => Float32x4
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||||
|
wgpu::VertexBufferLayout {
|
||||||
|
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &Self::ATTRIBUTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
|
||||||
|
/// `background` in `color` zeichnet; leer bei blankem Papier.
|
||||||
|
pub fn generate_pattern_quad(
|
||||||
background: PageBackground,
|
background: PageBackground,
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
size: glam::Vec2,
|
size: glam::Vec2,
|
||||||
) -> Vec<InkVertex> {
|
) -> Vec<PatternVertex> {
|
||||||
let (page_width, page_height) = (size.x, size.y);
|
let params = match background {
|
||||||
match background {
|
PageBackground::Blank => return Vec::new(),
|
||||||
PageBackground::Blank => Vec::new(),
|
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||||
PageBackground::Grid5mm => {
|
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||||
let mut vertices = Vec::with_capacity(1200);
|
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||||
let half_t = LINE_THICKNESS / 2.0;
|
};
|
||||||
|
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
|
||||||
// Vertikale Linien alle 5 mm
|
let corner = |x: f32, y: f32| PatternVertex {
|
||||||
let mut x = GRID_5MM_SPACING;
|
position: [x, y],
|
||||||
while x < page_width {
|
|
||||||
push_quad(
|
|
||||||
&mut vertices,
|
|
||||||
color,
|
color,
|
||||||
x - half_t,
|
params,
|
||||||
0.0,
|
bounds,
|
||||||
x + half_t,
|
};
|
||||||
page_height,
|
vec![
|
||||||
);
|
corner(0.0, 0.0),
|
||||||
x += GRID_5MM_SPACING;
|
corner(size.x, 0.0),
|
||||||
}
|
corner(0.0, size.y),
|
||||||
|
corner(0.0, size.y),
|
||||||
// Horizontale Linien alle 5 mm
|
corner(size.x, 0.0),
|
||||||
let mut y = GRID_5MM_SPACING;
|
corner(size.x, size.y),
|
||||||
while y < page_height {
|
]
|
||||||
push_quad(
|
|
||||||
&mut vertices,
|
|
||||||
color,
|
|
||||||
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,
|
|
||||||
color,
|
|
||||||
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,
|
|
||||||
color,
|
|
||||||
x - half_d,
|
|
||||||
y - half_d,
|
|
||||||
x + half_d,
|
|
||||||
y + half_d,
|
|
||||||
);
|
|
||||||
x += GRID_5MM_SPACING;
|
|
||||||
}
|
|
||||||
y += GRID_5MM_SPACING;
|
|
||||||
}
|
|
||||||
|
|
||||||
vertices
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -323,32 +278,25 @@ mod tests {
|
|||||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_meshes() {
|
fn test_pattern_quads() {
|
||||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
|
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
|
||||||
assert!(blank.is_empty());
|
for (background, kind) in [
|
||||||
|
(PageBackground::Grid5mm, 1.0),
|
||||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
|
(PageBackground::Lined, 2.0),
|
||||||
assert!(!grid.is_empty());
|
(PageBackground::DotGrid, 3.0),
|
||||||
assert_eq!(
|
] {
|
||||||
grid.len() % 6,
|
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
|
||||||
0,
|
assert_eq!(quad.len(), 6);
|
||||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
assert!(quad
|
||||||
);
|
.iter()
|
||||||
|
.all(|v| v.params[0] == kind && v.color == PATTERN));
|
||||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
|
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
assert!(!lined.is_empty());
|
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
assert_eq!(lined.len() % 6, 0);
|
}
|
||||||
|
|
||||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
|
|
||||||
assert!(!dots.is_empty());
|
|
||||||
assert_eq!(dots.len() % 6, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_mesh_uses_theme_color() {
|
fn test_background_mesh_uses_theme_color() {
|
||||||
let dark = PaperTheme::Black.pattern_color();
|
|
||||||
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
|
|
||||||
assert!(mesh.iter().all(|v| v.color == dark));
|
|
||||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
||||||
assert!(quad
|
assert!(quad
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+171
-36
@@ -12,7 +12,9 @@ use std::sync::Arc;
|
|||||||
use winit::dpi::PhysicalSize;
|
use winit::dpi::PhysicalSize;
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
|
|
||||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor};
|
use crate::background::{
|
||||||
|
generate_paper_quad, generate_pattern_quad, PaperTheme, PatternColor, PatternVertex,
|
||||||
|
};
|
||||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
@@ -150,8 +152,9 @@ struct NeighborPage {
|
|||||||
offset: glam::Vec2,
|
offset: glam::Vec2,
|
||||||
camera_buffer: wgpu::Buffer,
|
camera_buffer: wgpu::Buffer,
|
||||||
camera_bind_group: wgpu::BindGroup,
|
camera_bind_group: wgpu::BindGroup,
|
||||||
background_buffer: Option<wgpu::Buffer>,
|
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
|
||||||
background_vertex_count: u32,
|
paper_buffer: wgpu::Buffer,
|
||||||
|
pattern_buffer: Option<wgpu::Buffer>,
|
||||||
vertex_buffer: Option<wgpu::Buffer>,
|
vertex_buffer: Option<wgpu::Buffer>,
|
||||||
stroke_ranges: Vec<Range<u32>>,
|
stroke_ranges: Vec<Range<u32>>,
|
||||||
}
|
}
|
||||||
@@ -160,6 +163,9 @@ pub struct CanvasState {
|
|||||||
pub surface: wgpu::Surface<'static>,
|
pub surface: wgpu::Surface<'static>,
|
||||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||||
adapter: wgpu::Adapter,
|
adapter: wgpu::Adapter,
|
||||||
|
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||||
|
msaa_samples: u32,
|
||||||
|
msaa_view: Option<wgpu::TextureView>,
|
||||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||||
presentation_warned: bool,
|
presentation_warned: bool,
|
||||||
pub device: wgpu::Device,
|
pub device: wgpu::Device,
|
||||||
@@ -184,9 +190,10 @@ pub struct CanvasState {
|
|||||||
|
|
||||||
// Statische Hintergrund-Geometrie
|
// Statische Hintergrund-Geometrie
|
||||||
pub paper_buffer: wgpu::Buffer,
|
pub paper_buffer: wgpu::Buffer,
|
||||||
|
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
|
||||||
pub background_buffer: wgpu::Buffer,
|
pub background_buffer: wgpu::Buffer,
|
||||||
pub background_vertex_capacity: usize,
|
|
||||||
pub background_vertex_count: u32,
|
pub background_vertex_count: u32,
|
||||||
|
pattern_pipeline: wgpu::RenderPipeline,
|
||||||
pub current_background: PageBackground,
|
pub current_background: PageBackground,
|
||||||
/// Papierfarbe der Anzeige
|
/// Papierfarbe der Anzeige
|
||||||
pub paper_theme: PaperTheme,
|
pub paper_theme: PaperTheme,
|
||||||
@@ -210,6 +217,9 @@ pub struct CanvasState {
|
|||||||
pub live_vertex_capacity: usize,
|
pub live_vertex_capacity: usize,
|
||||||
pub live_vertex_count: u32,
|
pub live_vertex_count: u32,
|
||||||
pub live_samples: Vec<StylusSample>,
|
pub live_samples: Vec<StylusSample>,
|
||||||
|
/// Ungefilterte Eingabepunkte des Live-Strichs (nach dem Lineal): Grundlage des fertigen
|
||||||
|
/// Strichs, damit kleine Schleifen nicht durch die Filterverzögerung schrumpfen
|
||||||
|
live_raw_samples: Vec<StylusSample>,
|
||||||
pub live_control_points: Vec<ControlPoint>,
|
pub live_control_points: Vec<ControlPoint>,
|
||||||
pub live_vertices: Vec<InkVertex>,
|
pub live_vertices: Vec<InkVertex>,
|
||||||
pub filter: OneEuroFilter,
|
pub filter: OneEuroFilter,
|
||||||
@@ -240,7 +250,6 @@ pub struct CanvasState {
|
|||||||
impl CanvasState {
|
impl CanvasState {
|
||||||
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
||||||
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
||||||
const INITIAL_BACKGROUND_CAPACITY: usize = 16384;
|
|
||||||
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
||||||
|
|
||||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||||
@@ -323,6 +332,18 @@ impl CanvasState {
|
|||||||
};
|
};
|
||||||
surface.configure(&device, &config);
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
|
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||||
|
let msaa_samples = if adapter
|
||||||
|
.get_texture_format_features(config.format)
|
||||||
|
.flags
|
||||||
|
.sample_count_supported(MSAA_SAMPLES)
|
||||||
|
{
|
||||||
|
MSAA_SAMPLES
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
||||||
|
|
||||||
// 5. Shader & Bind Group Layout
|
// 5. Shader & Bind Group Layout
|
||||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
label: Some("Ink WGSL Shader"),
|
label: Some("Ink WGSL Shader"),
|
||||||
@@ -403,7 +424,10 @@ impl CanvasState {
|
|||||||
conservative: false,
|
conservative: false,
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,7 +459,10 @@ impl CanvasState {
|
|||||||
conservative: false,
|
conservative: false,
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -451,19 +478,46 @@ impl CanvasState {
|
|||||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||||
|
|
||||||
let current_background = PageBackground::Grid5mm;
|
let current_background = PageBackground::Grid5mm;
|
||||||
let bg_mesh = generate_background_mesh(
|
let bg_mesh = generate_pattern_quad(
|
||||||
current_background,
|
current_background,
|
||||||
paper_theme.pattern_color(),
|
paper_theme.pattern_color(),
|
||||||
crate::document::A4_SIZE,
|
crate::document::A4_SIZE,
|
||||||
);
|
);
|
||||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Background Mesh Buffer"),
|
label: Some("Pattern Quad Buffer"),
|
||||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
size: (6 * std::mem::size_of::<PatternVertex>()) as u64,
|
||||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
||||||
|
|
||||||
|
// Karo, Linien und Punkte: pro Pixel im Shader berechnet
|
||||||
|
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("Pattern Pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "vs_pattern",
|
||||||
|
buffers: &[PatternVertex::desc()],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: "fs_pattern",
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: config.format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: msaa_samples,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
multiview: None,
|
||||||
|
});
|
||||||
|
|
||||||
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
||||||
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Live Stroke Vertex Buffer"),
|
label: Some("Live Stroke Vertex Buffer"),
|
||||||
@@ -484,6 +538,7 @@ impl CanvasState {
|
|||||||
&shader,
|
&shader,
|
||||||
&bind_group_layout,
|
&bind_group_layout,
|
||||||
config.format,
|
config.format,
|
||||||
|
msaa_samples,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. egui Renderer für das schwebende UI initialisieren
|
// 8. egui Renderer für das schwebende UI initialisieren
|
||||||
@@ -492,6 +547,8 @@ impl CanvasState {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
adapter,
|
adapter,
|
||||||
|
msaa_samples,
|
||||||
|
msaa_view,
|
||||||
presentation_warned: false,
|
presentation_warned: false,
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
@@ -508,8 +565,8 @@ impl CanvasState {
|
|||||||
current_stroke_width_mm: 0.5,
|
current_stroke_width_mm: 0.5,
|
||||||
paper_buffer,
|
paper_buffer,
|
||||||
background_buffer,
|
background_buffer,
|
||||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
|
||||||
background_vertex_count: bg_mesh.len() as u32,
|
background_vertex_count: bg_mesh.len() as u32,
|
||||||
|
pattern_pipeline,
|
||||||
current_background,
|
current_background,
|
||||||
paper_theme,
|
paper_theme,
|
||||||
adapt_ink: false,
|
adapt_ink: false,
|
||||||
@@ -523,6 +580,7 @@ impl CanvasState {
|
|||||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||||
live_vertex_count: 0,
|
live_vertex_count: 0,
|
||||||
live_samples: Vec::with_capacity(256),
|
live_samples: Vec::with_capacity(256),
|
||||||
|
live_raw_samples: Vec::with_capacity(256),
|
||||||
live_control_points: Vec::with_capacity(256),
|
live_control_points: Vec::with_capacity(256),
|
||||||
live_vertices: Vec::with_capacity(1024),
|
live_vertices: Vec::with_capacity(1024),
|
||||||
filter: OneEuroFilter::default(),
|
filter: OneEuroFilter::default(),
|
||||||
@@ -639,6 +697,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||||
self.recalculate_viewport();
|
self.recalculate_viewport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,23 +868,12 @@ impl CanvasState {
|
|||||||
|
|
||||||
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||||
fn upload_background_mesh(&mut self) {
|
fn upload_background_mesh(&mut self) {
|
||||||
let mesh = generate_background_mesh(
|
let mesh = generate_pattern_quad(
|
||||||
self.current_background,
|
self.current_background,
|
||||||
self.pattern.color(self.paper_theme),
|
self.pattern.color(self.paper_theme),
|
||||||
self.page_size,
|
self.page_size,
|
||||||
);
|
);
|
||||||
self.background_vertex_count = mesh.len() as u32;
|
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() {
|
if !mesh.is_empty() {
|
||||||
self.queue
|
self.queue
|
||||||
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||||
@@ -933,20 +981,39 @@ impl CanvasState {
|
|||||||
buffer
|
buffer
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
|
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
|
||||||
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||||
background.extend(generate_background_mesh(
|
let pattern = generate_pattern_quad(
|
||||||
page.background,
|
page.background,
|
||||||
self.pattern.color(self.paper_theme),
|
self.pattern.color(self.paper_theme),
|
||||||
page.size,
|
page.size,
|
||||||
));
|
);
|
||||||
|
let paper_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("Neighbor Paper Buffer"),
|
||||||
|
size: std::mem::size_of_val(paper.as_slice()) as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.queue
|
||||||
|
.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper));
|
||||||
|
let pattern_buffer = (!pattern.is_empty()).then(|| {
|
||||||
|
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("Neighbor Pattern Buffer"),
|
||||||
|
size: std::mem::size_of_val(pattern.as_slice()) as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.queue
|
||||||
|
.write_buffer(&buffer, 0, bytemuck::cast_slice(&pattern));
|
||||||
|
buffer
|
||||||
|
});
|
||||||
NeighborPage {
|
NeighborPage {
|
||||||
fingerprint,
|
fingerprint,
|
||||||
offset: glam::Vec2::ZERO,
|
offset: glam::Vec2::ZERO,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
camera_bind_group,
|
camera_bind_group,
|
||||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
paper_buffer,
|
||||||
background_vertex_count: background.len() as u32,
|
pattern_buffer,
|
||||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||||
stroke_ranges: page.stroke_ranges.clone(),
|
stroke_ranges: page.stroke_ranges.clone(),
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1118,8 @@ impl CanvasState {
|
|||||||
.ruler
|
.ruler
|
||||||
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
|
self.live_raw_samples.clear();
|
||||||
|
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
@@ -1076,6 +1145,7 @@ impl CanvasState {
|
|||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
|
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||||
|
|
||||||
@@ -1098,6 +1168,25 @@ impl CanvasState {
|
|||||||
self.update_live_mesh();
|
self.update_live_mesh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merkt sich einen ungefilterten Eingabepunkt (Punkte näher als 0,03 mm am vorigen
|
||||||
|
/// entfallen).
|
||||||
|
fn push_raw_sample(&mut self, x: f32, y: f32, pressure: f32, timestamp: f64) {
|
||||||
|
let position = glam::Vec2::new(x, y);
|
||||||
|
if !position.is_finite() || !pressure.is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(last) = self.live_raw_samples.last() {
|
||||||
|
if (position - last.position).length_squared() < 0.09 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.live_raw_samples.push(StylusSample {
|
||||||
|
position,
|
||||||
|
pressure: pressure.clamp(0.0, 1.0),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||||
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||||
match self.ruler_lock {
|
match self.ruler_lock {
|
||||||
@@ -1114,6 +1203,7 @@ impl CanvasState {
|
|||||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||||
pub fn cancel_stroke(&mut self) {
|
pub fn cancel_stroke(&mut self) {
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
|
self.live_raw_samples.clear();
|
||||||
self.live_control_points.clear();
|
self.live_control_points.clear();
|
||||||
self.live_vertices.clear();
|
self.live_vertices.clear();
|
||||||
self.live_vertex_count = 0;
|
self.live_vertex_count = 0;
|
||||||
@@ -1131,10 +1221,16 @@ impl CanvasState {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-Stroke Finalisierung (Corner Preservation, Druckglättung, RDP, runde Kappen)
|
// Post-Stroke Finalisierung (Glättung, RDP, Ecken, runde Kappen) aus den ungefilterten
|
||||||
|
// Punkten: Der 1€-Filter dient nur der Anzeige während des Schreibens
|
||||||
|
let source = if self.live_raw_samples.is_empty() {
|
||||||
|
&self.live_samples
|
||||||
|
} else {
|
||||||
|
&self.live_raw_samples
|
||||||
|
};
|
||||||
let stroke = spline::finalize_stroke_with_config(
|
let stroke = spline::finalize_stroke_with_config(
|
||||||
stroke_id,
|
stroke_id,
|
||||||
&self.live_samples,
|
source,
|
||||||
self.current_ink_color,
|
self.current_ink_color,
|
||||||
&self.spline_config,
|
&self.spline_config,
|
||||||
);
|
);
|
||||||
@@ -1144,6 +1240,7 @@ impl CanvasState {
|
|||||||
|
|
||||||
// Temporären GPU-Live-Buffer vollständig leeren
|
// Temporären GPU-Live-Buffer vollständig leeren
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
|
self.live_raw_samples.clear();
|
||||||
self.live_control_points.clear();
|
self.live_control_points.clear();
|
||||||
self.live_vertices.clear();
|
self.live_vertices.clear();
|
||||||
self.live_vertex_count = 0;
|
self.live_vertex_count = 0;
|
||||||
@@ -1230,12 +1327,17 @@ impl CanvasState {
|
|||||||
{
|
{
|
||||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
label: Some("Ink Canvas Render Pass"),
|
label: Some("Ink Canvas Render Pass"),
|
||||||
|
// Mit Multisampling ins Zwischenbild zeichnen und in die Fensterfläche auflösen
|
||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: &view,
|
view: self.msaa_view.as_ref().unwrap_or(&view),
|
||||||
resolve_target: None,
|
resolve_target: self.msaa_view.as_ref().map(|_| &view),
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||||
store: wgpu::StoreOp::Store,
|
store: if self.msaa_view.is_some() {
|
||||||
|
wgpu::StoreOp::Discard
|
||||||
|
} else {
|
||||||
|
wgpu::StoreOp::Store
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})],
|
})],
|
||||||
depth_stencil_attachment: None,
|
depth_stencil_attachment: None,
|
||||||
@@ -1247,9 +1349,12 @@ impl CanvasState {
|
|||||||
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||||
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||||
if let Some(buffer) = &neighbor.background_buffer {
|
render_pass.set_vertex_buffer(0, neighbor.paper_buffer.slice(..));
|
||||||
|
render_pass.draw(0..6, 0..1);
|
||||||
|
if let Some(buffer) = &neighbor.pattern_buffer {
|
||||||
|
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
render_pass.draw(0..6, 0..1);
|
||||||
}
|
}
|
||||||
self.image_layer.draw(&mut render_pass, i + 1);
|
self.image_layer.draw(&mut render_pass, i + 1);
|
||||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||||
@@ -1273,6 +1378,7 @@ impl CanvasState {
|
|||||||
|
|
||||||
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
||||||
if self.background_vertex_count > 0 {
|
if self.background_vertex_count > 0 {
|
||||||
|
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||||
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
||||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||||
}
|
}
|
||||||
@@ -1342,6 +1448,35 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
||||||
|
const MSAA_SAMPLES: u32 = 4;
|
||||||
|
|
||||||
|
/// Zwischenbild für das Multisampling in Fenstergröße; `None` ohne Multisampling.
|
||||||
|
fn create_msaa_view(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
config: &wgpu::SurfaceConfiguration,
|
||||||
|
samples: u32,
|
||||||
|
) -> Option<wgpu::TextureView> {
|
||||||
|
(samples > 1).then(|| {
|
||||||
|
device
|
||||||
|
.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("MSAA Color Target"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: config.width.max(1),
|
||||||
|
height: config.height.max(1),
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: samples,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: config.format,
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
view_formats: &[],
|
||||||
|
})
|
||||||
|
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Zu rasterndes Textfeld.
|
/// Zu rasterndes Textfeld.
|
||||||
struct TextSpec {
|
struct TextSpec {
|
||||||
text: crate::text::TextBox,
|
text: crate::text::TextBox,
|
||||||
|
|||||||
+91
-15
@@ -10,10 +10,13 @@ use std::f32::consts::PI;
|
|||||||
|
|
||||||
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
|
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
|
||||||
pub const DEFAULT_FC_MIN: f32 = 4.5;
|
pub const DEFAULT_FC_MIN: f32 = 4.5;
|
||||||
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen)
|
/// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
|
||||||
pub const DEFAULT_BETA: f32 = 0.05;
|
/// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
|
||||||
/// Standard-Grenzfrequenz für die Ableitung in Hz
|
/// werden kaum noch verkleinert
|
||||||
pub const DEFAULT_D_CUTOFF: f32 = 1.0;
|
pub const DEFAULT_BETA: f32 = 0.15;
|
||||||
|
/// Standard-Grenzfrequenz für die Geschwindigkeitsschätzung in Hz (reagiert nach wenigen
|
||||||
|
/// Millisekunden auf schnellere Bewegung)
|
||||||
|
pub const DEFAULT_D_CUTOFF: f32 = 8.0;
|
||||||
|
|
||||||
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
|
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
|
||||||
///
|
///
|
||||||
@@ -157,13 +160,18 @@ pub struct FilteredPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
|
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
|
||||||
|
///
|
||||||
|
/// Die Position wird mit der Geschwindigkeit in der Ebene gefiltert (nicht je Achse):
|
||||||
|
/// Auf einer Kreisbahn ist eine Achse an den Umkehrpunkten in Ruhe, eine Filterung je
|
||||||
|
/// Achse würde dort stark glätten und Schleifen zusammenziehen.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OneEuroFilter {
|
pub struct OneEuroFilter {
|
||||||
pub fc_min: f32,
|
pub fc_min: f32,
|
||||||
pub beta: f32,
|
pub beta: f32,
|
||||||
pub d_cutoff: f32,
|
pub d_cutoff: f32,
|
||||||
x_filter: OneEuroFilter1D,
|
pos_prev: Option<glam::Vec2>,
|
||||||
y_filter: OneEuroFilter1D,
|
speed_prev: f32,
|
||||||
|
t_prev: Option<f64>,
|
||||||
p_filter: OneEuroFilter1D,
|
p_filter: OneEuroFilter1D,
|
||||||
last_output: Option<(f32, f32, f32)>,
|
last_output: Option<(f32, f32, f32)>,
|
||||||
}
|
}
|
||||||
@@ -175,8 +183,9 @@ impl OneEuroFilter {
|
|||||||
fc_min,
|
fc_min,
|
||||||
beta,
|
beta,
|
||||||
d_cutoff,
|
d_cutoff,
|
||||||
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
pos_prev: None,
|
||||||
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
speed_prev: 0.0,
|
||||||
|
t_prev: None,
|
||||||
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||||
last_output: None,
|
last_output: None,
|
||||||
}
|
}
|
||||||
@@ -184,8 +193,9 @@ impl OneEuroFilter {
|
|||||||
|
|
||||||
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
|
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.x_filter.reset();
|
self.pos_prev = None;
|
||||||
self.y_filter.reset();
|
self.speed_prev = 0.0;
|
||||||
|
self.t_prev = None;
|
||||||
self.p_filter.reset();
|
self.p_filter.reset();
|
||||||
self.last_output = None;
|
self.last_output = None;
|
||||||
}
|
}
|
||||||
@@ -202,8 +212,8 @@ impl OneEuroFilter {
|
|||||||
// Druck immer auf [0.0, 1.0] klemmen
|
// Druck immer auf [0.0, 1.0] klemmen
|
||||||
let p_in = pressure.clamp(0.0, 1.0);
|
let p_in = pressure.clamp(0.0, 1.0);
|
||||||
|
|
||||||
let x_out = self.x_filter.filter(x, t);
|
let pos = self.filter_position(glam::Vec2::new(x, y), t);
|
||||||
let y_out = self.y_filter.filter(y, t);
|
let (x_out, y_out) = (pos.x, pos.y);
|
||||||
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
|
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
|
||||||
|
|
||||||
let out = (x_out, y_out, p_out);
|
let out = (x_out, y_out, p_out);
|
||||||
@@ -211,6 +221,51 @@ impl OneEuroFilter {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 1€-Filter der Position mit der Geschwindigkeit in der Ebene.
|
||||||
|
fn filter_position(&mut self, x: glam::Vec2, t: f64) -> glam::Vec2 {
|
||||||
|
let (Some(prev), Some(prev_t)) = (self.pos_prev, self.t_prev) else {
|
||||||
|
self.pos_prev = Some(x);
|
||||||
|
self.speed_prev = 0.0;
|
||||||
|
self.t_prev = Some(t);
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
let raw_dt = t - prev_t;
|
||||||
|
// Lange Pause: neu beginnen statt über die Pause hinweg zu glätten
|
||||||
|
if raw_dt > 1.0 {
|
||||||
|
self.pos_prev = Some(x);
|
||||||
|
self.speed_prev = 0.0;
|
||||||
|
self.t_prev = Some(t);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
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 mut speed = (x - prev).length() / dt;
|
||||||
|
if !speed.is_finite() {
|
||||||
|
speed = 0.0;
|
||||||
|
}
|
||||||
|
let a_d = smoothing_factor(dt, self.d_cutoff);
|
||||||
|
let speed_hat = a_d * speed + (1.0 - a_d) * self.speed_prev;
|
||||||
|
|
||||||
|
let fc = (self.fc_min + self.beta * speed_hat).max(self.fc_min);
|
||||||
|
let a = smoothing_factor(dt, fc);
|
||||||
|
let mut out = prev + (x - prev) * a;
|
||||||
|
if !out.is_finite() {
|
||||||
|
out = prev;
|
||||||
|
}
|
||||||
|
self.pos_prev = Some(out);
|
||||||
|
self.speed_prev = if speed_hat.is_finite() {
|
||||||
|
speed_hat
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
|
/// 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) {
|
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);
|
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
|
||||||
@@ -279,6 +334,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_loops_do_not_shrink() {
|
||||||
|
// Kreis mit 0,8 mm Radius in 0,1 s: die Filterung in der Ebene folgt ihm
|
||||||
|
let mut filter = OneEuroFilter::default();
|
||||||
|
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||||
|
let mut radii = Vec::new();
|
||||||
|
for i in 0..=(period * hz) as usize * 2 {
|
||||||
|
let t = i as f64 / hz;
|
||||||
|
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||||
|
let p = glam::Vec2::new(500.0 + r * a.sin(), 500.0 - r * a.cos());
|
||||||
|
let (out, _) = filter.filter_point(p, 0.5, t);
|
||||||
|
if t > period {
|
||||||
|
radii.push((out - glam::Vec2::new(500.0, 500.0)).length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
|
||||||
|
assert!(mean > r * 0.9, "Schleife geschrumpft: {mean}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_high_frequency_jitter() {
|
fn test_high_frequency_jitter() {
|
||||||
let mut filter = OneEuroFilter::default();
|
let mut filter = OneEuroFilter::default();
|
||||||
@@ -286,7 +360,8 @@ mod tests {
|
|||||||
let base_x = 100.0_f32;
|
let base_x = 100.0_f32;
|
||||||
let base_y = 50.0_f32;
|
let base_y = 50.0_f32;
|
||||||
let base_p = 0.5_f32;
|
let base_p = 0.5_f32;
|
||||||
let jitter_amplitude = 2.0_f32;
|
// Realistisches Digitizer-Rauschen (±0,05 mm) bei ruhendem Stift
|
||||||
|
let jitter_amplitude = 0.5_f32;
|
||||||
|
|
||||||
let num_samples = 120;
|
let num_samples = 120;
|
||||||
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
|
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
|
||||||
@@ -331,9 +406,10 @@ mod tests {
|
|||||||
.sum::<f32>()
|
.sum::<f32>()
|
||||||
/ count;
|
/ count;
|
||||||
|
|
||||||
// Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen
|
// Der 1€-Filter muss das hochfrequente Jitter deutlich dämpfen (der fertige Strich
|
||||||
|
// wird zusätzlich symmetrisch geglättet)
|
||||||
assert!(
|
assert!(
|
||||||
filtered_variance < raw_variance * 0.05,
|
filtered_variance < raw_variance * 0.2,
|
||||||
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
|
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -102,6 +102,7 @@ impl ImageLayer {
|
|||||||
shader: &wgpu::ShaderModule,
|
shader: &wgpu::ShaderModule,
|
||||||
camera_layout: &wgpu::BindGroupLayout,
|
camera_layout: &wgpu::BindGroupLayout,
|
||||||
format: wgpu::TextureFormat,
|
format: wgpu::TextureFormat,
|
||||||
|
sample_count: u32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
label: Some("Image Texture Bind Group Layout"),
|
label: Some("Image Texture Bind Group Layout"),
|
||||||
@@ -151,7 +152,10 @@ impl ImageLayer {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: sample_count,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
multiview: None,
|
multiview: None,
|
||||||
});
|
});
|
||||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
|||||||
@@ -77,3 +77,84 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
|||||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||||
return textureSample(image_texture, image_sampler, in.uv);
|
return textureSample(image_texture, image_sampler, in.uv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
|
||||||
|
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
|
||||||
|
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
|
||||||
|
struct PatternInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
|
||||||
|
@location(2) params: vec4<f32>,
|
||||||
|
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
|
||||||
|
@location(3) bounds: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PatternOutput {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) page: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
@location(2) params: vec4<f32>,
|
||||||
|
@location(3) bounds: vec4<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_pattern(in: PatternInput) -> PatternOutput {
|
||||||
|
var out: PatternOutput;
|
||||||
|
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||||
|
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||||
|
out.page = in.position;
|
||||||
|
out.color = in.color;
|
||||||
|
out.params = in.params;
|
||||||
|
out.bounds = in.bounds;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
|
||||||
|
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
|
||||||
|
let half_px = max(half_width / upp, 0.5);
|
||||||
|
let d = abs(c - line) / upp;
|
||||||
|
return clamp(half_px + 0.5 - d, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
|
||||||
|
let p = in.page;
|
||||||
|
let kind = in.params.x;
|
||||||
|
let spacing = in.params.y;
|
||||||
|
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
|
||||||
|
let size = in.bounds.xy;
|
||||||
|
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
|
||||||
|
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
|
||||||
|
|
||||||
|
var coverage = 0.0;
|
||||||
|
if (kind < 1.5) {
|
||||||
|
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
|
||||||
|
let k = round(p / spacing);
|
||||||
|
let line = k * spacing;
|
||||||
|
if (k.x >= 1.0 && line.x < size.x - 0.01) {
|
||||||
|
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
|
||||||
|
}
|
||||||
|
if (k.y >= 1.0 && line.y < size.y - 0.01) {
|
||||||
|
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
|
||||||
|
}
|
||||||
|
} else if (kind < 2.5) {
|
||||||
|
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
|
||||||
|
let margin = in.bounds.z;
|
||||||
|
let k = round((p.y - margin) / spacing);
|
||||||
|
let line = margin + k * spacing;
|
||||||
|
if (k >= 0.0 && line <= size.y - margin + 0.01) {
|
||||||
|
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Punkte: an den Kreuzungen des Karos
|
||||||
|
let k = round(p / spacing);
|
||||||
|
let point = k * spacing;
|
||||||
|
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
|
||||||
|
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
|
||||||
|
let d = length(p - point) / upp;
|
||||||
|
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vec4<f32>(in.color.rgb, in.color.a * coverage * fade);
|
||||||
|
}
|
||||||
|
|||||||
+66
-7
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
|
|||||||
|
|
||||||
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
|
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
|
||||||
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
|
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
|
||||||
|
/// Ecken nur zwischen Abschnitten, die nach der Ausdünnung mindestens so lang sind (0,6 mm).
|
||||||
|
/// Kleine Schleifen („o“, „a“, „e“) bestehen aus kurzen Abschnitten mit großem Knickwinkel;
|
||||||
|
/// würden sie als Ecken getrennt, würden sie zu spitzen Zacken statt Rundungen.
|
||||||
|
pub const CORNER_MIN_SEGMENT_UNITS: f32 = 6.0;
|
||||||
|
|
||||||
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
|
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -270,7 +274,7 @@ pub fn generate_triangle_strip(
|
|||||||
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
|
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
|
||||||
let pt = control_points[0];
|
let pt = control_points[0];
|
||||||
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
|
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
|
||||||
let segments = 8;
|
let segments = 20;
|
||||||
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
||||||
|
|
||||||
for i in 0..=segments {
|
for i in 0..=segments {
|
||||||
@@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
|
|||||||
smoothed
|
smoothed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Glättet die Positionen mit dem Fenster [0.25, 0.5, 0.25] gegen Digitizer-Rauschen.
|
||||||
|
/// Symmetrisch (ohne Verzögerung), daher bleiben auch kleine Schleifen rund. Anfang, Ende
|
||||||
|
/// und Punkte mit scharfem Knick (über [`CORNER_ANGLE_THRESHOLD_DEG`]) bleiben unverändert,
|
||||||
|
/// damit Ecken spitz bleiben.
|
||||||
|
pub fn smooth_positions(samples: &[StylusSample]) -> Vec<StylusSample> {
|
||||||
|
if samples.len() < 3 {
|
||||||
|
return samples.to_vec();
|
||||||
|
}
|
||||||
|
let cos_limit = CORNER_ANGLE_THRESHOLD_DEG.to_radians().cos();
|
||||||
|
let mut smoothed = samples.to_vec();
|
||||||
|
for i in 1..samples.len() - 1 {
|
||||||
|
let (a, b, c) = (
|
||||||
|
samples[i - 1].position,
|
||||||
|
samples[i].position,
|
||||||
|
samples[i + 1].position,
|
||||||
|
);
|
||||||
|
let (d_in, d_out) = (b - a, c - b);
|
||||||
|
if d_in.length_squared() > 1e-8 && d_out.length_squared() > 1e-8 {
|
||||||
|
let cos = d_in.normalize().dot(d_out.normalize());
|
||||||
|
if cos < cos_limit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smoothed[i].position = 0.25 * a + 0.5 * b + 0.25 * c;
|
||||||
|
}
|
||||||
|
smoothed
|
||||||
|
}
|
||||||
|
|
||||||
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
|
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
|
||||||
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
|
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
|
||||||
if end <= start + 1 {
|
if end <= start + 1 {
|
||||||
@@ -402,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`.
|
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`
|
||||||
|
/// (nur zwischen Segmenten von mindestens [`CORNER_MIN_SEGMENT_UNITS`] Länge).
|
||||||
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
|
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
|
||||||
if samples.len() < 3 {
|
if samples.len() < 3 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -415,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
|||||||
let l_in = v_in.length();
|
let l_in = v_in.length();
|
||||||
let l_out = v_out.length();
|
let l_out = v_out.length();
|
||||||
|
|
||||||
if l_in > 1e-4 && l_out > 1e-4 {
|
if l_in >= CORNER_MIN_SEGMENT_UNITS && l_out >= CORNER_MIN_SEGMENT_UNITS {
|
||||||
let d_in = v_in / l_in;
|
let d_in = v_in / l_in;
|
||||||
let d_out = v_out / l_out;
|
let d_out = v_out / l_out;
|
||||||
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
|
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
|
||||||
@@ -566,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAP_STEPS: usize = 4;
|
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
|
||||||
|
const CAP_STEPS: usize = 10;
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
|
|
||||||
// 3. Runde Start-Kappe vor dem ersten Run
|
// 3. Runde Start-Kappe vor dem ersten Run
|
||||||
@@ -626,7 +660,7 @@ pub fn generate_finalized_triangle_strip(
|
|||||||
|
|
||||||
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
|
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
|
||||||
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
|
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
|
||||||
/// 2. Druckglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
/// 2. Druck- und Positionsglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||||
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
||||||
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
||||||
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
||||||
@@ -649,8 +683,8 @@ pub fn finalize_stroke_with_config(
|
|||||||
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
|
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
// 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||||
let smoothed = smooth_pressure(raw_samples);
|
let smoothed = smooth_positions(&smooth_pressure(raw_samples));
|
||||||
|
|
||||||
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
|
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
|
||||||
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
|
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
|
||||||
@@ -866,6 +900,31 @@ mod tests {
|
|||||||
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_small_loops_stay_round() {
|
||||||
|
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
|
||||||
|
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||||
|
let center = |t: f64| glam::Vec2::new(500.0 + t as f32 * 40.0, 500.0);
|
||||||
|
let raw: Vec<StylusSample> = (0..=((period * hz) as usize))
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f64 / hz;
|
||||||
|
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||||
|
StylusSample {
|
||||||
|
position: center(t) + glam::Vec2::new(r * a.sin(), -r * a.cos()),
|
||||||
|
pressure: 0.6,
|
||||||
|
timestamp: t,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let stroke =
|
||||||
|
finalize_stroke_with_config(1, &raw, [0.0; 4], &SplineConfig::from_width_units(5.0));
|
||||||
|
for p in &stroke.points {
|
||||||
|
let d = (p.position - center(p.timestamp)).length();
|
||||||
|
assert!((d - r).abs() < 0.6, "Schleife verformt: Abstand {d}");
|
||||||
|
}
|
||||||
|
assert!(detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pressure_smoothing() {
|
fn test_pressure_smoothing() {
|
||||||
let samples = vec![
|
let samples = vec![
|
||||||
|
|||||||
@@ -3089,14 +3089,18 @@ pub fn render_ui(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame_resp = egui::Frame::none()
|
// Nur der Titelbereich öffnet das Notizbuch: Eine Klickfläche über der
|
||||||
|
// ganzen Zeile würde (als zuletzt angelegtes Element) die Klicks auf
|
||||||
|
// Umbenennen und Löschen abfangen
|
||||||
|
let mut open_clicked = false;
|
||||||
|
egui::Frame::none()
|
||||||
.fill(bg)
|
.fill(bg)
|
||||||
.stroke(border)
|
.stroke(border)
|
||||||
.rounding(egui::Rounding::same(8.0))
|
.rounding(egui::Rounding::same(8.0))
|
||||||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.vertical(|ui| {
|
let title_area = ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(&summary.title)
|
egui::RichText::new(&summary.title)
|
||||||
@@ -3121,6 +3125,11 @@ pub fn render_ui(
|
|||||||
.color(egui::Color32::from_gray(160)),
|
.color(egui::Color32::from_gray(160)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
open_clicked = title_area
|
||||||
|
.response
|
||||||
|
.interact(egui::Sense::click())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
|
.clicked();
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if summary.status == crate::storage::NotebookStatus::Damaged {
|
if summary.status == crate::storage::NotebookStatus::Damaged {
|
||||||
@@ -3161,7 +3170,7 @@ pub fn render_ui(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if summary.status != crate::storage::NotebookStatus::Damaged
|
if summary.status != crate::storage::NotebookStatus::Damaged
|
||||||
&& frame_resp.response.interact(egui::Sense::click()).clicked()
|
&& open_clicked
|
||||||
&& !is_current
|
&& !is_current
|
||||||
{
|
{
|
||||||
actions.push(UiAction::OpenNotebook(summary.id.clone()));
|
actions.push(UiAction::OpenNotebook(summary.id.clone()));
|
||||||
@@ -3969,6 +3978,81 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_notebook_rename_delete_and_open_are_clickable() {
|
||||||
|
let notebook = Notebook::new();
|
||||||
|
let fresh = || {
|
||||||
|
let mut state = UiState::new();
|
||||||
|
state.is_document_menu_open = true;
|
||||||
|
state.available_notebooks = ["default", "zweites"]
|
||||||
|
.iter()
|
||||||
|
.map(|id| NotebookSummary {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: format!("Notizbuch {id}"),
|
||||||
|
page_count: 1,
|
||||||
|
modified_at: 0,
|
||||||
|
status: Default::default(),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
state
|
||||||
|
};
|
||||||
|
let mut probe = fresh();
|
||||||
|
render_frames(&mut probe, ¬ebook, 10);
|
||||||
|
let modal = probe.document_modal_rect.expect("Notizbuch-Dialog offen");
|
||||||
|
|
||||||
|
let updater = crate::updater::UpdaterController::new();
|
||||||
|
let (mut deleted, mut renamed, mut opened) = (false, false, false);
|
||||||
|
let mut y = modal.min.y + 4.0;
|
||||||
|
while y < modal.max.y && !(deleted && renamed && opened) {
|
||||||
|
let mut x = modal.min.x + 4.0;
|
||||||
|
while x < modal.max.x {
|
||||||
|
let mut state = fresh();
|
||||||
|
let ctx = render_frames(&mut state, ¬ebook, 3);
|
||||||
|
let pos = egui::pos2(x, y);
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for pressed in [true, false] {
|
||||||
|
ctx.begin_frame(egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
|
egui::Pos2::ZERO,
|
||||||
|
egui::vec2(1280.0, 850.0),
|
||||||
|
)),
|
||||||
|
events: vec![
|
||||||
|
egui::Event::PointerMoved(pos),
|
||||||
|
egui::Event::PointerButton {
|
||||||
|
pos,
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
actions.extend(render_ui(
|
||||||
|
&ctx,
|
||||||
|
&mut state,
|
||||||
|
¬ebook,
|
||||||
|
AutosaveStatus::Saved,
|
||||||
|
&updater,
|
||||||
|
));
|
||||||
|
let _ = ctx.end_frame();
|
||||||
|
}
|
||||||
|
deleted |= actions
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a, UiAction::DeleteNotebook(_)));
|
||||||
|
opened |= actions
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a, UiAction::OpenNotebook(_)));
|
||||||
|
renamed |= state.renaming_notebook_id.is_some();
|
||||||
|
x += 8.0;
|
||||||
|
}
|
||||||
|
y += 6.0;
|
||||||
|
}
|
||||||
|
assert!(deleted, "Löschen-Knopf reagiert nicht");
|
||||||
|
assert!(renamed, "Umbenennen-Knopf reagiert nicht");
|
||||||
|
assert!(opened, "Titel öffnet das Notizbuch nicht");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sidebar_buttons_react_to_raw_pointer_events() {
|
fn test_sidebar_buttons_react_to_raw_pointer_events() {
|
||||||
// Gleicher Weg wie die Stift-Eingabe: PointerMoved + PointerButton in die RawInput
|
// Gleicher Weg wie die Stift-Eingabe: PointerMoved + PointerButton in die RawInput
|
||||||
|
|||||||
Reference in New Issue
Block a user