//! Zentripetale Catmull-Rom-Spline-Interpolation (alpha = 0.5) über De-Boor / Barry-Goldman. //! //! Berechnet für jeden Schritt entlang der Kurve: //! - Position C(t) //! - Tangente T(t) (analytische Ableitung) //! - Normale N(t) (senkrecht auf Tangente) //! - Druckabhängigen Radius r(t) //! - Linke und rechte Hüllpunkte //! //! 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)] pub struct Vertex { pub position: [f32; 2], pub pressure: f32, pub side: f32, // -1.0 für linken Hüllpunkt, +1.0 für rechten Hüllpunkt } impl Vertex { pub fn new(x: f32, y: f32, pressure: f32, side: f32) -> Self { Self { position: [x, y], pressure, side, } } pub fn from_vec2(pos: glam::Vec2, pressure: f32, side: f32) -> Self { Self { position: [pos.x, pos.y], pressure, side, } } pub fn pos(&self) -> glam::Vec2 { glam::Vec2::new(self.position[0], self.position[1]) } } /// Kontrollpunkt für die Spline-Interpolation mit Position und Druck p ∈ [0.0, 1.0]. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ControlPoint { pub position: glam::Vec2, pub pressure: f32, } impl ControlPoint { pub fn new(x: f32, y: f32, pressure: f32) -> Self { Self { position: glam::Vec2::new(x, y), pressure: pressure.clamp(0.0, 1.0), } } pub fn from_vec2(position: glam::Vec2, pressure: f32) -> Self { Self { position, pressure: pressure.clamp(0.0, 1.0), } } } /// Ein berechneter Abtastschritt entlang des Splines mit Tangente, Normale und Hüllpunkten. #[derive(Debug, Clone, Copy, PartialEq)] pub struct SplineSample { pub position: glam::Vec2, pub tangent: glam::Vec2, pub normal: glam::Vec2, pub pressure: f32, pub radius: f32, pub left: glam::Vec2, pub right: glam::Vec2, } /// Konfiguration für die Spline-Interpolation und das Meshing. #[derive(Debug, Clone, Copy, PartialEq)] pub struct SplineConfig { /// Minimaler Pinselradius bei Druck p = 0.0 pub min_radius: f32, /// Maximaler Pinselradius bei Druck p = 1.0 pub max_radius: f32, /// Anzahl an Diskretisierungsschritten pro Spline-Segment pub samples_per_segment: usize, } impl Default for SplineConfig { fn default() -> Self { Self { min_radius: 1.5, max_radius: 4.5, samples_per_segment: 16, } } } impl SplineConfig { /// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm). pub fn from_width_units(width_units: f32) -> Self { let base_radius = (width_units / 2.0).max(0.25); Self { min_radius: (base_radius * 0.5).max(0.15), max_radius: (base_radius * 1.5).max(0.35), samples_per_segment: 16, } } } /// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r). pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 { let p = pressure.clamp(0.0, 1.0); min_radius + p * (max_radius - min_radius) } /// Zentripetale Catmull-Rom-Interpolation (alpha = 0.5) über De-Boor / Barry-Goldman. /// /// Berechnet Position, Tangente, Normale, Radius und linke/rechte Hüllpunkte /// für den Parameter `u` ∈ [0.0, 1.0] im Segment zwischen `p1` und `p2`. pub fn evaluate_catmull_rom_segment( p0: ControlPoint, p1: ControlPoint, p2: ControlPoint, p3: ControlPoint, u: f32, config: &SplineConfig, ) -> SplineSample { let u = u.clamp(0.0, 1.0); // Knotensequenz mit alpha = 0.5 (zentripetal: sqrt(Distanz)) let d01 = (p1.position - p0.position).length(); let d12 = (p2.position - p1.position).length(); let d23 = (p3.position - p2.position).length(); let dt01 = if d01 > 1e-6 { d01.sqrt() } else { 1e-4 }; let dt12 = if d12 > 1e-6 { d12.sqrt() } else { 1e-4 }; let dt23 = if d23 > 1e-6 { d23.sqrt() } else { 1e-4 }; let t0 = 0.0_f32; let t1 = t0 + dt01; let t2 = t1 + dt12; let t3 = t2 + dt23; let t = t1 + u * (t2 - t1); // --- De-Boor / Barry-Goldman Pyramide --- // Ebene 1: let a1 = ((t1 - t) * p0.position + (t - t0) * p1.position) / (t1 - t0); let a2 = ((t2 - t) * p1.position + (t - t1) * p2.position) / (t2 - t1); let a3 = ((t3 - t) * p2.position + (t - t2) * p3.position) / (t3 - t2); let pa1 = ((t1 - t) * p0.pressure + (t - t0) * p1.pressure) / (t1 - t0); let pa2 = ((t2 - t) * p1.pressure + (t - t1) * p2.pressure) / (t2 - t1); let pa3 = ((t3 - t) * p2.pressure + (t - t2) * p3.pressure) / (t3 - t2); // Ebene 1 Ableitungen: let da1 = (p1.position - p0.position) / (t1 - t0); let da2 = (p2.position - p1.position) / (t2 - t1); let da3 = (p3.position - p2.position) / (t3 - t2); // Ebene 2: let b1 = ((t2 - t) * a1 + (t - t0) * a2) / (t2 - t0); let b2 = ((t3 - t) * a2 + (t - t1) * a3) / (t3 - t1); let pb1 = ((t2 - t) * pa1 + (t - t0) * pa2) / (t2 - t0); let pb2 = ((t3 - t) * pa2 + (t - t1) * pa3) / (t3 - t1); // Ebene 2 Ableitungen: let db1 = (a2 - a1) / (t2 - t0) + ((t2 - t) * da1 + (t - t0) * da2) / (t2 - t0); let db2 = (a3 - a2) / (t3 - t1) + ((t3 - t) * da2 + (t - t1) * da3) / (t3 - t1); // Ebene 3 (Ergebnis): let pos = ((t2 - t) * b1 + (t - t1) * b2) / (t2 - t1); let pressure = (((t2 - t) * pb1 + (t - t1) * pb2) / (t2 - t1)).clamp(0.0, 1.0); // Ebene 3 Ableitung (analytische Tangente C'(t)): let dc = (b2 - b1) / (t2 - t1) + ((t2 - t) * db1 + (t - t1) * db2) / (t2 - t1); // Tangente normieren (oder Fallback bei Stillstand) let tangent = if dc.length_squared() > 1e-10 { dc.normalize() } else { let fallback = p2.position - p1.position; if fallback.length_squared() > 1e-10 { fallback.normalize() } else { glam::Vec2::new(1.0, 0.0) } }; // Normale senkrecht zur Tangente: (-Ty, Tx) let normal = glam::Vec2::new(-tangent.y, tangent.x); // Druckabhängiger Radius r(t) let radius = calculate_radius(pressure, config.min_radius, config.max_radius); // Linke und rechte Hüllpunkte let left = pos + normal * radius; let right = pos - normal * radius; SplineSample { position: pos, tangent, normal, pressure, radius, left, right, } } /// Tastet ein einzelnes Segment zwischen `p1` und `p2` ab. pub fn sample_segment( p0: ControlPoint, p1: ControlPoint, p2: ControlPoint, p3: ControlPoint, config: &SplineConfig, ) -> Vec { let samples = config.samples_per_segment.max(1); let mut result = Vec::with_capacity(samples + 1); for i in 0..=samples { let u = i as f32 / samples as f32; result.push(evaluate_catmull_rom_segment(p0, p1, p2, p3, u, config)); } result } /// Wandelt eine Folge von `SplineSample`s in ein abwechselndes Triangle-Strip-Vertex-Array um: /// [left_0, right_0, left_1, right_1, left_2, right_2, ...] pub fn samples_to_triangle_strip(samples: &[SplineSample]) -> Vec { if samples.is_empty() { return Vec::new(); } let mut vertices = Vec::with_capacity(samples.len() * 2); for s in samples { 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)); } vertices } /// Generiert einen zusammenhängenden Triangle-Strip aus einer Liste von Eingabe-Kontrollpunkten. /// /// Handhabt automatisch Randpunkte (Geisterpunkte / Spiegelung an den Enden), /// interpoliert mit zentripetalen Catmull-Rom-Splines und erzeugt ein flüssiges Ribbon-Mesh. pub fn generate_triangle_strip( control_points: &[ControlPoint], config: &SplineConfig, ) -> Vec { match control_points.len() { 0 => Vec::new(), 1 => { // 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 mut vertices = Vec::with_capacity((segments + 1) * 2); for i in 0..=segments { let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius); vertices.push(Vertex::from_vec2(pt.position, pt.pressure, 0.0)); vertices.push(Vertex::from_vec2(pt.position + offset, pt.pressure, 1.0)); } vertices } 2 => { let p0 = control_points[0]; let p1 = control_points[1]; // Geisterpunkte durch lineare Extrapolation let ghost_before = ControlPoint { position: 2.0 * p0.position - p1.position, pressure: p0.pressure, }; let ghost_after = ControlPoint { position: 2.0 * p1.position - p0.position, pressure: p1.pressure, }; let samples = sample_segment(ghost_before, p0, p1, ghost_after, config); samples_to_triangle_strip(&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]; 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, } }; // Für das erste Segment sample von u = 0.0 bis 1.0 // Für nachfolgende Segmente bei u > 0 beginnen, um doppelte Vertices zu vermeiden 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_to_triangle_strip(&samples) } } } /// 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 (offset, &pos) in positions[(start + 1)..end].iter().enumerate() { let dist = point_to_segment_distance(pos, a, b); if dist > max_dist { max_dist = dist; max_idx = start + 1 + offset; } } 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(); let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color); stroke.width = config.min_radius + config.max_radius; stroke.finalized = true; stroke } #[cfg(test)] mod tests { use super::*; #[test] fn test_centripetal_interpolation_endpoints() { let p0 = ControlPoint::new(0.0, 0.0, 0.2); let p1 = ControlPoint::new(10.0, 2.0, 0.4); let p2 = ControlPoint::new(20.0, 8.0, 0.8); let p3 = ControlPoint::new(30.0, 10.0, 1.0); let config = SplineConfig::default(); // Am Start u = 0.0 muss der Spline exakt bei p1 sein let start_sample = evaluate_catmull_rom_segment(p0, p1, p2, p3, 0.0, &config); assert!((start_sample.position.x - p1.position.x).abs() < 1e-4); assert!((start_sample.position.y - p1.position.y).abs() < 1e-4); assert!((start_sample.pressure - p1.pressure).abs() < 1e-4); // Am Ende u = 1.0 muss der Spline exakt bei p2 sein let end_sample = evaluate_catmull_rom_segment(p0, p1, p2, p3, 1.0, &config); assert!((end_sample.position.x - p2.position.x).abs() < 1e-4); assert!((end_sample.position.y - p2.position.y).abs() < 1e-4); assert!((end_sample.pressure - p2.pressure).abs() < 1e-4); } #[test] fn test_tangent_and_normal_orthogonality() { let p0 = ControlPoint::new(0.0, 0.0, 0.5); let p1 = ControlPoint::new(10.0, 10.0, 0.5); let p2 = ControlPoint::new(20.0, 5.0, 0.5); let p3 = ControlPoint::new(30.0, 0.0, 0.5); let config = SplineConfig::default(); let samples = sample_segment(p0, p1, p2, p3, &config); for s in samples { // Tangente und Normale müssen normiert sein (Länge ca. 1.0) assert!((s.tangent.length() - 1.0).abs() < 1e-4); assert!((s.normal.length() - 1.0).abs() < 1e-4); // Skalarprodukt aus Tangente und Normale muss 0 sein (orthogonal) let dot = s.tangent.dot(s.normal); assert!( dot.abs() < 1e-4, "Tangente ({:?}) und Normale ({:?}) müssen orthogonal sein, dot = {}", s.tangent, s.normal, dot ); // Linker und rechter Punkt müssen im Abstand 'radius' von der Mittellinie liegen let dist_left = (s.left - s.position).length(); let dist_right = (s.right - s.position).length(); assert!((dist_left - s.radius).abs() < 1e-4); assert!((dist_right - s.radius).abs() < 1e-4); } } #[test] fn test_pressure_dependent_radius() { let min_r = 1.0; let max_r = 5.0; assert_eq!(calculate_radius(0.0, min_r, max_r), 1.0); assert_eq!(calculate_radius(1.0, min_r, max_r), 5.0); assert_eq!(calculate_radius(0.5, min_r, max_r), 3.0); // Außerhalb des Intervalls geklemmt assert_eq!(calculate_radius(-0.5, min_r, max_r), 1.0); assert_eq!(calculate_radius(1.5, min_r, max_r), 5.0); } #[test] fn test_triangle_strip_generation() { let points = vec![ ControlPoint::new(0.0, 0.0, 0.2), ControlPoint::new(10.0, 10.0, 0.5), ControlPoint::new(20.0, 10.0, 0.8), ControlPoint::new(30.0, 0.0, 1.0), ]; let config = SplineConfig { min_radius: 1.0, max_radius: 4.0, samples_per_segment: 10, }; let strip = generate_triangle_strip(&points, &config); // 3 Segmente, erstes mit 11 Samples, nächste zwei je 10 Samples = 31 Samples // Jedes Sample liefert 2 Vertices (left, right) -> 62 Vertices assert_eq!(strip.len(), 31 * 2); // Vertices müssen abwechselnd side -1.0 und +1.0 haben for (i, v) in strip.iter().enumerate() { assert!(v.position[0].is_finite()); assert!(v.position[1].is_finite()); assert!((0.0..=1.0).contains(&v.pressure)); if i % 2 == 0 { assert_eq!(v.side, -1.0, "Gerader Index muss linker Hüllpunkt sein"); } else { assert_eq!(v.side, 1.0, "Ungerader Index muss rechter Hüllpunkt sein"); } } } #[test] fn test_degenerate_points() { // Zwei identische Punkte dürfen keine Panik, keine Division durch Null und kein NaN erzeugen let points = vec![ ControlPoint::new(5.0, 5.0, 0.5), ControlPoint::new(5.0, 5.0, 0.5), ]; let config = SplineConfig::default(); let strip = generate_triangle_strip(&points, &config); assert!(!strip.is_empty()); for v in strip { assert!(v.position[0].is_finite()); assert!(v.position[1].is_finite()); assert!(v.pressure.is_finite()); 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)); } }