diff --git a/crates/analysis/src/rasterops.rs b/crates/analysis/src/rasterops.rs index 786d4b9cb..03e59584c 100644 --- a/crates/analysis/src/rasterops.rs +++ b/crates/analysis/src/rasterops.rs @@ -523,29 +523,6 @@ pub fn zonal_means(r: &Raster, bands: &[usize], rings: &[Vec]) -> Option< (n > 0).then(|| (sums.into_iter().map(|s| s / n as f64).collect(), n)) } -/// Transforms one polygon part, keeping its exterior and holes together. -/// A failed coordinate or degenerate ring (< 3 points) rejects the entire polygon part. -pub fn transform_polygon(rings: &[Vec], mut transform: impl FnMut(Coord) -> Option) -> Option>> { - if rings.first()?.len() < 3 { - return None; - } - let mut out = Vec::new(); - for ring in rings { - if ring.len() < 3 { - return None; - } - let transformed: Option> = ring - .iter() - .map(|&c| { - let p = transform(c)?; - (p.x.is_finite() && p.y.is_finite()).then_some(p) - }) - .collect(); - out.push(transformed?); - } - Some(out) -} - /// An error encountered while validating or transforming a polygon geometry. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PolygonTransformError { @@ -643,6 +620,24 @@ pub fn transform_feature_polygons_to_lonlat( Ok(out) } +/// Validates a 1-based band number and returns its 0-based index. +pub(crate) fn band_from_f64(value: f64, total_bands: usize) -> Result { + if !value.is_finite() { + return Err("band number must be a finite number".into()); + } + if value <= 0.0 { + return Err("band numbers must be positive (1-based)".into()); + } + if value.fract() != 0.0 { + return Err("band numbers must be integers, not decimals".into()); + } + // Check before casting, including on wasm32, where huge values would saturate. + if value > total_bands as f64 || value >= usize::MAX as f64 { + return Err(format!("band index {value} is invalid: raster has bands 1 to {total_bands}")); + } + Ok(value as usize - 1) +} + /// Parses a band specification string into 0-based band indices. /// /// Supported inputs: @@ -691,20 +686,7 @@ pub fn parse_bands_str(s: &str, total_bands: usize) -> Result, String continue; } let f: f64 = subpart.parse().map_err(|_| format!("invalid band number '{subpart}'"))?; - if !f.is_finite() { - return Err("band number must be a finite number".into()); - } - if f <= 0.0 { - return Err("band numbers must be positive (1-based)".into()); - } - if f.fract() != 0.0 { - return Err("band numbers must be integers, not decimals".into()); - } - let b = f as usize; - if b > total_bands { - return Err(format!("band index {b} is invalid: raster has bands 1 to {total_bands}")); - } - let b_idx = b - 1; + let b_idx = band_from_f64(f, total_bands)?; if !out.contains(&b_idx) { out.push(b_idx); } @@ -716,53 +698,51 @@ pub fn parse_bands_str(s: &str, total_bands: usize) -> Result, String Ok(out) } +/// Projects a complete ring to the raster grid without dropping failed vertices. +fn project_ring(r: &Raster, ring: &[Coord]) -> Option> { + if ring.len() < 3 { + return None; + } + let tr = r.transformer(); + ring.iter() + .map(|&c| { + if !c.is_finite() { + return None; + } + let pt = r.transform.to_pixel(tr.from_lonlat(c)?); + (pt.0.is_finite() && pt.1.is_finite()).then_some(pt) + }) + .collect() +} + /// The cells of `r` whose centres fall inside any of `polygons` (each an exterior ring and its /// holes, in longitude/latitude): the column and row window around the polygons, clamped to the /// raster, and a row-major mask over it. `None` when no cell centre falls inside. /// /// The rings are projected onto the raster's own grid once and filled by scanline, so a large /// raster costs a pass over the polygon edges per row rather than a point test per cell. +/// A malformed ring or a failed projection rejects the entire polygon part, including its holes. pub fn polygon_mask(r: &Raster, polygons: &[Vec>]) -> Option<(Range, Range, Vec)> { - let tr = r.transformer(); + polygon_mask_with_failures(r, polygons, &mut Vec::new()) +} + +/// Builds a polygon mask and appends the zero-based indices of rejected parts to `failed_parts`. +/// Valid parts are retained even if another part cannot be projected onto the raster grid. +pub fn polygon_mask_with_failures( + r: &Raster, + polygons: &[Vec>], + failed_parts: &mut Vec, +) -> Option<(Range, Range, Vec)> { let projected: Vec>> = polygons .iter() - .filter_map(|rings| { - let exterior = rings.first()?; - let mut ext_pts = Vec::with_capacity(exterior.len()); - for c in exterior { - if !c.is_finite() { - return None; - } - let w = tr.from_lonlat(*c)?; - let pt = r.transform.to_pixel(w); - if !pt.0.is_finite() || !pt.1.is_finite() { - return None; - } - ext_pts.push(pt); + .enumerate() + .filter_map(|(index, rings)| { + // Reject a complete part if any ring fails, so a lost hole cannot enlarge it. + let part = if rings.is_empty() { None } else { rings.iter().map(|ring| project_ring(r, ring)).collect::>>() }; + if part.is_none() { + failed_parts.push(index); } - if ext_pts.len() < 3 { - return None; - } - let mut valid_rings = vec![ext_pts]; - for hole in rings.iter().skip(1) { - let mut hole_pts = Vec::with_capacity(hole.len()); - for c in hole { - if !c.is_finite() { - return None; - } - let w = tr.from_lonlat(*c)?; - let pt = r.transform.to_pixel(w); - if !pt.0.is_finite() || !pt.1.is_finite() { - return None; - } - hole_pts.push(pt); - } - if hole_pts.len() < 3 { - return None; - } - valid_rings.push(hole_pts); - } - Some(valid_rings) + part }) .collect(); let (mut c0, mut r0, mut c1, mut r1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); @@ -918,6 +898,16 @@ impl Accumulator { /// excluded. If no valid cells fall inside the polygon or if the polygon is outside the raster extent, /// returns `ZoneStats` with `count = 0` and all numeric statistics set to `None`. pub fn zonal_statistics(r: &Raster, bands: &[usize], polygons: &[Vec>]) -> Result, String> { + zonal_statistics_with_failures(r, bands, polygons, &mut Vec::new()) +} + +/// Computes zonal statistics, appending raster-side projection failures as zero-based part indices. +pub(crate) fn zonal_statistics_with_failures( + r: &Raster, + bands: &[usize], + polygons: &[Vec>], + failed_parts: &mut Vec, +) -> Result, String> { r.require_coordinates()?; for &b in bands { if b >= r.band_count() { @@ -928,7 +918,7 @@ pub fn zonal_statistics(r: &Raster, bands: &[usize], polygons: &[Vec> return Ok(Vec::new()); } - let Some((cols, rows, mask)) = polygon_mask(r, polygons) else { + let Some((cols, rows, mask)) = polygon_mask_with_failures(r, polygons, failed_parts) else { return Ok(vec![ZoneStats::default(); bands.len()]); }; @@ -1091,6 +1081,27 @@ mod tests { assert!(polygon_mask(&r, &[vec![sq(0.0051, 0.0051, 0.0052, 0.0052)]]).is_none()); } + /// A failed hole drops only its containing part and reports that part, even with no mask. + #[test] + fn polygon_mask_reports_rejected_parts_and_retains_valid_parts() { + let r = ramp(); + let valid = vec![vec![Coord::new(0.0, 0.0), Coord::new(0.002, 0.0), Coord::new(0.002, 0.002), Coord::new(0.0, 0.002)]]; + let mut invalid = valid.clone(); + invalid.push(vec![Coord::new(f64::NAN, 0.0); 3]); + let polys = vec![invalid.clone(), valid.clone()]; + let mut failures = Vec::new(); + let mask = polygon_mask_with_failures(&r, &polys, &mut failures); + assert_eq!(mask, polygon_mask(&r, &[valid])); + assert_eq!(failures, vec![0]); + failures.clear(); + let stats = zonal_statistics_with_failures(&r, &[0], &polys, &mut failures).unwrap(); + assert_eq!(stats[0].count, 4); + assert_eq!(failures, vec![0]); + failures.clear(); + assert!(polygon_mask_with_failures(&r, &[invalid], &mut failures).is_none()); + assert_eq!(failures, vec![0]); + } + #[test] fn terrain_math_and_classes() { let r = ramp(); diff --git a/crates/analysis/src/tools.rs b/crates/analysis/src/tools.rs index feeaf7054..24fbef12b 100644 --- a/crates/analysis/src/tools.rs +++ b/crates/analysis/src/tools.rs @@ -587,11 +587,11 @@ pub fn find_field_case_insensitive(ds: &Dataset, name: &str) -> Result Err(format!("no field named '{name}'")), - 1 => Ok(matches[0]), + match matches.as_slice() { + [] => Err(format!("no field named '{name}'")), + [only] => Ok(*only), _ => { - let names: Vec = matches.iter().map(|&i| ds.fields[i].name.clone()).collect(); + let names: Vec = matches.iter().filter_map(|&i| ds.fields.get(i).map(|f| f.name.clone())).collect(); Err(format!( "field name '{name}' is ambiguous: case-insensitive collision between {}", names.iter().map(|n| format!("'{n}'")).collect::>().join(" and ") @@ -1546,79 +1546,33 @@ impl StatOp { } /// Parses the `bands` parameter for zonal statistics. +/// Null and an empty string default to band 1, matching the workflow form. pub fn zonal_bands(value: &Json, total_bands: usize) -> Result, String> { - let dummy = serde_json::json!({ "bands": value }); - parse_bands(&dummy, total_bands) -} - -fn parse_bands(p: &Json, total_bands: usize) -> Result, String> { if total_bands == 0 { return Err("raster has no bands".into()); } - match p.get("bands") { - None | Some(Json::Null) => Ok(vec![0]), - Some(Json::String(s)) => { - if s.trim().is_empty() { - return Err("bands parameter cannot be an empty string".into()); - } - crate::rasterops::parse_bands_str(s, total_bands) - } - Some(Json::Number(n)) => { - let f = n.as_f64().ok_or_else(|| "invalid band number".to_string())?; - if !f.is_finite() { - return Err("band number must be a finite number".into()); - } - if f <= 0.0 { - return Err("band numbers must be positive (1-based)".into()); - } - if f.fract() != 0.0 { - return Err("band numbers must be integers, not decimals".into()); - } - let b = f as usize; - if b > total_bands { - return Err(format!("band index {b} is invalid: raster has bands 1 to {total_bands}")); - } - Ok(vec![b - 1]) + match value { + Json::Null => Ok(vec![0]), + Json::String(s) => crate::rasterops::parse_bands_str(s, total_bands), + Json::Number(n) => { + Ok(vec![crate::rasterops::band_from_f64(n.as_f64().ok_or_else(|| "invalid band number".to_string())?, total_bands)?]) } - Some(Json::Array(arr)) => { + Json::Array(arr) => { if arr.is_empty() { return Err("bands array cannot be empty".into()); } let mut out = Vec::new(); for item in arr { - match item { - Json::Number(n) => { - let f = n.as_f64().ok_or_else(|| "invalid band number".to_string())?; - if !f.is_finite() { - return Err("band number must be a finite number".into()); - } - if f <= 0.0 { - return Err("band numbers must be positive (1-based)".into()); - } - if f.fract() != 0.0 { - return Err("band numbers must be integers, not decimals".into()); - } - let b = f as usize; - if b > total_bands { - return Err(format!("band index {b} is invalid: raster has bands 1 to {total_bands}")); - } - let b_idx = b - 1; - if !out.contains(&b_idx) { - out.push(b_idx); - } - } - Json::String(s) => { - if s.trim().is_empty() { - return Err("band items cannot be empty strings".into()); - } - let sub = crate::rasterops::parse_bands_str(s, total_bands)?; - for b_idx in sub { - if !out.contains(&b_idx) { - out.push(b_idx); - } - } + if !matches!(item, Json::Number(_) | Json::String(_)) { + return Err("band items must be numbers or strings".into()); + } + if item.as_str().is_some_and(|s| s.trim().is_empty()) { + return Err("band items cannot be empty strings".into()); + } + for band in zonal_bands(item, total_bands)? { + if !out.contains(&band) { + out.push(band); } - _ => return Err("band items must be numbers or strings".into()), } } Ok(out) @@ -1627,6 +1581,10 @@ fn parse_bands(p: &Json, total_bands: usize) -> Result, String> { } } +fn parse_bands(p: &Json, total_bands: usize) -> Result, String> { + zonal_bands(p.get("bands").unwrap_or(&Json::Null), total_bands) +} + fn parse_stats(p: &Json) -> Result, String> { match p.get("stats") { None | Some(Json::Null) => Ok(StatOp::ALL.to_vec()), @@ -1750,6 +1708,7 @@ fn zonal_stats(inp: &dyn Inputs, p: &Json) -> Result { let target_rows = rows(inp, p, poly_param, poly_ds); let mut skipped_features = 0usize; + let mut skipped_parts = 0usize; let mut failure_details = Vec::new(); for r_idx in target_rows { let Some(f) = poly_ds.features.get(r_idx) else { continue }; @@ -1757,7 +1716,13 @@ fn zonal_stats(inp: &dyn Inputs, p: &Json) -> Result { Some(Geometry::Polygon(polys)) if !polys.is_empty() => { match crate::rasterops::transform_feature_polygons_to_lonlat(polys, poly_tr.as_ref()) { Ok(polys_lonlat) => { - let st = crate::rasterops::zonal_statistics(r, &bands, &polys_lonlat)?; + let mut failed_parts = Vec::new(); + let st = crate::rasterops::zonal_statistics_with_failures(r, &bands, &polys_lonlat, &mut failed_parts)?; + skipped_parts += failed_parts.len(); + if !failed_parts.is_empty() { + failure_details + .push(format!("feature {}: raster projection failed for polygon part(s) {failed_parts:?}", f.id)); + } (st, true) } Err(e) => { @@ -1798,6 +1763,11 @@ fn zonal_stats(inp: &dyn Inputs, p: &Json) -> Result { let mut messages = vec![format!("Calculated zonal statistics for {n_features} feature(s)")]; if skipped_features > 0 { messages.push(format!("{skipped_features} feature(s) had invalid geometry or failed coordinate transformation")); + } + if skipped_parts > 0 { + messages.push(format!("{skipped_parts} polygon part(s) could not be projected onto the raster grid; valid parts were retained")); + } + if !failure_details.is_empty() { for detail in failure_details.iter().take(5) { messages.push(detail.clone()); } @@ -3140,9 +3110,9 @@ mod tests { assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "stats": ["median"]})).is_err()); assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "stats": [123]})).is_err()); - // 2. Invalid bands inputs + // 2. An empty band string defaults to band 1; malformed inputs fail. assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": []})).is_err()); - assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": ""})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": ""})).is_ok()); assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": 1.5})).is_err()); assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": 99})).is_err()); assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": -1})).is_err()); diff --git a/crates/engine/src/cmd/imagery.rs b/crates/engine/src/cmd/imagery.rs index 4fb18210b..ce1cd3f1a 100644 --- a/crates/engine/src/cmd/imagery.rs +++ b/crates/engine/src/cmd/imagery.rs @@ -536,10 +536,10 @@ fn index(s: &mut Session, p: &Value) -> CmdResult { /// - Polygons are combined into a composite scanline mask over the raster. Holes cut out cells using the /// even-odd rule within each polygon. Where multiple polygons overlap, their coverage is combined /// (union: any cell falling inside at least one polygon is retained). -/// - The raster is cropped to the tight bounding box of all selected polygons, and cells outside the +/// - The raster is cropped to the tight bounding box of valid selected polygons, and cells outside the /// polygons receive NoData. /// - **Partial Failure Reporting**: -/// - Features with invalid geometry or unprojectable coordinates are skipped and logged with a warning. +/// - Features with invalid geometry or unprojectable coordinates are skipped and reported in the result messages. /// - If all features fail, the operation fails and reports the specific failure cause of the first feature. fn process(s: &mut Session, p: &Value) -> CmdResult { let r = raster(s, p)?; @@ -606,10 +606,29 @@ fn process(s: &mut Session, p: &Value) -> CmdResult { if !failed_features.is_empty() { log::warn!("{pl}: {} feature(s) failed polygon transformation during clipping", failed_features.len()); } - let (cols, rows, mask) = - rasterops::polygon_mask(&r, &polygons).ok_or_else(|| format!("no cell of {} falls inside {pl}", r.name))?; + let mut failed_parts = Vec::new(); + let (cols, rows, mask) = rasterops::polygon_mask_with_failures(&r, &polygons, &mut failed_parts).ok_or_else(|| { + format!("no cell of {} falls inside {pl} ({} polygon part(s) failed raster projection)", r.name, failed_parts.len()) + })?; let out = clip_window(&r, cols, rows, Some(&mask), name(p, format!("{}_clip", r.name)))?; - add(s, out, false) + let mut result = add(s, out, false)?; + let mut messages = Vec::new(); + if !failed_features.is_empty() { + messages.push(format!( + "{pl}: skipped {} feature(s) with invalid polygon geometry or failed coordinate transformation", + failed_features.len() + )); + } + if !failed_parts.is_empty() { + messages.push(format!( + "{pl}: skipped {} polygon part(s) that could not be projected onto the raster grid; valid parts were retained", + failed_parts.len() + )); + } + if !messages.is_empty() { + result["messages"] = json!(messages); + } + Ok(result) } "clip" => { // Before the extent is placed on the raster's coordinates. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5306b6a56..d98e0146a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15547,11 +15547,25 @@ fn gp_zonal_stats_end_to_end_and_undo() { assert_eq!(merc_layer.dataset.value(0, m_count_idx), &Value::Int(4)); assert_eq!(merc_layer.dataset.value(0, m_mean_idx), &Value::Float(16.5)); + let multiband = Raster::new( + "multi", + w, + h, + vec![vec![2.0; (w * h) as usize], vec![7.0; (w * h) as usize]], + gt, + Crs::LonLat, + None, + ColorHint::Gray, + "Float32", + ) + .unwrap(); + s.map_mut().add_raster("multi", rustgis_map::RasterLayer::from_path(multiband, "multi.tif")); + // "bands": "all" let all_res = s.run( "gp.zonalStats", &json!({ - "raster": "dem", + "raster": "multi", "polygons": "districts", "output": "all_bands_out", "bands": "all", @@ -15561,11 +15575,23 @@ fn gp_zonal_stats_end_to_end_and_undo() { assert!(s.run("gp.zonalStats", &json!({})).is_err()); assert!(s.run("gp.zonalStats", &json!({"raster": "nonexistent", "polygons": "districts"})).is_err()); - assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "Cities"})).is_err()); - assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [999]})).is_err()); - assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [0]})).is_err()); - assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [-1]})).is_err()); - assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [1.5]})).is_err()); + let all_layer = s.map().layer_by_name("all_bands_out").unwrap(); + for (field, expected) in [("multi_b1_mean", 2.0), ("multi_b2_mean", 7.0)] { + let idx = all_layer.dataset.field_index(field).expect("all bands must be expanded"); + assert_eq!(all_layer.dataset.value(0, idx), &Value::Float(expected)); + } + let error = s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "Cities"})).unwrap_err(); + assert!(error.contains("needs polygon geometries"), "{error}"); + for (band, fragment) in [ + (json!(999), "raster has bands"), + (json!(0), "positive"), + (json!(-1), "positive"), + (json!(1.5), "integers"), + (json!(4294967297u64), "raster has bands"), + ] { + let error = s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [band]})).unwrap_err(); + assert!(error.contains(fragment), "{error}"); + } } #[test] @@ -15719,7 +15745,9 @@ fn raster_clip_polygon_selection_semantics_and_partial_failure() { "name": "dem_partial", }), ); - assert!(res_partial.is_ok(), "clip partial should succeed for valid feature: {res_partial:?}"); + let partial_result = res_partial.expect("clip partial should succeed for valid feature"); + let warning = partial_result["messages"][0].as_str().expect("partial failures must be visible in command output"); + assert!(warning.contains("skipped 1 feature(s)"), "{warning}"); let partial_layer = s.map().layer_by_name("dem_partial").expect("dem_partial exists"); let partial_raster = partial_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); assert_eq!(partial_raster.width, 2); @@ -15984,3 +16012,37 @@ fn manual_breaks_are_the_ones_given() { assert_eq!(breaks(&s), before); } } + +/// Raster-side projection failures retain good multipart coverage and reach both command results. +#[test] +fn raster_projection_failures_are_reported_for_zonal_statistics_and_clipping() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + use rustgis_data::{Dataset, Geometry, GeometryType}; + use rustgis_geom::Coord; + + let mut s = world(); + let gt = GeoTransform { origin_x: 500_000.0, origin_y: 4_500_000.0, pixel_width: 1_000.0, pixel_height: -1_000.0 }; + let raster = Raster::new("utm", 10, 10, vec![vec![7.0; 100]], gt, Crs::Epsg(32617), None, ColorHint::Gray, "Float32").unwrap(); + let ring: Vec<_> = [(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0)] + .into_iter() + .map(|(x, y)| raster.transformer().to_lonlat(gt.to_world(x, y)).unwrap()) + .collect(); + let invalid = vec![Coord::new(-81.0, 100.0), Coord::new(-80.0, 100.0), Coord::new(-80.0, 101.0)]; + assert!(raster.transformer().from_lonlat(invalid[0]).is_none()); + s.map_mut().add_raster("utm", rustgis_map::RasterLayer::from_path(raster, "utm.tif")); + let mut zones = Dataset::new("parts", GeometryType::Polygon, vec![]); + zones.push(Some(Geometry::Polygon(vec![vec![ring], vec![invalid]])), vec![]); + s.map_mut().add_layer(rustgis_map::Layer::new(0, zones)); + + s.run("gp.zonalStats", &json!({"raster": "utm", "polygons": "parts", "output": "stats"})).unwrap(); + let messages = &s.project.gp_history.last().unwrap().messages; + assert!(messages.iter().any(|message| message.contains("1 polygon part(s) could not be projected")), "{messages:?}"); + let out = &s.map().layer_by_name("stats").unwrap().dataset; + assert_eq!(out.value(0, out.field_index("utm_count").unwrap()), &rustgis_data::Value::Int(64)); + assert_eq!(out.value(0, out.field_index("utm_mean").unwrap()), &rustgis_data::Value::Float(7.0)); + + let clip = s.run("raster.process", &json!({"layer": "utm", "operation": "clip", "polygon": "parts", "name": "clipped"})).unwrap(); + assert!(clip["messages"].to_string().contains("skipped 1 polygon part(s)"), "{clip}"); + let data = s.map().layer_by_name("clipped").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap(); + assert_eq!((data.width, data.height), (8, 8)); +} diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 22d02bcb0..1155b103c 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -322,6 +322,9 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { combo(ui, "wf_zs_raster", "Raster layer", &mut app.workflow.layer, &rasters); ui.label(egui::RichText::new(tl!("Polygon layer")).small().weak()); combo(ui, "wf_zs_poly", "Polygon layer", &mut app.workflow.layer2, &polygons); + if app.session.map().layer_by_name(&app.workflow.layer2).is_some_and(|l| !l.selection.is_empty()) { + ui.label(egui::RichText::new(tl!("Only selected polygons will be included in the output.")).small().weak()); + } ui.horizontal(|ui| { let band_label = ui.label(tl!("Band")); ui.add(egui::TextEdit::singleline(&mut app.workflow.zonal_stats.bands).hint_text("1").desired_width(40.0)) @@ -340,24 +343,26 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { .labelled_by(output_label.id); }); let band_str = app.workflow.zonal_stats.bands.trim(); - let total_bands = app - .session - .map() - .layer_by_name(&app.workflow.layer) - .and_then(|l| l.raster.as_ref()) - .and_then(|r| r.data.as_ref()) - .map(|d| d.bands.len()) - .unwrap_or(1); - let band_res = - if band_str.is_empty() { Ok(vec![0]) } else { rustgis_engine::analysis::rasterops::parse_bands_str(band_str, total_bands) }; + let raster_data = + app.session.map().layer_by_name(&app.workflow.layer).and_then(|l| l.raster.as_ref()).and_then(|r| r.data.as_ref()); + let total_bands = raster_data.map_or(0, |d| d.band_count()); + let raster_ready = raster_data.is_some(); + let band_res = rustgis_engine::analysis::zonal_bands(&json!(band_str), total_bands); let band_valid = band_res.is_ok(); let band_err = band_res.err(); let layers_ready = !app.workflow.layer.is_empty() && !app.workflow.layer2.is_empty(); - let can_run = layers_ready && band_valid; + let can_run = layers_ready && raster_ready && band_valid; if !layers_ready { ui.label(egui::RichText::new(tl!("Add a raster and a polygon layer to calculate zonal statistics.")).small().weak()); - } else if let Some(err) = band_err { - ui.label(egui::RichText::new(err).small().color(t.amber)); + } else if !raster_ready { + ui.label(egui::RichText::new(tl!("Raster data is unavailable.")).small().color(t.amber)); + } else if band_err.is_some() { + ui.label(egui::RichText::new(tl!("Band must be a positive integer, list of bands, or 'all'.")).small().color(t.amber)); + ui.label( + egui::RichText::new(crate::i18n::fmt("Raster has bands 1 to {count}.", &[("count", &total_bands.to_string())])) + .small() + .color(t.amber), + ); } if ui.add_enabled(can_run, egui::Button::new(tl!("Calculate Statistics"))).clicked() { let mut params = json!({ @@ -729,7 +734,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { "operation": "clip", "polygon": app.workflow.layer2, }); - p["selectedOnly"] = json!(app.workflow.selected_only); + p["selectedOnly"] = json!(effective_selected_only); run(app, "raster.process", p); } } else { diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index e0333f030..c4ab4fa43 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -3215,3 +3215,23 @@ fn line_strokes_editor_edits_one_class() { assert_eq!(class_strokes(&h, &ClassRef::Value("1".into())), 1); assert!(h.state().session.map().layer_by_name("Rivers").unwrap().line_strokes.is_empty(), "the layer's own are untouched"); } + +/// Out-of-range band input shows the raster's range and keeps calculation disabled. +#[test] +fn zonal_statistics_shows_available_band_range() { + use egui_kittest::kittest::NodeT; + use rustgis_engine::data::raster::{ColorHint, Crs, GeoTransform, Raster}; + let mut h = harness(); + let gt = GeoTransform { origin_x: 0.0, origin_y: 1.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = Raster::new("dem", 1, 1, vec![vec![1.0]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + h.state_mut().session.map_mut().add_raster("dem", rustgis_engine::map::RasterLayer::from_path(raster, "dem.tif")); + h.state_mut().run("ui.pane.workflows", json!(null)).unwrap(); + h.state_mut().workflow.section = "zonalStats"; + h.state_mut().workflow.layer = "dem".into(); + h.state_mut().workflow.layer2 = "Countries".into(); + h.state_mut().workflow.zonal_stats.bands = "2".into(); + h.run_steps(4); + assert!(h.query_by_label("Raster has bands 1 to 1.").is_some()); + assert!(h.query_by_label("Band must be a positive integer, list of bands, or 'all'.").is_some()); + assert!(h.get_by_label("Calculate Statistics").accesskit_node().is_disabled()); +} diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 71a87dfe3..5f1683694 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -539,10 +539,13 @@ rustgis-cli run --cmd 'cube.reduce={"url":"https://data.inpe.br/bdc/stac/v1","co ## Zonal statistics and polygon clipping +Open **Zonal Statistics** through Command Search, or choose it in the Workflows pane. + Zonal Statistics uses the selected polygon features when the layer has a selection. It keeps original geometries and fields, and reports features with missing or rejected polygon parts. -If any polygon part fails validation or transformation, the feature receives count 0 and null -statistics, so results never summarize only the surviving parts. Large +If any polygon part fails validation or transformation to WGS 84, the feature receives count 0 and null +statistics. If a part subsequently fails projection onto the raster grid, it is omitted and reported; +statistics cover the remaining valid parts. Large overlapping zones each require a separate pass over their raster window. For `raster.process` polygon clipping, `selectedOnly: true` requires a selection, @@ -550,6 +553,18 @@ For `raster.process` polygon clipping, `selectedOnly: true` requires a selection The Process pane initially checks Selected only when the chosen layer has a selection, and sends the choice explicitly. Clipping and Zonal Statistics share polygon transformation rules so a failed hole cannot silently enlarge the sampled area. +A malformed ring or a failed projection rejects its complete polygon part in all raster masking +operations. Clipping skips invalid features and reports their count in the command result; +its output bounds cover the valid polygons only. + +An empty band field or `bands: ""` selects band 1. The pane disables calculation when raster +data is unavailable, shows the available band range for invalid band input, and indicates when +only selected polygons will appear in the output. Both tools report parts omitted during raster-grid +projection while retaining the valid parts. + +Field parameters in Dissolve, XY Table to Point, IDW, Kriging and Summary Statistics accept +case-insensitive names. An exact spelling takes precedence; otherwise a name matching several +fields is rejected as ambiguous. This prevents silently using the wrong field. ## Deep learning and hosted imagery