Skip to content

Geometry Builders

Geometry utility functions for EnergyPlus surface manipulation. For creating building zones and surfaces, see Zoning.

Quick Start

from idfkit import new_document
from idfkit.geometry_builders import add_shading_block

doc = new_document()
add_shading_block(doc, "Neighbour", [(30, 0), (50, 0), (50, 20), (30, 20)], height=25)

print(len(doc["Shading:Site:Detailed"]))  # 5

Shading Blocks

add_shading_block creates Shading:Site:Detailed surfaces -- opaque boxes that cast shadows but have no thermal zones.

from idfkit import new_document
from idfkit.geometry_builders import add_shading_block

doc = new_document()

# Neighbouring building
add_shading_block(doc, "Neighbour", [(30, 0), (50, 0), (50, 20), (30, 20)], height=25)

# Elevated canopy
add_shading_block(doc, "Canopy", [(0, -3), (10, -3), (10, 0), (0, 0)], height=0.2, base_z=3)

Each call creates one wall surface per footprint edge plus a horizontal top cap.

GlobalGeometryRules Convention

All builder functions read the document's GlobalGeometryRules to determine the vertex ordering convention:

  • starting_vertex_position -- which corner is listed first for walls (UpperLeftCorner, LowerLeftCorner, etc.)
  • vertex_entry_direction -- winding direction (Counterclockwise or Clockwise)

new_document() pre-seeds GlobalGeometryRules with UpperLeftCorner / Counterclockwise defaults. If a model is missing GlobalGeometryRules (for example, some legacy inputs), the same EnergyPlus defaults are assumed.

This means you can safely add geometry to an existing model that uses a non-default convention without having to rewrite all existing surfaces:

from idfkit import load_idf, create_block

# Model uses Clockwise vertex convention
model = load_idf("existing_building.idf")

# New surfaces will automatically use Clockwise ordering
# to match the model's GlobalGeometryRules
create_block(model, "Addition", [(20, 0), (30, 0), (30, 10), (20, 10)], floor_to_floor=3)

Wall Vertex Order by Convention

For a wall between footprint vertices p1 and p2 (height z_bot to z_top), viewed from outside:

Starting Position Counterclockwise Clockwise
UpperLeftCorner UL LL LR UR UL UR LR LL
LowerLeftCorner LL LR UR UL LL UL UR LR
LowerRightCorner LR UR UL LL LR LL UL UR
UpperRightCorner UR UL LL LR UR LR LL UL

Where UL = (p1, z_top), LL = (p1, z_bot), LR = (p2, z_bot), UR = (p2, z_top).

Horizontal Surfaces

For floors and ceilings, the winding direction is adapted so that EnergyPlus computes the correct outward normal regardless of convention:

  • Floor: outward normal points down (toward ground)
  • Ceiling / Roof: outward normal points up (toward sky)

Utility Functions

set_default_constructions

Assigns a placeholder construction name to any surface that lacks one:

from idfkit.geometry_builders import set_default_constructions

count = set_default_constructions(doc, "Generic Wall")
print(f"Updated {count} surfaces")

bounding_box

Returns the 2D axis-aligned bounding box of all BuildingSurface:Detailed objects:

from idfkit.geometry_builders import bounding_box

bbox = bounding_box(doc)
if bbox:
    (min_x, min_y), (max_x, max_y) = bbox
    print(f"Footprint spans {max_x - min_x:.1f} x {max_y - min_y:.1f} m")

scale_building

Scales all surface vertices around an anchor point:

from idfkit.geometry_builders import scale_building
from idfkit.geometry import Vector3D

# Double the building in all directions
scale_building(doc, 2.0)

# Stretch only the X axis
scale_building(doc, (1.5, 1.0, 1.0))

# Scale around the building centroid
scale_building(doc, 0.5, anchor=Vector3D(15, 10, 0))

Horizontal Adjacency Detection

When building models with stacked blocks (e.g. setback towers), roof and floor surfaces at shared elevations need to be detected, split, and linked. The high-level link_blocks handles this automatically, but you can use the lower-level API for custom geometry workflows.

Detecting Adjacencies

detect_horizontal_adjacencies scans all BuildingSurface:Detailed surfaces for horizontal Roof/Floor pairs at the same z-elevation with Outdoors boundary condition, and computes their 2-D polygon intersection:

from idfkit.geometry_builders import detect_horizontal_adjacencies

adjacencies = detect_horizontal_adjacencies(doc)
for adj in adjacencies:
    print(f"Roof '{adj.roof_surface.name}' overlaps floor "
          f"'{adj.floor_surface.name}' at z={adj.z} "
          f"({adj.intersection_area:.1f} m²)")

Each HorizontalAdjacency record contains the roof surface, floor surface, z-elevation, 2-D intersection polygon, and intersection area.

Splitting Surfaces

split_horizontal_surface creates a new surface for a 2-D region within an existing horizontal surface. The original surface is shrunk to the remaining area:

from idfkit.geometry_builders import split_horizontal_surface

new_surface, remaining = split_horizontal_surface(doc, adj.roof_surface, adj.intersection)
# new_surface covers the intersection region
# remaining is the original surface, now covering only the exposed area

Linking Surfaces

link_horizontal_surfaces sets mutual Surface boundary conditions between a ceiling and floor:

from idfkit.geometry_builders import link_horizontal_surfaces

link_horizontal_surfaces(new_surface, adj.floor_surface)
# new_surface is now a Ceiling pointing at the floor, and vice versa

Full Example

from idfkit.geometry_builders import (
    detect_horizontal_adjacencies,
    link_horizontal_surfaces,
    split_horizontal_surface,
)

adjacencies = detect_horizontal_adjacencies(doc)
for adj in adjacencies:
    new_ceiling, _ = split_horizontal_surface(doc, adj.roof_surface, adj.intersection)
    link_horizontal_surfaces(new_ceiling, adj.floor_surface)

API Reference

Geometry utility functions for EnergyPlus surface manipulation.

Provides shading block creation, default construction assignment, bounding box queries, building scaling, horizontal adjacency detection, surface splitting, and GlobalGeometryRules vertex-ordering helpers.

For building zone and surface creation, see zoning which provides create_block and ZonedBlock.

HorizontalAdjacency dataclass

A detected adjacency between a roof and floor surface at the same elevation.

Attributes:

Name Type Description
roof_surface IDFObject

The roof surface from the lower block.

floor_surface IDFObject

The floor surface from the upper block.

z float

The shared elevation in metres.

intersection list[tuple[float, float]]

2-D polygon of the overlapping area.

intersection_area float

Area of the overlap in square metres.

Source code in src/idfkit/geometry_builders.py
@dataclass(frozen=True)
class HorizontalAdjacency:
    """A detected adjacency between a roof and floor surface at the same elevation.

    Attributes:
        roof_surface: The roof surface from the lower block.
        floor_surface: The floor surface from the upper block.
        z: The shared elevation in metres.
        intersection: 2-D polygon of the overlapping area.
        intersection_area: Area of the overlap in square metres.
    """

    roof_surface: IDFObject
    floor_surface: IDFObject
    z: float
    intersection: list[tuple[float, float]]
    intersection_area: float

add_shading_block(doc, name, footprint, height, base_z=0.0)

Create Shading:Site:Detailed surfaces from a 2D footprint.

Creates one shading surface per footprint edge (walls) plus a horizontal top cap. No zones or thermal surfaces are created.

Parameters:

Name Type Description Default
doc IDFDocument

The document to add objects to.

required
name str

Base name for shading surfaces.

required
footprint Sequence[tuple[float, float]]

2D footprint as (x, y) tuples (counter-clockwise).

required
height float

Height of the shading block in metres.

required
base_z float

Z-coordinate of the block base (default 0.0). Use this to create elevated shading surfaces such as canopies.

0.0

Returns:

Type Description
list[IDFObject]

List of created Shading:Site:Detailed objects.

Raises:

Type Description
ValueError

If footprint has fewer than 3 vertices or height <= 0.

Source code in src/idfkit/geometry_builders.py
def add_shading_block(
    doc: IDFDocument,
    name: str,
    footprint: Sequence[tuple[float, float]],
    height: float,
    base_z: float = 0.0,
) -> list[IDFObject]:
    """Create ``Shading:Site:Detailed`` surfaces from a 2D footprint.

    Creates one shading surface per footprint edge (walls) plus a
    horizontal top cap.  No zones or thermal surfaces are created.

    Args:
        doc: The document to add objects to.
        name: Base name for shading surfaces.
        footprint: 2D footprint as ``(x, y)`` tuples (counter-clockwise).
        height: Height of the shading block in metres.
        base_z: Z-coordinate of the block base (default ``0.0``).
            Use this to create elevated shading surfaces such as canopies.

    Returns:
        List of created ``Shading:Site:Detailed`` objects.

    Raises:
        ValueError: If footprint has fewer than 3 vertices or height <= 0.
    """
    fp = list(footprint)
    if len(fp) < 3:
        msg = f"Footprint must have at least 3 vertices, got {len(fp)}"
        raise ValueError(msg)
    if height <= 0:
        msg = f"Height must be positive, got {height}"
        raise ValueError(msg)

    svp, clockwise = get_geometry_convention(doc)
    wall_order = WALL_ORDER.get((svp, clockwise), (0, 1, 2, 3))

    z_bot = base_z
    z_top = base_z + height
    created: list[IDFObject] = []
    n = len(fp)

    # Walls
    for j in range(n):
        p1 = fp[j]
        p2 = fp[(j + 1) % n]
        wall_name = f"{name} Wall {j + 1}"
        corners = [
            Vector3D(p1[0], p1[1], z_top),  # UL
            Vector3D(p1[0], p1[1], z_bot),  # LL
            Vector3D(p2[0], p2[1], z_bot),  # LR
            Vector3D(p2[0], p2[1], z_top),  # UR
        ]
        poly = Polygon3D([corners[k] for k in wall_order])
        obj = doc.add("Shading:Site:Detailed", wall_name, validate=False)
        set_surface_coords(obj, poly)
        created.append(obj)

    # Top cap — horizontal surface with normal pointing up
    cap_name = f"{name} Top"
    cap = doc.add("Shading:Site:Detailed", cap_name, validate=False)
    set_surface_coords(cap, horizontal_poly(fp, z_top, reverse=clockwise))
    created.append(cap)

    return created

bounding_box(doc)

Return the 2D axis-aligned bounding box of all building surfaces.

Scans all BuildingSurface:Detailed vertices and returns the bounding envelope projected onto the XY plane.

Note

Only BuildingSurface:Detailed objects are considered. Fenestration and shading surfaces are excluded because they are either coplanar with (windows) or outside (shading) the thermal envelope.

Returns:

Type Description
tuple[tuple[float, float], tuple[float, float]] | None

((min_x, min_y), (max_x, max_y)) or None if no

tuple[tuple[float, float], tuple[float, float]] | None

surfaces with valid coordinates exist.

Source code in src/idfkit/geometry_builders.py
def bounding_box(doc: IDFDocument) -> tuple[tuple[float, float], tuple[float, float]] | None:
    """Return the 2D axis-aligned bounding box of all building surfaces.

    Scans all ``BuildingSurface:Detailed`` vertices and returns the
    bounding envelope projected onto the XY plane.

    !!! note
        Only ``BuildingSurface:Detailed`` objects are considered.
        Fenestration and shading surfaces are excluded because they
        are either coplanar with (windows) or outside (shading) the
        thermal envelope.

    Returns:
        ``((min_x, min_y), (max_x, max_y))`` or ``None`` if no
        surfaces with valid coordinates exist.
    """
    min_x = float("inf")
    min_y = float("inf")
    max_x = float("-inf")
    max_y = float("-inf")
    found = False

    for srf in doc["BuildingSurface:Detailed"]:
        coords = get_surface_coords(srf)
        if coords is None:
            continue
        for v in coords.vertices:
            min_x = min(min_x, v.x)
            min_y = min(min_y, v.y)
            max_x = max(max_x, v.x)
            max_y = max(max_y, v.y)
            found = True

    if not found:
        return None
    return ((min_x, min_y), (max_x, max_y))

detect_horizontal_adjacencies(doc)

Find roof/floor surface pairs at matching elevations.

Scans all BuildingSurface:Detailed surfaces for horizontal Roof surfaces with Outdoors boundary condition and horizontal Floor surfaces with Outdoors boundary condition at the same elevation.

For each overlapping pair the 2-D polygon intersection is computed.

Parameters:

Name Type Description Default
doc IDFDocument

The document to scan.

required

Returns:

Type Description
list[HorizontalAdjacency]

List of :class:HorizontalAdjacency records for each detected

list[HorizontalAdjacency]

overlap.

Source code in src/idfkit/geometry_builders.py
def detect_horizontal_adjacencies(
    doc: IDFDocument,
) -> list[HorizontalAdjacency]:
    """Find roof/floor surface pairs at matching elevations.

    Scans all ``BuildingSurface:Detailed`` surfaces for horizontal Roof
    surfaces with ``Outdoors`` boundary condition and horizontal Floor
    surfaces with ``Outdoors`` boundary condition at the same elevation.

    For each overlapping pair the 2-D polygon intersection is computed.

    Args:
        doc: The document to scan.

    Returns:
        List of :class:`HorizontalAdjacency` records for each detected
        overlap.
    """
    roofs, floors = _collect_outdoor_horizontal_surfaces(doc)

    adjacencies: list[HorizontalAdjacency] = []
    for z_key, roof_list in roofs.items():
        floor_list = floors.get(z_key)
        if floor_list is None:
            continue
        for roof_srf, roof_fp in roof_list:
            for floor_srf, floor_fp in floor_list:
                inter = polygon_intersection_2d(roof_fp, floor_fp)
                if inter is None:
                    continue
                area = abs(polygon_area_2d(inter))
                if area < 0.01:
                    continue
                adjacencies.append(
                    HorizontalAdjacency(
                        roof_surface=roof_srf,
                        floor_surface=floor_srf,
                        z=z_key,
                        intersection=inter,
                        intersection_area=area,
                    )
                )
    return adjacencies

get_geometry_convention(doc)

Read the vertex ordering convention from GlobalGeometryRules.

Returns:

Type Description
str

(starting_vertex_position, clockwise) where clockwise is

bool

True when vertex_entry_direction is "Clockwise".

tuple[str, bool]

Defaults to ("UpperLeftCorner", False) if no rules exist.

Source code in src/idfkit/geometry_builders.py
def get_geometry_convention(doc: IDFDocument) -> tuple[str, bool]:
    """Read the vertex ordering convention from ``GlobalGeometryRules``.

    Returns:
        ``(starting_vertex_position, clockwise)`` where *clockwise* is
        ``True`` when ``vertex_entry_direction`` is ``"Clockwise"``.
        Defaults to ``("UpperLeftCorner", False)`` if no rules exist.
    """
    geo_rules = doc["GlobalGeometryRules"]
    if not geo_rules:
        return ("UpperLeftCorner", False)
    rules = geo_rules.first()
    if rules is None:
        return ("UpperLeftCorner", False)
    svp = getattr(rules, "starting_vertex_position", None) or "UpperLeftCorner"
    ved = getattr(rules, "vertex_entry_direction", None) or "Counterclockwise"
    return (str(svp), str(ved).lower() == "clockwise")

horizontal_poly(footprint, z, *, reverse)

Build a horizontal polygon at height z.

When reverse is True the footprint is reversed, flipping the polygon normal. Used to produce floor and ceiling polygons in the correct winding for the active GlobalGeometryRules convention.

Source code in src/idfkit/geometry_builders.py
def horizontal_poly(footprint: list[tuple[float, float]], z: float, *, reverse: bool) -> Polygon3D:
    """Build a horizontal polygon at height *z*.

    When *reverse* is ``True`` the footprint is reversed, flipping the
    polygon normal.  Used to produce floor and ceiling polygons in the
    correct winding for the active ``GlobalGeometryRules`` convention.
    """
    pts = reversed(footprint) if reverse else footprint
    return Polygon3D([Vector3D(p[0], p[1], z) for p in pts])

Set mutual Surface boundary conditions between ceiling and floor.

Modifies both surfaces in-place:

  • The ceiling's surface_type is set to Ceiling.
  • Both surfaces get outside_boundary_condition = "Surface" pointing at each other, with sun/wind exposure set to NoSun / NoWind.

Parameters:

Name Type Description Default
ceiling IDFObject

The surface to designate as the ceiling.

required
floor IDFObject

The surface to designate as the floor.

required
Source code in src/idfkit/geometry_builders.py
def link_horizontal_surfaces(ceiling: IDFObject, floor: IDFObject) -> None:
    """Set mutual ``Surface`` boundary conditions between ceiling and floor.

    Modifies both surfaces in-place:

    * The ceiling's ``surface_type`` is set to ``Ceiling``.
    * Both surfaces get ``outside_boundary_condition = "Surface"``
      pointing at each other, with sun/wind exposure set to ``NoSun``
      / ``NoWind``.

    Args:
        ceiling: The surface to designate as the ceiling.
        floor: The surface to designate as the floor.
    """
    ceiling.surface_type = "Ceiling"
    ceiling.outside_boundary_condition = "Surface"
    ceiling.outside_boundary_condition_object = floor.name
    ceiling.sun_exposure = "NoSun"
    ceiling.wind_exposure = "NoWind"

    floor.outside_boundary_condition = "Surface"
    floor.outside_boundary_condition_object = ceiling.name
    floor.sun_exposure = "NoSun"
    floor.wind_exposure = "NoWind"

scale_building(doc, factor, anchor=None)

Scale all surface vertices around an anchor point.

Parameters:

Name Type Description Default
doc IDFDocument

The document to modify in-place.

required
factor float | tuple[float, float, float]

Scale factor. A single float applies uniform scaling; a (fx, fy, fz) tuple scales each axis independently (e.g. (2.0, 1.0, 1.0) doubles X only).

required
anchor Vector3D | None

Point to scale around. If None, the origin (0, 0, 0) is used.

None
Source code in src/idfkit/geometry_builders.py
def scale_building(
    doc: IDFDocument,
    factor: float | tuple[float, float, float],
    anchor: Vector3D | None = None,
) -> None:
    """Scale all surface vertices around an anchor point.

    Args:
        doc: The document to modify in-place.
        factor: Scale factor.  A single ``float`` applies uniform scaling;
            a ``(fx, fy, fz)`` tuple scales each axis independently
            (e.g. ``(2.0, 1.0, 1.0)`` doubles X only).
        anchor: Point to scale around.  If ``None``, the origin
            ``(0, 0, 0)`` is used.
    """
    if isinstance(factor, tuple):
        fx, fy, fz = factor
    else:
        fx = fy = fz = factor

    ax, ay, az = (anchor.x, anchor.y, anchor.z) if anchor else (0.0, 0.0, 0.0)

    for stype in VERTEX_SURFACE_TYPES:
        for srf in doc.get_collection(stype):
            coords = get_surface_coords(srf)
            if coords is None:
                continue
            new_vertices = [
                Vector3D(
                    ax + (v.x - ax) * fx,
                    ay + (v.y - ay) * fy,
                    az + (v.z - az) * fz,
                )
                for v in coords.vertices
            ]
            set_surface_coords(srf, Polygon3D(new_vertices))

set_default_constructions(doc, construction_name='Default Construction')

Assign a placeholder construction to surfaces that lack one.

Iterates all BuildingSurface:Detailed and FenestrationSurface:Detailed objects and sets construction_name for any whose current value is empty or None.

Does not create the Construction object itself — the caller is responsible for ensuring it exists.

Parameters:

Name Type Description Default
doc IDFDocument

The document to modify.

required
construction_name str

Name of the construction to assign.

'Default Construction'

Returns:

Type Description
int

Number of surfaces updated.

Source code in src/idfkit/geometry_builders.py
def set_default_constructions(doc: IDFDocument, construction_name: str = "Default Construction") -> int:
    """Assign a placeholder construction to surfaces that lack one.

    Iterates all ``BuildingSurface:Detailed`` and
    ``FenestrationSurface:Detailed`` objects and sets
    ``construction_name`` for any whose current value is empty or ``None``.

    Does **not** create the ``Construction`` object itself — the caller
    is responsible for ensuring it exists.

    Args:
        doc: The document to modify.
        construction_name: Name of the construction to assign.

    Returns:
        Number of surfaces updated.
    """
    count = 0
    for stype in ("BuildingSurface:Detailed", "FenestrationSurface:Detailed"):
        for srf in doc.get_collection(stype):
            if not srf.get("Construction Name"):
                srf.construction_name = construction_name
                count += 1
    return count

split_horizontal_surface(doc, surface, region)

Split a horizontal surface at a 2-D region boundary.

A new surface is created for the area inside the region. The original surface is shrunk to the area outside the region (the remaining frame).

Parameters:

Name Type Description Default
doc IDFDocument

The document containing the surface.

required
surface IDFObject

Horizontal surface to split (floor, ceiling, or roof).

required
region Sequence[tuple[float, float]]

2-D polygon defining the split region.

required

Returns:

Type Description
IDFObject

(new_region_surface, remaining_surface).

IDFObject | None

remaining_surface is None if the region covers the entire

tuple[IDFObject, IDFObject | None]

surface (i.e. no remaining area).

Source code in src/idfkit/geometry_builders.py
def split_horizontal_surface(
    doc: IDFDocument,
    surface: IDFObject,
    region: Sequence[tuple[float, float]],
) -> tuple[IDFObject, IDFObject | None]:
    """Split a horizontal surface at a 2-D region boundary.

    A new surface is created for the area *inside* the region.  The
    original surface is shrunk to the area *outside* the region (the
    remaining frame).

    Args:
        doc: The document containing the surface.
        surface: Horizontal surface to split (floor, ceiling, or roof).
        region: 2-D polygon defining the split region.

    Returns:
        ``(new_region_surface, remaining_surface)``.
        *remaining_surface* is ``None`` if the region covers the entire
        surface (i.e. no remaining area).
    """
    result = _extract_horizontal_footprint(surface)
    if result is None:
        msg = f"Surface '{surface.name}' has no coordinates"
        raise ValueError(msg)
    z, footprint = result
    _, clockwise = get_geometry_convention(doc)

    # Compute intersection of footprint and region
    inter = polygon_intersection_2d(footprint, list(region))
    if inter is None:
        msg = f"Region does not overlap surface '{surface.name}'"
        raise ValueError(msg)

    inter_area = abs(polygon_area_2d(inter))
    surface_area = abs(polygon_area_2d(footprint))

    # Determine surface type for winding
    st = (getattr(surface, "surface_type", "") or "").upper()
    # For roofs/ceilings, winding is clockwise; for floors, not clockwise
    reverse = clockwise if st in ("ROOF", "CEILING") else not clockwise

    # Create new surface for the intersection region
    zone_name: str = getattr(surface, "zone_name", "") or ""
    construction: str = getattr(surface, "construction_name", "") or ""
    new_name = f"{surface.name} Split"
    # Ensure unique name
    counter = 1
    while doc.getobject("BuildingSurface:Detailed", new_name) is not None:
        counter += 1
        new_name = f"{surface.name} Split {counter}"

    new_srf = doc.add(
        "BuildingSurface:Detailed",
        new_name,
        surface_type=getattr(surface, "surface_type", "") or "",
        construction_name=construction,
        zone_name=zone_name,
        outside_boundary_condition=getattr(surface, "outside_boundary_condition", "") or "",
        outside_boundary_condition_object=getattr(surface, "outside_boundary_condition_object", "") or "",
        sun_exposure=getattr(surface, "sun_exposure", "") or "",
        wind_exposure=getattr(surface, "wind_exposure", "") or "",
        validate=False,
    )
    set_surface_coords(new_srf, horizontal_poly(inter, z, reverse=reverse))

    # Check if the region covers the entire surface
    if abs(inter_area - surface_area) < 0.01:
        return new_srf, None

    # Compute remaining area (frame polygon)
    remaining = polygon_difference_2d(footprint, inter)

    if remaining is None or abs(polygon_area_2d(remaining)) < 0.01:
        return new_srf, None

    # Update original surface geometry to the remaining area
    set_surface_coords(surface, horizontal_poly(remaining, z, reverse=reverse))
    return new_srf, surface

See Also

  • Zoning -- create_block, link_blocks, core-perimeter zoning, footprint helpers, and multi-zone building generation
  • Geometry -- Lower-level 3D primitives, coordinate transforms, and surface intersection
  • Visualization -- 3D rendering of building geometry
  • Thermal -- R/U-value calculations for constructions