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
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ And alongside it:
- Export into an existing GeoPackage as an added or replaced table, leaving its other tables
([#852](https://github.com/opengeos/RustGIS/issues/852); export refuses an existing file today).
- Polygon editing: cut a polygon, reshape and trace _(open)_; then shared edges, where moving one
boundary moves its neighbour's too.
boundary moves its neighbour's too. Coincident polygon vertices can already move together
with Shared Vertices (`edit.moveSharedVertex`); different vertex densities and linked
insertion/deletion remain open.
- Editor tracking fields (created/edited by and at).
- Attribute table columns: rename, hide and reorder _(open)_.
- Persistent attribute joins and virtual fields. _Coordinate first_ (they change `Feature`).
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/cmd/design.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use serde_json::{Value as Json, json};
use super::{b, f, need_feature_layer, ps};
use crate::{CmdResult, CommandSpec as C, Session};

mod shared_vertex;
mod snap_vertex;

/// One edited feature: its state before (none for an insert) and its row after (none for a
Expand Down Expand Up @@ -208,6 +209,8 @@ pub(crate) fn apply(s: &mut Session, id: u32, changes: Vec<Change>) -> Result<()
Ok(())
}

/// Registers data-design and editing commands, including opt-in exact-coordinate polygon moves.
/// Feature edits share `apply` for rules, archives and counts inside the session transaction.
pub fn commands() -> Vec<C> {
vec![
// Data design.
Expand Down Expand Up @@ -361,6 +364,9 @@ pub fn commands() -> Vec<C> {
r#"{"layer"?, "row": number, "action": "move"|"insert"|"delete", "part"?: number, "ring"?: number, "index": number (insert: the vertex the new one follows), "lon"?, "lat"?}"#,
),
C::new("edit.merge", "Merge", "Edit › Features › Modify", merge).when(need_editable_selection),
C::new("edit.moveSharedVertex", "Move Shared Vertex", "Edit › Features › Modify", shared_vertex::run).when(need_editable).params(
r#"{"layer"?, "row": number, "part"?: number, "ring"?: number, "index": number, "lon": degrees, "lat": degrees}"#,
),
C::new("edit.explode", "Explode", "Edit › Features › Modify", explode).when(need_editable_selection),
C::new("edit.split", "Split", "Edit › Features › Modify", split).when(need_editable_selection).params(r#"{"lon", "lat"}"#),
C::new("edit.traverse", "Traverse", "Edit › Features › Modify", traverse).when(need_editable).params(
Expand Down
79 changes: 79 additions & 0 deletions crates/engine/src/cmd/design/shared_vertex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! Moves every polygon vertex stored at the source coordinate, within one layer.
//! Exact coordinate equality includes point-only contacts; no edge remapping or tolerance.

use rustgis_data::{Geometry, VertexId};
use rustgis_geom::Coord;
use serde_json::{Value, json};

use super::{Change, apply};
use crate::{CmdResult, Session};

/// Moves coincident polygon nodes through one design-rule batch and the caller's transaction.
/// Source addresses match `edit.vertex`; closed rings follow `Geometry::move_vertex`.
/// Same-position calls still run design rules and retain ordinary command history behaviour.
pub(super) fn run(s: &mut Session, p: &Value) -> CmdResult {
let id = s.layer_id(p)?;
let row = index(p, "row", None)?;
let vertex = VertexId::new(index(p, "part", Some(0))?, index(p, "ring", Some(0))?, index(p, "index", None)?);
let lon = super::f(p, "lon").ok_or("lon is required")?;
let lat = super::f(p, "lat").ok_or("lat is required")?;
if !(lon.is_finite() && lat.is_finite()) || lon.abs() > 360.0 || lat.abs() > 90.0 {
return Err("lon and lat are degrees".into());
}
let at = Coord::new(lon, lat);
let layer = s.map().layer(id).ok_or("no such layer")?;
let source = layer.dataset.features.get(row).ok_or("no such row")?.geometry.as_ref().ok_or("the feature has no shape")?;
if !matches!(source, Geometry::Polygon(_)) {
return Err("shared vertices can only be moved in polygons".into());
}
let original = source.vertex(vertex).ok_or("no such vertex")?;
// This clone retains the Arc, not a second row table. The first write can still copy the
// full table, which the undo snapshot also shares; keep these rows for post-rule counts.
let before = layer.dataset.features.clone();

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.

The comment says retaining the shared rows "adds no table copy", but this clone makes the Arc shared. The first get_mut below goes through Features::deref_mut → Arc::make_mut, which deep-copies the whole Vec<Feature>, geometries included, even when only a few rows change. On a large layer each shared move therefore costs O(table), not O(matches). The session undo snapshot may already force that copy, so I'm not certain it's extra. Either way the comment is misleading. Consider keeping only the changed rows' prior state (you already build Change.before) and comparing against those for modified. Confidence: medium-low.

let mut replacements = Vec::new();
for (row, feature) in before.iter().enumerate() {
let Some(geometry) = &feature.geometry else { continue };
let Some(moved) = move_matching(geometry, original, at) else { continue };
super::super::edit::validate(&moved)?;
replacements.push((row, moved));
}
let layer = s.map_mut().layer_mut(id).ok_or("no such layer")?;
let mut changes = Vec::new();
for (row, geometry) in replacements {
let feature = layer.dataset.features.get_mut(row).ok_or("no such row")?;
changes.push(Change { before: Some(feature.clone()), row: Some(row) });
feature.geometry = Some(geometry);
}
layer.touch_geometry();
let rows: Vec<usize> = changes.iter().filter_map(|c| c.row).collect();
apply(s, id, changes)?;
let layer = s.map().layer(id).ok_or("no such layer")?;
let modified = rows.iter().filter(|&&row| before.get(row) != layer.dataset.features.get(row)).count();
Ok(json!({"part": vertex.part, "ring": vertex.ring, "index": vertex.index, "modified": modified}))
}

/// Copies a matching polygon once and rewrites its original-coordinate matches in linear time.
/// Stored closing duplicates match their first node and move with it, as with `move_vertex`;
/// open rings stay unnormalized. Non-polygons and polygons without a match need no clone.
fn move_matching(geometry: &Geometry, original: Coord, at: Coord) -> Option<Geometry> {
let Geometry::Polygon(parts) = geometry else { return None };
if !parts.iter().flatten().flatten().any(|c| *c == original) {
return None;
}
let mut moved = parts.clone();
for c in moved.iter_mut().flatten().flatten() {
if *c == original {
*c = at;
}
}
Some(Geometry::Polygon(moved))
}

/// Reads a platform-sized address; optional null parts/rings use the same defaults as `edit.vertex`.

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.

Nit (low confidence): the bounds lon.abs() > 360.0 || lat.abs() > 90.0 are looser than the "degrees" error implies, and differ from how edit.vertex validates a destination. edit::validate runs on the moved geometry, so this probably catches the rest, but it's worth reusing the same check as edit.vertex for consistency. The message could also say which value is out of range.

fn index(p: &Value, key: &str, default: Option<usize>) -> Result<usize, String> {
match p.get(key) {
None | Some(Value::Null) if default.is_some() => default.ok_or_else(|| format!("{key} is required")),
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| format!("{key} must be a whole number")),
None => Err(format!("{key} is required")),
}
Comment thread
yongyin-leon marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,9 @@ fn hostile_params_never_panic() {
let junk = [
Value::Null,
json!({}),
// Shared vertex moves: valid row/destination, hostile node addresses and coordinate types.
json!({"layer":"Countries","row":0,"part":u64::MAX,"ring":-1,"index":[],"lon":12,"lat":45}),
json!({"layer":"Countries","row":0,"part":0,"ring":0,"index":0,"lon":{},"lat":1e300}),
json!({"layer": 99999, "path": "/nonexistent/x.geojson", "name": "", "where": "((", "tool": "buffer", "params": 7}),
json!({"layer": "Countries", "row": 1e30, "field": "nope", "value": [1], "scale": -1, "lon": 1e300, "lat": "x", "classes": 1e9, "type": "graduated"}),
json!({"layer": "Cities", "rows": [1, 99999999], "geometry": {"type": "Polygon", "coordinates": [[]]}, "width": 1e9, "height": -3, "expression": "'"}),
Expand Down
Loading
Loading