Skip to content

Surface Matching

Robust intersect-and-split matching for coplanar, oppositely-facing interzone surfaces -- splits partially-overlapping surfaces into congruent matched fragments (interior Surface boundary conditions) plus exterior remainders.

Robust intersect-and-match for interzone surfaces.

Splits coplanar, oppositely-facing building surfaces that overlap into congruent matched fragments (interior Surface boundary conditions) plus remainder fragments (which keep their original, usually exterior, boundary). Unlike a simple congruent-pair matcher, this handles the one-to-many case: a single long wall shared with several smaller neighbouring zones is split into one matched fragment per neighbour, with any leftover left exterior.

The algorithm is dependency-free and convex-preserving:

  1. Cluster eligible surfaces by plane (anti-parallel normals share a cluster; tolerances absorb wall thickness).
  2. Project every surface (and its detailed fenestration) into a shared 2-D frame for the plane, snap-rounding coordinates so shared edges become exactly shared.
  3. For each host surface P facing neighbours M1..Mk, the matched fragments are the intersections P & Mj and the remainder is P minus the union of all Mj. Because intersection is symmetric, the fragment computed from P's side is congruent to the one from Mj's side, so both zones get matching surfaces with no ordering effects.
  4. Re-lift each 2-D fragment onto its own source surface's plane, so each zone's surface stays exactly where the modeller drew it (wall-thickness offsets are preserved). Winding falls out: the plus-side lifts to +N and its minus-side partner to -N -- the reversed-vertex pair EnergyPlus wants.
  5. Rewrite the document: the first fragment keeps the original surface's name (so inbound references survive untouched); the rest become new surfaces; detailed windows are re-homed onto the fragment that contains them.

The remainder is computed with a convex-cutter onion difference built entirely on half-plane clipping, so every output piece stays convex (EnergyPlus-friendly and window-safe). A concave cutter or subject (rare) is ear-clipped into triangles first.

MatchOptions dataclass

Tunable tolerances and scope for :func:intersect_and_match.

Attributes:

Name Type Description
angle_tol_deg float

Two surfaces are treated as coplanar when the angle between their normals (modulo sign) is within this tolerance. Defaults to ~8.1°, matching the dot <= -0.99 antiparallel tolerance of the previous intersect_match implementation.

max_thickness float

Maximum plane-offset difference (metres) tolerated when clustering surfaces onto one plane — absorbs construction thickness.

snap_tol float

2-D snap-rounding grid (metres). Projected coordinates are quantised to this grid so shared edges coincide exactly.

min_area float

Fragments below this area (m²) are dropped as slivers.

min_edge float

Fragments with any edge shorter than this (metres) are dropped as slivers.

surface_classes tuple[str, ...]

Uppercase surface_type values eligible for matching. Defaults to walls, floors, ceilings and roofs.

match_same_zone bool

When False (default), two surfaces belonging to the same zone are never matched to each other.

Source code in src/idfkit/surface_matching.py
@dataclass(frozen=True)
class MatchOptions:
    """Tunable tolerances and scope for :func:`intersect_and_match`.

    Attributes:
        angle_tol_deg: Two surfaces are treated as coplanar when the angle
            between their normals (modulo sign) is within this tolerance.
            Defaults to ~8.1°, matching the ``dot <= -0.99`` antiparallel
            tolerance of the previous ``intersect_match`` implementation.
        max_thickness: Maximum plane-offset difference (metres) tolerated when
            clustering surfaces onto one plane — absorbs construction thickness.
        snap_tol: 2-D snap-rounding grid (metres). Projected coordinates are
            quantised to this grid so shared edges coincide exactly.
        min_area: Fragments below this area (m²) are dropped as slivers.
        min_edge: Fragments with any edge shorter than this (metres) are
            dropped as slivers.
        surface_classes: Uppercase ``surface_type`` values eligible for
            matching. Defaults to walls, floors, ceilings and roofs.
        match_same_zone: When ``False`` (default), two surfaces belonging to the
            same zone are never matched to each other.
    """

    angle_tol_deg: float = 8.1
    max_thickness: float = 0.5
    snap_tol: float = 1e-4
    min_area: float = 1e-4
    min_edge: float = 1e-3
    surface_classes: tuple[str, ...] = ("WALL", "FLOOR", "CEILING", "ROOF")
    match_same_zone: bool = False

MatchReport dataclass

Summary of what :func:intersect_and_match changed.

Attributes:

Name Type Description
pairs_matched int

Number of matched interior fragment pairs created.

surfaces_split int

Number of original surfaces broken into >1 fragment.

fragments_created int

Number of new surfaces added (kept originals are not counted).

slivers_dropped int

Number of below-tolerance fragments discarded.

fenestration_conflicts list[str]

Names of surfaces left unsplit because a window/door straddled a cut line (never silently clipped).

unmatched_exteriors int

Number of eligible surfaces that ended up with no matched fragment (remained fully exterior).

Source code in src/idfkit/surface_matching.py
@dataclass
class MatchReport:
    """Summary of what :func:`intersect_and_match` changed.

    Attributes:
        pairs_matched: Number of matched interior fragment pairs created.
        surfaces_split: Number of original surfaces broken into >1 fragment.
        fragments_created: Number of *new* surfaces added (kept originals are
            not counted).
        slivers_dropped: Number of below-tolerance fragments discarded.
        fenestration_conflicts: Names of surfaces left unsplit because a
            window/door straddled a cut line (never silently clipped).
        unmatched_exteriors: Number of eligible surfaces that ended up with no
            matched fragment (remained fully exterior).
    """

    pairs_matched: int = 0
    surfaces_split: int = 0
    fragments_created: int = 0
    slivers_dropped: int = 0
    fenestration_conflicts: list[str] = field(default_factory=lambda: [])
    unmatched_exteriors: int = 0

intersect_and_match(doc, options=None)

Intersect and match coplanar interzone surfaces, splitting as needed.

Coplanar, oppositely-facing surfaces that overlap are split into congruent matched fragments (interior Surface boundary) plus exterior remainders. A single surface abutting several neighbours is split into one matched fragment per neighbour. The document is modified in place.

Parameters:

Name Type Description Default
doc IDFDocument

The document to modify.

required
options MatchOptions | None

Tolerances and scope; defaults to :class:MatchOptions.

None

Returns:

Name Type Description
A MatchReport

class:MatchReport describing what changed.

Examples:

>>> from idfkit import new_document
>>> doc = new_document(version=(24, 1, 0))
>>> report = intersect_and_match(doc)
>>> report.pairs_matched
0
Source code in src/idfkit/surface_matching.py
def intersect_and_match(doc: IDFDocument, options: MatchOptions | None = None) -> MatchReport:
    """Intersect and match coplanar interzone surfaces, splitting as needed.

    Coplanar, oppositely-facing surfaces that overlap are split into congruent
    matched fragments (interior ``Surface`` boundary) plus exterior remainders.
    A single surface abutting several neighbours is split into one matched
    fragment per neighbour. The document is modified in place.

    Args:
        doc: The document to modify.
        options: Tolerances and scope; defaults to :class:`MatchOptions`.

    Returns:
        A :class:`MatchReport` describing what changed.

    Examples:
        >>> from idfkit import new_document
        >>> doc = new_document(version=(24, 1, 0))
        >>> report = intersect_and_match(doc)
        >>> report.pairs_matched
        0
    """
    opts = options or MatchOptions()
    report = MatchReport()

    surfs = _collect_surfaces(doc, opts)
    clusters = _cluster_by_plane(surfs, opts)

    for cluster in clusters:
        _process_cluster(doc, cluster, opts, report)

    logger.debug(
        "intersect_and_match: %d pairs, %d split, %d new fragments, %d slivers, %d conflicts",
        report.pairs_matched,
        report.surfaces_split,
        report.fragments_created,
        report.slivers_dropped,
        len(report.fenestration_conflicts),
    )
    return report