# Implements a 3D turtle class using the Tk library # Implements an orthographic transformation with no scaling (yet) # Bruce Maxwell # Updated Spring 2011 import Tkinter import math import random tk = Tkinter def _zsort(a, b): asum = 0.0 for item in a[0]: asum += item[2] az = asum/float( len( a[0] ) ) bsum = 0.0; for item in b[0]: bsum += item[2] bz = bsum/float( len( b[0] ) ) if az < bz: return -1 elif az == bz: return 0 return 1 # copies a 3-element vector def copyVec( vecTo, vecFrom ): vecTo[0] = vecFrom[0] vecTo[1] = vecFrom[1] vecTo[2] = vecFrom[2] def length( vec ): return math.sqrt( vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2]) # vec /= len(vec) def normalize( vec ): length = math.sqrt(vec[0]*vec[0] + vec[1]*vec[1] + vec[2]*vec[2]) vec[0] /= length vec[1] /= length vec[2] /= length return # vecC = vecA x vecB def cross( vecA, vecB, vecC ): vecC[0] = vecA[1]*vecB[2] - vecA[2]*vecB[1] vecC[1] = vecA[2]*vecB[0] - vecA[0]*vecB[2] vecC[2] = vecA[0]*vecB[1] - vecA[1]*vecB[0] return # dot product of vecA and vecB def dot( vecA, vecB ): return vecA[0]*vecB[0] + vecA[1]*vecB[1] + vecA[2]*vecB[2] # transforms v in place def xform( M, v ): t = [0.0, 0.0, 0.0] for i in range(3): t[i] = M[i][0]*v[0] + M[i][1]*v[1] + M[i][2]*v[2] v[0] = t[0] v[1] = t[1] v[2] = t[2] return # matrix multiplication, returns a new matrix def mtxmul( A, B ): C = [ [0, 0, 0], [0, 0, 0], [0, 0, 0] ] for i in range(3): for j in range(3): C[i][j] = 0.0 for k in range(3): C[i][j] += A[i][k] * B[k][j] return C # clones a 3x3 matrix def mtxclone( A ): return [ [A[0][0], A[0][1], A[0][2]], [A[1][0], A[1][1], A[1][2]], [A[2][0], A[2][1], A[2][2]] ] # utility class to mirror the standard turtle fields class RawPen3D(): def __init__(self): self.penDown = True self._color = 'black' self._width = 1 self.fill = False self.fillPoints = [] # the main turtle class class Turtle3D: frameid = 0 def __init__(self, winx = 800, winy = 800, title = "Turtle 3D", position = (0.0, 0.0, 0.0), heading = (1.0, 0.0, 0.0), up = (0.0, 0.0, 1.0) ): # windowing stuff self.root = tk.Tk() self.root.geometry( "%dx%d+200+30" % (winx, winy) ) self.root.title(title) self.root.maxsize( 1024, 1024 ) self.root.lift() self.winx = winx self.winy = winy # build the menus # print 'Building menus' self.buildMenus() # print 'Building canvas' self.buildCanvas() # print 'Setting bindings' self.setBindings() ### turtle stuff # position of the turtle in 3D space self.iposition = [position[0], position[1], position[2]] # orientation as a coordinate system self.x = [heading[0], heading[1], heading[2]] self.y = [up[0], up[1], up[2]] self.z = [0, 0, 0] # the overall rotation matrix for the 'view' self.rotation = [[ 1.0, 0.0, 0.0 ], [ 0.0, 1.0, 0.0 ], [ 0.0, 0.0, 1.0 ] ] # set up the orthonormal system normalize(self.x) cross( self.x, self.y, self.z ) normalize(self.z) cross( self.z, self.x, self.y ) normalize(self.y) # other state variables self._pen = RawPen3D() # list of drawn stuff self.shapes = [] def buildMenus(self): self.menu = tk.Menu(self.root) self.root.config(menu = self.menu) self.menulist = [] menu = tk.Menu( self.menu ) self.menu.add_cascade(label="File", menu=menu ) self.menulist.append(menu) menu = tk.Menu( self.menu ) self.menu.add_cascade(label="View", menu=menu ) self.menulist.append(menu) menutext = [ [ '-', 'Quit \xE2\x8C\x98-Q' ], [ 'Reset View'] ] menucmd = [ [ None, self.handleQuit ], [ self.handleReset ] ] for i in range( len(self.menulist) ): for j in range( len(menutext[i]) ): if menutext[i][j] != '-': self.menulist[i].add_command( label=menutext[i][j], command=menucmd[i][j] ) else: self.menulist[i].add_separator() def buildCanvas(self): self.canvas = tk.Canvas( self.root, width=self.winx, height=self.winy ) self.canvas.pack( expand=tk.YES, fill=tk.BOTH ) def setBindings(self): self.root.bind( '<Button-1>', self.handleButton1 ) self.root.bind( '<B1-Motion>', self.handleButton1Motion ) self.root.bind( '<Command-q>', self.handleModQ ) self.root.bind( '<Configure>', self.handleConfigure ) self.root.bind( 'q', self.handleModQ ) self.root.bind( 'r', self.resetView ) def setRightMouseCallback(self, func): self.root.bind( '<Button-2>', func ) def handleButton1(self, event): self.baseClick = (event.x, event.y) self.R0 = mtxclone(self.rotation) def viewXform(self, x): xform( self.rotation, x ) x[0] = x[0] + self.winx/2 x[1] = -x[1] + self.winy/2 def handleButton1Motion(self, event): diff = [ event.x - self.baseClick[0], event.y - self.baseClick[1] ] anglex = math.pi * diff[0] / 200.0 angley = math.pi * diff[1] / 200.0 cth = math.cos( anglex ) sth = math.sin( anglex ) Ry = [ [cth, 0.0, sth], [0.0, 1.0, 0.0 ], [-sth, 0.0, cth ] ] cth = math.cos( angley ) sth = math.sin( angley ) Rx = [ [1.0, 0.0, 0.0 ], [0.0, cth, -sth ], [0.0, sth, cth] ] # rotation matrix relative to the original orientation Rmtx = mtxmul( Rx, Ry ) self.rotation = mtxmul( Rmtx, self.R0 ) # redraw self.updateShapes() return def reset(self): for item in self.shapes: self.canvas.delete( item[1] ) # position of the turtle in 3D space self.iposition = [0.0, 0.0, 0.0] # orientation as a coordinate system self.x = [1.0, 0.0, 0.0] self.y = [0.0, 0.0, 1.0] self.z = [0, 0, 0] # the overall rotation matrix for the 'view' self.rotation = [[ 1.0, 0.0, 0.0 ], [ 0.0, 1.0, 0.0 ], [ 0.0, 0.0, 1.0 ] ] return def resetView(self, event=None): # position of the turtle in 3D space self.iposition = [0.0, 0.0, 0.0] # orientation as a coordinate system self.x = [1.0, 0.0, 0.0] self.y = [0.0, 0.0, 1.0] self.z = [0, 0, 0] # the overall rotation matrix for the 'view' self.rotation = [[ 1.0, 0.0, 0.0 ], [ 0.0, 1.0, 0.0 ], [ 0.0, 0.0, 1.0 ] ] self.updateShapes() return def updateCanvas(self): self.canvas.update() def updateShapes(self): # need to sort the shapes by z value # need to transform the points by the current view, first # make a list of the z coordinates zlist = [] for i in range(len(self.shapes)): item = self.shapes[i] if item[0] == 'line': x0 = [ item[2], item[3], item[4] ] x1 = [ item[5], item[6], item[7] ] # view transform self.viewXform( x0 ) self.viewXform( x1 ) vertices = [x0, x1] zlist.append( (vertices, i) ) elif item[0] == 'polygon': # make a list of vertices vertices = [] for pt in item[2]: v = [pt[0], pt[1], pt[2]] # transform the original vertex points by the view self.viewXform( v ) vertices.append( (v[0], v[1], v[2]) ) zlist.append( (vertices, i) ) zlist.sort(_zsort) # need to update all of the drawn lines for i in range(len(self.shapes)): ix = zlist[i][1] if self.shapes[ix][0] == 'line': line = self.shapes[ix] # edit coordinates x0 = zlist[i][0][0] x1 = zlist[i][0][1] self.canvas.coords( line[1], x0[0], x0[1], x1[0], x1[1] ) elif self.shapes[ix][0] == 'polygon': self.canvas.delete( self.shapes[ix][1] ) # make a list of vertices vertices = [] for item in zlist[i][0]: vertices.append( (item[0], item[1]) ) tpoly = self.canvas.create_polygon( vertices, fill = self.shapes[ix][3], width = self.shapes[ix][4], outline = self.shapes[ix][3] ) self.shapes[ix] = ('polygon', tpoly, self.shapes[ix][2], self.shapes[ix][3], self.shapes[ix][4] ) self.canvas.lift( self.shapes[ix][1] ) self.canvas.update() return def handleReset(self): self.rotation = [ [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0] ] self.updateShapes() return def handleModQ(self, event): self.handleQuit() def handleConfigure(self, event): # resize the screen self.winx = event.width self.winy = event.height # update the view? def handleQuit(self): print 'Terminating' self.root.destroy() def cube(self, distance = 100): # draw a cube using relative commands for i in range(4): self.forward(distance) self.left(90) self.up() self.pitch(90) self.forward(distance) self.pitch(-90) self.down() for i in range(4): self.forward(distance) self.pitch(-90) self.forward(distance) self.up() self.forward(-distance) self.pitch(90) self.down() self.left(90) # probably want a clone method def testCallback(self, event): self.setheading( ( (1.0, 0.0, 0.0), (0.0, 0.0, 1.0) ) ) self.yaw(30) self.roll(30) # screen coordinates to world coords? x = ( event.x - self.winx/2, -event.y + self.winy/2, 0.0 ) self.up() self.goto( x ) self.down() self.cube() def test(self): tc = ['red', 'green', 'blue', 'yellow' ] self.width(3) self.up() self.goto(0,0,0) self.down() self.color('red') self.goto(200, 0, 0) self.up() self.goto(0,0,0) self.down() self.color('green') self.goto(0, 200, 0) self.up() self.goto(0,0,0) self.down() self.color('blue') self.goto(0, 0, 200) self.up() self.goto(0, 0, 0) self.setheading( ( (1.0, 0.0, 0.0), (0.0, 0.0, 1.0) ) ) self.color('yellow') self.down() self.cube() self.up() self.goto(-200, 200, 100) self.setheading(0) self.down() self.roll(30) self.color('purple') self.fill(True) for i in range(8): self.forward(50) self.left(45) self.fill(False) self.setRightMouseCallback( self.testCallback ) def testCircle(self): self.up() self.goto( 0, 0, 0) self.setheading(0) self.down() for i in range(10): self.color( random.random(), random.random(), random.random() ) self.up() self.goto( 0, i*5, i*30 ) self.setheading(0) self.down() self.fill(True) self.circle( 110 - i*10, 360 - i*30) self.fill(False) def wait(self): self.main() def mainloop(self): self.main() def main(self): # print 'Entering main loop' self.root.mainloop() # begin turtle commands def backward(self, distance): self.forward(-distance) def forward(self, distance): # move along the local x axis by distance xnew = self.iposition[0] + distance * self.x[0] ynew = self.iposition[1] + distance * self.x[1] znew = self.iposition[2] + distance * self.x[2] if self._pen.penDown: # coordinate system convert (turtle to Tk) x0 = [ self.iposition[0], self.iposition[1], self.iposition[2] ] xn = [ xnew, ynew, znew ] self.viewXform( x0 ) self.viewXform( xn ) # create a line object from the current to the new position lt = self.canvas.create_line( x0[0], x0[1], xn[0], xn[1], width=self._pen._width, fill=self._pen._color) # store the shape reference and its coordinates in world space self.shapes.append( ('line', lt, self.iposition[0], self.iposition[1], self.iposition[2], xnew, ynew, znew ) ) # see if the fill flag is on and we need to store the point if self._pen.fill: #print 'adding point: (%d %d)' % (int(xn), int(yn)) self._pen.fillPoints.append( ( xnew, ynew, znew ) ) # update the turtle location self.iposition[0] = xnew self.iposition[1] = ynew self.iposition[2] = znew return def goto(self, xnew, ynew=None, znew=0.0 ): if ynew == None: if len(xnew) == 2: ynew = xnew[1] xnew = xnew[0] elif len(xnew) == 3: ynew = xnew[1] znew = xnew[2] xnew = xnew[0] if self._pen.penDown: x0 = [ self.iposition[0], self.iposition[1], self.iposition[2] ] xn = [ xnew, ynew, znew ] self.viewXform( x0 ) self.viewXform( xn ) lt = self.canvas.create_line( x0[0], x0[1], xn[0], xn[1], width=self._pen._width, fill=self._pen._color) # store the shape reference self.shapes.append( ('line', lt, self.iposition[0], self.iposition[1], self.iposition[2], xnew, ynew, znew ) ) # see if the fill flag is on and we need to store the point if self._pen.fill: self._pen.fillPoints.append( ( xnew, ynew, znew ) ) # update the turtle location self.iposition[0] = xnew self.iposition[1] = ynew self.iposition[2] = znew # print 'position %.2f %.2f %.2f' % (self.iposition[0], self.iposition[1], self.iposition[2]) return # adds an offset to the x vector, then renormalizes and rebuilds the turtle orientation coordinates def nudge(self, direction): self.x[0] += direction[0] self.x[1] += direction[1] self.x[2] += direction[2] # if a nudge sets the x vector to zero, then set it back to the default X direction if length(self.x) == 0.0: self.x[0] = 1.0 # set up the orthonormal system normalize(self.x) cross( self.x, self.y, self.z ) normalize(self.z) cross( self.z, self.x, self.y ) normalize(self.y) def left(self, angle): # temporary versions of x and z tmpx = [ self.x[0], self.x[1], self.x[2] ] tmpy = [ self.y[0], self.y[1], self.y[2] ] tmpz = [ self.z[0], self.z[1], self.z[2] ] # align the current axes with the base axes M = [ [self.x[0], self.x[1], self.x[2]], [self.y[0], self.y[1], self.y[2]], [self.z[0], self.z[1], self.z[2]] ] xform( M, tmpx ) xform( M, tmpy ) xform( M, tmpz ) # rotate left around the Y axis radangle = angle * math.pi / 180.0 cth = math.cos(radangle) sth = math.sin(radangle) R = [ [ cth, 0, sth ], [ 0, 1, 0 ], [ -sth, 0, cth ] ] xform( R, tmpx ) xform( R, tmpy ) xform( R, tmpz ) # unalign MInv = [ [self.x[0], self.y[0], self.z[0]], [self.x[1], self.y[1], self.z[1]], [self.x[2], self.y[2], self.z[2]] ] xform( MInv, tmpx ) xform( MInv, tmpy ) xform( MInv, tmpz ) # copy copyVec( self.x, tmpx ) copyVec( self.y, tmpy ) copyVec( self.z, tmpz ) # set up the orthonormal system normalize(self.x) cross( self.x, self.y, self.z ) normalize(self.z) cross( self.z, self.x, self.y ) normalize(self.y) def right(self, angle): self.left( -angle ) def yaw(self, angle): self.left( angle ) def width(self, w=None): if w != None: self._pen._width = w return self._pen._width def color(self, r=None, g=None, b=None): if r == None: if self._pen._color[0] == '#': tr = eval( '0x' + self._pen._color[1:3] ) tg = eval( '0x' + self._pen._color[3:5] ) tb = eval( '0x' + self._pen._color[5:7] ) return (tr/255.0, tg/255.0, tb/255.0) else: return self._pen._color if r != None and (g == None and b == None): try: v = len(r) self._pen._color = "#%02X%02X%02X" % ( 255*r[0], 255*r[1], 255*r[2] ) except: self._pen._color = r; elif g != None and b != None and 0 <= r <= 1.0 and 0 <= g <= 1.0 and 0 <= b <= 1.0: self._pen._color = "#%02X%02X%02X" % ( int(255*r), int(255*g), int(255*b) ) elif g != None and b != None and 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255: self._pen._color = "#%02X%02X%02X" % (int(r), int(g), int(b)) return def pitch(self, angle ): # temporary versions of x and y tmpx = [ self.x[0], self.x[1], self.x[2] ] tmpy = [ self.y[0], self.y[1], self.y[2] ] tmpz = [ self.z[0], self.z[1], self.z[2] ] # align the current axes with the base axes M = [ [self.x[0], self.x[1], self.x[2]], [self.y[0], self.y[1], self.y[2]], [self.z[0], self.z[1], self.z[2]] ] xform( M, tmpx ) xform( M, tmpy ) xform( M, tmpz ) # rotate around the Z axis radangle = angle * math.pi / 180.0 cth = math.cos(radangle) sth = math.sin(radangle) R = [ [ cth, sth, 0 ], [ -sth, cth, 0 ], [ 0, 0, 1 ] ] xform( R, tmpx ) xform( R, tmpy ) xform( R, tmpz ) # unalign MInv = [ [self.x[0], self.y[0], self.z[0]], [self.x[1], self.y[1], self.z[1]], [self.x[2], self.y[2], self.z[2]] ] xform( MInv, tmpx ) xform( MInv, tmpy ) xform( MInv, tmpz ) # copy copyVec( self.x, tmpx ) copyVec( self.y, tmpy ) copyVec( self.z, tmpz ) # set up the orthonormal system normalize(self.x) cross( self.x, self.y, self.z ) normalize(self.z) cross( self.z, self.x, self.y ) normalize(self.y) def roll(self, angle ): # temporary versions of y and z tmpx = [ self.x[0], self.x[1], self.x[2] ] tmpz = [ self.z[0], self.z[1], self.z[2] ] tmpy = [ self.y[0], self.y[1], self.y[2] ] # align the current axes with the base axes M = [ [self.x[0], self.x[1], self.x[2]], [self.y[0], self.y[1], self.y[2]], [self.z[0], self.z[1], self.z[2]] ] xform( M, tmpx ) xform( M, tmpz ) xform( M, tmpy ) # rotate around the X axis radangle = angle * math.pi / 180.0 cth = math.cos(radangle) sth = math.sin(radangle) R = [ [ 1, 0, 0 ], [ 0, cth, -sth ], [ 0, sth, cth ] ] xform( R, tmpx ) xform( R, tmpy ) xform( R, tmpz ) # unalign MInv = [ [self.x[0], self.y[0], self.z[0]], [self.x[1], self.y[1], self.z[1]], [self.x[2], self.y[2], self.z[2]] ] xform( MInv, tmpx ) xform( MInv, tmpy ) xform( MInv, tmpz ) # copy copyVec( self.x, tmpx ) copyVec( self.y, tmpy ) copyVec( self.z, tmpz ) # set up the orthonormal system normalize(self.x) cross( self.x, self.y, self.z ) normalize(self.z) cross( self.z, self.x, self.y ) normalize(self.y) def up(self): self._pen.penDown = False return def isdown(self): return self._pen.penDown def down(self): self._pen.penDown = True return def tracer(self, blah): return def hideturtle(self): return def showturtle(self): return def circle(self, r, theta = 360): # have the turtle make a circle going forward and left #1/r radians per step radPerStep = 3.0/r steps = int ((math.pi * theta / 180.0) / radPerStep) # check if steps is zero if steps == 0: p = self.position() self.forward(1) self.up() self.goto(p) return dxPerStep = (math.pi * 2.0 * r) * theta/360.0 / float(steps) daPerStep = float(theta) / float(steps) for i in range(steps): self.forward(dxPerStep) self.left(daPerStep) return def position(self): return (self.iposition[0], self.iposition[1], self.iposition[2]) def heading(self): return ( (self.x[0], self.x[1], self.x[2]), (self.y[0], self.y[1], self.y[2]) ) def seth(self, head): self.setheading(head) # assumes heading is given as two vectors representing x (forward) and y (up) def setheading(self, head): # should check if this is a single value try: a = len(head) except: a = head * math.pi / 180.0 ca = math.cos( a ) sa = math.sin( a ) head = ( ( ca, sa, 0.0 ), (0.0, 0.0, 1.0) ) # if not a single value self.x[0] = head[0][0] self.x[1] = head[0][1] self.x[2] = head[0][2] self.y[0] = head[1][0] self.y[1] = head[1][1] self.y[2] = head[1][2] # set up the orthonormal system normalize(self.x) cross( self.x, self.y, self.z ) normalize(self.z) cross( self.z, self.x, self.y ) normalize(self.y) # print '(%.2f %.2f %.2f) (%.2f %.2f %.2f) (%.2f %.2f %.2f)' % (self.x[0], self.x[1], self.x[2], # self.y[0], self.y[1], self.y[2], # self.z[0], self.z[1], self.z[2] ) def begin_fill(self): self.fill(True) return def end_fill(self): self.fill(False) return def fill(self, dowhat): if dowhat: # set the fill state to true #print 'Setting fill state to true' self._pen.fill = True # add the current location to the fill list (polygon starts here) self._pen.fillPoints = [ ( self.iposition[0], self.iposition[1], self.iposition[2]) ] else: # set the fill state to false self._pen.fill = False # need at least 3 points to make a polygon if len(self._pen.fillPoints) < 3: return # create and color/fill the polygon vertices = [] for pt in self._pen.fillPoints: v = [pt[0], pt[1], pt[2]] self.viewXform(v) vertices.append( (v[0], v[1]) ) tpoly = self.canvas.create_polygon( vertices, fill = self._pen._color, width = self._pen._width, outline = self._pen._color ) # add the shape to shape list for the turtle self.shapes.append( ('polygon', tpoly, self._pen.fillPoints, self._pen._color, self._pen._width) ) return def save(self, filename=''): if filename == '': filename = "frame%03d.ps" % Turtle3D.frameid Turtle3D.frameid += 1 self.canvas.postscript(file=filename, colormode = 'color') return def update(self): self.updateCanvas() def window2turtle(self, x, y ): return ( x - self.winx/2, -y + self.winy/2, 0) if __name__ == "__main__": t = Turtle3D(800, 800, 'Turtle 3D Test' ) t.test() t.main()