SickSad

Long Exposure Light Animation

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.

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.

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.

animation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
#!/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()