Compare commits

..
Author SHA1 Message Date
haraldandClaude Opus 5.5 904b36e5f7 chore(release): bump version to 0.14.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:44:53 +02:00
haraldandClaude Opus 5.5 80d319099f fix: Karos und Linien in gleichmäßigen Abständen (Issue #10)
Karo, Linien und Punkte waren Dreiecke mit 0,15 mm breiten Linien –
schmaler als ein Bildschirmpixel. Je nach Lage auf dem Pixelraster
erschienen sie stärker, schwächer oder versetzt, die Abstände wirkten
unregelmäßig. Jetzt berechnet ein Fragment-Shader das Muster für jedes
Pixel aus der Seitenkoordinate: Linien und Punkte sind mindestens ein
Pixel breit, kantengeglättet und gleichmäßig; bei sehr kleinen Abständen
(weit herausgezoomt) blendet das Muster aus. Gemessen: Karo-Abstand
13,5 ± 0,04 Pixel bei gleicher Linienstärke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:21:16 +02:00
haraldandClaude Opus 5.5 ee5257d744 fix: Umbenennen und Löschen in der Notizbuchverwaltung reagieren (Issue #9)
Die Klickfläche zum Öffnen lag über der ganzen Zeile und wurde nach den
Knöpfen angelegt; egui gibt Klicks dem zuletzt angelegten Element, die
Knöpfe ✏ und 🗑 bekamen daher nie einen Klick. Jetzt öffnet nur der
Titelbereich das Notizbuch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:16:54 +02:00
haraldandClaude Opus 5.5 fa1ab5ab8f chore(release): bump version to 0.13.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:01:25 +02:00
haraldandClaude Opus 5.5 47300f2ca2 fix: Glatte Strichkanten (Issue #7)
Die Zeichenfläche wird mit 4× Multisampling gezeichnet und in die
Fensterfläche aufgelöst; Strichkanten, Formen und Bildränder sind damit
kantengeglättet (ohne Unterstützung des Formats wie bisher). Runde
Strichenden und Punkte sind feiner unterteilt (10 statt 4 Schritte je
Viertelkreis, 20 statt 8 für Punkte) und wirken auch stark vergrößert rund.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:54:12 +02:00
haraldandClaude Opus 5.5 27f84ff1b1 fix: Kleine Schleifen bleiben rund (Issue #8)
Der fertige Strich entsteht jetzt aus den ungefilterten Eingabepunkten mit
einer symmetrischen 3-Punkt-Glättung (ohne Verzögerung); der 1€-Filter
dient nur der Anzeige während des Schreibens. Der Filter arbeitet mit der
Geschwindigkeit in der Ebene statt je Achse und reagiert schneller. Ecken
werden nur zwischen Abschnitten ab 0,6 mm getrennt. Ein „o“ mit 0,8 mm
Radius behielt bisher im Mittel 6,1 mm/10 statt 8 und bekam zwei Zacken,
jetzt 7,8 ohne Zacken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:05:17 +02:00
haraldandClaude Opus 5.5 ffab2cea7a chore(release): bump version to 0.12.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:31:45 +02:00
10 changed files with 580 additions and 190 deletions
Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.11.0"
version = "0.14.0"
dependencies = [
"ab_glyph",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.11.0"
version = "0.14.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+3
View File
@@ -3,6 +3,9 @@
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### 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
- **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
+74 -126
View File
@@ -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.
#[inline]
fn push_quad(
vertices: &mut Vec<InkVertex>,
color: [f32; 4],
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
) {
vertices.push(InkVertex::new(x_min, y_min, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_max, y_max, color));
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
pub const LINED_MARGIN: f32 = 150.0;
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PatternVertex {
/// Seitenkoordinate
pub position: [f32; 2],
pub color: [f32; 4],
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
pub params: [f32; 4],
/// 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
/// einem Blatt der Größe `size`.
pub fn generate_background_mesh(
impl PatternVertex {
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
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,
color: [f32; 4],
size: glam::Vec2,
) -> Vec<InkVertex> {
let (page_width, page_height) = (size.x, size.y);
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,
color,
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,
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
}
}
) -> Vec<PatternVertex> {
let params = match background {
PageBackground::Blank => return Vec::new(),
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
};
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
let corner = |x: f32, y: f32| PatternVertex {
position: [x, y],
color,
params,
bounds,
};
vec![
corner(0.0, 0.0),
corner(size.x, 0.0),
corner(0.0, size.y),
corner(0.0, size.y),
corner(size.x, 0.0),
corner(size.x, size.y),
]
}
#[cfg(test)]
@@ -323,32 +278,25 @@ mod tests {
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test]
fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
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, PATTERN, A4_SIZE);
assert!(!lined.is_empty());
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);
fn test_pattern_quads() {
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
for (background, kind) in [
(PageBackground::Grid5mm, 1.0),
(PageBackground::Lined, 2.0),
(PageBackground::DotGrid, 3.0),
] {
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
assert_eq!(quad.len(), 6);
assert!(quad
.iter()
.all(|v| v.params[0] == kind && v.color == PATTERN));
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
}
}
#[test]
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);
assert!(quad
.iter()
+171 -36
View File
@@ -12,7 +12,9 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize;
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::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
@@ -150,8 +152,9 @@ struct NeighborPage {
offset: glam::Vec2,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
background_buffer: Option<wgpu::Buffer>,
background_vertex_count: u32,
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
paper_buffer: wgpu::Buffer,
pattern_buffer: Option<wgpu::Buffer>,
vertex_buffer: Option<wgpu::Buffer>,
stroke_ranges: Vec<Range<u32>>,
}
@@ -160,6 +163,9 @@ pub struct CanvasState {
pub surface: wgpu::Surface<'static>,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter,
/// 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
presentation_warned: bool,
pub device: wgpu::Device,
@@ -184,9 +190,10 @@ pub struct CanvasState {
// Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer,
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
pub background_buffer: wgpu::Buffer,
pub background_vertex_capacity: usize,
pub background_vertex_count: u32,
pattern_pipeline: wgpu::RenderPipeline,
pub current_background: PageBackground,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
@@ -210,6 +217,9 @@ pub struct CanvasState {
pub live_vertex_capacity: usize,
pub live_vertex_count: u32,
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_vertices: Vec<InkVertex>,
pub filter: OneEuroFilter,
@@ -240,7 +250,6 @@ pub struct CanvasState {
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.
@@ -323,6 +332,18 @@ impl CanvasState {
};
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
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Ink WGSL Shader"),
@@ -403,7 +424,10 @@ impl CanvasState {
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None,
});
@@ -435,7 +459,10 @@ impl CanvasState {
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multisample: wgpu::MultisampleState {
count: msaa_samples,
..Default::default()
},
multiview: None,
});
@@ -451,19 +478,46 @@ impl CanvasState {
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
let current_background = PageBackground::Grid5mm;
let bg_mesh = generate_background_mesh(
let bg_mesh = generate_pattern_quad(
current_background,
paper_theme.pattern_color(),
crate::document::A4_SIZE,
);
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,
label: Some("Pattern Quad Buffer"),
size: (6 * std::mem::size_of::<PatternVertex>()) 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));
// 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
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Live Stroke Vertex Buffer"),
@@ -484,6 +538,7 @@ impl CanvasState {
&shader,
&bind_group_layout,
config.format,
msaa_samples,
);
// 8. egui Renderer für das schwebende UI initialisieren
@@ -492,6 +547,8 @@ impl CanvasState {
Ok(Self {
surface,
adapter,
msaa_samples,
msaa_view,
presentation_warned: false,
device,
queue,
@@ -508,8 +565,8 @@ impl CanvasState {
current_stroke_width_mm: 0.5,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
background_vertex_count: bg_mesh.len() as u32,
pattern_pipeline,
current_background,
paper_theme,
adapt_ink: false,
@@ -523,6 +580,7 @@ impl CanvasState {
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
live_vertex_count: 0,
live_samples: Vec::with_capacity(256),
live_raw_samples: Vec::with_capacity(256),
live_control_points: Vec::with_capacity(256),
live_vertices: Vec::with_capacity(1024),
filter: OneEuroFilter::default(),
@@ -639,6 +697,7 @@ impl CanvasState {
}
}
self.surface.configure(&self.device, &self.config);
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
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.
fn upload_background_mesh(&mut self) {
let mesh = generate_background_mesh(
let mesh = generate_pattern_quad(
self.current_background,
self.pattern.color(self.paper_theme),
self.page_size,
);
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));
@@ -933,20 +981,39 @@ impl CanvasState {
buffer
})
};
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
background.extend(generate_background_mesh(
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
let pattern = generate_pattern_quad(
page.background,
self.pattern.color(self.paper_theme),
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 {
fingerprint,
offset: glam::Vec2::ZERO,
camera_buffer,
camera_bind_group,
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
background_vertex_count: background.len() as u32,
paper_buffer,
pattern_buffer,
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
stroke_ranges: page.stroke_ranges.clone(),
}
@@ -1051,6 +1118,8 @@ impl CanvasState {
.ruler
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
self.live_raw_samples.clear();
self.push_raw_sample(page_x, page_y, pressure, timestamp);
// 1. Zwingend durch den 1€-Filter leiten
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
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 new_pt = ControlPoint::new(fx, fy, fp);
@@ -1098,6 +1168,25 @@ impl CanvasState {
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.
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
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.
pub fn cancel_stroke(&mut self) {
self.live_samples.clear();
self.live_raw_samples.clear();
self.live_control_points.clear();
self.live_vertices.clear();
self.live_vertex_count = 0;
@@ -1131,10 +1221,16 @@ impl CanvasState {
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(
stroke_id,
&self.live_samples,
source,
self.current_ink_color,
&self.spline_config,
);
@@ -1144,6 +1240,7 @@ impl CanvasState {
// Temporären GPU-Live-Buffer vollständig leeren
self.live_samples.clear();
self.live_raw_samples.clear();
self.live_control_points.clear();
self.live_vertices.clear();
self.live_vertex_count = 0;
@@ -1230,12 +1327,17 @@ impl CanvasState {
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Ink Canvas Render Pass"),
// Mit Multisampling ins Zwischenbild zeichnen und in die Fensterfläche auflösen
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
view: self.msaa_view.as_ref().unwrap_or(&view),
resolve_target: self.msaa_view.as_ref().map(|_| &view),
ops: wgpu::Operations {
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,
@@ -1247,9 +1349,12 @@ impl CanvasState {
for (i, neighbor) in self.neighbors.iter().enumerate() {
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
render_pass.set_pipeline(&self.triangle_pipeline);
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.draw(0..neighbor.background_vertex_count, 0..1);
render_pass.draw(0..6, 0..1);
}
self.image_layer.draw(&mut render_pass, i + 1);
if let Some(buffer) = &neighbor.vertex_buffer {
@@ -1273,6 +1378,7 @@ impl CanvasState {
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
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.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.
struct TextSpec {
text: crate::text::TextBox,
+91 -15
View File
@@ -10,10 +10,13 @@ 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;
/// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
/// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
/// werden kaum noch verkleinert
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).
///
@@ -157,13 +160,18 @@ pub struct FilteredPoint {
}
/// 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)]
pub struct OneEuroFilter {
pub fc_min: f32,
pub beta: f32,
pub d_cutoff: f32,
x_filter: OneEuroFilter1D,
y_filter: OneEuroFilter1D,
pos_prev: Option<glam::Vec2>,
speed_prev: f32,
t_prev: Option<f64>,
p_filter: OneEuroFilter1D,
last_output: Option<(f32, f32, f32)>,
}
@@ -175,8 +183,9 @@ impl OneEuroFilter {
fc_min,
beta,
d_cutoff,
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
pos_prev: None,
speed_prev: 0.0,
t_prev: None,
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
last_output: None,
}
@@ -184,8 +193,9 @@ impl OneEuroFilter {
/// 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.pos_prev = None;
self.speed_prev = 0.0;
self.t_prev = None;
self.p_filter.reset();
self.last_output = None;
}
@@ -202,8 +212,8 @@ impl OneEuroFilter {
// 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 pos = self.filter_position(glam::Vec2::new(x, 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 out = (x_out, y_out, p_out);
@@ -211,6 +221,51 @@ impl OneEuroFilter {
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`.
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);
@@ -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]
fn test_high_frequency_jitter() {
let mut filter = OneEuroFilter::default();
@@ -286,7 +360,8 @@ mod tests {
let base_x = 100.0_f32;
let base_y = 50.0_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 dt = 1.0 / 120.0; // 120 Hz Abtastrate
@@ -331,9 +406,10 @@ mod tests {
.sum::<f32>()
/ 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!(
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})"
);
}
+5 -1
View File
@@ -102,6 +102,7 @@ impl ImageLayer {
shader: &wgpu::ShaderModule,
camera_layout: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
sample_count: u32,
) -> Self {
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Texture Bind Group Layout"),
@@ -151,7 +152,10 @@ impl ImageLayer {
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multisample: wgpu::MultisampleState {
count: sample_count,
..Default::default()
},
multiview: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+81
View File
@@ -77,3 +77,84 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
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
View File
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
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).
#[repr(C)]
@@ -270,7 +274,7 @@ pub fn generate_triangle_strip(
// 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 segments = 20;
let mut vertices = Vec::with_capacity((segments + 1) * 2);
for i in 0..=segments {
@@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
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.
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
if end <= start + 1 {
@@ -402,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
.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> {
if samples.len() < 3 {
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_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_out = v_out / l_out;
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();
}
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();
// 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:
/// 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)
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
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);
}
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
let smoothed = smooth_pressure(raw_samples);
// 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
let smoothed = smooth_positions(&smooth_pressure(raw_samples));
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
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);
}
#[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]
fn test_pressure_smoothing() {
let samples = vec![
+87 -3
View File
@@ -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)
.stroke(border)
.rounding(egui::Rounding::same(8.0))
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.vertical(|ui| {
let title_area = ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&summary.title)
@@ -3121,6 +3125,11 @@ pub fn render_ui(
.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| {
if summary.status == crate::storage::NotebookStatus::Damaged {
@@ -3161,7 +3170,7 @@ pub fn render_ui(
});
if summary.status != crate::storage::NotebookStatus::Damaged
&& frame_resp.response.interact(egui::Sense::click()).clicked()
&& open_clicked
&& !is_current
{
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, &notebook, 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, &notebook, 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,
&notebook,
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]
fn test_sidebar_buttons_react_to_raw_pointer_events() {
// Gleicher Weg wie die Stift-Eingabe: PointerMoved + PointerButton in die RawInput