I vibe coded something with Copilot and validated it’s results on a few simple runs.
For elbows, it tries to use a few specific parameters to determine the fitting length, and if they are unavailable it approximates the elbow curve based on the positions of the fitting connectors. This approximation will never be perfect because Revit doesn’t inherently know the length of the straight segments of the fitting unless you pass them to a parameter that the code can access.
I didn’t need it to calculate j-boxes at this time so it assumes they add zero length for now.
Not my cleanest code, but hopefully it helps others (or me in the future lol).
__title__ = "Conduit Run Length"
import clr
import math
clr.AddReference("RevitAPI")
clr.AddReference("RevitAPIUI")
from Autodesk.Revit.DB import (
BuiltInCategory, MEPCurve, FamilyInstance, UnitFormatUtils, SpecTypeId, XYZ
)
from Autodesk.Revit.UI.Selection import ObjectType, ISelectionFilter
from pyrevit import revit, forms, script
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
# -------------------------------
# Selection Filter
# -------------------------------
class ConduitAndFittingFilter(ISelectionFilter):
def AllowElement(self, elem):
try:
if elem.Category is None:
return False
bic = elem.Category.Id.IntegerValue
return bic in (
int(BuiltInCategory.OST_Conduit),
int(BuiltInCategory.OST_ConduitFitting),
)
except:
return False
def AllowReference(self, ref, point):
return False
# -------------------------------
# Connector helpers
# ConnectorManager access differs for MEPCurve vs FamilyInstance [1](https://pythoncvc.net/?p=417)
# -------------------------------
def get_connector_manager(elem):
try:
return elem.ConnectorManager # MEPCurve (Conduit)
except:
pass
try:
return elem.MEPModel.ConnectorManager # FamilyInstance (fittings)
except:
return None
def get_connectors(elem):
cm = get_connector_manager(elem)
if not cm:
return []
try:
return list(cm.Connectors)
except:
return []
def is_conduit_or_fitting(elem):
if elem is None or elem.Category is None:
return False
bic = elem.Category.Id.IntegerValue
return bic in (
int(BuiltInCategory.OST_Conduit),
int(BuiltInCategory.OST_ConduitFitting),
)
# -------------------------------
# Graph traversal: get connected component (run/network)
# -------------------------------
def get_connected_component(seed_elem, global_seen):
comp = []
queue = [seed_elem]
local_seen = set()
while queue:
e = queue.pop()
if e is None:
continue
eid = e.Id.IntegerValue
if eid in global_seen or eid in local_seen:
continue
if not is_conduit_or_fitting(e):
continue
local_seen.add(eid)
comp.append(e)
for c in get_connectors(e):
try:
for rc in c.AllRefs:
owner = rc.Owner
if owner is None:
continue
if owner.Id.IntegerValue == eid:
continue
if is_conduit_or_fitting(owner):
queue.append(owner)
except:
continue
for eid in local_seen:
global_seen.add(eid)
return comp
# -------------------------------
# Length computation
# -------------------------------
def conduit_length_internal(conduit_elem):
# LocationCurve length (internal feet)
try:
loc = conduit_elem.Location
crv = loc.Curve
return crv.Length
except:
# fallback: try parameter "Length"
try:
p = conduit_elem.LookupParameter("Length")
if p:
return p.AsDouble()
except:
pass
return 0.0
def _normalize(v):
try:
ln = v.GetLength()
if ln < 1e-9:
return v
return XYZ(v.X / ln, v.Y / ln, v.Z / ln)
except:
return v
import math
from Autodesk.Revit.DB import SpecTypeId, UnitUtils
def _get_param_as_double(elem, names):
"""Try multiple parameter names and return AsDouble() or None."""
for n in names:
p = elem.LookupParameter(n)
if p and p.StorageType.ToString() == "Double":
try:
return p.AsDouble()
except:
pass
return None
def _looks_like_degrees(angle_val):
"""
Heuristic: if it's > ~2*pi, it's probably degrees (e.g. 90, 45, 30).
API internal for angles is radians. [2](https://www.revitapidocs.com/2026/128dd879-fea8-5d7b-1eb2-d64f87753990.htm)
"""
return angle_val is not None and angle_val > (2.0 * math.pi + 0.5)
def fitting_developed_length_from_params_internal(fit_elem, warnlist=None):
"""
Developed length = 2*offset + radius*angle
- radius: internal feet
- offset: internal feet (per end)
- angle: radians (if degrees detected, convert)
"""
if warnlist is None:
warnlist = []
# Adjust these name lists to match your content standard
radius = _get_param_as_double(fit_elem, [
"Bend Radius", "Radius", "Centerline Radius", "Bend radius"
])
angle = _get_param_as_double(fit_elem, [
"Angle", "Bend Angle", "Conduit Angle"
])
offset = _get_param_as_double(fit_elem, [
"Conduit Length", "End Offset", "Takeoff", "Extension", "Stub Length"
])
# If any are missing, bail out (caller can fallback)
if radius is None or angle is None or offset is None:
warnlist.append("Missing parameters on fitting id {} (radius={}, angle={}, offset={})"
.format(fit_elem.Id.IntegerValue,
radius is not None, angle is not None, offset is not None))
return None
# Convert degrees -> radians if needed (defensive)
if _looks_like_degrees(angle):
angle = angle * math.pi / 180.0
# Developed length in internal feet
total = (2.0 * offset) + (radius * angle)
return total
def fitting_developed_length_internal(fit_elem, warnlist):
# 1) Try family parameters first
val = fitting_developed_length_from_params_internal(fit_elem, warnlist)
if val is not None:
return val
# 2) Fallback: Connector-based estimate
warnlist.append("Falling back to connector estimate for fitting id {}"
.format(fit_elem.Id.IntegerValue))
return fitting_developed_length_from_connectors(fit_elem, warnlist)
def fitting_developed_length_from_connectors(fit_elem, warnlist):
"""
For 2-connector fittings:
- If connectors nearly colinear (theta ~ pi), treat as straight: length = chord
- Else treat as bend: arc length computed from chord + included angle
For 3+ connectors (tees/cross), return 0 and warn.
"""
conns = get_connectors(fit_elem)
# keep only connectors with an origin
conns = [c for c in conns if getattr(c, "Origin", None) is not None]
if len(conns) != 2:
# tees/crosses/etc.
warnlist.append("Skipped fitting id {} ({} connectors)".format(
fit_elem.Id.IntegerValue, len(conns)
))
return 0.0
c1, c2 = conns[0], conns[1]
p1, p2 = c1.Origin, c2.Origin
chord = p1.DistanceTo(p2)
# connector direction vectors
try:
d1 = _normalize(c1.CoordinateSystem.BasisZ)
d2 = _normalize(c2.CoordinateSystem.BasisZ)
dot = max(-1.0, min(1.0, d1.DotProduct(d2)))
theta = math.acos(dot) # radians (internal for angles) [3](https://forums.autodesk.com/t5/revit-api-forum/how-to-get-angle-parameter-from-familyinstance/td-p/9988944)
except:
# if direction can't be read, fallback to chord
return chord
# If nearly straight (connector dirs opposite => theta ~ pi), use chord
if abs(math.pi - theta) < 1e-3 or theta < 1e-6:
return chord
# Arc length from chord and included angle
s = math.sin(theta / 2.0)
if abs(s) < 1e-9:
return chord
R = chord / (2.0 * s)
arc = R * theta
return arc
def format_length(val_internal):
# Format in project units (internal feet -> formatted string)
try:
return UnitFormatUtils.Format(doc.GetUnits(), SpecTypeId.Length, val_internal, False)
except:
# fallback: raw feet
return "{:.3f} ft".format(val_internal)
# # -------------------------------
# # Main
# # -------------------------------
sel_ids = list(uidoc.Selection.GetElementIds())
seed_elems = []
if sel_ids:
seed_elems = [doc.GetElement(i) for i in sel_ids if doc.GetElement(i)]
else:
try:
picked = uidoc.Selection.PickObjects(
ObjectType.Element,
ConduitAndFittingFilter(),
"Select conduit(s) and/or conduit fitting(s) to define run(s)"
)
seed_elems = [doc.GetElement(r.ElementId) for r in picked]
except:
forms.alert("Selection canceled.", exitscript=True)
# Build connected components starting from each seed
global_seen = set()
runs = []
for s in seed_elems:
if not s:
continue
if s.Id.IntegerValue in global_seen:
continue
comp = get_connected_component(s, global_seen)
if comp:
runs.append(comp)
if not runs:
forms.alert("No connected conduit/fitting elements found.", exitscript=True)
# Report
grand_total = 0.0
output.print_md("# Conduit Run Length Report")
output.print_md("Found **{}** connected run(s).".format(len(runs)))
for idx, elems in enumerate(runs, start=1):
conduits = [e for e in elems if isinstance(e, MEPCurve)]
fittings = [e for e in elems if isinstance(e, FamilyInstance)]
warn = []
conduit_sum = sum(conduit_length_internal(c) for c in conduits)
fitting_sum = sum(fitting_developed_length_internal(f, warn) for f in fittings)
total = conduit_sum + fitting_sum
grand_total += total
output.print_md("## Run {}".format(idx))
output.print_md("- Elements: **{}** (Conduit: **{}**, Fittings: **{}**)".format(
len(elems), len(conduits), len(fittings)
))
output.print_md("- Conduit length: **{}**".format(format_length(conduit_sum)))
output.print_md("- Fitting developed length (2-connector only): **{}**".format(format_length(fitting_sum)))
output.print_md("- **TOTAL RUN LENGTH: {}**".format(format_length(total)))
if warn:
output.print_md("### Notes")
for w in warn:
output.print_md("- {}".format(w))
output.print_md("---")
output.print_md("Grand Total all runs")
output.print_md("**{}**".format(format_length(grand_total)))
print("Script complete.")