Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Isometric diagrams in plain SVG: projection, four-tone shading and painter's order

Isometric drawings explain hardware and systems better than flat boxes and arrows: a reader sees depth, which part sits on which, and where things connect. You don't need a 3D engine for them. About 45 lines of JavaScrip

Isometric drawings explain hardware and systems better than flat boxes and arrows: a reader sees depth, which part sits on which, and where things connect. You don't need a 3D engine for them. About 45 lines of JavaScript and plain SVG polygons are enough to start, and the three ideas below are the ones that decide whether the result looks crafted or broken.

The cover image is exactly what the script at the end draws: a base plate with a tank, a pipe, a pump and a valve.

1. The projection

In a classic isometric view the x and y axes sit 30 degrees below the horizontal and z points straight up. A world point (x, y, z) lands on screen at:

const COS30 = Math.cos(Math.PI / 6), SIN30 = 0.5;

function project([x, y, z], s = 20, ox = 280, oy = 190) {
  return [ox + (x - y) * COS30 * s, oy + (x + y) * SIN30 * s - z * s];
}

s is the pixel size of one world unit and (ox, oy) is where the world origin lands. On screen +x runs down-right, +y runs down-left and +z runs up. That means a bigger x + y is closer to the viewer, which matters in step 3.

2. A box is three faces, and each face gets its own tone

From this camera you only ever see three faces of a box: the top, the face at x + w (screen right) and the face at y + d (screen left). Draw those three as polygons and skip the rest.

Flat colour makes boxes look like stickers. The trick is four tones from one base colour: the top lightest (it faces the light), the right face at the base colour, the left face darker, and an outline darker still so edges read on any background.

function tones(hex) {
  const n = parseInt(hex.slice(1), 16), c = [n >> 16, (n >> 8) & 255, n & 255];
  const mix = k => '#' + c.map(v => Math.round(Math.min(255, v * k)).toString(16).padStart(2, '0')).join('');
  return { top: mix(1.25), right: mix(1.0), left: mix(0.75), line: mix(0.45) };
}

Keep the same four multipliers for every material and the whole drawing reads as lit from one direction.

3. Painter's order (the bug that bites first)

SVG paints in document order, so whatever comes later covers what came before. You have to emit far things first.

The obvious first try is to sort every box by its near corner, (x + w) + (y + d). It looks reasonable on paper and fails at once on this scene: the base plate is the widest part, so its near corner is the biggest, so it is drawn last and buries the pipe and the valve, the lower half of the tank and most of the pump.

For drawings built in layers (a plate, then parts standing on it) a two-key sort fixes it: the bottom z first, then the near corner inside each layer.

const order = (a, b) => (a.z - b.z) || ((a.x + a.w + a.y + a.d) - (b.x + b.w + b.y + b.d));

Know its limits. It holds for compact, similar-sized parts. A long member breaks it even when nothing touches: a long pipe running along x behind a short pump has the bigger near-corner sum, so it gets drawn after the pump and paints over it. Parts that float above another layer can fail the same way. For anything beyond simple layered scenes, use the separation test: with this camera, box A is behind box B when A.x + A.w <= B.x, or A.y + A.d <= B.y, or A.z + A.h <= B.z. Add an edge for each pair whose screen outlines overlap, then sort topologically. If the graph has a cycle (three bars in a pinwheel, each in front of the next), split one box in two to break it.

The whole script

Save it as iso.mjs and run node iso.mjs > machine.svg. Open the SVG in any browser. Node 18 or newer is enough; there are no dependencies.

// iso.mjs: isometric boxes in plain SVG, no libraries. Run: node iso.mjs > machine.svg
const COS30 = Math.cos(Math.PI / 6), SIN30 = 0.5;

// World -> screen. On screen, +x runs down-right, +y runs down-left, +z runs straight up. One unit = `s` px.
function project([x, y, z], s = 20, ox = 280, oy = 190) {
  return [ox + (x - y) * COS30 * s, oy + (x + y) * SIN30 * s - z * s];
}

// Four tones per material: top lightest, right face mid, left face darker, outline darkest.
function tones(hex) {
  const n = parseInt(hex.slice(1), 16), c = [n >> 16, (n >> 8) & 255, n & 255];
  const mix = k => '#' + c.map(v => Math.round(Math.min(255, v * k)).toString(16).padStart(2, '0')).join('');
  return { top: mix(1.25), right: mix(1.0), left: mix(0.75), line: mix(0.45) };
}

const pts = p => p.map(q => project(q).map(v => v.toFixed(1)).join(',')).join(' ');

// A box at (x, y, z) with size (w, d, h). Only the three faces the viewer sees are drawn.
function box({ x, y, z, w, d, h, color }) {
  const t = tones(color);
  const top   = [[x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]];
  const right = [[x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]];
  const left  = [[x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]];
  const face = (p, fill) => `<polygon points="${pts(p)}" fill="${fill}" stroke="${t.line}" stroke-width="1" stroke-linejoin="round"/>`;
  return face(left, t.left) + face(right, t.right) + face(top, t.top);
}

// Painter's order: draw what is farther from the viewer first. For parts that sit on layers
// (a plate, then things standing on it), sort by the bottom z first, then by the near corner
// (x + w) + (y + d) inside each layer.
const order = (a, b) => (a.z - b.z) || ((a.x + a.w + a.y + a.d) - (b.x + b.w + b.y + b.d));

const parts = [
  { x: 0, y: 0, z: 0,   w: 10, d: 6, h: 1,   color: '#5b6b82' }, // base plate
  { x: 1, y: 1, z: 1,   w: 3,  d: 3, h: 5,   color: '#2f7de1' }, // tank
  { x: 4, y: 2, z: 1,   w: 3,  d: 1, h: 1,   color: '#9aa7b8' }, // pipe
  { x: 7, y: 1, z: 1,   w: 2,  d: 3, h: 3,   color: '#e2a03f' }, // pump
  { x: 7.5, y: 4.5, z: 1, w: 1, d: 1, h: 0.6, color: '#d9534f' }  // valve
];

const body = [...parts].sort(order).map(box).join('\n  ');
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 460" width="600" height="460">
  <rect width="600" height="460" fill="#0f1420"/>
  ${body}
</svg>
`);

To add a part, add one line to parts. To change the look, change the four multipliers in tones. Pick mid-tone base colours: tones clamps at 255, so a very light base loses the contrast between its top and right faces. On Windows PowerShell 5.1, node iso.mjs | Out-File -Encoding utf8 machine.svg avoids a UTF-16 file.

Where to take it next

Real explainer figures need more than boxes: cylinders and rounded parts, labels that never collide, cutaways that open a wall to show the inside, live numbers on gauges, and motion. Each of those is its own small project.

For detailed figures without building the renderer yourself, the open-source anatomy skill (MIT) is an agent skill that has Claude Code create detailed, interactive isometric SVG illustrations of machines and systems. Its source is a good place to learn the techniques.

Disclosure: I'm the founder of Homingo; Homingo Learn is our learning brand. We built a paid Claude Code skill that includes that open-source skill with our reel and explainer kit on top. Isometric Explainers adds a 1080x1920 reel kit (camera moves, cutaways, callouts locked to parts, named cues synced to your narration), live readouts, and built-in layout, motion and geometry checks, so one figure becomes both a docs illustration and a vertical explainer reel. It's Rs 1,499 one-time: Isometric Explainers for Claude Code. Everything above works without it.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.