Help with smoothly connecting two circles

Hello! My goal is to write a function that can organically connect two circles no matter the distance. I originally tried metaballs but found they don’t connect at large distances without increasing the threshold (which changes circle radiuses as a side effect).

So instead, I tried Bézier curves to connect the two circles. I’m limiting the case to (2, 13) and (2, 0). The below code is close but the curve is not perfectly smooth. Please let me know how I can make this curve smoother programmatically & what other techniques I can try.

void setup() {
  size(400, 400);
  noFill();
  stroke(0);
  
  //fill(255, 0, 0);
  //stroke(255, 0, 0);
}

void draw() {
  background(255);
  translate(width/2, height/2);

  PVector c1 = new PVector(2, 13);
  PVector c2 = new PVector(2, 0);
  float r1 = 5;
  float r2 = 2;

  float scale = 10;
  connectCircles(PVector.mult(c1, scale), r1 * scale, PVector.mult(c2, scale), r2 * scale);

  ellipse(c1.x * scale, c1.y * scale, r1 * 2 * scale, r1 * 2 * scale);
  ellipse(c2.x * scale, c2.y * scale, r2 * 2 * scale, r2 * 2 * scale);
}

void connectCircles(PVector c1, float r1, PVector c2, float r2) {
  PVector dir = PVector.sub(c2, c1);
  float d = dir.mag();
  println(d);
  if (d == 0) return;

  float angle = atan2(dir.y, dir.x);

  // Calculate offset angle from center line to tangent points
  float u = acos(constrain((r1 - r2) / d, -1, 1));
  float angle1 = angle + u;
  float angle2 = angle - u;

  // Edge points
  PVector p1 = c1.copy().add(PVector.fromAngle(angle1).mult(r1));
  PVector p2 = c2.copy().add(PVector.fromAngle(angle1).mult(r2));
  PVector p3 = c2.copy().add(PVector.fromAngle(angle2).mult(r2));
  PVector p4 = c1.copy().add(PVector.fromAngle(angle2).mult(r1));

  // This is the tweak: offset control points slightly along the normal to "cinch" the waist
  float offset = 0.8 * min(r1, r2); // tweakable

  beginShape();
  vertex(p1.x, p1.y);
  bezierVertex(p1.x - offset, p1.y - offset, p2.x - offset, p2.y + offset, p2.x, p2.y);
  vertex(p3.x, p3.y);
  bezierVertex(p3.x + offset, p3.y + offset, p4.x + offset, p4.y - offset, p4.x, p4.y);
  endShape(CLOSE);
  noLoop();
}

You might be able to get some help by reading this thread: Shape generator help (Armin Hofmann's 'rubber band' shape generator)

To get a smooth transition from a straight line to a circle the line needs to be a tangent so that seems to be a great place to start.

Now for 2 non-intersecting circles there will be 4 lines that are mutually tangential to both circles so calculating the tangent points on the circumferences will allow you to find these straight lines. Then try and connect these points with Bezier curves by calculating appropriate intermediate control points.

This web page (go to page 3) in the book shows how to calculate the tangent points.

I have created a small demo in p5js (JavaScript) that demonstrates the technique I described in my last post. It is not perfect but I think it can be modified to suit your purpose.

The small circle will follow the mouse position.

2 Likes

Hello @arkwl ,

Having some fun with your code:

  float rot = map(mouseX, 0, width/3, 0, TAU/8);

  // Edge points
  PVector p1 = c1.copy().add(PVector.fromAngle(angle1).mult(r1).rotate(-rot));
  PVector p2 = c2.copy().add(PVector.fromAngle(angle1).mult(r2).rotate(+rot));
  PVector p3 = c2.copy().add(PVector.fromAngle(angle2).mult(r2));
  PVector p4 = c1.copy().add(PVector.fromAngle(angle2).mult(r1));

It look much better with P2D on my PC.

:)

Hi @arkwl,

A visually similar approach would be to approximate the closing of the pair of circles by inwardly offsetting its outward offset. This eliminates the need to extract arc segments and connect them to the input circles.

Please refer to this post for a quick overview and to part 3 of this other post (entitled “metaballs”) for further explanations.

The main challenge in your case is to dynamically adjust the offset value to ensure the closing of the two circles remains valid regardless of their distance.

If this value is too low, the closing cannot be formed (the two parts of the blob remain disconnected), and if it is too high, the closing will extend beyond the circles’ boundaries.

One way of calculating a minimum offset value is to approximate the radius of the arcs that are externally tangent to the two circles (bitangent arcs), with the additional constraint that these arcs should not intersect each other.

The intuition is that these bitangent arcs naturally define the minimum curvature needed to smoothly connect the circles while maintaining their original shapes. By using this radius as our offset value, we ensure that the closing operation will always produce a valid, organic connection between the circles, regardless of their distance.

To compute a valid minimum radius for the bitangent arc circles, the procedure is as follows:

  • find the minimum radius R that makes the arcs tangent to both circles
  • use the Heron’s formula to locate the centers of the circular arcs
  • if the arc circles are colliding, iteratively increase R by a small factor until they are properly separated
  • compute an approximation of the closing of the pair of circles based on that updated R value using PGS_Morphology.dilationErosion()

gooey

Please note that I’ve added a mechanism that linearly increases the arc radius as the distance between the circles decreases. This creates a more organic, gooey feel to the connection - as if the circles were made of a sticky, elastic material that stretches more when they’re far apart.

Annotated Script (py5)
"""
Example script demonstrating how to create an organic connection between two circles
using morphological operations (dilation and erosion). The offset value is calculated
by finding two bitangent arcs between the circles. The arcs are guaranteed to be tangent
to both circles and will not intersect each other. The script handles various cases
including intersecting circles and provides a mechanism to control the separation
between the arcs.
           
# Author: sol/ub ◐
# Created: 2025-17-06
# Python Version: 3.9
# Context: Reply to @arkwl -> t.ly/5tipm (Processing Discourse Forum) 
           
"""

from micycle.pgs import *
from org.locationtech.jts.operation.buffer import BufferOp, BufferParameters
import math

W, H = 1400, 800       # Dimensions of canvas

R1 = 40                # Radius of the first circle
R2 = 160               # Radius of the second circle
C2x, C2y = W//2, H//2  # Coordinates of the second circle

SW = 2                 # Stroke weight
BG = '#FFF'            # Background color

# Define high-resolution buffer parameters (PGS related)
NS = 32                # increase for smoother contour
params = BufferParameters()
params.setQuadrantSegments(NS)     

def setup():
    size(W, H, P2D)
    smooth(8)
    
    global c2
    
    # Set the second circle at the center of the canvas
    c2 = create_shape(ELLIPSE, C2x, C2y, R2*2, R2*2)
    
def draw():
    background(BG)
    
    # Set the first ellipse at mouse position
    c1 = create_shape(ELLIPSE, mouse_x, mouse_y, R1*2, R1*2)
            
    # Calculate a minimum tangent arc radius (= offset value for the dilatation/erosion process)
    R = find_min_radius(mouse_x, mouse_y, R1, C2x, C2y, R2)
    
    # Convert the circles to JTS Geometry
    shapes = PGS_Conversion.flatten(*(c1, c2))
    geom = PGS_Conversion.fromPShape(shapes)
    
    # Dilate & erode
    dilated = BufferOp.bufferOp(geom, R, params)    # First buffer (dilation)
    closed = BufferOp.bufferOp(dilated, -R, params) # Second buffer (erosion)
    
    # Convert the closing back to PShape
    closing = PGS_Conversion.toPShape(closed)
    
    # Set colors and stroke weight
    closing.setStroke(color.Colors.BLACK)
    closing.setStrokeWeight(SW)
    closing.setFill(color.ColorUtils.composeColor(235, 238, 240))
    shape(closing)
        


def find_min_radius(x1, y1, r1, x2, y2, r2):
    
    # Calculate distance between circle centers
    dx = x2 - x1
    dy = y2 - y1
    d = math.sqrt(dx*dx + dy*dy)
    theta = math.atan2(dy, dx)
    
    extra_separation = constrain(remap(d, R2-R1, W*.8, 600, 1), 1, 600) # personalized values to be modified according to your preferences
    
    # Check if one circle is completely inside the other
    if d <= abs(r1 - r2):
        #raise ValueError("One circle is completely inside the other")
        return 0
    
    # Check if circles intersect
    if d < r1 + r2:
        # Calculate intersection points
        # Using the formula from: https://mathworld.wolfram.com/Circle-CircleIntersection.html
        d2 = d * d
        r12 = r1 * r1
        r22 = r2 * r2
        
        # Calculate the distance from circle 1's center to the intersection line
        a = (r12 - r22 + d2) / (2 * d)
        
        # Calculate the distance from the intersection line to the intersection points
        h = math.sqrt(r12 - a * a)
        
        # Calculate the intersection points
        x3 = x1 + a * dx / d
        y3 = y1 + a * dy / d
        
        # Calculate the perpendicular vector
        perp_x = -dy / d
        perp_y = dx / d
        
        # Calculate the two intersection points
        int1_x = x3 + h * perp_x
        int1_y = x3 + h * perp_y
        int2_x = x3 - h * perp_x
        int2_y = x3 - h * perp_y
        
        # Calculate the radius of the tangent arcs
        # The radius should be large enough to pass through both intersection points
        # and be tangent to both circles
        R = max(r1, r2) + extra_separation
        
        # Calculate the centers of the tangent arcs
        # They should be equidistant from both intersection points
        mid_x = (int1_x + int2_x) / 2
        mid_y = (int1_y + int2_y) / 2
        
        # Calculate the direction vector between intersection points
        dir_x = int2_x - int1_x
        dir_y = int2_y - int1_y
        dir_len = math.sqrt(dir_x * dir_x + dir_y * dir_y)
        dir_x /= dir_len
        dir_y /= dir_len
        
        # Calculate the perpendicular distance from midpoint to arc centers
        # Using the formula: R^2 = (d/2)^2 + h^2, where d is distance between intersection points
        h = math.sqrt(R * R - (dir_len/2) * (dir_len/2))
        
        # Calculate the arc centers
        center1_x = mid_x - h * dir_y
        center1_y = mid_y + h * dir_x
        center2_x = mid_x + h * dir_y
        center2_y = mid_y - h * dir_x
        
        return R
    
    # If circles don't intersect, use the original method
    # Calculate initial R for tangency
    R = (d*d - (r1 + r2)*(r1 + r2)) / (4 * (r1 + r2))
    
    # Calculate arc centers
    def calculate_centers(R):
        a, b, c = (r1 + R), (r2 + R), d
        s = (a + b + c) / 2
        
        # Calculate area using Heron's formula
        try:
            area = math.sqrt(s * (s - a) * (s - b) * (s - c))
        except ValueError:
            # If we get here, the triangle inequality doesn't hold
            # This shouldn't happen with the checks above, but just in case
            raise ValueError("Invalid triangle configuration")
            
        h = 2 * area / c
        
        # If h is too large, we need to adjust R
        if h >= a:
            # Calculate maximum possible h for current R
            h = a * 0.99  # Use 99% of a to ensure we're within bounds
        
        x = math.sqrt(a*a - h*h)
        
        center1_x = x1 + x * math.cos(theta) - h * math.sin(theta)
        center1_y = y1 + x * math.sin(theta) + h * math.cos(theta)
        center2_x = x1 + x * math.cos(theta) + h * math.sin(theta)
        center2_y = y1 + x * math.sin(theta) - h * math.cos(theta)
        
        return (center1_x, center1_y), (center2_x, center2_y)
    
    # Calculate initial positions
    center1, center2 = calculate_centers(R)
    
    # Check if arcs are too close
    arc_dx = center2[0] - center1[0]
    arc_dy = center2[1] - center1[1]
    arc_d = math.sqrt(arc_dx*arc_dx + arc_dy*arc_dy)
    
    # If arcs are too close, increase R until they're properly separated
    target_separation = 2 * R + extra_separation
    
    if arc_d < target_separation:
        max_attempts = 100  # Prevent infinite loops
        attempts = 0
        while arc_d < target_separation and attempts < max_attempts:
            R *= 1.1  # Increase R by 10%
            center1, center2 = calculate_centers(R)
            
            # Calculate new distance between centers
            arc_dx = center2[0] - center1[0]
            arc_dy = center2[1] - center1[1]
            arc_d = math.sqrt(arc_dx*arc_dx + arc_dy*arc_dy)
            attempts += 1
    
    return R
3 Likes