Shrink an isometric SVG 12x: share styles, round coordinates, merge paths

A naive renderer turned a 355-cube isometric diagram into a 193358-byte SVG with 1065 paths, and four mechanical steps cut it to 15959 bytes (12.1x smaller) and 68 paths. An isometric diagram built from cubes emits three faces per cube. In a naive renderer, each face carries its own inline style, and its coordinates are written at full repr precision. Each step below is measured by counting <path elements and bytes.

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

The test scene

The program builds a 12x12 heightmap with random column heights from 1 to 4. That gives 355 cubes. Each cube contributes a top, left and right face, projected with a 30 degree isometric transform. Cubes are sorted by i+j+k and painted in that order, which is back to front for this view direction.

The same scene is rendered five times, with one change added at each stage. For every stage the program prints the path count and the UTF-8 byte length.

import math
import random
import re
from itertools import groupby

S = 20.0
CX, SY = math.cos(math.pi / 6) * S, math.sin(math.pi / 6) * S
COL = {"top": "#e8d5b7", "left": "#b5895a", "right": "#8a5a3b"}
CORNERS = {
    "top": [(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)],
    "left": [(0, 1, 0), (1, 1, 0), (1, 1, 1), (0, 1, 1)],
    "right": [(1, 0, 0), (1, 1, 0), (1, 1, 1), (1, 0, 1)],
}
COVER = {"top": (0, 0, 1), "left": (0, 1, 0), "right": (1, 0, 0)}


def iso(x, y, z):
    return ((x - y) * CX + 300, (x + y) * SY - z * S + 200)


random.seed(7)
N = 12
heights = {(i, j): random.randint(1, 4) for i in range(N) for j in range(N)}
cubes = sorted(((i, j, k) for (i, j), h in heights.items() for k in range(h)), key=sum)
occupied = set(cubes)


def faces(cull):
    out = []
    for c in cubes:
        for kind, corners in CORNERS.items():
            if cull and tuple(a + b for a, b in zip(c, COVER[kind])) in occupied:
                continue
            pts = [iso(*(a + b for a, b in zip(c, p))) for p in corners]
            out.append((sum(c), kind, pts))
    return out


def num(v, nd):
    return repr(v) if nd is None else f"{round(v, nd):g}"


def render(fs, nd, shared, merge):
    # Cubes with equal i+j+k project to non-overlapping hexagons, so faces
    # within one layer can be regrouped by kind without changing paint order.
    if merge:
        fs = sorted(fs, key=lambda f: f[:2])
        groups = [list(g) for _, g in groupby(fs, key=lambda f: f[:2])]
    else:
        groups = [[f] for f in fs]
    paths = []
    for g in groups:
        kind = g[0][1]
        d = "".join("M" + "L".join(f"{num(x, nd)} {num(y, nd)}" for x, y in pts) + "Z"
                    for _, _, pts in g)
        attr = (f'class="{kind[0]}"' if shared else
                f'style="fill:{COL[kind]};stroke:#333333;stroke-width:0.5;stroke-linejoin:round"')
        paths.append(f'<path {attr} d="{d}"/>')
    head = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 500">'
    if not shared:
        return head + "".join(paths) + "</svg>"
    css = "".join(f".{k[0]}{{fill:{v}}}" for k, v in COL.items())
    return (head + f"<style>{css}</style>"
            '<g stroke="#333" stroke-width=".5" stroke-linejoin="round">'
            + "".join(paths) + "</g></svg>")


stages = [("naive", False, None, False, False),
          ("+ shared styles", False, None, True, False),
          ("+ round to 0.1", False, 1, True, False),
          ("+ cull hidden faces", True, 1, True, False),
          ("+ merge per layer", True, 1, True, True)]
print(f"{len(cubes)} cubes on a {N}x{N} heightmap")
print(f"{'stage':<22}{'paths':>7}{'bytes':>9}")
sizes = []
for name, cull, nd, shared, merge in stages:
    svg = render(faces(cull), nd, shared, merge)
    sizes.append(len(svg.encode()))
    print(f"{name:<22}{svg.count('<path'):>7}{sizes[-1]:>9}")

final = sorted(faces(True), key=lambda f: f[:2])
exact = [v for _, _, pts in final for p in pts for v in p]
ds = re.findall(r' d="([^"]*)"', svg)
got = [float(t) for d in ds for t in re.findall(r"-?\d+(?:\.\d+)?", d)]
assert len(got) == len(exact), (len(got), len(exact))
assert sum(d.count("M") for d in ds) == len(final)
err = max(abs(a - b) for a, b in zip(got, exact))
assert err <= 0.05 + 1e-9, err
print(f"visible faces kept: {len(final)} of {len(faces(False))}")
print(f"max coordinate error after rounding: {err:.4f} px")
print(f"size: {sizes[0]} -> {sizes[-1]} bytes ({sizes[0] / sizes[-1]:.1f}x smaller)")
355 cubes on a 12x12 heightmap
stage                   paths    bytes
naive                    1065   193358
+ shared styles          1065   124259
+ round to 0.1           1065    65459
+ cull hidden faces       362    22427
+ merge per layer          68    15959
visible faces kept: 362 of 1065
max coordinate error after rounding: 0.0461 px
size: 193358 -> 15959 bytes (12.1x smaller)

Stage by stage

  • Shared styles. The naive path repeats fill, stroke, stroke-width and stroke-linejoin inline. That costs 74 bytes of attribute per face. A one-letter class costs 9 bytes. Saving 65 bytes on each of 1065 paths, minus 126 bytes for the new <style> block and the wrapping <g>, accounts exactly for the drop from 193358 to 124259 bytes. The stroke can move to the group because every face uses the same one.
  • Round to 0.1. The naive output writes repr of each projected float, which can run to 17 significant digits for a value such as 300 + 20·cos 30°. Rounding to one decimal shrinks the file to 65459 bytes with the same 1065 paths.
  • Cull hidden faces. A face is dropped when the cell directly against it is occupied. A top face is dropped if a cube sits on it, a left face if a cube sits at j+1, and a right face if a cube sits at i+1. This keeps 362 of 1065 faces, at 22427 bytes.
  • Merge per layer. Faces with the same layer i+j+k and the same kind are joined into one d attribute with multiple M...Z subpaths. The result is 68 paths and 15959 bytes.

Why the merge is allowed

Merging changes paint order within a layer, so it needs a justification. Two cubes in the same layer differ by (a, b, -(a+b)). Under this projection, that offset moves their hexagons along a lattice on which the hexagons tile. They can share edges, but their interiors never overlap. Reordering faces within a layer therefore does not change which fill ends up on top. Order across layers is unchanged.

Merging also moves every stroke in a group after every fill in that group. With one uniform stroke the result looks the same.

Every subpath in a group has the same fill, and every path has the same stroke. One path can therefore stand in for many.

This argument comes from the geometry, and the program does not test it. Nothing here compares rendered pixels.

What the program actually checks

  • The final SVG is parsed back. Its coordinate count must equal the coordinate count of the culled face list.
  • Its count of M commands must equal the 362 kept faces.
  • Each parsed coordinate, taken in order, must lie within 0.05 viewBox units of the matching exact float. The measured worst case is 0.0461 viewBox units.

These asserts check serialization. They would catch a face dropped or duplicated during rendering or merging, a coordinate emitted out of order, or rounding outside tolerance.

They do not check that culling removed only hidden faces. The reference list comes from the same faces(True) call that the renderer uses, so a culling bug would appear on both sides. They also do not check paint order.

Where it bites

  • The culling is conservative. It only removes faces covered by an adjacent cube. A cube is also fully hidden when the cube at offset (1,1,1) is occupied, because that cube projects onto the same hexagon and is painted later. The hidden cube's uncovered faces are still drawn and then painted over, so further savings may exist. This program does not count them.
  • The number formatting is f"{round(v, nd):g}", and :g keeps 6 significant digits. One decimal therefore survives only while coordinates stay below 100000. Larger values get rounded harder, or switch to exponent notation, which the check's regex would not parse.
  • The rounding error is up to 0.05 in viewBox units. When the diagram is scaled up, the error scales with it.

Where this stops being enough

All the numbers above are uncompressed bytes. The program does not measure gzip size or render time, and repetitive text like this compresses well. Measure what your server actually sends before you chase the last kilobyte.

Merged paths also lose per-face identity. If faces need hover states, tooltips or click targets, keep them as separate elements.

The layer argument depends on a unit cube grid viewed along this axis. Arbitrary isometric shapes need a different justification before their faces can be reordered or merged.

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