Resolving Nested Block References with ezdxf

An INSERT entity places a named block definition with a scale, a rotation and an insertion point, and blocks routinely contain further INSERTs. Flattening them means recursing, composing the placement transforms as you descend, guarding against circular definitions, and keeping the chain of block names so a resolved entity can be traced back. A single-level expansion leaves the nested geometry sitting at the wrong place, at the wrong size, silently. This page is part of DXF Entity Structure Breakdown.

How Placement Composes

Each INSERT carries four things that place its block: an insertion point, X, Y and Z scale factors, a rotation about the Z axis, and an extrusion vector defining the plane it all happens in. Together they form a transform from the block’s own coordinate space into the space of whatever contains the INSERT.

Each level multiplies into the placement The chain from modelspace down to a piece of geometry three blocks deep. Each insert contributes a transform from its block space into its container space, and the geometry world position is the product of every transform on the path. Expanding one level leaves the deeper geometry placed in the wrong space, at the wrong size, without any error. Modelspace world space Block A A-space Block B B-space Placed entity product of all three INSERT INSERT geometry

For an INSERT in modelspace, that containing space is the world. For an INSERT inside a block definition, it is the parent block’s space — so the geometry’s world position is the product of the transforms of every INSERT on the path from modelspace down to it. The order matters: composing parent-then-child gives a different result from child-then-parent, and both produce geometry that looks plausible.

The library’s virtual-entity expansion does one level of this correctly. Ask a modelspace INSERT for its virtual entities and you get its block’s contents transformed into world space — including any nested INSERT, itself correctly placed, still unexpanded. Recursion is the caller’s job, and so is the cycle guard: a block that references itself, directly or through a chain, is invalid and does occur in files that have been through several rounds of editing.

Production-Ready Script

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

from dataclasses import dataclass
from typing import Iterator

import ezdxf
from ezdxf.entities import Insert


class CircularBlockError(ValueError):
    pass


@dataclass(frozen=True)
class PlacedEntity:
    entity: object                  # the transformed DXF entity
    block_path: tuple[str, ...]     # outermost block first; empty for modelspace
    depth: int


def flatten(layout, *, max_depth: int = 64) -> Iterator[PlacedEntity]:
    """Yield every entity in world coordinates, expanding nested INSERTs."""
    yield from _walk(layout, path=(), depth=0, max_depth=max_depth)


def _walk(container, *, path: tuple[str, ...], depth: int, max_depth: int
          ) -> Iterator[PlacedEntity]:
    if depth > max_depth:
        raise CircularBlockError(
            f"block nesting exceeded {max_depth} via {' > '.join(path)}"
        )
    for entity in container:
        if isinstance(entity, Insert):
            name = entity.dxf.name
            if name in path:
                raise CircularBlockError(
                    f"block {name!r} references itself via {' > '.join(path)}"
                )
            # virtual_entities() applies THIS insert's transform to the block
            # contents; nested inserts come back placed but unexpanded.
            yield from _walk(entity.virtual_entities(),
                             path=path + (name,), depth=depth + 1, max_depth=max_depth)
        else:
            yield PlacedEntity(entity=entity, block_path=path, depth=depth)


def flatten_document(dxf_path: str) -> list[PlacedEntity]:
    doc = ezdxf.readfile(dxf_path)
    return list(flatten(doc.modelspace()))


def summarise(placed: list[PlacedEntity]) -> dict:
    from collections import Counter
    by_depth = Counter(p.depth for p in placed)
    by_type = Counter(p.entity.dxftype() for p in placed)
    return {
        "entities": len(placed),
        "max_depth": max(by_depth) if by_depth else 0,
        "at_each_depth": dict(sorted(by_depth.items())),
        "top_types": dict(by_type.most_common(5)),
    }


if __name__ == "__main__":
    placed = flatten_document("federated.dxf")
    print(summarise(placed))
    deep = [p for p in placed if p.depth >= 2]
    if deep:
        print("example nested:", deep[0].entity.dxftype(), "via", " > ".join(deep[0].block_path))
The path test, not a visited set A branch on whether a block name already appears on the current recursion path. A block legitimately used twice in different branches must be expanded twice, so a global visited set would silently drop the second use. Only membership in the current path distinguishes reuse from a genuine cycle. Block name already on this path? Descend expand it no Circular name it and stop yes

Key implementation notes:

  • The cycle guard tests membership in the current path, not in a global visited set. A block legitimately used twice in different branches is fine; a block that contains itself is not, and only the path distinguishes them.
  • The depth limit is a second backstop with a different message, so an unusual-but-legal deep nesting is distinguishable from a cycle.
  • virtual_entities() does the transform composition, which is why none appears explicitly here — reimplementing the matrix arithmetic is a common and unnecessary source of placement bugs.
  • block_path travels with every entity. When a feature turns out to be in the wrong place, the path names the block to look at.
  • The summary reports entities per depth. A drawing where everything sits at depth 0 has no nesting, and one where most entities are at depth 3 explains why a single-level expansion looked empty.

Compatibility Matrix

Component Supported range Notes
ezdxf >=1.1.0 virtual_entities on INSERT
DXF revision R12 – R2018 block table present throughout
Nesting depth bounded by max_depth 64 is generous for real drawings
Non-uniform scale supported mirrored inserts have a negative scale
External references expanded when bound unbound references have empty definitions

Fallback Strategies

1. Nothing comes back for a block. Its definition is empty, which is what an unresolved external reference looks like. Check whether the reference was bound before treating it as a data problem.

Entity count by nesting depth on a federated drawing How many entities sit at each level of block nesting in one federated drawing. Most of the geometry is two levels down, which is why a single-level expansion returned almost nothing and looked like an empty drawing. The profile is worth reporting because it explains the result before anyone starts debugging the query. depth 0 — modelspace 412 entities depth 1 3,180 entities depth 2 21,470 entities depth 3 640 entities 0 21,470 A one-level expansion here returns under two per cent of the geometry.

2. Geometry is mirrored. A negative scale factor on an INSERT is a legitimate mirror. It propagates correctly through the transform; what it also does is reverse ring winding, so downstream polygon orientation needs normalising.

3. Runaway recursion. The cycle guard names the block. Repair the drawing rather than raising the depth limit.

4. Attributes are missing after flattening. Flattening yields geometry; attribute values live on the INSERT itself, which the walk descends past. Collect attributes at the INSERT before recursing, as the block attribute extraction guide describes.

5. Memory grows on a large drawing. flatten is a generator; the list materialisation in flatten_document is what allocates. Consume the generator directly for a large file.

FAQ

Does virtual_entities handle nesting?

It expands one level. A block containing another block reference yields that nested INSERT as one of its virtual entities, already transformed, but not the geometry inside it. For a fully flattened result you have to recurse on any INSERT that comes back, composing the transform as you go.

What order do the placement components apply in?

Scale, then rotation, then translation — and the scales are applied about the block base point rather than about the world origin. Composing them in a different order produces geometry that is the right shape in the wrong place, which looks like a coordinate system problem rather than a matrix one.

How do I detect a circular block definition?

Track the block names on the current recursion path in a set and refuse to enter one already on it. A depth limit alone is a blunter instrument: it stops the runaway but reports a depth error rather than naming the cycle, and legitimate deep nesting then looks like corruption.