commit c78520ee65e1fa325ccdb2d58b95ee9f0ea8e863 Author: Jonas Rabenstein Date: Wed Aug 12 01:01:53 2026 +0200 start with chatgpt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2571667 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,949 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.9", + "stable_deref_trait", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "borderpoi-rs" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "geo", + "geo-types", + "geojson", + "gpx", + "rstar 0.13.0", + "serde", + "serde_json", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "earcut" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88459a2a8e3a514b6e6de38cf3aaa9250a894cb098f74a932db77fcc8341b6d0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "float_next_after" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37007738a80ea34f969af54a3390dd72cacdef654974cfd449c9f6f72dbaac10" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30eb1fdc57c1e5cfd11826fe0caec4b9dc7901f3758263bb506228d88c8d9e9a" +dependencies = [ + "earcut", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "rand", + "rand_pcg", + "robust", + "rstar 0.12.2", + "sif-itree", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" +dependencies = [ + "approx", + "num-traits", + "rayon", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.13.0", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", + "spade", + "thiserror 2.0.20", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "geojson" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510c094bfc76ea34d02eee00833254945b70491d79a9c0b050abed6eaa799ffb" +dependencies = [ + "geo-types", + "log", + "serde", + "serde_json", + "thiserror 2.0.20", + "tinyvec", +] + +[[package]] +name = "gpx" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfabaf0e8a17a6fb7977fac3bd5846488462edb9f8b246605835483a5501e698" +dependencies = [ + "geo-types", + "thiserror 1.0.69", + "time", + "xml-rs", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.9", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "i_float" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "813145bb0ad5b60f55cbbf3c74cdceda1c0a9d253b35c4cc36ae0df7887cb78f" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d122b937fca067feb0ad74f62388920272b27c356d4df2d0cfdd59e044cf0" +dependencies = [ + "rayon", +] + +[[package]] +name = "i_overlay" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd314b4668e2b3a12508f2e125558c82a6c0a8636fa5107a900f79ce414e450" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa9eac533d7509a8ab87672b60ac610c17240f9ea4851d26227689fdfe349c8" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4804bdc1dc124eb7e1aa9e144ecc04096bcf787a10a15fa44af682b51f0f6cce" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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", + "libm", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +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.3", +] + +[[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 = "sif-itree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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 = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "serde_core", + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[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..620f9d1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "borderpoi-rs" +version = "0.1.0" +edition = "2021" +description = "Find administrative border crossings along a GPX track" +license = "MIT" + +[[bin]] +name = "borderpoi-rs" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } + +geo = "0.33.1" +geo-types = "0.7.19" +geojson = { version = "1.0.0", features = ["geo-types"] } +gpx = "0.10.0" +rstar = "0.13.0" + +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3464e9b --- /dev/null +++ b/README.md @@ -0,0 +1,330 @@ +# borderpoi-rs + +`borderpoi-rs` finds administrative border crossings along a GPX track. + +It is designed for long-distance cycling, bikepacking and ultracycling. + +The program reads a GPX track and a GeoJSON file containing administrative +boundary polygons. It detects every county/district transition, preserves +repeated visits, and adds a GPX waypoint for every border crossing. + +## Features + +- GPX input +- GPX output +- stdin/stdout support +- GeoJSON boundary input +- R*-tree spatial index +- repeated county visits are preserved +- one GPX POI per border crossing +- no distance calculation +- no GDAL dependency +- no native GIS library dependency + +## Input boundary data + +The boundary GeoJSON must contain Polygon or MultiPolygon features. + +Coordinates must be WGS84 / EPSG:4326. + +For BKG VG250 data, the relevant layer is normally: + + VG250_KRS + +The default properties expected by `borderpoi-rs` are: + + GEN + AGS + +`GEN` is used as the displayed county name. + +`AGS` is used as the administrative identifier. + +## Usage + +### GPX file to GPX file + + borderpoi-rs \ + --track route.gpx \ + --boundaries VG250_KRS.geojson \ + --output route-with-borders.gpx + +### stdin to stdout + + cat route.gpx \ + | borderpoi-rs \ + --boundaries VG250_KRS.geojson \ + > route-with-borders.gpx + +### Explicit stdin/stdout + +`-` can be used explicitly: + + borderpoi-rs \ + --track - \ + --boundaries VG250_KRS.geojson \ + --output - + +### Unix pipeline + +The program writes diagnostic information to stderr and GPX data to stdout. + +Therefore this works: + + borderpoi-rs \ + --boundaries VG250_KRS.geojson \ + < route.gpx \ + > route-with-borders.gpx + +The county report can still be seen in the terminal. + +## Command line + + Usage: borderpoi-rs [OPTIONS] --boundaries + + Options: + -t, --track + Input GPX track. Reads from stdin when omitted or set to '-'. + + -b, --boundaries + Boundary GeoJSON. + + The GeoJSON must contain Polygon/MultiPolygon geometries + in WGS84 / EPSG:4326. + + Required. + + --name-field + Property containing the county name. + + [default: GEN] + + --id-field + Property containing the administrative identifier. + + [default: AGS] + + -o, --output + Output GPX. Writes to stdout when omitted or set to '-'. + + -h, --help + Print help. + + -V, --version + Print version. + +## County sequence + +The output preserves the order in which counties are visited. + +Repeated visits are intentionally preserved. + +For example: + + Roth + Neumarkt i.d.OPf. + Regensburg + Neumarkt i.d.OPf. + Roth + +is reported as exactly that. + +It is not reduced to: + + Roth + Neumarkt i.d.OPf. + Regensburg + +This is important for bikepacking and ultracycling routes that cross +administrative boundaries multiple times. + +## Border POIs + +For every transition, a waypoint is added to the output GPX. + +Example: + + Border 01: Landkreis Roth -> Landkreis Neumarkt i.d.OPf. + Border 02: Landkreis Neumarkt i.d.OPf. -> Landkreis Regensburg + Border 03: Landkreis Regensburg -> Landkreis Neumarkt i.d.OPf. + Border 04: Landkreis Neumarkt i.d.OPf. -> Landkreis Roth + +Each waypoint contains: + +- crossing number +- source county +- destination county +- source AGS +- destination AGS +- `administrative_boundary` as GPX waypoint type + +## Spatial index + +All county geometries are inserted into an R*-tree. + +For every GPX track segment the algorithm performs: + + GPX segment + | + v + segment bounding box + | + v + R*-tree lookup + | + v + candidate county polygons + | + v + exact point-in-polygon test + | + v + county transition + | + v + exact boundary intersection + | + v + GPX waypoint + +This avoids testing every GPX segment against every county polygon. + +This is particularly useful for long GPX tracks. + +## Coordinate reference system + +`borderpoi-rs` expects the boundary GeoJSON to use: + + EPSG:4326 / WGS84 + +GPX coordinates are also WGS84. + +No CRS transformation is performed inside the program. + +This is intentional: it removes the GDAL dependency and keeps the +application entirely Rust-native. + +## Why no GDAL? + +The previous implementation used GDAL to read the BKG GeoPackage. + +That caused the Rust build to depend on the system GDAL version. + +For example: + + gdal 0.19.0 + gdal-sys 0.12.0 + system GDAL 3.13.2 + +and required generated GDAL bindings. + +`borderpoi-rs` does not actually need GDAL for its runtime operation. + +The recommended workflow is therefore: + + BKG VG250 + | + | one-time conversion + v + WGS84 GeoJSON + | + v + borderpoi-rs + +The conversion from the original BKG dataset can be performed with +GDAL/QGIS once, but the resulting command line tool has no GDAL +dependency. + +## Building with Nix + +Enter the development environment: + + nix develop + +Then: + + cargo build --release + +Run: + + cargo run --release -- \ + --boundaries VG250_KRS.geojson \ + < route.gpx \ + > route-with-borders.gpx + +## Building entirely with Nix + +First generate the lock file: + + cargo generate-lockfile + +Then: + + nix build + +The resulting executable is: + + ./result/bin/borderpoi-rs + +Example: + + ./result/bin/borderpoi-rs \ + --boundaries VG250_KRS.geojson \ + --track route.gpx \ + --output route-with-borders.gpx + +## Formatting and linting + +Format: + + cargo fmt + +Check: + + cargo check + +Run Clippy: + + cargo clippy --all-targets --all-features -- -D warnings + +## Input assumptions + +The current implementation assumes: + +1. GPX coordinates are WGS84. +2. Boundary coordinates are WGS84. +3. Boundary features are Polygon or MultiPolygon. +4. The GPX track normally lies inside a county polygon. +5. Administrative boundary geometries are topologically valid. + +Tracks that run exactly along a county boundary are inherently ambiguous. + +Such a section is deliberately not interpreted as a sequence of +crossings. + +## Output contract + +stdout: + + output GPX only + +stderr: + + diagnostic messages + county sequence + border crossing report + +This makes the program suitable for Unix pipelines. + +Example: + + borderpoi-rs \ + --boundaries counties.geojson \ + < route.gpx \ + > result.gpx + +while the report remains visible on the terminal. + +## License + +MIT diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..dd1e383 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786384358, + "narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..edcefc1 --- /dev/null +++ b/flake.nix @@ -0,0 +1,52 @@ +{ + description = "borderpoi-rs - GPX administrative border crossing detector"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + rustc + cargo + rustfmt + clippy + ]; + + shellHook = '' + echo "borderpoi-rs development environment" + echo "Rust: $(rustc --version)" + ''; + }; + + packages.default = + pkgs.rustPlatform.buildRustPackage { + pname = "borderpoi-rs"; + version = "0.1.0"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + meta = { + description = + "Find administrative border crossings along a GPX track"; + + mainProgram = + "borderpoi-rs"; + }; + }; + } + ); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cf3f79d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,670 @@ +use anyhow::{bail, Context, Result}; +use clap::Parser; + +use geo::{algorithm::bounding_rect::BoundingRect, Contains, Coord, Geometry, LineString, Point}; + +use geojson::{Feature, GeoJson}; + +use gpx::{read, write, Gpx, Waypoint}; + +use rstar::{RTree, RTreeObject, AABB}; + +use serde_json::Value; + +use std::{ + cmp::Ordering, + collections::HashSet, + fs::File, + io::{self, BufReader, BufWriter}, + path::{Path, PathBuf}, +}; + +#[derive(Parser, Debug)] +#[command( + name = "borderpoi-rs", + version, + about = "Find administrative border crossings along a GPX track" +)] +struct Args { + /// Input GPX track. Reads from stdin when omitted or set to '-'. + #[arg(short, long)] + track: Option, + + /// Boundary GeoJSON in WGS84 / EPSG:4326. + #[arg(short, long)] + boundaries: PathBuf, + + /// GeoJSON property containing the county name. + #[arg(long, default_value = "GEN")] + name_field: String, + + /// GeoJSON property containing the administrative identifier. + #[arg(long, default_value = "AGS")] + id_field: String, + + /// Output GPX. Writes to stdout when omitted or set to '-'. + #[arg(short, long)] + output: Option, +} + +#[derive(Debug, Clone)] +struct County { + name: String, + ags: String, + geometry: Geometry, + envelope: AABB<[f64; 2]>, +} + +impl RTreeObject for County { + type Envelope = AABB<[f64; 2]>; + + fn envelope(&self) -> Self::Envelope { + self.envelope + } +} + +#[derive(Debug, Clone, Copy)] +struct TrackPoint { + lon: f64, + lat: f64, +} + +#[derive(Debug, Clone)] +struct Crossing { + point: Point, + from: County, + to: County, + segment_index: usize, + position: f64, +} + +#[derive(Debug, Clone)] +struct BoundaryHit { + point: Point, + position: f64, +} + +// ----------------------------------------------------------------------------- +// Main +// ----------------------------------------------------------------------------- + +fn main() -> Result<()> { + let args = Args::parse(); + + eprintln!("borderpoi-rs"); + eprintln!("============"); + eprintln!(); + + let gpx = read_gpx(args.track.as_deref())?; + + let track = extract_track_points(&gpx)?; + + eprintln!("Track points: {}", track.len()); + + let counties = load_counties(&args.boundaries, &args.name_field, &args.id_field)?; + + eprintln!("Loaded counties: {}", counties.len()); + + let tree = RTree::bulk_load(counties); + + eprintln!("Spatial index: R*-tree"); + + let crossings = find_crossings(&track, &tree)?; + + print_report(&crossings); + + let mut output_gpx = gpx; + + append_crossing_waypoints(&mut output_gpx, &crossings); + + write_gpx(&output_gpx, args.output.as_deref())?; + + Ok(()) +} + +// ----------------------------------------------------------------------------- +// GPX I/O +// ----------------------------------------------------------------------------- + +fn read_gpx(path: Option<&Path>) -> Result { + match path { + Some(path) if path.as_os_str() != "-" => { + let file = File::open(path) + .with_context(|| format!("Failed to open GPX: {}", path.display()))?; + + read(BufReader::new(file)).context("Failed to parse GPX") + } + + _ => { + let stdin = io::stdin(); + read(stdin.lock()).context("Failed to parse GPX from stdin") + } + } +} + +fn write_gpx(gpx: &Gpx, path: Option<&Path>) -> Result<()> { + match path { + Some(path) if path.as_os_str() != "-" => { + let file = File::create(path) + .with_context(|| format!("Failed to create output GPX: {}", path.display()))?; + + write(gpx, BufWriter::new(file)).context("Failed to write GPX")?; + } + + _ => { + let stdout = io::stdout(); + + write(gpx, BufWriter::new(stdout.lock())).context("Failed to write GPX to stdout")?; + } + } + + Ok(()) +} + +fn extract_track_points(gpx: &Gpx) -> Result> { + let mut points = Vec::new(); + + for track in &gpx.tracks { + for segment in &track.segments { + for waypoint in &segment.points { + let point = waypoint.point(); + + points.push(TrackPoint { + lon: point.x(), + lat: point.y(), + }); + } + } + } + + if points.len() < 2 { + bail!("The GPX contains fewer than two track points."); + } + + Ok(points) +} + +// ----------------------------------------------------------------------------- +// GeoJSON +// ----------------------------------------------------------------------------- + +fn load_counties(path: &Path, name_field: &str, id_field: &str) -> Result> { + let file = + File::open(path).with_context(|| format!("Failed to open GeoJSON: {}", path.display()))?; + + let geojson: GeoJson = serde_json::from_reader(BufReader::new(file)) + .context("Failed to parse boundary GeoJSON")?; + + let features = match geojson { + GeoJson::FeatureCollection(collection) => collection.features, + + GeoJson::Feature(feature) => { + vec![feature] + } + + GeoJson::Geometry(_) => { + bail!("Boundary GeoJSON must be a FeatureCollection or Feature."); + } + }; + + let mut counties = Vec::with_capacity(features.len()); + + for feature in features { + counties.push(feature_to_county(&feature, name_field, id_field)?); + } + + if counties.is_empty() { + bail!("No boundary features were found."); + } + + Ok(counties) +} + +fn feature_to_county(feature: &Feature, name_field: &str, id_field: &str) -> Result { + let properties = feature + .properties + .as_ref() + .context("Boundary feature has no properties.")?; + + let name = property_as_string( + properties + .get(name_field) + .with_context(|| format!("Missing name field '{}'.", name_field))?, + )?; + + let ags = property_as_string( + properties + .get(id_field) + .with_context(|| format!("Missing ID field '{}'.", id_field))?, + )?; + + let geojson_geometry = feature + .geometry + .as_ref() + .context("Boundary feature has no geometry.")?; + + let geometry: Geometry = geojson_geometry + .try_into() + .context("Failed to convert GeoJSON geometry.")?; + + match geometry { + Geometry::Polygon(_) | Geometry::MultiPolygon(_) => {} + + _ => { + bail!("Feature '{}' is not a Polygon or MultiPolygon.", name); + } + } + + let bbox = geometry + .bounding_rect() + .with_context(|| format!("Feature '{}' has no bounding box.", name))?; + + let envelope = AABB::from_corners([bbox.min().x, bbox.min().y], [bbox.max().x, bbox.max().y]); + + Ok(County { + name, + ags, + geometry, + envelope, + }) +} + +fn property_as_string(value: &Value) -> Result { + match value { + Value::String(value) => Ok(value.clone()), + + Value::Number(value) => Ok(value.to_string()), + + _ => { + bail!("Expected string or number property, got {}.", value); + } + } +} + +// ----------------------------------------------------------------------------- +// Track processing +// ----------------------------------------------------------------------------- + +/// Find all county transitions along the complete track. +/// +/// Every track segment is processed independently. A segment may cross +/// multiple administrative boundaries. All intersections are therefore +/// collected, sorted along the segment, and converted into transitions. +fn find_crossings(track: &[TrackPoint], tree: &RTree) -> Result> { + let mut crossings = Vec::new(); + + for (segment_index, pair) in track.windows(2).enumerate() { + let start = Point::new(pair[0].lon, pair[0].lat); + + let end = Point::new(pair[1].lon, pair[1].lat); + + let segment = LineString::from(vec![ + Coord { + x: start.x(), + y: start.y(), + }, + Coord { + x: end.x(), + y: end.y(), + }, + ]); + + let bbox = segment + .bounding_rect() + .context("Track segment has no bounding box.")?; + + let envelope = + AABB::from_corners([bbox.min().x, bbox.min().y], [bbox.max().x, bbox.max().y]); + + let candidates: Vec = tree + .locate_in_envelope_intersecting(envelope) + .cloned() + .collect(); + + if candidates.is_empty() { + continue; + } + + let hits = collect_segment_hits(start, end, &candidates); + + if hits.is_empty() { + continue; + } + + let transitions = reconstruct_transitions(start, end, &candidates, &hits); + + for transition in transitions { + crossings.push(Crossing { + point: transition.point, + from: transition.from, + to: transition.to, + segment_index, + position: transition.position, + }); + } + } + + Ok(deduplicate_crossings(crossings)) +} + +// ----------------------------------------------------------------------------- +// Boundary intersections +// ----------------------------------------------------------------------------- + +fn collect_segment_hits( + start: Point, + end: Point, + candidates: &[County], +) -> Vec { + let start_coord = Coord { + x: start.x(), + y: start.y(), + }; + + let end_coord = Coord { + x: end.x(), + y: end.y(), + }; + + let mut hits = Vec::new(); + + for county in candidates { + collect_geometry_intersections(&county.geometry, start_coord, end_coord, &mut hits); + } + + hits.sort_by(|a, b| { + a.position + .partial_cmp(&b.position) + .unwrap_or(Ordering::Equal) + }); + + deduplicate_hits(hits) +} + +fn collect_geometry_intersections( + geometry: &Geometry, + start: Coord, + end: Coord, + hits: &mut Vec, +) { + match geometry { + Geometry::Polygon(polygon) => { + collect_ring_intersections(polygon.exterior(), start, end, hits); + + for interior in polygon.interiors() { + collect_ring_intersections(interior, start, end, hits); + } + } + + Geometry::MultiPolygon(multipolygon) => { + for polygon in &multipolygon.0 { + collect_ring_intersections(polygon.exterior(), start, end, hits); + + for interior in polygon.interiors() { + collect_ring_intersections(interior, start, end, hits); + } + } + } + + _ => {} + } +} + +fn collect_ring_intersections( + ring: &LineString, + start: Coord, + end: Coord, + hits: &mut Vec, +) { + for edge in ring.lines() { + if let Some((position, point)) = segment_intersection(start, end, edge.start, edge.end) { + hits.push(BoundaryHit { point, position }); + } + } +} + +fn segment_intersection( + p: Coord, + p2: Coord, + q: Coord, + q2: Coord, +) -> Option<(f64, Point)> { + let r = Coord { + x: p2.x - p.x, + y: p2.y - p.y, + }; + + let s = Coord { + x: q2.x - q.x, + y: q2.y - q.y, + }; + + let denominator = cross(r, s); + + if denominator.abs() < 1e-14 { + return None; + } + + let qp = Coord { + x: q.x - p.x, + y: q.y - p.y, + }; + + let t = cross(qp, s) / denominator; + let u = cross(qp, r) / denominator; + + const EPSILON: f64 = 1e-10; + + if !(-EPSILON..=1.0 + EPSILON).contains(&t) || !(-EPSILON..=1.0 + EPSILON).contains(&u) { + return None; + } + + let point = Point::new(p.x + t * r.x, p.y + t * r.y); + + Some((t.clamp(0.0, 1.0), point)) +} + +fn cross(a: Coord, b: Coord) -> f64 { + a.x * b.y - a.y * b.x +} + +fn deduplicate_hits(hits: Vec) -> Vec { + let mut result = Vec::new(); + + for hit in hits { + let duplicate = result + .iter() + .any(|existing: &BoundaryHit| (existing.position - hit.position).abs() < 1e-9); + + if !duplicate { + result.push(hit); + } + } + + result +} + +// ----------------------------------------------------------------------------- +// County reconstruction +// ----------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct Transition { + point: Point, + position: f64, + from: County, + to: County, +} + +/// Reconstruct the county sequence between all boundary intersections. +/// +/// The county immediately before an intersection is sampled slightly before +/// the intersection; the county immediately after is sampled slightly after +/// it. This avoids ambiguity when the intersection lies exactly on a shared +/// polygon boundary. +fn reconstruct_transitions( + start: Point, + end: Point, + candidates: &[County], + hits: &[BoundaryHit], +) -> Vec { + let mut transitions = Vec::new(); + + for hit in hits { + let before_t = (hit.position - 1e-8).max(0.0); + + let after_t = (hit.position + 1e-8).min(1.0); + + let before = interpolate(start, end, before_t); + + let after = interpolate(start, end, after_t); + + let from = county_at_point(before, candidates); + + let to = county_at_point(after, candidates); + + let (Some(from), Some(to)) = (from, to) else { + continue; + }; + + if from.ags == to.ags { + continue; + } + + transitions.push(Transition { + point: hit.point, + position: hit.position, + from, + to, + }); + } + + transitions +} + +fn interpolate(start: Point, end: Point, t: f64) -> Point { + Point::new( + start.x() + t * (end.x() - start.x()), + start.y() + t * (end.y() - start.y()), + ) +} + +fn county_at_point(point: Point, candidates: &[County]) -> Option { + candidates + .iter() + .find(|county| county.geometry.contains(&point)) + .cloned() +} + +// ----------------------------------------------------------------------------- +// Crossing cleanup +// ----------------------------------------------------------------------------- + +fn deduplicate_crossings(crossings: Vec) -> Vec { + let mut result = Vec::new(); + + for crossing in crossings { + let duplicate = result.iter().any(|existing: &Crossing| { + existing.segment_index == crossing.segment_index + && existing.from.ags == crossing.from.ags + && existing.to.ags == crossing.to.ags + && same_point(existing.point, crossing.point) + }); + + if !duplicate { + result.push(crossing); + } + } + + result.sort_by(|a, b| { + a.segment_index.cmp(&b.segment_index).then_with(|| { + a.position + .partial_cmp(&b.position) + .unwrap_or(Ordering::Equal) + }) + }); + + result +} + +fn same_point(a: Point, b: Point) -> bool { + let dx = a.x() - b.x(); + let dy = a.y() - b.y(); + + dx * dx + dy * dy < 1e-10 +} + +// ----------------------------------------------------------------------------- +// Report +// ----------------------------------------------------------------------------- + +fn print_report(crossings: &[Crossing]) { + eprintln!(); + eprintln!("County visits"); + eprintln!("============="); + + if crossings.is_empty() { + eprintln!("No county crossings found."); + return; + } + + let mut visits = Vec::new(); + + visits.push(crossings[0].from.clone()); + + for crossing in crossings { + visits.push(crossing.to.clone()); + } + + for (index, county) in visits.iter().enumerate() { + eprintln!("{:3} {} [{}]", index + 1, county.name, county.ags); + } + + let unique: HashSet = visits.iter().map(|county| county.ags.clone()).collect(); + + eprintln!(); + eprintln!("Visits: {}", visits.len()); + + eprintln!("Unique counties: {}", unique.len()); + + eprintln!(); + eprintln!("Border crossings"); + eprintln!("================="); + + for (index, crossing) in crossings.iter().enumerate() { + eprintln!( + "{:3} {} -> {}", + index + 1, + crossing.from.name, + crossing.to.name + ); + } +} + +// ----------------------------------------------------------------------------- +// GPX POIs +// ----------------------------------------------------------------------------- + +fn append_crossing_waypoints(gpx: &mut Gpx, crossings: &[Crossing]) { + for (index, crossing) in crossings.iter().enumerate() { + let mut waypoint = Waypoint::new(crossing.point); + + waypoint.name = Some(format!( + "Border {:02}: {} -> {}", + index + 1, + crossing.from.name, + crossing.to.name + )); + + waypoint.description = Some(format!( + "Administrative border crossing: {} [{}] -> {} [{}]", + crossing.from.name, crossing.from.ags, crossing.to.name, crossing.to.ags + )); + + waypoint.comment = Some(format!("{} -> {}", crossing.from.name, crossing.to.name)); + + waypoint.type_ = Some("administrative_boundary".to_string()); + + gpx.waypoints.push(waypoint); + } +}