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