Detecting and Merging Duplicate Wall Segments

Part of Geometry Cleanup & Topology Repair: the pass that finds walls the drawing contains twice and collapses them into one, without touching the parallel faces that are a wall’s thickness rather than a mistake.

Where Duplicate Walls Come From

Duplicates are not drafting errors so much as drafting history. The recurring sources are all mundane:

  • Layered exports. An architectural base drawing and a fit-out drawing both contain the core walls. Export both layers and every core wall appears twice, offset by whatever coordinate noise the two files carry.
  • Copy-paste revisions. A wing is duplicated to create a mirrored floor, edited, and the original left in place on a frozen layer that the export includes.
  • Block explosion. A wall assembly block is both referenced by an INSERT and present as exploded geometry, which happens when someone explodes a block to edit it and the reference is not removed.
  • Trace-over. A raster underlay is traced, and the resulting vector geometry is exported alongside a partially vectorised earlier attempt.

In every case the two copies are within a few centimetres of each other, and neither is marked as redundant.

What duplicate merging removes from one floor Grouped bars comparing one floor before and after duplicate wall merging. Wall segments fall from 6,820 to 5,140, a quarter of them removed. Routing graph nodes fall from 1,180 to 742. Most significantly, the number of disconnected components in the routing graph falls from 14 to 1: the duplicates had been generating parallel node chains that never joined, fragmenting the graph. A quarter fewer segments, and a connected graph 0 2000 4000 6000 1 2 1 before merging 2 after merging count (log-ish scale, one typical floor) wall segments routing graph nodes disconnected components

The third bar is why this stage exists. Duplicated walls do not just bloat the geometry — they build two parallel node chains that never meet, so the floor arrives at the router as fourteen islands.

The cost is not primarily storage. Two parallel wall runs a few centimetres apart produce two parallel chains of routing nodes, and because the chains never share a vertex the routing graph gains a disconnected component for every doubled corridor. The floor above went from fourteen components to one purely by merging duplicates — no geometry was moved, and nothing else changed.

The Three Tests

Four near-parallel segment pairs and which of them may be merged Four labelled cases. In case A two segments overlap along their length and lie 0.12 metres apart, which is below any real partition thickness, so they are merged into one centreline spanning the union of both. In case B two segments are collinear but disjoint, separated by a two-metre gap that is a doorway, so both are kept. In case C two parallel segments lie 0.9 metres apart, which is a genuine wall pair expressing thickness and must never be merged. In case D two segments differ in bearing by 3.4 degrees, so they are not collinear at all — one is a splayed reveal — and both are kept. Only one of these four pairs is a duplicate A: overlapping, 0.12 m apart -> merge union of both spans, one centreline B: collinear but disjoint -> keep both the 2 m gap is a doorway C: parallel, 0.9 m apart -> a real wall pair never merge: this is wall thickness D: 3.4° apart -> not collinear keep both; one is a splayed reveal

Three tests, applied in order. Bearing difference rules out case D, perpendicular separation rules out case C, and longitudinal overlap rules out case B — only a pair that fails all three is a duplicate.

Merging is guarded by three independent tests, and a pair is merged only when all three say “these are the same wall”. The asymmetry is deliberate: a missed duplicate is a redundancy, while a wrongly merged pair destroys a partition.

The three tests that must all fail before two segments are merged One decision with four outcomes. If the two segments differ in bearing by more than two degrees they are not collinear and both are kept. If their perpendicular separation exceeds three times the snapping tolerance they are a genuine wall pair expressing thickness and both are kept. If they do not overlap along their shared axis, the gap between them is a doorway and both are kept. Only when all three tests fail are the segments treated as a duplicate, merged into a single span, with both source entity handles recorded. Three ways to say “these are different walls” Are these two segments the same wall drawn twice? all three tests must pass before anything is merged bearing > 2° Not collinear keep both separation > 3 x tol A wall pair this is thickness keep both no overlap A doorway the gap is the opening keep both all three fail Duplicate merge to one span record both handles

The default is to keep. A missed duplicate costs a redundant wall in the graph; a wrongly merged pair erases a partition the building has, and nothing downstream can recover it.

1. Bearing. The two segments must be near-parallel — within about 2°. Real building geometry contains plenty of nearly-parallel-but-not walls: splayed reveals, chamfered corners, ramped edges. A tighter threshold than 2° misses duplicates that carry rotation noise from a trace; a looser one starts merging genuine splays.

2. Perpendicular separation. The distance between the two lines, measured perpendicular to their shared bearing, must be below about three times the snapping tolerance — a few centimetres. Anything larger is a wall pair expressing thickness, which wall and door detection needs intact.

3. Longitudinal overlap. Projected onto their shared axis, the two segments must actually overlap. Two collinear segments with a gap between them are a wall with a doorway in it, and the gap is the single most important feature on the floor.

Minimal Working Example

import logging
import math

import numpy as np
from shapely.geometry import LineString

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def _bearing(seg: LineString) -> float:
    """Bearing in degrees, folded to [0, 180) so direction does not matter."""
    (x0, y0), (x1, y1) = seg.coords[0], seg.coords[-1]
    return math.degrees(math.atan2(y1 - y0, x1 - x0)) % 180.0


def is_duplicate(a: LineString, b: LineString, *, max_bearing: float = 2.0,
                 max_sep: float = 0.075, min_overlap: float = 0.5) -> bool:
    """True only when all three duplicate tests agree. Biased towards keeping both."""
    if a.is_empty or b.is_empty:
        raise ValueError("empty geometry reached duplicate detection; clean upstream")

    d_bearing = abs(_bearing(a) - _bearing(b))
    d_bearing = min(d_bearing, 180.0 - d_bearing)
    if d_bearing > max_bearing:
        return False                                   # test 1: not collinear

    # perpendicular separation: distance from b's midpoint to a's infinite line
    if a.distance(b.interpolate(0.5, normalized=True)) > max_sep:
        return False                                   # test 2: a real wall pair

    axis = np.array(a.coords[-1]) - np.array(a.coords[0])
    axis = axis / (np.linalg.norm(axis) or 1.0)
    proj = lambda p: float(np.dot(np.array(p) - np.array(a.coords[0]), axis))
    a0, a1 = sorted((proj(a.coords[0]), proj(a.coords[-1])))
    b0, b1 = sorted((proj(b.coords[0]), proj(b.coords[-1])))
    overlap = min(a1, b1) - max(a0, b0)
    if overlap < min_overlap:
        return False                                   # test 3: a doorway between them

    return True


def merge_pair(a: LineString, b: LineString) -> LineString:
    """Replace a duplicate pair with one segment spanning the union of both."""
    pts = list(a.coords) + list(b.coords)
    axis = np.array(a.coords[-1]) - np.array(a.coords[0])
    axis = axis / (np.linalg.norm(axis) or 1.0)
    keyed = sorted(pts, key=lambda p: float(np.dot(np.array(p) - np.array(a.coords[0]), axis)))
    merged = LineString([keyed[0], keyed[-1]])
    logger.info("merged duplicate: %.2f m + %.2f m -> %.2f m", a.length, b.length, merged.length)
    return merged

Note what merge_pair does with the geometry: it spans the union of the two segments rather than keeping the longer one. Duplicates frequently disagree at their ends — one copy stops at the column, the other runs past it — and taking the union preserves the full wall run, which is what polygonisation needs to close the ring.

Parameter Reference

Parameter Type Default Notes
max_bearing float 2.0° Below ~1° traced geometry is missed; above ~4° splays merge
max_sep float 0.075 m ≈ 3× the snapping tolerance; must stay under the thinnest partition
min_overlap float 0.5 m Shorter overlaps are usually corner artefacts, not duplicates
pair_index STRtree built Candidate lookup; without it the pass is O(n²)
record_handles bool True Keep both source entity handles on the survivor

record_handles matters more than it looks. When a wall turns out to be missing from the published map, the question is which drawing entity produced it — and if a merge discarded one of the two handles, half the answers are unavailable. Carrying both on the merged segment costs a list and makes the merge reversible in diagnosis if not in data.

Common Errors & Fixes

Wall thickness disappears across a whole building. max_sep was set larger than the building’s partitions. The symptom is distinctive: wall and door detection suddenly finds almost no offset pairs, because the pairs were merged into single centrelines before it ran. Derive max_sep from the same partition-thickness distribution that sets the snapping tolerance and keep it strictly below the fifth percentile.

Doorways vanish. min_overlap was set to zero or the overlap test was skipped, so two collinear runs either side of a door were merged across the opening. The opening is then invisible to detection and the two rooms never connect. This is the most damaging failure in the whole cleanup stage, because the resulting map looks complete and is unroutable, and it is worth a dedicated assertion in the test suite:

def test_doorway_survives_merging(wall_left, wall_right):
    assert not is_duplicate(wall_left, wall_right), "a doorway was merged shut"

The pass is quadratic and takes minutes. Every segment is being compared with every other. Use an STRtree over segment bounding boxes expanded by max_sep, and compare only candidates it returns; on a 7,000-segment floor that takes the pass from roughly 25 seconds to under 200 ms.

Integration Point

Duplicate merging runs after snapping and before noding. The ordering is load-bearing in both directions: snapping first means the two copies of a wall have already had their endpoints pulled together, which makes the overlap test cleaner; merging before noding means the noder does not have to resolve the many near-parallel intersections that duplicates generate, which is where its superlinear cost comes from.

Its output feeds polygonisation, and its report feeds the same delta gate as the rest of the cleanup stage: a level whose merged-segment count changes sharply between builds has had something change in the export, and that is worth a human look before the map is published.

Frequently Asked Questions

How do I tell a duplicate from a wall drawn as two faces?

By perpendicular separation, and the numbers are not close. A duplicated wall run sits a few millimetres to a few centimetres from its copy — that distance is coordinate noise, not a measurement. A wall drawn as two faces sits at the wall’s actual thickness, which is 75 mm at the very thinnest and usually 100 to 300 mm. Setting the separation threshold at roughly three times the snapping tolerance puts it comfortably in the gap between those two populations. If your portfolio genuinely contains partitions thinner than the noise in its drawings, the drawings are not accurate enough to derive geometry from and the problem is upstream.

Should duplicates be merged or just flagged?

Merged, but with the merge recorded. Leaving them in place means the routing graph builds parallel node chains that fragment the floor, so a flag alone does not solve the problem it identifies. What makes merging safe is that the survivor carries both source entity handles and the merge appears in the level’s cleanup report, so a wall that turns out to be wrong can be traced back to both originals. The one case for flagging without merging is a first run against a new portfolio, where the counts tell you whether the thresholds are right before you let the pass change anything.

Does this replace layer filtering?

No — it complements it, and layer filtering should come first because it is both cheaper and more precise. If the duplicate wall runs come from a known redundant layer, excluding that layer at parse time removes them exactly, with no thresholds and no risk of a false merge. Duplicate detection exists for the cases layer filtering cannot reach: exploded blocks that landed on the same layer as their reference, copy-paste revisions within one layer, and portfolios where layer naming is too inconsistent to filter on at all.

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