commit d7ffef5368b4cc70c524e25a4716697fb057d780 Author: Harald Date: Tue Oct 6 10:15:22 2026 +0200 Initial commit: ink-notes (latency-optimized Linux inking app in Rust) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64d3193 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*.pdf +export.pdf diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b9b5dc4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2940 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser 0.25.1", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "cesu8", + "jni", + "jni-sys 0.3.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" +dependencies = [ + "objc-sys", +] + +[[package]] +name = "block2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" +dependencies = [ + "block-sys", + "objc2", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" +dependencies = [ + "bitflags 2.13.2", + "libloading 0.7.4", + "winapi", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "ecolor" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20930a432bbd57a6d55e07976089708d4893f3d556cf42a0d79e9e321fa73b10" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "egui" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584c5d1bf9a67b25778a3323af222dbe1a1feb532190e103901187f92c7fe29a" +dependencies = [ + "ahash", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469ff65843f88a702b731a1532b7d03b0e8e96d283e70f3a22b0e06c46cb9b37" +dependencies = [ + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror", + "type-map", + "web-time", + "wgpu", +] + +[[package]] +name = "egui-winit" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3da0cbe020f341450c599b35b92de4af7b00abde85624fd16f09c885573609" +dependencies = [ + "egui", + "log", + "raw-window-handle 0.6.2", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "emath" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c3a552cfca14630702449d35f41c84a0d15963273771c6059175a803620f3f" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "epaint" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b381f8b149657a4acf837095351839f32cd5c4aec1817fc4df84e18d76334176" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "log", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" +dependencies = [ + "serde", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.2", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +dependencies = [ + "bitflags 2.13.2", + "gpu-descriptor-types", + "hashbrown 0.14.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.2", + "com", + "libc", + "libloading 0.8.9", + "thiserror", + "widestring", + "winapi", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "icrate" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" +dependencies = [ + "block2", + "dispatch", + "objc2", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ink-notes" +version = "0.1.0" +dependencies = [ + "bytemuck", + "egui", + "egui-wgpu", + "egui-winit", + "glam", + "pollster", + "printpdf", + "serde", + "serde_json", + "tempfile", + "wgpu", + "winit", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +dependencies = [ + "bit-set", + "bitflags 2.13.2", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser 0.19.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "js-sys", + "lopdf", + "owned_ttf_parser 0.19.0", + "time", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay-client-toolkit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" +dependencies = [ + "bitflags 2.13.2", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" +dependencies = [ + "core-foundation", + "home", + "jni", + "log", + "ndk-context", + "objc", + "raw-window-handle 0.5.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.2", + "cfg_aliases", + "codespan-reporting", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "rustc-hash 1.1.0", + "smallvec", + "thiserror", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block", + "cfg_aliases", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle 0.6.2", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" +dependencies = [ + "bitflags 2.13.2", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.29.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "bytemuck", + "calloop", + "cfg_aliases", + "core-foundation", + "core-graphics", + "cursor-icon", + "icrate", + "js-sys", + "libc", + "log", + "memmap2", + "ndk", + "ndk-sys", + "objc2", + "once_cell", + "orbclient", + "percent-encoding", + "raw-window-handle 0.6.2", + "redox_syscall 0.3.5", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.48.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..44b8e7c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "ink-notes" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Fenster- & Event-Handling (Wayland/X11, Stylus) +winit = { version = "0.29", features = ["rwh_06"] } + +# Low-Level GPU-Rendering (Vulkan/Direct3D12 via WSLg) +wgpu = "0.19" + +# Daten-Casting für GPU-Buffer +bytemuck = { version = "1.16", features = ["derive"] } + +# Vektor-Mathematik +glam = { version = "0.27", features = ["serde"] } + +# Asynchroner Starter für wgpu-Device-Setup +pollster = "0.3" + +# PDF-Vektorexport +printpdf = "0.7" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" + +# Immediate-Mode UI (Toolbar & Menüs) +egui = "0.27" +egui-wgpu = "0.27" +egui-winit = { version = "0.27", default-features = false, features = ["links"] } + +[profile.dev] +opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..22ff619 --- /dev/null +++ b/run.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Starter-Skript für ink-notes (konfiguriert X11/Xwayland & xkb-Bibliotheken) +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH}" +export WAYLAND_DISPLAY="" + +exec "$SCRIPT_DIR/target/debug/ink-notes" "$@" diff --git a/src/autosave.rs b/src/autosave.rs new file mode 100644 index 0000000..7a5bb52 --- /dev/null +++ b/src/autosave.rs @@ -0,0 +1,275 @@ +//! Asynchroner Autosave-Mechanismus mit Debounce-Logik und Hintergrund-Worker-Thread. +//! +//! Stellt sicher, dass Datei-I/O niemals den 120/240 Hz Render-Loop blockiert. +//! Schnelle aufeinanderfolgende Änderungen werden gebündelt (debounced). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::document::Page; +use crate::storage::{self, NotebookManifest, PageData}; + +/// Aktueller Status des Autosave-Controllers für OSD und UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AutosaveStatus { + /// Alle Änderungen sind dauerhaft auf der Festplatte gesichert. + Saved, + /// Es liegen ungespeicherte Änderungen vor (im Debounce-Fenster). + Pending, + /// Ein Hintergrund-Schreibvorgang wird gerade ausgeführt. + Saving, + /// Ein Fehler ist beim Schreiben aufgetreten. + Error, +} + +impl AutosaveStatus { + pub fn display_text(&self) -> &'static str { + match self { + AutosaveStatus::Saved => "Gespeichert", + AutosaveStatus::Pending => "Ungespeichert", + AutosaveStatus::Saving => "Speichert...", + AutosaveStatus::Error => "Speicherfehler!", + } + } +} + +/// Befehle vom Haupt-Thread an den Hintergrund-Worker. +pub enum AutosaveCommand { + SavePage { + notebook_dir: PathBuf, + page_data: PageData, + }, + SaveManifest { + notebook_dir: PathBuf, + manifest: NotebookManifest, + }, + Flush(Sender<()>), + Shutdown, +} + +/// Antworten des Hintergrund-Workers an den Haupt-Thread. +pub enum AutosaveResponse { + PageSaved { + page_id: u64, + result: Result<(), String>, + }, + ManifestSaved { + result: Result<(), String>, + }, +} + +/// Controller zur Verwaltung des asynchronen Autosave-Ablaufs. +pub struct AutosaveController { + notebook_dir: PathBuf, + debounce_duration: Duration, + last_change_time: Option, + pending_pages: HashMap, + pending_manifest: Option, + status: AutosaveStatus, + in_flight_count: usize, + total_page_writes: Arc, + cmd_tx: Sender, + resp_rx: Receiver, + worker_handle: Option>, +} + +impl AutosaveController { + /// Erzeugt einen neuen Controller und startet den Hintergrund-Worker-Thread. + pub fn new(notebook_dir: PathBuf, debounce_duration: Duration) -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (resp_tx, resp_rx) = mpsc::channel::(); + let total_page_writes = Arc::new(AtomicUsize::new(0)); + let worker_writes_counter = Arc::clone(&total_page_writes); + + let worker_handle = thread::spawn(move || { + while let Ok(cmd) = cmd_rx.recv() { + match cmd { + AutosaveCommand::SavePage { + notebook_dir, + page_data, + } => { + let page_id = page_data.id; + let res = storage::save_page(¬ebook_dir, &page_data); + if res.is_ok() { + worker_writes_counter.fetch_add(1, Ordering::SeqCst); + } + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: res.map_err(|e| e.to_string()), + }); + } + AutosaveCommand::SaveManifest { + notebook_dir, + manifest, + } => { + let res = storage::save_manifest(¬ebook_dir, &manifest); + let _ = resp_tx.send(AutosaveResponse::ManifestSaved { + result: res.map_err(|e| e.to_string()), + }); + } + AutosaveCommand::Flush(ack_tx) => { + let _ = ack_tx.send(()); + } + AutosaveCommand::Shutdown => { + break; + } + } + } + }); + + Self { + notebook_dir, + debounce_duration, + last_change_time: None, + pending_pages: HashMap::new(), + pending_manifest: None, + status: AutosaveStatus::Saved, + in_flight_count: 0, + total_page_writes, + cmd_tx, + resp_rx, + worker_handle: Some(worker_handle), + } + } + + /// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster. + pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) { + let page_data = PageData::from(page); + self.pending_pages.insert(page.id, page_data); + self.pending_manifest = Some(manifest.clone()); + self.last_change_time = Some(Instant::now()); + self.status = AutosaveStatus::Pending; + } + + /// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf des Debounce-Timers an. + /// + /// Sollte regelmäßig im Event-Loop aufgerufen werden. + pub fn update(&mut self) -> AutosaveStatus { + // 1. Antworten des Hintergrund-Workers verarbeiten + while let Ok(response) = self.resp_rx.try_recv() { + if self.in_flight_count > 0 { + self.in_flight_count -= 1; + } + + match response { + AutosaveResponse::PageSaved { page_id: _, result } => { + if let Err(e) = result { + eprintln!("[Autosave-Fehler] Seite konnte nicht gespeichert werden: {e}"); + self.status = AutosaveStatus::Error; + } + } + AutosaveResponse::ManifestSaved { result } => { + if let Err(e) = result { + 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 { + self.status = AutosaveStatus::Saved; + } + } + + // 2. Debounce-Timer prüfen: Wenn Zeit abgelaufen, an Worker übergeben + if !self.pending_pages.is_empty() { + if let Some(last_change) = self.last_change_time { + if last_change.elapsed() >= self.debounce_duration { + self.dispatch_pending(); + } + } + } + + self.status + } + + /// Übergibt alle anstehenden Änderungen sofort an den Worker-Kanal. + fn dispatch_pending(&mut self) { + for (_page_id, page_data) in self.pending_pages.drain() { + let cmd = AutosaveCommand::SavePage { + notebook_dir: self.notebook_dir.clone(), + page_data, + }; + if self.cmd_tx.send(cmd).is_ok() { + self.in_flight_count += 1; + } + } + + if let Some(manifest) = self.pending_manifest.take() { + let cmd = AutosaveCommand::SaveManifest { + notebook_dir: self.notebook_dir.clone(), + manifest, + }; + if self.cmd_tx.send(cmd).is_ok() { + self.in_flight_count += 1; + } + } + + self.status = AutosaveStatus::Saving; + self.last_change_time = None; + } + + /// Führt einen synchronen Flush aller noch ausstehenden Änderungen durch (z. B. vor Beenden). + pub fn flush(&mut self) { + // Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken + if !self.pending_pages.is_empty() || self.pending_manifest.is_some() { + self.dispatch_pending(); + } + + let (ack_tx, ack_rx) = mpsc::channel(); + if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() { + let _ = ack_rx.recv(); + } + + // Restliche Antworten leeren + while let Ok(response) = self.resp_rx.try_recv() { + if self.in_flight_count > 0 { + self.in_flight_count -= 1; + } + if let AutosaveResponse::PageSaved { result: Err(e), .. } = response { + eprintln!("[Autosave-Flush] Fehler: {e}"); + } + } + + self.status = AutosaveStatus::Saved; + } + + /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel), + /// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden. + pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) { + self.flush(); + self.notebook_dir = new_notebook_dir; + } + + /// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush. + pub fn shutdown(&mut self) { + self.flush(); + let _ = self.cmd_tx.send(AutosaveCommand::Shutdown); + if let Some(handle) = self.worker_handle.take() { + let _ = handle.join(); + } + } + + /// Gibt die Gesamtzahl aller tatsächlich auf die Disk geschriebenen Seiten zurück. + pub fn total_page_writes(&self) -> usize { + self.total_page_writes.load(Ordering::SeqCst) + } + + /// Gibt den aktuellen Status zurück. + pub fn status(&self) -> AutosaveStatus { + self.status + } +} + +impl Drop for AutosaveController { + fn drop(&mut self) { + if self.worker_handle.is_some() { + self.shutdown(); + } + } +} diff --git a/src/background.rs b/src/background.rs new file mode 100644 index 0000000..a17f9a5 --- /dev/null +++ b/src/background.rs @@ -0,0 +1,145 @@ +//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko). + +use crate::canvas::InkVertex; +use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; + +/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm). +pub const LINE_THICKNESS: f32 = 1.5; +/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm). +pub const DOT_SIZE: f32 = 2.5; + +/// Rasterabstand 5 mm (50 Einheiten). +pub const GRID_5MM_SPACING: f32 = 50.0; +/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten). +pub const LINED_SPACING: f32 = 75.0; + +/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0). +pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0]; +/// Papierweiß für das DIN-A4-Blatt (#FFFFFF). +pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D). +pub const DESK_COLOR: wgpu::Color = wgpu::Color { + r: 0.16, + g: 0.16, + b: 0.18, + a: 1.0, +}; + +/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt. +pub fn generate_paper_quad() -> Vec { + vec![ + InkVertex::new(0.0, 0.0, PAPER_COLOR), + InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), + InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), + InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), + InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), + InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR), + ] +} + +/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. +#[inline] +fn push_quad( + vertices: &mut Vec, + 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)); + vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); + vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); + vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR)); +} + +/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund. +pub fn generate_background_mesh(background: PageBackground) -> Vec { + match background { + PageBackground::Blank => Vec::new(), + PageBackground::Grid5mm => { + let mut vertices = Vec::with_capacity(1200); + let half_t = LINE_THICKNESS / 2.0; + + // Vertikale Linien alle 5 mm + let mut x = GRID_5MM_SPACING; + while x < PAGE_WIDTH { + push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT); + x += GRID_5MM_SPACING; + } + + // Horizontale Linien alle 5 mm + let mut y = GRID_5MM_SPACING; + while y < PAGE_HEIGHT { + push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); + y += GRID_5MM_SPACING; + } + + vertices + } + PageBackground::Lined => { + let mut vertices = Vec::with_capacity(600); + let half_t = LINE_THICKNESS / 2.0; + + // Oberer und unterer Randabstand (z. B. 15 mm) + let margin_top = 150.0; + let margin_bottom = 150.0; + + let mut y = margin_top; + while y <= PAGE_HEIGHT - margin_bottom { + push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); + y += LINED_SPACING; + } + + vertices + } + PageBackground::DotGrid => { + let mut vertices = Vec::with_capacity(15000); + let half_d = DOT_SIZE / 2.0; + + let mut y = GRID_5MM_SPACING; + 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); + x += GRID_5MM_SPACING; + } + y += GRID_5MM_SPACING; + } + + vertices + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paper_quad_generation() { + let quad = generate_paper_quad(); + assert_eq!(quad.len(), 6); + assert_eq!(quad[0].position, [0.0, 0.0]); + assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]); + } + + #[test] + fn test_background_meshes() { + let blank = generate_background_mesh(PageBackground::Blank); + assert!(blank.is_empty()); + + 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)"); + + let lined = generate_background_mesh(PageBackground::Lined); + assert!(!lined.is_empty()); + assert_eq!(lined.len() % 6, 0); + + let dots = generate_background_mesh(PageBackground::DotGrid); + assert!(!dots.is_empty()); + assert_eq!(dots.len() % 6, 0); + } +} diff --git a/src/canvas.rs b/src/canvas.rs new file mode 100644 index 0000000..d4ae4ac --- /dev/null +++ b/src/canvas.rs @@ -0,0 +1,857 @@ +//! GPU-Canvas-Renderpipeline mit wgpu und Low-Latency-Verwaltung. +//! +//! Features: +//! - Zero-Latency-Pufferung via `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`. +//! - Strikte Pipeline-Trennung: DIN-A4-Hintergrund, permanente Striche und temporäre Live-Geometrie. +//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms. +//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`). + +use std::ops::Range; +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::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; +use crate::filter::OneEuroFilter; +use crate::spline::{self, ControlPoint, SplineConfig}; + +/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color). +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub struct InkVertex { + pub position: [f32; 2], + pub color: [f32; 4], +} + +impl InkVertex { + pub fn new(x: f32, y: f32, color: [f32; 4]) -> Self { + Self { + position: [x, y], + color, + } + } + + pub fn desc() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + ], + } + } +} + +/// Uniform-Daten für die 2D-Kamera (Scale und Offset im NDC-Raum). +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub struct CameraUniforms { + pub transform_scale: [f32; 2], + pub transform_offset: [f32; 2], +} + +/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0]. +#[inline] +pub fn pixel_to_ndc(x: f32, y: f32, width: f32, height: f32) -> [f32; 2] { + let w = width.max(1.0); + let h = height.max(1.0); + let x_ndc = (x / w) * 2.0 - 1.0; + let y_ndc = 1.0 - (y / h) * 2.0; + [x_ndc, y_ndc] +} + +/// Transformiert eine Folge von Spline-Vertices aus dem Pixelraum in NDC-`InkVertex`-Elemente. +pub fn spline_vertices_to_ndc( + vertices: &[spline::Vertex], + width: f32, + height: f32, + color: [f32; 4], +) -> Vec { + vertices + .iter() + .map(|v| { + let ndc = pixel_to_ndc(v.position[0], v.position[1], width, height); + InkVertex::new(ndc[0], ndc[1], color) + }) + .collect() +} + +/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking. +pub struct CanvasState { + pub surface: wgpu::Surface<'static>, + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub config: wgpu::SurfaceConfiguration, + pub size: PhysicalSize, + pub viewport: PageViewport, + pub camera: crate::viewport::ViewportState, + + // Pipelines + pub strip_pipeline: wgpu::RenderPipeline, + pub triangle_pipeline: wgpu::RenderPipeline, + + // 2D-Kamera Uniforms & Bind Group (gilt einheitlich für alle Vertizes) + pub camera_buffer: wgpu::Buffer, + pub camera_bind_group: wgpu::BindGroup, + pub current_ink_color: [f32; 4], + + // Statische Hintergrund-Geometrie + pub paper_buffer: wgpu::Buffer, + pub background_buffer: wgpu::Buffer, + pub background_vertex_capacity: usize, + pub background_vertex_count: u32, + pub current_background: PageBackground, + + // Pipeline-Trennung: Live-Strich (aktiver Stroke) + pub live_vertex_buffer: wgpu::Buffer, + pub live_vertex_capacity: usize, + pub live_vertex_count: u32, + pub live_samples: Vec, + pub live_control_points: Vec, + pub live_vertices: Vec, + pub filter: OneEuroFilter, + pub spline_config: SplineConfig, + + // Pipeline-Trennung: Abgeschlossene Striche der aktuellen Seite + pub committed_vertex_buffer: wgpu::Buffer, + pub committed_vertex_capacity: usize, + pub committed_vertex_count: u32, + pub committed_strokes: Vec>, + + // egui UI-Renderer + pub egui_renderer: egui_wgpu::Renderer, +} + +impl CanvasState { + const INITIAL_LIVE_CAPACITY: usize = 4096; + const INITIAL_COMMITTED_CAPACITY: usize = 65536; + const INITIAL_BACKGROUND_CAPACITY: usize = 16384; + pub const VIEWPORT_MARGIN: f32 = 24.0; + + /// Initialisiert den CanvasState für das übergebene Winit-Fenster. + pub async fn new(window: Arc) -> Result> { + let size = window.inner_size(); + 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 { + backends: wgpu::Backends::all(), + ..Default::default() + }); + + let surface = instance.create_surface(window.clone())?; + + // 2. Adapter auswählen (hohe Performance bevorzugt) + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .ok_or("Kein passender GPU-Adapter gefunden")?; + + // 3. Device & Queue erstellen + let (device, queue) = adapter + .request_device( + &wgpu::DeviceDescriptor { + label: Some("Ink Device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + }, + None, + ) + .await?; + + // 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1 + let surface_caps = surface.get_capabilities(&adapter); + let surface_format = surface_caps + .formats + .iter() + .copied() + .find(|f| f.is_srgb()) + .unwrap_or(surface_caps.formats[0]); + + let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() { + eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)"); + wgpu::PresentMode::Fifo + } else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) { + eprintln!("Verwende Zero-Latency PresentMode::Mailbox"); + wgpu::PresentMode::Mailbox + } else { + eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo"); + wgpu::PresentMode::Fifo + }; + + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + width: size.width.max(1), + height: size.height.max(1), + present_mode, + alpha_mode: surface_caps.alpha_modes[0], + view_formats: vec![], + desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame + }; + surface.configure(&device, &config); + + // 5. Shader & Bind Group Layout + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Ink WGSL Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Stroke Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let (scale, offset) = viewport.ndc_transform(); + + let default_ink_color = [0.1, 0.1, 0.15, 1.0]; + let camera_uniforms = CameraUniforms { + transform_scale: scale, + transform_offset: offset, + }; + let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Camera Uniform Buffer"), + size: std::mem::size_of::() as wgpu::BufferAddress, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms])); + + let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Camera Bind Group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }], + }); + + // Pipeline Layout + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Ink Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + + // TriangleStrip-Pipeline (für Inking-Striche) + let strip_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Ink Strip Render Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: "vs_main", + buffers: &[InkVertex::desc()], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + }); + + // TriangleList-Pipeline (für Hintergrundpapier und Muster) + let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Background TriangleList Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: "vs_main", + buffers: &[InkVertex::desc()], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + }); + + // 6. Statische Buffer + let paper_quad = generate_paper_quad(); + let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Paper Quad Buffer"), + size: (paper_quad.len() * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); + + let current_background = PageBackground::Grid5mm; + let bg_mesh = generate_background_mesh(current_background); + let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Background Mesh Buffer"), + size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh)); + + // 7. GPU-Puffer für Live- und Committed-Geometrie + let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Live Stroke Vertex Buffer"), + size: (Self::INITIAL_LIVE_CAPACITY * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let committed_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Committed Strokes Vertex Buffer"), + size: (Self::INITIAL_COMMITTED_CAPACITY * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // 8. egui Renderer für das schwebende UI initialisieren + let egui_renderer = egui_wgpu::Renderer::new( + &device, + config.format, + None, + 1, + ); + + Ok(Self { + surface, + device, + queue, + config, + size, + viewport, + strip_pipeline, + triangle_pipeline, + camera_buffer, + camera_bind_group, + current_ink_color: default_ink_color, + paper_buffer, + background_buffer, + background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY, + background_vertex_count: bg_mesh.len() as u32, + current_background, + live_vertex_buffer, + live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY, + live_vertex_count: 0, + live_samples: Vec::with_capacity(256), + live_control_points: Vec::with_capacity(256), + live_vertices: Vec::with_capacity(1024), + filter: OneEuroFilter::default(), + spline_config: SplineConfig { + min_radius: 1.25, // 0.125 mm bei 10 Einheiten/mm (Standard 0.5 mm Strichbreite) + max_radius: 3.75, // 0.375 mm bei vollem Druck + samples_per_segment: 16, + }, + committed_vertex_buffer, + committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY, + committed_vertex_count: 0, + committed_strokes: Vec::with_capacity(128), + egui_renderer, + camera: crate::viewport::ViewportState::new(), + }) + } + + /// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera. + pub fn update_uniforms(&mut self) { + let (scale, offset) = self.camera.ndc_transform(&self.viewport); + let camera_uniforms = CameraUniforms { + transform_scale: scale, + transform_offset: offset, + }; + self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms])); + } + + /// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an. + pub fn resize(&mut self, new_size: PhysicalSize) { + if new_size.width > 0 && new_size.height > 0 { + self.size = new_size; + self.config.width = new_size.width; + self.config.height = new_size.height; + self.surface.configure(&self.device, &self.config); + + self.viewport = PageViewport::calculate( + new_size.width as f32, + new_size.height as f32, + Self::VIEWPORT_MARGIN, + ); + + self.update_uniforms(); + } + } + + /// 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.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.update_uniforms(); + } + + /// Setzt Zoom und Pan auf 100% zentriert zurück. + pub fn reset_view(&mut self) { + self.camera.reset(); + self.update_uniforms(); + } + + /// Transformiert Fenster-Pixel in Seitenkoordinaten. + pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option { + 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) + } + + /// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten. + pub fn page_to_screen(&self, page_pt: glam::Vec2) -> [f32; 2] { + self.camera.page_to_screen_vec2(page_pt, &self.viewport) + } + + /// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold). + pub fn set_live_shape_mesh(&mut self, samples: Vec, vertices: Vec) { + self.live_samples = samples; + self.live_vertices = vertices; + 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_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Live Stroke Vertex Buffer (Reallocated)"), + size: (self.live_vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + if !self.live_vertices.is_empty() { + self.queue.write_buffer( + &self.live_vertex_buffer, + 0, + bytemuck::cast_slice(&self.live_vertices), + ); + } + } + + /// Setzt die Strichfarbe (RGB + Alpha) für neue Striche. + pub fn set_stroke_color(&mut self, color: [f32; 4]) { + self.current_ink_color = color; + } + + /// Konfiguriert die Strichbreite in Millimetern. + pub fn set_stroke_width_mm(&mut self, width_mm: f32) { + // 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0. + let base_radius = (width_mm * 5.0).max(0.25); + self.spline_config.min_radius = (base_radius * 0.5).max(0.15); + self.spline_config.max_radius = (base_radius * 1.5).max(0.35); + } + + /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. + pub fn set_background(&mut self, background: PageBackground) { + if self.current_background == background { + return; + } + self.current_background = background; + let mesh = generate_background_mesh(background); + self.background_vertex_count = mesh.len() as u32; + + if mesh.len() > self.background_vertex_capacity { + self.background_vertex_capacity = mesh.len() * 2; + self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Background Mesh Buffer (Reallocated)"), + size: (self.background_vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + if !mesh.is_empty() { + self.queue.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh)); + } + } + + /// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite. + pub fn sync_page(&mut self, page: &mut Page) { + page.rebuild_cache_if_dirty(); + self.set_background(page.background); + + self.committed_vertex_count = page.vertex_cache.len() as u32; + self.committed_strokes = page.stroke_ranges.clone(); + + if page.vertex_cache.is_empty() { + return; + } + + 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_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Committed Strokes Vertex Buffer (Reallocated)"), + size: (self.committed_vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + self.queue.write_buffer( + &self.committed_vertex_buffer, + 0, + bytemuck::cast_slice(&page.vertex_cache), + ); + } + + /// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten. + pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { + self.filter.reset(); + self.live_samples.clear(); + self.live_control_points.clear(); + self.live_vertices.clear(); + self.live_vertex_count = 0; + + // 1. Zwingend durch den 1€-Filter leiten + let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); + let pt = ControlPoint::new(fx, fy, fp); + self.live_control_points.push(pt); + + let sample = StylusSample { + position: glam::Vec2::new(fx, fy), + pressure: fp, + timestamp, + }; + self.live_samples.push(sample); + + self.update_live_mesh(); + } + + /// Fügt dem aktuellen Live-Strich einen neuen Bewegungspunkt hinzu. + pub fn extend_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { + if self.live_control_points.is_empty() { + self.start_stroke(page_x, page_y, pressure, timestamp); + return; + } + + // 1. Zwingend durch den 1€-Filter leiten + let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); + let new_pt = ControlPoint::new(fx, fy, fp); + + // 2. Mindestabstand zu vorherigem Kontrollpunkt prüfen (0.3 Einheiten = 0.03 mm für hohe Pfadpräzision) + if let Some(last) = self.live_control_points.last() { + let dist_sq = (new_pt.position - last.position).length_squared(); + if dist_sq < 0.09 { + return; + } + } + + // 3. Gefilterten Punkt synchron in Samples und Kontrollpunkten ablegen + let sample = StylusSample { + position: glam::Vec2::new(fx, fy), + pressure: fp, + timestamp, + }; + self.live_samples.push(sample); + self.live_control_points.push(new_pt); + self.update_live_mesh(); + } + + /// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück. + pub fn cancel_stroke(&mut self) { + self.live_samples.clear(); + self.live_control_points.clear(); + self.live_vertices.clear(); + self.live_vertex_count = 0; + self.filter.reset(); + } + + /// Schließt den aktuellen Live-Strich ab und erzeugt das fertige `Stroke`-Objekt. + pub fn finish_stroke(&mut self, stroke_id: u64) -> Option { + if self.live_vertex_count == 0 || self.live_vertices.is_empty() { + self.live_samples.clear(); + self.live_control_points.clear(); + self.live_vertices.clear(); + self.live_vertex_count = 0; + self.filter.reset(); + return None; + } + + let stroke = Stroke::new( + stroke_id, + self.live_samples.clone(), + self.live_vertices.clone(), + self.current_ink_color, + ); + + self.live_samples.clear(); + self.live_control_points.clear(); + self.live_vertices.clear(); + self.live_vertex_count = 0; + self.filter.reset(); + + Some(stroke) + } + + /// Baut das Mesh des Live-Strichs in Seitenkoordinaten neu auf. + fn update_live_mesh(&mut self) { + if self.live_control_points.is_empty() { + self.live_vertex_count = 0; + self.live_vertices.clear(); + return; + } + + let color = self.current_ink_color; + 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)) + .collect(); + self.live_vertex_count = self.live_vertices.len() as u32; + + if self.live_vertex_count == 0 { + return; + } + + 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_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Live Stroke Vertex Buffer (Reallocated)"), + size: (self.live_vertex_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + } + + self.queue.write_buffer( + &self.live_vertex_buffer, + 0, + bytemuck::cast_slice(&self.live_vertices), + ); + } + + /// Rendert den gesamten Viewport: + /// - Pass 1: Schreibtisch, DIN-A4-Papier, Hintergrundraster, permanente Striche und Live-Strich. + /// - Pass 2: egui UI (Toolbar, Menüs, Toast) über `LoadOp::Load` auf dieselbe Surface-Textur. + pub fn render( + &mut self, + clipped_primitives: &[egui::ClippedPrimitive], + screen_descriptor: &egui_wgpu::ScreenDescriptor, + textures_delta: &egui::TexturesDelta, + ) -> 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); + } + + let output = self.surface.get_current_texture()?; + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Ink & UI Render Encoder"), + }); + + // egui Vertex- und Index-Puffer aktualisieren + self.egui_renderer.update_buffers( + &self.device, + &self.queue, + &mut encoder, + clipped_primitives, + screen_descriptor, + ); + + // PASS 1: Canvas (Hintergrund, Papierquadrat, Gittermuster, Striche) + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Ink Canvas Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + // 2D-Kamera Uniforms (Scale & Offset) binden + render_pass.set_bind_group(0, &self.camera_bind_group, &[]); + + // 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt) + render_pass.set_pipeline(&self.triangle_pipeline); + render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); + render_pass.draw(0..6, 0..1); + + // 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe) + if self.background_vertex_count > 0 { + render_pass.set_vertex_buffer(0, self.background_buffer.slice(..)); + render_pass.draw(0..self.background_vertex_count, 0..1); + } + + // 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben) + render_pass.set_pipeline(&self.strip_pipeline); + + if self.committed_vertex_count > 0 { + render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..)); + for range in &self.committed_strokes { + if range.end > range.start { + render_pass.draw(range.clone(), 0..1); + } + } + } + + // 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency) + if self.live_vertex_count > 0 { + render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); + render_pass.draw(0..self.live_vertex_count, 0..1); + } + } + + // PASS 2: egui UI (LoadOp::Load auf dieselbe Surface-Textur!) + if !clipped_primitives.is_empty() { + let mut egui_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("egui UI Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + self.egui_renderer.render(&mut egui_pass, clipped_primitives, screen_descriptor); + } + + self.queue.submit(std::iter::once(encoder.finish())); + output.present(); + + // egui Texturen freigeben + for id in &textures_delta.free { + self.egui_renderer.free_texture(id); + } + + Ok(()) + } + + /// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests). + pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> { + let dummy_delta = egui::TexturesDelta::default(); + let dummy_screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: [self.size.width, self.size.height], + pixels_per_point: 1.0, + }; + self.render(&[], &dummy_screen, &dummy_delta) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pixel_to_ndc_mapping() { + let width = 800.0; + let height = 600.0; + + let top_left = pixel_to_ndc(0.0, 0.0, width, height); + assert!((top_left[0] - (-1.0)).abs() < 1e-5); + assert!((top_left[1] - 1.0).abs() < 1e-5); + + let center = pixel_to_ndc(400.0, 300.0, width, height); + assert!(center[0].abs() < 1e-5); + assert!(center[1].abs() < 1e-5); + } + + #[test] + fn test_ink_vertex_layout() { + assert_eq!(std::mem::size_of::(), 24); + let v = InkVertex::new(0.5, -0.5, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(v.position, [0.5, -0.5]); + assert_eq!(v.color, [1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn test_camera_uniforms() { + let _u = CameraUniforms::default(); + assert_eq!(std::mem::size_of::(), 16); + } + + #[test] + fn test_viewport_aspect_ratio_ndc_transform() { + let vp = PageViewport::calculate(1920.0, 1080.0, 20.0); + let (scale, offset) = vp.ndc_transform(); + + // Zentrum der DIN-A4-Seite muss im NDC-Zentrum (0, 0) landen + let center_x = 1050.0; // PAGE_WIDTH / 2 + let center_y = 1485.0; // PAGE_HEIGHT / 2 + + let ndc_x = center_x * scale[0] + offset[0]; + let ndc_y = center_y * scale[1] + offset[1]; + + assert!(ndc_x.abs() < 1e-4, "NDC X ({ndc_x}) muss nahe 0.0 sein"); + assert!(ndc_y.abs() < 1e-4, "NDC Y ({ndc_y}) muss nahe 0.0 sein"); + } +} diff --git a/src/document.rs b/src/document.rs new file mode 100644 index 0000000..63a9cd1 --- /dev/null +++ b/src/document.rs @@ -0,0 +1,975 @@ +//! Notizbuch- und Seitenverwaltung mit DIN-A4-Koordinatensystem und Vektor-Stroke-Modell. + +use std::ops::Range; + +use crate::canvas::InkVertex; +use crate::history::{HistoryAction, HistoryStack}; + +/// Standardmaße DIN A4 in Millimetern. +pub const A4_WIDTH_MM: f32 = 210.0; +pub const A4_HEIGHT_MM: f32 = 297.0; +/// Seitenverhältnis DIN A4 (1 / sqrt(2) ≈ 0.7071). +pub const A4_ASPECT_RATIO: f32 = A4_WIDTH_MM / A4_HEIGHT_MM; + +/// Virtuelle Seitenauflösung in internen Einheiten (10 Einheiten = 1 mm). +pub const PAGE_WIDTH: f32 = 2100.0; +pub const PAGE_HEIGHT: f32 = 2970.0; +pub const UNITS_PER_MM: f32 = 10.0; + +/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StylusSample { + pub position: glam::Vec2, + pub pressure: f32, + pub timestamp: f64, +} + +/// 2D Axis-Aligned Bounding Box (AABB) für schnellen Kollisionsausschluss. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Aabb { + pub min: glam::Vec2, + pub max: glam::Vec2, +} + +impl Aabb { + pub fn new(min: glam::Vec2, max: glam::Vec2) -> Self { + Self { min, max } + } + + pub fn from_point(p: glam::Vec2) -> Self { + Self { min: p, max: p } + } + + pub fn from_points(points: &[glam::Vec2]) -> Self { + if points.is_empty() { + return Self::new(glam::Vec2::ZERO, glam::Vec2::ZERO); + } + let mut min = points[0]; + let mut max = points[0]; + for &p in &points[1..] { + min = min.min(p); + max = max.max(p); + } + Self { min, max } + } + + pub fn expand_by_point(&mut self, p: glam::Vec2) { + self.min = self.min.min(p); + self.max = self.max.max(p); + } + + /// Vergrößert die Bounding Box allseitig um einen Radius `r`. + pub fn expanded(&self, radius: f32) -> Self { + let r = glam::Vec2::splat(radius); + Self { + min: self.min - r, + max: self.max + r, + } + } + + /// Prüft, ob ein Punkt innerhalb der Bounding Box liegt. + pub fn contains_point(&self, p: glam::Vec2) -> bool { + p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y + } + + /// Prüft, ob zwei Bounding Boxes überlappen. + pub fn intersects(&self, other: &Aabb) -> bool { + self.min.x <= other.max.x + && self.max.x >= other.min.x + && self.min.y <= other.max.y + && self.max.y >= other.min.y + } + + /// Kombiniert diese Bounding Box mit einer anderen zu einer minimalen Hüllbox. + pub fn union(&self, other: &Aabb) -> Self { + Self { + min: self.min.min(other.min), + max: self.max.max(other.max), + } + } +} + +/// Berechnet den minimalen Abstand eines Punktes `p` zu einer Strecke zwischen `a` und `b`. +pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) -> f32 { + let ab = b - a; + let len_sq = ab.length_squared(); + if len_sq < 1e-6 { + return (p - a).length(); + } + // Orthogonale Projektion t ∈ [0, 1] + let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0); + let projection = a + t * ab; + (p - projection).length() +} + +/// Ein einzelner Vektor-Strich auf einer Seite. +#[derive(Debug, Clone, PartialEq)] +pub struct Stroke { + pub id: u64, + pub points: Vec, + pub vertices: Vec, + pub aabb: Aabb, + pub color: [f32; 4], + pub shape: Option, +} + +impl Stroke { + pub fn new(id: u64, points: Vec, vertices: Vec, color: [f32; 4]) -> Self { + let aabb = if !points.is_empty() { + let pts: Vec = points.iter().map(|s| s.position).collect(); + Aabb::from_points(&pts) + } else if !vertices.is_empty() { + let pts: Vec = 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) + }; + + Self { + id, + points, + vertices, + aabb, + color, + shape: None, + } + } + + /// Verknüpft diesen Strich mit einer mathematisch erkannten Form. + pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self { + self.shape = Some(shape); + self + } + + /// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich. + pub fn distance_to_point(&self, p: glam::Vec2) -> f32 { + if self.points.is_empty() { + return f32::INFINITY; + } + if self.points.len() == 1 { + return (p - self.points[0].position).length(); + } + let mut min_dist = f32::INFINITY; + for window in self.points.windows(2) { + let d = point_to_segment_distance(p, window[0].position, window[1].position); + if d < min_dist { + min_dist = d; + } + } + min_dist + } + + /// 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 { + // 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) { + return false; + } + + // 2. Punkt-zu-Strecke-Distanztest entlang aller Segmente + if self.points.len() < 2 { + if let Some(first) = self.points.first() { + let dist = (eraser_pos - first.position).length(); + return dist <= total_margin; + } + return false; + } + + for i in 0..self.points.len() - 1 { + let p1 = self.points[i].position; + let p2 = self.points[i + 1].position; + let dist = point_to_segment_distance(eraser_pos, p1, p2); + if dist <= total_margin { + return true; + } + } + + false + } + + /// Berechnet die Bounding Box aus den aktuellen Punkten/Vertizes neu. + pub fn recompute_aabb(&mut self) { + self.aabb = if !self.points.is_empty() { + let pts: Vec = self.points.iter().map(|s| s.position).collect(); + Aabb::from_points(&pts) + } else if !self.vertices.is_empty() { + let pts: Vec = self + .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) + }; + } + + /// Verschiebt den Strich um `delta` (DIN-A4-Einheiten). + pub fn translate(&mut self, delta: glam::Vec2) { + for pt in &mut self.points { + pt.position += delta; + } + for v in &mut self.vertices { + v.position[0] += delta.x; + v.position[1] += delta.y; + } + if let Some(ref mut shape) = self.shape { + shape.translate(delta); + } + self.recompute_aabb(); + } + + /// Dreht den Strich um `origin` mit dem Winkel `angle_rad`. + pub fn rotate(&mut self, origin: glam::Vec2, angle_rad: f32) { + let (sin, cos) = angle_rad.sin_cos(); + let rot = |pos: glam::Vec2| -> glam::Vec2 { + let diff = pos - origin; + origin + glam::Vec2::new(diff.x * cos - diff.y * sin, diff.x * sin + diff.y * cos) + }; + + for pt in &mut self.points { + pt.position = rot(pt.position); + } + for v in &mut self.vertices { + let rotated = rot(glam::Vec2::new(v.position[0], v.position[1])); + v.position = [rotated.x, rotated.y]; + } + if let Some(ref mut shape) = self.shape { + shape.rotate(origin, angle_rad); + } + self.recompute_aabb(); + } + + /// 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 + }; + + for pt in &mut self.points { + pt.position = sc(pt.position); + } + for v in &mut self.vertices { + let scaled = sc(glam::Vec2::new(v.position[0], v.position[1])); + v.position = [scaled.x, scaled.y]; + } + if let Some(ref mut shape) = self.shape { + shape.scale(origin, factors); + } + self.recompute_aabb(); + } + + /// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben. + pub fn set_color(&mut self, new_color: [f32; 4]) { + self.color = new_color; + for v in &mut self.vertices { + v.color = new_color; + } + } +} + +/// Hintergrundtyp für eine DIN-A4-Seite. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PageBackground { + /// Komplett blanko (Weiß) + Blank, + /// 5 mm Karomuster + Grid5mm, + /// Linierte Seite mit 7 mm Zeilenabstand + Lined, + /// Punktraster im 5 mm Abstand + DotGrid, +} + +/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes. +#[derive(Debug, Clone)] +pub struct Page { + pub id: u64, + pub strokes: Vec, + pub background: PageBackground, + pub vertex_cache: Vec, + pub stroke_ranges: Vec>, + pub is_dirty: bool, + pub is_loaded: bool, + pub history: HistoryStack, +} + +impl Page { + pub fn new(id: u64, background: PageBackground) -> Self { + Self { + id, + strokes: Vec::new(), + background, + vertex_cache: Vec::new(), + stroke_ranges: Vec::new(), + is_dirty: false, + is_loaded: true, + history: HistoryStack::new(), + } + } + + /// Erzeugt einen noch nicht aus dem Speicher geladenen Seiten-Platzhalter (Lazy Loading). + pub fn new_unloaded(id: u64, background: PageBackground) -> Self { + Self { + id, + strokes: Vec::new(), + background, + vertex_cache: Vec::new(), + stroke_ranges: Vec::new(), + is_dirty: false, + is_loaded: false, + history: HistoryStack::new(), + } + } + + /// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack. + pub fn add_stroke(&mut self, stroke: Stroke) { + self.history.push(HistoryAction::AddStroke(stroke.clone())); + self.strokes.push(stroke); + self.is_dirty = true; + } + + /// Ersetzt einen vorhandenen Strich (z. B. durch eine erkannte geometrische Form) + /// und hinterlegt die Aktion für Undo/Redo im History-Stack. + pub fn replace_stroke(&mut self, old_id: u64, new_stroke: Stroke) -> bool { + if let Some(pos) = self.strokes.iter().position(|s| s.id == old_id) { + let old = self.strokes[pos].clone(); + self.history.push(HistoryAction::ReplaceStroke { + old, + new: new_stroke.clone(), + }); + self.strokes[pos] = new_stroke; + self.is_dirty = true; + true + } else { + false + } + } + + /// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius. + pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> { + let mut best: Option<(&Stroke, f32)> = None; + for s in &self.strokes { + if !s.aabb.expanded(max_dist).contains_point(pos) { + continue; + } + let d = s.distance_to_point(pos); + if d <= max_dist { + if let Some((_, best_d)) = best { + if d < best_d { + best = Some((s, d)); + } + } else { + best = Some((s, d)); + } + } + } + best.map(|(s, _)| s) + } + + /// Macht die letzte Aktion rückgängig (Undo). + pub fn undo(&mut self) -> bool { + if self.history.undo(&mut self.strokes) { + self.is_dirty = true; + true + } else { + false + } + } + + /// Stellt die letzte rückgängig gemachte Aktion wieder her (Redo). + pub fn redo(&mut self) -> bool { + if self.history.redo(&mut self.strokes) { + self.is_dirty = true; + true + } else { + false + } + } + + /// 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 { + let mut hit_indices = Vec::new(); + + for (i, stroke) in self.strokes.iter().enumerate() { + if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) { + hit_indices.push(i); + } + } + + if hit_indices.is_empty() { + return Vec::new(); + } + + // Rückwärts entfernen, um Indexverschiebungen zu vermeiden + let mut removed = Vec::with_capacity(hit_indices.len()); + for &idx in hit_indices.iter().rev() { + removed.push(self.strokes.remove(idx)); + } + + removed.reverse(); + self.history.push(HistoryAction::RemoveStrokes(removed.clone())); + self.is_dirty = true; + + removed + } + + /// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist. + pub fn rebuild_cache_if_dirty(&mut self) { + if !self.is_dirty { + return; + } + + self.vertex_cache.clear(); + self.stroke_ranges.clear(); + + for stroke in &self.strokes { + let start = self.vertex_cache.len() as u32; + self.vertex_cache.extend_from_slice(&stroke.vertices); + let end = self.vertex_cache.len() as u32; + self.stroke_ranges.push(start..end); + } + + self.is_dirty = false; + } + + /// Berechnet die kombinierte AABB einer Menge von Strichen. + pub fn selection_aabb(strokes: &[Stroke]) -> Option { + strokes.first().map(|first| { + let mut combined = first.aabb; + for s in strokes.iter().skip(1) { + combined = combined.union(&s.aabb); + } + combined + }) + } + + /// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen. + pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec { + let sel_box = Aabb::new( + glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)), + glam::Vec2::new(box_min.x.max(box_max.x), box_min.y.max(box_max.y)), + ); + self.strokes + .iter() + .filter(|s| s.aabb.intersects(&sel_box)) + .map(|s| s.id) + .collect() + } + + /// Liefert Klone der Striche mit den angegebenen IDs. + pub fn get_strokes(&self, ids: &[u64]) -> Vec { + self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect() + } + + /// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack. + pub fn modify_strokes(&mut self, modified: Vec) { + if modified.is_empty() { + return; + } + let ids: Vec = modified.iter().map(|s| s.id).collect(); + let before: Vec = 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) { + self.strokes[pos] = m.clone(); + } + } + + self.history.push(HistoryAction::ModifyStrokes { + before, + after: modified, + }); + self.is_dirty = true; + } + + /// Aktualisiert Striche flüchtig (z. B. während des Ziehens / Dragging), OHNE die History zu verändern. + pub fn update_strokes_ephemeral(&mut self, modified: &[Stroke]) { + for m in modified { + if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) { + self.strokes[pos] = m.clone(); + } + } + self.is_dirty = true; + } + + /// Schließt eine Transformationsoperation transaktional mit vorherigem und finalem Zustand ab. + pub fn commit_modified_strokes(&mut self, before: Vec, after: Vec) { + if before.is_empty() || after.is_empty() { + return; + } + for m in &after { + if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) { + self.strokes[pos] = m.clone(); + } + } + self.history.push(HistoryAction::ModifyStrokes { before, after }); + self.is_dirty = true; + } + + /// Entfernt Striche anhand ihrer IDs und hinterlegt die Aktion im History-Stack. + pub fn remove_strokes_by_ids(&mut self, ids: &[u64]) -> Vec { + let mut removed = Vec::new(); + self.strokes.retain(|s| { + if ids.contains(&s.id) { + removed.push(s.clone()); + false + } else { + true + } + }); + + if !removed.is_empty() { + self.history.push(HistoryAction::RemoveStrokes(removed.clone())); + self.is_dirty = true; + } + removed + } + + /// Fügt mehrere Striche hinzu und hinterlegt die Aktion transaktional im History-Stack. + pub fn add_strokes(&mut self, new_strokes: Vec) { + if new_strokes.is_empty() { + return; + } + self.history.push(HistoryAction::AddStrokes(new_strokes.clone())); + self.strokes.extend(new_strokes); + self.is_dirty = true; + } +} + +/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation. +#[derive(Debug, Clone)] +pub struct Notebook { + pub pages: Vec, + pub current_page_index: usize, + pub next_stroke_id: u64, + pub next_page_id: u64, +} + +impl Notebook { + pub fn new() -> Self { + let mut nb = Self { + pages: Vec::new(), + current_page_index: 0, + next_stroke_id: 1, + next_page_id: 1, + }; + // Erste Standardseite anlegen (5mm Grid) + nb.add_page(PageBackground::Grid5mm); + nb + } + + /// Erzeugt ein Notizbuch aus vorhandenen Seiten und Zählern (für Session-Wiederherstellung). + pub fn from_pages( + pages: Vec, + current_page_index: usize, + next_stroke_id: u64, + next_page_id: u64, + ) -> Self { + let mut nb = Self { + pages, + current_page_index, + next_stroke_id, + next_page_id, + }; + if nb.pages.is_empty() { + nb.add_page(PageBackground::Grid5mm); + } + if nb.current_page_index >= nb.pages.len() { + nb.current_page_index = 0; + } + nb + } + + /// Gibt die aktuell aktive Seite zurück. + pub fn current_page(&self) -> &Page { + &self.pages[self.current_page_index] + } + + /// Gibt die aktuell aktive Seite als veränderliche Referenz zurück. + pub fn current_page_mut(&mut self) -> &mut Page { + &mut self.pages[self.current_page_index] + } + + /// Erzeugt eine eindeutige neue Stroke-ID. + pub fn next_stroke_id(&mut self) -> u64 { + let id = self.next_stroke_id; + self.next_stroke_id += 1; + id + } + + /// Fügt eine neue Seite mit dem angegebenen Hintergrund hinzu. + pub fn add_page(&mut self, background: PageBackground) -> usize { + let id = self.next_page_id; + self.next_page_id += 1; + self.pages.push(Page::new(id, background)); + self.pages.len() - 1 + } + + /// Wechselt zur nächsten Seite (PageDown / N). Hängt automatisch eine neue Seite an, falls am Ende. + pub fn next_page(&mut self) { + if self.current_page_index + 1 < self.pages.len() { + self.current_page_index += 1; + } else { + // Neue leere Seite mit gleichem Hintergrund anhängen + let current_bg = self.current_page().background; + let new_idx = self.add_page(current_bg); + self.current_page_index = new_idx; + } + } + + /// Wechselt zur vorherigen Seite (PageUp / P). + pub fn previous_page(&mut self) { + if self.current_page_index > 0 { + self.current_page_index -= 1; + } + } + + /// Springt zur ersten Seite (Pos1 / Home). + pub fn first_page(&mut self) { + self.current_page_index = 0; + } + + /// Springt zur letzten Seite (Ende / End). + pub fn last_page(&mut self) { + if !self.pages.is_empty() { + self.current_page_index = self.pages.len() - 1; + } + } + + /// 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()) + } +} + +impl Default for Notebook { + fn default() -> Self { + Self::new() + } +} + +/// Berechnet die DIN-A4-Darstellung im Fenster mit Letterboxing/Pillarboxing. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PageViewport { + pub window_width: f32, + pub window_height: f32, + pub page_x: f32, + pub page_y: f32, + pub page_width: f32, + pub page_height: f32, +} + +impl PageViewport { + /// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses. + pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self { + let avail_w = (window_width - 2.0 * margin).max(10.0); + let avail_h = (window_height - 2.0 * margin).max(10.0); + + let window_ratio = avail_w / avail_h; + let page_ratio = A4_ASPECT_RATIO; + + let (page_w, page_h) = if window_ratio > page_ratio { + // Fenster ist breiter als DIN A4 -> Pillarboxing (Balken links/rechts) + let h = avail_h; + let w = h * page_ratio; + (w, h) + } else { + // Fenster ist höher als DIN A4 -> Letterboxing (Balken oben/unten) + let w = avail_w; + let h = w / page_ratio; + (w, h) + }; + + let page_x = (window_width - page_w) / 2.0; + let page_y = (window_height - page_h) / 2.0; + + Self { + window_width, + window_height, + page_x, + page_y, + page_width: page_w, + page_height: page_h, + } + } + + /// Transformiert einen Fenster-Pixelpunkt in DIN-A4-Seitenkoordinaten [0.0, PAGE_WIDTH] x [0.0, PAGE_HEIGHT]. + /// + /// Liefert `None`, falls der Punkt außerhalb der DIN-A4-Papierfläche liegt. + pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option { + let u = (screen_x - self.page_x) / self.page_width; + let v = (screen_y - self.page_y) / self.page_height; + + if (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) { + Some(glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT)) + } else { + None + } + } + + /// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die DIN-A4-Papierfläche. + pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 { + let u = ((screen_x - self.page_x) / self.page_width).clamp(0.0, 1.0); + let v = ((screen_y - self.page_y) / self.page_height).clamp(0.0, 1.0); + glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT) + } + + /// Berechnet die Shader-Transformationsparameter (Scale, Offset), + /// um DIN-A4-Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden. + pub fn ndc_transform(&self) -> ([f32; 2], [f32; 2]) { + let scale_x = (2.0 * self.page_width) / (self.window_width * PAGE_WIDTH); + let scale_y = (-2.0 * self.page_height) / (self.window_height * PAGE_HEIGHT); + let offset_x = (2.0 * self.page_x) / self.window_width - 1.0; + let offset_y = 1.0 - (2.0 * self.page_y) / self.window_height; + ([scale_x, scale_y], [offset_x, offset_y]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_point_to_segment_distance() { + let a = glam::Vec2::new(0.0, 0.0); + let b = glam::Vec2::new(10.0, 0.0); + + // Punkt liegt genau senkrecht über der Streckenmitte (5, 5) + let p_mid = glam::Vec2::new(5.0, 5.0); + assert!((point_to_segment_distance(p_mid, a, b) - 5.0).abs() < 1e-5); + + // Punkt liegt links von a (-5, 0) -> Abstand zu a + let p_left = glam::Vec2::new(-5.0, 0.0); + assert!((point_to_segment_distance(p_left, a, b) - 5.0).abs() < 1e-5); + + // Punkt liegt rechts von b (15, 0) -> Abstand zu b + let p_right = glam::Vec2::new(15.0, 0.0); + assert!((point_to_segment_distance(p_right, a, b) - 5.0).abs() < 1e-5); + + // Punkt liegt direkt auf der Strecke + let p_on = glam::Vec2::new(3.0, 0.0); + assert!(point_to_segment_distance(p_on, a, b) < 1e-5); + } + + #[test] + fn test_stroke_eraser_hit() { + let points = vec![ + StylusSample { + position: glam::Vec2::new(100.0, 100.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(200.0, 100.0), + pressure: 0.5, + timestamp: 0.01, + }, + ]; + + let stroke = Stroke::new(1, points, Vec::new(), [0.0, 0.0, 0.0, 1.0]); + + let eraser_radius = 10.0; + 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)); + + // Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12) + 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)); + } + + #[test] + fn test_notebook_navigation() { + let mut nb = Notebook::new(); + assert_eq!(nb.pages.len(), 1); + assert_eq!(nb.current_page_index, 0); + assert_eq!(nb.osd_text(), "Seite 1 / 1"); + + // Weiterblättern am Ende erzeugt automatisch neue Seite + nb.next_page(); + assert_eq!(nb.pages.len(), 2); + assert_eq!(nb.current_page_index, 1); + assert_eq!(nb.osd_text(), "Seite 2 / 2"); + + // Zurückblättern + nb.previous_page(); + assert_eq!(nb.current_page_index, 0); + + // Zurückblättern auf erster Seite bleibt auf Seite 0 + nb.previous_page(); + assert_eq!(nb.current_page_index, 0); + + // Ende und Home + nb.last_page(); + assert_eq!(nb.current_page_index, 1); + nb.first_page(); + assert_eq!(nb.current_page_index, 0); + } + + #[test] + fn test_page_viewport_projection() { + let vp = PageViewport::calculate(1600.0, 900.0, 20.0); + assert!(vp.page_width > 0.0); + assert!(vp.page_height > 0.0); + + // Aspekt DIN A4 muss exakt erhalten bleiben + let ratio = vp.page_width / vp.page_height; + assert!((ratio - A4_ASPECT_RATIO).abs() < 1e-4); + + // Transformation Mitte des Papiers + let center_screen_x = vp.page_x + vp.page_width / 2.0; + let center_screen_y = vp.page_y + vp.page_height / 2.0; + let page_pt = vp.screen_to_page(center_screen_x, center_screen_y).unwrap(); + assert!((page_pt.x - PAGE_WIDTH / 2.0).abs() < 1.0); + assert!((page_pt.y - PAGE_HEIGHT / 2.0).abs() < 1.0); + + // NDC-Transformation der Mitte muss (0.0, 0.0) ergeben + let (scale, offset) = vp.ndc_transform(); + let ndc_x = (PAGE_WIDTH / 2.0) * scale[0] + offset[0]; + let ndc_y = (PAGE_HEIGHT / 2.0) * scale[1] + offset[1]; + assert!(ndc_x.abs() < 1e-4); + assert!(ndc_y.abs() < 1e-4); + } + + #[test] + fn test_page_erase_and_undo_redo() { + let mut page = Page::new(1, PageBackground::Grid5mm); + + let s1 = Stroke::new( + 1, + vec![ + StylusSample { + position: glam::Vec2::new(10.0, 10.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(50.0, 10.0), + pressure: 0.5, + timestamp: 0.01, + }, + ], + vec![ + InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]), + InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]), + ], + [0.0, 0.0, 0.0, 1.0], + ); + + let s2 = Stroke::new( + 2, + vec![ + StylusSample { + position: glam::Vec2::new(10.0, 100.0), + pressure: 0.5, + timestamp: 0.02, + }, + StylusSample { + position: glam::Vec2::new(50.0, 100.0), + pressure: 0.5, + timestamp: 0.03, + }, + ], + vec![ + InkVertex::new(10.0, 100.0, [0.0, 0.0, 0.0, 1.0]), + InkVertex::new(50.0, 100.0, [0.0, 0.0, 0.0, 1.0]), + ], + [0.0, 0.0, 0.0, 1.0], + ); + + page.add_stroke(s1); + page.add_stroke(s2); + assert_eq!(page.strokes.len(), 2); + + // Radieren bei (30, 12): Trifft nur s1 + let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0); + assert_eq!(removed.len(), 1); + assert_eq!(removed[0].id, 1); + assert_eq!(page.strokes.len(), 1); + assert_eq!(page.strokes[0].id, 2); + + // Undo: s1 wird wiederhergestellt + assert!(page.undo()); + assert_eq!(page.strokes.len(), 2); + + // Redo: s1 wird wieder gelöscht + assert!(page.redo()); + assert_eq!(page.strokes.len(), 1); + assert_eq!(page.strokes[0].id, 2); + } + + #[test] + fn test_page_isolation() { + let mut nb = Notebook::new(); + + let s1 = Stroke::new( + 100, + vec![StylusSample { + position: glam::Vec2::new(50.0, 50.0), + pressure: 0.5, + timestamp: 0.0, + }], + Vec::new(), + [0.0, 0.0, 0.0, 1.0], + ); + + nb.current_page_mut().add_stroke(s1); + assert_eq!(nb.current_page().strokes.len(), 1); + + // Neue Seite anlegen + nb.next_page(); + assert_eq!(nb.current_page_index, 1); + // Neue Seite muss leer sein + assert_eq!(nb.current_page().strokes.len(), 0); + + // Zurück zu Seite 0: Strich muss unverändert da sein + nb.previous_page(); + assert_eq!(nb.current_page_index, 0); + assert_eq!(nb.current_page().strokes.len(), 1); + assert_eq!(nb.current_page().strokes[0].id, 100); + } + + #[test] + fn test_page_ephemeral_and_commit_manipulations() { + let mut page = Page::new(1, PageBackground::Blank); + let s = Stroke::new( + 10, + vec![StylusSample { + position: glam::Vec2::new(10.0, 10.0), + pressure: 0.5, + timestamp: 0.0, + }], + vec![InkVertex::new(10.0, 10.0, [1.0, 0.0, 0.0, 1.0])], + [1.0, 0.0, 0.0, 1.0], + ); + page.add_stroke(s.clone()); + + // Ephemeral modification (simulating in-progress mouse drag) + 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)); + // History should not have been updated yet + assert_eq!(page.history.can_redo(), false); + + // Transactional commit (mouse release) + let before = vec![s.clone()]; + let after = vec![moved.clone()]; + page.commit_modified_strokes(before, after); + + // Verify Undo restores original position + assert!(page.undo()); + 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)); + } +} diff --git a/src/export.rs b/src/export.rs new file mode 100644 index 0000000..9289a40 --- /dev/null +++ b/src/export.rs @@ -0,0 +1,824 @@ +//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs via printpdf. +//! +//! Features: +//! - Hochformat DIN A4 (210 mm x 297 mm) +//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink") +//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links) +//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) +//! - Zwei Export-Strategien: +//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke) +//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line` + +use std::error::Error; +use std::fs::File; +use std::io::BufWriter; +use std::path::Path; + +use printpdf::path::{PaintMode, WindingOrder}; +use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; + +use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; +use crate::document::{ + Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, +}; + +/// Wählbare Exportstrategie für Vektorstriche. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ExportStrategy { + /// Gefüllte Polygone aus den Triangle-Strip-Hüllen (ideal für variable Druckstärken). + #[default] + FilledPolygon, + /// Kubische Bézier-Kurven mit Segment-Linienstärke. + BezierCurve, +} + +/// Rechnet eine interne Seitenkoordinate (x, y) in PDF-Millimeter-Koordinaten um (mit Y-Inversion). +#[inline] +pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) { + let x_norm = (x / PAGE_WIDTH).clamp(0.0, 1.0); + let y_norm = (y / PAGE_HEIGHT).clamp(0.0, 1.0); + + let x_mm = x_norm * A4_WIDTH_MM; + let y_mm = (1.0 - y_norm) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links + + (x_mm, y_mm) +} + +/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. +pub fn export_notebook_to_pdf>( + notebook: &Notebook, + path: P, + strategy: ExportStrategy, +) -> Result<(), Box> { + let file = File::create(path)?; + let mut writer = BufWriter::new(file); + let doc = generate_pdf_document(notebook, strategy)?; + doc.save(&mut writer)?; + Ok(()) +} + +/// Erzeugt ein PDF-Dokument aus einem Notizbuch und gibt die rohen Bytes zurück. +pub fn export_notebook_to_bytes( + notebook: &Notebook, + strategy: ExportStrategy, +) -> Result, Box> { + let doc = generate_pdf_document(notebook, strategy)?; + let bytes = doc.save_to_bytes()?; + Ok(bytes) +} + +/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs. +pub fn generate_pdf_document( + notebook: &Notebook, + strategy: ExportStrategy, +) -> Result> { + if notebook.pages.is_empty() { + return Err("Notizbuch enthält keine Seiten".into()); + } + + // 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", + ); + + // Seite 1 befüllen + let page1_ref = doc.get_page(page1_idx); + let layer1_bg_ref = page1_ref.get_layer(layer1_bg); + render_background_layer(&layer1_bg_ref, notebook.pages[0].background); + + let layer1_ink_ref = page1_ref.add_layer("Ink"); + render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy); + + // 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_ref = doc.get_page(page_idx); + let layer_bg_ref = page_ref.get_layer(layer_bg); + render_background_layer(&layer_bg_ref, page.background); + + let layer_ink_ref = page_ref.add_layer("Ink"); + render_ink_layer(&layer_ink_ref, page, strategy); + } + + Ok(doc) +} + +/// Rendert das Vektorraster auf Layer 1 ("Background"). +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) + layer.set_fill_color(grid_color); + + match background { + PageBackground::Blank => {} + + PageBackground::Grid5mm => { + let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm + + // Vertikale Linien alle 5 mm + let mut x = spacing_mm; + while x < A4_WIDTH_MM { + let line = Line { + points: vec![ + (Point::new(Mm(x), Mm(0.0)), false), + (Point::new(Mm(x), Mm(A4_HEIGHT_MM)), false), + ], + is_closed: false, + }; + layer.add_line(line); + x += spacing_mm; + } + + // Horizontale Linien alle 5 mm + let mut y = spacing_mm; + while y < A4_HEIGHT_MM { + let line = Line { + points: vec![ + (Point::new(Mm(0.0), Mm(y)), false), + (Point::new(Mm(A4_WIDTH_MM), Mm(y)), false), + ], + is_closed: false, + }; + layer.add_line(line); + y += spacing_mm; + } + } + + PageBackground::Lined => { + let spacing_mm = LINED_SPACING / 10.0; // 7.5 mm + let margin_top_mm = 15.0; + let margin_bottom_mm = 15.0; + + let mut y = margin_bottom_mm; + while y <= A4_HEIGHT_MM - margin_top_mm { + let line = Line { + points: vec![ + (Point::new(Mm(0.0), Mm(y)), false), + (Point::new(Mm(A4_WIDTH_MM), Mm(y)), false), + ], + is_closed: false, + }; + layer.add_line(line); + y += spacing_mm; + } + } + + PageBackground::DotGrid => { + let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm + let half_dot = 0.2; // 0.4 mm Kantenlänge + + let mut y = spacing_mm; + while y < A4_HEIGHT_MM { + let mut x = spacing_mm; + while x < A4_WIDTH_MM { + let dot_poly = Polygon { + rings: vec![vec![ + (Point::new(Mm(x - half_dot), Mm(y - half_dot)), false), + (Point::new(Mm(x + half_dot), Mm(y - half_dot)), false), + (Point::new(Mm(x + half_dot), Mm(y + half_dot)), false), + (Point::new(Mm(x - half_dot), Mm(y + half_dot)), false), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(dot_poly); + x += spacing_mm; + } + y += spacing_mm; + } + } + } +} + +/// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). +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); + } else { + match strategy { + ExportStrategy::FilledPolygon => { + render_stroke_polygon(layer, stroke); + } + ExportStrategy::BezierCurve => { + render_stroke_bezier(layer, stroke); + } + } + } + } +} + +/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) +/// als verlustfreien Vektorpfad im PDF. +fn render_recognized_shape( + layer: &printpdf::PdfLayerReference, + shape: &crate::geometry::RecognizedShape, + color: [f32; 4], +) { + use crate::geometry::RecognizedShape; + + let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None)); + layer.set_outline_color(stroke_color.clone()); + layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke + + match shape { + RecognizedShape::Line { start, end } => { + let (x0, y0) = page_to_pdf_mm(start.x, start.y); + let (x1, y1) = page_to_pdf_mm(end.x, end.y); + let line = Line { + points: vec![ + (Point::new(Mm(x0), Mm(y0)), false), + (Point::new(Mm(x1), Mm(y1)), false), + ], + is_closed: false, + }; + layer.add_line(line); + } + + RecognizedShape::Circle { center, radius } => { + let (cx, cy) = page_to_pdf_mm(center.x, center.y); + let r_mm = radius * (A4_WIDTH_MM / PAGE_WIDTH); + let segments = 64; + let mut ring = Vec::with_capacity(segments); + for i in 0..segments { + let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; + let px = cx + angle.cos() * r_mm; + let py = cy + angle.sin() * r_mm; + ring.push((Point::new(Mm(px), Mm(py)), false)); + } + let poly = Polygon { + rings: vec![ring], + mode: PaintMode::Stroke, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); + } + + RecognizedShape::Rectangle { corners } => { + let mut ring = Vec::with_capacity(4); + for c in corners { + let (x, y) = page_to_pdf_mm(c.x, c.y); + ring.push((Point::new(Mm(x), Mm(y)), false)); + } + let poly = Polygon { + rings: vec![ring], + mode: PaintMode::Stroke, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); + } + + RecognizedShape::Triangle { corners } => { + let mut ring = Vec::with_capacity(3); + for c in corners { + let (x, y) = page_to_pdf_mm(c.x, c.y); + ring.push((Point::new(Mm(x), Mm(y)), false)); + } + let poly = Polygon { + rings: vec![ring], + mode: PaintMode::Stroke, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); + } + + RecognizedShape::Rhombus { corners } => { + let mut ring = Vec::with_capacity(4); + for c in corners { + let (x, y) = page_to_pdf_mm(c.x, c.y); + ring.push((Point::new(Mm(x), Mm(y)), false)); + } + let poly = Polygon { + rings: vec![ring], + mode: PaintMode::Stroke, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); + } + + RecognizedShape::Polygon { vertices } => { + let mut ring = Vec::with_capacity(vertices.len()); + for c in vertices { + let (x, y) = page_to_pdf_mm(c.x, c.y); + ring.push((Point::new(Mm(x), Mm(y)), false)); + } + let poly = Polygon { + rings: vec![ring], + mode: PaintMode::Stroke, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); + } + + 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); + let (x2, y2) = page_to_pdf_mm(p2.x, p2.y); + let line = Line { + points: vec![ + (Point::new(Mm(x1), Mm(y1)), false), + (Point::new(Mm(x2), Mm(y2)), false), + ], + is_closed: false, + }; + layer.add_line(line); + } + } + + RecognizedShape::CoordinateSystem { + origin, + x_axis_end, + y_axis_end, + with_arrows, + } => { + let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); + let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); + let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); + + // Achse 1 (Y) + let line_y = Line { + points: vec![ + (Point::new(Mm(ox), Mm(oy)), false), + (Point::new(Mm(yx), Mm(yy)), false), + ], + is_closed: false, + }; + layer.add_line(line_y); + + // Achse 2 (X) + let line_x = Line { + points: vec![ + (Point::new(Mm(ox), Mm(oy)), false), + (Point::new(Mm(xx), Mm(xy)), false), + ], + is_closed: false, + }; + layer.add_line(line_x); + + // Pfeilspitzen als gefüllte Dreiecke + if *with_arrows { + layer.set_fill_color(stroke_color); + + let y_dir = (*y_axis_end - *origin).normalize_or_zero(); + let arrow_len_page = 25.0; + let arrow_w_page = 16.0; + + let base_y = *y_axis_end - y_dir * arrow_len_page; + let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); + let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); + + let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); + let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y); + let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y); + + let arrow_poly_y = Polygon { + rings: vec![vec![ + (Point::new(Mm(tx), Mm(ty)), false), + (Point::new(Mm(lx), Mm(ly)), false), + (Point::new(Mm(rx), Mm(ry)), false), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(arrow_poly_y); + + let x_dir = (*x_axis_end - *origin).normalize_or_zero(); + let base_x = *x_axis_end - x_dir * arrow_len_page; + let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5); + let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5); + + let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); + let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y); + let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y); + + let arrow_poly_x = Polygon { + rings: vec![vec![ + (Point::new(Mm(tx_x), Mm(ty_x)), false), + (Point::new(Mm(lx_x), Mm(ly_x)), false), + (Point::new(Mm(rx_x), Mm(ry_x)), false), + ]], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(arrow_poly_x); + } + } + } +} + +/// Strategie A: Gefülltes Polygon aus den Triangle-Strip-Hüllpunkten. +fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { + if stroke.vertices.is_empty() { + return; + } + + let n = stroke.vertices.len() / 2; + if n < 2 { + // Einzelner Klickpunkt (Dot): Kreisförmiges Polygon rendern + if let Some(first) = stroke.points.first() { + let (cx, cy) = page_to_pdf_mm(first.position.x, first.position.y); + let r_mm = (first.pressure * 0.75 + 0.25).max(0.2); // ca. 0.25 bis 1.0 mm + + let segments = 12; + let mut dot_pts = Vec::with_capacity(segments); + for i in 0..segments { + let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; + let px = cx + angle.cos() * r_mm; + let py = cy + angle.sin() * r_mm; + dot_pts.push((Point::new(Mm(px), Mm(py)), false)); + } + + let fill_color = Color::Rgb(Rgb::new( + stroke.color[0], + stroke.color[1], + stroke.color[2], + None, + )); + layer.set_fill_color(fill_color); + + let poly = Polygon { + rings: vec![dot_pts], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); + } + return; + } + + // Geschlossener Pfad um den Ribbon-Rand: + // Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0] + let mut ring = Vec::with_capacity(n * 2); + + // 1. Linke Hüllkurve vorwärts + for i in 0..n { + let v = stroke.vertices[i * 2]; + let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]); + ring.push((Point::new(Mm(x), Mm(y)), false)); + } + + // 2. Rechte Hüllkurve rückwärts + for i in (0..n).rev() { + let v = stroke.vertices[i * 2 + 1]; + let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]); + ring.push((Point::new(Mm(x), Mm(y)), false)); + } + + let fill_color = Color::Rgb(Rgb::new( + stroke.color[0], + stroke.color[1], + stroke.color[2], + None, + )); + layer.set_fill_color(fill_color); + + let poly = Polygon { + rings: vec![ring], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + layer.add_polygon(poly); +} + +/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line. +fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { + if stroke.points.is_empty() { + return; + } + + let outline_color = Color::Rgb(Rgb::new( + stroke.color[0], + stroke.color[1], + stroke.color[2], + None, + )); + layer.set_outline_color(outline_color); + layer.set_line_cap_style(printpdf::LineCapStyle::Round); + 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::() / 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); + + if stroke.points.len() == 1 { + // Einpunktiger Stroke + let (x, y) = page_to_pdf_mm(stroke.points[0].position.x, stroke.points[0].position.y); + let line = Line { + points: vec![ + (Point::new(Mm(x), Mm(y)), false), + (Point::new(Mm(x + 0.05), Mm(y)), false), + ], + is_closed: false, + }; + layer.add_line(line); + return; + } + + let n = stroke.points.len(); + let mut line_points = Vec::new(); + + // Catmull-Rom zu kubischen Bézier-Segmenten konvertieren + for i in 0..n - 1 { + let p_curr = stroke.points[i].position; + let p_next = stroke.points[i + 1].position; + + let p_prev = if i == 0 { + 2.0 * p_curr - p_next + } else { + stroke.points[i - 1].position + }; + + let p_after = if i + 2 < n { + stroke.points[i + 2].position + } else { + 2.0 * p_next - p_curr + }; + + // Knotensequenz mit zentripetalem Catmull-Rom (alpha = 0.5) + let d01 = (p_curr - p_prev).length(); + let d12 = (p_next - p_curr).length(); + let d23 = (p_after - p_next).length(); + + let dt01 = if d01 > 1e-6 { d01.sqrt() } else { 1e-4 }; + let dt12 = if d12 > 1e-6 { d12.sqrt() } else { 1e-4 }; + let dt23 = if d23 > 1e-6 { d23.sqrt() } else { 1e-4 }; + + // Geschwindigkeitsvektoren (Tangenten an P1 und P2) + let v1 = (p_next - p_prev) / (dt01 + dt12); + let v2 = (p_after - p_curr) / (dt12 + dt23); + + // Kubische Bézier-Kontrollpunkte B1 und B2 + let b1 = p_curr + (dt12 / 3.0) * v1; + let b2 = p_next - (dt12 / 3.0) * v2; + + let (b0_x, b0_y) = page_to_pdf_mm(p_curr.x, p_curr.y); + let (b1_x, b1_y) = page_to_pdf_mm(b1.x, b1.y); + let (b2_x, b2_y) = page_to_pdf_mm(b2.x, b2.y); + let (b3_x, b3_y) = page_to_pdf_mm(p_next.x, p_next.y); + + let pt0 = Point::new(Mm(b0_x), Mm(b0_y)); + let pt1 = Point::new(Mm(b1_x), Mm(b1_y)); + let pt2 = Point::new(Mm(b2_x), Mm(b2_y)); + let pt3 = Point::new(Mm(b3_x), Mm(b3_y)); + + if i == 0 { + line_points.push((pt0, true)); + } + + line_points.push((pt1, true)); + line_points.push((pt2, false)); + + // Für das nächste Segment als Startpunkt mit true markieren, am Ende mit false + let is_last_segment = i == n - 2; + line_points.push((pt3, !is_last_segment)); + } + + if !line_points.is_empty() { + let line = Line { + points: line_points, + is_closed: false, + }; + layer.add_line(line); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::InkVertex; + use crate::document::{StylusSample, PAGE_HEIGHT, PAGE_WIDTH}; + + #[test] + fn test_page_to_pdf_mm_mapping() { + // Oben-Links (0, 0) -> PDF (0.0, 297.0) + let (x0, y0) = page_to_pdf_mm(0.0, 0.0); + assert!((x0 - 0.0).abs() < 1e-4); + assert!((y0 - A4_HEIGHT_MM).abs() < 1e-4); + + // Unten-Rechts (PAGE_WIDTH, PAGE_HEIGHT) -> PDF (210.0, 0.0) + let (x1, y1) = page_to_pdf_mm(PAGE_WIDTH, PAGE_HEIGHT); + assert!((x1 - A4_WIDTH_MM).abs() < 1e-4); + assert!((y1 - 0.0).abs() < 1e-4); + + // Mitte (PAGE_WIDTH / 2, PAGE_HEIGHT / 2) -> PDF (105.0, 148.5) + let (xm, ym) = page_to_pdf_mm(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0); + assert!((xm - 105.0).abs() < 1e-4); + assert!((ym - 148.5).abs() < 1e-4); + } + + #[test] + fn test_pdf_export_generation() { + // 1. Mock-Notebook mit 2 Seiten und unterschiedlichen Hintergründen + let mut notebook = Notebook::new(); + notebook.pages[0].background = PageBackground::Grid5mm; + + // Stroke auf Seite 1 hinzufügen + let stroke1 = Stroke::new( + 1, + vec![ + StylusSample { + position: glam::Vec2::new(100.0, 200.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(300.0, 400.0), + pressure: 0.8, + timestamp: 0.01, + }, + StylusSample { + position: glam::Vec2::new(500.0, 300.0), + pressure: 0.6, + timestamp: 0.02, + }, + ], + vec![ + InkVertex::new(95.0, 200.0, [0.1, 0.1, 0.15, 1.0]), + InkVertex::new(105.0, 200.0, [0.1, 0.1, 0.15, 1.0]), + InkVertex::new(290.0, 400.0, [0.1, 0.1, 0.15, 1.0]), + InkVertex::new(310.0, 400.0, [0.1, 0.1, 0.15, 1.0]), + InkVertex::new(495.0, 300.0, [0.1, 0.1, 0.15, 1.0]), + InkVertex::new(505.0, 300.0, [0.1, 0.1, 0.15, 1.0]), + ], + [0.1, 0.1, 0.15, 1.0], + ); + notebook.pages[0].add_stroke(stroke1); + + // Seite 2 mit Liniertem Hintergrund + let page2_idx = notebook.add_page(PageBackground::Lined); + let stroke2 = Stroke::new( + 2, + vec![ + StylusSample { + position: glam::Vec2::new(200.0, 500.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(600.0, 500.0), + pressure: 0.5, + timestamp: 0.01, + }, + ], + vec![ + InkVertex::new(200.0, 498.0, [0.85, 0.2, 0.2, 1.0]), + InkVertex::new(200.0, 502.0, [0.85, 0.2, 0.2, 1.0]), + InkVertex::new(600.0, 498.0, [0.85, 0.2, 0.2, 1.0]), + InkVertex::new(600.0, 502.0, [0.85, 0.2, 0.2, 1.0]), + ], + [0.85, 0.2, 0.2, 1.0], + ); + notebook.pages[page2_idx].add_stroke(stroke2); + + // 2. Export als Datei testen (Strategie A: FilledPolygon) + 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"); + + // Datei-Existenz und Größe prüfen + assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren"); + let metadata = std::fs::metadata(&pdf_path).unwrap(); + assert!( + metadata.len() > 1024, + "Dateigröße ({}) muss größer als 1 KB sein", + metadata.len() + ); + + // PDF-Header verifizieren + let content = std::fs::read(&pdf_path).unwrap(); + assert!( + content.starts_with(b"%PDF-1."), + "Datei muss mit gültigem PDF-Header (%PDF-1.) beginnen" + ); + + // 3. Export als Bytes testen (Strategie B: BezierCurve) + let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve); + assert!(bytes_res.is_ok()); + let bytes = bytes_res.unwrap(); + assert!(bytes.len() > 1024); + assert!(bytes.starts_with(b"%PDF-1.")); + + // Aufräumen + let _ = std::fs::remove_file(pdf_path); + } + + #[test] + fn test_pdf_export_recognized_shapes() { + let mut notebook = Notebook::new(); + let page = notebook.current_page_mut(); + + let line_stroke = Stroke::new(1, vec![], vec![], [0.0, 0.0, 0.0, 1.0]).with_shape( + crate::geometry::RecognizedShape::Line { + start: glam::Vec2::new(100.0, 100.0), + end: glam::Vec2::new(500.0, 500.0), + }, + ); + page.add_stroke(line_stroke); + + let circle_stroke = Stroke::new(2, vec![], vec![], [0.1, 0.4, 0.9, 1.0]).with_shape( + crate::geometry::RecognizedShape::Circle { + center: glam::Vec2::new(1000.0, 1000.0), + radius: 200.0, + }, + ); + page.add_stroke(circle_stroke); + + let rect_stroke = Stroke::new(3, vec![], vec![], [0.2, 0.7, 0.3, 1.0]).with_shape( + crate::geometry::RecognizedShape::Rectangle { + corners: [ + glam::Vec2::new(200.0, 200.0), + glam::Vec2::new(600.0, 200.0), + glam::Vec2::new(600.0, 400.0), + glam::Vec2::new(200.0, 400.0), + ], + }, + ); + page.add_stroke(rect_stroke); + + let coords_stroke = Stroke::new(4, vec![], vec![], [0.8, 0.1, 0.1, 1.0]).with_shape( + crate::geometry::RecognizedShape::CoordinateSystem { + origin: glam::Vec2::new(300.0, 1500.0), + x_axis_end: glam::Vec2::new(800.0, 1500.0), + y_axis_end: glam::Vec2::new(300.0, 1000.0), + with_arrows: true, + }, + ); + page.add_stroke(coords_stroke); + + let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape( + crate::geometry::RecognizedShape::Triangle { + corners: [ + glam::Vec2::new(100.0, 500.0), + glam::Vec2::new(400.0, 500.0), + glam::Vec2::new(250.0, 300.0), + ], + }, + ); + page.add_stroke(tri_stroke); + + let rhombus_stroke = Stroke::new(6, vec![], vec![], [0.9, 0.5, 0.1, 1.0]).with_shape( + crate::geometry::RecognizedShape::Rhombus { + corners: [ + glam::Vec2::new(500.0, 700.0), + glam::Vec2::new(700.0, 850.0), + glam::Vec2::new(500.0, 1000.0), + glam::Vec2::new(300.0, 850.0), + ], + }, + ); + page.add_stroke(rhombus_stroke); + + let octagon_stroke = Stroke::new(7, vec![], vec![], [0.1, 0.6, 0.6, 1.0]).with_shape( + crate::geometry::RecognizedShape::Polygon { + vertices: vec![ + glam::Vec2::new(1200.0, 600.0), + glam::Vec2::new(1350.0, 650.0), + glam::Vec2::new(1400.0, 800.0), + glam::Vec2::new(1350.0, 950.0), + glam::Vec2::new(1200.0, 1000.0), + glam::Vec2::new(1050.0, 950.0), + glam::Vec2::new(1000.0, 800.0), + glam::Vec2::new(1050.0, 650.0), + ], + }, + ); + page.add_stroke(octagon_stroke); + + let octahedron_stroke = Stroke::new(8, vec![], vec![], [0.3, 0.3, 0.8, 1.0]).with_shape( + crate::geometry::RecognizedShape::Octahedron { + center: glam::Vec2::new(800.0, 1500.0), + radius_x: 200.0, + radius_y: 250.0, + depth: 70.0, + }, + ); + page.add_stroke(octahedron_stroke); + + let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon); + assert!(bytes_res.is_ok(), "Shape export must succeed"); + let bytes = bytes_res.unwrap(); + assert!(bytes.len() > 1024); + assert!(bytes.starts_with(b"%PDF-1.")); + } +} diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..a03d55f --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,369 @@ +//! Adaptiver 1€-Filter (One Euro Filter) für rauschfreie und latenzoptimierte Stylus-Eingaben. +//! +//! Filtert x, y und Druck (pressure p ∈ [0.0, 1.0]). +//! Implementiert numerische Härtung: +//! - dt-Clamping: dt <= 0 wird mit dt = 1e-4 s abgefangen, dt > 1.0 s löst ein reset() aus. +//! - Glättungsfaktor alpha wird strikt auf den Bereich [1e-5, 1.0] begrenzt. +//! - NaN/Inf-Schutz gegen Filtervergiftung. + +use std::f32::consts::PI; + +/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten) +pub const DEFAULT_FC_MIN: f32 = 4.5; +/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen) +pub const DEFAULT_BETA: f32 = 0.05; +/// Standard-Grenzfrequenz für die Ableitung in Hz +pub const DEFAULT_D_CUTOFF: f32 = 1.0; + +/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1). +/// +/// Numerisch gehärtet: Garantiert Werte im Bereich [1e-5, 1.0] und schützt vor NaN/Inf. +pub fn smoothing_factor(dt: f32, cutoff: f32) -> f32 { + let r = 2.0 * PI * cutoff * dt; + if !r.is_finite() || r <= 0.0 { + 1e-5 + } else { + let alpha = r / (r + 1.0); + if !alpha.is_finite() { + 1e-5 + } else { + alpha.clamp(1e-5, 1.0) + } + } +} + +/// 1D One-Euro-Filter für skalare Signale. +#[derive(Debug, Clone)] +pub struct OneEuroFilter1D { + pub fc_min: f32, + pub beta: f32, + pub d_cutoff: f32, + x_prev: Option, + dx_prev: f32, + t_prev: Option, +} + +impl OneEuroFilter1D { + /// Erstellt einen neuen 1D-Filter mit den angegebenen Parametern. + pub fn new(fc_min: f32, beta: f32, d_cutoff: f32) -> Self { + Self { + fc_min, + beta, + d_cutoff, + x_prev: None, + dx_prev: 0.0, + t_prev: None, + } + } + + /// Setzt den Filterzustand vollständig zurück. + pub fn reset(&mut self) { + self.x_prev = None; + self.dx_prev = 0.0; + self.t_prev = None; + } + + /// Gibt zurück, ob der Filter bereits mindestens einen Messwert empfangen hat. + pub fn is_initialized(&self) -> bool { + self.x_prev.is_some() + } + + /// Letzter gefilterter Wert, falls vorhanden. + pub fn last_value(&self) -> Option { + self.x_prev + } + + /// Filtert einen Wert `x` zum Zeitstempel `t` (in Sekunden). + pub fn filter(&mut self, x: f32, t: f64) -> f32 { + // Schutz vor NaN- und Inf-Eingaben + if !x.is_finite() || !t.is_finite() { + return self.x_prev.unwrap_or(0.0); + } + + let Some(prev_t) = self.t_prev else { + // Erster Messwert: Initialisierung + self.x_prev = Some(x); + self.dx_prev = 0.0; + self.t_prev = Some(t); + return x; + }; + + let raw_dt = t - prev_t; + + // Numerische Härtung: dt > 1.0 s führt zu reset() + if raw_dt > 1.0 { + self.reset(); + self.x_prev = Some(x); + self.dx_prev = 0.0; + self.t_prev = Some(t); + return x; + } + + // Numerische Härtung: dt <= 0 wird mit dt = 1e-4 s abgefangen + let dt = if raw_dt <= 0.0 || !raw_dt.is_finite() { + 1e-4_f32 + } else { + raw_dt as f32 + }; + + self.t_prev = Some(t); + + let prev_x = self.x_prev.unwrap_or(x); + + // 1. Ableitung (Geschwindigkeit) filtern + let mut dx = (x - prev_x) / dt; + if !dx.is_finite() { + dx = 0.0; + } + + let a_d = smoothing_factor(dt, self.d_cutoff); + let mut dx_hat = a_d * dx + (1.0 - a_d) * self.dx_prev; + if !dx_hat.is_finite() { + dx_hat = self.dx_prev; + } + + // 2. Adaptive Grenzfrequenz fc berechnen + let mut fc = self.fc_min + self.beta * dx_hat.abs(); + if !fc.is_finite() || fc < self.fc_min { + fc = self.fc_min; + } + + // 3. Signal mit adaptivem fc filtern + let a = smoothing_factor(dt, fc); + let mut x_hat = a * x + (1.0 - a) * prev_x; + if !x_hat.is_finite() { + x_hat = prev_x; + } + + self.x_prev = Some(x_hat); + self.dx_prev = dx_hat; + + x_hat + } +} + +impl Default for OneEuroFilter1D { + fn default() -> Self { + Self::new(DEFAULT_FC_MIN, DEFAULT_BETA, DEFAULT_D_CUTOFF) + } +} + +/// Gefilterter Eingabepunkt mit 2D-Position, Druck und Zeitstempel. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FilteredPoint { + pub position: glam::Vec2, + pub pressure: f32, + pub timestamp: f64, +} + +/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0]. +#[derive(Debug, Clone)] +pub struct OneEuroFilter { + pub fc_min: f32, + pub beta: f32, + pub d_cutoff: f32, + x_filter: OneEuroFilter1D, + y_filter: OneEuroFilter1D, + p_filter: OneEuroFilter1D, + last_output: Option<(f32, f32, f32)>, +} + +impl OneEuroFilter { + /// Erstellt einen neuen OneEuroFilter mit den angegebenen Parametern. + pub fn new(fc_min: f32, beta: f32, d_cutoff: f32) -> Self { + Self { + fc_min, + beta, + d_cutoff, + x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff), + y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff), + p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff), + last_output: None, + } + } + + /// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke). + pub fn reset(&mut self) { + self.x_filter.reset(); + self.y_filter.reset(); + self.p_filter.reset(); + self.last_output = None; + } + + /// Filtert x, y und Druck p ∈ [0.0, 1.0] zum Zeitstempel `t` (in Sekunden). + /// + /// Gibt das gefilterte Tupel `(x, y, pressure)` zurück. + pub fn filter(&mut self, x: f32, y: f32, pressure: f32, t: f64) -> (f32, f32, f32) { + // Schutz vor NaN- und Inf-Eingaben + if !x.is_finite() || !y.is_finite() || !pressure.is_finite() || !t.is_finite() { + return self.last_output.unwrap_or((0.0, 0.0, 0.0)); + } + + // Druck immer auf [0.0, 1.0] klemmen + let p_in = pressure.clamp(0.0, 1.0); + + let x_out = self.x_filter.filter(x, t); + let y_out = self.y_filter.filter(y, t); + let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0); + + let out = (x_out, y_out, p_out); + self.last_output = Some(out); + out + } + + /// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`. + pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) { + let (x, y, p) = self.filter(pos.x, pos.y, pressure, t); + (glam::Vec2::new(x, y), p) + } + + /// Filtert einen `FilteredPoint` und gibt den geglätteten Punkt zurück. + pub fn filter_stroke_point(&mut self, point: FilteredPoint) -> FilteredPoint { + let (pos, p) = self.filter_point(point.position, point.pressure, point.timestamp); + FilteredPoint { + position: pos, + pressure: p, + timestamp: point.timestamp, + } + } +} + +impl Default for OneEuroFilter { + fn default() -> Self { + Self::new(DEFAULT_FC_MIN, DEFAULT_BETA, DEFAULT_D_CUTOFF) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zero_and_negative_dt() { + let mut filter = OneEuroFilter::default(); + + // 1. Erster gültiger Datenpunkt bei t = 1.0 + let (x1, y1, p1) = filter.filter(10.0, 20.0, 0.5, 1.0); + assert_eq!(x1, 10.0); + assert_eq!(y1, 20.0); + assert_eq!(p1, 0.5); + + // 2. dt == 0 (gleicher Zeitstempel t = 1.0): Sollte dt = 1e-4 abfangen + let (x2, y2, p2) = filter.filter(10.5, 20.5, 0.6, 1.0); + assert!(x2.is_finite(), "x2 muss endlich sein"); + assert!(y2.is_finite(), "y2 muss endlich sein"); + assert!(p2.is_finite(), "p2 muss endlich sein"); + assert!((0.0..=1.0).contains(&p2), "Druck muss in [0, 1] liegen"); + + // 3. dt < 0 (Zeitstempel springt zurück auf t = 0.5): Sollte mit dt = 1e-4 abgefangen werden + let (x3, y3, p3) = filter.filter(11.0, 21.0, 0.7, 0.5); + assert!(x3.is_finite(), "x3 muss nach negativem dt endlich sein"); + assert!(y3.is_finite(), "y3 muss nach negativem dt endlich sein"); + assert!(p3.is_finite(), "p3 muss nach negativem dt endlich sein"); + assert!((0.0..=1.0).contains(&p3)); + + // 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"); + } + + #[test] + fn test_high_frequency_jitter() { + let mut filter = OneEuroFilter::default(); + + let base_x = 100.0_f32; + let base_y = 50.0_f32; + let base_p = 0.5_f32; + let jitter_amplitude = 2.0_f32; + + let num_samples = 120; + let dt = 1.0 / 120.0; // 120 Hz Abtastrate + + let mut raw_x_values = Vec::with_capacity(num_samples); + let mut filtered_x_values = Vec::with_capacity(num_samples); + + for i in 0..num_samples { + let t = i as f64 * dt; + // Hochfrequentes Rauschen mit Vorzeichenwechsel + let sign = if i % 2 == 0 { 1.0 } else { -1.0 }; + let raw_x = base_x + sign * jitter_amplitude; + let raw_y = base_y + sign * jitter_amplitude; + let raw_p = base_p + sign * 0.1; + + raw_x_values.push(raw_x); + + let (fx, fy, fp) = filter.filter(raw_x, raw_y, raw_p, t); + filtered_x_values.push(fx); + + assert!(fx.is_finite()); + assert!(fy.is_finite()); + assert!(fp.is_finite()); + assert!((0.0..=1.0).contains(&fp)); + } + + // Varianz nach der Einschwingphase (letzte 60 Samples) berechnen + let eval_range = 60..num_samples; + let count = eval_range.len() as f32; + + let raw_mean: f32 = raw_x_values[eval_range.clone()].iter().sum::() / count; + let raw_variance: f32 = raw_x_values[eval_range.clone()] + .iter() + .map(|v| (v - raw_mean).powi(2)) + .sum::() + / count; + + let filtered_mean: f32 = filtered_x_values[eval_range.clone()].iter().sum::() / count; + let filtered_variance: f32 = filtered_x_values[eval_range] + .iter() + .map(|v| (v - filtered_mean).powi(2)) + .sum::() + / count; + + // Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen + assert!( + filtered_variance < raw_variance * 0.05, + "Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})" + ); + } + + #[test] + fn test_nan_inf_poisoning() { + let mut filter = OneEuroFilter::default(); + + // 1. Zuerst normale Werte einspeisen + let (x0, y0, p0) = filter.filter(10.0, 20.0, 0.5, 0.0); + assert_eq!((x0, y0, p0), (10.0, 20.0, 0.5)); + + let (x1, y1, p1) = filter.filter(11.0, 21.0, 0.55, 0.01); + assert!(x1.is_finite()); + assert!(y1.is_finite()); + assert!(p1.is_finite()); + + // 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"); + + let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN); + assert!(x_nan2.is_finite()); + assert!(y_nan2.is_finite()); + assert!(p_nan2.is_finite()); + + // 3. Nach der Vergiftung wieder normale Werte einspeisen + let (x2, y2, p2) = filter.filter(13.0, 23.0, 0.65, 0.03); + assert!(x2.is_finite(), "x2 muss nach Vergiftung endlich sein"); + assert!(y2.is_finite(), "y2 muss nach Vergiftung endlich sein"); + assert!(p2.is_finite(), "p2 muss nach Vergiftung endlich sein"); + + // Sicherstellen, dass der Filter nicht bei einem fixen Wert 'steckenbleibt' + let (x3, y3, p3) = filter.filter(30.0, 40.0, 0.8, 0.04); + assert!(x3.is_finite()); + assert!(y3.is_finite()); + assert!(p3.is_finite()); + assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen"); + } +} diff --git a/src/geometry.rs b/src/geometry.rs new file mode 100644 index 0000000..e4bf8b6 --- /dev/null +++ b/src/geometry.rs @@ -0,0 +1,1600 @@ +//! Geometrie- und Objekterkennung (Shape Recognition) für Freihandstriche. +//! +//! 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 crate::canvas::InkVertex; +use crate::document::{Aabb, Stroke, StylusSample}; + +/// Arten der geometrischen Primitiven. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShapeKind { + Line, + Circle, + Rectangle, + CoordinateSystem, + Triangle, + Rhombus, + Polygon, + Octahedron, +} + +/// Erkannte, mathematisch bereinigte Form. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum RecognizedShape { + Line { + start: Vec2, + end: Vec2, + }, + Circle { + center: Vec2, + radius: f32, + }, + Rectangle { + /// 4 Eckpunkte in umlaufender Reihenfolge (strikt rechtwinklig). + corners: [Vec2; 4], + }, + CoordinateSystem { + origin: Vec2, + x_axis_end: Vec2, + y_axis_end: Vec2, + with_arrows: bool, + }, + Triangle { + /// 3 Eckpunkte in umlaufender Reihenfolge. + corners: [Vec2; 3], + }, + Rhombus { + /// 4 Eckpunkte in umlaufender Reihenfolge (Raute/Karo mit gleich langen Kanten). + corners: [Vec2; 4], + }, + Polygon { + /// N Eckpunkte in umlaufender Reihenfolge (z. B. 5-Eck, 6-Eck, 8-Eck / Oktagon). + vertices: Vec, + }, + Octahedron { + /// 3D-Kantenmodell (Doppelpyramide) mit 6 Eckpunkten und 12 Kanten im Schrägbild. + center: Vec2, + radius_x: f32, + radius_y: f32, + depth: f32, + }, +} + +impl RecognizedShape { + pub fn kind(&self) -> ShapeKind { + match self { + Self::Line { .. } => ShapeKind::Line, + Self::Circle { .. } => ShapeKind::Circle, + Self::Rectangle { .. } => ShapeKind::Rectangle, + Self::CoordinateSystem { .. } => ShapeKind::CoordinateSystem, + Self::Triangle { .. } => ShapeKind::Triangle, + Self::Rhombus { .. } => ShapeKind::Rhombus, + Self::Polygon { .. } => ShapeKind::Polygon, + Self::Octahedron { .. } => ShapeKind::Octahedron, + } + } + + pub fn name(&self) -> &'static str { + match self { + Self::Line { .. } => "Gerade", + Self::Circle { .. } => "Kreis", + Self::Rectangle { .. } => "Rechteck", + Self::CoordinateSystem { .. } => "Koordinatensystem", + Self::Triangle { .. } => "Dreieck", + Self::Rhombus { .. } => "Raute (Rhombus)", + Self::Polygon { vertices } => match vertices.len() { + 5 => "Fünfeck (Pentagon)", + 6 => "Sechseck (Hexagon)", + 8 => "Achteck (Oktagon)", + _ => "Polygon", + }, + Self::Octahedron { .. } => "Oktaeder (3D-Kantenmodell)", + } + } + + /// Verschiebt die geometrische Form um `delta`. + pub fn translate(&mut self, delta: Vec2) { + match self { + Self::Line { start, end } => { + *start += delta; + *end += delta; + } + Self::Circle { center, .. } => { + *center += delta; + } + Self::Rectangle { corners } | Self::Rhombus { corners } => { + for c in corners { + *c += delta; + } + } + Self::Triangle { corners } => { + for c in corners { + *c += delta; + } + } + Self::CoordinateSystem { origin, x_axis_end, y_axis_end, .. } => { + *origin += delta; + *x_axis_end += delta; + *y_axis_end += delta; + } + Self::Polygon { vertices } => { + for v in vertices { + *v += delta; + } + } + Self::Octahedron { center, .. } => { + *center += delta; + } + } + } + + /// Dreht die geometrische Form um `origin` mit dem Winkel `angle_rad`. + pub fn rotate(&mut self, origin: Vec2, angle_rad: f32) { + let rot = |pt: Vec2| -> Vec2 { + let diff = pt - origin; + let (sin, cos) = angle_rad.sin_cos(); + origin + Vec2::new(diff.x * cos - diff.y * sin, diff.x * sin + diff.y * cos) + }; + + match self { + Self::Line { start, end } => { + *start = rot(*start); + *end = rot(*end); + } + Self::Circle { center, .. } => { + *center = rot(*center); + } + Self::Rectangle { corners } | Self::Rhombus { corners } => { + for c in corners { + *c = rot(*c); + } + } + Self::Triangle { corners } => { + for c in corners { + *c = rot(*c); + } + } + 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); + } + Self::Polygon { vertices } => { + for v in vertices { + *v = rot(*v); + } + } + Self::Octahedron { center, .. } => { + *center = rot(*center); + } + } + } + + /// 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 + }; + + match self { + Self::Line { start, end } => { + *start = sc(*start); + *end = sc(*end); + } + Self::Circle { center, radius } => { + *center = sc(*center); + *radius *= (factors.x.abs() + factors.y.abs()) * 0.5; + } + Self::Rectangle { corners } | Self::Rhombus { corners } => { + for c in corners { + *c = sc(*c); + } + } + Self::Triangle { corners } => { + for c in corners { + *c = sc(*c); + } + } + 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); + } + Self::Polygon { vertices } => { + for v in vertices { + *v = sc(*v); + } + } + Self::Octahedron { center, radius_x, radius_y, depth } => { + *center = sc(*center); + *radius_x *= factors.x.abs(); + *radius_y *= factors.y.abs(); + *depth *= (factors.x.abs() + factors.y.abs()) * 0.5; + } + } + } +} + +/// Vereinfacht einen Polygonzug mit dem Ramer-Douglas-Peucker (RDP) Algorithmus. +pub fn ramer_douglas_peucker(points: &[Vec2], epsilon: f32) -> Vec { + if points.len() <= 2 { + return points.to_vec(); + } + rdp_recursive(points, epsilon) +} + +fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec { + if points.len() <= 2 { + return points.to_vec(); + } + + let first = points[0]; + let last = *points.last().unwrap(); + let mut max_dist = 0.0f32; + let mut max_idx = 0; + + for (i, &pt) in points.iter().enumerate().skip(1).take(points.len() - 2) { + let dist = point_to_line_dist(pt, first, last); + if dist > max_dist { + max_dist = dist; + max_idx = i; + } + } + + if max_dist > epsilon { + let mut left = rdp_recursive(&points[..=max_idx], epsilon); + let right = rdp_recursive(&points[max_idx..], epsilon); + left.pop(); + left.extend(right); + left + } else { + vec![first, last] + } +} + +/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`. +#[inline] +pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 { + let ab = b - a; + let len = ab.length(); + if len < 1e-6 { + return (p - a).length(); + } + let cross = (p.x - a.x) * ab.y - (p.y - a.y) * ab.x; + cross.abs() / len +} + +/// Berechnet die aufsummierte Pfadlänge einer Punktfolge. +pub fn path_length(points: &[Vec2]) -> f32 { + points.windows(2).map(|w| (w[1] - w[0]).length()).sum() +} + +/// 1. Geradenerkennung: Verhältnis Sehnenlänge zu Pfadlänge > 0.92. +pub fn detect_line(points: &[Vec2]) -> Option { + if points.len() < 2 { + return None; + } + let start = points[0]; + let end = *points.last().unwrap(); + let chord_len = (end - start).length(); + let total_len = path_length(points); + + if total_len < 10.0 { + return None; + } + + let ratio = chord_len / total_len; + if ratio > 0.92 { + // Bei Winkeln nahe an horizontal/vertikal (innerhalb 5°) einrasten + let dir = end - start; + let angle_deg = dir.y.atan2(dir.x).to_degrees().abs(); + let snapped_end = if angle_deg < 5.0 || (angle_deg - 180.0).abs() < 5.0 { + Vec2::new(end.x, start.y) + } else if (angle_deg - 90.0).abs() < 5.0 { + Vec2::new(start.x, end.y) + } else { + end + }; + + Some(RecognizedShape::Line { + start, + end: snapped_end, + }) + } else { + None + } +} + +/// 2. Kreis- & Ellipsenerkennung: Geschlossener Pfad und geringe Standardabweichung der Radien. +pub fn detect_circle(points: &[Vec2]) -> Option { + if points.len() < 8 { + return None; + } + + let total_len = path_length(points); + if total_len < 30.0 { + return None; + } + + let start = points[0]; + let end = *points.last().unwrap(); + let chord_len = (end - start).length(); + + // Pfad muss annähernd geschlossen sein + if chord_len > 0.35 * total_len { + return None; + } + + // Schwerpunkt als Startpunkt + let n = points.len() as f32; + let mean_x = points.iter().map(|p| p.x).sum::() / n; + let mean_y = points.iter().map(|p| p.y).sum::() / n; + + // Algebraic Least-Squares Circle Fit (Kåsa-Verfahren) + let mut suu = 0.0; + let mut suv = 0.0; + let mut svv = 0.0; + let mut suuu = 0.0; + let mut svvv = 0.0; + let mut suvv = 0.0; + let mut svuu = 0.0; + + for p in points { + let u = p.x - mean_x; + let v = p.y - mean_y; + let u2 = u * u; + let v2 = v * v; + suu += u2; + suv += u * v; + svv += v2; + suuu += u2 * u; + svvv += v2 * v; + suvv += u * v2; + svuu += v * u2; + } + + let det = suu * svv - suv * suv; + let center = if det.abs() > 1e-5 { + let rhs_u = 0.5 * (suuu + suvv); + let rhs_v = 0.5 * (svvv + svuu); + let uc = (rhs_u * svv - rhs_v * suv) / det; + let vc = (suu * rhs_v - suv * rhs_u) / det; + Vec2::new(mean_x + uc, mean_y + vc) + } else { + Vec2::new(mean_x, mean_y) + }; + + // Radien vom berechneten Mittelpunkt ermitteln + let radii: Vec = points.iter().map(|p| (*p - center).length()).collect(); + let mean_r = radii.iter().sum::() / n; + if mean_r < 10.0 { + return None; + } + + let variance = radii.iter().map(|r| (r - mean_r).powi(2)).sum::() / n; + let std_dev = variance.sqrt(); + let cv = std_dev / mean_r; // Variationskoeffizient + + // Geringe Standardabweichung der Radien (für Freihandkreise/Ovale < 0.22) + if cv < 0.22 { + Some(RecognizedShape::Circle { + center, + radius: mean_r, + }) + } else { + None + } +} + +/// 3. Rechteckerkennung: Geschlossener Pfad mit 4 dominanten Ecken (~90° +/- 18°). +pub fn detect_rectangle(points: &[Vec2]) -> Option { + if points.len() < 8 { + return None; + } + + let total_len = path_length(points); + if total_len < 40.0 { + return None; + } + + let start = points[0]; + let end = *points.last().unwrap(); + let chord_len = (end - start).length(); + + // Pfad muss annähernd geschlossen sein + if chord_len > 0.35 * total_len { + return None; + } + + // RDP mit ca. 5% Pfadlänge als Toleranz + let epsilon = (total_len * 0.05).clamp(8.0, 45.0); + let simplified = ramer_douglas_peucker(points, epsilon); + + let corners: Vec = 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; + } + + // Innenwinkel prüfen: 4 Kanten müssen jeweils ~90° (+/- 18°) zueinander stehen + let c = [corners[0], corners[1], corners[2], corners[3]]; + for i in 0..4 { + let prev = c[(i + 3) % 4]; + let curr = c[i]; + let next = c[(i + 1) % 4]; + let v1 = (prev - curr).normalize_or_zero(); + let v2 = (next - curr).normalize_or_zero(); + let dot = v1.dot(v2).clamp(-1.0, 1.0); + let angle_deg = dot.acos().to_degrees(); + if !(72.0..=108.0).contains(&angle_deg) { + return None; + } + } + + // Saubere rechtwinklige Ausrichtung + let aabb = Aabb::from_points(&c); + let center = (c[0] + c[1] + c[2] + c[3]) * 0.25; + + let e0 = c[1] - c[0]; + let angle_deg = e0.y.atan2(e0.x).to_degrees().abs(); + + // Nahe an Achsenparallelität (< 12°)? + let snapped_corners = if angle_deg < 12.0 + || (angle_deg - 90.0).abs() < 12.0 + || (angle_deg - 180.0).abs() < 12.0 + { + [ + Vec2::new(aabb.min.x, aabb.min.y), + Vec2::new(aabb.max.x, aabb.min.y), + Vec2::new(aabb.max.x, aabb.max.y), + Vec2::new(aabb.min.x, aabb.max.y), + ] + } else { + // Rotiertes Rechteck mit exakt 90° Kanten + let dir = e0.normalize_or_zero(); + let norm = Vec2::new(-dir.y, dir.x); + let w = 0.5 * ((c[1] - c[0]).length() + (c[2] - c[3]).length()); + let h = 0.5 * ((c[2] - c[1]).length() + (c[3] - c[0]).length()); + let hw = w * 0.5; + let hh = h * 0.5; + [ + center - dir * hw - norm * hh, + center + dir * hw - norm * hh, + center + dir * hw + norm * hh, + center - dir * hw + norm * hh, + ] + }; + + Some(RecognizedShape::Rectangle { + corners: snapped_corners, + }) +} + +/// Rastet einen Winkel in Radiant an horizontalen, vertikalen oder diagonalen Achsen ein. +pub fn snap_angle(angle_rad: f32, tolerance_deg: f32) -> f32 { + let tol = tolerance_deg.to_radians(); + let cardinal = [ + 0.0, + PI * 0.5, + PI, + -PI, + -PI * 0.5, + PI * 0.25, + PI * 0.75, + -PI * 0.25, + -PI * 0.75, + ]; + for &target in &cardinal { + let diff = (angle_rad - target).abs(); + let diff_wrapped = diff + .min((angle_rad - (target + 2.0 * PI)).abs()) + .min((angle_rad - (target - 2.0 * PI)).abs()); + if diff_wrapped < tol { + return target; + } + } + angle_rad +} + +/// Extrahiert die k markantesten Ecken (stärkste Richtungsänderung) aus einem vereinfachten Polygonzug. +pub fn extract_k_corners(points: &[Vec2], k: usize) -> Option> { + let n = points.len(); + if n < k { + return None; + } + + let mut scored: Vec<(usize, f32)> = Vec::with_capacity(n); + for i in 0..n { + let prev = points[(i + n - 1) % n]; + let curr = points[i]; + let next = points[(i + 1) % n]; + let v1 = (prev - curr).normalize_or_zero(); + let v2 = (next - curr).normalize_or_zero(); + // Dot product: je kleiner, desto spitzer/schärfer die Richtungsänderung + let dot = v1.dot(v2).clamp(-1.0, 1.0); + scored.push((i, dot)); + } + + scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + let mut selected_indices: Vec = scored.iter().take(k).map(|&(idx, _)| idx).collect(); + selected_indices.sort_unstable(); + + Some(selected_indices.into_iter().map(|idx| points[idx]).collect()) +} + +/// 4. Dreieck-Erkennung: Geschlossener Pfad mit 3 dominanten Ecken. +pub fn detect_triangle(points: &[Vec2]) -> Option { + if points.len() < 4 { + return None; + } + + let total_len = path_length(points); + if total_len < 35.0 { + return None; + } + + let start = points[0]; + let end = *points.last().unwrap(); + let chord_len = (end - start).length(); + + // Pfad muss annähernd geschlossen sein + if chord_len > 0.40 * total_len { + return None; + } + + let epsilon = (total_len * 0.07).clamp(8.0, 50.0); + let simplified = ramer_douglas_peucker(points, epsilon); + + let corners: Vec = if (simplified.len() == 4 && (simplified[0] - simplified[3]).length() < epsilon * 2.5) + || simplified.len() == 3 + { + simplified[0..3].to_vec() + } else if simplified.len() > 4 { + extract_k_corners(&simplified, 3)? + } else { + return None; + }; + + if corners.len() != 3 { + return None; + } + + let a = corners[0]; + let mut b = corners[1]; + let mut c = corners[2]; + + let l_ab = (b - a).length(); + let l_bc = (c - b).length(); + let l_ca = (a - c).length(); + + if l_ab < 15.0 || l_bc < 15.0 || l_ca < 15.0 { + return None; + } + + // Kreuzprodukt für Fläche + let cross = ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs(); + if cross < 80.0 { + return None; + } + + let angle_at = |prev: Vec2, curr: Vec2, next: Vec2| -> f32 { + let v1 = (prev - curr).normalize_or_zero(); + let v2 = (next - curr).normalize_or_zero(); + v1.dot(v2).clamp(-1.0, 1.0).acos().to_degrees() + }; + + let ang_a = angle_at(c, a, b); + let ang_b = angle_at(a, b, c); + let ang_c = angle_at(b, c, a); + + if ang_a < 18.0 || ang_b < 18.0 || ang_c < 18.0 { + return None; + } + + // Geometrisches Einrasten (Snapping): + // 1. Gleichseitig? (Alle Winkel nahe 60°) + if (ang_a - 60.0).abs() < 14.0 && (ang_b - 60.0).abs() < 14.0 && (ang_c - 60.0).abs() < 14.0 { + let center = (a + b + c) / 3.0; + let r = ((a - center).length() + (b - center).length() + (c - center).length()) / 3.0; + let base_dir = (a - center).normalize_or_zero(); + let base_angle = base_dir.y.atan2(base_dir.x); + 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] }); + } + + // 2. Rechtwinklig? (Ein Winkel nahe 90°) + let angles = [(0, ang_a), (1, ang_b), (2, ang_c)]; + if let Some(&(idx, _)) = angles.iter().find(|(_, ang)| (*ang - 90.0).abs() < 14.0) { + let (corner_v, p1, p2) = match idx { + 0 => (a, b, c), + 1 => (b, c, a), + _ => (c, a, b), + }; + let mut v1 = p1 - corner_v; + let mut v2 = p2 - corner_v; + + let a1 = v1.y.atan2(v1.x); + let a1_snapped = snap_angle(a1, 15.0); + let len1 = v1.length(); + 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 }; + v2 = Vec2::new(-v1.y * sign, v1.x * sign).normalize_or_zero() * len2; + + let new_p1 = corner_v + v1; + let new_p2 = corner_v + v2; + let snapped = match idx { + 0 => [corner_v, new_p1, new_p2], + 1 => [new_p2, corner_v, new_p1], + _ => [new_p1, new_p2, corner_v], + }; + return Some(RecognizedShape::Triangle { corners: snapped }); + } + + // 3. Gleichschenklig? + if (l_ab - l_ca).abs() / l_ab.max(l_ca) < 0.12 { + let mid_bc = (b + c) * 0.5; + let axis = (a - mid_bc).normalize_or_zero(); + let perp = Vec2::new(-axis.y, axis.x); + let half_base = ((b - mid_bc).length() + (c - mid_bc).length()) * 0.5; + b = mid_bc + perp * half_base; + c = mid_bc - perp * half_base; + } else if (l_ab - l_bc).abs() / l_ab.max(l_bc) < 0.12 { + let mid_ac = (a + c) * 0.5; + let axis = (b - mid_ac).normalize_or_zero(); + let perp = Vec2::new(-axis.y, axis.x); + 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] }); + } 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(); + let perp = Vec2::new(-axis.y, axis.x); + 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] }); + } + + Some(RecognizedShape::Triangle { corners: [a, b, c] }) +} + +/// 5. Rauten-Erkennung (Rhombus / Karo): Geschlossener Pfad mit 4 gleich langen Kanten und orthogonalen Diagonalen. +pub fn detect_rhombus(points: &[Vec2]) -> Option { + if points.len() < 4 { + return None; + } + + let total_len = path_length(points); + if total_len < 40.0 { + return None; + } + + let start = points[0]; + let end = *points.last().unwrap(); + if (end - start).length() > 0.35 * total_len { + return None; + } + + let epsilon = (total_len * 0.05).clamp(8.0, 45.0); + let simplified = ramer_douglas_peucker(points, epsilon); + + let corners: Vec = 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; + } + + let c = [corners[0], corners[1], corners[2], corners[3]]; + + let l0 = (c[1] - c[0]).length(); + let l1 = (c[2] - c[1]).length(); + let l2 = (c[3] - c[2]).length(); + let l3 = (c[0] - c[3]).length(); + let mean_l = (l0 + l1 + l2 + l3) * 0.25; + + if mean_l < 15.0 { + return None; + } + + let max_dev = ((l0 - mean_l).abs()) + .max((l1 - mean_l).abs()) + .max((l2 - mean_l).abs()) + .max((l3 - mean_l).abs()); + if max_dev / mean_l > 0.28 { + return None; + } + + let center = (c[0] + c[1] + c[2] + c[3]) * 0.25; + let d1 = c[2] - c[0]; + let d2 = c[3] - c[1]; + + let len1 = d1.length(); + let len2 = d2.length(); + if len1 < 10.0 || len2 < 10.0 { + return None; + } + + let dot = (d1.normalize_or_zero().dot(d2.normalize_or_zero())).clamp(-1.0, 1.0); + let diag_angle_deg = dot.acos().to_degrees(); + if !(65.0..=115.0).contains(&diag_angle_deg) { + return None; + } + + let mut dir1 = d1.normalize_or_zero(); + let angle1 = dir1.y.atan2(dir1.x); + 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 dir2 = Vec2::new(-dir1.y * sign, dir1.x * sign); + + let h1 = len1 * 0.5; + let h2 = len2 * 0.5; + + let c0 = center - dir1 * h1; + let c1 = center - dir2 * h2; + let c2 = center + dir1 * h1; + let c3 = center + dir2 * h2; + + Some(RecognizedShape::Rhombus { + corners: [c0, c1, c2, c3], + }) +} + +/// 6. Polygon-Erkennung (z. B. 5-Eck, 6-Eck, 8-Eck / Oktagon). +pub fn detect_polygon(points: &[Vec2]) -> Option { + if points.len() < 6 { + return None; + } + + let total_len = path_length(points); + if total_len < 50.0 { + return None; + } + + let start = points[0]; + let end = *points.last().unwrap(); + if (end - start).length() > 0.35 * total_len { + return None; + } + + let epsilon = (total_len * 0.04).clamp(6.0, 30.0); + let simplified = ramer_douucker_closed(points, epsilon); + + let n = simplified.len(); + if n != 5 && n != 6 && n != 8 { + return None; + } + + let corners = simplified; + let center = corners.iter().copied().sum::() / (n as f32); + + let radii: Vec = corners.iter().map(|&pt| (pt - center).length()).collect(); + let mean_r = radii.iter().sum::() / (n as f32); + if mean_r < 15.0 { + return None; + } + + 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(); + let base_angle = snap_angle(base_dir.y.atan2(base_dir.x), 15.0); + let step = 2.0 * PI / (n as f32); + + (0..n) + .map(|i| { + let ang = base_angle + i as f32 * step; + center + Vec2::new(ang.cos(), ang.sin()) * mean_r + }) + .collect() + } else { + corners + }; + + Some(RecognizedShape::Polygon { + vertices: snapped_vertices, + }) +} + +fn ramer_douucker_closed(points: &[Vec2], epsilon: f32) -> Vec { + let simplified = ramer_douglas_peucker(points, epsilon); + if simplified.len() >= 2 && (simplified[0] - *simplified.last().unwrap()).length() < epsilon * 2.0 { + simplified[0..simplified.len() - 1].to_vec() + } else { + simplified + } +} + +/// 7. Oktaeder-Erkennung (3D-Kantenmodell / Doppelpyramide). +pub fn detect_octahedron(points: &[Vec2]) -> Option { + if points.len() < 8 { + return None; + } + + let total_len = path_length(points); + if total_len < 70.0 { + return None; + } + + let aabb = Aabb::from_points(points); + let w = (aabb.max.x - aabb.min.x) * 0.5; + let h = (aabb.max.y - aabb.min.y) * 0.5; + + if w < 20.0 || h < 20.0 { + return None; + } + + let aspect = w / h; + if !(0.3..=3.0).contains(&aspect) { + return None; + } + + let center = (aabb.min + aabb.max) * 0.5; + + let mut has_top = false; + let mut has_bottom = false; + let mut has_left = false; + let mut has_right = false; + let mut has_center_equator = false; + + for &pt in points { + if pt.y <= aabb.min.y + 0.18 * h && (pt.x - center.x).abs() < 0.40 * w { + has_top = true; + } + if pt.y >= aabb.max.y - 0.18 * h && (pt.x - center.x).abs() < 0.40 * w { + has_bottom = true; + } + if pt.x <= aabb.min.x + 0.18 * w && (pt.y - center.y).abs() < 0.40 * h { + has_left = true; + } + if pt.x >= aabb.max.x - 0.18 * w && (pt.y - center.y).abs() < 0.40 * h { + has_right = true; + } + if (pt.x - center.x).abs() < 0.45 * w && (pt.y - center.y).abs() < 0.22 * h { + has_center_equator = true; + } + } + + if has_top && has_bottom && has_left && has_right && has_center_equator { + let depth = (w * 0.35).clamp(10.0, 50.0); + return Some(RecognizedShape::Octahedron { + center, + radius_x: w, + radius_y: h, + depth, + }); + } + + None +} + +/// 4. Koordinatensystem-Erkennung: Zwei aufeinander stehende Linien (80° bis 100°), gesnappt auf exakt 90°. +pub fn detect_coordinate_system(points: &[Vec2]) -> Option { + if points.len() < 5 { + return None; + } + + let total_len = path_length(points); + if total_len < 30.0 { + return None; + } + + let start = points[0]; + let end = *points.last().unwrap(); + let chord_len = (end - start).length(); + + // Darf NICHT geschlossen sein + if chord_len < 0.3 * total_len { + return None; + } + + // RDP zur Findung des Eckpunktes (Ursprungs) + let epsilon = (total_len * 0.08).clamp(12.0, 50.0); + let simplified = ramer_douglas_peucker(points, epsilon); + + if simplified.len() != 3 { + return None; + } + + let p0 = simplified[0]; + let origin = simplified[1]; + let p2 = simplified[2]; + + let v0 = p0 - origin; + let v2 = p2 - origin; + let len0 = v0.length(); + let len2 = v2.length(); + + if len0 < 15.0 || len2 < 15.0 { + return None; + } + + let dot = (v0.normalize().dot(v2.normalize())).clamp(-1.0, 1.0); + let angle_deg = dot.acos().to_degrees(); + + // Orthogonalitäts-Bedingung: 80° bis 100° + if !(80.0..=100.0).contains(&angle_deg) { + return None; + } + + // Orthogonalitäts-Snap: Auf exakt 90° ausrichten + // Bestimme, welche Achse näher an der Vertikalen (Y) und welche an der Horizontalen (X) liegt + let (x_end, y_end) = if v0.x.abs() > v0.y.abs() { + // v0 ist eher horizontal, v2 eher vertikal + let sign_x = if v0.x >= 0.0 { 1.0 } else { -1.0 }; + let sign_y = if v2.y >= 0.0 { 1.0 } else { -1.0 }; + ( + origin + Vec2::new(sign_x * len0, 0.0), + origin + Vec2::new(0.0, sign_y * len2), + ) + } else { + // v2 ist eher horizontal, v0 eher vertikal + let sign_y = if v0.y >= 0.0 { 1.0 } else { -1.0 }; + let sign_x = if v2.x >= 0.0 { 1.0 } else { -1.0 }; + ( + origin + Vec2::new(sign_x * len2, 0.0), + origin + Vec2::new(0.0, sign_y * len0), + ) + }; + + Some(RecognizedShape::CoordinateSystem { + origin, + x_axis_end: x_end, + y_axis_end: y_end, + with_arrows: true, + }) +} + +/// Führt die automatische Erkennung für einen Strich aus. +pub fn detect_shape(points: &[StylusSample]) -> Option { + if points.len() < 2 { + return None; + } + let pts: Vec = points.iter().map(|s| s.position).collect(); + + // 1. Zuerst 3D-Kantenmodelle prüfen (Oktaeder / Doppelpyramide) + if let Some(octa) = detect_octahedron(&pts) { + return Some(octa); + } + + // 2. Geschlossene Formen prüfen (Kreis, Dreieck, Rechteck, Raute, Polygon/Oktagon) + if let Some(circle) = detect_circle(&pts) { + return Some(circle); + } + if let Some(triangle) = detect_triangle(&pts) { + return Some(triangle); + } + if let Some(rect) = detect_rectangle(&pts) { + return Some(rect); + } + if let Some(rhombus) = detect_rhombus(&pts) { + return Some(rhombus); + } + if let Some(polygon) = detect_polygon(&pts) { + return Some(polygon); + } + + // 3. Offene Formen: Koordinatensystem oder Gerade + if let Some(coord) = detect_coordinate_system(&pts) { + return Some(coord); + } + if let Some(line) = detect_line(&pts) { + return Some(line); + } + + None +} + +/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck). +pub fn closed_polygon_to_mesh( + corners: &[Vec2], + half_w: f32, + color: [f32; 4], +) -> (Vec, Vec) { + let n = corners.len(); + if n < 3 { + return (Vec::new(), Vec::new()); + } + + let mut vertices = Vec::with_capacity((n + 1) * 2); + let mut samples = Vec::with_capacity(n * 8); + + for i in 0..n { + let prev = corners[(i + n - 1) % n]; + let curr = corners[i]; + let next = corners[(i + 1) % n]; + + let d_prev = (curr - prev).normalize_or_zero(); + let d_next = (next - curr).normalize_or_zero(); + + let n_prev = Vec2::new(-d_prev.y, d_prev.x); + 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)); + + let edge_len = (next - curr).length(); + let steps = ((edge_len / 10.0).ceil() as usize).max(2); + for s in 0..steps { + let t = s as f32 / steps as f32; + samples.push(StylusSample { + position: curr.lerp(next, t), + pressure: 0.5, + timestamp: 0.0, + }); + } + } + + if vertices.len() >= 2 { + let v0 = vertices[0]; + let v1 = vertices[1]; + vertices.push(v0); + vertices.push(v1); + } + + (samples, vertices) +} + +/// 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)> { + 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); + let right = center + Vec2::new(radius_x, 0.0); + let front = center + Vec2::new(-0.2 * radius_x, depth); + let back = center + Vec2::new(0.2 * radius_x, -depth); + + vec![ + // Äquator-Ring (4 Kanten) + (left, front), + (front, right), + (right, back), + (back, left), + // Obere Pyramide (4 Kanten) + (top, left), + (top, front), + (top, right), + (top, back), + // Untere Pyramide (4 Kanten) + (bottom, left), + (bottom, front), + (bottom, right), + (bottom, back), + ] +} + +/// Erzeugt dichte Abtastpunkte und ein GPU-kompatibles Triangle-Strip-Mesh für eine erkannte Form. +pub fn shape_to_mesh( + shape: &RecognizedShape, + width_units: f32, + color: [f32; 4], +) -> (Vec, Vec) { + let half_w = (width_units * 0.5).max(1.0); + + match shape { + RecognizedShape::Line { start, end } => { + let dir = (*end - *start).normalize_or_zero(); + let norm = Vec2::new(-dir.y, dir.x) * half_w; + + let vertices = vec![ + InkVertex::new(start.x + norm.x, start.y + norm.y, color), + InkVertex::new(start.x - norm.x, start.y - norm.y, color), + InkVertex::new(end.x + norm.x, end.y + norm.y, color), + InkVertex::new(end.x - norm.x, end.y - norm.y, color), + ]; + + let len = (*end - *start).length(); + let steps = ((len / 5.0).ceil() as usize).max(2); + let mut samples = Vec::with_capacity(steps); + for i in 0..steps { + let t = i as f32 / (steps - 1) as f32; + samples.push(StylusSample { + position: start.lerp(*end, t), + pressure: 0.5, + timestamp: 0.0, + }); + } + + (samples, vertices) + } + + RecognizedShape::Circle { center, radius } => { + let segments = 64; + let r_in = (radius - half_w).max(0.1); + let r_out = radius + half_w; + + let mut vertices = Vec::with_capacity((segments + 1) * 2); + let mut samples = Vec::with_capacity(segments); + + for i in 0..=segments { + 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)); + + if i < segments { + samples.push(StylusSample { + position: *center + Vec2::new(cos * radius, sin * radius), + pressure: 0.5, + timestamp: 0.0, + }); + } + } + + (samples, vertices) + } + + RecognizedShape::Rectangle { 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::Polygon { vertices } => { + closed_polygon_to_mesh(vertices, half_w, color) + } + + 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(); + + for (i, &(p1, p2)) in edges.iter().enumerate() { + let dir = (p2 - p1).normalize_or_zero(); + let norm = Vec2::new(-dir.y, dir.x) * half_w; + + let v0 = InkVertex::new(p1.x + norm.x, p1.y + norm.y, color); + let v1 = InkVertex::new(p1.x - norm.x, p1.y - norm.y, color); + let v2 = InkVertex::new(p2.x + norm.x, p2.y + norm.y, color); + let v3 = InkVertex::new(p2.x - norm.x, p2.y - norm.y, color); + + if i > 0 { + let last = *vertices.last().unwrap(); + vertices.push(last); + vertices.push(v0); + } + + vertices.push(v0); + vertices.push(v1); + vertices.push(v2); + vertices.push(v3); + + let len = (p2 - p1).length(); + let steps = ((len / 15.0).ceil() as usize).max(2); + for s in 0..steps { + let t = s as f32 / steps as f32; + samples.push(StylusSample { + position: p1.lerp(p2, t), + pressure: 0.5, + timestamp: 0.0, + }); + } + } + + (samples, vertices) + } + + RecognizedShape::CoordinateSystem { + origin, + x_axis_end, + y_axis_end, + with_arrows, + } => { + let mut vertices = Vec::new(); + let mut samples = Vec::new(); + + // 1. Y-Achse (von Pfeilspitze oben zum Ursprung) + let y_dir = (*y_axis_end - *origin).normalize_or_zero(); + 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)); + + // Degenerate Dreiecke für Übergang zur X-Achse + let x_dir = (*x_axis_end - *origin).normalize_or_zero(); + let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w; + + let last_v = *vertices.last().unwrap(); + let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color); + vertices.push(last_v); + vertices.push(next_v); + + // 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)); + + // 3. Optionale Pfeilspitzen + if *with_arrows { + let arrow_len = (half_w * 5.0).max(18.0); + let arrow_w = (half_w * 3.5).max(12.0); + + // Pfeilspitze Y + let y_base = *y_axis_end - y_dir * arrow_len; + let y_left = y_base + Vec2::new(-y_dir.y, y_dir.x) * arrow_w; + let y_right = y_base - Vec2::new(-y_dir.y, y_dir.x) * arrow_w; + + let last_v = *vertices.last().unwrap(); + let tip_v = InkVertex::new(y_axis_end.x, y_axis_end.y, color); + vertices.push(last_v); + vertices.push(tip_v); + + vertices.push(tip_v); + vertices.push(InkVertex::new(y_left.x, y_left.y, color)); + vertices.push(InkVertex::new(y_right.x, y_right.y, color)); + + // Pfeilspitze X + let x_base = *x_axis_end - x_dir * arrow_len; + let x_left = x_base + Vec2::new(-x_dir.y, x_dir.x) * arrow_w; + let x_right = x_base - Vec2::new(-x_dir.y, x_dir.x) * arrow_w; + + let last_v = *vertices.last().unwrap(); + let tip_x = InkVertex::new(x_axis_end.x, x_axis_end.y, color); + vertices.push(last_v); + vertices.push(tip_x); + + vertices.push(tip_x); + vertices.push(InkVertex::new(x_left.x, x_left.y, color)); + vertices.push(InkVertex::new(x_right.x, x_right.y, color)); + } + + // Abtastpunkte + samples.push(StylusSample { + position: *y_axis_end, + pressure: 0.5, + timestamp: 0.0, + }); + samples.push(StylusSample { + position: *origin, + pressure: 0.5, + timestamp: 0.0, + }); + samples.push(StylusSample { + position: *x_axis_end, + pressure: 0.5, + timestamp: 0.0, + }); + + (samples, vertices) + } + } +} + +/// Erzeugt einen neuen `Stroke` aus einer erkannten Form. +pub fn shape_to_stroke( + shape: RecognizedShape, + stroke_id: u64, + color: [f32; 4], + width_units: f32, +) -> Stroke { + let (samples, vertices) = shape_to_mesh(&shape, width_units, color); + let mut stroke = Stroke::new(stroke_id, samples, vertices, color); + stroke.shape = Some(shape); + stroke +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_line() { + // Leicht zittrige diagonale Punktfolge + let mut points = Vec::new(); + 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)); + } + + let detected = detect_line(&points); + assert!(detected.is_some(), "Gerade sollte erkannt werden"); + + if let Some(RecognizedShape::Line { start, end }) = detected { + assert!((start - points[0]).length() < 5.0); + assert!((end - *points.last().unwrap()).length() < 10.0); + } else { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_rectangle() { + // Handgezeichnete 4-Punkt-Schleife mit leichtem Jitter + let mut points = Vec::new(); + // Kante 1: Oben links nach oben rechts + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(Vec2::new(100.0 + t * 200.0, 100.0 + (i as f32).sin() * 1.2)); + } + // Kante 2: Oben rechts nach unten rechts + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(Vec2::new(300.0 + (i as f32).cos() * 1.2, 100.0 + t * 150.0)); + } + // Kante 3: Unten rechts nach unten links + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(Vec2::new(300.0 - t * 200.0, 250.0 - (i as f32).sin() * 1.2)); + } + // Kante 4: Unten links nach oben links (Schließen) + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(Vec2::new(100.0 - (i as f32).cos() * 1.2, 250.0 - t * 150.0)); + } + points.push(points[0]); + + let detected = detect_rectangle(&points); + assert!(detected.is_some(), "Rechteck sollte erkannt werden"); + + if let Some(RecognizedShape::Rectangle { corners }) = detected { + // Alle 4 Winkel müssen exakt 90° sein + for i in 0..4 { + let prev = corners[(i + 3) % 4]; + let curr = corners[i]; + let next = corners[(i + 1) % 4]; + 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); + } + } else { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_circle() { + // Unrundes Oval / Kreis mit leicht variierendem Radius (50 bis 58) + let mut points = Vec::new(); + let center = Vec2::new(200.0, 200.0); + let n = 40; + for i in 0..=n { + let angle = (i as f32 / n as f32) * 2.0 * PI; + let r = 54.0 + (angle * 3.0).sin() * 3.5; + points.push(center + Vec2::new(angle.cos() * r, angle.sin() * r)); + } + + let detected = detect_circle(&points); + assert!(detected.is_some(), "Kreis sollte erkannt werden"); + + 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 { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_coordinate_system() { + // Zwei zusammenhängende Linien im 85°-Winkel + let mut points = Vec::new(); + let origin = Vec2::new(100.0, 300.0); + + // Vertikaler Schenkel (Y-Achse von oben (100, 100) hinunter zum Ursprung (100, 300)) + for i in 0..=20 { + let t = i as f32 / 20.0; + points.push(Vec2::new(100.0, 100.0 + t * 200.0)); + } + + // Schenkel im 85°-Winkel zur Y-Achse (5° nach oben geneigt statt rein horizontal) + let angle_rad = 5.0f32.to_radians(); + for i in 1..=20 { + let t = i as f32 / 20.0; + let len = t * 200.0; + points.push(origin + Vec2::new(len * angle_rad.cos(), -len * angle_rad.sin())); + } + + let detected = detect_coordinate_system(&points); + assert!(detected.is_some(), "Koordinatensystem sollte erkannt werden"); + + 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); + } else { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_triangle() { + let mut points = Vec::new(); + let a = Vec2::new(100.0, 300.0); + let b = Vec2::new(300.0, 300.0); + let c = Vec2::new(200.0, 126.8); // ~Gleichseitiges Dreieck + + // Kante A -> B + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(a.lerp(b, t) + Vec2::new(0.0, (i as f32).sin() * 1.0)); + } + // Kante B -> C + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(b.lerp(c, t) + Vec2::new((i as f32).cos() * 1.0, 0.0)); + } + // Kante C -> A + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(c.lerp(a, t)); + } + points.push(a); + + let detected = detect_triangle(&points); + assert!(detected.is_some(), "Dreieck sollte erkannt werden"); + + if let Some(RecognizedShape::Triangle { corners }) = detected { + assert_eq!(corners.len(), 3); + 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!((bc - ca).abs() < 5.0); + } else { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_rhombus() { + let mut points = Vec::new(); + // Raute mit Kantenlängen ~200, nicht rechtwinklig (Winkel 60° und 120°) + 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 + + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(c0.lerp(c1, t) + Vec2::new((i as f32).sin() * 1.0, 0.0)); + } + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(c1.lerp(c2, t)); + } + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(c2.lerp(c3, t) - Vec2::new((i as f32).cos() * 1.0, 0.0)); + } + for i in 0..20 { + let t = i as f32 / 20.0; + points.push(c3.lerp(c0, t)); + } + points.push(c0); + + let detected = detect_rhombus(&points); + assert!(detected.is_some(), "Raute sollte erkannt werden"); + + if let Some(RecognizedShape::Rhombus { corners }) = detected { + assert_eq!(corners.len(), 4); + // Gegenüberliegende Kanten müssen parallel und gleich lang sein + let l0 = (corners[1] - corners[0]).length(); + 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!((l1 - l2).abs() < 1e-3); + assert!((l2 - l3).abs() < 1e-3); + } else { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_polygon_octagon() { + let mut points = Vec::new(); + let center = Vec2::new(200.0, 200.0); + let r = 80.0; + let n = 8; + + for i in 0..n { + let a1 = i as f32 * (2.0 * PI / n as f32); + let a2 = (i + 1) as f32 * (2.0 * PI / n as f32); + let p1 = center + Vec2::new(a1.cos() * r, a1.sin() * r); + let p2 = center + Vec2::new(a2.cos() * r, a2.sin() * r); + + for step in 0..10 { + let t = step as f32 / 10.0; + points.push(p1.lerp(p2, t) + Vec2::new((step as f32 * 0.5).sin() * 0.8, 0.0)); + } + } + points.push(points[0]); + + let detected = detect_polygon(&points); + 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"); + } else { + panic!("Falscher Formtyp erkannt"); + } + } + + #[test] + fn test_detect_octahedron() { + let mut points = Vec::new(); + let top = Vec2::new(200.0, 50.0); + let right = Vec2::new(350.0, 200.0); + let bottom = Vec2::new(200.0, 350.0); + let left = Vec2::new(50.0, 200.0); + + // Äußere Raute + for i in 0..15 { + points.push(top.lerp(right, i as f32 / 15.0)); + } + for i in 0..15 { + points.push(right.lerp(bottom, i as f32 / 15.0)); + } + for i in 0..15 { + points.push(bottom.lerp(left, i as f32 / 15.0)); + } + for i in 0..15 { + points.push(left.lerp(top, i as f32 / 15.0)); + } + // Interne Äquatorlinie von Links nach Rechts durch das Zentrum + for i in 0..20 { + points.push(left.lerp(right, i as f32 / 20.0)); + } + + 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 { + assert!((center.x - 200.0).abs() < 5.0); + assert!((center.y - 200.0).abs() < 5.0); + assert!(radius_x > 100.0); + assert!(radius_y > 100.0); + assert!(depth > 10.0); + } else { + panic!("Falscher Formtyp erkannt"); + } + } +} diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..27efc30 --- /dev/null +++ b/src/history.rs @@ -0,0 +1,241 @@ +//! Transaktionales Undo / Redo mit History-Stack pro Seite oder Notizbuch. + +use crate::document::Stroke; + +/// Eine rückgängig machbare Benutzeraktion auf einer Seite. +#[derive(Debug, Clone)] +pub enum HistoryAction { + /// Ein Strich wurde neu gezeichnet. + AddStroke(Stroke), + /// Mehrere Striche wurden auf einmal hinzugefügt (z. B. Einfügen/Duplizieren). + AddStrokes(Vec), + /// Ein oder mehrere Striche wurden durch den Radierer oder Selektion gelöscht. + RemoveStrokes(Vec), + /// Ein Strich wurde durch eine geometrische Form ersetzt. + ReplaceStroke { old: Stroke, new: Stroke }, + /// Ein oder mehrere Striche wurden modifiziert (z. B. verschoben, gedreht, skaliert, umgefärbt). + ModifyStrokes { + before: Vec, + after: Vec, + }, +} + +/// History-Stack für Transaktionsverwaltung (Undo / Redo). +#[derive(Debug, Clone, Default)] +pub struct HistoryStack { + pub undo_stack: Vec, + pub redo_stack: Vec, +} + +impl HistoryStack { + pub fn new() -> Self { + Self { + undo_stack: Vec::new(), + redo_stack: Vec::new(), + } + } + + /// Registriert eine neue Aktion. Leert den Redo-Stack. + pub fn push(&mut self, action: HistoryAction) { + self.undo_stack.push(action); + self.redo_stack.clear(); + } + + /// Gibt zurück, ob Undo verfügbar ist. + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Gibt zurück, ob Redo verfügbar ist. + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Führt Undo auf der Liste der Striche aus. + /// + /// Gibt `true` zurück, wenn sich die Strichliste geändert hat. + pub fn undo(&mut self, strokes: &mut Vec) -> bool { + let Some(action) = self.undo_stack.pop() else { + return false; + }; + + match action { + HistoryAction::AddStroke(stroke) => { + // Den hinzugefügten Strich aus der Seite entfernen + if let Some(pos) = strokes.iter().position(|s| s.id == stroke.id) { + strokes.remove(pos); + } + self.redo_stack.push(HistoryAction::AddStroke(stroke)); + } + HistoryAction::AddStrokes(added) => { + let ids: Vec = added.iter().map(|s| s.id).collect(); + strokes.retain(|s| !ids.contains(&s.id)); + self.redo_stack.push(HistoryAction::AddStrokes(added)); + } + HistoryAction::RemoveStrokes(removed) => { + // Die gelöschten Striche wiederherstellen + for s in &removed { + strokes.push(s.clone()); + } + self.redo_stack.push(HistoryAction::RemoveStrokes(removed)); + } + HistoryAction::ReplaceStroke { old, new } => { + if let Some(pos) = strokes.iter().position(|s| s.id == new.id) { + strokes[pos] = old.clone(); + } + self.redo_stack.push(HistoryAction::ReplaceStroke { old, new }); + } + HistoryAction::ModifyStrokes { before, after } => { + for b in &before { + if let Some(pos) = strokes.iter().position(|s| s.id == b.id) { + strokes[pos] = b.clone(); + } + } + self.redo_stack.push(HistoryAction::ModifyStrokes { before, after }); + } + } + + true + } + + /// Führt Redo auf der Liste der Striche aus. + /// + /// Gibt `true` zurück, wenn sich die Strichliste geändert hat. + pub fn redo(&mut self, strokes: &mut Vec) -> bool { + let Some(action) = self.redo_stack.pop() else { + return false; + }; + + match action { + HistoryAction::AddStroke(stroke) => { + // Strich erneut hinzufügen + strokes.push(stroke.clone()); + self.undo_stack.push(HistoryAction::AddStroke(stroke)); + } + HistoryAction::AddStrokes(added) => { + strokes.extend(added.clone()); + self.undo_stack.push(HistoryAction::AddStrokes(added)); + } + HistoryAction::RemoveStrokes(removed) => { + // Striche erneut entfernen + let ids: Vec = removed.iter().map(|s| s.id).collect(); + strokes.retain(|s| !ids.contains(&s.id)); + self.undo_stack.push(HistoryAction::RemoveStrokes(removed)); + } + HistoryAction::ReplaceStroke { old, new } => { + if let Some(pos) = strokes.iter().position(|s| s.id == old.id) { + strokes[pos] = new.clone(); + } + self.undo_stack.push(HistoryAction::ReplaceStroke { old, new }); + } + HistoryAction::ModifyStrokes { before, after } => { + for a in &after { + if let Some(pos) = strokes.iter().position(|s| s.id == a.id) { + strokes[pos] = a.clone(); + } + } + self.undo_stack.push(HistoryAction::ModifyStrokes { before, after }); + } + } + + true + } + + /// Setzt den History-Stack zurück. + pub fn clear(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::StylusSample; + + fn make_dummy_stroke(id: u64) -> Stroke { + Stroke::new( + id, + vec![StylusSample { + position: glam::Vec2::new(0.0, 0.0), + pressure: 0.5, + timestamp: 0.0, + }], + Vec::new(), + [0.0, 0.0, 0.0, 1.0], + ) + } + + #[test] + fn test_undo_redo_add_stroke() { + let mut history = HistoryStack::new(); + let mut strokes = Vec::new(); + + let s1 = make_dummy_stroke(1); + strokes.push(s1.clone()); + history.push(HistoryAction::AddStroke(s1)); + + assert_eq!(strokes.len(), 1); + assert!(history.can_undo()); + assert!(!history.can_redo()); + + // Undo + assert!(history.undo(&mut strokes)); + assert_eq!(strokes.len(), 0); + assert!(!history.can_undo()); + assert!(history.can_redo()); + + // Redo + assert!(history.redo(&mut strokes)); + assert_eq!(strokes.len(), 1); + assert_eq!(strokes[0].id, 1); + assert!(history.can_undo()); + assert!(!history.can_redo()); + } + + #[test] + fn test_undo_redo_remove_strokes() { + let mut history = HistoryStack::new(); + let mut strokes = vec![make_dummy_stroke(1), make_dummy_stroke(2)]; + + // Löschen von Stroke 2 + let removed = vec![strokes.remove(1)]; + history.push(HistoryAction::RemoveStrokes(removed)); + + assert_eq!(strokes.len(), 1); + + // Undo: Stroke 2 wird wiederhergestellt + assert!(history.undo(&mut strokes)); + assert_eq!(strokes.len(), 2); + + // Redo: Stroke 2 wird wieder entfernt + assert!(history.redo(&mut strokes)); + assert_eq!(strokes.len(), 1); + assert_eq!(strokes[0].id, 1); + } + + #[test] + fn test_undo_redo_replace_stroke() { + let mut history = HistoryStack::new(); + let s_old = make_dummy_stroke(1); + let mut s_new = make_dummy_stroke(1); + s_new.color = [1.0, 0.0, 0.0, 1.0]; // Modifizierter Strich (z. B. erkannte Form) + + let mut strokes = vec![s_old.clone()]; + strokes[0] = s_new.clone(); + history.push(HistoryAction::ReplaceStroke { + old: s_old.clone(), + new: s_new.clone(), + }); + + assert_eq!(strokes[0].color, [1.0, 0.0, 0.0, 1.0]); + + // Undo: stellt den alten Strich wieder her + assert!(history.undo(&mut strokes)); + assert_eq!(strokes[0].color, s_old.color); + + // Redo: setzt den neuen Strich wieder ein + assert!(history.redo(&mut strokes)); + assert_eq!(strokes[0].color, [1.0, 0.0, 0.0, 1.0]); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c21ad1b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +//! ink-notes: Latenzoptimierte Inking-App für Linux. + +pub mod autosave; +pub mod background; +pub mod canvas; +pub mod document; +pub mod export; +pub mod filter; +pub mod geometry; +pub mod history; +pub mod selection; +pub mod spline; +pub mod storage; +pub mod ui; +pub mod viewport; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b99cf51 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1678 @@ +//! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar, +//! Seitenverwaltung, Undo/Redo, Vektor-PDF-Export und asynchroner Notizbuch-Persistenz (Autosave). + +use std::sync::Arc; +use std::time::{Duration, Instant}; +use winit::{ + event::{ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + keyboard::{Key, ModifiersState, NamedKey}, + window::{Window, WindowBuilder}, +}; + +use glam::Vec2; +use ink_notes::autosave::{AutosaveController, AutosaveStatus}; +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::storage::{self, NotebookManifest}; +use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; + +/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. +fn update_window_title( + window: &Window, + notebook: &Notebook, + manifest: &NotebookManifest, + tool: ActiveTool, + status: AutosaveStatus, +) { + let page = notebook.current_page(); + let bg_name = match page.background { + PageBackground::Blank => "Blanko", + PageBackground::Grid5mm => "Kariert (5mm)", + PageBackground::Lined => "Liniert (7.5mm)", + PageBackground::DotGrid => "Punktraster (5mm)", + }; + + let title = format!( + "ink-notes | [{}] {} | [{}] | {} | Hintergrund: {} | [Ctrl+Z] Undo [Ctrl+Y] Redo [E] Radierer [Ctrl+E/S] PDF-Export", + manifest.title, + notebook.osd_text(), + status.display_text(), + tool.name(), + bg_name + ); + window.set_title(&title); +} + +fn main() -> Result<(), Box> { + let event_loop = EventLoop::new()?; + let window = Arc::new( + WindowBuilder::new() + .with_title("ink-notes (Latenzoptimiertes Linux-Inking)") + .with_inner_size(winit::dpi::LogicalSize::new(1280.0, 850.0)) + .build(&event_loop)?, + ); + + let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?; + + // 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung) + let storage_base_dir = storage::default_storage_dir(); + let available_notebooks = storage::list_notebooks(&storage_base_dir); + + let (initial_id, initial_title) = if let Some(first) = available_notebooks.first() { + (first.id.clone(), first.title.clone()) + } else { + ("default".to_string(), "Standard-Notizbuch".to_string()) + }; + + let mut storage_dir = storage_base_dir.join(&initial_id); + let (mut notebook, mut manifest) = if storage_dir.join("manifest.json").exists() { + println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display()); + match storage::load_notebook(&storage_dir) { + 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)) + } + } + } else { + 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 + let mut autosave = AutosaveController::new(storage_dir.clone(), Duration::from_millis(1500)); + + // 3. egui Context und State initialisieren + let egui_ctx = egui::Context::default(); + egui_ctx.set_visuals(egui::Visuals::dark()); + + let mut egui_winit_state = egui_winit::State::new( + egui_ctx.clone(), + egui::ViewportId::ROOT, + &window, + Some(window.scale_factor() as f32), + Some(2048), + ); + + let mut ui_state = UiState::new(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = available_notebooks; + let mut selection_state = SelectionState::new(); + + // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren + canvas.sync_page(notebook.current_page_mut()); + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + + let start_time = Instant::now(); + let mut cursor_screen_pos = (0.0_f32, 0.0_f32); + let mut is_drawing = false; + let mut is_erasing = false; + let mut current_pressure = 0.5_f32; + let mut modifiers = ModifiersState::empty(); + + let mut is_mouse_drawing = false; + let mut active_touch_id: Option = None; + + // Viewport-Pan & Zoom-Zustand + let mut is_panning = false; + let mut pan_last_screen_pos = (0.0_f32, 0.0_f32); + let mut is_space_pressed = false; + + // Draw-and-Hold Zustand für automatische Formerkennung + let mut stroke_last_motion_time = Instant::now(); + let mut stroke_hold_anchor_pos = (0.0_f32, 0.0_f32); + let mut stroke_already_snapped = false; + let mut stroke_snapped_shape: Option = None; + + // Radier-Parameter + 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()); + + println!("=== ink-notes (Multi-Color & Objektauswahl/Transformation) bereit ==="); + println!("Toolbar:"); + println!(" Stift / Textmarker / Formen (Auto-Snap) / Radierer / Auswählen (Lasso)"); + println!(" Farbpalette (Multicolor), Strichbreite, Undo/Redo, Zoom, Navigation & PDF-Export"); + println!("Objektauswahl & Transformation:"); + println!(" V : Auswahlwerkzeug (Lasso) umschalten"); + println!(" Lasso-Drag : Auswahlrahmen über Striche aufziehen"); + println!(" Klick/Shift+Kl.: Einzelne Striche auswählen / Auswahl umschalten"); + println!(" Objekt ziehen : Striche in Echtzeit verschieben"); + println!(" Griffe (Ecken) : Ausgewählte Objekte stufenlos skalieren"); + println!(" Dreh-Griff / R : Objekte stufenlos drehen oder um 90° kippen (R)"); + println!(" Palette-Klick : Ausgewählte Objekte direkt umfärben"); + println!(" Strg + C / V : Ausgewählte Objekte kopieren & einfügen"); + println!(" Entf/Backspace : Ausgewählte Objekte löschen (Undo mit Strg+Z)"); + println!("Tastatur- & Maus-Shortcuts:"); + println!(" Mausrad : Stufenloser Zoom zentriert auf Cursor"); + println!(" Mittlere Taste : Canvas verschieben (Pan)"); + println!(" Leertaste+Drag : Canvas verschieben (Pan)"); + println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)"); + println!(" Draw-and-Hold : Form am Strichende >400ms halten zum automatischen Einrasten"); + println!(" Strg + Z / Y : Transaktionales Undo / Redo für alle Aktionen"); + println!(" Strg + E / S : Vektor-PDF-Export (nach ./export.pdf)"); + println!(" ESC / Q : Beenden (mit sicherem Disk-Flush)"); + + event_loop.run(move |event, target| { + match event { + Event::AboutToWait => { + let prev_status = autosave.status(); + let new_status = autosave.update(); + if new_status != prev_status { + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, new_status); + } + + // Draw-and-Hold Überprüfung: Stift regungslos für > 400 ms gehalten (< 3 px Distanz) + if is_drawing && !stroke_already_snapped { + let elapsed = stroke_last_motion_time.elapsed(); + let dx = cursor_screen_pos.0 - stroke_hold_anchor_pos.0; + let dy = cursor_screen_pos.1 - stroke_hold_anchor_pos.1; + let dist = (dx * dx + dy * dy).sqrt(); + + if elapsed >= Duration::from_millis(400) && dist < 3.0 && canvas.live_samples.len() >= 4 { + if let Some(shape) = geometry::detect_shape(&canvas.live_samples) { + stroke_already_snapped = true; + stroke_snapped_shape = Some(shape.clone()); + let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; + let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color); + canvas.set_live_shape_mesh(samples, vertices); + ui_state.set_export_message(format!("{} eingerastet!", shape.name())); + window.request_redraw(); + } + } + } + + // Bei anstehenden Änderungen, aktivem Toast oder aktivem Zeichnen regelmäßig aufwachen + if new_status == AutosaveStatus::Pending + || new_status == AutosaveStatus::Saving + || ui_state.export_message.is_some() + || (is_drawing && !stroke_already_snapped) + { + target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(16))); + } else { + target.set_control_flow(ControlFlow::Wait); + } + } + + Event::WindowEvent { event, window_id } if window_id == window.id() => { + // 1. Window-Event zuerst an egui übergeben + let egui_response = egui_winit_state.on_window_event(&window, &event); + if egui_response.repaint { + window.request_redraw(); + } + + // 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation) + // Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs + let is_pointer_in_ui = egui_ctx.wants_pointer_input() + || ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1) + || (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area()) + || (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area()); + + match event { + WindowEvent::CloseRequested => { + println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); + autosave.shutdown(); + target.exit(); + } + + WindowEvent::Focused(false) => { + // Bei Fokusverlust Strich abbrechen und 1€-Filter resetten + if is_drawing { + is_drawing = false; + canvas.cancel_stroke(); + window.request_redraw(); + } + is_erasing = false; + is_mouse_drawing = false; + active_touch_id = None; + } + + WindowEvent::Resized(physical_size) => { + canvas.resize(physical_size); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + + WindowEvent::ScaleFactorChanged { .. } => { + canvas.resize(window.inner_size()); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + + WindowEvent::ModifiersChanged(new_mods) => { + modifiers = new_mods.state(); + } + + WindowEvent::KeyboardInput { + event: + KeyEvent { + logical_key, + state: key_state, + .. + }, + .. + } => { + // Tastatureingaben nicht für Shortcuts nutzen, wenn ein Textfeld fokussiert ist + if egui_ctx.wants_keyboard_input() { + return; + } + + if let Key::Named(NamedKey::Space) = &logical_key { + is_space_pressed = key_state == ElementState::Pressed; + if !is_space_pressed && is_panning { + is_panning = false; + } + } + + if key_state != ElementState::Pressed { + return; + } + + let ctrl = modifiers.control_key(); + let shift = modifiers.shift_key(); + + match &logical_key { + Key::Named(NamedKey::Escape) => { + println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); + autosave.shutdown(); + target.exit(); + } + + // Undo: Strg + Z + Key::Character(c) if c.eq_ignore_ascii_case("z") && ctrl && !shift => { + if notebook.current_page_mut().undo() { + selection_state.clear(); + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + + // Redo: Strg + Y oder Strg + Umschalt + Z + Key::Character(c) + if (c.eq_ignore_ascii_case("y") && ctrl) + || (c.eq_ignore_ascii_case("z") && ctrl && shift) => + { + if notebook.current_page_mut().redo() { + selection_state.clear(); + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + + // Kopieren: Strg + C + Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => { + let count = selection_state.copy_to_clipboard(notebook.current_page()); + if count > 0 { + ui_state.set_export_message(format!("{count} Objekt(e) kopiert")); + window.request_redraw(); + } + } + + // Einfügen: Strg + V + Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => { + let mut next_id = notebook.next_stroke_id; + let new_ids = selection_state.paste_from_clipboard( + notebook.current_page_mut(), + &mut || { + let id = next_id; + next_id += 1; + id + }, + ); + notebook.next_stroke_id = next_id; + if !new_ids.is_empty() { + canvas.sync_page(notebook.current_page_mut()); + manifest.next_stroke_id = notebook.next_stroke_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); + window.request_redraw(); + } + } + + // Löschen: Entf oder Backspace + Key::Named(NamedKey::Delete) | Key::Named(NamedKey::Backspace) => { + if !selection_state.is_empty() { + let removed = selection_state.delete_selected(notebook.current_page_mut()); + if !removed.is_empty() { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); + window.request_redraw(); + } + } + } + + // PDF-Export: Strg + E oder Strg + S + Key::Character(c) + if (c.eq_ignore_ascii_case("e") || c.eq_ignore_ascii_case("s")) + && ctrl => + { + let notebook_clone = notebook.clone(); + let export_path = "export.pdf".to_string(); + let window_ref = window.clone(); + println!( + "[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...", + export_path + ); + ui_state.set_export_message("Exportiere PDF..."); + std::thread::spawn(move || { + let start = std::time::Instant::now(); + match export_notebook_to_pdf( + ¬ebook_clone, + &export_path, + ExportStrategy::FilledPolygon, + ) { + Ok(()) => { + let elapsed = start.elapsed(); + println!( + "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", + export_path, + elapsed, + notebook_clone.pages.len() + ); + window_ref.request_redraw(); + } + Err(err) => { + eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"); + } + } + }); + ui_state.set_export_message("PDF erfolgreich als export.pdf exportiert!"); + window.request_redraw(); + } + + // Radierer umschalten: E + Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => { + ui_state.active_tool = match ui_state.active_tool { + ActiveTool::Pen | ActiveTool::Highlighter | ActiveTool::Shape | ActiveTool::Select => ActiveTool::Eraser, + ActiveTool::Eraser => ActiveTool::Pen, + }; + ui_state.is_pen_menu_open = false; + ui_state.is_highlighter_menu_open = false; + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Auswahlwerkzeug umschalten: V (ohne Strg) + Key::Character(c) if c.eq_ignore_ascii_case("v") && !ctrl => { + ui_state.active_tool = if ui_state.active_tool == ActiveTool::Select { + ActiveTool::Pen + } else { + ActiveTool::Select + }; + ui_state.is_pen_menu_open = false; + ui_state.is_highlighter_menu_open = false; + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Stiftfarbe Notiz-Schwarz: B + Key::Character(c) if c.eq_ignore_ascii_case("b") && !ctrl => { + if !selection_state.is_empty() { + let color = [0.12, 0.12, 0.14, 1.0]; + if selection_state.recolor_selected(notebook.current_page_mut(), color) { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string()); + window.request_redraw(); + } + } else { + ui_state.active_tool = ActiveTool::Pen; + ui_state.pen_color = [0.12, 0.12, 0.14, 1.0]; + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + + // Stiftfarbe Universitäts-Blau: U + Key::Character(c) if c.eq_ignore_ascii_case("u") && !ctrl => { + if !selection_state.is_empty() { + let color = [0.15, 0.35, 0.85, 1.0]; + if selection_state.recolor_selected(notebook.current_page_mut(), color) { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string()); + window.request_redraw(); + } + } else { + ui_state.active_tool = ActiveTool::Pen; + ui_state.pen_color = [0.15, 0.35, 0.85, 1.0]; + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + + // Stiftfarbe Korrektur-Rot oder Drehen: R + Key::Character(c) if c.eq_ignore_ascii_case("r") && !ctrl => { + if !selection_state.is_empty() { + if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); + window.request_redraw(); + } + } else { + ui_state.active_tool = ActiveTool::Pen; + ui_state.pen_color = [0.85, 0.20, 0.20, 1.0]; + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + + // Hintergrund wechseln: G + Key::Character(c) if c.eq_ignore_ascii_case("g") && !ctrl => { + let page = notebook.current_page_mut(); + page.background = match page.background { + PageBackground::Grid5mm => PageBackground::Lined, + PageBackground::Lined => PageBackground::DotGrid, + PageBackground::DotGrid => PageBackground::Blank, + PageBackground::Blank => PageBackground::Grid5mm, + }; + canvas.sync_page(page); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Nächste Seite: PageDown oder N (mit Lazy Loading) + Key::Named(NamedKey::PageDown) => { + selection_state.clear(); + notebook.next_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + manifest.next_page_id = notebook.next_page_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + Key::Character(c) if c.eq_ignore_ascii_case("n") && !ctrl => { + selection_state.clear(); + notebook.next_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + manifest.next_page_id = notebook.next_page_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Vorherige Seite: PageUp + Key::Named(NamedKey::PageUp) => { + selection_state.clear(); + notebook.previous_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Erste Seite: Home / Pos1 + Key::Named(NamedKey::Home) => { + selection_state.clear(); + notebook.first_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Letzte Seite: End / Ende + Key::Named(NamedKey::End) => { + selection_state.clear(); + notebook.last_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Zoom zurücksetzen: Strg + 0 + Key::Character(c) if c == "0" && ctrl => { + canvas.reset_view(); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + + // Zoom In: Strg + '+' oder Strg + '=' + Key::Character(c) if (c == "+" || c == "=") && ctrl => { + canvas.zoom_at(canvas.size.width as f32 * 0.5, canvas.size.height as f32 * 0.5, 1.15); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + + // Zoom Out: Strg + '-' oder Strg + '_' + Key::Character(c) if (c == "-" || c == "_") && ctrl => { + canvas.zoom_at(canvas.size.width as f32 * 0.5, canvas.size.height as f32 * 0.5, 1.0 / 1.15); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + + // Formen-Werkzeug umschalten: S (ohne Strg) oder F + Key::Character(c) if (c.eq_ignore_ascii_case("s") || c.eq_ignore_ascii_case("f")) && !ctrl => { + ui_state.active_tool = if ui_state.active_tool == ActiveTool::Shape { + ActiveTool::Pen + } else { + ActiveTool::Shape + }; + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + + // Beenden: Q + Key::Character(c) if c.eq_ignore_ascii_case("q") && !ctrl => { + println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); + autosave.shutdown(); + target.exit(); + } + + _ => {} + } + } + + WindowEvent::MouseInput { state, button, .. } => { + let now = start_time.elapsed().as_secs_f64(); + let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser + || button == MouseButton::Right; + + match state { + ElementState::Pressed => { + // Palm-Rejection: Keine Mausevents verarbeiten, wenn Touch aktiv ist + if active_touch_id.is_some() { + return; + } + + // Event-Isolation: Klicks auf die egui-Toolbar NICHT als Strich zeichnen + if is_pointer_in_ui { + return; + } + + // 1. Pan via mittlere Maustaste oder Leertaste + Linksklick + if button == MouseButton::Middle || (button == MouseButton::Left && is_space_pressed) { + is_panning = true; + pan_last_screen_pos = cursor_screen_pos; + return; + } + + if is_eraser_action { + is_erasing = true; + let (sx, sy) = cursor_screen_pos; + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + let page = notebook.current_page_mut(); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + if !removed.is_empty() { + canvas.sync_page(page); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } else if button == MouseButton::Left { + let (sx, sy) = cursor_screen_pos; + + // 2. Objektauswahl & Transformation im Select-Modus + if ui_state.active_tool == ActiveTool::Select { + let screen_vec = Vec2::new(sx, sy); + let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec); + match hit { + SelectionHandleHit::RotateHandle { center_page } => { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + let start_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x); + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Rotate { + center_page, + start_angle, + initial_strokes, + }; + window.request_redraw(); + return; + } + } + SelectionHandleHit::CornerHandle { + corner_idx: _, + fixed_corner_page, + start_pos_page, + initial_box_size, + } => { + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Scale { + fixed_corner_page, + start_pos_page, + initial_box_size, + initial_strokes, + }; + window.request_redraw(); + return; + } + SelectionHandleHit::Body => { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Move { + start_page: page_pt, + initial_strokes, + }; + window.request_redraw(); + return; + } + } + SelectionHandleHit::None => { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + let shift = modifiers.shift_key(); + if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 25.0) { + let stroke_id = stroke.id; + if shift { + selection_state.toggle_select(stroke_id); + } else if !selection_state.selected_ids.contains(&stroke_id) { + selection_state.select_single(stroke_id); + } + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Move { + start_page: page_pt, + initial_strokes, + }; + window.request_redraw(); + return; + } else { + if !shift { + selection_state.clear(); + } + selection_state.drag_mode = DragMode::BoxSelect { + start_page: page_pt, + current_page: page_pt, + }; + window.request_redraw(); + return; + } + } + } + } + } + + // 3. Tap-to-Convert im Shape-Modus + if ui_state.active_tool == ActiveTool::Shape { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 40.0) { + if let Some(shape) = geometry::detect_shape(&stroke.points) { + let old_id = stroke.id; + let shape_name = shape.name(); + let stroke_color = stroke.color; + let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; + let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width); + notebook.current_page_mut().replace_stroke(old_id, new_stroke); + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)")); + window.request_redraw(); + return; + } + } + } + } + + // 4. Strich zeichnen + if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + is_mouse_drawing = true; + is_drawing = true; + stroke_last_motion_time = Instant::now(); + stroke_hold_anchor_pos = cursor_screen_pos; + stroke_already_snapped = false; + stroke_snapped_shape = None; + canvas.start_stroke(page_pt.x, page_pt.y, current_pressure, now); + window.request_redraw(); + } + } + } + } + ElementState::Released => { + if button == MouseButton::Middle || (button == MouseButton::Left && is_panning) { + is_panning = false; + } + if is_erasing { + is_erasing = false; + } + if ui_state.active_tool == ActiveTool::Select { + let shift = modifiers.shift_key(); + match std::mem::take(&mut selection_state.drag_mode) { + DragMode::BoxSelect { start_page, current_page } => { + let box_min = Vec2::new(start_page.x.min(current_page.x), start_page.y.min(current_page.y)); + let box_max = Vec2::new(start_page.x.max(current_page.x), start_page.y.max(current_page.y)); + if (box_max.x - box_min.x).max(box_max.y - box_min.y) > 3.0 { + let found_ids = notebook.current_page().find_strokes_in_box(box_min, box_max); + if shift { + for id in found_ids { + if !selection_state.selected_ids.contains(&id) { + selection_state.selected_ids.push(id); + } + } + } else { + selection_state.select_multiple(found_ids); + } + } + window.request_redraw(); + } + DragMode::Move { initial_strokes, .. } + | DragMode::Rotate { initial_strokes, .. } + | DragMode::Scale { initial_strokes, .. } => { + let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes { + notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + } + window.request_redraw(); + } + DragMode::None => {} + } + } + if is_drawing && is_mouse_drawing { + is_drawing = false; + is_mouse_drawing = false; + if !stroke_already_snapped { + let (sx, sy) = cursor_screen_pos; + let page_pt = canvas.screen_to_page_clamped(sx, sy); + let now = start_time.elapsed().as_secs_f64(); + canvas.extend_stroke(page_pt.x, page_pt.y, current_pressure, now); + } + let new_id = notebook.next_stroke_id(); + + // Prüfen, ob Form durch Draw-and-Hold gesnappt ist oder im Shape-Modus gezeichnet wurde + let snapped_shape = stroke_snapped_shape.take().or_else(|| { + if ui_state.active_tool == ActiveTool::Shape { + geometry::detect_shape(&canvas.live_samples) + } else { + None + } + }); + + let stroke_opt = if let Some(shape) = snapped_shape { + let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; + let color = canvas.current_ink_color; + canvas.cancel_stroke(); + Some(geometry::shape_to_stroke(shape, new_id, color, stroke_width)) + } else { + canvas.finish_stroke(new_id) + }; + + if let Some(stroke) = stroke_opt { + let page = notebook.current_page_mut(); + page.add_stroke(stroke); + canvas.sync_page(page); + manifest.next_stroke_id = notebook.next_stroke_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } + } + } + + WindowEvent::CursorMoved { position, .. } => { + cursor_screen_pos = (position.x as f32, position.y as f32); + let (sx, sy) = cursor_screen_pos; + + // Canvas-Panning via Mausbewegung + if is_panning { + let dx = sx - pan_last_screen_pos.0; + let dy = sy - pan_last_screen_pos.1; + pan_last_screen_pos = (sx, sy); + canvas.pan(dx, dy); + window.request_redraw(); + return; + } + + // Palm-Rejection: Keine Cursor-Events verarbeiten, wenn Touch aktiv ist + if active_touch_id.is_none() { + if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + match &mut selection_state.drag_mode { + DragMode::BoxSelect { current_page, .. } => { + *current_page = page_pt; + window.request_redraw(); + } + DragMode::Move { start_page, initial_strokes } => { + let delta = page_pt - *start_page; + let mut updated = initial_strokes.clone(); + for s in &mut updated { + s.translate(delta); + } + notebook.current_page_mut().update_strokes_ephemeral(&updated); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + DragMode::Rotate { center_page, start_angle, initial_strokes } => { + let current_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x); + let delta_angle = current_angle - *start_angle; + let mut updated = initial_strokes.clone(); + for s in &mut updated { + s.rotate(*center_page, delta_angle); + } + notebook.current_page_mut().update_strokes_ephemeral(&updated); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + DragMode::Scale { fixed_corner_page, start_pos_page, initial_box_size: _, initial_strokes } => { + let orig_delta = *start_pos_page - *fixed_corner_page; + let new_delta = page_pt - *fixed_corner_page; + let scale_x = if orig_delta.x.abs() > 1.0 { + (new_delta.x / orig_delta.x).clamp(0.05, 20.0) + } else { + 1.0 + }; + let scale_y = if orig_delta.y.abs() > 1.0 { + (new_delta.y / orig_delta.y).clamp(0.05, 20.0) + } else { + 1.0 + }; + let factors = Vec2::new(scale_x, scale_y); + let mut updated = initial_strokes.clone(); + for s in &mut updated { + s.scale(*fixed_corner_page, factors); + } + notebook.current_page_mut().update_strokes_ephemeral(&updated); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + DragMode::None => {} + } + } + } else if is_erasing { + if let Some(page_pt) = canvas.screen_to_page(sx, sy) { + let page = notebook.current_page_mut(); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + if !removed.is_empty() { + canvas.sync_page(page); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } else if is_drawing && is_mouse_drawing { + // Draw-and-Hold Bewegungsmessung: Bei Bewegung > 3 px Timer zurücksetzen + let dx = sx - stroke_hold_anchor_pos.0; + let dy = sy - stroke_hold_anchor_pos.1; + if (dx * dx + dy * dy).sqrt() > 3.0 { + stroke_last_motion_time = Instant::now(); + stroke_hold_anchor_pos = (sx, sy); + } + + if !stroke_already_snapped { + let page_pt = canvas.screen_to_page_clamped(sx, sy); + let now = start_time.elapsed().as_secs_f64(); + canvas.extend_stroke(page_pt.x, page_pt.y, current_pressure, now); + window.request_redraw(); + } + } + } + } + + WindowEvent::MouseWheel { delta, .. } => { + if is_pointer_in_ui { + return; + } + let zoom_factor = match delta { + MouseScrollDelta::LineDelta(_x, y) => { + if y > 0.0 { + 1.12_f32.powf(y) + } else if y < 0.0 { + (1.0 / 1.12_f32).powf(-y) + } else { + 1.0 + } + } + MouseScrollDelta::PixelDelta(pos) => { + let y = pos.y as f32; + if y > 0.0 { + 1.0 + (y * 0.005).min(0.5) + } else if y < 0.0 { + 1.0 / (1.0 + (-y * 0.005).min(0.5)) + } else { + 1.0 + } + } + }; + canvas.zoom_at(cursor_screen_pos.0, cursor_screen_pos.1, zoom_factor); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + + WindowEvent::Touch(touch) => { + let now = start_time.elapsed().as_secs_f64(); + let tx = touch.location.x as f32; + let ty = touch.location.y as f32; + let pressure = match touch.force { + Some(winit::event::Force::Normalized(f)) => f as f32, + Some(winit::event::Force::Calibrated { + force, + max_possible_force, + .. + }) => (force / max_possible_force).clamp(0.0, 1.0) as f32, + None => 0.5, + }; + + let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser; + + match touch.phase { + TouchPhase::Started => { + // Palm-Rejection + if is_mouse_drawing || (active_touch_id.is_some() && active_touch_id != Some(touch.id)) { + return; + } + + // Event-Isolation für Touch-Eingaben + let is_touch_in_ui = egui_ctx.wants_pointer_input() + || ui_state.is_point_in_ui(tx, ty) + || (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area()) + || (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area()); + if is_touch_in_ui { + return; + } + + active_touch_id = Some(touch.id); + current_pressure = pressure; + cursor_screen_pos = (tx, ty); + + if is_eraser_action { + is_erasing = true; + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + let page = notebook.current_page_mut(); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + if !removed.is_empty() { + canvas.sync_page(page); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } else if ui_state.active_tool == ActiveTool::Select { + let screen_vec = Vec2::new(tx, ty); + let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec); + match hit { + SelectionHandleHit::RotateHandle { center_page } => { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + let start_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x); + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Rotate { + center_page, + start_angle, + initial_strokes, + }; + window.request_redraw(); + } + } + SelectionHandleHit::CornerHandle { + corner_idx: _, + fixed_corner_page, + start_pos_page, + initial_box_size, + } => { + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Scale { + fixed_corner_page, + start_pos_page, + initial_box_size, + initial_strokes, + }; + window.request_redraw(); + } + SelectionHandleHit::Body => { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Move { + start_page: page_pt, + initial_strokes, + }; + window.request_redraw(); + } + } + SelectionHandleHit::None => { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 25.0) { + let stroke_id = stroke.id; + if !selection_state.selected_ids.contains(&stroke_id) { + selection_state.select_single(stroke_id); + } + let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + selection_state.drag_mode = DragMode::Move { + start_page: page_pt, + initial_strokes, + }; + window.request_redraw(); + } else { + selection_state.clear(); + selection_state.drag_mode = DragMode::BoxSelect { + start_page: page_pt, + current_page: page_pt, + }; + window.request_redraw(); + } + } + } + } + } else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + is_drawing = true; + stroke_last_motion_time = Instant::now(); + stroke_hold_anchor_pos = (tx, ty); + stroke_already_snapped = false; + stroke_snapped_shape = None; + canvas.start_stroke(page_pt.x, page_pt.y, pressure, now); + window.request_redraw(); + } + } + } + TouchPhase::Moved => { + if is_mouse_drawing || active_touch_id != Some(touch.id) { + return; + } + + current_pressure = pressure; + cursor_screen_pos = (tx, ty); + + if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + match &mut selection_state.drag_mode { + DragMode::BoxSelect { current_page, .. } => { + *current_page = page_pt; + window.request_redraw(); + } + DragMode::Move { start_page, initial_strokes } => { + let delta = page_pt - *start_page; + let mut updated = initial_strokes.clone(); + for s in &mut updated { + s.translate(delta); + } + notebook.current_page_mut().update_strokes_ephemeral(&updated); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + DragMode::Rotate { center_page, start_angle, initial_strokes } => { + let current_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x); + let delta_angle = current_angle - *start_angle; + let mut updated = initial_strokes.clone(); + for s in &mut updated { + s.rotate(*center_page, delta_angle); + } + notebook.current_page_mut().update_strokes_ephemeral(&updated); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + DragMode::Scale { fixed_corner_page, start_pos_page, initial_box_size: _, initial_strokes } => { + let orig_delta = *start_pos_page - *fixed_corner_page; + let new_delta = page_pt - *fixed_corner_page; + let scale_x = if orig_delta.x.abs() > 1.0 { + (new_delta.x / orig_delta.x).clamp(0.05, 20.0) + } else { + 1.0 + }; + let scale_y = if orig_delta.y.abs() > 1.0 { + (new_delta.y / orig_delta.y).clamp(0.05, 20.0) + } else { + 1.0 + }; + let factors = Vec2::new(scale_x, scale_y); + let mut updated = initial_strokes.clone(); + for s in &mut updated { + s.scale(*fixed_corner_page, factors); + } + notebook.current_page_mut().update_strokes_ephemeral(&updated); + canvas.sync_page(notebook.current_page_mut()); + window.request_redraw(); + } + DragMode::None => {} + } + } + } else if is_erasing { + if let Some(page_pt) = canvas.screen_to_page(tx, ty) { + let page = notebook.current_page_mut(); + let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); + if !removed.is_empty() { + canvas.sync_page(page); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } else if is_drawing { + let dx = tx - stroke_hold_anchor_pos.0; + let dy = ty - stroke_hold_anchor_pos.1; + if (dx * dx + dy * dy).sqrt() > 3.0 { + stroke_last_motion_time = Instant::now(); + stroke_hold_anchor_pos = (tx, ty); + } + + if !stroke_already_snapped { + let page_pt = canvas.screen_to_page_clamped(tx, ty); + canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); + window.request_redraw(); + } + } + } + TouchPhase::Ended => { + if active_touch_id == Some(touch.id) { + active_touch_id = None; + if is_erasing { + is_erasing = false; + } + if ui_state.active_tool == ActiveTool::Select { + match std::mem::take(&mut selection_state.drag_mode) { + DragMode::BoxSelect { start_page, current_page } => { + let box_min = Vec2::new(start_page.x.min(current_page.x), start_page.y.min(current_page.y)); + let box_max = Vec2::new(start_page.x.max(current_page.x), start_page.y.max(current_page.y)); + if (box_max.x - box_min.x).max(box_max.y - box_min.y) > 3.0 { + let found_ids = notebook.current_page().find_strokes_in_box(box_min, box_max); + selection_state.select_multiple(found_ids); + } + window.request_redraw(); + } + DragMode::Move { initial_strokes, .. } + | DragMode::Rotate { initial_strokes, .. } + | DragMode::Scale { initial_strokes, .. } => { + let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); + if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes { + notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + } + window.request_redraw(); + } + DragMode::None => {} + } + } + if is_drawing { + is_drawing = false; + if !stroke_already_snapped { + let page_pt = canvas.screen_to_page_clamped(tx, ty); + let now = start_time.elapsed().as_secs_f64(); + canvas.extend_stroke(page_pt.x, page_pt.y, pressure, now); + } + let new_id = notebook.next_stroke_id(); + + let snapped_shape = stroke_snapped_shape.take().or_else(|| { + if ui_state.active_tool == ActiveTool::Shape { + geometry::detect_shape(&canvas.live_samples) + } else { + None + } + }); + + let stroke_opt = if let Some(shape) = snapped_shape { + let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; + let color = canvas.current_ink_color; + canvas.cancel_stroke(); + Some(geometry::shape_to_stroke(shape, new_id, color, stroke_width)) + } else { + canvas.finish_stroke(new_id) + }; + + if let Some(stroke) = stroke_opt { + let page = notebook.current_page_mut(); + page.add_stroke(stroke); + canvas.sync_page(page); + manifest.next_stroke_id = notebook.next_stroke_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } + } + TouchPhase::Cancelled => { + if active_touch_id == Some(touch.id) { + active_touch_id = None; + selection_state.drag_mode = DragMode::None; + if is_drawing { + is_drawing = false; + canvas.cancel_stroke(); + window.request_redraw(); + } + is_erasing = false; + } + } + } + } + + WindowEvent::RedrawRequested => { + // 1. egui Eingabe erfassen und Frame starten + let raw_input = egui_winit_state.take_egui_input(&window); + egui_ctx.begin_frame(raw_input); + + // 2. Auswahl-Overlay rendern (Bounding Box, Griffe, Lasso) + render_selection_overlay(&egui_ctx, &selection_state, notebook.current_page(), &canvas); + + // 3. Toolbar & Widgets rendern + let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status()); + + // 4. UI-Aktionen verarbeiten + for action in actions { + match action { + UiAction::SetTool(tool) => { + ui_state.active_tool = tool; + match tool { + ActiveTool::Pen => { + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + } + ActiveTool::Highlighter => { + canvas.set_stroke_color(ui_state.highlighter_color); + canvas.set_stroke_width_mm(ui_state.highlighter_stroke_width_mm); + } + ActiveTool::Shape => { + canvas.set_stroke_color(ui_state.pen_color); + canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); + } + ActiveTool::Eraser | ActiveTool::Select => {} + } + update_window_title(&window, ¬ebook, &manifest, tool, autosave.status()); + window.request_redraw(); + } + UiAction::SetColor(color) => { + if !selection_state.is_empty() + && selection_state.recolor_selected(notebook.current_page_mut(), color) { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message("Auswahl umgefärbt".to_string()); + } + match ui_state.active_tool { + ActiveTool::Pen | ActiveTool::Shape => { + ui_state.pen_color = color; + canvas.set_stroke_color(color); + } + ActiveTool::Highlighter => { + ui_state.highlighter_color = color; + canvas.set_stroke_color(color); + } + ActiveTool::Eraser | ActiveTool::Select => {} + } + window.request_redraw(); + } + UiAction::SetStrokeWidthMm(width) => { + match ui_state.active_tool { + ActiveTool::Pen | ActiveTool::Shape => { + ui_state.pen_stroke_width_mm = width; + canvas.set_stroke_width_mm(width); + } + ActiveTool::Highlighter => { + ui_state.highlighter_stroke_width_mm = width; + canvas.set_stroke_width_mm(width); + } + ActiveTool::Eraser | ActiveTool::Select => {} + } + window.request_redraw(); + } + UiAction::DeleteSelected => { + if !selection_state.is_empty() { + let removed = selection_state.delete_selected(notebook.current_page_mut()); + if !removed.is_empty() { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); + window.request_redraw(); + } + } + } + UiAction::CopySelected => { + let count = selection_state.copy_to_clipboard(notebook.current_page()); + if count > 0 { + ui_state.set_export_message(format!("{count} Objekt(e) kopiert")); + window.request_redraw(); + } + } + UiAction::PasteSelected => { + let mut next_id = notebook.next_stroke_id; + let new_ids = selection_state.paste_from_clipboard( + notebook.current_page_mut(), + &mut || { + let id = next_id; + next_id += 1; + id + }, + ); + notebook.next_stroke_id = next_id; + if !new_ids.is_empty() { + canvas.sync_page(notebook.current_page_mut()); + manifest.next_stroke_id = notebook.next_stroke_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); + window.request_redraw(); + } + } + UiAction::RotateSelected => { + if !selection_state.is_empty() + && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); + window.request_redraw(); + } + } + UiAction::ZoomIn => { + let center_x = canvas.size.width as f32 * 0.5; + let center_y = canvas.size.height as f32 * 0.5; + canvas.zoom_at(center_x, center_y, 1.25); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + UiAction::ZoomOut => { + let center_x = canvas.size.width as f32 * 0.5; + let center_y = canvas.size.height as f32 * 0.5; + canvas.zoom_at(center_x, center_y, 1.0 / 1.25); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + UiAction::ResetZoom => { + canvas.reset_view(); + ui_state.current_zoom = canvas.camera.zoom; + window.request_redraw(); + } + UiAction::Undo => { + if notebook.current_page_mut().undo() { + selection_state.clear(); + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + UiAction::Redo => { + if notebook.current_page_mut().redo() { + selection_state.clear(); + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + UiAction::PreviousPage => { + selection_state.clear(); + notebook.previous_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + UiAction::NextPage => { + selection_state.clear(); + notebook.next_page(); + let curr = notebook.current_page_index; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = notebook.current_page_index; + manifest.next_page_id = notebook.next_page_id; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + UiAction::AddPage => { + selection_state.clear(); + let bg = notebook.current_page().background; + notebook.add_page(bg); + manifest.current_page_index = notebook.current_page_index; + manifest.next_page_id = notebook.next_page_id; + canvas.sync_page(notebook.current_page_mut()); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + UiAction::GoToPage(idx) => { + if idx < notebook.pages.len() { + selection_state.clear(); + notebook.current_page_index = idx; + let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); + canvas.sync_page(notebook.current_page_mut()); + manifest.current_page_index = idx; + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + UiAction::SetBackground(bg) => { + let page = notebook.current_page_mut(); + page.background = bg; + canvas.sync_page(page); + autosave.mark_page_dirty(notebook.current_page(), &manifest); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + UiAction::ExportPdf => { + let notebook_clone = notebook.clone(); + let export_path = "export.pdf".to_string(); + let window_ref = window.clone(); + println!( + "[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...", + export_path + ); + std::thread::spawn(move || { + let start = std::time::Instant::now(); + match export_notebook_to_pdf( + ¬ebook_clone, + &export_path, + ExportStrategy::FilledPolygon, + ) { + Ok(()) => { + let elapsed = start.elapsed(); + println!( + "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", + export_path, + elapsed, + notebook_clone.pages.len() + ); + window_ref.request_redraw(); + } + Err(err) => { + eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"); + } + } + }); + ui_state.set_export_message("PDF erfolgreich als export.pdf exportiert!"); + window.request_redraw(); + } + UiAction::RefreshNotebooks => { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + window.request_redraw(); + } + UiAction::OpenNotebook(id) => { + if id == manifest.id { + ui_state.is_document_menu_open = false; + window.request_redraw(); + } else { + let new_notebook_dir = storage_base_dir.join(&id); + match storage::load_notebook(&new_notebook_dir) { + Ok((loaded_nb, loaded_manifest)) => { + autosave.switch_notebook_dir(new_notebook_dir.clone()); + notebook = loaded_nb; + manifest = loaded_manifest; + storage_dir = new_notebook_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.is_document_menu_open = false; + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title)); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht laden: {err}"); + ui_state.set_export_message(format!("Fehler beim Öffnen: {err}")); + } + } + } + } + UiAction::CreateNotebook(title) => { + autosave.flush(); + match storage::create_new_notebook(&storage_base_dir, &title) { + Ok((new_nb, new_manifest, new_dir)) => { + autosave.switch_notebook_dir(new_dir.clone()); + notebook = new_nb; + manifest = new_manifest; + storage_dir = new_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.is_document_menu_open = false; + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(format!("Dokument '{}' erstellt", manifest.title)); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}"); + ui_state.set_export_message(format!("Fehler beim Erstellen: {err}")); + } + } + } + UiAction::RenameNotebook { id, new_title } => { + let target_dir = storage_base_dir.join(&id); + if id == manifest.id { + match storage::rename_notebook(&target_dir, &mut manifest, &new_title) { + Ok(()) => { + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht umbenennen: {err}"); + ui_state.set_export_message(format!("Fehler beim Umbenennen: {err}")); + } + } + } else { + let manifest_path = target_dir.join("manifest.json"); + if let Ok(content) = std::fs::read_to_string(&manifest_path) { + if let Ok(mut other_manifest) = serde_json::from_str::(&content) { + let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); + window.request_redraw(); + } + } + } + } + UiAction::DeleteNotebook(id) => { + let target_dir = storage_base_dir.join(&id); + let is_current = id == manifest.id; + match storage::delete_notebook(&target_dir) { + Ok(()) => { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + if is_current { + if let Some(first) = ui_state.available_notebooks.first().cloned() { + let new_notebook_dir = storage_base_dir.join(&first.id); + match storage::load_notebook(&new_notebook_dir) { + Ok((loaded_nb, loaded_manifest)) => { + autosave.switch_notebook_dir(new_notebook_dir.clone()); + notebook = loaded_nb; + manifest = loaded_manifest; + storage_dir = new_notebook_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title)); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id); + } + } + } else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") { + autosave.switch_notebook_dir(new_dir.clone()); + notebook = new_nb; + manifest = new_manifest; + storage_dir = new_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message("Standard-Notizbuch erstellt".to_string()); + } + } else { + ui_state.set_export_message("Dokument gelöscht".to_string()); + } + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Dokument '{id}' nicht löschen: {err}"); + ui_state.set_export_message(format!("Fehler beim Löschen: {err}")); + } + } + } + } + } + + // 4. egui Frame beenden & Primitives tessellieren + let full_output = egui_ctx.end_frame(); + egui_winit_state.handle_platform_output(&window, full_output.platform_output); + + let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point()); + let screen_descriptor = egui_wgpu::ScreenDescriptor { + size_in_pixels: [canvas.size.width, canvas.size.height], + pixels_per_point: egui_ctx.pixels_per_point(), + }; + + if ui_state.export_message.is_some() { + window.request_redraw(); + } + + // 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern + match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) { + Ok(_) => {} + Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + canvas.resize(canvas.size); + canvas.sync_page(notebook.current_page_mut()); + } + Err(wgpu::SurfaceError::OutOfMemory) => { + eprintln!("GPU Out of Memory!"); + target.exit(); + } + Err(wgpu::SurfaceError::Timeout) => { + eprintln!("Surface present timeout"); + } + } + } + + _ => {} + } + } + _ => {} + } + })?; + + Ok(()) +} diff --git a/src/selection.rs b/src/selection.rs new file mode 100644 index 0000000..8583ddd --- /dev/null +++ b/src/selection.rs @@ -0,0 +1,469 @@ +//! Objekt-Auswahl und Manipulation (Selektion, Verschieben, Drehen, Skalieren, Kopieren, Einfügen, Umfärben). +//! +//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit. + +use std::f32::consts::FRAC_PI_2; +use glam::Vec2; + +use crate::canvas::CanvasState; +use crate::document::{Aabb, Page, Stroke}; + +/// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum DragMode { + #[default] + None, + /// Aufziehen eines Auswahlrahmens (Marquee Box) + BoxSelect { + start_page: Vec2, + current_page: Vec2, + }, + /// Verschieben ausgewählter Objekte + Move { + start_page: Vec2, + initial_strokes: Vec, + }, + /// Drehen ausgewählter Objekte um das Zentrum der Auswahl + Rotate { + center_page: Vec2, + start_angle: f32, + initial_strokes: Vec, + }, + /// Skalieren ausgewählter Objekte relativ zum gegenüberliegenden Eckpunkt + Scale { + fixed_corner_page: Vec2, + start_pos_page: Vec2, + initial_box_size: Vec2, + initial_strokes: Vec, + }, +} + +/// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SelectionHandleHit { + None, + /// Dreh-Griff (oberhalb der Auswahlbox) + RotateHandle { + center_page: Vec2, + }, + /// Einer der 4 Eck-Skalierungsgriffe (0: oben-links, 1: oben-rechts, 2: unten-rechts, 3: unten-links) + CornerHandle { + corner_idx: usize, + fixed_corner_page: Vec2, + start_pos_page: Vec2, + initial_box_size: Vec2, + }, + /// Klick in den Körper / Innenbereich der Auswahlbox + Body, +} + +/// Verwaltet den Zustand der Objektauswahl, des Clipboards und der Manipulationen. +#[derive(Debug, Clone, Default)] +pub struct SelectionState { + pub selected_ids: Vec, + pub drag_mode: DragMode, + pub clipboard: Vec, +} + +impl SelectionState { + pub fn new() -> Self { + Self { + selected_ids: Vec::new(), + drag_mode: DragMode::None, + clipboard: Vec::new(), + } + } + + /// Prüft, ob aktuell Objekte ausgewählt sind. + pub fn is_empty(&self) -> bool { + self.selected_ids.is_empty() + } + + /// Anzahl der aktuell ausgewählten Objekte. + pub fn count(&self) -> usize { + self.selected_ids.len() + } + + /// Leert die aktuelle Auswahl. + pub fn clear(&mut self) { + self.selected_ids.clear(); + self.drag_mode = DragMode::None; + } + + /// Wählt ein einzelnes Objekt aus. + pub fn select_single(&mut self, id: u64) { + self.selected_ids = vec![id]; + } + + /// Schaltet die Auswahl eines Objekts um (z. B. bei Shift+Klick). + pub fn toggle_select(&mut self, id: u64) { + if let Some(pos) = self.selected_ids.iter().position(|&x| x == id) { + self.selected_ids.remove(pos); + } else { + self.selected_ids.push(id); + } + } + + /// Setzt die Auswahl auf eine Liste von Objekt-IDs. + pub fn select_multiple(&mut self, ids: Vec) { + self.selected_ids = ids; + } + + /// Berechnet die gemeinsame AABB aller ausgewählten Objekte. + pub fn get_selection_aabb(&self, page: &Page) -> Option { + let strokes = page.get_strokes(&self.selected_ids); + Page::selection_aabb(&strokes) + } + + /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch. + pub fn hit_test_handles( + &self, + page: &Page, + canvas: &CanvasState, + screen_pos: Vec2, + ) -> SelectionHandleHit { + let Some(aabb) = self.get_selection_aabb(page) else { + return SelectionHandleHit::None; + }; + + let p_min = canvas.page_to_screen(aabb.min); + let p_max = canvas.page_to_screen(aabb.max); + + let screen_min = Vec2::new(p_min[0].min(p_max[0]), p_min[1].min(p_max[1])); + let screen_max = Vec2::new(p_min[0].max(p_max[0]), p_min[1].max(p_max[1])); + let screen_center = (screen_min + screen_max) * 0.5; + let page_center = (aabb.min + aabb.max) * 0.5; + + // 1. Drehgriff testen (26 px oberhalb des oberen Randes) + let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0); + if (screen_pos - rot_screen).length() <= 14.0 { + return SelectionHandleHit::RotateHandle { + center_page: page_center, + }; + } + + // 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 + ]; + 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 + ]; + + let corners_screen = [ + screen_min, + Vec2::new(screen_max.x, screen_min.y), + screen_max, + Vec2::new(screen_min.x, screen_max.y), + ]; + + for i in 0..4 { + if (screen_pos - corners_screen[i]).length() <= 12.0 { + return SelectionHandleHit::CornerHandle { + corner_idx: i, + fixed_corner_page: fixed_corners_page[i], + start_pos_page: corners_page[i], + initial_box_size: aabb.max - aabb.min, + }; + } + } + + // 3. Körper-Test + let margin = 6.0; + if screen_pos.x >= screen_min.x - margin + && screen_pos.x <= screen_max.x + margin + && screen_pos.y >= screen_min.y - margin + && screen_pos.y <= screen_max.y + margin + { + return SelectionHandleHit::Body; + } + + SelectionHandleHit::None + } + + /// Kopiert die aktuell ausgewählten Objekte in das Clipboard. + pub fn copy_to_clipboard(&mut self, page: &Page) -> usize { + let strokes = page.get_strokes(&self.selected_ids); + let count = strokes.len(); + self.clipboard = strokes; + count + } + + /// Fügt Objekte aus dem Clipboard mit Versatz (+30 Einheiten) ein, vergibt neue IDs und wählt sie aus. + pub fn paste_from_clipboard( + &mut self, + page: &mut Page, + next_id_gen: &mut impl FnMut() -> u64, + ) -> Vec { + if self.clipboard.is_empty() { + return Vec::new(); + } + + let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten + let mut new_strokes = Vec::with_capacity(self.clipboard.len()); + let mut new_ids = Vec::with_capacity(self.clipboard.len()); + + for stroke in &self.clipboard { + let new_id = next_id_gen(); + let mut cloned = stroke.clone(); + cloned.id = new_id; + cloned.translate(offset); + new_ids.push(new_id); + new_strokes.push(cloned); + } + + page.add_strokes(new_strokes); + self.selected_ids = new_ids.clone(); + new_ids + } + + /// Löscht die ausgewählten Objekte transaktional aus der Seite. + pub fn delete_selected(&mut self, page: &mut Page) -> Vec { + if self.selected_ids.is_empty() { + return Vec::new(); + } + let removed = page.remove_strokes_by_ids(&self.selected_ids); + self.selected_ids.clear(); + self.drag_mode = DragMode::None; + removed + } + + /// Färbt alle ausgewählten Objekte auf `new_color` um (inkl. Vertex-Buffer). + pub fn recolor_selected(&mut self, page: &mut Page, new_color: [f32; 4]) -> bool { + if self.selected_ids.is_empty() { + return false; + } + let mut strokes = page.get_strokes(&self.selected_ids); + if strokes.is_empty() { + return false; + } + + for s in &mut strokes { + s.set_color(new_color); + } + + page.modify_strokes(strokes); + true + } + + /// Dreht alle ausgewählten Objekte um 90° im Uhrzeigersinn um das Zentrum ihrer AABB. + pub fn rotate_selected_90_deg(&mut self, page: &mut Page) -> bool { + let Some(aabb) = self.get_selection_aabb(page) else { + return false; + }; + let center = (aabb.min + aabb.max) * 0.5; + let mut strokes = page.get_strokes(&self.selected_ids); + if strokes.is_empty() { + return false; + } + + for s in &mut strokes { + s.rotate(center, FRAC_PI_2); + } + + page.modify_strokes(strokes); + true + } +} + +/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui. +pub fn render_selection_overlay( + ctx: &egui::Context, + selection: &SelectionState, + page: &Page, + canvas: &CanvasState, +) { + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("selection_overlay_layer"), + )); + + // 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box) + 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]); + let min_y = p1[1].min(p2[1]); + let max_x = p1[0].max(p2[0]); + let max_y = p1[1].max(p2[1]); + + let rect = egui::Rect::from_min_max(egui::pos2(min_x, min_y), egui::pos2(max_x, max_y)); + + // Halbtransparente Füllung & blauer Auswahlrand + painter.rect_filled( + rect, + egui::Rounding::same(4.0_f32), + egui::Color32::from_rgba_premultiplied(40, 120, 240, 35), + ); + painter.rect_stroke( + rect, + egui::Rounding::same(4.0_f32), + egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)), + ); + return; + } + + // 2. Wenn Objekte ausgewählt sind, Bounding Box und Griffe zeichnen + let Some(aabb) = selection.get_selection_aabb(page) else { + return; + }; + + let p_min = canvas.page_to_screen(aabb.min); + let p_max = canvas.page_to_screen(aabb.max); + + 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 rect = egui::Rect::from_min_max(screen_min, screen_max); + + // Haupt-Auswahlrahmen (leuchtend blau) + let border_color = egui::Color32::from_rgb(70, 150, 255); + painter.rect_stroke( + rect, + egui::Rounding::same(3.0_f32), + egui::Stroke::new(1.5_f32, border_color), + ); + + // Dreh-Stängel (Stalk) und Drehgriff + let top_center = egui::pos2(rect.center().x, rect.min.y); + let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32); + + painter.line_segment( + [top_center, rot_handle], + egui::Stroke::new(1.2_f32, border_color), + ); + + // Drehgriff-Kreis + painter.circle_filled(rot_handle, 6.0_f32, egui::Color32::WHITE); + painter.circle_stroke( + rot_handle, + 6.0_f32, + egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(40, 90, 180)), + ); + + // 4 Eck-Skalierungsgriffe (kleine weiße Quadrate mit blauem Rand) + let handle_size = 7.0_f32; + let corners = [ + rect.min, + egui::pos2(rect.max.x, rect.min.y), + rect.max, + egui::pos2(rect.min.x, rect.max.y), + ]; + + 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_stroke( + handle_rect, + egui::Rounding::same(1.5_f32), + egui::Stroke::new(1.2_f32, egui::Color32::from_rgb(30, 80, 170)), + ); + } + + // Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen") + let badge_text = format!( + "{} {} | [R] Drehen [Entf] Löschen", + selection.count(), + if selection.count() == 1 { "Objekt" } else { "Objekte" } + ); + let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0); + painter.text( + badge_pos, + egui::Align2::CENTER_CENTER, + badge_text, + egui::FontId::proportional(11.0), + egui::Color32::from_gray(220), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::InkVertex; + use crate::document::{PageBackground, StylusSample}; + + fn make_test_stroke(id: u64, x: f32, y: f32, color: [f32; 4]) -> Stroke { + let s1 = StylusSample { + position: Vec2::new(x, y), + pressure: 0.5, + timestamp: 0.0, + }; + let s2 = StylusSample { + position: Vec2::new(x + 50.0, y + 50.0), + pressure: 0.5, + timestamp: 0.01, + }; + let v1 = InkVertex::new(x, y, color); + let v2 = InkVertex::new(x + 50.0, y + 50.0, color); + Stroke::new(id, vec![s1, s2], vec![v1, v2], color) + } + + #[test] + fn test_selection_copy_paste_delete() { + let mut page = Page::new(1, PageBackground::Blank); + let s1 = make_test_stroke(1, 100.0, 100.0, [0.1, 0.1, 0.1, 1.0]); + let s2 = make_test_stroke(2, 300.0, 300.0, [0.8, 0.2, 0.2, 1.0]); + page.add_stroke(s1); + page.add_stroke(s2); + + let mut sel = SelectionState::new(); + sel.select_single(1); + assert_eq!(sel.count(), 1); + + // Copy + let copied = sel.copy_to_clipboard(&page); + assert_eq!(copied, 1); + + // Paste + let mut next_id = 100; + let new_ids = sel.paste_from_clipboard(&mut page, &mut || { + next_id += 1; + next_id + }); + assert_eq!(new_ids.len(), 1); + assert_eq!(page.strokes.len(), 3); + assert_eq!(sel.selected_ids, new_ids); + + // Delete + let deleted = sel.delete_selected(&mut page); + assert_eq!(deleted.len(), 1); + assert_eq!(page.strokes.len(), 2); + assert!(sel.is_empty()); + } + + #[test] + fn test_selection_recolor_and_rotate() { + let mut page = Page::new(1, PageBackground::Blank); + let s1 = make_test_stroke(1, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]); + page.add_stroke(s1); + + let mut sel = SelectionState::new(); + sel.select_single(1); + + // Recolor + let new_color = [0.0, 1.0, 0.0, 1.0]; + assert!(sel.recolor_selected(&mut page, new_color)); + assert_eq!(page.strokes[0].color, new_color); + assert_eq!(page.strokes[0].vertices[0].color, new_color); + + // Undo recolor + assert!(page.undo()); + assert_eq!(page.strokes[0].color, [0.1, 0.1, 0.1, 1.0]); + + // Rotate 90 deg + 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); + + // Undo rotate + assert!(page.undo()); + let restored_pos = page.strokes[0].points[0].position; + assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); + } +} diff --git a/src/shader.wgsl b/src/shader.wgsl new file mode 100644 index 0000000..c1ddd8f --- /dev/null +++ b/src/shader.wgsl @@ -0,0 +1,34 @@ +// WGSL Shader für 2D-Vektor-Meshes der Inking-App +// Unterstützt echte Multi-Color-Vertizes und DIN-A4-Seitenkoordinatentransformation + +struct VertexInput { + @location(0) position: vec2, + @location(1) color: vec4, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +}; + +struct CameraUniforms { + transform_scale: vec2, + transform_offset: vec2, +}; + +@group(0) @binding(0) +var camera: CameraUniforms; + +@vertex +fn vs_main(in: VertexInput) -> VertexOutput { + var out: VertexOutput; + let ndc = in.position * camera.transform_scale + camera.transform_offset; + out.clip_position = vec4(ndc, 0.0, 1.0); + out.color = in.color; + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + return in.color; +} diff --git a/src/spline.rs b/src/spline.rs new file mode 100644 index 0000000..74516f8 --- /dev/null +++ b/src/spline.rs @@ -0,0 +1,450 @@ +//! Zentripetale Catmull-Rom-Spline-Interpolation (alpha = 0.5) über De-Boor / Barry-Goldman. +//! +//! Berechnet für jeden Schritt entlang der Kurve: +//! - Position C(t) +//! - Tangente T(t) (analytische Ableitung) +//! - Normale N(t) (senkrecht auf Tangente) +//! - Druckabhängigen Radius r(t) +//! - Linke und rechte Hüllpunkte +//! +//! Generiert daraus Triangle-Strips für das GPU-Rendering mit `wgpu`. + +/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern). +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Vertex { + pub position: [f32; 2], + pub pressure: f32, + pub side: f32, // -1.0 für linken Hüllpunkt, +1.0 für rechten Hüllpunkt +} + +impl Vertex { + pub fn new(x: f32, y: f32, pressure: f32, side: f32) -> Self { + Self { + position: [x, y], + pressure, + side, + } + } + + pub fn from_vec2(pos: glam::Vec2, pressure: f32, side: f32) -> Self { + Self { + position: [pos.x, pos.y], + pressure, + side, + } + } + + pub fn pos(&self) -> glam::Vec2 { + glam::Vec2::new(self.position[0], self.position[1]) + } +} + +/// Kontrollpunkt für die Spline-Interpolation mit Position und Druck p ∈ [0.0, 1.0]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ControlPoint { + pub position: glam::Vec2, + pub pressure: f32, +} + +impl ControlPoint { + pub fn new(x: f32, y: f32, pressure: f32) -> Self { + Self { + position: glam::Vec2::new(x, y), + pressure: pressure.clamp(0.0, 1.0), + } + } + + pub fn from_vec2(position: glam::Vec2, pressure: f32) -> Self { + Self { + position, + pressure: pressure.clamp(0.0, 1.0), + } + } +} + +/// Ein berechneter Abtastschritt entlang des Splines mit Tangente, Normale und Hüllpunkten. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SplineSample { + pub position: glam::Vec2, + pub tangent: glam::Vec2, + pub normal: glam::Vec2, + pub pressure: f32, + pub radius: f32, + pub left: glam::Vec2, + pub right: glam::Vec2, +} + +/// Konfiguration für die Spline-Interpolation und das Meshing. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SplineConfig { + /// Minimaler Pinselradius bei Druck p = 0.0 + pub min_radius: f32, + /// Maximaler Pinselradius bei Druck p = 1.0 + pub max_radius: f32, + /// Anzahl an Diskretisierungsschritten pro Spline-Segment + pub samples_per_segment: usize, +} + +impl Default for SplineConfig { + fn default() -> Self { + Self { + min_radius: 1.5, + max_radius: 4.5, + samples_per_segment: 16, + } + } +} + +/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r). +pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 { + let p = pressure.clamp(0.0, 1.0); + min_radius + p * (max_radius - min_radius) +} + +/// Zentripetale Catmull-Rom-Interpolation (alpha = 0.5) über De-Boor / Barry-Goldman. +/// +/// Berechnet Position, Tangente, Normale, Radius und linke/rechte Hüllpunkte +/// für den Parameter `u` ∈ [0.0, 1.0] im Segment zwischen `p1` und `p2`. +pub fn evaluate_catmull_rom_segment( + p0: ControlPoint, + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + u: f32, + config: &SplineConfig, +) -> SplineSample { + let u = u.clamp(0.0, 1.0); + + // Knotensequenz mit alpha = 0.5 (zentripetal: sqrt(Distanz)) + let d01 = (p1.position - p0.position).length(); + let d12 = (p2.position - p1.position).length(); + let d23 = (p3.position - p2.position).length(); + + let dt01 = if d01 > 1e-6 { d01.sqrt() } else { 1e-4 }; + let dt12 = if d12 > 1e-6 { d12.sqrt() } else { 1e-4 }; + let dt23 = if d23 > 1e-6 { d23.sqrt() } else { 1e-4 }; + + let t0 = 0.0_f32; + let t1 = t0 + dt01; + let t2 = t1 + dt12; + let t3 = t2 + dt23; + + let t = t1 + u * (t2 - t1); + + // --- De-Boor / Barry-Goldman Pyramide --- + + // Ebene 1: + let a1 = ((t1 - t) * p0.position + (t - t0) * p1.position) / (t1 - t0); + let a2 = ((t2 - t) * p1.position + (t - t1) * p2.position) / (t2 - t1); + let a3 = ((t3 - t) * p2.position + (t - t2) * p3.position) / (t3 - t2); + + let pa1 = ((t1 - t) * p0.pressure + (t - t0) * p1.pressure) / (t1 - t0); + let pa2 = ((t2 - t) * p1.pressure + (t - t1) * p2.pressure) / (t2 - t1); + let pa3 = ((t3 - t) * p2.pressure + (t - t2) * p3.pressure) / (t3 - t2); + + // Ebene 1 Ableitungen: + let da1 = (p1.position - p0.position) / (t1 - t0); + let da2 = (p2.position - p1.position) / (t2 - t1); + let da3 = (p3.position - p2.position) / (t3 - t2); + + // Ebene 2: + let b1 = ((t2 - t) * a1 + (t - t0) * a2) / (t2 - t0); + let b2 = ((t3 - t) * a2 + (t - t1) * a3) / (t3 - t1); + + let pb1 = ((t2 - t) * pa1 + (t - t0) * pa2) / (t2 - t0); + let pb2 = ((t3 - t) * pa2 + (t - t1) * pa3) / (t3 - t1); + + // Ebene 2 Ableitungen: + let db1 = (a2 - a1) / (t2 - t0) + ((t2 - t) * da1 + (t - t0) * da2) / (t2 - t0); + let db2 = (a3 - a2) / (t3 - t1) + ((t3 - t) * da2 + (t - t1) * da3) / (t3 - t1); + + // Ebene 3 (Ergebnis): + let pos = ((t2 - t) * b1 + (t - t1) * b2) / (t2 - t1); + let pressure = (((t2 - t) * pb1 + (t - t1) * pb2) / (t2 - t1)).clamp(0.0, 1.0); + + // Ebene 3 Ableitung (analytische Tangente C'(t)): + let dc = (b2 - b1) / (t2 - t1) + ((t2 - t) * db1 + (t - t1) * db2) / (t2 - t1); + + // Tangente normieren (oder Fallback bei Stillstand) + let tangent = if dc.length_squared() > 1e-10 { + dc.normalize() + } else { + let fallback = p2.position - p1.position; + if fallback.length_squared() > 1e-10 { + fallback.normalize() + } else { + glam::Vec2::new(1.0, 0.0) + } + }; + + // Normale senkrecht zur Tangente: (-Ty, Tx) + let normal = glam::Vec2::new(-tangent.y, tangent.x); + + // Druckabhängiger Radius r(t) + let radius = calculate_radius(pressure, config.min_radius, config.max_radius); + + // Linke und rechte Hüllpunkte + let left = pos + normal * radius; + let right = pos - normal * radius; + + SplineSample { + position: pos, + tangent, + normal, + pressure, + radius, + left, + right, + } +} + +/// Tastet ein einzelnes Segment zwischen `p1` und `p2` ab. +pub fn sample_segment( + p0: ControlPoint, + p1: ControlPoint, + p2: ControlPoint, + p3: ControlPoint, + config: &SplineConfig, +) -> Vec { + let samples = config.samples_per_segment.max(1); + let mut result = Vec::with_capacity(samples + 1); + + for i in 0..=samples { + let u = i as f32 / samples as f32; + result.push(evaluate_catmull_rom_segment(p0, p1, p2, p3, u, config)); + } + + result +} + +/// Wandelt eine Folge von `SplineSample`s in ein abwechselndes Triangle-Strip-Vertex-Array um: +/// [left_0, right_0, left_1, right_1, left_2, right_2, ...] +pub fn samples_to_triangle_strip(samples: &[SplineSample]) -> Vec { + if samples.is_empty() { + return Vec::new(); + } + + let mut vertices = Vec::with_capacity(samples.len() * 2); + + for s in samples { + vertices.push(Vertex::new(s.left.x, s.left.y, s.pressure, -1.0)); + vertices.push(Vertex::new(s.right.x, s.right.y, s.pressure, 1.0)); + } + + vertices +} + +/// Generiert einen zusammenhängenden Triangle-Strip aus einer Liste von Eingabe-Kontrollpunkten. +/// +/// Handhabt automatisch Randpunkte (Geisterpunkte / Spiegelung an den Enden), +/// interpoliert mit zentripetalen Catmull-Rom-Splines und erzeugt ein flüssiges Ribbon-Mesh. +pub fn generate_triangle_strip( + control_points: &[ControlPoint], + config: &SplineConfig, +) -> Vec { + match control_points.len() { + 0 => Vec::new(), + 1 => { + // Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad + let pt = control_points[0]; + let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius); + let segments = 8; + let mut vertices = Vec::with_capacity((segments + 1) * 2); + + for i in 0..=segments { + 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(Vertex::from_vec2(pt.position, pt.pressure, 0.0)); + vertices.push(Vertex::from_vec2(pt.position + offset, pt.pressure, 1.0)); + } + + vertices + } + 2 => { + let p0 = control_points[0]; + let p1 = control_points[1]; + + // Geisterpunkte durch lineare Extrapolation + let ghost_before = ControlPoint { + position: 2.0 * p0.position - p1.position, + pressure: p0.pressure, + }; + let ghost_after = ControlPoint { + position: 2.0 * p1.position - p0.position, + pressure: p1.pressure, + }; + + let samples = sample_segment(ghost_before, p0, p1, ghost_after, config); + samples_to_triangle_strip(&samples) + } + n => { + let steps = config.samples_per_segment.max(1); + let mut samples = Vec::new(); + + for i in 0..n - 1 { + let p_curr = control_points[i]; + let p_next = control_points[i + 1]; + + let p_prev = if i == 0 { + ControlPoint { + position: 2.0 * p_curr.position - p_next.position, + pressure: p_curr.pressure, + } + } else { + control_points[i - 1] + }; + + let p_after = if i + 2 < n { + control_points[i + 2] + } else { + ControlPoint { + position: 2.0 * p_next.position - p_curr.position, + pressure: p_next.pressure, + } + }; + + // Für das erste Segment sample von u = 0.0 bis 1.0 + // Für nachfolgende Segmente bei u > 0 beginnen, um doppelte Vertices zu vermeiden + let start_step = if i == 0 { 0 } else { 1 }; + + for s in start_step..=steps { + let u = s as f32 / steps as f32; + samples.push(evaluate_catmull_rom_segment( + p_prev, p_curr, p_next, p_after, u, config, + )); + } + } + + samples_to_triangle_strip(&samples) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_centripetal_interpolation_endpoints() { + let p0 = ControlPoint::new(0.0, 0.0, 0.2); + let p1 = ControlPoint::new(10.0, 2.0, 0.4); + let p2 = ControlPoint::new(20.0, 8.0, 0.8); + let p3 = ControlPoint::new(30.0, 10.0, 1.0); + + let config = SplineConfig::default(); + + // Am Start u = 0.0 muss der Spline exakt bei p1 sein + let start_sample = evaluate_catmull_rom_segment(p0, p1, p2, p3, 0.0, &config); + assert!((start_sample.position.x - p1.position.x).abs() < 1e-4); + assert!((start_sample.position.y - p1.position.y).abs() < 1e-4); + assert!((start_sample.pressure - p1.pressure).abs() < 1e-4); + + // Am Ende u = 1.0 muss der Spline exakt bei p2 sein + let end_sample = evaluate_catmull_rom_segment(p0, p1, p2, p3, 1.0, &config); + assert!((end_sample.position.x - p2.position.x).abs() < 1e-4); + assert!((end_sample.position.y - p2.position.y).abs() < 1e-4); + assert!((end_sample.pressure - p2.pressure).abs() < 1e-4); + } + + #[test] + fn test_tangent_and_normal_orthogonality() { + let p0 = ControlPoint::new(0.0, 0.0, 0.5); + let p1 = ControlPoint::new(10.0, 10.0, 0.5); + let p2 = ControlPoint::new(20.0, 5.0, 0.5); + let p3 = ControlPoint::new(30.0, 0.0, 0.5); + + let config = SplineConfig::default(); + let samples = sample_segment(p0, p1, p2, p3, &config); + + for s in samples { + // Tangente und Normale müssen normiert sein (Länge ca. 1.0) + assert!((s.tangent.length() - 1.0).abs() < 1e-4); + assert!((s.normal.length() - 1.0).abs() < 1e-4); + + // Skalarprodukt aus Tangente und Normale muss 0 sein (orthogonal) + let dot = s.tangent.dot(s.normal); + assert!( + dot.abs() < 1e-4, + "Tangente ({:?}) und Normale ({:?}) müssen orthogonal sein, dot = {}", + s.tangent, + s.normal, + dot + ); + + // Linker und rechter Punkt müssen im Abstand 'radius' von der Mittellinie liegen + let dist_left = (s.left - s.position).length(); + let dist_right = (s.right - s.position).length(); + assert!((dist_left - s.radius).abs() < 1e-4); + assert!((dist_right - s.radius).abs() < 1e-4); + } + } + + #[test] + fn test_pressure_dependent_radius() { + let min_r = 1.0; + let max_r = 5.0; + + assert_eq!(calculate_radius(0.0, min_r, max_r), 1.0); + assert_eq!(calculate_radius(1.0, min_r, max_r), 5.0); + assert_eq!(calculate_radius(0.5, min_r, max_r), 3.0); + // Außerhalb des Intervalls geklemmt + assert_eq!(calculate_radius(-0.5, min_r, max_r), 1.0); + assert_eq!(calculate_radius(1.5, min_r, max_r), 5.0); + } + + #[test] + fn test_triangle_strip_generation() { + let points = vec![ + ControlPoint::new(0.0, 0.0, 0.2), + ControlPoint::new(10.0, 10.0, 0.5), + ControlPoint::new(20.0, 10.0, 0.8), + ControlPoint::new(30.0, 0.0, 1.0), + ]; + + let config = SplineConfig { + min_radius: 1.0, + max_radius: 4.0, + samples_per_segment: 10, + }; + + let strip = generate_triangle_strip(&points, &config); + + // 3 Segmente, erstes mit 11 Samples, nächste zwei je 10 Samples = 31 Samples + // Jedes Sample liefert 2 Vertices (left, right) -> 62 Vertices + assert_eq!(strip.len(), 31 * 2); + + // Vertices müssen abwechselnd side -1.0 und +1.0 haben + for (i, v) in strip.iter().enumerate() { + assert!(v.position[0].is_finite()); + assert!(v.position[1].is_finite()); + assert!((0.0..=1.0).contains(&v.pressure)); + + if i % 2 == 0 { + assert_eq!(v.side, -1.0, "Gerader Index muss linker Hüllpunkt sein"); + } else { + assert_eq!(v.side, 1.0, "Ungerader Index muss rechter Hüllpunkt sein"); + } + } + } + + #[test] + fn test_degenerate_points() { + // Zwei identische Punkte dürfen keine Panik, keine Division durch Null und kein NaN erzeugen + let points = vec![ + ControlPoint::new(5.0, 5.0, 0.5), + ControlPoint::new(5.0, 5.0, 0.5), + ]; + + let config = SplineConfig::default(); + let strip = generate_triangle_strip(&points, &config); + + assert!(!strip.is_empty()); + for v in strip { + assert!(v.position[0].is_finite()); + assert!(v.position[1].is_finite()); + assert!(v.pressure.is_finite()); + assert!(v.side.is_finite()); + } + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..7ecd6dc --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,751 @@ +//! Robuste lokale Notizbuch-Persistenz mit atomaren Writes (Crash-Resistenz) +//! und semantischer Datenspeicherung (ohne gecachte GPU-Vertizes). + +use std::error::Error; +use std::fmt; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::canvas::InkVertex; +use crate::document::{Notebook, Page, PageBackground, Stroke, StylusSample}; +use crate::filter::OneEuroFilter; +use crate::spline::{self, ControlPoint, SplineConfig}; + +/// Serialisierbarer Stylus-Abtastpunkt in DIN-A4-Koordinaten. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SampleData { + pub x: f32, + pub y: f32, + pub p: f32, + pub t: f64, +} + +impl From<&StylusSample> for SampleData { + fn from(s: &StylusSample) -> Self { + Self { + x: s.position.x, + y: s.position.y, + p: s.pressure, + t: s.timestamp, + } + } +} + +impl From for StylusSample { + fn from(d: SampleData) -> Self { + Self { + position: glam::Vec2::new(d.x, d.y), + pressure: d.p, + timestamp: d.t, + } + } +} + +/// Serialisierbare semantische Strichdaten (ohne GPU-Vertizes). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StrokeData { + pub id: u64, + pub color: [f32; 4], + pub samples: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shape: Option, +} + +impl From<&Stroke> for StrokeData { + fn from(stroke: &Stroke) -> Self { + Self { + id: stroke.id, + color: stroke.color, + samples: stroke.points.iter().map(SampleData::from).collect(), + shape: stroke.shape.clone(), + } + } +} + +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 = 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 + } else { + reconstruct_stroke_vertices(&stylus_samples, self.color) + }; + let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color); + stroke.shape = self.shape.clone(); + stroke + } +} + +/// Serialisierbare semantische Seitendaten. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PageData { + pub id: u64, + pub background: PageBackground, + pub strokes: Vec, +} + +impl From<&Page> for PageData { + fn from(page: &Page) -> Self { + Self { + id: page.id, + background: page.background, + strokes: page.strokes.iter().map(StrokeData::from).collect(), + } + } +} + +impl PageData { + /// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um. + pub fn to_page(&self) -> Page { + let mut page = Page::new(self.id, self.background); + for stroke_data in &self.strokes { + page.strokes.push(stroke_data.to_stroke()); + } + page.is_dirty = true; + page.rebuild_cache_if_dirty(); + page.is_loaded = true; + page + } +} + +/// Metadaten des gesamten Notizbuchs (`manifest.json`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NotebookManifest { + pub id: String, + pub title: String, + pub created_at: u64, + pub modified_at: u64, + pub current_page_index: usize, + pub page_ids: Vec, + pub next_stroke_id: u64, + pub next_page_id: u64, +} + +impl NotebookManifest { + pub fn new(id: impl Into, title: impl Into) -> Self { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Self { + id: id.into(), + title: title.into(), + created_at: now, + modified_at: now, + current_page_index: 0, + page_ids: vec![1], + next_stroke_id: 1, + next_page_id: 2, + } + } +} + +/// Mögliche Fehler bei Speicher- und Ladeoperationen. +#[derive(Debug)] +pub enum StorageError { + Io(io::Error), + Serialization(serde_json::Error), + InvalidManifest(String), + CorruptedPage(u64, String), + PageNotFound(u64), +} + +impl fmt::Display for StorageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + StorageError::Io(e) => write!(f, "I/O-Fehler: {e}"), + StorageError::Serialization(e) => write!(f, "Serialisierungsfehler: {e}"), + StorageError::InvalidManifest(msg) => write!(f, "Ungültiges Manifest: {msg}"), + StorageError::CorruptedPage(id, msg) => { + write!(f, "Beschädigte Seite {id}: {msg}") + } + StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"), + } + } +} + +impl Error for StorageError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + StorageError::Io(e) => Some(e), + StorageError::Serialization(e) => Some(e), + _ => None, + } + } +} + +impl From for StorageError { + fn from(e: io::Error) -> Self { + StorageError::Io(e) + } +} + +impl From for StorageError { + fn from(e: serde_json::Error) -> Self { + StorageError::Serialization(e) + } +} + +/// Rekonstruiert deterministisch die GPU-Triangle-Strip-Vertizes aus Stylus-Samples. +pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> Vec { + if samples.is_empty() { + return Vec::new(); + } + + if samples.len() == 1 { + let pt = samples[0]; + let radius = spline::calculate_radius(pt.pressure, 2.5, 10.0); + let segments = 8; + let mut vertices = Vec::with_capacity((segments + 1) * 2); + for i in 0..=segments { + 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)); + } + return vertices; + } + + let mut filter = OneEuroFilter::default(); + let mut control_points: Vec = Vec::with_capacity(samples.len()); + + for s in samples { + let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp); + let pt = ControlPoint::new(fx, fy, fp); + if let Some(last) = control_points.last() { + if (pt.position - last.position).length_squared() < 1.0 { + continue; + } + } + control_points.push(pt); + } + + let config = SplineConfig { + min_radius: 2.5, + max_radius: 10.0, + samples_per_segment: 16, + }; + let raw_strip = spline::generate_triangle_strip(&control_points, &config); + raw_strip + .into_iter() + .map(|v| InkVertex::new(v.position[0], v.position[1], color)) + .collect() +} + +/// 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") + })?; + fs::create_dir_all(dir)?; + + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); + let random_suffix: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let temp_filename = format!("{filename}.tmp.{}.{random_suffix}", std::process::id()); + let temp_path = dir.join(temp_filename); + + // In temporäre Datei schreiben und auf Datenträger flushen + { + let mut file = File::create(&temp_path)?; + file.write_all(content)?; + file.sync_all()?; + } + + // Atomares Rename + if let Err(e) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(e); + } + + Ok(()) +} + +/// Serialisiert einen Wert als formatiertes JSON und schreibt ihn atomar. +pub fn atomic_write_json(path: &Path, value: &T) -> Result<(), StorageError> { + let bytes = serde_json::to_vec_pretty(value)?; + atomic_write_file(path, &bytes)?; + Ok(()) +} + +/// Speichert das Manifest atomar in `manifest.json`. +pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> { + let path = notebook_dir.join("manifest.json"); + atomic_write_json(&path, manifest) +} + +/// Lädt das Manifest aus `manifest.json`. +pub fn load_manifest(notebook_dir: &Path) -> Result { + let path = notebook_dir.join("manifest.json"); + if !path.exists() { + return Err(StorageError::InvalidManifest(format!( + "Manifestdatei nicht gefunden: {}", + path.display() + ))); + } + let mut file = File::open(&path)?; + let mut json_str = String::new(); + file.read_to_string(&mut json_str)?; + let manifest: NotebookManifest = serde_json::from_str(&json_str)?; + Ok(manifest) +} + +/// Speichert eine Seite atomar in `page_.json`. +pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> { + let path = notebook_dir.join(format!("page_{}.json", page.id)); + atomic_write_json(&path, page) +} + +/// Lädt eine Seite aus `page_.json`. +pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result { + let path = notebook_dir.join(format!("page_{page_id}.json")); + if !path.exists() { + return Err(StorageError::PageNotFound(page_id)); + } + + let mut file = File::open(&path)?; + let mut json_str = String::new(); + file.read_to_string(&mut json_str)?; + + match serde_json::from_str::(&json_str) { + Ok(data) => Ok(data), + Err(e) => { + eprintln!( + "[Storage-Warnung] Seite {page_id} in '{}' ist beschädigt: {e}", + path.display() + ); + Err(StorageError::CorruptedPage(page_id, e.to_string())) + } + } +} + +/// Speichert ein gesamtes Notebook synchron auf die Festplatte (z. B. initial oder beim Flush). +pub fn save_notebook( + notebook_dir: &Path, + notebook: &Notebook, + manifest: &mut NotebookManifest, +) -> Result<(), StorageError> { + fs::create_dir_all(notebook_dir)?; + + manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect(); + manifest.current_page_index = notebook.current_page_index; + manifest.next_stroke_id = notebook.next_stroke_id; + manifest.next_page_id = notebook.next_page_id; + manifest.modified_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Alle bereits geladenen Seiten speichern + for page in ¬ebook.pages { + if page.is_loaded { + let page_data = PageData::from(page); + save_page(notebook_dir, &page_data)?; + } + } + + save_manifest(notebook_dir, manifest)?; + Ok(()) +} + +/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading). +pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> { + let manifest = load_manifest(notebook_dir)?; + if manifest.page_ids.is_empty() { + let nb = Notebook::new(); + return Ok((nb, manifest)); + } + + let mut pages = Vec::with_capacity(manifest.page_ids.len()); + + // Erste Seite sofort laden und initialisieren + let first_id = manifest.page_ids[0]; + let first_page = match load_page(notebook_dir, first_id) { + Ok(data) => data.to_page(), + Err(e) => { + eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); + Page::new(first_id, PageBackground::Grid5mm) + } + }; + pages.push(first_page); + + // Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading) + for &page_id in &manifest.page_ids[1..] { + pages.push(Page::new_unloaded(page_id, PageBackground::Grid5mm)); + } + + let notebook = Notebook::from_pages( + pages, + manifest.current_page_index, + manifest.next_stroke_id, + manifest.next_page_id, + ); + + Ok((notebook, manifest)) +} + +/// Stellt sicher, dass die Seite am Index `page_idx` geladen ist (Lazy Loading beim Seitenwechsel). +pub fn ensure_page_loaded( + notebook_dir: &Path, + notebook: &mut Notebook, + page_idx: usize, +) -> Result<(), StorageError> { + if page_idx >= notebook.pages.len() { + return Err(StorageError::PageNotFound(page_idx as u64)); + } + + if notebook.pages[page_idx].is_loaded { + return Ok(()); + } + + let page_id = notebook.pages[page_idx].id; + match load_page(notebook_dir, page_id) { + Ok(data) => { + notebook.pages[page_idx] = data.to_page(); + Ok(()) + } + Err(e) => { + eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}"); + // Ersatzseite als geladen markieren, damit die App nicht hängt + notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].rebuild_cache_if_dirty(); + Err(e) + } + } +} + +/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation). +pub fn default_storage_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("ink-notes").join("notebooks"); + } + } + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("ink-notes") + .join("notebooks"); + } + } + PathBuf::from("./data/notebooks") +} + +/// Zusammenfassende Metadaten eines Notizbuchs für die Dokumentenliste. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NotebookSummary { + pub id: String, + pub title: String, + pub page_count: usize, + pub modified_at: u64, +} + +/// Durchsucht das Basis-Verzeichnis nach allen vorhandenen Notizbüchern. +pub fn list_notebooks(base_dir: &Path) -> Vec { + let mut list = Vec::new(); + let Ok(entries) = fs::read_dir(base_dir) else { + return list; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let manifest_path = path.join("manifest.json"); + if manifest_path.exists() { + if let Ok(manifest) = load_manifest(&path) { + list.push(NotebookSummary { + id: manifest.id, + title: manifest.title, + page_count: manifest.page_ids.len(), + modified_at: manifest.modified_at, + }); + } + } + } + } + + // Nach letztem Änderungsdatum absteigend sortieren (neueste zuerst) + list.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); + list +} + +/// Erstellt ein neues Notizbuch mit dem angegebenen Titel und initialisiert Seite 1. +pub fn create_new_notebook( + base_dir: &Path, + title: &str, +) -> Result<(Notebook, NotebookManifest, PathBuf), StorageError> { + fs::create_dir_all(base_dir)?; + + let now_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + + let clean_slug: String = title + .chars() + .filter_map(|c| { + if c.is_alphanumeric() { + Some(c.to_ascii_lowercase()) + } else if c == ' ' || c == '-' || c == '_' { + Some('_') + } else { + None + } + }) + .collect(); + let clean_slug = clean_slug.trim_matches('_'); + + let id = if clean_slug.is_empty() { + format!("nb_{now_ts}") + } else { + format!("{clean_slug}_{now_ts}") + }; + + let notebook_dir = base_dir.join(&id); + fs::create_dir_all(¬ebook_dir)?; + + let notebook = Notebook::new(); + let mut manifest = NotebookManifest::new(&id, title); + + save_notebook(¬ebook_dir, ¬ebook, &mut manifest)?; + + Ok((notebook, manifest, notebook_dir)) +} + +/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte. +pub fn rename_notebook( + notebook_dir: &Path, + manifest: &mut NotebookManifest, + new_title: &str, +) -> Result<(), StorageError> { + manifest.title = new_title.to_string(); + manifest.modified_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + save_manifest(notebook_dir, manifest)?; + Ok(()) +} + +/// Löscht ein Notizbuch vollständig vom Datenträger. +pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> { + if notebook_dir.exists() { + fs::remove_dir_all(notebook_dir)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::autosave::AutosaveController; + use std::time::Duration; + + #[test] + fn test_atomic_save_and_reload() { + let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); + let path = temp_dir.path(); + + let mut page = Page::new(42, PageBackground::DotGrid); + let sample1 = StylusSample { + position: glam::Vec2::new(120.0, 340.0), + pressure: 0.45, + timestamp: 1.0, + }; + let sample2 = StylusSample { + position: glam::Vec2::new(135.0, 360.0), + pressure: 0.85, + timestamp: 1.02, + }; + let sample3 = StylusSample { + position: glam::Vec2::new(160.0, 390.0), + pressure: 0.65, + timestamp: 1.05, + }; + + let stroke = Stroke::new( + 101, + vec![sample1, sample2, sample3], + vec![ + InkVertex::new(118.0, 340.0, [0.1, 0.2, 0.8, 1.0]), + InkVertex::new(122.0, 340.0, [0.1, 0.2, 0.8, 1.0]), + InkVertex::new(132.0, 360.0, [0.1, 0.2, 0.8, 1.0]), + InkVertex::new(138.0, 360.0, [0.1, 0.2, 0.8, 1.0]), + InkVertex::new(158.0, 390.0, [0.1, 0.2, 0.8, 1.0]), + InkVertex::new(162.0, 390.0, [0.1, 0.2, 0.8, 1.0]), + ], + [0.1, 0.2, 0.8, 1.0], + ); + page.add_stroke(stroke); + + // Speichern + let page_data = PageData::from(&page); + let save_res = save_page(path, &page_data); + assert!(save_res.is_ok(), "Speichern der Seite muss gelingen"); + + // Datei existiert + let file_path = path.join("page_42.json"); + assert!(file_path.exists()); + + // Neu laden + let load_res = load_page(path, 42); + assert!(load_res.is_ok(), "Laden der Seite muss gelingen"); + let loaded_data = load_res.unwrap(); + let loaded_page = loaded_data.to_page(); + + // Überprüfungen + assert_eq!(loaded_page.id, 42); + assert_eq!(loaded_page.background, PageBackground::DotGrid); + assert_eq!(loaded_page.strokes.len(), 1); + + let loaded_stroke = &loaded_page.strokes[0]; + assert_eq!(loaded_stroke.id, 101); + 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()) { + 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); + assert!((orig.timestamp - loaded.timestamp).abs() < 1e-4); + } + + // Vertizes wurden deterministisch regeneriert + assert!(!loaded_stroke.vertices.is_empty()); + assert!(!loaded_page.vertex_cache.is_empty()); + assert!(loaded_page.is_loaded); + } + + #[test] + fn test_corrupted_file_recovery() { + let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); + let path = temp_dir.path(); + + // 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"#; + 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"); + + match load_res.unwrap_err() { + StorageError::CorruptedPage(id, msg) => { + assert_eq!(id, 99); + assert!(!msg.is_empty()); + } + other => panic!("Erwartete CorruptedPage-Fehler, erhielt: {other:?}"), + } + } + + #[test] + fn test_debounce_timing() { + let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); + let notebook_dir = temp_dir.path().to_path_buf(); + + let debounce_duration = Duration::from_millis(150); + let mut controller = AutosaveController::new(notebook_dir.clone(), debounce_duration); + let manifest = NotebookManifest::new("test_nb", "Test Notizbuch"); + + let mut page = Page::new(1, PageBackground::Grid5mm); + + // 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms, deutlich kürzer als 150 ms Debounce) + for i in 0..10 { + let sample = StylusSample { + position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32), + pressure: 0.5, + timestamp: i as f64 * 0.01, + }; + let stroke = Stroke::new( + i + 1, + vec![sample], + vec![InkVertex::new(10.0, 20.0, [0.0, 0.0, 0.0, 1.0])], + [0.0, 0.0, 0.0, 1.0], + ); + page.add_stroke(stroke); + + controller.mark_page_dirty(&page, &manifest); + controller.update(); + std::thread::sleep(Duration::from_millis(5)); + } + + // Während der schnellen Änderungen (ca. 50 ms vergangen) darf noch KEIN Schreibvorgang erfolgt sein! + assert_eq!( + controller.total_page_writes(), + 0, + "Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben" + ); + + // Jetzt warten wir, bis das Debounce-Fenster abgelaufen ist (> 150 ms) + std::thread::sleep(Duration::from_millis(180)); + controller.update(); + + // Flush abwarten + controller.flush(); + + // Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben + assert_eq!( + controller.total_page_writes(), + 1, + "10 schnelle Änderungen müssen in genau 1 gebündelten Schreibvorgang münden" + ); + + // Verifiziere, dass die Datei existiert und alle 10 Striche enthält + let loaded = load_page(¬ebook_dir, 1).expect("Seite muss lesbar sein"); + assert_eq!(loaded.strokes.len(), 10); + + controller.shutdown(); + } + + #[test] + fn test_notebook_management() { + let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); + let base_dir = temp_dir.path(); + + // 1. Erstes Notizbuch anlegen + let (nb1, mut m1, dir1) = create_new_notebook(base_dir, "Mathe Vorlesung") + .expect("Erstellen von Notizbuch 1 muss klappen"); + assert_eq!(m1.title, "Mathe Vorlesung"); + assert_eq!(nb1.pages.len(), 1); + assert!(dir1.exists()); + + // 2. Zweites Notizbuch anlegen + let (_nb2, m2, dir2) = create_new_notebook(base_dir, "Skizzen & Entwürfe") + .expect("Erstellen von Notizbuch 2 muss klappen"); + assert_eq!(m2.title, "Skizzen & Entwürfe"); + assert!(dir2.exists()); + + // 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")); + + // 4. Umbenennen + 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")); + + // 5. Löschen + delete_notebook(&dir2).expect("Löschen muss klappen"); + assert!(!dir2.exists()); + + let final_summaries = list_notebooks(base_dir); + assert_eq!(final_summaries.len(), 1); + assert_eq!(final_summaries[0].id, m1.id); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..7325d24 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,1626 @@ +//! Immediate-Mode UI mit `egui` für ink-notes. +//! +//! Stellt eine moderne, schwebende Top-Toolbar im Pill-Design bereit: +//! - Milchglas-Look (Transluzenter Dark-Theme-Hintergrund mit weichem Schatten) +//! - Abgerundete Ecken (14 px Radius) und feine Kante +//! - Visuelle Farbindikatoren (3 px hohe Balken) direkt an den Werkzeug-Buttons +//! - Schwebendes Popover-Menü zur Konfiguration von Stiftfarbe, Dicken-Presets mit Linien-Vorschau und stufenlosem Slider +//! - Exaktes Hit-Testing für Event-Transparenz außerhalb der Pill und des Popovers +//! - Werkzeugauswahl: Stift (Pen), Textmarker (Highlighter), Radierer (Eraser) mit Akzent-Highlight +//! - Undo & Redo (mit dynamischer Deaktivierung) +//! - Diskrete Seitennavigation (◄, Seite X/Y mit Schnellauswahl, ►, ➕ Neue Seite) +//! - Hintergrundraster-Dropdown (Kariert, Liniert, Punktraster, Blanko) +//! - PDF-Export-Button mit Feedback-Banner +//! - Autosave-Statusanzeige (Live-Badge mit Farbindikator) + +use std::time::Instant; + +use crate::autosave::AutosaveStatus; +use crate::document::{Notebook, PageBackground}; +use crate::storage::NotebookSummary; + +/// Aktives Werkzeug für das Zeichnen oder Bearbeiten. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActiveTool { + Pen, + Highlighter, + Eraser, + Shape, + Select, +} + +impl ActiveTool { + pub fn name(&self) -> &'static str { + match self { + ActiveTool::Pen => "Stift (Pen)", + ActiveTool::Highlighter => "Textmarker (Highlighter)", + ActiveTool::Eraser => "Radierer (Eraser)", + ActiveTool::Shape => "Formen (Shape)", + ActiveTool::Select => "Auswahl / Lasso (Select)", + } + } + + pub fn is_drawing(&self) -> bool { + matches!(self, ActiveTool::Pen | ActiveTool::Highlighter) + } +} + +/// Schnellauswahlfarben für den normalen Stift laut Meilenstein 8: +/// - Notiz-Schwarz: [0.12, 0.12, 0.14, 1.0] +/// - Universitäts-Blau: [0.15, 0.35, 0.85, 1.0] +/// - Korrektur-Rot: [0.85, 0.20, 0.20, 1.0] +/// - Tafel-Grün: [0.18, 0.65, 0.35, 1.0] +/// - Graphit-Grau: [0.45, 0.45, 0.48, 1.0] +pub const PEN_COLOR_PRESETS: [([f32; 4], &str); 5] = [ + ([0.12, 0.12, 0.14, 1.0], "Notiz-Schwarz"), + ([0.15, 0.35, 0.85, 1.0], "Universitäts-Blau"), + ([0.85, 0.20, 0.20, 1.0], "Korrektur-Rot"), + ([0.18, 0.65, 0.35, 1.0], "Tafel-Grün"), + ([0.45, 0.45, 0.48, 1.0], "Graphit-Grau"), +]; + +/// Schnellauswahlfarben für den Textmarker (halbtransparent, Alpha 0.35): +/// - Neon-Gelb, Hellgrün, Pastell-Cyan, Rosa +pub const HIGHLIGHTER_COLOR_PRESETS: [([f32; 4], &str); 4] = [ + ([1.0, 0.95, 0.1, 0.35], "Neon-Gelb"), + ([0.2, 0.9, 0.3, 0.35], "Hellgrün"), + ([0.25, 0.7, 1.0, 0.35], "Pastell-Cyan"), + ([1.0, 0.3, 0.65, 0.35], "Rosa"), +]; + +/// Dicken-Presets für feines Inking und präzise Notizen: +/// - Extra-Fein: 0.3 mm (für kleine Formeln / Indizes) +/// - Fein: 0.5 mm (Standard-Fließtext) +/// - Normal: 0.8 mm (Hervorhebungen) +/// - Fett: 1.2 mm (Überschriften) +pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [ + (0.3, "0.3 mm"), + (0.5, "0.5 mm"), + (0.8, "0.8 mm"), + (1.2, "1.2 mm"), +]; + +/// 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"), +]; + +/// Aktionen, die von der UI an die Anwendungslogik signalisiert werden. +#[derive(Debug, Clone, PartialEq)] +pub enum UiAction { + SetTool(ActiveTool), + SetColor([f32; 4]), + SetStrokeWidthMm(f32), + Undo, + Redo, + PreviousPage, + NextPage, + AddPage, + GoToPage(usize), + SetBackground(PageBackground), + ExportPdf, + ZoomIn, + ZoomOut, + ResetZoom, + DeleteSelected, + CopySelected, + PasteSelected, + RotateSelected, + // Dokumenten- & Notizbuch-Verwaltung + OpenNotebook(String), + CreateNotebook(String), + RenameNotebook { id: String, new_title: String }, + DeleteNotebook(String), + RefreshNotebooks, +} + +/// Zustand der grafischen Benutzeroberfläche. +#[derive(Debug, Clone)] +pub struct UiState { + pub active_tool: ActiveTool, + pub pen_color: [f32; 4], + pub highlighter_color: [f32; 4], + pub pen_stroke_width_mm: f32, + pub highlighter_stroke_width_mm: f32, + pub export_message: Option<(String, Instant)>, + pub show_page_jump_popup: bool, + /// Aktueller Zoomfaktor zur Anzeige in der Toolbar + pub current_zoom: f32, + /// Bildschirm-Rechteck der schwebenden Toolbar für pixelgenaues Hit-Testing + pub toolbar_rect: Option, + /// Öffnungszustand des Einstellungs-Popovers für den Stift + pub is_pen_menu_open: bool, + /// Öffnungszustand des Einstellungs-Popovers für den Textmarker + pub is_highlighter_menu_open: bool, + /// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers + pub popover_rect: Option, + /// Dokumenten-Manager Zustand + pub is_document_menu_open: bool, + pub current_notebook_id: String, + pub document_title: String, + pub available_notebooks: Vec, + pub new_notebook_title_input: String, + pub renaming_notebook_id: Option, + pub renaming_title_input: String, +} + +impl Default for UiState { + fn default() -> Self { + Self { + active_tool: ActiveTool::Pen, + pen_color: PEN_COLOR_PRESETS[0].0, + highlighter_color: HIGHLIGHTER_COLOR_PRESETS[0].0, + pen_stroke_width_mm: 0.5, // Standard: 0.5 mm (feine Handschrift) + highlighter_stroke_width_mm: 6.0, + export_message: None, + show_page_jump_popup: false, + current_zoom: 1.0, + toolbar_rect: None, + is_pen_menu_open: false, + is_highlighter_menu_open: false, + popover_rect: None, + is_document_menu_open: false, + current_notebook_id: "default".to_string(), + document_title: "Standard-Notizbuch".to_string(), + available_notebooks: Vec::new(), + new_notebook_title_input: String::new(), + renaming_notebook_id: None, + renaming_title_input: String::new(), + } + } +} + +impl UiState { + /// Radius der abgerundeten Ecken für die Toolbar-Pill. + pub const TOOLBAR_ROUNDING: f32 = 14.0; + + pub fn new() -> Self { + Self::default() + } + + /// Gibt die aktuelle Farbe des gewählten Werkzeugs zurück. + pub fn current_color(&self) -> [f32; 4] { + match self.active_tool { + ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_color, + ActiveTool::Highlighter => self.highlighter_color, + ActiveTool::Eraser => [0.0, 0.0, 0.0, 0.0], + } + } + + /// Gibt die aktuelle Strichbreite in mm zurück. + pub fn current_stroke_width_mm(&self) -> f32 { + match self.active_tool { + ActiveTool::Pen | ActiveTool::Shape | ActiveTool::Select => self.pen_stroke_width_mm, + ActiveTool::Highlighter => self.highlighter_stroke_width_mm, + ActiveTool::Eraser => 3.0, + } + } + + /// Zeigt eine temporäre Erfolgs- oder Statusmeldung im UI an. + pub fn set_export_message(&mut self, msg: impl Into) { + self.export_message = Some((msg.into(), Instant::now())); + } + + /// Exaktes Hit-Testing: Prüft, ob ein Bildschirmpunkt (px, py) innerhalb der + /// abgerundeten Pill-Toolbar liegt (unter Berücksichtigung der 4 Eckradien). + pub fn is_point_inside_toolbar(&self, px: f32, py: f32) -> bool { + let Some(rect) = self.toolbar_rect else { + return false; + }; + + if px < rect.min.x || px > rect.max.x || py < rect.min.y || py > rect.max.y { + return false; + } + + let r = Self::TOOLBAR_ROUNDING; + + // 1. Oben-links + if px < rect.min.x + r && py < rect.min.y + r { + let dx = px - (rect.min.x + r); + let dy = py - (rect.min.y + r); + return dx * dx + dy * dy <= r * r; + } + // 2. Oben-rechts + if px > rect.max.x - r && py < rect.min.y + r { + let dx = px - (rect.max.x - r); + let dy = py - (rect.min.y + r); + return dx * dx + dy * dy <= r * r; + } + // 3. Unten-links + if px < rect.min.x + r && py > rect.max.y - r { + let dx = px - (rect.min.x + r); + let dy = py - (rect.max.y - r); + return dx * dx + dy * dy <= r * r; + } + // 4. Unten-rechts + if px > rect.max.x - r && py > rect.max.y - r { + let dx = px - (rect.max.x - r); + let dy = py - (rect.max.y - r); + return dx * dx + dy * dy <= r * r; + } + + true + } + + /// Prüft, ob ein Bildschirmpunkt (px, py) innerhalb der UI liegt: + /// - Entweder innerhalb der abgerundeten Pill-Toolbar + /// - ODER innerhalb des geöffneten Einstellungs-Popovers + pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool { + if self.is_point_inside_toolbar(px, py) { + return true; + } + if let Some(pop_rect) = self.popover_rect { + if pop_rect.contains(egui::pos2(px, py)) { + return true; + } + } + false + } +} + +/// Stilvoller Werkzeug-Button mit 3 px hohem Farbindikator-Balken an der Unterkante +/// und Ausklapp-Indikator (▾ / ▴). +fn tool_pill_button_with_indicator( + ui: &mut egui::Ui, + is_active: bool, + text: &str, + indicator_color: [f32; 4], + is_menu_open: bool, + tooltip: &str, +) -> egui::Response { + let size = egui::vec2(90.0, 30.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + + if ui.is_rect_visible(rect) { + let (bg, text_color, stroke) = if is_active { + ( + 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)), + ) + } 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)), + ) + } 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)), + ) + }; + + // Button-Hintergrund + 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 { + format!("{} ▴", text) + } else { + format!("{} ▾", text) + }; + ui.painter().text( + egui::pos2(rect.center().x, rect.center().y - 2.5), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(12.5), + text_color, + ); + + // 3 px hoher Farbbalken an der Unterkante + let bar_margin_x = 10.0; + let bar_h = 3.0; + let bar_rect = egui::Rect::from_min_max( + egui::pos2(rect.min.x + bar_margin_x, rect.max.y - bar_h - 2.5), + egui::pos2(rect.max.x - bar_margin_x, rect.max.y - 2.5), + ); + let bar_color = egui::Color32::from_rgba_unmultiplied( + (indicator_color[0] * 255.0) as u8, + (indicator_color[1] * 255.0) as u8, + (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); + } + + response.on_hover_text(tooltip) +} + +/// Stilvoller einfacher Werkzeug-Button (für den Radierer). +fn tool_pill_button( + ui: &mut egui::Ui, + is_active: bool, + text: &str, + tooltip: &str, +) -> egui::Response { + let (bg, text_color, stroke) = if is_active { + ( + 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)), + ) + } 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)), + ) + }; + + let button = egui::Button::new( + egui::RichText::new(text) + .color(text_color) + .size(13.0) + .strong(), + ) + .fill(bg) + .stroke(stroke) + .rounding(egui::Rounding::same(8.0)) + .min_size(egui::vec2(0.0, 28.0)); + + ui.add(button).on_hover_text(tooltip) +} + +/// Dicken-Preset-Button mit visueller Linien-Vorschau in Originalfarbe und Proportion. +fn stroke_preview_button( + ui: &mut egui::Ui, + is_active: bool, + stroke_width_mm: f32, + label_text: &str, + color: [f32; 4], +) -> egui::Response { + let size = egui::vec2(66.0, 34.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + + if ui.is_rect_visible(rect) { + 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)), + ) + } 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)), + ) + } 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)), + ) + }; + + 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); + let start_x = rect.min.x + 8.0; + let end_x = rect.max.x - 8.0; + let y = rect.center().y - 4.5; + + let line_color = egui::Color32::from_rgba_unmultiplied( + (color[0] * 255.0) as u8, + (color[1] * 255.0) as u8, + (color[2] * 255.0) as u8, + ((color[3] * 255.0) as u8).max(180), + ); + ui.painter().line_segment( + [egui::pos2(start_x, y), egui::pos2(end_x, y)], + egui::Stroke::new(line_thickness, line_color), + ); + + // Text unterhalb der Linie (z. B. "2.0 mm") + let text_pos = egui::pos2(rect.center().x, rect.max.y - 7.5); + ui.painter().text( + text_pos, + egui::Align2::CENTER_CENTER, + label_text, + egui::FontId::proportional(10.0), + if is_active { egui::Color32::WHITE } else { egui::Color32::from_gray(190) }, + ); + } + + response.on_hover_text(format!("Stärke auf {} setzen", label_text)) +} + +/// Kompakter Strichbreiten-Preset-Button für die Haupt-Toolbar. +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)), + ) + } 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)), + ) + }; + + 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) +} + +/// Abgerundeter Aktions-Button (Undo, Redo, Navigation, PDF). +fn action_pill_button( + ui: &mut egui::Ui, + enabled: bool, + text: &str, + tooltip: &str, +) -> egui::Response { + let text_color = if enabled { + egui::Color32::from_gray(230) + } else { + egui::Color32::from_gray(105) + }; + + 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)), + ) + } 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)), + ) + }; + + 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) +} + +/// Feine, transparente Trennlinie für Gruppenabstände. +fn toolbar_divider(ui: &mut egui::Ui) { + ui.add_space(2.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(1.0, 20.0), egui::Sense::hover()); + ui.painter().vline( + rect.center().x, + rect.y_range(), + egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 25)), + ); + ui.add_space(2.0); +} + +/// Zeichnet einen runden Farbauswahl-Knopf (Color Swatch) mit anpassbarem Durchmesser. +fn color_circle_swatch( + ui: &mut egui::Ui, + color: [f32; 4], + is_selected: bool, + tooltip: &str, + diameter: f32, +) -> egui::Response { + let size = egui::vec2(diameter, diameter); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + + if ui.is_rect_visible(rect) { + let r = (color[0].clamp(0.0, 1.0) * 255.0) as u8; + let g = (color[1].clamp(0.0, 1.0) * 255.0) as u8; + let b = (color[2].clamp(0.0, 1.0) * 255.0) as u8; + let a = ((color[3].clamp(0.0, 1.0) * 255.0) as u8).max(180); + let c32 = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + + let stroke = if is_selected { + egui::Stroke::new(2.5_f32, egui::Color32::WHITE) + } else if response.hovered() { + egui::Stroke::new(1.8_f32, egui::Color32::from_white_alpha(200)) + } else { + egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(65)) + }; + + let radius = (diameter / 2.0) - 1.5; + ui.painter().circle_filled(rect.center(), radius, c32); + ui.painter().circle_stroke(rect.center(), radius, stroke); + } + + response.on_hover_text(tooltip) +} + +/// Rendert die schwebende Toolbar und alle zugehörigen UI-Komponenten mit `egui`. +/// +/// Gibt eine Liste von `UiAction`-Events zurück, die vom Hauptprogramm verarbeitet werden. +pub fn render_ui( + ctx: &egui::Context, + state: &mut UiState, + notebook: &Notebook, + autosave_status: AutosaveStatus, +) -> Vec { + let mut actions = Vec::new(); + + let mut pen_btn_rect = None; + let mut high_btn_rect = None; + + // 1. Schwebende Top-Toolbar (zentriert oben mit 16 px Abstand zum Fensterrand) + let area_response = egui::Area::new(egui::Id::new("floating_toolbar_area")) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 16.0)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let frame_response = egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 225)) // Transluzenter Milchglas-Hintergrund + .rounding(egui::Rounding::same(UiState::TOOLBAR_ROUNDING)) // Abgerundete Pill-Ecken (14 px) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), // Feine Kante + )) + .inner_margin(egui::Margin::symmetric(14.0, 8.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 4.0), + blur: 16.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(100), // Weicher Schattenwurf + }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0); + + // --- 0. DOKUMENTEN- & NOTIZBUCH-AUSWAHL --- + let doc_title_truncated = if state.document_title.chars().count() > 16 { + let prefix: String = state.document_title.chars().take(14).collect(); + format!("{prefix}…") + } else if state.document_title.is_empty() { + "Notizbuch".to_string() + } else { + state.document_title.clone() + }; + let doc_btn_label = format!("📚 {} ▾", doc_title_truncated); + let doc_resp = tool_pill_button( + ui, + state.is_document_menu_open, + &doc_btn_label, + "Dokumenten-Manager: Notizbuch wechseln, anlegen oder verwalten", + ); + if doc_resp.clicked() { + state.is_document_menu_open = !state.is_document_menu_open; + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + if state.is_document_menu_open { + actions.push(UiAction::RefreshNotebooks); + } + } + + toolbar_divider(ui); + + // --- 1. WERKZEUGAUSWAHL MIT FARBINDIKATOR --- + let pen_resp = tool_pill_button_with_indicator( + ui, + state.active_tool == ActiveTool::Pen, + "✏ Stift", + state.pen_color, + state.is_pen_menu_open, + "Stift wählen / Einstellungen öffnen (Farbe & Stärke)", + ); + pen_btn_rect = Some(pen_resp.rect); + + if pen_resp.clicked() { + if state.active_tool != ActiveTool::Pen { + state.active_tool = ActiveTool::Pen; + state.is_pen_menu_open = true; + state.is_highlighter_menu_open = false; + actions.push(UiAction::SetTool(ActiveTool::Pen)); + actions.push(UiAction::SetColor(state.pen_color)); + actions.push(UiAction::SetStrokeWidthMm(state.pen_stroke_width_mm)); + } else { + state.is_pen_menu_open = !state.is_pen_menu_open; + state.is_highlighter_menu_open = false; + } + } + + let high_resp = tool_pill_button_with_indicator( + ui, + state.active_tool == ActiveTool::Highlighter, + "🖍 Marker", + state.highlighter_color, + state.is_highlighter_menu_open, + "Textmarker wählen / Einstellungen öffnen", + ); + high_btn_rect = Some(high_resp.rect); + + if high_resp.clicked() { + if state.active_tool != ActiveTool::Highlighter { + state.active_tool = ActiveTool::Highlighter; + state.is_highlighter_menu_open = true; + state.is_pen_menu_open = false; + actions.push(UiAction::SetTool(ActiveTool::Highlighter)); + actions.push(UiAction::SetColor(state.highlighter_color)); + actions.push(UiAction::SetStrokeWidthMm(state.highlighter_stroke_width_mm)); + } else { + state.is_highlighter_menu_open = !state.is_highlighter_menu_open; + state.is_pen_menu_open = false; + } + } + + let shape_resp = tool_pill_button( + ui, + state.active_tool == ActiveTool::Shape, + "📐 Formen", + "Geometrie / Auto-Snap (Tippe auf Striche zur perfekten Formerkennung)", + ); + if shape_resp.clicked() { + state.active_tool = ActiveTool::Shape; + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + actions.push(UiAction::SetTool(ActiveTool::Shape)); + } + + if tool_pill_button( + ui, + state.active_tool == ActiveTool::Eraser, + "⌫ Radierer", + "Strichradierer (Taste: E oder Rechtsklick)", + ) + .clicked() + { + state.active_tool = ActiveTool::Eraser; + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + actions.push(UiAction::SetTool(ActiveTool::Eraser)); + } + + let select_resp = tool_pill_button( + ui, + state.active_tool == ActiveTool::Select, + "⬚ Auswählen", + "Objektauswahl / Lasso (Taste: V) - Verschieben, Drehen, Skalieren, Kopieren, Löschen", + ); + if select_resp.clicked() { + state.active_tool = ActiveTool::Select; + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + actions.push(UiAction::SetTool(ActiveTool::Select)); + } + + toolbar_divider(ui); + + // --- 2. FARBPALETTE SCHNELLAUSWAHL --- + if state.active_tool == ActiveTool::Pen || state.active_tool == ActiveTool::Shape || state.active_tool == ActiveTool::Select { + for (color, label) in PEN_COLOR_PRESETS { + 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, 20.0).clicked() { + state.pen_color = color; + actions.push(UiAction::SetColor(color)); + } + } + } else if state.active_tool == ActiveTool::Highlighter { + for (color, label) in HIGHLIGHTER_COLOR_PRESETS { + 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, 20.0).clicked() { + state.highlighter_color = color; + actions.push(UiAction::SetColor(color)); + } + } + } + + // Bei aktiver Auswahl: Direkte Schnell-Aktionen in der Toolbar + if state.active_tool == ActiveTool::Select { + toolbar_divider(ui); + if ui.button(egui::RichText::new("📋 Kopieren").size(12.0)).on_hover_text("Auswahl in die Zwischenablage kopieren (Strg+C)").clicked() { + actions.push(UiAction::CopySelected); + } + if ui.button(egui::RichText::new("📥 Einfügen").size(12.0)).on_hover_text("Aus Zwischenablage einfügen (Strg+V)").clicked() { + actions.push(UiAction::PasteSelected); + } + if ui.button(egui::RichText::new("🔄 90° Drehen").size(12.0)).on_hover_text("Ausgewählte Objekte um 90° im Uhrzeigersinn drehen (R)").clicked() { + actions.push(UiAction::RotateSelected); + } + if ui.button(egui::RichText::new("🗑 Löschen").color(egui::Color32::from_rgb(255, 110, 110)).size(12.0)).on_hover_text("Ausgewählte Objekte entfernen (Entf/Backspace)").clicked() { + actions.push(UiAction::DeleteSelected); + } + } + + if state.active_tool != ActiveTool::Eraser && state.active_tool != ActiveTool::Select { + toolbar_divider(ui); + } + + // --- 3. STRICHBREITEN-SCHNELLAUSWAHL --- + if state.active_tool == ActiveTool::Pen || state.active_tool == ActiveTool::Shape { + for &(preset, label) in &PEN_WIDTH_PRESETS { + let is_sel = (state.pen_stroke_width_mm - preset).abs() < 0.05; + if preset_width_button(ui, is_sel, label).clicked() { + state.pen_stroke_width_mm = preset; + actions.push(UiAction::SetStrokeWidthMm(preset)); + } + } + toolbar_divider(ui); + } else if state.active_tool == ActiveTool::Highlighter { + for &(preset, label) in &HIGHLIGHTER_WIDTH_PRESETS { + let is_sel = (state.highlighter_stroke_width_mm - preset).abs() < 0.1; + if preset_width_button(ui, is_sel, label).clicked() { + state.highlighter_stroke_width_mm = preset; + actions.push(UiAction::SetStrokeWidthMm(preset)); + } + } + toolbar_divider(ui); + } + + // --- 4. UNDO & REDO --- + let can_undo = notebook.current_page().history.can_undo(); + let can_redo = notebook.current_page().history.can_redo(); + + if action_pill_button(ui, can_undo, "⟲ Undo", "Rückgängig (Strg + Z)").clicked() { + actions.push(UiAction::Undo); + } + + if action_pill_button(ui, can_redo, "⟳ Redo", "Wiederholen (Strg + Y)").clicked() { + actions.push(UiAction::Redo); + } + + toolbar_divider(ui); + + // --- 5. DISKRETE SEITENNAVIGATION --- + let can_prev = notebook.current_page_index > 0; + if action_pill_button(ui, can_prev, "◄", "Vorherige Seite (PageUp)").clicked() { + actions.push(UiAction::PreviousPage); + } + + let page_badge_text = format!( + "Seite {} / {}", + notebook.current_page_index + 1, + notebook.pages.len() + ); + if action_pill_button(ui, true, &page_badge_text, "Schnellauswahl öffnen").clicked() { + state.show_page_jump_popup = !state.show_page_jump_popup; + } + + if action_pill_button(ui, true, "►", "Nächste Seite (PageDown / N)").clicked() { + actions.push(UiAction::NextPage); + } + + if action_pill_button(ui, true, "➕", "Neue Seite anlegen").clicked() { + actions.push(UiAction::AddPage); + } + + toolbar_divider(ui); + + // --- 6. HINTERGRUNDRASTER-DROPDOWN --- + let current_bg = notebook.current_page().background; + let current_bg_text = match current_bg { + PageBackground::Grid5mm => "Kariert (5mm)", + PageBackground::Lined => "Liniert (7.5mm)", + PageBackground::DotGrid => "Punktraster (5mm)", + PageBackground::Blank => "Blanko", + }; + + egui::ComboBox::from_id_source("bg_dropdown_picker") + .selected_text(current_bg_text) + .show_ui(ui, |ui| { + if ui + .selectable_label(current_bg == PageBackground::Grid5mm, "Kariert (5mm)") + .clicked() + { + actions.push(UiAction::SetBackground(PageBackground::Grid5mm)); + } + if ui + .selectable_label(current_bg == PageBackground::Lined, "Liniert (7.5mm)") + .clicked() + { + actions.push(UiAction::SetBackground(PageBackground::Lined)); + } + if ui + .selectable_label(current_bg == PageBackground::DotGrid, "Punktraster (5mm)") + .clicked() + { + actions.push(UiAction::SetBackground(PageBackground::DotGrid)); + } + if ui + .selectable_label(current_bg == PageBackground::Blank, "Blanko") + .clicked() + { + actions.push(UiAction::SetBackground(PageBackground::Blank)); + } + }); + + toolbar_divider(ui); + + // --- 7. PDF-EXPORT-BUTTON --- + if action_pill_button( + ui, + true, + "📄 PDF", + "Dokument als Vektor-PDF exportieren (Strg + E / S)", + ) + .clicked() + { + actions.push(UiAction::ExportPdf); + } + + toolbar_divider(ui); + + // --- 8. ZOOM-STEUERUNG & ANZEIGE --- + if action_pill_button(ui, state.current_zoom > 0.25, "🔍-", "Herauszoomen (Mausrad)").clicked() { + actions.push(UiAction::ZoomOut); + } + + let zoom_pct = (state.current_zoom * 100.0).round() as u32; + let zoom_badge = format!("{}%", zoom_pct); + if action_pill_button(ui, true, &zoom_badge, "Zoom auf 100% zentriert zurücksetzen (Strg + 0)").clicked() { + actions.push(UiAction::ResetZoom); + } + + if action_pill_button(ui, state.current_zoom < 5.0, "🔍+", "Heranzoomen (Mausrad)").clicked() { + actions.push(UiAction::ZoomIn); + } + + toolbar_divider(ui); + + // --- 9. AUTOSAVE-STATUSANZEIGE --- + let (dot_color, label_text) = match autosave_status { + AutosaveStatus::Saved => (egui::Color32::from_rgb(46, 204, 113), "Gespeichert"), + AutosaveStatus::Saving => (egui::Color32::from_rgb(241, 196, 15), "Speichert..."), + AutosaveStatus::Pending => (egui::Color32::from_rgb(230, 126, 34), "Ungespeichert"), + AutosaveStatus::Error => (egui::Color32::from_rgb(231, 76, 60), "Fehler"), + }; + + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 0.0); + ui.colored_label(dot_color, "●"); + ui.label( + egui::RichText::new(label_text) + .color(dot_color) + .size(11.5) + .strong(), + ); + }); + }); + }); + + frame_response.response.rect + }); + + // Toolbar-Rechteck für pixelgenaue Event-Isolation speichern + state.toolbar_rect = Some(area_response.inner); + + // 2. SCHWEBENDES POPOVER-MENÜ (FÜR STIFT ODER TEXTMARKER) + let mut popover_rect = None; + + if state.is_pen_menu_open { + if let Some(btn_rect) = pen_btn_rect { + let popover_pos = egui::pos2(btn_rect.min.x - 12.0, btn_rect.max.y + 8.0); + let pop_response = egui::Area::new(egui::Id::new("pen_settings_popover")) + .fixed_pos(popover_pos) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let frame = egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235)) // Milchglas-Look + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + )) + .inner_margin(egui::Margin::symmetric(14.0, 12.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 6.0), + blur: 16.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(120), + }) + .show(ui, |ui| { + ui.set_width(295.0); + + // Titelzeile mit Schließen-Button + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Stift-Konfiguration") + .strong() + .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.add_space(8.0); + + // Zeile 1: Farbauswahl (Color Swatches) + ui.label( + egui::RichText::new("FARBE") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 0.0); + for (color, label) in PEN_COLOR_PRESETS { + 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() { + state.pen_color = color; + actions.push(UiAction::SetColor(color)); + } + } + + // RGBA-Colorpicker + let mut egui_color = egui::Rgba::from_rgba_premultiplied( + state.pen_color[0], + state.pen_color[1], + state.pen_color[2], + state.pen_color[3], + ); + if egui::color_picker::color_edit_button_rgba( + ui, + &mut egui_color, + egui::color_picker::Alpha::Opaque, + ) + .changed() + { + state.pen_color = egui_color.to_array(); + actions.push(UiAction::SetColor(state.pen_color)); + } + }); + + ui.add_space(10.0); + + // Zeile 2: Dicken-Presets mit visueller Linien-Vorschau + ui.label( + egui::RichText::new("DICKEN-PRESETS") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(3.0); + ui.horizontal(|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() + { + state.pen_stroke_width_mm = preset; + actions.push(UiAction::SetStrokeWidthMm(preset)); + } + } + }); + + ui.add_space(10.0); + + // Zeile 3: Feinjustierung (Slider) + ui.label( + egui::RichText::new("STUFENLOSE STÄRKE") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(2.0); + if ui + .add( + egui::Slider::new(&mut state.pen_stroke_width_mm, 0.15..=5.0) + .suffix(" mm") + .step_by(0.05), + ) + .changed() + { + actions.push(UiAction::SetStrokeWidthMm(state.pen_stroke_width_mm)); + } + }); + + frame.response.rect + }); + + popover_rect = Some(pop_response.inner); + } + } else if state.is_highlighter_menu_open { + if let Some(btn_rect) = high_btn_rect { + let popover_pos = egui::pos2(btn_rect.min.x - 12.0, btn_rect.max.y + 8.0); + let pop_response = egui::Area::new(egui::Id::new("highlighter_settings_popover")) + .fixed_pos(popover_pos) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let frame = egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235)) + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + )) + .inner_margin(egui::Margin::symmetric(14.0, 12.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 6.0), + blur: 16.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(120), + }) + .show(ui, |ui| { + ui.set_width(280.0); + + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Textmarker-Konfiguration") + .strong() + .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.add_space(8.0); + + // Zeile 1: Farbauswahl + ui.label( + egui::RichText::new("MARKER-FARBE") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(3.0); + 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 + && (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() { + state.highlighter_color = color; + actions.push(UiAction::SetColor(color)); + } + } + + let mut egui_color = egui::Rgba::from_rgba_premultiplied( + state.highlighter_color[0], + state.highlighter_color[1], + state.highlighter_color[2], + state.highlighter_color[3], + ); + if egui::color_picker::color_edit_button_rgba( + ui, + &mut egui_color, + egui::color_picker::Alpha::OnlyBlend, + ) + .changed() + { + state.highlighter_color = egui_color.to_array(); + actions.push(UiAction::SetColor(state.highlighter_color)); + } + }); + + ui.add_space(10.0); + + // Zeile 2: Dicken-Presets + ui.label( + egui::RichText::new("DICKEN-PRESETS") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(3.0); + 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; + if stroke_preview_button( + ui, + is_sel, + preset, + label, + state.highlighter_color, + ) + .clicked() + { + state.highlighter_stroke_width_mm = preset; + actions.push(UiAction::SetStrokeWidthMm(preset)); + } + } + }); + + ui.add_space(10.0); + + // Zeile 3: Slider + ui.label( + egui::RichText::new("STUFENLOSE STÄRKE") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + 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), + ) + .changed() + { + actions.push(UiAction::SetStrokeWidthMm(state.highlighter_stroke_width_mm)); + } + }); + + frame.response.rect + }); + + popover_rect = Some(pop_response.inner); + } + } + + state.popover_rect = popover_rect; + + // Automatisches Schließen des Popovers bei Klick außerhalb von Toolbar und Popover + if (state.is_pen_menu_open || state.is_highlighter_menu_open) + && ctx.input(|i| i.pointer.any_pressed()) + { + if let Some(pos) = ctx.input(|i| i.pointer.press_origin()) { + let in_toolbar = state.toolbar_rect.is_some_and(|r| r.contains(pos)); + let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos)); + if !in_toolbar && !in_popover { + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + } + } + } + + // 3. Schnellauswahl-Fenster für Seiten (im gleichen Milchglas-Look) + if state.show_page_jump_popup { + egui::Window::new("Seiten-Schnellauswahl") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 235)) + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + )) + .inner_margin(egui::Margin::symmetric(14.0, 12.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 6.0), + blur: 16.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(120), + }), + ) + .show(ctx, |ui| { + ui.label( + egui::RichText::new(format!("Notizbuch: {} Seite(n)", notebook.pages.len())) + .strong() + .color(egui::Color32::WHITE), + ); + 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; + } + } + }); + + ui.separator(); + if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() { + state.show_page_jump_popup = false; + } + }); + } + + // 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen) + if state.is_document_menu_open { + egui::Window::new("Dokumenten-Manager") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 242)) + .rounding(egui::Rounding::same(14.0)) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + )) + .inner_margin(egui::Margin::symmetric(18.0, 16.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 8.0), + blur: 20.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(140), + }), + ) + .show(ctx, |ui| { + ui.set_width(380.0); + + // Kopfzeile + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("📚 Notizbücher verwalten") + .strong() + .size(15.0) + .color(egui::Color32::WHITE), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new(egui::RichText::new("✕").size(12.0)).frame(false)) + .clicked() + { + state.is_document_menu_open = false; + } + }); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Neues Dokument anlegen + ui.label( + egui::RichText::new("NEUES NOTIZBUCH ERSTELLEN") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(4.0); + ui.horizontal(|ui| { + let text_edit = ui.add( + egui::TextEdit::singleline(&mut state.new_notebook_title_input) + .hint_text("Titel des Notizbuchs...") + .desired_width(260.0), + ); + let create_btn = ui.button(egui::RichText::new("➕ Erstellen").strong()); + let submit = create_btn.clicked() + || (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + + if submit { + let title = state.new_notebook_title_input.trim(); + if !title.is_empty() { + actions.push(UiAction::CreateNotebook(title.to_string())); + state.new_notebook_title_input.clear(); + } + } + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + // Liste vorhandener Dokumente + ui.label( + egui::RichText::new("VORHANDENE DOKUMENTE") + .size(10.5) + .strong() + .color(egui::Color32::from_gray(160)), + ); + ui.add_space(4.0); + + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for summary in &state.available_notebooks { + let is_current = summary.id == state.current_notebook_id; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0); + + if state.renaming_notebook_id.as_deref() == Some(&summary.id) { + let edit = ui.add( + egui::TextEdit::singleline(&mut state.renaming_title_input) + .desired_width(230.0), + ); + if ui.button("✓").clicked() + || (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + let new_t = state.renaming_title_input.trim(); + if !new_t.is_empty() { + actions.push(UiAction::RenameNotebook { + id: summary.id.clone(), + new_title: new_t.to_string(), + }); + } + state.renaming_notebook_id = None; + } + if ui.button("✕").clicked() { + state.renaming_notebook_id = None; + } + } else { + let (bg, border) = if is_current { + ( + egui::Color32::from_rgba_premultiplied(45, 80, 145, 200), + egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(110, 165, 255, 180)), + ) + } else { + ( + egui::Color32::from_rgba_premultiplied(255, 255, 255, 8), + egui::Stroke::new(1.0_f32, egui::Color32::from_rgba_premultiplied(255, 255, 255, 16)), + ) + }; + + let frame_resp = egui::Frame::none() + .fill(bg) + .stroke(border) + .rounding(egui::Rounding::same(8.0)) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&summary.title) + .strong() + .size(13.0) + .color(egui::Color32::WHITE), + ); + if is_current { + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "● Aktiv"); + } + }); + let subtext = format!("{} Seite(n)", summary.page_count); + ui.label( + egui::RichText::new(subtext) + .size(11.0) + .color(egui::Color32::from_gray(160)), + ); + }); + + 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))) + .on_hover_text("Notizbuch unwiderruflich löschen") + .clicked() + { + actions.push(UiAction::DeleteNotebook(summary.id.clone())); + } + } + + if ui.button(egui::RichText::new("✏").size(11.0)) + .on_hover_text("Titel umbenennen") + .clicked() + { + state.renaming_notebook_id = Some(summary.id.clone()); + state.renaming_title_input = summary.title.clone(); + } + }); + }); + }); + + if frame_resp.response.interact(egui::Sense::click()).clicked() && !is_current { + actions.push(UiAction::OpenNotebook(summary.id.clone())); + state.is_document_menu_open = false; + } + } + }); + ui.add_space(4.0); + } + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if action_pill_button(ui, true, "Schließen", "Dokumenten-Manager schließen").clicked() { + state.is_document_menu_open = false; + } + }); + }); + } + + // 4. Feedback-Toast für PDF-Export oder wichtige Systemmeldungen + if let Some((msg, timestamp)) = &state.export_message { + let elapsed = timestamp.elapsed().as_secs_f32(); + if elapsed < 4.0 { + let alpha = if elapsed > 3.0 { + ((4.0 - elapsed) * 255.0) as u8 + } else { + 255 + }; + + egui::Area::new(egui::Id::new("export_toast_banner")) + .anchor(egui::Align2::CENTER_BOTTOM, egui::vec2(0.0, -35.0)) + .order(egui::Order::Tooltip) + .show(ctx, |ui| { + egui::Frame::none() + .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, + egui::Color32::from_rgba_unmultiplied(46, 204, 113, alpha), + )) + .inner_margin(egui::Margin::symmetric(20.0, 10.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 4.0), + blur: 16.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(alpha.min(130)), + }) + .show(ui, |ui| { + ui.label( + egui::RichText::new(format!("✓ {}", msg)) + .color(egui::Color32::from_rgba_unmultiplied(220, 255, 230, alpha)) + .size(13.0) + .strong(), + ); + }); + }); + } else { + state.export_message = None; + } + } + + actions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ui_state_defaults() { + let state = UiState::default(); + assert_eq!(state.active_tool, ActiveTool::Pen); + assert_eq!(state.pen_stroke_width_mm, 0.5); + assert_eq!(state.highlighter_stroke_width_mm, 6.0); + assert_eq!(state.current_stroke_width_mm(), 0.5); + assert!(!state.show_page_jump_popup); + assert!(!state.is_pen_menu_open); + assert!(!state.is_highlighter_menu_open); + assert!(!state.is_document_menu_open); + assert_eq!(state.current_notebook_id, "default"); + assert_eq!(state.document_title, "Standard-Notizbuch"); + assert!(state.export_message.is_none()); + assert!(state.toolbar_rect.is_none()); + assert!(state.popover_rect.is_none()); + } + + #[test] + fn test_active_tool_properties() { + assert!(ActiveTool::Pen.is_drawing()); + assert!(ActiveTool::Highlighter.is_drawing()); + assert!(!ActiveTool::Eraser.is_drawing()); + assert!(!ActiveTool::Select.is_drawing()); + assert_eq!(ActiveTool::Pen.name(), "Stift (Pen)"); + assert_eq!(ActiveTool::Eraser.name(), "Radierer (Eraser)"); + assert_eq!(ActiveTool::Select.name(), "Auswahl / Lasso (Select)"); + } + + #[test] + fn test_render_ui_generates_shapes_and_rect() { + let ctx = egui::Context::default(); + let mut state = UiState::new(); + let notebook = Notebook::new(); + + ctx.begin_frame(egui::RawInput::default()); + let actions = render_ui(&ctx, &mut state, ¬ebook, AutosaveStatus::Saved); + let output = ctx.end_frame(); + + assert!(actions.is_empty()); + assert!(!output.shapes.is_empty()); + assert!(state.toolbar_rect.is_some()); + } + + #[test] + fn test_pill_hit_testing() { + let mut state = UiState::new(); + state.toolbar_rect = Some(egui::Rect::from_min_max( + egui::pos2(200.0, 16.0), + egui::pos2(800.0, 60.0), + )); + + // 1. Punkt im Zentrum der Pill -> getroffen + assert!(state.is_point_inside_toolbar(500.0, 38.0)); + + // 2. Punkt oberhalb der Pill (in der 16 px Lücke zum Fensterrand) -> frei + assert!(!state.is_point_inside_toolbar(500.0, 8.0)); + + // 3. Punkt weit außerhalb links/rechts -> frei + assert!(!state.is_point_inside_toolbar(50.0, 38.0)); + assert!(!state.is_point_inside_toolbar(950.0, 38.0)); + + // 4. Punkt unterhalb der Pill -> frei + assert!(!state.is_point_inside_toolbar(500.0, 100.0)); + + // 5. Eckpunkt ganz außen in der Bounding Box (außerhalb des 14 px Kreises) -> frei! + assert!(!state.is_point_inside_toolbar(201.0, 17.0)); + } + + #[test] + fn test_popover_hit_testing() { + let mut state = UiState::new(); + state.toolbar_rect = Some(egui::Rect::from_min_max( + egui::pos2(200.0, 16.0), + egui::pos2(800.0, 60.0), + )); + state.popover_rect = Some(egui::Rect::from_min_max( + egui::pos2(200.0, 68.0), + egui::pos2(480.0, 240.0), + )); + + // Klick in Toolbar -> UI + assert!(state.is_point_in_ui(500.0, 38.0)); + // Klick in Popover -> UI + assert!(state.is_point_in_ui(300.0, 120.0)); + // Klick außerhalb -> frei für Canvas + assert!(!state.is_point_in_ui(600.0, 150.0)); + } + + #[test] + fn test_pen_color_presets() { + assert_eq!(PEN_COLOR_PRESETS.len(), 5); + assert_eq!(PEN_COLOR_PRESETS[0].1, "Notiz-Schwarz"); + assert_eq!(PEN_COLOR_PRESETS[1].1, "Universitäts-Blau"); + assert_eq!(PEN_COLOR_PRESETS[2].1, "Korrektur-Rot"); + assert_eq!(PEN_COLOR_PRESETS[3].1, "Tafel-Grün"); + assert_eq!(PEN_COLOR_PRESETS[4].1, "Graphit-Grau"); + } + + #[test] + fn test_pen_width_presets() { + assert_eq!(PEN_WIDTH_PRESETS.len(), 4); + assert_eq!(PEN_WIDTH_PRESETS[0].0, 0.3); + assert_eq!(PEN_WIDTH_PRESETS[1].0, 0.5); + assert_eq!(PEN_WIDTH_PRESETS[2].0, 0.8); + assert_eq!(PEN_WIDTH_PRESETS[3].0, 1.2); + } + + #[test] + fn test_document_manager_ui_actions() { + let mut state = UiState::new(); + state.is_document_menu_open = true; + state.available_notebooks = vec![ + NotebookSummary { + id: "nb-1".to_string(), + title: "Mathematik I".to_string(), + page_count: 3, + modified_at: 100, + }, + NotebookSummary { + id: "nb-2".to_string(), + title: "Physik II".to_string(), + page_count: 5, + modified_at: 200, + }, + ]; + state.current_notebook_id = "nb-1".to_string(); + state.document_title = "Mathematik I".to_string(); + + let notebook = Notebook::new(); + let ctx = egui::Context::default(); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + let _ = super::render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved); + }); + + assert!(state.is_document_menu_open); + assert_eq!(state.available_notebooks.len(), 2); + } +} diff --git a/src/viewport.rs b/src/viewport.rs new file mode 100644 index 0000000..7a4eefb --- /dev/null +++ b/src/viewport.rs @@ -0,0 +1,247 @@ +//! Viewport- und Kameraverwaltung für stufenloses Zoomen und Pannen. +//! +//! 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}; + +/// Minimaler und maximaler Zoomfaktor. +pub const MIN_ZOOM: f32 = 0.25; +pub const MAX_ZOOM: f32 = 5.0; + +/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ViewportState { + /// Zoomfaktor (1.0 = 100%, 0.25 bis 5.0). + pub zoom: f32, + /// Verschiebung (Pan) in virtuellen DIN-A4-Seitenkoordinaten. + pub pan_offset: [f32; 2], +} + +impl Default for ViewportState { + fn default() -> Self { + Self { + zoom: 1.0, + pan_offset: [0.0, 0.0], + } + } +} + +impl ViewportState { + pub fn new() -> Self { + Self::default() + } + + /// Setzt Zoom und Pan auf die Ausgangsposition (100% zentriert) zurück. + pub fn reset(&mut self) { + self.zoom = 1.0; + self.pan_offset = [0.0, 0.0]; + } + + /// 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) { + 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); + self.pan_offset[1] += delta_y / (base_scale * self.zoom); + } + } + + /// Zoomt zentriert auf eine Cursor-Position in Bildschirmkoordinaten. + /// + /// Formel: offset_neu = cursor - (cursor - offset_alt) * (zoom_neu / zoom_alt) + pub fn zoom_at_cursor( + &mut self, + cursor_screen: [f32; 2], + zoom_factor: f32, + page_viewport: &PageViewport, + ) { + let old_zoom = self.zoom; + let new_zoom = (self.zoom * zoom_factor).clamp(MIN_ZOOM, MAX_ZOOM); + if (new_zoom - old_zoom).abs() < 1e-6 { + return; + } + + let base_scale = page_viewport.page_width / PAGE_WIDTH; + + // Bildschirmposition des Seitenursprungs (0, 0) + let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom; + let old_offset_y = page_viewport.page_y + self.pan_offset[1] * base_scale * old_zoom; + + let ratio = new_zoom / old_zoom; + let new_offset_x = cursor_screen[0] - (cursor_screen[0] - old_offset_x) * ratio; + let new_offset_y = cursor_screen[1] - (cursor_screen[1] - old_offset_y) * ratio; + + self.zoom = new_zoom; + self.pan_offset[0] = (new_offset_x - page_viewport.page_x) / (base_scale * new_zoom); + self.pan_offset[1] = (new_offset_y - page_viewport.page_y) / (base_scale * new_zoom); + } + + /// Transformiert einen Fenster-Pixelpunkt invers in DIN-A4-Seitenkoordinaten (ungeklemmt). + pub fn screen_to_page_unclamped( + &self, + screen_x: f32, + screen_y: f32, + page_viewport: &PageViewport, + ) -> Vec2 { + let base_scale = page_viewport.page_width / PAGE_WIDTH; + let effective_scale = base_scale * self.zoom; + if effective_scale < 1e-6 { + return Vec2::ZERO; + } + + let page_x = (screen_x - page_viewport.page_x) / effective_scale - self.pan_offset[0]; + let page_y = (screen_y - page_viewport.page_y) / effective_scale - self.pan_offset[1]; + + Vec2::new(page_x, page_y) + } + + /// Transformiert einen Fenster-Pixelpunkt invers in DIN-A4-Seitenkoordinaten. + /// + /// Liefert `None`, falls der Punkt außerhalb der virtuellen Seite liegt. + pub fn screen_to_page( + &self, + screen_x: f32, + screen_y: f32, + page_viewport: &PageViewport, + ) -> Option { + let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport); + if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) { + Some(pt) + } else { + None + } + } + + /// Transformiert einen Fenster-Pixelpunkt invers in DIN-A4-Seitenkoordinaten und klemmt auf die Seite. + pub fn screen_to_page_clamped( + &self, + screen_x: f32, + screen_y: f32, + 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), + ) + } + + /// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten. + pub fn page_to_screen_vec2(&self, page_pt: Vec2, page_viewport: &PageViewport) -> [f32; 2] { + self.page_to_screen(page_pt.x, page_pt.y, page_viewport) + } + + /// Transformiert einen DIN-A4-Seitenkoordinatenpunkt in Fenster-Bildschirmkoordinaten. + pub fn page_to_screen( + &self, + page_x: f32, + page_y: f32, + page_viewport: &PageViewport, + ) -> [f32; 2] { + let base_scale = page_viewport.page_width / PAGE_WIDTH; + let effective_scale = base_scale * self.zoom; + let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale; + let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale; + [screen_x, screen_y] + } + + /// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader, + /// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden. + pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) { + let base_scale = page_viewport.page_width / PAGE_WIDTH; + let effective_scale = base_scale * self.zoom; + + // ndc_x = (screen_x / window_width) * 2.0 - 1.0 + // screen_x = page_viewport.page_x + (page_x + pan_offset[0]) * effective_scale + // = page_x * effective_scale + (page_viewport.page_x + pan_offset[0] * effective_scale) + let scale_x = (2.0 * effective_scale) / page_viewport.window_width; + let offset_x = (2.0 * (page_viewport.page_x + self.pan_offset[0] * effective_scale)) + / page_viewport.window_width + - 1.0; + + // ndc_y = 1.0 - (screen_y / window_height) * 2.0 + // screen_y = page_viewport.page_y + (page_y + pan_offset[1]) * effective_scale + // = page_y * effective_scale + (page_viewport.page_y + pan_offset[1] * effective_scale) + let scale_y = (-2.0 * effective_scale) / page_viewport.window_height; + let offset_y = 1.0 + - (2.0 * (page_viewport.page_y + self.pan_offset[1] * effective_scale)) + / page_viewport.window_height; + + ([scale_x, scale_y], [offset_x, offset_y]) + } + + /// Berechnet die 2D-Projektionsmatrix (Ortho-Projektion kombiniert mit Pan und Zoom). + pub fn projection_matrix(&self, page_viewport: &PageViewport) -> Mat4 { + let (scale, offset) = self.ndc_transform(page_viewport); + Mat4::from_cols( + Vec4::new(scale[0], 0.0, 0.0, 0.0), + Vec4::new(0.0, scale[1], 0.0, 0.0), + Vec4::new(0.0, 0.0, 1.0, 0.0), + Vec4::new(offset[0], offset[1], 0.0, 1.0), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_viewport_zoom_limits() { + let mut vp = ViewportState::new(); + let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); + + vp.zoom_at_cursor([960.0, 540.0], 10.0, &page_vp); + assert!((vp.zoom - MAX_ZOOM).abs() < 1e-4); + + vp.zoom_at_cursor([960.0, 540.0], 0.01, &page_vp); + assert!((vp.zoom - MIN_ZOOM).abs() < 1e-4); + } + + #[test] + fn test_viewport_zoom_centered_at_cursor() { + let mut vp = ViewportState::new(); + let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); + let cursor = [960.0, 540.0]; // Mitten im Fenster und auf der Seite + + // Punkt vor Zoom berechnen + let page_pt_before = vp.screen_to_page_unclamped(cursor[0], cursor[1], &page_vp); + + // Zoom verdoppeln + vp.zoom_at_cursor(cursor, 2.0, &page_vp); + + // Nach Zoom muss der Punkt unter dem Cursor unverändert sein + let screen_pt_after = vp.page_to_screen(page_pt_before.x, page_pt_before.y, &page_vp); + assert!((screen_pt_after[0] - cursor[0]).abs() < 0.1); + assert!((screen_pt_after[1] - cursor[1]).abs() < 0.1); + } + + #[test] + fn test_viewport_pan() { + let mut vp = ViewportState::new(); + let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); + + let pt0 = vp.screen_to_page_unclamped(960.0, 540.0, &page_vp); + vp.pan_by_screen_delta(50.0, 30.0, &page_vp); + let pt1 = vp.page_to_screen(pt0.x, pt0.y, &page_vp); + + assert!((pt1[0] - 1010.0).abs() < 0.1); + assert!((pt1[1] - 570.0).abs() < 0.1); + } + + #[test] + fn test_ndc_transform_consistency_at_default() { + let vp = ViewportState::new(); + let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); + + let (scale1, offset1) = vp.ndc_transform(&page_vp); + let (scale2, offset2) = page_vp.ndc_transform(); + + assert!((scale1[0] - scale2[0]).abs() < 1e-5); + assert!((scale1[1] - scale2[1]).abs() < 1e-5); + assert!((offset1[0] - offset2[0]).abs() < 1e-5); + assert!((offset1[1] - offset2[1]).abs() < 1e-5); + } +}