From 6a043802d702fdc74a5913db71fcfe041cea0d16 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 20 Oct 2026 20:13:09 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Eingerasteter=20Textmarker=20beh=C3=A4lt?= =?UTF-8?q?=20Breite=20(Issue=20#32),=20runde=20Spitzen=20und=20ruhiger=20?= =?UTF-8?q?Strich=20beim=20Absetzen=20(Issue=20#33),=20zuverl=C3=A4ssigere?= =?UTF-8?q?s=20Nachgleiten=20(Issue=20#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Halten am Strichende rastet Formen in der Breite des gewählten Werkzeugs ein; ein eingerasteter Textmarker bleibt Textmarker und bildet kein Achsenkreuz - Ecken (Spitze einer „1“, Hochzahlen) werden rund verbunden statt stumpf abgeschnitten - Der Live-Strich nutzt dieselbe Tessellierung wie der fertige Strich, damit er beim Abheben nicht springt - Nachgleiten misst die Geschwindigkeit über die letzten 100 ms statt über einzelne Ereignisse; INK_NOTES_DEBUG_FLING gibt die Messung im Terminal aus Co-Authored-By: Claude Opus 5.5 --- src/canvas.rs | 6 ++++- src/main.rs | 32 +++++++++++++++++------ src/spline.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 8 +++++- src/viewport.rs | 66 +++++++++++++++++++++++++++++++++++++----------- 5 files changed, 152 insertions(+), 27 deletions(-) diff --git a/src/canvas.rs b/src/canvas.rs index 097fe96..1dc951b 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -1324,9 +1324,13 @@ impl CanvasState { return; } + // Gleiche Tessellierung wie beim Absetzen (runde Kappen und Ecken), damit der Strich + // beim Abheben nicht sichtbar springt (Issue #33) let color = self.current_ink_color; + let points = spline::rdp_filter_samples(&self.live_samples, spline::RDP_TOLERANCE_UNITS); + let corners = spline::detect_corners(&points, spline::CORNER_ANGLE_THRESHOLD_DEG); let raw_strip = - spline::generate_triangle_strip(&self.live_control_points, &self.spline_config); + spline::generate_finalized_triangle_strip(&points, &corners, &self.spline_config); self.live_vertices = raw_strip .into_iter() .map(|v| InkVertex::new(v.position[0], v.position[1], color)) diff --git a/src/main.rs b/src/main.rs index dd19fd1..4c86ec7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,9 +326,12 @@ fn commit_stroke_to_notebook( ) { // Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der // vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her - if let Some(shape) = &snapped_shape { + let stroke_width = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM; + if let Some(shape) = snapped_shape + .as_ref() + .filter(|_| ui_state.active_tool != ActiveTool::Highlighter) + { if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) { - let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; canvas.cancel_stroke(); let stroke = geometry::shape_to_stroke( cross, @@ -347,7 +350,6 @@ fn commit_stroke_to_notebook( let new_id = notebook.next_stroke_id(); let stroke_opt = if let Some(shape) = snapped_shape { - let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; let color = canvas.current_ink_color; canvas.cancel_stroke(); Some(geometry::shape_to_stroke( @@ -1931,9 +1933,14 @@ fn main() -> Result<(), Box> { stroke_snapped_shape = Some(shape.clone()); // Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das // Achsenkreuz (übernommen wird es beim Absetzen) - let shape = axis_cross_with_previous(¬ebook, &shape) - .map_or(shape, |(_, cross)| cross); - let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; + // Textmarker bilden kein Achsenkreuz + let shape = if ui_state.active_tool == ActiveTool::Highlighter { + shape + } else { + axis_cross_with_previous(¬ebook, &shape) + .map_or(shape, |(_, cross)| cross) + }; + let width_units = ui_state.current_stroke_width_mm() * ink_notes::document::UNITS_PER_MM; let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color); canvas.set_live_shape_mesh(samples, vertices); ui_state.set_export_message(t!("{shape} eingerastet!", shape = shape.name())); @@ -3019,7 +3026,18 @@ fn main() -> Result<(), Box> { pinch_prev_distance = None; pinch_prev_center = None; if is_touch_panning && touch.phase == TouchPhase::Ended { - fling.release(Instant::now()); + // Letztes Stück bis zum Abheben noch mitnehmen + let (dx, dy) = (tx - touch_pan_last_pos.0, ty - touch_pan_last_pos.1); + canvas.pan(dx, dy); + fling.track(Vec2::new(dx, dy), Instant::now()); + let velocity = fling.release(Instant::now()); + if std::env::var_os("INK_NOTES_DEBUG_FLING").is_some() { + eprintln!( + "[Nachgleiten] {:.0} px/s beim Abheben, gleitet: {}", + velocity.length(), + fling.is_active() + ); + } window.request_redraw(); } is_touch_panning = false; diff --git a/src/spline.rs b/src/spline.rs index 1c4153d..b58182b 100644 --- a/src/spline.rs +++ b/src/spline.rs @@ -601,6 +601,7 @@ pub fn generate_finalized_triangle_strip( // Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken const CAP_STEPS: usize = 10; + const CORNER_STEPS: usize = 24; let mut vertices = Vec::new(); // 3. Runde Start-Kappe vor dem ersten Run @@ -622,12 +623,22 @@ pub fn generate_finalized_triangle_strip( // 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 + // Runde Ecken-Verbindung: Ein voller Kreis um den Knick füllt die Außenseite, sonst + // enden beide Abschnitte stumpf und die Spitze (etwa einer „1“) wirkt abgeschnitten. + // Alle Dreiecke liegen im Kreis, auch die Übergänge zum vorigen und nächsten Run. 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)); + let r = s_corner.radius; + let prev_right = runs[run_idx - 1].last().map_or(s_corner.right, |s| s.right); + let d = prev_right - p_c; + let a0 = d.y.atan2(d.x); + for k in 0..=CORNER_STEPS { + let a = a0 + (k as f32 / CORNER_STEPS as f32) * std::f32::consts::TAU; + let rim = p_c + glam::Vec2::new(a.cos(), a.sin()) * r; + vertices.push(Vertex::new(p_c.x, p_c.y, pr_c, 0.0)); + vertices.push(Vertex::new(rim.x, rim.y, pr_c, 1.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)); } @@ -900,6 +911,56 @@ mod tests { assert!((corner_pt.position.y - 0.0).abs() < 1e-3); } + /// Liegt `p` in einem der Dreiecke des Triangle-Strips? + fn strip_covers(vertices: &[InkVertex], p: glam::Vec2) -> bool { + let pos = |v: &InkVertex| glam::Vec2::new(v.position[0], v.position[1]); + let cross = |a: glam::Vec2, b: glam::Vec2, c: glam::Vec2| (b - a).perp_dot(c - a); + vertices.windows(3).any(|t| { + let (a, b, c) = (pos(&t[0]), pos(&t[1]), pos(&t[2])); + let (d1, d2, d3) = (cross(a, b, p), cross(b, c, p), cross(c, a, p)); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) + }) + } + + #[test] + fn test_sharp_corner_tip_is_not_cut_off() { + // „1“: schräg hinauf bis (100, 0), dann senkrecht hinunter (Issue #33) + let mut raw = Vec::new(); + for i in 0..=20 { + let u = i as f32 / 20.0; + raw.push(StylusSample { + position: glam::Vec2::new(60.0 + 40.0 * u, 80.0 - 80.0 * u), + pressure: 0.5, + timestamp: i as f64 * 0.01, + }); + } + for i in 1..=30 { + raw.push(StylusSample { + position: glam::Vec2::new(100.0, i as f32 * 5.0), + pressure: 0.5, + timestamp: (20 + i) as f64 * 0.01, + }); + } + let config = SplineConfig::from_width_units(10.0); + let stroke = finalize_stroke_with_config(1, &raw, [0.0; 4], &config); + assert_eq!( + detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).len(), + 1 + ); + // Die Spitze reicht rund um den Knick so weit wie die Strichbreite + let r = calculate_radius(0.5, config.min_radius, config.max_radius) * 0.95; + for deg in [-150.0_f32, -120.0, -90.0, -60.0, -30.0, 0.0] { + let a = deg.to_radians(); + let p = glam::Vec2::new(100.0, 0.0) + glam::Vec2::new(a.cos(), a.sin()) * r; + assert!( + strip_covers(&stroke.vertices, p), + "Spitze bei {deg}° abgeschnitten" + ); + } + } + #[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 diff --git a/src/ui.rs b/src/ui.rs index fccf8fa..9d73aec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -60,8 +60,9 @@ impl ActiveTool { /// Werkzeug, das ein mit diesem Werkzeug gezeichneter Strich erhält. pub fn stroke_tool(&self, has_shape: bool) -> StrokeTool { match self { - _ if has_shape => StrokeTool::Shape, + // Ein eingerasteter Textmarker bleibt Textmarker (durchscheinend, eigener Radierer) ActiveTool::Highlighter => StrokeTool::Highlighter, + _ if has_shape => StrokeTool::Shape, _ => StrokeTool::Pen, } } @@ -5806,6 +5807,11 @@ mod tests { // Eingerastete Form (Formwerkzeug oder Halten am Strichende) assert_eq!(ActiveTool::Shape.stroke_tool(true), StrokeTool::Shape); assert_eq!(ActiveTool::Pen.stroke_tool(true), StrokeTool::Shape); + // Eingerasteter Textmarker (Issue #32) + assert_eq!( + ActiveTool::Highlighter.stroke_tool(true), + StrokeTool::Highlighter + ); } #[test] diff --git a/src/viewport.rs b/src/viewport.rs index 68bc7aa..03116b8 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -308,9 +308,11 @@ impl ViewportState { /// Schwung beim Scrollen mit dem Finger (Issue #28): Nach dem Abheben gleitet der /// Ausschnitt mit der zuletzt gewischten Geschwindigkeit weiter und bremst langsam ab. /// Geschwindigkeiten in Bildschirmpixeln je Sekunde. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct Fling { velocity: Vec2, + /// Fingerweg (aufsummiert) der letzten [`Self::WINDOW`] mit Zeitpunkt + trail: std::collections::VecDeque<(std::time::Instant, Vec2)>, /// Zeit der letzten Fingerbewegung bzw. des letzten Gleitschritts last: Option, active: bool, @@ -320,37 +322,53 @@ impl Fling { /// Abbremsen je Sekunde (Anteil der Geschwindigkeit, der nach 1 s bleibt: e^-FRICTION) const FRICTION: f32 = 2.5; /// Langsamer wischen gleitet nicht nach - const MIN_START_SPEED: f32 = 400.0; + const MIN_START_SPEED: f32 = 250.0; /// Darunter hört das Gleiten auf const MIN_SPEED: f32 = 40.0; /// Liegt der Finger vor dem Abheben so lange still, gleitet nichts nach - const MAX_REST: std::time::Duration = std::time::Duration::from_millis(80); + const MAX_REST: std::time::Duration = std::time::Duration::from_millis(120); + /// Die Geschwindigkeit ergibt sich aus dem Weg in dieser Zeit vor dem Abheben. Über + /// mehrere Ereignisse gemessen, weil Touchscreens kurz vor dem Abheben oft nur noch + /// winzige Bewegungen melden und Ereignisse gebündelt ankommen, wenn ein Bild länger + /// zum Zeichnen braucht. + const WINDOW: std::time::Duration = std::time::Duration::from_millis(100); /// Finger hat sich um `delta` verschoben. pub fn track(&mut self, delta: Vec2, now: std::time::Instant) { self.active = false; - let dt = self - .last - .map_or(0.0, |last| now.duration_since(last).as_secs_f32()); - self.last = Some(now); - if dt <= 0.0 { - return; + let pos = self.trail.back().map_or(Vec2::ZERO, |&(_, p)| p) + delta; + self.trail.push_back((now, pos)); + // Einen Punkt vor dem Fenster behalten, damit es ganz abgedeckt ist + while self.trail.len() > 2 && now.duration_since(self.trail[1].0) > Self::WINDOW { + self.trail.pop_front(); + } + if delta != Vec2::ZERO { + self.last = Some(now); } - // Geglättet: einzelne ungleichmäßige Touch-Ereignisse sollen nicht zählen - let sample = delta / dt.max(0.004); - self.velocity = self.velocity.lerp(sample, 0.4); } - /// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde. - pub fn release(&mut self, now: std::time::Instant) { + /// Finger abgehoben: gleitet weiter, wenn zuletzt schnell genug gewischt wurde. Gibt die + /// gemessene Geschwindigkeit zurück (für die Fehlersuche). + pub fn release(&mut self, now: std::time::Instant) -> Vec2 { let resting = self .last .is_none_or(|last| now.duration_since(last) > Self::MAX_REST); - self.active = !resting && self.velocity.length() >= Self::MIN_START_SPEED; + self.velocity = match (self.trail.front(), self.trail.back()) { + (Some(&(t0, p0)), Some(&(_, p1))) => { + // Bis zum Abheben gemessen; gebündelte Ereignisse zählen mindestens 10 ms + let dt = now.duration_since(t0).as_secs_f32().max(0.01); + (p1 - p0) / dt + } + _ => Vec2::ZERO, + }; + self.trail.clear(); + let velocity = self.velocity; + self.active = !resting && velocity.length() >= Self::MIN_START_SPEED; self.last = Some(now); if !self.active { self.stop(); } + velocity } /// Hält an (neue Berührung, Mausklick, Mausrad). @@ -432,6 +450,24 @@ mod tests { } rested.release(ms(400)); assert!(!rested.is_active()); + + // Touchscreens melden kurz vor dem Abheben oft nur noch winzige Bewegungen + let mut lifted = Fling::default(); + for i in 0..=8 { + lifted.track(Vec2::new(0.0, -30.0), ms(i * 10)); + } + lifted.track(Vec2::new(0.0, -1.0), ms(90)); + lifted.track(Vec2::new(0.0, 0.0), ms(100)); + lifted.release(ms(104)); + assert!(lifted.is_active()); + + // Gebündelt ankommende Ereignisse (ein Bild brauchte 50 ms) + let mut batched = Fling::default(); + for t in [0, 50, 50, 50, 50, 100, 100, 100, 100] { + batched.track(Vec2::new(0.0, -20.0), ms(t)); + } + batched.release(ms(100)); + assert!(batched.is_active()); } #[test]