Poor man’s LOGO – Writing a LOGO interpreter in Python
Remember the good old days of programming in LOGO? I do, at least! Back then it was the most 1337 thing to do. I remember waiting one week for that one day when I could go to my computer tuition and could spend 1 hour trying to get my name to draw. If you have forgotten how to write a LOGO program, here’s a quick one –
FD 100
RT 90
FD 100
“FD” moves the turtle forward by the given value (and draws a line), “RT” rotates the turtle by the angle given (in degrees). The commands to go backward, and rotate left are”BK” and “LT” respectively. There are other commands, but we won’t deal with them now.
Yesterday, It suddenly dawned on me, can I write a LOGO emulator in Python? My goal wasn’t getting it as accurate as possible, but to efficiently implement the moving and rotating part.
The end result is pretty decent. The program is not interactive. You write the commands in a text file, and pass it to the program, and it generates an SVG with the picture. For example, passing this
FD 100
RT 90
FD 100
RT 90
FD 100
RT 90
FD 100
Produces this –
Also it only recognises only FD, BK, RT, and LT and ignores anything else. Also no error checking for now, maybe later!
The important part was not writing the code, but implementing the movements, and there is a lot of Maths involved.
I could have implemented it using normal coordinate and line equations, but since I wanted efficiency, I decided to use complex numbers. If you have forgotten complex numbers, here’s a quick reminder –
Complex numbers
Complex numbers are basically supercharged coordinates. They are just normal pairs of real numbers $(a,b)$, but what makes them different from normal coordinates are that they can be added and multiplied (in Maths terms, they form a field)
The addition and multiplication are given by –
- $(a,b)+(c,d)=(a+b,c+d)$
- $(a,b) * (c,d) = (ac-bd, ad+bc)$
Just as an aside, to make this complete, the first coordinate is called the real part and the second coordinate is called the imaginary part. A complex number with imaginary part 0 (i.e. numbers of the form (a, 0)) is called a purely real number, and numbers with real part zero (numbers of the form (0, b)) is called a purely imaginary number.
Moreover, we identify the number $(a, 0)$ as $a$. What we have done is that we have effectively extended the real numbers by adding a $0$ as their second coordinate, and made them complex numbers.
We denote $(0, 1)$ as $i$ and note that a point $(a, b)$ can be written as –
\[(a,b) = (a, 0) + (b, 0) = (a, 0) + (0, 1) * (b, 0) \equiv a + ib\]
So, a point $(a,b)$ can be represented as $z = a + ib$. The value of $\sqrt{a^2 + b^2}$ is the distance of the point from origin, known as modulus of $z$ or $|z|$, and the angle the point makes with the positive $x$ axis is knows as the amplitude or argument of the complex number. This turns out to be $\tan^{-1} \frac{b}{a}$, but it’s a little complicated and we won’t go into much detail. Check out the following GeoGebra applet.
Why complex numbers?
Now comes the real question? Why are complex numbers efficient to implement movements? The (simplified) reason is that they can be interpreted as vectors too!
The point $(a,b)$ can also act as the position vector of the same point which, in familiar setting, would mean the vector $a \hat \imath + b \hat \jmath$ where $\hat \imath \text{ and } \hat \jmath$ are the basis vectors.
Moving from one point by a certain distance in a certain direction is as easy as multiplying the unit vector by the distance and add to the current point.
For example, if I wanted to move from $(2,3)$ in the upward direction by $5$ units, I have to first get the unit vector along the upward direction. If you have forgotten what a unit vector is, it’s a vector (complex number) with length (modulus) $1$. In this case it would be the vector $(0,1)$. So, we multiply $(0,1)$ by $5$, and add to $(2,3)$ and get $(2,8)$ and this will be our destination.
Rotating is also easy. Rotating a vector by an angle $\theta$ is as easy as multiplying it by $\cos{\theta} + i \sin{\theta}$
which is the same as $e^{i \theta}$. If $\theta$ is positive, it will be a counter clockwise rotation otherwise a clockwise rotation.
Check out the following applet. Try moving the unit vector OC around and see how the angle of rotation changes.
So now you see, instead of keeping track of the direction and position separately we can embed in into one vector, and we don’t even have to deal with any line equations or other complicated things, just simple complex number addition and multiplication is all, and good news? Python supports them by default!
Is that all?
Nope, there’s one more difficulty. The svgwrite
module, which we will use, uses a standard coordinate system for image processing, where the origin is situated at the top left corner and the y-axis is flipped. It means, y increases as we go down.
Now, we will use a different system. Firstly, our drawing will start from the center. Which means if we use a $1000 \times 1000$ image, $(500, 500)$ will be our origin $(0, 0)$. So we have to translate the origin. Hence, the point $(a, b)$ in our system will become $(a + 500, b + 500)$ in the actual system.
Also, in our system, y will increase as we go up, so to tackle that, we’ll internally reverse the directions. So, forwarding by $100$ units in our system will be reverting by 100 units in the actual system and a left turn in our system would be a right turn in the actual system.
Off to coding
Finally, we are off to coding, phew! We start with installing the svgwrite
module –
pip install svgwrite
Then create a new file. I’m calling it plogo.py
First, we start by doing the necessary imports –
import svgwrite
import sys
from math import sin, cos, radians
Then we create two vectors. One would be the current position vector and another would be the current direction vector. We will start pointing upwards. Hence our initial direction would be (0,1)
direction_vector = complex(0,1)
current_pos = complex(0,0)
Now we write our parser code. It will take two parameters – the input filename, and the output filename. It will –
- Open the input file
- Prepare the SVG.
- Read the input file line by line
- Split each line into two parts – command and value
- Handle the commands
- Save the SVG
def parse(inp, out):
with open(inp) as f:
lines =f.readlines()
dwg = svgwrite.Drawing(out, (1000, 1000))
dwg.add(dwg.rect((0,0), (1000,1000), fill=svgwrite.rgb(255,255,255)))
for line in lines:
comm, val = line.split(" ")
val = int(val)
prev_pos = current_pos
if comm == "FD":
go_to_point(-val)
elif comm == "BK":
go_to_point(val)
elif comm =="LT":
rotate(radians(val))
elif comm == "RT":
rotate(-radians(val))
else:
print("Unknown command %s, ignoring"%comm)
dwg.add(dwg.line((prev_pos.real + 500, 500 + prev_pos.imag),
(current_pos.real + 500, 500 + current_pos.imag),
stroke=svgwrite.rgb(255, 0, 0)))
dwg.save()
The code is pretty self-explanatory. We have created a document of 1000 by 1000 size, and filled it with a white rectangle. We have also saved our current position in a variable called prev_pos
so that we can draw the line after we move.
While handling FD and BK, we have used a function called go_to_point
which we’ll write soon. Remember the y-axis flip? This is why we are passing -val
in FD and val
in BK. Similar case is with the RT and LT.
And finally, we are drawing a line from the previous to current point, accounting for the origin translation.
Now we write the go_to_point
function. This one is easy, we multiply the direction vector by the distance and add with the current point, and update the current position.
def go_to_point(distance):
global direction_vector, current_pos
current_pos += direction_vector * distance
print("Moving to: ", current_pos)
And the rotate function is similar too. We take the direction vector and multiply it by the appropriate vector –
def rotate(angle):
global direction_vector
direction_vector *= complex(round(cos(angle),5), -round(sin(angle),5))
print("Rotating to ", direction_vector)
You might have noticed we have rounded off to 5 decimal places. The reason is that floating point arithmetic is never exact due to how floating point numbers are stored. This might cause trouble with trigonometric functions –
>>> cos(radians(90)
6.123233995736766e-17
This problem will cause our turtle to go off axis if we rotate through 90 degrees, although by a little bit. So we round off to 5 digits, which is enough for our purpose.
Finally, we take the filenames, and parse –
inp = sys.argv[1]
out = sys.argv[2]
parse(inp, out)
And that’s it. Let’s try a few inputs –
Create a file called input.txt
and type this in –
FD 100
RT 90
FD 100
RT 90
FD 100
RT 90
FD 100
And run –
python input.txt output.svg
Then open the resulting SVG with an image viewer. The output for this one is given above.
Try this one –
FD 300
RT 127
FD 500
LT 127
FD 300
Pen Up and Pen Down
PU, or Pen Up is a command to, unsurprisingly, put the pen up. After you issue a PU command, whatever you do will not produce a drawing. PD, or Pen Down command cancels PU and puts the pen down. Using these two, we can draw discontinuous drawings.
Implementing them is easy. We’ll have a new variable called pen_up
which will hold the current state of the pen. When we encounter a PU, we make it True, and when we encounter a PD, we make it false. And finally, we only draw a line if pen_up
is False.
So, make this global variable –
pen_up = False
Then at the top of the parse
function, add
global pen_up
After the elif
block for the RT, add
elif comm == "PU":
print("Pen up")
pen_up = True
continue
elif comm == "PD":
print("Pen down")
pen_up = False
continue
Move to where we are drawing the line, and move that under an if block –
if not pen_up:
dwg.add(...)
However, this will not work.
The reason is that so far we’ve assumed that all our lines are in the format command value
but PU and PD don’t have values.
So we will put together a quick workaround. After reading a line we will try to split it, and then find the length of the resulting array. If it’s 2, we will initialise both comm
and val
otherwise, we’ll just initialise comm
–
line = line.strip().split(" ")
comm =""
val = 0
if len(line) == 2:
comm, val = line
else:
comm = line[0]
Also, we have added a strip()
call to remove the newline at the end.
One more thing which I didn’t do in the previous post is to add continue
after we handle LT and RT, so do that right away.
Here’s the complete code –
import svgwrite
import sys
from math import sin, cos, radians
# direction_vector gives us current direction.
# It is a unit vector
direction_vector = complex(0,1)
# current_post gives us the current position
current_pos = complex(0,0)
pen_up = False
def parse(inp, out):
global pen_up
with open(inp) as f:
lines =f.readlines()
dwg = svgwrite.Drawing(out, (1000, 1000))
dwg.add(dwg.rect((0,0), (1000,1000), fill=svgwrite.rgb(255,255,255)))
for line in lines:
line = line.strip().split(" ")
comm =""
val = 0
if len(line) == 2:
comm, val = line
else:
comm = line[0]
val = int(val)
prev_pos = current_pos
if comm == "FD":
go_to_point(-val)
elif comm == "BK":
go_to_point(+val)
elif comm =="LT":
rotate(radians(val))
continue
elif comm == "RT":
rotate(-radians(val))
continue
elif comm == "PU":
print("Pen up")
pen_up = True
continue
elif comm == "PD":
print("Pen down")
pen_up = False
continue
else:
print("Unknown command %s, ignoring"%comm)
if not pen_up:
print("Drawing")
dwg.add(dwg.line((prev_pos.real + 500, 500 + prev_pos.imag),
(current_pos.real + 500, 500 + current_pos.imag),
stroke=svgwrite.rgb(255, 0, 0)))
else:
print("Not drawing")
dwg.save()
# Rotating any vector through an angle x is equivalent to
# multiplying by cos x + i sin x
# if x is negative, it results in a clockwise rotation (RT)
# if x is positve, it results in an anticlockwise rotation (LT)
def rotate(angle):
global direction_vector
direction_vector *= complex(round(cos(angle),5), -round(sin(angle),5))
print("Rotating to ", direction_vector)
# To move the current position is as easy as
# multiplying the unit vector with the distance and adding with current position
def go_to_point(distance):
global direction_vector, current_pos
current_pos += direction_vector * distance
print("Moving to: ", current_pos)
inp = sys.argv[1]
out = sys.argv[2]
parse(inp, out)
Let’s try with the following input –
FD 100
RT 90
PU
FD 100
RT 90
PD
FD 100
RT 90
FD 100
We get this output –
And that’s it for today. In the next instalment of this series, we will add error checking, and write a proper parser that doesn’t feel like a hack.
Stay tuned!!