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

yaml
# 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

python
# 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.

What Moves and What Does Not The left panel shows the input: a surveyed boundary drawn as a solid line with two monumented corners marked as ringed dots, and a derived parcel boundary drawn as a dashed line running a few centimetres away from it, leaving a thin sliver between them. The right panel shows the result: the derived boundary's intermediate vertices have been moved onto the surveyed line and the sliver is gone, while the two monumented corners are unchanged and are annotated as protected. A note records that every moved vertex is written to the displacement log with its before and after coordinates. Before — 3 cm sliver solid: surveyed boundary (authoritative) dashed: derived parcel boundary (movable) ringed dots: monumented corners the strip between the lines is the sliver After — snapped, corners intact two intermediate vertices moved 2.1 cm and 1.4 cm both monumented corners displaced 0.0 cm every displacement logged before and after the edit is reversible from the log alone

Step 3: Snap only the movable side Jump to heading

python
# 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

python
# 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

python
# 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

python
# 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:

text
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