Dynamic way to connect the balls with rods

I'm trying to illustrate a 3D mathematical mesh of points with balls and rods (see screenshot) using a script. However, if I move a ball, the connecting rods would have to be updated because they are not connected.

Each ball object in the 3D array can be addressed by ob = La[iz][iy][ix] and each pair is connected by a call to connect_them(ob1, ob2, rrod) as shown in the script.

Is there some way I could adjust connect_them to make the connection permanent? So if I move one of the balls by 3D cursor or python, all of the rods that connect to it are carried along?

note: by "adjust connect_them" I mean implement this as a modification to the script - not do it by hand each time.

Let's assume that the radius of the balls is larger than the radius of the rods, and balls are not transparent, so any ugliness at the rod ends is inside the ball and therefore not visible.

note: I have maintained the directionality of the connector.

def get_ico_data(n_subdiv=3, size=1.0, location=(0,0,1.0)):
make_ico(subdivisions=n_subdiv, size=size, location=location)
ico = bpy.context.active_object
ico_data = ico.data.copy()
bpy.ops.object.delete()
return ico_data

def get_cyl_data(n_sides=12, radius=1.0, height=2.0, location=(0, 0, 1)):
cyl = bpy.context.active_object
cyl_data = cyl.data.copy()
bpy.ops.object.delete()
return cyl_data

def make_new_meshobject_from_data(data, name=None, scene=None):
if scene == None:
scene = bpy.context.scene
if name == None:
name = "thing"
me = bpy.data.meshes.new(name)
ob = bpy.data.objects.new(name, me)
ob.data = data.copy()
ob.select = True
return ob

def connect_them(ob1, ob2, rad):
loc1, loc2 = ob1.location, ob2.location
vec = loc2 - loc1
cen = 0.5*(loc1 + loc2)
phi = np.arctan2(vec.y, vec.x)
theta = np.arccos(vec.normalized().z)
h = vec.length
cyl = make_new_meshobject_from_data(cyl_data)
cyl.location = cen
cyl.rotation_euler = (0.0, theta, phi)
return cyl

import bpy
import numpy as np

ico_data = get_ico_data(n_subdiv=2, size=1.0,               location=(0,0,0))
cyl_data = get_cyl_data(n_sides=12, radius=1.0, height=1.0, location=(0,0,0))

nx, ny, nz = 3, 5, 7

xa = np.arange(nx, dtype = 'float') - 1.0*float(nx-1)
ya = np.arange(ny, dtype = 'float') - 0.5*float(ny-1)
za = np.arange(nz, dtype = 'float') + 0.5

rsph = 0.12
La = []
for iz, z in enumerate(za):
L1a = []
for iy, y in enumerate(ya):
L2a = []
for ix, x in enumerate(xa):
ico = make_new_meshobject_from_data(ico_data)
ico.scale = [rsph]*3
ico.location = [x, 2.0*y / np.sqrt(z+1.), 1.2*z**0.8] # uneven spacing illustration
L2a.append(ico)
L1a.append(L2a)
La.append(L1a)

rrod = 0.04

# connect in z
for iz in range(nz-1):
for iy in range(ny):
for ix in range(nx):
ob1 = La[iz][iy][ix]
ob2 = La[iz+1][iy][ix]
connect_them(ob1, ob2, rrod)

# connect in y
for iz in range(nz):
for iy in range(ny-1):
for ix in range(nx):
ob1 = La[iz][iy][ix]
ob2 = La[iz][iy+1][ix]
connect_them(ob1, ob2, rrod)

# connect in x
for iz in range(nz):
for iy in range(ny):
for ix in range(nx-1):
ob1 = La[iz][iy][ix]
ob2 = La[iz][iy][ix+1]
connect_them(ob1, ob2, rrod)


EDIT: So based on the suggestions in the answer by @TLousky I have this so far: with partial results. See new script below. I'm still trying to figure out how to script the "hair"!

def get_ico_data(n_subdiv=3, size=1.0, location=(0,0,1.0)):
make_ico(subdivisions=n_subdiv, size=size, location=location)
ico = bpy.context.active_object
ico_data = ico.data.copy()
bpy.ops.object.delete()
return ico_data

def get_cyl_data(n_sides=12, radius=1.0, height=2.0, location=(0, 0, 1)):
cyl = bpy.context.active_object
cyl_data = cyl.data.copy()
bpy.ops.object.delete()
return cyl_data

def make_new_meshobject_from_data(data, name=None, scene=None):
if scene == None:
scene = bpy.context.scene
if name == None:
name = "thing"
me = bpy.data.meshes.new(name)
ob = bpy.data.objects.new(name, me)
ob.data = data.copy()
ob.select = True
return ob

def connect_them(ob1, ob2, rad):
loc1, loc2 = ob1.location, ob2.location
vec = loc2 - loc1
cen = 0.5*(loc1 + loc2)
phi = np.arctan2(vec.y, vec.x)
theta = np.arccos(vec.normalized().z)
h = vec.length
cyl = make_new_meshobject_from_data(cyl_data)
cyl.location = cen
cyl.rotation_euler = (0.0, theta, phi)
return cyl

import bpy
import bmesh
import numpy as np

#ico_data = get_ico_data(n_subdiv=2, size=1.0,               location=(0,0,0))
#cyl_data = get_cyl_data(n_sides=12, radius=1.0, height=1.0, location=(0,0,0))

nx, ny, nz = 3, 5, 7

xa = np.arange(nx, dtype = 'float') - 1.0*float(nx-1)
ya = np.arange(ny, dtype = 'float') - 0.5*float(ny-1)
za = np.arange(nz, dtype = 'float') + 0.5

Xa, Ya, Za = np.meshgrid(xa, ya, za, indexing='ij')
# ico.location = [x, 2.0*y / np.sqrt(z+1.), 1.2*z**0.8]
Ya = 2.0*Ya / np.sqrt(Za+1.0)
Za = 1.2*Za**0.8

verts = list(zip(Xa.flatten(), Ya.flatten(), Za.flatten()))
nverts = nx*ny*nz

edges = []

# for z-edges
for i in range(nverts):
if (i+1)%nz:  # all except for the last one in each a-row
edges.append((i, i+1))

# for y-edges
for i in range(nverts):
if ((i//nz)+1) % ny: # all except the last one in each y-row
edges.append((i, i+nz))

# for x-edges
for i in range(nverts):
if ((i//(nz*ny)) + 1) % nx:
edges.append((i, i+nz*ny))

me = bpy.data.meshes.new("wire")
me.from_pydata(verts, edges, [])

obme = bpy.data.objects.new("wire_we_here", me)
obme.location = (0, 0, 0)  # bpy.context.scene.cursor_location

bpy.context.scene.objects.active = obme
obme.select=True

oo = bpy.ops.object

oo.mode_set( mode = 'EDIT' )
bm = bmesh.from_edit_mesh( obme.data )

n = len( bm.verts )

for i in range(n):
oo.mode_set( mode = 'EDIT' )
bm = bmesh.from_edit_mesh( obme.data )

bpy.ops.mesh.select_all( action = 'DESELECT' )
bm.verts.ensure_lookup_table()
bm.verts[i].select = True
bm.select_flush( True )

bpy.ops.object.mode_set( mode = 'OBJECT' )
bpy.ops.object.select_all( action = 'DESELECT' )

obme.select = True
bpy.context.scene.objects.active = obme

do_the_rest_of_it = True
if do_the_rest_of_it:

ico_data = get_ico_data(n_subdiv=3, size=0.5, location=(0,0,0.0))

sphere_obj = make_new_meshobject_from_data(ico_data, name="ball")

#sphere_obj = bpy.context.scene.objects['Sphere']

empties = [ o for o in bpy.context.scene.objects if o.type == 'EMPTY' ]
for e in empties:
e.empty_draw_type = 'SPHERE'
#e.empty_draw_size = 0.2
e.empty_draw_size = sphere_obj.dimensions.z / 5.0

obme.select = True
bpy.context.scene.objects.active = obme

oo = bpy.ops.object


It Works! The solution by @TLousky in the answer works great!

• The last image (8x10x12 grid with glowing blue inside) was made using the excellent answer of @TLousky below. – uhoh Sep 22 '15 at 4:16
• balls and rods made from skin and hair (modifiers and particles) - creepy :) – uhoh Sep 22 '15 at 6:47

I'd like to suggest a different approach to solve this (download blend file here), that might be simpler.

The mesh is a simple edge wireframe, and the spheres are generated by a particle system (type hair, count = number of verts, render type = object, emit from verts without any randomness and set object size to 0.1).

The wireframe is displayed using a skin modifier with a subdivision surface.

And to move every sphere and the mesh connected to it, you can use a hook per vertex, which can be rather easily created with this script:

import bpy, bmesh

o = bpy.context.object

bpy.ops.object.mode_set( mode = 'EDIT' )
bm = bmesh.from_edit_mesh( o.data )

n = len( bm.verts )

for i in range(n):
bpy.ops.object.mode_set( mode = 'EDIT' )
bm = bmesh.from_edit_mesh( o.data )

bpy.ops.mesh.select_all( action = 'DESELECT' )
bm.verts.ensure_lookup_table()
bm.verts[i].select = True
bm.select_flush( True )

bpy.ops.object.mode_set( mode = 'OBJECT' )
bpy.ops.object.select_all( action = 'DESELECT' )

o.select = True
bpy.context.scene.objects.active = o


If you want it to look nicer you can set the hook empties to be of type sphere and adjust their size to fit perfectly to the sphere object you use to create the balls on each vert:

sphereObj = bpy.context.scene.objects['Sphere']
empties = [ o for o in C.scene.objects if o.type == 'EMPTY' ]
for e in empties:
e.empty_draw_type = 'SPHERE'
e.empty_draw_size = sphereObj.dimensions.z / 5


Then all you have to do is make your mesh unselectable and manipulate it only with the hooks.

EDIT: I completed the script including creating the particle system, setting the skin radius and adding the modifiers and hooks, you just need to replace the arbitrary mesh data with the one you actually need:

import bpy, bmesh

# Sample mesh - a subdivided cube
verts = [(-1.0, -1.0, -1.0), (-1.0, -1.0, 1.0), (-1.0, 1.0, -1.0), (-1.0, 1.0, 1.0), (1.0, -1.0, -1.0), (1.0, -1.0, 1.0), (1.0, 1.0, -1.0), (1.0, 1.0, 1.0), (0.0, 1.0, 1.0), (0.0, 1.0, -1.0), (0.0, -1.0, 1.0), (0.0, -1.0, -1.0), (-1.0, -1.0, 0.0), (-1.0, 1.0, 0.0), (1.0, 1.0, 0.0), (1.0, -1.0, 0.0), (0.0, -1.0, 0.0), (0.0, 1.0, 0.0), (-1.0, 0.0, 1.0), (-1.0, 0.0, -1.0), (1.0, 0.0, 1.0), (1.0, 0.0, -1.0), (0.0, 0.0, 1.0), (0.0, 0.0, -1.0), (1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)]

edges = [(12, 1), (18, 3), (13, 2), (19, 0), (8, 7), (14, 6), (9, 2), (20, 5), (15, 4), (21, 6), (10, 1), (11, 4), (3, 8), (6, 9), (5, 10), (0, 11), (22, 10), (23, 11), (16, 11), (17, 9), (0, 12), (3, 13), (7, 14), (5, 15), (10, 16), (8, 17), (17, 13), (16, 15), (12, 16), (24, 14), (14, 17), (25, 12), (1, 18), (2, 19), (7, 20), (4, 21), (8, 22), (9, 23), (15, 24), (13, 25), (18, 25), (20, 24), (19, 23), (20, 22), (22, 18), (23, 21), (24, 21), (25, 19)]

## Generate Mesh from pydata
def make_mesh( context, name, verts, edges ):
mesh = bpy.data.meshes.new( name = name )

o = bpy.data.objects.new(name, mesh)
o.location = (0,0,0) # place at object origin

mesh.from_pydata( verts, edges, [] )

return o

o = make_mesh( bpy.context, 'myMesh', verts, edges )

o.hide_select = True # Make mesh unclickable

## Create sphere for particle system
bpy.ops.mesh.primitive_ico_sphere_add( size = 0.5 )
s = bpy.context.scene.objects[ bpy.context.object.name ]
s.layers = [ False for i in range(19) ] + [ True ] # Place sphere on last layer only

bpy.ops.object.select_all( action = 'DESELECT' )
o.select = True
bpy.context.scene.objects.active = o

bpy.ops.object.mode_set( mode = 'EDIT' )
bm = bmesh.from_edit_mesh( o.data )

n = len( bm.verts )

for i in range(n):
bpy.ops.object.mode_set( mode = 'EDIT' )
bm = bmesh.from_edit_mesh( o.data )

bpy.ops.mesh.select_all( action = 'DESELECT' )
bm.verts.ensure_lookup_table()
bm.verts[i].select = True
bm.select_flush( True )

bpy.ops.object.mode_set( mode = 'OBJECT' )
bpy.ops.object.select_all( action = 'DESELECT' )

o.select = True
bpy.context.scene.objects.active = o

empties = [ o for o in bpy.context.scene.objects if o.type == 'EMPTY' ]
for e in empties:
e.empty_draw_type = 'SPHERE'
e.empty_draw_size = s.dimensions.z / 5

## Add and set particle system

psys = o.particle_systems[0].settings
psys.count             = len( o.data.vertices )
psys.type              = 'HAIR'
psys.emit_from         = 'VERT'
psys.use_emit_random   = False
psys.render_type       = 'OBJECT'
psys.dupli_object      = s
psys.particle_size     = 0.1

## Set up skin and subsurf modifiers
bpy.ops.object.modifier_add( type = 'SKIN' )

# Set skin radius for all verts
for v in o.data.skin_vertices[0].data:
v.radius = [ 0.05, 0.05 ]

bpy.ops.object.modifier_add( type = 'SUBSURF' )

• Wow a hairy wireframe! OK I will get back to a computer in a bit and take it for a spin in the scripting window. So I think that you are thinking that I should create the 3D wireframe topology with newmesh.from_pydata()? – uhoh Sep 21 '15 at 14:29
• Yup, just verts and edges. Hairy wireframe :-D – TLousky Sep 21 '15 at 14:36
• I think I'm close now!! - see edit to question. This thing needs to create itself, so I need to do all steps within the script. – uhoh Sep 21 '15 at 16:32
• @uhoh - I completed the script (see the one at the bottom), just replace the arbitrary mesh data with your own and it should work. Run it in an empty scene to test and see exactly what's going on. – TLousky Sep 21 '15 at 17:34
• +1 Fantastic! This works great!! I've added one more image at the bottom of the question. So to make the mesh "wiggle and dance" just need to access the locations of the Empty.xxx' objects. Thank you @TLousky! You've also taught a lot about particle system scripting as well. Super! – uhoh Sep 22 '15 at 4:13

Another option after creating a sphere at each point is to use a curve to join each pair together. A curve with two points and two hook modifiers can keep each end of the curve attached to the sphere and with vector handles they will stay straight between each end.

A nice bonus is being able to add a driver to the bevel_depth that will give you an automatic size change based on the distance (like this answer), so it can get thinner as the two ends are stretched apart.

While the code to replace your connect_them() looks large it is just setting options, nothing fancy.

conRes = 3

def connect_them(ob1, ob2, rad):
# create the curve data
jd = bpy.data.curves.new(name='curve',type='CURVE')
jd.bevel_resolution = conRes
jd.dimensions = '3D'
jd.fill_mode = 'FULL'

# create the curve object
jo = bpy.data.objects.new('joiner', jd)
spl = jd.splines.new(type='BEZIER')
p1 = spl.bezier_points[0]
p1.handle_left_type = 'VECTOR'
p1.handle_right_type = 'VECTOR'
p1.co = ob1.location
p2 = spl.bezier_points[1]
p2.handle_left_type = 'VECTOR'
p2.handle_right_type = 'VECTOR'
p2.co = ob2.location

# create hook modifiers
scene.objects.active = jo
bpy.ops.object.mode_set(mode="EDIT")
p = jd.splines[0].bezier_points
p[0].select_control_point = True
p[1].select_control_point = False
hm1 = jo.modifiers.new(name='Hook1',type='HOOK')
hm1.object = ob1
bpy.ops.object.hook_assign(modifier='Hook1')

p[0].select_control_point = False
p[1].select_control_point = True
hm2 = jo.modifiers.new(name='Hook2',type='HOOK')
hm2.object = ob2
bpy.ops.object.hook_assign(modifier='Hook2')

bpy.ops.object.mode_set(mode="OBJECT")

# add driver to alter diamater
`