Compare commits
14 changed files with 440 additions and 2399 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
target/
|
||||
949
Cargo.lock
generated
949
Cargo.lock
generated
|
|
@ -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"
|
||||
23
Cargo.toml
23
Cargo.toml
|
|
@ -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
330
README.md
|
|
@ -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
61
flake.lock
generated
|
|
@ -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
|
||||
}
|
||||
52
flake.nix
52
flake.nix
|
|
@ -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
440
landkreise.geojson
Normal file
File diff suppressed because one or more lines are too long
48
src/cli.rs
48
src/cli.rs
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
110
src/county.rs
110
src/county.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
379
src/crossings.rs
379
src/crossings.rs
|
|
@ -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
|
||||
}
|
||||
203
src/geometry.rs
203
src/geometry.rs
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
107
src/main.rs
107
src/main.rs
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue