MEP "Rolled/Kicked" Angles. Trimming Pipe and Duct at Specific Angles

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)