Snapping Shared Boundaries Without Moving Survey Corners Jump to heading
Closing a sliver is trivial geometry and difficult governance. The geometry is a snap: move one boundary onto the other and the 3 cm strip between them disappears. The governance is that one of those boundaries may be a surveyed line running through monumented corners — physical marks in the ground, referenced in deeds — and moving it by 3 cm to satisfy a topology rule silently overwrites evidence with a convenience. This procedure closes slivers while making it structurally impossible to move a protected point. It is the repair counterpart to detecting gaps and overlaps in parcel coverages, inside topology rule enforcement.
The steps map to configure (Steps 1–2, authority and protected set), execute (Step 3, the asymmetric snap), validate (Step 4, the budget), and log (Step 5, per-vertex displacement). The default posture is conservative: when in doubt the sliver stays and a human is asked, because a sliver is a visible defect while a moved corner is an invisible one.
Prerequisites checklist Jump to heading
Step 1: Declare authority before touching a coordinate Jump to heading
# snapping_policy.yaml — an extension of topology_rules.yaml
policy_version: "1.1.0"
pairs:
- authoritative: cadastral_survey_boundaries # evidence; never moved
movable: municipal_parcels # derived; may be adjusted
vertex_tolerance: 0.02 # metres — the snap radius
max_displacement: 0.05 # metres — hard ceiling; above this, quarantine
protected_sources: # vertices that may never move, whichever side they are on
- monument_register
- geodetic_control_points
The asymmetry is the whole design. A symmetric snap — “move both boundaries to their midpoint” — produces a tidy coverage in which neither line is any longer what any source asserted, and the resulting geometry cannot be traced to evidence. Declaring one side authoritative means every displacement has a direction and a justification: the derived layer was adjusted to agree with the survey.
Step 2: Build the protected-vertex set Jump to heading
# shapely >= 2.0, geopandas >= 0.14 — Python 3.10+
import geopandas as gpd
from shapely import STRtree
CRS = "EPSG:25832"
PROTECT_RADIUS = 0.005 # 5 mm — a vertex this close to a monument *is* that monument
def load_protected(sources: list[str]) -> tuple[STRtree, list[dict]]:
frames = [gpd.read_file(f"reference/{name}.gpkg").to_crs(CRS) for name in sources]
points = gpd.pd.concat(frames, ignore_index=True)
return STRtree(points.geometry.values), points.to_dict("records")
def is_protected(x: float, y: float, tree: STRtree) -> bool:
from shapely.geometry import Point
hits = tree.query(Point(x, y).buffer(PROTECT_RADIUS), predicate="intersects")
return len(hits) > 0
Two properties make this set trustworthy. It is loaded from a register, not inferred from the geometry — “vertices shared by three or more parcels” is a heuristic that misses a monument on a two-parcel boundary and protects a spurious node in a badly digitized curve. And the match radius is tiny: 5 mm asserts that a vertex within 5 mm of a monument is intended to be that monument, which is a much stronger claim than the 2 cm snap tolerance and deliberately so.
Step 3: Snap only the movable side Jump to heading
# shapely >= 2.0 — Python 3.10+
from shapely import snap
from shapely.geometry import LineString, Polygon
from shapely.geometry.base import BaseGeometry
def snap_movable(movable: BaseGeometry, authoritative: BaseGeometry,
tolerance: float) -> BaseGeometry:
"""Move `movable` onto `authoritative`. The authoritative geometry is never an output."""
return snap(movable, authoritative, tolerance)
shapely.snap moves vertices of the first geometry onto vertices and segments of the second when they lie within the tolerance. That behaviour is what closes a sliver whose two boundaries have different vertex counts, which is the normal case: a survey line with two vertices and a digitized line with nine cannot be reconciled by vertex-to-vertex matching alone.
What snap will not do is respect a protected set — it has no concept of one. That is Step 4’s job, and it is why the snap result is treated as a proposal rather than as output.
Step 4: Enforce the protected set and the displacement budget Jump to heading
# shapely >= 2.0 — Python 3.10+
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Displacement:
index: int
before: tuple[float, float]
after: tuple[float, float]
distance: float
def review_snap(before: Polygon, after: Polygon, protected_tree,
max_displacement: float) -> tuple[bool, list[Displacement], str]:
b = list(before.exterior.coords)
a = list(after.exterior.coords)
if len(b) != len(a):
# snap inserted or removed a vertex: not a pure adjustment, so do not accept it
return False, [], "TOPO_SNAP_TOPOLOGY_CHANGED"
moves: list[Displacement] = []
for i, ((bx, by), (ax, ay)) in enumerate(zip(b, a)):
distance = math.hypot(ax - bx, ay - by)
if distance == 0.0:
continue
if is_protected(bx, by, protected_tree):
return False, [], "TOPO_PROTECTED_VERTEX_MOVED"
if distance > max_displacement:
return False, [], "TOPO_DISPLACEMENT_OVER_BUDGET"
moves.append(Displacement(i, (bx, by), (ax, ay), distance))
if not after.is_valid:
return False, [], "TOPO_SNAP_PRODUCED_INVALID"
return True, moves, ""
Every branch that returns False leaves the original geometry in place and sends the sliver to the reconciliation queue with the reason. That includes the vertex-count check: if snap added a vertex, the operation is no longer “adjust this line to agree with the survey” but “redraw it”, and the difference matters to anyone reading the boundary later.
The protected check is evaluated on the before coordinate, which is deliberate. Asking whether the post-snap position is protected would let a vertex be dragged off a monument and then pass, because the monument is no longer where the vertex is.
Step 5: Record every displacement so the edit is reversible Jump to heading
# pyarrow >= 14 — Python 3.10+
import pyarrow as pa
DISPLACEMENT_SCHEMA = pa.schema([
("run_id", pa.string()),
("policy_version", pa.string()),
("feature_id", pa.string()), # the movable feature
("counterpart_id", pa.string()), # the authoritative feature it was snapped to
("vertex_index", pa.int32()),
("x_before", pa.float64()),
("y_before", pa.float64()),
("x_after", pa.float64()),
("y_after", pa.float64()),
("distance", pa.float64()),
("sliver_area_closed", pa.float64()),
])
Storing coordinates rather than a summary is what makes the operation reversible: the original boundary can be reconstructed exactly from the log, with no need to retain a full copy of the pre-edit layer. It also makes the aggregate question answerable — “what is the largest distance any parcel boundary has moved since 2024?” is a query, not an investigation. These rows join the lineage manifest on run_id, and their maximum displacement belongs in the published positional-accuracy statement.
Verification Jump to heading
# pytest >= 7, shapely >= 2.0
def test_monumented_corner_is_never_moved(fixture_pair, protected_tree):
movable, authoritative = fixture_pair
proposal = snap_movable(movable, authoritative, tolerance=0.02)
ok, moves, code = review_snap(movable, proposal, protected_tree, max_displacement=0.05)
assert ok
assert all(not is_protected(*m.before, protected_tree) for m in moves)
def test_snap_that_would_move_a_monument_is_refused(fixture_pair_with_offset_monument, protected_tree):
movable, authoritative = fixture_pair_with_offset_monument
proposal = snap_movable(movable, authoritative, tolerance=0.05)
ok, _, code = review_snap(movable, proposal, protected_tree, max_displacement=0.05)
assert not ok and code == "TOPO_PROTECTED_VERTEX_MOVED"
def test_edit_is_reversible_from_the_log(displacement_rows, snapped_geometry, original_geometry):
restored = apply_inverse(snapped_geometry, displacement_rows)
assert restored.equals_exact(original_geometry, tolerance=0.0)
The successful run reads like this, and the two zero-displacement corners are the line that proves the policy held:
INFO quality.snap feature=PARCEL-14-023-9 counterpart=SURVEY-2211 moved=2 max=0.021 m
INFO quality.snap protected_vertices_encountered=2 moved=0
INFO quality.snap sliver_area_closed=0.0284 m² queue=resolved
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
TOPO_SNAP_TOPOLOGY_CHANGED on most features |
Tolerance larger than the spacing between adjacent vertices, so snap collapses them | Reduce vertex_tolerance; it should be smaller than the shortest legitimate segment |
| Slivers close but new overlaps appear elsewhere | Only one of the two neighbours was snapped, so the third parcel at a junction no longer meets | Snap per shared boundary, then re-run the coverage check; junctions need all incident boundaries in one pass |
| Every displacement is refused as over budget | The two layers disagree by more than the tolerance — a datum or vintage difference, not a digitizing artefact | Stop; check the transformation history via datum transformation fallback chains before adjusting geometry |
| Protected set matches nothing | Monument register in a different CRS, or coordinates stored as text | Reproject the register on load and assert its CRS equals the working CRS |
| The snap makes the polygon invalid | Snapping pulled a vertex across an adjacent segment | Refuse the proposal (TOPO_SNAP_PRODUCED_INVALID) and queue it; repairing the result would compound one edit with another |
Related Jump to heading
- Topology Rule Enforcement — the parent stage and its reconcile-versus-quarantine routing
- Detecting Gaps and Overlaps in Parcel Coverages — where the slivers this procedure closes come from
- Setting Tolerance Thresholds for Automated Coordinate Snapping — how to choose the numbers this policy publishes
- Lineage Manifest Generation — where the displacement log is retained as permanent evidence