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()
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