Normalising XREF-Prefixed Layer Names in Python

A layer that arrived through a bound external reference carries a prefix naming the reference it came from, so A-WALL becomes something like SITE-PLAN$0$A-WALL. Strip the prefix iteratively — nesting produces several — upper-case the result, and keep the original name alongside it, because classification wants the stripped form and provenance wants the original. This page is part of Layer Mapping Logic.

How Binding Mangles a Layer Name

An external reference brings its own layer table with it. While the reference is merely attached, those layers live in the reference and cannot collide with the host’s. Binding brings them into the host drawing, and to keep them distinct the binding process rewrites each name with a prefix derived from the reference.

How prefixes stack through nested references One layer name at three depths of federation. Unbound, it is the drafted name. Bound once, it carries the reference name and a separator. Bound inside a reference that was itself bound, it carries both prefixes stacked. A normaliser that strips once handles the middle case and leaves the last one still prefixed. A-WALL as drafted, in the reference SITE$0$A-WALL bound once SITE$0$SERVICES$0$M-DUCT bound inside a bound reference SITE|C-ROAD-CNTR the other binding convention Strip in a loop, not once — the third line is what a single strip misses.

There are two binding styles and they produce different results. One inserts the reference’s contents and prefixes with the reference name and a separator; the other binds and produces a different separator with an index. A drawing that has been through several rounds of federation can carry both forms, and a reference bound inside a reference produces a name with two prefixes stacked.

The consequence for a mapping pipeline is that a rule table written against clean layer names matches nothing on a federated drawing, and the layers all land in the unmapped bucket. The volume makes it obvious; the cause does not, because the layer names look plausible.

Production-Ready Script

# ezdxf>=1.1.0, Python 3.9+
from __future__ import annotations

import re
from collections import Counter
from dataclasses import dataclass

import ezdxf

# Both binding conventions, plus the nested-reference form.
XREF_PREFIX = re.compile(r"^[^|$]+(?:\$\d+\$|\|)")


@dataclass(frozen=True)
class LayerName:
    original: str          # exactly as stored — provenance
    normalised: str        # prefix-free, upper case — classification
    xref_chain: tuple[str, ...]   # outermost first

    @property
    def from_xref(self) -> bool:
        return bool(self.xref_chain)


def normalise(name: str) -> LayerName:
    """Strip stacked external-reference prefixes and upper-case the remainder."""
    remainder = name
    chain: list[str] = []
    while True:
        match = XREF_PREFIX.match(remainder)
        if not match:
            break
        prefix = match.group(0)
        chain.append(prefix.rstrip("$|0123456789"))
        remainder = remainder[len(prefix):]
        if not remainder:                  # a name that was ONLY a prefix
            remainder = name
            chain.clear()
            break
    return LayerName(original=name, normalised=remainder.upper(),
                     xref_chain=tuple(chain))


def normalise_document(dxf_path: str) -> dict[str, LayerName]:
    doc = ezdxf.readfile(dxf_path)
    return {layer.dxf.name: normalise(layer.dxf.name) for layer in doc.layers}


def summarise(names: dict[str, LayerName]) -> dict:
    depth = Counter(len(n.xref_chain) for n in names.values())
    collisions = Counter(n.normalised for n in names.values())
    return {
        "layers": len(names),
        "from_xref": sum(1 for n in names.values() if n.from_xref),
        "max_nesting": max(depth) if depth else 0,
        "normalised_collisions": {k: v for k, v in collisions.items() if v > 1},
    }


if __name__ == "__main__":
    names = normalise_document("federated.dxf")
    print(summarise(names))
Two things the caller needs from one name The normaliser returns a record rather than a string because two different consumers want two different answers. Classification wants the prefix-free upper-case form, since geometry means the same thing whichever drawing supplied it. Provenance wants the original and the reference chain, since which drawing supplied it is exactly what an audit asks. For classification — prefix-free — upper-cased — matched against the rule table — one rule per convention For provenance — the original string — the reference chain — answers "which drawing?" — never reconstructable later Return both, or the audit question becomes a re-read of every file.

Key implementation notes:

  • The loop strips repeatedly. A single strip handles one level and leaves a nested reference still prefixed, which is the bug this page is really about.
  • A name consisting only of a prefix restores the original rather than returning an empty string, so a malformed name never becomes a blank classification key.
  • normalised_collisions is reported because stripping prefixes deliberately merges layers that binding deliberately separated. Two references each contributing A-WALL collapse to one classification key, which is usually correct and occasionally not — either way it should be visible.
  • The original name is kept on every record. An audit asking which drawing a feature came from is answered from xref_chain without re-reading the file.

Compatibility Matrix

Component Supported range Notes
ezdxf >=1.1.0 doc.layers iteration
DXF revision R2000 – R2018 binding conventions unchanged across this range
Separator forms $n$ and ` `
Nesting depth unbounded the loop handles stacked prefixes
Case upper-cased output match rule tables in one form only

Fallback Strategies

1. Everything lands unmapped on a federated drawing. The signature of unstripped prefixes. Check from_xref counts before suspecting the rule table.

What to do when stripping merges two layers A branch on what a normalised collision means. Binding deliberately separated two layers that were drafted with the same name, and stripping the prefix merges them again. Usually that is right — the same convention meant the same thing in both drawings. Where it is not, the classification key becomes the pair of reference chain and name rather than the name alone. Two references, same normalised name Merge usually correct same meaning Key on chain + name keep them apart different meaning

2. A reference name contains the separator. The pattern strips too much. Constrain it with the set of reference names actually present in the document rather than by pattern alone.

3. Normalised names collide. Two references contributed the same layer name. Decide whether they should merge; where they should not, key the classification on the pair of chain and name rather than on the name alone.

4. Layer 0 inside a reference. Entities on layer 0 inherit properties from the reference placement, so a normalised 0 is not the same thing as a host 0. Handle it as a distinct case rather than mapping it.

5. Names differing only in case. CAD layer names are case-insensitive but stored with case, so A-Wall and A-WALL are one layer conceptually and two strings. Upper-casing before matching, as above, is what makes the rule table small.

6. Non-ASCII characters in reference names. Project directories in languages other than English produce reference names with accented or non-Latin characters, and those survive into the bound layer name. The pattern above is character-class based rather than alphabet based, so it handles them, but a rule table written with the unaccented spelling will not match. Normalise Unicode to a single composition form before comparing, or two visually identical names remain two distinct strings.

Validating the Normaliser

Because the normaliser is a pure function from string to record, it is unusually cheap to test, and the cases worth committing are the ones a real federation produced:

# pytest
CASES = [
    ("A-WALL",                     "A-WALL",      0),
    ("SITE$0$A-WALL",              "A-WALL",      1),
    ("SITE$0$SERVICES$0$M-DUCT",   "M-DUCT",      2),
    ("SITE|C-ROAD-CNTR",           "C-ROAD-CNTR", 1),
    ("a-wall",                     "A-WALL",      0),
]

def test_normalise():
    for raw, expected, depth in CASES:
        result = normalise(raw)
        assert result.normalised == expected, raw
        assert len(result.xref_chain) == depth, raw
        assert result.original == raw          # provenance is never lost

Add a case each time an unexpected layer name appears in a delivery, whatever the outcome. Over a few projects the list becomes a description of what the offices you work with actually produce, which is more useful than the pattern itself when a convention changes.

FAQ

Why do bound XREF layers have prefixes at all?

To keep them unique. When an external reference is bound into a host drawing its layers have to coexist with the host layers, so the binding process prefixes each one with the reference name. Without the prefix, a layer called A-WALL in the reference would silently merge with the host layer of the same name.

Is the separator always the same?

No, and that is the practical problem. Insert-binding uses one separator and full binding uses another, and reference names themselves can contain characters that look like separators. Detect the separator from the set of names present rather than hard-coding one, and handle both forms.

Should I strip the prefix at all?

For classification, yes — the geometry means the same thing whichever drawing it came from. For provenance, no: which reference a layer came from is often exactly what an audit needs. Keep both, which is why the normaliser returns a record rather than a string.