Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 97 additions & 15 deletions crates/analysis/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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)]
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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 };

Copy link
Copy Markdown
Contributor

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 with as 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 returned Route::path can 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 of segs:

Suggested change
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
if !a.is_finite() || !b.is_finite() {
continue;
}

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.

Copy link
Copy Markdown
Member

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.

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 gp.route, and the PR does not report timings for it.

Consider measuring a large layer before and after, and either documenting the cost or lowering the cap for this path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance (medium confidence): every shortest_route call now runs one GeographicLib solve per graph segment. Graph build used to be a haversine per segment. The PR's performance argument only covers the snap scan, so this is a per-request cost on large layers, multiplied by the 10M-segment cap. The snap and attach paths are fine, since each uses a handful of solves.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 shortest_route rebuilds the graph on each call. Haversine is a few tens of nanoseconds and a geodesic solve is roughly a microsecond or more, so graph construction could be one to two orders of magnitude slower on large line layers, and each route query pays it again. The 10,000,000-segment cap also means a worst-case build can now take seconds. Worth a quick timing on a large network before merge, and consider caching edge lengths per layer rather than per query.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 shortest_route call, because the graph is rebuilt per call. The snap scan was made cheap (haversine ranking, one solve for the winner), but the graph build is O(E) solves. The PR notes a few microseconds per solve, so routing on a large line layer (around 1M segments) would add seconds of latency per request. Worth measuring on a realistic network, and considering caching the edge lengths per layer or only solving on the ellipsoid where the result is shown.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 wgs84_length_m doc already notes the per-segment cost, but this is the network path, so large networks (millions of segments) will pay seconds on every route build. This is probably an acceptable trade for accuracy. If it is kept, consider stating the cost in the tool docs, or caching the graph per layer and cost field.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 haversine_m. The snap scan was optimised to avoid this, but the build was not. For a network of millions of segments (the cap is 10 M) this adds seconds on every route request, since the graph is rebuilt each time. The route cost needs ellipsoid lengths, so the fix is to cache the graph per layer and cost field, or to document the cost. Please confirm with a timing on a large line layer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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. nearest also runs solves for candidates that survive the bound. The 10M-segment cap still applies, so one request on a large network could block for many seconds, and this is worse if routing runs on the UI thread. The PR documents this as an accepted follow-up. Please measure a large-network route before merge, or lower the cap for routing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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));
}
Expand All @@ -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));
}
Comment thread
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);
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

WGS84_MIN_RADIUS_M / EARTH_RADIUS_M is about 0.99776, but the ellipsoidal distance can fall to the minimum meridional radius of curvature, a(1 − e²) ≈ 6 335 439 m. That gives a factor of about 0.99442. Along a meridian at the equator, the true ellipsoidal length is about 0.9944 × the spherical length, which is below the 0.99776 bound. A candidate can therefore be pruned while it is actually nearer on WGS 84 than the current best.

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 WGS84_MIN_RADIUS_M in crates/geom/src/lib.rs, for example pub const WGS84_MIN_MERIDIAN_RADIUS_M: f64 = 6_335_439.327_293; (a(1−e²)), then use it here:

Suggested change
sphere * WGS84_MIN_RADIUS_M / EARTH_RADIUS_M
sphere * WGS84_MIN_MERIDIAN_RADIUS_M / EARTH_RADIUS_M

The comment above the fold also needs correcting, and the test at crates/geom/src/lib.rs (around line 587) that asserts the same WGS84_MIN_RADIUS_M factor should use the new constant. A regression test with a meridional equatorial pair would catch this.

@Mavrick-F Mavrick-F Nov 1, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing constant already has the suggested value: WGS84_MIN_RADIUS_M = 6_335_439.327_293 in geom. Its ratio to EARTH_RADIUS_M is 0.99441698, not 0.99776.

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));
}
Expand All @@ -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));
}
Expand All @@ -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));
}
Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/cmd/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand All @@ -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()}))
}

Expand Down
20 changes: 8 additions & 12 deletions crates/engine/src/geometry_calc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 analysis.md says the geoprocessing tool gives the smaller polar cap and "reads near zero" for full-turn bands, while this path returns the traced region and the sphere for those rings. The docs disclose the split, but the PR's stated goal is "one number per geometry", and this is the exception. Consider stating the exception in the PR description as a known divergence, or aligning the two tools in a follow-up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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,
}
}
Expand Down
23 changes: 20 additions & 3 deletions crates/engine/tests/geometry_calculator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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| {
Expand All @@ -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"}))
Expand Down
42 changes: 42 additions & 0 deletions crates/engine/tests/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Comment thread
Mavrick-F marked this conversation as resolved.
Resolved
} else {
assert_eq!(error, expected);
}
}
}
}
Loading
Loading