DEV Community

ndesmic
ndesmic

Posted on

How to Draw Squircles and Superellipses

Squircles are a shape that has become quite popular recently, particularly as a shape around icons. These are quite interesting from a math perspective and you likely won't find out-of-the-box ways to draw them and related shapes so I'd like to try and document how you might go about it.

What's a Squircle?

download (1)

A "squircle" is a portmanteau of square and circle and visually takes a shape somewhere between the both of then. Note that this is different than a square with rounded corners:

download

A square with corner radius equal to half it's length is a close approximation but not quite a squircle. A squircle has no actual straight lines in it but rather a subtle curve.

To really understand a squircle we need to look at some more esoteric math. It's interesting, I promise!

First let's try to define was a circle is in the first place. I claim that a circle is a shape in which all points are equidistant from a center point. So if that's a circle then let's take a look at our typical definition for a circle:

x**2 + y**2 = r ** 2
Enter fullscreen mode Exit fullscreen mode

Let's graph that in case you forgot what a circle looks like:

download (2)

What is the distance to any point? It's:

const r = Math.sqrt(x**2 + y**2);
Enter fullscreen mode Exit fullscreen mode

Which we get from Pythagoras's theorem. But that was a somewhat arbitrary way to decide what distance is. Another type of distance is "taxi" distance. You can think about it as the number of blocks you drive to a destination in a city full of perfect square blocks. It actually doesn't matter which route you take, it's always the same.

const d = Math.abs(x) + Math.abs(y);
Enter fullscreen mode Exit fullscreen mode

What does it look like to graph this?

download (1)

So according to our definition this too is a circle of sorts we've just changed the definition of what distance is.

As it turns out we can generalize definitions of distance called the Lp-norm. If you look closely the two distances share a similar shape:

const d = (Math.abs(x)**p + Math.abs(y)**p)**(1/p)
Enter fullscreen mode Exit fullscreen mode

And yes this can go to 3+ dimensions too by adding terms under the root. The taxi distance is p = 1, and our standard Euclidean distance is p = 2. We can graph some others like p = 0.5:

download (3)

Or maybe p = Infinity:

download

Interesting. These shapes are more generally called "superellipses" and take the form:

//Superellipse
Math.abs((x - cx) / rx) ** p + Math.abs((y - cy) / ry) ** p = 1
Enter fullscreen mode Exit fullscreen mode

What you might call different P values:

  • P < 1 - Astroid (Not to be confused with "Asteroid")
  • P = 1 - Diamond (Or a rotated square)
  • P = 2 - Circle
  • P = 4 - Squircle
  • P = Infinity - Square

P = 4 is the true definition of a Squircle.

A first stab in polar coordinates

My first shot was drawing in polar coordinates as I figured since it's a circle that this would be the easiest way to represent it. theta will vary from 0 to 2π and using it we'll find r. If we take the superellipse equation and plug in our polar transforms x = r*Math.cos(theta) and y = r*Math.sin(theta) we can solve for r:

const currentR = r ** 2 / (Math.abs(r * Math.cos(theta)) ** p + Math.abs(r * Math.sin(theta)) ** p ) ** (1/p);
Enter fullscreen mode Exit fullscreen mode

I'm using currentR (the polar value of r at a given theta) to differentiate it from r which is the radius of the superellipse.

Then we just iterate over theta in little bits (0.01) and draw the result by converting back into cartesian coordinates:

//this.dom.canvas is a reference to a canvas element
//this.#r and this.#p are properties corresponding to p and r
//this.#cx and this.#cy are the center of the superellipse
const context = this.dom.canvas.getContext("2d");
context.clearRect(0, 0, this.dom.canvas.width, this.dom.canvas.height);
for(let t = 0; t < Math.PI * 2; t += 0.01){ //play with the step size
    const r = this.#r ** 2 / (Math.abs(this.#r * Math.cos(t)) ** this.#p + Math.abs(this.#r * Math.sin(t)) ** this.#p ) ** (1/this.#p);
    const [x,y] = polarToCartesian(r, t, this.#cx, this.#cy);
    context.fillRect(x,y,1,1);
}
Enter fullscreen mode Exit fullscreen mode

This works pretty nicely:

download (1)

However when we try with P < 1 it doesn't work as well:

download (2)

This is because the steps we are taking are too large around the corners, the 'r's change too quickly. We can remedy this by making the the steps smaller, for instance if we use a step value of 0.001:

download

It's mostly better but we can still see some precision loss at the very tips. We can further decrease the step size but we're going to be doing a lot of extra work (100x the work of the original) just to get those extra little bits looking nice.

Improving efficiency

So mulling some ideas on how to improve efficiency, the most obvious way is to use cartesian coordinates and only try to calculate a color for each real pixel. Since we know exactly how wide the drawing is, we can simply create one pixel for each y value as we traverse x one-by-one. I'm going to drop back down into imageData to do it:

for(let i = this.#cx - this.#r; i < this.#cx + this.#r; i++){
    const x = i - this.#cx;
    const y = Math.floor(this.#r * ((1 - Math.abs(x/this.#r) ** this.#p) ** (1/this.#p)))
    writePixel(imageData, i, this.#cy - y, this.#color);
    writePixel(imageData, i, this.#cy + y, this.#color);
}
context.putImageData(imageData, 0, 0);
Enter fullscreen mode Exit fullscreen mode

writePixel is a utility function so we don't have to write out the whole indexing part:

export function writePixel(imageData, x, y, color){
    const index = (imageData.width * 4 * y) + (x * 4);
    imageData.data[index] = color[0] * 255; 
    imageData.data[index + 1] = color[1] * 255; 
    imageData.data[index + 2] = color[2] * 255; 
    imageData.data[index + 3] = color[3] * 255; 
}
Enter fullscreen mode Exit fullscreen mode

We iterate across X (accounting for centering) and then we use a cartesian form of the superellipse equation:

const y = Math.floor(this.#r * ((1 - Math.abs(x/this.#r) ** this.#p) ** (1/this.#p)))
Enter fullscreen mode Exit fullscreen mode

The Math.floor is to ensure we are using integers otherwise we couldn't index into the imageData array. Also, we write 2 pixels per x, as to undo the absolute value we need both y and -y and that forms the bottom part of the ellipse.

download (4)

We run into the same issue though where the changes are too big in the middle. However, we can just iterate through the same way in y:

for (let i = this.#cx - this.#r; i < this.#cy + this.#r; i++) {
    const y = i - this.#cx;
    const x = Math.floor(this.#r * ((1 - Math.abs(y / this.#r) ** this.#p) ** (1 / this.#p)))
    writePixel(imageData, this.#cx + x, i, this.#color);
    writePixel(imageData, this.#cx - x, i, this.#color);
}
Enter fullscreen mode Exit fullscreen mode

If we do that then we should naturally cover the areas where y changes quickly at the cost of redrawing a few points. But it should still be better than the tiny step method.

download (2)

It has some jaggedness to it, but that's not too bad as we have a full, intact shape.

You can also increase the sizes of the pixels drawn:

export function writePixelRect(imageData, x, y, color, width = 1, height = 1){
    const offsetX = Math.floor(width / 2);
    const offsetY = Math.floor(height / 2);
    for(let i = 0; i < width; i++){
        for(let j = 0; j < height; j++){
            writePixel(imageData, x + i - offsetX, y + j - offsetY, color);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And just call that instead of writePixel with the width set to something higher for example 2x2:

download (3)

In the end it's a trade-off as if we do pixel-by-pixel then we lose the natural anti-aliasing that the canvas gives us.

Rectllipse? Superoval?

The previous equations were simplified to be circles but if you follow the original equation we can change the radii for x and y:

//polar
const r = (this.#rx * this.#ry) / (Math.abs(this.#ry * Math.cos(t)) ** this.#p + Math.abs(this.#rx * Math.sin(t)) ** this.#p ) ** (1/this.#p);
//cartesian
const y = Math.floor(this.#ry * ((1 - Math.abs(x / this.#rx) ** this.#p) ** (1/this.#p)));
const x = Math.floor(this.#rx * ((1 - Math.abs(y / this.#ry) ** this.#p) ** (1 / this.#p)));
Enter fullscreen mode Exit fullscreen mode

I'm not sure if this has a name but it's a cross between an oval and a rectangle:

download (3)

If you use a 3:4 ratio of height to width it's pretty much the shape of an old television.

Rotation

download

Rotation is pretty easy in polar coordinates, we just add to theta a fixed amount:

//this.#angle should be in radians
const angle = normalizeAngle(t + this.#angle);
const r = (this.#rx * this.#ry) / (Math.abs(this.#ry * Math.cos(angle)) ** this.#p + Math.abs(this.#rx * Math.sin(angle)) ** this.#p ) ** (1/this.#p);
Enter fullscreen mode Exit fullscreen mode

Remember to use radians.

It's harder in Cartesian coordinates. We're going to use our trusty rotation function:

//rotates around 0,0
function rotate(position, angle){
    return [
        Math.cos(angle) * position[0] - Math.sin(angle) * position[1],
        Math.sin(angle) * position[0] + Math.cos(angle) * position[1]
    ];
}
Enter fullscreen mode Exit fullscreen mode

Then we're going to use it after getting the y value:

const y = this.#ry * ((1 - Math.abs(x / this.#rx) ** this.#p) ** (1/this.#p));
const [tx,ty] = rotate([x,y], this.#angle, this.#cx, this.#cy).map(x => Math.floor(x));
const [tx2, ty2] = rotate([x, -y], this.#angle, this.#cx, this.#cy).map(x => Math.floor(x));
Enter fullscreen mode Exit fullscreen mode

Since we're rotating we can no longer just flip the coordinate to get -y we need to rotate that as well. We also don't need to take the floor until after rotation but we need to do it on both points to avoid decimal indexes.

writePixelRect(imageData, tx - this.#cx, this.#cy - ty, this.#color, this.#thickness, this.#thickness);
writePixelRect(imageData, tx2 - this.#cx, this.#cy - ty2, this.#color, this.#thickness, this.#thickness);
Enter fullscreen mode Exit fullscreen mode

We also have to add some offsets from the center as the rotation was around point 0,0. It will rotate counterclock-wise, like school math. If you want the rotation to go clockwise then subtract instead of add this.#angle.

Superellipses as gradients

Since I've been doing a lot of tutorials on gradients I thought I'd add this enhancement to my radial gradients. Please check post: https://dev.to/ndesmic/circular-gradients-from-scratch-4hgg. It's not hard at all to add, we just change the exponents on the getRValue function with a new p property (I've also changed "clipTheta" to "clipAngle" if you were following along exactly):

getRValue(r, theta){
    const transformedTheta = normalizeAngle(theta - this.#clipAngle);
    return r / ((this.#rx * this.#ry) / (Math.abs(this.#ry * Math.cos(transformedTheta)) ** this.#p + Math.abs(this.#rx * Math.sin(transformedTheta)) ** this.#p) 
** (1 / this.#p));
}
Enter fullscreen mode Exit fullscreen mode

I also updated the r properties to remove r since we're now in terms of rx and ry:

set r(val) {
    const r = parseFloat(val);
    this.#rx = r;
    this.#ry = r;
}
set rx(val){
    this.#rx = parseFloat(val);
}
set ry(val){
    this.#ry = parseFloat(val);
}
Enter fullscreen mode Exit fullscreen mode

And they look like this (P=0.5, P=1, P=2, P=4, P=Infinity; clamped clipped).

Radial:
Screenshot 2021-02-07 163225

Conic (clamped are identical):
Screenshot 2021-02-07 163203

Code for this example can be found here: https://github.com/ndesmic/wc-lib/blob/master/shape/wc-superellipse.js

Top comments (0)