Generate an Isometric SVG Architecture Diagram from JSON and Detect Overlaps
A tall block in an isometric diagram can hide its neighbour, and the SVG gives no warning. This script turns three services and a database from JSON into an isometric SVG, routes the connectors, and reports every pair of blocks that overlap on screen.
If you want the product behind this, it is here: Homingo
The program
Each block in the JSON has a grid position (x, y) and a size (w, d, h). Each link names two blocks.
from __future__ import annotations
import html, itertools, json
SPEC = json.loads("""{
"services": [
{"name": "web", "kind": "service", "x": 0, "y": 0, "w": 2, "d": 2, "h": 1},
{"name": "api", "kind": "service", "x": 4, "y": 0, "w": 2, "d": 2, "h": 2},
{"name": "worker", "kind": "service", "x": 0, "y": 4, "w": 2, "d": 2, "h": 1},
{"name": "orders-db", "kind": "database", "x": 3, "y": 3, "w": 2, "d": 2, "h": 3}
],
"links": [["web", "api"], ["api", "orders-db"], ["worker", "orders-db"]]
}""")
FILLS = {"service": ("#a9cdee", "#5b9bd5", "#3d75a8"), "database": ("#f3c98b", "#e09f3e", "#b0761f")}
def project(x, y, z=0):
return (x - y) * 32, (x + y) * 16 - z * 32
def faces(b):
x, y, h, X, Y = b["x"], b["y"], b["h"], b["x"] + b["w"], b["y"] + b["d"]
top = [(x, y, h), (X, y, h), (X, Y, h), (x, Y, h)]
right = [(X, y, h), (X, y, 0), (X, Y, 0), (X, Y, h)]
left = [(x, Y, h), (X, Y, h), (X, Y, 0), (x, Y, 0)]
return [[project(*p) for p in face] for face in (top, right, left)]
def silhouette(b):
x, y, h, X, Y = b["x"], b["y"], b["h"], b["x"] + b["w"], b["y"] + b["d"]
return [project(*p) for p in [(x, y, h), (X, y, h), (X, y, 0), (X, Y, 0), (x, Y, 0), (x, Y, h)]]
def overlap(p, q):
"""Separating-axis test on convex polygons; shared edges or points do not count."""
for poly in (p, q):
for (ax, ay), (bx, by) in zip(poly, poly[1:] + poly[:1]):
nx, ny = ay - by, bx - ax
on_p = [nx * x + ny * y for x, y in p]
on_q = [nx * x + ny * y for x, y in q]
if max(on_p) <= min(on_q) or max(on_q) <= min(on_p):
return False
return True
def floor_clash(a, b):
return (a["x"] < b["x"] + b["w"] and b["x"] < a["x"] + a["w"]
and a["y"] < b["y"] + b["d"] and b["y"] < a["y"] + a["d"])
def center(b): # Sizes in SPEC are even, so centres land on grid points.
return b["x"] + b["w"] // 2, b["y"] + b["d"] // 2
def depth(b):
return sum(center(b))
def pts(poly):
return " ".join(f"{x},{y}" for x, y in poly)
blocks = SPEC["services"]
by_name = {b["name"]: b for b in blocks}
routes = []
for src, dst in SPEC["links"]:
(ax, ay), (bx, by) = center(by_name[src]), center(by_name[dst])
routes.append((src, dst, [project(ax, ay), project(bx, ay), project(bx, by)]))
all_points = [p for b in blocks for p in silhouette(b)]
left, top = min(p[0] for p in all_points) - 12, min(p[1] for p in all_points) - 12
width, height = max(p[0] for p in all_points) + 12 - left, max(p[1] for p in all_points) + 12 - top
svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{left} {top} {width} {height}">']
svg += [f'<polyline points="{pts(r)}" fill="none" stroke="#555" stroke-width="2"/>' for _, _, r in routes]
# Back-to-front by centre depth: enough for this layout, not for arbitrary boxes.
for b in sorted(blocks, key=depth):
polys = "".join(f'<polygon points="{pts(f)}" fill="{c}"/>' for f, c in zip(faces(b), FILLS[b["kind"]]))
tx, ty = project(*center(b), b["h"])
svg.append(f'<g>{polys}<text x="{tx}" y="{ty + 4}" text-anchor="middle" font-size="11">{html.escape(b["name"])}</text></g>')
svg.append("</svg>")
svg_text = "\n".join(svg)
assert svg_text.count("<polygon") == 3 * len(blocks) and svg_text.count("<polyline") == len(routes)
print("Blocks (screen bounds):")
for b in blocks:
xs, ys = zip(*silhouette(b))
print(f" {b['name']:<10} {b['kind']:<9} x {min(xs)}..{max(xs)} y {min(ys)}..{max(ys)}")
print("Connectors:")
for src, dst, route in routes:
print(f" {src} -> {dst}: {pts(route)}")
print("Pair checks:")
hits = []
for a, b in itertools.combinations(blocks, 2):
hit = overlap(silhouette(a), silhouette(b))
assert hit == overlap(silhouette(b), silhouette(a))
hits += [(a, b)] if hit else []
print(f" {a['name']:<10} {b['name']:<10} screen={'OVERLAP' if hit else 'clear':<8}floor={'CLASH' if floor_clash(a, b) else 'clear'}")
for a, b in hits:
front, back = sorted((a, b), key=depth, reverse=True)
fix = next((h for h in range(front["h"] - 1, 0, -1) if not overlap(silhouette({**front, "h": h}), silhouette(back))), None)
print(f"Overlap: {front['name']} (h={front['h']}) covers part of {back['name']}; clears at h={fix}")
print(f"Overlapping pairs: {len(hits)}\nSVG:\n{svg_text}")
How the placement works
In project, one step along x moves a point 32 px right and 16 px down, and one step along y moves it 32 px left and 16 px down. One unit of height moves it 32 px up.
We view the blocks from the side where x and y are largest, so each box shows three faces: the top, the x + w side, and the y + d side. The script draws blocks in ascending order of centre x + y, so blocks with a larger value paint over those with a smaller one.
Each connector is an L-shaped route on the ground (z = 0), drawn before any block. It moves along x, then along y. If two centres share a y value, the corner repeats a point, which is why web -> api ends in 128,96 128,96.
Why the overlap test works
A box projects to a convex hexagon, and silhouette returns its outline. Two convex shapes are separate if some line fits between them, and for convex polygons it is enough to try lines parallel to their edges. overlap does this using each edge's normal. If the projected ranges only touch on any tested axis, the shapes count as clear because the comparison uses <=.
This matters in the example. api and orders-db share part of the screen line x = 64, yet they are reported as clear.
Comparing bounding boxes would give the same answer on this layout if touching also counts as clear. In general, it can raise false alarms because a hexagon doesn't fill the corners of its bounding box.
What the run shows
Blocks (screen bounds):
web service x -64..64 y -32..64
api service x 64..192 y 0..128
worker service x -192..-64 y 32..128
orders-db database x -64..64 y 0..160
Connectors:
web -> api: 0,32 128,96 128,96
api -> orders-db: 128,96 96,80 0,128
worker -> orders-db: -128,96 -32,144 0,128
Pair checks:
web api screen=clear floor=clear
web worker screen=clear floor=clear
web orders-db screen=OVERLAP floor=clear
api worker screen=clear floor=clear
api orders-db screen=clear floor=clear
worker orders-db screen=clear floor=clear
Overlap: orders-db (h=3) covers part of web; clears at h=1
Overlapping pairs: 1
SVG:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-204 -44 408 216">
<polyline points="0,32 128,96 128,96" fill="none" stroke="#555" stroke-width="2"/>
<polyline points="128,96 96,80 0,128" fill="none" stroke="#555" stroke-width="2"/>
<polyline points="-128,96 -32,144 0,128" fill="none" stroke="#555" stroke-width="2"/>
<g><polygon points="0,-32 64,0 0,32 -64,0" fill="#a9cdee"/><polygon points="64,0 64,32 0,64 0,32" fill="#5b9bd5"/><polygon points="-64,0 0,32 0,64 -64,32" fill="#3d75a8"/><text x="0" y="4" text-anchor="middle" font-size="11">web</text></g>
<g><polygon points="128,0 192,32 128,64 64,32" fill="#a9cdee"/><polygon points="192,32 192,96 128,128 128,64" fill="#5b9bd5"/><polygon points="64,32 128,64 128,128 64,96" fill="#3d75a8"/><text x="128" y="36" text-anchor="middle" font-size="11">api</text></g>
<g><polygon points="-128,32 -64,64 -128,96 -192,64" fill="#a9cdee"/><polygon points="-64,64 -64,96 -128,128 -128,96" fill="#5b9bd5"/><polygon points="-192,64 -128,96 -128,128 -192,96" fill="#3d75a8"/><text x="-128" y="68" text-anchor="middle" font-size="11">worker</text></g>
<g><polygon points="0,0 64,32 0,64 -64,32" fill="#f3c98b"/><polygon points="64,32 64,128 0,160 0,64" fill="#e09f3e"/><polygon points="-64,32 0,64 0,160 -64,128" fill="#b0761f"/><text x="0" y="36" text-anchor="middle" font-size="11">orders-db</text></g>
</svg>
Every pair has floor=clear, so no two blocks occupy the same ground. The web and orders-db pair still shows screen=OVERLAP. These are different problems: a floor clash means the layout is impossible, while a screen overlap means one block hides part of another.
The label for web has its baseline at 0,4, and the top corner of orders-db is at 0,0. Since the database is drawn later, it can paint over the bottom of that label.
The suggested fix comes from retrying the front block at lower heights. Height 2 still overlaps, while height 1 leaves the two silhouettes touching at the single point 0,64, which counts as clear. Only that pair is rechecked, and if no lower height works, the script prints None.
What the script does not check
The two assert lines are weak. One compares polygon and polyline counts, and the other confirms that the overlap result is the same in both argument orders. Neither says the picture is readable.
Connectors are never tested against blocks, and the coordinates show the cost:
- **
web -> api:** every point on the line falls inside the silhouette ofweb,orders-db, orapi, so it is painted over. - **
api -> orders-db:** every point falls insideapiororders-db, so it is also hidden. - **
worker -> orders-db:** this is the only route with a clearly visible stretch, from-96,112to-64,128. It then runs along the database's bottom edge before its last leg is hidden inside the database.
So the SVG contains three connectors, but the viewer can clearly see only part of one. Lowering orders-db to h=1 clears the reported overlap, but api -> orders-db remains covered. To catch this, you could test each route segment against the block silhouettes. To avoid it, you could draw connectors last at a raised z. This script does neither.
Where this stops being enough
- Draw order: sorting by centre depth works for this layout, but it isn't a correct ordering for arbitrary boxes. A long block and a small neighbour can sort the wrong way.
- Odd sizes:
centeruses integer division, so an oddwordpushes labels and connectors off-centre. - Coverage: the overlap test treats a tiny corner clip and heavy occlusion alike. It also ignores labels and connectors.
- Layout: positions are written by hand. The script reports block overlaps and suggests a height, but it doesn't change the layout.
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