Compare commits

...
10 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 9571e2121c chore(release): bump version to 0.3.6
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m51s
Unter WSL standardmäßig X11 statt Wayland.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:21 +02:00
haraldandClaude Opus 5.5 cb11b1f828 chore(release): bump version to 0.3.5
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m34s
Kein Absturz mehr ohne nutzbare Grafikkarte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:49 +02:00
haraldandClaude Opus 5.5 89005f718e fix(grafik): kein Absturz ohne nutzbare Darstellungsmodi
Ohne Zugriff auf die Grafikkarte (z. B. Benutzer nicht in der Gruppe render)
lieferte EGL/Zink zunächst Darstellungsmodi, später eine leere Liste; die
nächste Neueinrichtung der Surface ließ wgpu abstürzen ("Requested present
mode Mailbox is not in the list of supported present modes: []").

- Adapter der Reihe nach prüfen (GPU, dann Software-Renderer); nur Adapter
  mit Formaten und Darstellungsmodi für das Fenster werden genommen
- Darstellungsmodus: Mailbox, sonst Fifo, sonst der erste angebotene
- Bei Größenänderung/verlorener Surface Fähigkeiten neu abfragen statt blind
  neu einzurichten; ohne Modi wird nicht eingerichtet (einmalige Meldung)
- Ohne nutzbaren Adapter verständliche Meldung (Gruppe render, /dev/dri,
  Software-Renderer, WGPU_BACKEND) statt Panic; WGPU_BACKEND wird beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:14:43 +02:00
haraldandClaude Opus 5.5 b5113cefa2 chore(release): bump version to 0.3.4
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m14s
Formerkennung: Rechtecke statt Kreise, Freiformen bleiben unverändert.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:05:37 +02:00
haraldandClaude Opus 5.5 f76fad92be chore(release): bump version to 0.3.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m10s
Behebt Datenverlust nach Neustart auf einer späteren Seite (v0.2.3 – v0.3.2); Seitenauswahl mit Vorschaubildern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:58 +02:00
haraldandClaude Opus 5.5 2c60a03460 feat(ui): Vorschaubilder in der Seitenauswahl
- Kacheln zeigen eine verkleinerte A4-Vorschau jeder Seite (Striche als
  Linien, Textmarker halbtransparent, volle Seiten auf ~1500 Punkte
  ausgedünnt), Seitenzahl darunter, aktuelle Seite blau umrandet
- Noch nicht geladene Seiten werden bei offener Seitenauswahl nachgeladen
  (UiAction::LoadAllPages)
- Kachelgröße über PAGE_TILE (vorher feste Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 b6580f197d fix(storage): Inhalt der zuletzt geöffneten Seite nach Neustart nicht überschreiben
Beim Start wurde nur die erste Seite geladen. War zuletzt eine spätere Seite
offen, war sie nach dem Neustart ein leerer, ungeladener Platzhalter: Das
Blatt erschien leer, und Zeichnen/Radieren/Hintergrund wechseln speicherte
den Platzhalter und überschrieb den bisherigen Inhalt der Seite.

- load_notebook lädt zusätzlich die aktuelle Seite
- Autosave schreibt nie ungeladene Platzhalter (Schutz ging beim Merge des
  Storage-Branches verloren)
- Regressionstests für beide Fälle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
11 changed files with 1247 additions and 148 deletions
Generated
+1 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.3.2"
version = "0.3.6"
dependencies = [
"bytemuck",
"egui",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.3.2"
version = "0.3.6"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+5 -2
View File
@@ -3,13 +3,16 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **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)
- **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 als Kachelraster, größere Bedienelemente in allen Popovern
- **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
- **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)
- **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
- **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
+6 -1
View File
@@ -312,7 +312,12 @@ impl AutosaveController {
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
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;
}
let page_data = PageData::from(page);
+92 -27
View File
@@ -87,8 +87,35 @@ pub fn spline_vertices_to_ndc(
}
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
let preferred: &[wgpu::PresentMode] = if force_fifo {
&[wgpu::PresentMode::Fifo]
} else {
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
};
preferred
.iter()
.copied()
.find(|m| available.contains(m))
.or_else(|| available.first().copied())
}
pub struct CanvasState {
pub surface: wgpu::Surface<'static>,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration,
@@ -149,23 +176,47 @@ impl CanvasState {
let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface
// 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
..Default::default()
});
let surface = instance.create_surface(window.clone())?;
// 2. Adapter auswählen (hohe Performance bevorzugt)
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.ok_or("Kein passender GPU-Adapter gefunden")?;
// 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
// Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
// anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
let mut chosen = None;
for force_fallback_adapter in [false, true] {
let Some(adapter) = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter,
})
.await
else {
continue;
};
let info = adapter.get_info();
let caps = surface.get_capabilities(&adapter);
match choose_present_mode(&caps.present_modes) {
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
eprintln!(
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
info.name, info.backend, mode
);
chosen = Some((adapter, caps, mode));
break;
}
_ => eprintln!(
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
info.name, info.backend
),
}
}
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
// 3. Device & Queue erstellen
let (device, queue) = adapter
@@ -179,8 +230,7 @@ impl CanvasState {
)
.await?;
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1
let surface_caps = surface.get_capabilities(&adapter);
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
let surface_format = surface_caps
.formats
.iter()
@@ -188,20 +238,6 @@ impl CanvasState {
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps
.present_modes
.contains(&wgpu::PresentMode::Mailbox)
{
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
wgpu::PresentMode::Fifo
};
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
@@ -369,6 +405,8 @@ impl CanvasState {
Ok(Self {
surface,
adapter,
presentation_warned: false,
device,
queue,
config,
@@ -452,6 +490,23 @@ impl CanvasState {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
let caps = self.surface.get_capabilities(&self.adapter);
if !caps.present_modes.contains(&self.config.present_mode) {
match choose_present_mode(&caps.present_modes) {
Some(mode) => self.config.present_mode = mode,
None => {
if !self.presentation_warned {
eprintln!(
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
NO_USABLE_GPU_MESSAGE
);
self.presentation_warned = true;
}
return;
}
}
}
self.surface.configure(&self.device, &self.config);
self.recalculate_viewport();
}
@@ -858,6 +913,16 @@ impl CanvasState {
mod tests {
use super::*;
#[test]
fn test_choose_present_mode() {
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
assert_eq!(choose_present_mode(&[]), None);
}
#[test]
fn test_pixel_to_ndc_mapping() {
let width = 800.0;
+761 -98
View File
@@ -409,7 +409,13 @@ pub fn detect_circle(points: &[Vec2]) -> Option<RecognizedShape> {
}
}
/// 3. Rechteckerkennung: Geschlossener Pfad mit 4 dominanten Ecken (~90° +/- 18°).
/// 3. Rechteckerkennung: kleinstes umschließendes Rechteck eines geschlossenen Pfads.
///
/// Statt Ecken über eine grobe Linienvereinfachung zu suchen (das schnitt bei kleinen oder
/// zittrigen Strichen die Ecken ab), wird über alle Drehwinkel das Rechteck mit der
/// kleinsten Fläche gesucht. Einzelne Ausreißer (z. B. ein Überstrich am Schluss) zählen
/// nicht: Die Ausdehnung wird über das 2.–98. Perzentil der Punkte bestimmt. Angenommen
/// wird das Rechteck nur, wenn es den Strich eng beschreibt.
pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 8 {
return None;
@@ -429,78 +435,99 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
return None;
}
// RDP mit ca. 5% Pfadlänge als Toleranz
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
let simplified = ramer_douglas_peucker(points, epsilon);
// Ausdehnung entlang einer Achse (Winkel in Grad), robust gegen Ausreißer
let extent = |angle_deg: f32| -> (f32, f32, f32, f32) {
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 mut across: Vec<f32> = points.iter().map(|p| -p.x * sin + p.y * cos).collect();
along.sort_by(f32::total_cmp);
across.sort_by(f32::total_cmp);
let lo = points.len() * 2 / 100;
let hi = points.len() - 1 - lo;
(along[lo], along[hi], across[lo], across[hi])
};
let area = |angle_deg: f32| {
let (a0, a1, b0, b1) = extent(angle_deg);
(a1 - a0) * (b1 - b0)
};
let corners: Vec<Vec2> =
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
simplified[0..4].to_vec()
} else if simplified.len() == 4 {
simplified
} else if simplified.len() > 4 {
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
extract_k_corners(&simplified, 4)?
} else {
return None;
};
// Grobsuche in 1°-Schritten, dann Feinsuche in 0,1°-Schritten um das Optimum
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);
if corners.len() != 4 {
// 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;
}
let (mut a0, mut a1, mut b0, mut b1) = extent(best_angle);
let (sin, cos) = best_angle.to_radians().sin_cos();
let dir = Vec2::new(cos, sin);
let norm = Vec2::new(-sin, cos);
// Kanten in die Mitte des Zitterns legen: Jeder Punkt gehört zur nächsten Kante, die Kante
// wandert auf den Median ihrer Punkte (das umschließende Rechteck liegt außen an den Spitzen)
let mut sides: [Vec<f32>; 4] = Default::default();
for p in points {
let (u, v) = (p.dot(dir), p.dot(norm));
let d = [
(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);
values[values.len() / 2]
};
a0 = median(&mut sides[0], a0);
a1 = median(&mut sides[1], a1);
b0 = median(&mut sides[2], b0);
b1 = median(&mut sides[3], b1);
let 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;
}
// Innenwinkel prüfen: 4 Kanten müssen jeweils ~90° (+/- 18°) zueinander stehen
let c = [corners[0], corners[1], corners[2], corners[3]];
for i in 0..4 {
let prev = c[(i + 3) % 4];
let curr = c[i];
let next = c[(i + 1) % 4];
let v1 = (prev - curr).normalize_or_zero();
let v2 = (next - curr).normalize_or_zero();
let dot = v1.dot(v2).clamp(-1.0, 1.0);
let angle_deg = dot.acos().to_degrees();
if !(72.0..=108.0).contains(&angle_deg) {
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;
}
// Saubere rechtwinklige Ausrichtung
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,
]
};
Some(RecognizedShape::Rectangle {
corners: snapped_corners,
})
Some(rect)
}
/// 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 v1 = (prev - 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);
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();
selected_indices.sort_unstable();
@@ -844,6 +874,11 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
}
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 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> {
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.len() - 1].to_vec()
} else {
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).
@@ -1035,37 +1148,171 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
}
let pts: Vec<Vec2> = points.iter().map(|s| s.position).collect();
// 1. Zuerst 3D-Kantenmodelle prüfen (Oktaeder / Doppelpyramide)
if let Some(octa) = detect_octahedron(&pts) {
return Some(octa);
}
// 1. Geschlossene Formen: Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird
// die Form, deren Kontur am besten zu den gezeichneten Punkten passt. Eine feste
// 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)
if let Some(circle) = detect_circle(&pts) {
return Some(circle);
}
if let Some(triangle) = detect_triangle(&pts) {
return Some(triangle);
}
if let Some(rect) = detect_rectangle(&pts) {
return Some(rect);
}
if let Some(rhombus) = detect_rhombus(&pts) {
return Some(rhombus);
}
if let Some(polygon) = detect_polygon(&pts) {
return Some(polygon);
}
// 2. 3D-Kantenmodelle (Oktaeder / Doppelpyramide) nur, wenn keine geschlossene Form den
// Strich gut beschreibt: Ein Oktaeder enthält innere Kanten, die weit von jedem Umriss
// entfernt liegen. Ein sauber passender Umriss ist daher kein Oktaeder.
let closed_fits_well = best_closed.as_ref().is_some_and(|(err, _)| {
let aabb = Aabb::from_points(&pts);
*err <= CLOSED_SHAPE_GOOD_FIT * (aabb.max - aabb.min).length()
});
let best_closed = best_closed.map(|(_, shape)| shape);
// 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
} 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))
}
None
/// 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).
@@ -1435,6 +1682,11 @@ mod tests {
}
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");
if let Some(RecognizedShape::Line { start, end }) = detected {
@@ -1472,6 +1724,11 @@ mod tests {
points.push(points[0]);
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");
if let Some(RecognizedShape::Rectangle { corners }) = detected {
@@ -1508,6 +1765,11 @@ mod tests {
}
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");
if let Some(RecognizedShape::Circle {
@@ -1543,6 +1805,11 @@ mod tests {
}
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!(
detected.is_some(),
"Koordinatensystem sollte erkannt werden"
@@ -1594,6 +1861,11 @@ mod tests {
points.push(a);
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");
if let Some(RecognizedShape::Triangle { corners }) = detected {
@@ -1639,6 +1911,11 @@ mod tests {
points.push(c0);
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");
if let Some(RecognizedShape::Rhombus { corners }) = detected {
@@ -1680,6 +1957,11 @@ mod tests {
points.push(points[0]);
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!(
detected.is_some(),
"Oktagon / Achteck sollte erkannt werden"
@@ -1719,6 +2001,11 @@ mod tests {
}
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");
if let Some(RecognizedShape::Octahedron {
@@ -1737,4 +2024,380 @@ mod tests {
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()
}
}
+1
View File
@@ -8,6 +8,7 @@ pub mod export;
pub mod filter;
pub mod geometry;
pub mod history;
pub mod platform;
pub mod selection;
pub mod settings;
pub mod spline;
+34 -3
View File
@@ -9,8 +9,9 @@ use winit::{
event::{
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
},
event_loop::{ControlFlow, EventLoop},
event_loop::{ControlFlow, EventLoopBuilder},
keyboard::{Key, ModifiersState, NamedKey},
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
window::{Window, WindowBuilder},
};
@@ -20,6 +21,7 @@ use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
};
@@ -137,7 +139,20 @@ fn commit_stroke_to_notebook(
}
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(
WindowBuilder::new()
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
@@ -145,7 +160,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.build(&event_loop)?,
);
let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?;
let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
Ok(canvas) => canvas,
Err(err) => {
eprintln!("[Grafik-Fehler] {err}");
std::process::exit(1);
}
};
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
Ok(mgr) => {
@@ -2068,6 +2089,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
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) => {
if idx < notebook.pages.len() {
selection_state.clear();
+96
View File
@@ -0,0 +1,96 @@
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
//!
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
//! übersteuert die Wahl.
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
Automatic,
X11,
Wayland,
}
/// Wählt das Anzeigesystem.
///
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
/// - `is_wsl`: läuft unter WSL
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
pub fn choose_display_backend(
override_value: Option<&str>,
is_wsl: bool,
has_x11_display: bool,
) -> DisplayBackend {
match override_value
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("x11") => return DisplayBackend::X11,
Some("wayland") => return DisplayBackend::Wayland,
_ => {}
}
if is_wsl && has_x11_display {
DisplayBackend::X11
} else {
DisplayBackend::Automatic
}
}
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
pub fn is_wsl() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
/// Wahl anhand der aktuellen Umgebung.
pub fn display_backend_from_env() -> DisplayBackend {
choose_display_backend(
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
is_wsl(),
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
assert_eq!(
choose_display_backend(None, true, true),
DisplayBackend::X11
);
// WSL ohne X11-Display: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, true, false),
DisplayBackend::Automatic
);
// Natives Linux: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, false, true),
DisplayBackend::Automatic
);
// Übersteuerung
assert_eq!(
choose_display_backend(Some("wayland"), true, true),
DisplayBackend::Wayland
);
assert_eq!(
choose_display_backend(Some(" X11 "), false, true),
DisplayBackend::X11
);
assert_eq!(
choose_display_backend(Some("quatsch"), true, true),
DisplayBackend::X11
);
}
}
+66
View File
@@ -697,6 +697,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
);
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))
}
@@ -2167,4 +2176,61 @@ mod tests {
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
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, &notebook, &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, &notebook, &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"
);
}
}
+184 -15
View File
@@ -12,7 +12,7 @@
use std::time::Instant;
use crate::autosave::AutosaveStatus;
use crate::document::{Notebook, PageBackground};
use crate::document::{Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH};
use crate::settings::SidebarSide;
use crate::storage::NotebookSummary;
@@ -96,6 +96,8 @@ pub enum UiAction {
NextPage,
AddPage,
GoToPage(usize),
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
LoadAllPages,
SetBackground(PageBackground),
ExportPdf,
ZoomIn,
@@ -699,8 +701,94 @@ fn color_circle_swatch(
/// Spalten der Seitenauswahl.
const PAGE_PICKER_COLUMNS: usize = 4;
/// Größe einer Kachel der Seitenauswahl.
const PAGE_TILE: egui::Vec2 = egui::vec2(56.0, 48.0);
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0);
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
const THUMBNAIL_POINT_BUDGET: usize = 1500;
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format).
/// Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig ausgedünnt.
pub fn page_thumbnail_shapes(page: &Page, rect: egui::Rect) -> Vec<egui::Shape> {
let mut shapes = vec![egui::Shape::rect_filled(
rect,
egui::Rounding::same(3.0),
egui::Color32::WHITE,
)];
let scale = rect.width() / PAGE_WIDTH;
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
let total: usize = page.strokes.iter().map(|s| s.points.len()).sum();
let step = total.div_ceil(THUMBNAIL_POINT_BUDGET).max(1);
for stroke in &page.strokes {
let mut color = color32_from(stroke.color);
if stroke.tool == StrokeTool::Highlighter {
color = color.gamma_multiply(0.45);
}
let width = (stroke.width * scale).clamp(0.6, 4.0);
let last = stroke.points.len().saturating_sub(1);
let points: Vec<egui::Pos2> = stroke
.points
.iter()
.enumerate()
.filter(|(i, _)| i % step == 0 || *i == last)
.map(|(_, s)| to_screen(s.position))
.collect();
match points.len() {
0 => {}
1 => shapes.push(egui::Shape::circle_filled(points[0], width / 2.0, color)),
_ => shapes.push(egui::Shape::line(points, egui::Stroke::new(width, color))),
}
}
shapes
}
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter.
fn page_thumbnail(
ui: &mut egui::Ui,
page: &Page,
number: usize,
is_current: bool,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
if ui.is_rect_visible(rect) {
let thumb = egui::Rect::from_min_size(
rect.min,
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
);
let painter = ui.painter();
painter.extend(page_thumbnail_shapes(page, thumb));
if !page.is_loaded {
painter.text(
thumb.center(),
egui::Align2::CENTER_CENTER,
"…",
egui::FontId::proportional(16.0),
egui::Color32::from_gray(140),
);
}
let border = if is_current {
egui::Stroke::new(2.5_f32, egui::Color32::from_rgb(90, 150, 255))
} else if response.hovered() {
egui::Stroke::new(1.5_f32, egui::Color32::from_white_alpha(200))
} else {
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
};
painter.rect_stroke(thumb, egui::Rounding::same(3.0), border);
painter.text(
egui::pos2(rect.center().x, rect.max.y - 8.0),
egui::Align2::CENTER_CENTER,
number.to_string(),
egui::FontId::proportional(13.0),
if is_current {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(180)
},
);
}
response.on_hover_text(format!("Seite {number}"))
}
/// Rahmen der Popover neben der Werkzeugleiste.
fn popover_frame() -> egui::Frame {
@@ -1696,24 +1784,19 @@ pub fn render_ui(
if dialog_header(ui, &title, true) {
state.show_page_jump_popup = false;
}
// Noch nicht geladene Seiten für die Vorschau nachladen lassen
if notebook.pages.iter().any(|p| !p.is_loaded) {
actions.push(UiAction::LoadAllPages);
}
egui::ScrollArea::vertical()
.max_height(320.0)
.max_height(420.0)
.show(ui, |ui| {
egui::Grid::new("page_picker_grid")
.spacing([8.0, 8.0])
.show(ui, |ui| {
for idx in 0..notebook.pages.len() {
for (idx, page) in notebook.pages.iter().enumerate() {
let is_current = idx == notebook.current_page_index;
let label =
egui::RichText::new(format!("{}", idx + 1))
.size(16.0);
if ui
.add_sized(
[56.0, 48.0],
egui::SelectableLabel::new(
is_current, label,
),
)
if page_thumbnail(ui, page, idx + 1, is_current)
.clicked()
{
actions.push(UiAction::GoToPage(idx));
@@ -3005,4 +3088,90 @@ mod tests {
}
assert_eq!(reached, (0..6).collect(), "alle Seiten erreichbar");
}
/// Seite mit `strokes` Strichen à `points` Punkten (diagonal über die Seite).
fn page_with_strokes(strokes: usize, points: usize) -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
for i in 0..strokes {
let samples: Vec<crate::document::StylusSample> = (0..points)
.map(|j| crate::document::StylusSample {
position: glam::Vec2::new(
100.0 + j as f32 * 1800.0 / points as f32,
100.0 + i as f32 * 20.0,
),
pressure: 0.5,
timestamp: j as f64 * 0.01,
})
.collect();
page.strokes.push(crate::document::Stroke::new(
i as u64 + 1,
samples,
Vec::new(),
[0.1, 0.1, 0.1, 1.0],
));
}
page
}
fn shape_points(shapes: &[egui::Shape]) -> Vec<egui::Pos2> {
shapes
.iter()
.flat_map(|s| match s {
egui::Shape::Path(p) => p.points.clone(),
egui::Shape::Circle(c) => vec![c.center],
_ => Vec::new(),
})
.collect()
}
#[test]
fn test_page_thumbnail_shapes() {
let rect = egui::Rect::from_min_size(egui::pos2(50.0, 80.0), egui::vec2(66.0, 93.0));
// Leere Seite: nur das weiße Blatt
let empty = Page::new(1, PageBackground::Blank);
assert_eq!(page_thumbnail_shapes(&empty, rect).len(), 1);
// Striche liegen innerhalb der Kachel
let page = page_with_strokes(3, 20);
let shapes = page_thumbnail_shapes(&page, rect);
assert_eq!(shapes.len(), 1 + 3);
let pts = shape_points(&shapes);
assert!(!pts.is_empty());
assert!(
pts.iter().all(|p| rect.contains(*p)),
"Punkte außerhalb der Kachel"
);
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
let full = page_with_strokes(100, 1000);
let pts = shape_points(&page_thumbnail_shapes(&full, rect));
assert!(
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
"{} Punkte statt höchstens {}",
pts.len(),
THUMBNAIL_POINT_BUDGET + 200
);
}
#[test]
fn test_page_picker_requests_loading_of_unloaded_pages() {
let updater = crate::updater::UpdaterController::new();
for (unloaded, expected) in [(false, false), (true, true)] {
let mut notebook = Notebook::new();
notebook.add_page(PageBackground::Grid5mm);
if unloaded {
notebook.pages[1] =
Page::new_unloaded(notebook.pages[1].id, PageBackground::Grid5mm);
}
let mut state = UiState::new();
state.show_page_jump_popup = true;
let ctx = render_frames(&mut state, &notebook, 3);
ctx.begin_frame(egui::RawInput::default());
let actions = render_ui(&ctx, &mut state, &notebook, AutosaveStatus::Saved, &updater);
let _ = ctx.end_frame();
let requested = actions.iter().any(|a| matches!(a, UiAction::LoadAllPages));
assert_eq!(requested, expected, "ungeladene Seite: {unloaded}");
}
}
}