Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9571e2121c | ||
|
|
4a7627e461 | ||
|
|
cb11b1f828 | ||
|
|
89005f718e | ||
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d | ||
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 | ||
|
|
5cd45831e2 | ||
|
|
71f6a70829 | ||
|
|
b751b71223 | ||
|
|
afb057692d | ||
|
|
eeae143dc6 | ||
|
|
992c3daad7 | ||
|
|
9684238a53 | ||
|
|
e8674e730f | ||
|
|
7e99d59042 | ||
|
|
7ed971d2a2 | ||
|
|
5ce2c250bb | ||
|
|
d61e1819ed | ||
|
|
12755d8eea | ||
|
|
510035e807 | ||
|
|
1afe4a0f9d | ||
|
|
04666bf389 | ||
|
|
a82402bfce | ||
|
|
06b3aeb311 |
@@ -44,18 +44,28 @@ jobs:
|
||||
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
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
|
||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
|
||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
|
||||
run: |
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/build-release.sh
|
||||
env:
|
||||
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
||||
|
||||
- name: Publish Release to Gitea
|
||||
run: |
|
||||
|
||||
Generated
+19
-1
@@ -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,13 +1048,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.1.1"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"fs2",
|
||||
"glam",
|
||||
"minisign-verify",
|
||||
"octotablet",
|
||||
"pollster",
|
||||
"printpdf",
|
||||
@@ -1293,6 +1305,12 @@ dependencies = [
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.1.1"
|
||||
version = "0.3.6"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
@@ -39,6 +39,8 @@ sha2 = "0.10"
|
||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||
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
|
||||
|
||||
@@ -71,7 +71,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
||||
|
||||
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
|
||||
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
|
||||
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, SHA-256-Prüfung und atomarer In-Place Austausch.
|
||||
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
|
||||
|
||||
### 1. Release-Pakete bauen
|
||||
|
||||
@@ -84,6 +84,23 @@ Ergebnis in `target/`:
|
||||
- `ink-notes_0.1.0_amd64.deb`
|
||||
- `ink-notes_0.1.0_x86_64.AppImage`
|
||||
- `SHA256SUMS.txt`
|
||||
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
|
||||
|
||||
### Release-Signatur einrichten (einmalig)
|
||||
|
||||
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
|
||||
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 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
|
||||
|
||||
@@ -123,15 +140,15 @@ export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
|
||||
|
||||
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
||||
|
||||
1. **SHA-256-Integritätsprüfung:**
|
||||
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen.
|
||||
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`).
|
||||
1. **Minisign-Signatur (Pflicht):**
|
||||
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
|
||||
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
|
||||
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
|
||||
2. **SHA-256-Integritätsprüfung:**
|
||||
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
|
||||
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
|
||||
- *Hinweis zur Authentizität:* Eine Prüfsumme vom selben Release-Server garantiert Schutz gegen Übertragungsfehler und Datei-Beschädigungen, jedoch keine kryptografische Nicht-Abstreitbarkeit gegenüber einem kompromittierten Server.
|
||||
2. **Signatur-Ausblick:**
|
||||
- Die Updater-Architektur enthält Schnittstellen für optionale `.sig`/`.minisig`-Signaturen zur zukünftigen End-to-End-Kryptoverifikation über öffentliche Schlüssel.
|
||||
3. **Redirect-Schutz & HTTPS:**
|
||||
- Alle API- und Download-Endpunkte erzwingen standardmäßig HTTPS.
|
||||
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
|
||||
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
|
||||
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
|
||||
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
|
||||
|
||||
+54
-51
@@ -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.
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
|
||||
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
|
||||
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
|
||||
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl 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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
||||
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/
|
||||
# Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
||||
cd "${ROOT_DIR}/target"
|
||||
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
||||
|
||||
# 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}"
|
||||
# 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
|
||||
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}"
|
||||
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
|
||||
# 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
|
||||
else
|
||||
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
|
||||
echo " Installierte Apps lehnen ein solches Release als Update ab."
|
||||
fi
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
||||
echo "======================================================================"
|
||||
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
||||
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
||||
echo ""
|
||||
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
||||
cat "${CHECKSUM_FILE}"
|
||||
|
||||
Executable
+82
@@ -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 "=================================================================="
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
||||
# Lädt .deb, .AppImage und SHA256SUMS.txt idempotent als Release-Assets hoch.
|
||||
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
|
||||
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
||||
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
||||
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
||||
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
|
||||
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
||||
|
||||
if [ ! -f "${TEMPLATE_FILE}" ]; then
|
||||
@@ -46,6 +47,12 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
|
||||
"${SCRIPT_DIR}/build-release.sh"
|
||||
fi
|
||||
|
||||
# Ohne Signatur lehnen installierte Apps das Release als Update ab
|
||||
if [ ! -f "${SIGNATURE_FILE}" ]; then
|
||||
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
||||
echo "======================================================================"
|
||||
@@ -230,6 +237,7 @@ except Exception:
|
||||
upload_asset "${DEB_FILE}"
|
||||
upload_asset "${APPIMAGE_FILE}"
|
||||
upload_asset "${CHECKSUMS_FILE}"
|
||||
upload_asset "${SIGNATURE_FILE}"
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
||||
|
||||
+899
-85
File diff suppressed because it is too large
Load Diff
@@ -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(¬ebook);
|
||||
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, ¬ebook, &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(¬ebook);
|
||||
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
@@ -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
-2
@@ -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,6 +362,7 @@ pub struct Page {
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
pub is_loaded: bool,
|
||||
pub persist: PersistState,
|
||||
pub history: HistoryStack,
|
||||
}
|
||||
|
||||
@@ -327,6 +376,7 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: true,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
@@ -341,10 +391,19 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: 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()));
|
||||
@@ -587,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 {
|
||||
@@ -596,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);
|
||||
@@ -614,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);
|
||||
@@ -710,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;
|
||||
@@ -728,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 {
|
||||
@@ -869,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);
|
||||
|
||||
@@ -139,6 +139,16 @@ pub fn generate_pdf_document(
|
||||
if notebook.pages.is_empty() {
|
||||
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.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||
}) {
|
||||
return Err(format!(
|
||||
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
|
||||
page.id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// 1. Erstes Dokument mit Seite 1 erstellen
|
||||
let title = "ink-notes Notebook";
|
||||
|
||||
+764
-98
@@ -409,7 +409,13 @@ pub fn detect_circle(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 3. Rechteckerkennung: Geschlossener Pfad mit 4 dominanten Ecken (~90° +/- 18°).
|
||||
/// 3. Rechteckerkennung: kleinstes umschließendes Rechteck eines geschlossenen Pfads.
|
||||
///
|
||||
/// Statt Ecken über eine grobe Linienvereinfachung zu suchen (das schnitt bei kleinen oder
|
||||
/// zittrigen Strichen die Ecken ab), wird über alle Drehwinkel das Rechteck mit der
|
||||
/// kleinsten Fläche gesucht. Einzelne Ausreißer (z. B. ein Überstrich am Schluss) zählen
|
||||
/// nicht: Die Ausdehnung wird über das 2.–98. Perzentil der Punkte bestimmt. Angenommen
|
||||
/// wird das Rechteck nur, wenn es den Strich eng beschreibt.
|
||||
pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
if points.len() < 8 {
|
||||
return None;
|
||||
@@ -429,78 +435,99 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// RDP mit ca. 5% Pfadlänge als Toleranz
|
||||
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||
// Ausdehnung entlang einer Achse (Winkel in Grad), robust gegen Ausreißer
|
||||
let extent = |angle_deg: f32| -> (f32, f32, f32, f32) {
|
||||
let (sin, cos) = angle_deg.to_radians().sin_cos();
|
||||
let mut along: Vec<f32> = points.iter().map(|p| p.x * cos + p.y * sin).collect();
|
||||
let mut across: Vec<f32> = points.iter().map(|p| -p.x * sin + p.y * cos).collect();
|
||||
along.sort_by(f32::total_cmp);
|
||||
across.sort_by(f32::total_cmp);
|
||||
let lo = points.len() * 2 / 100;
|
||||
let hi = points.len() - 1 - lo;
|
||||
(along[lo], along[hi], across[lo], across[hi])
|
||||
};
|
||||
let area = |angle_deg: f32| {
|
||||
let (a0, a1, b0, b1) = extent(angle_deg);
|
||||
(a1 - a0) * (b1 - b0)
|
||||
};
|
||||
|
||||
let corners: Vec<Vec2> =
|
||||
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
||||
simplified[0..4].to_vec()
|
||||
} else if simplified.len() == 4 {
|
||||
simplified
|
||||
} else if simplified.len() > 4 {
|
||||
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
|
||||
extract_k_corners(&simplified, 4)?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// Grobsuche in 1°-Schritten, dann Feinsuche in 0,1°-Schritten um das Optimum
|
||||
let mut best_angle = (0..90)
|
||||
.map(|d| d as f32)
|
||||
.min_by(|a, b| area(*a).total_cmp(&area(*b)))
|
||||
.unwrap_or(0.0);
|
||||
best_angle = (-10..=10)
|
||||
.map(|d| best_angle + d as f32 * 0.1)
|
||||
.min_by(|a, b| area(*a).total_cmp(&area(*b)))
|
||||
.unwrap_or(best_angle);
|
||||
|
||||
if corners.len() != 4 {
|
||||
// Nahe an der Achsenparallelität (≤ 10°) gerade ausrichten
|
||||
let rem = best_angle.rem_euclid(90.0);
|
||||
if rem <= RECTANGLE_AXIS_SNAP_DEG || rem >= 90.0 - RECTANGLE_AXIS_SNAP_DEG {
|
||||
best_angle = 0.0;
|
||||
}
|
||||
|
||||
let (mut a0, mut a1, mut b0, mut b1) = extent(best_angle);
|
||||
let (sin, cos) = best_angle.to_radians().sin_cos();
|
||||
let dir = Vec2::new(cos, sin);
|
||||
let norm = Vec2::new(-sin, cos);
|
||||
|
||||
// Kanten in die Mitte des Zitterns legen: Jeder Punkt gehört zur nächsten Kante, die Kante
|
||||
// wandert auf den Median ihrer Punkte (das umschließende Rechteck liegt außen an den Spitzen)
|
||||
let mut sides: [Vec<f32>; 4] = Default::default();
|
||||
for p in points {
|
||||
let (u, v) = (p.dot(dir), p.dot(norm));
|
||||
let d = [
|
||||
(u - a0).abs(),
|
||||
(u - a1).abs(),
|
||||
(v - b0).abs(),
|
||||
(v - b1).abs(),
|
||||
];
|
||||
let side = (0..4).min_by(|&i, &j| d[i].total_cmp(&d[j])).unwrap_or(0);
|
||||
sides[side].push(if side < 2 { u } else { v });
|
||||
}
|
||||
let median = |values: &mut Vec<f32>, fallback: f32| {
|
||||
if values.is_empty() {
|
||||
return fallback;
|
||||
}
|
||||
values.sort_by(f32::total_cmp);
|
||||
values[values.len() / 2]
|
||||
};
|
||||
a0 = median(&mut sides[0], a0);
|
||||
a1 = median(&mut sides[1], a1);
|
||||
b0 = median(&mut sides[2], b0);
|
||||
b1 = median(&mut sides[3], b1);
|
||||
|
||||
let corners = [
|
||||
dir * a0 + norm * b0,
|
||||
dir * a1 + norm * b0,
|
||||
dir * a1 + norm * b1,
|
||||
dir * a0 + norm * b1,
|
||||
];
|
||||
|
||||
// Alle vier Ecken müssen gezeichnet sein: Ovale und Kreise schneiden die Ecken ab
|
||||
// (Kreis: ~20 % der Seitenlänge), sonst würden flache Ellipsen zu Rechtecken
|
||||
let shorter_side = (a1 - a0).abs().min((b1 - b0).abs());
|
||||
let corner_reached = |corner: Vec2| {
|
||||
points
|
||||
.iter()
|
||||
.map(|p| (*p - corner).length())
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
<= RECTANGLE_CORNER_REACH * shorter_side
|
||||
};
|
||||
if !corners.iter().all(|c| corner_reached(*c)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Innenwinkel prüfen: 4 Kanten müssen jeweils ~90° (+/- 18°) zueinander stehen
|
||||
let c = [corners[0], corners[1], corners[2], corners[3]];
|
||||
for i in 0..4 {
|
||||
let prev = c[(i + 3) % 4];
|
||||
let curr = c[i];
|
||||
let next = c[(i + 1) % 4];
|
||||
let v1 = (prev - curr).normalize_or_zero();
|
||||
let v2 = (next - curr).normalize_or_zero();
|
||||
let dot = v1.dot(v2).clamp(-1.0, 1.0);
|
||||
let angle_deg = dot.acos().to_degrees();
|
||||
if !(72.0..=108.0).contains(&angle_deg) {
|
||||
return None;
|
||||
}
|
||||
// Nur annehmen, wenn das Rechteck den Strich eng beschreibt (sonst wären beliebige
|
||||
// geschlossene Kritzeleien „Rechtecke“)
|
||||
let rect = RecognizedShape::Rectangle { corners };
|
||||
let err = contour_rms(points, &rect)?;
|
||||
let diagonal = (corners[2] - corners[0]).length();
|
||||
if err > RECTANGLE_MAX_RMS * diagonal {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Saubere rechtwinklige Ausrichtung
|
||||
let aabb = Aabb::from_points(&c);
|
||||
let center = (c[0] + c[1] + c[2] + c[3]) * 0.25;
|
||||
|
||||
let e0 = c[1] - c[0];
|
||||
let angle_deg = e0.y.atan2(e0.x).to_degrees().abs();
|
||||
|
||||
// Nahe an Achsenparallelität (< 12°)?
|
||||
let snapped_corners = if angle_deg < 12.0
|
||||
|| (angle_deg - 90.0).abs() < 12.0
|
||||
|| (angle_deg - 180.0).abs() < 12.0
|
||||
{
|
||||
[
|
||||
Vec2::new(aabb.min.x, aabb.min.y),
|
||||
Vec2::new(aabb.max.x, aabb.min.y),
|
||||
Vec2::new(aabb.max.x, aabb.max.y),
|
||||
Vec2::new(aabb.min.x, aabb.max.y),
|
||||
]
|
||||
} else {
|
||||
// Rotiertes Rechteck mit exakt 90° Kanten
|
||||
let dir = e0.normalize_or_zero();
|
||||
let norm = Vec2::new(-dir.y, dir.x);
|
||||
let w = 0.5 * ((c[1] - c[0]).length() + (c[2] - c[3]).length());
|
||||
let h = 0.5 * ((c[2] - c[1]).length() + (c[3] - c[0]).length());
|
||||
let hw = w * 0.5;
|
||||
let hh = h * 0.5;
|
||||
[
|
||||
center - dir * hw - norm * hh,
|
||||
center + dir * hw - norm * hh,
|
||||
center + dir * hw + norm * hh,
|
||||
center - dir * hw + norm * hh,
|
||||
]
|
||||
};
|
||||
|
||||
Some(RecognizedShape::Rectangle {
|
||||
corners: snapped_corners,
|
||||
})
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
/// Rastet einen Winkel in Radiant an horizontalen, vertikalen oder diagonalen Achsen ein.
|
||||
@@ -543,12 +570,15 @@ pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option<Vec<Vec2>> {
|
||||
let next = points[(i + 1) % n];
|
||||
let v1 = (prev - curr).normalize_or_zero();
|
||||
let v2 = (next - curr).normalize_or_zero();
|
||||
// Dot product: je kleiner, desto spitzer/schärfer die Richtungsänderung
|
||||
// Skalarprodukt der Richtungen zu den Nachbarn: −1 = gerader Verlauf (180°),
|
||||
// 0 = rechter Winkel, gegen +1 = spitze Ecke. Je größer, desto markanter die Ecke.
|
||||
let dot = v1.dot(v2).clamp(-1.0, 1.0);
|
||||
scored.push((i, dot));
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
// Markanteste Ecken zuerst (größtes Skalarprodukt). Vorher war die Sortierung
|
||||
// umgekehrt und wählte die geradesten Punkte.
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
|
||||
selected_indices.sort_unstable();
|
||||
|
||||
@@ -844,6 +874,11 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
}
|
||||
|
||||
let corners = simplified;
|
||||
// Echte Ecken verlangen: Bei einem glatten Oval verteilt sich die Richtungsänderung
|
||||
// gleichmäßig, die Vereinfachung liefert aber trotzdem 6 oder 8 Punkte
|
||||
if !turning_concentrated_at_corners(points, &corners) {
|
||||
return None;
|
||||
}
|
||||
let center = corners.iter().copied().sum::<Vec2>() / (n as f32);
|
||||
|
||||
let radii: Vec<f32> = corners.iter().map(|&pt| (pt - center).length()).collect();
|
||||
@@ -877,15 +912,93 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss.
|
||||
const POLYGON_TURNING_AT_CORNERS: f32 = 0.7;
|
||||
/// Umgebung einer Ecke als Anteil des Umfangs.
|
||||
const POLYGON_CORNER_NEIGHBORHOOD: f32 = 0.03;
|
||||
|
||||
/// Liegt die Richtungsänderung des Strichs überwiegend an den Ecken (Vieleck) und nicht
|
||||
/// gleichmäßig verteilt (Oval)? Gemessen wird die vorzeichenbehaftete Drehung entlang des
|
||||
/// gleichmäßig neu abgetasteten Strichs; Zittern hebt sich dabei weitgehend auf.
|
||||
fn turning_concentrated_at_corners(points: &[Vec2], corners: &[Vec2]) -> bool {
|
||||
corner_turning_ratio(points, corners).is_some_and(|r| r >= POLYGON_TURNING_AT_CORNERS)
|
||||
}
|
||||
|
||||
/// Anteil der vorzeichenbehafteten Richtungsänderung, der in der Nähe der Ecken liegt.
|
||||
fn corner_turning_ratio(points: &[Vec2], corners: &[Vec2]) -> Option<f32> {
|
||||
let total_len = path_length(points);
|
||||
if total_len < 1e-3 || corners.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Gleichmäßig neu abtasten (120 Punkte entlang der Bogenlänge)
|
||||
const SAMPLES: usize = 120;
|
||||
let step = total_len / SAMPLES as f32;
|
||||
let mut resampled = vec![points[0]];
|
||||
let mut carried = 0.0;
|
||||
for w in points.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let seg = (b - a).length();
|
||||
let mut pos = step - carried;
|
||||
while pos <= seg {
|
||||
resampled.push(a + (b - a) * (pos / seg));
|
||||
pos += step;
|
||||
}
|
||||
carried = seg - (pos - step);
|
||||
}
|
||||
// Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten
|
||||
const W: usize = 3;
|
||||
if resampled.len() < 2 * W + 2 {
|
||||
return None;
|
||||
}
|
||||
let near = POLYGON_CORNER_NEIGHBORHOOD * total_len;
|
||||
let (mut turn_total, mut turn_near) = (0.0_f32, 0.0_f32);
|
||||
for i in W..resampled.len() - W {
|
||||
let d_in = resampled[i] - resampled[i - W];
|
||||
let d_out = resampled[i + W] - resampled[i];
|
||||
let turn = d_in.perp_dot(d_out).atan2(d_in.dot(d_out)) / W as f32;
|
||||
turn_total += turn;
|
||||
if corners.iter().any(|c| (*c - resampled[i]).length() <= near) {
|
||||
turn_near += turn;
|
||||
}
|
||||
}
|
||||
(turn_total.abs() > 1e-3).then(|| turn_near / turn_total)
|
||||
}
|
||||
|
||||
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||
if simplified.len() >= 2
|
||||
let mut vertices = if simplified.len() >= 2
|
||||
&& (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0
|
||||
{
|
||||
simplified[0..simplified.len() - 1].to_vec()
|
||||
} else {
|
||||
simplified
|
||||
};
|
||||
// Überzählige Punkte entfernen: zu dicht am Nachbarn (z. B. das Strichende kurz vor dem
|
||||
// Start) oder fast auf einer Geraden mit den Nachbarn (< 20° Richtungsänderung)
|
||||
loop {
|
||||
let n = vertices.len();
|
||||
if n <= 3 {
|
||||
break;
|
||||
}
|
||||
let redundant = (0..n).find(|&i| {
|
||||
let (prev, curr, next) = (
|
||||
vertices[(i + n - 1) % n],
|
||||
vertices[i],
|
||||
vertices[(i + 1) % n],
|
||||
);
|
||||
let d_in = curr - prev;
|
||||
let d_out = next - curr;
|
||||
let turn = d_in.perp_dot(d_out).atan2(d_in.dot(d_out)).abs();
|
||||
(next - curr).length() < epsilon || turn < 20f32.to_radians()
|
||||
});
|
||||
match redundant {
|
||||
Some(i) => {
|
||||
vertices.remove(i);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
vertices
|
||||
}
|
||||
|
||||
/// 7. Oktaeder-Erkennung (3D-Kantenmodell / Doppelpyramide).
|
||||
@@ -1035,37 +1148,171 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||
}
|
||||
let pts: Vec<Vec2> = points.iter().map(|s| s.position).collect();
|
||||
|
||||
// 1. Zuerst 3D-Kantenmodelle prüfen (Oktaeder / Doppelpyramide)
|
||||
if let Some(octa) = detect_octahedron(&pts) {
|
||||
return Some(octa);
|
||||
}
|
||||
// 1. Geschlossene Formen: Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird
|
||||
// die Form, deren Kontur am besten zu den gezeichneten Punkten passt. Eine feste
|
||||
// Reihenfolge genügt nicht: Auch Quadrate schwanken nur um ~10 % im Abstand zum
|
||||
// Mittelpunkt und bestünden den Kreistest, und die Dreieckserkennung akzeptiert
|
||||
// gestreckte Rechtecke.
|
||||
let best_closed = best_closed_shape(&pts);
|
||||
|
||||
// 2. Geschlossene Formen prüfen (Kreis, Dreieck, Rechteck, Raute, Polygon/Oktagon)
|
||||
if let Some(circle) = detect_circle(&pts) {
|
||||
return Some(circle);
|
||||
}
|
||||
if let Some(triangle) = detect_triangle(&pts) {
|
||||
return Some(triangle);
|
||||
}
|
||||
if let Some(rect) = detect_rectangle(&pts) {
|
||||
return Some(rect);
|
||||
}
|
||||
if let Some(rhombus) = detect_rhombus(&pts) {
|
||||
return Some(rhombus);
|
||||
}
|
||||
if let Some(polygon) = detect_polygon(&pts) {
|
||||
return Some(polygon);
|
||||
}
|
||||
// 2. 3D-Kantenmodelle (Oktaeder / Doppelpyramide) nur, wenn keine geschlossene Form den
|
||||
// Strich gut beschreibt: Ein Oktaeder enthält innere Kanten, die weit von jedem Umriss
|
||||
// entfernt liegen. Ein sauber passender Umriss ist daher kein Oktaeder.
|
||||
let closed_fits_well = best_closed.as_ref().is_some_and(|(err, _)| {
|
||||
let aabb = Aabb::from_points(&pts);
|
||||
*err <= CLOSED_SHAPE_GOOD_FIT * (aabb.max - aabb.min).length()
|
||||
});
|
||||
let best_closed = best_closed.map(|(_, shape)| shape);
|
||||
|
||||
// 3. Offene Formen: Koordinatensystem oder Gerade
|
||||
if let Some(coord) = detect_coordinate_system(&pts) {
|
||||
return Some(coord);
|
||||
}
|
||||
if let Some(line) = detect_line(&pts) {
|
||||
return Some(line);
|
||||
}
|
||||
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
||||
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
||||
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
|
||||
&|| {
|
||||
if closed_fits_well {
|
||||
None
|
||||
} else {
|
||||
detect_octahedron(&pts)
|
||||
}
|
||||
},
|
||||
&|| best_closed.clone(),
|
||||
&|| detect_polygon(&pts),
|
||||
&|| detect_coordinate_system(&pts),
|
||||
&|| detect_line(&pts),
|
||||
&|| None,
|
||||
];
|
||||
candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate())
|
||||
.find(|shape| shape_describes_stroke(&pts, shape))
|
||||
}
|
||||
|
||||
None
|
||||
/// Größte zulässige mittlere Abweichung (RMS) des Strichs von der erkannten Form, als Anteil
|
||||
/// der Strichdiagonale. Echte Formen liegen bei 0,5–3 %, Freiformen (Herz, Stern, Wolke,
|
||||
/// Spirale) und flache Ellipsen bei 5–6,5 %.
|
||||
const SHAPE_MAX_FIT_ERROR: f32 = 0.04;
|
||||
|
||||
/// Mittlere Abweichung (RMS) des Strichs von einer Form, relativ zur Strichdiagonale.
|
||||
/// Gemessen wird gegen den Linienzug der Punkte, aus denen die Form gezeichnet wird; das
|
||||
/// funktioniert für alle Formtypen gleich (auch Linien, Koordinatensysteme und Oktaeder).
|
||||
pub fn shape_fit_error(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
||||
if points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (model, _) = shape_to_mesh(shape, 1.0, [0.0; 4]);
|
||||
if model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let aabb = Aabb::from_points(points);
|
||||
let diagonal = (aabb.max - aabb.min).length();
|
||||
if diagonal < 1e-3 {
|
||||
return None;
|
||||
}
|
||||
// Abstand zum Linienzug der Modellpunkte (nicht zu den Punkten selbst: manche Formen,
|
||||
// z. B. das Koordinatensystem, liefern nur ihre Eckpunkte)
|
||||
let model: Vec<Vec2> = model.iter().map(|m| m.position).collect();
|
||||
let sum_sq: f32 = points
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let d = if model.len() == 1 {
|
||||
(model[0] - *p).length()
|
||||
} else {
|
||||
model
|
||||
.windows(2)
|
||||
.map(|w| segment_distance(*p, w[0], w[1]))
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
};
|
||||
d * d
|
||||
})
|
||||
.sum();
|
||||
Some((sum_sq / points.len() as f32).sqrt() / diagonal)
|
||||
}
|
||||
|
||||
/// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen?
|
||||
fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool {
|
||||
shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR)
|
||||
}
|
||||
|
||||
/// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale.
|
||||
const RECTANGLE_MAX_RMS: f32 = 0.05;
|
||||
/// Jede Rechteckecke muss bis auf diesen Anteil der kürzeren Seite gezeichnet sein.
|
||||
const RECTANGLE_CORNER_REACH: f32 = 0.15;
|
||||
/// Rechtecke, die höchstens so viele Grad schief sind, werden achsenparallel ausgerichtet.
|
||||
const RECTANGLE_AXIS_SNAP_DEG: f32 = 10.0;
|
||||
|
||||
/// Anteil der Diagonale, bis zu dem die Abweichung einer geschlossenen Form als „passt gut“ gilt.
|
||||
const CLOSED_SHAPE_GOOD_FIT: f32 = 0.04;
|
||||
/// Gewichte der Abweichung im Formvergleich (kleiner = bevorzugt). Ein Rechteck gewinnt gegen
|
||||
/// Raute und Vieleck bis zur 1,5-fachen Abweichung (sonst würden Quadrate oft zur Raute);
|
||||
/// ein Vieleck gewinnt entsprechend gegen den Kreis.
|
||||
const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5;
|
||||
const CIRCLE_WEIGHT: f32 = 1.5;
|
||||
|
||||
/// Wählt unter Kreis, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
|
||||
/// und liefert sie mit ihrer Abweichung (RMS).
|
||||
fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
|
||||
// Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht.
|
||||
// Rechte Winkel schlagen die freiere Raute bzw. ein freies Vieleck; ein Vieleck mit
|
||||
// nachgewiesenen Ecken schlägt den Kreis (ein Achteck ist fast ein Kreis).
|
||||
let fit = |shape: Option<RecognizedShape>, weight: f32| {
|
||||
shape.and_then(|s| contour_rms(pts, &s).map(|err| (err * weight, s)))
|
||||
};
|
||||
[
|
||||
fit(detect_circle(pts), CIRCLE_WEIGHT),
|
||||
fit(detect_triangle(pts), 1.0),
|
||||
fit(detect_rectangle(pts), RECTANGLE_WEIGHT),
|
||||
fit(detect_rhombus(pts), 1.0),
|
||||
fit(detect_polygon(pts), 1.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
}
|
||||
|
||||
/// Abstand eines Punktes zur Strecke `a`–`b`.
|
||||
fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||
let ab = b - a;
|
||||
let len_sq = ab.length_squared();
|
||||
if len_sq < 1e-9 {
|
||||
return (p - a).length();
|
||||
}
|
||||
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||
(p - (a + ab * t)).length()
|
||||
}
|
||||
|
||||
/// Abstand eines Punktes zum Rand eines geschlossenen Polygons.
|
||||
fn polygon_distance(p: Vec2, corners: &[Vec2]) -> f32 {
|
||||
(0..corners.len())
|
||||
.map(|i| segment_distance(p, corners[i], corners[(i + 1) % corners.len()]))
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
}
|
||||
|
||||
/// Mittlere quadratische Abweichung (RMS) der Strichpunkte von der Kontur einer erkannten
|
||||
/// geschlossenen Form. Je kleiner, desto besser beschreibt die Form den Strich.
|
||||
/// Für offene Formen (Linie, Koordinatensystem, Oktaeder) gibt es keinen Wert.
|
||||
pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
||||
if points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let distance = |p: Vec2| -> Option<f32> {
|
||||
match shape {
|
||||
RecognizedShape::Circle { center, radius } => {
|
||||
Some(((p - *center).length() - radius).abs())
|
||||
}
|
||||
RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)),
|
||||
RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => {
|
||||
Some(polygon_distance(p, corners))
|
||||
}
|
||||
RecognizedShape::Polygon { vertices } => Some(polygon_distance(p, vertices)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let mut sum_sq = 0.0;
|
||||
for &p in points {
|
||||
let d = distance(p)?;
|
||||
sum_sq += d * d;
|
||||
}
|
||||
Some((sum_sq / points.len() as f32).sqrt())
|
||||
}
|
||||
|
||||
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ pub mod export;
|
||||
pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod platform;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
|
||||
+503
-171
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
|
||||
//!
|
||||
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
|
||||
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
|
||||
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
|
||||
//! übersteuert die Wahl.
|
||||
|
||||
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayBackend {
|
||||
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
|
||||
Automatic,
|
||||
X11,
|
||||
Wayland,
|
||||
}
|
||||
|
||||
/// Wählt das Anzeigesystem.
|
||||
///
|
||||
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
|
||||
/// - `is_wsl`: läuft unter WSL
|
||||
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
|
||||
pub fn choose_display_backend(
|
||||
override_value: Option<&str>,
|
||||
is_wsl: bool,
|
||||
has_x11_display: bool,
|
||||
) -> DisplayBackend {
|
||||
match override_value
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("x11") => return DisplayBackend::X11,
|
||||
Some("wayland") => return DisplayBackend::Wayland,
|
||||
_ => {}
|
||||
}
|
||||
if is_wsl && has_x11_display {
|
||||
DisplayBackend::X11
|
||||
} else {
|
||||
DisplayBackend::Automatic
|
||||
}
|
||||
}
|
||||
|
||||
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
|
||||
pub fn is_wsl() -> bool {
|
||||
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Wahl anhand der aktuellen Umgebung.
|
||||
pub fn display_backend_from_env() -> DisplayBackend {
|
||||
choose_display_backend(
|
||||
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
|
||||
is_wsl(),
|
||||
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_choose_display_backend() {
|
||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
// WSL ohne X11-Display: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, false),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Natives Linux: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, false, true),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Übersteuerung
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("wayland"), true, true),
|
||||
DisplayBackend::Wayland
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some(" X11 "), false, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("quatsch"), true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-12
@@ -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],
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
+1558
-98
File diff suppressed because it is too large
Load Diff
+195
-59
@@ -7,7 +7,7 @@
|
||||
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
|
||||
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
|
||||
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
|
||||
//! - Erweiterbare Schnittstelle für optionale Signaturprüfung (.sig / .minisig)
|
||||
//! - Pflicht-Signaturprüfung der `SHA256SUMS.txt` via Minisign (eingebauter öffentlicher Schlüssel)
|
||||
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
|
||||
//! - Echtes Fortschritts-Streaming (~10 Updates/s)
|
||||
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
|
||||
@@ -313,22 +313,78 @@ pub fn verify_checksum(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Status einer Signaturverifikation (.sig / .minisig).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SignatureVerificationStatus {
|
||||
NotPresent,
|
||||
Verified { signer: String },
|
||||
Failed(String),
|
||||
/// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
|
||||
/// 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 =
|
||||
"RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
|
||||
|
||||
/// Name des Prüfsummen-Assets eines Releases.
|
||||
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||
/// Name der Minisign-Signatur zu [`CHECKSUMS_ASSET_NAME`].
|
||||
pub const CHECKSUMS_SIGNATURE_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
||||
|
||||
/// Prüft die Minisign-Signatur der Prüfsummendatei. Nur aktuelle (prehashed) Signaturen
|
||||
/// werden akzeptiert; ohne hinterlegten Schlüssel schlägt die Prüfung immer fehl.
|
||||
pub fn verify_checksums_signature(
|
||||
checksums: &[u8],
|
||||
signature_text: &str,
|
||||
public_key_b64: &str,
|
||||
) -> Result<(), String> {
|
||||
let key = public_key_b64.trim();
|
||||
if key.is_empty() {
|
||||
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
|
||||
}
|
||||
let public_key = minisign_verify::PublicKey::from_base64(key)
|
||||
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
|
||||
let signature = minisign_verify::Signature::decode(signature_text)
|
||||
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
|
||||
public_key
|
||||
.verify(checksums, &signature, false)
|
||||
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}."))
|
||||
}
|
||||
|
||||
/// Erweiterbare Schnittstelle für kryptografische Signaturprüfung.
|
||||
pub fn verify_signature_if_present(
|
||||
_asset_file: &Path,
|
||||
_sig_bytes: Option<&[u8]>,
|
||||
) -> Result<SignatureVerificationStatus, String> {
|
||||
// Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key
|
||||
// im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen.
|
||||
Ok(SignatureVerificationStatus::NotPresent)
|
||||
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
|
||||
fn fetch_release_text(
|
||||
agent: &ureq::Agent,
|
||||
release: &GiteaRelease,
|
||||
name: &str,
|
||||
config: &UpdaterConfig,
|
||||
) -> Result<String, String> {
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == name)
|
||||
.ok_or_else(|| format!("Release enthält keine {name}."))?;
|
||||
http_get_with_auth_guard(
|
||||
agent,
|
||||
&asset.browser_download_url,
|
||||
config.auth_token.as_deref(),
|
||||
5,
|
||||
config.allow_insecure_http,
|
||||
)
|
||||
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))?
|
||||
.into_string()
|
||||
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}."))
|
||||
}
|
||||
|
||||
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
|
||||
/// Signatur zurück.
|
||||
fn fetch_verified_checksums(
|
||||
agent: &ureq::Agent,
|
||||
release: &GiteaRelease,
|
||||
config: &UpdaterConfig,
|
||||
public_key_b64: &str,
|
||||
) -> Result<String, String> {
|
||||
// Ohne Schlüssel gar nicht erst Netzwerkanfragen stellen
|
||||
if public_key_b64.trim().is_empty() {
|
||||
return verify_checksums_signature(b"", "", public_key_b64).map(|()| String::new());
|
||||
}
|
||||
let checksums = fetch_release_text(agent, release, CHECKSUMS_ASSET_NAME, config)?;
|
||||
let signature = fetch_release_text(agent, release, CHECKSUMS_SIGNATURE_ASSET_NAME, config)?;
|
||||
verify_checksums_signature(checksums.as_bytes(), &signature, public_key_b64)?;
|
||||
Ok(checksums)
|
||||
}
|
||||
|
||||
/// Event-Typen für den asynchronen Worker.
|
||||
@@ -368,6 +424,17 @@ pub enum UpdaterStatus {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Lehnt jede URL ohne HTTPS ab, sofern unsicheres HTTP nicht ausdrücklich erlaubt ist.
|
||||
fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), String> {
|
||||
match url.scheme() {
|
||||
"https" => Ok(()),
|
||||
"http" if allow_insecure_http => Ok(()),
|
||||
scheme => Err(format!(
|
||||
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Erstellt einen ureq Agent mit sauberen Timeouts.
|
||||
fn create_http_agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
@@ -384,12 +451,15 @@ fn http_get_with_auth_guard(
|
||||
initial_url_str: &str,
|
||||
token: Option<&str>,
|
||||
max_redirects: usize,
|
||||
allow_insecure_http: bool,
|
||||
) -> Result<ureq::Response, String> {
|
||||
let mut current_url = url::Url::parse(initial_url_str)
|
||||
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
|
||||
let initial_origin = current_url.origin().ascii_serialization();
|
||||
|
||||
for _ in 0..=max_redirects {
|
||||
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
|
||||
ensure_secure_scheme(¤t_url, allow_insecure_http)?;
|
||||
let mut req = agent.get(current_url.as_str());
|
||||
|
||||
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
|
||||
@@ -500,7 +570,13 @@ impl UpdaterController {
|
||||
);
|
||||
|
||||
let agent = create_http_agent();
|
||||
match http_get_with_auth_guard(&agent, &api_url, config.auth_token.as_deref(), 5) {
|
||||
match http_get_with_auth_guard(
|
||||
&agent,
|
||||
&api_url,
|
||||
config.auth_token.as_deref(),
|
||||
5,
|
||||
config.allow_insecure_http,
|
||||
) {
|
||||
Ok(resp) => match resp.into_json::<GiteaRelease>() {
|
||||
Ok(release) => {
|
||||
if release.prerelease && !config.include_prereleases {
|
||||
@@ -563,12 +639,37 @@ impl UpdaterController {
|
||||
let target_file = cache_dir.join(&safe_asset_name);
|
||||
let agent = create_http_agent();
|
||||
|
||||
// 1. Download des Release-Assets mit Fortschritts-Streaming
|
||||
// 1. Signierte Prüfsummen laden und verifizieren, BEVOR das Paket geladen wird
|
||||
let checksums_content = match active_release.as_ref() {
|
||||
Some(release) => match fetch_verified_checksums(
|
||||
&agent,
|
||||
release,
|
||||
&config,
|
||||
UPDATE_SIGNING_PUBLIC_KEY,
|
||||
) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
||||
"{e} Download abgelehnt."
|
||||
)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(
|
||||
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Download des Release-Assets mit Fortschritts-Streaming
|
||||
let resp = match http_get_with_auth_guard(
|
||||
&agent,
|
||||
&asset.browser_download_url,
|
||||
config.auth_token.as_deref(),
|
||||
5,
|
||||
config.allow_insecure_http,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -646,53 +747,13 @@ impl UpdaterController {
|
||||
|
||||
drop(file);
|
||||
|
||||
// 2. Integritätsprüfung gegen SHA256SUMS.txt
|
||||
let checksum_asset = active_release
|
||||
.as_ref()
|
||||
.and_then(|r| r.assets.iter().find(|a| a.name == "SHA256SUMS.txt"));
|
||||
|
||||
let checksums_content = match checksum_asset {
|
||||
Some(ca) => {
|
||||
match http_get_with_auth_guard(
|
||||
&agent,
|
||||
&ca.browser_download_url,
|
||||
config.auth_token.as_deref(),
|
||||
5,
|
||||
) {
|
||||
Ok(c_resp) => match c_resp.into_string() {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&target_file);
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
||||
"Konnte SHA256SUMS.txt nicht dekodieren: {e}. Datei gelöscht."
|
||||
)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&target_file);
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
||||
"Konnte SHA256SUMS.txt nicht laden: {e}. Datei aus Sicherheitsgründen gelöscht."
|
||||
)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&target_file);
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(
|
||||
"Release enthält keine SHA256SUMS.txt-Prüfsummen. Download abgelehnt und gelöscht.".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
|
||||
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(err));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
|
||||
// 4. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
|
||||
if safe_asset_name.ends_with(".AppImage") {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -996,6 +1057,81 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
||||
assert!(allowed_insecure.validate_url().is_ok());
|
||||
}
|
||||
|
||||
/// Nur für Tests erzeugtes Minisign-Schlüsselpaar (der geheime Schlüssel wurde verworfen).
|
||||
const TEST_PUBLIC_KEY: &str = "RWQr5S8CVgsjS4UA3uY0xqTDua4uRSSzKWpFfJKLCypjgP3NGt9Tmdbh";
|
||||
const TEST_CHECKSUMS: &str = "abc123 ink-notes_9.9.9_amd64.deb\n";
|
||||
const TEST_SIGNATURE: &str = "untrusted comment: signature from rsign secret key
|
||||
RUQr5S8CVgsjS5BYMUx1ICe1E356aP7wesQXw+oKF6Rd8WsvdF1dmCb1ffonXgJ2GhWE5iAWPXQaT8Y/Y4zLIpyS1PA8JCe8eAM=
|
||||
trusted comment: ink-notes test
|
||||
iw0NOedyAQg3emO36fHjyfn+AbnI7Idyl+NwFTDJX1YceLM1jSfjIfeIc0D49IQXyGTpM/QnkZJkRO/eI8ZKBQ==
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn test_checksums_signature_verification() {
|
||||
// Gültige Signatur
|
||||
assert!(verify_checksums_signature(
|
||||
TEST_CHECKSUMS.as_bytes(),
|
||||
TEST_SIGNATURE,
|
||||
TEST_PUBLIC_KEY
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
// Manipulierte Prüfsummen (z. B. ausgetauschter Hash)
|
||||
let tampered = TEST_CHECKSUMS.replace("abc123", "abc124");
|
||||
assert!(
|
||||
verify_checksums_signature(tampered.as_bytes(), TEST_SIGNATURE, TEST_PUBLIC_KEY)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Fremder Schlüssel
|
||||
let other_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
|
||||
assert!(
|
||||
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, other_key)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Kein Schlüssel hinterlegt -> strikt ablehnen
|
||||
let err =
|
||||
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, "").unwrap_err();
|
||||
assert!(err.contains("kein Signaturschlüssel"), "{err}");
|
||||
|
||||
// Fehlende / kaputte Signatur
|
||||
assert!(
|
||||
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), "", TEST_PUBLIC_KEY).is_err()
|
||||
);
|
||||
|
||||
// Altes (nicht prehashed) Signaturformat wird nicht akzeptiert
|
||||
let legacy_sig = "untrusted comment: signature from minisign secret key
|
||||
RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=
|
||||
trusted comment: timestamp:1555779966\tfile:test
|
||||
QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA==";
|
||||
assert!(verify_checksums_signature(b"test", legacy_sig, other_key).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_https_enforced_for_every_request() {
|
||||
let https = url::Url::parse("https://gitea.pansi.eu/a.deb").unwrap();
|
||||
let http = url::Url::parse("http://cdn.example.com/a.deb").unwrap();
|
||||
let ftp = url::Url::parse("ftp://example.com/a.deb").unwrap();
|
||||
assert!(ensure_secure_scheme(&https, false).is_ok());
|
||||
assert!(ensure_secure_scheme(&http, false).is_err());
|
||||
assert!(ensure_secure_scheme(&http, true).is_ok());
|
||||
assert!(ensure_secure_scheme(&ftp, true).is_err());
|
||||
|
||||
// Ein http-Download-Link aus der Release-API wird abgewiesen, bevor eine
|
||||
// Verbindung aufgebaut wird
|
||||
let agent = create_http_agent();
|
||||
let err = http_get_with_auth_guard(
|
||||
&agent,
|
||||
"http://127.0.0.1:9/ink-notes.AppImage",
|
||||
Some("geheim"),
|
||||
5,
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("HTTPS"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_appimage_atomic_self_update() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+14
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"format_version": 99,
|
||||
"id": 1,
|
||||
"background": "Blank",
|
||||
"strokes": []
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
Vendored
+14
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user