SVG isometric text: compute matrix() values for top, left and right faces
A plain SVG <text> label sits flat on the screen, so on an isometric cube it looks pasted over the face rather than printed on it. The fix is one matrix() per face, and you can calculate all six values instead of nudging them by eye.
If you want the product behind this, it is here: Homingo
What the six numbers mean
SVG's matrix(a b c d e f) maps label coordinates like this:
x' = a*x + c*y + ey' = b*x + d*y + f
Each pair has its own job:
(a, b)is the screen step for one unit of text-right.(c, d)is the screen step for one unit of text-down.(e, f)is where the label's origin lands.
Building the matrix
The program below describes each face in 3D with three things: its top-left corner, a text-right axis, and a text-down axis. It projects 3D points with these formulas:
screen_x = (x - y) * cos(30°)screen_y = (x + y) * 0.5 - z
The z is subtracted because SVG's y axis grows downward.
To get a matrix, the program projects three label points: the origin, the top-right corner, and the bottom-left corner. It then subtracts the origin and divides by the label's width or height. This works because the label-to-screen mapping is affine, so equal steps along a label stay equal on screen.
On the top face, centering offsets of 20 and 40 (half the spare width and height) put the label origin at 3D point (20, 40, 100), which projects to (e, f) = (-17.32, -70.00).
from __future__ import annotations
import html
import math
import re
SIDE, LABEL_W, LABEL_H = 100.0, 60.0, 20.0
COS30 = math.cos(math.radians(30))
CORNERS = [(0.0, 0.0), (LABEL_W, 0.0), (LABEL_W, LABEL_H), (0.0, LABEL_H)]
# Each face: 3D position of its top-left corner, then 3D text-right and text-down axes.
FACES = {
"top": ((0, 0, SIDE), (1, 0, 0), (0, 1, 0)),
"left": ((0, SIDE, SIDE), (1, 0, 0), (0, 0, -1)),
"right": ((SIDE, SIDE, SIDE), (0, -1, 0), (0, 0, -1)),
}
def project(p):
x, y, z = p
return ((x - y) * COS30, (x + y) * 0.5 - z) # SVG y grows downward
def on_face(face, u, v):
origin, u_axis, v_axis = face
return tuple(origin[i] + u * u_axis[i] + v * v_axis[i] for i in range(3))
def label_target(face, x, y):
return project(on_face(face, (SIDE - LABEL_W) / 2 + x, (SIDE - LABEL_H) / 2 + y))
def matrix_string(face):
ox, oy = label_target(face, 0, 0)
rx, ry = label_target(face, LABEL_W, 0)
dx, dy = label_target(face, 0, LABEL_H)
vals = ((rx - ox) / LABEL_W, (ry - oy) / LABEL_W, (dx - ox) / LABEL_H, (dy - oy) / LABEL_H, ox, oy)
return "matrix(" + " ".join(f"{round(v, 4) + 0.0:.4f}" for v in vals) + ")"
def parse(attr):
return tuple(float(n) for n in re.findall(r"-?\d+\.\d+", attr))
def apply(m, x, y):
a, b, c, d, e, f = m
return (a * x + c * y + e, b * x + d * y + f)
def worst_error(m, face):
return max(math.dist(apply(m, x, y), label_target(face, x, y)) for x, y in CORNERS)
def inside_face(m, face):
quad = [project(on_face(face, u, v)) for u, v in [(0, 0), (SIDE, 0), (SIDE, SIDE), (0, SIDE)]]
edges = list(zip(quad, quad[1:] + quad[:1]))
points = [apply(m, x, y) for x, y in CORNERS]
return all((q[0] - p[0]) * (t[1] - p[1]) - (q[1] - p[1]) * (t[0] - p[0]) > 0
for p, q in edges for t in points)
def pt(p):
return f"({round(p[0], 2) + 0.0:8.2f}, {round(p[1], 2) + 0.0:8.2f})"
parsed = {}
for name, face in FACES.items():
attr = matrix_string(face)
m = parsed[name] = parse(attr) # read back from the rounded SVG text
print(f'{name}: <text transform="{attr}" y="{LABEL_H:g}">{html.escape(name.upper())}</text>')
for x, y in CORNERS:
print(f" label ({x:4.0f},{y:4.0f}) -> {pt(apply(m, x, y))} projected 3D {pt(label_target(face, x, y))}")
err, det = worst_error(m, face), m[0] * m[3] - m[1] * m[2]
inside = inside_face(m, face)
assert err < 0.01 and det > 0 and inside, name
print(f" worst error {err:.4f}, det {det:.4f}, inside face: {inside}")
control = worst_error(parsed["top"], FACES["left"])
assert control > 1
print(f"control: top matrix against left-face corners, worst error {control:.2f}")
top: <text transform="matrix(0.8660 0.5000 -0.8660 0.5000 -17.3205 -70.0000)" y="20">TOP</text>
label ( 0, 0) -> ( -17.32, -70.00) projected 3D ( -17.32, -70.00)
label ( 60, 0) -> ( 34.64, -40.00) projected 3D ( 34.64, -40.00)
label ( 60, 20) -> ( 17.32, -30.00) projected 3D ( 17.32, -30.00)
label ( 0, 20) -> ( -34.64, -60.00) projected 3D ( -34.64, -60.00)
worst error 0.0015, det 0.8660, inside face: True
left: <text transform="matrix(0.8660 0.5000 0.0000 1.0000 -69.2820 0.0000)" y="20">LEFT</text>
label ( 0, 0) -> ( -69.28, 0.00) projected 3D ( -69.28, 0.00)
label ( 60, 0) -> ( -17.32, 30.00) projected 3D ( -17.32, 30.00)
label ( 60, 20) -> ( -17.32, 50.00) projected 3D ( -17.32, 50.00)
label ( 0, 20) -> ( -69.28, 20.00) projected 3D ( -69.28, 20.00)
worst error 0.0015, det 0.8660, inside face: True
right: <text transform="matrix(0.8660 -0.5000 0.0000 1.0000 17.3205 30.0000)" y="20">RIGHT</text>
label ( 0, 0) -> ( 17.32, 30.00) projected 3D ( 17.32, 30.00)
label ( 60, 0) -> ( 69.28, 0.00) projected 3D ( 69.28, 0.00)
label ( 60, 20) -> ( 69.28, 20.00) projected 3D ( 69.28, 20.00)
label ( 0, 20) -> ( 17.32, 50.00) projected 3D ( 17.32, 50.00)
worst error 0.0015, det 0.8660, inside face: True
control: top matrix against left-face corners, worst error 87.18
Reading the results
The left and right faces share (c, d) = (0, 1) because text-down is -z on both, which projects straight down the screen.
The projected x and y axes have length 1, because cos²(30°) + 0.5² = 1; the z axis has length 1 because z carries a coefficient of 1. The axes are not perpendicular, though, so area shrinks: the determinant is 0.8660 on all three faces. A different vertical ratio would change the x and y lengths and the top face's determinant.
The cube's front-top vertex (100, 100, 100) projects to (0, 0), so the top and left labels land at negative coordinates. With the default viewport they get clipped, so set a viewBox or parent translate that allows for them.
What the checks do
Corner comparison. The program parses the numbers back out of the attribute string, applies them to the four label corners, and compares each result with the corner's direct 3D projection. The worst error is 0.0015, against an asserted limit of 0.01.
Three of the corners were used to build the matrix, so they mainly test the rounding and the string round trip. Corner (60, 20) is the only one not used in construction.
Determinant sign. The determinant is a*d - b*c. A positive determinant means the text isn't mirrored. On the right face, with text-down as (0, 0, -1), text-right must be (0, -1, 0). Using (0, 1, 0) would project to (-0.8660, 0.5000), which would make the determinant -0.8660 and mirror the text.
Containment. A cross-product test checks each transformed corner against all four edges of the projected face.
Control. Comparing the top matrix against the left face's corners gives a worst error of 87.18. This shows that the error measure can flag a wrong pairing.
There is one blind spot. The matrix, the expected corners, and the face outline all come from FACES. A face definition that is wrong but not mirrored, such as text placed on the wrong visible face, can pass every check.
Where this stops being enough
- Real glyphs: The program checks a 60 by 20 box, not text. It never renders the output, measures the font, or sets a
font-size, so a long word can overflow while the check still passes. - Perspective:
matrix()is affine, so parallel lines stay parallel. It generally can't represent a perspective projection, and in this corner comparison the mismatch would show up at the fourth corner. - Visibility: Nothing tests whether a face is hidden or covered by another shape.
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