Compare commits

...
14 changed files with 440 additions and 2399 deletions

1
.gitignore vendored
View file

@ -1 +0,0 @@
target/

949
Cargo.lock generated
View file

@ -1,949 +0,0 @@
# 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"

View file

@ -1,23 +0,0 @@
[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"

330
README.md
View file

@ -1,330 +0,0 @@
# 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 <BOUNDARIES>
Options:
-t, --track <TRACK>
Input GPX track. Reads from stdin when omitted or set to '-'.
-b, --boundaries <BOUNDARIES>
Boundary GeoJSON.
The GeoJSON must contain Polygon/MultiPolygon geometries
in WGS84 / EPSG:4326.
Required.
--name-field <NAME_FIELD>
Property containing the county name.
[default: GEN]
--id-field <ID_FIELD>
Property containing the administrative identifier.
[default: AGS]
-o, --output <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

61
flake.lock generated
View file

@ -1,61 +0,0 @@
{
"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
}

View file

@ -1,52 +0,0 @@
{
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";
};
};
}
);
}

440
landkreise.geojson Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,48 +0,0 @@
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "borderpoi-rs",
version,
about = "Find administrative border crossings along a GPX track"
)]
pub struct Args {
/// Input GPX track. Reads from stdin when omitted or set to '-'.
#[arg(short, long)]
pub track: Option<PathBuf>,
/// Boundary GeoJSON.
#[arg(short, long)]
pub boundaries: PathBuf,
/// GeoJSON property containing the county name.
#[arg(long, default_value = "GEN")]
pub name_field: String,
/// GeoJSON property containing the administrative identifier.
#[arg(long, default_value = "ARS")]
pub id_field: String,
/// Output GPX file. Writes to stdout when omitted or set to '-'.
#[arg(short, long)]
pub output: Option<PathBuf>,
/// Explicit name for the generated GPX track.
///
/// When omitted, the name is derived from GPX metadata,
/// output filename, or input filename and gets the
/// " (borderpoi)" suffix.
#[arg(long)]
pub name: Option<String>,
/// GPX waypoint type. Defaults to "SPRINT"
#[arg(long, default_value = "SPRINT")]
pub waypoint_type: String,
}
impl Args {
pub fn parse_args() -> Self {
Self::parse()
}
}

View file

@ -1,110 +0,0 @@
use anyhow::{bail, Context, Result};
use geo::{algorithm::bounding_rect::BoundingRect, Geometry};
use geojson::{Feature, GeoJson};
use rstar::{RTreeObject, AABB};
use serde_json::Value;
use std::{fs::File, io::BufReader, path::Path};
#[derive(Debug, Clone)]
pub struct County {
pub name: String,
pub id: String,
pub geometry: Geometry<f64>,
envelope: AABB<[f64; 2]>,
}
impl RTreeObject for County {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
self.envelope
}
}
pub fn load_counties(path: &Path, name_field: &str, id_field: &str) -> Result<Vec<County>> {
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<County> {
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 id = 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<f64> = 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,
id,
geometry,
envelope,
})
}
fn property_as_string(value: &Value) -> Result<String> {
match value {
Value::String(value) => Ok(value.clone()),
Value::Number(value) => Ok(value.to_string()),
_ => {
bail!("Expected string or number property, got {}.", value);
}
}
}

View file

@ -1,379 +0,0 @@
use anyhow::{Context, Result};
use geo::{
algorithm::bounding_rect::BoundingRect,
Contains,
Coord,
Geometry,
LineString,
Point,
};
use rstar::{AABB, RTree};
use std::cmp::Ordering;
use crate::{
county::County,
geometry::{
deduplicate_hits,
interpolate,
segment_intersection,
snap_to_segment,
},
};
#[derive(Debug, Clone)]
pub struct BoundaryHit {
pub position: f64,
pub point: Point<f64>,
}
#[derive(Debug, Clone)]
struct Transition {
position: f64,
from: County,
to: County,
}
#[derive(Debug, Clone)]
pub struct Crossing {
pub point: Point<f64>,
pub elevation: f64,
pub from: County,
pub to: County,
pub segment_index: usize,
pub position: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct TrackPoint {
pub lon: f64,
pub lat: f64,
pub elevation: Option<f64>,
}
/// Find all administrative boundary crossings along a GPX track.
///
/// The original track is never modified. Crossing points are reconstructed
/// from the original track segment and the intersection parameter, ensuring
/// that the resulting waypoint lies exactly on the original segment.
///
/// Boundary intersections are clustered before transitions are reconstructed.
/// This is important for polygon datasets such as the German VG250 data,
/// where the same shared Landkreis boundary is represented by both adjacent
/// polygons.
pub fn find_crossings(
track: &[TrackPoint],
tree: &RTree<County>,
) -> Result<Vec<Crossing>> {
let mut crossings = Vec::new();
for (segment_index, pair) in track.windows(2).enumerate() {
let start = &pair[0];
let end = &pair[1];
let start_point = Point::new(start.lon, start.lat);
let end_point = Point::new(end.lon, end.lat);
let segment = LineString::from(vec![
Coord {
x: start_point.x(),
y: start_point.y(),
},
Coord {
x: end_point.x(),
y: end_point.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<County> = tree
.locate_in_envelope_intersecting(envelope)
.cloned()
.collect();
if candidates.is_empty() {
continue;
}
let hits =
collect_segment_hits(start_point, end_point, &candidates);
if hits.is_empty() {
continue;
}
let transitions = reconstruct_transitions(
start_point,
end_point,
&candidates,
&hits,
);
for transition in transitions {
// Reconstruct the point from the original GPX segment.
//
// This is the actual snapping step and guarantees that the
// waypoint lies on the original track segment.
let snapped_point = snap_to_segment(
start_point,
end_point,
transition.position,
);
let elevation = interpolate_elevation(
start.elevation,
end.elevation,
transition.position,
);
crossings.push(Crossing {
point: snapped_point,
elevation,
from: transition.from,
to: transition.to,
segment_index,
position: transition.position,
});
}
}
Ok(deduplicate_crossings(crossings))
}
fn interpolate_elevation(
start: Option<f64>,
end: Option<f64>,
position: f64,
) -> f64 {
let position = position.clamp(0.0, 1.0);
match (start, end) {
(Some(start), Some(end)) => {
start + (end - start) * position
}
(Some(elevation), None) | (None, Some(elevation)) => {
elevation
}
// Garmin expects an elevation value for the waypoint.
(None, None) => 0.0,
}
}
fn collect_segment_hits(
start: Point<f64>,
end: Point<f64>,
candidates: &[County],
) -> Vec<BoundaryHit> {
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,
);
}
deduplicate_hits(hits)
}
fn collect_geometry_intersections(
geometry: &Geometry<f64>,
start: Coord<f64>,
end: Coord<f64>,
hits: &mut Vec<BoundaryHit>,
) {
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<f64>,
start: Coord<f64>,
end: Coord<f64>,
hits: &mut Vec<BoundaryHit>,
) {
for edge in ring.lines() {
if let Some((position, point)) =
segment_intersection(start, end, edge.start, edge.end)
{
hits.push(BoundaryHit {
position,
point,
});
}
}
}
fn reconstruct_transitions(
start: Point<f64>,
end: Point<f64>,
candidates: &[County],
hits: &[BoundaryHit],
) -> Vec<Transition> {
let mut transitions = Vec::new();
for hit in hits {
// Sample slightly before and after the intersection.
//
// The sampling distance is relative to the GPX segment, so it
// remains independent of the absolute coordinate values.
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;
};
// Ignore boundary touches where the track remains in the same
// administrative area.
if from.id == to.id {
continue;
}
transitions.push(Transition {
position: hit.position,
from,
to,
});
}
transitions
}
fn county_at_point(
point: Point<f64>,
candidates: &[County],
) -> Option<County> {
candidates
.iter()
.find(|county| county.geometry.contains(&point))
.cloned()
}
fn deduplicate_crossings(
mut crossings: Vec<Crossing>,
) -> Vec<Crossing> {
if crossings.len() <= 1 {
return crossings;
}
crossings.sort_by(|a, b| {
a.segment_index
.cmp(&b.segment_index)
.then_with(|| {
a.position
.partial_cmp(&b.position)
.unwrap_or(Ordering::Equal)
})
});
// Same tolerance used for boundary-hit clustering.
const POSITION_EPSILON: f64 = 1e-7;
let mut result: Vec<Crossing> = Vec::new();
for crossing in crossings {
let duplicate = result.iter().any(|existing| {
if existing.segment_index != crossing.segment_index {
return false;
}
if (existing.position - crossing.position).abs()
> POSITION_EPSILON
{
return false;
}
// Same transition reported twice.
if existing.from.id == crossing.from.id
&& existing.to.id == crossing.to.id
{
return true;
}
// The most important VG250 case:
//
// A -> B
// B -> A
//
// at effectively the same physical location.
//
// This can arise when both sides of a shared boundary produce
// an intersection independently.
existing.from.id == crossing.to.id
&& existing.to.id == crossing.from.id
});
if !duplicate {
result.push(crossing);
}
}
result
}

View file

@ -1,203 +0,0 @@
use geo::{Coord, Point};
use crate::crossings::BoundaryHit;
/// Calculate the 2D cross product of two vectors.
pub fn cross(a: Coord<f64>, b: Coord<f64>) -> f64 {
a.x * b.y - a.y * b.x
}
/// Interpolate a point on a segment.
///
/// `position = 0.0` returns `start`.
/// `position = 1.0` returns `end`.
pub fn interpolate(
start: Point<f64>,
end: Point<f64>,
position: f64,
) -> Point<f64> {
Point::new(
start.x() + position * (end.x() - start.x()),
start.y() + position * (end.y() - start.y()),
)
}
/// Calculate the squared coordinate distance between two points.
///
/// This intentionally operates in the coordinate system of the input data.
/// For the usual WGS84 GPX/GeoJSON workflow this is a squared degree
/// distance and is only used for a very small numerical tolerance.
pub fn squared_distance(a: Point<f64>, b: Point<f64>) -> f64 {
let dx = a.x() - b.x();
let dy = a.y() - b.y();
dx * dx + dy * dy
}
/// Intersect two line segments.
///
/// Returns the position on segment `p -> p2` and the intersection point.
pub fn segment_intersection(
p: Coord<f64>,
p2: Coord<f64>,
q: Coord<f64>,
q2: Coord<f64>,
) -> Option<(f64, Point<f64>)> {
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 t = t.clamp(0.0, 1.0);
let point = Point::new(
p.x + t * r.x,
p.y + t * r.y,
);
Some((t, point))
}
/// Deduplicate and cluster boundary intersections.
///
/// A shared Landkreis boundary is normally present in both adjacent
/// polygons. Consequently, the same physical crossing can be reported
/// several times with slightly different floating-point positions.
///
/// We cluster hits using both:
///
/// 1. their position along the GPX segment, and
/// 2. their actual coordinate distance.
///
/// The tolerances are deliberately small enough not to merge ordinary
/// separate crossings, while being large enough for the numerical noise
/// introduced by polygon conversion and floating-point intersection.
///
/// The function preserves the first hit in each cluster and uses the
/// average position for the cluster. The actual crossing point is later
/// reconstructed from the original GPX segment.
pub fn deduplicate_hits(mut hits: Vec<BoundaryHit>) -> Vec<BoundaryHit> {
if hits.len() <= 1 {
return hits;
}
// About one metre at German latitudes when coordinates are WGS84.
//
// This is deliberately conservative. We mainly want to collapse
// duplicate representations of the same shared polygon boundary.
const COORD_EPSILON: f64 = 1.5e-5;
const COORD_EPSILON_SQUARED: f64 =
COORD_EPSILON * COORD_EPSILON;
// Position epsilon protects against tiny differences in the
// intersection calculation itself.
const POSITION_EPSILON: f64 = 1e-7;
hits.sort_by(|a, b| {
a.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut result = Vec::new();
let mut cluster: Vec<BoundaryHit> = Vec::new();
for hit in hits {
if cluster.is_empty() {
cluster.push(hit);
continue;
}
let representative = cluster
.last()
.expect("cluster cannot be empty");
let position_close =
(representative.position - hit.position).abs()
<= POSITION_EPSILON;
let point_close =
squared_distance(representative.point, hit.point)
<= COORD_EPSILON_SQUARED;
if position_close && point_close {
cluster.push(hit);
} else {
result.push(merge_hit_cluster(&cluster));
cluster.clear();
cluster.push(hit);
}
}
if !cluster.is_empty() {
result.push(merge_hit_cluster(&cluster));
}
result
}
/// Merge several numerical representations of the same boundary hit.
fn merge_hit_cluster(cluster: &[BoundaryHit]) -> BoundaryHit {
debug_assert!(!cluster.is_empty());
let position =
cluster.iter().map(|hit| hit.position).sum::<f64>()
/ cluster.len() as f64;
let x =
cluster.iter().map(|hit| hit.point.x()).sum::<f64>()
/ cluster.len() as f64;
let y =
cluster.iter().map(|hit| hit.point.y()).sum::<f64>()
/ cluster.len() as f64;
BoundaryHit {
position,
point: Point::new(x, y),
}
}
/// Recalculate the point directly from the original GPX segment.
///
/// This guarantees that the returned point lies on the original segment
/// rather than relying on the coordinates calculated during intersection.
pub fn snap_to_segment(
start: Point<f64>,
end: Point<f64>,
position: f64,
) -> Point<f64> {
interpolate(
start,
end,
position.clamp(0.0, 1.0),
)
}

View file

@ -1,90 +0,0 @@
use anyhow::{bail, Context, Result};
use gpx::{read, write, Gpx, Waypoint};
use std::{
fs::File,
io::{self, BufReader, BufWriter},
path::Path,
};
use crate::crossings::{Crossing, TrackPoint};
pub fn read_gpx(path: Option<&Path>) -> Result<Gpx> {
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")
}
}
}
pub 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(())
}
pub fn extract_track_points(gpx: &Gpx) -> Result<Vec<TrackPoint>> {
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(),
elevation: waypoint.elevation,
});
}
}
}
if points.len() < 2 {
bail!("The GPX contains fewer than two track points.");
}
Ok(points)
}
pub fn append_crossing_waypoints(gpx: &mut Gpx, crossings: &[Crossing], waypoint_type: &str) {
for crossing in crossings.iter() {
let mut waypoint = Waypoint::new(crossing.point);
waypoint.name = Some(crossing.to.name.clone());
waypoint.elevation = Some(crossing.elevation);
waypoint.description = Some(format!(
"Administrative border crossing: {} [{}] -> {} [{}]",
crossing.from.name, crossing.from.id, crossing.to.name, crossing.to.id,
));
waypoint.comment = Some(format!("{} -> {}", crossing.from.name, crossing.to.name,));
waypoint.type_ = Some(waypoint_type.to_owned());
gpx.waypoints.push(waypoint);
}
}

View file

@ -1,107 +0,0 @@
mod cli;
mod county;
mod crossings;
mod geometry;
mod gpx_io;
mod report;
use anyhow::Result;
use std::path::Path;
use cli::Args;
use county::load_counties;
use crossings::find_crossings;
use gpx_io::{append_crossing_waypoints, read_gpx, write_gpx};
use report::print_report;
fn basename(path: Option<&Path>) -> Option<String> {
path.filter(|path| path.as_os_str() != "-")
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.map(|name| {
Path::new(name)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(name)
.to_owned()
})
}
fn output_name(
explicit_name: Option<&str>,
input_metadata_name: Option<&str>,
output: Option<&Path>,
input: Option<&Path>,
) -> String {
if let Some(name) = explicit_name {
return name.to_owned();
}
let base_name = input_metadata_name
.filter(|name| !name.trim().is_empty())
.map(str::to_owned)
.or_else(|| basename(output))
.or_else(|| basename(input))
.unwrap_or_else(|| "track".to_owned());
format!("{base_name} (borderpoi)")
}
fn main() -> Result<()> {
let args = Args::parse_args();
let gpx = read_gpx(args.track.as_deref())?;
let track = gpx_io::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 = rstar::RTree::bulk_load(counties);
eprintln!("Spatial index: R-tree");
let crossings = find_crossings(&track, &tree)?;
print_report(&crossings);
let metadata_name = gpx
.metadata
.as_ref()
.and_then(|metadata| metadata.name.as_deref());
let name = output_name(
args.name.as_deref(),
metadata_name,
args.output.as_deref(),
args.track.as_deref(),
);
let mut output_gpx = gpx;
// set the generated track name(s).
for track in &mut output_gpx.tracks {
track.name = Some(name.clone());
}
output_gpx
.metadata
.get_or_insert_with(Default::default)
.name = Some(name);
append_crossing_waypoints(
&mut output_gpx,
&crossings,
&args.waypoint_type,
);
write_gpx(
&output_gpx,
args.output.as_deref(),
)?;
Ok(())
}

View file

@ -1,46 +0,0 @@
use std::collections::HashSet;
use crate::crossings::Crossing;
pub 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.id,);
}
let unique: HashSet<String> = visits.iter().map(|county| county.id.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,
);
}
}