Generate an Isometric SVG Diagram from a List of Boxes in Python

You have a few named boxes with positions and sizes, and you need an isometric SVG where nearer boxes cover farther ones. SVG has no depth buffer, so later elements paint over earlier ones. The job is to project the corners correctly and emit the polygons in a safe order.

If you want the product behind this, it is here: Homingo

The program

Each box is name, x, y, z, w, d, h. The script projects each box, works out a draw order, and builds the SVG as a string.

from __future__ import annotations

import heapq
import math
import re
from html import escape
from typing import NamedTuple


class Box(NamedTuple):
    name: str
    x: float
    y: float
    z: float
    w: float
    d: float
    h: float


BOXES = [Box("Gateway", 0, 0, 0, 2, 2, 1), Box("Cache", 0, 0, 1, 2, 2, 1),
         Box("Queue", 3, 0, 0, 1, 6, 1), Box("Worker", 0, 4, 0, 2, 2, 2)]
TONES = {"left": "#5f84ad", "right": "#8fb0d6", "top": "#dbe7f5"}
SCALE, PAD = 40, 10


def project(x, y, z):
    return (x - y) * math.cos(math.pi / 6) * SCALE, ((x + y) / 2 - z) * SCALE


def faces(b):
    x0, y0, z0, x1, y1, z1 = b.x, b.y, b.z, b.x + b.w, b.y + b.d, b.z + b.h
    return {"left": [(x0, y1, z0), (x1, y1, z0), (x1, y1, z1), (x0, y1, z1)],
            "right": [(x1, y0, z0), (x1, y1, z0), (x1, y1, z1), (x1, y0, z1)],
            "top": [(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)]}


def behind(a, b):
    return a.x + a.w <= b.x or a.y + a.d <= b.y or a.z + a.h <= b.z


def constraints(boxes):
    pairs = []
    for i, a in enumerate(boxes):
        for j, b in enumerate(boxes[i + 1:], i + 1):
            ab, ba = behind(a, b), behind(b, a)
            if not (ab or ba):
                raise ValueError(f"{a.name} intersects {b.name}")
            # Opposite separations on two axes mean the boxes cannot overlap on screen.
            if ab != ba:
                pairs.append((i, j) if ab else (j, i))
    return pairs


def back_to_front(boxes, pairs):
    waiting = [sum(after == i for _, after in pairs) for i in range(len(boxes))]
    ready = [i for i, n in enumerate(waiting) if n == 0]
    heapq.heapify(ready)
    order = []
    while ready:
        i = heapq.heappop(ready)
        order.append(i)
        for before, after in pairs:
            if before == i:
                waiting[after] -= 1
                if waiting[after] == 0:
                    heapq.heappush(ready, after)
    if len(order) != len(boxes):
        raise ValueError("cyclic overlap: split a box")
    return order


def render(boxes, order):
    pts = [project(*p) for b in boxes for face in faces(b).values() for p in face]
    left, top = min(p[0] for p in pts) - PAD, min(p[1] for p in pts) - PAD
    w, h = max(p[0] for p in pts) + PAD - left, max(p[1] for p in pts) + PAD - top
    out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{left:.1f} {top:.1f} {w:.1f} {h:.1f}">']
    for b in (boxes[i] for i in order):
        for tone, face in faces(b).items():
            coords = " ".join("{:.1f},{:.1f}".format(*project(*p)) for p in face)
            out.append(f'<polygon points="{coords}" fill="{TONES[tone]}" stroke="#23364d"/>')
        cx, cy = project(b.x + b.w / 2, b.y + b.d / 2, b.z + b.h)
        out.append(f'<text x="{cx:.1f}" y="{cy:.1f}" font-size="12" text-anchor="middle">{escape(b.name)}</text>')
    return "\n".join(out + ["</svg>"]), (left, top, w, h)


pairs = constraints(BOXES)
svg, (vx, vy, vw, vh) = render(BOXES, back_to_front(BOXES, pairs))
polygons = re.findall(r'<polygon points="([^"]+)" fill="([^"]+)"', svg)
drawn = re.findall(r">([^<]+)</text>", svg)
rank = {name: i for i, name in enumerate(drawn)}
naive = sorted(BOXES, key=lambda b: b.x + b.y + b.z)
naive_rank = {b.name: i for i, b in enumerate(naive)}
broken = [(BOXES[a].name, BOXES[b].name) for a, b in pairs
          if naive_rank[BOXES[a].name] > naive_rank[BOXES[b].name]]
assert len(polygons) == 3 * len(BOXES) and len({fill for _, fill in polygons}) == 3
assert all(rank[BOXES[a].name] < rank[BOXES[b].name] for a, b in pairs)
assert all(vx <= float(c.split(",")[0]) <= vx + vw and vy <= float(c.split(",")[1]) <= vy + vh
           for points, _ in polygons for c in points.split())
print(f"{len(BOXES)} boxes, {len(polygons)} polygons, {len({f for _, f in polygons})} tones")
print("drawn back to front:", " -> ".join(drawn))
print("min-corner sort:", " -> ".join(b.name for b in naive), "| wrong pairs:", broken)
print(svg)

Projection and faces

project maps a 3D point to the screen. SVG's y axis points down, so z is subtracted. One unit along x or y comes out SCALE long, 40 here, because cos(30°)² + 0.5² = 1, and one unit of z is SCALE directly. This equal scale on all three axes is what makes the view isometric.

The camera looks from the high-x, high-y, high-z side, so each box shows three faces: y = y1, x = x1 and the top. The other three are always hidden and never get emitted. Each visible face gets a fixed fill from TONES, so the shading is a colour per direction, not a lighting calculation.

Why the ordering works

Adding (1, 1, 1) to a point leaves both screen coordinates unchanged, so every line of sight runs along that direction. Moving toward the viewer increases x, y and z together. This gives three cases for a pair of boxes:

  • **a ends before b starts on some axis, and never the reverse:** on any shared line of sight, a is farther away, so the code draws a first.
  • Each box ends before the other starts, on different axes: no line of sight passes through both interiors, so they need no constraint. Cache and Queue are an example: Cache ends before Queue starts on x, and Queue ends before Cache starts on z.
  • Neither ends before the other starts on any axis: the boxes intersect, and constraints raises ValueError.

For this list, that yields five constraints: Gateway before Cache, Queue and Worker, Cache before Worker, and Worker before Queue. back_to_front then runs a topological sort. The heap breaks ties by list index, so the same input always gives the same order.

What the run shows

4 boxes, 12 polygons, 3 tones
drawn back to front: Gateway -> Cache -> Worker -> Queue
min-corner sort: Gateway -> Cache -> Queue -> Worker | wrong pairs: [('Worker', 'Queue')]
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-217.8 -90.0 366.4 300.0">
<polygon points="-69.3,40.0 0.0,80.0 0.0,40.0 -69.3,0.0" fill="#5f84ad" stroke="#23364d"/>
<polygon points="69.3,40.0 0.0,80.0 0.0,40.0 69.3,0.0" fill="#8fb0d6" stroke="#23364d"/>
<polygon points="0.0,-40.0 69.3,0.0 0.0,40.0 -69.3,0.0" fill="#dbe7f5" stroke="#23364d"/>
<text x="0.0" y="0.0" font-size="12" text-anchor="middle">Gateway</text>
<polygon points="-69.3,0.0 0.0,40.0 0.0,0.0 -69.3,-40.0" fill="#5f84ad" stroke="#23364d"/>
<polygon points="69.3,0.0 0.0,40.0 0.0,0.0 69.3,-40.0" fill="#8fb0d6" stroke="#23364d"/>
<polygon points="0.0,-80.0 69.3,-40.0 0.0,0.0 -69.3,-40.0" fill="#dbe7f5" stroke="#23364d"/>
<text x="0.0" y="-40.0" font-size="12" text-anchor="middle">Cache</text>
<polygon points="-207.8,120.0 -138.6,160.0 -138.6,80.0 -207.8,40.0" fill="#5f84ad" stroke="#23364d"/>
<polygon points="-69.3,120.0 -138.6,160.0 -138.6,80.0 -69.3,40.0" fill="#8fb0d6" stroke="#23364d"/>
<polygon points="-138.6,0.0 -69.3,40.0 -138.6,80.0 -207.8,40.0" fill="#dbe7f5" stroke="#23364d"/>
<text x="-138.6" y="40.0" font-size="12" text-anchor="middle">Worker</text>
<polygon points="-103.9,180.0 -69.3,200.0 -69.3,160.0 -103.9,140.0" fill="#5f84ad" stroke="#23364d"/>
<polygon points="138.6,80.0 -69.3,200.0 -69.3,160.0 138.6,40.0" fill="#8fb0d6" stroke="#23364d"/>
<polygon points="103.9,20.0 138.6,40.0 -69.3,160.0 -103.9,140.0" fill="#dbe7f5" stroke="#23364d"/>
<text x="17.3" y="90.0" font-size="12" text-anchor="middle">Queue</text>
</svg>

The program prints the summary lines and the SVG together and writes no file. To get a usable file, write the svg string to diagram.svg.

All three assertions passed:

  • There are 12 polygons, which is 3 per box, using exactly 3 fills.
  • The order of the labels in the SVG satisfies every constraint.
  • Every polygon vertex lies inside the viewBox.

These checks are weak. The first only counts what render emits. The second uses the same pairs that produced the order, so it doesn't show that the pairs are right. The third compares vertices with a viewBox computed from those same vertices. Nothing rendered the image or compared pixels.

Read the sort comparison carefully

Sorting by x + y + z of the minimum corner puts Queue (3) before Worker (4), and the script flags that as a wrong pair. It does break a constraint, but it would not visibly damage this picture. Worker and Queue only touch on screen, along the shared edge from (-103.9, 140.0) to (-69.3, 120.0). In this layout, Gateway and Cache are the only pair whose projections share any area, and the corner sort gets them right.

The failure becomes real if Queue's height changes from 1 to 2. Queue and Worker then overlap by area, while the corner sums stay 3 and 4, so Worker would paint over the part of Queue that should cover it. That follows from the same line-of-sight algebra, but I did not run this variant.

Where this stops being enough

  • Hidden labels: Gateway's label sits at (0.0, 0.0), and Cache is painted over it. It appears in the "drawn" list, but a reader won't see it. Drawing labels in a second pass makes them visible, though they can then float over boxes in front.
  • Cycles: three boxes can overlap in a loop, so that no box order works. One fix is to split a box, which this code does not do.
  • False alarms: constraints are added for pairs that may not overlap on screen, so the cycle error can fire even when a valid order exists. Testing for real screen overlap before adding a constraint removes these.
  • Shape and text: it only handles axis-aligned boxes, and it doesn't measure text, so long names can spill off their faces.

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

Popular posts from this blog

Catch Overlapping Labels Before Rendering With a Sweep Over X

Emit an isometric cube as three SVG polygons and test its shared edges

Shading an isometric box in plain JavaScript: three face tones from one light direction