Inigo Quilez   ::     ::  


Sometime in early 2023 there was a tweet going in social media about how inelegant the optimal packing of 17 squares is. The idea is that given 17 squares of equal size, one has to find the arrangement that makes them fit in the smallest square possible. Here's a drawing to better understand:

Side Length = 4.6755389909332266

Here the 17 squares, which without loss of generality we can assume have edge length and area of 1, have been awkwardly rotated and squeezed together to make the overall square that surrounds them as small as possible. In particular, the side length of that square is 4.6755389909332266 according to my computations.

This computations of mine do match with what's documented as the best known solution to this problem, which has been given as an upper bound of 4.6756. So, I might have been able to improve the best known result by a few nanounits, but in essence I landed in the same local minima as previous researchers. It is an open question whether this local minima is also a global minima, or if a better solution exists.

For completion here go the coordinates for the center of the 17 squares and their rotation angles. Naturally the origin of the coordinates is arbitrary, so any offset of the X or Y coordinates is still a valid solution:


Note that the solution requires three distinct angles. One might wonder though, could we land on an equally good solution if we constrained the system to only have two free angles, rather than three?

I did run some optimization for that case too, and I arrived to the following solution:

Side Length = 4.6776523612755243

Here we can see that this new solution is just 0.045% larger than the best known solution. Not too bad, maybe there's hopes for an optimal two angle solution?

These are the coordinates of the squares for the two angle configuration:


In order to find these results I wrote a small C program, which I did let run for almost a full 24 hours day. The code itself is probably not worth sharing since it wasn't a proper gradient descent optimized, but more of a stochastic coordinate descent one: for any particular current state in parameter space, I'd pick any of the 37 (17x2+3) coordinate at random, and evaluate the side length of the bounding square again for a small delta variation on that parameter. If the new side length was smaller than the previous, I'd accept the delta and resume. The delta started large and decreased exponentially over time, and also every now and then I applied really large deltas in the hopes of escaping local minima and exploring other regions of the parameter space. I also did quite a few manual tweaks during those 24 hours to get unstuck or to refine around potentially interesting areas of the parameter space as I sensed I was getting close to a minima.

The only interesting part of the code is perhaps the side length calculation, which is invoked millions of times in order to guide the coordinate descent. It looked something like this, in pseudo-C-code:

[double, bbox2d] = compute_side_length_and_bounding_box( const square *squares ) { bbox2d box = { vec3d(1e20), -vec3d(1e20) }; for( int i=0; i<17; i++ ) { vec2d ce = squares[i].get_center(); vec2d ve[4] = squares[i].get_four_vertices(); // expand bounding box box = include( box, ve[0] ); box = include( box, ve[1] ); box = include( box, ve[2] ); box = include( box, ve[3] ); // if overlaps exist, return negative area to signal no valid solution for( int j=0; j<17; j++ ) { if( i != j ) { // early skip if squares further than a full diagonal apart if( distance_squared( ce, squares[j].get_center() ) > 2.0 ) continue; if( inside( ve[0], squares[j] ) ) return -1.0; if( inside( ve[1], squares[j] ) ) return -1.0; if( inside( ve[2], squares[j] ) ) return -1.0; if( inside( ve[3], squares[j] ) ) return -1.0; if( inside( ce , squares[j] ) ) return -1.0; } } } // make square from box and return its side length double dx = box.mMaxX - box.mMinX; double dy = box.mMaxY - box.mMinY; double side_length = (dx > dy) ? dx : dy; return [ side_length, box ]; }

So, here include() expands the input box to contain the passed vertex. The function inside() computes whether a vertex is inside the square. Computing if a point v is inside a unit square at the origin is as simple as doing:

return max(abs(v.x),abs(v.y)) < 0.5;

Now, before we can use this line of code we need to convert the point v by inverse rotation and translation of our square, so that we can do this test in canonical space. However, computing the sine and cosine of the rotation can be so expensive, so it's a good idea to do it only once and reuse it in the 5 calls to inside(). A similar optimization can be done inside the get_four_vertices() function.