Compare commits

...
Author SHA1 Message Date
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
4 changed files with 262 additions and 30 deletions
+100 -7
View File
@@ -160,6 +160,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,
@@ -210,6 +213,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,
@@ -323,6 +329,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 +421,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 +456,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,
}); });
@@ -484,6 +508,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 +517,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,
@@ -523,6 +550,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 +667,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();
} }
} }
@@ -1051,6 +1080,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 +1107,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 +1130,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 +1165,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 +1183,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 +1202,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 +1289,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,
@@ -1342,6 +1406,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
View File
@@ -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
View File
@@ -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 {
+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). /// 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![