Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
616fc57666 | ||
|
|
d150fd1883 | ||
|
|
f9ede537ed | ||
|
|
9571e2121c | ||
|
|
4a7627e461 |
No files matched your search
Generated
+1
-1
@@ -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
@@ -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,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
|
||||||
|
|||||||
+121
-65
@@ -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)
|
points: vec![
|
||||||
let line_y = Line {
|
(Point::new(Mm(ox), Mm(oy)), false),
|
||||||
points: vec![
|
(Point::new(Mm(ex), Mm(ey)), false),
|
||||||
(Point::new(Mm(ox), Mm(oy)), false),
|
],
|
||||||
(Point::new(Mm(yx), Mm(yy)), 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);
|
mode: PaintMode::Fill,
|
||||||
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
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,
|
|
||||||
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: [
|
||||||
|
|||||||
+632
-67
@@ -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);
|
let last_v = *vertices.last().unwrap();
|
||||||
|
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||||
// Pfeilspitze Y
|
vertices.push(last_v);
|
||||||
let y_base = *y_axis_end - y_dir * arrow_len;
|
vertices.push(tip_v);
|
||||||
let y_left = y_base + Vec2::new(-y_dir.y, y_dir.x) * arrow_w;
|
vertices.push(tip_v);
|
||||||
let y_right = y_base - Vec2::new(-y_dir.y, y_dir.x) * arrow_w;
|
vertices.push(InkVertex::new(left.x, left.y, color));
|
||||||
|
vertices.push(InkVertex::new(right.x, right.y, color));
|
||||||
let last_v = *vertices.last().unwrap();
|
}
|
||||||
let tip_v = InkVertex::new(y_axis_end.x, y_axis_end.y, color);
|
|
||||||
vertices.push(last_v);
|
|
||||||
vertices.push(tip_v);
|
|
||||||
|
|
||||||
vertices.push(tip_v);
|
|
||||||
vertices.push(InkVertex::new(y_left.x, y_left.y, color));
|
|
||||||
vertices.push(InkVertex::new(y_right.x, y_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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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)")
|
||||||
|
|||||||
@@ -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
@@ -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, ¬ebook, &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<_>>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user