Compare commits

...
29 Commits
Author SHA1 Message Date
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
haraldandClaude Opus 5.5 22e35f2e77 chore(release): bump version to 0.3.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:40:44 +02:00
haraldandClaude Opus 5.5 a77e6cde90 feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover
- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:51:48 +02:00
haraldandClaude Opus 5.5 40eb9a666f chore(release): bump version to 0.3.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m58s
Behebt: In v0.3.0 ließ sich auf dem Blatt nicht zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:54 +02:00
haraldandClaude Opus 5.5 85d1ba1647 fix(ui): Zeichnen auf dem Blatt wieder möglich; Farben und Zeichen korrigiert
Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine
bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder
Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht.
Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs
und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2).

Außerdem:
- 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert
  umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift,
  die Auswahlfüllung war deckend statt durchscheinend
- Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen
  U+FE0F hinter ⚠)
- Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in
  ui.rs, selection.rs und main.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:12:56 +02:00
haraldandClaude Opus 5.5 b03faedd27 chore(release): bump version to 0.3.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:26:27 +02:00
haraldandClaude Opus 5.5 f17e50ec32 feat(ui): senkrechte Werkzeugleiste und Stift-/Touch-Bedienung der UI
Werkzeugleiste
- Schmale senkrechte Leiste statt ~30 Elemente in einer Zeile; seitlich oben
  angedockt (links, im ☰-Menü auf rechts umschaltbar, gespeichert in
  ~/.config/ink-notes/ui.json)
- Symbol-Knöpfe 40 px, auf niedrigen Fenstern schrittweise bis 28 px; Größe
  richtet sich nach dem höchsten Fall, damit Knöpfe beim Werkzeugwechsel
  nicht springen (fester Platz für die Farbpalette)
- Selten genutztes (PDF, Hintergrund, Zoom, Finger-Zeichnen, Leistenseite,
  Updates, Speicherstatus) im ☰-Menü; Popover öffnen zur Blattseite hin
- Blatt wird im freien Bereich neben der Leiste zentriert
- Symbole ohne Glyphe in den egui-Schriften (🖍 ⌫ ⬚ ◄ ► ● ✕ ▾) ersetzt bzw.
  selbst gezeichnet; Hover-Farbe korrigiert (falsch vormultipliziert)

Eingabe
- UI-Trefferprüfung in logischen Punkten (vorher physische Pixel gegen
  egui-Koordinaten: bei Skalierung ≠ 1 trafen Stift und Touch die Leiste nicht)
  und über egui::Context::layer_id_at für alle egui-Bereiche
- Stift-Ereignisse über egui_input_mut() statt ctx.input_mut() (wurden vom
  nächsten Frame verworfen); Stiftbewegungen für Hover/Ziehen weitergereicht,
  Stift bleibt vom Aufsetzen bis Abheben bei der UI
- Berührungen, die auf der UI beginnen, bleiben bis zum Abheben bei egui
- egui-Neuzeichenwünsche (repaint_delay) werden beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:51 +02:00
haraldandClaude Opus 5.5 5cd45831e2 chore(release): bump version to 0.2.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 71f6a70829 fix(ci): Release-Signatur mit rsign2 statt minisign-CLI
Der Signaturschlüssel wurde mit `rsign generate -W` erzeugt und ist mit
leerem Passwort verschlüsselt; das minisign-CLI aus Ubuntu (0.11) verlangt
dafür ein Terminal und brach in der CI mit "Error while loading the secret
key file" ab.

- Workflow installiert rsign2 0.6.7 (ohne -D warnings) statt minisign
- build-release.sh signiert und verifiziert mit rsign; da rsign bei falscher
  Key-ID mit Exit-Code 0 endet, wird zusätzlich die Erfolgsmeldung geprüft
- README: Signierwerkzeug und Fallstrick dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 b751b71223 chore(release): bump version to 0.2.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 9m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:51 +02:00
haraldandClaude Opus 5.5 afb057692d fix(ci): rustfmt und clippy in der Release-Pipeline installieren
setup-rust-toolchain installiert mit --profile minimal; ohne explizite
components fehlen rustfmt und clippy, wodurch die Quality Gates bei jedem
Release-Lauf sofort abbrachen (cargo fmt --check nach 2 Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:42:58 +02:00
haraldandClaude Opus 5.5 eeae143dc6 chore(release): bump version to 0.2.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:10 +02:00
haraldandClaude Opus 5.5 992c3daad7 fix(ci): Formatierung, Root-fester I/O-Test und robuste Schlüsselsuche
- updater.rs: rustfmt-konforme Formatierung des öffentlichen Schlüssels
  (cargo fmt --check ließ die Release-Pipeline scheitern)
- storage.rs: test_io_error_* simuliert den I/O-Fehler über ein Verzeichnis
  statt chmod 000, damit er auch als root (CI-Runner) greift
- build-release.sh: findet den öffentlichen Schlüssel auch, wenn rustfmt ihn
  in die Folgezeile umbricht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:30:52 +02:00
haraldandClaude Opus 5.5 9684238a53 chore(release): bump version to 0.2.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:20:29 +02:00
haraldandClaude Opus 5.5 e8674e730f feat(updater): Minisign-Schlüssel für Release-Signaturen hinterlegen
Öffentlicher Schlüssel (Key-ID 3CFE290C190DA9D9). Der geheime Schlüssel wird
in der CI als Secret MINISIGN_SECRET_KEY erwartet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:10:57 +02:00
haraldandClaude Opus 5.5 7e99d59042 Merge fix/storage-datensicherheit in main
Konfliktlösung: Speicher-Architektur des Branches (Formatversionierung,
Papierkorb, Backups, Instance-Lock, robuster Autosave) übernommen und die
dort fehlenden Korrekturen aus main darauf übertragen:

- Autosave gleicht das Manifest bei jeder Änderung mit dem Notizbuch ab
  (mark_dirty); per Taste N/Bild-ab angelegte Seiten gingen sonst nach
  einem Neustart verloren
- Start öffnet keine beschädigten Notizbücher automatisch und verwendet den
  Ordner eines nicht ladbaren Notizbuchs nicht wieder
- PDF-Export lädt nicht geöffnete Seiten nach (load_all_pages), verweigert
  unvollständige Exporte und meldet das echte Ergebnis an die UI
- Cargo.toml: minisign-verify und fs2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:22 +02:00
harald 12755d8eea test(storage): Crash-Simulation und Stresstest gegen unvollständige Schreibvorgänge 2026-10-06 19:21:59 +02:00
harald 510035e807 perf(storage): kompaktes JSON, Stroke-Treue und Snapshot-Optimierung 2026-10-06 19:14:52 +02:00
harald 1afe4a0f9d feat(storage): Dateisystem-Härtung, Trash, Rolling-Backup und Instance-Lock 2026-10-06 18:55:08 +02:00
harald 04666bf389 feat(autosave): robuster Autosave mit Max-Wait, Retries, PageStore-Trait und Shutdown-Sicherheit 2026-10-06 17:52:09 +02:00
harald a82402bfce feat(storage): Formatversionierung, Migrations-Backups und Fixture-Tests 2026-10-06 17:16:39 +02:00
harald 06b3aeb311 fix(storage): kein stilles Überschreiben bei Ladefehlern und Quarantäne defekter Seiten 2026-10-06 16:58:23 +02:00
27 changed files with 6534 additions and 1845 deletions

No files matched your search

+7 -1
View File
@@ -38,15 +38,21 @@ jobs:
squashfs-tools \
dpkg-dev \
curl \
minisign \
python3
- name: Setup Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
# Die Action installiert mit --profile minimal; ohne diese Komponenten
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
components: rustfmt, clippy
cache: true
- name: Install rsign2 (Release-Signatur)
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
- name: Code Quality & Testing (Quality Gates)
run: |
cargo fmt --check
Generated
+12 -1
View File
@@ -682,6 +682,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -1038,12 +1048,13 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.1.1"
version = "0.3.5"
dependencies = [
"bytemuck",
"egui",
"egui-wgpu",
"egui-winit",
"fs2",
"glam",
"minisign-verify",
"octotablet",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.1.1"
version = "0.3.5"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -40,6 +40,7 @@ ureq = { version = "2.10", default-features = false, features = ["tls", "gzip",
url = "2.5"
octotablet = "0.1.0"
minisign-verify = "0.3.0"
fs2 = "0.4"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
+4 -4
View File
@@ -88,19 +88,19 @@ Ergebnis in `target/`:
### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
```bash
minisign -G -W -p ink-notes.pub -s ink-notes.key
# oder ohne minisign-Paket, mit dem kompatiblen rsign2 (cargo install rsign2):
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
+54 -51
View File
@@ -1,6 +1,6 @@
# Dateiformat & Speicherstruktur von ink-notes
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
---
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
└── notebooks/
├── <notebook_id_1>/
│ ├── manifest.json
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
│ ├── page_1.json
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
│ ├── page_2.json
│ └── ...
└── <notebook_id_2>/
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```json
{
"format_version": 2,
"id": "notebook-a1b2c3d4",
"title": "Mathematik Vorlesung",
"version": 1,
"pages": [
{
"id": 1,
"file_name": "page_1.json"
"background": "Grid5mm"
},
{
"id": 2,
"file_name": "page_2.json"
"background": "DotGrid"
}
],
"page_ids": [1, 2],
"current_page_index": 0,
"next_page_id": 3,
"next_stroke_id": 42,
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```
### Felder:
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
- `version`: Format-Versionsnummer (aktuell `1`).
- `pages`: Geordnete Liste von Seitenreferenzen.
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
## 3. Seitendatei (`page_<id>.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
```json
{
"id": 1,
"background": "Grid5mm",
"strokes": [
{
"id": 1,
"color": [0.12, 0.12, 0.14, 1.0],
"width_mm": 0.5,
"samples": [
{
"x": 350.2,
"y": 420.5,
"pressure": 0.65,
"timestamp": 12.345
},
{
"x": 352.1,
"y": 425.8,
"pressure": 0.70,
"timestamp": 12.355
}
],
"recognized_shape": null
},
{
"id": 2,
"color": [0.15, 0.35, 0.85, 1.0],
"width_mm": 0.8,
"samples": [],
"recognized_shape": {
"Rectangle": {
"top_left": { "x": 500.0, "y": 600.0 },
"width": 300.0,
"height": 200.0
}
}
}
]
}
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
```
### Felder:
- `format_version`: Format-Versionsnummer (`2`).
- `id`: Eindeutige ID der Seite.
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
- `id`: Eindeutige ID des Strichs.
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
- `width_mm`: Basis-Strichbreite in Millimetern.
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
- `shape`: Optionales Enum für geometrische Vektorformen:
- `Line { start, end }`
- `Rectangle { top_left, width, height }`
- `Circle { center, radius }`
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
- `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }`
- `Octahedron { center, radius }`
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
---
## 4. Atomares Schreibverfahren
## 4. Datensicherheit & Migration
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
### Atomares Schreiben & Verzeichnis-Sync
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
### Quarantäne bei Parsefehlern
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
### Migrations-Backups & Rolling Backups
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
---
## 5. Versionshistorie
| Version | Status | Beschreibung |
|---|---|---|
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
---
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
- **Vorteile**:
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
- **Nachteile**:
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
- **Entscheidung**:
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
+9 -3
View File
@@ -3,12 +3,18 @@
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
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **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)
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases
- **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
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
### Installation:
+20 -5
View File
@@ -30,23 +30,38 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
# 4. Prüfsummen mit Minisign signieren (Pflicht für In-App-Updates)
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
rm -f "${SIGNATURE_FILE}"
EMBEDDED_PUBKEY=$(grep -m1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | cut -d '"' -f2)
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
if [ -z "${EMBEDDED_PUBKEY}" ]; then
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
exit 1
fi
echo "=== Signiere ${CHECKSUM_FILE} mit Minisign ==="
if ! command -v rsign >/dev/null 2>&1; then
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
exit 1
fi
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
KEY_FILE="$(mktemp)"
trap 'rm -f "${KEY_FILE}"' EXIT
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
minisign -S -s "${KEY_FILE}" -m "${CHECKSUM_FILE}" -t "ink-notes v${VERSION}"
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
rm -f "${KEY_FILE}"
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
minisign -V -P "${EMBEDDED_PUBKEY}" -m "${CHECKSUM_FILE}"
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
echo "${VERIFY_OUT}"
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
rm -f "${SIGNATURE_FILE}"
exit 1
fi
echo "${VERIFY_OUT}"
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
exit 1
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# ==============================================================================
# scripts/crash-test.sh
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
# und verifiziert die Integrität der Notizbücher und Seiten.
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ITERATIONS="${1:-200}"
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
echo "=================================================================="
echo " ink-notes Crash- & Datenverlust-Stresstest"
echo " Iterationen: ${ITERATIONS}"
echo " Testverzeichnis: ${TEST_DIR}"
echo "=================================================================="
# 1. Sicherstellen, dass das Test-Binary gebaut ist
if [ ! -f "${CRASH_SIM_BIN}" ]; then
echo "Baue crash_sim Release-Binary..."
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
fi
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
rm -rf "${TEST_DIR}"
mkdir -p "${TEST_DIR}"
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
SUCCESS_COUNT=0
INTERRUPTED_WRITES=0
trap 'rm -rf "${TEST_DIR}"' EXIT
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
for i in $(seq 1 "${ITERATIONS}"); do
# Worker im Hintergrund starten
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
WORKER_PID=$!
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
MS_DELAY=$(( (RANDOM % 33) + 2 ))
DELAY="0.$(printf "%03d" "$MS_DELAY")"
sleep "${DELAY}"
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
if kill -9 "${WORKER_PID}" 2>/dev/null; then
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
fi
wait "${WORKER_PID}" 2>/dev/null || true
# Integritäts- und Konsistenzprüfung
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
echo ""
echo "❌ FEHLER in Iteration ${i}!"
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
trap - EXIT
exit 1
fi
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
fi
done
echo ""
echo "=================================================================="
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
echo " - Keine unvollständigen Atom-Schreibungen"
echo "=================================================================="
+896 -153
View File
File diff suppressed because it is too large. Load diff
+221
View File
@@ -0,0 +1,221 @@
//! Crash-Simulationstesthelfer für ink-notes.
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
use std::env;
use std::path::Path;
use std::process;
use std::time::Duration;
use glam::Vec2;
use ink_notes::document::{Notebook, PageBackground, StylusSample};
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
use ink_notes::storage::{
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
NotebookManifest, PageData,
};
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
save_notebook(dir, &nb, &mut m)?;
println!("[init] Initialized clean notebook in {:?}", dir);
Ok(())
}
fn run_worker(dir: &Path) {
let (mut notebook, mut manifest) = match load_notebook(dir) {
Ok((nb, m)) => (nb, m),
Err(_) => {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
let _ = save_notebook(dir, &nb, &mut m);
(nb, m)
}
};
let spline_cfg = SplineConfig::default();
let mut stroke_counter = manifest.next_stroke_id;
println!("[worker] Started writing loop in {:?}", dir);
loop {
// Erzeuge neuen Strich mit einigen Samples
let base_x = (stroke_counter % 500) as f32;
let samples = vec![
StylusSample {
position: Vec2::new(base_x, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: Vec2::new(base_x + 10.0, 150.0),
pressure: 0.7,
timestamp: 0.01,
},
StylusSample {
position: Vec2::new(base_x + 20.0, 200.0),
pressure: 0.6,
timestamp: 0.02,
},
];
let stroke = finalize_stroke_with_config(
stroke_counter,
&samples,
[0.0, 0.0, 0.0, 1.0],
&spline_cfg,
);
stroke_counter += 1;
manifest.next_stroke_id = stroke_counter;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
let page_data = PageData::from(&*page);
// 1. Schreibe Seite atomar
if let Err(e) = save_page(dir, &page_data) {
eprintln!("[worker] Error saving page: {:?}", e);
}
// 2. Aktualisiere Manifest atomar
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
if stroke_counter % 5 == 0 {
if let Err(e) = save_notebook(dir, &notebook, &mut manifest) {
eprintln!("[worker] Error saving notebook: {:?}", e);
}
}
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
if stroke_counter % 25 == 0 {
let page_idx = notebook.add_page(PageBackground::DotGrid);
notebook.current_page_index = page_idx;
let new_page_data = PageData::from(notebook.current_page());
if let Err(e) = save_page(dir, &new_page_data) {
eprintln!("[worker] Error saving new page: {:?}", e);
}
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
}
}
}
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
println!("[verify] Checking notebook in {:?}", dir);
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
let manifest_path = dir.join("manifest.json");
let manifest_bak = dir.join("manifest.json.bak");
if !manifest_path.exists() && !manifest_bak.exists() {
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
return Ok(());
}
// 2. Notizbuch laden
let (_notebook, manifest) = match load_notebook(dir) {
Ok(res) => res,
Err(e) => {
eprintln!("[verify] FAILED to load notebook: {:?}", e);
return Err(Box::new(e));
}
};
println!(
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
manifest.title,
manifest.pages.len(),
manifest.next_stroke_id
);
// 3. Alle im Manifest registrierten Seiten laden und prüfen
for page_meta in &manifest.pages {
match load_page(dir, page_meta.id) {
Ok(page_data) => {
let page = page_data.to_page();
println!(
"[verify] Page {} verified: {} strokes, background={:?}",
page_meta.id,
page.strokes.len(),
page.background
);
}
Err(e) => {
eprintln!(
"[verify] FAILED to load page {} referenced by manifest: {:?}",
page_meta.id, e
);
return Err(Box::new(e));
}
}
}
// 4. Prüfen ob Quarantäne-Dateien existieren
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.contains(".corrupt-") {
println!("[verify] Notice: Found quarantined file: {}", name);
}
}
}
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
Ok(())
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
process::exit(2);
}
let command = &args[1];
let notebook_dir = Path::new(&args[2]);
match command.as_str() {
"init" => match run_init(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[init] Error: {}", e);
process::exit(1);
}
},
"worker" => {
run_worker(notebook_dir);
}
"verify" => match run_verify(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[verify] Error: {}", e);
process::exit(1);
}
},
other => {
eprintln!("Unknown command: {}", other);
process::exit(2);
}
}
}
+137 -39
View File
@@ -87,13 +87,43 @@ 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,
pub size: PhysicalSize<u32>,
pub viewport: PageViewport,
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
ui_inset_left: f32,
ui_inset_right: f32,
pub camera: crate::viewport::ViewportState,
// Pipelines
@@ -104,6 +134,8 @@ pub struct CanvasState {
pub camera_buffer: wgpu::Buffer,
pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32,
pub current_tool: crate::document::StrokeTool,
// Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer,
@@ -144,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
@@ -174,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()
@@ -183,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,
@@ -364,16 +405,22 @@ impl CanvasState {
Ok(Self {
surface,
adapter,
presentation_warned: false,
device,
queue,
config,
size,
viewport,
ui_inset_left: 0.0,
ui_inset_right: 0.0,
strip_pipeline,
triangle_pipeline,
camera_buffer,
camera_bind_group,
current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5,
current_tool: crate::document::StrokeTool::Pen,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
@@ -414,21 +461,54 @@ impl CanvasState {
);
}
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
return false;
}
self.ui_inset_left = left;
self.ui_inset_right = right;
self.recalculate_viewport();
true
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
self.ui_inset_left,
self.ui_inset_right,
);
self.update_uniforms();
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 {
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.viewport = PageViewport::calculate(
new_size.width as f32,
new_size.height as f32,
Self::VIEWPORT_MARGIN,
);
self.update_uniforms();
self.recalculate_viewport();
}
}
@@ -502,10 +582,14 @@ impl CanvasState {
/// Konfiguriert die Strichbreite in Millimetern.
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
let base_radius = (width_mm * 5.0).max(0.25);
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
self.current_stroke_width_mm = width_mm;
self.spline_config =
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
}
/// Setzt das aktive Zeichenwerkzeug für den Stift.
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
self.current_tool = tool;
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -643,6 +727,10 @@ impl CanvasState {
self.current_ink_color,
&self.spline_config,
);
let mut stroke = stroke;
stroke.tool = self.current_tool;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true;
// Temporären GPU-Live-Buffer vollständig leeren
self.live_samples.clear();
@@ -825,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;
+97 -7
View File
@@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length()
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrokeTool {
#[default]
Pen,
Highlighter,
Shape,
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
@@ -112,6 +122,9 @@ pub struct Stroke {
pub aabb: Aabb,
pub color: [f32; 4],
pub shape: Option<crate::geometry::RecognizedShape>,
pub tool: StrokeTool,
pub width: f32,
pub finalized: bool,
}
impl Stroke {
@@ -141,6 +154,9 @@ impl Stroke {
aabb,
color,
shape: None,
tool: StrokeTool::Pen,
width: 5.0,
finalized: true,
}
}
@@ -157,6 +173,22 @@ impl Stroke {
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
self.shape = Some(shape);
self.tool = StrokeTool::Shape;
self
}
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
self.tool = tool;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn with_finalized(mut self, finalized: bool) -> Self {
self.finalized = finalized;
self
}
@@ -304,6 +336,22 @@ pub enum PageBackground {
DotGrid,
}
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PersistState {
#[default]
Normal,
ReadOnlyAfterLoadError,
}
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
#[derive(Debug, Clone, PartialEq)]
pub struct PageLoadWarning {
pub page_id: u64,
pub message: String,
pub quarantine_path: Option<std::path::PathBuf>,
}
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
#[derive(Debug, Clone)]
pub struct Page {
@@ -314,9 +362,7 @@ pub struct Page {
pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool,
pub is_loaded: bool,
/// Laden von der Festplatte ist fehlgeschlagen; die Seite ist ein leerer Ersatz und
/// darf niemals gespeichert werden, damit die Originaldatei nicht überschrieben wird.
pub load_failed: bool,
pub persist: PersistState,
pub history: HistoryStack,
}
@@ -330,7 +376,7 @@ impl Page {
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: true,
load_failed: false,
persist: PersistState::Normal,
history: HistoryStack::new(),
}
}
@@ -345,11 +391,19 @@ impl Page {
stroke_ranges: Vec::new(),
is_dirty: false,
is_loaded: false,
load_failed: false,
persist: PersistState::Normal,
history: HistoryStack::new(),
}
}
pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError
}
pub fn unlock_after_load_error(&mut self) {
self.persist = PersistState::Normal;
}
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
pub fn add_stroke(&mut self, stroke: Stroke) {
self.history.push(HistoryAction::AddStroke(stroke.clone()));
@@ -592,6 +646,7 @@ pub struct Notebook {
pub current_page_index: usize,
pub next_stroke_id: u64,
pub next_page_id: u64,
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
}
impl Notebook {
@@ -601,6 +656,7 @@ impl Notebook {
current_page_index: 0,
next_stroke_id: 1,
next_page_id: 1,
load_warnings: std::collections::HashMap::new(),
};
// Erste Standardseite anlegen (5mm Grid)
nb.add_page(PageBackground::Grid5mm);
@@ -619,6 +675,7 @@ impl Notebook {
current_page_index,
next_stroke_id,
next_page_id,
load_warnings: std::collections::HashMap::new(),
};
if nb.pages.is_empty() {
nb.add_page(PageBackground::Grid5mm);
@@ -715,7 +772,21 @@ pub struct PageViewport {
impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
let avail_w = (window_width - 2.0 * margin).max(10.0);
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
}
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
pub fn calculate_with_insets(
window_width: f32,
window_height: f32,
margin: f32,
inset_left: f32,
inset_right: f32,
) -> Self {
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
let avail_h = (window_height - 2.0 * margin).max(10.0);
let window_ratio = avail_w / avail_h;
@@ -733,7 +804,7 @@ impl PageViewport {
(w, h)
};
let page_x = (window_width - page_w) / 2.0;
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0;
Self {
@@ -874,6 +945,25 @@ mod tests {
assert_eq!(nb.current_page_index, 0);
}
#[test]
fn test_viewport_insets_center_page_in_free_area() {
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
assert!((inset.page_width - plain.page_width).abs() < 0.01);
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
// Leiste rechts
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
}
#[test]
fn test_page_viewport_projection() {
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
+3 -5
View File
@@ -140,11 +140,9 @@ pub fn generate_pdf_document(
return Err("Notizbuch enthält keine Seiten".into());
}
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
if let Some(page) = notebook
.pages
.iter()
.find(|p| !p.is_loaded || p.load_failed)
{
if let Some(page) = notebook.pages.iter().find(|p| {
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
}) {
return Err(format!(
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
page.id
+764 -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).
@@ -1411,6 +1658,9 @@ pub fn shape_to_stroke(
let (samples, vertices) = shape_to_mesh(&shape, width_units, color);
let mut stroke = Stroke::new(stroke_id, samples, vertices, color);
stroke.shape = Some(shape);
stroke.width = width_units;
stroke.tool = crate::document::StrokeTool::Shape;
stroke.finalized = true;
stroke
}
@@ -1432,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 {
@@ -1469,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 {
@@ -1505,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 {
@@ -1540,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"
@@ -1591,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 {
@@ -1636,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 {
@@ -1677,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"
@@ -1716,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 {
@@ -1734,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
@@ -9,6 +9,7 @@ pub mod filter;
pub mod geometry;
pub mod history;
pub mod selection;
pub mod settings;
pub mod spline;
pub mod storage;
pub mod ui;
+436 -131
View File
@@ -23,6 +23,7 @@ use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
};
use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
@@ -144,7 +145,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) => {
@@ -159,9 +166,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung)
let storage_base_dir = storage::default_storage_dir();
let _ = storage::cleanup_trash(
&storage_base_dir,
std::time::Duration::from_secs(30 * 24 * 3600),
);
let available_notebooks = storage::list_notebooks(&storage_base_dir);
let (initial_id, initial_title) = if let Some(first) = available_notebooks.first() {
// Beschädigte Notizbücher nicht automatisch öffnen
let (initial_id, initial_title) = if let Some(first) = available_notebooks
.iter()
.find(|s| s.status == storage::NotebookStatus::Normal)
{
(first.id.clone(), first.title.clone())
} else {
("default".to_string(), "Standard-Notizbuch".to_string())
@@ -222,6 +237,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
let mut ui_state = UiState::new();
// Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste)
let ui_settings_path = UiSettings::default_path();
let mut ui_settings = ui_settings_path
.as_deref()
.map(UiSettings::load_from)
.unwrap_or_default();
ui_state.sidebar_side = ui_settings.sidebar_side;
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks;
@@ -231,6 +253,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let mut selection_state = SelectionState::new();
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
Ok(lock) => {
autosave.set_read_only(false);
Some(lock)
}
Err(storage::StorageError::AlreadyLocked(msg)) => {
eprintln!("[Storage-Lock] {msg}");
autosave.set_read_only(true);
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
None
}
Err(e) => {
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
autosave.set_read_only(true);
None
}
};
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
canvas.sync_page(notebook.current_page_mut());
canvas.set_stroke_color(ui_state.pen_color);
@@ -249,6 +289,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
std::collections::HashMap::new();
let mut pinch_prev_distance: Option<f32> = None;
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut pinch_prev_center: Option<(f32, f32)> = None;
let mut is_pinch_gesturing = false;
let mut is_touch_drawing = false;
@@ -261,6 +303,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false;
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
let mut stylus_ui_capture = false;
// Von egui angeforderter Zeitpunkt für das nächste Neuzeichnen (Layout-Vermessung, Tooltips)
let mut egui_repaint_at: Option<Instant> = None;
// Viewport-Pan & Zoom-Zustand
let mut is_panning = false;
@@ -353,6 +399,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
octotablet::events::ToolEvent::Out
| octotablet::events::ToolEvent::Removed => {
is_stylus_in_proximity = false;
if stylus_ui_capture {
stylus_ui_capture = false;
let (sx, sy) = stylus_screen_pos;
let input = egui_winit_state.egui_input_mut();
input.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
input.events.push(egui::Event::PointerGone);
window.request_redraw();
}
if stylus_tool_id == Some(tool.id()) {
stylus_tool_id = None;
if is_stylus_drawing {
@@ -400,6 +459,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
current_pressure = p;
}
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
// solange nicht auf dem Blatt gezeichnet oder radiert wird
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) {
let ui_pos = egui::pos2(sx / scale, sy / scale);
egui_winit_state
.egui_input_mut()
.events
.push(egui::Event::PointerMoved(ui_pos));
if stylus_ui_capture
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
{
window.request_redraw();
}
}
if stylus_ui_capture {
continue;
}
if is_erasing {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
@@ -410,7 +487,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(
&window,
&notebook,
@@ -446,28 +523,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
stylus_tool_id = Some(tool.id());
let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(sx, sy)
|| (ui_state.show_page_jump_popup
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open
&& egui_ctx.is_pointer_over_area());
if is_stylus_in_ui {
egui_ctx.input_mut(|i| {
i.events.push(egui::Event::PointerMoved(egui::pos2(
sx / scale,
sy / scale,
)));
i.events.push(egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
let ui_pos = egui::pos2(sx / scale, sy / scale);
let input = egui_winit_state.egui_input_mut();
input.events.push(egui::Event::PointerMoved(ui_pos));
input.events.push(egui::Event::PointerButton {
pos: ui_pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
stylus_ui_capture = true;
window.request_redraw();
continue;
}
@@ -487,7 +555,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(
&window,
&notebook,
@@ -520,24 +588,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
octotablet::events::ToolEvent::Up => {
let (sx, sy) = stylus_screen_pos;
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(sx, sy)
|| (ui_state.show_page_jump_popup
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open
&& egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open
&& egui_ctx.is_pointer_over_area());
if is_stylus_in_ui {
egui_ctx.input_mut(|i| {
i.events.push(egui::Event::PointerButton {
if stylus_ui_capture {
stylus_ui_capture = false;
egui_winit_state.egui_input_mut().events.push(
egui::Event::PointerButton {
pos: egui::pos2(sx / scale, sy / scale),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
});
},
);
window.request_redraw();
continue;
}
@@ -607,6 +667,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
if let Some(at) = egui_repaint_at {
if Instant::now() >= at {
egui_repaint_at = None;
window.request_redraw();
}
}
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
if new_status == AutosaveStatus::Pending
|| new_status == AutosaveStatus::Saving
@@ -616,6 +684,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|| is_stylus_in_proximity
{
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
} else if let Some(at) = egui_repaint_at {
target.set_control_flow(ControlFlow::WaitUntil(at));
} else {
target.set_control_flow(ControlFlow::Wait);
}
@@ -631,15 +701,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1)
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area());
|| ui::is_screen_point_in_ui(
&egui_ctx,
&ui_state,
cursor_screen_pos,
window.scale_factor() as f32,
);
match event {
WindowEvent::CloseRequested => {
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
autosave.shutdown();
target.exit();
match autosave.shutdown() {
Ok(()) => {
target.exit();
}
Err(err) => {
eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}");
ui_state.shutdown_error = Some(err);
window.request_redraw();
}
}
}
WindowEvent::Focused(false) => {
@@ -652,6 +733,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false;
is_mouse_drawing = false;
active_touches.clear();
ui_touch_ids.clear();
stylus_ui_capture = false;
is_pinch_gesturing = false;
pinch_prev_distance = None;
pinch_prev_center = None;
@@ -708,7 +791,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match &logical_key {
Key::Named(NamedKey::Escape) => {
let mut changed = false;
if ui_state.is_pen_menu_open
if ui_state.shutdown_error.is_some() {
ui_state.shutdown_error = None;
changed = true;
} else if ui_state.is_pen_menu_open
|| ui_state.is_highlighter_menu_open
|| ui_state.show_page_jump_popup
|| ui_state.is_document_menu_open
@@ -737,7 +823,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().undo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -751,7 +837,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().redo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -781,7 +867,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw();
}
@@ -793,7 +879,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = selection_state.delete_selected(notebook.current_page_mut());
if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
window.request_redraw();
}
@@ -802,9 +888,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Jetzt speichern (Disk Flush): Strg + S
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
match autosave.save_now(&notebook, &manifest) {
Ok(()) => ui_state.set_export_message("Gespeichert"),
Err(err) => ui_state.set_export_message(format!("Speichern fehlgeschlagen: {err}")),
match autosave.flush() {
Ok(()) => {
ui_state.set_export_message("Gespeichert");
}
Err(err) => {
ui_state.set_export_message(format!(
"Speicherfehler: {}",
err.messages.join(", ")
));
}
}
update_window_title(
&window,
@@ -855,7 +948,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let color = [0.12, 0.12, 0.14, 1.0];
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string());
window.request_redraw();
}
@@ -875,7 +968,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let color = [0.15, 0.35, 0.85, 1.0];
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string());
window.request_redraw();
}
@@ -894,7 +987,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() {
if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
window.request_redraw();
}
@@ -918,7 +1011,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
PageBackground::Blank => PageBackground::Grid5mm,
};
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -932,7 +1025,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -944,7 +1039,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -957,7 +1054,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -970,7 +1069,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -983,7 +1084,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1023,8 +1126,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Beenden: Strg + Q
Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => {
println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte...");
autosave.shutdown();
target.exit();
match autosave.shutdown() {
Ok(()) => {
target.exit();
}
Err(err) => {
eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}");
ui_state.shutdown_error = Some(err);
window.request_redraw();
}
}
}
@@ -1064,7 +1175,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1075,7 +1186,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Objektauswahl & Transformation im Select-Modus
if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(sx, sy);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -1162,7 +1273,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width);
notebook.current_page_mut().replace_stroke(old_id, new_stroke);
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)"));
window.request_redraw();
return;
@@ -1219,7 +1330,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
}
window.request_redraw();
}
@@ -1332,7 +1443,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1407,15 +1518,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return;
}
// Berührungen der UI (egui-winit hat sie bereits an egui weitergegeben)
if ui_touch_ids.contains(&touch.id) {
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
ui_touch_ids.remove(&touch.id);
}
window.request_redraw();
return;
}
match touch.phase {
TouchPhase::Started => {
// Event-Isolation für Touch-Eingaben
let is_touch_in_ui = egui_ctx.wants_pointer_input()
|| ui_state.is_point_in_ui(tx, ty)
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
let is_touch_in_ui = ui::is_screen_point_in_ui(
&egui_ctx,
&ui_state,
(tx, ty),
window.scale_factor() as f32,
);
if is_touch_in_ui {
ui_touch_ids.insert(touch.id);
window.request_redraw();
return;
}
@@ -1456,14 +1579,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
}
} else if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(tx, ty);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -1597,7 +1720,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1688,7 +1811,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
}
window.request_redraw();
}
@@ -1745,6 +1868,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
updater.update();
let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, autosave.status(), &updater);
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
let (inset_left, inset_right) = match ui_state.sidebar_side {
SidebarSide::Left => (reserved, 0.0),
SidebarSide::Right => (0.0, reserved),
};
if canvas.set_ui_insets(inset_left, inset_right) {
window.request_redraw();
}
// 4. UI-Aktionen verarbeiten
for action in actions {
match action {
@@ -1772,7 +1905,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty()
&& selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl umgefärbt".to_string());
}
match ui_state.active_tool {
@@ -1807,7 +1940,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = selection_state.delete_selected(notebook.current_page_mut());
if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
window.request_redraw();
}
@@ -1834,7 +1967,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw();
}
@@ -1843,7 +1976,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
window.request_redraw();
}
@@ -1871,7 +2004,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().undo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1880,7 +2013,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().redo() {
selection_state.clear();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1892,7 +2025,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1904,7 +2039,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id;
autosave.mark_dirty(&notebook, &manifest);
manifest.pages = notebook
.pages
.iter()
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
})
.collect();
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1914,11 +2059,31 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
notebook.add_page(bg);
manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id;
manifest.pages = notebook
.pages
.iter()
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
})
.collect();
canvas.sync_page(notebook.current_page_mut());
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
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();
@@ -1926,7 +2091,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = idx;
autosave.mark_dirty(&notebook, &manifest);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1935,7 +2102,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let page = notebook.current_page_mut();
page.background = bg;
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &manifest);
autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
@@ -1957,9 +2124,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let new_notebook_dir = storage_base_dir.join(&id);
match storage::load_notebook(&new_notebook_dir) {
Ok((loaded_nb, loaded_manifest)) => {
if let Err(err) = autosave.switch_notebook_dir(new_notebook_dir.clone()) {
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
}
let _ = autosave.flush();
match storage::NotebookLock::acquire(&new_notebook_dir) {
Ok(lock) => {
_notebook_lock = Some(lock);
autosave.set_read_only(false);
}
Err(storage::StorageError::AlreadyLocked(msg)) => {
eprintln!("[Storage-Lock] {msg}");
_notebook_lock = None;
autosave.set_read_only(true);
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
}
Err(e) => {
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
_notebook_lock = None;
autosave.set_read_only(true);
}
}
autosave.switch_notebook_dir(new_notebook_dir.clone());
notebook = loaded_nb;
manifest = loaded_manifest;
storage_dir = new_notebook_dir;
@@ -1970,7 +2153,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.is_document_menu_open = false;
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title));
if _notebook_lock.is_some() {
ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title));
}
window.request_redraw();
}
Err(err) => {
@@ -1981,11 +2166,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
UiAction::CreateNotebook(title) => {
let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((new_nb, new_manifest, new_dir)) => {
if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) {
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
}
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
autosave.set_read_only(_notebook_lock.is_none());
autosave.switch_notebook_dir(new_dir.clone());
notebook = new_nb;
manifest = new_manifest;
storage_dir = new_dir;
@@ -2012,6 +2198,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) => {
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
window.request_redraw();
@@ -2036,54 +2224,66 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::DeleteNotebook(id) => {
let target_dir = storage_base_dir.join(&id);
let is_current = id == manifest.id;
let deleted_title = ui_state
.available_notebooks
.iter()
.find(|s| s.id == id)
.map(|s| s.title.clone())
.unwrap_or_else(|| id.clone());
if is_current {
// Ausstehende Autosave-Schreibvorgänge VOR dem Löschen abarbeiten,
// sonst legen sie den gelöschten Ordner danach wieder an.
let _ = autosave.flush();
drop(_notebook_lock.take());
}
match storage::delete_notebook(&target_dir) {
Ok(()) => {
match storage::delete_notebook(&target_dir, &storage_base_dir) {
Ok(trash_path) => {
ui_state.last_deleted_notebook = Some((trash_path, deleted_title));
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
if is_current {
// Erstes ladbares Notizbuch öffnen, sonst ein neues anlegen
let replacement = ui_state
if let Some(first) = ui_state
.available_notebooks
.iter()
.find_map(|summary| {
let dir = storage_base_dir.join(&summary.id);
match storage::load_notebook(&dir) {
Ok((nb, m)) => Some((nb, m, dir)),
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", summary.id);
None
}
.find(|s| s.status != storage::NotebookStatus::Damaged)
.cloned()
{
let new_notebook_dir = storage_base_dir.join(&first.id);
match storage::load_notebook(&new_notebook_dir) {
Ok((loaded_nb, loaded_manifest)) => {
_notebook_lock = storage::NotebookLock::acquire(&new_notebook_dir).ok();
autosave.set_read_only(_notebook_lock.is_none());
autosave.switch_notebook_dir(new_notebook_dir.clone());
notebook = loaded_nb;
manifest = loaded_manifest;
storage_dir = new_notebook_dir;
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
}
})
.map(Ok)
.unwrap_or_else(|| storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch"));
match replacement {
Ok((new_nb, new_manifest, new_dir)) => {
if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) {
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id);
}
notebook = new_nb;
manifest = new_manifest;
storage_dir = new_dir;
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte kein Ersatz-Notizbuch anlegen: {err}");
ui_state.set_export_message(format!("Dokument gelöscht, aber kein Ersatz-Notizbuch: {err}"));
}
} else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") {
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
autosave.set_read_only(_notebook_lock.is_none());
autosave.switch_notebook_dir(new_dir.clone());
notebook = new_nb;
manifest = new_manifest;
storage_dir = new_dir;
selection_state.clear();
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
canvas.sync_page(notebook.current_page_mut());
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
}
} else {
ui_state.set_export_message("Dokument gelöscht".to_string());
ui_state.set_export_message("Dokument in Papierkorb verschoben".to_string());
}
window.request_redraw();
}
@@ -2093,6 +2293,40 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
UiAction::UndoDeleteNotebook => {
if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() {
match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) {
Ok(_restored_dir) => {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
ui_state.set_export_message("Notizbuch wiederhergestellt".to_string());
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Notizbuch nicht wiederherstellen: {err}");
ui_state.set_export_message(format!("Fehler beim Wiederherstellen: {err}"));
}
}
}
}
UiAction::RestoreNotebookBackup(id) => {
let target_dir = storage_base_dir.join(&id);
match storage::restore_manifest_from_backup(&target_dir) {
Ok(()) => {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string());
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Backup für '{id}' nicht wiederherstellen: {err}");
ui_state.set_export_message(format!("Wiederherstellung fehlgeschlagen: {err}"));
}
}
}
UiAction::OpenNotebookFolder(id) => {
let target_dir = storage_base_dir.join(&id);
ui_state.set_export_message(format!("Ordner: {}", target_dir.display()));
window.request_redraw();
}
UiAction::CheckForUpdates => {
updater.check_for_updates();
window.request_redraw();
@@ -2118,6 +2352,61 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.set_export_message("Server-Einstellungen gespeichert");
window.request_redraw();
}
UiAction::UnlockCorruptedPage(page_id) => {
notebook.load_warnings.remove(&page_id);
if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) {
page.unlock_after_load_error();
}
autosave.mark_dirty(&notebook, &mut manifest);
window.request_redraw();
}
UiAction::RetryShutdown => {
println!("[Shutdown] Wiederhole Speichern vor Beenden...");
match autosave.shutdown() {
Ok(()) => {
ui_state.shutdown_error = None;
target.exit();
}
Err(err) => {
ui_state.shutdown_error = Some(err);
window.request_redraw();
}
}
}
UiAction::ExportRecoveryAndExit => {
println!("[Shutdown] Starte Notfall-Export aller ungespeicherten Daten...");
match autosave.export_recovery(Some(&notebook)) {
Ok(paths) => {
eprintln!(
"[Recovery] Notfall-Export erfolgreich geschrieben ({} Dateien): {:?}",
paths.len(),
paths
);
target.exit();
}
Err(err) => {
eprintln!("[Recovery-Fehler] Konnte Notfall-Export nicht schreiben: {err}");
if let Some(ref mut se) = ui_state.shutdown_error {
se.messages.push(format!("Notfall-Export fehlgeschlagen: {err}"));
}
window.request_redraw();
}
}
}
UiAction::SetSidebarSide(side) => {
ui_settings.sidebar_side = side;
if let Some(path) = ui_settings_path.as_deref() {
if let Err(err) = ui_settings.save_to(path) {
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
}
}
window.request_redraw();
}
UiAction::ForceExit => {
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
target.exit();
}
}
}
@@ -2125,6 +2414,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let full_output = egui_ctx.end_frame();
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
let repaint_delay = full_output
.viewport_output
.get(&egui::ViewportId::ROOT)
.map(|v| v.repaint_delay)
.unwrap_or(Duration::MAX);
if repaint_delay.is_zero() {
egui_repaint_at = None;
window.request_redraw();
} else if repaint_delay < Duration::from_secs(10) {
egui_repaint_at = Some(Instant::now() + repaint_delay);
} else {
egui_repaint_at = None;
}
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
let screen_descriptor = egui_wgpu::ScreenDescriptor {
size_in_pixels: [canvas.size.width, canvas.size.height],
+28 -12
View File
@@ -115,12 +115,15 @@ impl SelectionState {
Page::selection_aabb(&strokes)
}
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
pub fn hit_test_handles(
&self,
page: &Page,
canvas: &CanvasState,
screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
if (screen_pos - rot_screen).length() <= 14.0 {
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let ppp = pixels_per_point;
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::RotateHandle {
center_page: page_center,
};
@@ -164,7 +168,7 @@ impl SelectionState {
];
for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 {
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle {
corner_idx: i,
fixed_corner_page: fixed_corners_page[i],
@@ -273,6 +277,16 @@ impl SelectionState {
}
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
const HANDLE_HIT_RADIUS: f32 = 18.0;
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
}
pub fn render_selection_overlay(
ctx: &egui::Context,
selection: &SelectionState,
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"),
));
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
let ppp = ctx.pixels_per_point();
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect {
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
current_page,
} = selection.drag_mode
{
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]);
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
painter.rect_filled(
rect,
egui::Rounding::same(4.0_f32),
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
);
painter.rect_stroke(
rect,
egui::Rounding::same(4.0_f32),
egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
),
);
return;
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
return;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
// Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
painter.line_segment(
[top_center, rot_handle],
+84
View File
@@ -0,0 +1,84 @@
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
//!
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SidebarSide {
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
#[default]
Left,
/// Rechts (für Linkshänder)
Right,
}
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub sidebar_side: SidebarSide,
}
impl UiSettings {
/// Standardpfad gemäß XDG-Spezifikation.
pub fn default_path() -> Option<PathBuf> {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
};
Some(base.join("ink-notes").join("ui.json"))
}
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
}
}
/// Speichert die Einstellungen atomar.
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(path, self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_settings_round_trip_and_fallbacks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ink-notes").join("ui.json");
// Fehlende Datei -> Standard (links)
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
let settings = UiSettings {
sidebar_side: SidebarSide::Right,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
// Beschädigte Datei -> Standard statt Absturz
std::fs::write(&path, b"{ kaputt").unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
// Unbekannte / fehlende Felder werden toleriert
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
}
}
+16 -1
View File
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
}
}
impl SplineConfig {
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
pub fn from_width_units(width_units: f32) -> Self {
let base_radius = (width_units / 2.0).max(0.25);
Self {
min_radius: (base_radius * 0.5).max(0.15),
max_radius: (base_radius * 1.5).max(0.35),
samples_per_segment: 16,
}
}
}
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
let p = pressure.clamp(0.0, 1.0);
@@ -653,7 +665,10 @@ pub fn finalize_stroke_with_config(
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect();
Stroke::new(stroke_id, decimated, ink_vertices, color)
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
stroke.width = config.min_radius + config.max_radius;
stroke.finalized = true;
stroke
}
#[cfg(test)]
+1512 -307
View File
File diff suppressed because it is too large. Load diff
+2092 -1025
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -317,7 +317,8 @@ pub fn verify_checksum(
/// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
/// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
/// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
pub const UPDATE_SIGNING_PUBLIC_KEY: &str = "";
pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
"RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
/// Name des Prüfsummen-Assets eines Releases.
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
+14
View File
@@ -0,0 +1,14 @@
{
"format_version": 99,
"id": "future_notebook",
"title": "Zukunfts-Notizbuch",
"created_at": 1800000000,
"modified_at": 1800001000,
"current_page_index": 0,
"pages": [
{ "id": 1, "background": "Grid5mm" }
],
"page_ids": [1],
"next_stroke_id": 10,
"next_page_id": 2
}
+6
View File
@@ -0,0 +1,6 @@
{
"format_version": 99,
"id": 1,
"background": "Blank",
"strokes": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "legacy_notebook",
"title": "Legacy Notizbuch v0",
"created_at": 1700000000,
"modified_at": 1700001000,
"current_page_index": 0,
"page_ids": [1],
"next_stroke_id": 5,
"next_page_id": 2
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": 1,
"background": "DotGrid",
"strokes": [
{
"id": 1,
"color": [0.1, 0.2, 0.3, 1.0],
"samples": [
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
]
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"format_version": 1,
"id": 1,
"background": "Lined",
"strokes": [],
"unknown_experimental_feature": {
"nested_data": [1, 2, 3],
"author": "Antigravity"
},
"random_future_flag": true
}