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 same tolerance acting on a drafting gap and on a real partition Two situations at the same scale. On the left, a wall corner whose two runs stop half a metre apart in drawing units; a snapping circle of 0.03 metres is enough to close it, which is the intended behaviour. On the right, a genuine 90-millimetre glazed partition drawn as two parallel faces; a snapping circle of 0.1 metres spans the whole partition and welds the two faces into one, erasing a wall the building actually has. One operation, two outcomes, decided entirely by one number gap 0.5 m at this corner (drawing units) tol = 0.03 m: closes 90 mm glazed partition tol = 0.1 m: welds it shut the same tolerance that closes a corner gap can erase a real partition intended closure unintended merge

Both operations are the same code. Nothing in the snapper can tell a mistake from a measurement — only the number you give it can, which is why it has to come from the building stock rather than from a default.

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

Partition thickness distribution across one portfolio A histogram of measured partition thicknesses across a portfolio. The bulk of wall runs fall between 0.1 and 0.3 metres, peaking at 0.2 metres with 402 runs and with a second cluster at 0.15. A thin tail runs down to 0.05 metres, and the fifth percentile sits at 0.075 metres — glazed office partitions. Dividing that fifth percentile by three gives a snapping tolerance of about 0.025 metres. Measure the building stock, then compute the tolerance 0 100 200 300 400 0.1 0.2 0.3 0.4 0.5 measured partition thickness (m) wall runs 5th percentile = 0.075 m -> tolerance 0.025 m

The tolerance is derived, not chosen. Take the fifth percentile of the thinnest partitions you must preserve and divide by three — snapping moves both endpoints, so a tolerance of t can close a genuine gap of up to t.

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).

  1. 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.
  2. 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.
  3. Divide by three. Snapping moves both endpoints towards a shared point, so a tolerance of t can close a genuine separation of up to t. 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

Four kinds of snapping and where each one is appropriate A table of four snapping operations. Endpoint-to-endpoint snapping joins two free ends, carries low risk and is the right tool for closing ring corners. Endpoint-to-segment snapping creates a T-junction, carries moderate risk and suits corridor spurs. Vertex-to-grid snapping moves everything onto a lattice, carries high risk and should never be used because it destroys genuine offsets. Segment-to-segment snapping collapses whole collinear runs, carries high risk, and belongs only in duplicate-wall merging. Four operations, one of them safe unattended Snap kind What it snaps Risk Use for Endpoint to endpoint two free ends ● low ● closing ring corners Endpoint to segment a T-junction moderate corridor spurs Vertex to grid everything, to a lattice △ high △ never — destroys real offsets Segment to segment whole collinear runs △ high duplicate wall merging only Only the first is safe to run unattended across a portfolio.

Grid snapping is the tempting one. It makes drawings look tidy and it quantises every real dimension to the lattice — a 2.85 m corridor becomes 2.9 m, and the error accumulates across a floor.

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.

This page is a companion to Geometry Cleanup & Topology Repair, part of the Automated Floor Plan Parsing & Vectorization section.