fix(updater): Sichere In-App Updates mit ureq, SHA256-Integrität und atomarem AppImage-Tausch
This commit is contained in:
Generated
+211
-1
@@ -118,6 +118,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -151,6 +157,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "block-sys"
|
||||
version = "0.2.1"
|
||||
@@ -375,6 +390,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -390,6 +414,16 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -403,7 +437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -413,6 +447,16 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -644,6 +688,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -654,6 +708,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -958,9 +1023,13 @@ dependencies = [
|
||||
"glam",
|
||||
"pollster",
|
||||
"printpdf",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"url",
|
||||
"webbrowser",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1609,6 +1678,20 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -1647,6 +1730,41 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1687,6 +1805,12 @@ dependencies = [
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -1730,6 +1854,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -1830,6 +1965,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -2041,6 +2182,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -2065,6 +2212,30 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -2099,6 +2270,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -2310,6 +2487,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -2503,6 +2698,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -2895,6 +3099,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -29,6 +29,10 @@ egui = "0.27"
|
||||
egui-wgpu = "0.27"
|
||||
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
|
||||
webbrowser = "0.8"
|
||||
semver = "1.0"
|
||||
sha2 = "0.10"
|
||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||
url = "2.5"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
|
||||
+7
-2
@@ -165,13 +165,18 @@ impl AutosaveController {
|
||||
}
|
||||
AutosaveResponse::ManifestSaved { result } => {
|
||||
if let Err(e) = result {
|
||||
eprintln!("[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}");
|
||||
eprintln!(
|
||||
"[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}"
|
||||
);
|
||||
self.status = AutosaveStatus::Error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.in_flight_count == 0 && self.pending_pages.is_empty() && self.status != AutosaveStatus::Error {
|
||||
if self.in_flight_count == 0
|
||||
&& self.pending_pages.is_empty()
|
||||
&& self.status != AutosaveStatus::Error
|
||||
{
|
||||
self.status = AutosaveStatus::Saved;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-9
@@ -39,13 +39,7 @@ pub fn generate_paper_quad() -> Vec<InkVertex> {
|
||||
|
||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||
#[inline]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
) {
|
||||
fn push_quad(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_max, y_min, 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 {
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
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;
|
||||
}
|
||||
y += GRID_5MM_SPACING;
|
||||
@@ -132,7 +132,11 @@ mod tests {
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
||||
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);
|
||||
assert!(!lined.is_empty());
|
||||
|
||||
+35
-23
@@ -11,9 +11,7 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{
|
||||
generate_background_mesh, generate_paper_quad, DESK_COLOR,
|
||||
};
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -143,7 +141,8 @@ impl CanvasState {
|
||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||
pub async fn new(window: Arc<Window>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
@@ -187,7 +186,10 @@ impl CanvasState {
|
||||
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
||||
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
||||
wgpu::PresentMode::Fifo
|
||||
} else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
||||
} else if surface_caps
|
||||
.present_modes
|
||||
.contains(&wgpu::PresentMode::Mailbox)
|
||||
{
|
||||
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
@@ -358,12 +360,7 @@ impl CanvasState {
|
||||
});
|
||||
|
||||
// 8. egui Renderer für das schwebende UI initialisieren
|
||||
let egui_renderer = egui_wgpu::Renderer::new(
|
||||
&device,
|
||||
config.format,
|
||||
None,
|
||||
1,
|
||||
);
|
||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
@@ -410,7 +407,11 @@ impl CanvasState {
|
||||
transform_scale: scale,
|
||||
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]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||
@@ -433,13 +434,15 @@ impl CanvasState {
|
||||
|
||||
/// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta.
|
||||
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();
|
||||
}
|
||||
|
||||
/// Zoomt zentriert auf eine Cursor-Bildschirmposition.
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -451,12 +454,14 @@ impl CanvasState {
|
||||
|
||||
/// Transformiert Fenster-Pixel in Seitenkoordinaten.
|
||||
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.
|
||||
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.
|
||||
@@ -471,7 +476,8 @@ impl CanvasState {
|
||||
self.live_vertex_count = self.live_vertices.len() as u32;
|
||||
|
||||
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 {
|
||||
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
||||
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -522,7 +528,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
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 +546,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
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 {
|
||||
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
|
||||
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -655,7 +663,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
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
|
||||
.into_iter()
|
||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||
@@ -667,7 +676,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
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 {
|
||||
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
||||
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -694,7 +704,8 @@ impl CanvasState {
|
||||
) -> Result<(), wgpu::SurfaceError> {
|
||||
// Vor dem Renderpass: egui-Texturen aktualisieren
|
||||
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()?;
|
||||
@@ -784,7 +795,8 @@ impl CanvasState {
|
||||
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()));
|
||||
|
||||
+75
-21
@@ -115,12 +115,20 @@ pub struct 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 pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
|
||||
Aabb::from_points(&pts)
|
||||
} 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)
|
||||
} else {
|
||||
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
|
||||
@@ -171,7 +179,12 @@ impl Stroke {
|
||||
}
|
||||
|
||||
/// 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?
|
||||
let total_margin = eraser_radius + max_stroke_radius;
|
||||
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
|
||||
@@ -254,9 +267,7 @@ impl Stroke {
|
||||
|
||||
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
|
||||
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
|
||||
let sc = |pos: glam::Vec2| -> glam::Vec2 {
|
||||
origin + (pos - origin) * factors
|
||||
};
|
||||
let sc = |pos: glam::Vec2| -> glam::Vec2 { origin + (pos - origin) * factors };
|
||||
|
||||
for pt in &mut self.points {
|
||||
pt.position = sc(pt.position);
|
||||
@@ -402,7 +413,12 @@ impl Page {
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
|
||||
///
|
||||
/// 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();
|
||||
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
@@ -422,7 +438,8 @@ impl Page {
|
||||
}
|
||||
|
||||
removed.reverse();
|
||||
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.history
|
||||
.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.is_dirty = true;
|
||||
|
||||
removed
|
||||
@@ -473,7 +490,11 @@ impl Page {
|
||||
|
||||
/// Liefert Klone der Striche mit den angegebenen IDs.
|
||||
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.
|
||||
@@ -482,7 +503,12 @@ impl Page {
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
||||
@@ -517,7 +543,8 @@ impl Page {
|
||||
self.strokes[pos] = m.clone();
|
||||
}
|
||||
}
|
||||
self.history.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.history
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.is_dirty = true;
|
||||
}
|
||||
|
||||
@@ -534,7 +561,8 @@ impl Page {
|
||||
});
|
||||
|
||||
if !removed.is_empty() {
|
||||
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.history
|
||||
.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.is_dirty = true;
|
||||
}
|
||||
removed
|
||||
@@ -545,7 +573,8 @@ impl Page {
|
||||
if new_strokes.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.history.push(HistoryAction::AddStrokes(new_strokes.clone()));
|
||||
self.history
|
||||
.push(HistoryAction::AddStrokes(new_strokes.clone()));
|
||||
self.strokes.extend(new_strokes);
|
||||
self.is_dirty = true;
|
||||
}
|
||||
@@ -653,7 +682,11 @@ impl Notebook {
|
||||
|
||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,13 +820,25 @@ mod tests {
|
||||
let stroke_radius = 2.0;
|
||||
|
||||
// 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)
|
||||
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
|
||||
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]
|
||||
@@ -966,9 +1011,12 @@ mod tests {
|
||||
let mut moved = s.clone();
|
||||
moved.translate(glam::Vec2::new(20.0, 30.0));
|
||||
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
|
||||
assert_eq!(page.history.can_redo(), false);
|
||||
assert!(!page.history.can_redo());
|
||||
|
||||
// Transactional commit (mouse release)
|
||||
let before = vec![s.clone()];
|
||||
@@ -977,10 +1025,16 @@ mod tests {
|
||||
|
||||
// Verify Undo restores original position
|
||||
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
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-25
@@ -78,12 +78,8 @@ pub fn generate_pdf_document(
|
||||
|
||||
// 1. Erstes Dokument mit Seite 1 erstellen
|
||||
let title = "ink-notes Notebook";
|
||||
let (doc, page1_idx, layer1_bg) = PdfDocument::new(
|
||||
title,
|
||||
Mm(A4_WIDTH_MM),
|
||||
Mm(A4_HEIGHT_MM),
|
||||
"Background",
|
||||
);
|
||||
let (doc, page1_idx, layer1_bg) =
|
||||
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||
|
||||
// Seite 1 befüllen
|
||||
let page1_ref = doc.get_page(page1_idx);
|
||||
@@ -95,11 +91,7 @@ pub fn generate_pdf_document(
|
||||
|
||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||
for page in ¬ebook.pages[1..] {
|
||||
let (page_idx, layer_bg) = doc.add_page(
|
||||
Mm(A4_WIDTH_MM),
|
||||
Mm(A4_HEIGHT_MM),
|
||||
"Background",
|
||||
);
|
||||
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||
let page_ref = doc.get_page(page_idx);
|
||||
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
||||
render_background_layer(&layer_bg_ref, page.background);
|
||||
@@ -112,10 +104,7 @@ pub fn generate_pdf_document(
|
||||
}
|
||||
|
||||
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
||||
fn render_background_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
background: PageBackground,
|
||||
) {
|
||||
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) {
|
||||
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
|
||||
layer.set_outline_color(grid_color.clone());
|
||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||
@@ -203,11 +192,7 @@ fn render_background_layer(
|
||||
}
|
||||
|
||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||
fn render_ink_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
strategy: ExportStrategy,
|
||||
) {
|
||||
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
||||
for stroke in &page.strokes {
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
@@ -326,7 +311,12 @@ fn render_recognized_shape(
|
||||
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);
|
||||
for (p1, p2) in edges {
|
||||
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
|
||||
@@ -514,7 +504,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
|
||||
|
||||
// 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 thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
|
||||
layer.set_outline_thickness(thickness_pt);
|
||||
@@ -693,11 +684,18 @@ mod tests {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
||||
|
||||
let export_res = export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
||||
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
|
||||
let export_res =
|
||||
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
|
||||
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();
|
||||
assert!(
|
||||
metadata.len() > 1024,
|
||||
|
||||
+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
|
||||
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
|
||||
assert_eq!(x4, 50.0, "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");
|
||||
assert_eq!(
|
||||
x4, 50.0,
|
||||
"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]
|
||||
@@ -344,9 +353,18 @@ mod tests {
|
||||
|
||||
// 2. Vergiftungsversuch mit NaN und Infinity
|
||||
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!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben");
|
||||
assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben");
|
||||
assert!(
|
||||
x_nan.is_finite(),
|
||||
"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);
|
||||
assert!(x_nan2.is_finite());
|
||||
@@ -364,6 +382,9 @@ mod tests {
|
||||
assert!(x3.is_finite());
|
||||
assert!(y3.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+213
-76
@@ -3,9 +3,9 @@
|
||||
//! Erkennt Geraden, Rechtecke, Kreise und Koordinatensysteme (Achsen)
|
||||
//! und erzeugt saubere Vektor-Primitiven und GPU-Triangle-Meshes.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
use glam::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use crate::canvas::InkVertex;
|
||||
use crate::document::{Aabb, Stroke, StylusSample};
|
||||
@@ -117,7 +117,12 @@ impl RecognizedShape {
|
||||
*c += delta;
|
||||
}
|
||||
}
|
||||
Self::CoordinateSystem { origin, x_axis_end, y_axis_end, .. } => {
|
||||
Self::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
..
|
||||
} => {
|
||||
*origin += delta;
|
||||
*x_axis_end += delta;
|
||||
*y_axis_end += delta;
|
||||
@@ -159,7 +164,12 @@ impl RecognizedShape {
|
||||
*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);
|
||||
*x_axis_end = rot(*x_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`.
|
||||
pub fn scale(&mut self, origin: Vec2, factors: Vec2) {
|
||||
let sc = |pt: Vec2| -> Vec2 {
|
||||
origin + (pt - origin) * factors
|
||||
};
|
||||
let sc = |pt: Vec2| -> Vec2 { origin + (pt - origin) * factors };
|
||||
|
||||
match self {
|
||||
Self::Line { start, end } => {
|
||||
@@ -200,7 +208,12 @@ impl RecognizedShape {
|
||||
*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);
|
||||
*x_axis_end = sc(*x_axis_end);
|
||||
*y_axis_end = sc(*y_axis_end);
|
||||
@@ -210,7 +223,12 @@ impl RecognizedShape {
|
||||
*v = sc(*v);
|
||||
}
|
||||
}
|
||||
Self::Octahedron { center, radius_x, radius_y, depth } => {
|
||||
Self::Octahedron {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
depth,
|
||||
} => {
|
||||
*center = sc(*center);
|
||||
*radius_x *= factors.x.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 simplified = ramer_douglas_peucker(points, epsilon);
|
||||
|
||||
let corners: Vec<Vec2> = if simplified.len() == 5
|
||||
&& (simplified[0] - simplified[4]).length() < epsilon * 2.5
|
||||
{
|
||||
simplified[0..4].to_vec()
|
||||
} else if simplified.len() == 4 {
|
||||
simplified
|
||||
} else if simplified.len() > 4 {
|
||||
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
|
||||
extract_k_corners(&simplified, 4)?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let corners: Vec<Vec2> =
|
||||
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
||||
simplified[0..4].to_vec()
|
||||
} else if simplified.len() == 4 {
|
||||
simplified
|
||||
} else if simplified.len() > 4 {
|
||||
// Suche die 4 markantesten Ecken (stärkste Richtungsänderung)
|
||||
extract_k_corners(&simplified, 4)?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if corners.len() != 4 {
|
||||
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();
|
||||
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.
|
||||
@@ -561,7 +583,8 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
let epsilon = (total_len * 0.07).clamp(8.0, 50.0);
|
||||
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[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 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 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] });
|
||||
let c1 = center
|
||||
+ Vec2::new(
|
||||
(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°)
|
||||
@@ -639,7 +672,11 @@ pub fn detect_triangle(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
let len2 = v2.length();
|
||||
|
||||
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;
|
||||
|
||||
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 new_a = 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 {
|
||||
let mid_ab = (a + b) * 0.5;
|
||||
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 new_a = 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] })
|
||||
@@ -701,15 +742,16 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
let epsilon = (total_len * 0.05).clamp(8.0, 45.0);
|
||||
let simplified = ramer_douglas_peucker(points, epsilon);
|
||||
|
||||
let corners: Vec<Vec2> = if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
||||
simplified[0..4].to_vec()
|
||||
} else if simplified.len() == 4 {
|
||||
simplified
|
||||
} else if simplified.len() > 4 {
|
||||
extract_k_corners(&simplified, 4)?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let corners: Vec<Vec2> =
|
||||
if simplified.len() == 5 && (simplified[0] - simplified[4]).length() < epsilon * 2.5 {
|
||||
simplified[0..4].to_vec()
|
||||
} else if simplified.len() == 4 {
|
||||
simplified
|
||||
} else if simplified.len() > 4 {
|
||||
extract_k_corners(&simplified, 4)?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if corners.len() != 4 {
|
||||
return None;
|
||||
@@ -756,7 +798,11 @@ pub fn detect_rhombus(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
let snapped_a1 = snap_angle(angle1, 15.0);
|
||||
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 h1 = len1 * 0.5;
|
||||
@@ -806,7 +852,10 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
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 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> {
|
||||
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()
|
||||
} else {
|
||||
simplified
|
||||
@@ -1043,8 +1094,16 @@ pub fn closed_polygon_to_mesh(
|
||||
let n_next = Vec2::new(-d_next.y, d_next.x);
|
||||
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(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,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
curr.x - corner_norm.x,
|
||||
curr.y - corner_norm.y,
|
||||
color,
|
||||
));
|
||||
|
||||
let edge_len = (next - curr).length();
|
||||
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).
|
||||
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 bottom = center + Vec2::new(0.0, radius_y);
|
||||
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 (sin, cos) = angle.sin_cos();
|
||||
|
||||
vertices.push(InkVertex::new(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));
|
||||
vertices.push(InkVertex::new(
|
||||
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 {
|
||||
samples.push(StylusSample {
|
||||
@@ -1158,23 +1230,20 @@ pub fn shape_to_mesh(
|
||||
(samples, vertices)
|
||||
}
|
||||
|
||||
RecognizedShape::Rectangle { corners } => {
|
||||
closed_polygon_to_mesh(corners, half_w, color)
|
||||
}
|
||||
RecognizedShape::Rectangle { corners } => closed_polygon_to_mesh(corners, half_w, color),
|
||||
|
||||
RecognizedShape::Triangle { corners } => {
|
||||
closed_polygon_to_mesh(corners, half_w, color)
|
||||
}
|
||||
RecognizedShape::Triangle { corners } => closed_polygon_to_mesh(corners, half_w, color),
|
||||
|
||||
RecognizedShape::Rhombus { corners } => {
|
||||
closed_polygon_to_mesh(corners, half_w, color)
|
||||
}
|
||||
RecognizedShape::Rhombus { corners } => closed_polygon_to_mesh(corners, half_w, color),
|
||||
|
||||
RecognizedShape::Polygon { vertices } => {
|
||||
closed_polygon_to_mesh(vertices, half_w, color)
|
||||
}
|
||||
RecognizedShape::Polygon { vertices } => 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 mut vertices = 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;
|
||||
|
||||
// Y-Linienband
|
||||
vertices.push(InkVertex::new(origin.x + y_norm.x, origin.y + y_norm.y, 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));
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x + y_norm.x,
|
||||
origin.y + y_norm.y,
|
||||
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
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
@@ -1244,9 +1329,21 @@ pub fn shape_to_mesh(
|
||||
|
||||
// 2. X-Linienband
|
||||
vertices.push(next_v);
|
||||
vertices.push(InkVertex::new(origin.x - x_norm.x, 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));
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x - x_norm.x,
|
||||
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
|
||||
if *with_arrows {
|
||||
@@ -1328,7 +1425,10 @@ mod tests {
|
||||
for i in 0..=50 {
|
||||
let t = i as f32 / 50.0;
|
||||
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);
|
||||
@@ -1380,7 +1480,12 @@ mod tests {
|
||||
let v1 = (prev - curr).normalize();
|
||||
let v2 = (next - curr).normalize();
|
||||
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 {
|
||||
panic!("Falscher Formtyp erkannt");
|
||||
@@ -1402,7 +1507,11 @@ mod tests {
|
||||
let detected = detect_circle(&points);
|
||||
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!((r - 54.0).abs() < 5.0, "Radius weicht ab: {}", r);
|
||||
} else {
|
||||
@@ -1431,14 +1540,27 @@ mod tests {
|
||||
}
|
||||
|
||||
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");
|
||||
let v_x = (x_axis_end - o).normalize();
|
||||
let v_y = (y_axis_end - o).normalize();
|
||||
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 {
|
||||
panic!("Falscher Formtyp erkannt");
|
||||
}
|
||||
@@ -1476,7 +1598,10 @@ mod tests {
|
||||
let ab = (corners[1] - corners[0]).length();
|
||||
let bc = (corners[2] - corners[1]).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);
|
||||
} else {
|
||||
panic!("Falscher Formtyp erkannt");
|
||||
@@ -1490,7 +1615,7 @@ mod tests {
|
||||
let c0 = Vec2::new(200.0, 100.0); // Oben
|
||||
let c1 = Vec2::new(320.0, 250.0); // Rechts
|
||||
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 {
|
||||
let t = i as f32 / 20.0;
|
||||
@@ -1520,7 +1645,10 @@ mod tests {
|
||||
let l1 = (corners[2] - corners[1]).length();
|
||||
let l2 = (corners[3] - corners[2]).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!((l2 - l3).abs() < 1e-3);
|
||||
} else {
|
||||
@@ -1549,7 +1677,10 @@ mod tests {
|
||||
points.push(points[0]);
|
||||
|
||||
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 {
|
||||
assert_eq!(vertices.len(), 8, "Achteck muss 8 Vertizes haben");
|
||||
@@ -1587,7 +1718,13 @@ mod tests {
|
||||
let detected = detect_octahedron(&points);
|
||||
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.y - 200.0).abs() < 5.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) {
|
||||
strokes[pos] = old.clone();
|
||||
}
|
||||
self.redo_stack.push(HistoryAction::ReplaceStroke { old, new });
|
||||
self.redo_stack
|
||||
.push(HistoryAction::ReplaceStroke { old, new });
|
||||
}
|
||||
HistoryAction::ModifyStrokes { before, after } => {
|
||||
for b in &before {
|
||||
@@ -91,7 +92,8 @@ impl HistoryStack {
|
||||
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) {
|
||||
strokes[pos] = new.clone();
|
||||
}
|
||||
self.undo_stack.push(HistoryAction::ReplaceStroke { old, new });
|
||||
self.undo_stack
|
||||
.push(HistoryAction::ReplaceStroke { old, new });
|
||||
}
|
||||
HistoryAction::ModifyStrokes { before, after } => {
|
||||
for a in &after {
|
||||
@@ -134,7 +137,8 @@ impl HistoryStack {
|
||||
strokes[pos] = a.clone();
|
||||
}
|
||||
}
|
||||
self.undo_stack.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.undo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-6
@@ -4,7 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::{
|
||||
event::{ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
|
||||
event::{
|
||||
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
|
||||
},
|
||||
event_loop::{ControlFlow, EventLoop},
|
||||
keyboard::{Key, ModifiersState, NamedKey},
|
||||
window::{Window, WindowBuilder},
|
||||
@@ -16,7 +18,9 @@ use ink_notes::canvas::CanvasState;
|
||||
use ink_notes::document::{Notebook, PageBackground};
|
||||
use ink_notes::export::{export_notebook_to_pdf, ExportStrategy};
|
||||
use ink_notes::geometry::{self, RecognizedShape};
|
||||
use ink_notes::selection::{render_selection_overlay, DragMode, SelectionHandleHit, SelectionState};
|
||||
use ink_notes::selection::{
|
||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||
};
|
||||
use ink_notes::storage::{self, NotebookManifest};
|
||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||
use ink_notes::updater::UpdaterController;
|
||||
@@ -76,12 +80,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok((nb, m)) => (nb, m),
|
||||
Err(err) => {
|
||||
eprintln!("[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). Erstelle neues Notizbuch.");
|
||||
(Notebook::new(), NotebookManifest::new(&initial_id, &initial_title))
|
||||
(
|
||||
Notebook::new(),
|
||||
NotebookManifest::new(&initial_id, &initial_title),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("[Storage] Erstelle neues Notizbuch in: {}", storage_dir.display());
|
||||
(Notebook::new(), NotebookManifest::new(&initial_id, &initial_title))
|
||||
println!(
|
||||
"[Storage] Erstelle neues Notizbuch in: {}",
|
||||
storage_dir.display()
|
||||
);
|
||||
(
|
||||
Notebook::new(),
|
||||
NotebookManifest::new(&initial_id, &initial_title),
|
||||
)
|
||||
};
|
||||
|
||||
// 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten
|
||||
@@ -139,7 +152,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let eraser_radius = 30.0_f32; // 3 mm in DIN-A4-Einheiten
|
||||
let stroke_max_radius = 12.0_f32;
|
||||
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
&manifest,
|
||||
ui_state.active_tool,
|
||||
autosave.status(),
|
||||
);
|
||||
|
||||
println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ===");
|
||||
println!("Toolbar:");
|
||||
|
||||
+31
-14
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
|
||||
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
use glam::Vec2;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::document::{Aabb, Page, Stroke};
|
||||
@@ -144,16 +144,16 @@ impl SelectionState {
|
||||
|
||||
// 2. 4 Eck-Skalierungsgriffe testen
|
||||
let corners_page = [
|
||||
aabb.min, // 0: oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
|
||||
aabb.max, // 2: unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
|
||||
aabb.min, // 0: oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
|
||||
aabb.max, // 2: unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
|
||||
];
|
||||
let fixed_corners_page = [
|
||||
aabb.max, // gegenüber 0 -> unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
|
||||
aabb.min, // gegenüber 2 -> oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
|
||||
aabb.max, // gegenüber 0 -> unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
|
||||
aabb.min, // gegenüber 2 -> oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
|
||||
];
|
||||
|
||||
let corners_screen = [
|
||||
@@ -285,7 +285,11 @@ pub fn render_selection_overlay(
|
||||
));
|
||||
|
||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
|
||||
if let DragMode::BoxSelect {
|
||||
start_page,
|
||||
current_page,
|
||||
} = selection.drag_mode
|
||||
{
|
||||
let p1 = canvas.page_to_screen(start_page);
|
||||
let p2 = canvas.page_to_screen(current_page);
|
||||
let min_x = p1[0].min(p2[0]);
|
||||
@@ -304,7 +308,10 @@ pub fn render_selection_overlay(
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
egui::Rounding::same(4.0_f32),
|
||||
egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)),
|
||||
egui::Stroke::new(
|
||||
1.5_f32,
|
||||
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +364,11 @@ pub fn render_selection_overlay(
|
||||
|
||||
for c in corners {
|
||||
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(
|
||||
handle_rect,
|
||||
egui::Rounding::same(1.5_f32),
|
||||
@@ -369,7 +380,11 @@ pub fn render_selection_overlay(
|
||||
let badge_text = format!(
|
||||
"{} {} | [R] Drehen [Entf] Löschen",
|
||||
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);
|
||||
painter.text(
|
||||
@@ -459,7 +474,9 @@ mod tests {
|
||||
let orig_pos = page.strokes[0].points[0].position;
|
||||
assert!(sel.rotate_selected_90_deg(&mut page));
|
||||
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
|
||||
assert!(page.undo());
|
||||
|
||||
+34
-15
@@ -355,11 +355,11 @@ fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, ke
|
||||
let mut max_dist = 0.0_f32;
|
||||
let mut max_idx = start;
|
||||
|
||||
for i in (start + 1)..end {
|
||||
let dist = point_to_segment_distance(positions[i], a, b);
|
||||
for (offset, &pos) in positions[(start + 1)..end].iter().enumerate() {
|
||||
let dist = point_to_segment_distance(pos, a, b);
|
||||
if dist > max_dist {
|
||||
max_dist = dist;
|
||||
max_idx = i;
|
||||
max_idx = start + 1 + offset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,10 +418,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
||||
}
|
||||
|
||||
/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab.
|
||||
fn sample_smooth_run(
|
||||
control_points: &[ControlPoint],
|
||||
config: &SplineConfig,
|
||||
) -> Vec<SplineSample> {
|
||||
fn sample_smooth_run(control_points: &[ControlPoint], config: &SplineConfig) -> Vec<SplineSample> {
|
||||
match control_points.len() {
|
||||
0 => Vec::new(),
|
||||
1 => {
|
||||
@@ -621,7 +618,12 @@ pub fn generate_finalized_triangle_strip(
|
||||
/// 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.
|
||||
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.
|
||||
@@ -808,9 +810,10 @@ mod tests {
|
||||
assert!(!stroke.vertices.is_empty());
|
||||
|
||||
// 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben
|
||||
let corner_found = stroke.points.iter().any(|s| {
|
||||
(s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3
|
||||
});
|
||||
let corner_found = stroke
|
||||
.points
|
||||
.iter()
|
||||
.any(|s| (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3);
|
||||
assert!(
|
||||
corner_found,
|
||||
"Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!"
|
||||
@@ -837,7 +840,11 @@ mod tests {
|
||||
|
||||
// 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad)
|
||||
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_pt = stroke.points[corner_idx];
|
||||
assert!((corner_pt.position.x - 100.0).abs() < 1e-3);
|
||||
@@ -847,9 +854,21 @@ mod tests {
|
||||
#[test]
|
||||
fn test_pressure_smoothing() {
|
||||
let samples = vec![
|
||||
StylusSample { position: glam::Vec2::new(0.0, 0.0), 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 },
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
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);
|
||||
|
||||
+37
-12
@@ -69,7 +69,12 @@ impl StrokeData {
|
||||
/// Wandelt die semantischen Daten deterministisch zurück in einen `Stroke` um,
|
||||
/// inklusive Wiederherstellung des Triangle-Strip-Vertexpuffers und der AABB.
|
||||
pub fn to_stroke(&self) -> Stroke {
|
||||
let stylus_samples: Vec<StylusSample> = self.samples.iter().copied().map(StylusSample::from).collect();
|
||||
let stylus_samples: Vec<StylusSample> = self
|
||||
.samples
|
||||
.iter()
|
||||
.copied()
|
||||
.map(StylusSample::from)
|
||||
.collect();
|
||||
let vertices = if let Some(ref shape) = self.shape {
|
||||
let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color);
|
||||
shape_verts
|
||||
@@ -207,7 +212,11 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) ->
|
||||
let angle = (i as f32 / segments as f32) * std::f32::consts::TAU;
|
||||
let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius);
|
||||
vertices.push(InkVertex::new(pt.position.x, pt.position.y, color));
|
||||
vertices.push(InkVertex::new(pt.position.x + offset.x, pt.position.y + offset.y, color));
|
||||
vertices.push(InkVertex::new(
|
||||
pt.position.x + offset.x,
|
||||
pt.position.y + offset.y,
|
||||
color,
|
||||
));
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
@@ -241,7 +250,10 @@ pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) ->
|
||||
/// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`).
|
||||
pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
|
||||
let dir = path.parent().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "Übergeordnetes Verzeichnis nicht gefunden")
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"Übergeordnetes Verzeichnis nicht gefunden",
|
||||
)
|
||||
})?;
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
@@ -475,7 +487,7 @@ pub fn list_notebooks(base_dir: &Path) -> Vec<NotebookSummary> {
|
||||
}
|
||||
|
||||
// Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst)
|
||||
list.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
list.sort_by_key(|a| std::cmp::Reverse(a.modified_at));
|
||||
list
|
||||
}
|
||||
|
||||
@@ -613,7 +625,11 @@ mod tests {
|
||||
assert_eq!(loaded_stroke.color, [0.1, 0.2, 0.8, 1.0]);
|
||||
assert_eq!(loaded_stroke.points.len(), 3);
|
||||
|
||||
for (orig, loaded) in page.strokes[0].points.iter().zip(loaded_stroke.points.iter()) {
|
||||
for (orig, loaded) in page.strokes[0]
|
||||
.points
|
||||
.iter()
|
||||
.zip(loaded_stroke.points.iter())
|
||||
{
|
||||
assert!((orig.position.x - loaded.position.x).abs() < 1e-4);
|
||||
assert!((orig.position.y - loaded.position.y).abs() < 1e-4);
|
||||
assert!((orig.pressure - loaded.pressure).abs() < 1e-4);
|
||||
@@ -633,12 +649,16 @@ mod tests {
|
||||
|
||||
// Beschädigtes, unvollständiges JSON manuell schreiben
|
||||
let corrupted_file = path.join("page_99.json");
|
||||
let truncated_json = br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
|
||||
let truncated_json =
|
||||
br#"{"id": 99, "background": "Grid5mm", "strokes": [{"id": 1, "color": [0.1, 0.2"#;
|
||||
fs::write(&corrupted_file, truncated_json).expect("Konnte Datei nicht schreiben");
|
||||
|
||||
// Laden muss einen abgefangenen CorruptedPage-Fehler liefern und darf nicht panicken
|
||||
let load_res = load_page(path, 99);
|
||||
assert!(load_res.is_err(), "Laden beschädigter Daten muss fehlschlagen");
|
||||
assert!(
|
||||
load_res.is_err(),
|
||||
"Laden beschädigter Daten muss fehlschlagen"
|
||||
);
|
||||
|
||||
match load_res.unwrap_err() {
|
||||
StorageError::CorruptedPage(id, msg) => {
|
||||
@@ -729,16 +749,21 @@ mod tests {
|
||||
// 3. Auflisten
|
||||
let summaries = list_notebooks(base_dir);
|
||||
assert_eq!(summaries.len(), 2);
|
||||
assert!(summaries.iter().any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
|
||||
assert!(summaries.iter().any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
|
||||
assert!(summaries
|
||||
.iter()
|
||||
.any(|s| s.id == m1.id && s.title == "Mathe Vorlesung"));
|
||||
assert!(summaries
|
||||
.iter()
|
||||
.any(|s| s.id == m2.id && s.title == "Skizzen & Entwürfe"));
|
||||
|
||||
// 4. Umbenennen
|
||||
rename_notebook(&dir1, &mut m1, "Höhere Mathematik")
|
||||
.expect("Umbenennen muss klappen");
|
||||
rename_notebook(&dir1, &mut m1, "Höhere Mathematik").expect("Umbenennen muss klappen");
|
||||
assert_eq!(m1.title, "Höhere Mathematik");
|
||||
|
||||
let updated_summaries = list_notebooks(base_dir);
|
||||
assert!(updated_summaries.iter().any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
|
||||
assert!(updated_summaries
|
||||
.iter()
|
||||
.any(|s| s.id == m1.id && s.title == "Höhere Mathematik"));
|
||||
|
||||
// 5. Löschen
|
||||
delete_notebook(&dir2).expect("Löschen muss klappen");
|
||||
|
||||
@@ -81,11 +81,8 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
||||
];
|
||||
|
||||
/// Dicken-Presets für den Textmarker:
|
||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = [
|
||||
(4.0, "4.0 mm"),
|
||||
(6.0, "6.0 mm"),
|
||||
(8.0, "8.0 mm"),
|
||||
];
|
||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||
|
||||
/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -111,7 +108,10 @@ pub enum UiAction {
|
||||
// Dokumenten- & Notizbuch-Verwaltung
|
||||
OpenNotebook(String),
|
||||
CreateNotebook(String),
|
||||
RenameNotebook { id: String, new_title: String },
|
||||
RenameNotebook {
|
||||
id: String,
|
||||
new_title: String,
|
||||
},
|
||||
DeleteNotebook(String),
|
||||
RefreshNotebooks,
|
||||
// Gitea Release & Updater
|
||||
@@ -119,7 +119,10 @@ pub enum UiAction {
|
||||
DownloadUpdateAsset(crate::updater::GiteaAsset),
|
||||
InstallOrOpenUpdate(std::path::PathBuf),
|
||||
OpenUrlInBrowser(String),
|
||||
SaveUpdaterConfig { server_url: String, auth_token: Option<String> },
|
||||
SaveUpdaterConfig {
|
||||
server_url: String,
|
||||
auth_token: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Zustand der grafischen Benutzeroberfläche.
|
||||
@@ -312,25 +315,36 @@ fn tool_pill_button_with_indicator(
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
|
||||
egui::Color32::WHITE,
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 190)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(110, 165, 255, 190),
|
||||
),
|
||||
)
|
||||
} else if response.hovered() {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 16),
|
||||
egui::Color32::WHITE,
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
|
||||
egui::Color32::from_gray(215),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 15)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 15),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
// Button-Hintergrund
|
||||
ui.painter().rect_filled(rect, egui::Rounding::same(8.0), bg);
|
||||
ui.painter().rect_stroke(rect, egui::Rounding::same(8.0), stroke);
|
||||
ui.painter()
|
||||
.rect_filled(rect, egui::Rounding::same(8.0), bg);
|
||||
ui.painter()
|
||||
.rect_stroke(rect, egui::Rounding::same(8.0), stroke);
|
||||
|
||||
// Text & Caret
|
||||
let label = if is_menu_open {
|
||||
@@ -359,7 +373,8 @@ fn tool_pill_button_with_indicator(
|
||||
(indicator_color[2] * 255.0) as u8,
|
||||
((indicator_color[3] * 255.0) as u8).max(180),
|
||||
);
|
||||
ui.painter().rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color);
|
||||
ui.painter()
|
||||
.rect_filled(bar_rect, egui::Rounding::same(1.5), bar_color);
|
||||
}
|
||||
|
||||
response.on_hover_text(tooltip)
|
||||
@@ -376,13 +391,19 @@ fn tool_pill_button(
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(45, 80, 145, 230),
|
||||
egui::Color32::WHITE,
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 190)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(110, 165, 255, 190),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
|
||||
egui::Color32::from_gray(215),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 15)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 15),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -415,22 +436,33 @@ fn stroke_preview_button(
|
||||
let (bg, border) = if is_active {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(120, 175, 255, 200)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(120, 175, 255, 200),
|
||||
),
|
||||
)
|
||||
} else if response.hovered() {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 16),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 14)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 14),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
ui.painter().rect_filled(rect, egui::Rounding::same(6.0), bg);
|
||||
ui.painter().rect_stroke(rect, egui::Rounding::same(6.0), border);
|
||||
ui.painter()
|
||||
.rect_filled(rect, egui::Rounding::same(6.0), bg);
|
||||
ui.painter()
|
||||
.rect_stroke(rect, egui::Rounding::same(6.0), border);
|
||||
|
||||
// Horizontale Vorschau-Linie zeichnen
|
||||
let line_thickness = (stroke_width_mm * 2.5).clamp(1.0, 9.0);
|
||||
@@ -456,7 +488,11 @@ fn stroke_preview_button(
|
||||
egui::Align2::CENTER_CENTER,
|
||||
label_text,
|
||||
egui::FontId::proportional(10.0),
|
||||
if is_active { egui::Color32::WHITE } else { egui::Color32::from_gray(190) },
|
||||
if is_active {
|
||||
egui::Color32::WHITE
|
||||
} else {
|
||||
egui::Color32::from_gray(190)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,34 +500,32 @@ fn stroke_preview_button(
|
||||
}
|
||||
|
||||
/// Kompakter Strichbreiten-Preset-Button für die Haupt-Toolbar.
|
||||
fn preset_width_button(
|
||||
ui: &mut egui::Ui,
|
||||
is_active: bool,
|
||||
text: &str,
|
||||
) -> egui::Response {
|
||||
fn preset_width_button(ui: &mut egui::Ui, is_active: bool, text: &str) -> egui::Response {
|
||||
let (bg, text_color, stroke) = if is_active {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(50, 85, 150, 230),
|
||||
egui::Color32::WHITE,
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(120, 175, 255, 190)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(120, 175, 255, 190),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 6),
|
||||
egui::Color32::from_gray(190),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 12)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 12),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let button = egui::Button::new(
|
||||
egui::RichText::new(text)
|
||||
.color(text_color)
|
||||
.size(11.5),
|
||||
)
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.rounding(egui::Rounding::same(6.0))
|
||||
.min_size(egui::vec2(0.0, 24.0));
|
||||
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(11.5))
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.rounding(egui::Rounding::same(6.0))
|
||||
.min_size(egui::vec2(0.0, 24.0));
|
||||
|
||||
ui.add(button)
|
||||
}
|
||||
@@ -512,24 +546,26 @@ fn action_pill_button(
|
||||
let (bg, stroke) = if enabled {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 10),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 22)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 22),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 3),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 8)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 8),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let button = egui::Button::new(
|
||||
egui::RichText::new(text)
|
||||
.color(text_color)
|
||||
.size(12.5),
|
||||
)
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.rounding(egui::Rounding::same(7.0))
|
||||
.min_size(egui::vec2(0.0, 26.0));
|
||||
let button = egui::Button::new(egui::RichText::new(text).color(text_color).size(12.5))
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.rounding(egui::Rounding::same(7.0))
|
||||
.min_size(egui::vec2(0.0, 26.0));
|
||||
|
||||
ui.add_enabled(enabled, button).on_hover_text(tooltip)
|
||||
}
|
||||
@@ -541,7 +577,10 @@ fn toolbar_divider(ui: &mut egui::Ui) {
|
||||
ui.painter().vline(
|
||||
rect.center().x,
|
||||
rect.y_range(),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)),
|
||||
egui::Stroke::new(
|
||||
1.0_f32,
|
||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 25),
|
||||
),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
@@ -1014,14 +1053,22 @@ pub fn render_ui(
|
||||
.color(egui::Color32::WHITE)
|
||||
.size(12.5),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(egui::Button::new(egui::RichText::new("✕").size(11.0)).frame(false))
|
||||
.clicked()
|
||||
{
|
||||
state.is_pen_menu_open = false;
|
||||
}
|
||||
});
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
egui::RichText::new("✕").size(11.0),
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.is_pen_menu_open = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
@@ -1040,7 +1087,8 @@ pub fn render_ui(
|
||||
let is_sel = (state.pen_color[0] - color[0]).abs() < 0.05
|
||||
&& (state.pen_color[1] - color[1]).abs() < 0.05
|
||||
&& (state.pen_color[2] - color[2]).abs() < 0.05;
|
||||
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() {
|
||||
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked()
|
||||
{
|
||||
state.pen_color = color;
|
||||
actions.push(UiAction::SetColor(color));
|
||||
}
|
||||
@@ -1079,7 +1127,14 @@ pub fn render_ui(
|
||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
||||
for (preset, label) in PEN_WIDTH_PRESETS {
|
||||
let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05;
|
||||
if stroke_preview_button(ui, is_sel, preset, label, state.pen_color).clicked()
|
||||
if stroke_preview_button(
|
||||
ui,
|
||||
is_sel,
|
||||
preset,
|
||||
label,
|
||||
state.pen_color,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.pen_stroke_width_mm = preset;
|
||||
actions.push(UiAction::SetStrokeWidthMm(preset));
|
||||
@@ -1145,14 +1200,22 @@ pub fn render_ui(
|
||||
.color(egui::Color32::WHITE)
|
||||
.size(12.5),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(egui::Button::new(egui::RichText::new("✕").size(11.0)).frame(false))
|
||||
.clicked()
|
||||
{
|
||||
state.is_highlighter_menu_open = false;
|
||||
}
|
||||
});
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
egui::RichText::new("✕").size(11.0),
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.is_highlighter_menu_open = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
@@ -1168,10 +1231,12 @@ pub fn render_ui(
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0);
|
||||
for (color, label) in HIGHLIGHTER_COLOR_PRESETS {
|
||||
let is_sel = (state.highlighter_color[0] - color[0]).abs() < 0.05
|
||||
let is_sel = (state.highlighter_color[0] - color[0]).abs()
|
||||
< 0.05
|
||||
&& (state.highlighter_color[1] - color[1]).abs() < 0.05
|
||||
&& (state.highlighter_color[2] - color[2]).abs() < 0.05;
|
||||
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked() {
|
||||
if color_circle_swatch(ui, color, is_sel, label, 22.0).clicked()
|
||||
{
|
||||
state.highlighter_color = color;
|
||||
actions.push(UiAction::SetColor(color));
|
||||
}
|
||||
@@ -1208,7 +1273,8 @@ pub fn render_ui(
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
||||
for (preset, label) in HIGHLIGHTER_WIDTH_PRESETS {
|
||||
let is_sel = (state.highlighter_stroke_width_mm - preset).abs() < 0.1;
|
||||
let is_sel =
|
||||
(state.highlighter_stroke_width_mm - preset).abs() < 0.1;
|
||||
if stroke_preview_button(
|
||||
ui,
|
||||
is_sel,
|
||||
@@ -1236,13 +1302,18 @@ pub fn render_ui(
|
||||
ui.add_space(2.0);
|
||||
if ui
|
||||
.add(
|
||||
egui::Slider::new(&mut state.highlighter_stroke_width_mm, 2.0..=16.0)
|
||||
.suffix(" mm")
|
||||
.step_by(0.5),
|
||||
egui::Slider::new(
|
||||
&mut state.highlighter_stroke_width_mm,
|
||||
2.0..=16.0,
|
||||
)
|
||||
.suffix(" mm")
|
||||
.step_by(0.5),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
actions.push(UiAction::SetStrokeWidthMm(state.highlighter_stroke_width_mm));
|
||||
actions.push(UiAction::SetStrokeWidthMm(
|
||||
state.highlighter_stroke_width_mm,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1299,16 +1370,18 @@ pub fn render_ui(
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
|
||||
for idx in 0..notebook.pages.len() {
|
||||
let is_current = idx == notebook.current_page_index;
|
||||
let text = format!("Seite {}", idx + 1);
|
||||
if tool_pill_button(ui, is_current, &text, "").clicked() {
|
||||
actions.push(UiAction::GoToPage(idx));
|
||||
state.show_page_jump_popup = false;
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(220.0)
|
||||
.show(ui, |ui| {
|
||||
for idx in 0..notebook.pages.len() {
|
||||
let is_current = idx == notebook.current_page_index;
|
||||
let text = format!("Seite {}", idx + 1);
|
||||
if tool_pill_button(ui, is_current, &text, "").clicked() {
|
||||
actions.push(UiAction::GoToPage(idx));
|
||||
state.show_page_jump_popup = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() {
|
||||
@@ -1471,13 +1544,13 @@ pub fn render_ui(
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if state.available_notebooks.len() > 1 {
|
||||
if ui.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120)))
|
||||
if state.available_notebooks.len() > 1
|
||||
&& ui
|
||||
.button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120)))
|
||||
.on_hover_text("Notizbuch unwiderruflich löschen")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
|
||||
}
|
||||
{
|
||||
actions.push(UiAction::DeleteNotebook(summary.id.clone()));
|
||||
}
|
||||
|
||||
if ui.button(egui::RichText::new("✏").size(11.0))
|
||||
@@ -1701,10 +1774,10 @@ pub fn render_ui(
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
if !release.html_url.is_empty() {
|
||||
if ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked() {
|
||||
actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone()));
|
||||
}
|
||||
if !release.html_url.is_empty()
|
||||
&& ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked()
|
||||
{
|
||||
actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1810,7 +1883,12 @@ pub fn render_ui(
|
||||
.order(egui::Order::Tooltip)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::none()
|
||||
.fill(egui::Color32::from_rgba_unmultiplied(20, 25, 30, alpha.min(235)))
|
||||
.fill(egui::Color32::from_rgba_unmultiplied(
|
||||
20,
|
||||
25,
|
||||
30,
|
||||
alpha.min(235),
|
||||
))
|
||||
.rounding(egui::Rounding::same(10.0))
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0_f32,
|
||||
@@ -1826,7 +1904,9 @@ pub fn render_ui(
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("✓ {}", msg))
|
||||
.color(egui::Color32::from_rgba_unmultiplied(220, 255, 230, alpha))
|
||||
.color(egui::Color32::from_rgba_unmultiplied(
|
||||
220, 255, 230, alpha,
|
||||
))
|
||||
.size(13.0)
|
||||
.strong(),
|
||||
);
|
||||
|
||||
+738
-163
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -3,8 +3,8 @@
|
||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||
//! 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 glam::{Mat4, Vec2, Vec4};
|
||||
|
||||
/// Minimaler und maximaler Zoomfaktor.
|
||||
pub const MIN_ZOOM: f32 = 0.25;
|
||||
@@ -40,7 +40,12 @@ impl ViewportState {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
if base_scale * self.zoom > 1e-6 {
|
||||
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
|
||||
@@ -122,10 +127,7 @@ impl ViewportState {
|
||||
page_viewport: &PageViewport,
|
||||
) -> Vec2 {
|
||||
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
||||
Vec2::new(
|
||||
pt.x.clamp(0.0, PAGE_WIDTH),
|
||||
pt.y.clamp(0.0, PAGE_HEIGHT),
|
||||
)
|
||||
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
|
||||
}
|
||||
|
||||
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
|
||||
|
||||
Reference in New Issue
Block a user