Compare commits
33
Commits
dfb5a64917
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 | ||
|
|
5cd45831e2 | ||
|
|
71f6a70829 | ||
|
|
b751b71223 | ||
|
|
afb057692d | ||
|
|
eeae143dc6 | ||
|
|
992c3daad7 | ||
|
|
9684238a53 | ||
|
|
e8674e730f | ||
|
|
7e99d59042 | ||
|
|
7ed971d2a2 | ||
|
|
5ce2c250bb | ||
|
|
d61e1819ed | ||
|
|
12755d8eea | ||
|
|
510035e807 | ||
|
|
1afe4a0f9d | ||
|
|
04666bf389 | ||
|
|
a82402bfce | ||
|
|
06b3aeb311 | ||
|
|
f9b39c6fe3 | ||
|
|
f9ebcb0296 | ||
|
|
df72981a9a | ||
|
|
5cb77b9af7 | ||
|
|
bb0faf424b | ||
|
|
3999996e98 | ||
|
|
3eb21dadc4 | ||
|
|
5780c266b7 | ||
|
|
91e2d78b4f |
No files matched your search
@@ -9,15 +9,25 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Build Packages & Publish to Gitea
|
name: Build Packages & Publish to Gitea
|
||||||
|
# Hinweis zum Gitea act_runner:
|
||||||
|
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
|
||||||
|
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Install System Packaging Tools
|
- name: Install System Packaging Tools
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
sudo apt-get install -y --no-install-recommends \
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
$SUDO apt-get update -y
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libx11-dev \
|
libx11-dev \
|
||||||
@@ -31,20 +41,37 @@ jobs:
|
|||||||
python3
|
python3
|
||||||
|
|
||||||
- name: Setup Rust Toolchain
|
- name: Setup Rust Toolchain
|
||||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
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
|
cache: true
|
||||||
|
|
||||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
|
- 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 + .minisig)
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh
|
||||||
|
env:
|
||||||
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
|
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
||||||
|
|
||||||
- name: Publish Release to Gitea
|
- name: Publish Release to Gitea
|
||||||
run: |
|
run: |
|
||||||
./scripts/publish-gitea-release.sh "${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}"
|
./scripts/publish-gitea-release.sh
|
||||||
env:
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
GITEA_SERVER: ${{ github.server_url }}
|
GITEA_SERVER: ${{ github.server_url }}
|
||||||
REPO_OWNER: ${{ github.repository_owner }}
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
REPO_NAME: ${{ github.event.repository.name }}
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
Generated
+355
-8
@@ -118,6 +118,12 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -151,6 +157,15 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-sys"
|
name = "block-sys"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -269,6 +284,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codespan-reporting"
|
name = "codespan-reporting"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -375,6 +396,15 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.2"
|
version = "1.5.2"
|
||||||
@@ -390,6 +420,16 @@ version = "0.8.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-icon"
|
name = "cursor-icon"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -403,7 +443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
|
checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"libloading 0.7.4",
|
"libloading 0.8.9",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -413,6 +453,16 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -529,6 +579,18 @@ dependencies = [
|
|||||||
"simdutf8",
|
"simdutf8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enum_dispatch"
|
||||||
|
version = "0.3.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "epaint"
|
name = "epaint"
|
||||||
version = "0.27.2"
|
version = "0.27.2"
|
||||||
@@ -620,6 +682,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -644,6 +716,16 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gethostname"
|
name = "gethostname"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -654,6 +736,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -747,7 +840,7 @@ dependencies = [
|
|||||||
"presser",
|
"presser",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"winapi",
|
"winapi",
|
||||||
"windows",
|
"windows 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -801,6 +894,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -949,18 +1048,25 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
"egui-winit",
|
"egui-winit",
|
||||||
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
|
"minisign-verify",
|
||||||
|
"octotablet",
|
||||||
"pollster",
|
"pollster",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"ureq",
|
||||||
|
"url",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
@@ -1199,6 +1305,12 @@ dependencies = [
|
|||||||
"paste",
|
"paste",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -1349,6 +1461,25 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "octotablet"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c4b9470450ffa669cb3be55ef863dfd9592a06284cb291546363c514bb4aa3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cfg_aliases 0.2.2",
|
||||||
|
"enum_dispatch",
|
||||||
|
"raw-window-handle 0.6.2",
|
||||||
|
"smallvec",
|
||||||
|
"strum",
|
||||||
|
"thiserror",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"windows 0.54.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -1609,6 +1740,20 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1647,6 +1792,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1687,6 +1867,12 @@ dependencies = [
|
|||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -1730,6 +1916,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1830,6 +2027,34 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -2041,6 +2266,12 @@ dependencies = [
|
|||||||
"rustc-hash 2.1.3",
|
"rustc-hash 2.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.26"
|
version = "1.0.26"
|
||||||
@@ -2065,6 +2296,30 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "2.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -2099,6 +2354,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
name = "wasip2"
|
||||||
version = "1.0.4+wasi-0.2.12"
|
version = "1.0.4+wasi-0.2.12"
|
||||||
@@ -2310,6 +2571,24 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -2324,7 +2603,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.1.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -2350,7 +2629,7 @@ dependencies = [
|
|||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bit-vec",
|
"bit-vec",
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.1.1",
|
||||||
"codespan-reporting",
|
"codespan-reporting",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
@@ -2379,7 +2658,7 @@ dependencies = [
|
|||||||
"bit-set",
|
"bit-set",
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block",
|
"block",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.1.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"d3d12",
|
"d3d12",
|
||||||
"glow",
|
"glow",
|
||||||
@@ -2466,7 +2745,19 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core",
|
"windows-core 0.52.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.54.0",
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2479,12 +2770,53 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.54.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.53.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
@@ -2503,6 +2835,15 @@ dependencies = [
|
|||||||
"windows-targets 0.48.5",
|
"windows-targets 0.48.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -2711,7 +3052,7 @@ dependencies = [
|
|||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"calloop",
|
"calloop",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.1.1",
|
||||||
"core-foundation",
|
"core-foundation",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
@@ -2895,6 +3236,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|||||||
+14
-1
@@ -1,7 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.1.0"
|
version = "0.3.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||||
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
rust-version = "1.75"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||||
@@ -29,6 +34,13 @@ egui = "0.27"
|
|||||||
egui-wgpu = "0.27"
|
egui-wgpu = "0.27"
|
||||||
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
|
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
|
||||||
webbrowser = "0.8"
|
webbrowser = "0.8"
|
||||||
|
semver = "1.0"
|
||||||
|
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]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
@@ -37,6 +49,7 @@ opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
|||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
strip = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Harald Pansi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -10,9 +10,10 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
|
|
||||||
### ⚡ Latenzoptimierte Inking-Pipeline
|
### ⚡ Latenzoptimierte Inking-Pipeline
|
||||||
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
|
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
|
||||||
- **1€-Filter (One-Euro-Filter):** Dynamischer Tiefpassfilter zur Glättung von Zittern bei langsamen Bewegungen ohne spürbare Latenz bei schnellen Schwüngen.
|
- **Zwei-Stufen-Modell:**
|
||||||
|
1. *Live-Phase:* Eingehende Stylus-Samples werden in Echtzeit durch einen 1€-Filter (One-Euro-Filter) entprellt und inkrementell als GPU-Meshes dargestellt.
|
||||||
|
2. *Commit-Phase (Post-Stroke Finalisierung):* Akausale Ecken-Erkennung (Corner Preservation bei Knicken > 65°), gewichtetes 3-Punkt-Druckfenster, Ramer-Douglas-Peucker (RDP) Dezimierung und saubere runde Endkappen.
|
||||||
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen.
|
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen.
|
||||||
- **Drucksensitive Dreiecksstreifen-Tessellierung:** Rechnerische Generierung von Triangle-Meshes mit winkelhalbierenden Normalenvektoren entlang des Pfads.
|
|
||||||
- **Werkzeuge:**
|
- **Werkzeuge:**
|
||||||
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
||||||
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
||||||
@@ -29,7 +30,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92.
|
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92.
|
||||||
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP).
|
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP).
|
||||||
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit.
|
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit.
|
||||||
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit optionalen Richtungspfeilen.
|
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit Richtungspfeilen.
|
||||||
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
|
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
|
||||||
- **Interaktionsmodelle:**
|
- **Interaktionsmodelle:**
|
||||||
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
|
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
|
||||||
@@ -46,93 +47,158 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
|
|
||||||
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung
|
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung
|
||||||
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert.
|
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert.
|
||||||
- **Dokumenten-Manager:** Dunkles Glassmorphism-Modal direkt in der Toolbar:
|
- **Dokumenten-Manager:** Toolbar-Modal zum Anlegen, Wechseln, Umbenennen und Löschen von Notizbüchern.
|
||||||
- Neues Notizbuch anlegen.
|
|
||||||
- Zwischen Notizbüchern mit einem Klick wechseln.
|
|
||||||
- Dokumente direkt umbenennen (`✏`).
|
|
||||||
- Dokumente löschen (`🗑`).
|
|
||||||
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab.
|
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab.
|
||||||
|
|
||||||
### 📄 Seiten & Hintergründe
|
### 📄 Seiten & Hintergründe
|
||||||
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
|
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
|
||||||
- **Hintergrundmuster:**
|
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
|
||||||
- Blanko
|
|
||||||
- Kariert (5 mm Raster)
|
|
||||||
- Liniert (7.5 mm Zeilenabstand)
|
|
||||||
- Punktraster (5 mm Dot-Grid)
|
|
||||||
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
|
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
|
||||||
|
|
||||||
### 💾 Autosave & Persistenz
|
### 💾 Autosave & Persistenz
|
||||||
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund-Thread nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
||||||
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen.
|
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen.
|
||||||
- **Sicherer Shutdown:** `ESC` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
|
- **Dediziertes Speichern (`Strg + S`):** Sofortiger, synchroner Disk-Flush mit visueller Toast-Rückmeldung.
|
||||||
|
- **Sicherer Shutdown:** `Strg + Q` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
|
||||||
|
|
||||||
### 🖨️ Vektor-PDF-Export
|
### 🖨️ Vektor-PDF-Export
|
||||||
- Exportiert das gesamte Dokument via `printpdf` nach `./export.pdf`.
|
- Exportiert das Dokument via `printpdf` nach `~/Documents/<Titel>.pdf` (mit automatischer Kollisionsvermeidung `_1`, `_2`).
|
||||||
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben.
|
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben. Kürzel: `Strg + E`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🚀 Release, Packaging & In-App Auto-Update (Zorin OS 18 / Ubuntu)
|
## 📦 Release, Packaging & Updater
|
||||||
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Noble-Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & 256x256 PNG, AppStream Metainfo).
|
|
||||||
- **Portables .AppImage**: Eigenständiges, distributionsunabhängiges Bundle (x86_64) mit integrierten Laufzeitabhängigkeiten.
|
|
||||||
- **In-App Gitea Updater (`src/updater.rs`)**:
|
|
||||||
- Fragt im Hintergrund die Gitea Releases API (`https://gitea.pansi.eu/api/v1/repos/harald/ink-notes/releases/latest`) ab.
|
|
||||||
- Zeigt bei neuen Versionen ein Update-Badge in der Toolbar an.
|
|
||||||
- Release-Modal mit Versionsvergleich, Changelog und 1-Click-Download / In-Place-Aktualisierung.
|
|
||||||
- **Gitea Actions CI/CD (`.gitea/workflows/release.yaml`)**: Automatisches Paketieren und Veröffentlichen auf Gitea bei Git-Tags (`v*`).
|
|
||||||
|
|
||||||
---
|
- **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.
|
||||||
## 📦 Paketierung & Installation (Zorin OS 18)
|
- **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
|
### 1. Release-Pakete bauen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Baut sowohl .deb als auch .AppImage und berechnet SHA256SUMS.txt in target/
|
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Ergebnis in `target/`:
|
Ergebnis in `target/`:
|
||||||
- `ink-notes_0.1.0_amd64.deb` (~4.2 MB)
|
- `ink-notes_0.1.0_amd64.deb`
|
||||||
- `ink-notes_0.1.0_x86_64.AppImage` (~5.6 MB)
|
- `ink-notes_0.1.0_x86_64.AppImage`
|
||||||
- `SHA256SUMS.txt`
|
- `SHA256SUMS.txt`
|
||||||
|
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
|
||||||
|
|
||||||
### 2. Installation auf Zorin OS 18
|
### 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Natives Debian-Paket installieren
|
# Natives Debian-Paket installieren
|
||||||
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
|
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
|
||||||
|
|
||||||
# Oder portables AppImage direkt starten
|
# Oder portables AppImage starten
|
||||||
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
|
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
|
||||||
./target/ink-notes_0.1.0_x86_64.AppImage
|
./target/ink-notes_0.1.0_x86_64.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
Nach der `.deb`-Installation ist `ink-notes` nahtlos im Zorin OS Anwendungsmenü unter *Büro* und *Grafik* integriert.
|
#### Hinweise zu AppImage, glibc & FUSE:
|
||||||
|
- **glibc-Mindestversion:** `ink-notes` benötigt `glibc >= 2.39` (Standard unter Ubuntu 24.04 LTS und Zorin OS 18).
|
||||||
|
- **Host-Grafikstack:** Das AppImage bündelt die Anwendungslogik, nutzt für maximale Performance und Hardwarebeschleunigung jedoch den Treiber- und Grafikstack des Host-Systems (Vulkan ICD-Treiber, Wayland bzw. X11).
|
||||||
|
- **FUSE 2 / FUSE 3 Kompatibilität:** Ubuntu 24.04-basierte Distributionen nutzen standardmäßig FUSE 3 (`fuse3`). Sollte das AppImage beim Start einen Fehler bezüglich `libfuse.so.2` ausgeben, kann entweder das Kompatibilitätspaket installiert werden:
|
||||||
|
```bash
|
||||||
|
sudo apt install libfuse2t64
|
||||||
|
```
|
||||||
|
oder das AppImage direkt ohne FUSE entpackt und ausgeführt werden:
|
||||||
|
```bash
|
||||||
|
./ink-notes_0.1.0_x86_64.AppImage --appimage-extract-and-run
|
||||||
|
```
|
||||||
|
|
||||||
### 3. Release auf Gitea veröffentlichen
|
### 3. Release auf Gitea veröffentlichen
|
||||||
|
|
||||||
|
Das Skript arbeitet vollkommen idempotent und aktualisiert bei wiederholter Ausführung Release-Notizen und Assets:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Veröffentlicht die Artefakte auf https://gitea.pansi.eu/harald/ink-notes
|
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
|
||||||
./scripts/publish-gitea-release.sh <DEIN_GITEA_TOKEN>
|
./scripts/publish-gitea-release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🛡️ Updater-Sicherheit
|
||||||
|
|
||||||
|
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
||||||
|
|
||||||
|
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.
|
||||||
|
3. **Redirect-Schutz & 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`).
|
||||||
|
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
|
||||||
|
5. **Atomarer AppImage-Tausch:**
|
||||||
|
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
|
||||||
|
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
|
||||||
|
|
||||||
|
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
|
||||||
|
|
||||||
|
### Runner-Label konfigurieren
|
||||||
|
Der Workflow nutzt standardmäßig das Runner-Label:
|
||||||
|
```yaml
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
```
|
||||||
|
|
||||||
|
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||||
|
# oder für native Host-Ausführung:
|
||||||
|
# - "ubuntu-latest:host"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagnose & Fehlerbehebung
|
||||||
|
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
|
||||||
|
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
|
||||||
|
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
|
||||||
|
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Schnellstart (Entwicklung)
|
## 🚀 Schnellstart (Entwicklung)
|
||||||
|
|
||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`)
|
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`)
|
||||||
- **System-Bibliotheken (Linux):**
|
- **System-Bibliotheken (Linux):**
|
||||||
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber)
|
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber)
|
||||||
- X11 / XWayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
|
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
|
||||||
|
|
||||||
### Bauen & Ausführen
|
### Bauen & Ausführen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Repository klonen
|
# Repository klonen
|
||||||
git clone gitea@farmersdog.pansi.eu:harald/ink-notes.git
|
git clone https://gitea.pansi.eu/harald/ink-notes.git
|
||||||
cd ink-notes
|
cd ink-notes
|
||||||
|
|
||||||
# Kompilieren und starten via Starter-Skript
|
# Kompilieren und starten via Starter-Skript
|
||||||
@@ -164,8 +230,10 @@ cargo run --release
|
|||||||
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
|
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
|
||||||
| **Vorherige Seite** | `Bild auf (PageUp)` |
|
| **Vorherige Seite** | `Bild auf (PageUp)` |
|
||||||
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
|
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
|
||||||
| **PDF-Export** | `Strg + E` oder `Strg + S` |
|
| **Jetzt speichern (Disk Flush)** | `Strg + S` |
|
||||||
| **Beenden (mit Disk-Flush)** | `Esc` oder `Q` |
|
| **Vektor-PDF-Export** | `Strg + E` |
|
||||||
|
| **Dialoge / Menüs schließen** | `Esc` |
|
||||||
|
| **Beenden (mit Disk-Flush)** | `Strg + Q` (oder Fenster schließen) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,7 +247,7 @@ src/
|
|||||||
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
|
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
|
||||||
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
|
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
|
||||||
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
|
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
|
||||||
├── spline.rs # Centripetal Catmull-Rom & Dreiecksstreifen-Tessellierung
|
├── spline.rs # Catmull-Rom, Corner Preservation & Dreiecksstreifen
|
||||||
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
|
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
|
||||||
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
|
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
|
||||||
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
|
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
|
||||||
@@ -187,7 +255,7 @@ src/
|
|||||||
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
|
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
|
||||||
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
|
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
|
||||||
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
|
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
|
||||||
├── updater.rs # In-App Gitea Updater (SemVer, Release-API, Download, Self-Update)
|
├── updater.rs # In-App Gitea Updater (SemVer, HTTPS, SHA256-Integrität, Self-Update)
|
||||||
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
|
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
|
||||||
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
|
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
|
||||||
```
|
```
|
||||||
@@ -198,16 +266,16 @@ Ausführliche Details zur Architektur und mathematischen Modellen findest du unt
|
|||||||
|
|
||||||
## 🧪 Tests ausführen
|
## 🧪 Tests ausführen
|
||||||
|
|
||||||
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit Unit-Tests abgesichert:
|
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
Alle 61 Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz und Versionsvergleiche konsistent ab.
|
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
MIT License / Open Source Inking Project for Linux.
|
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
|
||||||
+54
-51
@@ -1,6 +1,6 @@
|
|||||||
# Dateiformat & Speicherstruktur von ink-notes
|
# 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/
|
└── notebooks/
|
||||||
├── <notebook_id_1>/
|
├── <notebook_id_1>/
|
||||||
│ ├── manifest.json
|
│ ├── manifest.json
|
||||||
|
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
|
||||||
│ ├── page_1.json
|
│ ├── page_1.json
|
||||||
|
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
|
||||||
│ ├── page_2.json
|
│ ├── page_2.json
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── <notebook_id_2>/
|
└── <notebook_id_2>/
|
||||||
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"format_version": 2,
|
||||||
"id": "notebook-a1b2c3d4",
|
"id": "notebook-a1b2c3d4",
|
||||||
"title": "Mathematik Vorlesung",
|
"title": "Mathematik Vorlesung",
|
||||||
"version": 1,
|
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"file_name": "page_1.json"
|
"background": "Grid5mm"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"file_name": "page_2.json"
|
"background": "DotGrid"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"page_ids": [1, 2],
|
||||||
"current_page_index": 0,
|
"current_page_index": 0,
|
||||||
"next_page_id": 3,
|
"next_page_id": 3,
|
||||||
"next_stroke_id": 42,
|
"next_stroke_id": 42,
|
||||||
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Felder:
|
### Felder:
|
||||||
|
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
|
||||||
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
||||||
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
||||||
- `version`: Format-Versionsnummer (aktuell `1`).
|
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
|
||||||
- `pages`: Geordnete Liste von Seitenreferenzen.
|
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
|
||||||
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
||||||
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
||||||
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
|
- `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`)
|
## 3. Seitendatei (`page_<id>.json`)
|
||||||
|
|
||||||
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
|
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
|
```json
|
||||||
{
|
{"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}]}
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Felder:
|
### Felder:
|
||||||
|
- `format_version`: Format-Versionsnummer (`2`).
|
||||||
- `id`: Eindeutige ID der Seite.
|
- `id`: Eindeutige ID der Seite.
|
||||||
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
||||||
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
||||||
- `id`: Eindeutige ID des Strichs.
|
- `id`: Eindeutige ID des Strichs.
|
||||||
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
|
- `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, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
|
||||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
|
- `shape`: Optionales Enum für geometrische Vektorformen:
|
||||||
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
|
|
||||||
- `Line { start, end }`
|
- `Line { start, end }`
|
||||||
- `Rectangle { top_left, width, height }`
|
- `Rectangle { top_left, width, height }`
|
||||||
- `Circle { center, radius }`
|
- `Circle { center, radius }`
|
||||||
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
|
|||||||
- `Triangle { p1, p2, p3 }`
|
- `Triangle { p1, p2, p3 }`
|
||||||
- `Rhombus { center, width, height }`
|
- `Rhombus { center, width, height }`
|
||||||
- `Octahedron { center, radius }`
|
- `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:
|
### Atomares Schreiben & Verzeichnis-Sync
|
||||||
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
|
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.
|
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.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Name=ink-notes
|
||||||
|
GenericName=Notizbuch- & Inking-App
|
||||||
|
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
||||||
|
Exec=ink-notes %F
|
||||||
|
Icon=eu.pansi.ink-notes
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Categories=Office;Graphics;Utility;
|
||||||
|
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;
|
||||||
|
StartupWMClass=ink-notes
|
||||||
|
MimeType=application/x-inknotes;
|
||||||
|
Actions=NewWindow;
|
||||||
|
|
||||||
|
[Desktop Action NewWindow]
|
||||||
|
Name=Neues Fenster
|
||||||
|
Exec=ink-notes
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>eu.pansi.ink-notes</id>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<name>ink-notes</name>
|
||||||
|
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux,
|
||||||
|
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus.
|
||||||
|
</p>
|
||||||
|
<p>Hauptmerkmale:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
|
||||||
|
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
|
||||||
|
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
|
||||||
|
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
|
||||||
|
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
|
||||||
|
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||||
|
<provides>
|
||||||
|
<binary>ink-notes</binary>
|
||||||
|
</provides>
|
||||||
|
<categories>
|
||||||
|
<category>Office</category>
|
||||||
|
<category>Graphics</category>
|
||||||
|
<category>Utility</category>
|
||||||
|
</categories>
|
||||||
|
<developer id="eu.pansi">
|
||||||
|
<name>Harald</name>
|
||||||
|
</developer>
|
||||||
|
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
|
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
</component>
|
||||||
@@ -4,7 +4,7 @@ Name=ink-notes
|
|||||||
GenericName=Notizbuch- & Inking-App
|
GenericName=Notizbuch- & Inking-App
|
||||||
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
||||||
Exec=ink-notes %F
|
Exec=ink-notes %F
|
||||||
Icon=ink-notes
|
Icon=eu.pansi.ink-notes
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Office;Graphics;Utility;
|
Categories=Office;Graphics;Utility;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||||
</ul>
|
</ul>
|
||||||
</description>
|
</description>
|
||||||
<launchable type="desktop-id">ink-notes.desktop</launchable>
|
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||||
<provides>
|
<provides>
|
||||||
<binary>ink-notes</binary>
|
<binary>ink-notes</binary>
|
||||||
</provides>
|
</provides>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<developer id="eu.pansi">
|
<developer id="eu.pansi">
|
||||||
<name>Harald</name>
|
<name>Harald</name>
|
||||||
</developer>
|
</developer>
|
||||||
<url type="homepage">https://farmersdog.pansi.eu/harald/ink-notes</url>
|
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||||
<url type="bugtracker">https://farmersdog.pansi.eu/harald/ink-notes/issues</url>
|
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
</component>
|
</component>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## ink-notes {{TAG_NAME}}
|
||||||
|
|
||||||
|
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
|
### Highlights:
|
||||||
|
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||||
|
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl als Kachelraster, größere Bedienelemente in allen Popovern
|
||||||
|
- **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, 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:
|
||||||
|
- **Zorin OS / Ubuntu / Debian (.deb)**:
|
||||||
|
```bash
|
||||||
|
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
|
||||||
|
```
|
||||||
|
- **Portables AppImage (Alle Linux-Distributionen)**:
|
||||||
|
```bash
|
||||||
|
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
|
||||||
|
./ink-notes_{{VERSION}}_x86_64.AppImage
|
||||||
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
|||||||
cd "${ROOT_DIR}/target"
|
cd "${ROOT_DIR}/target"
|
||||||
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
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 "======================================================================"
|
||||||
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
||||||
|
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
||||||
cat "${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 "=================================================================="
|
||||||
+48
-11
@@ -18,11 +18,22 @@ APPDIR="${ROOT_DIR}/target/AppDir"
|
|||||||
CACHE_DIR="${ROOT_DIR}/target/tools"
|
CACHE_DIR="${ROOT_DIR}/target/tools"
|
||||||
APPIMAGETOOL="${CACHE_DIR}/appimagetool-x86_64.AppImage"
|
APPIMAGETOOL="${CACHE_DIR}/appimagetool-x86_64.AppImage"
|
||||||
|
|
||||||
|
# Pinned appimagetool Release & SHA-256 Hash
|
||||||
|
APPIMAGETOOL_VERSION="1.9.1"
|
||||||
|
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage"
|
||||||
|
APPIMAGETOOL_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||||
|
|
||||||
echo "=== [1/5] Stelle Release-Binary sicher (ink-notes v${VERSION}) ==="
|
echo "=== [1/5] Stelle Release-Binary sicher (ink-notes v${VERSION}) ==="
|
||||||
if [ ! -f "target/release/ink-notes" ]; then
|
if [ ! -f "target/release/ink-notes" ]; then
|
||||||
cargo build --release
|
cargo build --release
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Prüfe glibc-Mindestversion
|
||||||
|
echo "=== Prüfe glibc-Symbolversionen ==="
|
||||||
|
MAX_GLIBC=$(objdump -T "target/release/ink-notes" | grep "GLIBC_" | sed -e 's/.*GLIBC_//' | cut -d')' -f1 | sort -V | tail -n 1 || echo "unknown")
|
||||||
|
echo "Höchste benötigte glibc-Symbolversion: GLIBC_${MAX_GLIBC}"
|
||||||
|
echo "Hinweis: ink-notes nutzt den Host-Grafikstack (Vulkan ICD, Wayland/X11) und glibc >= ${MAX_GLIBC}."
|
||||||
|
|
||||||
# Sicherstellen, dass das PNG-Icon existiert
|
# Sicherstellen, dass das PNG-Icon existiert
|
||||||
if [ ! -f "assets/ink-notes.png" ]; then
|
if [ ! -f "assets/ink-notes.png" ]; then
|
||||||
echo "Generiere PNG-Icon..."
|
echo "Generiere PNG-Icon..."
|
||||||
@@ -41,15 +52,20 @@ mkdir -p "${APPDIR}/usr/share/metainfo"
|
|||||||
cp "target/release/ink-notes" "${APPDIR}/usr/bin/ink-notes"
|
cp "target/release/ink-notes" "${APPDIR}/usr/bin/ink-notes"
|
||||||
chmod 755 "${APPDIR}/usr/bin/ink-notes"
|
chmod 755 "${APPDIR}/usr/bin/ink-notes"
|
||||||
|
|
||||||
cp "packaging/ink-notes.desktop" "${APPDIR}/usr/share/applications/ink-notes.desktop"
|
# Desktop-Datei
|
||||||
cp "packaging/ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
|
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
|
||||||
|
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/eu.pansi.ink-notes.desktop"
|
||||||
|
ln -sf "eu.pansi.ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
|
||||||
|
|
||||||
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
# Icons
|
||||||
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
|
||||||
cp "assets/ink-notes.png" "${APPDIR}/ink-notes.png"
|
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
|
||||||
ln -sf "ink-notes.png" "${APPDIR}/.DirIcon"
|
cp "assets/ink-notes.png" "${APPDIR}/eu.pansi.ink-notes.png"
|
||||||
|
ln -sf "eu.pansi.ink-notes.png" "${APPDIR}/.DirIcon"
|
||||||
|
|
||||||
cp "packaging/ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
# Metainfo
|
||||||
|
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||||
|
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.appdata.xml"
|
||||||
|
|
||||||
# AppRun Skript erstellen
|
# AppRun Skript erstellen
|
||||||
cat <<'EOF' > "${APPDIR}/AppRun"
|
cat <<'EOF' > "${APPDIR}/AppRun"
|
||||||
@@ -62,11 +78,32 @@ exec "${SELF_DIR}/usr/bin/ink-notes" "$@"
|
|||||||
EOF
|
EOF
|
||||||
chmod 755 "${APPDIR}/AppRun"
|
chmod 755 "${APPDIR}/AppRun"
|
||||||
|
|
||||||
echo "=== [3/5] Hole appimagetool (gecacht in ${CACHE_DIR}) ==="
|
echo "=== [3/5] Hole & verifiziere gepinntes appimagetool v${APPIMAGETOOL_VERSION} ==="
|
||||||
mkdir -p "${CACHE_DIR}"
|
mkdir -p "${CACHE_DIR}"
|
||||||
if [ ! -f "${APPIMAGETOOL}" ]; then
|
|
||||||
echo "Lade appimagetool herunter..."
|
need_download=true
|
||||||
curl -fsSL -o "${APPIMAGETOOL}" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
if [ -f "${APPIMAGETOOL}" ]; then
|
||||||
|
CURRENT_HASH=$(sha256sum "${APPIMAGETOOL}" | cut -d' ' -f1)
|
||||||
|
if [ "${CURRENT_HASH}" = "${APPIMAGETOOL_SHA256}" ]; then
|
||||||
|
need_download=false
|
||||||
|
else
|
||||||
|
echo "Gecachtes appimagetool hat ungültigen Hash. Lade neu herunter..."
|
||||||
|
rm -f "${APPIMAGETOOL}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${need_download}" = true ]; then
|
||||||
|
echo "Lade appimagetool v${APPIMAGETOOL_VERSION} herunter..."
|
||||||
|
curl -fsSL -o "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL_URL}"
|
||||||
|
DOWNLOAD_HASH=$(sha256sum "${APPIMAGETOOL}.tmp" | cut -d' ' -f1)
|
||||||
|
if [ "${DOWNLOAD_HASH}" != "${APPIMAGETOOL_SHA256}" ]; then
|
||||||
|
echo "FEHLER: SHA256-Prüfsumme für appimagetool stimmt nicht überein!"
|
||||||
|
echo "Erwartet: ${APPIMAGETOOL_SHA256}"
|
||||||
|
echo "Erhalten: ${DOWNLOAD_HASH}"
|
||||||
|
rm -f "${APPIMAGETOOL}.tmp"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL}"
|
||||||
chmod +x "${APPIMAGETOOL}"
|
chmod +x "${APPIMAGETOOL}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+96
-20
@@ -16,7 +16,7 @@ ARCH="amd64"
|
|||||||
DEB_NAME="${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
DEB_NAME="${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
||||||
STAGING_DIR="${ROOT_DIR}/target/deb_staging"
|
STAGING_DIR="${ROOT_DIR}/target/deb_staging"
|
||||||
|
|
||||||
echo "=== [1/4] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
|
echo "=== [1/5] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Sicherstellen, dass das PNG-Icon existiert
|
# Sicherstellen, dass das PNG-Icon existiert
|
||||||
@@ -25,7 +25,23 @@ if [ ! -f "assets/ink-notes.png" ]; then
|
|||||||
python3 scripts/generate_png_icon.py
|
python3 scripts/generate_png_icon.py
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== [2/4] Erstelle Paketstruktur in ${STAGING_DIR} ==="
|
echo "=== [2/5] Ermittle tatsächliche Abhängigkeiten (ldd & dpkg-shlibdeps) ==="
|
||||||
|
echo "Linker-Abhängigkeiten (ldd):"
|
||||||
|
ldd target/release/ink-notes
|
||||||
|
|
||||||
|
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
|
||||||
|
echo "Ermittle automatische shlibdeps via dpkg-shlibdeps..."
|
||||||
|
TEMP_CONTROL_DIR="$(mktemp -d)"
|
||||||
|
mkdir -p "${TEMP_CONTROL_DIR}/debian"
|
||||||
|
echo "Source: ${PKG_NAME}" > "${TEMP_CONTROL_DIR}/debian/control"
|
||||||
|
(
|
||||||
|
cd "${TEMP_CONTROL_DIR}"
|
||||||
|
dpkg-shlibdeps -O "${ROOT_DIR}/target/release/ink-notes" 2>/dev/null || true
|
||||||
|
)
|
||||||
|
rm -rf "${TEMP_CONTROL_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== [3/5] Erstelle Paketstruktur in ${STAGING_DIR} ==="
|
||||||
rm -rf "${STAGING_DIR}"
|
rm -rf "${STAGING_DIR}"
|
||||||
mkdir -p "${STAGING_DIR}/DEBIAN"
|
mkdir -p "${STAGING_DIR}/DEBIAN"
|
||||||
mkdir -p "${STAGING_DIR}/usr/bin"
|
mkdir -p "${STAGING_DIR}/usr/bin"
|
||||||
@@ -33,44 +49,104 @@ mkdir -p "${STAGING_DIR}/usr/share/applications"
|
|||||||
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps"
|
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps"
|
||||||
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps"
|
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps"
|
||||||
mkdir -p "${STAGING_DIR}/usr/share/metainfo"
|
mkdir -p "${STAGING_DIR}/usr/share/metainfo"
|
||||||
|
mkdir -p "${STAGING_DIR}/usr/share/doc/${PKG_NAME}"
|
||||||
|
|
||||||
# 2. Dateien kopieren
|
# Dateien kopieren
|
||||||
cp "target/release/ink-notes" "${STAGING_DIR}/usr/bin/ink-notes"
|
cp "target/release/ink-notes" "${STAGING_DIR}/usr/bin/ink-notes"
|
||||||
|
|
||||||
|
# Desktop-Dateien (sowohl mit AppStream ID als auch Legacy Name)
|
||||||
|
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
|
||||||
|
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
|
||||||
|
|
||||||
|
# Icons (eu.pansi.ink-notes & ink-notes)
|
||||||
|
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
|
||||||
|
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
||||||
|
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
|
||||||
|
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
||||||
|
|
||||||
|
# AppStream Metainfo
|
||||||
|
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||||
|
|
||||||
|
# Debian doc: copyright
|
||||||
|
cat <<'EOF' > "${STAGING_DIR}/usr/share/doc/ink-notes/copyright"
|
||||||
|
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||||
|
Upstream-Name: ink-notes
|
||||||
|
Upstream-Contact: Harald <harald@pansi.eu>
|
||||||
|
Source: https://gitea.pansi.eu/harald/ink-notes
|
||||||
|
|
||||||
|
Files: *
|
||||||
|
Copyright: 2026 Harald Pansi <harald@pansi.eu>
|
||||||
|
License: MIT
|
||||||
|
|
||||||
|
License: MIT
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
.
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
.
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Debian doc: changelog.gz
|
||||||
|
CHANGELOG_TMP="$(mktemp)"
|
||||||
|
cat <<EOF > "${CHANGELOG_TMP}"
|
||||||
|
ink-notes (${VERSION}-1) stable; urgency=medium
|
||||||
|
|
||||||
|
* Release v${VERSION}.
|
||||||
|
* Low-latency vector inking with two-stage pipeline.
|
||||||
|
* Shape recognition and infinite canvas with zoom/pan.
|
||||||
|
* Multi-notebook management and PDF export.
|
||||||
|
|
||||||
|
-- Harald <harald@pansi.eu> $(date -R)
|
||||||
|
EOF
|
||||||
|
gzip -n -9 -c "${CHANGELOG_TMP}" > "${STAGING_DIR}/usr/share/doc/ink-notes/changelog.gz"
|
||||||
|
rm -f "${CHANGELOG_TMP}"
|
||||||
|
|
||||||
|
# Berechtigungen penibel setzen
|
||||||
|
find "${STAGING_DIR}" -type d -exec chmod 755 {} +
|
||||||
|
find "${STAGING_DIR}/usr/share" -type f -exec chmod 644 {} +
|
||||||
|
chmod 755 "${STAGING_DIR}/DEBIAN"
|
||||||
chmod 755 "${STAGING_DIR}/usr/bin/ink-notes"
|
chmod 755 "${STAGING_DIR}/usr/bin/ink-notes"
|
||||||
|
|
||||||
cp "packaging/ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
|
# DEBIAN/control Datei generieren
|
||||||
chmod 644 "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
|
|
||||||
|
|
||||||
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
|
||||||
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
|
||||||
|
|
||||||
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
|
||||||
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
|
||||||
|
|
||||||
cp "packaging/ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
|
||||||
chmod 644 "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
|
||||||
|
|
||||||
# 3. DEBIAN/control Datei generieren
|
|
||||||
cat <<EOF > "${STAGING_DIR}/DEBIAN/control"
|
cat <<EOF > "${STAGING_DIR}/DEBIAN/control"
|
||||||
Package: ${PKG_NAME}
|
Package: ${PKG_NAME}
|
||||||
Version: ${VERSION}
|
Version: ${VERSION}
|
||||||
Architecture: ${ARCH}
|
Architecture: ${ARCH}
|
||||||
Maintainer: Harald <harald@pansi.eu>
|
Maintainer: Harald <harald@pansi.eu>
|
||||||
Installed-Size: $(du -ks "${STAGING_DIR}/usr" | cut -f1)
|
Installed-Size: $(du -ks "${STAGING_DIR}/usr" | cut -f1)
|
||||||
Depends: libc6, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0
|
Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-icd, libwayland-client0, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0, xdg-utils
|
||||||
Section: utils
|
Section: utils
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Homepage: https://farmersdog.pansi.eu/harald/ink-notes
|
Homepage: https://gitea.pansi.eu/harald/ink-notes
|
||||||
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux
|
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux
|
||||||
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu),
|
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu),
|
||||||
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen,
|
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen,
|
||||||
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
|
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
|
||||||
EOF
|
EOF
|
||||||
|
chmod 644 "${STAGING_DIR}/DEBIAN/control"
|
||||||
|
|
||||||
echo "=== [3/4] Schnüre .deb-Paket mit dpkg-deb ==="
|
echo "=== [4/5] Schnüre .deb-Paket mit dpkg-deb ==="
|
||||||
dpkg-deb --build --root-owner-group "${STAGING_DIR}" "${ROOT_DIR}/target/${DEB_NAME}"
|
dpkg-deb --build --root-owner-group "${STAGING_DIR}" "${ROOT_DIR}/target/${DEB_NAME}"
|
||||||
|
|
||||||
echo "=== [4/4] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
|
echo "=== [5/5] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
|
||||||
ls -lh "${ROOT_DIR}/target/${DEB_NAME}"
|
ls -lh "${ROOT_DIR}/target/${DEB_NAME}"
|
||||||
dpkg-deb -I "${ROOT_DIR}/target/${DEB_NAME}"
|
dpkg-deb -I "${ROOT_DIR}/target/${DEB_NAME}"
|
||||||
|
|
||||||
|
if command -v lintian >/dev/null 2>&1; then
|
||||||
|
echo "=== Prüfe Paket mit lintian ==="
|
||||||
|
lintian "${ROOT_DIR}/target/${DEB_NAME}" || true
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Fertig!"
|
echo "Fertig!"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
||||||
# Lädt .deb, .AppImage und SHA256SUMS.txt als Release-Assets hoch.
|
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,13 +14,15 @@ GITEA_SERVER="${GITEA_SERVER:-https://gitea.pansi.eu}"
|
|||||||
REPO_OWNER="${REPO_OWNER:-harald}"
|
REPO_OWNER="${REPO_OWNER:-harald}"
|
||||||
REPO_NAME="${REPO_NAME:-ink-notes}"
|
REPO_NAME="${REPO_NAME:-ink-notes}"
|
||||||
|
|
||||||
# Token aus Argument oder Umgebungsvariablen ermitteln
|
# Token ausschließlich aus Umgebungsvariablen ermitteln (Fallback für Legacy-Aufruf mit Argument)
|
||||||
TOKEN="${1:-${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}}"
|
TOKEN="${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}"
|
||||||
|
if [ -z "${TOKEN}" ] && [ -n "${1:-}" ]; then
|
||||||
|
TOKEN="$1"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -z "${TOKEN}" ]; then
|
if [ -z "${TOKEN}" ]; then
|
||||||
echo "FEHLER: Kein Gitea-API-Token angegeben."
|
echo "FEHLER: Kein Gitea-API-Token angegeben."
|
||||||
echo "Verwendung: $0 <GITEA_API_TOKEN>"
|
echo "Bitte setze die Umgebungsvariable GITEA_TOKEN oder INK_NOTES_GITEA_TOKEN."
|
||||||
echo "Oder setze die Umgebungsvariable INK_NOTES_GITEA_TOKEN."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -31,6 +33,13 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
|
|||||||
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
||||||
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
||||||
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
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
|
||||||
|
echo "FEHLER: Release-Template '${TEMPLATE_FILE}' nicht gefunden."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Sicherstellen, dass die Artefakte existieren
|
# Sicherstellen, dass die Artefakte existieren
|
||||||
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
|
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
|
||||||
@@ -38,101 +47,197 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
|
|||||||
"${SCRIPT_DIR}/build-release.sh"
|
"${SCRIPT_DIR}/build-release.sh"
|
||||||
fi
|
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 "======================================================================"
|
||||||
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
|
|
||||||
RELEASE_BODY=$(cat <<EOF
|
# 1. Payload robust per Python aus Template erzeugen (verhindert Shell-Escaping-Probleme)
|
||||||
## ink-notes ${TAG_NAME}
|
PAYLOAD_FILE="$(mktemp)"
|
||||||
|
CLEANUP_FILES=("${PAYLOAD_FILE}")
|
||||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
cleanup() {
|
||||||
|
for f in "${CLEANUP_FILES[@]}"; do
|
||||||
### Highlights:
|
rm -f "${f}"
|
||||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
done
|
||||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
|
||||||
- **Viewport**: Stufenloser Zoom (25% - 500%) und Panning
|
|
||||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
|
|
||||||
- **In-App Updater**: Automatische Versionsprüfung und Aktualisierung via Gitea Releases
|
|
||||||
- **Vektor-PDF-Export**: Verluste freie Ausgabe für Druck und Weitergabe
|
|
||||||
|
|
||||||
### Installation:
|
|
||||||
- **Zorin OS / Ubuntu / Debian (.deb)**:
|
|
||||||
\`\`\`bash
|
|
||||||
sudo dpkg -i ink-notes_${VERSION}_amd64.deb
|
|
||||||
\`\`\`
|
|
||||||
- **Portables AppImage (Alle Linux-Distributionen)**:
|
|
||||||
\`\`\`bash
|
|
||||||
chmod +x ink-notes_${VERSION}_x86_64.AppImage
|
|
||||||
./ink-notes_${VERSION}_x86_64.AppImage
|
|
||||||
\`\`\`
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. Release via Gitea REST API erstellen (oder ID abfragen, falls bereits existent)
|
|
||||||
CREATE_PAYLOAD=$(python3 -c "
|
|
||||||
import json, sys
|
|
||||||
data = {
|
|
||||||
'tag_name': '${TAG_NAME}',
|
|
||||||
'target_commitish': 'main',
|
|
||||||
'name': '${RELEASE_TITLE}',
|
|
||||||
'body': '''${RELEASE_BODY}''',
|
|
||||||
'draft': False,
|
|
||||||
'prerelease': False
|
|
||||||
}
|
}
|
||||||
print(json.dumps(data))
|
trap cleanup EXIT
|
||||||
")
|
|
||||||
|
RELEASE_VERSION="${VERSION}" RELEASE_TAG="${TAG_NAME}" RELEASE_TITLE="${RELEASE_TITLE}" \
|
||||||
|
python3 -c '
|
||||||
|
import json, os, sys
|
||||||
|
|
||||||
|
template_path = sys.argv[1]
|
||||||
|
with open(template_path, "r", encoding="utf-8") as f:
|
||||||
|
template_content = f.read()
|
||||||
|
|
||||||
|
version = os.environ.get("RELEASE_VERSION", "")
|
||||||
|
tag = os.environ.get("RELEASE_TAG", "")
|
||||||
|
title = os.environ.get("RELEASE_TITLE", "")
|
||||||
|
|
||||||
|
body = template_content.replace("{{VERSION}}", version).replace("{{TAG_NAME}}", tag)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": "main",
|
||||||
|
"name": title,
|
||||||
|
"body": body,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False
|
||||||
|
}
|
||||||
|
print(json.dumps(payload))
|
||||||
|
' "${TEMPLATE_FILE}" > "${PAYLOAD_FILE}"
|
||||||
|
|
||||||
API_BASE="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
API_BASE="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
||||||
|
|
||||||
echo "Erstelle Release über Gitea API..."
|
# 2. Prüfen, ob Release bereits existiert (Idempotenz)
|
||||||
HTTP_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \
|
echo "Prüfe bestehende Releases auf ${GITEA_SERVER}..."
|
||||||
-X POST "${API_BASE}/releases" \
|
TAG_RESP_FILE="$(mktemp)"
|
||||||
-H "Accept: application/json" \
|
CLEANUP_FILES+=("${TAG_RESP_FILE}")
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-d "${CREATE_PAYLOAD}")
|
|
||||||
|
|
||||||
HTTP_STATUS=$(echo "${HTTP_RESPONSE}" | grep "HTTP_STATUS:" | cut -d':' -f2)
|
HTTP_STATUS=$(curl -s -S -o "${TAG_RESP_FILE}" -w "%{http_code}" \
|
||||||
RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '/HTTP_STATUS:/d')
|
-X GET "${API_BASE}/releases/tags/${TAG_NAME}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Authorization: token ${TOKEN}" || echo "000")
|
||||||
|
|
||||||
RELEASE_ID=""
|
RELEASE_ID=""
|
||||||
if [ "${HTTP_STATUS}" -eq 201 ]; then
|
|
||||||
RELEASE_ID=$(echo "${RESPONSE_BODY}" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])")
|
|
||||||
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
|
|
||||||
else
|
|
||||||
echo "Release existiert womöglich bereits (Status ${HTTP_STATUS}). Suche bestehendes Release..."
|
|
||||||
EXISTING_RELEASE=$(curl -fsSL \
|
|
||||||
-H "Accept: application/json" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/releases/tags/${TAG_NAME}" || true)
|
|
||||||
if [ -n "${EXISTING_RELEASE}" ]; then
|
|
||||||
RELEASE_ID=$(echo "${EXISTING_RELEASE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "${RELEASE_ID}" ]; then
|
if [ "${HTTP_STATUS}" = "200" ]; then
|
||||||
echo "FEHLER: Konnte Release nicht erstellen oder abfragen. Serverantwort:"
|
RELEASE_ID=$(python3 -c '
|
||||||
echo "${RESPONSE_BODY}"
|
import sys, json
|
||||||
|
try:
|
||||||
|
data = json.load(open(sys.argv[1]))
|
||||||
|
print(data.get("id", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
' "${TAG_RESP_FILE}")
|
||||||
|
echo "Release ${TAG_NAME} existiert bereits (ID: ${RELEASE_ID}). Aktualisiere Release-Notizen..."
|
||||||
|
PATCH_STATUS=$(curl -s -S -o /dev/null -w "%{http_code}" \
|
||||||
|
-X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-d @"${PAYLOAD_FILE}" || echo "000")
|
||||||
|
if [ "${PATCH_STATUS}" != "200" ]; then
|
||||||
|
echo "Hinweis: Aktualisierung der Metadaten ergab HTTP ${PATCH_STATUS}."
|
||||||
|
fi
|
||||||
|
elif [ "${HTTP_STATUS}" = "404" ]; then
|
||||||
|
echo "Release existiert noch nicht. Erstelle neues Release ${TAG_NAME}..."
|
||||||
|
CREATE_RESP_FILE="$(mktemp)"
|
||||||
|
CLEANUP_FILES+=("${CREATE_RESP_FILE}")
|
||||||
|
CREATE_STATUS=$(curl -s -S -o "${CREATE_RESP_FILE}" -w "%{http_code}" \
|
||||||
|
-X POST "${API_BASE}/releases" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-d @"${PAYLOAD_FILE}" || echo "000")
|
||||||
|
|
||||||
|
if [ "${CREATE_STATUS}" = "201" ]; then
|
||||||
|
RELEASE_ID=$(python3 -c '
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
data = json.load(open(sys.argv[1]))
|
||||||
|
print(data.get("id", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
' "${CREATE_RESP_FILE}")
|
||||||
|
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
|
||||||
|
else
|
||||||
|
echo "FEHLER: Release konnte nicht angelegt werden (HTTP ${CREATE_STATUS})."
|
||||||
|
cat "${CREATE_RESP_FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FEHLER: Unerwartete Serverantwort beim Abfragen des Release-Tags (HTTP ${HTTP_STATUS})."
|
||||||
|
cat "${TAG_RESP_FILE}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Assets hochladen
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
|
echo "FEHLER: Keine gültige Release-ID ermittelt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Vorhandene Assets auflisten (um Dubletten vor Upload sauber zu löschen)
|
||||||
|
ASSETS_LIST_FILE="$(mktemp)"
|
||||||
|
CLEANUP_FILES+=("${ASSETS_LIST_FILE}")
|
||||||
|
|
||||||
|
LIST_STATUS=$(curl -s -S -o "${ASSETS_LIST_FILE}" -w "%{http_code}" \
|
||||||
|
-X GET "${API_BASE}/releases/${RELEASE_ID}/assets" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Authorization: token ${TOKEN}" || echo "000")
|
||||||
|
|
||||||
|
if [ "${LIST_STATUS}" != "200" ]; then
|
||||||
|
echo "Hinweis: Konnte bestehende Assets nicht abrufen (HTTP ${LIST_STATUS})."
|
||||||
|
fi
|
||||||
|
|
||||||
upload_asset() {
|
upload_asset() {
|
||||||
local file_path="$1"
|
local file_path="$1"
|
||||||
local filename="$(basename "${file_path}")"
|
local filename
|
||||||
|
filename="$(basename "${file_path}")"
|
||||||
|
|
||||||
|
# Asset-Namen in URL URL-kodieren
|
||||||
|
local encoded_name
|
||||||
|
encoded_name=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "${filename}")
|
||||||
|
|
||||||
|
# Prüfen, ob bereits Assets mit demselben Namen existieren (können mehrere sein)
|
||||||
|
local existing_ids=()
|
||||||
|
if [ -f "${ASSETS_LIST_FILE}" ] && [ -s "${ASSETS_LIST_FILE}" ]; then
|
||||||
|
mapfile -t existing_ids < <(python3 -c '
|
||||||
|
import sys, json
|
||||||
|
try:
|
||||||
|
assets = json.load(open(sys.argv[1]))
|
||||||
|
target = sys.argv[2]
|
||||||
|
for a in assets:
|
||||||
|
if a.get("name") == target and a.get("id"):
|
||||||
|
print(a.get("id"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
' "${ASSETS_LIST_FILE}" "${filename}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for eid in "${existing_ids[@]}"; do
|
||||||
|
if [ -n "${eid}" ]; then
|
||||||
|
echo "Lösche bestehendes Asset '${filename}' (ID: ${eid}) vor Re-Upload..."
|
||||||
|
local del_status
|
||||||
|
del_status=$(curl -s -S -o /dev/null -w "%{http_code}" \
|
||||||
|
-X DELETE "${API_BASE}/releases/${RELEASE_ID}/assets/${eid}" \
|
||||||
|
-H "Authorization: token ${TOKEN}" || echo "000")
|
||||||
|
if [ "${del_status}" != "204" ] && [ "${del_status}" != "200" ]; then
|
||||||
|
echo "Warnung: Löschen von bestehendem Asset '${filename}' ergab HTTP ${del_status}."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "Lade Asset hoch: ${filename}..."
|
echo "Lade Asset hoch: ${filename}..."
|
||||||
curl -fsSL \
|
local upload_res_file="$(mktemp)"
|
||||||
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
CLEANUP_FILES+=("${upload_res_file}")
|
||||||
|
|
||||||
|
local upload_status
|
||||||
|
upload_status=$(curl -s -S -o "${upload_res_file}" -w "%{http_code}" \
|
||||||
|
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${encoded_name}" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${file_path}" > /dev/null
|
--data-binary "@${file_path}" || echo "000")
|
||||||
echo "✓ ${filename} erfolgreich hochgeladen."
|
|
||||||
|
if [ "${upload_status}" = "201" ]; then
|
||||||
|
echo "✓ ${filename} erfolgreich hochgeladen."
|
||||||
|
else
|
||||||
|
echo "FEHLER: Asset-Upload für '${filename}' fehlgeschlagen (HTTP ${upload_status})."
|
||||||
|
cat "${upload_res_file}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
upload_asset "${DEB_FILE}"
|
upload_asset "${DEB_FILE}"
|
||||||
upload_asset "${APPIMAGE_FILE}"
|
upload_asset "${APPIMAGE_FILE}"
|
||||||
upload_asset "${CHECKSUMS_FILE}"
|
upload_asset "${CHECKSUMS_FILE}"
|
||||||
|
upload_asset "${SIGNATURE_FILE}"
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
||||||
|
|||||||
+897
-83
File diff suppressed because it is too large.
Load diff
+13
-9
@@ -39,13 +39,7 @@ pub fn generate_paper_quad() -> Vec<InkVertex> {
|
|||||||
|
|
||||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn push_quad(
|
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
|
||||||
vertices: &mut Vec<InkVertex>,
|
|
||||||
x_min: f32,
|
|
||||||
y_min: f32,
|
|
||||||
x_max: f32,
|
|
||||||
y_max: f32,
|
|
||||||
) {
|
|
||||||
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
||||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||||
@@ -102,7 +96,13 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
while y < PAGE_HEIGHT {
|
while y < PAGE_HEIGHT {
|
||||||
let mut x = GRID_5MM_SPACING;
|
let mut x = GRID_5MM_SPACING;
|
||||||
while x < PAGE_WIDTH {
|
while x < PAGE_WIDTH {
|
||||||
push_quad(&mut vertices, x - half_d, y - half_d, x + half_d, y + half_d);
|
push_quad(
|
||||||
|
&mut vertices,
|
||||||
|
x - half_d,
|
||||||
|
y - half_d,
|
||||||
|
x + half_d,
|
||||||
|
y + half_d,
|
||||||
|
);
|
||||||
x += GRID_5MM_SPACING;
|
x += GRID_5MM_SPACING;
|
||||||
}
|
}
|
||||||
y += GRID_5MM_SPACING;
|
y += GRID_5MM_SPACING;
|
||||||
@@ -132,7 +132,11 @@ mod tests {
|
|||||||
|
|
||||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
||||||
assert!(!grid.is_empty());
|
assert!(!grid.is_empty());
|
||||||
assert_eq!(grid.len() % 6, 0, "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)");
|
assert_eq!(
|
||||||
|
grid.len() % 6,
|
||||||
|
0,
|
||||||
|
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||||
|
);
|
||||||
|
|
||||||
let lined = generate_background_mesh(PageBackground::Lined);
|
let lined = generate_background_mesh(PageBackground::Lined);
|
||||||
assert!(!lined.is_empty());
|
assert!(!lined.is_empty());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-35
@@ -11,9 +11,7 @@ use std::sync::Arc;
|
|||||||
use winit::dpi::PhysicalSize;
|
use winit::dpi::PhysicalSize;
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
|
|
||||||
use crate::background::{
|
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
|
||||||
generate_background_mesh, generate_paper_quad, DESK_COLOR,
|
|
||||||
};
|
|
||||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
@@ -96,6 +94,9 @@ pub struct CanvasState {
|
|||||||
pub config: wgpu::SurfaceConfiguration,
|
pub config: wgpu::SurfaceConfiguration,
|
||||||
pub size: PhysicalSize<u32>,
|
pub size: PhysicalSize<u32>,
|
||||||
pub viewport: PageViewport,
|
pub viewport: PageViewport,
|
||||||
|
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||||
|
ui_inset_left: f32,
|
||||||
|
ui_inset_right: f32,
|
||||||
pub camera: crate::viewport::ViewportState,
|
pub camera: crate::viewport::ViewportState,
|
||||||
|
|
||||||
// Pipelines
|
// Pipelines
|
||||||
@@ -106,6 +107,8 @@ pub struct CanvasState {
|
|||||||
pub camera_buffer: wgpu::Buffer,
|
pub camera_buffer: wgpu::Buffer,
|
||||||
pub camera_bind_group: wgpu::BindGroup,
|
pub camera_bind_group: wgpu::BindGroup,
|
||||||
pub current_ink_color: [f32; 4],
|
pub current_ink_color: [f32; 4],
|
||||||
|
pub current_stroke_width_mm: f32,
|
||||||
|
pub current_tool: crate::document::StrokeTool,
|
||||||
|
|
||||||
// Statische Hintergrund-Geometrie
|
// Statische Hintergrund-Geometrie
|
||||||
pub paper_buffer: wgpu::Buffer,
|
pub paper_buffer: wgpu::Buffer,
|
||||||
@@ -143,7 +146,8 @@ impl CanvasState {
|
|||||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||||
pub async fn new(window: Arc<Window>) -> Result<Self, Box<dyn std::error::Error>> {
|
pub async fn new(window: Arc<Window>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let size = window.inner_size();
|
let size = window.inner_size();
|
||||||
let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
let viewport =
|
||||||
|
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
||||||
|
|
||||||
// 1. WGPU Instance & Surface
|
// 1. WGPU Instance & Surface
|
||||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||||
@@ -187,7 +191,10 @@ impl CanvasState {
|
|||||||
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
||||||
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
||||||
wgpu::PresentMode::Fifo
|
wgpu::PresentMode::Fifo
|
||||||
} else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
} else if surface_caps
|
||||||
|
.present_modes
|
||||||
|
.contains(&wgpu::PresentMode::Mailbox)
|
||||||
|
{
|
||||||
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
||||||
wgpu::PresentMode::Mailbox
|
wgpu::PresentMode::Mailbox
|
||||||
} else {
|
} else {
|
||||||
@@ -358,12 +365,7 @@ impl CanvasState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 8. egui Renderer für das schwebende UI initialisieren
|
// 8. egui Renderer für das schwebende UI initialisieren
|
||||||
let egui_renderer = egui_wgpu::Renderer::new(
|
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||||
&device,
|
|
||||||
config.format,
|
|
||||||
None,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
@@ -372,11 +374,15 @@ impl CanvasState {
|
|||||||
config,
|
config,
|
||||||
size,
|
size,
|
||||||
viewport,
|
viewport,
|
||||||
|
ui_inset_left: 0.0,
|
||||||
|
ui_inset_right: 0.0,
|
||||||
strip_pipeline,
|
strip_pipeline,
|
||||||
triangle_pipeline,
|
triangle_pipeline,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
camera_bind_group,
|
camera_bind_group,
|
||||||
current_ink_color: default_ink_color,
|
current_ink_color: default_ink_color,
|
||||||
|
current_stroke_width_mm: 0.5,
|
||||||
|
current_tool: crate::document::StrokeTool::Pen,
|
||||||
paper_buffer,
|
paper_buffer,
|
||||||
background_buffer,
|
background_buffer,
|
||||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||||
@@ -410,7 +416,34 @@ impl CanvasState {
|
|||||||
transform_scale: scale,
|
transform_scale: scale,
|
||||||
transform_offset: offset,
|
transform_offset: offset,
|
||||||
};
|
};
|
||||||
self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms]));
|
self.queue.write_buffer(
|
||||||
|
&self.camera_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&[camera_uniforms]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||||
|
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||||
|
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.ui_inset_left = left;
|
||||||
|
self.ui_inset_right = right;
|
||||||
|
self.recalculate_viewport();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recalculate_viewport(&mut self) {
|
||||||
|
self.viewport = PageViewport::calculate_with_insets(
|
||||||
|
self.size.width as f32,
|
||||||
|
self.size.height as f32,
|
||||||
|
Self::VIEWPORT_MARGIN,
|
||||||
|
self.ui_inset_left,
|
||||||
|
self.ui_inset_right,
|
||||||
|
);
|
||||||
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||||
@@ -420,26 +453,21 @@ impl CanvasState {
|
|||||||
self.config.width = new_size.width;
|
self.config.width = new_size.width;
|
||||||
self.config.height = new_size.height;
|
self.config.height = new_size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.recalculate_viewport();
|
||||||
self.viewport = PageViewport::calculate(
|
|
||||||
new_size.width as f32,
|
|
||||||
new_size.height as f32,
|
|
||||||
Self::VIEWPORT_MARGIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.update_uniforms();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta.
|
/// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta.
|
||||||
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
|
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
|
||||||
self.camera.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
|
self.camera
|
||||||
|
.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
|
||||||
self.update_uniforms();
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zoomt zentriert auf eine Cursor-Bildschirmposition.
|
/// Zoomt zentriert auf eine Cursor-Bildschirmposition.
|
||||||
pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) {
|
pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) {
|
||||||
self.camera.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
|
self.camera
|
||||||
|
.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
|
||||||
self.update_uniforms();
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,12 +479,14 @@ impl CanvasState {
|
|||||||
|
|
||||||
/// Transformiert Fenster-Pixel in Seitenkoordinaten.
|
/// Transformiert Fenster-Pixel in Seitenkoordinaten.
|
||||||
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
|
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
|
||||||
self.camera.screen_to_page(screen_x, screen_y, &self.viewport)
|
self.camera
|
||||||
|
.screen_to_page(screen_x, screen_y, &self.viewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite.
|
/// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite.
|
||||||
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
|
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
|
||||||
self.camera.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
|
self.camera
|
||||||
|
.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten.
|
/// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten.
|
||||||
@@ -471,7 +501,8 @@ impl CanvasState {
|
|||||||
self.live_vertex_count = self.live_vertices.len() as u32;
|
self.live_vertex_count = self.live_vertices.len() as u32;
|
||||||
|
|
||||||
if self.live_vertices.len() > self.live_vertex_capacity {
|
if self.live_vertices.len() > self.live_vertex_capacity {
|
||||||
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
self.live_vertex_capacity =
|
||||||
|
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
||||||
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
||||||
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -496,10 +527,14 @@ impl CanvasState {
|
|||||||
|
|
||||||
/// Konfiguriert die Strichbreite in Millimetern.
|
/// Konfiguriert die Strichbreite in Millimetern.
|
||||||
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
|
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.
|
self.current_stroke_width_mm = width_mm;
|
||||||
let base_radius = (width_mm * 5.0).max(0.25);
|
self.spline_config =
|
||||||
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
|
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||||
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||||
@@ -522,7 +557,8 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !mesh.is_empty() {
|
if !mesh.is_empty() {
|
||||||
self.queue.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
self.queue
|
||||||
|
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +575,8 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if page.vertex_cache.len() > self.committed_vertex_capacity {
|
if page.vertex_cache.len() > self.committed_vertex_capacity {
|
||||||
self.committed_vertex_capacity = (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
|
self.committed_vertex_capacity =
|
||||||
|
(page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
|
||||||
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
|
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
|
||||||
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -635,6 +672,10 @@ impl CanvasState {
|
|||||||
self.current_ink_color,
|
self.current_ink_color,
|
||||||
&self.spline_config,
|
&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
|
// Temporären GPU-Live-Buffer vollständig leeren
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
@@ -655,7 +696,8 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let color = self.current_ink_color;
|
let color = self.current_ink_color;
|
||||||
let raw_strip = spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
|
let raw_strip =
|
||||||
|
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
|
||||||
self.live_vertices = raw_strip
|
self.live_vertices = raw_strip
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||||
@@ -667,7 +709,8 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.live_vertices.len() > self.live_vertex_capacity {
|
if self.live_vertices.len() > self.live_vertex_capacity {
|
||||||
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
self.live_vertex_capacity =
|
||||||
|
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
||||||
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
||||||
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -694,7 +737,8 @@ impl CanvasState {
|
|||||||
) -> Result<(), wgpu::SurfaceError> {
|
) -> Result<(), wgpu::SurfaceError> {
|
||||||
// Vor dem Renderpass: egui-Texturen aktualisieren
|
// Vor dem Renderpass: egui-Texturen aktualisieren
|
||||||
for (id, image_delta) in &textures_delta.set {
|
for (id, image_delta) in &textures_delta.set {
|
||||||
self.egui_renderer.update_texture(&self.device, &self.queue, *id, image_delta);
|
self.egui_renderer
|
||||||
|
.update_texture(&self.device, &self.queue, *id, image_delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = self.surface.get_current_texture()?;
|
let output = self.surface.get_current_texture()?;
|
||||||
@@ -784,7 +828,8 @@ impl CanvasState {
|
|||||||
occlusion_query_set: None,
|
occlusion_query_set: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.egui_renderer.render(&mut egui_pass, clipped_primitives, screen_descriptor);
|
self.egui_renderer
|
||||||
|
.render(&mut egui_pass, clipped_primitives, screen_descriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.queue.submit(std::iter::once(encoder.finish()));
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
|
|||||||
+172
-23
@@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
|
|||||||
(p - projection).length()
|
(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.
|
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Stroke {
|
pub struct Stroke {
|
||||||
@@ -112,15 +122,26 @@ pub struct Stroke {
|
|||||||
pub aabb: Aabb,
|
pub aabb: Aabb,
|
||||||
pub color: [f32; 4],
|
pub color: [f32; 4],
|
||||||
pub shape: Option<crate::geometry::RecognizedShape>,
|
pub shape: Option<crate::geometry::RecognizedShape>,
|
||||||
|
pub tool: StrokeTool,
|
||||||
|
pub width: f32,
|
||||||
|
pub finalized: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stroke {
|
impl Stroke {
|
||||||
pub fn new(id: u64, points: Vec<StylusSample>, vertices: Vec<InkVertex>, color: [f32; 4]) -> Self {
|
pub fn new(
|
||||||
|
id: u64,
|
||||||
|
points: Vec<StylusSample>,
|
||||||
|
vertices: Vec<InkVertex>,
|
||||||
|
color: [f32; 4],
|
||||||
|
) -> Self {
|
||||||
let aabb = if !points.is_empty() {
|
let aabb = if !points.is_empty() {
|
||||||
let pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
|
let pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
|
||||||
Aabb::from_points(&pts)
|
Aabb::from_points(&pts)
|
||||||
} else if !vertices.is_empty() {
|
} else if !vertices.is_empty() {
|
||||||
let pts: Vec<glam::Vec2> = vertices.iter().map(|v| glam::Vec2::new(v.position[0], v.position[1])).collect();
|
let pts: Vec<glam::Vec2> = vertices
|
||||||
|
.iter()
|
||||||
|
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
|
||||||
|
.collect();
|
||||||
Aabb::from_points(&pts)
|
Aabb::from_points(&pts)
|
||||||
} else {
|
} else {
|
||||||
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
|
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
|
||||||
@@ -133,6 +154,9 @@ impl Stroke {
|
|||||||
aabb,
|
aabb,
|
||||||
color,
|
color,
|
||||||
shape: None,
|
shape: None,
|
||||||
|
tool: StrokeTool::Pen,
|
||||||
|
width: 5.0,
|
||||||
|
finalized: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +173,22 @@ impl Stroke {
|
|||||||
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
|
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
|
||||||
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
|
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
|
||||||
self.shape = Some(shape);
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +211,12 @@ impl Stroke {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
|
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
|
||||||
pub fn intersects_eraser(&self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> bool {
|
pub fn intersects_eraser(
|
||||||
|
&self,
|
||||||
|
eraser_pos: glam::Vec2,
|
||||||
|
eraser_radius: f32,
|
||||||
|
max_stroke_radius: f32,
|
||||||
|
) -> bool {
|
||||||
// 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB?
|
// 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB?
|
||||||
let total_margin = eraser_radius + max_stroke_radius;
|
let total_margin = eraser_radius + max_stroke_radius;
|
||||||
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
|
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
|
||||||
@@ -254,9 +299,7 @@ impl Stroke {
|
|||||||
|
|
||||||
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
|
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
|
||||||
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
|
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
|
||||||
let sc = |pos: glam::Vec2| -> glam::Vec2 {
|
let sc = |pos: glam::Vec2| -> glam::Vec2 { origin + (pos - origin) * factors };
|
||||||
origin + (pos - origin) * factors
|
|
||||||
};
|
|
||||||
|
|
||||||
for pt in &mut self.points {
|
for pt in &mut self.points {
|
||||||
pt.position = sc(pt.position);
|
pt.position = sc(pt.position);
|
||||||
@@ -293,6 +336,22 @@ pub enum PageBackground {
|
|||||||
DotGrid,
|
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.
|
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Page {
|
pub struct Page {
|
||||||
@@ -303,6 +362,7 @@ pub struct Page {
|
|||||||
pub stroke_ranges: Vec<Range<u32>>,
|
pub stroke_ranges: Vec<Range<u32>>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
pub is_loaded: bool,
|
pub is_loaded: bool,
|
||||||
|
pub persist: PersistState,
|
||||||
pub history: HistoryStack,
|
pub history: HistoryStack,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +376,7 @@ impl Page {
|
|||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
is_loaded: true,
|
is_loaded: true,
|
||||||
|
persist: PersistState::Normal,
|
||||||
history: HistoryStack::new(),
|
history: HistoryStack::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,10 +391,19 @@ impl Page {
|
|||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
is_loaded: false,
|
is_loaded: false,
|
||||||
|
persist: PersistState::Normal,
|
||||||
history: HistoryStack::new(),
|
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.
|
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
|
||||||
pub fn add_stroke(&mut self, stroke: Stroke) {
|
pub fn add_stroke(&mut self, stroke: Stroke) {
|
||||||
self.history.push(HistoryAction::AddStroke(stroke.clone()));
|
self.history.push(HistoryAction::AddStroke(stroke.clone()));
|
||||||
@@ -402,7 +472,12 @@ impl Page {
|
|||||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
|
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
|
||||||
///
|
///
|
||||||
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
|
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
|
||||||
pub fn erase_at(&mut self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> Vec<Stroke> {
|
pub fn erase_at(
|
||||||
|
&mut self,
|
||||||
|
eraser_pos: glam::Vec2,
|
||||||
|
eraser_radius: f32,
|
||||||
|
max_stroke_radius: f32,
|
||||||
|
) -> Vec<Stroke> {
|
||||||
let mut hit_indices = Vec::new();
|
let mut hit_indices = Vec::new();
|
||||||
|
|
||||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||||
@@ -422,7 +497,8 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removed.reverse();
|
removed.reverse();
|
||||||
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
|
self.history
|
||||||
|
.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||||
self.is_dirty = true;
|
self.is_dirty = true;
|
||||||
|
|
||||||
removed
|
removed
|
||||||
@@ -473,7 +549,11 @@ impl Page {
|
|||||||
|
|
||||||
/// Liefert Klone der Striche mit den angegebenen IDs.
|
/// Liefert Klone der Striche mit den angegebenen IDs.
|
||||||
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
|
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
|
||||||
self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect()
|
self.strokes
|
||||||
|
.iter()
|
||||||
|
.filter(|s| ids.contains(&s.id))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
|
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
|
||||||
@@ -482,7 +562,12 @@ impl Page {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
|
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
|
||||||
let before: Vec<Stroke> = self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect();
|
let before: Vec<Stroke> = self
|
||||||
|
.strokes
|
||||||
|
.iter()
|
||||||
|
.filter(|s| ids.contains(&s.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
for m in modified.iter() {
|
for m in modified.iter() {
|
||||||
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
||||||
@@ -517,7 +602,8 @@ impl Page {
|
|||||||
self.strokes[pos] = m.clone();
|
self.strokes[pos] = m.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.history.push(HistoryAction::ModifyStrokes { before, after });
|
self.history
|
||||||
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
self.is_dirty = true;
|
self.is_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,7 +620,8 @@ impl Page {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if !removed.is_empty() {
|
if !removed.is_empty() {
|
||||||
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
|
self.history
|
||||||
|
.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||||
self.is_dirty = true;
|
self.is_dirty = true;
|
||||||
}
|
}
|
||||||
removed
|
removed
|
||||||
@@ -545,7 +632,8 @@ impl Page {
|
|||||||
if new_strokes.is_empty() {
|
if new_strokes.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.history.push(HistoryAction::AddStrokes(new_strokes.clone()));
|
self.history
|
||||||
|
.push(HistoryAction::AddStrokes(new_strokes.clone()));
|
||||||
self.strokes.extend(new_strokes);
|
self.strokes.extend(new_strokes);
|
||||||
self.is_dirty = true;
|
self.is_dirty = true;
|
||||||
}
|
}
|
||||||
@@ -558,6 +646,7 @@ pub struct Notebook {
|
|||||||
pub current_page_index: usize,
|
pub current_page_index: usize,
|
||||||
pub next_stroke_id: u64,
|
pub next_stroke_id: u64,
|
||||||
pub next_page_id: u64,
|
pub next_page_id: u64,
|
||||||
|
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Notebook {
|
impl Notebook {
|
||||||
@@ -567,6 +656,7 @@ impl Notebook {
|
|||||||
current_page_index: 0,
|
current_page_index: 0,
|
||||||
next_stroke_id: 1,
|
next_stroke_id: 1,
|
||||||
next_page_id: 1,
|
next_page_id: 1,
|
||||||
|
load_warnings: std::collections::HashMap::new(),
|
||||||
};
|
};
|
||||||
// Erste Standardseite anlegen (5mm Grid)
|
// Erste Standardseite anlegen (5mm Grid)
|
||||||
nb.add_page(PageBackground::Grid5mm);
|
nb.add_page(PageBackground::Grid5mm);
|
||||||
@@ -585,6 +675,7 @@ impl Notebook {
|
|||||||
current_page_index,
|
current_page_index,
|
||||||
next_stroke_id,
|
next_stroke_id,
|
||||||
next_page_id,
|
next_page_id,
|
||||||
|
load_warnings: std::collections::HashMap::new(),
|
||||||
};
|
};
|
||||||
if nb.pages.is_empty() {
|
if nb.pages.is_empty() {
|
||||||
nb.add_page(PageBackground::Grid5mm);
|
nb.add_page(PageBackground::Grid5mm);
|
||||||
@@ -653,7 +744,11 @@ impl Notebook {
|
|||||||
|
|
||||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
||||||
pub fn osd_text(&self) -> String {
|
pub fn osd_text(&self) -> String {
|
||||||
format!("Seite {} / {}", self.current_page_index + 1, self.pages.len())
|
format!(
|
||||||
|
"Seite {} / {}",
|
||||||
|
self.current_page_index + 1,
|
||||||
|
self.pages.len()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +772,21 @@ pub struct PageViewport {
|
|||||||
impl PageViewport {
|
impl PageViewport {
|
||||||
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
||||||
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
||||||
let avail_w = (window_width - 2.0 * margin).max(10.0);
|
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
|
||||||
|
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
|
||||||
|
pub fn calculate_with_insets(
|
||||||
|
window_width: f32,
|
||||||
|
window_height: f32,
|
||||||
|
margin: f32,
|
||||||
|
inset_left: f32,
|
||||||
|
inset_right: f32,
|
||||||
|
) -> Self {
|
||||||
|
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
|
||||||
|
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
|
||||||
|
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
|
||||||
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
||||||
|
|
||||||
let window_ratio = avail_w / avail_h;
|
let window_ratio = avail_w / avail_h;
|
||||||
@@ -695,7 +804,7 @@ impl PageViewport {
|
|||||||
(w, h)
|
(w, h)
|
||||||
};
|
};
|
||||||
|
|
||||||
let page_x = (window_width - page_w) / 2.0;
|
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
|
||||||
let page_y = (window_height - page_h) / 2.0;
|
let page_y = (window_height - page_h) / 2.0;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -787,13 +896,25 @@ mod tests {
|
|||||||
let stroke_radius = 2.0;
|
let stroke_radius = 2.0;
|
||||||
|
|
||||||
// Treffer: Radierer bei (150, 105) (Abstand 5 <= 12)
|
// Treffer: Radierer bei (150, 105) (Abstand 5 <= 12)
|
||||||
assert!(stroke.intersects_eraser(glam::Vec2::new(150.0, 105.0), eraser_radius, stroke_radius));
|
assert!(stroke.intersects_eraser(
|
||||||
|
glam::Vec2::new(150.0, 105.0),
|
||||||
|
eraser_radius,
|
||||||
|
stroke_radius
|
||||||
|
));
|
||||||
|
|
||||||
// Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12)
|
// Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12)
|
||||||
assert!(!stroke.intersects_eraser(glam::Vec2::new(150.0, 150.0), eraser_radius, stroke_radius));
|
assert!(!stroke.intersects_eraser(
|
||||||
|
glam::Vec2::new(150.0, 150.0),
|
||||||
|
eraser_radius,
|
||||||
|
stroke_radius
|
||||||
|
));
|
||||||
|
|
||||||
// Kein Treffer: Weit außerhalb der AABB
|
// Kein Treffer: Weit außerhalb der AABB
|
||||||
assert!(!stroke.intersects_eraser(glam::Vec2::new(500.0, 500.0), eraser_radius, stroke_radius));
|
assert!(!stroke.intersects_eraser(
|
||||||
|
glam::Vec2::new(500.0, 500.0),
|
||||||
|
eraser_radius,
|
||||||
|
stroke_radius
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -824,6 +945,25 @@ mod tests {
|
|||||||
assert_eq!(nb.current_page_index, 0);
|
assert_eq!(nb.current_page_index, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewport_insets_center_page_in_free_area() {
|
||||||
|
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
|
||||||
|
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
|
||||||
|
assert!((inset.page_width - plain.page_width).abs() < 0.01);
|
||||||
|
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
|
||||||
|
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
|
||||||
|
|
||||||
|
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
|
||||||
|
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
|
||||||
|
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
|
||||||
|
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
|
||||||
|
|
||||||
|
// Leiste rechts
|
||||||
|
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
|
||||||
|
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_viewport_projection() {
|
fn test_page_viewport_projection() {
|
||||||
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
@@ -966,9 +1106,12 @@ mod tests {
|
|||||||
let mut moved = s.clone();
|
let mut moved = s.clone();
|
||||||
moved.translate(glam::Vec2::new(20.0, 30.0));
|
moved.translate(glam::Vec2::new(20.0, 30.0));
|
||||||
page.update_strokes_ephemeral(&[moved.clone()]);
|
page.update_strokes_ephemeral(&[moved.clone()]);
|
||||||
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
|
assert_eq!(
|
||||||
|
page.strokes[0].points[0].position,
|
||||||
|
glam::Vec2::new(30.0, 40.0)
|
||||||
|
);
|
||||||
// History should not have been updated yet
|
// History should not have been updated yet
|
||||||
assert_eq!(page.history.can_redo(), false);
|
assert!(!page.history.can_redo());
|
||||||
|
|
||||||
// Transactional commit (mouse release)
|
// Transactional commit (mouse release)
|
||||||
let before = vec![s.clone()];
|
let before = vec![s.clone()];
|
||||||
@@ -977,10 +1120,16 @@ mod tests {
|
|||||||
|
|
||||||
// Verify Undo restores original position
|
// Verify Undo restores original position
|
||||||
assert!(page.undo());
|
assert!(page.undo());
|
||||||
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(10.0, 10.0));
|
assert_eq!(
|
||||||
|
page.strokes[0].points[0].position,
|
||||||
|
glam::Vec2::new(10.0, 10.0)
|
||||||
|
);
|
||||||
|
|
||||||
// Verify Redo applies modified position
|
// Verify Redo applies modified position
|
||||||
assert!(page.redo());
|
assert!(page.redo());
|
||||||
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
|
assert_eq!(
|
||||||
|
page.strokes[0].points[0].position,
|
||||||
|
glam::Vec2::new(30.0, 40.0)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+137
-26
@@ -12,7 +12,7 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufWriter;
|
use std::io::BufWriter;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use printpdf::path::{PaintMode, WindingOrder};
|
use printpdf::path::{PaintMode, WindingOrder};
|
||||||
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
||||||
@@ -22,6 +22,70 @@ use crate::document::{
|
|||||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
|
||||||
|
pub fn sanitize_filename_component(title: &str) -> String {
|
||||||
|
let sanitized: String = title
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = sanitized.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"Notizbuch".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
|
||||||
|
pub fn default_documents_dir() -> PathBuf {
|
||||||
|
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
return PathBuf::from(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
if !home.trim().is_empty() {
|
||||||
|
return PathBuf::from(home).join("Documents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PathBuf::from(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt den kollisionsfreien Export-Pfad für ein Notizbuch im übergebenen Verzeichnis.
|
||||||
|
/// Existiert die Datei bereits, wird ein Zähler angehängt (`_1`, `_2`, etc.).
|
||||||
|
pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
|
||||||
|
let sanitized_title = sanitize_filename_component(title);
|
||||||
|
let base_name = format!("{}.pdf", sanitized_title);
|
||||||
|
let primary_path = dir.join(&base_name);
|
||||||
|
|
||||||
|
if !primary_path.exists() {
|
||||||
|
return primary_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut counter = 1;
|
||||||
|
loop {
|
||||||
|
let candidate_name = format!("{}_{}.pdf", sanitized_title, counter);
|
||||||
|
let candidate_path = dir.join(candidate_name);
|
||||||
|
if !candidate_path.exists() {
|
||||||
|
return candidate_path;
|
||||||
|
}
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
|
||||||
|
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
|
||||||
|
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
|
||||||
|
let docs_dir = default_documents_dir();
|
||||||
|
let _ = std::fs::create_dir_all(&docs_dir);
|
||||||
|
resolve_pdf_export_path_in_dir(&docs_dir, title)
|
||||||
|
}
|
||||||
|
|
||||||
/// Wählbare Exportstrategie für Vektorstriche.
|
/// Wählbare Exportstrategie für Vektorstriche.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum ExportStrategy {
|
pub enum ExportStrategy {
|
||||||
@@ -75,15 +139,21 @@ pub fn generate_pdf_document(
|
|||||||
if notebook.pages.is_empty() {
|
if notebook.pages.is_empty() {
|
||||||
return Err("Notizbuch enthält keine Seiten".into());
|
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
|
// 1. Erstes Dokument mit Seite 1 erstellen
|
||||||
let title = "ink-notes Notebook";
|
let title = "ink-notes Notebook";
|
||||||
let (doc, page1_idx, layer1_bg) = PdfDocument::new(
|
let (doc, page1_idx, layer1_bg) =
|
||||||
title,
|
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||||
Mm(A4_WIDTH_MM),
|
|
||||||
Mm(A4_HEIGHT_MM),
|
|
||||||
"Background",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Seite 1 befüllen
|
// Seite 1 befüllen
|
||||||
let page1_ref = doc.get_page(page1_idx);
|
let page1_ref = doc.get_page(page1_idx);
|
||||||
@@ -95,11 +165,7 @@ pub fn generate_pdf_document(
|
|||||||
|
|
||||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||||
for page in ¬ebook.pages[1..] {
|
for page in ¬ebook.pages[1..] {
|
||||||
let (page_idx, layer_bg) = doc.add_page(
|
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||||
Mm(A4_WIDTH_MM),
|
|
||||||
Mm(A4_HEIGHT_MM),
|
|
||||||
"Background",
|
|
||||||
);
|
|
||||||
let page_ref = doc.get_page(page_idx);
|
let page_ref = doc.get_page(page_idx);
|
||||||
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
||||||
render_background_layer(&layer_bg_ref, page.background);
|
render_background_layer(&layer_bg_ref, page.background);
|
||||||
@@ -112,10 +178,7 @@ pub fn generate_pdf_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
||||||
fn render_background_layer(
|
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) {
|
||||||
layer: &printpdf::PdfLayerReference,
|
|
||||||
background: PageBackground,
|
|
||||||
) {
|
|
||||||
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
|
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
|
||||||
layer.set_outline_color(grid_color.clone());
|
layer.set_outline_color(grid_color.clone());
|
||||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||||
@@ -203,11 +266,7 @@ fn render_background_layer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||||
fn render_ink_layer(
|
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
||||||
layer: &printpdf::PdfLayerReference,
|
|
||||||
page: &Page,
|
|
||||||
strategy: ExportStrategy,
|
|
||||||
) {
|
|
||||||
for stroke in &page.strokes {
|
for stroke in &page.strokes {
|
||||||
if let Some(ref shape) = stroke.shape {
|
if let Some(ref shape) = stroke.shape {
|
||||||
render_recognized_shape(layer, shape, stroke.color);
|
render_recognized_shape(layer, shape, stroke.color);
|
||||||
@@ -326,7 +385,12 @@ fn render_recognized_shape(
|
|||||||
layer.add_polygon(poly);
|
layer.add_polygon(poly);
|
||||||
}
|
}
|
||||||
|
|
||||||
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
|
RecognizedShape::Octahedron {
|
||||||
|
center,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
depth,
|
||||||
|
} => {
|
||||||
let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth);
|
let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth);
|
||||||
for (p1, p2) in edges {
|
for (p1, p2) in edges {
|
||||||
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
|
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
|
||||||
@@ -514,7 +578,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
|
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
|
||||||
|
|
||||||
// Durchschnittliche Linienstärke aus dem Druck berechnen
|
// Durchschnittliche Linienstärke aus dem Druck berechnen
|
||||||
let avg_pressure = stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
|
let avg_pressure =
|
||||||
|
stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
|
||||||
let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25);
|
let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25);
|
||||||
let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
|
let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
|
||||||
layer.set_outline_thickness(thickness_pt);
|
layer.set_outline_thickness(thickness_pt);
|
||||||
@@ -693,11 +758,18 @@ mod tests {
|
|||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
||||||
|
|
||||||
let export_res = export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
let export_res =
|
||||||
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
|
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
||||||
|
assert!(
|
||||||
|
export_res.is_ok(),
|
||||||
|
"PDF-Export zu Datei muss erfolgreich sein"
|
||||||
|
);
|
||||||
|
|
||||||
// Datei-Existenz und Größe prüfen
|
// Datei-Existenz und Größe prüfen
|
||||||
assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren");
|
assert!(
|
||||||
|
pdf_path.exists(),
|
||||||
|
"Die generierte PDF-Datei muss existieren"
|
||||||
|
);
|
||||||
let metadata = std::fs::metadata(&pdf_path).unwrap();
|
let metadata = std::fs::metadata(&pdf_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
metadata.len() > 1024,
|
metadata.len() > 1024,
|
||||||
@@ -821,4 +893,43 @@ mod tests {
|
|||||||
assert!(bytes.len() > 1024);
|
assert!(bytes.len() > 1024);
|
||||||
assert!(bytes.starts_with(b"%PDF-1."));
|
assert!(bytes.starts_with(b"%PDF-1."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_filename_component() {
|
||||||
|
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_filename_component("Projekt/2026: Entwurf"),
|
||||||
|
"Projekt_2026_ Entwurf"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_filename_component(" "), "Notizbuch");
|
||||||
|
assert_eq!(sanitize_filename_component(""), "Notizbuch");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_filename_component("Mathe * Vorlesung?"),
|
||||||
|
"Mathe _ Vorlesung_"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_pdf_export_path_collision_avoidance() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Konnte tempdir nicht erstellen");
|
||||||
|
let dir = temp_dir.path();
|
||||||
|
|
||||||
|
// 1. Erstmaliger Export: keine Kollision
|
||||||
|
let path1 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||||
|
assert_eq!(path1, dir.join("Mathe Vorlesung.pdf"));
|
||||||
|
|
||||||
|
// Datei anlegen
|
||||||
|
std::fs::write(&path1, b"dummy pdf 1").expect("Konnte Datei nicht schreiben");
|
||||||
|
|
||||||
|
// 2. Zweiter Export: Kollision -> _1
|
||||||
|
let path2 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||||
|
assert_eq!(path2, dir.join("Mathe Vorlesung_1.pdf"));
|
||||||
|
|
||||||
|
// Zweite Datei anlegen
|
||||||
|
std::fs::write(&path2, b"dummy pdf 2").expect("Konnte Datei nicht schreiben");
|
||||||
|
|
||||||
|
// 3. Dritter Export: Kollision -> _2
|
||||||
|
let path3 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||||
|
assert_eq!(path3, dir.join("Mathe Vorlesung_2.pdf"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+28
-7
@@ -265,9 +265,18 @@ mod tests {
|
|||||||
// 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen
|
// 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen
|
||||||
let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0);
|
let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0);
|
||||||
// Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen
|
// Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen
|
||||||
assert_eq!(x4, 50.0, "Nach Reset muss der neue X-Wert übernommen werden");
|
assert_eq!(
|
||||||
assert_eq!(y4, 100.0, "Nach Reset muss der neue Y-Wert übernommen werden");
|
x4, 50.0,
|
||||||
assert_eq!(p4, 0.9, "Nach Reset muss der neue Druckwert übernommen werden");
|
"Nach Reset muss der neue X-Wert übernommen werden"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
y4, 100.0,
|
||||||
|
"Nach Reset muss der neue Y-Wert übernommen werden"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p4, 0.9,
|
||||||
|
"Nach Reset muss der neue Druckwert übernommen werden"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -344,9 +353,18 @@ mod tests {
|
|||||||
|
|
||||||
// 2. Vergiftungsversuch mit NaN und Infinity
|
// 2. Vergiftungsversuch mit NaN und Infinity
|
||||||
let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02);
|
let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02);
|
||||||
assert!(x_nan.is_finite(), "Filter darf kein NaN bei Vergiftung ausgeben");
|
assert!(
|
||||||
assert!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben");
|
x_nan.is_finite(),
|
||||||
assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben");
|
"Filter darf kein NaN bei Vergiftung ausgeben"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
y_nan.is_finite(),
|
||||||
|
"Filter darf kein Inf bei Vergiftung ausgeben"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
p_nan.is_finite(),
|
||||||
|
"Filter darf kein -Inf bei Vergiftung ausgeben"
|
||||||
|
);
|
||||||
|
|
||||||
let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN);
|
let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN);
|
||||||
assert!(x_nan2.is_finite());
|
assert!(x_nan2.is_finite());
|
||||||
@@ -364,6 +382,9 @@ mod tests {
|
|||||||
assert!(x3.is_finite());
|
assert!(x3.is_finite());
|
||||||
assert!(y3.is_finite());
|
assert!(y3.is_finite());
|
||||||
assert!(p3.is_finite());
|
assert!(p3.is_finite());
|
||||||
assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen");
|
assert!(
|
||||||
|
x3 > x2,
|
||||||
|
"Filter muss sich nach Vergiftung weiter normal anpassen"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+216
-76
@@ -3,9 +3,9 @@
|
|||||||
//! Erkennt Geraden, Rechtecke, Kreise und Koordinatensysteme (Achsen)
|
//! Erkennt Geraden, Rechtecke, Kreise und Koordinatensysteme (Achsen)
|
||||||
//! und erzeugt saubere Vektor-Primitiven und GPU-Triangle-Meshes.
|
//! und erzeugt saubere Vektor-Primitiven und GPU-Triangle-Meshes.
|
||||||
|
|
||||||
use std::f32::consts::PI;
|
|
||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::document::{Aabb, Stroke, StylusSample};
|
use crate::document::{Aabb, Stroke, StylusSample};
|
||||||
@@ -117,7 +117,12 @@ impl RecognizedShape {
|
|||||||
*c += delta;
|
*c += delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::CoordinateSystem { origin, x_axis_end, y_axis_end, .. } => {
|
Self::CoordinateSystem {
|
||||||
|
origin,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
*origin += delta;
|
*origin += delta;
|
||||||
*x_axis_end += delta;
|
*x_axis_end += delta;
|
||||||
*y_axis_end += delta;
|
*y_axis_end += delta;
|
||||||
@@ -159,7 +164,12 @@ impl RecognizedShape {
|
|||||||
*c = rot(*c);
|
*c = rot(*c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. } => {
|
Self::CoordinateSystem {
|
||||||
|
origin: o,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
*o = rot(*o);
|
*o = rot(*o);
|
||||||
*x_axis_end = rot(*x_axis_end);
|
*x_axis_end = rot(*x_axis_end);
|
||||||
*y_axis_end = rot(*y_axis_end);
|
*y_axis_end = rot(*y_axis_end);
|
||||||
@@ -177,9 +187,7 @@ impl RecognizedShape {
|
|||||||
|
|
||||||
/// Skaliert die geometrische Form bezüglich `origin` um den Faktor `factors`.
|
/// Skaliert die geometrische Form bezüglich `origin` um den Faktor `factors`.
|
||||||
pub fn scale(&mut self, origin: Vec2, factors: Vec2) {
|
pub fn scale(&mut self, origin: Vec2, factors: Vec2) {
|
||||||
let sc = |pt: Vec2| -> Vec2 {
|
let sc = |pt: Vec2| -> Vec2 { origin + (pt - origin) * factors };
|
||||||
origin + (pt - origin) * factors
|
|
||||||
};
|
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
Self::Line { start, end } => {
|
Self::Line { start, end } => {
|
||||||
@@ -200,7 +208,12 @@ impl RecognizedShape {
|
|||||||
*c = sc(*c);
|
*c = sc(*c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. } => {
|
Self::CoordinateSystem {
|
||||||
|
origin: o,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
*o = sc(*o);
|
*o = sc(*o);
|
||||||
*x_axis_end = sc(*x_axis_end);
|
*x_axis_end = sc(*x_axis_end);
|
||||||
*y_axis_end = sc(*y_axis_end);
|
*y_axis_end = sc(*y_axis_end);
|
||||||
@@ -210,7 +223,12 @@ impl RecognizedShape {
|
|||||||
*v = sc(*v);
|
*v = sc(*v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self::Octahedron { center, radius_x, radius_y, depth } => {
|
Self::Octahedron {
|
||||||
|
center,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
depth,
|
||||||
|
} => {
|
||||||
*center = sc(*center);
|
*center = sc(*center);
|
||||||
*radius_x *= factors.x.abs();
|
*radius_x *= factors.x.abs();
|
||||||
*radius_y *= factors.y.abs();
|
*radius_y *= factors.y.abs();
|
||||||
@@ -415,18 +433,17 @@ pub fn detect_rectangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
||||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||||
|
|
||||||
let corners: Vec<Vec2> = if simplified.len() == 5
|
let corners: Vec<Vec2> =
|
||||||
&& (simplified[0] - simplified[4]).length() < epsilon * 2.5
|
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
||||||
{
|
simplified[0..4].to_vec()
|
||||||
simplified[0..4].to_vec()
|
} else if simplified.len() == 4 {
|
||||||
} else if simplified.len() == 4 {
|
simplified
|
||||||
simplified
|
} else if simplified.len() > 4 {
|
||||||
} else if simplified.len() > 4 {
|
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
|
||||||
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
|
extract_k_corners(&simplified, 4)?
|
||||||
extract_k_corners(&simplified, 4)?
|
} else {
|
||||||
} else {
|
return None;
|
||||||
return None;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if corners.len() != 4 {
|
if corners.len() != 4 {
|
||||||
return None;
|
return None;
|
||||||
@@ -535,7 +552,12 @@ pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option<Vec<Vec2>> {
|
|||||||
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
|
let mut selected_indices: Vec<usize> = scored.iter().take(k).map(|&(idx, _)| idx).collect();
|
||||||
selected_indices.sort_unstable();
|
selected_indices.sort_unstable();
|
||||||
|
|
||||||
Some(selected_indices.into_iter().map(|idx| points[idx]).collect())
|
Some(
|
||||||
|
selected_indices
|
||||||
|
.into_iter()
|
||||||
|
.map(|idx| points[idx])
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 4. Dreieck-Erkennung: Geschlossener Pfad mit 3 dominanten Ecken.
|
/// 4. Dreieck-Erkennung: Geschlossener Pfad mit 3 dominanten Ecken.
|
||||||
@@ -561,7 +583,8 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let epsilon = (total_len * 0.07).clamp(8.0, 50.0);
|
let epsilon = (total_len * 0.07).clamp(8.0, 50.0);
|
||||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||||
|
|
||||||
let corners: Vec<Vec2> = if (simplified.len() == 4 && (simplified[0] - simplified[3]).length() < epsilon * 2.5)
|
let corners: Vec<Vec2> = if (simplified.len() == 4
|
||||||
|
&& (simplified[0] - simplified[3]).length() < epsilon * 2.5)
|
||||||
|| simplified.len() == 3
|
|| simplified.len() == 3
|
||||||
{
|
{
|
||||||
simplified[0..3].to_vec()
|
simplified[0..3].to_vec()
|
||||||
@@ -617,9 +640,19 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let snapped_angle = snap_angle(base_angle, 15.0);
|
let snapped_angle = snap_angle(base_angle, 15.0);
|
||||||
|
|
||||||
let c0 = center + Vec2::new(snapped_angle.cos(), snapped_angle.sin()) * r;
|
let c0 = center + Vec2::new(snapped_angle.cos(), snapped_angle.sin()) * r;
|
||||||
let c1 = center + Vec2::new((snapped_angle + 2.0 * PI / 3.0).cos(), (snapped_angle + 2.0 * PI / 3.0).sin()) * r;
|
let c1 = center
|
||||||
let c2 = center + Vec2::new((snapped_angle + 4.0 * PI / 3.0).cos(), (snapped_angle + 4.0 * PI / 3.0).sin()) * r;
|
+ Vec2::new(
|
||||||
return Some(RecognizedShape::Triangle { corners: [c0, c1, c2] });
|
(snapped_angle + 2.0 * PI / 3.0).cos(),
|
||||||
|
(snapped_angle + 2.0 * PI / 3.0).sin(),
|
||||||
|
) * r;
|
||||||
|
let c2 = center
|
||||||
|
+ Vec2::new(
|
||||||
|
(snapped_angle + 4.0 * PI / 3.0).cos(),
|
||||||
|
(snapped_angle + 4.0 * PI / 3.0).sin(),
|
||||||
|
) * r;
|
||||||
|
return Some(RecognizedShape::Triangle {
|
||||||
|
corners: [c0, c1, c2],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Rechtwinklig? (Ein Winkel nahe 90°)
|
// 2. Rechtwinklig? (Ein Winkel nahe 90°)
|
||||||
@@ -639,7 +672,11 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let len2 = v2.length();
|
let len2 = v2.length();
|
||||||
|
|
||||||
v1 = Vec2::new(a1_snapped.cos(), a1_snapped.sin()) * len1;
|
v1 = Vec2::new(a1_snapped.cos(), a1_snapped.sin()) * len1;
|
||||||
let sign = if (v1.x * v2.y - v1.y * v2.x) >= 0.0 { 1.0 } else { -1.0 };
|
let sign = if (v1.x * v2.y - v1.y * v2.x) >= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
-1.0
|
||||||
|
};
|
||||||
v2 = Vec2::new(-v1.y * sign, v1.x * sign).normalize_or_zero() * len2;
|
v2 = Vec2::new(-v1.y * sign, v1.x * sign).normalize_or_zero() * len2;
|
||||||
|
|
||||||
let new_p1 = corner_v + v1;
|
let new_p1 = corner_v + v1;
|
||||||
@@ -667,7 +704,9 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let half_base = ((a - mid_ac).length() + (c - mid_ac).length()) * 0.5;
|
let half_base = ((a - mid_ac).length() + (c - mid_ac).length()) * 0.5;
|
||||||
let new_a = mid_ac + perp * half_base;
|
let new_a = mid_ac + perp * half_base;
|
||||||
c = mid_ac - perp * half_base;
|
c = mid_ac - perp * half_base;
|
||||||
return Some(RecognizedShape::Triangle { corners: [new_a, b, c] });
|
return Some(RecognizedShape::Triangle {
|
||||||
|
corners: [new_a, b, c],
|
||||||
|
});
|
||||||
} else if (l_bc - l_ca).abs() / l_bc.max(l_ca) < 0.12 {
|
} else if (l_bc - l_ca).abs() / l_bc.max(l_ca) < 0.12 {
|
||||||
let mid_ab = (a + b) * 0.5;
|
let mid_ab = (a + b) * 0.5;
|
||||||
let axis = (c - mid_ab).normalize_or_zero();
|
let axis = (c - mid_ab).normalize_or_zero();
|
||||||
@@ -675,7 +714,9 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let half_base = ((a - mid_ab).length() + (b - mid_ab).length()) * 0.5;
|
let half_base = ((a - mid_ab).length() + (b - mid_ab).length()) * 0.5;
|
||||||
let new_a = mid_ab + perp * half_base;
|
let new_a = mid_ab + perp * half_base;
|
||||||
b = mid_ab - perp * half_base;
|
b = mid_ab - perp * half_base;
|
||||||
return Some(RecognizedShape::Triangle { corners: [new_a, b, c] });
|
return Some(RecognizedShape::Triangle {
|
||||||
|
corners: [new_a, b, c],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(RecognizedShape::Triangle { corners: [a, b, c] })
|
Some(RecognizedShape::Triangle { corners: [a, b, c] })
|
||||||
@@ -701,15 +742,16 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
||||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||||
|
|
||||||
let corners: Vec<Vec2> = if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
let corners: Vec<Vec2> =
|
||||||
simplified[0..4].to_vec()
|
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
||||||
} else if simplified.len() == 4 {
|
simplified[0..4].to_vec()
|
||||||
simplified
|
} else if simplified.len() == 4 {
|
||||||
} else if simplified.len() > 4 {
|
simplified
|
||||||
extract_k_corners(&simplified, 4)?
|
} else if simplified.len() > 4 {
|
||||||
} else {
|
extract_k_corners(&simplified, 4)?
|
||||||
return None;
|
} else {
|
||||||
};
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
if corners.len() != 4 {
|
if corners.len() != 4 {
|
||||||
return None;
|
return None;
|
||||||
@@ -756,7 +798,11 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
let snapped_a1 = snap_angle(angle1, 15.0);
|
let snapped_a1 = snap_angle(angle1, 15.0);
|
||||||
dir1 = Vec2::new(snapped_a1.cos(), snapped_a1.sin());
|
dir1 = Vec2::new(snapped_a1.cos(), snapped_a1.sin());
|
||||||
|
|
||||||
let sign = if (dir1.x * d2.y - dir1.y * d2.x) >= 0.0 { 1.0 } else { -1.0 };
|
let sign = if (dir1.x * d2.y - dir1.y * d2.x) >= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
-1.0
|
||||||
|
};
|
||||||
let dir2 = Vec2::new(-dir1.y * sign, dir1.x * sign);
|
let dir2 = Vec2::new(-dir1.y * sign, dir1.x * sign);
|
||||||
|
|
||||||
let h1 = len1 * 0.5;
|
let h1 = len1 * 0.5;
|
||||||
@@ -806,7 +852,10 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_r_diff = radii.iter().map(|r| (r - mean_r).abs()).fold(0.0f32, f32::max);
|
let max_r_diff = radii
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r - mean_r).abs())
|
||||||
|
.fold(0.0f32, f32::max);
|
||||||
|
|
||||||
let snapped_vertices = if max_r_diff / mean_r < 0.25 {
|
let snapped_vertices = if max_r_diff / mean_r < 0.25 {
|
||||||
let base_dir = (corners[0] - center).normalize_or_zero();
|
let base_dir = (corners[0] - center).normalize_or_zero();
|
||||||
@@ -830,7 +879,9 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
|||||||
|
|
||||||
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
||||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||||
if simplified.len() >= 2 && (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0 {
|
if simplified.len() >= 2
|
||||||
|
&& (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0
|
||||||
|
{
|
||||||
simplified[0..simplified.len() - 1].to_vec()
|
simplified[0..simplified.len() - 1].to_vec()
|
||||||
} else {
|
} else {
|
||||||
simplified
|
simplified
|
||||||
@@ -1043,8 +1094,16 @@ pub fn closed_polygon_to_mesh(
|
|||||||
let n_next = Vec2::new(-d_next.y, d_next.x);
|
let n_next = Vec2::new(-d_next.y, d_next.x);
|
||||||
let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w;
|
let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w;
|
||||||
|
|
||||||
vertices.push(InkVertex::new(curr.x + corner_norm.x, curr.y + corner_norm.y, color));
|
vertices.push(InkVertex::new(
|
||||||
vertices.push(InkVertex::new(curr.x - corner_norm.x, curr.y - corner_norm.y, color));
|
curr.x + corner_norm.x,
|
||||||
|
curr.y + corner_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
curr.x - corner_norm.x,
|
||||||
|
curr.y - corner_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
|
||||||
let edge_len = (next - curr).length();
|
let edge_len = (next - curr).length();
|
||||||
let steps = ((edge_len / 10.0).ceil() as usize).max(2);
|
let steps = ((edge_len / 10.0).ceil() as usize).max(2);
|
||||||
@@ -1069,7 +1128,12 @@ pub fn closed_polygon_to_mesh(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Berechnet die 12 Kanten der 3D-Schrägbild-Projektion eines Oktaeders (Doppelpyramide).
|
/// Berechnet die 12 Kanten der 3D-Schrägbild-Projektion eines Oktaeders (Doppelpyramide).
|
||||||
pub fn octahedron_edges(center: Vec2, radius_x: f32, radius_y: f32, depth: f32) -> Vec<(Vec2, Vec2)> {
|
pub fn octahedron_edges(
|
||||||
|
center: Vec2,
|
||||||
|
radius_x: f32,
|
||||||
|
radius_y: f32,
|
||||||
|
depth: f32,
|
||||||
|
) -> Vec<(Vec2, Vec2)> {
|
||||||
let top = center + Vec2::new(0.0, -radius_y);
|
let top = center + Vec2::new(0.0, -radius_y);
|
||||||
let bottom = center + Vec2::new(0.0, radius_y);
|
let bottom = center + Vec2::new(0.0, radius_y);
|
||||||
let left = center + Vec2::new(-radius_x, 0.0);
|
let left = center + Vec2::new(-radius_x, 0.0);
|
||||||
@@ -1143,8 +1207,16 @@ pub fn shape_to_mesh(
|
|||||||
let angle = (i as f32 / segments as f32) * 2.0 * PI;
|
let angle = (i as f32 / segments as f32) * 2.0 * PI;
|
||||||
let (sin, cos) = angle.sin_cos();
|
let (sin, cos) = angle.sin_cos();
|
||||||
|
|
||||||
vertices.push(InkVertex::new(center.x + cos * r_out, center.y + sin * r_out, color));
|
vertices.push(InkVertex::new(
|
||||||
vertices.push(InkVertex::new(center.x + cos * r_in, center.y + sin * r_in, color));
|
center.x + cos * r_out,
|
||||||
|
center.y + sin * r_out,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
center.x + cos * r_in,
|
||||||
|
center.y + sin * r_in,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
|
||||||
if i < segments {
|
if i < segments {
|
||||||
samples.push(StylusSample {
|
samples.push(StylusSample {
|
||||||
@@ -1158,23 +1230,20 @@ pub fn shape_to_mesh(
|
|||||||
(samples, vertices)
|
(samples, vertices)
|
||||||
}
|
}
|
||||||
|
|
||||||
RecognizedShape::Rectangle { corners } => {
|
RecognizedShape::Rectangle { corners } => closed_polygon_to_mesh(corners, half_w, color),
|
||||||
closed_polygon_to_mesh(corners, half_w, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecognizedShape::Triangle { corners } => {
|
RecognizedShape::Triangle { corners } => closed_polygon_to_mesh(corners, half_w, color),
|
||||||
closed_polygon_to_mesh(corners, half_w, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecognizedShape::Rhombus { corners } => {
|
RecognizedShape::Rhombus { corners } => closed_polygon_to_mesh(corners, half_w, color),
|
||||||
closed_polygon_to_mesh(corners, half_w, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecognizedShape::Polygon { vertices } => {
|
RecognizedShape::Polygon { vertices } => closed_polygon_to_mesh(vertices, half_w, color),
|
||||||
closed_polygon_to_mesh(vertices, half_w, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
|
RecognizedShape::Octahedron {
|
||||||
|
center,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
depth,
|
||||||
|
} => {
|
||||||
let edges = octahedron_edges(*center, *radius_x, *radius_y, *depth);
|
let edges = octahedron_edges(*center, *radius_x, *radius_y, *depth);
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
@@ -1228,10 +1297,26 @@ pub fn shape_to_mesh(
|
|||||||
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
||||||
|
|
||||||
// Y-Linienband
|
// Y-Linienband
|
||||||
vertices.push(InkVertex::new(origin.x + y_norm.x, origin.y + y_norm.y, color));
|
vertices.push(InkVertex::new(
|
||||||
vertices.push(InkVertex::new(origin.x - y_norm.x, origin.y - y_norm.y, color));
|
origin.x + y_norm.x,
|
||||||
vertices.push(InkVertex::new(y_axis_end.x + y_norm.x, y_axis_end.y + y_norm.y, color));
|
origin.y + y_norm.y,
|
||||||
vertices.push(InkVertex::new(y_axis_end.x - y_norm.x, y_axis_end.y - y_norm.y, color));
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
origin.x - y_norm.x,
|
||||||
|
origin.y - y_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
y_axis_end.x + y_norm.x,
|
||||||
|
y_axis_end.y + y_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
y_axis_end.x - y_norm.x,
|
||||||
|
y_axis_end.y - y_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
|
||||||
// Degenerate Dreiecke für Übergang zur X-Achse
|
// Degenerate Dreiecke für Übergang zur X-Achse
|
||||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||||
@@ -1244,9 +1329,21 @@ pub fn shape_to_mesh(
|
|||||||
|
|
||||||
// 2. X-Linienband
|
// 2. X-Linienband
|
||||||
vertices.push(next_v);
|
vertices.push(next_v);
|
||||||
vertices.push(InkVertex::new(origin.x - x_norm.x, origin.y - x_norm.y, color));
|
vertices.push(InkVertex::new(
|
||||||
vertices.push(InkVertex::new(x_axis_end.x + x_norm.x, x_axis_end.y + x_norm.y, color));
|
origin.x - x_norm.x,
|
||||||
vertices.push(InkVertex::new(x_axis_end.x - x_norm.x, x_axis_end.y - x_norm.y, color));
|
origin.y - x_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
x_axis_end.x + x_norm.x,
|
||||||
|
x_axis_end.y + x_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
vertices.push(InkVertex::new(
|
||||||
|
x_axis_end.x - x_norm.x,
|
||||||
|
x_axis_end.y - x_norm.y,
|
||||||
|
color,
|
||||||
|
));
|
||||||
|
|
||||||
// 3. Optionale Pfeilspitzen
|
// 3. Optionale Pfeilspitzen
|
||||||
if *with_arrows {
|
if *with_arrows {
|
||||||
@@ -1314,6 +1411,9 @@ pub fn shape_to_stroke(
|
|||||||
let (samples, vertices) = shape_to_mesh(&shape, width_units, color);
|
let (samples, vertices) = shape_to_mesh(&shape, width_units, color);
|
||||||
let mut stroke = Stroke::new(stroke_id, samples, vertices, color);
|
let mut stroke = Stroke::new(stroke_id, samples, vertices, color);
|
||||||
stroke.shape = Some(shape);
|
stroke.shape = Some(shape);
|
||||||
|
stroke.width = width_units;
|
||||||
|
stroke.tool = crate::document::StrokeTool::Shape;
|
||||||
|
stroke.finalized = true;
|
||||||
stroke
|
stroke
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,7 +1428,10 @@ mod tests {
|
|||||||
for i in 0..=50 {
|
for i in 0..=50 {
|
||||||
let t = i as f32 / 50.0;
|
let t = i as f32 / 50.0;
|
||||||
let jitter = (i as f32 * 0.7).sin() * 1.5;
|
let jitter = (i as f32 * 0.7).sin() * 1.5;
|
||||||
points.push(Vec2::new(100.0 + t * 400.0 + jitter, 100.0 + t * 300.0 - jitter));
|
points.push(Vec2::new(
|
||||||
|
100.0 + t * 400.0 + jitter,
|
||||||
|
100.0 + t * 300.0 - jitter,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let detected = detect_line(&points);
|
let detected = detect_line(&points);
|
||||||
@@ -1380,7 +1483,12 @@ mod tests {
|
|||||||
let v1 = (prev - curr).normalize();
|
let v1 = (prev - curr).normalize();
|
||||||
let v2 = (next - curr).normalize();
|
let v2 = (next - curr).normalize();
|
||||||
let dot = v1.dot(v2).abs();
|
let dot = v1.dot(v2).abs();
|
||||||
assert!(dot < 1e-4, "Winkel an Ecke {} ist nicht rechtwinklig: dot={}", i, dot);
|
assert!(
|
||||||
|
dot < 1e-4,
|
||||||
|
"Winkel an Ecke {} ist nicht rechtwinklig: dot={}",
|
||||||
|
i,
|
||||||
|
dot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
panic!("Falscher Formtyp erkannt");
|
panic!("Falscher Formtyp erkannt");
|
||||||
@@ -1402,7 +1510,11 @@ mod tests {
|
|||||||
let detected = detect_circle(&points);
|
let detected = detect_circle(&points);
|
||||||
assert!(detected.is_some(), "Kreis sollte erkannt werden");
|
assert!(detected.is_some(), "Kreis sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Circle { center: c, radius: r }) = detected {
|
if let Some(RecognizedShape::Circle {
|
||||||
|
center: c,
|
||||||
|
radius: r,
|
||||||
|
}) = detected
|
||||||
|
{
|
||||||
assert!((c - center).length() < 5.0, "Zentrum weicht ab: {:?}", c);
|
assert!((c - center).length() < 5.0, "Zentrum weicht ab: {:?}", c);
|
||||||
assert!((r - 54.0).abs() < 5.0, "Radius weicht ab: {}", r);
|
assert!((r - 54.0).abs() < 5.0, "Radius weicht ab: {}", r);
|
||||||
} else {
|
} else {
|
||||||
@@ -1431,14 +1543,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detected = detect_coordinate_system(&points);
|
let detected = detect_coordinate_system(&points);
|
||||||
assert!(detected.is_some(), "Koordinatensystem sollte erkannt werden");
|
assert!(
|
||||||
|
detected.is_some(),
|
||||||
|
"Koordinatensystem sollte erkannt werden"
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(RecognizedShape::CoordinateSystem { origin: o, x_axis_end, y_axis_end, .. }) = detected {
|
if let Some(RecognizedShape::CoordinateSystem {
|
||||||
|
origin: o,
|
||||||
|
x_axis_end,
|
||||||
|
y_axis_end,
|
||||||
|
..
|
||||||
|
}) = detected
|
||||||
|
{
|
||||||
assert!((o - origin).length() < 2.0, "Ursprung weicht ab");
|
assert!((o - origin).length() < 2.0, "Ursprung weicht ab");
|
||||||
let v_x = (x_axis_end - o).normalize();
|
let v_x = (x_axis_end - o).normalize();
|
||||||
let v_y = (y_axis_end - o).normalize();
|
let v_y = (y_axis_end - o).normalize();
|
||||||
let dot = v_x.dot(v_y).abs();
|
let dot = v_x.dot(v_y).abs();
|
||||||
assert!(dot < 1e-4, "Achsen sind nach Orthogonalitäts-Snap nicht exakt 90°: dot={}", dot);
|
assert!(
|
||||||
|
dot < 1e-4,
|
||||||
|
"Achsen sind nach Orthogonalitäts-Snap nicht exakt 90°: dot={}",
|
||||||
|
dot
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
panic!("Falscher Formtyp erkannt");
|
panic!("Falscher Formtyp erkannt");
|
||||||
}
|
}
|
||||||
@@ -1476,7 +1601,10 @@ mod tests {
|
|||||||
let ab = (corners[1] - corners[0]).length();
|
let ab = (corners[1] - corners[0]).length();
|
||||||
let bc = (corners[2] - corners[1]).length();
|
let bc = (corners[2] - corners[1]).length();
|
||||||
let ca = (corners[0] - corners[2]).length();
|
let ca = (corners[0] - corners[2]).length();
|
||||||
assert!((ab - bc).abs() < 5.0, "Gleichseitiges Dreieck sollte gesnappt sein");
|
assert!(
|
||||||
|
(ab - bc).abs() < 5.0,
|
||||||
|
"Gleichseitiges Dreieck sollte gesnappt sein"
|
||||||
|
);
|
||||||
assert!((bc - ca).abs() < 5.0);
|
assert!((bc - ca).abs() < 5.0);
|
||||||
} else {
|
} else {
|
||||||
panic!("Falscher Formtyp erkannt");
|
panic!("Falscher Formtyp erkannt");
|
||||||
@@ -1490,7 +1618,7 @@ mod tests {
|
|||||||
let c0 = Vec2::new(200.0, 100.0); // Oben
|
let c0 = Vec2::new(200.0, 100.0); // Oben
|
||||||
let c1 = Vec2::new(320.0, 250.0); // Rechts
|
let c1 = Vec2::new(320.0, 250.0); // Rechts
|
||||||
let c2 = Vec2::new(200.0, 400.0); // Unten
|
let c2 = Vec2::new(200.0, 400.0); // Unten
|
||||||
let c3 = Vec2::new(80.0, 250.0); // Links
|
let c3 = Vec2::new(80.0, 250.0); // Links
|
||||||
|
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
let t = i as f32 / 20.0;
|
let t = i as f32 / 20.0;
|
||||||
@@ -1520,7 +1648,10 @@ mod tests {
|
|||||||
let l1 = (corners[2] - corners[1]).length();
|
let l1 = (corners[2] - corners[1]).length();
|
||||||
let l2 = (corners[3] - corners[2]).length();
|
let l2 = (corners[3] - corners[2]).length();
|
||||||
let l3 = (corners[0] - corners[3]).length();
|
let l3 = (corners[0] - corners[3]).length();
|
||||||
assert!((l0 - l1).abs() < 1e-3, "Alle 4 Seiten einer Raute müssen exakt gleich lang sein");
|
assert!(
|
||||||
|
(l0 - l1).abs() < 1e-3,
|
||||||
|
"Alle 4 Seiten einer Raute müssen exakt gleich lang sein"
|
||||||
|
);
|
||||||
assert!((l1 - l2).abs() < 1e-3);
|
assert!((l1 - l2).abs() < 1e-3);
|
||||||
assert!((l2 - l3).abs() < 1e-3);
|
assert!((l2 - l3).abs() < 1e-3);
|
||||||
} else {
|
} else {
|
||||||
@@ -1549,7 +1680,10 @@ mod tests {
|
|||||||
points.push(points[0]);
|
points.push(points[0]);
|
||||||
|
|
||||||
let detected = detect_polygon(&points);
|
let detected = detect_polygon(&points);
|
||||||
assert!(detected.is_some(), "Oktagon / Achteck sollte erkannt werden");
|
assert!(
|
||||||
|
detected.is_some(),
|
||||||
|
"Oktagon / Achteck sollte erkannt werden"
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(RecognizedShape::Polygon { vertices }) = detected {
|
if let Some(RecognizedShape::Polygon { vertices }) = detected {
|
||||||
assert_eq!(vertices.len(), 8, "Achteck muss 8 Vertizes haben");
|
assert_eq!(vertices.len(), 8, "Achteck muss 8 Vertizes haben");
|
||||||
@@ -1587,7 +1721,13 @@ mod tests {
|
|||||||
let detected = detect_octahedron(&points);
|
let detected = detect_octahedron(&points);
|
||||||
assert!(detected.is_some(), "Oktaeder sollte erkannt werden");
|
assert!(detected.is_some(), "Oktaeder sollte erkannt werden");
|
||||||
|
|
||||||
if let Some(RecognizedShape::Octahedron { center, radius_x, radius_y, depth }) = detected {
|
if let Some(RecognizedShape::Octahedron {
|
||||||
|
center,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
depth,
|
||||||
|
}) = detected
|
||||||
|
{
|
||||||
assert!((center.x - 200.0).abs() < 5.0);
|
assert!((center.x - 200.0).abs() < 5.0);
|
||||||
assert!((center.y - 200.0).abs() < 5.0);
|
assert!((center.y - 200.0).abs() < 5.0);
|
||||||
assert!(radius_x > 100.0);
|
assert!(radius_x > 100.0);
|
||||||
|
|||||||
+8
-4
@@ -83,7 +83,8 @@ impl HistoryStack {
|
|||||||
if let Some(pos) = strokes.iter().position(|s| s.id == new.id) {
|
if let Some(pos) = strokes.iter().position(|s| s.id == new.id) {
|
||||||
strokes[pos] = old.clone();
|
strokes[pos] = old.clone();
|
||||||
}
|
}
|
||||||
self.redo_stack.push(HistoryAction::ReplaceStroke { old, new });
|
self.redo_stack
|
||||||
|
.push(HistoryAction::ReplaceStroke { old, new });
|
||||||
}
|
}
|
||||||
HistoryAction::ModifyStrokes { before, after } => {
|
HistoryAction::ModifyStrokes { before, after } => {
|
||||||
for b in &before {
|
for b in &before {
|
||||||
@@ -91,7 +92,8 @@ impl HistoryStack {
|
|||||||
strokes[pos] = b.clone();
|
strokes[pos] = b.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.redo_stack.push(HistoryAction::ModifyStrokes { before, after });
|
self.redo_stack
|
||||||
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +128,8 @@ impl HistoryStack {
|
|||||||
if let Some(pos) = strokes.iter().position(|s| s.id == old.id) {
|
if let Some(pos) = strokes.iter().position(|s| s.id == old.id) {
|
||||||
strokes[pos] = new.clone();
|
strokes[pos] = new.clone();
|
||||||
}
|
}
|
||||||
self.undo_stack.push(HistoryAction::ReplaceStroke { old, new });
|
self.undo_stack
|
||||||
|
.push(HistoryAction::ReplaceStroke { old, new });
|
||||||
}
|
}
|
||||||
HistoryAction::ModifyStrokes { before, after } => {
|
HistoryAction::ModifyStrokes { before, after } => {
|
||||||
for a in &after {
|
for a in &after {
|
||||||
@@ -134,7 +137,8 @@ impl HistoryStack {
|
|||||||
strokes[pos] = a.clone();
|
strokes[pos] = a.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.undo_stack.push(HistoryAction::ModifyStrokes { before, after });
|
self.undo_stack
|
||||||
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|||||||
+1096
-359
File diff suppressed because it is too large.
Load diff
+58
-25
@@ -2,8 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
|
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
|
||||||
|
|
||||||
use std::f32::consts::FRAC_PI_2;
|
|
||||||
use glam::Vec2;
|
use glam::Vec2;
|
||||||
|
use std::f32::consts::FRAC_PI_2;
|
||||||
|
|
||||||
use crate::canvas::CanvasState;
|
use crate::canvas::CanvasState;
|
||||||
use crate::document::{Aabb, Page, Stroke};
|
use crate::document::{Aabb, Page, Stroke};
|
||||||
@@ -115,12 +115,15 @@ impl SelectionState {
|
|||||||
Page::selection_aabb(&strokes)
|
Page::selection_aabb(&strokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||||
|
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||||
|
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||||
pub fn hit_test_handles(
|
pub fn hit_test_handles(
|
||||||
&self,
|
&self,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
canvas: &CanvasState,
|
canvas: &CanvasState,
|
||||||
screen_pos: Vec2,
|
screen_pos: Vec2,
|
||||||
|
pixels_per_point: f32,
|
||||||
) -> SelectionHandleHit {
|
) -> SelectionHandleHit {
|
||||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||||
return SelectionHandleHit::None;
|
return SelectionHandleHit::None;
|
||||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
|||||||
let screen_center = (screen_min + screen_max) * 0.5;
|
let screen_center = (screen_min + screen_max) * 0.5;
|
||||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||||
|
|
||||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
let ppp = pixels_per_point;
|
||||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||||
|
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::RotateHandle {
|
return SelectionHandleHit::RotateHandle {
|
||||||
center_page: page_center,
|
center_page: page_center,
|
||||||
};
|
};
|
||||||
@@ -144,16 +148,16 @@ impl SelectionState {
|
|||||||
|
|
||||||
// 2. 4 Eck-Skalierungsgriffe testen
|
// 2. 4 Eck-Skalierungsgriffe testen
|
||||||
let corners_page = [
|
let corners_page = [
|
||||||
aabb.min, // 0: oben-links
|
aabb.min, // 0: oben-links
|
||||||
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
|
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
|
||||||
aabb.max, // 2: unten-rechts
|
aabb.max, // 2: unten-rechts
|
||||||
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
|
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
|
||||||
];
|
];
|
||||||
let fixed_corners_page = [
|
let fixed_corners_page = [
|
||||||
aabb.max, // gegenüber 0 -> unten-rechts
|
aabb.max, // gegenüber 0 -> unten-rechts
|
||||||
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
|
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
|
||||||
aabb.min, // gegenüber 2 -> oben-links
|
aabb.min, // gegenüber 2 -> oben-links
|
||||||
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
|
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
|
||||||
];
|
];
|
||||||
|
|
||||||
let corners_screen = [
|
let corners_screen = [
|
||||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::CornerHandle {
|
return SelectionHandleHit::CornerHandle {
|
||||||
corner_idx: i,
|
corner_idx: i,
|
||||||
fixed_corner_page: fixed_corners_page[i],
|
fixed_corner_page: fixed_corners_page[i],
|
||||||
@@ -273,6 +277,16 @@ impl SelectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||||
|
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||||
|
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||||
|
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||||
|
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||||
|
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||||
|
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_selection_overlay(
|
pub fn render_selection_overlay(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
selection: &SelectionState,
|
selection: &SelectionState,
|
||||||
@@ -283,11 +297,17 @@ pub fn render_selection_overlay(
|
|||||||
egui::Order::Foreground,
|
egui::Order::Foreground,
|
||||||
egui::Id::new("selection_overlay_layer"),
|
egui::Id::new("selection_overlay_layer"),
|
||||||
));
|
));
|
||||||
|
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||||
|
let ppp = ctx.pixels_per_point();
|
||||||
|
|
||||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||||
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
|
if let DragMode::BoxSelect {
|
||||||
let p1 = canvas.page_to_screen(start_page);
|
start_page,
|
||||||
let p2 = canvas.page_to_screen(current_page);
|
current_page,
|
||||||
|
} = selection.drag_mode
|
||||||
|
{
|
||||||
|
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_x = p1[0].min(p2[0]);
|
||||||
let min_y = p1[1].min(p2[1]);
|
let min_y = p1[1].min(p2[1]);
|
||||||
let max_x = p1[0].max(p2[0]);
|
let max_x = p1[0].max(p2[0]);
|
||||||
@@ -299,12 +319,15 @@ pub fn render_selection_overlay(
|
|||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||||
);
|
);
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)),
|
egui::Stroke::new(
|
||||||
|
1.5_f32,
|
||||||
|
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,8 +337,8 @@ pub fn render_selection_overlay(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let p_min = canvas.page_to_screen(aabb.min);
|
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||||
let p_max = canvas.page_to_screen(aabb.max);
|
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||||
|
|
||||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||||
@@ -331,7 +354,7 @@ pub fn render_selection_overlay(
|
|||||||
|
|
||||||
// Dreh-Stängel (Stalk) und Drehgriff
|
// Dreh-Stängel (Stalk) und Drehgriff
|
||||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[top_center, rot_handle],
|
[top_center, rot_handle],
|
||||||
@@ -357,7 +380,11 @@ pub fn render_selection_overlay(
|
|||||||
|
|
||||||
for c in corners {
|
for c in corners {
|
||||||
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
|
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
|
||||||
painter.rect_filled(handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE);
|
painter.rect_filled(
|
||||||
|
handle_rect,
|
||||||
|
egui::Rounding::same(1.5_f32),
|
||||||
|
egui::Color32::WHITE,
|
||||||
|
);
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
handle_rect,
|
handle_rect,
|
||||||
egui::Rounding::same(1.5_f32),
|
egui::Rounding::same(1.5_f32),
|
||||||
@@ -369,7 +396,11 @@ pub fn render_selection_overlay(
|
|||||||
let badge_text = format!(
|
let badge_text = format!(
|
||||||
"{} {} | [R] Drehen [Entf] Löschen",
|
"{} {} | [R] Drehen [Entf] Löschen",
|
||||||
selection.count(),
|
selection.count(),
|
||||||
if selection.count() == 1 { "Objekt" } else { "Objekte" }
|
if selection.count() == 1 {
|
||||||
|
"Objekt"
|
||||||
|
} else {
|
||||||
|
"Objekte"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
|
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
|
||||||
painter.text(
|
painter.text(
|
||||||
@@ -459,7 +490,9 @@ mod tests {
|
|||||||
let orig_pos = page.strokes[0].points[0].position;
|
let orig_pos = page.strokes[0].points[0].position;
|
||||||
assert!(sel.rotate_selected_90_deg(&mut page));
|
assert!(sel.rotate_selected_90_deg(&mut page));
|
||||||
let rotated_pos = page.strokes[0].points[0].position;
|
let rotated_pos = page.strokes[0].points[0].position;
|
||||||
assert!((orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1);
|
assert!(
|
||||||
|
(orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1
|
||||||
|
);
|
||||||
|
|
||||||
// Undo rotate
|
// Undo rotate
|
||||||
assert!(page.undo());
|
assert!(page.undo());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-16
@@ -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).
|
/// 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 {
|
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
|
||||||
let p = pressure.clamp(0.0, 1.0);
|
let p = pressure.clamp(0.0, 1.0);
|
||||||
@@ -355,11 +367,11 @@ fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, ke
|
|||||||
let mut max_dist = 0.0_f32;
|
let mut max_dist = 0.0_f32;
|
||||||
let mut max_idx = start;
|
let mut max_idx = start;
|
||||||
|
|
||||||
for i in (start + 1)..end {
|
for (offset, &pos) in positions[(start + 1)..end].iter().enumerate() {
|
||||||
let dist = point_to_segment_distance(positions[i], a, b);
|
let dist = point_to_segment_distance(pos, a, b);
|
||||||
if dist > max_dist {
|
if dist > max_dist {
|
||||||
max_dist = dist;
|
max_dist = dist;
|
||||||
max_idx = i;
|
max_idx = start + 1 + offset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,10 +430,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab.
|
/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab.
|
||||||
fn sample_smooth_run(
|
fn sample_smooth_run(control_points: &[ControlPoint], config: &SplineConfig) -> Vec<SplineSample> {
|
||||||
control_points: &[ControlPoint],
|
|
||||||
config: &SplineConfig,
|
|
||||||
) -> Vec<SplineSample> {
|
|
||||||
match control_points.len() {
|
match control_points.len() {
|
||||||
0 => Vec::new(),
|
0 => Vec::new(),
|
||||||
1 => {
|
1 => {
|
||||||
@@ -621,7 +630,12 @@ pub fn generate_finalized_triangle_strip(
|
|||||||
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
||||||
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
||||||
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
||||||
finalize_stroke_with_config(0, raw_samples, [0.0, 0.0, 0.0, 1.0], &SplineConfig::default())
|
finalize_stroke_with_config(
|
||||||
|
0,
|
||||||
|
raw_samples,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
&SplineConfig::default(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig.
|
/// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig.
|
||||||
@@ -651,7 +665,10 @@ pub fn finalize_stroke_with_config(
|
|||||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||||
.collect();
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -808,9 +825,10 @@ mod tests {
|
|||||||
assert!(!stroke.vertices.is_empty());
|
assert!(!stroke.vertices.is_empty());
|
||||||
|
|
||||||
// 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben
|
// 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben
|
||||||
let corner_found = stroke.points.iter().any(|s| {
|
let corner_found = stroke
|
||||||
(s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3
|
.points
|
||||||
});
|
.iter()
|
||||||
|
.any(|s| (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3);
|
||||||
assert!(
|
assert!(
|
||||||
corner_found,
|
corner_found,
|
||||||
"Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!"
|
"Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!"
|
||||||
@@ -837,7 +855,11 @@ mod tests {
|
|||||||
|
|
||||||
// 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad)
|
// 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad)
|
||||||
let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG);
|
let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG);
|
||||||
assert_eq!(corners.len(), 1, "Exakt eine 90-Grad-Ecke muss erkannt werden");
|
assert_eq!(
|
||||||
|
corners.len(),
|
||||||
|
1,
|
||||||
|
"Exakt eine 90-Grad-Ecke muss erkannt werden"
|
||||||
|
);
|
||||||
let corner_idx = corners[0];
|
let corner_idx = corners[0];
|
||||||
let corner_pt = stroke.points[corner_idx];
|
let corner_pt = stroke.points[corner_idx];
|
||||||
assert!((corner_pt.position.x - 100.0).abs() < 1e-3);
|
assert!((corner_pt.position.x - 100.0).abs() < 1e-3);
|
||||||
@@ -847,9 +869,21 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_pressure_smoothing() {
|
fn test_pressure_smoothing() {
|
||||||
let samples = vec![
|
let samples = vec![
|
||||||
StylusSample { position: glam::Vec2::new(0.0, 0.0), pressure: 0.2, timestamp: 0.0 },
|
StylusSample {
|
||||||
StylusSample { position: glam::Vec2::new(1.0, 0.0), pressure: 0.8, timestamp: 0.1 },
|
position: glam::Vec2::new(0.0, 0.0),
|
||||||
StylusSample { position: glam::Vec2::new(2.0, 0.0), pressure: 0.2, timestamp: 0.2 },
|
pressure: 0.2,
|
||||||
|
timestamp: 0.0,
|
||||||
|
},
|
||||||
|
StylusSample {
|
||||||
|
position: glam::Vec2::new(1.0, 0.0),
|
||||||
|
pressure: 0.8,
|
||||||
|
timestamp: 0.1,
|
||||||
|
},
|
||||||
|
StylusSample {
|
||||||
|
position: glam::Vec2::new(2.0, 0.0),
|
||||||
|
pressure: 0.2,
|
||||||
|
timestamp: 0.2,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let smoothed = smooth_pressure(&samples);
|
let smoothed = smooth_pressure(&samples);
|
||||||
|
|||||||
+1524
-105
File diff suppressed because it is too large.
Load diff
+874
-163
File diff suppressed because it is too large.
Load diff
+44
-6
@@ -3,8 +3,8 @@
|
|||||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||||
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
||||||
|
|
||||||
use glam::{Mat4, Vec2, Vec4};
|
|
||||||
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
|
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
|
||||||
|
use glam::{Mat4, Vec2, Vec4};
|
||||||
|
|
||||||
/// Minimaler und maximaler Zoomfaktor.
|
/// Minimaler und maximaler Zoomfaktor.
|
||||||
pub const MIN_ZOOM: f32 = 0.25;
|
pub const MIN_ZOOM: f32 = 0.25;
|
||||||
@@ -40,7 +40,12 @@ impl ViewportState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
|
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
|
||||||
pub fn pan_by_screen_delta(&mut self, delta_x: f32, delta_y: f32, page_viewport: &PageViewport) {
|
pub fn pan_by_screen_delta(
|
||||||
|
&mut self,
|
||||||
|
delta_x: f32,
|
||||||
|
delta_y: f32,
|
||||||
|
page_viewport: &PageViewport,
|
||||||
|
) {
|
||||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
||||||
if base_scale * self.zoom > 1e-6 {
|
if base_scale * self.zoom > 1e-6 {
|
||||||
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
|
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
|
||||||
@@ -122,10 +127,7 @@ impl ViewportState {
|
|||||||
page_viewport: &PageViewport,
|
page_viewport: &PageViewport,
|
||||||
) -> Vec2 {
|
) -> Vec2 {
|
||||||
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
||||||
Vec2::new(
|
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
|
||||||
pt.x.clamp(0.0, PAGE_WIDTH),
|
|
||||||
pt.y.clamp(0.0, PAGE_HEIGHT),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
|
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
|
||||||
@@ -244,4 +246,40 @@ mod tests {
|
|||||||
assert!((offset1[0] - offset2[0]).abs() < 1e-5);
|
assert!((offset1[0] - offset2[0]).abs() < 1e-5);
|
||||||
assert!((offset1[1] - offset2[1]).abs() < 1e-5);
|
assert!((offset1[1] - offset2[1]).abs() < 1e-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pinch_to_zoom_math() {
|
||||||
|
let mut vp = ViewportState::new();
|
||||||
|
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
|
||||||
|
|
||||||
|
// Simulation von 2 Fingern:
|
||||||
|
// Finger 1 bei (400, 500), Finger 2 bei (600, 500) -> Distanz: 200, Zentrum: (500, 500)
|
||||||
|
let p1_start = (400.0_f32, 500.0_f32);
|
||||||
|
let p2_start = (600.0_f32, 500.0_f32);
|
||||||
|
let dist_start =
|
||||||
|
((p2_start.0 - p1_start.0).powi(2) + (p2_start.1 - p1_start.1).powi(2)).sqrt();
|
||||||
|
let center_start = (
|
||||||
|
(p1_start.0 + p2_start.0) * 0.5,
|
||||||
|
(p1_start.1 + p2_start.1) * 0.5,
|
||||||
|
);
|
||||||
|
assert_eq!(dist_start, 200.0);
|
||||||
|
assert_eq!(center_start, (500.0, 500.0));
|
||||||
|
|
||||||
|
// Finger auseinanderziehen und leicht verschieben:
|
||||||
|
// Finger 1 bei (350, 520), Finger 2 bei (650, 520) -> Distanz: 300, Zentrum: (500, 520)
|
||||||
|
let p1_end = (350.0_f32, 520.0_f32);
|
||||||
|
let p2_end = (650.0_f32, 520.0_f32);
|
||||||
|
let dist_end = ((p2_end.0 - p1_end.0).powi(2) + (p2_end.1 - p1_end.1).powi(2)).sqrt();
|
||||||
|
let center_end = ((p1_end.0 + p2_end.0) * 0.5, (p1_end.1 + p2_end.1) * 0.5);
|
||||||
|
assert_eq!(dist_end, 300.0);
|
||||||
|
assert_eq!(center_end, (500.0, 520.0));
|
||||||
|
|
||||||
|
let zoom_factor = dist_end / dist_start; // 1.5x Vergrößerung
|
||||||
|
let pan_delta = (center_end.0 - center_start.0, center_end.1 - center_start.1); // (0, +20)
|
||||||
|
|
||||||
|
vp.pan_by_screen_delta(pan_delta.0, pan_delta.1, &page_vp);
|
||||||
|
vp.zoom_at_cursor([center_end.0, center_end.1], zoom_factor, &page_vp);
|
||||||
|
|
||||||
|
assert!((vp.zoom - 1.5).abs() < 1e-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+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