Isometric SVG depth sorting: why x+y+z puts the wrong box on top, and the fix
A long slab and a small cube sit side by side on an isometric grid. The cube is in front, yet a painter's-order renderer that sorts by x + y + z of box centers draws the slab over it. That key mixes position with size, so it can fail when boxes have different sizes.
If you want the product behind this, it is here: Homingo
The setup
Each box is (x0, x1, y0, y1, z0, z1). The viewer looks from the +(1, 1, 1) direction, so larger coordinates are nearer. Painter's algorithm draws far boxes first and near boxes last, so later shapes cover earlier ones.
The scene has three boxes:
slab: 4 long in x, 1 deep in y, 1 tall, aty = 0..1.cube: a unit cube aty = 1..2, directly in front of the slab's left end.pillar: a 3-tall box atx = 3..4,y = 2..3.
import heapq
from itertools import permutations
BOXES = {
"slab": (0, 4, 0, 1, 0, 1),
"cube": (0, 1, 1, 2, 0, 1),
"pillar": (3, 4, 2, 3, 0, 3),
}
def center_sum(b):
return sum((b[i] + b[i + 1]) / 2 for i in (0, 2, 4))
def behind(a, b):
return a[1] <= b[0] or a[3] <= b[2] or a[5] <= b[4]
def topo_order(boxes):
succ = {n: [] for n in boxes}
indeg = {n: 0 for n in boxes}
for a, b in permutations(boxes, 2):
if behind(boxes[a], boxes[b]) and not behind(boxes[b], boxes[a]):
succ[a].append(b)
indeg[b] += 1
heap = [(center_sum(boxes[n]), n) for n in boxes if indeg[n] == 0]
heapq.heapify(heap)
order = []
while heap:
_, n = heapq.heappop(heap)
order.append(n)
for m in succ[n]:
indeg[m] -= 1
if indeg[m] == 0:
heapq.heappush(heap, (center_sum(boxes[m]), m))
assert len(order) == len(boxes), "cycle in behind-graph"
return order
def entry(b, u, w):
# Ray (s, s-u, (2s-u-w)/2) toward the viewer at +(1,1,1); larger s is nearer.
lo = max(b[0], b[2] + u, b[4] + (u + w) / 2)
hi = min(b[1], b[3] + u, b[5] + (u + w) / 2)
return hi if lo < hi else None
def visible(u, w):
hits = {n: entry(b, u, w) for n, b in BOXES.items()}
hits = {n: s for n, s in hits.items() if s is not None}
return max(hits, key=hits.get) if hits else None
def painted(order, u, w):
hit = [n for n in order if entry(BOXES[n], u, w) is not None]
return hit[-1] if hit else None
samples = [(-4.875 + 0.25 * i, -6.875 + 0.25 * j) for i in range(48) for j in range(60)]
covered = [(u, w) for u, w in samples if visible(u, w) is not None]
for n, b in BOXES.items():
print(f"{n:7s} box={b} center x+y+z = {center_sum(b)}")
naive = sorted(BOXES, key=lambda n: center_sum(BOXES[n]))
topo = topo_order(BOXES)
results = {}
for label, order in (("naive x+y+z sort", naive), ("topological sort", topo)):
bad = [(u, w) for u, w in covered if painted(order, u, w) != visible(u, w)]
results[label] = bad
print(f"\n{label}: draw order {' -> '.join(order)}")
print(f" wrong top box at {len(bad)} of {len(covered)} covered samples")
if bad:
u, w = bad[0]
print(f" e.g. screen (u={u}, w={w}): visible={visible(u, w)}, "
f"painted on top={painted(order, u, w)}")
assert results["naive x+y+z sort"], "naive sort unexpectedly correct"
assert not results["topological sort"], "topological sort produced errors"
Why the naive sort fails
center_sum adds the box's center coordinates. The slab is long, so its center sits at x = 2 and its sum is 3.0. The cube's sum is 2.5. Sorting ascending draws the cube first and the slab second, so the slab covers the cube wherever they overlap on screen.
The geometry says otherwise. The slab ends at y = 1 and the cube starts at y = 1. The cube lies entirely on the near side of the slab along y, so the slab must be drawn first. One long axis can push a box's center sum past a neighbor's even when that neighbor lies entirely on the near side of it along another axis.
The fix: order by separation, not by center
behind(a, b) checks whether a ends at or before the point where b begins on some axis. Every view-direction component is positive. If a is separated from b that way, a can never cover b. The code turns each one-way behind relation into a graph edge a -> b and runs a topological sort (Kahn's algorithm). The center sum is used only as a tie-breaker among boxes that are ready to draw.
If behind holds in both directions, the code adds no edge and lets the tie-breaker decide. That happens when the boxes are separated on two axes in opposite directions. This case was not tested here. In this scene, every pair gets an edge: slab before cube, slab before pillar, and cube before pillar.
What the program checks
The program does not trust either order. It samples a grid of 48 × 60 screen points at 0.25 spacing in screen coordinates u = x - y, w = x + y - 2z. At each point it casts a ray along the view direction and picks the box whose near surface is closest to the viewer. That box is the ground truth. The program compares it with the last box in the draw order whose silhouette covers the point. The final asserts stop the run if the naive sort came out correct, or if the topological sort produced any mismatch.
slab box=(0, 4, 0, 1, 0, 1) center x+y+z = 3.0
cube box=(0, 1, 1, 2, 0, 1) center x+y+z = 2.5
pillar box=(3, 4, 2, 3, 0, 3) center x+y+z = 7.5
naive x+y+z sort: draw order cube -> slab -> pillar
wrong top box at 28 of 440 covered samples
e.g. screen (u=-0.875, w=-0.625): visible=cube, painted on top=slab
topological sort: draw order slab -> cube -> pillar
wrong top box at 0 of 440 covered samples
Of the 440 sampled points covered by any box, the naive order gets 28 wrong. At (u=-0.875, w=-0.625), for example, the cube is visible but the slab is painted on top. The topological order gets 0 of 440 wrong. The two orders differ only by swapping slab and cube.
This result is narrow. For one scene, at one sampling resolution, the graph order matches ray-cast ground truth at every sample and the center sum does not. It is not a proof for all scenes, and a 0.25 grid could miss a sliver thinner than that spacing.
Where this stops being enough
- Cycles. Boxes can cover each other in a loop, and then no single draw order works. The
assertstops on a cycle in the behind-graph but does not resolve it. You would need to split a box. This scene has no cycle. - Interpenetrating boxes. If two boxes overlap in volume,
behindis false both ways. The code adds no edge and the tie-breaker decides, so the result may be wrong for part of the overlap. - Scale. Building the graph checks every pair, which is quadratic. For large scenes, you can compare only boxes whose screen footprints overlap.
- Unequal sizes. The failure here comes from the slab being longer than its neighbor. This program does not test whether
x + y + zis safe for uniform unit cubes, so check your own scene before relying on it.
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