Spiro 04: Rather Nice, Actually
I typed in some code with one finger, iPad on my lap last night. Here’s a report on how nicely it turned out.
As documented contemporaneously in the preceding article, I got to a point with the prior design where it was clear to me that I didn’t see a path forward. Or perhaps I should say it wasn’t clear to me that there was a path forward. Whatever. I was clearly in need of a more clear idea.
The point where I got stuck was when I realized that my program wasn’t dealing with the accumulating rotation of positions as things were drawn. Each wheel rotates, driving itself forward, resulting in a new angle for the parent to draw at, but that angle wasn’t being accumulated. When I looked at how to retain it and apply it, I just didn’t see a good way. So, wisely, I stopped and read my book.
I began again, last evening, by drawing yet another picture on the living room iPad.
In that drawing, as with at least ten others done over the course of this little effort, I drew a few circles and radii. At some point I realized that the circles are irrelevant. What is happening can be seen as a pencil on the end of some lines, drawing as the lines rotate around their other end.
In the picture above you see my new idea, Base, Line, and Dot. It’s a Dot, not a Pencil, because what Hill wanted, if I understood him, was to show the dots whirling around. He plans to use the picture to draw some lessons about team interactions.
This morning, we’ll be looking at the spirograph pictures, not just the dots.
The little finger-tap program has evolved a bit as I tapped away last night. Let me trim it down to the basics and we’ll start there.
There are three classes, Base, Line, and Dot. Of these, Line is the most interesting, the other two are exceedingly not interesting, so we’ll talk about them first.
There is only one Base, it is set to screen center (WIDTH/2, HEIGHT/2). Sometimes I let it draw itself with a small x, marking the spot. The code looks like this:
--# Base
Base = class()
function Base:init()
self.children = {}
end
function Base:add(child)
table.insert(self.children,child)
end
function Base:draw()
translate(WIDTH/2, HEIGHT/2)
--line(-4,-4, 4,4)
--line(4,-4,-4,4)
for i,child in ipairs(self.children) do
child:draw()
end
end
We can see that a Base has children. When it draws itself, it translates the graphic window to its standard position, the middle of the screen. If the x isn’t commented out, it draws a little x. Then it draws its children.
In play, the children are Lines. The Line class looks like this:
--# Line
Line = class()
function Line:init(len, rate, dot)
self.len =len
self.angle = 0
self.children = {}
self.rate = rate or 20
if dot then self:add(Dot()) end
end
function Line:add(child)
table.insert(self.children,child)
end
function Line:draw()
self.angle = self.angle +self.rate*DeltaTime
pushMatrix()
rotate(self.angle)
if Lines then line(0,0,0,self.len) end
translate(0,self.len)
for i,child in ipairs(self.children) do
child:draw()
end
popMatrix()
end
A Line has length, angle, children, and a rate. It’s a rate of rotation. I was typing with one finger, iPad on my lap. I prefer short names when typing with one finger.
The line draws as follows:
- It increments its angle by the scaled rate, which ensures that it rotates at constant speed even if DeltaTime varies.
- It saves the current graphics matrix, with the current values of the translation and rotation.
- It rotates the screen to the line’s accumulated angle. Each time we draw, it’ll rotate a bit further. If we’re down a bit in the children, the screen has already been translated and rotated once or more.
- If Lines is true, it’ll draw a line from the current (0,0) to (0,self.len). If we were not rotated, that would be a line straight down. Since we are rotated, the line will be at some angle.
- It translates to its end point, (0, self.len). That moves the origin to that location, the end of the current line.
- It draws each of its children. Their origin is now the end of the current line. Whatever they do starts there.
- When the children are all drawn, it pops the matrix and returns.
Now, the Dot:
--# Dot
Dot = class()
function Dot:init()
end
function Dot:draw()
fill(255)
local size = 3
ellipse(0,0,size)
end
The Dot just draws a circle at (0,0). If the Dot is at the end of some Line, (0,0) will be the point at the end of that Line, because the Line has rotated and translated to its end point before calling us. If it’s at the end of three lines, they have all rotated and translated before we draw.
Let’s see this thing in action.
We’ll give it this starting configuration:
base = Base()
l1 = Line(150, 30)
base:add(l1)
l2 = Line(50, -120, true)
l1:add(l2)
What will this draw? It draws this:
Let’s change the second line:
base = Base()
l1 = Line(150, 30)
base:add(l1)
l2 = Line(50, -210, true)
l1:add(l2)
I expect seven points this time. I am slightly surprised to see this:
I wonder why it did the loopy things. I was expecting a pointy thing like the four case. Interesting. I was just guessing at what it would do, using numerology to create rotation values that are more or less proportional.
But guessing at the patterns isn’t my point. My point is that the lengths of the lines are the same as the radii of the circles we are trying to roll, and the dots are marks at the end of the line. We can get anything we can get from the circles just from these lines and dots.
If we want to simulate a pencil point other than at the edge, we just adjust the line lengths accordingly. The rotation rate and radius are all that matters.
I’ll include some pictures at the end of this article. But I’d like to reflect on some key points.
Reflection
Almost every article you can find about simulating Spirograph will take you down a path of r*cos(theta)
calculations until they are coming out of your ears. What are those expressions? Well, they are the expressions for rotating a point. If you have a vector from (0,0) to (x,y), and you want to rotate that vector by an angle a, the new coordinates you want are (r*cos(a),r*sin(a))
. Rotate the big circle and the little circle, and now you’re adding angles, adding and subtracting radii, and applying sines and cosines until your eyes are moving like the lines on the Spirograph.
Look at this solution. Look at it:
function Line:draw()
self.angle = self.angle +self.rate*DeltaTime
pushMatrix()
rotate(self.angle)
translate(0,self.len)
for i,child in ipairs(self.children) do
child:draw()
end
popMatrix()
end
Alan Kay once said “Point of view is worth 80 IQ points”. I’m not sure about the 80. I might rise to that occasion on a good day. (80 IQ’s not bad, is it?)1
In this solution, I changed my point of view from circles running around inside circles to lines connected at the ends, each rotating at some speed. It’s the same situation: the lines are exactly the radii of the circles, no more and no less. But it changed the problem in my mind from rotating circles and calculating angles, to just incrementing the angles.
The change meant that I didn’t need to think about so many elements.
And then … instead of working out where all the points have to be, this program doesn’t figure that out at all!
There is no place in the code that knows the coordinates of the Dot that it draws. The Dot’s x and y coordinates are never calculated in the program at all. If you had to print them out, you couldn’t!2
Instead of computing anything at all, this program just uses the initial literal value of the line its on to draw the Dot. It’s always drawn at (0,length)
, where length
is the length of the line it’s on.
The entire Spirograph issue comes down to one rotate and one translate. No math. The math is all done inside the graphics system. We don’t have to know any sines and cosines, none of that jazz. We just rotate by our angle, translate to our end.
This technique, by the way, is very often as valuable as we see here. We have ripped untold numbers of sines and cosines out of the program and turned them over to the graphical system. We do similar things when we draw our spaceships.
fun draw(drawer: Drawer) {
drawer.translate(position)
drawer.rotate(heading )
drawer.lineStrip(points)
}
We use translate
to put the ship wherever it belongs on the screen, rotate it around itself to its heading and draw the lines as if it was oriented in its standard position.
Knowing the rudiments of translate and rotate can be very helpful. If you know a bit more, you can make the graphics system do distance resizing, perspective, and all that. But just translate and rotate make simple flat games like dungeons and spaceships much easier.
But how can we get this better point of view?
I don’t know how I got that idea, but I know how I got ready to have that idea, and a bit about how I managed to squeeze it out for this case.
I got ready for this idea by learning how to use the graphical functions, translate and rotate. I probably got ready for it by studying affine transformations in math class, though I swear I don’t remember the class, much less what I learned. But it relates to what graphics systems do.
I got ready for this idea by having lots of experience, and trying to consolidate it, trying to understand things. I never understand anything perfectly, but after a while, I sort of get the idea built into my head.
And I squeezed the idea out by returning again and again to drawing pictures and thinking about the wheels driving around inside other wheels. I have nine pictures in Procreate, including the one I showed above, sketching and scribbling about how to do this.
I spent perhaps an hour or two on those diagrams. I probably could have forced out the sine and cosine solution today. But fortunately, I drew the diagram that made me see the lines instead of the circles.
And one more thing: while I very much prefer to “let the code participate in my design thinking”, as Kent Beck once put it, I have also learned to recognize when I’m not getting anywhere good with the code. When that happens, I stop. Well, sometimes I bang my head on it a while but finally I face facts and stop. I pause, think, do something else, maybe draw a picture of the problem. I wait until I have a better idea and until my mind is fresh, and then I come back to it.
Works for me. I describe it not so that you’ll do what I do, but in the hope that you’ll recognize your own good and not so good working habits.
So three lessons:
- Look for a better viewpoint;
- Know the principles;
- Pause when I’m behind, but don’t quit.
And what we have here is just about the simplest Spirograph program you’re ever gonna see.
See you next time!
Footnotes follow the pictures, if you’re into that sort of thing.
This one is drawn with three lines appended, rather than just two. Wheels within wheels:
local line4 = Line(75, 49, false)
base:add(line4)
local line5 = Line(60, 40, false)
line4:add(line5)
local line6 = Line(30, -125, false)
line5:add(line6)
local line7 = Line(50,-200, true)
line6:add(line7)
-
Yes, IQ is almost meaningless. To the extent that it measures anything, it measures more about cultural matters than actual intelligence. And intelligence, whatever it might be, won’t boil down to a number. Our intelligence, such as it is, is incredibly multi-dimensional, quite beautiful, and surely essentially immeasurable. It’s just a saying, about having the right angle on the problem. No pun intended. ↩
-
Well, truth is you could fetch the graphical matrix and extract the net translation but, seriously, who would even know how to do that? ↩