Draw an Isometric Grid in SVG with Python as One Clipped Path
An isometric grid drawn from three separate sets of lines can look nearly right while one set sits slightly off, so its lines miss the points where the other two cross. Here, we generate all three families from one spacing rule, clip them to a rectangle, write them into a single SVG path, and test the emitted text to check that the lines meet.
If you want the product behind this, it is here: Homingo
Why the lines meet
Let each triangle have side S, which makes its height H = S * sqrt(3) / 2. The lattice points are:
(S * (i + j / 2), j * H)
Each family has a direction of 0, 60, or 120 degrees. Its lines sit at whole-number multiples of H from the origin, measured along the line's normal, (-dy, dx).
If you project a lattice point onto those normals and divide by H, you get these whole-number line indices:
- 0 degrees:
n0 = j - 60 degrees:
n60 = -i - 120 degrees:
n120 = -(i + j)
These indices are tied together by n120 = n60 - n0, and this holds at any point, not just at lattice points. Any crossing of two families therefore has a whole-number index in the third family, so a third line passes through it.
This also means that a family shifted by a non-whole multiple of H misses every crossing of the other two. A shift of 0.25 * H leaves a gap of 0.25 * H.
The program
clip computes the visible part of each infinite line using the Liang-Barsky approach. Each axis gives an interval of t values inside the rectangle, and the visible segment is where those intervals overlap. Horizontal lines have dy == 0, so they need the parallel-line branch to avoid dividing by zero.
Because the endpoints are computed directly, the SVG doesn't need a clipPath.
R sets how many lines to try on each side of the origin. It is large enough to cover the rectangle because the corner (x1, y1) is the farthest point from the origin when all coordinates are non-negative.
import math
import re
S = 40
H = S * math.sqrt(3) / 2
RECT = (10, 10, 190, 110)
R = math.ceil(math.hypot(RECT[2], RECT[3]) / H) + 1
def clip(px, py, dx, dy):
"""Liang-Barsky clip of the infinite line p + t*d to RECT."""
x0, y0, x1, y1 = RECT
lo, hi = -math.inf, math.inf
for p, d, a, b in ((px, dx, x0, x1), (py, dy, y0, y1)):
if abs(d) < 1e-12:
if not a <= p <= b:
return None
else:
t1, t2 = sorted(((a - p) / d, (b - p) / d))
lo, hi = max(lo, t1), min(hi, t2)
return None if lo >= hi else (px + lo * dx, py + lo * dy, px + hi * dx, py + hi * dy)
def grid_path(shift=0.0):
"""One path string; shift moves the 120-degree family off the lattice."""
parts = []
for angle in (0, 60, 120):
dx, dy = math.cos(math.radians(angle)), math.sin(math.radians(angle))
for n in range(-R, R + 1):
offset = (n + (shift if angle == 120 else 0)) * H
seg = clip(-dy * offset, dx * offset, dx, dy)
if seg:
parts.append("M{:.2f} {:.2f}L{:.2f} {:.2f}".format(*seg))
return "".join(parts)
def parse_families(path):
"""Rebuild the families from the emitted text, not the generator's state."""
families = [[], [], []]
for match in re.findall(r"M([\d.]+) ([\d.]+)L([\d.]+) ([\d.]+)", path):
x0, y0, x1, y1 = map(float, match)
angle = math.degrees(math.atan2(y1 - y0, x1 - x0))
families[round(angle / 60) % 3].append((x0, y0, x1, y1))
return families
def crossing(a, b):
rx, ry, sx, sy = a[2] - a[0], a[3] - a[1], b[2] - b[0], b[3] - b[1]
den = rx * sy - ry * sx
t = ((b[0] - a[0]) * sy - (b[1] - a[1]) * sx) / den
u = ((b[0] - a[0]) * ry - (b[1] - a[1]) * rx) / den
return (a[0] + t * rx, a[1] + t * ry) if 0 <= t <= 1 and 0 <= u <= 1 else None
def distance(p, seg):
dx, dy = seg[2] - seg[0], seg[3] - seg[1]
t = ((p[0] - seg[0]) * dx + (p[1] - seg[1]) * dy) / (dx * dx + dy * dy)
t = max(0.0, min(1.0, t))
return math.hypot(p[0] - seg[0] - t * dx, p[1] - seg[1] - t * dy)
def audit(families):
"""Return (crossings, crossings also on the third family) per family pair."""
rows = []
for a, b, c in ((0, 1, 2), (0, 2, 1), (1, 2, 0)):
hits = [crossing(p, q) for p in families[a] for q in families[b]]
points = [h for h in hits if h]
shared = sum(any(distance(h, s) < 0.05 for s in families[c]) for h in points)
rows.append((len(points), shared))
return rows
x0, y0, x1, y1 = RECT
lattice = sum(x0 <= S * (i + j / 2) <= x1 and y0 <= j * H <= y1
for j in range(-R, R + 1) for i in range(-2 * R, 2 * R + 1))
path = grid_path()
families, broken = parse_families(path), parse_families(grid_path(shift=0.25))
good, bad = audit(families), audit(broken)
coords = [seg for family in families for seg in family]
xs = [v for s in coords for v in (s[0], s[2])]
ys = [v for s in coords for v in (s[1], s[3])]
print(f'<svg viewBox="0 0 200 120"><path d="{path[:44]}..."/></svg>')
print(f"path: {len(path)} chars, lines per family: {[len(f) for f in families]}")
print(f"bounds: x {min(xs)}..{max(xs)}, y {min(ys)}..{max(ys)}, clip {RECT}")
print(f"lattice points inside clip: {lattice}")
print(f"aligned (crossings, on third family): {good}")
print(f"shifted (crossings, on third family): {bad}")
assert all(row == (lattice, lattice) for row in good)
assert all(shared == 0 for _, shared in bad)
assert min(xs) >= x0 and max(xs) <= x1 and min(ys) >= y0 and max(ys) <= y1
<svg viewBox="0 0 200 120"><path d="M10.00 34.64L190.00 34.64M10.00 69.28L190.00..."/></svg>
path: 387 chars, lines per family: [3, 6, 6]
bounds: x 10.0..190.0, y 10.0..110.0, clip (10, 10, 190, 110)
lattice points inside clip: 14
aligned (crossings, on third family): [(14, 14), (14, 14), (14, 14)]
shifted (crossings, on third family): [(14, 0), (15, 0), (14, 0)]
What the checks show
The audit works from the emitted text. parse_families parses the path string and sorts segments into families by angle. audit then intersects each pair of families and counts how many crossings lie within 0.05 units of a line in the third family.
The expected count comes from a separate loop over the lattice formula, which finds 14 points inside the clip rectangle. For this grid, the checks show:
- Crossings and shared points: All three family pairs report
(14, 14), matching the lattice count. Every crossing also lies within 0.05 units of the third family. - Negative control: With the 120-degree family shifted by
0.25 * H, the shared count drops to 0 for every pair. This shows that the check can fail. - Bounds: The emitted coordinates span exactly 10 to 190 in x and 10 to 110 in y, so nothing leaves the rectangle.
The shifted run also shows why counting crossings isn't enough. Two pairs still report 14 crossings, and only the 0-and-120-degree pair changes, to 15. The third-family test catches the misalignment in all three.
The audit compares counts, not the individual coordinates of each lattice point. The program also never renders the SVG, so these checks say nothing about how it looks.
Where it bites
- Missing stroke: The printed
<svg>line is a truncated preview with no styling. SVG paths default to a black fill and no stroke, so addfill="none"and astrokebefore you write a real file. - Negative coordinates: The parser's regex,
[\d.]+, doesn't match a minus sign, so it would miss those segments. The formula forRalso assumes that(x1, y1)is the farthest corner. Both are safe for this rectangle, but not for every rectangle. - Lattice points on the clip edge: None occur here, because lattice x values are multiples of 20, y values are multiples of
H, and the edges at x = 10 and 190 and y = 10 and 110 don't fall on those multiples. If a point did land on an edge, rounding could push a crossing just outside a segment and change the count. - Very short segments: Lines that barely cut a corner have few significant digits left after rounding. Their parsed angle may then be wrong, placing them in the wrong family.
Where this stops being enough
A single path has one stroke style, so coloring one family differently means splitting the path. The lines also contain no cell information, so hit-testing or filling individual triangles needs a separate model, such as one built from i and j.
Very fine grids put pressure on the two-decimal format and the 0.05 tolerance, because the rounding error becomes larger relative to S. At that point, increase the precision and tighten the tolerance. Other clip shapes need a different clip function, and the bounds check and lattice count must change with it.
The audit passing here covers this one S and rectangle. To use other values, run the audit again with those values.
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