Choosing Snapping Tolerances for Floor Plan Geometry
Snapping is one line of the geometry cleanup pipeline and one number, and that number decides whether a floor comes out as forty rooms or as one. This page is about deriving it from the buildings you actually process rather than inheriting it from a tutorial.
What the Tolerance Controls
Endpoint snapping pulls coordinates that are within a tolerance of each other onto a shared point. Everything else in the cleanup stage — noding, polygonisation, validity — is deterministic given its input. The tolerance is the only free parameter, and it sits at the very front of the pipeline, so its effects are amplified by everything after it.
The two cases above are the whole problem. A drafting gap is an artefact: two wall runs that were meant to meet and do not, by a distance that reflects nothing about the building. A thin partition is a measurement: two wall faces that are genuinely separate, by a distance that is the wall. The snapper cannot distinguish them, because at the coordinate level they look identical — two nearby endpoints.
What separates them is scale, and that is why the tolerance has to be derived from the buildings. A tolerance below the thinnest real partition closes gaps without merging walls; above it, the merges begin. The number is a property of the portfolio, not of the algorithm.
Deriving the Number from the Building Stock
The derivation is three steps and needs to be done once per portfolio, then revisited when the portfolio changes character (a hospital acquisition, a warehouse estate).
- Measure partition thicknesses. Run wall-face pairing across a representative sample of levels and collect the perpendicular distance for every offset pair found. This is the same measurement wall and door detection makes, so the data is usually already available.
- Take the fifth percentile. Not the minimum — the minimum is a sliver or a drafting artefact and will drive the tolerance to zero. The fifth percentile is the thinnest partition class that genuinely occurs at scale. In the distribution above that is 0.075 m: glazed office partitions.
- Divide by three. Snapping moves both endpoints towards a shared point, so a tolerance of
tcan close a genuine separation of up tot. A factor of three leaves margin for the accumulated coordinate noise the drawing already carries.
That gives 0.025 m for this portfolio. Round down rather than up: an unclosed ring is a visible, countable defect, while a merged pair of rooms looks like a large room and is not.
Minimal Working Example
The sweep that proves the value is worth more than the derivation, because it measures both effects at once on your own data.
import logging
from dataclasses import dataclass
from shapely.geometry import LineString, Polygon
from shapely.ops import polygonize, unary_union
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class SweepRow:
tol: float
faces: int
merged: int # faces larger than any real room => a merge happened
def sweep_tolerance(segments: list[LineString], reference_faces: int,
tols: tuple[float, ...] = (0.005, 0.01, 0.02, 0.03, 0.05, 0.1),
merge_area: float = 400.0) -> list[SweepRow]:
"""Run cleanup at each tolerance and report closure against over-merging."""
if reference_faces <= 0:
raise ValueError("reference_faces must come from a known-good level")
rows: list[SweepRow] = []
for tol in tols:
try:
snapped = snap_endpoints(segments, tol=tol)
faces = list(polygonize(unary_union(snapped)))
except Exception as exc: # GEOSException on pathological noding
logger.error("sweep failed at tol=%.3f: %s", tol, exc)
continue
merged = sum(1 for f in faces if isinstance(f, Polygon) and f.area > merge_area)
rows.append(SweepRow(tol, len(faces), merged))
logger.info("tol=%.3f m -> %d/%d faces, %d over-merged",
tol, len(faces), reference_faces, merged)
return rows
Run it against a level whose true room count you know — one that has been checked by hand, or one whose room count matches the lease schedule. The right tolerance is the smallest one that reaches the reference face count with zero over-merged faces. If no tolerance satisfies both, the drawing has gaps too large to close safely and needs correcting upstream.
Snapping Kinds Reference
| Parameter | Type | Default | Notes |
|---|---|---|---|
tol |
float |
0.025 | Metres. Derived per portfolio; never a global constant |
mode |
str |
endpoint |
endpoint, endpoint_to_segment; grid snapping is not offered |
per_level |
bool |
True |
Snapping across levels is never correct |
index |
STRtree |
built | Without it the pass is O(n²) and dominates the stage |
max_moves |
int |
None |
Optional circuit breaker: abort if more than N endpoints move |
max_moves is worth setting once you have a baseline. A level that normally snaps 120 endpoints
and suddenly snaps 900 has had something change upstream — a different export scale, a new CAD
version writing coarser coordinates — and aborting is more useful than producing geometry that is
technically clean and semantically different.
Common Errors & Fixes
Rooms merge after a CAD upgrade with no config change. The tolerance did not move; the coordinate precision did. Some CAD exports write coordinates to fewer decimal places, which inflates the apparent gaps and, past a point, brings genuinely separate walls within tolerance of one another. The diagnosis is the endpoint-move count, which jumps sharply. The fix is to re-derive the tolerance against the new export precision, not to lower it blindly.
The tolerance works on offices and destroys plant rooms. Different building types have different thinnest-partition classes, and a single portfolio-wide number is a compromise. If the distribution is genuinely bimodal — thin glazed partitions in offices, thick blockwork in plant areas — derive the tolerance per building type and carry it in the ingest configuration rather than in code. It is one more field and it removes an entire class of argument.
Snapping appears to do nothing. Almost always because the geometry is not in metres yet. A tolerance of 0.025 against coordinates in millimetres is a tolerance of 25 microns, which closes nothing. The check is cheap and worth asserting explicitly:
span = max(seg.bounds[2] for seg in segments) - min(seg.bounds[0] for seg in segments)
if span > 500: # no indoor level is 500 m across in metres
raise ValueError(f"geometry is not in metres (span={span:.0f}); scale before cleanup")
Integration Point
Snapping is the first transformation in the cleanup pipeline and the one every later stage depends on. Noding assumes endpoints that were meant to coincide already do; polygonisation assumes noding produced closed rings; the plausibility filters assume polygonisation returned rooms rather than a merged floor. A tolerance error therefore surfaces as a symptom in whichever of those stages notices first, which is why the endpoint-move count is reported alongside the face count — together they distinguish “the drawing changed” from “the tolerance is wrong”.
Upstream, the requirement is that unit scaling has already happened, which is the responsibility of SVG/DWG parsing workflows. Downstream, the tolerance also bounds what duplicate wall merging can safely collapse, since two walls closer together than the snapping tolerance have already been merged by the time that stage runs.
Frequently Asked Questions
Can I use a single tolerance for a mixed portfolio?
You can, at the cost of leaving some gaps unclosed in the buildings with the thickest partitions. A single number must be safe for the thinnest partition anywhere in the portfolio, so a mixed estate ends up with the office tolerance applied to warehouses where a much larger one would have been fine. Whether that matters depends on how many unclosed rings the tight tolerance leaves: if the answer is a handful per level, take the simplicity; if it is dozens, carry the tolerance per building type in the ingest configuration. What you should not do is set it from the thickest partitions and accept occasional merges, because merges are much harder to detect after the fact.
Why divide the fifth percentile by three rather than two?
Because a tolerance of t can close a real gap of up to t — snapping pulls both endpoints toward a shared coordinate — and the drawing already carries coordinate noise of its own. A factor of two leaves no margin for that noise, so a 0.075 m partition with 10 mm of drafting jitter is at risk under a 0.037 m tolerance. Three is a working compromise that has room for the jitter and still closes the overwhelming majority of drafting gaps, which typically sit well under 20 mm. If your sweep shows closure saturating below the derived value, take the smaller number.
Should snapping ever move a vertex that is not an endpoint?
Only in the endpoint-to-segment mode, and only where corridor spurs genuinely meet wall runs at T-junctions that the drawing left unresolved. Moving interior vertices generally — grid snapping — quantises every real dimension in the building onto a lattice, so a 2.85 m corridor silently becomes 2.9 m and the error compounds along the floor. The output looks tidier and is measurably less true, which is a bad trade for a dataset whose whole purpose is telling people how far they have to walk.
Related
- Geometry Cleanup & Topology Repair — the pipeline this tolerance controls, front to back.
- Fixing Invalid Polygons with Shapely — what to do with the faces that survive snapping but not validity.
- Wall & Door Detection Algorithms — the source of the partition-thickness measurements the tolerance is derived from.
This page is a companion to Geometry Cleanup & Topology Repair, part of the Automated Floor Plan Parsing & Vectorization section.