From 7afe380bd2690061d8d07db9fbd24b7aa392890f Mon Sep 17 00:00:00 2001 From: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:12:42 +0800 Subject: [PATCH 1/2] fix(edit): honor vertex snapping for Point and MultiPoint targets --- crates/engine/src/cmd/design.rs | 14 +- crates/engine/tests/snapping_point_types.rs | 178 +++++++++++++++ crates/ui-egui/tests/snapping_point_types.rs | 228 +++++++++++++++++++ docs/user-guide/editing.md | 4 +- 4 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/snapping_point_types.rs create mode 100644 crates/ui-egui/tests/snapping_point_types.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index f5f9c7ef7..d3070d4e3 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -1067,6 +1067,9 @@ pub fn snap_at(s: &mut Session, c: Coord) -> Option<(Coord, &'static str)> { snap_except(s, c, None) } +/// Point features and MultiPoint members are vertex targets only, never edges or line ends. +/// Points and line ends take priority over edges. Equal distances keep the original layer, +/// row and member order. /// [`snap_at`], leaving out one feature (layer id, row): a vertex being dragged doesn't snap to /// the shape it is changing. pub fn snap_except(s: &mut Session, c: Coord, skip: Option<(u32, usize)>) -> Option<(Coord, &'static str)> { @@ -1102,8 +1105,15 @@ pub fn snap_except(s: &mut Session, c: Coord, skip: Option<(u32, usize)>) -> Opt Geometry::Polygon(polys) => polys.iter().flatten().collect(), _ => Vec::new(), }; - if let Geometry::Point(pt) = g { - consider((pt.x - m.x).powi(2) + (pt.y - m.y).powi(2), *pt, "vertex"); + if sn.vertex { + let points: &[Coord] = match g { + Geometry::Point(pt) => std::slice::from_ref(pt), + Geometry::MultiPoint(points) => points, + _ => &[], + }; + for pt in points { + consider((pt.x - m.x).powi(2) + (pt.y - m.y).powi(2), *pt, "vertex"); + } } for path in paths { if sn.end && matches!(g, Geometry::Line(_)) { diff --git a/crates/engine/tests/snapping_point_types.rs b/crates/engine/tests/snapping_point_types.rs new file mode 100644 index 000000000..cef5ee2a9 --- /dev/null +++ b/crates/engine/tests/snapping_point_types.rs @@ -0,0 +1,178 @@ +//! Point targets obey the same switches without changing feature exclusion or snap ordering. +use rustgis_engine::{ + Project, Session, + cmd::design::{snap_at, snap_except}, + data::{Dataset, Feature, Geometry}, + geom::{Coord, merc_to_lonlat}, + map::{LayerGroup, Projection, design::Snapping}, +}; +use serde_json::json; + +/// Stores metre-sized fixtures in the longitude/latitude coordinates used by feature layers. +fn c(x: f64, y: f64) -> Coord { + merc_to_lonlat(Coord::new(x, y)) +} + +/// Adds one ordinary feature without importing any external data or application-specific state. +fn add(s: &mut Session, geometry: Geometry) -> u32 { + let mut data = Dataset::new("Targets", geometry.kind(), vec![]); + data.features = vec![Feature { id: 1, geometry: Some(geometry), attrs: vec![] }].into(); + s.map_mut().add_dataset(data) +} + +/// Uses a two-pixel tolerance at one Mercator metre per pixel, with only vertices enabled. +fn fixture(geometry: Geometry) -> (Session, u32) { + let mut s = Session::new(Project::new("Point target types")); + let id = add(&mut s, geometry); + s.map_mut().camera.res = 1.; + s.map_mut().editing.snapping = Snapping { enabled: true, tolerance_px: 2., vertex: true, edge: false, end: false }; + (s, id) +} + +/// Checks both candidate kind and inverse-projected position, allowing projection rounding only. +fn hit(actual: Option<(Coord, &'static str)>, at: Coord, kind: &str) { + let (found, what) = actual.expect("eligible snap target"); + assert_eq!(what, kind); + assert!((found.x - at.x).abs() < 1e-9 && (found.y - at.y).abs() < 1e-9, "{found:?} != {at:?}"); +} + +/// A Point must not override the user's edge-only choice, even inside the tolerance. +#[test] +fn point_requires_vertex() { + let (mut s, _) = fixture(Geometry::Point(c(0., 0.))); + s.map_mut().editing.snapping.vertex = false; + s.map_mut().editing.snapping.edge = true; + assert!(snap_at(&mut s, c(0.5, 0.)).is_none()); +} + +/// Every MultiPoint member is a vertex target, and the nearest member wins over an earlier one. +#[test] +fn multipoint_members_are_vertices() { + let (mut s, _) = fixture(Geometry::MultiPoint(vec![c(0., 0.), c(3., 0.)])); + hit(snap_at(&mut s, c(2.5, 0.)), c(3., 0.), "vertex"); +} + +/// Both point representations obey all eight type-switch combinations and the master switch. +/// MultiPoint members never form implicit connecting edges or supply line-end candidates. +#[test] +fn both_point_types_obey_every_switch_combination() { + for geometry in [Geometry::Point(c(0., 0.)), Geometry::MultiPoint(vec![c(0., 0.)])] { + let (mut s, _) = fixture(geometry); + for enabled in [false, true] { + for bits in 0..8 { + s.map_mut().editing.snapping = + Snapping { enabled, tolerance_px: 2., vertex: bits & 1 != 0, edge: bits & 2 != 0, end: bits & 4 != 0 }; + assert_eq!(snap_at(&mut s, c(0.5, 0.)).is_some(), enabled && bits & 1 != 0, "enabled={enabled}, bits={bits}"); + } + } + } + let (mut s, _) = fixture(Geometry::MultiPoint(vec![c(-10., 0.), c(10., 0.)])); + s.map_mut().editing.snapping.edge = true; + s.map_mut().editing.snapping.end = true; + assert!(snap_at(&mut s, c(0., 0.)).is_none(), "no edge joins MultiPoint members"); + let (mut s, _) = fixture(Geometry::MultiPoint(vec![])); + assert!(snap_at(&mut s, c(0., 0.)).is_none()); +} + +/// Point targets keep the original point-before-edge priority and layer/row/member tie order. +/// Existing line-end and polygon-vertex switches continue to work independently. +#[test] +fn mixed_targets_preserve_priority_and_ties() { + let (mut s, id) = fixture(Geometry::MultiPoint(vec![c(-1., 0.), c(1., 0.), c(-1., 0.)])); + s.map_mut().layer_mut(id).unwrap().dataset.features.push(Feature { id: 2, geometry: Some(Geometry::Point(c(1., 0.))), attrs: vec![] }); + hit(snap_at(&mut s, c(0., 0.)), c(-1., 0.), "vertex"); + add(&mut s, Geometry::Point(c(1., 0.))); + add(&mut s, Geometry::Line(vec![vec![c(-10., 0.), c(10., 0.)]])); + s.map_mut().editing.snapping.edge = true; + hit(snap_at(&mut s, c(0., 0.)), c(1., 0.), "vertex"); + s.map_mut().layers.reverse(); + hit(snap_at(&mut s, c(0., 0.)), c(-1., 0.), "vertex"); + s.map_mut().editing.snapping.vertex = false; + hit(snap_at(&mut s, c(0., 0.)), c(0., 0.), "edge"); + s.map_mut().editing.snapping.end = true; + hit(snap_at(&mut s, c(9., 0.)), c(10., 0.), "end"); + let (mut s, _) = fixture(Geometry::Point(c(1., 0.))); + add(&mut s, Geometry::MultiPoint(vec![c(-1., 0.)])); + hit(snap_at(&mut s, c(0., 0.)), c(-1., 0.), "vertex"); + let (mut s, _) = fixture(Geometry::Polygon(vec![vec![vec![c(0., 0.), c(100., 0.), c(100., 100.), c(0., 0.)]]])); + hit(snap_at(&mut s, c(0.5, 0.)), c(0., 0.), "vertex"); + s.map_mut().editing.snapping.vertex = false; + s.map_mut().editing.snapping.end = true; + assert!(snap_at(&mut s, c(0.5, 0.)).is_none(), "polygon nodes are not line ends"); +} + +/// Feature exclusion still removes every member of an addressed MultiPoint and only that row. +/// Direct and command queries return the same target without changing geometry or undo state. +#[test] +fn whole_feature_exclusion_and_read_only_queries_remain_intact() { + for geometry in [Geometry::Point(c(0., 0.)), Geometry::MultiPoint(vec![c(0., 0.), c(1., 0.)])] { + let (mut s, id) = fixture(geometry); + assert!(snap_except(&mut s, c(0.5, 0.), Some((id, 0))).is_none()); + s.map_mut().layer_mut(id).unwrap().dataset.features.push(Feature { + id: 2, + geometry: Some(Geometry::Point(c(1.5, 0.))), + attrs: vec![], + }); + hit(snap_except(&mut s, c(0.5, 0.), Some((id, 0))), c(1.5, 0.), "vertex"); + let before = serde_json::to_value(&s.project).unwrap(); + let revision = s.revision; + let expected = snap_at(&mut s, c(0.5, 0.)).unwrap(); + assert_eq!(snap_except(&mut s, c(0.5, 0.), None), Some(expected)); + assert_eq!(s.revision, revision, "direct previews do not invalidate the view"); + let query = c(0.5, 0.); + let out = s.run("edit.snap", &json!({"lon":query.x,"lat":query.y})).unwrap(); + assert_eq!(out["snapped"], true); + assert_eq!(out["kind"], expected.1); + assert_eq!(out["lon"], expected.0.x); + assert_eq!(out["lat"], expected.0.y); + assert_eq!(serde_json::to_value(&s.project).unwrap(), before); + assert_eq!(s.revision, revision.wrapping_add(1), "commands retain the existing view-invalidation revision"); + assert_eq!(s.undo_depth(), 0); + s.map_mut().editing.snapping.enabled = false; + let out = s.run("edit.snap", &json!({"lon":query.x,"lat":query.y})).unwrap(); + assert_eq!(out, json!({"lon":query.x,"lat":query.y,"snapped":false})); + } +} + +/// Pixel tolerance scales with map resolution, with inclusive boundary and outside rejection. +/// Hidden, out-of-scale and grouped-off point layers cannot supply candidates. +#[test] +fn tolerance_and_layer_eligibility_are_unchanged() { + for geometry in [Geometry::Point(c(0., 0.)), Geometry::MultiPoint(vec![c(0., 0.)])] { + let (mut s, id) = fixture(geometry); + hit(snap_at(&mut s, c(2., 0.)), c(0., 0.), "vertex"); + assert!(snap_at(&mut s, c(2.01, 0.)).is_none()); + s.map_mut().camera.res = 2.; + hit(snap_at(&mut s, c(3., 0.)), c(0., 0.), "vertex"); + s.map_mut().layer_mut(id).unwrap().visible = false; + assert!(snap_at(&mut s, c(0., 0.)).is_none()); + s.map_mut().layer_mut(id).unwrap().visible = true; + s.map_mut().layer_mut(id).unwrap().min_scale = Some(1.); + assert!(snap_at(&mut s, c(0., 0.)).is_none()); + s.map_mut().layer_mut(id).unwrap().min_scale = None; + s.map_mut().layer_mut(id).unwrap().graphics = true; + assert!(snap_at(&mut s, c(0., 0.)).is_none()); + s.map_mut().layer_mut(id).unwrap().graphics = false; + let mut group = LayerGroup::new("Hidden targets"); + group.children.push(id); + group.visible = false; + s.map_mut().add_group(group); + assert!(snap_at(&mut s, c(0., 0.)).is_none()); + } +} + +/// World and regional projections retain valid members and ignore unprojectable coordinates. +#[test] +fn projected_multipoints_keep_valid_members_only() { + for code in ["equirectangular", "EPSG:4549", "mollweide +lon_0=-90"] { + let at = if code.starts_with("mollweide") { Coord::new(-110., 20.) } else { Coord::new(120., 31.) }; + let (mut s, _) = fixture(Geometry::MultiPoint(vec![Coord::new(f64::NAN, 0.), at, Coord::new(0., f64::INFINITY)])); + s.map_mut().projection = Projection::from_code(code).unwrap(); + s.map_mut().camera.center = s.map().to_map(at).unwrap(); + hit(snap_at(&mut s, at), at, "vertex"); + assert!(snap_at(&mut s, Coord::new(f64::NAN, 0.)).is_none()); + } + let (mut s, _) = fixture(Geometry::MultiPoint(vec![Coord::new(0., 95.), Coord::new(120., 31.)])); + s.map_mut().projection = Projection::from_code("EPSG:4549").unwrap(); + hit(snap_at(&mut s, Coord::new(120., 31.)), Coord::new(120., 31.), "vertex"); +} diff --git a/crates/ui-egui/tests/snapping_point_types.rs b/crates/ui-egui/tests/snapping_point_types.rs new file mode 100644 index 000000000..6abbf513e --- /dev/null +++ b/crates/ui-egui/tests/snapping_point_types.rs @@ -0,0 +1,228 @@ +//! Actual map gestures share the engine's Point/MultiPoint target switches. +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use rustgis_engine::{ + Project, Session, + data::{Dataset, Feature, Geometry, VertexId}, + geom::{Coord, merc_to_lonlat}, + map::design::Snapping, +}; +use rustgis_ui_egui::RustGisApp; +use serde_json::json; + +/// Keeps synthetic feature coordinates in longitude/latitude while laying them out in metres. +fn c(x: f64, y: f64) -> Coord { + merc_to_lonlat(Coord::new(x, y)) +} + +/// Creates an ordinary upstream feature layer; no files, provider or product shell is involved. +fn add(session: &mut Session, name: &str, geometry: Geometry) -> u32 { + let mut data = Dataset::new(name, geometry.kind(), vec![]); + data.features = vec![Feature { id: 1, geometry: Some(geometry), attrs: vec![] }].into(); + session.map_mut().add_dataset(data) +} + +/// Shows one selected source and an independent target in the application's actual map view. +fn harness(source: Geometry, target: Geometry, vertex: bool, bearing: f64) -> Harness<'static, RustGisApp> { + let mut session = Session::new(Project::new("Point target switches")); + let id = add(&mut session, "Editable source", source); + add(&mut session, "Snap targets", target); + // New layers are inserted at the front; keep the source first for the stored-node assertions. + session.map_mut().layers.reverse(); + session.active_layer = Some(id); + session.map_mut().layer_mut(id).unwrap().selection.push(0); + session.map_mut().camera.center = Coord::new(100., 80.); + session.map_mut().camera.res = 1.; + session.map_mut().camera.set_bearing(bearing); + session.map_mut().editing.snapping = Snapping { enabled: true, tolerance_px: 8., vertex, edge: !vertex, end: false }; + let mut app = RustGisApp::new(session); + app.run("tool.set", json!({"tool":"editVertices"})).unwrap(); + app.ui.tab = "Edit".into(); + app.session.clear_history(); + let mut h = Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1480., 900.)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + h.step(); + } + h +} + +/// Supplies a line whose first node can move without changing any target geometry. +fn line() -> Geometry { + Geometry::Line(vec![vec![c(0., 0.), c(0., 160.)]]) +} + +/// Converts a fixture coordinate using the actual map rectangle and camera bearing. +fn screen(h: &Harness<'_, RustGisApp>, at: Coord) -> Pos2 { + let rect = h.state().map.view_rect.unwrap().0; + let map = h.state().session.map(); + let (x, y) = map.camera.to_screen(map.to_map(at).unwrap(), rect.width() as f64, rect.height() as f64); + rect.min + egui::vec2(x as f32, y as f32) +} + +/// Holds the primary button while moving through real pointer events past the drag threshold. +fn drag(h: &mut Harness<'_, RustGisApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for i in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 8.))); + h.step(); + } +} + +/// Releases the pointer and processes the single edit or point-creation command. +fn release(h: &mut Harness<'_, RustGisApp>, at: Pos2) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + for _ in 0..3 { + h.step(); + } +} + +/// Reads a stored source node, which a drag preview must leave untouched. +fn first(h: &Harness<'_, RustGisApp>) -> Coord { + h.state().session.map().layers[0].dataset.features[0].geometry.as_ref().unwrap().vertex(VertexId::new(0, 0, 0)).unwrap() +} + +/// Compares projected round trips without allowing a visible fraction of a fixture pixel. +fn near(actual: Coord, expected: Coord) { + assert!((actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9, "{actual:?} != {expected:?}"); +} + +/// Finds the map's painted magenta snap ring without exposing the application's private cache. +fn marker(h: &Harness<'_, RustGisApp>) -> Option { + /// Searches nested paint lists for the existing seven-point snap-ring style. + fn find(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Circle(circle) if circle.radius == 7. && circle.stroke.color == egui::Color32::from_rgb(0xD0, 0x2A, 0xB0) => { + Some(circle.center) + } + egui::Shape::Vec(shapes) => shapes.iter().find_map(find), + _ => None, + } + } + h.output().shapes.iter().find_map(|shape| find(&shape.shape)) +} + +/// A rotated drag targets a later MultiPoint member, previews read-only and commits once. +#[test] +fn rotated_drag_to_multipoint_commits_and_undoes_once() { + let target = c(180., 40.); + let mut h = harness(line(), Geometry::MultiPoint(vec![c(260., 160.), target]), true, 27.); + let camera = h.state().session.map().camera; + let from = screen(&h, c(0., 0.)); + let to = screen(&h, target) + egui::vec2(3., 2.); + drag(&mut h, from, to); + let preview = h.state().map.vertex_drag.unwrap(); + assert!(preview.snapped && !preview.insert); + near(preview.at, target); + assert!(marker(&h).unwrap().distance(screen(&h, target)) < 0.1); + near(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), 0); + release(&mut h, to); + near(first(&h), target); + assert_eq!(h.state().session.undo_depth(), 1); + assert_eq!(h.state().session.map().camera, camera); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + near(first(&h), c(0., 0.)); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + near(first(&h), target); +} + +/// With only edges enabled, a nearby Point supplies neither a snap preview nor a snapped commit. +#[test] +fn edge_only_drag_ignores_point_target() { + let target = c(180., 40.); + let mut h = harness(line(), Geometry::Point(target), false, 0.); + let from = screen(&h, c(0., 0.)); + let to = screen(&h, target) + egui::vec2(3., 2.); + drag(&mut h, from, to); + let preview = h.state().map.vertex_drag.unwrap(); + assert!(!preview.snapped); + assert!(marker(&h).is_none()); + assert!((preview.at.x - target.x).abs() > 1e-6); + near(first(&h), c(0., 0.)); + release(&mut h, to); + near(first(&h), preview.at); + assert_eq!(h.state().session.undo_depth(), 1); +} + +/// Escape cancels a new multi-point target preview, and a read-only map refuses the same drag. +#[test] +fn cancelled_and_read_only_drags_leave_source_untouched() { + for read_only in [false, true] { + let target = c(180., 40.); + let mut h = harness(line(), Geometry::MultiPoint(vec![target]), true, 0.); + h.state_mut().session.map_mut().editing.settings.enabled = !read_only; + let from = screen(&h, c(0., 0.)); + let to = screen(&h, target) + egui::vec2(3., 2.); + drag(&mut h, from, to); + if read_only { + assert!(h.state().map.vertex_drag.is_none()); + } else { + assert!(h.state().map.vertex_drag.unwrap().snapped); + h.key_press(egui::Key::Escape); + h.step(); + } + release(&mut h, to); + assert!(h.state().map.vertex_drag.is_none()); + near(first(&h), c(0., 0.)); + assert_eq!(h.state().session.undo_depth(), 0); + } +} + +/// Moving a MultiPoint member continues to exclude its entire own feature, including other members. +#[test] +fn multipoint_source_remains_excluded_as_a_whole() { + let own = c(180., 40.); + let mut h = harness(Geometry::MultiPoint(vec![c(0., 0.), own]), Geometry::Point(c(300., 160.)), true, 0.); + let from = screen(&h, c(0., 0.)); + let to = screen(&h, own) + egui::vec2(3., 2.); + drag(&mut h, from, to); + let preview = h.state().map.vertex_drag.unwrap(); + assert!(!preview.snapped); + release(&mut h, to); + near(first(&h), preview.at); + let geometry = h.state().session.map().layers[0].dataset.features[0].geometry.as_ref().unwrap(); + assert_eq!(geometry.vertex(VertexId::new(0, 0, 1)), Some(own)); + assert_eq!(h.state().session.undo_depth(), 1); +} + +/// Creation's cached marker and command-based placement agree for enabled multi-points and disabled points. +#[test] +fn creation_preview_and_committed_position_agree() { + let target = c(180., 40.); + for (geometry, vertex) in [(Geometry::MultiPoint(vec![target]), true), (Geometry::Point(target), false)] { + let mut h = harness(Geometry::MultiPoint(vec![]), geometry, vertex, 0.); + h.state_mut().run("tool.set", json!({"tool":"createPoint"})).unwrap(); + h.state_mut().session.clear_history(); + let to = screen(&h, target) + egui::vec2(3., 2.); + h.event(Event::PointerMoved(to)); + for _ in 0..3 { + h.step(); + } + let preview = marker(&h); + assert_eq!(preview.is_some(), vertex); + if let Some(preview) = preview { + assert!(preview.distance(screen(&h, target)) < 0.1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + release(&mut h, to); + let source = &h.state().session.map().layers[0].dataset.features; + assert_eq!(source.len(), 2); + let Geometry::Point(at) = source[1].geometry.as_ref().unwrap() else { panic!("created point") }; + if preview.is_some() { + near(*at, target); + } else { + assert!((at.x - target.x).abs() > 1e-6); + } + assert_eq!(h.state().session.undo_depth(), 1); + } +} diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 892ce71e0..06947c036 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -50,7 +50,9 @@ you edit the value or choose units; click **Apply elevation** to apply the new i - **Snapping** (**Edit › Snapping**): new and dragged vertices snap to nearby vertices, edges and line ends of visible feature layers at the current scale (magenta marker; graphics and rasters are not targets). Vertices and ends take priority over edges within the tolerance. Snapping - starts off; its vertex, edge and end options start on. The tolerance is how near, in screen + starts off; its vertex, edge and end options start on. Both Point features and individual + MultiPoint members use the vertex option; they supply neither edges nor line ends. Dragging + a Point or MultiPoint member excludes its entire source feature. The tolerance is how near, in screen pixels, a vertex must come to snap (10 by default, 1 to 100): `edit.snapping={"on": true, "tolerancePx": 12, "edge": false}` (without `"on"` it toggles snapping, even when other options are supplied). `edit.snap={"lon":12.5,"lat":41.9}` queries From 0f59c19ba31ce3e0ba602eb002667b88ccbd0087 Mon Sep 17 00:00:00 2001 From: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:34:17 +0800 Subject: [PATCH 2/2] docs(engine): clarify point-target snapping contracts --- crates/engine/src/cmd/design.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index d3070d4e3..efb5f9ec8 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -1063,13 +1063,14 @@ fn snap(s: &mut Session, p: &Json) -> CmdResult { /// Where a point at `c` (longitude/latitude) snaps to, and to what, if anywhere. Read-only, for /// drawing the snap marker without running a command (projected shapes are cached on the way). +/// +/// Point features and MultiPoint members are vertex targets only, never edges or line ends. +/// Vertices and line ends take priority over edges. Equal distances keep the original layer, +/// row and member order. pub fn snap_at(s: &mut Session, c: Coord) -> Option<(Coord, &'static str)> { snap_except(s, c, None) } -/// Point features and MultiPoint members are vertex targets only, never edges or line ends. -/// Points and line ends take priority over edges. Equal distances keep the original layer, -/// row and member order. /// [`snap_at`], leaving out one feature (layer id, row): a vertex being dragged doesn't snap to /// the shape it is changing. pub fn snap_except(s: &mut Session, c: Coord, skip: Option<(u32, usize)>) -> Option<(Coord, &'static str)> {