Repository navigation
Measure on the WGS 84 ellipsoid in the attribute table, measure tool and network tools (#939) #998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
686a381
94b6d92
4e94638
5f5fcdc
eb349c0
2a72752
511c75b
f02b08f
45a901a
7131b54
6698418
56d579c
63b3dea
6d2d8ee
5f5b3a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,7 +4,7 @@ | |||||
| use std::collections::{BinaryHeap, HashMap}; | ||||||
|
|
||||||
| use rustgis_data::{Dataset, Geometry}; | ||||||
| use rustgis_geom::{Coord, haversine_m}; | ||||||
| use rustgis_geom::{Coord, EARTH_RADIUS_M, WGS84_MIN_RADIUS_M, haversine_m, wgs84_distance_m}; | ||||||
|
|
||||||
| /// A shortest route: its path, length in metres, cost, and the line rows it uses. | ||||||
| #[derive(Clone, Debug, PartialEq)] | ||||||
|
|
@@ -13,6 +13,8 @@ pub struct Route { | |||||
| pub length_m: f64, | ||||||
| pub cost: f64, | ||||||
| pub rows: Vec<usize>, | ||||||
| /// Segments omitted because an endpoint has non-finite coordinates or an invalid latitude. | ||||||
| pub skipped_segments: usize, | ||||||
| } | ||||||
|
|
||||||
| #[derive(PartialEq)] | ||||||
|
|
@@ -35,8 +37,8 @@ fn key(c: Coord) -> (i64, i64) { | |||||
| } | ||||||
|
|
||||||
| /// The shortest route between two points over the lines of `rows`, joining where vertices | ||||||
| /// coincide. Each edge costs its length times the row's `cost_field` value (1 when empty; lines | ||||||
| /// costing zero or less are closed). Each | ||||||
| /// coincide. Each edge costs its length (on the WGS 84 ellipsoid) times the row's `cost_field` | ||||||
| /// value (1 when empty; lines costing zero or less are closed). Each | ||||||
| /// end snaps to the nearest point on a line (within `snap_m`), where the route starts or stops. | ||||||
| pub fn shortest_route( | ||||||
| ds: &Dataset, | ||||||
|
|
@@ -46,6 +48,21 @@ pub fn shortest_route( | |||||
| cost_field: Option<usize>, | ||||||
| snap_m: f64, | ||||||
| ) -> Result<Route, String> { | ||||||
| if !snap_m.is_finite() { | ||||||
| return Err("the snap distance must be finite".into()); | ||||||
| } | ||||||
| if [from, to].iter().any(|c| !c.is_finite() || c.y.abs() > 90.0) { | ||||||
| return Err("a route end has invalid coordinates".into()); | ||||||
| } | ||||||
| // Preserve clean-network errors; failed partial routes must still disclose omissions. | ||||||
| let route_error = |reason: String, skipped_segments: usize| { | ||||||
| if skipped_segments == 0 { | ||||||
| reason | ||||||
| } else { | ||||||
| format!("{reason}; {skipped_segments} segments with invalid coordinates were omitted from '{}'", ds.name) | ||||||
| } | ||||||
| }; | ||||||
| let mut skipped_segments = 0; | ||||||
| let mut ids: HashMap<(i64, i64), usize> = HashMap::new(); | ||||||
| let mut nodes: Vec<Coord> = Vec::new(); | ||||||
| let mut adj: Vec<Vec<(usize, f64, f64, usize)>> = Vec::new(); // (to, cost, length, row) | ||||||
|
|
@@ -69,8 +86,14 @@ pub fn shortest_route( | |||||
| for part in parts { | ||||||
| for w in part.windows(2) { | ||||||
| let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; | ||||||
| // Non-finite coordinates cast to ordinary integer keys (NaN becomes zero). | ||||||
| // Keep unroutable segments out before they can corrupt a valid shared node. | ||||||
| if !a.is_finite() || !b.is_finite() || a.y.abs() > 90.0 || b.y.abs() > 90.0 { | ||||||
| skipped_segments += 1; | ||||||
| continue; | ||||||
| } | ||||||
| let (ia, ib) = (node(*a, &mut ids, &mut nodes, &mut adj), node(*b, &mut ids, &mut nodes, &mut adj)); | ||||||
| let len = haversine_m(*a, *b); | ||||||
| let len = wgs84_distance_m(*a, *b); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): the snap scan now avoids a geodesic solve per segment, but graph construction still solves one per segment (here, and again at lines 123 and 140). A line layer near the 10 M-segment cap now costs a geodesic solve per segment during the build, where it previously cost a haversine. That is a large slowdown for Consider measuring a large layer before and after, and either documenting the cost or lowering the cap for this path.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Graph build (crates/analysis/src/network.rs:73) now solves one geodesic per segment, which is the cost the PR accepts for the ellipsoid. Whether to measure it, document it or lower the 10 M segment cap for routing is a maintainer decision. No change made.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The measurement and scope decision are recorded in the human review: 180,600 segments took 40→70 ms for an ordinary grid and 21→124 ms for near-antipodal edges. We retain exact WGS 84 edge lengths for this accuracy fix. The user guide now states that routing rebuilds the graph and solves each edge per request; graph caching remains follow-up work.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): every Suggest benchmarking a road-sized layer (about 500k segments) before merging. If the overhead is material, options are to keep haversine for graph weights and solve only the snap endpoints, or to cache the built graph per layer and cost-field.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Needs a benchmark on a road-sized layer before deciding. The choice between keeping haversine for graph weights and caching the built graph is a design decision for a maintainer, so no change here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): every segment now pays a GeographicLib inverse solve (roughly microseconds) while the graph is built, where it used to pay a haversine (tens of nanoseconds). The snap scan is still an O(segments) haversine pass per end, so graph construction is now the dominant cost, and the 10M-segment cap means a large layer can take many seconds per route call. The PR body's note about avoiding a solve per segment covers only the snap scan. Worth timing on a large network (e.g. 1M segments) before merging, and considering caching the edge lengths per layer or a cheaper ellipsoidal approximation for the graph.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Not changed: there is no shell here to time a large network, so the cost is unmeasured. Caching edge lengths or using a cheaper approximation would change the ellipsoidal lengths that #939 standardised on, so it is a maintainer call once timed on a 1M-segment layer.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): every segment now costs one WGS 84 inverse solve, and
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Not changed. The cost is real in principle (one WGS 84 solve per segment, rebuilt on every shortest_route call), but the fix is a design choice: cache edge lengths per layer, or keep haversine for the graph. Needs a timing run on a large line layer, which this pass can't do (no shell), before a maintainer picks one.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium, confidence high): this line now runs an inverse geodesic solve for every segment of the layer on every
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Not changed. Same as the other performance thread on this line: the per-call graph build is O(E) geodesic solves, and the remedy (caching edge lengths per layer, or solving only where shown) is a design decision. Needs measurement on a realistic network first.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): every edge now costs a WGS 84 inverse geodesic solve during graph build, up to 10M segments under the cap, where it was a haversine before. The snap scan was kept cheap on purpose, but the build is not. Worth a benchmark on a large network, and a note in the PR if the slowdown is acceptable.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Not changed. The WGS 84 build cost is the point of #939, and this environment can't run a benchmark. Needs timings on a large network and a maintainer decision on whether the slowdown is acceptable.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (low-medium confidence): graph construction now runs one inverse geodesic solve per line segment, where it used to run a haversine (a few nanoseconds). The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): No change. Graph construction now solves one geodesic per segment (network.rs:73), which is slower on very large networks but is the accuracy trade the measures were meant to make; the cost is already noted on wgs84_length_m in crates/geom/src/lib.rs. Whether to state it in the tool docs or cache the graph per layer and cost field is a design call for a maintainer.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): the graph build now runs a full WGS 84 inverse solve per edge, where it used
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance (medium confidence): every route request rebuilds the graph, and each edge now costs a WGS 84 inverse solve, where it used to be a haversine.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was measured: for 180,600 segments, the human reviewer reported 40→70 ms on an ordinary grid and 21→124 ms on near-antipodal edges. See the measurements and nonblocking scope decision. The cost is documented, and caching is tracked in the PR’s follow-up checklist; no new benchmark or cap change is needed here. |
||||||
| if let Some(v) = adj.get_mut(ia) { | ||||||
| v.push((ib, len * weight, len, *r)); | ||||||
| } | ||||||
|
|
@@ -80,11 +103,14 @@ pub fn shortest_route( | |||||
| segs.push((ia, ib, weight, *r)); | ||||||
| // Checked per segment, so one huge feature can't grow the network unbounded. | ||||||
| if segs.len() > 10_000_000 || nodes.len() > 5_000_000 { | ||||||
| return Err("the network is too large".into()); | ||||||
| return Err(route_error("the network is too large".into(), skipped_segments)); | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| if segs.is_empty() && skipped_segments > 0 { | ||||||
| return Err(route_error("no usable network remains".into(), skipped_segments)); | ||||||
| } | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||||||
| // The closest point on any segment: (segment, fraction along it, the point, metres away). | ||||||
| let nearest = |p: Coord, nodes: &[Coord]| -> Option<(usize, f64, Coord, f64)> { | ||||||
| let k = p.y.to_radians().cos().max(0.01); | ||||||
|
|
@@ -98,15 +124,31 @@ pub fn shortest_route( | |||||
| let q = Coord::new(a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)); | ||||||
| Some((i, t, q, haversine_m(q, p))) | ||||||
| }) | ||||||
| .min_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal)) | ||||||
| .fold(None, |best: Option<(usize, f64, Coord, f64)>, (i, t, q, sphere)| { | ||||||
| // The minimum WGS 84 radius gives a lower bound, so a farther spherical | ||||||
| // candidate cannot be discarded merely because the two metrics rank it differently. | ||||||
| let lower = if (-90.0..=90.0).contains(&p.y) && (-90.0..=90.0).contains(&q.y) { | ||||||
| sphere * WGS84_MIN_RADIUS_M / EARTH_RADIUS_M | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (medium-high confidence): the pruning bound is too tight, so a valid snap can be discarded.
Failure example: best so far is 1000 m (ellipsoid). A meridional candidate has sphere distance 1002.5 m, so its ellipsoid distance is about 997 m. The bound gives 0.99776 × 1002.5 ≈ 1000.3 > 1000, so it is pruned even though it is nearer. Why the bound holds: ds_ell² = M²dφ² + N²cos²φ dλ² ≥ M_min²dφ² + R²cos²φ dλ², since N ≥ a ≥ R and M_min ≤ R. So M_min / R is a valid lower factor. Suggested fix: add a constant next to
Suggested change
The comment above the fold also needs correcting, and the test at
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The existing constant already has the suggested value: For the supplied 1002.5 m spherical candidate, the actual lower bound is 996.903022 m, below the current 1000 m best, so it is retained and evaluated on WGS 84. The proposed new constant would duplicate the existing one. The existing equatorial-meridian regression also checks this lower bound. No code change is needed. |
||||||
| } else { | ||||||
| 0.0 // The inverse solver clamps latitudes; the spherical bound would not apply. | ||||||
| }; | ||||||
| if best.as_ref().is_some_and(|b| lower > b.3) { | ||||||
| return best; | ||||||
| } | ||||||
| let distance = wgs84_distance_m(q, p); | ||||||
| if distance.is_finite() && best.as_ref().is_none_or(|b| distance < b.3) { Some((i, t, q, distance)) } else { best } | ||||||
| }) | ||||||
| }; | ||||||
| // Each end becomes a node on its segment, joined to the segment's two ends. | ||||||
| let attach = |p: Coord, nodes: &mut Vec<Coord>, adj: &mut Vec<Vec<(usize, f64, f64, usize)>>| -> Result<(usize, usize, f64), String> { | ||||||
| let (si, t, q, d) = nearest(p, nodes).ok_or("the layer has no lines")?; | ||||||
| let (si, t, q, d) = nearest(p, nodes).ok_or_else(|| route_error("the layer has no lines".into(), skipped_segments))?; | ||||||
| if d > snap_m { | ||||||
| return Err(format!("a route end is more than {} from the network", rustgis_geom::format_distance(snap_m))); | ||||||
| return Err(route_error( | ||||||
| format!("a route end is more than {} from the network", rustgis_geom::format_distance(snap_m)), | ||||||
| skipped_segments, | ||||||
| )); | ||||||
| } | ||||||
| let &(a, b, weight, row) = segs.get(si).ok_or("the layer has no lines")?; | ||||||
| let &(a, b, weight, row) = segs.get(si).ok_or_else(|| route_error("the layer has no lines".into(), skipped_segments))?; | ||||||
| if t <= 0.0 { | ||||||
| return Ok((a, si, t)); | ||||||
| } | ||||||
|
|
@@ -117,7 +159,7 @@ pub fn shortest_route( | |||||
| adj.push(Vec::new()); | ||||||
| let n = nodes.len() - 1; | ||||||
| for end in [a, b] { | ||||||
| let len = haversine_m(q, nodes.get(end).copied().unwrap_or(q)); | ||||||
| let len = wgs84_distance_m(q, nodes.get(end).copied().unwrap_or(q)); | ||||||
| if let Some(v) = adj.get_mut(n) { | ||||||
| v.push((end, len * weight, len, row)); | ||||||
| } | ||||||
|
|
@@ -132,9 +174,9 @@ pub fn shortest_route( | |||||
| // Both ends on one segment: they're joined directly along it. | ||||||
| if s_seg == t_seg && s != t && (0.0..=1.0).contains(&s_t) && (0.0..=1.0).contains(&t_t) { | ||||||
| let (Some(&(_, _, weight, row)), Some(a), Some(b)) = (segs.get(s_seg), nodes.get(s).copied(), nodes.get(t).copied()) else { | ||||||
| return Err("the layer has no lines".into()); | ||||||
| return Err(route_error("the layer has no lines".into(), skipped_segments)); | ||||||
| }; | ||||||
| let len = haversine_m(a, b); | ||||||
| let len = wgs84_distance_m(a, b); | ||||||
| if let Some(v) = adj.get_mut(s) { | ||||||
| v.push((t, len * weight, len, row)); | ||||||
| } | ||||||
|
|
@@ -172,7 +214,7 @@ pub fn shortest_route( | |||||
| } | ||||||
| let cost = dist.get(t).copied().unwrap_or(f64::INFINITY); | ||||||
| if !cost.is_finite() { | ||||||
| return Err("the two points aren't connected by the network".into()); | ||||||
| return Err(route_error("the two points aren't connected by the network".into(), skipped_segments)); | ||||||
| } | ||||||
| let mut path = Vec::new(); | ||||||
| let mut length = 0.0; | ||||||
|
|
@@ -190,7 +232,7 @@ pub fn shortest_route( | |||||
| path.reverse(); | ||||||
| used.reverse(); | ||||||
| used.dedup(); | ||||||
| Ok(Route { path, length_m: length, cost, rows: used }) | ||||||
| Ok(Route { path, length_m: length, cost, rows: used, skipped_segments }) | ||||||
| } | ||||||
|
|
||||||
| /// Most random points made in one call, and point-in-polygon tries spent making them. | ||||||
|
|
@@ -445,9 +487,49 @@ mod tests { | |||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(0.09, 0.0)]])), vec![]); | ||||||
| // Clicks beside its middle, far from both vertices but close to the road. | ||||||
| let r = shortest_route(&ds, &[0], Coord::new(0.03, 0.0001), Coord::new(0.06, -0.0001), None, 50.0).unwrap(); | ||||||
| assert!((r.length_m - 3336.0).abs() < 20.0, "{}", r.length_m); | ||||||
| assert!((r.length_m - 3339.6).abs() < 1.0, "{}", r.length_m); | ||||||
| assert!((r.path[0].x - 0.03).abs() < 1e-9 && (r.path.last().unwrap().x - 0.06).abs() < 1e-9); | ||||||
| assert!(shortest_route(&ds, &[0], Coord::new(0.03, 0.01), Coord::new(0.06, 0.0), None, 50.0).is_err(), "1 km away"); | ||||||
| let error = shortest_route(&ds, &[0], Coord::new(f64::NAN, 0.0), Coord::new(0.06, 0.0), None, 50.0).unwrap_err(); | ||||||
| assert!(error.contains("route end"), "{error}"); | ||||||
| let error = shortest_route(&ds, &[0], Coord::new(0.0, 95.0), Coord::new(0.06, 0.0), None, 50.0).unwrap_err(); | ||||||
| assert!(error.contains("route end"), "{error}"); | ||||||
| assert!(shortest_route(&ds, &[0], Coord::new(0.0, 1.0), Coord::new(0.06, 1.0), None, f64::NAN).is_err()); | ||||||
| } | ||||||
|
|
||||||
| #[test] | ||||||
| fn a_broken_segment_listed_first_does_not_hide_a_valid_one() { | ||||||
| // The invalid segment is excluded before graph construction and cannot hide the road. | ||||||
| let mut ds = Dataset::new("roads", GeometryType::Polyline, vec![]); | ||||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.5, 0.5), Coord::new(f64::NAN, 0.5)]])), vec![]); | ||||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(0.09, 0.0)]])), vec![]); | ||||||
| let r = shortest_route(&ds, &[0, 1], Coord::new(0.03, 0.0001), Coord::new(0.06, -0.0001), None, 50.0).unwrap(); | ||||||
| assert!((r.length_m - 3339.6).abs() < 1.0, "{}", r.length_m); | ||||||
| } | ||||||
|
|
||||||
| #[test] | ||||||
| fn nonfinite_vertices_do_not_alias_valid_network_nodes() { | ||||||
| let mut ds = Dataset::new("roads", GeometryType::Polyline, vec![]); | ||||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(f64::NAN, 0.5), Coord::new(0.5, 0.5)]])), vec![]); | ||||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.5), Coord::new(0.09, 0.5)]])), vec![]); | ||||||
| let r = shortest_route(&ds, &[0, 1], Coord::new(0.0, 0.5), Coord::new(0.09, 0.5), None, 1.0).unwrap(); | ||||||
| assert!(r.path.iter().all(|c| c.is_finite()), "{:?}", r.path); | ||||||
| assert_eq!(r.rows, vec![1]); | ||||||
| assert!((r.length_m - 10_018.375).abs() < 1.0, "{}", r.length_m); | ||||||
| } | ||||||
|
|
||||||
| #[test] | ||||||
| fn snap_acceptance_uses_the_nearest_ellipsoidal_candidate() { | ||||||
| let mut ds = Dataset::new("roads", GeometryType::Polyline, vec![]); | ||||||
| // At the equator, 0.001 degrees east is nearer on the sphere than 0.001003 | ||||||
| // degrees north. On WGS 84 only the northern segment is within 111 metres. | ||||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.001, 0.0), Coord::new(0.002, 0.0)]])), vec![]); | ||||||
| ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.001003), Coord::new(0.0, 0.003)]])), vec![]); | ||||||
| let from = Coord::new(0.0, 0.0); | ||||||
| assert!(haversine_m(from, Coord::new(0.001, 0.0)) < haversine_m(from, Coord::new(0.0, 0.001003))); | ||||||
| let r = shortest_route(&ds, &[0, 1], from, Coord::new(0.0, 0.003), None, 111.0).unwrap(); | ||||||
| assert_eq!(r.rows, vec![1]); | ||||||
| assert_eq!(r.path.first(), Some(&Coord::new(0.0, 0.001003))); | ||||||
| } | ||||||
|
|
||||||
| fn offset(at: Coord, x: f64, y: f64) -> Coord { | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ | |
| //! X/Y coordinate, calculated from its WGS84 geometry, in a field. | ||
| use crate::{CmdResult, Session}; | ||
| use rustgis_data::{FieldType, Geometry, GeometryType, Value, raster::Crs}; | ||
| use rustgis_geom::{geodesic_length_m, geodesic_ring_area_m2, spherical_centre}; | ||
| use rustgis_geom::{spherical_centre, wgs84_length_m, wgs84_ring_perimeter_area_m2}; | ||
| use serde_json::{Value as Json, json}; | ||
|
|
||
| /// The properties a layer of `kind` can calculate, as `(key, label)` pairs. | ||
|
|
@@ -32,25 +32,21 @@ pub fn units(property: &str) -> &'static [(&'static str, &'static str)] { | |
| } | ||
| } | ||
|
|
||
| /// Measures WGS 84 geometry in metres or square metres; holes subtract from polygon area. | ||
| fn measure(g: &Geometry, property: &str) -> Option<f64> { | ||
| match (g, property) { | ||
| // Full-turn and polar rings retain the documented exceptions to geoprocessing parity: | ||
| // docs/user-guide/analysis.md (Calculate Geometry Attributes). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Quality (medium confidence): the parity claim doesn't hold for polar and full-turn rings. The commit says the attribute table matches the geoprocessing tool, but
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The full-turn and polar divergences are explicitly tracked in the PR’s Limits checklist and documented in the user guide. I also qualified the attribute-table bullet to say parity applies to ordinary geometries. The existing accepted scope decision is retained. |
||
| (Geometry::Polygon(polys), "area") => Some( | ||
| polys | ||
| .iter() | ||
| .map(|rings| rings.iter().enumerate().map(|(i, r)| geodesic_ring_area_m2(r) * if i == 0 { 1.0 } else { -1.0 }).sum::<f64>()) | ||
| .sum(), | ||
| ), | ||
| (Geometry::Polygon(polys), "perimeter") => Some( | ||
| polys | ||
| .iter() | ||
| .flatten() | ||
| .map(|r| { | ||
| let closing = r.first().zip(r.last()).filter(|(a, b)| a != b).map(|(a, b)| geodesic_length_m(&[*b, *a])).unwrap_or(0.0); | ||
| geodesic_length_m(r) + closing | ||
| .map(|rings| { | ||
| rings.iter().enumerate().map(|(i, r)| wgs84_ring_perimeter_area_m2(r).1 * if i == 0 { 1.0 } else { -1.0 }).sum::<f64>() | ||
| }) | ||
| .sum(), | ||
| ), | ||
| (Geometry::Line(parts), "length") => Some(parts.iter().map(|r| geodesic_length_m(r)).sum()), | ||
| (Geometry::Polygon(polys), "perimeter") => Some(polys.iter().flatten().map(|r| wgs84_ring_perimeter_area_m2(r).0).sum()), | ||
| (Geometry::Line(parts), "length") => Some(parts.iter().map(|r| wgs84_length_m(r)).sum()), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug (medium confidence):
key()(line 34) casts withas i64, and Rust maps NaN to 0. A vertex with a NaN x therefore joins the node at x≈0 with the same y, and that node keeps whichever coordinate was seen first. If the broken vertex comes first, the node holds NaN, so the valid segments touching it get NaN snap distances and the returnedRoute::pathcan contain a NaN point. The NaN-length edges are harmless in Dijkstra, but the node merge is not. Skipping non-finite segments before they enter the graph fixes this and keeps the broken segment out ofsegs:A regression test would be a valid segment at (0.0, 0.5) next to a broken segment at (NaN, 0.5), checking that the path has no NaN point.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 63b3dea: non-finite segments are excluded before integer node keys are created. The regression puts a NaN longitude next to a valid x=0 node and requires a finite route using only the valid row. It failed before the guard and passes now; full workspace CI passes.