An LLM asked to draft parcel boundaries or a road network will emit vertices that are almost coincident and intersections that cross without a shared node — geometry that looks right on a map but fails every topology rule the moment it hits PostGIS. This guide cleans that output with a snap-to-grid and noding pass before validity enforcement, so near-duplicate vertices collapse and every crossing gets a node. It is a preprocessing step within topology rule enforcement via LLMs.
Two distinct defects dominate model output. First, near-coincident vertices: two points meant to be identical differ in the eighth decimal, leaving hairline slivers and gaps. Second, unnoded intersections: two lines cross geometrically but share no vertex, so a coverage or polygonization step treats them as unconnected. Snapping fixes the first; noding fixes the second. Run them in that order, then validate.
When to Use This Approach
Reach for snap-and-node whenever geometries come from a generative source, a digitizer, or a merge of independently-authored layers — anywhere exact coordinate equality cannot be assumed. If your geometries already come from a topologically clean source, skip it; snapping is lossy and should not be applied gratuitously.
| Symptom | Root cause | Fix |
|---|---|---|
| Slivers / gaps at shared edges | Near-coincident vertices | Snap to a tolerance grid |
| “Non-noded intersection” error | Crossings without shared nodes | Node the linework |
| Persisting invalidity after snap/node | Degenerate rings, spikes | make_valid fallback |
Pick the snap tolerance from the data’s real precision, not arbitrarily: too coarse and you merge genuinely distinct features, too fine and near-duplicates survive. Snapping and noding are prerequisites, not replacements, for enforcing topological rules in LLM-generated geometries — they make the input clean enough that rule checks become meaningful. Do this in a projected CRS so tolerance is in meters; see coordinate reference system normalization.
Implementation
The routine below runs in Shapely for local cleaning with a PostGIS equivalent for set-wide noding. It snaps vertices to a grid, nodes the linework via a union, guards validity, and falls back to make_valid when residual defects remain — never returning an invalid geometry silently.
import logging
from shapely import set_precision
from shapely.geometry import GeometryCollection
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union, polygonize
from shapely.validation import make_valid
log = logging.getLogger("snap_node")
class NodingError(Exception):
pass
def snap_and_node(geom: BaseGeometry, grid_size_m: float = 0.01) -> BaseGeometry:
"""
Snap vertices to `grid_size_m`, node all intersections, then guarantee validity.
Tolerance is in the geometry's CRS units — use a projected CRS (meters).
"""
if geom is None or geom.is_empty:
raise NodingError("empty or null geometry")
# 1. Snap to grid: collapses near-coincident vertices deterministically.
try:
snapped = set_precision(geom, grid_size=grid_size_m)
except Exception as exc:
log.warning("set_precision failed (%s); falling back to make_valid only", exc)
return make_valid(geom)
if snapped.is_empty:
raise NodingError("geometry collapsed entirely at this grid size")
# 2. Node the linework: unary_union inserts nodes at every crossing.
try:
noded = unary_union(snapped)
except Exception as exc:
log.error("noding via unary_union failed: %s", exc)
return make_valid(snapped)
# 3. Validity guard with deterministic fallback.
if not noded.is_valid:
log.info("noded output invalid; applying make_valid fallback")
repaired = make_valid(noded)
if not repaired.is_valid or repaired.is_empty:
raise NodingError("geometry unrecoverable after snap+node+make_valid")
return repaired
return noded
def rebuild_polygons(lines: BaseGeometry, grid_size_m: float = 0.01) -> BaseGeometry:
"""Snap+node linework, then re-polygonize into clean faces."""
noded = snap_and_node(lines, grid_size_m)
faces = list(polygonize(noded))
if not faces:
log.warning("no polygons formed; returning noded linework")
return noded
return unary_union(faces)
# PostGIS equivalent for set-wide cleaning (run after snapping into a projected SRID):
POSTGIS_SNAP_NODE = """
WITH candidates AS (
-- && bbox pre-filter narrows pairs via the GiST index before the exact test.
SELECT a.id, ST_Node(
ST_SnapToGrid(ST_MakeValid(a.geom), 0.01)
) AS geom
FROM edges a
JOIN edges b
ON a.geom && b.geom
AND ST_Intersects(a.geom, b.geom)
AND a.id < b.id
)
SELECT id, geom
FROM candidates
WHERE ST_IsValid(geom);
"""
if __name__ == "__main__":
from shapely.geometry import LineString
# Two lines that cross without a shared node:
a = LineString([(0, 0), (10, 10)])
b = LineString([(0, 10), (10.0000001, -0.0000001)])
cleaned = snap_and_node(unary_union([a, b]), grid_size_m=0.001)
print("valid:", cleaned.is_valid, "type:", cleaned.geom_type)
Order matters: set_precision first so noding operates on already-collapsed vertices; unary_union to insert nodes; then make_valid only as a last resort. The PostGIS variant does the same set-wide, and its self-join leads with the && bbox pre-filter so noding candidate pairs are found through the spatial index rather than a full cross product.
Validation & Testing
- Crossings gain a shared node. Feed two lines that intersect without a common vertex; assert the result’s coordinate set includes the intersection point and that
ST_IsValid/is_validholds. - Near-duplicates collapse. Assert two vertices
0.0000001apart snap to one at a0.001grid, and that vertex count strictly decreases. - Fallback recovers invalid input. Pass a self-intersecting bowtie and assert
snap_and_nodereturns a valid geometry (via themake_validpath) rather than raising, and that a fully degenerate input raisesNodingErrordeterministically.
Gotchas & Edge Cases
- Tolerance too coarse merges real features. A grid larger than the smallest legitimate gap will weld distinct parcels together. Derive
grid_size_mfrom the data’s true precision and test on the densest region before applying globally. - Snapping in a geographic CRS. A grid of
0.01inEPSG:4326is degrees, not centimeters, and varies with latitude. Reproject to a projected CRS first, consistent with normalizing mixed CRS data before LLM ingestion. make_validchanging geometry type. Repair can turn a Polygon into a GeometryCollection containing stray lines or points. Filter the collection back to the expected dimension before persisting, or downstream schema constraints will reject it.- Order inverted. Noding before snapping leaves the near-coincident vertices in place, so the union produces micro-slivers instead of clean nodes. Always snap first, then node.
Operating This Step Over Time
Tolerances chosen once tend to survive the data that justified them. A source that improves its capture precision, or one that is replaced by a coarser product, changes what the right tolerance is, and a constant that was derived carefully two years ago is now a number nobody can defend. Recording the derivation alongside the constant — which source, which stated accuracy, which date — makes a later review a five-minute exercise rather than an archaeology project.
The second thing to watch is the displacement distribution rather than the maximum. A snap where most vertices move a few millimetres and a handful move nearly the whole tolerance is behaving as intended; one where a large share move the maximum is a tolerance set too coarse, and the geometry is being reshaped rather than cleaned. Publishing the distribution on every run costs nothing and makes that visible before anyone notices it on a map.
Finally, keep the pre-snap geometry until the output has been validated. Snapping is lossy and occasionally wrong, and the ability to re-run with a different tolerance without re-fetching the source is worth the storage for as long as the pipeline is being tuned.
Should the tolerance be uniform across a layer?
Usually yes, because a varying tolerance produces geometry whose accuracy depends on where it is, which is difficult to describe and harder to defend. The exception is a layer assembled from sources of genuinely different precision, where a per-source tolerance is more honest — provided the source of each feature is recorded, so the tolerance applied to it can be stated later.
Frequently Asked Questions
Should snapping run in the storage frame or a projected one?
A projected one, always, so the tolerance is a distance rather than a coordinate difference. A tolerance expressed in degrees is a different distance at every latitude, which means a corpus spanning a continent is snapped inconsistently by a constant that looks uniform. Project, snap, node, validate, and transform back.
How do I know the snap did not move something that mattered?
Measure the maximum vertex displacement and compare it against the tolerance — they should agree. A displacement larger than the tolerance means something other than snapping moved the geometry, usually a repair applied afterwards, and that is worth investigating before the output is stored.
What should happen when snapping produces an invalid geometry?
Repair it deterministically and record that the repair happened. Collapsing two vertices onto one can turn a thin sliver into a zero-area spike, which is a legitimate consequence of the snap rather than a bug — but the resulting geometry is not the one the source published, and anything measured from it inherits that difference.
Is it worth snapping to a grid rather than to neighbours?
Grid snapping is simpler, faster and slightly more destructive, because it moves every vertex rather than only the ones that were nearly coincident. For generated geometry, where near-duplicates are the dominant defect, it is usually the better trade; for surveyed data it is not, and snapping only the vertices within tolerance of each other preserves more.