Iso(and Di/Tri)metric Projection in SVGs
I recently put together an animated isometric SVG, and in the process learnt more than any web developer rightly should about axonometric projection.
To save you (or future me) the mental gymnastics of figuring out how to convert from 3D to 2D coordinates here’s the gist of it.
Coordinate System
WebGL and CSS both use right-handed coordinate systems where $+z$ points out of the screen, but in isometric projection there is no axis perpendicular to the display.
This is the closest I could find to a standard for isometric views:
SVG generally uses a left-top origin, but this maps clumsily to a 3D space. A centralised origin is easier to reason with.
SVG has a 2D coordinate system (henceforth $(x^\prime,y^\prime)$). The
origin can be centralised with negative min-x/min-y viewBox arguments (e.g.
viewBox="-5 -5 10 10"
).
Mapping 3D to 2D
For a given position $(x,y,z)$ in 3D space the projected $(x^\prime,y^\prime)$ can be determined by considering each axis independently.
In isometric projection all axes are 120° apart. Alternatively the “horizontal” axes are 30° from screen horizontal.
Thus for the $x$ axis:
$$ \begin{aligned} x^\prime(x) &= x \cdot cos(30°) \newline y^\prime(x) &= x \cdot sin(30°) \newline \newline \end{aligned} $$
For the $y$ axis:
$$ \begin{aligned} x^\prime(y) &= -y \cdot cos(30°) \newline y^\prime(y) &= y \cdot sin(30°) \newline \newline \end{aligned} $$
And for the $z$ axis:
$$ \begin{aligned} x^\prime(z) &= 0 \newline y^\prime(z) &= -z \end{aligned} $$
All together:
$$ \begin{bmatrix} x^\prime \newline y^\prime \end{bmatrix} = f(x,y,z) = \begin{bmatrix} (x - y) \cdot \cos(30°) \newline (x + y) \cdot \sin(30°) - z \end{bmatrix} $$
In TypeScript:
// 30° = π / 6 in radians
const xy = (
x: number,
y: number,
z: number,
): [number, number] => [
(x - y) * Math.cos(Math.PI / 6),
(x + y) * Math.sin(Math.PI / 6) - z,
]
Planes
Each axis has a corresponding normal plane.
To project onto a plane we can use SVG’s matrix transform function.
<g transform="matrix(a, b, c, d, e, f)"></g>
Ignoring e
and f
for now:
$$ \begin{bmatrix} x^\prime \newline y^\prime \end{bmatrix} = \begin{bmatrix} x^\prime(x) + x^\prime(y) \newline y^\prime(x) + y^\prime(y) \end{bmatrix} = \begin{bmatrix} a & c \newline b & d \end{bmatrix} \begin{bmatrix} x \newline y \end{bmatrix} = \begin{bmatrix} ax + cy \newline bx + dy \end{bmatrix} $$
For the $XY$ plane, $x_{XY}$ projects onto $+x$ in 3D space, and $y_{XY}$ projects onto $+y$. Substituting the 3D-to-2D equations from above:
$$ \begin{bmatrix} x^\prime \newline y^\prime \end{bmatrix} = \begin{bmatrix} x^\prime(x) + x^\prime(y) \newline y^\prime(x) + y^\prime(y) \end{bmatrix} = \begin{bmatrix} cos(30°) & -cos(30°) \newline sin(30°) & sin(30°) \end{bmatrix} \begin{bmatrix} x \newline y \end{bmatrix} $$ $$ \begin{bmatrix} a & c \newline b & d \end{bmatrix} = \begin{bmatrix} cos(30°) & -cos(30°) \newline sin(30°) & sin(30°) \end{bmatrix} $$
Thus to project 2D content onto the $XY$ plane:
<svg viewBox="-6 -6 12 12">
<!--
cos(30°) ≈ 0.866
sin(30°) = 0.5
-->
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, 0)">
<text
text-anchor="middle"
dominant-baseline="middle"
y="-2"
>
Hello World
</text>
<circle cx="-2" cy="2" r="1"/>
<rect x="1" y="1" width="2" height="2"/>
</g>
</svg>
Revisiting e
& f
the $XY$ plane can also be translated along its normal
($z_{XY}$) axis (which projects to $z$ for the $XY$ plane).
$$ \begin{bmatrix} x^\prime \newline y^\prime \newline 1 \end{bmatrix} = \begin{bmatrix} a & c & e \newline b & d & f \newline 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \newline y \newline z \end{bmatrix} = \begin{bmatrix} ax + cy + ez \newline bx + dy + fz \newline z \end{bmatrix} = \begin{bmatrix} x^\prime(x) + x^\prime(y) + x^\prime(z) \newline y^\prime(x) + y^\prime(y) + y^\prime(z) \newline z \end{bmatrix} $$ $$ \begin{aligned} x^\prime(z) &= 0 \newline y^\prime(z) &= -z \end{aligned} $$ $$ \begin{bmatrix} a & c & e \newline b & d & f \end{bmatrix} = \begin{bmatrix} cos(30°) & -cos(30°) & 0 \newline sin(30°) & sin(30°) & -1 \end{bmatrix} $$
Shifting by one unit in the $-z$ direction is projected as a unit shift in the $+y^\prime$ direction.
Here the purple plane is shifted in the $+z$ direction ($f = -1$) and the orange plane is shifted in the $-z$ direction ($f = 1$):
<svg viewBox="-1 -1 2 2">
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, 1)">
<rect x="-1" y="-1" width="2" height="2" fill="orange"/>
</g>
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, 0)">
<rect x="-1" y="-1" width="2" height="2" fill="seagreen"/>
</g>
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 0, -1)">
<rect x="-1" y="-1" width="2" height="2" fill="rebeccapurple"/>
</g>
</svg>
By considering axes directions transformation matrices can be intuitively determined for the $XZ$ and $YZ$ planes as well.
$$ \begin{aligned} x_{XZ} \rightarrow +x \newline y_{XZ} \rightarrow -z \newline z_{XZ} \rightarrow +y \end{aligned} $$ $$ \begin{bmatrix} a & c & e \newline b & d & f \end{bmatrix} = \begin{bmatrix} cos(30°) & 0 & -cos(30°) \newline sin(30°) & 1 & sin(30°) \end{bmatrix} $$
transform_xz = `matrix(
0.866,
0.5,
0,
1,
${-0.866 * offset},
${0.5 * offset}
)`
$$ \begin{aligned} x_{YZ} \rightarrow -y \newline y_{YZ} \rightarrow -z \newline z_{YZ} \rightarrow +x \end{aligned} $$ $$ \begin{bmatrix} a & c & e \newline b & d & f \end{bmatrix} = \begin{bmatrix} cos(30°) & 0 & cos(30°) \newline -sin(30°) & 1 & sin(30°) \end{bmatrix} $$
transform_yz = `matrix(
0.866,
-0.5,
0,
1,
${0.866 * offset},
${0.5 * offset})`
Planes can be packaged as a component with an offset
prop in your framework
of choice:
function XYPlane({ children, offset }) {
const transform = `matrix(
0.866,
0.5,
-0.866,
0.5,
0,
${offset}
)`
return (
<g
transform={matrix}
>
{children}
</g>
)
}
<XYPlane offset="1">
<!-- content to project onto XY plane -->
</XYPlane>
Cuboids
A cuboid can be rendered by combining 3 rectangles rendered on each of the normal planes.
<XYPlane>
<rect
x="0" y="0" width="2" height="1"
fill="orange"
/>
</XYPlane>
<XZPlane offset="1">
<rect
x="0" y="0" width="2" height="1"
fill="orange"
style="filter: brightness(0.9)"
/>
</XZPlane>
<YZPlane offset="2">
<rect
x="-1" y="0" width="1" height="1"
fill="orange"
style="filter: brightness(0.8)"
/>
</YZPlane>
This can also be packaged as a component with x/y/z and width/height/depth props, which define the cuboid’s size along the $x$, $y$, and $z$ axes respectively.
<Cuboid
x={0} y={0} z={0}
w={2} h={1} d={1}
/>
Other Solids
By considering geometry in $(x,y,z)$ coordinates and using the xy(x,y,z)
function more complex solids can also be rendered.
const
front = [
[0, 0, 2]
[1, 1, 0],
[-1, 1, 0],
].map(xyz => xy(...xyz).join(',')).join(' '),
right = [
[0, 0, 2],
[1, -1, 0],
[1, 1, 0],
].map(xyz => xy(...xyz).join(',')).join(' ')
<polygon points={front}/>
<polygon points={right} style="filter: brightness(0.9)"/>
Other Projection Angles
From a viewing angle normal to the face of a cube, rotating $45°$ about the vertical axis and $\arctan(\frac{1}{\sqrt{2}}) \approx 35.264°$ from the horizontal axis places the subject in isometric projection.
Rather than hardcoding the 120°/30° isometric angle, the transformation matrices can be updated to be a function of the viewing angle:
const
azimuth = Math.PI / 4, // 45°
elevation = Math.atan(Math.SQRT1_2), // ≈ 35.264°
matXY = offset => [
Math.cos(azimuth),
Math.sin(azimuth) * Math.sin(elevation),
-Math.sin(azimuth),
Math.cos(azimuth) * Math.sin(elevation),
0,
-Math.cos(elevation) * offset,
],
matXZ = offset => [
Math.cos(azimuth),
Math.sin(azimuth) * Math.sin(elevation),
0,
Math.cos(elevation),
-Math.sin(azimuth) * offset,
Math.cos(azimuth) * Math.sin(elevation) * offset,
],
matYZ = offset => [
Math.sin(azimuth),
-Math.cos(azimuth) * Math.sin(elevation),
0,
Math.cos(elevation),
Math.cos(azimuth) * offset,
Math.sin(azimuth) * Math.sin(elevation) * offset,
]
The intuition for each value is, given a unit movement in the $x$ or $y$ direction on an untransformed plane, how does that project to $(x^\prime,y^\prime)$ for a given azimuth (viewing angle around the vertical axis) and elevation (viewing angle around the horizontal axis), relative to a starting angle normal to the $\text{YZ}$ plane.
The xy(x,y,z)
function can also be updated:
const xy = (
x: number,
y: number,
z: number,
azimuth: number = Math.PI / 4,
elevation: number = Math.atan(Math.SQRT1_2),
): [number, number] => [
y * Math.cos(azimuth) - x * Math.sin(azimuth),
z * Math.cos(elevation)
- x * Math.cos(azimuth) * Math.sin(elevation)
- y * Math.sin(azimuth) * Math.sin(elevation),
]
Now alternative (di/tri)metric projections can be achieved by adjusting the viewing angle:
Closing Notes
Attempting to render complex scenes quickly comes up against some hurdles, notably:
- Elements later in the DOM are rendered “in-front” of earlier elements regardless of their $(x,y,z)$ position.
- To make things appear 3D shadows have to be manually created.
For more advanced rendering a 3D library such as Three.js may be more appropriate.
But for simple diagramming—with the added benefit of being able to create self-contained SVGs—the above approach may well be sufficient.