1mod providers;
4mod terrain;
5
6pub use providers::{
7 AuthorityTrailProvider, DEFAULT_NY_STATE_PARKS_ENDPOINT, DEFAULT_TEXAS_STATE_PARKS_ENDPOINT,
8 DEFAULT_USGS_TRAILS_ENDPOINT, NetworkProvider, NormalizedNetwork, ProviderDescriptor,
9 ProviderId, ProviderPayload, RawShard, UsgsNationalTrails,
10};
11pub use terrain::{TerrainTileId, TopographicTile};
12
13use anyhow::{Context as _, Result, ensure};
14use reqwest::blocking::Response;
15use serde::{Deserialize, Serialize};
16use sha2::{Digest as _, Sha256};
17use std::{
18 cmp::Ordering,
19 collections::{BTreeMap, BTreeSet, BinaryHeap},
20 env,
21 fmt::Write as _,
22 fs::{self, File, OpenOptions},
23 io::{Read as _, Write as _},
24 path::{Path, PathBuf},
25 str::FromStr,
26 time::Duration,
27};
28use trailgen_core::{
29 Access, ContextOverlay, Coord, CrossingControl, CrossingKind, DEFAULT_SNAP_TOLERANCE_M, Edge,
30 EdgeTravel, EnrichmentConfig, GRAPH_CACHE, GeometryClaim, GraphBuilder, JunctionKey,
31 LineString, Provenance, SegmentDraft, Terrain, TrailMarking, TrailStanding, WalkGraph, WayKind,
32 WayRealm, apply_context_overlays, decode_graph, encode_graph,
33 io::{geojson, osm},
34 model::TerrainEvidence,
35 source::{
36 GeoBounds, SourceCandidate, SourceFingerprint, SourceKind, SourceManifest,
37 adapter_registry, discovery_recommendations, source_coverage,
38 },
39};
40
41pub const MIN_RADIUS_KM: f64 = 2.0;
42pub const DEFAULT_RADIUS_KM: f64 = 8.0;
43pub const MAX_RADIUS_KM: f64 = 40.0;
44pub const DEFAULT_NOMINATIM_ENDPOINT: &str = "https://nominatim.openstreetmap.org/search";
45pub const DEFAULT_OVERPASS_ENDPOINT: &str = "https://overpass-api.de/api/interpreter";
46pub const FALLBACK_OVERPASS_ENDPOINT: &str = "https://overpass.private.coffee/api/interpreter";
47pub const MAX_REGION_DEG2: f64 = 4.0;
48pub(crate) const MAX_SOURCE_BYTES: u64 = 256 * 1024 * 1024;
49const AUTOMATIC_OSM_PROFILE: OsmProfile = OsmProfile::All;
50const INDEX_SCHEMA: u8 = 17;
51const RAW_SCHEMA: u8 = 4;
52const MAX_OSM_CONNECTOR_M: f64 = 1_000.0;
53const LOCATION_CACHE: &str = "sources/location.json";
54const TRAIL_INDEX: &str = "cache/trails.json";
55const GRAPH_GEOJSON: &str = "cache/graph.geojson";
56const CONFLATION_REPORT: &str = "cache/conflation.json";
57const SOURCE_MANIFEST: &str = "sources/manifest.json";
58const GRAPH_AUXILIARIES: &[&str] = &[
59 "cache/graph.json",
60 GRAPH_GEOJSON,
61 "cache/edges.csv",
62 "cache/vertices.csv",
63];
64const OSM_TRAIL_SELECTORS: &[&str] = &[
65 r#"way["highway"~"^(path|footway|cycleway|pedestrian|track|steps|bridleway)$"]"#,
66 r#"way["disused:highway"~"^(path|footway|cycleway|track|pedestrian|steps|bridleway)$"]"#,
67 r#"way["abandoned:highway"~"^(path|footway|cycleway|track|pedestrian|steps|bridleway)$"]"#,
68 r#"way["route"~"^(hiking|foot|walking)$"]"#,
69];
70const OSM_ROAD_SELECTORS: &[&str] = &[
71 r#"way["highway"~"^(motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|service|track|road)$"]"#,
72];
73const OSM_HYDROLOGY_SELECTORS: &[&str] =
74 &[r#"way["waterway"~"^(stream|river|canal|drain|ditch|brook)$"]"#];
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum OsmProfile {
79 All,
80 Trails,
81 Roads,
82 Hydrology,
83}
84
85impl OsmProfile {
86 #[must_use]
87 pub const fn default_output(self) -> &'static str {
88 match self {
89 Self::All => "osm-extract.osm",
90 Self::Trails => "osm-trails.osm",
91 Self::Roads => "roads.osm",
92 Self::Hydrology => "hydrology.osm",
93 }
94 }
95
96 #[must_use]
97 pub const fn label(self) -> &'static str {
98 match self {
99 Self::All => "all",
100 Self::Trails => "trails",
101 Self::Roads => "roads",
102 Self::Hydrology => "hydrology",
103 }
104 }
105}
106
107impl FromStr for OsmProfile {
108 type Err = String;
109
110 fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
111 match raw {
112 "all" => Ok(Self::All),
113 "trails" => Ok(Self::Trails),
114 "roads" => Ok(Self::Roads),
115 "hydrology" => Ok(Self::Hydrology),
116 _ => Err("expected all, trails, roads, or hydrology".to_owned()),
117 }
118 }
119}
120
121#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
122pub struct Place {
123 pub query: String,
124 pub label: String,
125 pub center: Coord,
126 pub license: String,
127 pub provider: String,
128}
129
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132pub struct SurveyRegion {
133 pub id: String,
134 pub bounds: GeoBounds,
135}
136
137impl SurveyRegion {
138 pub fn new(bounds: GeoBounds) -> Result<Self> {
139 validate_region(bounds)?;
140 Ok(Self {
141 id: region_key(bounds),
142 bounds,
143 })
144 }
145
146 pub fn validate(&self) -> Result<()> {
147 validate_region(self.bounds)?;
148 ensure!(
149 self.id == region_key(self.bounds),
150 "survey-region id does not match its bounds"
151 );
152 Ok(())
153 }
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct Inventory {
158 pub trail_segments: usize,
159 pub road_features: usize,
160 pub waterway_features: usize,
161}
162
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164pub struct Summary {
165 pub regions: Vec<SurveyRegion>,
166 pub providers: Vec<ProviderId>,
167 pub inventory: Inventory,
168 pub vertices: usize,
169 pub edges: usize,
170 #[serde(default)]
171 pub elevation_tiles: usize,
172 pub raw_paths: Vec<PathBuf>,
173 pub conflation: trailgen_core::ConflationStats,
174 pub reused: bool,
175}
176
177#[derive(Clone, Debug)]
178pub struct Topography {
179 pub identity: String,
180 pub tiles: Vec<TopographicTile>,
181}
182
183pub fn indexed_topography_identity(project: &Path) -> Result<Option<String>> {
185 Ok(topographic_index(project)?.map(|index| topographic_identity(&index)))
186}
187
188pub fn indexed_topography(project: &Path) -> Result<Option<Topography>> {
190 let Some(index) = topographic_index(project)? else {
191 return Ok(None);
192 };
193 let identity = topographic_identity(&index);
194 let tiles = index
195 .elevation
196 .iter()
197 .map(|receipt| terrain::topographic_tile(project, receipt))
198 .collect::<Result<Vec<_>>>()?;
199 Ok(Some(Topography { identity, tiles }))
200}
201
202fn topographic_index(project: &Path) -> Result<Option<TrailIndex>> {
203 let path = project.join(TRAIL_INDEX);
204 let raw = match fs::read(&path) {
205 Ok(raw) => raw,
206 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
207 Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
208 };
209 let index: TrailIndex =
210 serde_json::from_slice(&raw).with_context(|| format!("parse {}", path.display()))?;
211 ensure!(
212 index.schema == INDEX_SCHEMA,
213 "trail index schema is obsolete"
214 );
215 Ok((!index.elevation.is_empty()).then_some(index))
216}
217
218fn topographic_identity(index: &TrailIndex) -> String {
219 let mut identity = Sha256::new();
220 for receipt in &index.elevation {
221 identity.update(receipt.tile.z.to_le_bytes());
222 identity.update(receipt.tile.x.to_le_bytes());
223 identity.update(receipt.tile.y.to_le_bytes());
224 identity.update(receipt.raw.sha256.as_bytes());
225 }
226 hex(&identity.finalize())
227}
228
229fn hex(bytes: &[u8]) -> String {
230 bytes
231 .iter()
232 .fold(String::with_capacity(bytes.len() * 2), |mut text, byte| {
233 write!(text, "{byte:02x}").expect("write hexadecimal digest");
234 text
235 })
236}
237
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
239#[serde(default)]
240pub struct TrailDataConfig {
241 pub managed: bool,
243 #[serde(skip_serializing_if = "Vec::is_empty")]
244 pub regions: Vec<SurveyRegion>,
245 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
247 pub region_names: BTreeMap<String, String>,
248 pub providers: Vec<ProviderId>,
249}
250
251impl Default for TrailDataConfig {
252 fn default() -> Self {
253 Self {
254 managed: false,
255 regions: Vec::new(),
256 region_names: BTreeMap::new(),
257 providers: automatic_provider_ids(),
258 }
259 }
260}
261
262fn automatic_provider_ids() -> Vec<ProviderId> {
263 [
264 "ny-state-parks",
265 "osm",
266 "texas-state-parks",
267 "usgs-national-trails",
268 ]
269 .map(|id| ProviderId::new(id).expect("static provider id is valid"))
270 .into_iter()
271 .collect()
272}
273
274fn legacy_automatic_provider_ids() -> Vec<ProviderId> {
275 ["osm", "usgs-national-trails"]
276 .map(|id| ProviderId::new(id).expect("static provider id is valid"))
277 .into_iter()
278 .collect()
279}
280
281#[derive(Clone, Debug)]
282pub enum Event {
283 Locating,
284 Located(Place),
285 Ranging {
286 provider: ProviderId,
287 region: SurveyRegion,
288 },
289 Downloaded {
290 provider: ProviderId,
291 bytes: u64,
292 },
293 Elevating {
294 complete: usize,
295 total: usize,
296 },
297 Indexing,
298 Ready(Summary),
299}
300
301impl Event {
302 #[must_use]
303 pub fn status(&self) -> String {
304 match self {
305 Self::Locating => "LOCATING US TRAIL AREA".to_owned(),
306 Self::Located(place) => format!("LOCATED · {}", place.label.to_ascii_uppercase()),
307 Self::Ranging { provider, region } => format!(
308 "FETCHING {provider} · {:.4}, {:.4} TO {:.4}, {:.4}",
309 region.bounds.west, region.bounds.south, region.bounds.east, region.bounds.north
310 ),
311 Self::Downloaded { provider, bytes } => {
312 let mib = bytes / 1_048_576;
313 let tenth = (bytes % 1_048_576) * 10 / 1_048_576;
314 format!("SEQUESTERED {mib}.{tenth} MIB FROM {provider}")
315 }
316 Self::Elevating { complete, total } => {
317 format!("FETCHING TOPOGRAPHY · {complete}/{total} TILES")
318 }
319 Self::Indexing => "INDEXING ROUTABLE TRAIL GRAPH".to_owned(),
320 Self::Ready(summary) if summary.reused => {
321 format!("TRAIL INDEX READY · {} EDGES · CACHED", summary.edges)
322 }
323 Self::Ready(summary) => format!("TRAIL INDEX READY · {} EDGES", summary.edges),
324 }
325 }
326}
327
328pub trait PlaceIndex {
329 fn locate_us(&self, query: &str) -> Result<Place>;
330}
331
332#[derive(Clone, Debug)]
333pub struct Nominatim {
334 endpoint: String,
335 timeout: Duration,
336}
337
338impl Default for Nominatim {
339 fn default() -> Self {
340 Self {
341 endpoint: env::var("TRAILGEN_GEOCODER_ENDPOINT")
342 .unwrap_or_else(|_| DEFAULT_NOMINATIM_ENDPOINT.to_owned()),
343 timeout: Duration::from_secs(30),
344 }
345 }
346}
347
348impl Nominatim {
349 #[must_use]
350 pub fn new(endpoint: impl Into<String>, timeout: Duration) -> Self {
351 Self {
352 endpoint: endpoint.into(),
353 timeout,
354 }
355 }
356}
357
358impl PlaceIndex for Nominatim {
359 fn locate_us(&self, query: &str) -> Result<Place> {
360 let query = query.trim();
361 ensure!(!query.is_empty(), "enter a US place or trailhead");
362 let replies = provider_client("place-search", self.timeout)
363 .context("build OpenStreetMap place-search client")?
364 .get(&self.endpoint)
365 .query(&[
366 ("q", query),
367 ("format", "jsonv2"),
368 ("countrycodes", "us"),
369 ("limit", "1"),
370 ])
371 .header("Accept-Language", "en-US,en;q=0.8")
372 .send()
373 .with_context(|| format!("search US places through {}", self.endpoint))?
374 .error_for_status()
375 .with_context(|| format!("place-search endpoint {} returned an error", self.endpoint))?
376 .json::<Vec<NominatimReply>>()
377 .context("decode OpenStreetMap place-search response")?;
378 let reply = replies
379 .into_iter()
380 .next()
381 .with_context(|| format!("no US place matched {query:?}"))?;
382 let lat = parse_coordinate(&reply.lat, "latitude")?;
383 let lon = parse_coordinate(&reply.lon, "longitude")?;
384 ensure!(
385 (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon),
386 "place search returned an invalid coordinate"
387 );
388 Ok(Place {
389 query: query.to_owned(),
390 label: reply.display_name,
391 center: Coord::new(lon, lat),
392 license: reply.licence,
393 provider: self.endpoint.clone(),
394 })
395 }
396}
397
398#[derive(Clone, Debug)]
399pub struct Overpass {
400 endpoints: Vec<String>,
401 timeout: Duration,
402}
403
404impl Default for Overpass {
405 fn default() -> Self {
406 let endpoints = env::var("TRAILGEN_OVERPASS_ENDPOINT").map_or_else(
407 |_| {
408 vec![
409 DEFAULT_OVERPASS_ENDPOINT.to_owned(),
410 FALLBACK_OVERPASS_ENDPOINT.to_owned(),
411 ]
412 },
413 |endpoint| vec![endpoint],
414 );
415 Self {
416 endpoints,
417 timeout: Duration::from_secs(90),
418 }
419 }
420}
421
422impl Overpass {
423 #[must_use]
424 pub fn new(endpoint: impl Into<String>, timeout: Duration) -> Self {
425 Self {
426 endpoints: vec![endpoint.into()],
427 timeout,
428 }
429 }
430
431 #[must_use]
432 pub fn query(&self, profile: OsmProfile, bounds: GeoBounds) -> String {
433 overpass_query(profile, bounds, self.timeout.as_secs())
434 }
435
436 pub fn fetch(&self, profile: OsmProfile, bounds: GeoBounds) -> Result<OsmPayload> {
437 let area_deg2 = (bounds.east - bounds.west) * (bounds.north - bounds.south);
438 ensure!(bounds.is_valid(), "invalid trail-data bounds");
439 ensure!(
440 area_deg2 <= MAX_REGION_DEG2,
441 "trail-data bounds span {area_deg2:.2} square degrees; limit is {MAX_REGION_DEG2:.2}"
442 );
443 let query = self.query(profile, bounds);
444 let client = provider_client("trail-source", self.timeout)
445 .context("build OpenStreetMap trail-source client")?;
446 let mut faults = Vec::new();
447 for endpoint in &self.endpoints {
448 let response = match client
449 .post(endpoint)
450 .form(&[("data", query.as_str())])
451 .send()
452 {
453 Ok(response) => response,
454 Err(err) => {
455 faults.push(format!("{endpoint}: {err}"));
456 continue;
457 }
458 };
459 let status = response.status();
460 if !status.is_success() {
461 let fault = format!("{endpoint}: HTTP {status}");
462 if status.as_u16() == 429 || status.is_server_error() {
463 faults.push(fault);
464 continue;
465 }
466 anyhow::bail!("Overpass rejected the trail query through {fault}");
467 }
468 match read_bounded(response, MAX_SOURCE_BYTES, "OpenStreetMap response") {
469 Ok(bytes) => {
470 return Ok(OsmPayload {
471 bytes,
472 query,
473 origin: endpoint.clone(),
474 });
475 }
476 Err(err) => faults.push(format!("{endpoint}: {err:#}")),
477 }
478 }
479 anyhow::bail!("all Overpass providers failed: {}", faults.join("; "))
480 }
481}
482
483pub struct Surveyor<L = Nominatim> {
484 locator: L,
485 providers: Vec<Box<dyn NetworkProvider>>,
486 fixed_providers: bool,
487}
488
489impl Default for Surveyor {
490 fn default() -> Self {
491 Self {
492 locator: Nominatim::default(),
493 providers: vec![
494 Box::new(AuthorityTrailProvider::new_york()),
495 Box::new(Overpass::default()),
496 Box::new(AuthorityTrailProvider::texas()),
497 Box::new(UsgsNationalTrails::default()),
498 ],
499 fixed_providers: false,
500 }
501 }
502}
503
504impl<L> Surveyor<L>
505where
506 L: PlaceIndex,
507{
508 #[must_use]
509 pub fn new<P: NetworkProvider + 'static>(locator: L, provider: P) -> Self {
510 Self {
511 locator,
512 providers: vec![Box::new(provider)],
513 fixed_providers: true,
514 }
515 }
516
517 #[must_use]
518 pub fn with_providers(locator: L, providers: Vec<Box<dyn NetworkProvider>>) -> Self {
519 assert!(
520 !providers.is_empty(),
521 "a surveyor needs at least one provider"
522 );
523 let distinct = providers
524 .iter()
525 .map(|provider| provider.descriptor().id)
526 .collect::<BTreeSet<_>>();
527 assert_eq!(
528 distinct.len(),
529 providers.len(),
530 "a surveyor cannot carry duplicate provider ids"
531 );
532 Self {
533 locator,
534 providers,
535 fixed_providers: true,
536 }
537 }
538
539 pub fn survey(
540 &self,
541 project: &Path,
542 query: &str,
543 radius_km: f64,
544 mut emit: impl FnMut(Event),
545 ) -> Result<Summary> {
546 validate_project(project)?;
547 let query = query.trim();
548 ensure!(!query.is_empty(), "enter a US place or trailhead");
549 validate_radius(radius_km)?;
550 emit(Event::Locating);
551 let place =
552 cached_place(project, query)?.map_or_else(|| self.locator.locate_us(query), Ok)?;
553 write_json_atomic(project.join(LOCATION_CACHE), &place)?;
554 emit(Event::Located(place.clone()));
555 self.add_region(project, bounds_around(&place, radius_km)?, emit)
556 }
557
558 pub fn add_region(
560 &self,
561 project: &Path,
562 bounds: GeoBounds,
563 mut emit: impl FnMut(Event),
564 ) -> Result<Summary> {
565 validate_project(project)?;
566 let region = SurveyRegion::new(bounds)?;
567 let mut config = project_config(project)?;
568 config.managed = true;
569 if self.fixed_providers {
570 let providers = self.provider_ids();
571 if config.providers != providers {
572 config.providers = providers;
573 }
574 }
575 if !config.regions.iter().any(|known| known.id == region.id) {
576 config.regions.push(region);
577 }
578 configure_project(project, &config)?;
579 self.reconcile(project, &config, true, &mut emit)
580 }
581
582 pub fn refresh(&self, project: &Path, mut emit: impl FnMut(Event)) -> Result<Option<Summary>> {
584 validate_project(project)?;
585 let config = project_config(project)?;
586 if config.regions.is_empty() {
587 clear_corpus(project)?;
588 reap_provider_receipts(project, &[], &self.provider_descriptors())?;
589 return Ok(None);
590 }
591 configure_project(project, &config)?;
595 self.reconcile(project, &config, true, &mut emit).map(Some)
596 }
597
598 pub fn remove_region(
600 &self,
601 project: &Path,
602 id: &str,
603 mut emit: impl FnMut(Event),
604 ) -> Result<Option<Summary>> {
605 validate_project(project)?;
606 let mut config = project_config(project)?;
607 let before = config.regions.len();
608 config.regions.retain(|region| region.id != id);
609 ensure!(
610 config.regions.len() != before,
611 "project has no survey region {id}"
612 );
613 let _name = config.region_names.remove(id);
614 configure_project(project, &config)?;
615 if config.regions.is_empty() {
616 clear_corpus(project)?;
617 reap_provider_receipts(project, &[], &self.provider_descriptors())?;
618 return Ok(None);
619 }
620 self.reconcile(project, &config, false, &mut emit).map(Some)
621 }
622
623 pub fn replace_region(
627 &self,
628 project: &Path,
629 id: &str,
630 bounds: GeoBounds,
631 mut emit: impl FnMut(Event),
632 ) -> Result<Summary> {
633 validate_project(project)?;
634 let replacement = SurveyRegion::new(bounds)?;
635 let mut config = project_config(project)?;
636 let slot = config
637 .regions
638 .iter()
639 .position(|region| region.id == id)
640 .with_context(|| format!("project has no survey region {id}"))?;
641 ensure!(
642 config
643 .regions
644 .iter()
645 .enumerate()
646 .all(|(known_slot, known)| known_slot == slot || known.id != replacement.id),
647 "that map area duplicates another downloaded area"
648 );
649 if replacement.id == id {
650 return self.reconcile(project, &config, true, &mut emit);
651 }
652 config.regions[slot] = replacement;
653 let replacement_id = config.regions[slot].id.clone();
654 if let Some(name) = config.region_names.remove(id) {
655 let _old = config.region_names.insert(replacement_id, name);
656 }
657 configure_project(project, &config)?;
658 self.reconcile(project, &config, true, &mut emit)
659 }
660
661 fn reconcile(
662 &self,
663 project: &Path,
664 config: &TrailDataConfig,
665 fetch_missing: bool,
666 emit: &mut impl FnMut(Event),
667 ) -> Result<Summary> {
668 ensure!(
669 !config.regions.is_empty(),
670 "live area has no survey regions"
671 );
672 let providers = self.active_providers(config)?;
673 let descriptors = providers
674 .iter()
675 .map(|provider| provider.descriptor())
676 .collect::<Vec<_>>();
677 if let Some(mut summary) =
678 reusable_index(project, config, Some(&descriptors), !self.fixed_providers)?
679 {
680 summary.reused = true;
681 emit(Event::Ready(summary.clone()));
682 return Ok(summary);
683 }
684
685 let mut sources = Vec::with_capacity(config.regions.len() * providers.len());
686 for provider in &providers {
687 let descriptor = provider.descriptor();
688 for region in &config.regions {
689 let raw_relative = PathBuf::from("sources")
690 .join(descriptor.id.as_str())
691 .join(format!("{}.{}", region.id, descriptor.extension));
692 let raw_path = project.join(&raw_relative);
693 let request_path = raw_path.with_extension(descriptor.request_extension);
694 let artifact_path = raw_path.with_extension("json");
695 let cached = cached_provider(
696 &raw_path,
697 &request_path,
698 &artifact_path,
699 region,
700 &descriptor,
701 )?;
702 let (bytes, origin) = if let Some(cached) = cached {
703 (cached.bytes, cached.origin)
704 } else {
705 ensure!(
706 fetch_missing,
707 "{} region {} has no intact source receipt",
708 descriptor.label,
709 region.id
710 );
711 let covered = provider.covers(region.bounds);
712 if covered {
713 emit(Event::Ranging {
714 provider: descriptor.id.clone(),
715 region: region.clone(),
716 });
717 }
718 let payload = provider.acquire(region.bounds)?;
719 let artifact = ProviderArtifact {
720 schema: RAW_SCHEMA,
721 provider: descriptor.id.clone(),
722 adapter_revision: descriptor.adapter_revision,
723 region: region.clone(),
724 origin: payload.origin.clone(),
725 request: payload.request.clone(),
726 raw: fingerprint(&payload.bytes),
727 };
728 write_atomic(&request_path, payload.request.as_bytes())?;
729 write_json_atomic(&artifact_path, &artifact)?;
730 write_atomic(&raw_path, &payload.bytes)?;
732 if covered {
733 emit(Event::Downloaded {
734 provider: descriptor.id.clone(),
735 bytes: payload.bytes.len() as u64,
736 });
737 }
738 (payload.bytes, payload.origin)
739 };
740 sources.push(ProviderSource {
741 descriptor: descriptor.clone(),
742 region: region.clone(),
743 raw_relative,
744 fingerprint: fingerprint(&bytes),
745 bytes,
746 origin,
747 });
748 }
749 }
750 let terrain = if self.fixed_providers {
751 Vec::new()
752 } else {
753 terrain::acquire(project, &config.regions, fetch_missing, emit)?
754 };
755 emit(Event::Indexing);
756 let summary = index_corpus(project, config, &sources, &providers, &terrain)?;
757 reap_provider_receipts(project, &sources, &self.provider_descriptors())?;
758 emit(Event::Ready(summary.clone()));
759 Ok(summary)
760 }
761
762 fn provider_ids(&self) -> Vec<ProviderId> {
763 let mut ids = self
764 .providers
765 .iter()
766 .map(|provider| provider.descriptor().id)
767 .collect::<Vec<_>>();
768 ids.sort();
769 ids.dedup();
770 ids
771 }
772
773 fn provider_descriptors(&self) -> Vec<ProviderDescriptor> {
774 self.providers
775 .iter()
776 .map(|provider| provider.descriptor())
777 .collect()
778 }
779
780 fn active_providers(&self, config: &TrailDataConfig) -> Result<Vec<&dyn NetworkProvider>> {
781 let mut providers = Vec::with_capacity(config.providers.len());
782 for id in &config.providers {
783 let provider = self
784 .providers
785 .iter()
786 .find(|provider| provider.descriptor().id == *id)
787 .with_context(|| format!("project requests unavailable trail provider {id}"))?;
788 providers.push(provider.as_ref());
789 }
790 ensure!(!providers.is_empty(), "project has no trail providers");
791 Ok(providers)
792 }
793}
794
795pub struct OsmPayload {
796 pub bytes: Vec<u8>,
797 pub query: String,
798 pub origin: String,
799}
800
801impl NetworkProvider for Overpass {
802 fn descriptor(&self) -> ProviderDescriptor {
803 ProviderDescriptor {
804 id: ProviderId::new("osm").expect("static provider id is valid"),
805 label: "OpenStreetMap",
806 adapter_revision: 7,
807 precedence: 10,
808 extension: "osm",
809 request_extension: "overpassql",
810 }
811 }
812
813 fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
814 let payload = self.fetch(AUTOMATIC_OSM_PROFILE, bounds)?;
815 Ok(ProviderPayload {
816 bytes: payload.bytes,
817 request: payload.query,
818 origin: payload.origin,
819 })
820 }
821
822 fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
823 let merged = merge_osm(shards)?;
824 Ok(NormalizedNetwork {
825 drafts: classify_osm_realms(osm::network_from_str(&merged)?),
826 context: osm::context_overlays_from_str(&merged)?,
827 })
828 }
829}
830
831type OsmJunction = JunctionKey;
832
833#[derive(Clone, Debug)]
834struct OsmConnector {
835 draft: usize,
836 a: OsmJunction,
837 b: OsmJunction,
838 length_m: f64,
839}
840
841#[derive(Clone, Debug, PartialEq)]
842struct OsmWalk {
843 junction: OsmJunction,
844 owner: OsmJunction,
845 distance_m: f64,
846}
847
848#[derive(Clone, Debug)]
849struct OsmReach {
850 owner: OsmJunction,
851 distance_m: f64,
852 predecessor: Option<(OsmJunction, usize)>,
853}
854
855#[derive(Clone, Debug)]
856struct OsmBridge {
857 distance_m: f64,
858 edge: usize,
859 a: OsmJunction,
860 b: OsmJunction,
861}
862
863impl Eq for OsmWalk {}
864
865impl Ord for OsmWalk {
866 fn cmp(&self, other: &Self) -> Ordering {
867 other
868 .distance_m
869 .total_cmp(&self.distance_m)
870 .then_with(|| other.owner.cmp(&self.owner))
871 .then_with(|| self.junction.cmp(&other.junction))
872 }
873}
874
875impl PartialOrd for OsmWalk {
876 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
877 Some(self.cmp(other))
878 }
879}
880
881fn classify_osm_realms(mut drafts: Vec<SegmentDraft>) -> Vec<SegmentDraft> {
885 let mut finder = drafts.iter().map(osm_trail_anchor).collect::<Vec<_>>();
886 let mut anchor_junctions = BTreeSet::new();
887 let mut connectors = Vec::new();
888 for (index, draft) in drafts.iter().enumerate() {
889 let Some([a, b]) = draft.junction_keys.clone() else {
890 continue;
891 };
892 if finder[index] {
893 let _ = anchor_junctions.insert(a.clone());
894 let _ = anchor_junctions.insert(b.clone());
895 } else if osm_connector(draft) {
896 connectors.push(OsmConnector {
897 draft: index,
898 a,
899 b,
900 length_m: draft.geometry.length_m(),
901 });
902 }
903 }
904
905 let mut adjacency = BTreeMap::<OsmJunction, Vec<usize>>::new();
906 for (index, connector) in connectors.iter().enumerate() {
907 adjacency
908 .entry(connector.a.clone())
909 .or_default()
910 .push(index);
911 adjacency
912 .entry(connector.b.clone())
913 .or_default()
914 .push(index);
915 }
916 let terminals = adjacency
917 .keys()
918 .filter(|junction| anchor_junctions.contains(junction))
919 .cloned()
920 .collect::<BTreeSet<_>>();
921 retain_nearest_connectors(&terminals, &adjacency, &connectors, &mut finder);
922 for (draft, admitted) in drafts.iter_mut().zip(finder) {
923 if admitted && draft.realm == WayRealm::Urban {
924 draft.realm = WayRealm::Connector;
925 }
926 }
927 drafts
928}
929
930fn retain_nearest_connectors(
931 terminals: &BTreeSet<OsmJunction>,
932 adjacency: &BTreeMap<OsmJunction, Vec<usize>>,
933 connectors: &[OsmConnector],
934 finder: &mut [bool],
935) {
936 let mut reach = terminals
937 .iter()
938 .cloned()
939 .map(|terminal| {
940 (
941 terminal.clone(),
942 OsmReach {
943 owner: terminal,
944 distance_m: 0.0,
945 predecessor: None,
946 },
947 )
948 })
949 .collect::<BTreeMap<_, _>>();
950 let mut frontier = terminals
951 .iter()
952 .cloned()
953 .map(|terminal| OsmWalk {
954 junction: terminal.clone(),
955 owner: terminal,
956 distance_m: 0.0,
957 })
958 .collect::<BinaryHeap<_>>();
959 while let Some(walk) = frontier.pop() {
960 if walk.distance_m > MAX_OSM_CONNECTOR_M
961 || reach
962 .get(&walk.junction)
963 .is_none_or(|known| known.owner != walk.owner || walk.distance_m > known.distance_m)
964 {
965 continue;
966 }
967 for edge in adjacency.get(&walk.junction).into_iter().flatten() {
968 let connector = &connectors[*edge];
969 let next = if connector.a == walk.junction {
970 connector.b.clone()
971 } else {
972 connector.a.clone()
973 };
974 let candidate = walk.distance_m + connector.length_m;
975 let improves = reach.get(&next).is_none_or(|known| {
976 candidate.total_cmp(&known.distance_m).is_lt()
977 || candidate.total_cmp(&known.distance_m).is_eq() && walk.owner < known.owner
978 });
979 if candidate <= MAX_OSM_CONNECTOR_M && improves {
980 let _ = reach.insert(
981 next.clone(),
982 OsmReach {
983 owner: walk.owner.clone(),
984 distance_m: candidate,
985 predecessor: Some((walk.junction.clone(), *edge)),
986 },
987 );
988 frontier.push(OsmWalk {
989 junction: next,
990 owner: walk.owner.clone(),
991 distance_m: candidate,
992 });
993 }
994 }
995 }
996
997 let mut nearest = BTreeMap::<OsmJunction, OsmBridge>::new();
998 for (edge, connector) in connectors.iter().enumerate() {
999 let (Some(a), Some(b)) = (reach.get(&connector.a), reach.get(&connector.b)) else {
1000 continue;
1001 };
1002 if a.owner == b.owner {
1003 continue;
1004 }
1005 let distance_m = a.distance_m + connector.length_m + b.distance_m;
1006 if distance_m > MAX_OSM_CONNECTOR_M {
1007 continue;
1008 }
1009 let bridge = OsmBridge {
1010 distance_m,
1011 edge,
1012 a: connector.a.clone(),
1013 b: connector.b.clone(),
1014 };
1015 for owner in [&a.owner, &b.owner] {
1016 let replace = nearest
1017 .get(owner)
1018 .is_none_or(|known| bridge_order(&bridge, known).is_lt());
1019 if replace {
1020 let _ = nearest.insert(owner.clone(), bridge.clone());
1021 }
1022 }
1023 }
1024 for bridge in nearest.into_values() {
1025 finder[connectors[bridge.edge].draft] = true;
1026 retain_reach(&bridge.a, &reach, connectors, finder);
1027 retain_reach(&bridge.b, &reach, connectors, finder);
1028 }
1029}
1030
1031fn bridge_order(left: &OsmBridge, right: &OsmBridge) -> Ordering {
1032 left.distance_m
1033 .total_cmp(&right.distance_m)
1034 .then_with(|| left.edge.cmp(&right.edge))
1035 .then_with(|| left.a.cmp(&right.a))
1036 .then_with(|| left.b.cmp(&right.b))
1037}
1038
1039fn retain_reach(
1040 start: &OsmJunction,
1041 reach: &BTreeMap<OsmJunction, OsmReach>,
1042 connectors: &[OsmConnector],
1043 finder: &mut [bool],
1044) {
1045 let mut junction = start.clone();
1046 while let Some((prior, edge)) = reach
1047 .get(&junction)
1048 .and_then(|label| label.predecessor.clone())
1049 {
1050 finder[connectors[edge].draft] = true;
1051 junction = prior;
1052 }
1053}
1054
1055fn osm_trail_anchor(draft: &SegmentDraft) -> bool {
1056 draft.realm == WayRealm::Recreational
1057}
1058
1059fn osm_connector(draft: &SegmentDraft) -> bool {
1060 draft.realm == WayRealm::Urban
1061}
1062
1063#[derive(Clone, Debug, Eq, PartialEq)]
1064pub struct OsmInventory {
1065 pub trail_segments: usize,
1066 pub road_features: usize,
1067 pub waterway_features: usize,
1068}
1069
1070pub fn inspect_osm(profile: OsmProfile, raw: &str) -> Result<OsmInventory> {
1071 let trails = if matches!(profile, OsmProfile::All | OsmProfile::Trails) {
1072 osm::network_from_str(raw)?.len()
1073 } else {
1074 0
1075 };
1076 let (roads, waterways) = if matches!(
1077 profile,
1078 OsmProfile::All | OsmProfile::Roads | OsmProfile::Hydrology
1079 ) {
1080 osm::context_overlays_from_str(raw)?.into_iter().fold(
1081 (0, 0),
1082 |(roads, waterways), overlay| match overlay.kind {
1083 trailgen_core::CrossingKind::Road => (roads + 1, waterways),
1084 trailgen_core::CrossingKind::Water => (roads, waterways + 1),
1085 },
1086 )
1087 } else {
1088 (0, 0)
1089 };
1090 let inventory = OsmInventory {
1091 trail_segments: trails,
1092 road_features: roads,
1093 waterway_features: waterways,
1094 };
1095 let selected = match profile {
1096 OsmProfile::All => trails + roads + waterways,
1097 OsmProfile::Trails => trails,
1098 OsmProfile::Roads => roads,
1099 OsmProfile::Hydrology => waterways,
1100 };
1101 ensure!(
1102 selected > 0,
1103 "OpenStreetMap response contained no normalizable {} ways",
1104 profile.label()
1105 );
1106 Ok(inventory)
1107}
1108
1109#[must_use]
1110pub fn overpass_query(profile: OsmProfile, area: GeoBounds, timeout_s: u64) -> String {
1111 let bbox = format!(
1112 "({},{},{},{})",
1113 area.south, area.west, area.north, area.east
1114 );
1115 if matches!(profile, OsmProfile::All | OsmProfile::Trails) {
1116 return trail_overpass_query(&bbox, timeout_s, profile == OsmProfile::All);
1117 }
1118 let selectors = match profile {
1119 OsmProfile::Roads => OSM_ROAD_SELECTORS.to_vec(),
1120 OsmProfile::Hydrology => OSM_HYDROLOGY_SELECTORS.to_vec(),
1121 OsmProfile::All | OsmProfile::Trails => unreachable!("trail profiles handled above"),
1122 };
1123 let mut query = format!("[out:xml][timeout:{timeout_s}];\n(\n");
1124 for selector in selectors {
1125 query.push_str(" ");
1126 query.push_str(selector);
1127 query.push_str(&bbox);
1128 query.push_str(";\n");
1129 }
1130 query.push_str(");\n(._;>;);\nout body;\n");
1131 query
1132}
1133
1134fn trail_overpass_query(bbox: &str, timeout_s: u64, context: bool) -> String {
1135 let mut query = format!("[out:xml][timeout:{timeout_s}];\n(\n");
1136 for selector in OSM_TRAIL_SELECTORS {
1137 writeln!(query, " {selector}{bbox};").expect("write to string");
1138 }
1139 query.push_str(
1140 ")->.trailways;\n\
1141 rel(bw.trailways)[\"type\"=\"route\"][\"route\"~\"^(hiking|foot|walking)$\"]->.routes;\n\
1142 rel(bw.trailways)[\"type\"=\"restriction\"][\"restriction:foot\"]->.restrictions;\n",
1143 );
1144 if context {
1145 query.push_str("node(w.trailways)->.trailnodes;\n(\n");
1146 writeln!(query, " {}{bbox};", OSM_ROAD_SELECTORS[0]).expect("write to string");
1147 let hydrology = OSM_HYDROLOGY_SELECTORS[0]
1148 .strip_prefix("way")
1149 .expect("way selector");
1150 writeln!(query, " way(bn.trailnodes){hydrology};").expect("write to string");
1151 query.push_str(
1152 ")->.context;\n\
1153 (.trailways; .routes; .restrictions; .context; .trailways >; .context >;);\n",
1154 );
1155 } else {
1156 query.push_str("(.trailways; .routes; .restrictions; .trailways >;);\n");
1157 }
1158 query.push_str("out body;\n");
1159 query
1160}
1161
1162#[must_use]
1163pub const fn overpass_selector_count(profile: OsmProfile) -> usize {
1164 match profile {
1165 OsmProfile::All => {
1166 OSM_TRAIL_SELECTORS.len() + OSM_ROAD_SELECTORS.len() + OSM_HYDROLOGY_SELECTORS.len() + 2
1167 }
1168 OsmProfile::Trails => OSM_TRAIL_SELECTORS.len() + 2,
1169 OsmProfile::Roads => OSM_ROAD_SELECTORS.len(),
1170 OsmProfile::Hydrology => OSM_HYDROLOGY_SELECTORS.len(),
1171 }
1172}
1173
1174#[derive(Deserialize)]
1175struct NominatimReply {
1176 licence: String,
1177 lat: String,
1178 lon: String,
1179 display_name: String,
1180}
1181
1182#[derive(Clone, Debug, Deserialize, Serialize)]
1183struct TrailIndex {
1184 schema: u8,
1185 summary: Summary,
1186 sources: Vec<ProviderReceipt>,
1187 #[serde(default)]
1188 elevation: Vec<terrain::TerrainReceipt>,
1189 graph: SourceFingerprint,
1190}
1191
1192#[derive(Clone, Debug, Deserialize, Serialize)]
1193struct ProviderReceipt {
1194 provider: ProviderId,
1195 adapter_revision: u16,
1196 region: SurveyRegion,
1197 raw_path: PathBuf,
1198 raw: SourceFingerprint,
1199}
1200
1201#[derive(Clone, Debug, Deserialize, Serialize)]
1202struct ProviderArtifact {
1203 schema: u8,
1204 provider: ProviderId,
1205 adapter_revision: u16,
1206 region: SurveyRegion,
1207 origin: String,
1208 request: String,
1209 raw: SourceFingerprint,
1210}
1211
1212struct CachedProvider {
1213 bytes: Vec<u8>,
1214 origin: String,
1215}
1216
1217struct ProviderSource {
1218 descriptor: ProviderDescriptor,
1219 region: SurveyRegion,
1220 raw_relative: PathBuf,
1221 fingerprint: SourceFingerprint,
1222 bytes: Vec<u8>,
1223 origin: String,
1224}
1225
1226#[derive(Clone, Copy, Deserialize)]
1227#[serde(default)]
1228struct GraphLaw {
1229 snap_tolerance_m: f64,
1230 conflation: trailgen_core::ConflationPolicy,
1231 enrichment: EnrichmentConfig,
1232}
1233
1234impl Default for GraphLaw {
1235 fn default() -> Self {
1236 Self {
1237 snap_tolerance_m: DEFAULT_SNAP_TOLERANCE_M,
1238 conflation: trailgen_core::ConflationPolicy::default(),
1239 enrichment: EnrichmentConfig::default(),
1240 }
1241 }
1242}
1243
1244fn index_corpus(
1245 project: &Path,
1246 config: &TrailDataConfig,
1247 sources: &[ProviderSource],
1248 providers: &[&dyn NetworkProvider],
1249 terrain: &[terrain::TerrainSource],
1250) -> Result<Summary> {
1251 let corpus_bytes = sources
1252 .iter()
1253 .map(|source| source.bytes.len() as u64)
1254 .sum::<u64>();
1255 ensure!(
1256 corpus_bytes <= MAX_SOURCE_BYTES * providers.len().max(1) as u64 * 4,
1257 "live trail corpus exceeds {} MiB",
1258 MAX_SOURCE_BYTES * providers.len().max(1) as u64 * 4 / 1_048_576
1259 );
1260 let mut strata = Vec::with_capacity(providers.len());
1261 let mut overlays = Vec::new();
1262 for provider in providers {
1263 let descriptor = provider.descriptor();
1264 let shards = sources
1265 .iter()
1266 .filter(|source| source.descriptor.id == descriptor.id)
1267 .map(|source| RawShard {
1268 region: &source.region,
1269 bytes: &source.bytes,
1270 })
1271 .collect::<Vec<_>>();
1272 let normalized = provider.normalize(&shards)?;
1273 strata.push(trailgen_core::NetworkStratum {
1274 precedence: descriptor.precedence,
1275 drafts: clip_drafts(normalized.drafts, &config.regions),
1276 });
1277 overlays.extend(clip_overlays(normalized.context, &config.regions));
1278 }
1279 let law = read_graph_law(project)?;
1280 let conflated = trailgen_core::conflate(strata, law.conflation);
1281 let drafts = conflated.drafts;
1282 ensure!(
1283 !drafts.is_empty(),
1284 "the live area contains no routable trails"
1285 );
1286 let inventory = Inventory {
1287 trail_segments: drafts.len(),
1288 road_features: overlays
1289 .iter()
1290 .filter(|overlay| overlay.kind == CrossingKind::Road)
1291 .count(),
1292 waterway_features: overlays
1293 .iter()
1294 .filter(|overlay| overlay.kind == CrossingKind::Water)
1295 .count(),
1296 };
1297 let graph = forge_graph(&drafts, &overlays, terrain, law)?;
1298 let graph_cache = encode_graph(&graph)?;
1299 let graph_fingerprint = fingerprint(&graph_cache);
1300 clear_graph_auxiliaries(project)?;
1301 write_json_atomic(project.join(CONFLATION_REPORT), &conflated.report)?;
1302 let bounds = live_bounds(&config.regions).context("live area has no bounds")?;
1303 store_area(project, Some(bounds))?;
1304 write_source_manifest(project, sources, &inventory, bounds)?;
1305 let summary = Summary {
1306 regions: config.regions.clone(),
1307 providers: config.providers.clone(),
1308 inventory,
1309 vertices: graph.vertices.len(),
1310 edges: graph.edges.len(),
1311 elevation_tiles: terrain.len(),
1312 raw_paths: sources
1313 .iter()
1314 .map(|source| source.raw_relative.clone())
1315 .collect(),
1316 conflation: trailgen_core::ConflationStats::from(&conflated.report),
1317 reused: false,
1318 };
1319 let receipts = sources
1320 .iter()
1321 .map(|source| ProviderReceipt {
1322 provider: source.descriptor.id.clone(),
1323 adapter_revision: source.descriptor.adapter_revision,
1324 region: source.region.clone(),
1325 raw_path: source.raw_relative.clone(),
1326 raw: source.fingerprint.clone(),
1327 })
1328 .collect();
1329 write_json_atomic(
1330 project.join(TRAIL_INDEX),
1331 &TrailIndex {
1332 schema: INDEX_SCHEMA,
1333 summary: summary.clone(),
1334 sources: receipts,
1335 elevation: terrain
1336 .iter()
1337 .map(|source| source.receipt.clone())
1338 .collect(),
1339 graph: graph_fingerprint,
1340 },
1341 )?;
1342 write_atomic(&project.join(GRAPH_CACHE), &graph_cache)?;
1345 Ok(summary)
1346}
1347
1348fn forge_graph(
1349 drafts: &[SegmentDraft],
1350 overlays: &[ContextOverlay],
1351 terrain: &[terrain::TerrainSource],
1352 law: GraphLaw,
1353) -> Result<WalkGraph> {
1354 let mut graph = GraphBuilder {
1355 snap_tolerance_m: law.snap_tolerance_m,
1356 enrichment: law.enrichment,
1357 }
1358 .build(drafts)
1359 .context("index live-area trail topology")?;
1360 apply_context_overlays(&mut graph, overlays);
1361 if let Some(atlas) = terrain::TerrainAtlas::decode(terrain)? {
1362 trailgen_core::enrich_graph(&mut graph, &atlas, law.enrichment)
1363 .context("sample live-area topography")?;
1364 }
1365 Ok(graph)
1366}
1367
1368fn merge_osm(sources: &[RawShard<'_>]) -> Result<String> {
1369 struct Object {
1370 version: u64,
1371 xml: String,
1372 }
1373
1374 let mut objects = BTreeMap::<(u8, String), Object>::new();
1375 for source in sources {
1376 let raw =
1377 std::str::from_utf8(source.bytes).context("OpenStreetMap response is not UTF-8 XML")?;
1378 let document = roxmltree::Document::parse(raw).context("parse region OSM XML")?;
1379 let root = document.root_element();
1380 ensure!(root.has_tag_name("osm"), "region source has no OSM root");
1381 for node in root.children().filter(roxmltree::Node::is_element) {
1382 let rank = match node.tag_name().name() {
1383 "node" => 0,
1384 "way" => 1,
1385 "relation" => 2,
1386 _ => continue,
1387 };
1388 let Some(id) = node.attribute("id") else {
1389 continue;
1390 };
1391 let version = node
1392 .attribute("version")
1393 .and_then(|value| value.parse().ok())
1394 .unwrap_or(0);
1395 let xml = raw[node.range()].to_owned();
1396 let key = (rank, id.to_owned());
1397 match objects.get_mut(&key) {
1398 Some(known)
1399 if version > known.version || (version == known.version && xml > known.xml) =>
1400 {
1401 *known = Object { version, xml };
1402 }
1403 Some(_) => {}
1404 None => {
1405 objects.insert(key, Object { version, xml });
1406 }
1407 }
1408 }
1409 }
1410 let mut merged = String::from(
1411 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<osm version=\"0.6\" generator=\"trailgen-corpus\">\n",
1412 );
1413 for object in objects.into_values() {
1414 merged.push_str(&object.xml);
1415 merged.push('\n');
1416 }
1417 merged.push_str("</osm>\n");
1418 Ok(merged)
1419}
1420
1421fn clip_drafts(drafts: Vec<SegmentDraft>, regions: &[SurveyRegion]) -> Vec<SegmentDraft> {
1422 drafts
1423 .into_iter()
1424 .flat_map(|draft| {
1425 clip_line(&draft.geometry, regions)
1426 .into_iter()
1427 .map(move |geometry| draft.fragment(geometry))
1428 })
1429 .collect()
1430}
1431
1432fn clip_overlays(overlays: Vec<ContextOverlay>, regions: &[SurveyRegion]) -> Vec<ContextOverlay> {
1433 overlays
1434 .into_iter()
1435 .flat_map(|overlay| {
1436 clip_line(&overlay.geometry, regions)
1437 .into_iter()
1438 .map(move |geometry| {
1439 let mut clipped = overlay.clone();
1440 clipped.geometry = geometry;
1441 clipped
1442 })
1443 })
1444 .collect()
1445}
1446
1447fn clip_line(line: &LineString, regions: &[SurveyRegion]) -> Vec<LineString> {
1448 let mut result = Vec::new();
1449 let mut points = Vec::new();
1450 for segment in line.points.windows(2) {
1451 let [a, b] = [segment[0], segment[1]];
1452 let mut cuts = vec![0.0, 1.0];
1453 for region in regions {
1454 let dx = b.lon - a.lon;
1455 if dx.abs() > f64::EPSILON {
1456 cuts.extend(
1457 [region.bounds.west, region.bounds.east]
1458 .map(|lon| (lon - a.lon) / dx)
1459 .into_iter()
1460 .filter(|t| (0.0..=1.0).contains(t)),
1461 );
1462 }
1463 let dy = b.lat - a.lat;
1464 if dy.abs() > f64::EPSILON {
1465 cuts.extend(
1466 [region.bounds.south, region.bounds.north]
1467 .map(|lat| (lat - a.lat) / dy)
1468 .into_iter()
1469 .filter(|t| (0.0..=1.0).contains(t)),
1470 );
1471 }
1472 }
1473 cuts.sort_by(f64::total_cmp);
1474 cuts.dedup_by(|left, right| (*left - *right).abs() <= 1.0e-12);
1475 for interval in cuts.windows(2) {
1476 let [from, to] = [interval[0], interval[1]];
1477 if to - from <= 1.0e-12 {
1478 continue;
1479 }
1480 let midpoint = a.lerp(b, (from + to) * 0.5);
1481 if regions
1482 .iter()
1483 .any(|region| contains(region.bounds, midpoint))
1484 {
1485 append_clipped_segment(&mut result, &mut points, a.lerp(b, from), a.lerp(b, to));
1486 } else {
1487 seal_line(&mut result, &mut points);
1488 }
1489 }
1490 }
1491 seal_line(&mut result, &mut points);
1492 result
1493}
1494
1495fn append_clipped_segment(
1496 result: &mut Vec<LineString>,
1497 points: &mut Vec<Coord>,
1498 start: Coord,
1499 end: Coord,
1500) {
1501 let joins = points
1502 .last()
1503 .is_some_and(|last| same_location(*last, start));
1504 if !joins {
1505 seal_line(result, points);
1506 points.push(start);
1507 }
1508 if points.last().is_none_or(|last| !same_location(*last, end)) {
1509 points.push(end);
1510 }
1511}
1512
1513fn seal_line(result: &mut Vec<LineString>, points: &mut Vec<Coord>) {
1514 if points.len() >= 2 {
1515 result.push(LineString::unchecked(std::mem::take(points)));
1516 } else {
1517 points.clear();
1518 }
1519}
1520
1521fn contains(bounds: GeoBounds, coord: Coord) -> bool {
1522 bounds.west <= coord.lon
1523 && coord.lon <= bounds.east
1524 && bounds.south <= coord.lat
1525 && coord.lat <= bounds.north
1526}
1527
1528const fn same_location(left: Coord, right: Coord) -> bool {
1529 left.lon.to_bits() == right.lon.to_bits() && left.lat.to_bits() == right.lat.to_bits()
1530}
1531
1532fn cached_provider(
1533 raw_path: &Path,
1534 request_path: &Path,
1535 artifact_path: &Path,
1536 region: &SurveyRegion,
1537 descriptor: &ProviderDescriptor,
1538) -> Result<Option<CachedProvider>> {
1539 let bytes = match fs::read(raw_path) {
1540 Ok(bytes) => bytes,
1541 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1542 Err(err) => {
1543 return Err(err).with_context(|| format!("read trail source {}", raw_path.display()));
1544 }
1545 };
1546 let raw = match fs::read_to_string(artifact_path) {
1547 Ok(raw) => raw,
1548 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1549 Err(err) => {
1550 return Err(err)
1551 .with_context(|| format!("read trail-source index {}", artifact_path.display()));
1552 }
1553 };
1554 let Ok(artifact) = serde_json::from_str::<ProviderArtifact>(&raw) else {
1555 return Ok(None);
1556 };
1557 let request = match fs::read_to_string(request_path) {
1558 Ok(request) => request,
1559 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1560 Err(err) => {
1561 return Err(err)
1562 .with_context(|| format!("read trail-source request {}", request_path.display()));
1563 }
1564 };
1565 if artifact.schema != RAW_SCHEMA
1566 || artifact.provider != descriptor.id
1567 || artifact.adapter_revision != descriptor.adapter_revision
1568 || &artifact.region != region
1569 || artifact.request != request
1570 || artifact.raw != fingerprint(&bytes)
1571 {
1572 return Ok(None);
1573 }
1574 Ok(Some(CachedProvider {
1575 bytes,
1576 origin: artifact.origin,
1577 }))
1578}
1579
1580fn cached_place(project: &Path, query: &str) -> Result<Option<Place>> {
1581 let path = project.join(LOCATION_CACHE);
1582 match fs::read_to_string(&path) {
1583 Ok(raw) => {
1584 let Ok(place) = serde_json::from_str::<Place>(&raw) else {
1585 return Ok(None);
1586 };
1587 Ok(place
1588 .query
1589 .trim()
1590 .eq_ignore_ascii_case(query.trim())
1591 .then_some(place))
1592 }
1593 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
1594 Err(err) => Err(err).with_context(|| format!("read location cache {}", path.display())),
1595 }
1596}
1597
1598fn reusable_index(
1599 project: &Path,
1600 config: &TrailDataConfig,
1601 descriptors: Option<&[ProviderDescriptor]>,
1602 terrain_expected: bool,
1603) -> Result<Option<Summary>> {
1604 let index_path = project.join(TRAIL_INDEX);
1605 let raw = match fs::read_to_string(&index_path) {
1606 Ok(raw) => raw,
1607 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1608 Err(err) => {
1609 return Err(err).with_context(|| format!("read trail index {}", index_path.display()));
1610 }
1611 };
1612 let Ok(index) = serde_json::from_str::<TrailIndex>(&raw) else {
1613 return Ok(None);
1614 };
1615 if !index_matches_config(&index, config)
1616 || !project.join(GRAPH_CACHE).is_file()
1617 || !project.join(CONFLATION_REPORT).is_file()
1618 {
1619 return Ok(None);
1620 }
1621 let mut receipts = BTreeSet::new();
1622 for receipt in &index.sources {
1623 let current = descriptors.and_then(|descriptors| {
1624 descriptors
1625 .iter()
1626 .find(|descriptor| descriptor.id == receipt.provider)
1627 });
1628 if !config.regions.contains(&receipt.region)
1629 || !config.providers.contains(&receipt.provider)
1630 || receipt.adapter_revision == 0
1631 || current.is_some_and(|descriptor| {
1632 receipt.adapter_revision != descriptor.adapter_revision
1633 || receipt.raw_path
1634 != PathBuf::from("sources")
1635 .join(descriptor.id.as_str())
1636 .join(format!("{}.{}", receipt.region.id, descriptor.extension))
1637 })
1638 || !receipts.insert((receipt.provider.clone(), receipt.region.id.clone()))
1639 {
1640 return Ok(None);
1641 }
1642 let bytes = match fs::read(project.join(&receipt.raw_path)) {
1643 Ok(bytes) => bytes,
1644 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1645 Err(err) => return Err(err).context("read region source receipt"),
1646 };
1647 if fingerprint(&bytes) != receipt.raw {
1648 return Ok(None);
1649 }
1650 }
1651 if receipts.len() != config.regions.len() * config.providers.len() {
1652 return Ok(None);
1653 }
1654 if terrain_expected {
1655 let desired = terrain::desired_tiles(&config.regions);
1656 if index.elevation.len() != desired.len()
1657 || index
1658 .elevation
1659 .iter()
1660 .map(|receipt| receipt.tile)
1661 .collect::<Vec<_>>()
1662 != desired
1663 {
1664 return Ok(None);
1665 }
1666 for receipt in &index.elevation {
1667 let bytes = match fs::read(project.join(&receipt.raw_path)) {
1668 Ok(bytes) => bytes,
1669 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1670 Err(err) => return Err(err).context("read terrain receipt"),
1671 };
1672 if fingerprint(&bytes) != receipt.raw {
1673 return Ok(None);
1674 }
1675 }
1676 } else if !index.elevation.is_empty() {
1677 return Ok(None);
1678 }
1679 let graph_bytes = fs::read(project.join(GRAPH_CACHE)).context("read cached trail graph")?;
1680 if fingerprint(&graph_bytes) != index.graph {
1681 return Ok(None);
1682 }
1683 let Ok(graph) = decode_graph(&graph_bytes) else {
1684 return Ok(None);
1685 };
1686 if graph.vertices.len() != index.summary.vertices || graph.edges.len() != index.summary.edges {
1687 return Ok(None);
1688 }
1689 Ok(Some(index.summary))
1690}
1691
1692fn index_matches_config(index: &TrailIndex, config: &TrailDataConfig) -> bool {
1693 let mut receipts = BTreeSet::new();
1694 let lawful_receipts = index.sources.iter().all(|receipt| {
1695 config.regions.contains(&receipt.region)
1696 && config.providers.contains(&receipt.provider)
1697 && receipt.adapter_revision != 0
1698 && receipts.insert((receipt.provider.clone(), receipt.region.id.clone()))
1699 });
1700 let raw_paths = index
1701 .sources
1702 .iter()
1703 .map(|receipt| receipt.raw_path.as_path())
1704 .collect::<BTreeSet<_>>();
1705 index.schema == INDEX_SCHEMA
1706 && index.summary.regions == config.regions
1707 && index.summary.providers == config.providers
1708 && index.sources.len() == config.regions.len() * config.providers.len()
1709 && lawful_receipts
1710 && receipts.len() == index.sources.len()
1711 && raw_paths.len() == index.sources.len()
1712 && raw_paths
1713 == index
1714 .summary
1715 .raw_paths
1716 .iter()
1717 .map(PathBuf::as_path)
1718 .collect()
1719}
1720
1721fn bounds_around(place: &Place, radius_km: f64) -> Result<GeoBounds> {
1722 validate_radius(radius_km)?;
1723 let lat_radius = radius_km / 111.32;
1724 let longitude_scale = place.center.lat.to_radians().cos().abs().max(0.05);
1725 let lon_radius = radius_km / (111.32 * longitude_scale);
1726 let bounds = GeoBounds::new(
1727 (place.center.lon - lon_radius).max(-180.0),
1728 (place.center.lat - lat_radius).max(-90.0),
1729 (place.center.lon + lon_radius).min(180.0),
1730 (place.center.lat + lat_radius).min(90.0),
1731 );
1732 validate_region(bounds)?;
1733 Ok(bounds)
1734}
1735
1736fn validate_radius(radius_km: f64) -> Result<()> {
1737 ensure!(
1738 radius_km.is_finite() && (MIN_RADIUS_KM..=MAX_RADIUS_KM).contains(&radius_km),
1739 "trail survey radius must be within {MIN_RADIUS_KM}–{MAX_RADIUS_KM} km"
1740 );
1741 Ok(())
1742}
1743
1744pub fn validate_region(bounds: GeoBounds) -> Result<()> {
1745 let area = (bounds.east - bounds.west) * (bounds.north - bounds.south);
1746 ensure!(
1747 bounds.is_valid(),
1748 "survey region has invalid lon/lat bounds"
1749 );
1750 ensure!(
1751 area <= MAX_REGION_DEG2,
1752 "survey region spans {area:.2} square degrees; limit is {MAX_REGION_DEG2:.2}"
1753 );
1754 Ok(())
1755}
1756
1757fn validate_project(project: &Path) -> Result<()> {
1758 ensure!(
1759 project.join("trailgen.toml").is_file(),
1760 "{} is not a trailgen project",
1761 project.display()
1762 );
1763 Ok(())
1764}
1765
1766fn region_key(bounds: GeoBounds) -> String {
1767 let digest = Sha256::digest(
1768 format!(
1769 "trail-region-v1:{:.8}:{:.8}:{:.8}:{:.8}",
1770 bounds.west, bounds.south, bounds.east, bounds.north
1771 )
1772 .as_bytes(),
1773 );
1774 hex(&digest[..12])
1775}
1776
1777#[must_use]
1778pub fn live_bounds(regions: &[SurveyRegion]) -> Option<GeoBounds> {
1779 regions
1780 .iter()
1781 .map(|region| region.bounds)
1782 .reduce(|left, right| {
1783 GeoBounds::new(
1784 left.west.min(right.west),
1785 left.south.min(right.south),
1786 left.east.max(right.east),
1787 left.north.max(right.north),
1788 )
1789 })
1790}
1791
1792fn read_graph_law(project: &Path) -> Result<GraphLaw> {
1793 let path = project.join("trailgen.toml");
1794 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1795 toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))
1796}
1797
1798pub fn project_config(project: &Path) -> Result<TrailDataConfig> {
1800 #[derive(Deserialize)]
1801 struct ProjectConfig {
1802 #[serde(default)]
1803 trail_data: TrailDataConfig,
1804 #[serde(default)]
1805 area: Option<GeoBounds>,
1806 }
1807
1808 let path = project.join("trailgen.toml");
1809 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1810 let document =
1811 toml::from_str::<toml::Value>(&raw).with_context(|| format!("parse {}", path.display()))?;
1812 let parsed = toml::from_str::<ProjectConfig>(&raw)
1813 .with_context(|| format!("parse {}", path.display()))?;
1814 let mut config = parsed.trail_data;
1815 if config.providers == legacy_automatic_provider_ids() {
1816 config.providers = automatic_provider_ids();
1817 }
1818 let mut names = std::mem::take(&mut config.region_names);
1819 for region in &mut config.regions {
1820 validate_region(region.bounds)?;
1821 let legacy_id = region.id.clone();
1822 region.id = region_key(region.bounds);
1823 if legacy_id != region.id
1824 && let Some(name) = names.remove(&legacy_id)
1825 {
1826 let _replaced = names.insert(region.id.clone(), name);
1827 }
1828 }
1829 config.region_names = names;
1830 let legacy_place = document
1831 .get("trail_data")
1832 .and_then(|trail_data| trail_data.get("place"))
1833 .and_then(toml::Value::as_str)
1834 .is_some_and(|place| !place.trim().is_empty());
1835 if config.regions.is_empty()
1836 && legacy_place
1837 && let Some(bounds) = parsed.area
1838 {
1839 config.regions.push(SurveyRegion::new(bounds)?);
1840 }
1841 config.managed |= legacy_place || !config.regions.is_empty();
1842 validate_config(&config)?;
1843 Ok(config)
1844}
1845
1846pub fn indexed_summary(project: &Path) -> Result<Option<Summary>> {
1848 let path = project.join(TRAIL_INDEX);
1849 let raw = match fs::read_to_string(&path) {
1850 Ok(raw) => raw,
1851 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1852 Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
1853 };
1854 let Ok(index) = serde_json::from_str::<TrailIndex>(&raw) else {
1855 return Ok(None);
1856 };
1857 if index.schema != INDEX_SCHEMA {
1858 return Ok(None);
1859 }
1860 let config = project_config(project)?;
1861 if !index_matches_config(&index, &config) {
1862 return Ok(None);
1863 }
1864 let graph = match fs::metadata(project.join(GRAPH_CACHE)) {
1865 Ok(graph) => graph,
1866 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1867 Err(err) => return Err(err).context("inspect cached trail graph"),
1868 };
1869 if !graph.is_file()
1870 || graph.len() != index.graph.bytes
1871 || !project.join(CONFLATION_REPORT).is_file()
1872 {
1873 return Ok(None);
1874 }
1875 if index.summary.elevation_tiles != index.elevation.len() {
1876 return Ok(None);
1877 }
1878 for receipt in &index.elevation {
1879 let bytes = match fs::read(project.join(&receipt.raw_path)) {
1880 Ok(bytes) => bytes,
1881 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1882 Err(err) => return Err(err).context("read terrain receipt"),
1883 };
1884 if fingerprint(&bytes) != receipt.raw {
1885 return Ok(None);
1886 }
1887 }
1888 Ok(Some(index.summary))
1889}
1890
1891pub fn configure_project(project: &Path, trail_data: &TrailDataConfig) -> Result<()> {
1893 validate_config(trail_data)?;
1894 let path = project.join("trailgen.toml");
1895 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1896 let mut config =
1897 toml::from_str::<toml::Value>(&raw).with_context(|| format!("parse {}", path.display()))?;
1898 config
1899 .as_table_mut()
1900 .context("trailgen.toml root must be a table")?
1901 .insert("trail_data".to_owned(), toml::Value::try_from(trail_data)?);
1902 write_atomic(&path, toml::to_string_pretty(&config)?.as_bytes())
1903}
1904
1905pub fn name_region(project: &Path, id: &str, name: &str) -> Result<TrailDataConfig> {
1907 validate_project(project)?;
1908 let mut config = project_config(project)?;
1909 ensure!(
1910 config.regions.iter().any(|region| region.id == id),
1911 "project has no survey region {id}"
1912 );
1913 let name = name.trim();
1914 if name.is_empty() {
1915 let _old = config.region_names.remove(id);
1916 } else {
1917 validate_region_name(name)?;
1918 let _old = config.region_names.insert(id.to_owned(), name.to_owned());
1919 }
1920 configure_project(project, &config)?;
1921 Ok(config)
1922}
1923
1924fn validate_config(config: &TrailDataConfig) -> Result<()> {
1925 let mut ids = BTreeSet::new();
1926 for region in &config.regions {
1927 region.validate()?;
1928 ensure!(
1929 ids.insert(®ion.id),
1930 "duplicate survey region {}",
1931 region.id
1932 );
1933 }
1934 for (id, name) in &config.region_names {
1935 ensure!(
1936 ids.contains(id),
1937 "map-area name refers to unknown region {id}"
1938 );
1939 validate_region_name(name)?;
1940 }
1941 let providers = config.providers.iter().collect::<BTreeSet<_>>();
1942 ensure!(
1943 !providers.is_empty(),
1944 "trail data needs at least one provider"
1945 );
1946 ensure!(
1947 providers.len() == config.providers.len(),
1948 "trail data contains duplicate providers"
1949 );
1950 Ok(())
1951}
1952
1953fn validate_region_name(name: &str) -> Result<()> {
1954 ensure!(!name.trim().is_empty(), "map-area name is empty");
1955 ensure!(name.chars().count() <= 80, "map-area name is too long");
1956 ensure!(
1957 !name.chars().any(char::is_control),
1958 "map-area name contains control characters"
1959 );
1960 Ok(())
1961}
1962
1963fn store_area(project: &Path, bounds: Option<GeoBounds>) -> Result<()> {
1964 let path = project.join("trailgen.toml");
1965 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
1966 let mut config =
1967 toml::from_str::<toml::Value>(&raw).with_context(|| format!("parse {}", path.display()))?;
1968 let table = config
1969 .as_table_mut()
1970 .context("trailgen.toml root must be a table")?;
1971 if let Some(bounds) = bounds {
1972 table.insert("area".to_owned(), toml::Value::try_from(bounds)?);
1973 } else {
1974 table.remove("area");
1975 }
1976 write_atomic(&path, toml::to_string_pretty(&config)?.as_bytes())
1977}
1978
1979fn write_source_manifest(
1980 project: &Path,
1981 sources: &[ProviderSource],
1982 inventory: &Inventory,
1983 bounds: GeoBounds,
1984) -> Result<()> {
1985 let manifest_path = project.join(SOURCE_MANIFEST);
1986 let mut manifest = match fs::read_to_string(&manifest_path) {
1987 Ok(raw) => serde_json::from_str::<SourceManifest>(&raw)
1988 .with_context(|| format!("parse {}", manifest_path.display()))?,
1989 Err(err) if err.kind() == std::io::ErrorKind::NotFound => SourceManifest {
1990 adapters: Vec::new(),
1991 recommendations: Vec::new(),
1992 coverage: Vec::new(),
1993 candidates: Vec::new(),
1994 },
1995 Err(err) => {
1996 return Err(err).with_context(|| format!("read {}", manifest_path.display()));
1997 }
1998 };
1999 manifest
2000 .candidates
2001 .retain(|candidate| !is_live_provider_candidate(candidate));
2002 for source in sources {
2003 let raw_path = source.raw_relative.display().to_string();
2004 let region = source.region.bounds;
2005 let origin = format!(
2006 "provider:{}:{} bbox={},{},{},{}",
2007 source.descriptor.id,
2008 source.origin,
2009 region.west,
2010 region.south,
2011 region.east,
2012 region.north
2013 );
2014 manifest.candidates.push(candidate(
2015 &raw_path,
2016 SourceKind::TrailNetwork,
2017 if source.descriptor.id.as_str() == "osm" {
2018 "osm-xml-network"
2019 } else {
2020 "geojson-network"
2021 },
2022 &source.fingerprint,
2023 &origin,
2024 ));
2025 if source.descriptor.id.as_str() == "osm" && inventory.road_features > 0 {
2026 manifest.candidates.push(candidate(
2027 &raw_path,
2028 SourceKind::Road,
2029 "osm-road-context",
2030 &source.fingerprint,
2031 &origin,
2032 ));
2033 }
2034 if source.descriptor.id.as_str() == "osm" && inventory.waterway_features > 0 {
2035 manifest.candidates.push(candidate(
2036 &raw_path,
2037 SourceKind::Hydrology,
2038 "osm-hydrology-context",
2039 &source.fingerprint,
2040 &origin,
2041 ));
2042 }
2043 }
2044 manifest.candidates.sort_by(|left, right| {
2045 (&left.path, left.kind, &left.adapter_id).cmp(&(&right.path, right.kind, &right.adapter_id))
2046 });
2047 manifest.adapters = adapter_registry();
2048 manifest.recommendations = discovery_recommendations(Some(bounds));
2049 manifest.coverage = source_coverage(
2050 &manifest.adapters,
2051 &manifest.recommendations,
2052 &manifest.candidates,
2053 );
2054 write_json_atomic(manifest_path, &manifest)
2055}
2056
2057fn clear_corpus(project: &Path) -> Result<()> {
2058 clear_graph_auxiliaries(project)?;
2059 remove_files(project, &[TRAIL_INDEX, GRAPH_CACHE, CONFLATION_REPORT])?;
2060 store_area(project, None)?;
2061 let manifest_path = project.join(SOURCE_MANIFEST);
2062 match fs::read_to_string(&manifest_path) {
2063 Ok(raw) => {
2064 let mut manifest = serde_json::from_str::<SourceManifest>(&raw)
2065 .with_context(|| format!("parse {}", manifest_path.display()))?;
2066 manifest
2067 .candidates
2068 .retain(|candidate| !is_live_provider_candidate(candidate));
2069 manifest.adapters = adapter_registry();
2070 manifest.recommendations = discovery_recommendations(None);
2071 manifest.coverage = source_coverage(
2072 &manifest.adapters,
2073 &manifest.recommendations,
2074 &manifest.candidates,
2075 );
2076 write_json_atomic(manifest_path, &manifest)?;
2077 }
2078 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2079 Err(err) => {
2080 return Err(err).with_context(|| format!("read {}", manifest_path.display()));
2081 }
2082 }
2083 Ok(())
2084}
2085
2086fn remove_files(project: &Path, relatives: &[&str]) -> Result<()> {
2087 for relative in relatives {
2088 match fs::remove_file(project.join(relative)) {
2089 Ok(()) => {}
2090 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2091 Err(err) => return Err(err).with_context(|| format!("remove {relative}")),
2092 }
2093 }
2094 Ok(())
2095}
2096
2097fn reap_provider_receipts(
2098 project: &Path,
2099 sources: &[ProviderSource],
2100 descriptors: &[ProviderDescriptor],
2101) -> Result<()> {
2102 let desired = sources
2103 .iter()
2104 .flat_map(|source| {
2105 let raw = project.join(&source.raw_relative);
2106 [
2107 raw.clone(),
2108 raw.with_extension(source.descriptor.request_extension),
2109 raw.with_extension("json"),
2110 ]
2111 })
2112 .collect::<BTreeSet<_>>();
2113 for descriptor in descriptors {
2114 let root = project.join("sources").join(descriptor.id.as_str());
2115 let entries = match fs::read_dir(&root) {
2116 Ok(entries) => entries,
2117 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2118 Err(error) => {
2119 return Err(error).with_context(|| format!("inspect {}", root.display()));
2120 }
2121 };
2122 let owned_extensions = [descriptor.extension, descriptor.request_extension, "json"];
2123 for entry in entries {
2124 let path = entry?.path();
2125 let owned = path.is_file()
2126 && path
2127 .file_stem()
2128 .and_then(|stem| stem.to_str())
2129 .is_some_and(region_receipt_stem)
2130 && path
2131 .extension()
2132 .and_then(|extension| extension.to_str())
2133 .is_some_and(|extension| owned_extensions.contains(&extension));
2134 if owned && !desired.contains(&path) {
2135 fs::remove_file(&path)
2136 .with_context(|| format!("reap obsolete receipt {}", path.display()))?;
2137 }
2138 }
2139 }
2140 Ok(())
2141}
2142
2143fn region_receipt_stem(stem: &str) -> bool {
2144 stem.len() == 24 && stem.bytes().all(|byte| byte.is_ascii_hexdigit())
2145}
2146
2147fn is_live_provider_candidate(candidate: &SourceCandidate) -> bool {
2148 candidate
2149 .origin
2150 .as_deref()
2151 .is_some_and(|origin| origin.starts_with("provider:") || origin.starts_with("overpass:"))
2152}
2153
2154fn candidate(
2155 path: &str,
2156 kind: SourceKind,
2157 adapter_id: &str,
2158 fingerprint: &SourceFingerprint,
2159 origin: &str,
2160) -> SourceCandidate {
2161 SourceCandidate {
2162 path: path.to_owned(),
2163 kind,
2164 adapter_id: adapter_id.to_owned(),
2165 origin: Some(origin.to_owned()),
2166 fingerprint: Some(fingerprint.clone()),
2167 }
2168}
2169
2170fn parse_coordinate(raw: &str, name: &str) -> Result<f64> {
2171 raw.parse::<f64>()
2172 .with_context(|| format!("place search returned an invalid {name}"))
2173}
2174
2175fn user_agent(task: &str) -> String {
2176 format!(
2177 "adequate-trailgen/{} {task} ({})",
2178 env!("CARGO_PKG_VERSION"),
2179 env!("CARGO_PKG_REPOSITORY")
2180 )
2181}
2182
2183pub fn provider_client(task: &str, timeout: Duration) -> Result<reqwest::blocking::Client> {
2184 let builder = reqwest::blocking::Client::builder()
2185 .timeout(timeout)
2186 .user_agent(user_agent(task));
2187 #[cfg(unix)]
2188 let builder = if let Some(raw) = env::var_os("TRAILGEN_HTTP_UNIX_SOCKET") {
2189 let socket = PathBuf::from(raw);
2190 ensure!(
2191 socket.is_absolute(),
2192 "TRAILGEN_HTTP_UNIX_SOCKET must name an absolute path"
2193 );
2194 builder.unix_socket(socket)
2195 } else {
2196 builder
2197 };
2198 #[cfg(not(unix))]
2199 ensure!(
2200 env::var_os("TRAILGEN_HTTP_UNIX_SOCKET").is_none(),
2201 "TRAILGEN_HTTP_UNIX_SOCKET is unavailable on this platform"
2202 );
2203 builder.build().context("build HTTP provider client")
2204}
2205
2206fn fingerprint(bytes: &[u8]) -> SourceFingerprint {
2207 SourceFingerprint {
2208 bytes: bytes.len() as u64,
2209 sha256: hex(&Sha256::digest(bytes)),
2210 }
2211}
2212
2213pub fn store_graph(project: &Path, graph: &WalkGraph) -> Result<()> {
2215 let graph_cache = encode_graph(graph)?;
2216 clear_graph_auxiliaries(project)?;
2217 write_atomic(
2218 &project.join(GRAPH_GEOJSON),
2219 &serde_json::to_vec_pretty(&geojson::graph_to_geojson(graph))?,
2220 )?;
2221 write_atomic(
2222 &project.join("cache/edges.csv"),
2223 graph_edges_csv(graph).as_bytes(),
2224 )?;
2225 write_atomic(
2226 &project.join("cache/vertices.csv"),
2227 graph_vertices_csv(graph).as_bytes(),
2228 )?;
2229 write_atomic(&project.join(GRAPH_CACHE), &graph_cache)
2230}
2231
2232fn clear_graph_auxiliaries(project: &Path) -> Result<()> {
2233 remove_files(project, GRAPH_AUXILIARIES)
2234}
2235
2236fn graph_vertices_csv(graph: &WalkGraph) -> String {
2237 let mut out = String::from("vertex_id,junction_id,lon,lat,elevation_m,wkt\n");
2238 for vertex in &graph.vertices {
2239 let Coord { lon, lat, ele } = vertex.coord;
2240 writeln!(
2241 out,
2242 "{},{},{lon:.7},{lat:.7},{},{}",
2243 vertex.id.0,
2244 csv_cell(vertex.junction.as_ref().map_or("", |key| key.0.as_str())),
2245 csv_f64(ele),
2246 csv_cell(&point_wkt(vertex.coord))
2247 )
2248 .expect("write to string");
2249 }
2250 out
2251}
2252
2253fn graph_edges_csv(graph: &WalkGraph) -> String {
2254 let mut out = String::from(
2255 "edge_id,from_vertex,to_vertex,travel,length_m,ascent_m,descent_m,grade_abs_mean,grade_abs_max,sustained_steep_m,hill_slope_deg,way_kind,realm,geometry_claim,crossing_control,trail_standing,trail_marking,terrain,surface,terrain_confidence,terrain_evidence,access,access_confidence,access_provenance,road_exposure,confidence,lower_limb_load_forward_km,moving_time_forward_s,lower_limb_load_reverse_km,moving_time_reverse_s,seed_count,seed_provenance,elevation_provenance,road_crossings,water_crossings,provenance,wkt\n",
2256 );
2257 for edge in &graph.edges {
2258 let (roads, water) = edge_crossing_counts(edge);
2259 let row = [
2260 edge.id.0.to_string(),
2261 edge.a.0.to_string(),
2262 edge.b.0.to_string(),
2263 edge_travel_tag(edge.attr.travel).to_owned(),
2264 format!("{:.3}", edge.attr.length_m),
2265 format!("{:.3}", edge.attr.ascent_m),
2266 format!("{:.3}", edge.attr.descent_m),
2267 format!("{:.6}", edge.attr.grade_abs_mean),
2268 format!("{:.6}", edge.attr.grade_abs_max),
2269 format!("{:.3}", edge.attr.sustained_steep_m),
2270 csv_f64(edge.attr.hill_slope_deg),
2271 way_kind_tag(edge.attr.way_kind).to_owned(),
2272 way_realm_tag(edge.attr.realm).to_owned(),
2273 geometry_claim_tag(edge.attr.geometry_claim).to_owned(),
2274 crossing_control_tag(edge.attr.crossing_control).to_owned(),
2275 trail_standing_tag(edge.attr.standing).to_owned(),
2276 trail_marking_tag(edge.attr.marking).to_owned(),
2277 terrain_tag(edge.attr.terrain).to_owned(),
2278 csv_cell(edge.attr.surface.as_deref().unwrap_or("")),
2279 format!("{:.6}", edge.attr.terrain_confidence),
2280 csv_cell(&terrain_evidence_summary(&edge.attr.terrain_evidence)),
2281 access_tag(edge.attr.access).to_owned(),
2282 format!("{:.6}", edge.attr.access_confidence),
2283 csv_cell(&provenance_summary(&edge.attr.access_provenance)),
2284 format!("{:.6}", edge.attr.road_exposure),
2285 format!("{:.6}", edge.attr.confidence),
2286 format!("{:.6}", edge.attr.traversal.forward.lower_limb_load_km),
2287 format!("{:.3}", edge.attr.traversal.forward.moving_time_s),
2288 format!("{:.6}", edge.attr.traversal.reverse.lower_limb_load_km),
2289 format!("{:.3}", edge.attr.traversal.reverse.moving_time_s),
2290 edge.attr.seed_count.to_string(),
2291 csv_cell(&provenance_summary(&edge.attr.seed_provenance)),
2292 csv_cell(&provenance_summary(&edge.attr.elevation_provenance)),
2293 roads.to_string(),
2294 water.to_string(),
2295 csv_cell(&provenance_summary(&edge.attr.provenance)),
2296 csv_cell(&line_wkt(&edge.geometry)),
2297 ];
2298 writeln!(out, "{}", row.join(",")).expect("write to string");
2299 }
2300 out
2301}
2302
2303fn terrain_evidence_summary(evidence: &[TerrainEvidence]) -> String {
2304 evidence
2305 .iter()
2306 .map(|evidence| {
2307 let mut summary = format!(
2308 "{}:{:.0}%:{}",
2309 terrain_tag(evidence.terrain),
2310 evidence.confidence * 100.0,
2311 evidence.rationale
2312 );
2313 if let Some(provenance) = &evidence.provenance {
2314 write!(summary, ":{}", provenance_csv_label(provenance)).expect("write to string");
2315 }
2316 summary
2317 })
2318 .collect::<Vec<_>>()
2319 .join("|")
2320}
2321
2322fn edge_crossing_counts(edge: &Edge) -> (u32, u32) {
2323 edge.attr
2324 .crossings
2325 .iter()
2326 .fold((0, 0), |(roads, water), crossing| match crossing.kind {
2327 CrossingKind::Road => (roads + crossing.count, water),
2328 CrossingKind::Water => (roads, water + crossing.count),
2329 })
2330}
2331
2332fn provenance_summary(provenance: &[Provenance]) -> String {
2333 provenance
2334 .iter()
2335 .map(provenance_csv_label)
2336 .collect::<Vec<_>>()
2337 .join("|")
2338}
2339
2340fn provenance_csv_label(provenance: &Provenance) -> String {
2341 let mut label = provenance.source.clone();
2342 if let Some(layer) = &provenance.layer {
2343 write!(label, ":{layer}").expect("write to string");
2344 }
2345 if let Some(source_id) = &provenance.source_id {
2346 write!(label, ":{source_id}").expect("write to string");
2347 }
2348 label
2349}
2350
2351fn line_wkt(line: &LineString) -> String {
2352 format!(
2353 "LINESTRING Z ({})",
2354 line.points
2355 .iter()
2356 .map(coord_wkt_tuple)
2357 .collect::<Vec<_>>()
2358 .join(", ")
2359 )
2360}
2361
2362fn point_wkt(coord: Coord) -> String {
2363 format!("POINT Z ({})", coord_wkt_tuple(&coord))
2364}
2365
2366fn coord_wkt_tuple(coord: &Coord) -> String {
2367 format!(
2368 "{:.7} {:.7} {:.3}",
2369 coord.lon,
2370 coord.lat,
2371 coord.ele.unwrap_or(0.0)
2372 )
2373}
2374
2375fn csv_cell(value: &str) -> String {
2376 if value.contains([',', '"', '\n', '\r']) {
2377 format!("\"{}\"", value.replace('"', "\"\""))
2378 } else {
2379 value.to_owned()
2380 }
2381}
2382
2383fn csv_f64(value: Option<f64>) -> String {
2384 value.map_or_else(String::new, |value| format!("{value:.3}"))
2385}
2386
2387const fn way_kind_tag(class: WayKind) -> &'static str {
2388 match class {
2389 WayKind::Unknown => "unknown",
2390 WayKind::Path => "path",
2391 WayKind::Footway => "footway",
2392 WayKind::Sidewalk => "sidewalk",
2393 WayKind::Crossing => "crossing",
2394 WayKind::Track => "track",
2395 WayKind::ServiceRoad => "service",
2396 WayKind::PedestrianStreet => "pedestrian",
2397 WayKind::Steps => "steps",
2398 WayKind::Bridleway => "bridleway",
2399 WayKind::Bushwhack => "bushwhack",
2400 WayKind::Roadway => "road",
2401 WayKind::Cycleway => "cycleway",
2402 }
2403}
2404
2405const fn trail_standing_tag(standing: TrailStanding) -> &'static str {
2406 match standing {
2407 TrailStanding::Unknown => "unknown",
2408 TrailStanding::Established => "established",
2409 TrailStanding::Unmaintained => "unmaintained",
2410 TrailStanding::Informal => "informal",
2411 TrailStanding::Historical => "historical",
2412 }
2413}
2414
2415const fn way_realm_tag(realm: WayRealm) -> &'static str {
2416 match realm {
2417 WayRealm::Recreational => "recreational",
2418 WayRealm::Connector => "connector",
2419 WayRealm::Urban => "urban",
2420 }
2421}
2422
2423const fn geometry_claim_tag(claim: GeometryClaim) -> &'static str {
2424 match claim {
2425 GeometryClaim::Surveyed => "surveyed",
2426 GeometryClaim::CenterlineProxy => "centerline-proxy",
2427 }
2428}
2429
2430const fn crossing_control_tag(control: CrossingControl) -> &'static str {
2431 match control {
2432 CrossingControl::None => "none",
2433 CrossingControl::Uncontrolled => "uncontrolled",
2434 CrossingControl::Marked => "marked",
2435 CrossingControl::Signals => "signals",
2436 CrossingControl::GradeSeparated => "grade-separated",
2437 }
2438}
2439
2440const fn trail_marking_tag(marking: TrailMarking) -> &'static str {
2441 match marking {
2442 TrailMarking::Unknown => "unknown",
2443 TrailMarking::Marked => "marked",
2444 TrailMarking::Unmarked => "unmarked",
2445 }
2446}
2447
2448const fn terrain_tag(terrain: Terrain) -> &'static str {
2449 match terrain {
2450 Terrain::Unknown => "unknown",
2451 Terrain::Trail => "trail",
2452 Terrain::Forest => "forest",
2453 Terrain::Alpine => "alpine",
2454 Terrain::Talus => "talus",
2455 Terrain::Scramble => "scramble",
2456 Terrain::Pavement => "pavement",
2457 Terrain::Road => "road",
2458 Terrain::Water => "water",
2459 }
2460}
2461
2462const fn access_tag(access: Access) -> &'static str {
2463 match access {
2464 Access::Unknown => "unknown",
2465 Access::Open => "open",
2466 Access::Restricted => "restricted",
2467 Access::Closed => "closed",
2468 Access::Private => "private",
2469 }
2470}
2471
2472const fn edge_travel_tag(travel: EdgeTravel) -> &'static str {
2473 match travel {
2474 EdgeTravel::Both => "both",
2475 EdgeTravel::Forward => "forward",
2476 EdgeTravel::Backward => "backward",
2477 }
2478}
2479
2480fn read_bounded(mut response: Response, limit: u64, label: &str) -> Result<Vec<u8>> {
2481 if let Some(length) = response.content_length() {
2482 ensure!(
2483 length <= limit,
2484 "{label} is {length} bytes; limit is {limit}"
2485 );
2486 }
2487 let mut bytes = Vec::new();
2488 response
2489 .by_ref()
2490 .take(limit + 1)
2491 .read_to_end(&mut bytes)
2492 .with_context(|| format!("read {label}"))?;
2493 ensure!(bytes.len() as u64 <= limit, "{label} exceeds {limit} bytes");
2494 Ok(bytes)
2495}
2496
2497fn write_json_atomic(path: impl AsRef<Path>, value: &impl Serialize) -> Result<()> {
2498 write_atomic(path.as_ref(), serde_json::to_vec_pretty(value)?.as_slice())
2499}
2500
2501fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
2502 let parent = path.parent().context("artifact path has no parent")?;
2503 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
2504 let mut staging = Staging::raise(path)?;
2505 staging
2506 .file_mut()?
2507 .write_all(bytes)
2508 .with_context(|| format!("write {}", staging.path.display()))?;
2509 staging
2510 .file_mut()?
2511 .sync_all()
2512 .with_context(|| format!("flush {}", staging.path.display()))?;
2513 staging.commit(path)
2514}
2515
2516struct Staging {
2517 path: PathBuf,
2518 file: Option<File>,
2519}
2520
2521impl Staging {
2522 fn raise(target: &Path) -> Result<Self> {
2523 let extension = target
2524 .extension()
2525 .and_then(|extension| extension.to_str())
2526 .unwrap_or("");
2527 for nonce in 0..64 {
2528 let path = target.with_extension(format!(
2529 "{extension}.{}.{}.partial",
2530 std::process::id(),
2531 nonce
2532 ));
2533 match OpenOptions::new().write(true).create_new(true).open(&path) {
2534 Ok(file) => {
2535 return Ok(Self {
2536 path,
2537 file: Some(file),
2538 });
2539 }
2540 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
2541 Err(err) => {
2542 return Err(err)
2543 .with_context(|| format!("raise staging file {}", path.display()));
2544 }
2545 }
2546 }
2547 anyhow::bail!("staging namespace exhausted beside {}", target.display())
2548 }
2549
2550 fn file_mut(&mut self) -> Result<&mut File> {
2551 self.file.as_mut().context("staging file already sealed")
2552 }
2553
2554 fn commit(mut self, target: &Path) -> Result<()> {
2555 drop(self.file.take());
2556 fs::rename(&self.path, target).with_context(|| format!("commit {}", target.display()))?;
2557 self.path.clear();
2558 Ok(())
2559 }
2560}
2561
2562impl Drop for Staging {
2563 fn drop(&mut self) {
2564 if !self.path.as_os_str().is_empty() {
2565 let _partial = fs::remove_file(&self.path);
2566 }
2567 }
2568}
2569
2570#[cfg(test)]
2571mod tests {
2572 use super::*;
2573 use std::{cell::Cell, rc::Rc};
2574 use trailgen_core::JunctionPolicy;
2575
2576 #[derive(Clone)]
2577 struct FixedPlace;
2578
2579 impl PlaceIndex for FixedPlace {
2580 fn locate_us(&self, query: &str) -> Result<Place> {
2581 Ok(Place {
2582 query: query.to_owned(),
2583 label: "Harriman State Park, New York, United States".to_owned(),
2584 center: Coord::new(-74.124_792_4, 41.230_375_5),
2585 license: "OpenStreetMap contributors".to_owned(),
2586 provider: "fixture".to_owned(),
2587 })
2588 }
2589 }
2590
2591 #[derive(Clone, Default)]
2592 struct FixedProvider {
2593 calls: Rc<Cell<usize>>,
2594 fail_at: Option<usize>,
2595 }
2596
2597 impl NetworkProvider for FixedProvider {
2598 fn descriptor(&self) -> ProviderDescriptor {
2599 ProviderDescriptor {
2600 id: ProviderId::new("fixture").unwrap(),
2601 label: "Fixture trails",
2602 adapter_revision: 1,
2603 precedence: 0,
2604 extension: "osm",
2605 request_extension: "request",
2606 }
2607 }
2608
2609 fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
2610 assert!(bounds.is_valid());
2611 let call = self.calls.get() + 1;
2612 self.calls.set(call);
2613 if self.fail_at == Some(call) {
2614 anyhow::bail!("fixture acquisition failure");
2615 }
2616 Ok(ProviderPayload {
2617 bytes: include_bytes!("../tests/fixtures/tiny-overpass.osm").to_vec(),
2618 request: "fixture request".to_owned(),
2619 origin: "fixture://overpass".to_owned(),
2620 })
2621 }
2622
2623 fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
2624 let merged = merge_osm(shards)?;
2625 Ok(NormalizedNetwork {
2626 drafts: osm::network_from_str(&merged)?,
2627 context: osm::context_overlays_from_str(&merged)?,
2628 })
2629 }
2630 }
2631
2632 #[derive(Clone, Default)]
2633 struct FixedUsgs {
2634 calls: Rc<Cell<usize>>,
2635 }
2636
2637 impl NetworkProvider for FixedUsgs {
2638 fn descriptor(&self) -> ProviderDescriptor {
2639 UsgsNationalTrails::default().descriptor()
2640 }
2641
2642 fn acquire(&self, bounds: GeoBounds) -> Result<ProviderPayload> {
2643 assert!(bounds.is_valid());
2644 self.calls.set(self.calls.get() + 1);
2645 Ok(ProviderPayload {
2646 bytes: include_bytes!("../tests/fixtures/tiny-usgs-trails.geojson").to_vec(),
2647 request: "fixture USGS request".to_owned(),
2648 origin: "fixture://usgs-national-trails".to_owned(),
2649 })
2650 }
2651
2652 fn normalize(&self, shards: &[RawShard<'_>]) -> Result<NormalizedNetwork> {
2653 UsgsNationalTrails::default().normalize(shards)
2654 }
2655 }
2656
2657 fn fixed_surveyor() -> (Surveyor<FixedPlace>, Rc<Cell<usize>>) {
2658 let provider = FixedProvider::default();
2659 let calls = Rc::clone(&provider.calls);
2660 (Surveyor::new(FixedPlace, provider), calls)
2661 }
2662
2663 fn fixed_multi_surveyor() -> (Surveyor<FixedPlace>, Rc<Cell<usize>>, Rc<Cell<usize>>) {
2664 let osm = FixedProvider::default();
2665 let usgs = FixedUsgs::default();
2666 let osm_calls = Rc::clone(&osm.calls);
2667 let usgs_calls = Rc::clone(&usgs.calls);
2668 (
2669 Surveyor::with_providers(FixedPlace, vec![Box::new(osm), Box::new(usgs)]),
2670 osm_calls,
2671 usgs_calls,
2672 )
2673 }
2674
2675 #[test]
2676 fn line_clipping_realizes_the_exact_rectangle_union() -> Result<()> {
2677 let line = LineString::new(vec![Coord::new(-2.0, 0.0), Coord::new(2.0, 0.0)])?;
2678 let regions = [
2679 SurveyRegion::new(GeoBounds::new(-1.0, -0.5, -0.2, 0.5))?,
2680 SurveyRegion::new(GeoBounds::new(0.3, -0.5, 1.0, 0.5))?,
2681 ];
2682
2683 let clipped = clip_line(&line, ®ions);
2684
2685 assert_eq!(clipped.len(), 2);
2686 for (actual, expected) in [
2687 (clipped[0].start(), Coord::new(-1.0, 0.0)),
2688 (clipped[0].end(), Coord::new(-0.2, 0.0)),
2689 (clipped[1].start(), Coord::new(0.3, 0.0)),
2690 (clipped[1].end(), Coord::new(1.0, 0.0)),
2691 ] {
2692 assert!((actual.lon - expected.lon).abs() < 1.0e-12);
2693 assert!((actual.lat - expected.lat).abs() < 1.0e-12);
2694 }
2695 Ok(())
2696 }
2697
2698 #[test]
2699 fn clipping_preserves_contracted_osm_junctions() -> Result<()> {
2700 let raw = r#"<osm version="0.6">
2701 <node id="1" lon="-1" lat="0"/><node id="2" lon="0" lat="0"/>
2702 <node id="3" lon="1" lat="0"/><node id="4" lon="0" lat="1"/>
2703 <way id="10"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="path"/></way>
2704 <way id="11"><nd ref="2"/><nd ref="4"/><tag k="highway" v="path"/></way>
2705 </osm>"#;
2706 let region = SurveyRegion::new(GeoBounds::new(-0.5, -0.5, 0.5, 0.5))?;
2707
2708 let drafts = clip_drafts(osm::network_from_str(raw)?, &[region]);
2709 assert!(
2710 drafts
2711 .iter()
2712 .all(|draft| draft.junctions == JunctionPolicy::ExplicitEndpoints)
2713 );
2714 let graph = GraphBuilder::default().build(&drafts)?;
2715 let junction = graph
2716 .vertices
2717 .iter()
2718 .find(|vertex| same_location(vertex.coord, Coord::new(0.0, 0.0)))
2719 .context("shared OSM node should survive clipping")?;
2720 assert_eq!(graph.adjacency[junction.id.0].len(), 3);
2721 Ok(())
2722 }
2723
2724 #[test]
2725 fn clipping_does_not_join_distinct_osm_ways_at_a_shared_boundary_coordinate() -> Result<()> {
2726 let raw = r#"<osm version="0.6">
2727 <node id="1" lon="-1" lat="0"/><node id="2" lon="0" lat="0"/>
2728 <node id="3" lon="-1" lat="0"/><node id="4" lon="0.5" lat="0"/>
2729 <way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="path"/></way>
2730 <way id="11"><nd ref="3"/><nd ref="4"/><tag k="highway" v="path"/></way>
2731 </osm>"#;
2732 let region = SurveyRegion::new(GeoBounds::new(-0.5, -0.5, 0.5, 0.5))?;
2733
2734 let drafts = clip_drafts(osm::network_from_str(raw)?, &[region]);
2735 let graph = GraphBuilder::default().build(&drafts)?;
2736
2737 assert_eq!(graph.edges.len(), 2);
2738 assert_eq!(
2739 graph
2740 .vertices
2741 .iter()
2742 .filter(|vertex| same_location(vertex.coord, Coord::new(-0.5, 0.0)))
2743 .count(),
2744 2
2745 );
2746 Ok(())
2747 }
2748
2749 #[test]
2750 fn survey_sequesters_indexes_and_reuses_one_canonical_pipeline() -> Result<()> {
2751 let temp = tempfile::tempdir()?;
2752 let project = temp.path();
2753 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2754 let (surveyor, calls) = fixed_surveyor();
2755 let mut first_events = Vec::new();
2756
2757 let first = surveyor.survey(project, "Harriman", 20.0, |event| {
2758 first_events.push(event.status());
2759 })?;
2760
2761 assert!(!first.reused);
2762 assert_eq!(first.inventory.trail_segments, 2);
2763 assert_eq!(first.inventory.road_features, 1);
2764 assert_eq!(first.inventory.waterway_features, 1);
2765 assert_eq!(calls.get(), 1);
2766 assert_eq!(first_events.len(), 6);
2767 assert_eq!(first.regions.len(), 1);
2768 assert_eq!(first.providers, vec![ProviderId::new("fixture")?]);
2769 assert_eq!(first.raw_paths.len(), 1);
2770 assert!(project.join(&first.raw_paths[0]).is_file());
2771 assert_eq!(indexed_summary(project)?, Some(first.clone()));
2772 assert!(
2773 project
2774 .join(&first.raw_paths[0])
2775 .with_extension("json")
2776 .is_file()
2777 );
2778 assert!(
2779 project
2780 .join(&first.raw_paths[0])
2781 .with_extension("request")
2782 .is_file()
2783 );
2784 for artifact in [LOCATION_CACHE, TRAIL_INDEX, GRAPH_CACHE, SOURCE_MANIFEST] {
2785 assert!(project.join(artifact).is_file(), "missing {artifact}");
2786 }
2787 for absent in [GRAPH_GEOJSON, "cache/edges.csv", "cache/vertices.csv"] {
2788 assert!(!project.join(absent).exists(), "unsolicited {absent}");
2789 }
2790 let manifest = serde_json::from_str::<SourceManifest>(&fs::read_to_string(
2791 project.join(SOURCE_MANIFEST),
2792 )?)?;
2793 assert_eq!(manifest.candidates.len(), 1);
2794 assert!(manifest.candidates.iter().all(|candidate| {
2795 candidate
2796 .origin
2797 .as_deref()
2798 .is_some_and(|origin| origin.contains("fixture://overpass"))
2799 }));
2800
2801 let mut second_events = Vec::new();
2802 let second = surveyor.survey(project, " harriman ", 20.0, |event| {
2803 second_events.push(event.status());
2804 })?;
2805 assert!(second.reused);
2806 assert_eq!(second.raw_paths, first.raw_paths);
2807 assert_eq!(calls.get(), 1);
2808 assert_eq!(second_events.len(), 3);
2809 assert!(
2810 second_events
2811 .last()
2812 .is_some_and(|event| event.contains("CACHED"))
2813 );
2814 Ok(())
2815 }
2816
2817 #[test]
2818 fn providers_keep_independent_receipts_but_feed_one_graph() -> Result<()> {
2819 let temp = tempfile::tempdir()?;
2820 let project = temp.path();
2821 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2822 let (surveyor, osm_calls, usgs_calls) = fixed_multi_surveyor();
2823
2824 let first = surveyor.survey(project, "Harriman", 20.0, drop)?;
2825
2826 assert_eq!(osm_calls.get(), 1);
2827 assert_eq!(usgs_calls.get(), 1);
2828 assert_eq!(first.providers.len(), 2);
2829 assert_eq!(first.raw_paths.len(), 2);
2830 assert_eq!(first.conflation.strata, 2);
2831 let graph = decode_graph(&fs::read(project.join(GRAPH_CACHE))?)?;
2832 let sources = graph
2833 .edges
2834 .iter()
2835 .flat_map(|edge| &edge.attr.provenance)
2836 .map(|provenance| provenance.source.as_str())
2837 .collect::<BTreeSet<_>>();
2838 assert!(sources.contains("osm-xml"));
2839 assert!(sources.contains("usgs-national-trails"));
2840
2841 let cached = surveyor.survey(project, "Harriman", 20.0, drop)?;
2842 assert!(cached.reused);
2843 assert_eq!(osm_calls.get(), 1);
2844 assert_eq!(usgs_calls.get(), 1);
2845
2846 let usgs_raw = first
2847 .raw_paths
2848 .iter()
2849 .find(|path| path.starts_with("sources/usgs-national-trails"))
2850 .context("USGS receipt missing")?;
2851 fs::write(project.join(usgs_raw), b"damaged")?;
2852 assert_eq!(indexed_summary(project)?, Some(first));
2853 let repaired = surveyor.survey(project, "Harriman", 20.0, drop)?;
2854 assert!(!repaired.reused);
2855 assert_eq!(osm_calls.get(), 1);
2856 assert_eq!(usgs_calls.get(), 2);
2857 Ok(())
2858 }
2859
2860 #[test]
2861 fn damaged_derived_receipts_are_repaired_without_poisoning_the_project() -> Result<()> {
2862 let temp = tempfile::tempdir()?;
2863 let project = temp.path();
2864 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2865 let (surveyor, calls) = fixed_surveyor();
2866 let first = surveyor.survey(project, "Harriman", 20.0, drop)?;
2867 fs::write(project.join(TRAIL_INDEX), b"{")?;
2868
2869 let repaired = surveyor.survey(project, "Harriman", 20.0, drop)?;
2870
2871 assert!(!repaired.reused);
2872 assert_eq!(calls.get(), 1);
2873 let artifact = project.join(&first.raw_paths[0]).with_extension("json");
2874 fs::write(artifact, b"{")?;
2875 fs::write(project.join(TRAIL_INDEX), b"{")?;
2876
2877 surveyor.survey(project, "Harriman", 20.0, drop)?;
2878
2879 assert_eq!(calls.get(), 2);
2880 Ok(())
2881 }
2882
2883 #[test]
2884 fn overlapping_regions_form_one_deduplicated_corpus_and_can_be_excised() -> Result<()> {
2885 let temp = tempfile::tempdir()?;
2886 let project = temp.path();
2887 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2888 let (surveyor, calls) = fixed_surveyor();
2889 let west = GeoBounds::new(-74.130, 41.225, -74.120, 41.235);
2890 let east = GeoBounds::new(-74.127, 41.228, -74.123, 41.234);
2891
2892 let first = surveyor.add_region(project, west, drop)?;
2893 let joined = surveyor.add_region(project, east, drop)?;
2894
2895 assert_eq!(calls.get(), 2);
2896 assert_eq!(joined.regions.len(), 2);
2897 assert_eq!(joined.inventory, first.inventory);
2898 assert_eq!(joined.vertices, first.vertices);
2899 assert_eq!(joined.edges, first.edges);
2900 assert_eq!(project_config(project)?.regions, joined.regions);
2901
2902 let shorn = surveyor
2903 .remove_region(project, &joined.regions[1].id, drop)?
2904 .context("one region should survive")?;
2905 assert_eq!(shorn.regions, first.regions);
2906 assert_eq!(calls.get(), 2);
2907 let removed = joined
2908 .raw_paths
2909 .iter()
2910 .find(|path| path.to_string_lossy().contains(&joined.regions[1].id))
2911 .context("second region receipt missing")?;
2912 assert!(!project.join(removed).exists());
2913 assert!(
2914 surveyor
2915 .remove_region(project, &first.regions[0].id, drop)?
2916 .is_none()
2917 );
2918 assert!(project_config(project)?.regions.is_empty());
2919 assert!(project_config(project)?.managed);
2920 assert!(!project.join(GRAPH_CACHE).exists());
2921 assert!(!project.join(&first.raw_paths[0]).exists());
2922 Ok(())
2923 }
2924
2925 #[test]
2926 fn replacement_intent_survives_an_interrupted_acquisition() -> Result<()> {
2927 let temp = tempfile::tempdir()?;
2928 let project = temp.path();
2929 fs::write(project.join("trailgen.toml"), "name = 'Harriman'\n")?;
2930 let provider = FixedProvider {
2931 fail_at: Some(3),
2932 ..FixedProvider::default()
2933 };
2934 let calls = Rc::clone(&provider.calls);
2935 let surveyor = Surveyor::new(FixedPlace, provider);
2936 let west = GeoBounds::new(-74.130, 41.225, -74.120, 41.235);
2937 let east = GeoBounds::new(-74.127, 41.228, -74.123, 41.234);
2938 let moved = GeoBounds::new(-74.131, 41.224, -74.119, 41.236);
2939 let first = surveyor.add_region(project, west, drop)?;
2940 let joined = surveyor.add_region(project, east, drop)?;
2941 let displaced = first.regions[0].id.clone();
2942 let survivor = joined.regions[1].clone();
2943 name_region(project, &displaced, "West Gate")?;
2944
2945 let fault = surveyor
2946 .replace_region(project, &displaced, moved, drop)
2947 .expect_err("the fixture's third acquisition must fail");
2948 let replacement = SurveyRegion::new(moved)?;
2949 let config = project_config(project)?;
2950
2951 assert!(fault.to_string().contains("fixture acquisition failure"));
2952 assert_eq!(calls.get(), 3);
2953 assert_eq!(config.regions, vec![replacement.clone(), survivor]);
2954 assert_eq!(
2955 config.region_names,
2956 BTreeMap::from([(replacement.id, "West Gate".to_owned())])
2957 );
2958 assert!(indexed_summary(project)?.is_none());
2959 Ok(())
2960 }
2961}