Help with Grid Bubble Clash Offset

I started to make this “Fix Grid Bubble” clash offset tool just now. I’d like to ask for help to make it work better. i am able to get the elbow added but not get the elbow moved after its been added. it stays at this point which is the default placement by clicking on the add elbow. I’d like to make it move further so it’s no longer clashing.

# -*- coding: utf-8 -*-
"""_________________________________________________________________
Description:
Detects clashing grid bubbles and resolves by offsetting
the bubble leader with an elbow jog.
Rules:
- For vertical grids → move the LEFTMOST bubble.
- For horizontal grids → move the TOPMOST bubble.
- Offset distance is auto-calculated based on the bubble size,
  view scale, and the actual overlap distance.
- Elbow is placed relative to the grid anchor point, so it
  always jogs outward instead of overlapping on the grid line.
_________________________________________________________________
"""

import clr
clr.AddReference('RevitAPI')
clr.AddReference('RevitServices')

import Autodesk
from Autodesk.Revit.DB import *
from Autodesk.Revit.UI import TaskDialog
from Autodesk.Revit.UI.Selection import ObjectType, ISelectionFilter
from pyrevit import revit, forms
import sys

# Setup
doc = revit.doc
uidoc = revit.uidoc
view = doc.ActiveView


# ---------------- Selection Filter ----------------
class GridSelectionFilter(ISelectionFilter):
    def AllowElement(self, e):
        return e.Category.Id.IntegerValue == int(BuiltInCategory.OST_Grids)
    def AllowReference(self, ref, point):
        return True


# ---------------- Helpers ----------------
def get_bubble_point(grid, end, view):
    """Return bubble location via leader if it exists, else endpoint."""
    leader = grid.GetLeader(end, view)
    if leader:
        return leader.End
    else:
        return grid.Curve.GetEndPoint(0 if end == DatumEnds.End0 else 1)


def move_bubble(grid, clash_end, view, jog_offset, end_offset):
    """Move a grid bubble by adjusting its leader elbow + end."""
    if not grid.IsBubbleVisibleInView(clash_end, view):
        grid.ShowBubbleInView(clash_end, view)

    leader = grid.GetLeader(clash_end, view)
    if not leader:
        print("No leader found on {0}, adding one...".format(grid.Name))
        leader = grid.AddLeader(clash_end, view)

    if leader:
        anchor = leader.Anchor
        old_end = leader.End

        # Place elbow explicitly relative to anchor
        leader.Elbow = anchor + jog_offset

        # Move bubble outward from old end
        leader.End = old_end + end_offset

        print("Bubble for {0} moved. Anchor={1}, Elbow={2}, End={3}".format(
            grid.Name, anchor, leader.Elbow, leader.End))


def get_clash_end_and_points(g1, g2, view, bubble_radius):
    """Check both ends and return clash info if found."""
    for end in [DatumEnds.End0, DatumEnds.End1]:
        if not (g1.HasBubbleInView(end, view) and g1.IsBubbleVisibleInView(end, view)):
            continue
        if not (g2.HasBubbleInView(end, view) and g2.IsBubbleVisibleInView(end, view)):
            continue

        pt1_tmp = get_bubble_point(g1, end, view)
        pt2_tmp = get_bubble_point(g2, end, view)

        if pt1_tmp and pt2_tmp:
            dist = pt1_tmp.DistanceTo(pt2_tmp)
            print("Distance between bubble centers at {0} = {1}".format(end, dist))

            if dist < (bubble_radius * 2.0):
                return True, end, pt1_tmp, pt2_tmp, dist
    return False, None, None, None, None


# ---------------- Main ----------------
try:
    refs = uidoc.Selection.PickObjects(ObjectType.Element, GridSelectionFilter(),
                                       "Select two grids with clashing bubbles")
    if len(refs) < 2:
        forms.alert("Please select at least two grids.", exitscript=True)

    grids = [doc.GetElement(r) for r in refs]
    g1, g2 = grids[0], grids[1]

    print("Two grids selected: {0} and {1}".format(g1.Name, g2.Name))

    # Bubble radius auto-scales with view
    BUBBLE_RADIUS_INCHES = 0.25  # 1/4" radius for 1/2" bubble diameter
    BUBBLE_RADIUS = (BUBBLE_RADIUS_INCHES / 12.0) * view.Scale
    bubble_diameter_ft = BUBBLE_RADIUS * 2.0

    print("Bubble diameter on sheet = {0}\"; in model space = {1} ft".format(
        BUBBLE_RADIUS_INCHES * 2.0, round(bubble_diameter_ft, 2)))

    clash_detected, clash_end, pt1, pt2, dist = get_clash_end_and_points(g1, g2, view, BUBBLE_RADIUS)

    if clash_detected:
        print("Selected grid bubbles clash detected at {0}".format(clash_end))

        with Transaction(doc, "Resolve Grid Bubble Clash") as t:
            t.Start()

            curve = g1.Curve
            pt0 = curve.GetEndPoint(0)
            pt1_curve = curve.GetEndPoint(1)
            dir_vec = (pt1_curve - pt0).Normalize()

            # Determine movement direction by grid orientation
            if abs(dir_vec.X) < 0.1:  # Vertical grids
                if pt1.X < pt2.X:
                    grid_to_move = g1
                else:
                    grid_to_move = g2
                move_dir = XYZ(-1, 0, 0)  # always left
            else:  # Horizontal grids
                if pt1.Y > pt2.Y:
                    grid_to_move = g1
                else:
                    grid_to_move = g2
                move_dir = XYZ(0, 1, 0)  # always up

            # Calculate overlap and move amount
            overlap = bubble_diameter_ft - dist if dist < bubble_diameter_ft else 0
            clearance = bubble_diameter_ft * 0.25  # 25% extra
            move_amount = overlap + clearance

            jog_offset = move_dir * (move_amount / 2.0)
            end_offset = move_dir * move_amount

            print("Moving bubble for {0} by {1} ft".format(grid_to_move.Name, round(move_amount, 2)))
            move_bubble(grid_to_move, clash_end, view, jog_offset, end_offset)

            t.Commit()

        forms.alert("Clash resolved: Bubble moved on {0}".format(grid_to_move.Name), title="Success")

    else:
        print("Selected grid bubbles do not clash.")
        forms.alert("No clash detected between selected grid bubbles.", title="Info")

except Autodesk.Revit.Exceptions.OperationCanceledException:
    print("User canceled operation.")
    sys.exit()
except Exception as ex:
    TaskDialog.Show("Error", "Warning: {0}".format(ex))
    print("Error: {0}".format(ex))

2 Likes

after exploring this some more, i was not able to move the anchor/elbows/leader endpoints.

This is what i came up with so far. If anyone knows how to take this further to make the bubbles move further apart to avoid overlaps, give it a whirl and share :upside_down_face:.

GridBubbleElbow_script.py

# -*- coding: utf-8 -*-
__title__ = "Fix Grid Bubble Overlaps (Active View)"
__doc__ = """Checks visible grid bubbles in the active view and, if overlaps are found,
adds a leader (elbow) to one of the grids to stagger bubbles apart.
If both already have leaders, the overlap is only reported.

Shift+Click this tool to configure the bubble radius (bubble_config.py).
"""

import clr
clr.AddReference('RevitAPI')
clr.AddReference('RevitServices')

from Autodesk.Revit.DB import *
from pyrevit import revit, forms
import bubble_config   # shared config file

# Revit context
doc = revit.doc
view = doc.ActiveView


# ---------------- Helper: distance ----------------
def distance(pt1, pt2):
    return pt1.DistanceTo(pt2)


# ---------------- Main ----------------
def fix_grid_bubble_overlaps():
    # Load bubble radius from config (default = 0.25")
    bubble_radius_inches = bubble_config.load_radius()
    bubble_radius_ft = (bubble_radius_inches / 12.0) * view.Scale
    bubble_diameter_ft = bubble_radius_ft * 2.0

    grids = FilteredElementCollector(doc, view.Id).OfClass(Grid).ToElements()

    # Collect bubble centers
    bubble_points = []   # list of (grid, end, XYZ)
    for g in grids:
        curve = g.Curve
        for end in [DatumEnds.End0, DatumEnds.End1]:
            if g.HasBubbleInView(end, view) and g.IsBubbleVisibleInView(end, view):
                pt = curve.GetEndPoint(0) if end == DatumEnds.End0 else curve.GetEndPoint(1)
                bubble_points.append((g, end, pt))

    # Compare pairwise and fix overlaps
    overlaps = []
    with Transaction(doc, "Fix Grid Bubble Overlaps") as t:
        t.Start()
        for i in range(len(bubble_points)):
            g1, e1, p1 = bubble_points[i]
            for j in range(i + 1, len(bubble_points)):
                g2, e2, p2 = bubble_points[j]

                # Only compare bubbles at the same end type (End0 vs End0, End1 vs End1)
                if e1 != e2:
                    continue

                d = distance(p1, p2)
                if d < bubble_diameter_ft:
                    overlaps.append((g1.Name, g2.Name, str(e1), round(d, 3)))

                    # Try to add elbow intelligently
                    try:
                        g2.AddLeader(e2, view)
                    except:
                        try:
                            g1.AddLeader(e1, view)
                        except:
                            # both already had leaders → skip
                            pass

        t.Commit()

    # Report
    if overlaps:
        msg_lines = ["Bubble diameter per view scale ≈ {0:.2f} ft\nOverlaps fixed :".format(bubble_diameter_ft)]
        for g1, g2, end, d in overlaps:
            msg_lines.append(" - {0} and {1}".format(g1, g2))
        forms.alert("\n".join(msg_lines), title="Grid Bubble Elbows Added")
    else:
        forms.alert("No overlapping grid bubbles detected.", title="Grid Bubble Check")


# Run
fix_grid_bubble_overlaps()

This is the corresponding bubble_config.py

# -*- coding: utf-8 -*-
# bubble_config.py
# Configuration module for bubble radius
# Runs when user Shift+Clicks the tool button

from pyrevit import forms, script

cfg = script.get_config("grid_bubble_config")

DEFAULT_RADIUS = 0.25  # inches


def load_radius():
    """Load saved bubble radius or default"""
    return getattr(cfg, "bubble_radius_inches", DEFAULT_RADIUS)


def save_radius(val):
    """Save bubble radius"""
    cfg.bubble_radius_inches = val
    script.save_config()


def configure_radius():
    """Ask user to set bubble radius"""
    current = load_radius()
    s = forms.ask_for_string(
        prompt="Enter grid bubble's radius in inches as found in the family:",
        title="Grid Bubble Radius",
        default=str(current)
    )
    try:
        val = float(s)
        if val <= 0:
            raise ValueError
        save_radius(val)
        forms.alert("Bubble radius saved: {:.4f} in".format(val),
                    title="Grid Bubble Config")
    except:
        forms.alert("Invalid input. Keeping existing radius: {:.4f} in".format(current),
                    title="Grid Bubble Config")


# If Shift+Clicked directly, run configurator
if __name__ == "__main__":
    configure_radius()

2 Likes

I am hoping to have time soon to dive deeper into this. I had a similar problem as I was trying to essentially propagate extents of grids from parent to child views. If I used the Add Leader function, I had to use a doc.Regenerate() and then I can adjust the leader. A small excerpt of what I had to do is below.

tgrid.AddLeader(end, tview)
doc.Regenerate()
tleader = tgrid.GetLeader(end, tview)

@Rafael-Marquez

I want to say thank you for the start of this code. Below is my full code to offset bubbles. It isn’t perfect but it does what I need it to. Main thoughts I have were to use the anchor points of the leaders to detect overlaps as that should be updating with each iteration as the grids are moved. From there, adjust the elbows a small amount. I was having problems getting the exact distance to adjust the elbow as it kept erroring out that the elbow was outside the limits of the anchor and end points of the leader. I’m sure there is more that could be optimized too.

import clr
clr.AddReference('RevitAPI')
clr.AddReference('RevitServices')

from Autodesk.Revit.DB import *
from pyrevit import revit, forms
import bubble_config  # shared config file

# Revit context
doc = revit.doc
view = doc.ActiveView

# ---------------- Detect overlaps ----------------
def detect_overlaps(bubble_points, bubble_diameter_ft):
    """Detect all current overlaps - returns list of (g1, e1, p1, g2, e2, p2)"""
    overlaps = []
    for i in range(len(bubble_points)):
        g1, e1, p1 = bubble_points[i]
        for j in range(i + 1, len(bubble_points)):
            g2, e2, p2 = bubble_points[j]
            d = p1.DistanceTo(p2)
            if d < bubble_diameter_ft:
                overlaps.append((g1, e1, p1, g2, e2, p2))
    return overlaps

# ---------------- Get bubble points for compare ----------------
def grid_bubble_points(grids, view):
    """Collect bubble center points for grids in view"""
    bubble_points = []  # list of (grid, end, XYZ)
    for g in grids:
        curve = g.Curve
        for i, end in enumerate([DatumEnds.End0, DatumEnds.End1]):
            if g.HasBubbleInView(end, view) and g.IsBubbleVisibleInView(end, view):
                # Get leader anchor point if exists, else fallback to curve end
                leader = g.GetLeader(end, view)
                if leader:
                    pt = leader.Anchor  # Use anchor point of leader
                else:
                    pt = curve.GetEndPoint(i)
                bubble_points.append((g, end, pt))
    return bubble_points

# ---------------- Adjust anchor ----------------
def adjust_elbow(grid, end, direction_vec, distance_ft):
    """Move anchor outward by vector"""
    leader = grid.GetLeader(end, view)
    if leader:
        try:
            new_elbow = leader.Elbow
            if leader.Elbow.IsAlmostEqualTo(leader.Anchor, 1e-6):
                mid_point = (leader.Anchor + leader.End) * 0.5
                new_elbow = mid_point + (direction_vec * distance_ft)
            else:
                new_elbow = leader.Elbow + (direction_vec * distance_ft)
            leader.Elbow = new_elbow
            grid.SetLeader(end, view, leader)
        except Exception as e:
            print("ERROR adjusting elbow for grid '{}' end '{}': {}".format(grid.Name, end, str(e)))
            pass

# ---------------- Resolve overlaps iteratively ----------------
def resolve_overlaps(grids, view, bubble_diameter_ft, max_iterations=500):
    """Iteratively resolve all overlaps - returns True if all resolved after max_iterations"""
    iteration = 0
    while iteration < max_iterations:
        bubble_points = grid_bubble_points(grids, view)
        overlaps = detect_overlaps(bubble_points, bubble_diameter_ft)
        if not overlaps:
            # All overlaps resolved
            return True
                
        # For each overlap, move the RIGHTMOST/LOWERMOST bubble outward
        for g1, e1, p1, g2, e2, p2 in overlaps:
            # Determine which grid to move (rightmost for vertical, lowermost for horizontal)
            g1_curve = g1.Curve
            g2_curve = g2.Curve
            g1_point = g1_curve.GetEndPoint(0) if e1 == DatumEnds.End0 else g1_curve.GetEndPoint(1)
            g2_point = g2_curve.GetEndPoint(0) if e2 == DatumEnds.End0 else g2_curve.GetEndPoint(1)

            if abs(g1_curve.Direction.X) < 0.1:  # Vertical grids
                if g1_point.X > g2_point.X:
                    grid_to_move, end_to_move, anchor_here, anchor_other = g1, e1, p1, p2
                else:
                    grid_to_move, end_to_move, anchor_here, anchor_other = g2, e2, p2, p1
            else:  # Horizontal grids
                if g1_point.Y > g2_point.Y:
                    grid_to_move, end_to_move, anchor_here, anchor_other = g1, e1, p1, p2
                else:
                    grid_to_move, end_to_move, anchor_here, anchor_other = g2, e2, p2, p1

            g_curve = grid_to_move.Curve  # use the grid that is moving
            tan = g_curve.Direction  # grid direction in model coords
            # perpendicular in XY plane
            perp = XYZ(-tan.Y, tan.X, 0.0)
            perp_len = perp.GetLength()
            if perp_len == 0:
                continue
            perp_unit = perp / perp_len

            clash_vec = anchor_here - anchor_other
            sign = 1.0 if clash_vec.DotProduct(perp_unit) >= 0 else -1.0
            move_dir = perp_unit * sign
            
            # Calculate required clearance
            overlap_dist = bubble_diameter_ft/8.0/view.Scale
            adjust_elbow(grid_to_move, end_to_move, move_dir, overlap_dist)
        
        iteration += 1

    return False

# ---------------- Main ----------------
def fix_grid_bubble_overlaps():
    # Load bubble radius from config
    bubble_radius_inches = bubble_config.load_radius()
    bubble_radius_ft = (bubble_radius_inches / 12.0) * view.Scale
    bubble_diameter_ft = bubble_radius_ft * 2.0

    grids = FilteredElementCollector(doc, view.Id).OfClass(Grid).ToElements()

    with revit.Transaction("Fix Grid Bubble Overlaps"):
        # First ensure all grids have leaders if bubbles are visible
        first_leaders = []
        for g in grids:
            for end in [DatumEnds.End0, DatumEnds.End1]:
                if g.HasBubbleInView(end, view) and g.IsBubbleVisibleInView(end, view):
                    try:
                        if not g.GetLeader(end, view):
                            g.AddLeader(end, view)
                            first_leaders.append([g, end])
                    except:
                        pass  # Already exists
        doc.Regenerate()
        # Only reset the new leader elbows back to straight line
        for g, end in first_leaders:
            if g.HasBubbleInView(end, view) and g.IsBubbleVisibleInView(end, view):
                try:
                    leader = g.GetLeader(end, view)
                    curve = g.GetCurvesInView(DatumExtentType.ViewSpecific, view)[0]
                    leader.Elbow = curve.Project(leader.Elbow).XYZPoint
                    g.SetLeader(end, view, leader)
                except:
                    pass
        
        success = resolve_overlaps(grids, view, bubble_diameter_ft)
        
        if success:
            forms.alert("All grid bubble overlaps resolved!")
        else:
            forms.alert("Some overlaps remain after max iterations.")

# Run
fix_grid_bubble_overlaps()