fix: Eingerasteter Textmarker behält Breite (Issue #32), runde Spitzen und ruhiger Strich beim Absetzen (Issue #33), zuverlässigeres Nachgleiten (Issue #28)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:13:09 +02:00
co-authored by Claude Opus 5.5
parent 76968f9e58
commit 6a043802d7
5 changed files with 152 additions and 27 deletions
+5 -1
View File
@@ -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))
+25 -7
View File
@@ -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<dyn std::error::Error>> {
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(&notebook, &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(&notebook, &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<dyn std::error::Error>> {
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;
+64 -3
View File
@@ -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
+7 -1
View File
@@ -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]
+51 -15
View File
@@ -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<std::time::Instant>,
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]