Isometric paint order that doesn't break: separation test and topological sort in plain JavaScript
Isometric drawings with parts of mixed sizes all hit the same bug. You sort your boxes by depth, it looks right, then you add one long pipe and it paints straight over the pump in front of it. Change the sort key and something else breaks.
The reason is that with mixed sizes, "which box is in front" isn't a number each box has. It's a relation between pairs of boxes. This tutorial builds the version that holds, in about 100 lines of JavaScript with no libraries: test each overlapping pair, build a graph, sort the graph, and refuse the scenes no order can draw. On the test scene below, the usual layered sort gets 299 of 2,846 sampled overlap points wrong. This one gets 0.
1. Why a sort key fails
SVG paints in document order, so you must emit far things first. With this projection, +x runs down-right on screen, +y down-left, +z up, and the viewer sits on the high side of all three axes.
The obvious key is the near corner, (x + w) + (y + d). Run it on a scene with a plate, a pipe, a pump, a tank and a floating lamp:
near corner: pump lamp pipe tank plate | wrong: pipe under plate, pump under plate, tank under plate, lamp under plate, pump under pipe, lamp under pipe
The plate is the widest part, so its near corner is largest and it's drawn last, over everything standing on it. Sorting by height first fixes that and leaves the harder failure:
layer, then near corner: plate pump pipe tank lamp | wrong: pump under pipe, tank under lamp
The pipe is long, so its near corner is far along even though it sits behind the pump. And the lamp floats higher than the tank's base, so it's drawn after the tank even though the tank stands in front of it. A single number can't say "long but behind" or "high but behind".
2. The separation test
For one pair, the answer is simple. A can't cover B if A ends before B starts on any axis, because the viewer looks from the high side of all three:
const behind = (a, b) => a.x + a.w <= b.x || a.y + a.d <= b.y || a.z + a.h <= b.z;
If the pipe's y + d is 2 and the pump's y is 2.5, the pipe is behind the pump. It doesn't matter how long the pipe is, and that's exactly what the sort key got wrong.
3. Only overlapping pairs matter
Two boxes on opposite sides of the drawing have no order at all, and pretending they do creates false conflicts. So first check whether their outlines overlap on screen.
This has a neat shortcut. The view direction is (1, 1, 1), so moving along it changes none of x - y, x - z and y - z. That makes those three values screen quantities: two points that land on the same pixel share all three. And a box outline is a hexagon whose sides follow only three directions, one for each value, so you don't need general polygon clipping. Compare the three ranges. If they overlap on all three, the outlines overlap.
For x - y on one box, the smallest value is at low x and high y, x - y - d, and the largest is at high x and low y, x + w - y. In the helper below, p and q are the two axes and ps and qs are their sizes:
function overlapOnScreen(a, b) {
const range = (o, p, q, ps, qs) => [o[p] - o[q] - o[qs], o[p] + o[ps] - o[q]];
return [['x', 'y', 'w', 'd'], ['x', 'z', 'w', 'h'], ['y', 'z', 'd', 'h']].every(([p, q, ps, qs]) => {
const [a0, a1] = range(a, p, q, ps, qs), [b0, b1] = range(b, p, q, ps, qs);
return a0 < b1 && b0 < a1;
});
}
4. Sort the graph
Each overlapping pair gives one rule: draw A before B. Those rules form a directed graph, and any order that respects every rule is correct. That's a topological sort. Kahn's algorithm does it: draw anything that isn't waiting on anything, remove it, repeat.
5. The scenes no order can draw
There are two. Boxes that share space have no separating axis, so neither is behind the other. The script reports those and stops, because the fix is to correct the scene.
The other is more interesting. Three bars can each be covered by the next, like a pinwheel: b over a, c over b, a over c. Now the graph has a cycle, and Kahn's algorithm stops with those boxes still waiting. That's the useful part, because it reports the problem instead of drawing it wrong:
pinwheel: drawn 0 of 3; stuck: a b c
Cut one bar in two where the part blocking it has a face, here bar a at x = 1, on the plane of c's face. Each half takes its own place in the order, and the cycle is gone. The place matters: for this scene a cut at any other x leaves one half in a cycle.
pinwheel split: a1 b c a2 | stuck: none
The whole script
Save it as depth.mjs. I ran it on Node 26; there are no dependencies:
// depth.mjs: correct paint order for isometric boxes: separation test + topological sort. No libraries.
// Run: node depth.mjs prints what each sort gets wrong, and writes scene.svg
import { writeFileSync } from 'node:fs';
const COS30 = Math.cos(Math.PI / 6), S = 22, OX = 330, OY = 120;
const project = ([x, y, z]) => [OX + (x - y) * COS30 * S, OY + (x + y) * 0.5 * S - z * S];
// x, y, z is the far corner; w, d, h the size. +x runs down-right, +y down-left, +z up, so the viewer sits at +x +y +z.
const scene = [
{ id: 'plate', x: 0, y: 0, z: 0, w: 12, d: 8, h: 0.5, color: '#64748b' },
{ id: 'pipe', x: 0.5, y: 1, z: 0.5, w: 11, d: 1, h: 1, color: '#b45309' },
{ id: 'pump', x: 3, y: 2.5, z: 0.5, w: 2, d: 2, h: 2, color: '#2563eb' },
{ id: 'tank', x: 7, y: 3, z: 0.5, w: 3, d: 3, h: 3, color: '#16a34a' },
{ id: 'lamp', x: 5, y: 4, z: 2.5, w: 2, d: 2, h: 0.5, color: '#eab308' }, // floats, behind the taller tank
];
// Three bars where each one is covered by the next, all the way round. No order can draw this.
const pinwheel = [
{ id: 'a', x: 0, y: 1, z: 0, w: 3, d: 1, h: 1, color: '#2563eb' },
{ id: 'b', x: 0, y: 0, z: 1, w: 1, d: 3, h: 1, color: '#16a34a' },
{ id: 'c', x: 1, y: 0, z: 0, w: 1, d: 1, h: 3, color: '#b45309' },
];
// True if the two outlines overlap on screen. An isometric box outline is a hexagon whose sides
// follow only three directions, so comparing ranges along x-y, x-z and y-z is enough.
function overlapOnScreen(a, b) {
const range = (o, p, q, ps, qs) => [o[p] - o[q] - o[qs], o[p] + o[ps] - o[q]];
return [['x', 'y', 'w', 'd'], ['x', 'z', 'w', 'h'], ['y', 'z', 'd', 'h']].every(([p, q, ps, qs]) => {
const [a0, a1] = range(a, p, q, ps, qs), [b0, b1] = range(b, p, q, ps, qs);
return a0 < b1 && b0 < a1;
});
}
// A can't cover B if A ends before B starts on any axis, because the viewer looks from the high side of all three.
const behind = (a, b) => a.x + a.w <= b.x || a.y + a.d <= b.y || a.z + a.h <= b.z;
// Edge a -> b means a must be drawn before b. Only pairs that overlap on screen get one.
function edges(boxes) {
const out = [];
for (const a of boxes) for (const b of boxes) {
if (a !== b && overlapOnScreen(a, b) && behind(a, b)) out.push([a, b]);
}
return out;
}
// Boxes that share space: no axis separates them, so no order is right. Fix the scene.
const intersecting = boxes => boxes.flatMap((a, i) => boxes.slice(i + 1)
.filter(b => !behind(a, b) && !behind(b, a)).map(b => `${a.id}+${b.id}`));
// Kahn's algorithm. Whatever is left when nothing is free to draw is part of a cycle, or depends on one.
function paintOrder(boxes) {
const all = edges(boxes), waiting = new Map(boxes.map(b => [b, 0]));
for (const [, b] of all) waiting.set(b, waiting.get(b) + 1);
const ready = boxes.filter(b => waiting.get(b) === 0), order = [];
while (ready.length) {
const a = ready.shift();
order.push(a);
for (const [from, to] of all) {
if (from === a) {
waiting.set(to, waiting.get(to) - 1);
if (waiting.get(to) === 0) ready.push(to);
}
}
}
return { order, stuck: boxes.filter(b => !order.includes(b)) };
}
// How many overlapping pairs does this order draw the wrong way round?
function wrongPairs(order, boxes) {
return edges(boxes).filter(([a, b]) => order.indexOf(a) > order.indexOf(b)).map(([a, b]) => `${b.id} under ${a.id}`);
}
function svg(order) {
const mix = (hex, k) => '#' + [1, 3, 5].map(i => Math.round(Math.min(255, parseInt(hex.slice(i, i + 2), 16) * k)).toString(16).padStart(2, '0')).join('');
const face = (p, fill, line) => `<polygon points="${p.map(q => project(q).map(v => v.toFixed(1)).join(',')).join(' ')}" fill="${fill}" stroke="${line}" stroke-linejoin="round"/>`;
const box = ({ x, y, z, w, d, h, color: c }) => [
face([[x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]], mix(c, 0.75), mix(c, 0.45)),
face([[x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]], c, mix(c, 0.45)),
face([[x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]], mix(c, 1.25), mix(c, 0.45)),
].join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 460" width="640" height="460"><rect width="640" height="460" fill="#f8fafc"/>${order.map(box).join('')}</svg>`;
}
const near = b => b.x + b.w + b.y + b.d;
const byNearCorner = [...scene].sort((a, b) => near(a) - near(b));
const byLayer = [...scene].sort((a, b) => (a.z - b.z) || (near(a) - near(b)));
const { order, stuck } = paintOrder(scene);
if (stuck.length || intersecting(scene).length) { // never draw a scene with parts silently missing
console.error('fix the scene. stuck:', stuck.map(b => b.id).join(' ') || 'none', '| intersecting:', intersecting(scene).join(' ') || 'none');
process.exit(1);
}
console.log('near corner:', byNearCorner.map(b => b.id).join(' '), '| wrong:', wrongPairs(byNearCorner, scene).join(', ') || 'none');
console.log('layer, then near corner:', byLayer.map(b => b.id).join(' '), '| wrong:', wrongPairs(byLayer, scene).join(', ') || 'none');
console.log('topological:', order.map(b => b.id).join(' '), '| wrong:', wrongPairs(order, scene).join(', ') || 'none');
const pin = paintOrder(pinwheel);
console.log('pinwheel: drawn', pin.order.length, 'of 3; stuck:', pin.stuck.map(b => b.id).join(' ') || 'none');
// Break the cycle: cut bar a on the plane of c's face, x = 1. Each half can now take its own place in the order.
const [a, b, c] = pinwheel;
const split = [{ ...a, id: 'a1', w: 1 }, { ...a, id: 'a2', x: a.x + 1, w: 2 }, b, c];
const fixed = paintOrder(split);
console.log('pinwheel split:', fixed.order.map(x => x.id).join(' '), '| stuck:', fixed.stuck.map(x => x.id).join(' ') || 'none');
writeFileSync('scene.svg', svg(order));
node depth.mjs
near corner: pump lamp pipe tank plate | wrong: pipe under plate, pump under plate, tank under plate, lamp under plate, pump under pipe, lamp under pipe
layer, then near corner: plate pump pipe tank lamp | wrong: pump under pipe, tank under lamp
topological: plate pipe pump lamp tank | wrong: none
pinwheel: drawn 0 of 3; stuck: a b c
pinwheel split: a1 b c a2 | stuck: none
It also writes scene.svg, so open that to see the result: the pipe at the back, then the pump, the lamp and the tank in front. To watch the bug, replace order with byLayer in the last line and run it again. If your own scene has a cycle or an intersection, the script says which boxes and exits without drawing, so parts never go missing unnoticed.
"Wrong: none" comes from the same rules that built the order, so it can't catch a mistake in those rules. To test them separately, I used a small check script that isn't part of this listing. It casts a ray along the view direction through a grid of screen points (0.13 world units apart), finds the box truly nearest the viewer, and compares it with the top painted box. Across the 2,846 points where boxes overlap, the layered sort was wrong at 299 and the topological order at 0. The exact counts depend on the grid.
Where this stops being enough
Comparing every pair is O(n²), and this paintOrder also rescans the edge list for each box, so keep it to static drawings of modest size (I haven't benchmarked it). For large scenes, index the edges by box and only compare neighbours. The test works on axis-aligned boxes, so cylinders and slanted parts need a box around them, or splitting. And when parts move, the order can change mid-animation, so recompute it whenever a part crosses another, and handle the case where a move creates a cycle.
Disclosure: I'm the founder of Homingo; Homingo sells the product below, and this article was written with AI assistance, with the code run before publishing.
If you want finished machine figures without hand-writing the geometry: Isometric Explainers is our Claude Code skill. Claude Code draws how-it-works machines for your docs, blogs and landing pages, and one command turns the same figure into a 1080x1920 explainer reel with camera moves, cutaways and callouts timed to narration. 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.
Comments
Post a Comment