One of the common frustrations when preparing COBie data is populating the NominalHeight, NominalLength, and NominalWidth fields. These are required in the COBie Type worksheet, but Revit families don’t always provide an obvious way to get those values. Some families have reference planes with labelled dimensions, while others don’t. And even if they do, you’re left manually checking, entering, or mapping the parameters.

That’s where Dynamo and a little bit of Python scripting can save a huge amount of time.

The challenge

Revit stores family geometry internally, but it doesn’t expose a direct “bounding box size” parameter in the user interface. You can measure instances with dimensions or reference planes, but there’s no quick way to batch-extract the overall X, Y, and Z extents and push them into COBie fields.

If you’ve ever had to deliver COBie on a healthcare project or any facilities-focused job, you’ll know how tedious this can become.

The solution: Dynamo + Python

By combining Dynamo’s ability to select and iterate through elements with a small Python script that taps the Revit API, you can automatically:

Calculate the Length, Width, and Height of each family type from its bounding box or geometry,

Convert those values into the units you want (mm, m, ft, inches),

Write them straight into the COBie.Type.NominalLength, NominalWidth, and NominalHeight parameters.

The Python code

Here’s the script you can paste directly into a Dynamo Python node. It works in Revit 2019 and newer (IronPython 2.7 or CPython3 in later versions).

# Dynamo Python (Revit 2019+) — Robust BBox -> COBie.Type Nominal params
# – Handles bbox=None by falling back to geometry extents
# – Writes once per FamilySymbol (Type)
# – Skips linked instances
# – Table output with Status column

import clr

# RevitServices / RevitAPI
clr.AddReference(“RevitServices”)
from RevitServices.Persistence import DocumentManager
from RevitServices.Transactions import TransactionManager

clr.AddReference(“RevitAPI”)
from Autodesk.Revit.DB import (
    Element, StorageType, FamilySymbol, RevitLinkInstance, Options,
    Solid, Mesh, GeometryInstance, Transform, XYZ
)

doc = DocumentManager.Instance.CurrentDBDocument

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

def unwrap(elem):
    try: return UnwrapElement(elem)
    except: return elem

def unit_factor(to_units):
    m = (to_units or “mm”).lower()
    if m == “mm”:   return 304.8
    if m == “m”:    return 0.3048
    if m == “inch”: return 12.0
    if m == “ft”:   return 1.0
    return 304.8

def to_internal_feet(value, from_units):
    m = (from_units or “mm”).lower()
    if m == “mm”:   return value / 304.8
    if m == “m”:    return value / 0.3048
    if m == “inch”: return value / 12.0
    if m == “ft”:   return value
    return value / 304.8

def combine_minmax(cur_min, cur_max, pt):
    if cur_min is None:
        return XYZ(pt.X, pt.Y, pt.Z), XYZ(pt.X, pt.Y, pt.Z)
    return XYZ(min(cur_min.X, pt.X), min(cur_min.Y, pt.Y), min(cur_min.Z, pt.Z)), \
           XYZ(max(cur_max.X, pt.X), max(cur_max.Y, pt.Y), max(cur_max.Z, pt.Z))

def extents_from_minmax(pt_min, pt_max):
    if (pt_min is None) or (pt_max is None): return None
    dx = abs(pt_max.X – pt_min.X)
    dy = abs(pt_max.Y – pt_min.Y)
    dz = abs(pt_max.Z – pt_min.Z)
    return (dx, dy, dz)

def try_bbox(elem):
    try:
        bb = elem.get_BoundingBox(None)
        if bb and bb.Min and bb.Max:
            return (abs(bb.Max.X – bb.Min.X), abs(bb.Max.Y – bb.Min.Y), abs(bb.Max.Z – bb.Min.Z)), “bbox:model”
    except: pass
    try:
        bb = elem.get_BoundingBox(doc.ActiveView)
        if bb and bb.Min and bb.Max:
            return (abs(bb.Max.X – bb.Min.X), abs(bb.Max.Y – bb.Min.Y), abs(bb.Max.Z – bb.Min.Z)), “bbox:view”
    except: pass
    return None, “bbox:none”

def try_geometry_extents(elem):
    try:
        opt = Options()
        geo = elem.get_Geometry(opt)
        if geo is None: return None, “geom:none”
        def walk_geom(geom_elem, xform):
            gmin, gmax = None, None
            for gobj in geom_elem:
                if isinstance(gobj, Solid) and gobj.Faces.Size > 0:
                    bb = gobj.GetBoundingBox()
                    if bb and bb.Min and bb.Max:
                        corners = [
                            XYZ(bb.Min.X, bb.Min.Y, bb.Min.Z),
                            XYZ(bb.Max.X, bb.Max.Y, bb.Max.Z)
                        ]
                        for c in corners:
                            wc = xform.OfPoint(c)
                            gmin, gmax = combine_minmax(gmin, gmax, wc)
                elif isinstance(gobj, Mesh):
                    for i in range(gobj.Vertices.Count):
                        wc = xform.OfPoint(gobj.Vertices[i])
                        gmin, gmax = combine_minmax(gmin, gmax, wc)
                elif isinstance(gobj, GeometryInstance):
                    inst_xf = xform.Multiply(gobj.Transform)
                    mmin, mmax = walk_geom(gobj.GetInstanceGeometry(), inst_xf)
                    if mmin: gmin, gmax = combine_minmax(gmin, gmax, mmin)
                    if mmax: gmin, gmax = combine_minmax(gmin, gmax, mmax)
            return gmin, gmax
        mn, mx = walk_geom(geo, Transform.Identity)
        if mn is None: return None, “geom:empty”
        return extents_from_minmax(mn, mx), “geom:ok”
    except:
        return None, “geom:err”

def set_type_param(sym, name, value_feet):
    if not sym or not name: return False
    p = sym.LookupParameter(name)
    if p and not p.IsReadOnly:
        if p.StorageType == StorageType.Double:
            try: return p.Set(value_feet)
            except: return False
        else:
            try: return p.Set(str(value_feet))
            except: return False
    return False

# ———- Inputs ———-
elements_in = ensure_list(IN[0] if len(IN) > 0 else [])
units = IN[1] if (len(IN) > 1 and IN[1]) else “mm”
factor = unit_factor(units)

table = [[“ElementId”,”UniqueId”,”TypeName”,”Method”,”Length(“+units+”)”,”Width(“+units+”)”,”Height(“+units+”)”,”Units”,”Status”]]
processed_type_ids = set()

TransactionManager.Instance.EnsureInTransaction(doc)

for e in elements_in:
    e = unwrap(e)
    if not isinstance(e, Element): continue
    if isinstance(e, RevitLinkInstance):
        table.append([e.Id.IntegerValue, e.UniqueId, “RevitLinkInstance”, “n/a”, None, None, None, units, “Skipped: Linked instance”])
        continue
    sym = getattr(e, “Symbol”, None)
    sym_name = None
    try: sym_name = sym.Family.Name + ” : ” + sym.Name if sym else None
    except: pass
    sizes_ft, which = try_bbox(e)
    if sizes_ft is None: sizes_ft, which = try_geometry_extents(e)
    if sizes_ft is None:
        table.append([e.Id.IntegerValue, e.UniqueId, sym_name, which, None, None, None, units, “No geometry/bbox”])
        continue
    L_ft, W_ft, H_ft = sizes_ft
    L_u, W_u, H_u = L_ft * factor, W_ft * factor, H_ft * factor
    status = “Computed only”
    if sym and isinstance(sym, FamilySymbol):
        type_id = sym.Id.IntegerValue
        if type_id not in processed_type_ids:
            set_type_param(sym, “COBie.Type.NominalLength”, to_internal_feet(L_u, units))
            set_type_param(sym, “COBie.Type.NominalWidth”,  to_internal_feet(W_u, units))
            set_type_param(sym, “COBie.Type.NominalHeight”, to_internal_feet(H_u, units))
            processed_type_ids.add(type_id)
            status = “Wrote Type params”
        else:
            status = “Type already written”
    table.append([e.Id.IntegerValue, e.UniqueId, sym_name, which, L_u, W_u, H_u, units, status])

TransactionManager.Instance.TransactionTaskDone()
OUT = table

Why it matters for Information Management

This may sound like a small automation, but it has a big impact on the quality and efficiency of COBie deliverables:

Consistency: Every type gets dimensions calculated from the same logic.

Speed: Hundreds of families can be processed in seconds.

Compliance: COBie sheets are populated correctly, reducing the chance of validation failures later.

Reusability: Once you have this Dynamo graph set up, it can be reused on every project.

For anyone acting as Lead Appointed Party or managing an Asset Information Model, small wins like this keep the COBie process on track without burning design team hours on data entry.

Wrapping up

COBie has a reputation for being painful, but often it’s the “little things” like NominalLength, Width, and Height that cause the biggest bottlenecks. By using Dynamo to calculate and populate these automatically, you reduce friction for your design team and raise the overall quality of your deliverables.

Next step: Copy the code above into a Dynamo Python node, connect a list of family instances, and test it on your own project. You’ll be surprised how quickly those COBie fields fill themselves.

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