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
35 changes: 34 additions & 1 deletion crates/engine/src/cmd/design.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use serde_json::{Value as Json, json};
use super::{b, f, need_feature_layer, ps};
use crate::{CmdResult, CommandSpec as C, Session};

mod snap_vertex;

/// One edited feature: its state before (none for an insert) and its row after (none for a
/// delete).
pub(crate) struct Change {
Expand Down Expand Up @@ -1069,7 +1071,31 @@ pub fn snap_at(s: &mut Session, c: Coord) -> Option<(Coord, &'static str)> {

/// [`snap_at`], leaving out one feature (layer id, row): a vertex being dragged doesn't snap to
/// the shape it is changing.
/// Use [`snap_vertex_except`] when the source node is known and its other parts should remain targets.
pub fn snap_except(s: &mut Session, c: Coord, skip: Option<(u32, usize)>) -> Option<(Coord, &'static str)> {
snap_excluding(s, c, skip, None)
}

/// Snaps while moving one source vertex, excluding that node and its incident edges instead of
/// its whole feature. Other line/polygon parts remain targets. Polygon closing copies alias the
/// first node; a closed line's two endpoints remain distinct, as in `Geometry::move_vertex`.
/// Invalid addresses are ignored. Point/multipoint targets retain the existing snapping policy.
/// Moving a point or multipoint still excludes its whole feature, as with [`snap_except`].
pub fn snap_vertex_except(s: &mut Session, c: Coord, skip: Option<(u32, usize, rustgis_data::VertexId)>) -> Option<(Coord, &'static str)> {
let skip = skip.filter(|(id, row, v)| {
s.map().layer(*id).and_then(|l| l.dataset.features.get(*row)).and_then(|f| f.geometry.as_ref()).and_then(|g| g.vertex(*v)).is_some()
});
snap_excluding(s, c, None, skip)
}

/// Shares the original layer/row ordering, scale rules and distance selection between legacy
/// feature exclusion and source-node exclusion. Only the addressed row needs source identities.
fn snap_excluding(
s: &mut Session,
c: Coord,
skip: Option<(u32, usize)>,
vertex: Option<(u32, usize, rustgis_data::VertexId)>,
) -> Option<(Coord, &'static str)> {
let sn = s.map().editing.snapping;
if !sn.enabled {
return None;
Expand All @@ -1092,11 +1118,18 @@ pub fn snap_except(s: &mut Session, c: Coord, skip: Option<(u32, usize)>) -> Opt
continue;
}
let id = l.id;
for (row, g) in l.projected_in(&flat, &key).iter().enumerate() {
l.projected_in(&flat, &key);
for (row, g) in l.projected_cache().iter().enumerate() {
let Some(g) = g.as_ref().filter(|_| skip != Some((id, row))) else { continue };
if !g.extent().padded(0.0, tol).contains(m) {
continue;
}
if let Some((_, _, v)) = vertex.filter(|(layer, source_row, _)| *layer == id && *source_row == row) {
if let Some(source) = l.dataset.features.get(row).and_then(|f| f.geometry.as_ref()) {
snap_vertex::consider(source, v, &flat, sn, m, &mut consider);
}
continue;
}
let paths: Vec<&Vec<Coord>> = match g {
Geometry::Line(parts) => parts.iter().collect(),
Geometry::Polygon(polys) => polys.iter().flatten().collect(),
Expand Down
115 changes: 115 additions & 0 deletions crates/engine/src/cmd/design/snap_vertex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! Source-addressed candidates for the one feature whose node is being moved.
//! Rendered parts cannot supply identities: world-projection cuts can add parts and vertices.

use rustgis_data::{Geometry, VertexId};
use rustgis_geom::Coord;
use rustgis_map::{design::Snapping, projection::Flat};

/// The same projection, switches and map-coordinate pointer apply to every source path.
#[derive(Clone, Copy)]
struct SnapView<'a> {
flat: &'a Flat,
snapping: Snapping,
pointer: Coord,
}

/// Visits eligible line/polygon paths in source order, without an index or whole-feature clone.
/// Other geometry kinds retain their previous whole-feature exclusion during a node move.
pub(super) fn consider(
geometry: &Geometry,
excluded: VertexId,
flat: &Flat,
snapping: Snapping,
pointer: Coord,
visit: &mut impl FnMut(f64, Coord, &'static str),
) {
let view = SnapView { flat, snapping, pointer };
let mut path = |points: &[Coord], part, ring, polygon| {
consider_path(points, VertexId::new(part, ring, 0), polygon, excluded, view, visit);
};
match geometry {
Geometry::Line(parts) => {
for (part, points) in parts.iter().enumerate() {
path(points, part, 0, false);
}
}
Geometry::Polygon(polygons) => {
for (part, rings) in polygons.iter().enumerate() {
for (ring, points) in rings.iter().enumerate() {
path(points, part, ring, true);
}
}
}
_ => {}
}
}

/// Excludes source endpoints before projection, preserving endpoint-before-vertex-before-edge
/// visitation. Only a polygon's actual repeated closing coordinate aliases its first node.
fn consider_path(
path: &[Coord],
address: VertexId,
polygon: bool,
excluded: VertexId,
view: SnapView<'_>,
visit: &mut impl FnMut(f64, Coord, &'static str),
) {
let SnapView { flat, snapping, pointer } = view;
let closed = polygon && path.len() > 1 && path.first() == path.last();
let count = path.len().saturating_sub(usize::from(closed));
let skip = |index| {
address.part == excluded.part && address.ring == excluded.ring && excluded.index == if closed && index == count { 0 } else { index }

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.

Low-confidence edge case: skip normalises the path index count to 0, but excluded.index is compared as given. If a caller addresses a polygon's stored closing copy (index == count) and Geometry::vertex/move_vertex accept that address as an alias of node 0, then excluded.index == count never matches. Nothing is excluded and the moving node snaps to itself. Normalise excluded the same way, for example by mapping excluded.index == count to 0 for a closed polygon ring. The tests only cover index 0 for polygons, so please add a case for the closing index. Ignore this if vertex() rejects that index.

};
let mut point = |index, kind| {
if !skip(index)
&& let Some(at) = path.get(index).and_then(|at| flat.forward_checked(*at))
{
visit((at.x - pointer.x).powi(2) + (at.y - pointer.y).powi(2), at, kind);
}
};
if snapping.end && !polygon {
for index in [0, path.len().saturating_sub(1)] {
point(index, "end");
}
}
if snapping.vertex {
for index in 0..count {
point(index, "vertex");
}
}
if snapping.edge {
for (index, pair) in path.windows(2).enumerate() {
if !skip(index)
&& !skip(index + 1)
&& let (Some(a), Some(b)) = (pair.first(), pair.get(1))
{
consider_edge(*a, *b, flat, pointer, visit);
}
}
}
}

/// Projects a surviving source edge using the same world-edge cuts as ordinary snapping.
/// Each projected piece is considered separately; no segment joins opposite sides of the map.
fn consider_edge(a: Coord, b: Coord, flat: &Flat, pointer: Coord, visit: &mut impl FnMut(f64, Coord, &'static str)) {
let mut segment = |a: Coord, b: Coord| {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let length = dx * dx + dy * dy;
let t = if length > 0.0 { (((pointer.x - a.x) * dx + (pointer.y - a.y) * dy) / length).clamp(0.0, 1.0) } else { 0.0 };
let at = Coord::new(a.x + t * dx, a.y + t * dy);
visit((at.x - pointer.x).powi(2) + (at.y - pointer.y).powi(2), at, "edge");
};
if matches!(flat, Flat::Custom(projector) if projector.edge().is_some()) {
if let Geometry::Line(parts) = flat.project(&Geometry::Line(vec![vec![a, b]])) {
for path in parts {
for pair in path.windows(2) {
if let (Some(a), Some(b)) = (pair.first(), pair.get(1)) {
segment(*a, *b);
}
}
}
}
} else if let (Some(a), Some(b)) = (flat.forward_checked(a), flat.forward_checked(b)) {
segment(a, b);
}
}
5 changes: 5 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11050,6 +11050,7 @@ fn coverage_answers_that_are_not_geotiffs_are_errors() {
/// The keys a command's source reads from its parameters, found by scanning `src/cmd/*.rs`: the
/// command's function (or closure) and the functions it passes its parameters to (in its own
/// file, or another's as `module::name(…)`), a few calls deep.
/// Scans top-level Rust modules; helper subdirectories are not source files or command entries.
fn keys_read_by_commands() -> Vec<(String, std::collections::BTreeSet<String>)> {
use std::collections::{BTreeMap, BTreeSet};
type Fn = (Vec<String>, String);
Expand All @@ -11058,6 +11059,9 @@ fn keys_read_by_commands() -> Vec<(String, std::collections::BTreeSet<String>)>
let mut modules: BTreeMap<String, (String, BTreeMap<String, Fn>)> = BTreeMap::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if !path.is_file() || path.extension().is_none_or(|ext| ext != "rs") {
continue;
}
let stem = path.file_stem().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
let mut fns: BTreeMap<String, Fn> = BTreeMap::new();
Expand Down Expand Up @@ -11217,6 +11221,7 @@ fn literal_keys(body: &str, prefix: &str) -> Vec<String> {
.collect()
}

/// Checks every discovered command's declared keys, even when helper directories accompany modules.
#[test]
fn params_name_every_key_a_command_reads() {
// Read by a shared helper but of no use to the command.
Expand Down
Loading
Loading