1use crate::{Access, Coord, LineString, Provenance, SegmentDraft, Terrain, WayKind};
2use rstar::{AABB, RTree, RTreeObject};
3use serde::{Deserialize, Serialize};
4
5const METRES_PER_LATITUDE_DEGREE: f64 = 111_320.0;
6
7#[derive(Clone, Debug, PartialEq)]
10pub struct NetworkStratum {
11 pub precedence: u16,
12 pub drafts: Vec<SegmentDraft>,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
16#[serde(default)]
17pub struct ConflationPolicy {
18 pub parallel_tolerance_m: f64,
19 pub min_parallel_cosine: f64,
20 pub max_reported_decisions: usize,
21}
22
23impl Default for ConflationPolicy {
24 fn default() -> Self {
25 Self {
26 parallel_tolerance_m: 8.0,
27 min_parallel_cosine: 0.94,
28 max_reported_decisions: 2_048,
29 }
30 }
31}
32
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct ConflationDecision {
35 pub preferred: Option<Provenance>,
36 pub suppressed: Option<Provenance>,
37 pub separation_m: f64,
38 pub geometry: LineString,
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42pub struct ConflationReport {
43 pub strata: usize,
44 pub input_drafts: usize,
45 pub output_drafts: usize,
46 pub suppressed_parallel_segments: usize,
47 pub decisions: Vec<ConflationDecision>,
48}
49
50#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
51pub struct ConflationStats {
52 pub strata: usize,
53 pub input_drafts: usize,
54 pub output_drafts: usize,
55 pub suppressed_parallel_segments: usize,
56}
57
58impl From<&ConflationReport> for ConflationStats {
59 fn from(report: &ConflationReport) -> Self {
60 Self {
61 strata: report.strata,
62 input_drafts: report.input_drafts,
63 output_drafts: report.output_drafts,
64 suppressed_parallel_segments: report.suppressed_parallel_segments,
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq)]
70pub struct ConflatedNetwork {
71 pub drafts: Vec<SegmentDraft>,
72 pub report: ConflationReport,
73}
74
75#[derive(Clone, Copy)]
76struct IndexedPrimitive {
77 draft: usize,
78 a: Coord,
79 b: Coord,
80}
81
82impl RTreeObject for IndexedPrimitive {
83 type Envelope = AABB<[f64; 2]>;
84
85 fn envelope(&self) -> Self::Envelope {
86 AABB::from_corners(
87 [self.a.lon.min(self.b.lon), self.a.lat.min(self.b.lat)],
88 [self.a.lon.max(self.b.lon), self.a.lat.max(self.b.lat)],
89 )
90 }
91}
92
93#[derive(Clone, Copy)]
94struct ParallelMatch {
95 draft: usize,
96 separation_m: f64,
97}
98
99#[must_use]
100pub fn conflate(mut strata: Vec<NetworkStratum>, policy: ConflationPolicy) -> ConflatedNetwork {
101 strata.sort_by_key(|stratum| stratum.precedence);
102 let mut canonical = Vec::<SegmentDraft>::new();
103 let mut index = RTree::<IndexedPrimitive>::new();
104 let mut report = ConflationReport {
105 strata: strata.len(),
106 input_drafts: strata.iter().map(|stratum| stratum.drafts.len()).sum(),
107 ..ConflationReport::default()
108 };
109
110 for mut stratum in strata {
111 stratum.drafts.sort_by(draft_order);
112 let higher_precedence_count = canonical.len();
113 let mut admitted = Vec::new();
114 for draft in stratum.drafts {
115 let mut run = Vec::<Coord>::new();
116 for segment in draft.geometry.points.windows(2) {
117 let [a, b] = [segment[0], segment[1]];
118 if let Some(parallel) = nearest_parallel(&draft, a, b, &canonical, &index, policy) {
119 seal_run(&draft, &mut run, &mut admitted);
120 report.suppressed_parallel_segments += 1;
121 corroborate(&mut canonical[parallel.draft], &draft);
122 if report.decisions.len() < policy.max_reported_decisions {
123 report.decisions.push(ConflationDecision {
124 preferred: canonical[parallel.draft].provenance.first().cloned(),
125 suppressed: draft.provenance.first().cloned(),
126 separation_m: parallel.separation_m,
127 geometry: LineString::unchecked(vec![a, b]),
128 });
129 }
130 } else {
131 append_segment(&mut run, a, b);
132 }
133 }
134 seal_run(&draft, &mut run, &mut admitted);
135 }
136
137 canonical.extend(admitted);
138 for (draft, line) in canonical.iter().enumerate().skip(higher_precedence_count) {
139 for points in line.geometry.points.windows(2) {
140 index.insert(IndexedPrimitive {
141 draft,
142 a: points[0],
143 b: points[1],
144 });
145 }
146 }
147 }
148 report.output_drafts = canonical.len();
149 ConflatedNetwork {
150 drafts: canonical,
151 report,
152 }
153}
154
155fn nearest_parallel(
156 draft: &SegmentDraft,
157 a: Coord,
158 b: Coord,
159 canonical: &[SegmentDraft],
160 index: &RTree<IndexedPrimitive>,
161 policy: ConflationPolicy,
162) -> Option<ParallelMatch> {
163 if policy.parallel_tolerance_m <= 0.0 {
164 return None;
165 }
166 let midpoint = a.lerp(b, 0.5);
167 let latitude_radius = policy.parallel_tolerance_m / METRES_PER_LATITUDE_DEGREE;
168 let longitude_radius = latitude_radius / midpoint.lat.to_radians().cos().abs().max(0.05);
169 let envelope = AABB::from_corners(
170 [
171 midpoint.lon - longitude_radius,
172 midpoint.lat - latitude_radius,
173 ],
174 [
175 midpoint.lon + longitude_radius,
176 midpoint.lat + latitude_radius,
177 ],
178 );
179 index
180 .locate_in_envelope_intersecting(envelope)
181 .filter_map(|candidate| {
182 if !same_facility(draft, &canonical[candidate.draft]) {
183 return None;
184 }
185 let (separation_m, projection) =
186 point_segment_distance(midpoint, candidate.a, candidate.b);
187 (separation_m <= policy.parallel_tolerance_m
188 && (0.0..=1.0).contains(&projection)
189 && parallel_cosine(a, b, candidate.a, candidate.b) >= policy.min_parallel_cosine)
190 .then_some(ParallelMatch {
191 draft: candidate.draft,
192 separation_m,
193 })
194 })
195 .min_by(|left, right| left.separation_m.total_cmp(&right.separation_m))
196}
197
198fn same_facility(left: &SegmentDraft, right: &SegmentDraft) -> bool {
199 if left.geometry_claim != right.geometry_claim {
200 return false;
201 }
202 let protected = |kind| {
203 matches!(
204 kind,
205 WayKind::Sidewalk
206 | WayKind::Crossing
207 | WayKind::PedestrianStreet
208 | WayKind::Roadway
209 | WayKind::ServiceRoad
210 | WayKind::Cycleway
211 | WayKind::Bushwhack
212 )
213 };
214 !(protected(left.way_kind) || protected(right.way_kind)) || left.way_kind == right.way_kind
215}
216
217fn point_segment_distance(point: Coord, a: Coord, b: Coord) -> (f64, f64) {
218 let latitude = point.lat.to_radians().cos();
219 let scale_x = METRES_PER_LATITUDE_DEGREE * latitude;
220 let scale_y = METRES_PER_LATITUDE_DEGREE;
221 let vx = (b.lon - a.lon) * scale_x;
222 let vy = (b.lat - a.lat) * scale_y;
223 let wx = (point.lon - a.lon) * scale_x;
224 let wy = (point.lat - a.lat) * scale_y;
225 let length2 = vx.mul_add(vx, vy * vy);
226 if length2 <= f64::EPSILON {
227 return (point.haversine_m(a), 0.0);
228 }
229 let projection = vx.mul_add(wx, vy * wy) / length2;
230 let t = projection.clamp(0.0, 1.0);
231 let dx = wx - t * vx;
232 let dy = wy - t * vy;
233 (dx.mul_add(dx, dy * dy).sqrt(), projection)
234}
235
236fn parallel_cosine(a: Coord, b: Coord, c: Coord, d: Coord) -> f64 {
237 let latitude = ((a.lat + b.lat + c.lat + d.lat) * 0.25).to_radians().cos();
238 let ab = ((b.lon - a.lon) * latitude, b.lat - a.lat);
239 let cd = ((d.lon - c.lon) * latitude, d.lat - c.lat);
240 let denominator = ab.0.hypot(ab.1) * cd.0.hypot(cd.1);
241 if denominator <= f64::EPSILON {
242 0.0
243 } else {
244 ab.0.mul_add(cd.0, ab.1 * cd.1).abs() / denominator
245 }
246}
247
248fn append_segment(run: &mut Vec<Coord>, a: Coord, b: Coord) {
249 if run.last().is_none_or(|last| !same_location(*last, a)) {
250 run.clear();
251 run.push(a);
252 }
253 run.push(b);
254}
255
256fn seal_run(draft: &SegmentDraft, run: &mut Vec<Coord>, admitted: &mut Vec<SegmentDraft>) {
257 if run.len() < 2 {
258 run.clear();
259 return;
260 }
261 admitted.push(draft.fragment(LineString::unchecked(std::mem::take(run))));
262}
263
264fn draft_order(left: &SegmentDraft, right: &SegmentDraft) -> std::cmp::Ordering {
265 left.provenance
266 .cmp(&right.provenance)
267 .then_with(|| {
268 left.geometry
269 .start()
270 .lon
271 .total_cmp(&right.geometry.start().lon)
272 })
273 .then_with(|| {
274 left.geometry
275 .start()
276 .lat
277 .total_cmp(&right.geometry.start().lat)
278 })
279 .then_with(|| left.geometry.end().lon.total_cmp(&right.geometry.end().lon))
280 .then_with(|| left.geometry.end().lat.total_cmp(&right.geometry.end().lat))
281}
282
283fn corroborate(preferred: &mut SegmentDraft, suppressed: &SegmentDraft) {
284 for provenance in &suppressed.provenance {
285 if !preferred.provenance.contains(provenance) {
286 preferred.provenance.push(provenance.clone());
287 }
288 }
289 preferred.confidence = preferred.confidence.max(suppressed.confidence);
290 if preferred.way_kind == WayKind::Unknown {
291 preferred.way_kind = suppressed.way_kind;
292 }
293 if preferred.standing == crate::TrailStanding::Unknown {
294 preferred.standing = suppressed.standing;
295 }
296 if preferred.marking == crate::TrailMarking::Unknown {
297 preferred.marking = suppressed.marking;
298 }
299 if preferred.terrain == Terrain::Unknown {
300 preferred.terrain = suppressed.terrain;
301 preferred.terrain_confidence = suppressed.terrain_confidence;
302 }
303 if preferred.surface.is_none() {
304 preferred.surface.clone_from(&suppressed.surface);
305 }
306 if preferred.access == Access::Unknown {
307 preferred.access = suppressed.access;
308 }
309}
310
311const fn same_location(left: Coord, right: Coord) -> bool {
312 left.lon.to_bits() == right.lon.to_bits() && left.lat.to_bits() == right.lat.to_bits()
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::{
319 CrossingControl, EdgeTravel, GeometryClaim, JunctionPolicy, TrailMarking, TrailStanding,
320 WayRealm,
321 };
322
323 fn draft(name: &str, latitude: f64, standing: TrailStanding) -> SegmentDraft {
324 SegmentDraft {
325 geometry: LineString::unchecked(vec![
326 Coord::new(-74.1, latitude),
327 Coord::new(-74.0, latitude),
328 ]),
329 junctions: JunctionPolicy::Planar,
330 turn_ref: None,
331 junction_keys: None,
332 turn_restrictions: Vec::new(),
333 way_kind: WayKind::Path,
334 realm: WayRealm::default(),
335 geometry_claim: GeometryClaim::default(),
336 crossing_control: CrossingControl::default(),
337 standing,
338 marking: TrailMarking::Unknown,
339 terrain: Terrain::Trail,
340 terrain_confidence: Some(0.8),
341 surface: None,
342 access: Access::Open,
343 travel: EdgeTravel::Both,
344 road_exposure: 0.0,
345 confidence: 0.8,
346 provenance: vec![Provenance::fixture(name)],
347 }
348 }
349
350 #[test]
351 fn higher_strata_suppress_parallel_duplicates_and_keep_provenance() {
352 let mut primary = draft("osm", 41.2, TrailStanding::Informal);
353 primary.provenance[0].source = "z-authority".to_owned();
354 let mut secondary = draft("usgs", 41.200_03, TrailStanding::Established);
355 secondary.provenance[0].source = "a-corroborator".to_owned();
356 secondary.marking = TrailMarking::Marked;
357 let network = conflate(
358 vec![
359 NetworkStratum {
360 precedence: 0,
361 drafts: vec![primary],
362 },
363 NetworkStratum {
364 precedence: 10,
365 drafts: vec![secondary],
366 },
367 ],
368 ConflationPolicy::default(),
369 );
370 assert_eq!(network.drafts.len(), 1);
371 assert_eq!(network.drafts[0].standing, TrailStanding::Informal);
372 assert_eq!(network.drafts[0].marking, TrailMarking::Marked);
373 assert_eq!(network.drafts[0].provenance.len(), 2);
374 assert_eq!(network.drafts[0].provenance[0].source, "z-authority");
375 assert_eq!(network.drafts[0].provenance[1].source, "a-corroborator");
376 assert_eq!(network.report.suppressed_parallel_segments, 1);
377 }
378
379 #[test]
380 fn conflation_cannot_erase_distinct_pedestrian_facilities() {
381 let mut road = draft("road", 41.2, TrailStanding::Established);
382 road.way_kind = WayKind::Roadway;
383 road.realm = WayRealm::Urban;
384 road.terrain = Terrain::Road;
385 let mut sidewalk = draft("sidewalk", 41.2, TrailStanding::Established);
386 sidewalk.way_kind = WayKind::Sidewalk;
387 sidewalk.realm = WayRealm::Urban;
388 sidewalk.terrain = Terrain::Pavement;
389
390 let network = conflate(
391 vec![
392 NetworkStratum {
393 precedence: 0,
394 drafts: vec![road],
395 },
396 NetworkStratum {
397 precedence: 10,
398 drafts: vec![sidewalk],
399 },
400 ],
401 ConflationPolicy::default(),
402 );
403
404 assert_eq!(network.drafts.len(), 2);
405 assert_eq!(network.report.suppressed_parallel_segments, 0);
406 }
407
408 #[test]
409 fn output_is_stable_under_equal_stratum_permutation() {
410 let a = draft("a", 41.2, TrailStanding::Established);
411 let b = draft("b", 41.3, TrailStanding::Established);
412 let forward = conflate(
413 vec![NetworkStratum {
414 precedence: 0,
415 drafts: vec![a.clone(), b.clone()],
416 }],
417 ConflationPolicy::default(),
418 );
419 let reverse = conflate(
420 vec![NetworkStratum {
421 precedence: 0,
422 drafts: vec![b, a],
423 }],
424 ConflationPolicy::default(),
425 );
426 let mut forward_ids = forward
427 .drafts
428 .iter()
429 .filter_map(|draft| draft.provenance[0].source_id.clone())
430 .collect::<Vec<_>>();
431 let mut reverse_ids = reverse
432 .drafts
433 .iter()
434 .filter_map(|draft| draft.provenance[0].source_id.clone())
435 .collect::<Vec<_>>();
436 forward_ids.sort();
437 reverse_ids.sort();
438 assert_eq!(forward_ids, reverse_ids);
439 }
440}