Geometry

Lab setup

First, make sure you have completed the initial setup.

If you are part of a course

  1. Open Terminal. Run the update command to make sure you have the latest code.
    $ mwc update
  2. Move to this lab's directory.
    $ cd ~/Desktop/making_with_code/mwc1/unit3/lab_geometry
    

If you are working on your own

  1. Move to your MWC directory.
    $ cd ~/Desktop/making_with_code
    
  2. Get a copy of this lab's materials.
    git clone https://git.makingwithcode.org/mwc/lab_geometry.git

Work in progress. This lab isn't finished yet -- it's included here so it can be worked on, not because it's ready to assign.

Note: the lab text is now drafted (magic methods, other vector methods, the tick model, gravity and collisions). Standards alignment beyond the existing 9-12.CT.5 tag, including a csta2026 pass, is still worth doing now that there's real content to judge it against -- just hasn't been done yet.

In this lab we will use object-oriented programming to work with ideas from geometry, specifically points, vectors, and lines. Much of the complexity of computer graphics, including realistic games, is created through simulations of the real world. The math behind phenomena such as objects colliding or light bouncing around a room is surprisingly simple; it's just applied very quickly, to many particles at the same time.

For example, here's a simple model of a solar system we'll build in this lab:

Points and Vectors

A point is a location in space. We'll be working in two dimensions in this lab, so every point will be represented by an ordered pair like (10, 20) or (-5, 5).

A vector represents motion in a particular direction. Again, because we are working in two dimensions, a vector will also be represented as an ordered pair. For example, the vector (1, 0) represents moving one unit in the x-direction.

💻 We have Python classes representing points and vectors, so let's try this out:

>>> from geometry.point import Point
>>> from geometry.vector import Vector
>>> p = Point((1, 1))
>>> up = Vector((0, 1))
>>> p + up
(1, 2)
>>> p + up + up + up
(1, 4)

Magic methods

In the example, we are adding instances of Point and Vector together using + as if they were numbers. How does this work?

It turns out that there's nothing special about integers like 4 or floats like 5.11. In Python, everything is an object. 4 is an instance of the class int, and it has special methods which define how it should respond to operators like +, -, *, etc. When you write your own class, you can similarly define how (and whether) instances respond to these operators. When you write p + up, Python is really calling p.__add__(up).

💻 Try this in the interactive Python environment:

>>> (4).__add__(3)
7
>>> 4 + 3
7

These do exactly the same thing--+ is really just a friendlier way of writing __add__. Because these special methods let ordinary-looking syntax trigger our own custom behavior, they're often called magic methods. (You may also hear them called dunder methods, short for "double underscore.")

Nearly every operator in Python has a matching magic method:

CodeMagic method
a + ba.__add__(b)
a - ba.__sub__(b)
a * ba.__mul__(b)
b * aa.__rmul__(b)
a / ba.__truediv__(b)
-aa.__neg__()
a == ba.__eq__(b)
bool(a)a.__bool__()

We could technically implement all of these for both Point and Vector--Python doesn't care whether the result makes sense. But some combinations don't make sense geometrically: what would it mean to multiply two points together? Instead, we'll only implement the operations which have a clear geometric meaning. For example, adding a Vector to a Point moves the point, so Point.__add__ makes sense when the thing being added is a Vector. But adding two Points together doesn't correspond to anything, so we'll raise an error if someone tries to add a Point to another Point.

👁 Open geometry/point.py, and examine the __add__ method:

    def __add__(self, other):
        expect_type(other, "Vector")
        return Point((self.x + other.x, self.y + other.y))

expect_type checks that other really is a Vector. If you tried Point((1, 1)) + Point((2, 2)), your program would crash with a TypeError, which is the correct behavior. Once we know other is a Vector, adding it to a Point is simple: add the x-coordinates, add the y-coordinates, and return a new Point using these values.

💻 Now write Point.__sub__. Subtracting a Point from a Point should give you the Vector you'd need to travel from one to the other; subtracting a Vector from a Point should move the point in the opposite direction. (Look back at Point.__add__ for the pattern, and check tests/test_point.py to see exactly what's expected.)

Now do the same for Vector. You'll need to implement:

After you implement methods, play around with them in the interactive shell:

>>> from geometry.point import Point
>>> from geometry.vector import Vector
>>> up = Vector((0, 1))
>>> right = Vector((1, 0))
>>> left = Vector((-1, 0))
>>> 3 * up + 2 * right
Vector((2, 3))
>>> right == -left
True

If you get tired of typing out tests, you could write a Python script which tests out your methods. We also wrote a test suite you can run, to systematically test all the methods:

python -m unittest discover tests

Can we break out the tests into modules so that students just run tests on these tasks?

For each test, you'll see . if it passed, F if it ran but got the wrong answer, and E if it crashed with an error. Don't worry if you see some Fs and Es further down the list--those are testing methods we haven't written yet.

Other vector methods

Vector also needs a handful of regular (non-magic) methods. None of these have special Python syntax like + or *--you just call them directly, like v.mag().

All these figures are pixelated. Re-export as SVGs. Ensure that variable names are consistent in figure and the text.

In the figure above, v.project(u) is the vector's shadow along u. We'll use exactly this idea later, when we build billiard-ball collisions: splitting a ball's velocity into the part heading straight at the other ball (which the collision affects) and the part perpendicular to that (which it doesn't).

💻 Implement mag, scale, normalize, dot, and project, then run the tests again.

Simulations

Now that we have expanded the capabilities of Point and Vector, we can use them to create simulations of real-world motion. We experience time as continuous, a smooth flow from then to now. However, simulating the continuous movement of objects requires calculus, which you probably haven't studied yet. So we can borrow an insight from animation: if you show people a bunch of still frames in quick succession, they perceive continuous motion.

Our simulations (and our games later in this unit) will use the tick model, where we treat time as moving forward one "tick" at a time. Each tick, we'll calculate the new position of our objects.

Let's see how this works. Open simulation/ball.py:

First, show init, explaining what acceleration, velocity, and position are, and emphasizing their instantiation as Vectors and a Point. Assume the semantics of += are already clear-- focus on points, vectors, and their usage for the simulation. Then go on to update.

    def update(self):
        self.velocity += self.acceleration
        self.position += self.velocity
        self.acceleration = Vector()

Look closely--self.velocity += self.acceleration is short for self.velocity = self.velocity.__add__(self.acceleration). This is the exact __add__ method you wrote earlier! Acceleration is a change in velocity, so we add it to the velocity; velocity is a change in position, so we add it to the position. Once we've used the acceleration for this tick, we reset it to zero--any forces acting on the ball will build it back up again on the next tick.

💻 Open sim_motion.py. Add a few Balls with different starting positions and velocities (check the comment at the top of the file for Ball's constructor), then run it:

Drop the Constructor language. Instead, provide one ball and have students add more.

python sim_motion.py

There's no gravity or collisions yet, so nothing will affect the balls' velocities--each will just drift forever in a straight line.

Gravity and collisions

You may have run into Newton's second law before: F = ma (force equals mass times acceleration), usually rearranged as a = F / m. In other words: add up the forces acting on an object, divide by its mass, and you get its acceleration--which the tick model above already knows what to do with. So all we need to simulate a new kind of force is a function that calculates it as a Vector.

Rephrase the previous paragraph assuming they have not seen this law before.

Here's gravity, from simulation/gravity.py:

def get_gravity_force(a, b, g):
    a_to_b = b.position - a.position
    distance = a_to_b.mag()
    force = a_to_b.scale(g * a.mass * b.mass / (distance * distance))
    return force

a_to_b is a Vector pointing from a to b--notice that's just Point.__sub__, the method you wrote earlier. Its magnitude gives us the distance between the two objects. Gravity gets stronger with bigger masses and weaker with distance--specifically, weaker with the square of the distance, the same "inverse square" pattern you'd get spreading light or sound outward over a growing sphere. scale turns that strength into a Vector, pointing from a toward b.

Collisions are trickier to derive, but the pattern is the same: work out the physics, express it with Point and Vector methods, and let the tick model animate the result. When two balls collide, simulation/collision.py uses project to split each ball's velocity into two parts: the part heading straight at the other ball (which the collision changes) and the part perpendicular to that (which it doesn't)--exactly the shadow-casting idea from the projection figure above.

💻 Open sim_gravity.py. Add a massive Ball to act as a star, then add a few lighter ones with enough sideways velocity to orbit it instead of falling straight in. Run it with python sim_gravity.py. Here's an example of what it might look like:

💻 Open sim_collision.py. Add a few Balls on a collision course and run it with python sim_collision.py. This one has walls, so your balls will also bounce off the edges. Here's an example:

💻 Finally, run the two simulations we built for you:

python sim_billiards.py
python sim_solar_system.py

sim_billiards.py racks up a triangle of balls and breaks them with a cue ball, just like real billiards. sim_solar_system.py is the simulation from the very top of this lab--a star and three planets, held in orbit by nothing but gravity, the tick model, and the Point and Vector methods you wrote.