pyRevit Community, I would like to ask for help. I am working on trying to create some MEP basic tools for connecting/trimming pipe/duct. Basically a “TRIM” tool but that lets you enter an angle to create rolled/kicked elbow angles.
here is an example of pipe trimming at a 45 and 60 degree entered angle
so far this work when pipe/duct are aligned/parallel. The goal would then be to use it at a perpendicular run as shown
I feel like this comes down to pure math. At least, I think it does.
I am hoping someone can take a look at this and see how this can be fixed.
Here is what I have so far:
# -*- coding: utf-8 -*-
# Import .NET and math libraries
import clr, math
# Add Revit API references so we can access Revit classes
clr.AddReference("RevitAPI")
clr.AddReference("RevitAPIUI")
# Import core Revit database classes
from Autodesk.Revit.DB import *
# Import specific MEP curve classes
from Autodesk.Revit.DB.Mechanical import Duct
from Autodesk.Revit.DB.Plumbing import Pipe
# Import selection tools and selection filter interface
from Autodesk.Revit.UI.Selection import ObjectType, ISelectionFilter
# Import pyRevit helpers
from pyrevit import revit, forms
# Active document and UI document
doc = revit.doc
uidoc = revit.uidoc
# ------------------------------------------------------------
# Selection Filter
# ------------------------------------------------------------
# Custom selection filter to allow ONLY Pipes and Ducts
class MEPFilter(ISelectionFilter):
# Determines if element can be selected
def AllowElement(self, e):
# Reject null elements or elements without category
if not e or not e.Category:
return False
# Allow only pipe curves and duct curves
return e.Category.Id.IntegerValue in (
int(BuiltInCategory.OST_PipeCurves),
int(BuiltInCategory.OST_DuctCurves)
)
# Disallow reference-based selections (faces, edges, etc.)
def AllowReference(self, r, p):
return False
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
# # Returns the first unconnected connector found on an element
# def get_open_connector(elem):
# for c in elem.ConnectorManager.Connectors:
# if not c.IsConnected:
# return c
# return None
# Returns the closest connectors on the selected elements
def get_closest_open_connector_pair(elem1, elem2):
min_dist = None
best_pair = (None, None)
conns1 = [c for c in elem1.ConnectorManager.Connectors if not c.IsConnected]
conns2 = [c for c in elem2.ConnectorManager.Connectors if not c.IsConnected]
for c1 in conns1:
for c2 in conns2:
d = c1.Origin.DistanceTo(c2.Origin)
if min_dist is None or d < min_dist:
min_dist = d
best_pair = (c1, c2)
return best_pair
# Finds the connector closest to a given point within tolerance
def nearest_connector(elem, pt, tol=0.5):
for c in elem.ConnectorManager.Connectors:
if c.Origin.DistanceTo(pt) < tol:
return c
return None
# Extends a linear MEP curve so that one endpoint moves to a new point
def extend_to_point(elem, pt):
# Get element location
loc = elem.Location
# Ensure element has a linear curve
if not isinstance(loc, LocationCurve):
return
# Access underlying line geometry
curve = loc.Curve
p0 = curve.GetEndPoint(0)
p1 = curve.GetEndPoint(1)
# Determine which endpoint is closer to target point
# Replace that endpoint with new location
if p0.DistanceTo(pt) < p1.DistanceTo(pt):
loc.Curve = Line.CreateBound(pt, p1)
else:
loc.Curve = Line.CreateBound(p0, pt)
# Copies size parameters (diameter OR width/height)
# from anchor element to target element
def copy_size(anchor, target):
# If pipe → copy diameter
if isinstance(anchor, Pipe):
dia = anchor.get_Parameter(
BuiltInParameter.RBS_PIPE_DIAMETER_PARAM)
if dia:
target.get_Parameter(
BuiltInParameter.RBS_PIPE_DIAMETER_PARAM
).Set(dia.AsDouble())
# If duct → handle round OR rectangular
elif isinstance(anchor, Duct):
# Try round duct first
dia = anchor.get_Parameter(
BuiltInParameter.RBS_CURVE_DIAMETER_PARAM)
if dia and dia.HasValue:
target.get_Parameter(
BuiltInParameter.RBS_CURVE_DIAMETER_PARAM
).Set(dia.AsDouble())
# Otherwise rectangular duct
else:
w = anchor.get_Parameter(
BuiltInParameter.RBS_CURVE_WIDTH_PARAM)
h = anchor.get_Parameter(
BuiltInParameter.RBS_CURVE_HEIGHT_PARAM)
if w and h:
target.get_Parameter(
BuiltInParameter.RBS_CURVE_WIDTH_PARAM
).Set(w.AsDouble())
target.get_Parameter(
BuiltInParameter.RBS_CURVE_HEIGHT_PARAM
).Set(h.AsDouble())
# ------------------------------------------------------------
# Pick Anchor + Target
# ------------------------------------------------------------
# Prompt user to pick anchor and target elements
try:
ref1 = uidoc.Selection.PickObject(
ObjectType.Element,
MEPFilter(),
"Select ANCHOR element"
)
ref2 = uidoc.Selection.PickObject(
ObjectType.Element,
MEPFilter(),
"Select TARGET element"
)
except:
forms.alert("Selection cancelled.", exitscript=True)
# Retrieve actual element objects
anchor = doc.GetElement(ref1.ElementId)
target = doc.GetElement(ref2.ElementId)
# Ensure both elements are same type (Pipe-Pipe or Duct-Duct)
if type(anchor) != type(target):
forms.alert("Both elements must be same category.", exitscript=True)
# ------------------------------------------------------------
# Enforce Same Type
# ------------------------------------------------------------
# If types differ (e.g., different pipe type)
if anchor.GetTypeId() != target.GetTypeId():
# Ask user if target should be converted
result = forms.alert(
"Target has different type.\n\n"
"Convert target to anchor type?",
options=["Yes", "No"]
)
# If Yes → change type
if result == "Yes":
with revit.Transaction("Match Type"):
target.ChangeTypeId(anchor.GetTypeId())
else:
forms.alert("Operation cancelled.", exitscript=True)
# ------------------------------------------------------------
# Ask for Angle
# ------------------------------------------------------------
# Prompt user for elbow angle
angle_str = forms.ask_for_string(
default="45",
prompt="Enter elbow angle (15°–90°)",
title="NXGN | Angle Connect"
)
# Convert input string to float
try:
angle_deg = float(angle_str)
except:
forms.alert("Invalid angle.", exitscript=True)
# Enforce allowed angle range
if angle_deg < 15 or angle_deg > 90:
forms.alert("Angle must be between 15° and 90°.", exitscript=True)
# Convert degrees to radians for math functions
theta = math.radians(angle_deg)
# ------------------------------------------------------------
# Main Logic
# ------------------------------------------------------------
# Use TransactionGroup to bundle multiple transactions
with revit.TransactionGroup("NXGN | Angle Connect"):
# Get open connectors on both elements
# base_conn = get_open_connector(anchor)
# target_conn = get_open_connector(target)
base_conn, target_conn = get_closest_open_connector_pair(anchor, target)
if not base_conn or not target_conn:
forms.alert("Could not find open connectors.", exitscript=True)
# Connector origin points
A = base_conn.Origin
T = target_conn.Origin
# Compute vertical difference between connectors
delta_z = T.Z - A.Z
# Reject if no elevation difference
if abs(delta_z) < 1e-6:
forms.alert("Elements are at same elevation.", exitscript=True)
# Calculate required horizontal projection
# Based on tan(theta) = opposite / adjacent
horiz_proj = abs(delta_z) / math.tan(theta)
# Get forward direction from connector coordinate system
fwd = base_conn.CoordinateSystem.BasisZ.Normalize()
# Extract horizontal component (remove Z)
horiz = XYZ(fwd.X, fwd.Y, 0.0)
# Reject if vertical
if horiz.GetLength() < 1e-6:
forms.alert("Anchor is vertical. Cannot determine direction.", exitscript=True)
# Normalize horizontal vector
horiz = horiz.Normalize()
# Ensure projection is toward target element
dir_to_target = XYZ(T.X - A.X, T.Y - A.Y, 0)
if horiz.DotProduct(dir_to_target) < 0:
horiz = horiz.Negate()
# Compute final 3D end point
end_pt = A.Add(horiz.Multiply(horiz_proj)).Add(XYZ(0, 0, delta_z))
# --------------------------------------------------------
# 1. Create Angled Segment
# --------------------------------------------------------
with revit.Transaction("Create Angled Segment"):
# Create new pipe or duct segment
if isinstance(anchor, Pipe):
new_seg = Pipe.Create(
doc,
anchor.MEPSystem.GetTypeId(),
anchor.GetTypeId(),
anchor.ReferenceLevel.Id,
A,
end_pt
)
else:
new_seg = Duct.Create(
doc,
anchor.MEPSystem.GetTypeId(),
anchor.GetTypeId(),
anchor.LevelId,
A,
end_pt
)
# Match size to anchor
copy_size(anchor, new_seg)
# Connect anchor to new segment with elbow
cA = nearest_connector(anchor, A)
cNewStart = nearest_connector(new_seg, A)
doc.Create.NewElbowFitting(cA, cNewStart)
# --------------------------------------------------------
# 2. Align Target Horizontally
# --------------------------------------------------------
with revit.Transaction("Align Target"):
# Ensure target size matches anchor
copy_size(anchor, target)
# Commented this line out since line 237 and 238 where replaced with 239
#target_conn = get_open_connector(target)
# Compute horizontal translation only
horizontal_shift = XYZ(
end_pt.X - target_conn.Origin.X,
end_pt.Y - target_conn.Origin.Y,
0
)
# Move target horizontally
ElementTransformUtils.MoveElement(doc, target.Id, horizontal_shift)
# --------------------------------------------------------
# 3. Trim + Connect
# --------------------------------------------------------
with revit.Transaction("Trim + Connect"):
# Extend target to exact end point
extend_to_point(target, end_pt)
# Get connectors at end point
cT = nearest_connector(target, end_pt)
cNewEnd = nearest_connector(new_seg, end_pt)
# Create final elbow connection
doc.Create.NewElbowFitting(cT, cNewEnd)


