deriving the points that define a rounded rectangle

Trading Card Game (TCG) cards tend to have a “rounded rectangle” shape to them. Pokemon cards, Yu-Gi-Oh cards, even Magic: the Gathering cards all have rounded edges. I had a need for such a shape in three.js but came to find that three.js didn’t have an out-of-the-box solution.
I wanted to see if I could create a rounded rectangle shape the same way that other geometries such as CircleGeometry are created.
terminology
Here’s a few terms I’ll be using throughout this article:
corner radius
:: the radius of the circle that defines a corner of the rounded rectangleinner height
::outer height
minus thecorner radius
at each endinner width
::outer width
minus thecorner radius
at each endouter height
:: the total height of the rectangle including thecorner radius
at each endouter width
:: the total width of the rectangle including thecorner radius
at each end.point count
:: the number of points that define the rectangle. a higherpoint count
means more detail
In the app below the highlighted path corresponds to the selected term.
planning
Most problems are solved by breaking the problem up into smaller pieces. I imagined what would a rounded rectangle look like if it had 0 inner width and 0 inner height. In that case all you’d be left with is the corner radius contributing to the outer width and outer height, in other words, you’d be left with a circle.
getting points around a circle
There’s a very simple formula for getting the points around a circle.
import type { Point } from "./types";
export const get_circular_points = ({ point_count = 32, radius = 1,} = {}): Point[] => { const division = (2 * Math.PI) / point_count;
const points: Point[] = [];
for (let i = 0; i < point_count; i += 1) { const a = division * i;
const x = radius * Math.cos(a); const y = radius * Math.sin(a);
const point: Point = { x, y, };
points.push(point); }
return points;};
The code above highlights where the point’s x and y can be adjusted.
applying an offset
Pushing the points is applied as an offset to the x and y. There’s two components to the offset.
- Magnitude :: the distance to push
- Direction :: should the push be positive or negative?
Looking at it this way you can see that the push is really a vector offset but that’s a topic for another day. The code that pushes the points by some amount in some direction would be:
const x = directionX * offsetX + radius * Math.cos(a);const y = directionY * offsetY + radius * Math.cos(a);
So the question now becomes: “what should the offset and direction be such that the end result gives the rounded rectangle?
determining the offset and the direction
We need to figure out two pieces of information:
- how much should the point be offset by
- which direction should the point be offset?
Both these apply separately to the x-axis and y-axis.
offset
To determine how much to push the point, all we need to do is think about a rounded rectangle that hasn’t had its points pushed out at all. In this case, we’d end up with a circle of radius corner radius
. Let’s consider the x-direction. We know the outer width
of the rectangle is equal to inner width + 2 * corner radius
. The circle will contribute 2 * corner radius
to this width leaving inner_width
to split between each side of the rectangle. Therefore the offset amount is equal to 0.5 * inner width
.
The same argument applies when determining how far to push the point in the y-direction substituting width
for height
.
direction
If the point lies in quadrant 1, the point needs to be pushed in the positive-x direction and in the postive-y direction. Here’s a table showing which direction and sign correspond to each quadrant.
quadrant | x-direction multiplier | y-direction muliplier | sign(point.x) | sign(point.y) |
---|---|---|---|---|
1 | 1 | 1 | + | + |
2 | -1 | 1 | - | + |
3 | -1 | -1 | - | - |
4 | 1 | -1 | + | - |
The direction to push the point is equal to the sign of its component in that axis. This gives us a really easy way to calculate the direction to push the point.
const directionX = Math.sign(point.x);const directionY = Math.sign(point.y);
Recall that the direction is a multiplier applied to the point’s components.
rounded rectangle
Now that we know the offset amount and the direction, all we need to do is bring it into our equation from before.
import type { Point } from "./types";
export const get_rounded_rectangle_points = ({ point_count = 32, inner_width = 1, inner_height = 1, corner_radius = 1,} = {}): Point[] => { const division = (2 * Math.PI) / point_count;
const half_inner_width = 0.5 * inner_width; const half_inner_height = 0.5 * inner_height;
const points: Point[] = [];
for (let i = 0; i < point_count; i += 1) { const a = division * i;
let x = corner_radius * Math.cos(a); x += Math.sign(x) * half_inner_width;
let y = corner_radius * Math.sin(a); y += Math.sign(y) * half_inner_height;
const point: Point = { x, y, };
points.push(point); }
return points;};
creating a RoundedPlaneGeometry in three.js
In three.js this shape would be called a RoundedPlaneGeometry to align with PlaneGeometry so for this section, I’ll refer to the rounded rectangle as a rounded plane.
I’m going to assume that you know some things upfront namely what buffer attributes are and how they’re used on the gpu.
buffer attributes
If you want your geometry to be compatible with materials in three.js, there’s three buffer attributes that need to be defined.
- position
- normal
- uv
I won’t describe how to attach these attributes but be aware that each of these can be created as we loop over the point_count
.
for (let i = 0; i <= point_count; i += 1) { // push data to each buffer}
However, there is one very important bit to cover - each triangle of the geometry must include the center point of the rounded plane. You’ll see this in the final demo at the end below. For this reason, the first thing in the each buffer array is the vertex attribute for the center. We want the “origin” point to be the center of the rounded plane. This means that its vertex position is (0, 0, 0)
, its normal is (0, 0, 1)
(more on this in the next section) and its uv coordinate is (0.5, 0.5)
. Note that uvs are a 2-dimensional entity.
The first thing to do is to push these values into each respective buffer array.
const position: number[] = [0, 0, 0];const normal: number[] = [0, 0, 1];const uv: number[] = [0.5, 0.5];
We’ll also initialize an index array to utilize buffer indexing. Buffer indexing is used to reduce the amount of data that is sent to the gpu and tells the gpu what order to draw the vertices.
const index: number[] = [];
Let’s start with the simplest attribute - the normal.
normal attribute
The normal is constant across the face of the geometry. Our geometry will exist in the xy-plane and is perpendicular to the z-axis so the normal at each vertex is simply (0, 0, 1)
- a normalized vector pointing in the positive z-direction.
In the loop all we need to do is push this vector into normal
for each point.
for (let i = 0; i <= point_count; i += 1) { normal.push(0, 0, 1);}
position attribute
The position for each point is simply the x
and y
that we get out of the get_rounded_rectangle_points
function.
for (let i = 0; i <= point_count; i += 1) { const x = get_x(/* ... */); const y = get_y(/* ... */); position.push(x, y, 0);}
Note that the z is always 0
. get_x
and get_y
are just stubs for how the get_rounded_rectangle_points
gets the xy for the point.
uv attribute
The sin and cos values of the rotation around the circle can be used to calculate the uv of the point. The sin and cos functions range from -1 -> 1
and uvs range from 0 -> 1
. The formula for this mapping is shown below. Its a simplified version of the map
function which is derived in this article.
// take an `n` in the range -1 -> 1 and map it to a number in the range 0 -> 1const get_uv = (n: number): number => { return 0.5 * (n + 1);};
for (let i = 0; i <= point_count; i += 1) { const c = Math.cos(amount); const s = Math.sin(amount); const uvx = get_uv(c); const uvy = get_uv(s);
uvs.push(uvx, uvy);}
index
The last thing to do in the loop is to push to the index
array. - The current triangle’s first point is lines up with the current index
, the next point with index + 1
, and the final point is the origin. We can safely use index + 1
because the index goes up to and includes point_count
. Remember that the 0th or first item in the each array corresponds to the origin.
for (let i = 0; i <= point_count; i += 1) { index.push(i, i + 1, 0);}
final code
Here’s a link to a svelte playground that uses the threlte library to demonstrate the geometry. It may take a moment to load the first time around.
I’m not sure if I want to add three.js as a dependency to the blog yet so I’ve declared the relevant three.js classes and methods in the code below.
declare class Float32BufferAttribute { constructor(array: number[], itemSize: number, normalized?: boolean);}
declare class BufferGeometry { setAttribute(name: string, attribute: Float32BufferAttribute): this; setIndex(index: number[]): this;}
class RoundedPlaneGeometry extends BufferGeometry { constructor({ corner_radius = 1, inner_width = 0, inner_height = 0, point_count = 32, } = {}) { super();
const index: number[] = []; const uv: number[] = [0.5, 0.5]; const position: number[] = [0, 0, 0]; const normal: number[] = [0, 0, 1];
const segment = (2 * Math.PI) / point_count;
const half_inner_width = 0.5 * inner_width; const half_inner_height = 0.5 * inner_height;
for (let i = 0; i <= point_count; i += 1) { const amount = segment * (i + 1);
const c = Math.cos(amount); const s = Math.sin(amount);
const uvx = 0.5 * (1 + c); const uvy = 0.5 * (1 + s); uv.push(uvx, uvy);
let x = corner_radius * c; x += Math.sign(x) * half_inner_width;
let y = corner_radius * s; y += Math.sign(y) * half_inner_height;
position.push(x, y, 0);
normal.push(0, 0, 1); index.push(i, i + 1, 0); }
this.setAttribute("position", new Float32BufferAttribute(position, 3)); this.setAttribute("normal", new Float32BufferAttribute(normal, 3)); this.setAttribute("uv", new Float32BufferAttribute(uv, 2)); this.setIndex(index); }}