One of the more frustrating tasks when delivering projects to ISO 19650 or COBie standards is making sure every component is linked to the right room. The COBie.Component.Space parameter exists for exactly this reason, but in practice it can be messy. Some elements sit neatly inside a room and behave themselves, while others—like walls on boundaries, or ceilings just above a space—slip through the cracks. Without some kind of automation, you end up checking and editing these values manually, which is both slow and prone to mistakes.

I decided to tackle this problem in Revit 2019 using Dynamo. I’ll be upfront: I’m not a coder. I know some very basic Python, but nowhere near enough to write a complete solution from scratch. So instead I leaned on ChatGPT to help me. I described the problem, tested the code it suggested, and together we went back and forth until the script actually worked. This approach—sometimes called vibe coding—is less about being a programmer and more about knowing what you want to achieve and letting AI help build the scaffolding around it.

The end result is a Python script inside Dynamo that automatically assigns the nearest room number to each element and writes it into the COBie.Component.Space parameter. The logic is straightforward but powerful. First, it tries to find out if an element’s location point is contained in a room. If so, that room’s number is written directly to the parameter. If not, the script doesn’t stop there—it looks for the closest room boundary in plan and assigns that number instead. That way, even if an element technically sits outside or just on a line, it still gets a sensible space reference.

What I like about this workflow is that it works across multiple categories. I started with walls, floors and ceilings, but it can just as easily be applied to casework, furniture or anything else that needs to be tied to a room. The speed is impressive too—hundreds of elements can be processed in seconds—and it adds a layer of consistency that would be difficult to achieve manually. For quality control, anything the script can’t confidently assign gets marked as “unassigned,” making it easy to spot check and tidy up.

What this exercise proved to me is that you don’t need to be a software developer to create genuinely useful automation. By combining BIM knowledge with a bit of patience and ChatGPT’s ability to generate and refine code, I ended up with a practical solution that improves our COBie outputs and saves time. It’s not glamorous, but for anyone managing data in Revit, it’s exactly the kind of small win that adds up on real projects.

The code is below if you want to try it. Please note, you use this at your own risk. Always have backups saved before running.


# -*- coding: utf-8 -*-
# Dynamo (IronPython 2.7) for Revit 2019
# Input: elements (list of Walls, Floors, Ceilings – Dynamo elements)
# Writes nearest Room Number to parameter “COBie.Component.Space”

import clr

# Dynamo / Revit
clr.AddReference(‘RevitServices’)
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager

clr.AddReference(‘RevitAPI’)
from Autodesk.Revit.DB import (
    FilteredElementCollector, BuiltInCategory, SpatialElement,
    SpatialElementBoundaryOptions, XYZ
)

doc = DocumentManager.Instance.CurrentDBDocument

# —- Settings —-
TARGET_PARAM = “COBie.Component.Space”
UNASSIGNED = “”        # or “UNASSIGNED”
XY_ONLY = True         # measure distance in XY
MAX_LEVEL_GAP_FT = 30  # acceptable Z-gap when matching rooms by level

# —- Helpers —-
def tolist(x):
    return x if isinstance(x, list) else [x]

def get_midpoint_of_curve(curve):
    try:
        return curve.Evaluate(0.5, True)
    except:
        return None

def element_representative_point(el):
    # Try LocationPoint or LocationCurve midpoint
    try:
        loc = el.Location
    except:
        loc = None
    if loc:
        try:
            pt = loc.Point
            if pt: return pt
        except:
            pass
        try:
            crv = loc.Curve
            mp = get_midpoint_of_curve(crv)
            if mp: return mp
        except:
            pass
    # Fallback to bbox centre (view-independent)
    try:
        bb = el.get_BoundingBox(None)
        if bb:
            minp, maxp = bb.Min, bb.Max
            return XYZ((minp.X+maxp.X)/2.0, (minp.Y+maxp.Y)/2.0, (minp.Z+maxp.Z)/2.0)
    except:
        pass
    return None

def rooms_in_doc(document):
    return list(FilteredElementCollector(document).OfCategory(BuiltInCategory.OST_Rooms).WhereElementIsNotElementType())

def is_point_in_room(room, point):
    try:
        return room.IsPointInRoom(point)
    except:
        return False

def room_level_z(room):
    try:
        bb = room.get_BoundingBox(None)
        if bb: return (bb.Min.Z + bb.Max.Z) / 2.0
    except:
        pass
    return None

def distance_point_to_curve_2d(pt, crv):
    try:
        z = crv.GetEndPoint(0).Z
        ppt = XYZ(pt.X, pt.Y, z)
        res = crv.Project(ppt)
        if res:
            return (ppt – res.XYZPoint).GetLength()
    except:
        pass
    return None

def min_dist_point_to_room_boundaries(pt, room):
    try:
        sbo = SpatialElementBoundaryOptions()
        loops = room.GetBoundarySegments(sbo)
        mind = None
        for loop in loops:
            for seg in loop:
                crv = seg.GetCurve()
                d = distance_point_to_curve_2d(pt, crv) if XY_ONLY else (pt – crv.Project(pt).XYZPoint).GetLength()
                if d is None:
                    continue
                if mind is None or d < mind:
                    mind = d
        return mind
    except:
        return None

def nearest_room(pt, rms):
    # 1) direct containment
    for r in rms:
        if is_point_in_room(r, pt):
            return r, 0.0
    # 2) nearest boundary
    best_room, best_dist = None, None
    for r in rms:
        d = min_dist_point_to_room_boundaries(pt, r)
        if d is None:
            continue
        if best_dist is None or d < best_dist:
            best_dist = d
            best_room = r
    return best_room, best_dist

def index_rooms_by_level(rooms):
    buckets = {}
    for r in rooms:
        z = room_level_z(r)
        key = round(z, 1) if z is not None else None
        buckets.setdefault(key, []).append(r)
    return buckets

def candidate_rooms_for_point(pt, rooms_by_level):
    if not rooms_by_level:
        return []
    candidates = []
    for key_z, rms in rooms_by_level.items():
        if key_z is None:
            candidates.extend(rms)
        else:
            if abs(key_z – pt.Z) <= MAX_LEVEL_GAP_FT:
                candidates.extend(rms)
    if not candidates:
        for rms in rooms_by_level.values():
            candidates.extend(rms)
    return candidates

def set_param(el, name, val):
    p = el.LookupParameter(name)
    if p and not p.IsReadOnly:
        try:
            return p.Set(val)
        except:
            return False
    return False

# —- MAIN —-
# Unwrap Dynamo elements to raw Revit API Elements — this fixes your error
IN_elems = tolist(IN[0]) if len(IN) > 0 else []
elements = [UnwrapElement(e) for e in IN_elems if e]  # <– critical line

rooms = rooms_in_doc(doc)
rooms_by_level = index_rooms_by_level(rooms)

updated, missing_param, no_room = [], [], []

TransactionManager.Instance.EnsureInTransaction(doc)

for el in elements:
    if el is None:
        continue
    pt = element_representative_point(el)
    room_to_set = None
    if pt:
        rms = candidate_rooms_for_point(pt, rooms_by_level)
        room_to_set, _ = nearest_room(pt, rms)
    if room_to_set:
        ok = set_param(el, TARGET_PARAM, room_to_set.Number or UNASSIGNED)
        (updated if ok else missing_param).append(el.Id.IntegerValue)
    else:
        if set_param(el, TARGET_PARAM, UNASSIGNED):
            no_room.append(el.Id.IntegerValue)
        else:
            missing_param.append(el.Id.IntegerValue)

TransactionManager.Instance.TransactionTaskDone()

# — replace everything from OUT = {…} with the block below —
OUT = [
    [“Updated_ElementIds”, updated],
    [“NoRoom_ButParamSetBlank”, no_room],
    [“MissingOrReadOnlyParam_ElementIds”, missing_param],
    [“TotalProcessed”, len(elements)],
    [“TotalRooms”, len(rooms)]
]

Leave a comment

I’m William

But feel free to call me Willy. I qualified with a BSc (Hons) in Architectural Technology and worked as an Architectural Technologist for over 15 years before moving into BIM Information Management. Since 2015, I’ve been working with BIM and digital construction workflows, and in 2023 I stepped into my current role as a BIM Information Manager. I am also BRE ISO 19650-2 certified, reflecting my commitment to best-practice information management. On this blog, I share insights on BIM and Information Management, along with personal reflections on investing and balancing professional life with family.

Husband | Dad | Dog Owner | Curious Mind