diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 219c09e..f17b70c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -52,6 +52,20 @@ Aus den interpolierten Spline-Punkten mit Radius $r_i = f(\text{pressure}_i)$ wi 4. Eckpunkte des Dreiecksstreifens: $$V_{i,\text{left}} = P_i + N_i \cdot r_i, \quad V_{i,\text{right}} = P_i - N_i \cdot r_i$$ +### 1.4. Zwei-Stufen-Modell (Live-Glättung vs. akausale Post-Stroke-Finalisierung) +Um minimale Latenz beim Zeichnen mit maximaler Präzision und Ecken-Erhaltung zu verbinden, arbeitet `ink-notes` mit einem strikten Zwei-Stufen-Modell: + +1. **Live-Phase (Kausal, Zero-Latency):** + - Eingehende Punkte durchlaufen in Echtzeit den 1€-Filter (`filter.rs`). + - `spline.rs` tesselliert inkrementell temporäre Triangle-Strips für die unverzögerte Bildschirmdarstellung im GPU-Live-Buffer. +2. **Commit-Phase (Akausal, `finalize_stroke`):** + - Beim Absetzen des Stifts wird der gesamte Strich akausal analysiert: + - **Druckglättung:** Glättung des Druckverlaufs $p$ über ein gewichtetes 3-Punkt-Fenster $[0.25, 0.5, 0.25]$ zur Ausblendung von Digitalisierer-Stufen. + - **RDP-Dezimierung:** Ausdünnung kollinearer Zwischenpunkte mit Ramer-Douglas-Peucker ($\epsilon \approx 0.075\text{ mm} = 0.75\text{ Einheiten}$). + - **Ecken-Erkennung (Corner Preservation):** Bei Richtungsänderungen $> 65^\circ$ wird die $C^1$-Spline-Kontinuität zu $C^0$ aufgebrochen, sodass Ecken (z. B. 90°-L-Knicke oder Zickzack-Linien) gestochen scharf bleiben und nicht rundgeschliffen werden. + - **Runde Kappen & Miter-Joins:** Generierung sauberer runder Kappen an Start und Ende des Triangle-Strips. + - Der temporäre Live-Buffer wird vollständig entleert und der bereinigte `Stroke` in den persistenten Puffer der Seite überführt. + --- ## 2. Rendering & Kamera-Pipeline (`wgpu`) diff --git a/src/canvas.rs b/src/canvas.rs index d4ae4ac..9a93b8e 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -617,9 +617,9 @@ impl CanvasState { self.filter.reset(); } - /// Schließt den aktuellen Live-Strich ab und erzeugt das fertige `Stroke`-Objekt. + /// Schließt den aktuellen Live-Strich ab und erzeugt das fertige `Stroke`-Objekt nach dem Zwei-Stufen-Modell. pub fn finish_stroke(&mut self, stroke_id: u64) -> Option { - if self.live_vertex_count == 0 || self.live_vertices.is_empty() { + if self.live_samples.is_empty() { self.live_samples.clear(); self.live_control_points.clear(); self.live_vertices.clear(); @@ -628,13 +628,15 @@ impl CanvasState { return None; } - let stroke = Stroke::new( + // Post-Stroke Finalisierung (Corner Preservation, Druckglättung, RDP, runde Kappen) + let stroke = spline::finalize_stroke_with_config( stroke_id, - self.live_samples.clone(), - self.live_vertices.clone(), + &self.live_samples, self.current_ink_color, + &self.spline_config, ); + // Temporären GPU-Live-Buffer vollständig leeren self.live_samples.clear(); self.live_control_points.clear(); self.live_vertices.clear(); diff --git a/src/document.rs b/src/document.rs index 63a9cd1..8766509 100644 --- a/src/document.rs +++ b/src/document.rs @@ -4,6 +4,7 @@ use std::ops::Range; use crate::canvas::InkVertex; use crate::history::{HistoryAction, HistoryStack}; +pub use crate::spline::{finalize_stroke, finalize_stroke_with_config}; /// Standardmaße DIN A4 in Millimetern. pub const A4_WIDTH_MM: f32 = 210.0; @@ -135,6 +136,16 @@ impl Stroke { } } + /// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell). + pub fn finalize( + id: u64, + raw_samples: &[StylusSample], + color: [f32; 4], + config: &crate::spline::SplineConfig, + ) -> Self { + crate::spline::finalize_stroke_with_config(id, raw_samples, color, config) + } + /// Verknüpft diesen Strich mit einer mathematisch erkannten Form. pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self { self.shape = Some(shape); diff --git a/src/spline.rs b/src/spline.rs index 74516f8..44fc9b0 100644 --- a/src/spline.rs +++ b/src/spline.rs @@ -9,6 +9,15 @@ //! //! Generiert daraus Triangle-Strips für das GPU-Rendering mit `wgpu`. +use crate::canvas::InkVertex; +use crate::document::{point_to_segment_distance, Stroke, StylusSample}; + +/// Standardtoleranz für Ramer-Douglas-Peucker (ca. 0.075 mm in DIN-A4-Einheiten). +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; + /// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern). #[repr(C)] #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] @@ -321,6 +330,330 @@ pub fn generate_triangle_strip( } } +/// Glättet den Druckwertverlauf p über ein einfaches gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]. +pub fn smooth_pressure(samples: &[StylusSample]) -> Vec { + if samples.len() < 3 { + return samples.to_vec(); + } + let mut smoothed = samples.to_vec(); + for i in 1..samples.len() - 1 { + let p = 0.25 * samples[i - 1].pressure + + 0.50 * samples[i].pressure + + 0.25 * samples[i + 1].pressure; + smoothed[i].pressure = p.clamp(0.0, 1.0); + } + 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 { + return; + } + let a = positions[start]; + let b = positions[end]; + let mut max_dist = 0.0_f32; + let mut max_idx = start; + + for i in (start + 1)..end { + let dist = point_to_segment_distance(positions[i], a, b); + if dist > max_dist { + max_dist = dist; + max_idx = i; + } + } + + if max_dist > epsilon { + keep[max_idx] = true; + rdp_mark(positions, start, max_idx, epsilon, keep); + rdp_mark(positions, max_idx, end, epsilon, keep); + } +} + +/// Dezimiert kollineare Zwischenpunkte mit Ramer-Douglas-Peucker (Toleranz ca. 0.05 bis 0.1 mm). +pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec { + if samples.len() <= 2 { + return samples.to_vec(); + } + let positions: Vec = samples.iter().map(|s| s.position).collect(); + let mut keep = vec![false; samples.len()]; + keep[0] = true; + keep[samples.len() - 1] = true; + + rdp_mark(&positions, 0, samples.len() - 1, epsilon, &mut keep); + + samples + .iter() + .enumerate() + .filter(|(i, _)| keep[*i]) + .map(|(_, s)| *s) + .collect() +} + +/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`. +pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec { + if samples.len() < 3 { + return Vec::new(); + } + let mut corners = Vec::new(); + + for i in 1..samples.len() - 1 { + let v_in = samples[i].position - samples[i - 1].position; + let v_out = samples[i + 1].position - samples[i].position; + let l_in = v_in.length(); + let l_out = v_out.length(); + + if l_in > 1e-4 && l_out > 1e-4 { + 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); + let angle_deg = dot.acos() * (180.0 / std::f32::consts::PI); + if angle_deg > threshold_deg { + corners.push(i); + } + } + } + + corners +} + +/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab. +fn sample_smooth_run( + control_points: &[ControlPoint], + config: &SplineConfig, +) -> Vec { + match control_points.len() { + 0 => Vec::new(), + 1 => { + let pt = control_points[0]; + let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius); + vec![SplineSample { + position: pt.position, + tangent: glam::Vec2::new(1.0, 0.0), + normal: glam::Vec2::new(0.0, 1.0), + pressure: pt.pressure, + radius, + left: pt.position + glam::Vec2::new(0.0, radius), + right: pt.position - glam::Vec2::new(0.0, radius), + }] + } + 2 => { + let p0 = control_points[0]; + let p1 = control_points[1]; + let dir = p1.position - p0.position; + let len = dir.length(); + let tangent = if len > 1e-5 { + dir / len + } else { + glam::Vec2::new(1.0, 0.0) + }; + let normal = glam::Vec2::new(-tangent.y, tangent.x); + + let steps = (len / 10.0).clamp(2.0, 8.0) as usize; + let mut samples = Vec::with_capacity(steps + 1); + + for s in 0..=steps { + let u = s as f32 / steps as f32; + let pos = p0.position.lerp(p1.position, u); + let pressure = p0.pressure + u * (p1.pressure - p0.pressure); + let radius = calculate_radius(pressure, config.min_radius, config.max_radius); + samples.push(SplineSample { + position: pos, + tangent, + normal, + pressure, + radius, + left: pos + normal * radius, + right: pos - normal * radius, + }); + } + + samples + } + n => { + let steps = config.samples_per_segment.max(1); + let mut samples = Vec::new(); + + for i in 0..n - 1 { + let p_curr = control_points[i]; + let p_next = control_points[i + 1]; + + // C0-Enden für den Run: Extrapolierte Geisterpunkte verhindern Ausbeulen an Ecken + let p_prev = if i == 0 { + ControlPoint { + position: 2.0 * p_curr.position - p_next.position, + pressure: p_curr.pressure, + } + } else { + control_points[i - 1] + }; + + let p_after = if i + 2 < n { + control_points[i + 2] + } else { + ControlPoint { + position: 2.0 * p_next.position - p_curr.position, + pressure: p_next.pressure, + } + }; + + let start_step = if i == 0 { 0 } else { 1 }; + for s in start_step..=steps { + let u = s as f32 / steps as f32; + samples.push(evaluate_catmull_rom_segment( + p_prev, p_curr, p_next, p_after, u, config, + )); + } + } + + samples + } + } +} + +/// Generiert das finale Triangle-Mesh aus den bereinigten Punkten unter Beachtung +/// von Ecken-Unterbrechungen (C0) sowie runden Kappen an Start und Ende. +pub fn generate_finalized_triangle_strip( + samples: &[StylusSample], + corners: &[usize], + config: &SplineConfig, +) -> Vec { + if samples.is_empty() { + return Vec::new(); + } + if samples.len() == 1 { + let pt = ControlPoint::from_vec2(samples[0].position, samples[0].pressure); + return generate_triangle_strip(&[pt], config); + } + + // 1. Teile die Punkte an erkannten Ecken in glatte Runs auf + let mut run_ranges = Vec::new(); + let mut current_start = 0; + + for &corner_idx in corners { + if corner_idx > current_start && corner_idx < samples.len() { + run_ranges.push(current_start..=corner_idx); + current_start = corner_idx; + } + } + if current_start < samples.len() { + run_ranges.push(current_start..=(samples.len() - 1)); + } + + // 2. Taste alle Runs einzeln ab + let mut runs: Vec> = Vec::with_capacity(run_ranges.len()); + for range in run_ranges { + let pts: Vec = samples[range] + .iter() + .map(|s| ControlPoint::from_vec2(s.position, s.pressure)) + .collect(); + let run_samples = sample_smooth_run(&pts, config); + if !run_samples.is_empty() { + runs.push(run_samples); + } + } + + if runs.is_empty() { + return Vec::new(); + } + + const CAP_STEPS: usize = 4; + let mut vertices = Vec::new(); + + // 3. Runde Start-Kappe vor dem ersten Run + let s_first = &runs[0][0]; + let p_start = s_first.position; + let t_start = s_first.tangent; + let n_start = s_first.normal; + let r_start = s_first.radius; + let pr_start = s_first.pressure; + + for k in 0..CAP_STEPS { + let alpha = (k as f32 / CAP_STEPS as f32) * (std::f32::consts::PI * 0.5); + let left = p_start - t_start * (r_start * alpha.cos()) + n_start * (r_start * alpha.sin()); + let right = p_start - t_start * (r_start * alpha.cos()) - n_start * (r_start * alpha.sin()); + vertices.push(Vertex::new(left.x, left.y, pr_start, -1.0)); + vertices.push(Vertex::new(right.x, right.y, pr_start, 1.0)); + } + + // 4. Runs und Ecken-Übergänge verbinden + for (run_idx, run) in runs.iter().enumerate() { + if run_idx > 0 { + // Ecken-Verbindung: Nahtlose C0-Verbindung am Knick + let s_corner = &run[0]; + let p_c = s_corner.position; + let pr_c = s_corner.pressure; + vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0)); + vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0)); + vertices.push(Vertex::new(s_corner.left.x, s_corner.left.y, pr_c, -1.0)); + vertices.push(Vertex::new(s_corner.right.x, s_corner.right.y, pr_c, 1.0)); + } + + let start_sample_idx = if run_idx > 0 { 1 } else { 0 }; + for s in &run[start_sample_idx..] { + vertices.push(Vertex::new(s.left.x, s.left.y, s.pressure, -1.0)); + vertices.push(Vertex::new(s.right.x, s.right.y, s.pressure, 1.0)); + } + } + + // 5. Runde End-Kappe nach dem letzten Run + let s_last = runs.last().unwrap().last().unwrap(); + let p_end = s_last.position; + let t_end = s_last.tangent; + let n_end = s_last.normal; + let r_end = s_last.radius; + let pr_end = s_last.pressure; + + for k in 1..=CAP_STEPS { + let beta = (std::f32::consts::PI * 0.5) * (1.0 - k as f32 / CAP_STEPS as f32); + let left = p_end + t_end * (r_end * beta.cos()) + n_end * (r_end * beta.sin()); + let right = p_end + t_end * (r_end * beta.cos()) - n_end * (r_end * beta.sin()); + vertices.push(Vertex::new(left.x, left.y, pr_end, -1.0)); + vertices.push(Vertex::new(right.x, right.y, pr_end, 1.0)); + } + + vertices +} + +/// 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] +/// 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 { + finalize_stroke_with_config(0, raw_samples, [0.0, 0.0, 0.0, 1.0], &SplineConfig::default()) +} + +/// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig. +pub fn finalize_stroke_with_config( + stroke_id: u64, + raw_samples: &[StylusSample], + color: [f32; 4], + config: &SplineConfig, +) -> Stroke { + if raw_samples.is_empty() { + 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); + + // 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten) + let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS); + + // 3. Ecken-Erkennung (Winkel > 65 Grad) + let corners = detect_corners(&decimated, CORNER_ANGLE_THRESHOLD_DEG); + + // 4. Finale Triangle-Strips mit C0-Ecken und runden Kappen generieren + let strip_vertices = generate_finalized_triangle_strip(&decimated, &corners, config); + let ink_vertices: Vec = strip_vertices + .into_iter() + .map(|v| InkVertex::new(v.position[0], v.position[1], color)) + .collect(); + + Stroke::new(stroke_id, decimated, ink_vertices, color) +} + #[cfg(test)] mod tests { use super::*; @@ -447,4 +780,99 @@ mod tests { assert!(v.side.is_finite()); } } + + #[test] + fn test_corner_preservation() { + let mut raw_samples = Vec::new(); + // Horizontaler Schenkel von (0, 0) bis (100, 0) + for i in 0..=20 { + raw_samples.push(StylusSample { + position: glam::Vec2::new(i as f32 * 5.0, 0.0), + pressure: 0.5, + timestamp: i as f64 * 0.01, + }); + } + // Vertikaler Schenkel von (100, 0) bis (100, 100) (90°-Knick / L-Form) + for i in 1..=20 { + raw_samples.push(StylusSample { + position: glam::Vec2::new(100.0, i as f32 * 5.0), + pressure: 0.5, + timestamp: (20 + i) as f64 * 0.01, + }); + } + + let stroke = finalize_stroke(&raw_samples); + + // 1. Strich und Meshes müssen sauber aufgebaut sein + assert!(!stroke.points.is_empty()); + assert!(!stroke.vertices.is_empty()); + + // 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben + let corner_found = stroke.points.iter().any(|s| { + (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3 + }); + assert!( + corner_found, + "Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!" + ); + + // 3. Ecken-Erhaltung: Entlang des horizontalen Schenkels vor der Ecke (x < 99) + // darf kein Rundungsausweichen in y-Richtung stattfinden + for pt in &stroke.points { + if pt.position.x < 99.0 { + assert!( + pt.position.y.abs() < 1e-3, + "Horizontaler Schenkel darf vor der Ecke nicht abgerundet werden (y = {})", + pt.position.y + ); + } + if pt.position.y > 1.0 { + assert!( + (pt.position.x - 100.0).abs() < 1e-3, + "Vertikaler Schenkel darf nach der Ecke nicht abgerundet werden (x = {})", + pt.position.x + ); + } + } + + // 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad) + let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG); + assert_eq!(corners.len(), 1, "Exakt eine 90-Grad-Ecke muss erkannt werden"); + let corner_idx = corners[0]; + let corner_pt = stroke.points[corner_idx]; + assert!((corner_pt.position.x - 100.0).abs() < 1e-3); + assert!((corner_pt.position.y - 0.0).abs() < 1e-3); + } + + #[test] + fn test_pressure_smoothing() { + let samples = vec![ + StylusSample { position: glam::Vec2::new(0.0, 0.0), pressure: 0.2, timestamp: 0.0 }, + StylusSample { position: glam::Vec2::new(1.0, 0.0), pressure: 0.8, timestamp: 0.1 }, + StylusSample { position: glam::Vec2::new(2.0, 0.0), pressure: 0.2, timestamp: 0.2 }, + ]; + + let smoothed = smooth_pressure(&samples); + // Fenster: 0.25 * 0.2 + 0.5 * 0.8 + 0.25 * 0.2 = 0.05 + 0.40 + 0.05 = 0.50 + assert!((smoothed[1].pressure - 0.50).abs() < 1e-4); + } + + #[test] + fn test_rdp_decimation() { + let mut samples = Vec::new(); + // 10 Punkte exakt auf einer Linie + for i in 0..=10 { + samples.push(StylusSample { + position: glam::Vec2::new(i as f32 * 10.0, 0.0), + pressure: 0.5, + timestamp: i as f64 * 0.01, + }); + } + + let decimated = rdp_filter_samples(&samples, RDP_TOLERANCE_UNITS); + // Kollineare Zwischenpunkte müssen entfernt werden -> nur Start und Ende bleiben + assert_eq!(decimated.len(), 2); + assert_eq!(decimated[0].position, glam::Vec2::new(0.0, 0.0)); + assert_eq!(decimated[1].position, glam::Vec2::new(100.0, 0.0)); + } }