diff --git a/crates/analysis/src/network.rs b/crates/analysis/src/network.rs index 0d79ca473..a5c8f51ac 100644 --- a/crates/analysis/src/network.rs +++ b/crates/analysis/src/network.rs @@ -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, + /// 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, snap_m: f64, ) -> Result { + 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 = Vec::new(); let mut adj: Vec> = 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); 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)); + } // 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 + } 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, adj: &mut Vec>| -> 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 { diff --git a/crates/engine/src/cmd/workflows.rs b/crates/engine/src/cmd/workflows.rs index 84832a97d..d3f48986e 100644 --- a/crates/engine/src/cmd/workflows.rs +++ b/crates/engine/src/cmd/workflows.rs @@ -678,6 +678,7 @@ fn interpolate(s: &mut Session, p: &Value) -> CmdResult { add_raster(s, r) } +/// Creates a route layer and reports invalid source segments omitted from its network. fn route(s: &mut Session, p: &Value) -> CmdResult { let id = s.layer_id(p)?; let l = s.map().layer(id).ok_or("no such layer")?; @@ -694,11 +695,18 @@ fn route(s: &mut Session, p: &Value) -> CmdResult { cost, f(p, "snapDistance").unwrap_or(1000.0).clamp(0.0, 1e6), )?; + let source_name = l.name.clone(); let name = out_name(p, format!("Route on {}", l.name)); let mut ds = Dataset::new(name, GeometryType::Polyline, vec![Field::new("length_km", FieldType::Double), Field::new("cost", FieldType::Double)]); ds.push(Some(Geometry::Line(vec![r.path.clone()])), vec![Attr::Float(r.length_m / 1000.0), Attr::Float(r.cost)]); let nid = super::layer::add_dataset(s, ds); + if r.skipped_segments > 0 { + s.notify( + format!("{source_name}: {} segments with invalid coordinates were omitted from the route network", r.skipped_segments), + false, + ); + } Ok(json!({"layer": nid, "lengthKm": r.length_m / 1000.0, "cost": r.cost, "segments": r.rows.len()})) } diff --git a/crates/engine/src/geometry_calc.rs b/crates/engine/src/geometry_calc.rs index 73df963f9..31c9a351e 100644 --- a/crates/engine/src/geometry_calc.rs +++ b/crates/engine/src/geometry_calc.rs @@ -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 { match (g, property) { + // Full-turn and polar rings retain the documented exceptions to geoprocessing parity: + // docs/user-guide/analysis.md (Calculate Geometry Attributes). (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::()) - .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::() }) .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, } } diff --git a/crates/engine/tests/geometry_calculator.rs b/crates/engine/tests/geometry_calculator.rs index c957ea391..de2d6563b 100644 --- a/crates/engine/tests/geometry_calculator.rs +++ b/crates/engine/tests/geometry_calculator.rs @@ -28,7 +28,7 @@ fn selected_units_overwrite_and_undo() { p["overwrite"] = json!(true); assert_eq!(s.run("edit.calculateGeometry", &p).unwrap()["rows"], 1); let value = s.map().layer(id).unwrap().dataset.value(1, 0).as_f64().unwrap(); - assert!((222.0..223.0).contains(&value)); + assert!((222.6..222.7).contains(&value), "{value}"); // the ellipsoid: the sphere gives 222.39 assert_eq!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64(), Some(99.0)); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.map().layer(id).unwrap().dataset.value(1, 0).as_f64(), Some(99.0)); @@ -49,6 +49,22 @@ fn selected_units_overwrite_and_undo() { assert!(s.map().layer(id).unwrap().dataset.field_index("metres").is_none()); } +#[test] +fn areas_are_on_the_wgs84_ellipsoid() { + // An equator 1° × 1° box measured by GeographicLib itself (pyproj.Geod / Polygon.Compute on + // WGS 84); the attribute table's Calculate Geometry values are checked against those (#939). + let ring = vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(0.0, 1.0)]; + let (mut s, id) = session(GeometryType::Polygon, vec![Some(Geometry::Polygon(vec![vec![ring]]))]); + s.run("edit.calculateGeometry", &json!({"layer":id,"field":"result","overwrite":true,"property":"area","unit":"m2","scope":"all"})) + .unwrap(); + let actual = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + assert!((actual - 12_308_778_361.469).abs() < 0.001, "{actual}"); + s.run("edit.calculateGeometry", &json!({"layer":id,"field":"result","overwrite":true,"property":"perimeter","unit":"m","scope":"all"})) + .unwrap(); + let actual = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + assert!((actual - 443_770.917).abs() < 0.001, "{actual}"); +} + #[test] fn polygon_holes_multipart_and_area_units() { let ring = |x: f64, size: f64| { @@ -57,8 +73,9 @@ fn polygon_holes_multipart_and_area_units() { let outer = ring(0.0, 0.01); let hole = ring(0.002, 0.002); let part = ring(0.02, 0.005); - let expected = rustgis_engine::geom::geodesic_ring_area_m2(&outer) - rustgis_engine::geom::geodesic_ring_area_m2(&hole) - + rustgis_engine::geom::geodesic_ring_area_m2(&part); + let expected = rustgis_engine::geom::wgs84_ring_perimeter_area_m2(&outer).1 + - rustgis_engine::geom::wgs84_ring_perimeter_area_m2(&hole).1 + + rustgis_engine::geom::wgs84_ring_perimeter_area_m2(&part).1; let (mut s, id) = session(GeometryType::Polygon, vec![Some(Geometry::Polygon(vec![vec![outer, hole], vec![part]]))]); for (unit, divisor) in [("m2", 1.0), ("km2", 1e6), ("ha", 1e4), ("ac", 4046.8564224)] { s.run("edit.calculateGeometry", &json!({"layer":id,"field":"result","overwrite":true,"property":"area","unit":unit,"scope":"all"})) diff --git a/crates/engine/tests/workflows.rs b/crates/engine/tests/workflows.rs index 22863b21b..8d71711d1 100644 --- a/crates/engine/tests/workflows.rs +++ b/crates/engine/tests/workflows.rs @@ -348,3 +348,45 @@ fn shelby_county_tract_areas_match_the_census() { reopened.run("share.exportLayer", &json!({"layer": "shelby", "path": gpkg})).unwrap(); check(&io::read_path(&gpkg).unwrap().0); } + +#[test] +fn network_routes_report_invalid_segments_but_clean_networks_are_quiet() { + use rustgis_engine::data::{Geometry, GeometryType}; + use rustgis_engine::geom::Coord; + for invalid in [false, true] { + let mut s = Session::new(Project::default()); + let mut ds = Dataset::new("roads", GeometryType::Polyline, vec![]); + ds.push( + Some(Geometry::Line(vec![ + vec![Coord::new(0.0, 0.0), Coord::new(0.01, 0.0)], + vec![Coord::new(0.02, 0.0), Coord::new(0.03, 0.0)], + ])), + vec![], + ); + if invalid { + ds.push(Some(Geometry::Line(vec![vec![Coord::new(f64::NAN, 0.0), Coord::new(0.02, 0.0)]])), vec![]); + ds.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 95.0), Coord::new(0.02, 95.0)]])), vec![]); + } + let id = s.map_mut().add_dataset(ds); + let mark = s.notice_mark(); + s.run("analysis.route", &json!({"layer":id, "from":[0.0,0.0], "to":[0.01,0.0], "snapDistance":1})).unwrap(); + let notices = s.notices_since(mark); + if invalid { + assert_eq!(notices.len(), 1, "{notices:?}"); + assert!(notices[0].text.contains("2 segments") && notices[0].text.contains("roads"), "{notices:?}"); + assert!(!notices[0].error); + } else { + assert!(notices.is_empty(), "{notices:?}"); + } + for (to, expected) in + [(0.03, "the two points aren't connected by the network"), (0.1, "a route end is more than 1 m from the network")] + { + let error = s.run("analysis.route", &json!({"layer":id, "from":[0.0,0.0], "to":[to,0.0], "snapDistance":1})).unwrap_err(); + if invalid { + assert!(error.contains("2 segments") && error.contains("roads"), "{error}"); + } else { + assert_eq!(error, expected); + } + } + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 93ad1686f..9df067429 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -13,7 +13,7 @@ pub use sinusoidal::Sinusoidal; use std::sync::LazyLock; -use geographiclib_rs::{DirectGeodesic, Geodesic, InverseGeodesic}; +use geographiclib_rs::{DirectGeodesic, Geodesic, InverseGeodesic, PolygonArea, Winding}; use serde::{Deserialize, Serialize}; /// Mean Earth radius used by the haversine formula (metres). @@ -243,10 +243,71 @@ pub fn geodesic_destination(c: Coord, azimuth_deg: f64, dist_m: f64) -> Coord { /// The geodesic distance between two lon/lat points on the WGS 84 ellipsoid (metres): the inverse /// geodesic problem, solved by GeographicLib. pub fn wgs84_distance_m(a: Coord, b: Coord) -> f64 { + // A broken coordinate is no place (see [`haversine_m`]): the clamp below would otherwise turn a + // latitude of infinity into a pole. + if !a.is_finite() || !b.is_finite() { + return f64::NAN; + } let (s12, _): (f64, f64) = WGS84.inverse(a.y.clamp(-90.0, 90.0), a.x, b.y.clamp(-90.0, 90.0), b.x); s12 } +/// Length of a lon/lat polyline along the WGS 84 ellipsoid's geodesics (metres): each segment is +/// an inverse geodesic solve, so it is exact, unlike [`geodesic_length_m`]'s great circles. Long +/// lines of many vertices cost a solve per segment (a few microseconds each). +pub fn wgs84_length_m(pts: &[Coord]) -> f64 { + pts.windows(2) + .map(|w| match w { + [a, b] => wgs84_distance_m(*a, *b), + _ => 0.0, + }) + .sum() +} + +/// The perimeter (metres) and the unsigned enclosed area (square metres) of a lon/lat ring on the +/// WGS 84 ellipsoid. The ring is closed along a geodesic to its first point, whatever its winding, +/// so the perimeter and area come from one pass over the ring, not two. A ring gives the +/// region it traces (so polar rings and globe-wide bands follow their own edges, as the sphere's +/// [`geodesic_ring_area_m2`] does); reversing the ring gives the same magnitude. +pub fn wgs84_ring_perimeter_area_m2(ring: &[Coord]) -> (f64, f64) { + // A broken vertex is no place: NaN, never a plausible number (see [`haversine_m`]). + if !ring.iter().all(|c| c.is_finite()) { + return (f64::NAN, f64::NAN); + } + if ring.len() < 2 { + return (0.0, 0.0); + } + // A two-vertex ring is a segment there and back: twice its length, and no area. + if let [a, b] = ring { + return (2.0 * wgs84_distance_m(*a, *b), 0.0); + } + // An edge of a full turn of longitude (-180° to 180°, as a globe-wide band has) joins two + // coordinates that name the same place, which a geodesic cannot tell from no edge at all: such + // a ring is measured on the sphere, as it always was. + let full_turn = ring.iter().zip(ring.iter().cycle().skip(1)).any(|(a, b)| (b.x - a.x).abs() >= 360.0); + if full_turn { + let mut closed = ring.to_vec(); + closed.extend(ring.first().copied()); + return (geodesic_length_m(&closed), geodesic_ring_area_m2(ring)); + } + let mut polygon = PolygonArea::new(&WGS84, Winding::Clockwise); + for c in ring { + polygon.add_point(c.y.clamp(-90.0, 90.0), c.x); + } + // `compute` reduces the signed area to at most half the ellipsoid, so a ring enclosing more + // than half of it comes back as the smaller complement: the sphere's area says which one the + // ring traces. + let (perimeter, area, _) = polygon.compute(true); + let area = area.abs(); + let sphere = geodesic_ring_area_m2(ring); + // Compare a sphere area with its own hemisphere; the ellipsoid's hemisphere differs. + let hemisphere = 2.0 * std::f64::consts::PI * EARTH_RADIUS_M * EARTH_RADIUS_M; + (perimeter, if sphere > hemisphere { WGS84_AREA_M2 - area } else { area }) +} + +/// Surface area 2πa²(1 + (1 − e²) atanh(e) / e) for WGS 84 (square metres). +const WGS84_AREA_M2: f64 = 510_065_621_724_079.0; + /// The WGS 84 ellipsoid's geodesic solver. static WGS84: LazyLock = LazyLock::new(Geodesic::wgs84); @@ -408,6 +469,97 @@ mod tests { } } + #[test] + fn wgs84_rings_keep_full_turn_edges_and_large_areas() { + let c = Coord::new; + // A globe-wide band: its -180° to 180° edges are parallels, not zero-length hops. Measured + // on the sphere: 2πR²(sin 10° − sin −10°) ≈ 8.85e13 m². + let band = [c(-180.0, -10.0), c(180.0, -10.0), c(180.0, 10.0), c(-180.0, 10.0)]; + let (_, area) = wgs84_ring_perimeter_area_m2(&band); + assert!(area > 8.5e13 && area < 9.2e13, "{area}"); + // A ring of 36 vertices at 60° N, closed by a wrapped 10° edge (no full-turn edge): it traces + // the southern cap of latitude < 60°, more than half the Earth, so it must not come back as + // the northern cap. Its area agrees with the sphere's, as the ring's own edges define it. + let big: Vec = (0..36).map(|i| c(-180.0 + f64::from(i) * 10.0, 60.0)).collect(); + let area = wgs84_ring_perimeter_area_m2(&big).1; + assert!(area > WGS84_AREA_M2 / 2.0, "{area}"); + assert!((area / geodesic_ring_area_m2(&big) - 1.0).abs() < 0.01, "{area}"); + // A broken latitude is no pole. + assert!(wgs84_length_m(&[c(0.0, 0.0), c(0.0, f64::INFINITY)]).is_nan()); + assert!(wgs84_distance_m(c(0.0, f64::NEG_INFINITY), c(0.0, 0.0)).is_nan()); + } + + #[test] + fn wgs84_near_hemisphere_rings_keep_the_spherical_side() { + // Both sides of the equator: the spherical and ellipsoidal hemisphere areas differ. + // Compare each model with its own hemisphere, independently of winding. + for latitude in [-0.00001, 0.00001] { + let ring: Vec<_> = (0..36).map(|i| Coord::new(-180.0 + f64::from(i) * 10.0, latitude)).collect(); + let (perimeter, area) = wgs84_ring_perimeter_area_m2(&ring); + assert_eq!(area > WGS84_AREA_M2 / 2.0, latitude > 0.0, "{latitude}: {area}"); + let reversed: Vec<_> = ring.iter().rev().copied().collect(); + let (reverse_perimeter, reverse_area) = wgs84_ring_perimeter_area_m2(&reversed); + assert!((reverse_area - area).abs() < 1.0); + assert!((reverse_perimeter - perimeter).abs() < 1e-6); + } + } + + #[test] + fn wgs84_measures_match_geographiclib() { + // References from GeographicLib (pyproj.Geod / Polygon.Compute on WGS 84), cross-checked + // against `geo`'s GeodesicArea: a 1° × 1° box at the equator, and the same box stretched + // across the antimeridian (unwrapped, 179° to 181°). + let equator = &[Coord::new(0.0, 0.0), Coord::new(0.0, 1.0), Coord::new(1.0, 1.0), Coord::new(1.0, 0.0)]; + let (perimeter, area) = wgs84_ring_perimeter_area_m2(equator); + assert!((perimeter - 443_770.917).abs() < 0.001, "{perimeter}"); + assert!((area - 12_308_778_361.469).abs() < 0.001, "{area}"); + // The ring traced the other way around gives the same magnitude. + let (perimeter_reversed, area_reversed) = wgs84_ring_perimeter_area_m2(&equator.iter().rev().copied().collect::>()); + assert!((perimeter - perimeter_reversed).abs() < 1e-6); + assert!((area - area_reversed).abs() < 1e-6); + // Unwrapped longitudes past the antimeridian are one ring, not a world-sized complement. + let across = &[Coord::new(179.0, 0.0), Coord::new(181.0, 0.0), Coord::new(181.0, 1.0), Coord::new(179.0, 1.0)]; + let (perimeter_across, area_across) = wgs84_ring_perimeter_area_m2(across); + assert!((perimeter_across - 666_393.055).abs() < 0.001, "{perimeter_across}"); + assert!((area_across - 24_619_443_759.277).abs() < 0.001, "{area_across}"); + // Segment lengths are inverse geodesic solves, unwrapped or folded alike. + assert!((wgs84_length_m(&[Coord::new(0.0, 0.0), Coord::new(3.0, 0.0)]) - 3.0 * 111_319.490_8).abs() < 0.01); + assert!((wgs84_distance_m(Coord::new(179.0, 0.0), Coord::new(-179.0, 0.0)) - 222_638.981_6).abs() < 0.001); + // A broken coordinate is no length and no area (see `haversine_m`'s guard). + assert!(wgs84_length_m(&[Coord::new(0.0, 0.0), Coord::new(f64::NAN, 0.0)]).is_nan()); + let (perimeter_broken, area_broken) = + wgs84_ring_perimeter_area_m2(&[Coord::new(0.0, 0.0), Coord::new(0.0, 1.0), Coord::new(f64::NAN, 0.0)]); + assert!(perimeter_broken.is_nan() && area_broken.is_nan()); + // A single vertex keeps its zero; two vertices are a segment there and back (2d, no area). + assert_eq!(wgs84_ring_perimeter_area_m2(&[Coord::new(0.0, 0.0)]), (0.0, 0.0)); + let (perimeter_two, area_two) = wgs84_ring_perimeter_area_m2(&[Coord::new(0.0, 0.0), Coord::new(1.0, 0.0)]); + assert!((perimeter_two - 2.0 * 111_319.490_8).abs() < 0.01, "{perimeter_two}"); + assert_eq!(area_two, 0.0); + // Degenerate rings keep their zero, unless a vertex is broken: that is NaN, even in a short ring. + let (perimeter_short, area_short) = wgs84_ring_perimeter_area_m2(&[Coord::new(f64::NAN, f64::NAN), Coord::new(0.0, 0.0)]); + assert!(perimeter_short.is_nan() && area_short.is_nan()); + } + + #[test] + fn a_non_finite_coordinate_is_no_distance_no_area_and_no_place() { + // A NaN vertex once measured as a quarter of the Earth's circumference (π·R = 20 015 114 + // m) through `h.sqrt().min(1.0)`: `f64::min` ignores NaN. It must be no distance at all, so + // sums and panels stay visibly broken and comparisons exclude it (NaN <= r is false). + let nan = f64::NAN; + let origin = Coord::new(0.0, 0.0); + for near in [Coord::new(nan, 0.0), Coord::new(0.0, nan), Coord::new(f64::INFINITY, 0.0)] { + assert!(haversine_m(origin, near).is_nan()); + assert!(geodesic_length_m(&[origin, near, origin]).is_nan()); + } + // A finite edge next to a broken one keeps measuring, through its own points. + assert!((geodesic_length_m(&[origin, Coord::new(1.0, 0.0)]) - 111_195.080).abs() < 0.01); + // Ring area: a NaN absorbing match on the older behaviour, then anything broken. + assert!(geodesic_ring_area_m2(&[origin, Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(nan, 1.0)]).is_nan()); + assert!(geodesic_ring_area_m2(&[origin, Coord::new(1.0, 0.0), Coord::new(1.0, f64::INFINITY)]).is_nan()); + // Heading away from a broken place is no place either. + assert!(geodesic_destination(Coord::new(10.0, 50.0), 90.0, f64::NAN).x.is_nan()); + } + #[test] fn azimuthal_equidistant_keeps_distances_from_its_centre() { // Distances and azimuths on WGS 84 from GeographicLib (pyproj.Geod(ellps="WGS84").inv). diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 34bdb88b7..6de26997c 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -8,7 +8,7 @@ use rustgis_engine::analysis::{ network::{radius_boundary_point, radius_distance_m}, selection::MAX_POINTS, }; -use rustgis_engine::geom::{Coord, Extent, geodesic_length_m, geodesic_ring_area_m2}; +use rustgis_engine::geom::{Coord, Extent, geodesic_length_m, geodesic_ring_area_m2, wgs84_length_m, wgs84_ring_perimeter_area_m2}; use rustgis_engine::map::layout::TextAlign; use rustgis_engine::map::projection::Flat; use rustgis_engine::map::{Camera, Globe, Projection}; @@ -2639,6 +2639,7 @@ pub fn finish_graphic(app: &mut RustGisApp, shape: &str, pts: Vec) { } } +/// Paints map decorations and interactive readouts, including measurements on the map's body. fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: &Tokens) { let accent = t.accent; // Box drag. @@ -2838,16 +2839,26 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: p.circle_stroke(*s, 3.5, Stroke::new(1.5, color)); } if matches!(tool, Tool::Measure | Tool::MeasureArea) && pts.len() >= 2 { - // On the map's body. + // On the map's body: the Earth measures on the WGS 84 ellipsoid, so the panel agrees + // with the geoprocessing tools (#939); another body measures on its sphere and scales. let body = app.session.map().body(); - let geodesic_length_m = |pts: &[Coord]| body.length(geodesic_length_m(pts)); - let geodesic_ring_area_m2 = |ring: &[Coord]| body.area(geodesic_ring_area_m2(ring)); + let measure_length = |pts: &[Coord]| { + if body.is_earth() { wgs84_length_m(pts) } else { body.length(geodesic_length_m(pts)) } + }; + // Perimeter and area in the map body's measurement model. + let measure_ring = |ring: &[Coord]| { + if body.is_earth() { + wgs84_ring_perimeter_area_m2(ring) + } else { + (body.length(geodesic_length_m(ring)), body.area(geodesic_ring_area_m2(ring))) + } + }; let text = if tool == Tool::Measure { - let seg = pts.len().checked_sub(2).and_then(|i| pts.get(i..)).map(geodesic_length_m).unwrap_or(0.0); + let seg = pts.len().checked_sub(2).and_then(|i| pts.get(i..)).map(measure_length).unwrap_or(0.0); crate::i18n::fmt( "Total {total} · Segment {segment}", &[ - ("total", &crate::measure_units::distance(geodesic_length_m(&pts), app.ui.measurement_imperial)), + ("total", &crate::measure_units::distance(measure_length(&pts), app.ui.measurement_imperial)), ("segment", &crate::measure_units::distance(seg, app.ui.measurement_imperial)), ], ) @@ -2856,11 +2867,12 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: if let Some(f) = ring.first().copied() { ring.push(f); } + let (perimeter, area) = measure_ring(&ring); crate::i18n::fmt( "Area {area} · Perimeter {perimeter}", &[ - ("area", &crate::measure_units::area(geodesic_ring_area_m2(&ring), app.ui.measurement_imperial)), - ("perimeter", &crate::measure_units::distance(geodesic_length_m(&ring), app.ui.measurement_imperial)), + ("area", &crate::measure_units::area(area, app.ui.measurement_imperial)), + ("perimeter", &crate::measure_units::distance(perimeter, app.ui.measurement_imperial)), ], ) }; diff --git a/crates/ui-egui/tests/measurement_units.rs b/crates/ui-egui/tests/measurement_units.rs index 933f20097..3bee6331a 100644 --- a/crates/ui-egui/tests/measurement_units.rs +++ b/crates/ui-egui/tests/measurement_units.rs @@ -391,3 +391,26 @@ fn measurement_unit_choice_is_saved_restored_and_available_to_scripts() { assert!(restored.run("ui.measurementUnits", json!({"units": "unknown"})).is_err()); assert!(!restored.ui.measurement_imperial); } + +#[test] +fn moon_measure_readout_uses_its_sphere_instead_of_wgs84() { + let mut h = harness(); + h.state_mut().run("map.body", json!({"name": "moon", "basemap": false})).unwrap(); + h.state_mut().run("map.goToXY", json!({"lon": 0, "lat": 0, "scale": 100000})).unwrap(); + h.state_mut().run("tool.set", json!({"tool": "measure"})).unwrap(); + for _ in 0..3 { + h.step(); + } + let center = h.state().map.view_rect.unwrap().0.center(); + click_map(&mut h, center - egui::vec2(50.0, 0.0)); + let first = h.state().map.pointer_lonlat.unwrap(); + click_map(&mut h, center + egui::vec2(50.0, 0.0)); + let last = h.state().map.pointer_lonlat.unwrap(); + assert_eq!(h.state().map.sketch_len(), 2); + let body = h.state().session.map().body(); + let expected = body.length(rustgis_engine::geom::geodesic_length_m(&[first, last])); + let expected = rustgis_engine::geom::format_distance(expected); + let text = painted_text(&h); + let readout = text.iter().find(|s| s.starts_with("Total ")).unwrap_or_else(|| panic!("{text:?}")); + assert!(readout.starts_with(&format!("Total {expected} ")), "{readout}; expected {expected}"); +} diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 914d5621b..a93c6b31d 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -228,7 +228,17 @@ use the smaller polar region in either direction; reversing a ring does not sele complement. Edges follow the shortest geodesics between vertices. The output fields remain `area_km2`, `perimeter_km` and `length_km`; point coordinates are unchanged. Spatial Join's **Largest overlap** still measures area on a sphere of radius 6,371 km; it can -differ from the ellipsoid by up to about 0.9 % (a small area near a pole). +differ from the ellipsoid by up to about 0.9 % (a small area near a pole). The same WGS 84 +measure backs the attribute table's **Calculate Geometry** (`edit.calculateGeometry`), the map's +**Measure** tools on the Earth, and every network tool's edge and route lengths, so all of these +read the same number for the same geometry, with two exceptions. A polygon ring with a full-turn +(-180° to 180°) edge, such as a globe-wide band, is measured on the sphere by the attribute table +and the Measure tools, while **Calculate Geometry Attributes** unwraps it, so it reads near zero +there. A ring winding around a pole is measured as the region it traces by the attribute table +and the Measure tools (a ring that goes all the way round at 60° N gives the larger region), +while **Calculate Geometry Attributes** gives the smaller polar cap. On the Moon or another body, the Measure tools measure on +that body's sphere. The Radius tools still measure a local Mercator distance, and **Mensuration +Tools** (`raster.measure`) still measure on the sphere, so those can differ from the numbers above. **Merge** (`merge`, Data Management › General) appends loaded vector layers of the same geometry type (points, lines or polygons) into a new layer. It needs no GeoLibre runner. Choose **Input @@ -656,6 +666,10 @@ click them on the map: | Embeddings | The features most like the selected one, by their numeric attributes | | Data Interop | KML, GPX, CSV with WKT, GeoJSON or CSV | +Network routing rebuilds its graph for each request and solves each edge length on WGS 84. +Large line networks can therefore take longer to route than with spherical lengths; repeated +routes do not currently share a cached graph. + Rasters these make are kept in the project. **Environments** (`gp.env`) set an extent from the map view, full extent, a layer or a longitude/latitude box (or `null` to clear it). For tools that honour it, this restricts an unselected main feature input by bounding-box intersection, not by diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 6bddd9d27..6f04ece8f 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -92,7 +92,10 @@ For an editable feature layer, double-click a cell to change it. The table's too fields, calculates field [expressions](expressions.md) and opens **Calculate Geometry** (`edit.calculateGeometry`). The geometry calculator fills a new double field or an existing numeric field with area, perimeter, length or point X/Y, for all or selected features. Point coordinates can be WGS 84 -degrees or a supported projected EPSG system's units. See [Editing](editing.md) for field +degrees or a supported projected EPSG system's units. Areas, perimeters and lengths measure on +the WGS 84 ellipsoid, with the exceptions for full-turn and polar rings listed under +[Geoprocessing](analysis.md#geoprocessing-tools). +See [Editing](editing.md) for field calculations and data-design rules. Streamed layers must first be loaded with **Load All Features**. diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 252e98022..ee334baf0 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -10,7 +10,11 @@ to change it. **Data › Data Design** (and **Data › Fields**) adds and delete field from an expression; **Calculate Geometry** (in the attribute table's toolbar) fills a new or existing numeric field with each feature's area, perimeter, length or X/Y coordinate, in the units you choose, for all features or the selected ones. Length and perimeter include feet and miles; -area includes square feet (`ft2`), acres (`ac`) and square miles (`mi2`). Every edit is one undo step. +area includes square feet (`ft2`), acres (`ac`) and square miles (`mi2`). Every edit is one undo +step. Areas, perimeters and lengths measure on the WGS 84 ellipsoid, the same measure as the network tools and the Measure panel (Spatial Join still +measures on the sphere), with two exceptions: a ring with a full-turn edge (a globe-wide band) +is measured on the sphere instead, and a ring winding around a pole is measured as the region it +traces, where [Geoprocessing](analysis.md#geoprocessing-tools) gives the smaller polar cap. Undo (++ctrl+z++) and Redo (++ctrl+y++ or ++ctrl+shift+z++) restore project changes, not just feature edits; use ++cmd++ instead of ++ctrl++ on macOS. While typing in a text field, those