Compare commits

...
5 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 616fc57666 chore(release): bump version to 0.3.7
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m48s
Ellipsen- und Pfeilerkennung, spitze Pfeilspitzen, Dateiformat-Version 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:42:24 +02:00
haraldandClaude Opus 5.5 d150fd1883 feat(geometry): Ellipsen- und Pfeilerkennung
Ellipse
- Achsen aus der Hauptkomponentenanalyse des gleichmäßig abgetasteten Strichs,
  Halbachsen aus der Ausdehnung (2.–98. Perzentil); fast runde Formen
  (Achsenverhältnis ≥ 0,85) bleiben Kreise; leicht schiefe werden gerade
- konkurriert im Formvergleich mit Kreis, Rechteck, Dreieck, Raute, Vieleck

Pfeil / Doppelpfeil
- in einem Zug gezeichnet: langer Schaft, an einem oder beiden Enden
  Widerhaken auf beiden Seiten (10°–80° schräg nach hinten); T-Enden und
  Zickzack sind keine Pfeile
- Schaft wird über fast gerade weiterlaufende Punkte verlängert (die
  Vereinfachung teilt ihn sonst kurz vor der Spitze)
- Spitzen über die gemeinsame arrow_head-Geometrie, Schaft endet an der Basis

Außerdem: Drehen/Skalieren/Verschieben, PDF-Export, Namen für die
Einrast-Meldung; Dateiformat-Version 3, damit ältere App-Versionen das
Notizbuch sauber ablehnen statt Seiten mit neuen Formen für beschädigt zu
halten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:28:17 +02:00
haraldandClaude Opus 5.5 f9ede537ed fix(geometry): spitze Pfeilspitzen beim Koordinatensystem
Die Achsenlinien liefen in voller Strichbreite bis zur Spitze und überdeckten
sie; zudem war die Spitze klein und breit (~67° Öffnung).

- Gemeinsame Pfeilspitzen-Geometrie (arrow_head) für Bildschirm und PDF:
  ~4 mm lang (dicke Linien länger, höchstens 1/5 der Achse), ~40° Öffnung
- Achsen enden an der Basis der Spitze
- PDF-Export nutzt dieselben Maße (vorher eigene, abweichende Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:14:06 +02:00
haraldandClaude Opus 5.5 9571e2121c chore(release): bump version to 0.3.6
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m51s
Unter WSL standardmäßig X11 statt Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:50:24 +02:00
haraldandClaude Opus 5.5 4a7627e461 fix(wsl): unter WSL standardmäßig X11 statt Wayland verwenden
Unter WSLg trennt der Wayland-Compositor die Verbindung, kurz nachdem die App
ein Bild über Vulkan übergibt ("Connection reset by peer", ExitFailure). Über
X11 (Xwayland) läuft die App stabil – deshalb funktionierte sie als root, wo
der Wayland-Socket nicht gefunden wird und winit auf X11 ausweicht.

- WSL-Erkennung (WSL_DISTRO_NAME/WSL_INTEROP, /proc/sys/kernel/osrelease)
- Unter WSL mit gesetztem DISPLAY wird die Ereignisschleife mit X11 erzeugt
- INK_NOTES_DISPLAY=x11|wayland übersteuert die Wahl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:21 +02:00
9 changed files with 923 additions and 139 deletions
Generated
+1 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.3.5" version = "0.3.7"
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.5" version = "0.3.7"
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"
+3 -1
View File
@@ -3,7 +3,9 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04). Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung - **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2) - **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert - **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern - **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
@@ -11,7 +13,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, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert - **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); 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
+115 -59
View File
@@ -329,6 +329,59 @@ fn render_recognized_shape(
layer.add_polygon(poly); layer.add_polygon(poly);
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
let ring = (0..72)
.map(|i| {
let t = i as f32 / 72.0 * std::f32::consts::TAU;
let p =
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
})
.collect();
layer.add_polygon(Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
});
}
RecognizedShape::Arrow { start, end, double } => {
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let start_head =
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let line_start = if *double { start_head.0 } else { *start };
let to_pdf = |p: glam::Vec2| {
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
};
layer.add_line(Line {
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
is_closed: false,
});
layer.set_fill_color(stroke_color);
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in heads {
layer.add_polygon(Polygon {
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
RecognizedShape::Rectangle { corners } => { RecognizedShape::Rectangle { corners } => {
let mut ring = Vec::with_capacity(4); let mut ring = Vec::with_capacity(4);
for c in corners { for c in corners {
@@ -412,76 +465,58 @@ fn render_recognized_shape(
y_axis_end, y_axis_end,
with_arrows, with_arrows,
} => { } => {
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = crate::geometry::arrow_head(
*y_axis_end,
y_dir,
PDF_SHAPE_HALF_WIDTH,
(*y_axis_end - *origin).length(),
);
let x_arrow = crate::geometry::arrow_head(
*x_axis_end,
x_dir,
PDF_SHAPE_HALF_WIDTH,
(*x_axis_end - *origin).length(),
);
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); for end in [y_line_end, x_line_end] {
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
// Achse 1 (Y)
let line_y = Line {
points: vec![ points: vec![
(Point::new(Mm(ox), Mm(oy)), false), (Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(yx), Mm(yy)), false), (Point::new(Mm(ex), Mm(ey)), false),
], ],
is_closed: false, is_closed: false,
}; });
layer.add_line(line_y); }
// Achse 2 (X)
let line_x = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(xx), Mm(xy)), false),
],
is_closed: false,
};
layer.add_line(line_x);
// Pfeilspitzen als gefüllte Dreiecke // Pfeilspitzen als gefüllte Dreiecke
if *with_arrows { if *with_arrows {
layer.set_fill_color(stroke_color); layer.set_fill_color(stroke_color);
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let y_dir = (*y_axis_end - *origin).normalize_or_zero(); let ring = [tip, left, right]
let arrow_len_page = 25.0; .iter()
let arrow_w_page = 16.0; .map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
let base_y = *y_axis_end - y_dir * arrow_len_page; (Point::new(Mm(px), Mm(py)), false)
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); })
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); .collect();
layer.add_polygon(Polygon {
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); rings: vec![ring],
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
let arrow_poly_y = Polygon {
rings: vec![vec![
(Point::new(Mm(tx), Mm(ty)), false),
(Point::new(Mm(lx), Mm(ly)), false),
(Point::new(Mm(rx), Mm(ry)), false),
]],
mode: PaintMode::Fill, mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero, winding_order: WindingOrder::NonZero,
}; });
layer.add_polygon(arrow_poly_y); }
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let base_x = *x_axis_end - x_dir * arrow_len_page;
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
let arrow_poly_x = Polygon {
rings: vec![vec![
(Point::new(Mm(tx_x), Mm(ty_x)), false),
(Point::new(Mm(lx_x), Mm(ly_x)), false),
(Point::new(Mm(rx_x), Mm(ry_x)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_x);
} }
} }
} }
@@ -838,6 +873,27 @@ mod tests {
); );
page.add_stroke(coords_stroke); page.add_stroke(coords_stroke);
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(1400.0, 600.0),
radius_x: 300.0,
radius_y: 150.0,
rotation: 0.4,
},
);
page.add_stroke(ellipse_stroke);
for (id, double) in [(41, false), (42, true)] {
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
double,
},
);
page.add_stroke(arrow_stroke);
}
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape( let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
crate::geometry::RecognizedShape::Triangle { crate::geometry::RecognizedShape::Triangle {
corners: [ corners: [
+628 -63
View File
@@ -15,6 +15,8 @@ use crate::document::{Aabb, Stroke, StylusSample};
pub enum ShapeKind { pub enum ShapeKind {
Line, Line,
Circle, Circle,
Ellipse,
Arrow,
Rectangle, Rectangle,
CoordinateSystem, CoordinateSystem,
Triangle, Triangle,
@@ -34,6 +36,20 @@ pub enum RecognizedShape {
center: Vec2, center: Vec2,
radius: f32, radius: f32,
}, },
/// Ellipse mit den Halbachsen `radius_x` (entlang `rotation`) und `radius_y`.
Ellipse {
center: Vec2,
radius_x: f32,
radius_y: f32,
/// Drehwinkel der ersten Halbachse zur x-Achse (Bogenmaß)
rotation: f32,
},
/// Pfeil vom Schaftende `start` zur Spitze `end`; `double` = Spitzen an beiden Enden.
Arrow {
start: Vec2,
end: Vec2,
double: bool,
},
Rectangle { Rectangle {
/// 4 Eckpunkte in umlaufender Reihenfolge (strikt rechtwinklig). /// 4 Eckpunkte in umlaufender Reihenfolge (strikt rechtwinklig).
corners: [Vec2; 4], corners: [Vec2; 4],
@@ -70,6 +86,8 @@ impl RecognizedShape {
match self { match self {
Self::Line { .. } => ShapeKind::Line, Self::Line { .. } => ShapeKind::Line,
Self::Circle { .. } => ShapeKind::Circle, Self::Circle { .. } => ShapeKind::Circle,
Self::Ellipse { .. } => ShapeKind::Ellipse,
Self::Arrow { .. } => ShapeKind::Arrow,
Self::Rectangle { .. } => ShapeKind::Rectangle, Self::Rectangle { .. } => ShapeKind::Rectangle,
Self::CoordinateSystem { .. } => ShapeKind::CoordinateSystem, Self::CoordinateSystem { .. } => ShapeKind::CoordinateSystem,
Self::Triangle { .. } => ShapeKind::Triangle, Self::Triangle { .. } => ShapeKind::Triangle,
@@ -83,6 +101,9 @@ impl RecognizedShape {
match self { match self {
Self::Line { .. } => "Gerade", Self::Line { .. } => "Gerade",
Self::Circle { .. } => "Kreis", Self::Circle { .. } => "Kreis",
Self::Ellipse { .. } => "Ellipse",
Self::Arrow { double: false, .. } => "Pfeil",
Self::Arrow { double: true, .. } => "Doppelpfeil",
Self::Rectangle { .. } => "Rechteck", Self::Rectangle { .. } => "Rechteck",
Self::CoordinateSystem { .. } => "Koordinatensystem", Self::CoordinateSystem { .. } => "Koordinatensystem",
Self::Triangle { .. } => "Dreieck", Self::Triangle { .. } => "Dreieck",
@@ -104,9 +125,13 @@ impl RecognizedShape {
*start += delta; *start += delta;
*end += delta; *end += delta;
} }
Self::Circle { center, .. } => { Self::Circle { center, .. } | Self::Ellipse { center, .. } => {
*center += delta; *center += delta;
} }
Self::Arrow { start, end, .. } => {
*start += delta;
*end += delta;
}
Self::Rectangle { corners } | Self::Rhombus { corners } => { Self::Rectangle { corners } | Self::Rhombus { corners } => {
for c in corners { for c in corners {
*c += delta; *c += delta;
@@ -154,6 +179,16 @@ impl RecognizedShape {
Self::Circle { center, .. } => { Self::Circle { center, .. } => {
*center = rot(*center); *center = rot(*center);
} }
Self::Ellipse {
center, rotation, ..
} => {
*center = rot(*center);
*rotation += angle_rad;
}
Self::Arrow { start, end, .. } => {
*start = rot(*start);
*end = rot(*end);
}
Self::Rectangle { corners } | Self::Rhombus { corners } => { Self::Rectangle { corners } | Self::Rhombus { corners } => {
for c in corners { for c in corners {
*c = rot(*c); *c = rot(*c);
@@ -198,6 +233,26 @@ impl RecognizedShape {
*center = sc(*center); *center = sc(*center);
*radius *= (factors.x.abs() + factors.y.abs()) * 0.5; *radius *= (factors.x.abs() + factors.y.abs()) * 0.5;
} }
Self::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
// Halbachsen als Vektoren skalieren (bei schräger Ellipse und ungleicher
// Skalierung eine gute Näherung)
let (sin, cos) = rotation.sin_cos();
let axis_x = Vec2::new(cos, sin) * *radius_x * factors;
let axis_y = Vec2::new(-sin, cos) * *radius_y * factors;
*center = sc(*center);
*radius_x = axis_x.length();
*radius_y = axis_y.length();
*rotation = axis_x.y.atan2(axis_x.x);
}
Self::Arrow { start, end, .. } => {
*start = sc(*start);
*end = sc(*end);
}
Self::Rectangle { corners } | Self::Rhombus { corners } => { Self::Rectangle { corners } | Self::Rhombus { corners } => {
for c in corners { for c in corners {
*c = sc(*c); *c = sc(*c);
@@ -912,6 +967,221 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
}) })
} }
/// Tastet einen Linienzug gleichmäßig entlang der Bogenlänge neu ab (`samples` Abschnitte).
fn resample_path(points: &[Vec2], samples: usize) -> Vec<Vec2> {
let total_len = path_length(points);
if points.is_empty() || total_len < 1e-3 || samples == 0 {
return points.to_vec();
}
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);
}
resampled
}
/// Ellipsen, deren kürzere Halbachse mindestens diesen Anteil der längeren hat, gelten als Kreis.
const ELLIPSE_MAX_ROUNDNESS: f32 = 0.85;
/// Ellipsen, die höchstens so viele Grad schief liegen, werden achsenparallel ausgerichtet.
const ELLIPSE_AXIS_SNAP_DEG: f32 = 8.0;
/// Ellipsenerkennung: geschlossener Pfad, Achsen aus der Hauptkomponentenanalyse der
/// gleichmäßig abgetasteten Punkte, Halbachsen aus der Ausdehnung entlang dieser Achsen
/// (2.–98. Perzentil, robust gegen Überstriche). Fast runde Formen bleiben Kreise.
pub fn detect_ellipse(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 8 {
return None;
}
let total_len = path_length(points);
if total_len < 40.0 {
return None;
}
let chord_len = (*points.last().unwrap() - points[0]).length();
if chord_len > 0.35 * total_len {
return None;
}
let pts = resample_path(points, 120);
let n = pts.len() as f32;
let mean = pts.iter().copied().sum::<Vec2>() / n;
let (mut sxx, mut syy, mut sxy) = (0.0_f32, 0.0_f32, 0.0_f32);
for p in &pts {
let d = *p - mean;
sxx += d.x * d.x;
syy += d.y * d.y;
sxy += d.x * d.y;
}
let mut rotation = 0.5 * (2.0 * sxy).atan2(sxx - syy);
// Achsenparallel ausrichten, wenn nur leicht schief
let deg = rotation.to_degrees().rem_euclid(90.0);
if deg <= ELLIPSE_AXIS_SNAP_DEG || deg >= 90.0 - ELLIPSE_AXIS_SNAP_DEG {
rotation = (rotation.to_degrees() / 90.0).round() * 90.0_f32.to_radians();
}
let (sin, cos) = rotation.sin_cos();
let mut along: Vec<f32> = pts.iter().map(|p| p.x * cos + p.y * sin).collect();
let mut across: Vec<f32> = pts.iter().map(|p| -p.x * sin + p.y * cos).collect();
along.sort_by(f32::total_cmp);
across.sort_by(f32::total_cmp);
let lo = pts.len() * 2 / 100;
let hi = pts.len() - 1 - lo;
let (a0, a1, b0, b1) = (along[lo], along[hi], across[lo], across[hi]);
let mut radius_x = (a1 - a0) * 0.5;
let mut radius_y = (b1 - b0) * 0.5;
let center = Vec2::new(cos, sin) * (a0 + a1) * 0.5 + Vec2::new(-sin, cos) * (b0 + b1) * 0.5;
if radius_x < 10.0 || radius_y < 5.0 {
return None;
}
// Erste Halbachse ist die längere
if radius_y > radius_x {
std::mem::swap(&mut radius_x, &mut radius_y);
rotation += std::f32::consts::FRAC_PI_2;
}
if radius_y / radius_x >= ELLIPSE_MAX_ROUNDNESS {
return None;
}
// Drehwinkel auf (−90°, 90°] normieren
if rotation > std::f32::consts::FRAC_PI_2 {
rotation -= std::f32::consts::PI;
} else if rotation <= -std::f32::consts::FRAC_PI_2 {
rotation += std::f32::consts::PI;
}
Some(RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
})
}
/// Höchster Abstand der Widerhaken von der Spitze, als Anteil der Schaftlänge.
const ARROW_HEAD_REACH: f32 = 0.4;
/// Punkte so nahe an der Spitze gelten als Rückweg zur Spitze, nicht als Widerhaken.
const ARROW_TIP_ZONE: f32 = 0.08;
/// Pfeilerkennung (auch Doppelpfeil) für einen in einem Zug gezeichneten Pfeil: langer Schaft
/// und an mindestens einem Ende Widerhaken auf beiden Seiten, schräg nach hinten (10°–80°;
/// ein T-Ende mit 90° ist kein Pfeil).
pub fn detect_arrow(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 6 {
return None;
}
let total_len = path_length(points);
if total_len < 40.0 {
return None;
}
let epsilon = (total_len * 0.02).clamp(3.0, 15.0);
let simplified = ramer_douglas_peucker(points, epsilon);
if simplified.len() < 4 {
return None;
}
// Schaft = längstes Segment, an beiden Enden um fast gerade weiterlaufende Punkte
// verlängert (die Vereinfachung teilt den Schaft sonst gern kurz vor der Spitze)
let longest = (0..simplified.len() - 1).max_by(|&i, &j| {
let li = (simplified[i + 1] - simplified[i]).length();
let lj = (simplified[j + 1] - simplified[j]).length();
li.total_cmp(&lj)
})?;
let (mut first, mut last) = (longest, longest + 1);
// Ein Folgepunkt gehört zum Schaft, wenn er nahe der Schaftgeraden in Verlängerung liegt
// (kein Winkelkriterium: kurze Stücke haben durch das Zittern stark schwankende Richtungen)
let continues = |from: Vec2, to: Vec2, next: Vec2| {
let dir = (to - from).normalize_or_zero();
let beyond = (next - to).dot(dir) > 0.0;
let tolerance = (2.0 * epsilon).max(0.05 * (to - from).length());
beyond && point_to_line_dist(next, from, to) <= tolerance
};
while last + 1 < simplified.len()
&& continues(simplified[first], simplified[last], simplified[last + 1])
{
last += 1;
}
while first > 0 && continues(simplified[last], simplified[first], simplified[first - 1]) {
first -= 1;
}
let (a, b) = (simplified[first], simplified[last]);
let shaft_len = (b - a).length();
if shaft_len < 0.4 * total_len {
return None;
}
let others: Vec<Vec2> = simplified
.iter()
.enumerate()
.filter(|(i, _)| *i < first || *i > last)
.map(|(_, p)| *p)
.collect();
// Hat das Schaftende `tip` (mit Gegenende `tail`) Widerhaken auf beiden Seiten?
// Liefert None, wenn ein zugeordneter Punkt nicht wie ein Widerhaken liegt.
let head_at = |tip: Vec2, tail: Vec2, cluster: &[Vec2]| -> Option<bool> {
let back = (tail - tip).normalize_or_zero();
let (mut left, mut right) = (false, false);
for p in cluster {
let v = *p - tip;
if v.length() < ARROW_TIP_ZONE * shaft_len {
continue;
}
let angle = back.angle_between(v).to_degrees().abs();
if !(10.0..=80.0).contains(&angle) {
return None;
}
if back.perp_dot(v) > 0.0 {
left = true;
} else {
right = true;
}
}
Some(left && right)
};
// Übrige Punkte dem näheren Schaftende zuordnen; sie müssen nahe an diesem liegen
let (mut near_a, mut near_b) = (Vec::new(), Vec::new());
for p in others {
let (da, db) = ((p - a).length(), (p - b).length());
if da.min(db) > ARROW_HEAD_REACH * shaft_len {
return None;
}
if da < db {
near_a.push(p);
} else {
near_b.push(p);
}
}
let head_b = head_at(b, a, &near_b)?;
let head_a = head_at(a, b, &near_a)?;
let (start, end, double) = match (head_a, head_b) {
(true, true) => (a, b, true),
(false, true) if near_a.is_empty() => (a, b, false),
(true, false) if near_b.is_empty() => (b, a, false),
_ => return None,
};
// Fast waagerechte/senkrechte Pfeile (≤ 5°) gerade ausrichten
let dir = end - start;
let angle_deg = dir.y.atan2(dir.x).to_degrees().abs();
let end = if angle_deg < 5.0 || (angle_deg - 180.0).abs() < 5.0 {
Vec2::new(end.x, start.y)
} else if (angle_deg - 90.0).abs() < 5.0 {
Vec2::new(start.x, end.y)
} else {
end
};
Some(RecognizedShape::Arrow { start, end, double })
}
/// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss. /// 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; const POLYGON_TURNING_AT_CORNERS: f32 = 0.7;
/// Umgebung einer Ecke als Anteil des Umfangs. /// Umgebung einer Ecke als Anteil des Umfangs.
@@ -930,21 +1200,7 @@ fn corner_turning_ratio(points: &[Vec2], corners: &[Vec2]) -> Option<f32> {
if total_len < 1e-3 || corners.is_empty() { if total_len < 1e-3 || corners.is_empty() {
return None; return None;
} }
// Gleichmäßig neu abtasten (120 Punkte entlang der Bogenlänge) let resampled = resample_path(points, 120);
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 // Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten
const W: usize = 3; const W: usize = 3;
if resampled.len() < 2 * W + 2 { if resampled.len() < 2 * W + 2 {
@@ -1177,9 +1433,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
}, },
&|| best_closed.clone(), &|| best_closed.clone(),
&|| detect_polygon(&pts), &|| detect_polygon(&pts),
// Pfeil vor Koordinatensystem und Gerade: Ein Pfeil ist eine Gerade mit Widerhaken
&|| detect_arrow(&pts),
&|| detect_coordinate_system(&pts), &|| detect_coordinate_system(&pts),
&|| detect_line(&pts), &|| detect_line(&pts),
&|| None,
]; ];
candidates candidates
.iter() .iter()
@@ -1229,8 +1486,12 @@ pub fn shape_fit_error(points: &[Vec2], shape: &RecognizedShape) -> Option<f32>
} }
/// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen? /// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen?
/// Pfeile sind ausgenommen: Ihre Erkennung prüft die Struktur (Schaft, Widerhaken auf beiden
/// Seiten) bereits streng, und gezeichnete Widerhaken weichen naturgemäß von der sauberen
/// Spitze ab.
fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool { fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool {
shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR) matches!(shape, RecognizedShape::Arrow { .. })
|| 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. /// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale.
@@ -1248,7 +1509,7 @@ const CLOSED_SHAPE_GOOD_FIT: f32 = 0.04;
const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5; const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5;
const CIRCLE_WEIGHT: f32 = 1.5; const CIRCLE_WEIGHT: f32 = 1.5;
/// Wählt unter Kreis, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt, /// Wählt unter Kreis, Ellipse, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
/// und liefert sie mit ihrer Abweichung (RMS). /// und liefert sie mit ihrer Abweichung (RMS).
fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> { fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
// Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht. // Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht.
@@ -1259,6 +1520,7 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
}; };
[ [
fit(detect_circle(pts), CIRCLE_WEIGHT), fit(detect_circle(pts), CIRCLE_WEIGHT),
fit(detect_ellipse(pts), 1.0),
fit(detect_triangle(pts), 1.0), fit(detect_triangle(pts), 1.0),
fit(detect_rectangle(pts), RECTANGLE_WEIGHT), fit(detect_rectangle(pts), RECTANGLE_WEIGHT),
fit(detect_rhombus(pts), 1.0), fit(detect_rhombus(pts), 1.0),
@@ -1269,6 +1531,45 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
.min_by(|a, b| a.0.total_cmp(&b.0)) .min_by(|a, b| a.0.total_cmp(&b.0))
} }
/// Halber Öffnungswinkel einer Pfeilspitze als Verhältnis halbe Breite zu Länge (tan 20° ≈ 0,36,
/// also ~40° Öffnung – schlank genug, dass die Spitze spitz wirkt).
const ARROW_HALF_WIDTH_RATIO: f32 = 0.36;
/// Geometrie einer Pfeilspitze an `tip`, Richtung `dir` (Einheitsvektor), für eine Linie der
/// halben Breite `half_w` (Seiteneinheiten) und der Länge `line_len`. Liefert (Basismitte,
/// linke Ecke, rechte Ecke). Die Spitze ist ~4 mm lang (dicke Linien länger), höchstens ein
/// Fünftel der Linie. Die Linie soll an der Basismitte enden – reicht sie bis zur Spitze,
/// wirkt diese stumpf.
pub fn arrow_head(tip: Vec2, dir: Vec2, half_w: f32, line_len: f32) -> (Vec2, Vec2, Vec2) {
let len = (half_w * 10.0).max(40.0).min(line_len * 0.2);
let half_width = len * ARROW_HALF_WIDTH_RATIO;
let base = tip - dir * len;
let normal = Vec2::new(-dir.y, dir.x);
(base, base + normal * half_width, base - normal * half_width)
}
/// Punkt auf einer Ellipse zum Parameter `t` (Bogenmaß).
pub fn ellipse_point(center: Vec2, radius_x: f32, radius_y: f32, rotation: f32, t: f32) -> Vec2 {
let (sin, cos) = rotation.sin_cos();
let local = Vec2::new(t.cos() * radius_x, t.sin() * radius_y);
center + Vec2::new(local.x * cos - local.y * sin, local.x * sin + local.y * cos)
}
/// Näherungsweiser Abstand eines Punktes zum Rand einer Ellipse: Abstand zum Mittelpunkt
/// minus Ellipsenradius in dieser Richtung (für die Formauswahl genau genug).
fn ellipse_distance(p: Vec2, center: Vec2, radius_x: f32, radius_y: f32, rotation: f32) -> f32 {
let d = p - center;
let (sin, cos) = rotation.sin_cos();
let local = Vec2::new(d.x * cos + d.y * sin, -d.x * sin + d.y * cos);
let scaled = Vec2::new(local.x / radius_x.max(1e-3), local.y / radius_y.max(1e-3));
let scaled_len = scaled.length();
if scaled_len < 1e-6 {
return radius_x.min(radius_y);
}
let radius_in_direction = local.length() / scaled_len;
(local.length() - radius_in_direction).abs()
}
/// Abstand eines Punktes zur Strecke `a`–`b`. /// Abstand eines Punktes zur Strecke `a`–`b`.
fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 { fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
let ab = b - a; let ab = b - a;
@@ -1299,6 +1600,14 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
RecognizedShape::Circle { center, radius } => { RecognizedShape::Circle { center, radius } => {
Some(((p - *center).length() - radius).abs()) Some(((p - *center).length() - radius).abs())
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => Some(ellipse_distance(
p, *center, *radius_x, *radius_y, *rotation,
)),
RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)), RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)),
RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => { RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => {
Some(polygon_distance(p, corners)) Some(polygon_distance(p, corners))
@@ -1442,6 +1751,70 @@ pub fn shape_to_mesh(
(samples, vertices) (samples, vertices)
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
const SEGMENTS: usize = 96;
let outline: Vec<Vec2> = (0..SEGMENTS)
.map(|i| {
let t = i as f32 / SEGMENTS as f32 * 2.0 * PI;
ellipse_point(*center, *radius_x, *radius_y, *rotation, t)
})
.collect();
closed_polygon_to_mesh(&outline, half_w, color)
}
RecognizedShape::Arrow { start, end, double } => {
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = arrow_head(*end, dir, half_w, shaft_len);
let start_head = arrow_head(*start, -dir, half_w, shaft_len);
// Der Schaft endet an der Basis der Spitze(n), damit diese spitz bleiben
let line_start = if *double { start_head.0 } else { *start };
let line_end = end_head.0;
let norm = Vec2::new(-dir.y, dir.x) * half_w;
let mut vertices = vec![
InkVertex::new(line_start.x + norm.x, line_start.y + norm.y, color),
InkVertex::new(line_start.x - norm.x, line_start.y - norm.y, color),
InkVertex::new(line_end.x + norm.x, line_end.y + norm.y, color),
InkVertex::new(line_end.x - norm.x, line_end.y - norm.y, color),
];
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in &heads {
let last_v = *vertices.last().unwrap();
let tip_v = InkVertex::new(tip.x, tip.y, color);
vertices.push(last_v);
vertices.push(tip_v);
vertices.push(tip_v);
vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(right.x, right.y, color));
}
// Abtastpunkte als zusammenhängender Linienzug: (Spitze am Start,) Schaft, Spitze am Ende
let sample = |p: Vec2| StylusSample {
position: p,
pressure: 0.5,
timestamp: 0.0,
};
let mut samples = Vec::new();
if *double {
let (_, l, r) = start_head;
samples.extend([*start, l, r, *start].map(sample));
}
let steps = ((shaft_len / 5.0).ceil() as usize).max(2);
samples.extend((0..=steps).map(|i| sample(start.lerp(*end, i as f32 / steps as f32))));
let (_, l, r) = end_head;
samples.extend([l, r, *end].map(sample));
(samples, vertices)
}
RecognizedShape::Circle { center, radius } => { RecognizedShape::Circle { center, radius } => {
let segments = 64; let segments = 64;
let r_in = (radius - half_w).max(0.1); let r_in = (radius - half_w).max(0.1);
@@ -1539,11 +1912,20 @@ pub fn shape_to_mesh(
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut samples = Vec::new(); let mut samples = Vec::new();
// 1. Y-Achse (von Pfeilspitze oben zum Ursprung) // Achsen enden bei Pfeilen an der Basis der Spitze, sonst überdeckt das Linienband
// die Spitze und sie wirkt stumpf
let y_dir = (*y_axis_end - *origin).normalize_or_zero(); let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w; let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = arrow_head(*y_axis_end, y_dir, half_w, (*y_axis_end - *origin).length());
let x_arrow = arrow_head(*x_axis_end, x_dir, half_w, (*x_axis_end - *origin).length());
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
// Y-Linienband // 1. Y-Achse (vom Ursprung zur Spitze)
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
origin.x + y_norm.x, origin.x + y_norm.x,
origin.y + y_norm.y, origin.y + y_norm.y,
@@ -1555,26 +1937,24 @@ pub fn shape_to_mesh(
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
y_axis_end.x + y_norm.x, y_line_end.x + y_norm.x,
y_axis_end.y + y_norm.y, y_line_end.y + y_norm.y,
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
y_axis_end.x - y_norm.x, y_line_end.x - y_norm.x,
y_axis_end.y - y_norm.y, y_line_end.y - y_norm.y,
color, color,
)); ));
// Degenerate Dreiecke für Übergang zur X-Achse // Degenerierte Dreiecke für den Übergang zur X-Achse
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w; let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
let last_v = *vertices.last().unwrap(); let last_v = *vertices.last().unwrap();
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color); let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
vertices.push(last_v); vertices.push(last_v);
vertices.push(next_v); vertices.push(next_v);
// 2. X-Linienband // 2. X-Achse
vertices.push(next_v); vertices.push(next_v);
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
origin.x - x_norm.x, origin.x - x_norm.x,
@@ -1582,48 +1962,27 @@ pub fn shape_to_mesh(
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
x_axis_end.x + x_norm.x, x_line_end.x + x_norm.x,
x_axis_end.y + x_norm.y, x_line_end.y + x_norm.y,
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
x_axis_end.x - x_norm.x, x_line_end.x - x_norm.x,
x_axis_end.y - x_norm.y, x_line_end.y - x_norm.y,
color, color,
)); ));
// 3. Optionale Pfeilspitzen // 3. Optionale Pfeilspitzen (je ein Dreieck, über degenerierte Dreiecke angehängt)
if *with_arrows { if *with_arrows {
let arrow_len = (half_w * 5.0).max(18.0); for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let arrow_w = (half_w * 3.5).max(12.0);
// Pfeilspitze Y
let y_base = *y_axis_end - y_dir * arrow_len;
let y_left = y_base + Vec2::new(-y_dir.y, y_dir.x) * arrow_w;
let y_right = y_base - Vec2::new(-y_dir.y, y_dir.x) * arrow_w;
let last_v = *vertices.last().unwrap(); let last_v = *vertices.last().unwrap();
let tip_v = InkVertex::new(y_axis_end.x, y_axis_end.y, color); let tip_v = InkVertex::new(tip.x, tip.y, color);
vertices.push(last_v); vertices.push(last_v);
vertices.push(tip_v); vertices.push(tip_v);
vertices.push(tip_v); vertices.push(tip_v);
vertices.push(InkVertex::new(y_left.x, y_left.y, color)); vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(y_right.x, y_right.y, color)); vertices.push(InkVertex::new(right.x, right.y, color));
}
// Pfeilspitze X
let x_base = *x_axis_end - x_dir * arrow_len;
let x_left = x_base + Vec2::new(-x_dir.y, x_dir.x) * arrow_w;
let x_right = x_base - Vec2::new(-x_dir.y, x_dir.x) * arrow_w;
let last_v = *vertices.last().unwrap();
let tip_x = InkVertex::new(x_axis_end.x, x_axis_end.y, color);
vertices.push(last_v);
vertices.push(tip_x);
vertices.push(tip_x);
vertices.push(InkVertex::new(x_left.x, x_left.y, color));
vertices.push(InkVertex::new(x_right.x, x_right.y, color));
} }
// Abtastpunkte // Abtastpunkte
@@ -2084,6 +2443,9 @@ mod tests {
match shape { match shape {
Some(RecognizedShape::Line { .. }) => "Linie", Some(RecognizedShape::Line { .. }) => "Linie",
Some(RecognizedShape::Circle { .. }) => "Kreis", Some(RecognizedShape::Circle { .. }) => "Kreis",
Some(RecognizedShape::Ellipse { .. }) => "Ellipse",
Some(RecognizedShape::Arrow { double: false, .. }) => "Pfeil",
Some(RecognizedShape::Arrow { double: true, .. }) => "Doppelpfeil",
Some(RecognizedShape::Rectangle { .. }) => "Rechteck", Some(RecognizedShape::Rectangle { .. }) => "Rechteck",
Some(RecognizedShape::Triangle { .. }) => "Dreieck", Some(RecognizedShape::Triangle { .. }) => "Dreieck",
Some(RecognizedShape::Rhombus { .. }) => "Raute", Some(RecognizedShape::Rhombus { .. }) => "Raute",
@@ -2288,6 +2650,57 @@ mod tests {
add("Oval 0.6".to_string(), kind(&detect_shape(&pts))); add("Oval 0.6".to_string(), kind(&detect_shape(&pts)));
} }
} }
// Pfeile in einem Zug: Schaft, dann Widerhaken links, zurück zur Spitze, Widerhaken rechts
for &len in &[200.0_f32, 400.0, 700.0] {
for &angle in &[0.0_f32, 30.0, 135.0, 260.0] {
for &barb_deg in &[25.0_f32, 40.0, 55.0] {
for &seed in &seeds[..3] {
let dir = Vec2::from_angle(angle.to_radians());
let tail = c - dir * len * 0.5;
let tip = c + dir * len * 0.5;
let barb = |side: f32| {
tip + Vec2::from_angle(side * barb_deg.to_radians()).rotate(-dir)
* len
* 0.15
};
let single = [tail, tip, barb(1.0), tip, barb(-1.0)];
add(
format!("Pfeil {barb_deg}°"),
kind(&detect_shape(&freehand(&single, false, seed))),
);
let barb_tail = |side: f32| {
tail + Vec2::from_angle(side * barb_deg.to_radians()).rotate(dir)
* len
* 0.15
};
let double = [
barb_tail(1.0),
tail,
barb_tail(-1.0),
tail,
tip,
barb(1.0),
tip,
barb(-1.0),
];
add(
format!("Doppelpfeil {barb_deg}°"),
kind(&detect_shape(&freehand(&double, false, seed))),
);
}
}
// Gerade bleibt Gerade
let dir = Vec2::from_angle(angle.to_radians());
add(
"Linie".to_string(),
kind(&detect_shape(&freehand(
&[c - dir * len * 0.5, c + dir * len * 0.5],
false,
0.3,
))),
);
}
}
// Freiformen, die KEIN Rechteck/Kreis werden sollen // Freiformen, die KEIN Rechteck/Kreis werden sollen
for &seed in &seeds { for &seed in &seeds {
let star: Vec<Vec2> = (0..10) let star: Vec<Vec2> = (0..10)
@@ -2349,8 +2762,19 @@ mod tests {
for group in stats.keys() { for group in stats.keys() {
let expected = if group.starts_with("Rechteck") { let expected = if group.starts_with("Rechteck") {
Some("Rechteck") Some("Rechteck")
} else if group == "Kreis/Ellipse 1" || group == "Kreis/Ellipse 0.8" { } else if group == "Kreis/Ellipse 1" {
Some("Kreis") Some("Kreis")
} else if group == "Kreis/Ellipse 0.8"
|| group == "Kreis/Ellipse 0.6"
|| group == "Oval 0.6"
{
Some("Ellipse")
} else if group.starts_with("Pfeil") {
Some("Pfeil")
} else if group.starts_with("Doppelpfeil") {
Some("Doppelpfeil")
} else if group.starts_with("Linie") {
Some("Linie")
} else if group == "Dreieck" { } else if group == "Dreieck" {
Some("Dreieck") Some("Dreieck")
} else if group.starts_with("Vieleck") { } else if group.starts_with("Vieleck") {
@@ -2364,7 +2788,7 @@ mod tests {
if let Some(kind) = expected { if let Some(kind) = expected {
let (hits, total) = count(group, kind); let (hits, total) = count(group, kind);
// Achteck/Sechseck und leicht ovale Kreise sind von Hand kaum zu unterscheiden // 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" { let min_share = if group.starts_with("Vieleck") {
0.9 0.9
} else { } else {
1.0 1.0
@@ -2400,4 +2824,145 @@ mod tests {
}) })
.collect() .collect()
} }
#[test]
fn test_arrow_head_is_slender_and_axes_end_at_its_base() {
// Spitze schlank (~40° Öffnung) und breiter als die Linie
let (base, left, right) = arrow_head(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0), 2.5, 500.0);
let len = base.length();
let half_width = (left - base).length();
let opening = 2.0 * (half_width / len).atan().to_degrees();
assert!(
(35.0..=45.0).contains(&opening),
"Öffnungswinkel {opening}°"
);
assert!(
half_width > 2.5 * 2.0,
"Spitze muss breiter als die Linie sein"
);
assert!(((left + right) * 0.5 - base).length() < 1e-4);
assert!(len >= 40.0, "Spitze mindestens 4 mm lang: {len}");
// Bei kurzen Linien höchstens ein Fünftel der Linie
let (short_base, ..) = arrow_head(Vec2::ZERO, Vec2::new(1.0, 0.0), 2.5, 100.0);
assert!((short_base.length() - 20.0).abs() < 1e-3);
// Kein Vertex des Linienbands ragt über die Basis hinaus in die Spitze hinein
let origin = Vec2::new(100.0, 300.0);
let y_end = Vec2::new(100.0, 100.0);
let x_end = Vec2::new(300.0, 300.0);
let shape = RecognizedShape::CoordinateSystem {
origin,
x_axis_end: x_end,
y_axis_end: y_end,
with_arrows: true,
};
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
let (x_base, ..) = arrow_head(x_end, Vec2::new(1.0, 0.0), 2.5, 200.0);
for v in &vertices {
let p = Vec2::new(v.position[0], v.position[1]);
let is_tip = (p - y_end).length() < 1e-3 || (p - x_end).length() < 1e-3;
assert!(
is_tip || p.y >= y_base.y - 1e-3,
"Y-Achse ragt in die Spitze: {p:?}"
);
assert!(
is_tip || p.x <= x_base.x + 1e-3,
"X-Achse ragt in die Spitze: {p:?}"
);
}
}
#[test]
fn test_arrow_negative_cases() {
let c = Vec2::new(1000.0, 1400.0);
// T-Ende: Querbalken im rechten Winkel ist kein Pfeil
let t_shape = [
c - Vec2::new(300.0, 0.0),
c + Vec2::new(300.0, 0.0),
c + Vec2::new(300.0, 60.0),
c + Vec2::new(300.0, -60.0),
];
assert_ne!(
kind(&detect_shape(&freehand(&t_shape, false, 0.4))),
"Pfeil"
);
// Zickzack ist kein Pfeil
let zigzag: Vec<Vec2> = (0..8)
.map(|i| c + Vec2::new(i as f32 * 80.0, if i % 2 == 0 { 0.0 } else { 60.0 }))
.collect();
let got = kind(&detect_shape(&freehand(&zigzag, false, 0.4)));
assert!(
got != "Pfeil" && got != "Doppelpfeil",
"Zickzack erkannt als {got}"
);
}
#[test]
fn test_ellipse_and_arrow_transforms_and_mesh() {
let mut ellipse = RecognizedShape::Ellipse {
center: Vec2::new(500.0, 500.0),
radius_x: 200.0,
radius_y: 100.0,
rotation: 0.0,
};
ellipse.translate(Vec2::new(10.0, 20.0));
ellipse.rotate(Vec2::new(510.0, 520.0), std::f32::consts::FRAC_PI_2);
ellipse.scale(Vec2::new(510.0, 520.0), Vec2::new(2.0, 1.0));
if let RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} = ellipse
{
assert!((center - Vec2::new(510.0, 520.0)).length() < 1e-3);
// Nach 90°-Drehung liegt die lange Achse senkrecht; die x-Skalierung trifft die kurze
assert!((radius_x - 200.0).abs() < 1e-2, "radius_x {radius_x}");
assert!((radius_y - 200.0).abs() < 1e-2, "radius_y {radius_y}");
assert!((rotation.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3);
} else {
panic!("Ellipse erwartet");
}
// Ellipsen-Mesh: alle Abtastpunkte liegen auf der Ellipse
let shape = RecognizedShape::Ellipse {
center: Vec2::new(0.0, 0.0),
radius_x: 300.0,
radius_y: 120.0,
rotation: 0.5,
};
let (samples, vertices) = shape_to_mesh(&shape, 5.0, [0.0; 4]);
assert!(!vertices.is_empty());
let pts: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
assert!(contour_rms(&pts, &shape).unwrap() < 1.0);
// Pfeil: Verschieben/Skalieren bewegt beide Enden, Mesh hat Spitze(n)
let mut arrow = RecognizedShape::Arrow {
start: Vec2::new(0.0, 0.0),
end: Vec2::new(100.0, 0.0),
double: true,
};
arrow.translate(Vec2::new(5.0, 5.0));
arrow.scale(Vec2::new(5.0, 5.0), Vec2::new(2.0, 2.0));
assert_eq!(
arrow,
RecognizedShape::Arrow {
start: Vec2::new(5.0, 5.0),
end: Vec2::new(205.0, 5.0),
double: true
}
);
let (_, single_v) = shape_to_mesh(
&RecognizedShape::Arrow {
start: Vec2::ZERO,
end: Vec2::new(300.0, 0.0),
double: false,
},
5.0,
[0.0; 4],
);
let (_, double_v) = shape_to_mesh(&arrow, 5.0, [0.0; 4]);
assert_eq!(double_v.len(), single_v.len() + 5, "zweite Spitze fehlt");
}
} }
+1
View File
@@ -8,6 +8,7 @@ pub mod export;
pub mod filter; pub mod filter;
pub mod geometry; pub mod geometry;
pub mod history; pub mod history;
pub mod platform;
pub mod selection; pub mod selection;
pub mod settings; pub mod settings;
pub mod spline; pub mod spline;
+17 -2
View File
@@ -9,8 +9,9 @@ use winit::{
event::{ event::{
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
}, },
event_loop::{ControlFlow, EventLoop}, event_loop::{ControlFlow, EventLoopBuilder},
keyboard::{Key, ModifiersState, NamedKey}, keyboard::{Key, ModifiersState, NamedKey},
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
window::{Window, WindowBuilder}, window::{Window, WindowBuilder},
}; };
@@ -20,6 +21,7 @@ use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground}; use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
use ink_notes::geometry::{self, RecognizedShape}; use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{ use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
}; };
@@ -137,7 +139,20 @@ fn commit_stroke_to_notebook(
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?; // Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
let mut event_loop_builder = EventLoopBuilder::new();
match platform::display_backend_from_env() {
DisplayBackend::X11 => {
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
event_loop_builder.with_x11();
}
DisplayBackend::Wayland => {
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
event_loop_builder.with_wayland();
}
DisplayBackend::Automatic => {}
}
let event_loop = event_loop_builder.build()?;
let window = Arc::new( let window = Arc::new(
WindowBuilder::new() WindowBuilder::new()
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)") .with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
+96
View File
@@ -0,0 +1,96 @@
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
//!
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
//! übersteuert die Wahl.
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
Automatic,
X11,
Wayland,
}
/// Wählt das Anzeigesystem.
///
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
/// - `is_wsl`: läuft unter WSL
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
pub fn choose_display_backend(
override_value: Option<&str>,
is_wsl: bool,
has_x11_display: bool,
) -> DisplayBackend {
match override_value
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("x11") => return DisplayBackend::X11,
Some("wayland") => return DisplayBackend::Wayland,
_ => {}
}
if is_wsl && has_x11_display {
DisplayBackend::X11
} else {
DisplayBackend::Automatic
}
}
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
pub fn is_wsl() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
/// Wahl anhand der aktuellen Umgebung.
pub fn display_backend_from_env() -> DisplayBackend {
choose_display_backend(
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
is_wsl(),
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
assert_eq!(
choose_display_backend(None, true, true),
DisplayBackend::X11
);
// WSL ohne X11-Display: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, true, false),
DisplayBackend::Automatic
);
// Natives Linux: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, false, true),
DisplayBackend::Automatic
);
// Übersteuerung
assert_eq!(
choose_display_backend(Some("wayland"), true, true),
DisplayBackend::Wayland
);
assert_eq!(
choose_display_backend(Some(" X11 "), false, true),
DisplayBackend::X11
);
assert_eq!(
choose_display_backend(Some("quatsch"), true, true),
DisplayBackend::X11
);
}
}
+51 -2
View File
@@ -124,8 +124,12 @@ impl StrokeData {
} }
} }
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized). /// Aktuelle Dateiformat-Version von ink-notes.
pub const CURRENT_FORMAT_VERSION: u32 = 2; /// - Version 2: Stroke-Treue mit tool, width, finalized
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
/// sauber als „neuere Version“ ab.
pub const CURRENT_FORMAT_VERSION: u32 = 3;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -2233,4 +2237,49 @@ mod tests {
"Seitendatei wurde überschrieben" "Seitendatei wurde überschrieben"
); );
} }
#[test]
fn test_new_shapes_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
let shapes = [
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(500.0, 600.0),
radius_x: 250.0,
radius_y: 120.0,
rotation: 0.3,
},
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(100.0, 100.0),
end: glam::Vec2::new(700.0, 300.0),
double: true,
},
];
for (i, shape) in shapes.iter().enumerate() {
notebook
.current_page_mut()
.strokes
.push(crate::geometry::shape_to_stroke(
shape.clone(),
i as u64 + 1,
[0.0, 0.0, 0.0, 1.0],
5.0,
));
}
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(
load_manifest(&dir).unwrap().format_version,
CURRENT_FORMAT_VERSION
);
let (reloaded, _) = load_notebook(&dir).unwrap();
let loaded: Vec<_> = reloaded
.current_page()
.strokes
.iter()
.map(|s| s.shape.clone())
.collect();
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
}
} }