diff --git a/src/canvas.rs b/src/canvas.rs index 4ef929f..291b0c0 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -210,6 +210,9 @@ pub struct CanvasState { pub live_vertex_capacity: usize, pub live_vertex_count: u32, pub live_samples: Vec, + /// 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, pub live_control_points: Vec, pub live_vertices: Vec, pub filter: OneEuroFilter, @@ -523,6 +526,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(), @@ -1051,6 +1055,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 +1082,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 +1105,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 +1140,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 +1158,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 +1177,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; diff --git a/src/filter.rs b/src/filter.rs index 97fa1f6..ff22373 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -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, + speed_prev: f32, + t_prev: Option, 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::() / 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::() / 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})" ); } diff --git a/src/spline.rs b/src/spline.rs index b72ecf8..fba5b56 100644 --- a/src/spline.rs +++ b/src/spline.rs @@ -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)] @@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec { 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 { + 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 `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 { if samples.len() < 3 { return Vec::new(); @@ -415,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec 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); @@ -626,7 +659,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 +682,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 +899,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 = (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![