Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9571e2121c | ||
|
|
4a7627e461 | ||
|
|
cb11b1f828 | ||
|
|
89005f718e | ||
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d |
Generated
+1
-1
@@ -1048,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¬ebook, &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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, ¬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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¬ebook, 3);
|
||||
ctx.begin_frame(egui::RawInput::default());
|
||||
let actions = render_ui(&ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater);
|
||||
let _ = ctx.end_frame();
|
||||
let requested = actions.iter().any(|a| matches!(a, UiAction::LoadAllPages));
|
||||
assert_eq!(requested, expected, "ungeladene Seite: {unloaded}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user