3
$\begingroup$

I would like to generate a 'N' number of spheres having different diameters within a cube. The condition is that the spheres shall not overlap. I use the following code to generate spheres (random diameter and random placement), however the spheres do overlap with each other.

import bpy
from random import randint

numX = 4
numY = 4
numZ = 4

bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False, confirm=False)

for x in range (0,numX):
    posX = randint(-25, 25)
    for y in range (0,numY):
        posY = randint(-25, 25)
        for z in range (0,numZ):
            posZ = randint(-25, 25)
            bpy.ops.mesh.primitive_uv_sphere_add(radius=randint(1,2), location=(posX,posY,posZ))
            bpy.ops.object.shade_smooth()

How can I overcome the issue with overlapping of spheres? Any pointers are welcome.

$\endgroup$
6
  • $\begingroup$ Is it important that the random x,y,z values are used in a nested fashion? This creates a pattern of pillar like distributions which is different from real random placement within the cube. $\endgroup$
    – taiyo
    Commented Jul 19, 2023 at 15:37
  • $\begingroup$ Could you say why you need these spheres? Could be the infamous XY problem. $\endgroup$ Commented Jul 20, 2023 at 8:18
  • $\begingroup$ @taiyo I guess what you mentioned is true. What I did creates a patterned arrangement of spheres in a cube (due to the nested loop perhaps), but I just tried to see how the nested loop works at the 1st instance. As you mentioned, the objective is to have random placement of the spheres in the cube and not have a patterned arrangement. $\endgroup$
    – Saideep
    Commented Jul 20, 2023 at 10:46
  • $\begingroup$ @Markus von Broady - I'm trying to generate an idealized (idealized because in reality the solid grains are not always spherical) porous media where the spheres will be accounted as solid obstacles and gap between the spheres will be the pore space through which fluid flows. I'm not aware of the 'XY problem' that you mentioned. $\endgroup$
    – Saideep
    Commented Jul 20, 2023 at 10:53
  • $\begingroup$ @Saideep en.wikipedia.org/wiki/XY_problem do you want to pack those spheres? Clearly you don't want to have same-sized, hexagonal packing of spheres, but perhaps something more like on the right side of the picture? en.wikipedia.org/wiki/File:Order_and_Chaos.tif $\endgroup$ Commented Jul 20, 2023 at 11:02

3 Answers 3

5
$\begingroup$

This is a tough question. There are many strategies possible to remove overlapping, all with their pros and cons. You could simply remove all overlapping spheres in a second pass and be happy with that. You could add some another spheres and repeat. You could try random positions for a new sphere until it does not overlap with any existing ones. You could move overlapping spheres away from each other and repeat until all spheres found a unique place, and so on... Brute force methods may be simple to implement but rather soon impractical, entering the realm of performance optimizations (for example space partitioning like Octrees).

I suggest you are looking for a non necessarily perfect method which is good enough to continue to work with. A very short way to achieve that is to give every sphere a rigid body and let the built-in physics engine do the collision resolving work for you. Try this script as a starter:

import bpy
from random import randint

numX = 4
numY = 4
numZ = 4

bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False, confirm=False)

bpy.context.scene.frame_set(1) # reset frame

for x in range (0,numX):
    posX = randint(-25, 25)
    for y in range (0,numY):
        posY = randint(-25, 25)
        for z in range (0,numZ):
            posZ = randint(-25, 25)
            bpy.ops.mesh.primitive_uv_sphere_add(radius=randint(1,2), location=(posX,posY,posZ))
            bpy.ops.rigidbody.object_add()   # connect to the physics api
            bpy.context.object.rigid_body.collision_shape = 'SPHERE'  # edit: faster than convex hull              
            bpy.ops.object.shade_smooth()
            
bpy.context.scene.frame_set(2)  # run the physics api for one frame to resolve overlapping

The big advantage is that you dont need to think about implementing your own collision detection and resolving algorithm (which is -as i hope made clear- pretty hard done well) and can utilize what others have already done better.

Keep in mind that you may have to tweak the simulation step and clean up the results appropiate to your problem, that may include

  • dont use gravity
  • throw away the rigid bodies
  • check spheres against the cube hull
  • ...

but the overlappings should be resolved.

Edit: I did some quick tests with 1000 spheres (numX/Y/Z = 10). Adding the spheres to the scene (no matter if with rigid body or not) is by far slower than the physics simulation step. Thus the simulation step is not the bottleneck here, but a more efficient way to add a lot of objects should be found.

Edit2: Investigated further the bottleneck. It turns out that bpy.ops calls (especially in loops) are slow as they trigger UI updates. Bypassing these calls ramps up the performance. For anyone who is interested i have assembled a script which creates 1000 non overlapping spheres in under a second (10000 spheres are close to 10 seconds) on my machine, whereas the original script takes around 2 minutes.

import bpy
import time
import random

NUMSPHERES = 1000


bpy.ops.object.select_all(action='DESELECT')
# geometry resolution and real duplicates affect performance of setup step
bpy.ops.mesh.primitive_uv_sphere_add(segments=8, ring_count=4)
sphere = bpy.context.object

if bpy.context.scene.rigidbody_world == None:
    bpy.ops.rigidbody.world_add()

spheres = []

# create n spheres without ops
t1 = time.time()

for i in range(NUMSPHERES - 1):
    obj = sphere.copy()
    #obj.data = sphere.data.copy()  # activate for real duplicates
    spheres.append(obj)
    bpy.context.collection.objects.link(obj)
    sphere.select_set(True)

# add rigid body for all at once
bpy.ops.rigidbody.objects_add(type='ACTIVE')

# optimize rigid body shape and tweak spheres
for sphere in spheres:
    sphere.rigid_body.collision_shape = 'SPHERE'
    sphere.location = (random.uniform(-25, 25), random.uniform(-25, 25), random.uniform(-25, 25))
    sc = random.uniform(1, 2)
    sphere.scale = (sc, sc, sc)

t2 = time.time()
print("setup spheres:", t2 - t1)

# run the physics api to resolve overlapping 
t1 = time.time()

bpy.context.scene.frame_set(1)     
bpy.context.scene.frame_set(2) 

# clean up
bpy.ops.object.visual_transform_apply() # apply simulation result
bpy.ops.rigidbody.objects_remove()      # remove rigid body for all at once
bpy.context.scene.frame_set(1)          # reset timeline
bpy.ops.object.select_all(action='DESELECT')

t2 = time.time()
print("simulation step:", t2 - t1)
$\endgroup$
7
  • $\begingroup$ @HarryMcKenzie How do you come to that conslusion? I edited my post with some tests. I heavily doubt that any self written version is faster. Except for some very rough approaches, e.g. simply removing overlapping spheres, but even this has quadratic runtime in its simplest form. $\endgroup$
    – taiyo
    Commented Jul 19, 2023 at 17:34
  • $\begingroup$ sorry i made a mistake in my tests. i ran the tests again and physics simulation is indeed faster with 0.65 seconds for 1000 objects on my machine. my version with overlap tests is a bit slower @ 0.84 seconds for 1000 objects. $\endgroup$
    – Harry McKenzie
    Commented Jul 19, 2023 at 23:15
  • $\begingroup$ i did another test by improving the check_overlap function. and now its faster @ 0.54 seconds for 1000 objects. can you confirm? $\endgroup$
    – Harry McKenzie
    Commented Jul 19, 2023 at 23:24
  • $\begingroup$ Yes can confirm, for 1000 spheres its very fast now +1. I'd say the physics setup has a little overhead in that region. But i have encountered problems when raising the number of spheres to 10000... $\endgroup$
    – taiyo
    Commented Jul 19, 2023 at 23:40
  • 1
    $\begingroup$ Physics add a lot of overhead. Problem is, Python interpreter adds even more overhead… BVHtree could be used to more efficiently find an overlap. Sadly Poisson Disk distribution can't be used for varying radius, unless you're fine distributing first, and then decreasing the size of some spheres (which might end up with similar result anyway...) $\endgroup$ Commented Jul 20, 2023 at 8:55
4
$\begingroup$

You can keep track of all the spheres in your scene and do an overlap check like in the following script. This creates 1000 spheres in just 0.5 seconds on my machine.

import bpy
import math
import random as r
import mathutils as m
import time

t = time.time()

def create_uv_sphere_mesh(name, radius, segments, rings):
    mesh = bpy.data.meshes.get(name)
    if mesh:
        return mesh
    bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=(0, 0, 0))
    mesh = bpy.context.object.data
    mesh.name = name
    return mesh

def create_uv_sphere(radius, pos, segments=8, rings=4):
    mesh = create_uv_sphere_mesh("UV_Sphere", 1, segments, rings)
    uv_sphere = bpy.data.objects.new(name="UV_Sphere", object_data=mesh)
    
    bpy.context.collection.objects.link(uv_sphere)
    bpy.context.view_layer.objects.active = uv_sphere
    uv_sphere.select_set(True)
    uv_sphere.location = pos
    uv_sphere.scale = (radius, radius, radius)
    return uv_sphere


MAX_ATTEMPTS = 1000
MAX_SPHERES = 1000

spheres = []

bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False, confirm=False)


def check_overlap(p, r):
    for pos, radius in spheres:
        if (pos - p).length < radius + r:
            return True
    return False

def add_sphere():
    radius = r.uniform(1, 2)
    pos = None
    a = 0

    while a < MAX_ATTEMPTS:
        a += 1
        pos = m.Vector((r.uniform(-25, 25), r.uniform(-25, 25), r.uniform(-25, 25)))
        if not check_overlap(pos, radius):
            break

    if a < MAX_ATTEMPTS:
        create_uv_sphere(radius, pos)
        #bpy.ops.object.shade_smooth()
        spheres.append((pos, radius))
    else:
        print("Max attempts reached so skipped placement of sphere due to overlap constraint")


for _ in range(MAX_SPHERES):
    add_sphere()
            
print(time.time() - t)
$\endgroup$
6
  • $\begingroup$ why def create_uv_sphere? Why not just add a primitive? Was this code AI-generated? Very sus :P $\endgroup$ Commented Jul 20, 2023 at 8:53
  • $\begingroup$ yes just the create_mesh function because i needed a quick way to generate primitive without using bpy.ops function haha, is there a non bpy.ops function to do that? $\endgroup$
    – Harry McKenzie
    Commented Jul 20, 2023 at 9:02
  • $\begingroup$ Do you think bpy.ops function is slower? I doubt that. Also, you don't use BVHtree, so you don't need to create actual geometry in order to check for an overlap. Just comment out the create_uv_sphere(...), and instead mantain a list of vertex positions and create a mesh from it at the end, store an attribute with radiuses and use e.g. geonodes to instance a single sphere on that (not sure if vanilla instancer allows to scale instances?) - should be much more efficient $\endgroup$ Commented Jul 20, 2023 at 9:14
  • $\begingroup$ yeah it was super slow when i used the bpy.ops.mesh.primitive_uv_sphere_add and i read that it's better to avoid bpy.ops functions. but in my script im just drawing the geometry ones and reusing the mesh. im only creating container objects that reference the same mesh and use the scale to adjust the radius. EDIT. oh well i can stick to using the bpy.ops coz im only drawing it once after all hahahaha wth $\endgroup$
    – Harry McKenzie
    Commented Jul 20, 2023 at 9:41
  • $\begingroup$ updated my code! i was initially looping and creating lots of meshes with bpy.ops that's why it was so slow and then i changed it to reuse the mesh and then i realized now i don't need to avoid it anymore lol $\endgroup$
    – Harry McKenzie
    Commented Jul 20, 2023 at 9:52
4
$\begingroup$

Since the problem is so lightly constrained, I decided to try just sampling Voronoi and I think I got a decent result - below 7.4k bubbles created in about a second:

Most of the code is about setting up a geonodes tree to render it efficiently using instances:

import bpy
from bpy import context as C, data as D
from mathutils.noise import voronoi


def vector_to_rounded_tuple(point):
    return tuple(round(v, 5) for v in point) # round to 5 significant digits


points = [voronoi((0,0,0))[1][0]]  # start with nearest voronoi point to origin
point_set = {vector_to_rounded_tuple(points[0])}
sphere_positions = []
radii = []
index = 0


def main():
    global index
    while index < len(points):
        evaluate_point()
        index += 1
    display_results()


def evaluate_point():
    spawner = points[index]
    distances, more_points = voronoi(spawner)
    signed_distance_to_boundary = 10 - max(abs(v) for v in spawner)
    
    if signed_distance_to_boundary < -1:
        return
    
    # override distance to self with distance to boundary
    distances[0] = signed_distance_to_boundary*2
    radius = min(distances) / 2  # meet me halfway

    # min r = 10%
    if signed_distance_to_boundary >= .1:
        sphere_positions.append(spawner)
        radii.append(radius)
        
    for candidate in more_points:
        rounded = vector_to_rounded_tuple(candidate)
        if rounded not in point_set:
            point_set.add(rounded)
            points.append(candidate)


def display_results():
    name = 'Bubblez'
    ob = D.objects.get(name)
    if ob:
        old_me = ob.data
        old_me.name = 'remove'    
        
    me = D.meshes.new('bubblez')
    me.from_pydata(sphere_positions, [], [])
    attr = me.attributes.new('radii', 'FLOAT', 'POINT')
    attr.data.foreach_set('value', radii)
    
    if ob:
        ob.data = me
        D.meshes.remove(old_me)  # Warning! There could be more users...
    else:
        ob = D.objects.new(name, me)
        C.collection.objects.link(ob)
    print("??")
    
    mods = ob.modifiers
    if name in mods:
        return
    tree = D.node_groups.get(name)
    if not tree:
        tree = create_geonode_tree(name)
    mod = mods.new(name, 'NODES')
    mod.node_group = tree
    

def create_geonode_tree(name):
    tree = D.node_groups.new(name, 'GeometryNodeTree')
    tree.inputs.new('NodeSocketGeometry', 'Geometry')
    tree.outputs.new('NodeSocketGeometry', 'Geometry')
    
    nodes = tree.nodes
    node_in = nodes.new(type='NodeGroupInput')
    node_instance = nodes.new(type='GeometryNodeInstanceOnPoints')
    node_out = nodes.new(type='NodeGroupOutput')
    
    node_ico = nodes.new(type='GeometryNodeMeshIcoSphere')
    node_ico.inputs['Subdivisions'].default_value = 3
    
    node_attr = nodes.new(type='GeometryNodeInputNamedAttribute')
    node_attr.data_type = 'FLOAT'
    node_attr.inputs['Name'].default_value = 'radii'

    links = tree.links
    links.new(node_in.outputs[0], node_instance.inputs['Points'])
    links.new(node_ico.outputs['Mesh'], node_instance.inputs['Instance'])
    
    attr_socket = next(o for o in node_attr.outputs if o.name == 'Attribute'
        and o.type == 'VALUE')
    links.new(attr_socket, node_instance.inputs['Scale'])    
    links.new(node_instance.outputs['Instances'], node_out.inputs[0])
    return tree
    
    
main()

Some last minute considerations:

  • I see now I do nothing if the point is too far away from the boundary - I should move this check to avoid adding the point to a list, so they are not evaluated to begin with.
  • To change the size of spheres, simply scale the object afterwards and adjust the minimum radius and the boundary accordingly.
  • To randomize, add a random offset when sampling the voronoi.
  • For a tighter packing I would consider using simulation nodes to efficiently grow bubbles, as I think the Python tag constraint is unnecessary for this question (at least OP never justified it).
  • For a tighter packing but using Python, consider using the Poisson Disk algorithm, but adjusted for the varying range. BVH might be heavy if you keep updating it, but maybe using the KDTree.find to find nearest will be useful.
$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .