Topics

Bouncing an Object Against a Path Using Snap.svg

For a recent project, we needed to animate elements within an SVG, and have them bounce against a “surface” with various curves and angles.

We were using Snap.svg, which makes working with SVGs dead easy, and provides some really useful methods to help with this. Before I get into the the bouncing algorithm, though, here’s how we set things up:

    // The path against which objects bounce
    var surface = 'M299.9,372.3c70.6,0,134.4,20.3,180.4,52.9c43.7-45.1,70.6-106.6,70.6-174.4C550.9,112.3,438.5,0,300,0C161.5,0,49.1,112.3,49.1,250.9c0,67.7,26.8,129.1,70.4,174.3C165.6,392.5,229.3,372.3,299.9,372.3z';

    // The speed the objects move at
    var speed = 5;

    // Select the objects we’re animating
    var things = Snap.selectAll('#things > circle');

    // Iterate over each object and start the animation loop
    things.forEach(function(thing) {
        bounceOffStuff(thing);
    });

The animation all happens within the `bounceOffStuff` function. Here’s the whole thing:

// `thing` is a Snap.svg element
function bounceOffStuff(thing) {

    // If this is the first time bounceOffStuff has been run on `thing` set up
    // the initial state
    if (!thing.data('state')) {
        thing.data('state') = {
            // Pick a random direction of movement to get started
            angle: Math.random() * 360,
            // Find the width of `thing`
            width: thing.getBBox().width
        }
        // Translate the angle of movement into x & y values between 0 and 1
        thing.data('state').xinc = (Math.cos(thing.data('state').angle * Math.PI/180));
        thing.data('state').yinc = (Math.sin(thing.data('state').angle * Math.PI/180));
    }

    // Calculate where the front edge of `thing` will be on the next frame, based on its speed and direction
    var newx = (thing.attr('cx') * 1) + (thing.data('state').xinc * speed) + (thing.data('state').xinc * (thing.data('state').width / 2)),
        newy = (thing.attr('cy') * 1) + (thing.data('state').yinc * speed) + (thing.data('state').yinc * (thing.data('state').width / 2));

    // Check if the point falls outside of `surface`
    if (!Snap.path.isPointInside(surface, newx, newy)) {

        // make a path that intersects `surface` on the trajectory of `thing`
        var trajectory = 'M' + (newx - (thing.data('state').xinc * 100)) + ',' + (newy - (thing.data('state').yinc * 100)) + 'L' + (newx + (thing.data('state').xinc * 100)) + ',' + (newy + (thing.data('state').yinc * 100));

        // find the intersection of `trajectory` and `surface`
        var intersection = Snap.path.intersection(surface, trajectory);

        // find point info at the intersection
        var point = Snap.path.findDotsAtSegment(
            intersection[0].bez1[0],
            intersection[0].bez1[1],
            intersection[0].bez1[2],
            intersection[0].bez1[3],
            intersection[0].bez1[4],
            intersection[0].bez1[5],
            intersection[0].bez1[6],
            intersection[0].bez1[7],
            intersection[0].t1);

        // find the angle of `surface` at the intersetion
        var angle = point.alpha;

        // set new angle
        thing.data('state').angle = 360 - thing.data('state').angle + (angle * 2);

        // make sure the new angle is between 0-360
        if (thing.data('state').angle < 0) {
            thing.data('state').angle += 360;
        }
        else if (thing.data('state').angle >= 360) {
            thing.data('state').angle -= 360;
        }

        // Translate the new angle of movement into x & y values between 0 and 1
        thing.data('state').xinc = Math.cos(thing.data('state').angle * Math.PI/180);
        thing.data('state').yinc = Math.sin(thing.data('state').angle * Math.PI/180);
    }

    // Update x,y coords for `thing`
    thing.attr({
        cx: (thing.attr('cx') * 1) + thing.data('state').xinc * speed,
        cy: (thing.attr('cy') * 1) + thing.data('state').yinc * speed
    });

    // Run the loop again
    requestAnimFrame(function() {bounceOffStuff(thing); });

}

A couple of elements of this took some working out…

Finding the angle of the surface

Illustration showing the angle of the surface at the intersection

Snap.svg provides two methods that, in combination, give us the angle of the surface where the trajectory of the object intersects it. The ‘Snap.path.intersection’ method provides the bezier coordinates for the segment (section between two points in the path) intersected, and tells us how far along that segment the intersection occurs:

// make a path that intersects `surface` on the trajectory of `thing`
var trajectory = 'M' + (newx - (thing.data('state').xinc * 100)) + ',' + (newy - (thing.data('state').yinc * 100)) + 'L' + (newx + (thing.data('state').xinc * 100)) + ',' + (newy + (thing.data('state').yinc * 100));

// find the intersection of `trajectory` and `surface`
var intersection = Snap.path.intersection(surface, trajectory);

The intersection variable now contains an array of intersections of the two paths (in this case, the array only contains one element, because our paths only intersect once). In the call to Snap.path.intersection we had surface as our first path, so the path segment we’re looking for is the intersection[0].bez1 array, and the t-number (a number between 0 and 1 which describes a relative distance along a path) for the point of intersection is intersection[0].t1.

We plug that info into the <a href="http://snapsvg.io/docs/#Snap.path.findDotsAtSegment">Snap.path.findDotsAtSegment</a> method, which returns the point data for the intersected point on the surface path, including the angle of the path at that point:

// find point info at the intersection
var point = Snap.path.findDotsAtSegment(
    // Describe the segment of the surface where the intersection happens
    intersection[0].bez1[0],
    intersection[0].bez1[1],
    intersection[0].bez1[2],
    intersection[0].bez1[3],
    intersection[0].bez1[4],
    intersection[0].bez1[5],
    intersection[0].bez1[6],
    intersection[0].bez1[7],
    // Provide the distance along the segment that the intersection occurs
    intersection[0].t1);

point now contains data about the exact point, along our surface path, at which the object will hit it. We just need to know the angle at this point, which is in point. alpha.

Calculating the new direction of travel

Calculating the new angle of travel

We have the angle of the object’s current trajectory and the angle of the surface its bouncing against. We can use these to calculate the angle of the object’s new trajectory.

I have to admit to sketching a bunch of angles and lines before finally getting my head around this. I’m pretty sure this is the kind of stuff you learn in geometry lessons at school, and it probably shouldn’t have confused me for as long as it did, but that stuff is long forgotten!

Starting with a flat surface at zero degrees, I drew some reflected angles and noticed that the reflected angle (the angle after bouncing against the surface) is always:

new angle = 360 – x where x is the angle before bouncing

Then it’s just a case of accounting for a surface angle greater (or less than) zero degrees:

new angle = 360 – (x – a) + a where x is the angle before bouncing, and a is the angle of the surface

refactoring that:

new angle = 360 – x + 2a

Which is represented like this in the bounceOffStuff function:

// set new angle
thing.data('state').angle = 360 - thing.data('state').angle + (angle * 2);

This equation can result in angles outside of the 0-360 range. For our purposes, a 90 degree angle is exatly the same as a 450 degree angle, so the results are normalised into the 0-360 range with:

// make sure the new angle is between 0-360
if (thing.data('state').angle < 0) {
    thing.data('state').angle += 360;
}
else if (thing.data('state').angle >= 360) {
    thing.data('state').angle -= 360;
}

The whole thing felt a bit like a high school maths project… which was ace.

Working out reflected angles with sketches and calculations in a notebook