Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d | ||
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 |
No files matched your search
Generated
+1
-1
@@ -1048,7 +1048,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.2.3"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.2.3"
|
version = "0.3.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
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:
|
||||||
|
- **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
|
||||||
|
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
|
||||||
|
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert
|
||||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||||
|
|||||||
+6
-1
@@ -312,7 +312,12 @@ impl AutosaveController {
|
|||||||
|
|
||||||
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
||||||
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
||||||
if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
|
// Nicht geladene Platzhalter nie schreiben: Sie sind leer und würden den Inhalt
|
||||||
|
// der Seitendatei überschreiben
|
||||||
|
if self.read_only
|
||||||
|
|| !page.is_loaded
|
||||||
|
|| page.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let page_data = PageData::from(page);
|
let page_data = PageData::from(page);
|
||||||
|
|||||||
+29
-8
@@ -94,6 +94,9 @@ pub struct CanvasState {
|
|||||||
pub config: wgpu::SurfaceConfiguration,
|
pub config: wgpu::SurfaceConfiguration,
|
||||||
pub size: PhysicalSize<u32>,
|
pub size: PhysicalSize<u32>,
|
||||||
pub viewport: PageViewport,
|
pub viewport: PageViewport,
|
||||||
|
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||||
|
ui_inset_left: f32,
|
||||||
|
ui_inset_right: f32,
|
||||||
pub camera: crate::viewport::ViewportState,
|
pub camera: crate::viewport::ViewportState,
|
||||||
|
|
||||||
// Pipelines
|
// Pipelines
|
||||||
@@ -371,6 +374,8 @@ impl CanvasState {
|
|||||||
config,
|
config,
|
||||||
size,
|
size,
|
||||||
viewport,
|
viewport,
|
||||||
|
ui_inset_left: 0.0,
|
||||||
|
ui_inset_right: 0.0,
|
||||||
strip_pipeline,
|
strip_pipeline,
|
||||||
triangle_pipeline,
|
triangle_pipeline,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
@@ -418,6 +423,29 @@ impl CanvasState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||||
|
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||||
|
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.ui_inset_left = left;
|
||||||
|
self.ui_inset_right = right;
|
||||||
|
self.recalculate_viewport();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recalculate_viewport(&mut self) {
|
||||||
|
self.viewport = PageViewport::calculate_with_insets(
|
||||||
|
self.size.width as f32,
|
||||||
|
self.size.height as f32,
|
||||||
|
Self::VIEWPORT_MARGIN,
|
||||||
|
self.ui_inset_left,
|
||||||
|
self.ui_inset_right,
|
||||||
|
);
|
||||||
|
self.update_uniforms();
|
||||||
|
}
|
||||||
|
|
||||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||||
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||||
if new_size.width > 0 && new_size.height > 0 {
|
if new_size.width > 0 && new_size.height > 0 {
|
||||||
@@ -425,14 +453,7 @@ impl CanvasState {
|
|||||||
self.config.width = new_size.width;
|
self.config.width = new_size.width;
|
||||||
self.config.height = new_size.height;
|
self.config.height = new_size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.recalculate_viewport();
|
||||||
self.viewport = PageViewport::calculate(
|
|
||||||
new_size.width as f32,
|
|
||||||
new_size.height as f32,
|
|
||||||
Self::VIEWPORT_MARGIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.update_uniforms();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -772,7 +772,21 @@ pub struct PageViewport {
|
|||||||
impl PageViewport {
|
impl PageViewport {
|
||||||
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
||||||
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
||||||
let avail_w = (window_width - 2.0 * margin).max(10.0);
|
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
|
||||||
|
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
|
||||||
|
pub fn calculate_with_insets(
|
||||||
|
window_width: f32,
|
||||||
|
window_height: f32,
|
||||||
|
margin: f32,
|
||||||
|
inset_left: f32,
|
||||||
|
inset_right: f32,
|
||||||
|
) -> Self {
|
||||||
|
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
|
||||||
|
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
|
||||||
|
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
|
||||||
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
||||||
|
|
||||||
let window_ratio = avail_w / avail_h;
|
let window_ratio = avail_w / avail_h;
|
||||||
@@ -790,7 +804,7 @@ impl PageViewport {
|
|||||||
(w, h)
|
(w, h)
|
||||||
};
|
};
|
||||||
|
|
||||||
let page_x = (window_width - page_w) / 2.0;
|
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
|
||||||
let page_y = (window_height - page_h) / 2.0;
|
let page_y = (window_height - page_h) / 2.0;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -931,6 +945,25 @@ mod tests {
|
|||||||
assert_eq!(nb.current_page_index, 0);
|
assert_eq!(nb.current_page_index, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewport_insets_center_page_in_free_area() {
|
||||||
|
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
|
||||||
|
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
|
||||||
|
assert!((inset.page_width - plain.page_width).abs() < 0.01);
|
||||||
|
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
|
||||||
|
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
|
||||||
|
|
||||||
|
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
|
||||||
|
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
|
||||||
|
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
|
||||||
|
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
|
||||||
|
|
||||||
|
// Leiste rechts
|
||||||
|
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
|
||||||
|
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_viewport_projection() {
|
fn test_page_viewport_projection() {
|
||||||
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
|||||||
+759
-96
@@ -409,7 +409,13 @@ pub fn detect_circle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 3. Rechteckerkennung: Geschlossener Pfad mit 4 dominanten Ecken (~90° +/- 18°).
|
/// 3. Rechteckerkennung: kleinstes umschließendes Rechteck eines geschlossenen Pfads.
|
||||||
|
///
|
||||||
|
/// Statt Ecken über eine grobe Linienvereinfachung zu suchen (das schnitt bei kleinen oder
|
||||||
|
/// zittrigen Strichen die Ecken ab), wird über alle Drehwinkel das Rechteck mit der
|
||||||
|
/// kleinsten Fläche gesucht. Einzelne Ausreißer (z. B. ein Überstrich am Schluss) zählen
|
||||||
|
/// nicht: Die Ausdehnung wird über das 2.–98. Perzentil der Punkte bestimmt. Angenommen
|
||||||
|
/// wird das Rechteck nur, wenn es den Strich eng beschreibt.
|
||||||
pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||||
if points.len() < 8 {
|
if points.len() < 8 {
|
||||||
return None;
|
return None;
|
||||||
@@ -429,78 +435,99 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RDP mit ca. 5% Pfadlänge als Toleranz
|
// Ausdehnung entlang einer Achse (Winkel in Grad), robust gegen Ausreißer
|
||||||
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
let extent = |angle_deg: f32| -> (f32, f32, f32, f32) {
|
||||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
let (sin, cos) = angle_deg.to_radians().sin_cos();
|
||||||
|
let mut along: Vec<f32> = points.iter().map(|p| p.x * cos + p.y * sin).collect();
|
||||||
let corners: Vec<Vec2> =
|
let mut across: Vec<f32> = points.iter().map(|p| -p.x * sin + p.y * cos).collect();
|
||||||
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
along.sort_by(f32::total_cmp);
|
||||||
simplified[0..4].to_vec()
|
across.sort_by(f32::total_cmp);
|
||||||
} else if simplified.len() == 4 {
|
let lo = points.len() * 2 / 100;
|
||||||
simplified
|
let hi = points.len() - 1 - lo;
|
||||||
} else if simplified.len() > 4 {
|
(along[lo], along[hi], across[lo], across[hi])
|
||||||
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
|
};
|
||||||
extract_k_corners(&simplified, 4)?
|
let area = |angle_deg: f32| {
|
||||||
} else {
|
let (a0, a1, b0, b1) = extent(angle_deg);
|
||||||
return None;
|
(a1 - a0) * (b1 - b0)
|
||||||
};
|
};
|
||||||
|
|
||||||
if corners.len() != 4 {
|
// Grobsuche in 1°-Schritten, dann Feinsuche in 0,1°-Schritten um das Optimum
|
||||||
return None;
|
let mut best_angle = (0..90)
|
||||||
|
.map(|d| d as f32)
|
||||||
|
.min_by(|a, b| area(*a).total_cmp(&area(*b)))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
best_angle = (-10..=10)
|
||||||
|
.map(|d| best_angle + d as f32 * 0.1)
|
||||||
|
.min_by(|a, b| area(*a).total_cmp(&area(*b)))
|
||||||
|
.unwrap_or(best_angle);
|
||||||
|
|
||||||
|
// Nahe an der Achsenparallelität (≤ 10°) gerade ausrichten
|
||||||
|
let rem = best_angle.rem_euclid(90.0);
|
||||||
|
if rem <= RECTANGLE_AXIS_SNAP_DEG || rem >= 90.0 - RECTANGLE_AXIS_SNAP_DEG {
|
||||||
|
best_angle = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Innenwinkel prüfen: 4 Kanten müssen jeweils ~90° (+/- 18°) zueinander stehen
|
let (mut a0, mut a1, mut b0, mut b1) = extent(best_angle);
|
||||||
let c = [corners[0], corners[1], corners[2], corners[3]];
|
let (sin, cos) = best_angle.to_radians().sin_cos();
|
||||||
for i in 0..4 {
|
let dir = Vec2::new(cos, sin);
|
||||||
let prev = c[(i + 3) % 4];
|
let norm = Vec2::new(-sin, cos);
|
||||||
let curr = c[i];
|
|
||||||
let next = c[(i + 1) % 4];
|
// Kanten in die Mitte des Zitterns legen: Jeder Punkt gehört zur nächsten Kante, die Kante
|
||||||
let v1 = (prev - curr).normalize_or_zero();
|
// wandert auf den Median ihrer Punkte (das umschließende Rechteck liegt außen an den Spitzen)
|
||||||
let v2 = (next - curr).normalize_or_zero();
|
let mut sides: [Vec<f32>; 4] = Default::default();
|
||||||
let dot = v1.dot(v2).clamp(-1.0, 1.0);
|
for p in points {
|
||||||
let angle_deg = dot.acos().to_degrees();
|
let (u, v) = (p.dot(dir), p.dot(norm));
|
||||||
if !(72.0..=108.0).contains(&angle_deg) {
|
let d = [
|
||||||
return None;
|
(u - a0).abs(),
|
||||||
|
(u - a1).abs(),
|
||||||
|
(v - b0).abs(),
|
||||||
|
(v - b1).abs(),
|
||||||
|
];
|
||||||
|
let side = (0..4).min_by(|&i, &j| d[i].total_cmp(&d[j])).unwrap_or(0);
|
||||||
|
sides[side].push(if side < 2 { u } else { v });
|
||||||
}
|
}
|
||||||
|
let median = |values: &mut Vec<f32>, fallback: f32| {
|
||||||
|
if values.is_empty() {
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
values.sort_by(f32::total_cmp);
|
||||||
// Saubere rechtwinklige Ausrichtung
|
values[values.len() / 2]
|
||||||
let aabb = Aabb::from_points(&c);
|
|
||||||
let center = (c[0] + c[1] + c[2] + c[3]) * 0.25;
|
|
||||||
|
|
||||||
let e0 = c[1] - c[0];
|
|
||||||
let angle_deg = e0.y.atan2(e0.x).to_degrees().abs();
|
|
||||||
|
|
||||||
// Nahe an Achsenparallelität (< 12°)?
|
|
||||||
let snapped_corners = if angle_deg < 12.0
|
|
||||||
|| (angle_deg - 90.0).abs() < 12.0
|
|
||||||
|| (angle_deg - 180.0).abs() < 12.0
|
|
||||||
{
|
|
||||||
[
|
|
||||||
Vec2::new(aabb.min.x, aabb.min.y),
|
|
||||||
Vec2::new(aabb.max.x, aabb.min.y),
|
|
||||||
Vec2::new(aabb.max.x, aabb.max.y),
|
|
||||||
Vec2::new(aabb.min.x, aabb.max.y),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
// Rotiertes Rechteck mit exakt 90° Kanten
|
|
||||||
let dir = e0.normalize_or_zero();
|
|
||||||
let norm = Vec2::new(-dir.y, dir.x);
|
|
||||||
let w = 0.5 * ((c[1] - c[0]).length() + (c[2] - c[3]).length());
|
|
||||||
let h = 0.5 * ((c[2] - c[1]).length() + (c[3] - c[0]).length());
|
|
||||||
let hw = w * 0.5;
|
|
||||||
let hh = h * 0.5;
|
|
||||||
[
|
|
||||||
center - dir * hw - norm * hh,
|
|
||||||
center + dir * hw - norm * hh,
|
|
||||||
center + dir * hw + norm * hh,
|
|
||||||
center - dir * hw + norm * hh,
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
a0 = median(&mut sides[0], a0);
|
||||||
|
a1 = median(&mut sides[1], a1);
|
||||||
|
b0 = median(&mut sides[2], b0);
|
||||||
|
b1 = median(&mut sides[3], b1);
|
||||||
|
|
||||||
Some(RecognizedShape::Rectangle {
|
let corners = [
|
||||||
corners: snapped_corners,
|
dir * a0 + norm * b0,
|
||||||
})
|
dir * a1 + norm * b0,
|
||||||
|
dir * a1 + norm * b1,
|
||||||
|
dir * a0 + norm * b1,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Alle vier Ecken müssen gezeichnet sein: Ovale und Kreise schneiden die Ecken ab
|
||||||
|
// (Kreis: ~20 % der Seitenlänge), sonst würden flache Ellipsen zu Rechtecken
|
||||||
|
let shorter_side = (a1 - a0).abs().min((b1 - b0).abs());
|
||||||
|
let corner_reached = |corner: Vec2| {
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.map(|p| (*p - corner).length())
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
<= RECTANGLE_CORNER_REACH * shorter_side
|
||||||
|
};
|
||||||
|
if !corners.iter().all(|c| corner_reached(*c)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur annehmen, wenn das Rechteck den Strich eng beschreibt (sonst wären beliebige
|
||||||
|
// geschlossene Kritzeleien „Rechtecke“)
|
||||||
|
let rect = RecognizedShape::Rectangle { corners };
|
||||||
|
let err = contour_rms(points, &rect)?;
|
||||||
|
let diagonal = (corners[2] - corners[0]).length();
|
||||||
|
if err > RECTANGLE_MAX_RMS * diagonal {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rastet einen Winkel in Radiant an horizontalen, vertikalen oder diagonalen Achsen ein.
|
/// Rastet einen Winkel in Radiant an horizontalen, vertikalen oder diagonalen Achsen ein.
|
||||||
@@ -543,12 +570,15 @@ pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option<Vec<Vec2>> {
|
|||||||
let next = points[(i + 1) % n];
|
let next = points[(i + 1) % n];
|
||||||
let v1 = (prev - curr).normalize_or_zero();
|
let v1 = (prev - curr).normalize_or_zero();
|
||||||
let v2 = (next - curr).normalize_or_zero();
|
let v2 = (next - curr).normalize_or_zero();
|
||||||
// Dot product: je kleiner, desto spitzer/schärfer die Richtungsänderung
|
// Skalarprodukt der Richtungen zu den Nachbarn: −1 = gerader Verlauf (180°),
|
||||||
|
// 0 = rechter Winkel, gegen +1 = spitze Ecke. Je größer, desto markanter die Ecke.
|
||||||
let dot = v1.dot(v2).clamp(-1.0, 1.0);
|
let dot = v1.dot(v2).clamp(-1.0, 1.0);
|
||||||
scored.push((i, dot));
|
scored.push((i, dot));
|
||||||
}
|
}
|
||||||
|
|
||||||
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
// Markanteste Ecken zuerst (größtes Skalarprodukt). Vorher war die Sortierung
|
||||||
|
// umgekehrt und wählte die geradesten Punkte.
|
||||||
|
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
|
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
|
||||||
selected_indices.sort_unstable();
|
selected_indices.sort_unstable();
|
||||||
|
|
||||||
@@ -844,6 +874,11 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let corners = simplified;
|
let corners = simplified;
|
||||||
|
// Echte Ecken verlangen: Bei einem glatten Oval verteilt sich die Richtungsänderung
|
||||||
|
// gleichmäßig, die Vereinfachung liefert aber trotzdem 6 oder 8 Punkte
|
||||||
|
if !turning_concentrated_at_corners(points, &corners) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let center = corners.iter().copied().sum::<Vec2>() / (n as f32);
|
let center = corners.iter().copied().sum::<Vec2>() / (n as f32);
|
||||||
|
|
||||||
let radii: Vec<f32> = corners.iter().map(|&pt| (pt - center).length()).collect();
|
let radii: Vec<f32> = corners.iter().map(|&pt| (pt - center).length()).collect();
|
||||||
@@ -877,15 +912,93 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss.
|
||||||
|
const POLYGON_TURNING_AT_CORNERS: f32 = 0.7;
|
||||||
|
/// Umgebung einer Ecke als Anteil des Umfangs.
|
||||||
|
const POLYGON_CORNER_NEIGHBORHOOD: f32 = 0.03;
|
||||||
|
|
||||||
|
/// Liegt die Richtungsänderung des Strichs überwiegend an den Ecken (Vieleck) und nicht
|
||||||
|
/// gleichmäßig verteilt (Oval)? Gemessen wird die vorzeichenbehaftete Drehung entlang des
|
||||||
|
/// gleichmäßig neu abgetasteten Strichs; Zittern hebt sich dabei weitgehend auf.
|
||||||
|
fn turning_concentrated_at_corners(points: &[Vec2], corners: &[Vec2]) -> bool {
|
||||||
|
corner_turning_ratio(points, corners).is_some_and(|r| r >= POLYGON_TURNING_AT_CORNERS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anteil der vorzeichenbehafteten Richtungsänderung, der in der Nähe der Ecken liegt.
|
||||||
|
fn corner_turning_ratio(points: &[Vec2], corners: &[Vec2]) -> Option<f32> {
|
||||||
|
let total_len = path_length(points);
|
||||||
|
if total_len < 1e-3 || corners.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Gleichmäßig neu abtasten (120 Punkte entlang der Bogenlänge)
|
||||||
|
const SAMPLES: usize = 120;
|
||||||
|
let step = total_len / SAMPLES as f32;
|
||||||
|
let mut resampled = vec![points[0]];
|
||||||
|
let mut carried = 0.0;
|
||||||
|
for w in points.windows(2) {
|
||||||
|
let (a, b) = (w[0], w[1]);
|
||||||
|
let seg = (b - a).length();
|
||||||
|
let mut pos = step - carried;
|
||||||
|
while pos <= seg {
|
||||||
|
resampled.push(a + (b - a) * (pos / seg));
|
||||||
|
pos += step;
|
||||||
|
}
|
||||||
|
carried = seg - (pos - step);
|
||||||
|
}
|
||||||
|
// Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten
|
||||||
|
const W: usize = 3;
|
||||||
|
if resampled.len() < 2 * W + 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let near = POLYGON_CORNER_NEIGHBORHOOD * total_len;
|
||||||
|
let (mut turn_total, mut turn_near) = (0.0_f32, 0.0_f32);
|
||||||
|
for i in W..resampled.len() - W {
|
||||||
|
let d_in = resampled[i] - resampled[i - W];
|
||||||
|
let d_out = resampled[i + W] - resampled[i];
|
||||||
|
let turn = d_in.perp_dot(d_out).atan2(d_in.dot(d_out)) / W as f32;
|
||||||
|
turn_total += turn;
|
||||||
|
if corners.iter().any(|c| (*c - resampled[i]).length() <= near) {
|
||||||
|
turn_near += turn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(turn_total.abs() > 1e-3).then(|| turn_near / turn_total)
|
||||||
|
}
|
||||||
|
|
||||||
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
||||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||||
if simplified.len() >= 2
|
let mut vertices = if simplified.len() >= 2
|
||||||
&& (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0
|
&& (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0
|
||||||
{
|
{
|
||||||
simplified[0..simplified.len() - 1].to_vec()
|
simplified[0..simplified.len() - 1].to_vec()
|
||||||
} else {
|
} else {
|
||||||
simplified
|
simplified
|
||||||
|
};
|
||||||
|
// Überzählige Punkte entfernen: zu dicht am Nachbarn (z. B. das Strichende kurz vor dem
|
||||||
|
// Start) oder fast auf einer Geraden mit den Nachbarn (< 20° Richtungsänderung)
|
||||||
|
loop {
|
||||||
|
let n = vertices.len();
|
||||||
|
if n <= 3 {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
let redundant = (0..n).find(|&i| {
|
||||||
|
let (prev, curr, next) = (
|
||||||
|
vertices[(i + n - 1) % n],
|
||||||
|
vertices[i],
|
||||||
|
vertices[(i + 1) % n],
|
||||||
|
);
|
||||||
|
let d_in = curr - prev;
|
||||||
|
let d_out = next - curr;
|
||||||
|
let turn = d_in.perp_dot(d_out).atan2(d_in.dot(d_out)).abs();
|
||||||
|
(next - curr).length() < epsilon || turn < 20f32.to_radians()
|
||||||
|
});
|
||||||
|
match redundant {
|
||||||
|
Some(i) => {
|
||||||
|
vertices.remove(i);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vertices
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 7. Oktaeder-Erkennung (3D-Kantenmodell / Doppelpyramide).
|
/// 7. Oktaeder-Erkennung (3D-Kantenmodell / Doppelpyramide).
|
||||||
@@ -1035,37 +1148,171 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
|||||||
}
|
}
|
||||||
let pts: Vec<Vec2> = points.iter().map(|s| s.position).collect();
|
let pts: Vec<Vec2> = points.iter().map(|s| s.position).collect();
|
||||||
|
|
||||||
// 1. Zuerst 3D-Kantenmodelle prüfen (Oktaeder / Doppelpyramide)
|
// 1. Geschlossene Formen: Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird
|
||||||
if let Some(octa) = detect_octahedron(&pts) {
|
// die Form, deren Kontur am besten zu den gezeichneten Punkten passt. Eine feste
|
||||||
return Some(octa);
|
// Reihenfolge genügt nicht: Auch Quadrate schwanken nur um ~10 % im Abstand zum
|
||||||
}
|
// Mittelpunkt und bestünden den Kreistest, und die Dreieckserkennung akzeptiert
|
||||||
|
// gestreckte Rechtecke.
|
||||||
|
let best_closed = best_closed_shape(&pts);
|
||||||
|
|
||||||
// 2. Geschlossene Formen prüfen (Kreis, Dreieck, Rechteck, Raute, Polygon/Oktagon)
|
// 2. 3D-Kantenmodelle (Oktaeder / Doppelpyramide) nur, wenn keine geschlossene Form den
|
||||||
if let Some(circle) = detect_circle(&pts) {
|
// Strich gut beschreibt: Ein Oktaeder enthält innere Kanten, die weit von jedem Umriss
|
||||||
return Some(circle);
|
// entfernt liegen. Ein sauber passender Umriss ist daher kein Oktaeder.
|
||||||
}
|
let closed_fits_well = best_closed.as_ref().is_some_and(|(err, _)| {
|
||||||
if let Some(triangle) = detect_triangle(&pts) {
|
let aabb = Aabb::from_points(&pts);
|
||||||
return Some(triangle);
|
*err <= CLOSED_SHAPE_GOOD_FIT * (aabb.max - aabb.min).length()
|
||||||
}
|
});
|
||||||
if let Some(rect) = detect_rectangle(&pts) {
|
let best_closed = best_closed.map(|(_, shape)| shape);
|
||||||
return Some(rect);
|
|
||||||
}
|
|
||||||
if let Some(rhombus) = detect_rhombus(&pts) {
|
|
||||||
return Some(rhombus);
|
|
||||||
}
|
|
||||||
if let Some(polygon) = detect_polygon(&pts) {
|
|
||||||
return Some(polygon);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Offene Formen: Koordinatensystem oder Gerade
|
|
||||||
if let Some(coord) = detect_coordinate_system(&pts) {
|
|
||||||
return Some(coord);
|
|
||||||
}
|
|
||||||
if let Some(line) = detect_line(&pts) {
|
|
||||||
return Some(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
||||||
|
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
||||||
|
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
||||||
|
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
|
||||||
|
&|| {
|
||||||
|
if closed_fits_well {
|
||||||
None
|
None
|
||||||
|
} else {
|
||||||
|
detect_octahedron(&pts)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
&|| best_closed.clone(),
|
||||||
|
&|| detect_polygon(&pts),
|
||||||
|
&|| detect_coordinate_system(&pts),
|
||||||
|
&|| detect_line(&pts),
|
||||||
|
&|| None,
|
||||||
|
];
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.filter_map(|candidate| candidate())
|
||||||
|
.find(|shape| shape_describes_stroke(&pts, shape))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Größte zulässige mittlere Abweichung (RMS) des Strichs von der erkannten Form, als Anteil
|
||||||
|
/// der Strichdiagonale. Echte Formen liegen bei 0,5–3 %, Freiformen (Herz, Stern, Wolke,
|
||||||
|
/// Spirale) und flache Ellipsen bei 5–6,5 %.
|
||||||
|
const SHAPE_MAX_FIT_ERROR: f32 = 0.04;
|
||||||
|
|
||||||
|
/// Mittlere Abweichung (RMS) des Strichs von einer Form, relativ zur Strichdiagonale.
|
||||||
|
/// Gemessen wird gegen den Linienzug der Punkte, aus denen die Form gezeichnet wird; das
|
||||||
|
/// funktioniert für alle Formtypen gleich (auch Linien, Koordinatensysteme und Oktaeder).
|
||||||
|
pub fn shape_fit_error(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
||||||
|
if points.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (model, _) = shape_to_mesh(shape, 1.0, [0.0; 4]);
|
||||||
|
if model.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let aabb = Aabb::from_points(points);
|
||||||
|
let diagonal = (aabb.max - aabb.min).length();
|
||||||
|
if diagonal < 1e-3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Abstand zum Linienzug der Modellpunkte (nicht zu den Punkten selbst: manche Formen,
|
||||||
|
// z. B. das Koordinatensystem, liefern nur ihre Eckpunkte)
|
||||||
|
let model: Vec<Vec2> = model.iter().map(|m| m.position).collect();
|
||||||
|
let sum_sq: f32 = points
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let d = if model.len() == 1 {
|
||||||
|
(model[0] - *p).length()
|
||||||
|
} else {
|
||||||
|
model
|
||||||
|
.windows(2)
|
||||||
|
.map(|w| segment_distance(*p, w[0], w[1]))
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
};
|
||||||
|
d * d
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
Some((sum_sq / points.len() as f32).sqrt() / diagonal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen?
|
||||||
|
fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool {
|
||||||
|
shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale.
|
||||||
|
const RECTANGLE_MAX_RMS: f32 = 0.05;
|
||||||
|
/// Jede Rechteckecke muss bis auf diesen Anteil der kürzeren Seite gezeichnet sein.
|
||||||
|
const RECTANGLE_CORNER_REACH: f32 = 0.15;
|
||||||
|
/// Rechtecke, die höchstens so viele Grad schief sind, werden achsenparallel ausgerichtet.
|
||||||
|
const RECTANGLE_AXIS_SNAP_DEG: f32 = 10.0;
|
||||||
|
|
||||||
|
/// Anteil der Diagonale, bis zu dem die Abweichung einer geschlossenen Form als „passt gut“ gilt.
|
||||||
|
const CLOSED_SHAPE_GOOD_FIT: f32 = 0.04;
|
||||||
|
/// Gewichte der Abweichung im Formvergleich (kleiner = bevorzugt). Ein Rechteck gewinnt gegen
|
||||||
|
/// Raute und Vieleck bis zur 1,5-fachen Abweichung (sonst würden Quadrate oft zur Raute);
|
||||||
|
/// ein Vieleck gewinnt entsprechend gegen den Kreis.
|
||||||
|
const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5;
|
||||||
|
const CIRCLE_WEIGHT: f32 = 1.5;
|
||||||
|
|
||||||
|
/// Wählt unter Kreis, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
|
||||||
|
/// und liefert sie mit ihrer Abweichung (RMS).
|
||||||
|
fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
|
||||||
|
// Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht.
|
||||||
|
// Rechte Winkel schlagen die freiere Raute bzw. ein freies Vieleck; ein Vieleck mit
|
||||||
|
// nachgewiesenen Ecken schlägt den Kreis (ein Achteck ist fast ein Kreis).
|
||||||
|
let fit = |shape: Option<RecognizedShape>, weight: f32| {
|
||||||
|
shape.and_then(|s| contour_rms(pts, &s).map(|err| (err * weight, s)))
|
||||||
|
};
|
||||||
|
[
|
||||||
|
fit(detect_circle(pts), CIRCLE_WEIGHT),
|
||||||
|
fit(detect_triangle(pts), 1.0),
|
||||||
|
fit(detect_rectangle(pts), RECTANGLE_WEIGHT),
|
||||||
|
fit(detect_rhombus(pts), 1.0),
|
||||||
|
fit(detect_polygon(pts), 1.0),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstand eines Punktes zur Strecke `a`–`b`.
|
||||||
|
fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
|
let ab = b - a;
|
||||||
|
let len_sq = ab.length_squared();
|
||||||
|
if len_sq < 1e-9 {
|
||||||
|
return (p - a).length();
|
||||||
|
}
|
||||||
|
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||||
|
(p - (a + ab * t)).length()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstand eines Punktes zum Rand eines geschlossenen Polygons.
|
||||||
|
fn polygon_distance(p: Vec2, corners: &[Vec2]) -> f32 {
|
||||||
|
(0..corners.len())
|
||||||
|
.map(|i| segment_distance(p, corners[i], corners[(i + 1) % corners.len()]))
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mittlere quadratische Abweichung (RMS) der Strichpunkte von der Kontur einer erkannten
|
||||||
|
/// geschlossenen Form. Je kleiner, desto besser beschreibt die Form den Strich.
|
||||||
|
/// Für offene Formen (Linie, Koordinatensystem, Oktaeder) gibt es keinen Wert.
|
||||||
|
pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
||||||
|
if points.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let distance = |p: Vec2| -> Option<f32> {
|
||||||
|
match shape {
|
||||||
|
RecognizedShape::Circle { center, radius } => {
|
||||||
|
Some(((p - *center).length() - radius).abs())
|
||||||
|
}
|
||||||
|
RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)),
|
||||||
|
RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => {
|
||||||
|
Some(polygon_distance(p, corners))
|
||||||
|
}
|
||||||
|
RecognizedShape::Polygon { vertices } => Some(polygon_distance(p, vertices)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut sum_sq = 0.0;
|
||||||
|
for &p in points {
|
||||||
|
let d = distance(p)?;
|
||||||
|
sum_sq += d * d;
|
||||||
|
}
|
||||||
|
Some((sum_sq / points.len() as f32).sqrt())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
|
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
|
||||||
@@ -1435,6 +1682,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detected = detect_line(&points);
|
let detected = detect_line(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(detected.is_some(), "Gerade sollte erkannt werden");
|
assert!(detected.is_some(), "Gerade sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Line { start, end }) = detected {
|
if let Some(RecognizedShape::Line { start, end }) = detected {
|
||||||
@@ -1472,6 +1724,11 @@ mod tests {
|
|||||||
points.push(points[0]);
|
points.push(points[0]);
|
||||||
|
|
||||||
let detected = detect_rectangle(&points);
|
let detected = detect_rectangle(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(detected.is_some(), "Rechteck sollte erkannt werden");
|
assert!(detected.is_some(), "Rechteck sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Rectangle { corners }) = detected {
|
if let Some(RecognizedShape::Rectangle { corners }) = detected {
|
||||||
@@ -1508,6 +1765,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detected = detect_circle(&points);
|
let detected = detect_circle(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(detected.is_some(), "Kreis sollte erkannt werden");
|
assert!(detected.is_some(), "Kreis sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Circle {
|
if let Some(RecognizedShape::Circle {
|
||||||
@@ -1543,6 +1805,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detected = detect_coordinate_system(&points);
|
let detected = detect_coordinate_system(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
detected.is_some(),
|
detected.is_some(),
|
||||||
"Koordinatensystem sollte erkannt werden"
|
"Koordinatensystem sollte erkannt werden"
|
||||||
@@ -1594,6 +1861,11 @@ mod tests {
|
|||||||
points.push(a);
|
points.push(a);
|
||||||
|
|
||||||
let detected = detect_triangle(&points);
|
let detected = detect_triangle(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(detected.is_some(), "Dreieck sollte erkannt werden");
|
assert!(detected.is_some(), "Dreieck sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Triangle { corners }) = detected {
|
if let Some(RecognizedShape::Triangle { corners }) = detected {
|
||||||
@@ -1639,6 +1911,11 @@ mod tests {
|
|||||||
points.push(c0);
|
points.push(c0);
|
||||||
|
|
||||||
let detected = detect_rhombus(&points);
|
let detected = detect_rhombus(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(detected.is_some(), "Raute sollte erkannt werden");
|
assert!(detected.is_some(), "Raute sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Rhombus { corners }) = detected {
|
if let Some(RecognizedShape::Rhombus { corners }) = detected {
|
||||||
@@ -1680,6 +1957,11 @@ mod tests {
|
|||||||
points.push(points[0]);
|
points.push(points[0]);
|
||||||
|
|
||||||
let detected = detect_polygon(&points);
|
let detected = detect_polygon(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
detected.is_some(),
|
detected.is_some(),
|
||||||
"Oktagon / Achteck sollte erkannt werden"
|
"Oktagon / Achteck sollte erkannt werden"
|
||||||
@@ -1719,6 +2001,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detected = detect_octahedron(&points);
|
let detected = detect_octahedron(&points);
|
||||||
|
assert_eq!(
|
||||||
|
kind(&detect_shape(&to_samples(&points))),
|
||||||
|
kind(&detected),
|
||||||
|
"Die vollständige Erkennung (detect_shape) muss dieselbe Form liefern"
|
||||||
|
);
|
||||||
assert!(detected.is_some(), "Oktaeder sollte erkannt werden");
|
assert!(detected.is_some(), "Oktaeder sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Octahedron {
|
if let Some(RecognizedShape::Octahedron {
|
||||||
@@ -1737,4 +2024,380 @@ mod tests {
|
|||||||
panic!("Falscher Formtyp erkannt");
|
panic!("Falscher Formtyp erkannt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bildet einen Freihandstrich entlang eines Umrisses nach: Punkte alle ~6 Einheiten
|
||||||
|
/// (0,6 mm), seitliches Zittern um ±4 Einheiten und ein nicht ganz exakter Schluss.
|
||||||
|
fn freehand(outline: &[Vec2], closed: bool, seed: f32) -> Vec<StylusSample> {
|
||||||
|
let mut path = outline.to_vec();
|
||||||
|
if closed {
|
||||||
|
path.push(outline[0]);
|
||||||
|
}
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
let mut i = 0usize;
|
||||||
|
for w in path.windows(2) {
|
||||||
|
let (a, b) = (w[0], w[1]);
|
||||||
|
let len = (b - a).length();
|
||||||
|
let dir = (b - a).normalize_or_zero();
|
||||||
|
let normal = Vec2::new(-dir.y, dir.x);
|
||||||
|
let steps = (len / 6.0).ceil().max(1.0) as usize;
|
||||||
|
for k in 0..steps {
|
||||||
|
let t = k as f32 / steps as f32;
|
||||||
|
let jitter =
|
||||||
|
(i as f32 * 0.9 + seed).sin() * 3.0 + (i as f32 * 2.3 + seed).cos() * 1.0;
|
||||||
|
samples.push(StylusSample {
|
||||||
|
position: a + (b - a) * t + normal * jitter,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: i as f64 * 0.008,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closed {
|
||||||
|
// Strich endet knapp vor dem Startpunkt (2 % des Umfangs)
|
||||||
|
let keep = samples.len() - samples.len() / 50;
|
||||||
|
samples.truncate(keep);
|
||||||
|
}
|
||||||
|
samples
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rect_outline(center: Vec2, w: f32, h: f32, angle_deg: f32) -> Vec<Vec2> {
|
||||||
|
let (s, c) = angle_deg.to_radians().sin_cos();
|
||||||
|
let rot = |v: Vec2| center + Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c);
|
||||||
|
vec![
|
||||||
|
rot(Vec2::new(-w / 2.0, -h / 2.0)),
|
||||||
|
rot(Vec2::new(w / 2.0, -h / 2.0)),
|
||||||
|
rot(Vec2::new(w / 2.0, h / 2.0)),
|
||||||
|
rot(Vec2::new(-w / 2.0, h / 2.0)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn circle_outline(center: Vec2, rx: f32, ry: f32) -> Vec<Vec2> {
|
||||||
|
(0..72)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||||
|
center + Vec2::new(a.cos() * rx, a.sin() * ry)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(shape: &Option<RecognizedShape>) -> &'static str {
|
||||||
|
match shape {
|
||||||
|
Some(RecognizedShape::Line { .. }) => "Linie",
|
||||||
|
Some(RecognizedShape::Circle { .. }) => "Kreis",
|
||||||
|
Some(RecognizedShape::Rectangle { .. }) => "Rechteck",
|
||||||
|
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
||||||
|
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
||||||
|
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
||||||
|
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
||||||
|
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
||||||
|
None => "nichts",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_shape_freehand_classification() {
|
||||||
|
let c = Vec2::new(1000.0, 1400.0);
|
||||||
|
let cases: Vec<(&str, Vec<StylusSample>, &str)> = vec![
|
||||||
|
(
|
||||||
|
"Quadrat 40 mm",
|
||||||
|
freehand(&rect_outline(c, 400.0, 400.0, 0.0), true, 0.3),
|
||||||
|
"Rechteck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Quadrat 15 mm",
|
||||||
|
freehand(&rect_outline(c, 150.0, 150.0, 0.0), true, 1.1),
|
||||||
|
"Rechteck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Quadrat 60 mm",
|
||||||
|
freehand(&rect_outline(c, 600.0, 600.0, 0.0), true, 2.0),
|
||||||
|
"Rechteck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Rechteck 60×30 mm",
|
||||||
|
freehand(&rect_outline(c, 600.0, 300.0, 0.0), true, 0.7),
|
||||||
|
"Rechteck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Rechteck 40×30 mm",
|
||||||
|
freehand(&rect_outline(c, 400.0, 300.0, 0.0), true, 1.7),
|
||||||
|
"Rechteck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Quadrat gedreht 20°",
|
||||||
|
freehand(&rect_outline(c, 400.0, 400.0, 20.0), true, 0.5),
|
||||||
|
"Rechteck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Kreis r = 20 mm",
|
||||||
|
freehand(&circle_outline(c, 200.0, 200.0), true, 0.9),
|
||||||
|
"Kreis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Kreis r = 8 mm",
|
||||||
|
freehand(&circle_outline(c, 80.0, 80.0), true, 1.3),
|
||||||
|
"Kreis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Kreis r = 35 mm",
|
||||||
|
freehand(&circle_outline(c, 350.0, 350.0), true, 2.4),
|
||||||
|
"Kreis",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Dreieck",
|
||||||
|
freehand(
|
||||||
|
&[
|
||||||
|
c + Vec2::new(0.0, -250.0),
|
||||||
|
c + Vec2::new(230.0, 170.0),
|
||||||
|
c + Vec2::new(-230.0, 170.0),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
0.4,
|
||||||
|
),
|
||||||
|
"Dreieck",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Linie",
|
||||||
|
freehand(&[c, c + Vec2::new(500.0, 120.0)], false, 0.2),
|
||||||
|
"Linie",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let mut wrong = Vec::new();
|
||||||
|
for (name, samples, expected) in &cases {
|
||||||
|
let got = kind(&detect_shape(samples));
|
||||||
|
if got != *expected {
|
||||||
|
wrong.push(format!("{name}: erwartet {expected}, erkannt {got}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(wrong.is_empty(), "Falsch erkannt:\n{}", wrong.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erkennt Rechtecke, Kreise, Dreiecke und Vielecke in vielen Größen, Seitenverhältnissen,
|
||||||
|
/// Drehungen und Zittermustern; Freiformen bleiben unverändert, flache Ellipsen werden nie
|
||||||
|
/// zu Rechtecken.
|
||||||
|
#[test]
|
||||||
|
fn test_detect_shape_robustness() {
|
||||||
|
let c = Vec2::new(1000.0, 1400.0);
|
||||||
|
let mut stats: std::collections::BTreeMap<String, std::collections::BTreeMap<&str, usize>> =
|
||||||
|
Default::default();
|
||||||
|
let mut add = |group: String, got: &'static str| {
|
||||||
|
*stats.entry(group).or_default().entry(got).or_default() += 1;
|
||||||
|
};
|
||||||
|
let seeds = [0.1_f32, 0.6, 1.2, 1.9, 2.7, 3.4, 4.2, 5.0];
|
||||||
|
for &size in &[150.0_f32, 250.0, 400.0, 600.0] {
|
||||||
|
for &aspect in &[1.0_f32, 0.75, 0.5] {
|
||||||
|
for &angle in &[0.0_f32, 15.0, 30.0, 45.0] {
|
||||||
|
for &seed in &seeds {
|
||||||
|
let pts =
|
||||||
|
freehand(&rect_outline(c, size, size * aspect, angle), true, seed);
|
||||||
|
add(format!("Rechteck {aspect}"), kind(&detect_shape(&pts)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &r in &[60.0_f32, 120.0, 200.0, 350.0] {
|
||||||
|
for &ry in &[1.0_f32, 0.8, 0.6] {
|
||||||
|
for &seed in &seeds {
|
||||||
|
let pts = freehand(&circle_outline(c, r, r * ry), true, seed);
|
||||||
|
add(format!("Kreis/Ellipse {ry}"), kind(&detect_shape(&pts)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &size in &[150.0_f32, 300.0, 500.0] {
|
||||||
|
for &seed in &seeds {
|
||||||
|
let tri = [
|
||||||
|
c + Vec2::new(0.0, -size),
|
||||||
|
c + Vec2::new(size * 0.9, size * 0.7),
|
||||||
|
c + Vec2::new(-size * 0.9, size * 0.7),
|
||||||
|
];
|
||||||
|
add(
|
||||||
|
"Dreieck".to_string(),
|
||||||
|
kind(&detect_shape(&freehand(&tri, true, seed))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Realistischere Rechtecke: abgerundete Ecken, schiefe Kanten, Überstrich am Schluss
|
||||||
|
for &size in &[150.0_f32, 300.0, 500.0] {
|
||||||
|
for &aspect in &[1.0_f32, 0.6] {
|
||||||
|
for &seed in &seeds {
|
||||||
|
let (w, h) = (size, size * aspect);
|
||||||
|
// Abgerundete Ecken (Radius 10 % der kürzeren Seite)
|
||||||
|
let r = 0.1 * w.min(h);
|
||||||
|
let mut rounded = Vec::new();
|
||||||
|
for (cx, cy, a0) in [
|
||||||
|
(w / 2.0 - r, -h / 2.0 + r, -90.0_f32),
|
||||||
|
(w / 2.0 - r, h / 2.0 - r, 0.0),
|
||||||
|
(-w / 2.0 + r, h / 2.0 - r, 90.0),
|
||||||
|
(-w / 2.0 + r, -h / 2.0 + r, 180.0),
|
||||||
|
] {
|
||||||
|
for k in 0..=6 {
|
||||||
|
let a = (a0 + k as f32 * 15.0).to_radians();
|
||||||
|
rounded.push(c + Vec2::new(cx + a.cos() * r, cy + a.sin() * r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
format!("Rechteck rund {aspect}"),
|
||||||
|
kind(&detect_shape(&freehand(&rounded, true, seed))),
|
||||||
|
);
|
||||||
|
// Schiefe Kanten: Ecken um bis zu 6 % verschoben
|
||||||
|
let mut skew = rect_outline(c, w, h, 0.0);
|
||||||
|
for (i, p) in skew.iter_mut().enumerate() {
|
||||||
|
let f = 0.06 * w.min(h);
|
||||||
|
*p += Vec2::new(
|
||||||
|
((i as f32 + seed) * 2.1).sin() * f,
|
||||||
|
((i as f32 + seed) * 1.3).cos() * f,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
add(
|
||||||
|
format!("Rechteck schief {aspect}"),
|
||||||
|
kind(&detect_shape(&freehand(&skew, true, seed))),
|
||||||
|
);
|
||||||
|
// Überstrich: Strich läuft 8 % des Umfangs über den Start hinaus
|
||||||
|
let mut over = freehand(&rect_outline(c, w, h, 0.0), true, seed);
|
||||||
|
let extra: Vec<StylusSample> =
|
||||||
|
over.iter().take(over.len() * 8 / 100).copied().collect();
|
||||||
|
over.extend(extra);
|
||||||
|
add(
|
||||||
|
format!("Rechteck Überstrich {aspect}"),
|
||||||
|
kind(&detect_shape(&over)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regelmäßige Vielecke (5, 6, 8 Ecken) und Ovale (0,6) – Ovale dürfen keine Vielecke werden
|
||||||
|
for &n in &[5usize, 6, 8] {
|
||||||
|
for &size in &[200.0_f32, 400.0] {
|
||||||
|
for &seed in &seeds {
|
||||||
|
let outline: Vec<Vec2> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / n as f32 * std::f32::consts::TAU
|
||||||
|
- std::f32::consts::FRAC_PI_2;
|
||||||
|
c + Vec2::new(a.cos(), a.sin()) * size
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
add(
|
||||||
|
format!("Vieleck {n}"),
|
||||||
|
kind(&detect_shape(&freehand(&outline, true, seed))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &r in &[120.0_f32, 250.0, 400.0] {
|
||||||
|
for &seed in &seeds {
|
||||||
|
let pts = freehand(&circle_outline(c, r, r * 0.6), true, seed);
|
||||||
|
add("Oval 0.6".to_string(), kind(&detect_shape(&pts)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Freiformen, die KEIN Rechteck/Kreis werden sollen
|
||||||
|
for &seed in &seeds {
|
||||||
|
let star: Vec<Vec2> = (0..10)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / 10.0 * std::f32::consts::TAU;
|
||||||
|
let r = if i % 2 == 0 { 300.0 } else { 120.0 };
|
||||||
|
c + Vec2::new(a.cos() * r, a.sin() * r)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
add(
|
||||||
|
"Freiform Stern".into(),
|
||||||
|
kind(&detect_shape(&freehand(&star, true, seed))),
|
||||||
|
);
|
||||||
|
let cloud: Vec<Vec2> = (0..72)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||||
|
let r = 250.0 * (1.0 + 0.3 * (3.0 * a).sin());
|
||||||
|
c + Vec2::new(a.cos() * r, a.sin() * r)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
add(
|
||||||
|
"Freiform Wolke".into(),
|
||||||
|
kind(&detect_shape(&freehand(&cloud, true, seed))),
|
||||||
|
);
|
||||||
|
let heart: Vec<Vec2> = (0..72)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||||
|
let x = 16.0 * t.sin().powi(3);
|
||||||
|
let y = -(13.0 * t.cos()
|
||||||
|
- 5.0 * (2.0 * t).cos()
|
||||||
|
- 2.0 * (3.0 * t).cos()
|
||||||
|
- (4.0 * t).cos());
|
||||||
|
c + Vec2::new(x, y) * 18.0
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
add(
|
||||||
|
"Freiform Herz".into(),
|
||||||
|
kind(&detect_shape(&freehand(&heart, true, seed))),
|
||||||
|
);
|
||||||
|
let spiral: Vec<Vec2> = (0..120)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / 30.0 * std::f32::consts::TAU;
|
||||||
|
let r = 40.0 + i as f32 * 2.5;
|
||||||
|
c + Vec2::new(a.cos() * r, a.sin() * r)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
add(
|
||||||
|
"Freiform Spirale".into(),
|
||||||
|
kind(&detect_shape(&freehand(&spiral, false, seed))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let count = |group: &str, kind: &str| -> (usize, usize) {
|
||||||
|
let m = stats
|
||||||
|
.get(group)
|
||||||
|
.unwrap_or_else(|| panic!("Gruppe {group} fehlt"));
|
||||||
|
(m.get(kind).copied().unwrap_or(0), m.values().sum())
|
||||||
|
};
|
||||||
|
let mut problems = Vec::new();
|
||||||
|
for group in stats.keys() {
|
||||||
|
let expected = if group.starts_with("Rechteck") {
|
||||||
|
Some("Rechteck")
|
||||||
|
} else if group == "Kreis/Ellipse 1" || group == "Kreis/Ellipse 0.8" {
|
||||||
|
Some("Kreis")
|
||||||
|
} else if group == "Dreieck" {
|
||||||
|
Some("Dreieck")
|
||||||
|
} else if group.starts_with("Vieleck") {
|
||||||
|
Some("Polygon")
|
||||||
|
} else if group.starts_with("Freiform") {
|
||||||
|
// Freiformen bleiben unverändert
|
||||||
|
Some("nichts")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(kind) = expected {
|
||||||
|
let (hits, total) = count(group, kind);
|
||||||
|
// Achteck/Sechseck und leicht ovale Kreise sind von Hand kaum zu unterscheiden
|
||||||
|
let min_share = if group.starts_with("Vieleck") || group == "Kreis/Ellipse 0.8" {
|
||||||
|
0.9
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
if (hits as f32) < min_share * total as f32 {
|
||||||
|
problems.push(format!(
|
||||||
|
"{group}: {hits}/{total} als {kind} erkannt ({:?})",
|
||||||
|
stats[group]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flache Ellipsen und Freiformen: niemals Rechteck
|
||||||
|
let (rects, _) = count(group, "Rechteck");
|
||||||
|
if rects > 0 {
|
||||||
|
problems.push(format!(
|
||||||
|
"{group}: {rects}× fälschlich Rechteck ({:?})",
|
||||||
|
stats[group]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_samples(points: &[Vec2]) -> Vec<StylusSample> {
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, p)| StylusSample {
|
||||||
|
position: *p,
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: i as f64 * 0.008,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|||||||
+145
-44
@@ -23,6 +23,7 @@ use ink_notes::geometry::{self, RecognizedShape};
|
|||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
};
|
};
|
||||||
|
use ink_notes::settings::{SidebarSide, UiSettings};
|
||||||
use ink_notes::storage::{self, NotebookManifest};
|
use ink_notes::storage::{self, NotebookManifest};
|
||||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||||
use ink_notes::updater::UpdaterController;
|
use ink_notes::updater::UpdaterController;
|
||||||
@@ -230,6 +231,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut ui_state = UiState::new();
|
let mut ui_state = UiState::new();
|
||||||
|
// Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste)
|
||||||
|
let ui_settings_path = UiSettings::default_path();
|
||||||
|
let mut ui_settings = ui_settings_path
|
||||||
|
.as_deref()
|
||||||
|
.map(UiSettings::load_from)
|
||||||
|
.unwrap_or_default();
|
||||||
|
ui_state.sidebar_side = ui_settings.sidebar_side;
|
||||||
ui_state.current_notebook_id = manifest.id.clone();
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
ui_state.document_title = manifest.title.clone();
|
ui_state.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = available_notebooks;
|
ui_state.available_notebooks = available_notebooks;
|
||||||
@@ -247,7 +255,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||||
eprintln!("[Storage-Lock] {msg}");
|
eprintln!("[Storage-Lock] {msg}");
|
||||||
autosave.set_read_only(true);
|
autosave.set_read_only(true);
|
||||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -275,6 +283,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
let mut pinch_prev_distance: Option<f32> = None;
|
let mut pinch_prev_distance: Option<f32> = None;
|
||||||
|
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
|
||||||
|
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||||
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
||||||
let mut is_pinch_gesturing = false;
|
let mut is_pinch_gesturing = false;
|
||||||
let mut is_touch_drawing = false;
|
let mut is_touch_drawing = false;
|
||||||
@@ -287,6 +297,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||||
let mut stylus_barrel_pressed = false;
|
let mut stylus_barrel_pressed = false;
|
||||||
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
||||||
|
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||||
|
let mut stylus_ui_capture = false;
|
||||||
|
// Von egui angeforderter Zeitpunkt für das nächste Neuzeichnen (Layout-Vermessung, Tooltips)
|
||||||
|
let mut egui_repaint_at: Option<Instant> = None;
|
||||||
|
|
||||||
// Viewport-Pan & Zoom-Zustand
|
// Viewport-Pan & Zoom-Zustand
|
||||||
let mut is_panning = false;
|
let mut is_panning = false;
|
||||||
@@ -379,6 +393,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
octotablet::events::ToolEvent::Out
|
octotablet::events::ToolEvent::Out
|
||||||
| octotablet::events::ToolEvent::Removed => {
|
| octotablet::events::ToolEvent::Removed => {
|
||||||
is_stylus_in_proximity = false;
|
is_stylus_in_proximity = false;
|
||||||
|
if stylus_ui_capture {
|
||||||
|
stylus_ui_capture = false;
|
||||||
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
let input = egui_winit_state.egui_input_mut();
|
||||||
|
input.events.push(egui::Event::PointerButton {
|
||||||
|
pos: egui::pos2(sx / scale, sy / scale),
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed: false,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
});
|
||||||
|
input.events.push(egui::Event::PointerGone);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
if stylus_tool_id == Some(tool.id()) {
|
if stylus_tool_id == Some(tool.id()) {
|
||||||
stylus_tool_id = None;
|
stylus_tool_id = None;
|
||||||
if is_stylus_drawing {
|
if is_stylus_drawing {
|
||||||
@@ -426,6 +453,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
current_pressure = p;
|
current_pressure = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||||
|
// solange nicht auf dem Blatt gezeichnet oder radiert wird
|
||||||
|
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) {
|
||||||
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
|
egui_winit_state
|
||||||
|
.egui_input_mut()
|
||||||
|
.events
|
||||||
|
.push(egui::Event::PointerMoved(ui_pos));
|
||||||
|
if stylus_ui_capture
|
||||||
|
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||||
|
{
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stylus_ui_capture {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if is_erasing {
|
if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
@@ -472,28 +517,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
stylus_tool_id = Some(tool.id());
|
stylus_tool_id = Some(tool.id());
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
|
||||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|
||||||
|| ui_state.is_point_in_ui(sx, sy)
|
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||||
|| (ui_state.show_page_jump_popup
|
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
||||||
&& egui_ctx.is_pointer_over_area())
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
|| (ui_state.is_document_menu_open
|
let input = egui_winit_state.egui_input_mut();
|
||||||
&& egui_ctx.is_pointer_over_area())
|
input.events.push(egui::Event::PointerMoved(ui_pos));
|
||||||
|| (ui_state.is_update_dialog_open
|
input.events.push(egui::Event::PointerButton {
|
||||||
&& egui_ctx.is_pointer_over_area());
|
pos: ui_pos,
|
||||||
|
|
||||||
if is_stylus_in_ui {
|
|
||||||
egui_ctx.input_mut(|i| {
|
|
||||||
i.events.push(egui::Event::PointerMoved(egui::pos2(
|
|
||||||
sx / scale,
|
|
||||||
sy / scale,
|
|
||||||
)));
|
|
||||||
i.events.push(egui::Event::PointerButton {
|
|
||||||
pos: egui::pos2(sx / scale, sy / scale),
|
|
||||||
button: egui::PointerButton::Primary,
|
button: egui::PointerButton::Primary,
|
||||||
pressed: true,
|
pressed: true,
|
||||||
modifiers: egui::Modifiers::default(),
|
modifiers: egui::Modifiers::default(),
|
||||||
});
|
});
|
||||||
});
|
stylus_ui_capture = true;
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -546,24 +582,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Up => {
|
octotablet::events::ToolEvent::Up => {
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
if stylus_ui_capture {
|
||||||
|| ui_state.is_point_in_ui(sx, sy)
|
stylus_ui_capture = false;
|
||||||
|| (ui_state.show_page_jump_popup
|
egui_winit_state.egui_input_mut().events.push(
|
||||||
&& egui_ctx.is_pointer_over_area())
|
egui::Event::PointerButton {
|
||||||
|| (ui_state.is_document_menu_open
|
|
||||||
&& egui_ctx.is_pointer_over_area())
|
|
||||||
|| (ui_state.is_update_dialog_open
|
|
||||||
&& egui_ctx.is_pointer_over_area());
|
|
||||||
|
|
||||||
if is_stylus_in_ui {
|
|
||||||
egui_ctx.input_mut(|i| {
|
|
||||||
i.events.push(egui::Event::PointerButton {
|
|
||||||
pos: egui::pos2(sx / scale, sy / scale),
|
pos: egui::pos2(sx / scale, sy / scale),
|
||||||
button: egui::PointerButton::Primary,
|
button: egui::PointerButton::Primary,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
modifiers: egui::Modifiers::default(),
|
modifiers: egui::Modifiers::default(),
|
||||||
});
|
},
|
||||||
});
|
);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -633,6 +661,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||||
|
if let Some(at) = egui_repaint_at {
|
||||||
|
if Instant::now() >= at {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
||||||
if new_status == AutosaveStatus::Pending
|
if new_status == AutosaveStatus::Pending
|
||||||
|| new_status == AutosaveStatus::Saving
|
|| new_status == AutosaveStatus::Saving
|
||||||
@@ -642,6 +678,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||||
|
} else if let Some(at) = egui_repaint_at {
|
||||||
|
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||||
} else {
|
} else {
|
||||||
target.set_control_flow(ControlFlow::Wait);
|
target.set_control_flow(ControlFlow::Wait);
|
||||||
}
|
}
|
||||||
@@ -657,9 +695,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
||||||
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
||||||
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
||||||
|| ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1)
|
|| ui::is_screen_point_in_ui(
|
||||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
&egui_ctx,
|
||||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area());
|
&ui_state,
|
||||||
|
cursor_screen_pos,
|
||||||
|
window.scale_factor() as f32,
|
||||||
|
);
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
@@ -686,6 +727,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_erasing = false;
|
is_erasing = false;
|
||||||
is_mouse_drawing = false;
|
is_mouse_drawing = false;
|
||||||
active_touches.clear();
|
active_touches.clear();
|
||||||
|
ui_touch_ids.clear();
|
||||||
|
stylus_ui_capture = false;
|
||||||
is_pinch_gesturing = false;
|
is_pinch_gesturing = false;
|
||||||
pinch_prev_distance = None;
|
pinch_prev_distance = None;
|
||||||
pinch_prev_center = None;
|
pinch_prev_center = None;
|
||||||
@@ -1137,7 +1180,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 2. Objektauswahl & Transformation im Select-Modus
|
// 2. Objektauswahl & Transformation im Select-Modus
|
||||||
if ui_state.active_tool == ActiveTool::Select {
|
if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(sx, sy);
|
let screen_vec = Vec2::new(sx, sy);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
match hit {
|
match hit {
|
||||||
SelectionHandleHit::RotateHandle { center_page } => {
|
SelectionHandleHit::RotateHandle { center_page } => {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
@@ -1469,15 +1512,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Berührungen der UI (egui-winit hat sie bereits an egui weitergegeben)
|
||||||
|
if ui_touch_ids.contains(&touch.id) {
|
||||||
|
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
|
||||||
|
ui_touch_ids.remove(&touch.id);
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match touch.phase {
|
match touch.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
// Event-Isolation für Touch-Eingaben
|
// Event-Isolation für Touch-Eingaben
|
||||||
let is_touch_in_ui = egui_ctx.wants_pointer_input()
|
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||||
|| ui_state.is_point_in_ui(tx, ty)
|
&egui_ctx,
|
||||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
&ui_state,
|
||||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|
(tx, ty),
|
||||||
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
|
window.scale_factor() as f32,
|
||||||
|
);
|
||||||
if is_touch_in_ui {
|
if is_touch_in_ui {
|
||||||
|
ui_touch_ids.insert(touch.id);
|
||||||
|
window.request_redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1525,7 +1580,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
} else if ui_state.active_tool == ActiveTool::Select {
|
} else if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(tx, ty);
|
let screen_vec = Vec2::new(tx, ty);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
match hit {
|
match hit {
|
||||||
SelectionHandleHit::RotateHandle { center_page } => {
|
SelectionHandleHit::RotateHandle { center_page } => {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
@@ -1807,6 +1862,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
updater.update();
|
updater.update();
|
||||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||||
|
|
||||||
|
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||||
|
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||||
|
let (inset_left, inset_right) = match ui_state.sidebar_side {
|
||||||
|
SidebarSide::Left => (reserved, 0.0),
|
||||||
|
SidebarSide::Right => (0.0, reserved),
|
||||||
|
};
|
||||||
|
if canvas.set_ui_insets(inset_left, inset_right) {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// 4. UI-Aktionen verarbeiten
|
// 4. UI-Aktionen verarbeiten
|
||||||
for action in actions {
|
for action in actions {
|
||||||
match action {
|
match action {
|
||||||
@@ -2003,6 +2068,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::LoadAllPages => {
|
||||||
|
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
|
||||||
|
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
|
||||||
|
for idx in 0..notebook.pages.len() {
|
||||||
|
if !notebook.pages[idx].is_loaded {
|
||||||
|
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::GoToPage(idx) => {
|
UiAction::GoToPage(idx) => {
|
||||||
if idx < notebook.pages.len() {
|
if idx < notebook.pages.len() {
|
||||||
selection_state.clear();
|
selection_state.clear();
|
||||||
@@ -2053,7 +2128,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
eprintln!("[Storage-Lock] {msg}");
|
eprintln!("[Storage-Lock] {msg}");
|
||||||
_notebook_lock = None;
|
_notebook_lock = None;
|
||||||
autosave.set_read_only(true);
|
autosave.set_read_only(true);
|
||||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
||||||
@@ -2312,6 +2387,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::SetSidebarSide(side) => {
|
||||||
|
ui_settings.sidebar_side = side;
|
||||||
|
if let Some(path) = ui_settings_path.as_deref() {
|
||||||
|
if let Err(err) = ui_settings.save_to(path) {
|
||||||
|
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
|
||||||
|
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::ForceExit => {
|
UiAction::ForceExit => {
|
||||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||||
target.exit();
|
target.exit();
|
||||||
@@ -2323,6 +2408,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let full_output = egui_ctx.end_frame();
|
let full_output = egui_ctx.end_frame();
|
||||||
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
||||||
|
|
||||||
|
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
|
||||||
|
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
|
||||||
|
let repaint_delay = full_output
|
||||||
|
.viewport_output
|
||||||
|
.get(&egui::ViewportId::ROOT)
|
||||||
|
.map(|v| v.repaint_delay)
|
||||||
|
.unwrap_or(Duration::MAX);
|
||||||
|
if repaint_delay.is_zero() {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
window.request_redraw();
|
||||||
|
} else if repaint_delay < Duration::from_secs(10) {
|
||||||
|
egui_repaint_at = Some(Instant::now() + repaint_delay);
|
||||||
|
} else {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
||||||
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
||||||
size_in_pixels: [canvas.size.width, canvas.size.height],
|
size_in_pixels: [canvas.size.width, canvas.size.height],
|
||||||
|
|||||||
+28
-12
@@ -115,12 +115,15 @@ impl SelectionState {
|
|||||||
Page::selection_aabb(&strokes)
|
Page::selection_aabb(&strokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||||
|
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||||
|
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||||
pub fn hit_test_handles(
|
pub fn hit_test_handles(
|
||||||
&self,
|
&self,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
canvas: &CanvasState,
|
canvas: &CanvasState,
|
||||||
screen_pos: Vec2,
|
screen_pos: Vec2,
|
||||||
|
pixels_per_point: f32,
|
||||||
) -> SelectionHandleHit {
|
) -> SelectionHandleHit {
|
||||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||||
return SelectionHandleHit::None;
|
return SelectionHandleHit::None;
|
||||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
|||||||
let screen_center = (screen_min + screen_max) * 0.5;
|
let screen_center = (screen_min + screen_max) * 0.5;
|
||||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||||
|
|
||||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
let ppp = pixels_per_point;
|
||||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||||
|
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::RotateHandle {
|
return SelectionHandleHit::RotateHandle {
|
||||||
center_page: page_center,
|
center_page: page_center,
|
||||||
};
|
};
|
||||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::CornerHandle {
|
return SelectionHandleHit::CornerHandle {
|
||||||
corner_idx: i,
|
corner_idx: i,
|
||||||
fixed_corner_page: fixed_corners_page[i],
|
fixed_corner_page: fixed_corners_page[i],
|
||||||
@@ -273,6 +277,16 @@ impl SelectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||||
|
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||||
|
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||||
|
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||||
|
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||||
|
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||||
|
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_selection_overlay(
|
pub fn render_selection_overlay(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
selection: &SelectionState,
|
selection: &SelectionState,
|
||||||
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
|
|||||||
egui::Order::Foreground,
|
egui::Order::Foreground,
|
||||||
egui::Id::new("selection_overlay_layer"),
|
egui::Id::new("selection_overlay_layer"),
|
||||||
));
|
));
|
||||||
|
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||||
|
let ppp = ctx.pixels_per_point();
|
||||||
|
|
||||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||||
if let DragMode::BoxSelect {
|
if let DragMode::BoxSelect {
|
||||||
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
|
|||||||
current_page,
|
current_page,
|
||||||
} = selection.drag_mode
|
} = selection.drag_mode
|
||||||
{
|
{
|
||||||
let p1 = canvas.page_to_screen(start_page);
|
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
|
||||||
let p2 = canvas.page_to_screen(current_page);
|
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
|
||||||
let min_x = p1[0].min(p2[0]);
|
let min_x = p1[0].min(p2[0]);
|
||||||
let min_y = p1[1].min(p2[1]);
|
let min_y = p1[1].min(p2[1]);
|
||||||
let max_x = p1[0].max(p2[0]);
|
let max_x = p1[0].max(p2[0]);
|
||||||
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
|
|||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||||
);
|
);
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Stroke::new(
|
egui::Stroke::new(
|
||||||
1.5_f32,
|
1.5_f32,
|
||||||
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
|
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let p_min = canvas.page_to_screen(aabb.min);
|
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||||
let p_max = canvas.page_to_screen(aabb.max);
|
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||||
|
|
||||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||||
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
|
|||||||
|
|
||||||
// Dreh-Stängel (Stalk) und Drehgriff
|
// Dreh-Stängel (Stalk) und Drehgriff
|
||||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[top_center, rot_handle],
|
[top_center, rot_handle],
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
|
||||||
|
//!
|
||||||
|
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::storage::{self, StorageError};
|
||||||
|
|
||||||
|
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum SidebarSide {
|
||||||
|
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
|
||||||
|
#[default]
|
||||||
|
Left,
|
||||||
|
/// Rechts (für Linkshänder)
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UiSettings {
|
||||||
|
pub sidebar_side: SidebarSide,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiSettings {
|
||||||
|
/// Standardpfad gemäß XDG-Spezifikation.
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||||
|
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||||
|
};
|
||||||
|
Some(base.join("ink-notes").join("ui.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||||
|
pub fn load_from(path: &Path) -> Self {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert die Einstellungen atomar.
|
||||||
|
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||||
|
storage::atomic_write_json(path, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_round_trip_and_fallbacks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("ink-notes").join("ui.json");
|
||||||
|
|
||||||
|
// Fehlende Datei -> Standard (links)
|
||||||
|
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
|
||||||
|
|
||||||
|
let settings = UiSettings {
|
||||||
|
sidebar_side: SidebarSide::Right,
|
||||||
|
};
|
||||||
|
settings.save_to(&path).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|
||||||
|
// Beschädigte Datei -> Standard statt Absturz
|
||||||
|
std::fs::write(&path, b"{ kaputt").unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
|
||||||
|
// Unbekannte / fehlende Felder werden toleriert
|
||||||
|
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -697,6 +697,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
|||||||
);
|
);
|
||||||
notebook.load_warnings = load_warnings;
|
notebook.load_warnings = load_warnings;
|
||||||
|
|
||||||
|
// Die zuletzt geöffnete Seite sofort laden: Sie ist nach dem Start die aktuelle Seite,
|
||||||
|
// und ein leerer Platzhalter würde beim Weiterschreiben ihren Inhalt überschreiben.
|
||||||
|
let current = notebook.current_page_index;
|
||||||
|
if !notebook.pages[current].is_loaded {
|
||||||
|
if let Err(e) = ensure_page_loaded(notebook_dir, &mut notebook, current) {
|
||||||
|
eprintln!("[Storage] Aktuelle Seite {current} konnte nicht geladen werden: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok((notebook, manifest))
|
Ok((notebook, manifest))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2167,4 +2176,61 @@ mod tests {
|
|||||||
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
||||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
|
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_restart_on_later_page_keeps_its_content() {
|
||||||
|
// Regression: Beim Start wurde nur Seite 1 geladen; war zuletzt eine spätere Seite
|
||||||
|
// offen, war sie ein leerer Platzhalter, und Weiterschreiben überschrieb ihren Inhalt.
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Neustart").unwrap();
|
||||||
|
notebook.add_page(PageBackground::Lined);
|
||||||
|
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
|
||||||
|
notebook.current_page_index = 1;
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
|
||||||
|
let (mut loaded, mut loaded_manifest) = load_notebook(&dir).unwrap();
|
||||||
|
assert_eq!(loaded.current_page_index, 1);
|
||||||
|
assert!(
|
||||||
|
loaded.current_page().is_loaded,
|
||||||
|
"aktuelle Seite muss geladen sein"
|
||||||
|
);
|
||||||
|
assert_eq!(loaded.current_page().strokes.len(), 1);
|
||||||
|
|
||||||
|
// Weiterschreiben und speichern darf den bisherigen Inhalt nicht verlieren
|
||||||
|
let extra = page_with_one_stroke(99, PageBackground::Lined).strokes[0].clone();
|
||||||
|
loaded.current_page_mut().strokes.push(extra);
|
||||||
|
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||||
|
autosave.mark_dirty(&loaded, &mut loaded_manifest);
|
||||||
|
autosave.flush().unwrap();
|
||||||
|
autosave.shutdown().unwrap();
|
||||||
|
|
||||||
|
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
|
assert_eq!(reloaded.current_page().strokes.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autosave_never_writes_unloaded_placeholder() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest, dir) =
|
||||||
|
create_new_notebook(temp_dir.path(), "Platzhalter").unwrap();
|
||||||
|
notebook.add_page(PageBackground::Lined);
|
||||||
|
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
let page_file = dir.join(format!("page_{}.json", notebook.pages[1].id));
|
||||||
|
let before = fs::read(&page_file).unwrap();
|
||||||
|
|
||||||
|
// Platzhalter (nicht geladen) als aktuelle Seite markieren und speichern
|
||||||
|
let placeholder = Page::new_unloaded(notebook.pages[1].id, PageBackground::Lined);
|
||||||
|
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||||
|
autosave.mark_page_dirty(&placeholder, &manifest);
|
||||||
|
autosave.flush().unwrap();
|
||||||
|
autosave.shutdown().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&page_file).unwrap(),
|
||||||
|
before,
|
||||||
|
"Seitendatei wurde überschrieben"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user