SVG Isometric Transform: Collapse Rotate, Skew and Scale Into One Matrix
Four transforms, one matrix: matrix(0.866025 0.5 -0.866025 0.5 100 50), verified against the chain to within about 1.4e-14. The usual way to draw a flat grid as an isometric floor in SVG is a chain like translate(100,50) rotate(30) skewX(-30) scale(1,0.866), where 0.866 approximates cos30. It works, but it is four operations to read and debug. This tutorial collapses that chain into one matrix(...). It then uses a Python model of SVG transforms to check that the single matrix and the chain put points in the same place.
If you want the product behind this, it is here: Homingo
The chain, and why each piece is there
SVG applies the functions in a transform list right to left to each point. So for a point (x, y):
scale(1, cos30)multiplies y bycos30.skewX(-30)addsy * tan(-30)to x.rotate(30)turns the result 30 degrees. SVG's y axis points down, so positive angles turn clockwise on screen.translate(100,50)moves the origin to where you want the floor.
The scale makes the projected y axis the same length as the projected x axis. Without it, skewX(-30) sends (0, 1) to (-tan30, 1), whose length is sqrt(tan²30 + 1) = 1/cos30, and rotation does not change length. Scaling y by cos30 first brings it back to 1.
Composing the matrix
An SVG matrix(a b c d e f) maps (x, y) to (a*x + c*y + e, b*x + d*y + f). Every transform function has one of these. Composing two of them is a 3x3 affine multiply. The order matches the attribute: T * R * K * S, where the rightmost factor acts on the point first.
The program builds each factor and multiplies them with mat_mul. It then runs the same points through both the step-by-step chain and the single matrix. It uses four grid corners plus four seeded random integer points. It also asserts two properties of the result. The code uses exact cos30. The 0.866 in the attribute above is the rounded form.
import math
import random
ANGLE = 30.0
TX, TY = 100.0, 50.0
def rotate_pt(p, deg):
r = math.radians(deg)
x, y = p
return (x * math.cos(r) - y * math.sin(r), x * math.sin(r) + y * math.cos(r))
def skewx_pt(p, deg):
x, y = p
return (x + y * math.tan(math.radians(deg)), y)
def scale_pt(p, sx, sy):
return (p[0] * sx, p[1] * sy)
def translate_pt(p, tx, ty):
return (p[0] + tx, p[1] + ty)
def chain(p):
# SVG: transform="translate(TX,TY) rotate(30) skewX(-30) scale(1,cos30)"
# applies right-to-left to points
p = scale_pt(p, 1.0, math.cos(math.radians(ANGLE)))
p = skewx_pt(p, -ANGLE)
p = rotate_pt(p, ANGLE)
return translate_pt(p, TX, TY)
def mat_mul(m, n):
a, b, c, d, e, f = m
a2, b2, c2, d2, e2, f2 = n
return (a * a2 + c * b2, b * a2 + d * b2,
a * c2 + c * d2, b * c2 + d * d2,
a * e2 + c * f2 + e, b * e2 + d * f2 + f)
def mat_apply(m, p):
a, b, c, d, e, f = m
return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f)
r = math.radians(ANGLE)
T = (1, 0, 0, 1, TX, TY)
R = (math.cos(r), math.sin(r), -math.sin(r), math.cos(r), 0, 0)
K = (1, 0, math.tan(-r), 1, 0, 0)
S = (1, 0, 0, math.cos(r), 0, 0)
M = mat_mul(mat_mul(mat_mul(T, R), K), S)
random.seed(7)
pts = [(0, 0), (10, 0), (0, 10), (10, 10)]
pts += [(random.randint(-20, 20), random.randint(-20, 20)) for _ in range(4)]
print(f"{'point':>10} | {'chain':>18} | {'matrix':>18} | err")
worst = 0.0
for p in pts:
q1, q2 = chain(p), mat_apply(M, p)
err = math.hypot(q1[0] - q2[0], q1[1] - q2[1])
worst = max(worst, err)
print(f"{str(p):>10} | ({q1[0]:7.3f},{q1[1]:7.3f}) | ({q2[0]:7.3f},{q2[1]:7.3f}) | {err:.1e}")
assert worst < 1e-9, worst
ax, ay = (M[0], M[1]), (M[2], M[3])
len_x, len_y = math.hypot(*ax), math.hypot(*ay)
between = math.degrees(math.acos((ax[0] * ay[0] + ax[1] * ay[1]) / (len_x * len_y)))
assert abs(len_x - len_y) < 1e-9 and abs(between - 120) < 1e-9
print(f"\nmax error: {worst:.2e}")
print(f"x axis -> ({ax[0]:.4f}, {ax[1]:.4f}) len {len_x:.4f}")
print(f"y axis -> ({ay[0]:.4f}, {ay[1]:.4f}) len {len_y:.4f}")
print(f"angle between projected axes: {between:.2f} deg")
print("single SVG transform:")
print('transform="matrix(' + " ".join(f"{v:.6g}" for v in M) + ')"')
point | chain | matrix | err
(0, 0) | (100.000, 50.000) | (100.000, 50.000) | 0.0e+00
(10, 0) | (108.660, 55.000) | (108.660, 55.000) | 0.0e+00
(0, 10) | ( 91.340, 55.000) | ( 91.340, 55.000) | 0.0e+00
(10, 10) | (100.000, 60.000) | (100.000, 60.000) | 0.0e+00
(0, -11) | (109.526, 44.500) | (109.526, 44.500) | 0.0e+00
(5, -17) | (119.053, 44.000) | (119.053, 44.000) | 1.4e-14
(-16, 14) | ( 74.019, 49.000) | ( 74.019, 49.000) | 7.1e-15
(-14, 3) | ( 85.278, 44.500) | ( 85.278, 44.500) | 0.0e+00
max error: 1.42e-14
x axis -> (0.8660, 0.5000) len 1.0000
y axis -> (-0.8660, 0.5000) len 1.0000
angle between projected axes: 120.00 deg
single SVG transform:
transform="matrix(0.866025 0.5 -0.866025 0.5 100 50)"
What the output shows
- Chain and matrix agree. The largest distance between the two results is
1.42e-14, which is floating point rounding. The assertion threshold is1e-9. Multiplying the factors in a different order gives a different matrix, so this check tests whethermat_mulcomposes the factors in the same order thatchainapplies them. It does not test whether either one matches a browser. - The axes are isometric. The x axis maps to
(0.8660, 0.5000)and the y axis maps to(-0.8660, 0.5000). Both have length 1, and they sit 120 degrees apart. Both properties are asserted. A missing scale would break the equal-length assertion, and a wrong sign on the skew would break the 120-degree assertion. - The result:
transform="matrix(0.866025 0.5 -0.866025 0.5 100 50)".
You can read the matrix directly. The first column (0.866, 0.5) is the screen step for one unit of grid x. The second column (-0.866, 0.5) is the screen step for one unit of grid y. The last pair is the translation. For example, (0, 0) lands at (100, 50) and (10, 10) lands at (100, 60). So the opposite corner (10,10) sits directly below the origin corner (0,0).
Using it
Wrap ordinary grid-space shapes in a group. You draw in flat grid units, and the matrix does the projection:
<svg viewBox="0 0 200 120" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(0.866025 0.5 -0.866025 0.5 100 50)"
fill="none" stroke="black" vector-effect="non-scaling-stroke">
<rect x="0" y="0" width="10" height="10"/>
<rect x="10" y="0" width="10" height="10"/>
<rect x="0" y="10" width="10" height="10"/>
</g>
</svg>
The program does not render this snippet. It uses the same matrix the program printed.
Where it bites
- Every check here runs in Python. It compares two models of SVG semantics against each other and does not render anything. The right-to-left rule is standard SVG behavior, but this program never confirms it in a browser.
- The printed values are rounded.
0.866025is rounded to 6 significant digits, so large coordinates drift slightly from the exact chain. The program does not measure that drift. If you need more precision, print more digits. - Only 30 degrees is tested. The 120-degree assertion is hard-coded, so it only describes
ANGLE = 30.0. If you change the angle, you must also revisit the assertions. - Strokes get transformed too. The matrix is not a rotation or a uniform scale, so stroke widths and circles distort along with the geometry. Use
vector-effect="non-scaling-stroke"if your lines should keep their width. - The matrix is less readable. A chain says what it does, and six numbers do not. Keep the chain in a comment or in the code that generates the matrix.
Where this stops being enough
This matrix projects only the 2D floor plane. SVG transforms are 2D, so you have to add height yourself as a third axis in your own code, usually pointing straight up on screen. SVG also paints in document order, so you must draw tiles back to front. Mapping a mouse click back to a grid cell needs the inverse matrix. Dimetric and other game-style projections use different angles and scales, so the 120-degree assertion would need to change for them.
Disclosure: I'm the founder of Homingo; Homingo sells this skill, and this article was written with AI assistance, with the code run before publishing. This is one small idea behind what we package in Isometric Explainers, our Claude Code skill: Claude Code draws how-it-works machines for your docs, blogs and landing pages, with live readouts and built-in layout, motion and geometry checks, and one command turns the same figure into a 1080x1920 explainer reel. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers. Everything above works without it.
Comments
Post a Comment