Compare commits

...
3 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 b5113cefa2 chore(release): bump version to 0.3.4
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m14s
Formerkennung: Rechtecke statt Kreise, Freiformen bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:21:00 +02:00
haraldandClaude Opus 5.5 88ea7d2d53 fix(geometry): Freiformen bleiben unverändert, Vielecke werden erkannt
- Eine erkannte Form ersetzt den Strich nur noch, wenn sie ihn gut beschreibt
  (mittlere Abweichung ≤ 4 % der Strichdiagonale, gemessen gegen den
  Linienzug der Form). Vorher wurde fast jeder geschlossene Strich umgewandelt
  (Herz → Kreis, Stern/Spirale → Oktaeder, Wolke → Dreieck).
- Vielecke verlangen echte Ecken: Die Richtungsänderung muss sich an den Ecken
  konzentrieren; glatte Ovale werden so nicht mehr zu Sechs-/Achtecken.
- Vielecke konkurrieren mit Kreis, Dreieck, Rechteck und Raute (gewichtet:
  Rechteck vor Raute/Vieleck, Vieleck vor Kreis); überzählige Punkte der
  Vereinfachung (Strichende vor dem Start, Punkte auf einer Geraden) entfallen.
- Tests: Freiformen bleiben unverändert, Fünf-/Sechs-/Achtecke, Ovale;
  jeder Einzeldetektor-Test prüft zusätzlich die vollständige Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:19:57 +02:00
haraldandClaude Opus 5.5 7345c00568 fix(geometry): Rechtecke und Quadrate werden nicht mehr als Kreis erkannt
- Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird die Form,
  deren Kontur am besten zu den Punkten passt (RMS-Abweichung). Vorher gewann
  der Kreis immer, weil auch Quadrate nur ~10 % Radiusschwankung haben, und
  die Dreieckserkennung griff gestreckte Rechtecke ab.
- Rechteckerkennung über das kleinste umschließende Rechteck (alle Drehwinkel,
  Ausreißer-robust), Kanten auf den Median der Punkte; verlangt gezeichnete
  Ecken und enge Passung, damit Ovale und Kritzeleien keine Rechtecke werden.
  Die alte Variante schnitt bei kleinen/zittrigen Strichen die Ecken ab.
- extract_k_corners wählte die geradesten statt der markantesten Punkte
  (Sortierung verkehrt herum); betrifft auch Dreieck und Raute.
- Rechteck wird der Raute vorgezogen, wenn es fast so gut passt.
- Oktaeder nur noch, wenn keine geschlossene Form gut passt.
- Tests mit nachgebildeten Freihandstrichen: Rechtecke in vielen Größen,
  Seitenverhältnissen und Drehungen, mit runden Ecken, schiefen Kanten und
  Überstrich; Kreise, Dreiecke; Ellipsen und Freiformen nie als Rechteck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:05:37 +02:00
4 changed files with 764 additions and 101 deletions
Generated
+1 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.3.3" version = "0.3.4"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"egui", "egui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.3.3" version = "0.3.4"
edition = "2021" edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux" description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
+1 -1
View File
@@ -10,7 +10,7 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung - **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus - **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation) - **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold) - **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups - **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern - **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS - **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
+759 -96
View File
@@ -409,7 +409,13 @@ pub fn detect_circle(points: &[Vec2]) -> Option<RecognizedShape> {
} }
} }
/// 3. Rechteckerkennung: Geschlossener Pfad mit 4 dominanten Ecken (~90° +/- 18°). /// 3. Rechteckerkennung: kleinstes umschließendes Rechteck eines geschlossenen Pfads.
///
/// Statt Ecken über eine grobe Linienvereinfachung zu suchen (das schnitt bei kleinen oder
/// zittrigen Strichen die Ecken ab), wird über alle Drehwinkel das Rechteck mit der
/// kleinsten Fläche gesucht. Einzelne Ausreißer (z. B. ein Überstrich am Schluss) zählen
/// nicht: Die Ausdehnung wird über das 2.–98. Perzentil der Punkte bestimmt. Angenommen
/// wird das Rechteck nur, wenn es den Strich eng beschreibt.
pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> { pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 8 { if points.len() < 8 {
return None; return None;
@@ -429,78 +435,99 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
return None; return None;
} }
// RDP mit ca. 5% Pfadlänge als Toleranz // Ausdehnung entlang einer Achse (Winkel in Grad), robust gegen Ausreißer
let epsilon = (total_len * 0.05).clamp(8.0, 45.0); let extent = |angle_deg: f32| -> (f32, f32, f32, f32) {
let simplified = ramer_douglas_peucker(points, epsilon); let (sin, cos) = angle_deg.to_radians().sin_cos();
let mut along: Vec<f32> = points.iter().map(|p| p.x * cos + p.y * sin).collect();
let corners: Vec<Vec2> = let mut across: Vec<f32> = points.iter().map(|p| -p.x * sin + p.y * cos).collect();
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 { along.sort_by(f32::total_cmp);
simplified[0..4].to_vec() across.sort_by(f32::total_cmp);
} else if simplified.len() == 4 { let lo = points.len() * 2 / 100;
simplified let hi = points.len() - 1 - lo;
} else if simplified.len() > 4 { (along[lo], along[hi], across[lo], across[hi])
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung) };
extract_k_corners(&simplified, 4)? let area = |angle_deg: f32| {
} else { let (a0, a1, b0, b1) = extent(angle_deg);
return None; (a1 - a0) * (b1 - b0)
}; };
if corners.len() != 4 { // Grobsuche in 1°-Schritten, dann Feinsuche in 0,1°-Schritten um das Optimum
return None; let mut best_angle = (0..90)
.map(|d| d as f32)
.min_by(|a, b| area(*a).total_cmp(&area(*b)))
.unwrap_or(0.0);
best_angle = (-10..=10)
.map(|d| best_angle + d as f32 * 0.1)
.min_by(|a, b| area(*a).total_cmp(&area(*b)))
.unwrap_or(best_angle);
// Nahe an der Achsenparallelität (≤ 10°) gerade ausrichten
let rem = best_angle.rem_euclid(90.0);
if rem <= RECTANGLE_AXIS_SNAP_DEG || rem >= 90.0 - RECTANGLE_AXIS_SNAP_DEG {
best_angle = 0.0;
} }
// Innenwinkel prüfen: 4 Kanten müssen jeweils ~90° (+/- 18°) zueinander stehen let (mut a0, mut a1, mut b0, mut b1) = extent(best_angle);
let c = [corners[0], corners[1], corners[2], corners[3]]; let (sin, cos) = best_angle.to_radians().sin_cos();
for i in 0..4 { let dir = Vec2::new(cos, sin);
let prev = c[(i + 3) % 4]; let norm = Vec2::new(-sin, cos);
let curr = c[i];
let next = c[(i + 1) % 4]; // Kanten in die Mitte des Zitterns legen: Jeder Punkt gehört zur nächsten Kante, die Kante
let v1 = (prev - curr).normalize_or_zero(); // wandert auf den Median ihrer Punkte (das umschließende Rechteck liegt außen an den Spitzen)
let v2 = (next - curr).normalize_or_zero(); let mut sides: [Vec<f32>; 4] = Default::default();
let dot = v1.dot(v2).clamp(-1.0, 1.0); for p in points {
let angle_deg = dot.acos().to_degrees(); let (u, v) = (p.dot(dir), p.dot(norm));
if !(72.0..=108.0).contains(&angle_deg) { let d = [
return None; (u - a0).abs(),
(u - a1).abs(),
(v - b0).abs(),
(v - b1).abs(),
];
let side = (0..4).min_by(|&i, &j| d[i].total_cmp(&d[j])).unwrap_or(0);
sides[side].push(if side < 2 { u } else { v });
} }
let median = |values: &mut Vec<f32>, fallback: f32| {
if values.is_empty() {
return fallback;
} }
values.sort_by(f32::total_cmp);
// Saubere rechtwinklige Ausrichtung values[values.len() / 2]
let aabb = Aabb::from_points(&c);
let center = (c[0] + c[1] + c[2] + c[3]) * 0.25;
let e0 = c[1] - c[0];
let angle_deg = e0.y.atan2(e0.x).to_degrees().abs();
// Nahe an Achsenparallelität (< 12°)?
let snapped_corners = if angle_deg < 12.0
|| (angle_deg - 90.0).abs() < 12.0
|| (angle_deg - 180.0).abs() < 12.0
{
[
Vec2::new(aabb.min.x, aabb.min.y),
Vec2::new(aabb.max.x, aabb.min.y),
Vec2::new(aabb.max.x, aabb.max.y),
Vec2::new(aabb.min.x, aabb.max.y),
]
} else {
// Rotiertes Rechteck mit exakt 90° Kanten
let dir = e0.normalize_or_zero();
let norm = Vec2::new(-dir.y, dir.x);
let w = 0.5 * ((c[1] - c[0]).length() + (c[2] - c[3]).length());
let h = 0.5 * ((c[2] - c[1]).length() + (c[3] - c[0]).length());
let hw = w * 0.5;
let hh = h * 0.5;
[
center - dir * hw - norm * hh,
center + dir * hw - norm * hh,
center + dir * hw + norm * hh,
center - dir * hw + norm * hh,
]
}; };
a0 = median(&mut sides[0], a0);
a1 = median(&mut sides[1], a1);
b0 = median(&mut sides[2], b0);
b1 = median(&mut sides[3], b1);
Some(RecognizedShape::Rectangle { let corners = [
corners: snapped_corners, dir * a0 + norm * b0,
}) dir * a1 + norm * b0,
dir * a1 + norm * b1,
dir * a0 + norm * b1,
];
// Alle vier Ecken müssen gezeichnet sein: Ovale und Kreise schneiden die Ecken ab
// (Kreis: ~20 % der Seitenlänge), sonst würden flache Ellipsen zu Rechtecken
let shorter_side = (a1 - a0).abs().min((b1 - b0).abs());
let corner_reached = |corner: Vec2| {
points
.iter()
.map(|p| (*p - corner).length())
.fold(f32::INFINITY, f32::min)
<= RECTANGLE_CORNER_REACH * shorter_side
};
if !corners.iter().all(|c| corner_reached(*c)) {
return None;
}
// Nur annehmen, wenn das Rechteck den Strich eng beschreibt (sonst wären beliebige
// geschlossene Kritzeleien „Rechtecke“)
let rect = RecognizedShape::Rectangle { corners };
let err = contour_rms(points, &rect)?;
let diagonal = (corners[2] - corners[0]).length();
if err > RECTANGLE_MAX_RMS * diagonal {
return None;
}
Some(rect)
} }
/// Rastet einen Winkel in Radiant an horizontalen, vertikalen oder diagonalen Achsen ein. /// Rastet einen Winkel in Radiant an horizontalen, vertikalen oder diagonalen Achsen ein.
@@ -543,12 +570,15 @@ pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option<Vec<Vec2>> {
let next = points[(i + 1) % n]; let next = points[(i + 1) % n];
let v1 = (prev - curr).normalize_or_zero(); let v1 = (prev - curr).normalize_or_zero();
let v2 = (next - curr).normalize_or_zero(); let v2 = (next - curr).normalize_or_zero();
// Dot product: je kleiner, desto spitzer/schärfer die Richtungsänderung // Skalarprodukt der Richtungen zu den Nachbarn: −1 = gerader Verlauf (180°),
// 0 = rechter Winkel, gegen +1 = spitze Ecke. Je größer, desto markanter die Ecke.
let dot = v1.dot(v2).clamp(-1.0, 1.0); let dot = v1.dot(v2).clamp(-1.0, 1.0);
scored.push((i, dot)); scored.push((i, dot));
} }
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); // Markanteste Ecken zuerst (größtes Skalarprodukt). Vorher war die Sortierung
// umgekehrt und wählte die geradesten Punkte.
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect(); let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
selected_indices.sort_unstable(); selected_indices.sort_unstable();
@@ -844,6 +874,11 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
} }
let corners = simplified; let corners = simplified;
// Echte Ecken verlangen: Bei einem glatten Oval verteilt sich die Richtungsänderung
// gleichmäßig, die Vereinfachung liefert aber trotzdem 6 oder 8 Punkte
if !turning_concentrated_at_corners(points, &corners) {
return None;
}
let center = corners.iter().copied().sum::<Vec2>() / (n as f32); let center = corners.iter().copied().sum::<Vec2>() / (n as f32);
let radii: Vec<f32> = corners.iter().map(|&pt| (pt - center).length()).collect(); let radii: Vec<f32> = corners.iter().map(|&pt| (pt - center).length()).collect();
@@ -877,15 +912,93 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
}) })
} }
/// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss.
const POLYGON_TURNING_AT_CORNERS: f32 = 0.7;
/// Umgebung einer Ecke als Anteil des Umfangs.
const POLYGON_CORNER_NEIGHBORHOOD: f32 = 0.03;
/// Liegt die Richtungsänderung des Strichs überwiegend an den Ecken (Vieleck) und nicht
/// gleichmäßig verteilt (Oval)? Gemessen wird die vorzeichenbehaftete Drehung entlang des
/// gleichmäßig neu abgetasteten Strichs; Zittern hebt sich dabei weitgehend auf.
fn turning_concentrated_at_corners(points: &[Vec2], corners: &[Vec2]) -> bool {
corner_turning_ratio(points, corners).is_some_and(|r| r >= POLYGON_TURNING_AT_CORNERS)
}
/// Anteil der vorzeichenbehafteten Richtungsänderung, der in der Nähe der Ecken liegt.
fn corner_turning_ratio(points: &[Vec2], corners: &[Vec2]) -> Option<f32> {
let total_len = path_length(points);
if total_len < 1e-3 || corners.is_empty() {
return None;
}
// Gleichmäßig neu abtasten (120 Punkte entlang der Bogenlänge)
const SAMPLES: usize = 120;
let step = total_len / SAMPLES as f32;
let mut resampled = vec![points[0]];
let mut carried = 0.0;
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
let seg = (b - a).length();
let mut pos = step - carried;
while pos <= seg {
resampled.push(a + (b - a) * (pos / seg));
pos += step;
}
carried = seg - (pos - step);
}
// Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten
const W: usize = 3;
if resampled.len() < 2 * W + 2 {
return None;
}
let near = POLYGON_CORNER_NEIGHBORHOOD * total_len;
let (mut turn_total, mut turn_near) = (0.0_f32, 0.0_f32);
for i in W..resampled.len() - W {
let d_in = resampled[i] - resampled[i - W];
let d_out = resampled[i + W] - resampled[i];
let turn = d_in.perp_dot(d_out).atan2(d_in.dot(d_out)) / W as f32;
turn_total += turn;
if corners.iter().any(|c| (*c - resampled[i]).length() <= near) {
turn_near += turn;
}
}
(turn_total.abs() > 1e-3).then(|| turn_near / turn_total)
}
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> { fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
let simplified = ramer_douglas_peucker(points, epsilon); let simplified = ramer_douglas_peucker(points, epsilon);
if simplified.len() >= 2 let mut vertices = if simplified.len() >= 2
&& (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0 && (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0
{ {
simplified[0..simplified.len() - 1].to_vec() simplified[0..simplified.len() - 1].to_vec()
} else { } else {
simplified simplified
};
// Überzählige Punkte entfernen: zu dicht am Nachbarn (z. B. das Strichende kurz vor dem
// Start) oder fast auf einer Geraden mit den Nachbarn (< 20° Richtungsänderung)
loop {
let n = vertices.len();
if n <= 3 {
break;
} }
let redundant = (0..n).find(|&i| {
let (prev, curr, next) = (
vertices[(i + n - 1) % n],
vertices[i],
vertices[(i + 1) % n],
);
let d_in = curr - prev;
let d_out = next - curr;
let turn = d_in.perp_dot(d_out).atan2(d_in.dot(d_out)).abs();
(next - curr).length() < epsilon || turn < 20f32.to_radians()
});
match redundant {
Some(i) => {
vertices.remove(i);
}
None => break,
}
}
vertices
} }
/// 7. Oktaeder-Erkennung (3D-Kantenmodell / Doppelpyramide). /// 7. Oktaeder-Erkennung (3D-Kantenmodell / Doppelpyramide).
@@ -1035,37 +1148,171 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
} }
let pts: Vec<Vec2> = points.iter().map(|s| s.position).collect(); let pts: Vec<Vec2> = points.iter().map(|s| s.position).collect();
// 1. Zuerst 3D-Kantenmodelle prüfen (Oktaeder / Doppelpyramide) // 1. Geschlossene Formen: Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird
if let Some(octa) = detect_octahedron(&pts) { // die Form, deren Kontur am besten zu den gezeichneten Punkten passt. Eine feste
return Some(octa); // Reihenfolge genügt nicht: Auch Quadrate schwanken nur um ~10 % im Abstand zum
} // Mittelpunkt und bestünden den Kreistest, und die Dreieckserkennung akzeptiert
// gestreckte Rechtecke.
let best_closed = best_closed_shape(&pts);
// 2. Geschlossene Formen prüfen (Kreis, Dreieck, Rechteck, Raute, Polygon/Oktagon) // 2. 3D-Kantenmodelle (Oktaeder / Doppelpyramide) nur, wenn keine geschlossene Form den
if let Some(circle) = detect_circle(&pts) { // Strich gut beschreibt: Ein Oktaeder enthält innere Kanten, die weit von jedem Umriss
return Some(circle); // entfernt liegen. Ein sauber passender Umriss ist daher kein Oktaeder.
} let closed_fits_well = best_closed.as_ref().is_some_and(|(err, _)| {
if let Some(triangle) = detect_triangle(&pts) { let aabb = Aabb::from_points(&pts);
return Some(triangle); *err <= CLOSED_SHAPE_GOOD_FIT * (aabb.max - aabb.min).length()
} });
if let Some(rect) = detect_rectangle(&pts) { let best_closed = best_closed.map(|(_, shape)| shape);
return Some(rect);
}
if let Some(rhombus) = detect_rhombus(&pts) {
return Some(rhombus);
}
if let Some(polygon) = detect_polygon(&pts) {
return Some(polygon);
}
// 3. Offene Formen: Koordinatensystem oder Gerade
if let Some(coord) = detect_coordinate_system(&pts) {
return Some(coord);
}
if let Some(line) = detect_line(&pts) {
return Some(line);
}
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
&|| {
if closed_fits_well {
None None
} else {
detect_octahedron(&pts)
}
},
&|| best_closed.clone(),
&|| detect_polygon(&pts),
&|| detect_coordinate_system(&pts),
&|| detect_line(&pts),
&|| None,
];
candidates
.iter()
.filter_map(|candidate| candidate())
.find(|shape| shape_describes_stroke(&pts, shape))
}
/// Größte zulässige mittlere Abweichung (RMS) des Strichs von der erkannten Form, als Anteil
/// der Strichdiagonale. Echte Formen liegen bei 0,5–3 %, Freiformen (Herz, Stern, Wolke,
/// Spirale) und flache Ellipsen bei 5–6,5 %.
const SHAPE_MAX_FIT_ERROR: f32 = 0.04;
/// Mittlere Abweichung (RMS) des Strichs von einer Form, relativ zur Strichdiagonale.
/// Gemessen wird gegen den Linienzug der Punkte, aus denen die Form gezeichnet wird; das
/// funktioniert für alle Formtypen gleich (auch Linien, Koordinatensysteme und Oktaeder).
pub fn shape_fit_error(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
if points.is_empty() {
return None;
}
let (model, _) = shape_to_mesh(shape, 1.0, [0.0; 4]);
if model.is_empty() {
return None;
}
let aabb = Aabb::from_points(points);
let diagonal = (aabb.max - aabb.min).length();
if diagonal < 1e-3 {
return None;
}
// Abstand zum Linienzug der Modellpunkte (nicht zu den Punkten selbst: manche Formen,
// z. B. das Koordinatensystem, liefern nur ihre Eckpunkte)
let model: Vec<Vec2> = model.iter().map(|m| m.position).collect();
let sum_sq: f32 = points
.iter()
.map(|p| {
let d = if model.len() == 1 {
(model[0] - *p).length()
} else {
model
.windows(2)
.map(|w| segment_distance(*p, w[0], w[1]))
.fold(f32::INFINITY, f32::min)
};
d * d
})
.sum();
Some((sum_sq / points.len() as f32).sqrt() / diagonal)
}
/// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen?
fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool {
shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR)
}
/// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale.
const RECTANGLE_MAX_RMS: f32 = 0.05;
/// Jede Rechteckecke muss bis auf diesen Anteil der kürzeren Seite gezeichnet sein.
const RECTANGLE_CORNER_REACH: f32 = 0.15;
/// Rechtecke, die höchstens so viele Grad schief sind, werden achsenparallel ausgerichtet.
const RECTANGLE_AXIS_SNAP_DEG: f32 = 10.0;
/// Anteil der Diagonale, bis zu dem die Abweichung einer geschlossenen Form als „passt gut“ gilt.
const CLOSED_SHAPE_GOOD_FIT: f32 = 0.04;
/// Gewichte der Abweichung im Formvergleich (kleiner = bevorzugt). Ein Rechteck gewinnt gegen
/// Raute und Vieleck bis zur 1,5-fachen Abweichung (sonst würden Quadrate oft zur Raute);
/// ein Vieleck gewinnt entsprechend gegen den Kreis.
const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5;
const CIRCLE_WEIGHT: f32 = 1.5;
/// Wählt unter Kreis, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
/// und liefert sie mit ihrer Abweichung (RMS).
fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
// Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht.
// Rechte Winkel schlagen die freiere Raute bzw. ein freies Vieleck; ein Vieleck mit
// nachgewiesenen Ecken schlägt den Kreis (ein Achteck ist fast ein Kreis).
let fit = |shape: Option<RecognizedShape>, weight: f32| {
shape.and_then(|s| contour_rms(pts, &s).map(|err| (err * weight, s)))
};
[
fit(detect_circle(pts), CIRCLE_WEIGHT),
fit(detect_triangle(pts), 1.0),
fit(detect_rectangle(pts), RECTANGLE_WEIGHT),
fit(detect_rhombus(pts), 1.0),
fit(detect_polygon(pts), 1.0),
]
.into_iter()
.flatten()
.min_by(|a, b| a.0.total_cmp(&b.0))
}
/// Abstand eines Punktes zur Strecke `a`–`b`.
fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
let ab = b - a;
let len_sq = ab.length_squared();
if len_sq < 1e-9 {
return (p - a).length();
}
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
(p - (a + ab * t)).length()
}
/// Abstand eines Punktes zum Rand eines geschlossenen Polygons.
fn polygon_distance(p: Vec2, corners: &[Vec2]) -> f32 {
(0..corners.len())
.map(|i| segment_distance(p, corners[i], corners[(i + 1) % corners.len()]))
.fold(f32::INFINITY, f32::min)
}
/// Mittlere quadratische Abweichung (RMS) der Strichpunkte von der Kontur einer erkannten
/// geschlossenen Form. Je kleiner, desto besser beschreibt die Form den Strich.
/// Für offene Formen (Linie, Koordinatensystem, Oktaeder) gibt es keinen Wert.
pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
if points.is_empty() {
return None;
}
let distance = |p: Vec2| -> Option<f32> {
match shape {
RecognizedShape::Circle { center, radius } => {
Some(((p - *center).length() - radius).abs())
}
RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)),
RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => {
Some(polygon_distance(p, corners))
}
RecognizedShape::Polygon { vertices } => Some(polygon_distance(p, vertices)),
_ => None,
}
};
let mut sum_sq = 0.0;
for &p in points {
let d = distance(p)?;
sum_sq += d * d;
}
Some((sum_sq / points.len() as f32).sqrt())
} }
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck). /// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
@@ -1435,6 +1682,11 @@ mod tests {
} }
let detected = detect_line(&points); let detected = detect_line(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!(detected.is_some(), "Gerade sollte erkannt werden"); assert!(detected.is_some(), "Gerade sollte erkannt werden");
if let Some(RecognizedShape::Line { start, end }) = detected { if let Some(RecognizedShape::Line { start, end }) = detected {
@@ -1472,6 +1724,11 @@ mod tests {
points.push(points[0]); points.push(points[0]);
let detected = detect_rectangle(&points); let detected = detect_rectangle(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!(detected.is_some(), "Rechteck sollte erkannt werden"); assert!(detected.is_some(), "Rechteck sollte erkannt werden");
if let Some(RecognizedShape::Rectangle { corners }) = detected { if let Some(RecognizedShape::Rectangle { corners }) = detected {
@@ -1508,6 +1765,11 @@ mod tests {
} }
let detected = detect_circle(&points); let detected = detect_circle(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!(detected.is_some(), "Kreis sollte erkannt werden"); assert!(detected.is_some(), "Kreis sollte erkannt werden");
if let Some(RecognizedShape::Circle { if let Some(RecognizedShape::Circle {
@@ -1543,6 +1805,11 @@ mod tests {
} }
let detected = detect_coordinate_system(&points); let detected = detect_coordinate_system(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!( assert!(
detected.is_some(), detected.is_some(),
"Koordinatensystem sollte erkannt werden" "Koordinatensystem sollte erkannt werden"
@@ -1594,6 +1861,11 @@ mod tests {
points.push(a); points.push(a);
let detected = detect_triangle(&points); let detected = detect_triangle(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!(detected.is_some(), "Dreieck sollte erkannt werden"); assert!(detected.is_some(), "Dreieck sollte erkannt werden");
if let Some(RecognizedShape::Triangle { corners }) = detected { if let Some(RecognizedShape::Triangle { corners }) = detected {
@@ -1639,6 +1911,11 @@ mod tests {
points.push(c0); points.push(c0);
let detected = detect_rhombus(&points); let detected = detect_rhombus(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!(detected.is_some(), "Raute sollte erkannt werden"); assert!(detected.is_some(), "Raute sollte erkannt werden");
if let Some(RecognizedShape::Rhombus { corners }) = detected { if let Some(RecognizedShape::Rhombus { corners }) = detected {
@@ -1680,6 +1957,11 @@ mod tests {
points.push(points[0]); points.push(points[0]);
let detected = detect_polygon(&points); let detected = detect_polygon(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!( assert!(
detected.is_some(), detected.is_some(),
"Oktagon / Achteck sollte erkannt werden" "Oktagon / Achteck sollte erkannt werden"
@@ -1719,6 +2001,11 @@ mod tests {
} }
let detected = detect_octahedron(&points); let detected = detect_octahedron(&points);
assert_eq!(
kind(&detect_shape(&to_samples(&points))),
kind(&detected),
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
);
assert!(detected.is_some(), "Oktaeder sollte erkannt werden"); assert!(detected.is_some(), "Oktaeder sollte erkannt werden");
if let Some(RecognizedShape::Octahedron { if let Some(RecognizedShape::Octahedron {
@@ -1737,4 +2024,380 @@ mod tests {
panic!("Falscher Formtyp erkannt"); panic!("Falscher Formtyp erkannt");
} }
} }
/// Bildet einen Freihandstrich entlang eines Umrisses nach: Punkte alle ~6 Einheiten
/// (0,6 mm), seitliches Zittern um ±4 Einheiten und ein nicht ganz exakter Schluss.
fn freehand(outline: &[Vec2], closed: bool, seed: f32) -> Vec<StylusSample> {
let mut path = outline.to_vec();
if closed {
path.push(outline[0]);
}
let mut samples = Vec::new();
let mut i = 0usize;
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
let len = (b - a).length();
let dir = (b - a).normalize_or_zero();
let normal = Vec2::new(-dir.y, dir.x);
let steps = (len / 6.0).ceil().max(1.0) as usize;
for k in 0..steps {
let t = k as f32 / steps as f32;
let jitter =
(i as f32 * 0.9 + seed).sin() * 3.0 + (i as f32 * 2.3 + seed).cos() * 1.0;
samples.push(StylusSample {
position: a + (b - a) * t + normal * jitter,
pressure: 0.5,
timestamp: i as f64 * 0.008,
});
i += 1;
}
}
if closed {
// Strich endet knapp vor dem Startpunkt (2 % des Umfangs)
let keep = samples.len() - samples.len() / 50;
samples.truncate(keep);
}
samples
}
fn rect_outline(center: Vec2, w: f32, h: f32, angle_deg: f32) -> Vec<Vec2> {
let (s, c) = angle_deg.to_radians().sin_cos();
let rot = |v: Vec2| center + Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c);
vec![
rot(Vec2::new(-w / 2.0, -h / 2.0)),
rot(Vec2::new(w / 2.0, -h / 2.0)),
rot(Vec2::new(w / 2.0, h / 2.0)),
rot(Vec2::new(-w / 2.0, h / 2.0)),
]
}
fn circle_outline(center: Vec2, rx: f32, ry: f32) -> Vec<Vec2> {
(0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
center + Vec2::new(a.cos() * rx, a.sin() * ry)
})
.collect()
}
fn kind(shape: &Option<RecognizedShape>) -> &'static str {
match shape {
Some(RecognizedShape::Line { .. }) => "Linie",
Some(RecognizedShape::Circle { .. }) => "Kreis",
Some(RecognizedShape::Rectangle { .. }) => "Rechteck",
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
Some(RecognizedShape::Rhombus { .. }) => "Raute",
Some(RecognizedShape::Polygon { .. }) => "Polygon",
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
None => "nichts",
}
}
#[test]
fn test_detect_shape_freehand_classification() {
let c = Vec2::new(1000.0, 1400.0);
let cases: Vec<(&str, Vec<StylusSample>, &str)> = vec![
(
"Quadrat 40 mm",
freehand(&rect_outline(c, 400.0, 400.0, 0.0), true, 0.3),
"Rechteck",
),
(
"Quadrat 15 mm",
freehand(&rect_outline(c, 150.0, 150.0, 0.0), true, 1.1),
"Rechteck",
),
(
"Quadrat 60 mm",
freehand(&rect_outline(c, 600.0, 600.0, 0.0), true, 2.0),
"Rechteck",
),
(
"Rechteck 60×30 mm",
freehand(&rect_outline(c, 600.0, 300.0, 0.0), true, 0.7),
"Rechteck",
),
(
"Rechteck 40×30 mm",
freehand(&rect_outline(c, 400.0, 300.0, 0.0), true, 1.7),
"Rechteck",
),
(
"Quadrat gedreht 20°",
freehand(&rect_outline(c, 400.0, 400.0, 20.0), true, 0.5),
"Rechteck",
),
(
"Kreis r = 20 mm",
freehand(&circle_outline(c, 200.0, 200.0), true, 0.9),
"Kreis",
),
(
"Kreis r = 8 mm",
freehand(&circle_outline(c, 80.0, 80.0), true, 1.3),
"Kreis",
),
(
"Kreis r = 35 mm",
freehand(&circle_outline(c, 350.0, 350.0), true, 2.4),
"Kreis",
),
(
"Dreieck",
freehand(
&[
c + Vec2::new(0.0, -250.0),
c + Vec2::new(230.0, 170.0),
c + Vec2::new(-230.0, 170.0),
],
true,
0.4,
),
"Dreieck",
),
(
"Linie",
freehand(&[c, c + Vec2::new(500.0, 120.0)], false, 0.2),
"Linie",
),
];
let mut wrong = Vec::new();
for (name, samples, expected) in &cases {
let got = kind(&detect_shape(samples));
if got != *expected {
wrong.push(format!("{name}: erwartet {expected}, erkannt {got}"));
}
}
assert!(wrong.is_empty(), "Falsch erkannt:\n{}", wrong.join("\n"));
}
/// Erkennt Rechtecke, Kreise, Dreiecke und Vielecke in vielen Größen, Seitenverhältnissen,
/// Drehungen und Zittermustern; Freiformen bleiben unverändert, flache Ellipsen werden nie
/// zu Rechtecken.
#[test]
fn test_detect_shape_robustness() {
let c = Vec2::new(1000.0, 1400.0);
let mut stats: std::collections::BTreeMap<String, std::collections::BTreeMap<&str, usize>> =
Default::default();
let mut add = |group: String, got: &'static str| {
*stats.entry(group).or_default().entry(got).or_default() += 1;
};
let seeds = [0.1_f32, 0.6, 1.2, 1.9, 2.7, 3.4, 4.2, 5.0];
for &size in &[150.0_f32, 250.0, 400.0, 600.0] {
for &aspect in &[1.0_f32, 0.75, 0.5] {
for &angle in &[0.0_f32, 15.0, 30.0, 45.0] {
for &seed in &seeds {
let pts =
freehand(&rect_outline(c, size, size * aspect, angle), true, seed);
add(format!("Rechteck {aspect}"), kind(&detect_shape(&pts)));
}
}
}
}
for &r in &[60.0_f32, 120.0, 200.0, 350.0] {
for &ry in &[1.0_f32, 0.8, 0.6] {
for &seed in &seeds {
let pts = freehand(&circle_outline(c, r, r * ry), true, seed);
add(format!("Kreis/Ellipse {ry}"), kind(&detect_shape(&pts)));
}
}
}
for &size in &[150.0_f32, 300.0, 500.0] {
for &seed in &seeds {
let tri = [
c + Vec2::new(0.0, -size),
c + Vec2::new(size * 0.9, size * 0.7),
c + Vec2::new(-size * 0.9, size * 0.7),
];
add(
"Dreieck".to_string(),
kind(&detect_shape(&freehand(&tri, true, seed))),
);
}
}
// Realistischere Rechtecke: abgerundete Ecken, schiefe Kanten, Überstrich am Schluss
for &size in &[150.0_f32, 300.0, 500.0] {
for &aspect in &[1.0_f32, 0.6] {
for &seed in &seeds {
let (w, h) = (size, size * aspect);
// Abgerundete Ecken (Radius 10 % der kürzeren Seite)
let r = 0.1 * w.min(h);
let mut rounded = Vec::new();
for (cx, cy, a0) in [
(w / 2.0 - r, -h / 2.0 + r, -90.0_f32),
(w / 2.0 - r, h / 2.0 - r, 0.0),
(-w / 2.0 + r, h / 2.0 - r, 90.0),
(-w / 2.0 + r, -h / 2.0 + r, 180.0),
] {
for k in 0..=6 {
let a = (a0 + k as f32 * 15.0).to_radians();
rounded.push(c + Vec2::new(cx + a.cos() * r, cy + a.sin() * r));
}
}
add(
format!("Rechteck rund {aspect}"),
kind(&detect_shape(&freehand(&rounded, true, seed))),
);
// Schiefe Kanten: Ecken um bis zu 6 % verschoben
let mut skew = rect_outline(c, w, h, 0.0);
for (i, p) in skew.iter_mut().enumerate() {
let f = 0.06 * w.min(h);
*p += Vec2::new(
((i as f32 + seed) * 2.1).sin() * f,
((i as f32 + seed) * 1.3).cos() * f,
);
}
add(
format!("Rechteck schief {aspect}"),
kind(&detect_shape(&freehand(&skew, true, seed))),
);
// Überstrich: Strich läuft 8 % des Umfangs über den Start hinaus
let mut over = freehand(&rect_outline(c, w, h, 0.0), true, seed);
let extra: Vec<StylusSample> =
over.iter().take(over.len() * 8 / 100).copied().collect();
over.extend(extra);
add(
format!("Rechteck Überstrich {aspect}"),
kind(&detect_shape(&over)),
);
}
}
}
// Regelmäßige Vielecke (5, 6, 8 Ecken) und Ovale (0,6) – Ovale dürfen keine Vielecke werden
for &n in &[5usize, 6, 8] {
for &size in &[200.0_f32, 400.0] {
for &seed in &seeds {
let outline: Vec<Vec2> = (0..n)
.map(|i| {
let a = i as f32 / n as f32 * std::f32::consts::TAU
- std::f32::consts::FRAC_PI_2;
c + Vec2::new(a.cos(), a.sin()) * size
})
.collect();
add(
format!("Vieleck {n}"),
kind(&detect_shape(&freehand(&outline, true, seed))),
);
}
}
}
for &r in &[120.0_f32, 250.0, 400.0] {
for &seed in &seeds {
let pts = freehand(&circle_outline(c, r, r * 0.6), true, seed);
add("Oval 0.6".to_string(), kind(&detect_shape(&pts)));
}
}
// Freiformen, die KEIN Rechteck/Kreis werden sollen
for &seed in &seeds {
let star: Vec<Vec2> = (0..10)
.map(|i| {
let a = i as f32 / 10.0 * std::f32::consts::TAU;
let r = if i % 2 == 0 { 300.0 } else { 120.0 };
c + Vec2::new(a.cos() * r, a.sin() * r)
})
.collect();
add(
"Freiform Stern".into(),
kind(&detect_shape(&freehand(&star, true, seed))),
);
let cloud: Vec<Vec2> = (0..72)
.map(|i| {
let a = i as f32 / 72.0 * std::f32::consts::TAU;
let r = 250.0 * (1.0 + 0.3 * (3.0 * a).sin());
c + Vec2::new(a.cos() * r, a.sin() * r)
})
.collect();
add(
"Freiform Wolke".into(),
kind(&detect_shape(&freehand(&cloud, true, seed))),
);
let heart: Vec<Vec2> = (0..72)
.map(|i| {
let t = i as f32 / 72.0 * std::f32::consts::TAU;
let x = 16.0 * t.sin().powi(3);
let y = -(13.0 * t.cos()
- 5.0 * (2.0 * t).cos()
- 2.0 * (3.0 * t).cos()
- (4.0 * t).cos());
c + Vec2::new(x, y) * 18.0
})
.collect();
add(
"Freiform Herz".into(),
kind(&detect_shape(&freehand(&heart, true, seed))),
);
let spiral: Vec<Vec2> = (0..120)
.map(|i| {
let a = i as f32 / 30.0 * std::f32::consts::TAU;
let r = 40.0 + i as f32 * 2.5;
c + Vec2::new(a.cos() * r, a.sin() * r)
})
.collect();
add(
"Freiform Spirale".into(),
kind(&detect_shape(&freehand(&spiral, false, seed))),
);
}
let count = |group: &str, kind: &str| -> (usize, usize) {
let m = stats
.get(group)
.unwrap_or_else(|| panic!("Gruppe {group} fehlt"));
(m.get(kind).copied().unwrap_or(0), m.values().sum())
};
let mut problems = Vec::new();
for group in stats.keys() {
let expected = if group.starts_with("Rechteck") {
Some("Rechteck")
} else if group == "Kreis/Ellipse 1" || group == "Kreis/Ellipse 0.8" {
Some("Kreis")
} else if group == "Dreieck" {
Some("Dreieck")
} else if group.starts_with("Vieleck") {
Some("Polygon")
} else if group.starts_with("Freiform") {
// Freiformen bleiben unverändert
Some("nichts")
} else {
None
};
if let Some(kind) = expected {
let (hits, total) = count(group, kind);
// Achteck/Sechseck und leicht ovale Kreise sind von Hand kaum zu unterscheiden
let min_share = if group.starts_with("Vieleck") || group == "Kreis/Ellipse 0.8" {
0.9
} else {
1.0
};
if (hits as f32) < min_share * total as f32 {
problems.push(format!(
"{group}: {hits}/{total} als {kind} erkannt ({:?})",
stats[group]
));
}
} else {
// Flache Ellipsen und Freiformen: niemals Rechteck
let (rects, _) = count(group, "Rechteck");
if rects > 0 {
problems.push(format!(
"{group}: {rects}× fälschlich Rechteck ({:?})",
stats[group]
));
}
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
fn to_samples(points: &[Vec2]) -> Vec<StylusSample> {
points
.iter()
.enumerate()
.map(|(i, p)| StylusSample {
position: *p,
pressure: 0.5,
timestamp: i as f64 * 0.008,
})
.collect()
}
} }