diff --git a/GEOLIBRE_PARITY.md b/GEOLIBRE_PARITY.md index 725d1d402..61f88ec26 100644 --- a/GEOLIBRE_PARITY.md +++ b/GEOLIBRE_PARITY.md @@ -166,8 +166,8 @@ _GeoLibre: docs/features.md › Attribute data and expressions; docs/arcgis-edit _GeoLibre: docs/features.md › Processing and analysis; docs/user-guide/processing.md, model-builder.md, segmentation.md, obia.md_ -- [ ] **Spatial join, intersect, union**. Partial: spatial join, clip, erase and intersect are - built in; union, identity and near are missing (_Roadmap: solid ground_). +- [ ] **Spatial join, intersect, union**. Partial: spatial join, clip, erase, intersect, identity + and union are built in; near is missing (_Roadmap: solid ground_). - [ ] **More built-in vector tools**: attribute join, merge, extract vertices, points along lines, fix/check geometry, random extract, buffer options. Partial (_Roadmap: solid ground_). - [ ] **Routing services**: isochrones, service areas, OD matrix, multi-stop routing, diff --git a/README.md b/README.md index a2e432fca..1e4b02da3 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Every screenshot is RustGIS itself, rendered offscreen by `cargo run -p rustgis- | **Web tiles** | XYZ/TMS/WMS raster tiles, Mapbox Vector Tiles and MapLibre styles (sprites, patterns, expressions), cached and fetched in the background | | **Cartography** | Single symbol, unique values, graduated colours, proportional symbols; label classes with SQL queries, straight and curved line placement, installed font families, scale ranges and annotation; stacked and dashed line strokes, symbol levels, reference scales and polygons without fill; masking, display filters, aggregation bins, 2.5D extrusion; graphics and dynamic text | | **Editing** | Create, move, rotate, scale, merge, split and explode features; snapping, templates, elevation, traverse, topology rules; domains, subtypes, attribute rules, relationships, archiving; undo for everything | -| **Analysis** | Selection by attributes (SQL) and location; Buffer, Clip, Erase, Intersect, Dissolve, Summarize Within, Summary Statistics, Spatial Join, Zonal Statistics, A5 grids and a geometry calculator; viewshed, line of sight, IDW, ordinary kriging, shortest path, suitability modelling; ModelBuilder on a node canvas and scripts with command, parameter and value completion; 1,000+ [GeoLibre](https://github.com/opengeos/geolibre-rust) and Whitebox raster, vector, LiDAR, hydrology and terrain tools | +| **Analysis** | Selection by attributes (SQL) and location; Buffer, Clip, Erase, Intersect, Identity, Union, Dissolve, Summarize Within, Summary Statistics, Spatial Join, Zonal Statistics, A5 grids and a geometry calculator; viewshed, line of sight, IDW, ordinary kriging, shortest path, suitability modelling; ModelBuilder on a node canvas and scripts with command, parameter and value completion; 1,000+ [GeoLibre](https://github.com/opengeos/geolibre-rust) and Whitebox raster, vector, LiDAR, hydrology and terrain tools | | **Imagery** | Ortho and reality mapping, pixel classification (k-means, minimum distance, a trainable neural network), mensuration, hosted imagery tiles | | **Layouts and reports** | Page layouts with map frames, legends, scale bars, grids and map series; publication sheets and figures in one command, format panes, guides, snapping and element locks; reports with groups, statistics, charts and subreports; presentations (HTML slideshow, PDF); notebooks (Jupyter in and out) | | **Sharing** | PNG export at any DPI; PDF; web map pages, OGC API Features collections, MapLibre styles, glTF scenes, locators; project, map and mobile map packages; offline areas with sync | diff --git a/ROADMAP.md b/ROADMAP.md index 5ed028da0..24d95fd13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,8 +63,7 @@ Correct, robust and recoverable, with the foundations other themes stand on. administrator can fix for a managed install (no AI Assistant, plugins or web services). - Extend the OS keychain secret store to service connections (database passwords, S3 keys, AI provider keys and the ChatGPT sign-in's tokens are already kept there). -- Overlay tools beside Clip, Erase and Intersect: union, identity and near - ([#959](https://github.com/opengeos/RustGIS/issues/959)). +- A Near tool beside the overlay tools ([#959](https://github.com/opengeos/RustGIS/issues/959)). - Spatial Join by distance, closest, centre in and largest overlap, with merge rules (sum, mean, first…) for one-to-one joins ([#851](https://github.com/opengeos/RustGIS/issues/851)). - Vector tools, each writing a new layer: join field (a one-off attribute join), merge (append @@ -220,11 +219,11 @@ Open questions rather than plans; discuss before building. templates, elevation, topology; domains, subtypes, attribute rules, contingent values, relationships, archiving. - Analysis: selection by attributes and location; polygon, lasso and radius on the map; overlay - (clip, erase, intersect), proximity (buffer) and summary tools, spatial join, zonal statistics, - A5 grids, kriging and Calculate Geometry with metric or imperial units; guided workflows - (viewshed, line of sight, IDW, shortest path, suitability); ModelBuilder on a node canvas, - scripts with command, parameter and value completion, and history copied as code; the GeoLibre - and Whitebox suite (1,000+ tools) natively and in the browser. + (clip, erase, intersect, identity, union), proximity (buffer) and summary tools, spatial join, + zonal statistics, A5 grids, kriging and Calculate Geometry with metric or imperial units; guided + workflows (viewshed, line of sight, IDW, shortest path, suitability); ModelBuilder on a node + canvas, scripts with command, parameter and value completion, and history copied as code; the + GeoLibre and Whitebox suite (1,000+ tools) natively and in the browser. - Layouts (map frames, surrounds, map series, publication sheets and figures in one command, format panes, guides, snapping, element locking and PDF), reports, presentations and notebooks; the web app embeddable in a page (`?embed`). diff --git a/crates/analysis/src/tools.rs b/crates/analysis/src/tools.rs index 037922ee8..df8def229 100644 --- a/crates/analysis/src/tools.rs +++ b/crates/analysis/src/tools.rs @@ -70,6 +70,30 @@ pub static TOOLS: &[ToolSpec] = &[ ], run: intersect, }, + ToolSpec { + id: "identity", + name: "Identity", + toolbox: "Analysis › Overlay", + summary: "Splits input features by overlay polygons, keeping every part: those inside get the overlay's attributes.", + params: &[ + P { name: "input", label: "Input Features", kind: K::Layer, required: true, default: "", help: "" }, + P { name: "overlay", label: "Identity Features", kind: K::PolygonLayer, required: true, default: "", help: "" }, + P { name: "output", label: "Output Name", kind: K::Text, required: false, default: "", help: "" }, + ], + run: identity, + }, + ToolSpec { + id: "union", + name: "Union", + toolbox: "Analysis › Overlay", + summary: "Combines two polygon layers into every piece either covers, with the attributes of both where they overlap.", + params: &[ + P { name: "input", label: "Input Features", kind: K::PolygonLayer, required: true, default: "", help: "" }, + P { name: "overlay", label: "Union Features", kind: K::PolygonLayer, required: true, default: "", help: "" }, + P { name: "output", label: "Output Name", kind: K::Text, required: false, default: "", help: "" }, + ], + run: union, + }, ToolSpec { id: "dissolve", name: "Dissolve", @@ -691,31 +715,48 @@ fn union_of(inp: &dyn Inputs, p: &Json, k: &str) -> Result, St Ok(union_all(polys.iter().flat_map(|m| m.0.iter()))) } -/// The part of a feature inside `mask` (polygons in Web Mercator, oriented as `union_all` leaves -/// them): a polygon's overlap with it, a line's stretches in it, the points in it or on its edge. +/// A feature as the overlay can take it: a feature of several polygons, which may overlap, unioned +/// into one; any other as it is, so a single polygon's edges stay exactly where they are. +fn dissolved(g: &Geometry) -> std::borrow::Cow<'_, Geometry> { + match g { + Geometry::Polygon(polys) if polys.len() > 1 => to_multipolygon(g) + .and_then(|m| from_multipolygon(&union_all(m.0.iter()))) + .map_or(std::borrow::Cow::Borrowed(g), std::borrow::Cow::Owned), + _ => std::borrow::Cow::Borrowed(g), + } +} + +/// The part of a feature inside `mask`, or outside it when `inside` is false. Inside, a polygon's +/// overlap with it, a line's stretches in it, the points in it or on its edge; outside, the rest. /// `None` when nothing is left, or only a shared edge or corner. -fn part_in(g: &Geometry, mask: &MultiPolygon) -> Option { +/// +/// The mask's polygons are in Web Mercator, oriented as `union_all` leaves them. The feature's may +/// wind either way (the reference corpus has a clockwise parcel), but must not overlap each other: +/// pass a feature of several polygons through [`dissolved`] first, once for all its masks. +fn part(g: &Geometry, mask: &MultiPolygon, inside: bool) -> Option { match g { - Geometry::Polygon(_) => to_multipolygon(g).and_then(|m| from_multipolygon(&m.intersection(mask))), - Geometry::Line(_) => to_multiline(g).and_then(|m| from_line(&mask.clip(&m, false))), + Geometry::Polygon(_) => { + to_multipolygon(g).and_then(|m| from_multipolygon(&if inside { m.intersection(mask) } else { m.difference(mask) })) + } + Geometry::Line(_) => to_multiline(g).and_then(|m| from_line(&mask.clip(&m, !inside))), Geometry::Point(_) | Geometry::MultiPoint(_) => { let pts: &[Coord] = match g { Geometry::Point(c) => std::slice::from_ref(c), Geometry::MultiPoint(v) => v, _ => &[], }; - let inside: Vec = pts + let kept: Vec = pts .iter() .copied() .filter(|c| { let m = rustgis_geom::lonlat_to_merc(*c); - mask.intersects(&geo::Point::new(m.x, m.y)) + mask.intersects(&geo::Point::new(m.x, m.y)) == inside }) .collect(); - match inside.len() { + match kept.len() { 0 => None, - 1 => inside.first().map(|c| Geometry::Point(*c)), - _ => Some(Geometry::MultiPoint(inside)), + 1 => kept.first().map(|c| Geometry::Point(*c)), + _ => Some(Geometry::MultiPoint(kept)), } } } @@ -727,7 +768,7 @@ fn clip(inp: &dyn Inputs, p: &Json) -> Result { let mut out = copy_schema(ds, out_name(p, format!("{}_Clip", ds.name)), ds.geometry_type); for r in rows(inp, p, "input", ds) { let Some(f) = ds.features.get(r) else { continue }; - if let Some(c) = f.geometry.as_ref().and_then(|g| part_in(g, &mask)) { + if let Some(c) = f.geometry.as_ref().and_then(|g| part(&dissolved(g), &mask, true)) { out.push(Some(c), f.attrs.clone()); } } @@ -750,7 +791,7 @@ fn erase(inp: &dyn Inputs, p: &Json) -> Result { } /// An overlay polygon: its row in the overlay layer, its extent and its polygons in Web Mercator, -/// oriented for the overlay. +/// oriented for the overlay, its parts unioned when it has several (as Clip's mask is). struct Zone { row: usize, ext: Extent, @@ -768,7 +809,11 @@ fn overlay_zones<'a>(inp: &'a dyn Inputs, p: &Json) -> Result<(&'a Dataset, Vec< .into_iter() .filter_map(|row| { let g = ov.features.get(row)?.geometry.as_ref()?; - Some(Zone { row, ext: g.extent(), poly: to_multipolygon(g)?.orient(Direction::Default) }) + // Parts of one feature may overlap, which the overlay can't take as they are; one + // polygon is only oriented, so its edges stay exactly where they are. + let poly = to_multipolygon(g)?; + let poly = if poly.0.len() > 1 { union_all(poly.0.iter()) } else { poly.orient(Direction::Default) }; + Some(Zone { row, ext: g.extent(), poly }) }) .collect(); Ok((ov, zones)) @@ -796,32 +841,123 @@ fn overlay_row(ds: &Dataset, a: Option<&[Value]>, ov: &Dataset, b: Option<&[Valu row } -/// Intersect: a feature for each part of an input feature inside an overlay polygon, with the -/// attributes of both, in input order and then overlay order. Parts that only touch are left out. -fn intersect(inp: &dyn Inputs, p: &Json) -> Result { +/// What an overlay tool keeps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Overlay { + /// The parts of input features inside overlay polygons. + Intersect, + /// Those, and the parts of input features outside every overlay polygon. + Identity, + /// Those, and the parts of overlay polygons outside every input polygon. + Union, +} + +impl Overlay { + /// The tool's name, for its messages and its output's default name. + fn name(self) -> &'static str { + match self { + Overlay::Intersect => "Intersect", + Overlay::Identity => "Identity", + Overlay::Union => "Union", + } + } +} + +/// Intersect, Identity and Union. Each input feature in turn gives its part in each overlay +/// polygon, in overlay order, with the attributes of both; then (Identity, Union) its part outside +/// them all, with empty overlay attributes. Union then adds each overlay polygon's part outside +/// every input polygon, with empty input attributes. Parts that only touch are left out, and +/// features of one layer that overlap each other are not split against each other. Intersect +/// leaves out input features without geometry; Identity and Union keep them, as they are. +fn overlay(inp: &dyn Inputs, p: &Json, op: Overlay) -> Result { let ds = input(inp, p, "input")?; if ds.geometry_type == GeometryType::None { return Err(format!("'{}' has no geometry", ds.name)); } + if op == Overlay::Union && ds.geometry_type != GeometryType::Polygon { + return Err(format!("'{}' is not a polygon layer", ds.name)); + } let (ov, zones) = overlay_zones(inp, p)?; let index = ExtentIndex::new(zones.iter().map(|z| z.ext)); - let mut out = overlay_schema(ds, ov, out_name(p, format!("{}_Intersect", ds.name)), ds.geometry_type); + let mut out = overlay_schema(ds, ov, out_name(p, format!("{}_{}", ds.name, op.name())), ds.geometry_type); + let zone_attrs = |z: &Zone| ov.features.get(z.row).map(|o| o.attrs.as_slice()); + // For Union, the rows of the input polygons that overlap each zone. + let mut covering: Vec> = vec![Vec::new(); zones.len()]; for r in rows(inp, p, "input", ds) { let Some(f) = ds.features.get(r) else { continue }; - let Some(g) = &f.geometry else { continue }; + let Some(g) = &f.geometry else { + // Identity and Union keep every input feature: one without geometry, as it is. + if op != Overlay::Intersect { + out.push_next(None, overlay_row(ds, Some(&f.attrs), ov, None)); + } + continue; + }; let ext = g.extent(); - for z in index.candidates(&ext).into_iter().filter_map(|i| zones.get(i)) { - if !ext.intersects(&z.ext) { - continue; + // A feature of overlapping parts is unioned once, for all the zones it meets. + let whole = dissolved(g); + let mut hit: Vec = Vec::new(); + for zi in index.candidates(&ext) { + let Some(z) = zones.get(zi).filter(|z| ext.intersects(&z.ext)) else { continue }; + if let Some(inside) = part(&whole, &z.poly, true) { + out.push_next(Some(inside), overlay_row(ds, Some(&f.attrs), ov, zone_attrs(z))); + hit.push(zi); } - if let Some(part) = part_in(g, &z.poly) { - let attrs = overlay_row(ds, Some(&f.attrs), ov, ov.features.get(z.row).map(|o| o.attrs.as_slice())); - out.push_next(Some(part), attrs); + } + if op == Overlay::Intersect { + continue; + } + let rest = if hit.is_empty() { + Some(g.clone()) + } else { + // The same mask the inside part was cut with, when one zone was hit: a union would move its + // edges by a rounding error, and a line along one could come out on both sides. + match hit.as_slice() { + [only] => zones.get(*only).and_then(|z| part(&whole, &z.poly, false)), + _ => part(&whole, &union_all(hit.iter().filter_map(|zi| zones.get(*zi)).flat_map(|z| z.poly.0.iter())), false), + } + }; + if let Some(rest) = rest { + out.push_next(Some(rest), overlay_row(ds, Some(&f.attrs), ov, None)); + } + if op == Overlay::Union { + for zi in hit { + if let Some(c) = covering.get_mut(zi) { + c.push(r); + } + } + } + } + if op == Overlay::Union { + for (z, inputs) in zones.iter().zip(&covering) { + let rest = if inputs.is_empty() { + from_multipolygon(&z.poly) + } else { + let polys: Vec> = + inputs.iter().filter_map(|r| ds.features.get(*r)?.geometry.as_ref()).filter_map(to_multipolygon).collect(); + from_multipolygon(&z.poly.difference(&union_all(polys.iter().flat_map(|m| m.0.iter())))) + }; + if let Some(rest) = rest { + out.push_next(Some(rest), overlay_row(ds, None, ov, zone_attrs(z))); } } } let n = out.features.len(); - result(out, format!("Intersect: {n} features")) + result(out, format!("{}: {n} features", op.name())) +} + +/// Intersect: see [`overlay`]. +fn intersect(inp: &dyn Inputs, p: &Json) -> Result { + overlay(inp, p, Overlay::Intersect) +} + +/// Identity: see [`overlay`]. +fn identity(inp: &dyn Inputs, p: &Json) -> Result { + overlay(inp, p, Overlay::Identity) +} + +/// Union: see [`overlay`]. +fn union(inp: &dyn Inputs, p: &Json) -> Result { + overlay(inp, p, Overlay::Union) } fn dissolve(inp: &dyn Inputs, p: &Json) -> Result { @@ -3183,6 +3319,27 @@ mod tests { } } + #[test] + fn intersect_unions_an_overlay_feature_whose_parts_overlap() { + // One zone of two overlapping squares (x 0–2 and 1–3), as a file can hold it. + let ring = + |x: f64| vec![Coord::new(x, 0.0), Coord::new(x + 2.0, 0.0), Coord::new(x + 2.0, 2.0), Coord::new(x, 2.0), Coord::new(x, 0.0)]; + let mut zones = Dataset::new("zones", GeometryType::Polygon, vec![]); + zones.push(Some(Geometry::Polygon(vec![vec![ring(0.0)], vec![ring(1.0)]])), vec![]); + let mut parcels = Dataset::new("parcels", GeometryType::Polygon, vec![]); + parcels.push(Some(sq(-1.0, 0.0, 5.0)), vec![]); + let m = Map(HashMap::from([("zones".to_string(), zones), ("parcels".to_string(), parcels)])); + let out = layer(crate::run("intersect", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(out.features.len(), 1); + // The part is the zone's whole outline, x 0–3, once: the overlap is neither lost nor doubled. + use geo::Area; + let part = to_multipolygon(out.features[0].geometry.as_ref().unwrap()).unwrap(); + let outline = vec![Coord::new(0.0, 0.0), Coord::new(3.0, 0.0), Coord::new(3.0, 2.0), Coord::new(0.0, 2.0), Coord::new(0.0, 0.0)]; + let want = to_multipolygon(&Geometry::Polygon(vec![vec![outline]])).unwrap(); + assert_eq!(part.0.len(), 1, "{part:?}"); + assert!((part.unsigned_area() - want.unsigned_area()).abs() < 1e-6 * want.unsigned_area(), "{part:?}"); + } + #[test] fn intersect_splits_points_by_each_polygon_they_fall_in() { let m = inputs(); @@ -3220,6 +3377,109 @@ mod tests { assert!(e.contains("no geometry"), "{e}"); } + /// Each feature's `id` and the overlay's (`id_2`), "" for an empty one: "p1|z1", "p1|", "|z2". + fn overlay_keys(out: &Dataset) -> Vec { + let text = |v: Option<&Value>| if let Some(Value::Text(t)) = v { t.clone() } else { String::new() }; + out.features.iter().map(|f| format!("{}|{}", text(f.attrs.first()), text(f.attrs.get(2)))).collect() + } + + #[test] + fn identity_keeps_every_part_of_the_input() { + let m = parcels_and_zones(); + let out = layer(crate::run("identity", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(out.name, "parcels_Identity"); + // p1's half in z1, then its other half; p2 only touches z2, so it is kept whole. + assert_eq!(overlay_keys(&out), ["p1|z1", "p1|", "p2|"]); + let rest = out.features[1].geometry.as_ref().unwrap().extent(); + assert!((rest.min_x - 0.0).abs() < 1e-9 && (rest.max_x - 1.0).abs() < 1e-9, "{rest:?}"); + assert_eq!(out.features[1].attrs[2..], [Value::Null, Value::Null]); + assert_eq!(out.features[2].geometry, m.0["parcels"].features[1].geometry); + } + + #[test] + fn identity_and_union_keep_input_features_without_geometry() { + let mut m = parcels_and_zones(); + m.0.get_mut("parcels").unwrap().push(None, vec![Value::Text("p3".into()), Value::Text("lot".into())]); + for (tool, keys) in [("identity", vec!["p1|z1", "p1|", "p2|", "p3|"]), ("union", vec!["p1|z1", "p1|", "p2|", "p3|", "|z1", "|z2"])] + { + let out = layer(crate::run(tool, &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(overlay_keys(&out), keys, "{tool}"); + let p3 = out.features.iter().find(|f| f.attrs.first() == Some(&Value::Text("p3".into()))).unwrap(); + assert_eq!((p3.geometry.is_none(), &p3.attrs[1]), (true, &Value::Text("lot".into())), "{tool}"); + } + let out = layer(crate::run("intersect", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(overlay_keys(&out), ["p1|z1"]); + } + + #[test] + fn identity_unions_an_input_feature_whose_parts_overlap() { + // One parcel of two overlapping squares (x 0–2 and 1–3), and a zone over its right edge. + let ring = + |x: f64| vec![Coord::new(x, 0.0), Coord::new(x + 2.0, 0.0), Coord::new(x + 2.0, 2.0), Coord::new(x, 2.0), Coord::new(x, 0.0)]; + let mut parcels = Dataset::new("parcels", GeometryType::Polygon, vec![]); + parcels.push(Some(Geometry::Polygon(vec![vec![ring(0.0)], vec![ring(1.0)]])), vec![]); + let mut zones = Dataset::new("zones", GeometryType::Polygon, vec![]); + zones.push(Some(sq(2.5, 0.0, 2.0)), vec![]); + let m = Map(HashMap::from([("parcels".to_string(), parcels), ("zones".to_string(), zones)])); + let out = layer(crate::run("identity", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(out.features.len(), 2); + // The part outside is x 0–2.5 whole, the overlap (x 1–2) included. + use geo::Area; + let rest = to_multipolygon(out.features[1].geometry.as_ref().unwrap()).unwrap(); + let outline = vec![Coord::new(0.0, 0.0), Coord::new(2.5, 0.0), Coord::new(2.5, 2.0), Coord::new(0.0, 2.0), Coord::new(0.0, 0.0)]; + let want = to_multipolygon(&Geometry::Polygon(vec![vec![outline]])).unwrap(); + assert_eq!(rest.0.len(), 1, "{rest:?}"); + assert!((rest.unsigned_area() - want.unsigned_area()).abs() < 1e-6 * want.unsigned_area(), "{rest:?}"); + } + + #[test] + fn identity_keeps_a_line_along_a_zone_edge_once() { + // A road along the top edge of a zone, and on past its corner. + let mut roads = Dataset::new("roads", GeometryType::Polyline, vec![Field::new("id", FieldType::Text)]); + roads.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 2.0), Coord::new(3.0, 2.0)]])), vec![Value::Text("r".into())]); + let mut zones = Dataset::new("zones", GeometryType::Polygon, vec![Field::new("zone", FieldType::Text)]); + zones.push(Some(sq(0.0, 0.0, 2.0)), vec![Value::Text("z".into())]); + let m = Map(HashMap::from([("roads".to_string(), roads), ("zones".to_string(), zones)])); + let out = layer(crate::run("identity", &m, &serde_json::json!({"input": "roads", "overlay": "zones"})).unwrap()); + // The stretch on the edge is inside (x 0–2), once; the rest (x 2–3) outside. + let spans: Vec<(f64, f64, &Value)> = out + .features + .iter() + .map(|f| { + let e = f.geometry.as_ref().unwrap().extent(); + (e.min_x, e.max_x, &f.attrs[1]) + }) + .collect(); + assert_eq!(spans.len(), 2, "{spans:?}"); + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert!(near(spans[0].0, 0.0) && near(spans[0].1, 2.0) && *spans[0].2 == Value::Text("z".into()), "{spans:?}"); + assert!(near(spans[1].0, 2.0) && near(spans[1].1, 3.0) && *spans[1].2 == Value::Null, "{spans:?}"); + } + + #[test] + fn identity_keeps_points_outside_every_polygon() { + let m = inputs(); + let out = layer(crate::run("identity", &m, &serde_json::json!({"input": "pts", "overlay": "polys"})).unwrap()); + let rows: Vec<(f64, Value)> = out.features.iter().map(|f| (f.attrs[0].as_f64().unwrap(), f.attrs[1].clone())).collect(); + let a = Value::Text("a".into()); + assert_eq!(rows, [(1.0, a.clone()), (2.0, a.clone()), (2.0, a), (3.0, Value::Null)]); + } + + #[test] + fn union_adds_the_overlay_left_uncovered() { + let m = parcels_and_zones(); + let out = layer(crate::run("union", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(out.name, "parcels_Union"); + // z1 less p1's half of it; z2, which no parcel overlaps, whole (overlapping z1 still). + assert_eq!(overlay_keys(&out), ["p1|z1", "p1|", "p2|", "|z1", "|z2"]); + let z1_rest = out.features[3].geometry.as_ref().unwrap().extent(); + assert!((z1_rest.min_x - 2.0).abs() < 1e-9 && (z1_rest.max_x - 3.0).abs() < 1e-9, "{z1_rest:?}"); + assert_eq!(out.features[3].attrs[..2], [Value::Null, Value::Null]); + assert_eq!(out.features[4].attrs[3], Value::Text("C2".into())); + let e = crate::run("union", &inputs(), &serde_json::json!({"input": "pts", "overlay": "polys"})).unwrap_err(); + assert!(e.contains("not a polygon layer"), "{e}"); + } + /// Layers with a selection in each, as the Tools pane passes them. struct Selected(Map, HashMap>); impl Inputs for Selected { @@ -3248,6 +3508,24 @@ mod tests { assert_eq!(ids, [&Value::Text("p2".into()), &Value::Text("z3".into())]); } + #[test] + fn identity_and_union_honour_both_layers_selections() { + // Only z2 is selected: it touches p1 without overlapping it, so z1 doesn't cut p1. + let m = Selected(parcels_and_zones(), HashMap::from([("zones".to_string(), vec![1])])); + let out = layer(crate::run("identity", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(overlay_keys(&out), ["p1|", "p2|"]); + assert_eq!(out.features[0].geometry, m.0.0["parcels"].features[0].geometry); + // Only p2 is selected: it touches z2 without overlapping it, so p1 doesn't cover z1. + let m = Selected(parcels_and_zones(), HashMap::from([("parcels".to_string(), vec![1])])); + let out = layer(crate::run("union", &m, &serde_json::json!({"input": "parcels", "overlay": "zones"})).unwrap()); + assert_eq!(overlay_keys(&out), ["p2|", "|z1", "|z2"]); + // z1 whole (back from Web Mercator, so not to the last bit). + let (got, want) = (out.features[1].geometry.as_ref().unwrap().extent(), sq(1.0, 0.0, 2.0).extent()); + for (g, w) in [(got.min_x, want.min_x), (got.max_x, want.max_x), (got.min_y, want.min_y), (got.max_y, want.max_y)] { + assert!((g - w).abs() < 1e-9, "{got:?}"); + } + } + #[test] fn select_by_location_invert_excludes_features_without_geometry() { let mut polys = Dataset::new("polys", GeometryType::Polygon, vec![]); diff --git a/crates/analysis/tests/reference/cases.json b/crates/analysis/tests/reference/cases.json index 0adf2a891..7a196cb00 100644 --- a/crates/analysis/tests/reference/cases.json +++ b/crates/analysis/tests/reference/cases.json @@ -105,6 +105,78 @@ ] } }, + { + "name": "identity polygons", + "tool": "identity", + "params": { + "input": "parcels", + "overlay": "zones" + }, + "expect": { + "features": "expected/identity_parcels.geojson", + "key": [ + "id", + "id_2" + ] + } + }, + { + "name": "identity lines", + "tool": "identity", + "params": { + "input": "roads", + "overlay": "zones" + }, + "expect": { + "features": "expected/identity_roads.geojson", + "key": [ + "id", + "id_2" + ] + } + }, + { + "name": "identity points", + "tool": "identity", + "params": { + "input": "points", + "overlay": "zones" + }, + "expect": { + "ids": [ + "a|", + "b|Z1", + "c|", + "d|Z4", + "e|Z1", + "e|Z4", + "f|", + "g|", + "h|", + "i|", + "j|Z5" + ], + "key": [ + "id", + "id_2" + ] + } + }, + { + "name": "union", + "tool": "union", + "params": { + "input": "parcels", + "overlay": "zones" + }, + "expect": { + "features": "expected/union_parcels.geojson", + "key": [ + "id", + "id_2" + ] + } + }, { "name": "dissolve by field", "tool": "dissolve", diff --git a/crates/analysis/tests/reference/expected/identity_parcels.geojson b/crates/analysis/tests/reference/expected/identity_parcels.geojson new file mode 100644 index 000000000..8be37e0f6 --- /dev/null +++ b/crates/analysis/tests/reference/expected/identity_parcels.geojson @@ -0,0 +1,597 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "id": "P1|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 45.999999999999986 + ], + [ + 10.250000000000002, + 45.999999999999986 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P1|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.0, + 45.999999999999986 + ], + [ + 10.0, + 46.50000000000001 + ], + [ + 10.500000000000002, + 46.50000000000001 + ], + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.250000000000002, + 45.999999999999986 + ], + [ + 10.0, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P2|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 11.0, + 46.37499999999999 + ], + [ + 11.0, + 45.999999999999986 + ], + [ + 10.500000000000002, + 45.999999999999986 + ], + [ + 10.500000000000002, + 46.37499999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P2|Z6" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.0, + 46.50000000000001 + ], + [ + 11.0, + 46.12500000000001 + ], + [ + 10.875, + 46.12500000000001 + ], + [ + 10.875, + 46.50000000000001 + ], + [ + 11.0, + 46.50000000000001 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P2|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 46.50000000000001 + ], + [ + 10.875, + 46.50000000000001 + ], + [ + 10.875, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.50000000000001 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P3|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 45.999999999999986 + ], + [ + 11.250000000000002, + 46.37499999999999 + ], + [ + 11.500000000000002, + 46.37499999999999 + ], + [ + 11.500000000000002, + 46.25 + ], + [ + 11.500000000000002, + 45.999999999999986 + ], + [ + 11.250000000000002, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P3|Z6" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 46.625 + ], + [ + 11.375, + 46.625 + ], + [ + 11.375, + 46.12500000000001 + ], + [ + 11.250000000000002, + 46.12500000000001 + ], + [ + 11.250000000000002, + 46.625 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P3|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 46.74999999999999 + ], + [ + 12.000000000000002, + 46.74999999999999 + ], + [ + 12.000000000000002, + 45.999999999999986 + ], + [ + 11.500000000000002, + 45.999999999999986 + ], + [ + 11.500000000000002, + 46.25 + ], + [ + 11.749999999999998, + 46.25 + ], + [ + 11.749999999999998, + 46.50000000000001 + ], + [ + 11.500000000000002, + 46.50000000000001 + ], + [ + 11.500000000000002, + 46.37499999999999 + ], + [ + 11.375, + 46.37499999999999 + ], + [ + 11.375, + 46.625 + ], + [ + 11.250000000000002, + 46.625 + ], + [ + 11.250000000000002, + 46.74999999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P4|Z2" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.0, + 47.0 + ], + [ + 10.0, + 47.250000000000014 + ], + [ + 10.250000000000002, + 47.250000000000014 + ], + [ + 10.250000000000002, + 47.0 + ], + [ + 10.0, + 47.0 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P4|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.75, + 47.250000000000014 + ], + [ + 10.75, + 47.0 + ], + [ + 10.500000000000002, + 47.0 + ], + [ + 10.500000000000002, + 47.250000000000014 + ], + [ + 10.75, + 47.250000000000014 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P5|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 12.499999999999998, + 45.999999999999986 + ], + [ + 13.250000000000002, + 45.999999999999986 + ], + [ + 13.250000000000002, + 46.25 + ], + [ + 12.749999999999998, + 46.25 + ], + [ + 12.749999999999998, + 46.74999999999999 + ], + [ + 12.499999999999998, + 46.74999999999999 + ], + [ + 12.499999999999998, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P6|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.75, + 46.25 + ], + [ + 10.250000000000002, + 46.25 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.75, + 46.37499999999999 + ], + [ + 10.75, + 46.25 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P6|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.250000000000002, + 46.74999999999999 + ], + [ + 10.75, + 46.74999999999999 + ], + [ + 10.75, + 46.37499999999999 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.250000000000002, + 46.74999999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P7|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 12.250000000000002, + 46.74999999999999 + ], + [ + 12.250000000000002, + 47.0 + ], + [ + 12.000000000000002, + 47.0 + ], + [ + 12.000000000000002, + 46.74999999999999 + ], + [ + 12.250000000000002, + 46.74999999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P8|Z5" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 47.5 + ], + [ + 11.500000000000002, + 47.2505900350133 + ], + [ + 11.500000000000002, + 47.12499999999999 + ], + [ + 11.125, + 47.12499999999999 + ], + [ + 11.125, + 47.2505900350133 + ], + [ + 11.250000000000002, + 47.5 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P8|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.749999999999998, + 47.0 + ], + [ + 11.0, + 47.0 + ], + [ + 11.125, + 47.2505900350133 + ], + [ + 11.125, + 47.12499999999999 + ], + [ + 11.500000000000002, + 47.12499999999999 + ], + [ + 11.500000000000002, + 47.2505900350133 + ], + [ + 11.749999999999998, + 47.0 + ] + ] + ] + } + } + ] +} diff --git a/crates/analysis/tests/reference/expected/identity_roads.geojson b/crates/analysis/tests/reference/expected/identity_roads.geojson new file mode 100644 index 000000000..276df012e --- /dev/null +++ b/crates/analysis/tests/reference/expected/identity_roads.geojson @@ -0,0 +1,245 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "id": "r1|Z1" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 10.250000000000002, + 46.25 + ], + [ + 11.125, + 46.25 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r1|Z6" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 10.875, + 46.25 + ], + [ + 11.125, + 46.25 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r1|" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 9.875, + 46.25 + ], + [ + 10.250000000000002, + 46.25 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r2|Z1" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 10.500000000000002, + 46.0625 + ], + [ + 10.500000000000002, + 46.37499999999999 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r2|" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.4375 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r3|Z4" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 11.5625, + 46.31250000000001 + ], + [ + 11.6875, + 46.4375 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r4|" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 12.000000000000002, + 46.50000000000001 + ], + [ + 12.499999999999998, + 46.50000000000001 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r5|" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 14.999999999999998, + 49.00000000000001 + ], + [ + 15.5, + 49.5 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r6|Z5" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 11.206763423774959, + 47.12499999999999 + ], + [ + 11.500000000000002, + 47.34499451815567 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r6|" + }, + "geometry": { + "type": "MultiLineString", + "coordinates": [ + [ + [ + 10.875, + 46.87500000000001 + ], + [ + 11.206763423774959, + 47.12499999999999 + ] + ], + [ + [ + 11.500000000000002, + 47.34499451815567 + ], + [ + 11.875, + 47.625000000000014 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r7|Z1" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 11.125, + 46.37499999999999 + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "r7|Z6" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 10.875, + 46.37499999999999 + ], + [ + 11.125, + 46.37499999999999 + ] + ] + } + } + ] +} diff --git a/crates/analysis/tests/reference/expected/union_parcels.geojson b/crates/analysis/tests/reference/expected/union_parcels.geojson new file mode 100644 index 000000000..e9586ebf4 --- /dev/null +++ b/crates/analysis/tests/reference/expected/union_parcels.geojson @@ -0,0 +1,857 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "id": "P1|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 45.999999999999986 + ], + [ + 10.250000000000002, + 45.999999999999986 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P1|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.0, + 45.999999999999986 + ], + [ + 10.0, + 46.50000000000001 + ], + [ + 10.500000000000002, + 46.50000000000001 + ], + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.250000000000002, + 45.999999999999986 + ], + [ + 10.0, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P2|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 11.0, + 46.37499999999999 + ], + [ + 11.0, + 45.999999999999986 + ], + [ + 10.500000000000002, + 45.999999999999986 + ], + [ + 10.500000000000002, + 46.37499999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P2|Z6" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.0, + 46.50000000000001 + ], + [ + 11.0, + 46.12500000000001 + ], + [ + 10.875, + 46.12500000000001 + ], + [ + 10.875, + 46.50000000000001 + ], + [ + 11.0, + 46.50000000000001 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P2|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 46.50000000000001 + ], + [ + 10.875, + 46.50000000000001 + ], + [ + 10.875, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.37499999999999 + ], + [ + 10.500000000000002, + 46.50000000000001 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P3|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 45.999999999999986 + ], + [ + 11.250000000000002, + 46.37499999999999 + ], + [ + 11.500000000000002, + 46.37499999999999 + ], + [ + 11.500000000000002, + 46.25 + ], + [ + 11.500000000000002, + 45.999999999999986 + ], + [ + 11.250000000000002, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P3|Z6" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 46.625 + ], + [ + 11.375, + 46.625 + ], + [ + 11.375, + 46.12500000000001 + ], + [ + 11.250000000000002, + 46.12500000000001 + ], + [ + 11.250000000000002, + 46.625 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P3|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 46.74999999999999 + ], + [ + 12.000000000000002, + 46.74999999999999 + ], + [ + 12.000000000000002, + 45.999999999999986 + ], + [ + 11.500000000000002, + 45.999999999999986 + ], + [ + 11.500000000000002, + 46.25 + ], + [ + 11.749999999999998, + 46.25 + ], + [ + 11.749999999999998, + 46.50000000000001 + ], + [ + 11.500000000000002, + 46.50000000000001 + ], + [ + 11.500000000000002, + 46.37499999999999 + ], + [ + 11.375, + 46.37499999999999 + ], + [ + 11.375, + 46.625 + ], + [ + 11.250000000000002, + 46.625 + ], + [ + 11.250000000000002, + 46.74999999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P4|Z2" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.0, + 47.0 + ], + [ + 10.0, + 47.250000000000014 + ], + [ + 10.250000000000002, + 47.250000000000014 + ], + [ + 10.250000000000002, + 47.0 + ], + [ + 10.0, + 47.0 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P4|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.75, + 47.250000000000014 + ], + [ + 10.75, + 47.0 + ], + [ + 10.500000000000002, + 47.0 + ], + [ + 10.500000000000002, + 47.250000000000014 + ], + [ + 10.75, + 47.250000000000014 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P5|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 12.499999999999998, + 45.999999999999986 + ], + [ + 13.250000000000002, + 45.999999999999986 + ], + [ + 13.250000000000002, + 46.25 + ], + [ + 12.749999999999998, + 46.25 + ], + [ + 12.749999999999998, + 46.74999999999999 + ], + [ + 12.499999999999998, + 46.74999999999999 + ], + [ + 12.499999999999998, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P6|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.75, + 46.25 + ], + [ + 10.250000000000002, + 46.25 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.75, + 46.37499999999999 + ], + [ + 10.75, + 46.25 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P6|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.250000000000002, + 46.74999999999999 + ], + [ + 10.75, + 46.74999999999999 + ], + [ + 10.75, + 46.37499999999999 + ], + [ + 10.250000000000002, + 46.37499999999999 + ], + [ + 10.250000000000002, + 46.74999999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P7|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 12.250000000000002, + 46.74999999999999 + ], + [ + 12.250000000000002, + 47.0 + ], + [ + 12.000000000000002, + 47.0 + ], + [ + 12.000000000000002, + 46.74999999999999 + ], + [ + 12.250000000000002, + 46.74999999999999 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P8|Z5" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.250000000000002, + 47.5 + ], + [ + 11.500000000000002, + 47.2505900350133 + ], + [ + 11.500000000000002, + 47.12499999999999 + ], + [ + 11.125, + 47.12499999999999 + ], + [ + 11.125, + 47.2505900350133 + ], + [ + 11.250000000000002, + 47.5 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "P8|" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.749999999999998, + 47.0 + ], + [ + 11.0, + 47.0 + ], + [ + 11.125, + 47.2505900350133 + ], + [ + 11.125, + 47.12499999999999 + ], + [ + 11.500000000000002, + 47.12499999999999 + ], + [ + 11.500000000000002, + 47.2505900350133 + ], + [ + 11.749999999999998, + 47.0 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "|Z1" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.500000000000002, + 45.75000000000001 + ], + [ + 10.250000000000002, + 45.75000000000001 + ], + [ + 10.250000000000002, + 45.999999999999986 + ], + [ + 10.500000000000002, + 45.999999999999986 + ], + [ + 11.0, + 45.999999999999986 + ], + [ + 11.0, + 46.37499999999999 + ], + [ + 11.250000000000002, + 46.37499999999999 + ], + [ + 11.250000000000002, + 45.999999999999986 + ], + [ + 11.500000000000002, + 45.999999999999986 + ], + [ + 11.500000000000002, + 45.75000000000001 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "|Z2" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.500000000000002, + 46.87500000000001 + ], + [ + 9.75, + 46.87500000000001 + ], + [ + 9.75, + 47.5 + ], + [ + 10.500000000000002, + 47.5 + ], + [ + 10.500000000000002, + 47.250000000000014 + ], + [ + 10.500000000000002, + 47.0 + ], + [ + 10.500000000000002, + 46.87500000000001 + ] + ], + [ + [ + 10.250000000000002, + 47.0 + ], + [ + 10.250000000000002, + 47.250000000000014 + ], + [ + 10.0, + 47.250000000000014 + ], + [ + 10.0, + 47.0 + ], + [ + 10.250000000000002, + 47.0 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "|Z3" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 14.0, + 45.999999999999986 + ], + [ + 14.0, + 46.50000000000001 + ], + [ + 13.499999999999998, + 46.50000000000001 + ], + [ + 13.499999999999998, + 45.999999999999986 + ], + [ + 14.0, + 45.999999999999986 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "|Z4" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.749999999999998, + 46.25 + ], + [ + 11.749999999999998, + 46.50000000000001 + ], + [ + 11.500000000000002, + 46.50000000000001 + ], + [ + 11.500000000000002, + 46.25 + ], + [ + 11.749999999999998, + 46.25 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "|Z5" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 11.125, + 47.75000000000001 + ], + [ + 11.500000000000002, + 47.75000000000001 + ], + [ + 11.500000000000002, + 47.2505900350133 + ], + [ + 11.250000000000002, + 47.5 + ], + [ + 11.125, + 47.2505900350133 + ], + [ + 11.125, + 47.75000000000001 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { + "id": "|Z6" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 10.875, + 46.625 + ], + [ + 11.250000000000002, + 46.625 + ], + [ + 11.250000000000002, + 46.12500000000001 + ], + [ + 11.0, + 46.12500000000001 + ], + [ + 11.0, + 46.50000000000001 + ], + [ + 10.875, + 46.50000000000001 + ], + [ + 10.875, + 46.625 + ] + ] + ] + } + } + ] +} diff --git a/crates/analysis/tests/reference/generate.py b/crates/analysis/tests/reference/generate.py index 0593b5174..935a365f4 100644 --- a/crates/analysis/tests/reference/generate.py +++ b/crates/analysis/tests/reference/generate.py @@ -235,13 +235,27 @@ def case(name, tool, params, expect): # Intersect: each input feature's part in each zone, keyed by both ids ("P1|Z1"), in input # order, then zone order. Parts that only touch (a shared edge or corner) are left out. The # zones' `id` is taken by the parcels', so it comes out as `id_2`. - def pieces(name, rows, part): - out = [] + # Identity adds each input feature's part outside the zones it has a part in, keyed "P1|" (no + # zone id); Union adds each zone's part outside the parcels that have a part in it, "|Z1". + def pieces(name, rows, part, rest=False, zone_rest=False): + out, covering = [], {zi: [] for zi, _ in ZONES} for i, g in rows: + hit = [] for zi, z in ZONES: r = part(m(g).intersection(m(z))) if r is not None and not r.is_empty: out.append((f"{i}|{zi}", ll(r))) + hit.append(m(z)) + covering[zi].append(m(g)) + if rest: + r = part(m(g).difference(unary_union(hit))) if hit else m(g) + if r is not None and not r.is_empty: + out.append((f"{i}|", ll(r))) + if zone_rest: + for zi, z in ZONES: + r = polygonal(m(z).difference(unary_union(covering[zi]))) if covering[zi] else m(z) + if r is not None and not r.is_empty: + out.append((f"|{zi}", ll(r))) return write_expected(name, out) both = ["id", "id_2"] @@ -251,6 +265,17 @@ def pieces(name, rows, part): {"features": pieces("intersect_roads", ROADS, linear), "key": both}) case("intersect points", "intersect", {"input": "points", "overlay": "zones"}, {"ids": [f"{i}|{zi}" for i, g in POINTS for zi, z in ZONES if m(z).intersects(m(g))], "key": both}) + case("identity polygons", "identity", {"input": "parcels", "overlay": "zones"}, + {"features": pieces("identity_parcels", parcels, polygonal, rest=True), "key": both}) + case("identity lines", "identity", {"input": "roads", "overlay": "zones"}, + {"features": pieces("identity_roads", ROADS, linear, rest=True), "key": both}) + ids = [] + for i, g in POINTS: + hits = [f"{i}|{zi}" for zi, z in ZONES if m(z).intersects(m(g))] + ids += hits or [f"{i}|"] + case("identity points", "identity", {"input": "points", "overlay": "zones"}, {"ids": ids, "key": both}) + case("union", "union", {"input": "parcels", "overlay": "zones"}, + {"features": pieces("union_parcels", parcels, polygonal, rest=True, zone_rest=True), "key": both}) # Dissolve: one feature per code, the union of its parcels. codes = sorted({c for _, c, _ in PARCELS}) diff --git a/crates/assistant/skills/geoprocessing/SKILL.md b/crates/assistant/skills/geoprocessing/SKILL.md index df374da38..604e466e3 100644 --- a/crates/assistant/skills/geoprocessing/SKILL.md +++ b/crates/assistant/skills/geoprocessing/SKILL.md @@ -12,7 +12,9 @@ Based on the RustGIS user guide, Analysis › Geoprocessing tools `describe_tool` for its parameters. Never guess parameter names. The built-in tools are: - proximity: `buffer` (`input`, `distance`, `units`, `dissolve`) - overlay: `clip` (`input`, `clip`), `erase` (`input`, `erase`), `intersect` (`input`, - `overlay`: the parts in each overlay polygon, with both layers' attributes) + `overlay`: the parts in each overlay polygon, with both layers' attributes), `identity` + (the same, plus the parts outside every overlay polygon), `union` (both polygon layers: + every piece either covers) - generalization: `dissolve` (`input`, `field`), `simplify` (`input`, `tolerance`) - geometry: `convexHull`, `featureToPoint`, `xyTableToPoint` (`input`, `x`, `y`, `crs`), `calculateGeometry` diff --git a/crates/engine/src/cmd/workflows.rs b/crates/engine/src/cmd/workflows.rs index 5f6800d40..967a6ec75 100644 --- a/crates/engine/src/cmd/workflows.rs +++ b/crates/engine/src/cmd/workflows.rs @@ -190,6 +190,8 @@ pub fn gallery_list(kind: &str) -> CmdResult { "clip", "erase", "intersect", + "identity", + "union", "dissolve", "convexHull", "featureToPoint", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1f1bc3a9e..5a1c8d1aa 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -508,6 +508,21 @@ pub fn paint(painter: &Painter, r: Rect, name: &str, ink: Color32, accent: Color p.rect(3.0, 3.0, 12.0, 12.0, None, Some((1.3, ink)), 0.0); p.rect(9.0, 9.0, 12.0, 12.0, None, Some((1.3, ink)), 0.0); } + "identity" => { + // Intersect's squares, the input's square all kept: its part outside lighter. + p.rect(3.0, 3.0, 12.0, 12.0, Some(p.tint(60)), None, 0.0); + p.rect(9.0, 9.0, 6.0, 6.0, Some(accent), None, 0.0); + p.rect(3.0, 3.0, 12.0, 12.0, None, Some((1.3, ink)), 0.0); + p.rect(9.0, 9.0, 12.0, 12.0, None, Some((1.3, ink)), 0.0); + } + "union" => { + // Both squares kept, their shared part marked. + p.rect(3.0, 3.0, 12.0, 12.0, Some(p.tint(60)), None, 0.0); + p.rect(9.0, 9.0, 12.0, 12.0, Some(p.tint(60)), None, 0.0); + p.rect(9.0, 9.0, 6.0, 6.0, Some(accent), None, 0.0); + p.rect(3.0, 3.0, 12.0, 12.0, None, Some((1.3, ink)), 0.0); + p.rect(9.0, 9.0, 12.0, 12.0, None, Some((1.3, ink)), 0.0); + } "dissolve" => { p.polygon_shape(&[(2.0, 6.0), (12.0, 3.0), (22.0, 6.0), (20.0, 19.0), (4.0, 19.0)], 110); p.line(&[(12.0, 3.5), (12.0, 18.5)], 1.0, ink.gamma_multiply(0.35)); @@ -1209,6 +1224,8 @@ pub const ALL: &[&str] = &[ "clip", "erase", "intersect", + "identity", + "union", "dissolve", "hull", "centroid", diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fae7d8c0c..83ffd3686 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3394,6 +3394,8 @@ pub fn tool_icon(id: &str) -> &'static str { "clip" => "clip", "erase" => "erase", "intersect" => "intersect", + "identity" => "identity", + "union" => "union", "dissolve" => "dissolve", "convexHull" => "hull", "featureToPoint" => "centroid", diff --git a/docs/comparison/index.md b/docs/comparison/index.md index 20a1ccb84..8a6107703 100644 --- a/docs/comparison/index.md +++ b/docs/comparison/index.md @@ -143,8 +143,8 @@ Stated plainly, so the tables above are worth trusting: - **Coordinate systems.** Vector data is stored in longitude/latitude; there is no full PROJ. - **Editing depth.** Vertex move, insert and delete are supported, but not versioned or multi-user database editing. Edits are kept in the project and exported, not written back to databases. -- **Specialized analysis.** Built-in spatial joins, intersect and shortest paths on line networks - are available, but full network datasets, overlay union and identity tools and advanced spatial +- **Specialized analysis.** Built-in spatial joins, overlays (intersect, identity, union) and + shortest paths on line networks are available, but full network datasets and advanced spatial statistics depend on the external suite or another GIS. - **Python.** The [`rustgis` package](../python.md) runs commands headlessly, but there is no Python console inside the app. Python process plugins are supported on desktop and CLI, without a sandbox. diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index cb4c347e0..7c22c3486 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -78,7 +78,7 @@ written some other way (a token typed into a SQL query, say) can still be there: before sharing it. A point buffered where the map was clicked is copied as the command that buffered it, with the point. -There are 22 built-in tools; GeoLibre and plugin tools join them when available. +There are 24 built-in tools; GeoLibre and plugin tools join them when available. Use these ids in `gp.run`'s `tool` parameter (`gp.tools` lists the available tools and their parameters): | Tool | Id | Toolbox | @@ -87,6 +87,8 @@ Use these ids in `gp.run`'s `tool` parameter (`gp.tools` lists the available too | Clip | `clip` | Analysis › Extract | | Erase | `erase` | Analysis › Overlay | | Intersect | `intersect` | Analysis › Overlay | +| Identity | `identity` | Analysis › Overlay | +| Union | `union` | Analysis › Overlay | | Spatial Join | `spatialJoin` | Analysis › Overlay | | Dissolve | `dissolve` | Data Management › Generalization | | Minimum Bounding Geometry | `convexHull` | Data Management › Features | @@ -171,6 +173,17 @@ the result: gp.run={"tool":"intersect","params":{"input":"parcels","overlay":"floodplain","output":"parcels_in_floodplain"}} ``` +**Identity** (`identity`) takes the same parameters. Each input feature gives its Intersect +features, then its part outside every overlay polygon, with empty overlay attributes. Every parcel +is then in the result, in or out of the floodplain; one without geometry is kept as it is (Intersect +leaves it out). **Union** (`union`) takes two polygon +layers and adds, after Identity's features, each overlay polygon's part outside every input polygon, +with empty input attributes: together the features cover everything either layer covers. Both +follow Intersect's rules for field names, touching parts and selections. Where overlay polygons +overlap each other, an input feature's part in both comes out once for each, and Union adds each +overlay polygon's own remainder, so the overlay's overlap outside the input comes out once for each +too. + **Spatial Join** (`spatialJoin`, also `gp.spatialJoin`) copies attributes from `join` features to `target` features. Layers may have different source coordinate systems: their geometry is stored in longitude/latitude before the join. `match` is one of: