long exposure light drawing of gears

Designs for all the hardware and software used in this blog post is available here under a Creative Commons Attribution-ShareAlike 3.0 Unported license.

Introduction

I have been experimenting with using a computer controlled delta robot to draw long exposure light animations.

By using a precisely controllable robot to draw each frame of the animation a level of precision is achieved which gives the animation a quality similar to that of CGI animations, while still maintaining natural lighting.

The process of drawing a single frame can be seen in the following video. This would of course take place in a darkened room with a camera set to take a long exposure photograph pointing towards the base of the delta robot.

animaion caputre

Delta Robot

delta robot

The delta robot used to create these animations is a custom design. All the hardware designs and control software and firmware are available here. The electronics is all based around an STM32F4 Discovery board because it has a floating point unit which is required to perform the inverse kinematics code fast enough using floating point arithmetic. The electronics for the project is largely undocumented as the design evolved while the robot was being built. Hardware connections to the STM32F4 Discovery board can be found in the “hardware.h” file in the git repository. The motor drivers used are Pololu A4988 driver boards.

electronics

Software

The delta robot is controlled using GCode. The following python script was used to generate the GCode for the animations. The spindle on/off command is used to control the LED. The GCode is then fed to the delta robot via a serial link, where it is then interpreted and the actions performed.

#!/usr/bin/env python
import random
import math
from random import randint


def ease_in_quad(time, begin, change, duration):
    time = float(time)
    begin = float(begin)
    change = float(change)
    duration = float(duration)
    val = change*(time/duration)*(time/duration) + begin
    return val

def ease_out_quad(time, begin, change, duration):
    time = float(time)
    begin = float(begin)
    change = float(change)
    duration = float(duration)
    val = -change*(time/duration)*((time/duration)-2.0) + begin
    return val

def generate_circle(raidus=1.0, degree_steps=10):
    points = []
    points.append((raidus, 0, 0))

    for val in range(degree_steps - 1):
        angle = (360.0 / degree_steps) * (val + 1)
        r = math.radians(angle)
        x = points[0][0]
        y = points[0][1]
        z = points[0][2]
        x2 = x * math.cos(r) - y * math.sin(r)
        y2 = y * math.cos(r) + x * math.sin(r)
        z2 = z
        points.append((x2, y2, z2))
    return points

def generate_line(start=(0,0,50), end=(0,0,45)):
    points = [start, end]
    return points

def circle(position, radius=10.0, degree_steps=10):
    points = []
    face = []
    points = generate_circle(radius, degree_steps)
    points = add_offset(points, position)
    face = range(len(points))
    face.append(0)
    return points, face

def line(start, end):
    points = generate_line(start, end)
    face = range(len(points))
    return points, face


def add_offset(points, (x_offset, y_offset, z_offset)):
    new_points = []
    for index in range(len(points)):
        x = points[index][0] + x_offset
        y = points[index][1] + y_offset
        z = points[index][2] + z_offset
        new_points.append((x, y, z))
    return new_points


def add_shape((points, faces), (new_points, new_face)):
    if new_face == None or new_points == None:
        return (points, faces)
    new_face = [f+len(points) for f in new_face]
    faces.append(new_face)
    points += new_points
    return (points, faces)

def add_object((points, faces), (new_points, new_faces)):
    if new_faces == None or new_points == None:
        return (points, faces)
    for new_face in new_faces:
        new_face = [f+len(points) for f in new_face]
        faces.append(new_face)
    points += new_points
    return points, faces

def save_as_gcode(points, faces, filename = 'output.gcode'):
    spindle_on = "M3"    
    spindle_off = "M5"
    f = open(filename, 'w')
    drawn = []
    for face in faces:
        for index, vertex_index in enumerate(face):
            if index == 1:
                f.write(spindle_on+'\n')
            vertex = points[vertex_index]
            x = vertex[0]
            y = vertex[1]
            z = vertex[2]
            cmd = "G1 X"+str(x)+" Y"+str(y)+" Z"+str(z)+ " F"+'3200.0'+'\n'
            f.write(cmd)
            drawn.append(cmd)
        f.write(spindle_off+'\n')
    f.close()


class ExpandingCircleAnimation:
    def __init__(self, position, start_tick, end_tick, tween_function, begin_radius, end_radius, degree_steps=20.0):
        self.start_tick = start_tick
        self.end_tick = end_tick
        self.tween_function = tween_function
        self.begin_radius = begin_radius
        self.end_radius = end_radius
        self.position = position
        self.degree_steps = degree_steps

    def get(self, tick):

        if tick < self.start_tick or tick >= self.end_tick:
            return None, None

        time = tick - self.start_tick 
        begin = self.begin_radius
        change = self.end_radius - self.begin_radius
        duration = self.end_tick - self.start_tick
        radius = self.tween_function(time, begin, change, duration)
        points, faces =  circle(self.position, radius, int(self.degree_steps))
        return (points, faces)


class FallingLineAnimation:
    def __init__(self, position, start_tick, end_tick, tween_function, begin_z, end_z, length = 5.0):
        self.start_tick = start_tick
        self.end_tick = end_tick
        self.tween_function = tween_function
        self.begin_z = begin_z
        self.end_z = end_z
        self.position = position
        self.length = length

    def get(self, tick):

        if tick < self.start_tick or tick >= self.end_tick:
            return None, None

        time = tick - self.start_tick 
        begin = self.begin_z
        change = self.end_z - self.begin_z
        duration = self.end_tick - self.start_tick
        z = self.tween_function(time, begin, change, duration)
        l_start = (self.position[0], self.position[1], z)
        l_end = (self.position[0], self.position[1], z - self.length)

        points, faces =  line(l_start, l_end)
        return (points, faces)

class RainDropAnimation:
    def __init__(self, position, start_tick, end_tick, height=50.0, radius=30.0, loop=False, debug =False):
        self.loop = loop
        self.debug = debug
        self.start_tick = start_tick
        self.end_tick = end_tick
        self.height = height
        self.radius = radius
        self.duration = self.end_tick - self.start_tick
        self.drop = FallingLineAnimation(position, start_tick, start_tick+self.duration/2.0, ease_in_quad, position[2]+height, position[2], 5.0)
        self.ripple1 = ExpandingCircleAnimation(position,(start_tick+(self.duration/2.0)),(start_tick+self.duration),ease_out_quad, 0.0,radius)

    def get(self, tick):
        if self.loop:
            tick = math.fabs(math.fmod(tick,self.duration))
        if self.debug:
            print tick
        points = []
        faces = []
        points, faces = add_shape((points, faces), self.drop.get(tick))
        points, faces = add_shape((points, faces), self.ripple1.get(tick))
        return points, faces



def main():
    points = []
    faces = []

    d1 = RainDropAnimation((20.0,20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    d2 = RainDropAnimation((0.0,20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    d3 = RainDropAnimation((-20.0,20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    
    d4 = RainDropAnimation((20.0,0.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    d5 = RainDropAnimation((0.0,0.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    d6 = RainDropAnimation((-20.0,0.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    
    d7 = RainDropAnimation((20.0,-20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    d8 = RainDropAnimation((0.0,-20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)
    d9 = RainDropAnimation((-20.0,-20.0,0.0), 0.0, 30.0, 80.0, 20.0, loop=True)


    points = []
    faces = []
    duration = 60

    o1 = randint(0,30)
    o2 = randint(0,30)
    o3 = randint(0,30)
    o4 = randint(0,30)
    o5 = randint(0,30)
    o6 = randint(0,30)
    o7 = randint(0,30)
    o8 = randint(0,30)
    o9 = randint(0,30)

    for tick in range(30):
        points = []
        faces = []
        points, faces = add_object((points, faces),d1.get(tick+o1))
        points, faces = add_object((points, faces),d2.get(tick+o2))
        points, faces = add_object((points, faces),d3.get(tick+o3))
        points, faces = add_object((points, faces),d4.get(tick+o4))
        points, faces = add_object((points, faces),d5.get(tick+o5))
        points, faces = add_object((points, faces),d6.get(tick+o6))
        points, faces = add_object((points, faces),d7.get(tick+o7))
        points, faces = add_object((points, faces),d8.get(tick+o8))
        points, faces = add_object((points, faces),d9.get(tick+o9))

        filename = str(tick).zfill(3)+".gcode"
        save_as_gcode(points, faces, filename)


if __name__ == "__main__":
    main()