Draw an isometric server rack in plain SVG: panels, flat text and a layout from data
A server rack looks like hours of hand-placed polygons. The one this script draws has 137 of them, and 130 are generated from a list of six units. The other 7 are the floor plate, the rack shell and its dark back panel. It takes about 100 lines of JavaScript with no libraries.
Once you can draw an isometric box, a rack is a good next step, because it brings three problems a box never shows: details that sit on a face, text that lies flat on a face, and a layout that comes from data. Each has a small answer.
1. Start from one box
The projection is the classic one. With a world point (x, y, z), +x runs down-right on screen, +y runs down-left and +z runs up:
const project = ([x, y, z]) => [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. A box draws three polygons: the top, the face at x + w and the face at y + d. In this drawing the y + d face is the rack's front.
2. Draw details in panel coordinates
Vents, ports and lights are where isometric drawings usually get painful, because people work out each corner's screen position. Don't. Work on the panel, then let the projection do the rest. This helper turns a position on a panel into a world point, with u running across and v running up from the panel's lower left corner:
const panel = b => (u, v) => [b.x + u, b.y + b.d, b.z + v];
const rect = (at, u, v, w, h, fill) => poly([at(u, v), at(u + w, v), at(u + w, v + h), at(u, v + h)], fill);
A vent slot is then just a flat rectangle at u = 6.2 + i * 0.35. It comes out as a correct parallelogram because this projection keeps parallel lines parallel. Every slot and port in the drawing goes through that one helper, so moving a panel moves everything on it.
This works because a box's three visible faces don't overlap on screen, so details painted after the box land only on their own face. They never need a depth sort of their own.
3. Text that lies flat on a face
Text is the part you can't do with polygons. SVG's transform="matrix(a b c d e f)" handles it, and you don't need to memorise anything. The first two numbers say where one text pixel along the line lands on screen. The next two say where one text pixel down the line lands. The last two are where the text starts.
On the front face, text runs along +x, which is (cos 30, sin 30) on screen, and "down" is world -z, which is straight down the screen, (0, 1). On the right face, text runs along -y so that it reads left to right, and on the top face, "down" is +y:
const MATRIX = { front: [COS30, SIN30, 0, 1], right: [COS30, -SIN30, 0, 1], top: [COS30, SIN30, -COS30, SIN30] };
It's easy to get a sign wrong here and end up with text on the wrong slant or mirrored, so the script checks for both. It transforms text coordinates through each matrix and compares the result with the projected world point, and it checks each determinant is positive, because a negative one means a mirrored face. Here's that output, from the full run below:
text matrix worst error: 0.000000 px
text faces not mirrored: true
When I rendered the labels in headless Chrome, they sat flat on all three faces. The check tests the matrices only. It says nothing about whether the font looks good at that size, so open rack.svg and check small sizes by eye.
4. The layout comes from a list
One world unit is 1U, the standard rack height unit, so a unit's height in the list is its height in the drawing. Each unit has a name, a height and a kind, and place stacks them from the bottom:
function place(units, capacity = CAPACITY) {
const need = units.reduce((n, u) => n + u.u, 0);
if (need > capacity) throw new Error(`needs ${need}U, rack has ${capacity}U`);
let z = RACK.z + 1;
return units.map(u => { const b = { ...u, ...SLOT, z, h: u.u }; z += u.u; return b; });
}
Refusing a list that doesn't fit matters more than it looks. Without it, extra units run past the last slot and over the rack's frame, and a big enough list pokes out of the top, yet the picture still renders, so nothing tells you it's wrong. Adding a 10U unit to this list gets the message below instead.
The kind picks a small function that draws that unit's face: drive bays in a grid, vent slots, a row of ports. To add a new kind of hardware, you add one function.
5. Why no depth sort?
Isometric drawings with mixed parts usually need a careful paint order. This one doesn't, because every unit has the same footprint and they only stack. For two such boxes, the lower one ends where the next begins, so the upper one is nearer the viewer. Painting from the bottom up is correct every time. The rack itself is painted first, and each unit sits on its front plane and sticks out by 0.2, so units always paint over the rack. Check for structure like this before you reach for a general sort.
The whole script
Save it as rack.mjs. I ran it on Node 26; there are no dependencies:
// rack.mjs: an isometric server rack in plain SVG, built from a list of units. No libraries.
// Run: node rack.mjs prints the layout and checks, writes rack.svg
import { writeFileSync } from 'node:fs';
const COS30 = Math.cos(Math.PI / 6), SIN30 = 0.5, S = 18, OX = 265, OY = 515;
const project = ([x, y, z]) => [OX + (x - y) * COS30 * S, OY + (x + y) * SIN30 * S - z * S];
const pts = p => p.map(q => project(q).map(v => v.toFixed(1)).join(',')).join(' ');
const poly = (p, fill, line = 'none') => `<polygon points="${pts(p)}" fill="${fill}" stroke="${line}" stroke-width="0.6" stroke-linejoin="round"/>`;
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.3), right: mix(0.7), left: mix(1), line: mix(0.45) };
}
function box({ x, y, z, w, d, h, color }) {
const t = tones(color);
return poly([[x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]], t.left, t.line)
+ poly([[x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]], t.right, t.line)
+ poly([[x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]], t.top, t.line);
}
// Everything on a front panel is drawn in panel coordinates: u across, v up, from the panel's lower left corner.
const panel = b => (u, v) => [b.x + u, b.y + b.d, b.z + v];
const rect = (at, u, v, w, h, fill) => poly([at(u, v), at(u + w, v), at(u + w, v + h), at(u, v + h)], fill);
// Text lying on a face: the matrix columns are where one text pixel along and one text pixel down land on screen.
const MATRIX = { front: [COS30, SIN30, 0, 1], right: [COS30, -SIN30, 0, 1], top: [COS30, SIN30, -COS30, SIN30] };
function label(world, face, text, size = 11, fill = '#e8eef6') {
const [e, f] = project(world), m = [...MATRIX[face], e, f].map(v => v.toFixed(4)).join(' ');
return `<text transform="matrix(${m})" font-family="monospace" font-size="${size}" fill="${fill}">${text}</text>`;
}
// One world unit is 1U. Units stack upward from the bottom and the rack refuses a list that does not fit.
const RACK = { x: 0, y: 0, z: 1, w: 12, d: 9, h: 26, color: '#2a3140' }, SLOT = { x: 0.7, y: 9, w: 10.6, d: 0.2 }, CAPACITY = 24;
function place(units, capacity = CAPACITY) {
const need = units.reduce((n, u) => n + u.u, 0);
if (need > capacity) throw new Error(`needs ${need}U, rack has ${capacity}U`);
let z = RACK.z + 1;
return units.map(u => { const b = { ...u, ...SLOT, z, h: u.u }; z += u.u; return b; });
}
const FACE = {
ups(a, b) { return rect(a, 4.5, 0.6, 4, b.h - 1.2, '#10151e') + rect(a, 9.4, b.h - 1, 0.5, 0.5, '#4ade80'); },
storage(a, b) { // a grid of drive bays, each with one activity light
let s = '';
for (let r = 0; r < 3; r++) for (let c = 0; c < 8; c++) {
const u = 0.4 + c * 1.27, v = 0.2 + r * (b.h - 1.2) / 3;
s += rect(a, u, v, 1.1, (b.h - 1.2) / 3 - 0.15, '#161c28') + rect(a, u + 0.85, v + 0.15, 0.16, 0.16, c % 3 ? '#4ade80' : '#fbbf24');
}
return s;
},
server(a, b) { // vent slots on the right, two status lights on the left
let s = rect(a, 0.5, b.h - 0.65, 0.3, 0.3, '#4ade80') + rect(a, 1, b.h - 0.65, 0.3, 0.3, '#60a5fa');
for (let i = 0; i < 12; i++) s += rect(a, 6.2 + i * 0.35, 0.3, 0.16, b.h - 0.6, '#10151e');
return s;
},
switch(a, b) { // a row of ports, every sixth one dark
let s = '';
for (let i = 0; i < 20; i++) s += rect(a, 0.5 + i * 0.48, 0.25, 0.34, b.h - 0.5, i % 6 === 5 ? '#10151e' : '#60a5fa');
return s;
},
};
const units = [
{ name: 'UPS', u: 3, kind: 'ups', color: '#394150' }, { name: 'STORAGE-1', u: 4, kind: 'storage', color: '#46536a' },
{ name: 'DB-1', u: 3, kind: 'server', color: '#2f6bd8' }, { name: 'APP-2', u: 2, kind: 'server', color: '#14795a' },
{ name: 'APP-1', u: 2, kind: 'server', color: '#14795a' }, { name: 'SWITCH', u: 1, kind: 'switch', color: '#5b4bb5' },
];
const placed = place(units);
// Same footprint, stacked: paint from the bottom up, and a lower unit never covers an upper one.
function unit(b) {
const at = panel(b);
return box(b) + FACE[b.kind](at, b) + (b.u > 1 ? label(at(0.5, b.kind === 'storage' ? b.h - 0.85 : 0.25), 'front', b.name) : '');
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 740" width="560" height="740">
<rect width="560" height="740" fill="#0f1420"/>
${box({ x: -1, y: -1, z: 0, w: 14, d: 11, h: 1, color: '#3a4556' })}
${box(RACK)}
${rect(panel(RACK), 0.7, 1, 10.6, 24, '#0a0d14')}
${placed.map(unit).join('\n')}
${label([3, 8.2, RACK.z + RACK.h], 'top', 'RACK A1', 14, '#c7d2e2')}
${label([12, 7.4, RACK.z + 25], 'right', '24U', 14, '#c7d2e2')}
</svg>
`;
writeFileSync('rack.svg', svg);
// Prove each matrix against the projection: text coordinates (10, 0) and (0, 10) must land on the projected world points.
const check = {
front: [[10, 0, 0], [0, 0, -10]], right: [[0, -10, 0], [0, 0, -10]], top: [[10, 0, 0], [0, 10, 0]],
};
let worst = 0;
for (const [face, [along, down]] of Object.entries(check)) {
const [a, b, c, d] = MATRIX[face], o = project([4, 4, 4]);
const at = (dx, dy) => [o[0] + a * dx + c * dy, o[1] + b * dx + d * dy];
const to = v => project([4 + v[0] / S, 4 + v[1] / S, 4 + v[2] / S]);
for (const [got, want] of [[at(10, 0), to(along)], [at(0, 10), to(down)]])
worst = Math.max(worst, Math.hypot(got[0] - want[0], got[1] - want[1]));
}
const used = units.reduce((n, u) => n + u.u, 0);
console.log(`rack ${CAPACITY}U: used ${used}U, free ${CAPACITY - used}U`);
for (const b of placed) console.log(`${b.name.padEnd(10)} U${b.z - RACK.z}-U${b.z - RACK.z + b.u - 1}`);
console.log(`text matrix worst error: ${worst.toFixed(6)} px`);
console.log(`text faces not mirrored: ${Object.values(MATRIX).every(([a, b, c, d]) => a * d - b * c > 0)}`);
try { place([...units, { name: 'GPU', u: 10, kind: 'server' }]); } catch (e) { console.log('extra 10U unit refused: ' + e.message); }
node rack.mjs
rack 24U: used 15U, free 9U
UPS U1-U3
STORAGE-1 U4-U7
DB-1 U8-U10
APP-2 U11-U12
APP-1 U13-U14
SWITCH U15-U15
text matrix worst error: 0.000000 px
text faces not mirrored: true
extra 10U unit refused: needs 25U, rack has 24U
It writes rack.svg, so open that in a browser. To change the rack, edit the units list and run it again. There are three limits to know about: ups and storage units need at least 2U, because their face drawings subtract from the height; a 1U unit gets no label, because there's no room for one beside what's on its face; and a switch of 2U or more gets its label on top of its ports.
Where this stops being enough
The drawing has one rack seen from one side, so the panel helper only needs the front face. Identical racks in a row are easy, since they paint back to front, but parts of mixed sizes standing around them bring back the depth problem. There are no cables yet, and routing them cleanly across faces is its own job. A door opening or a tray sliding out needs animation and a fresh look at paint order, and turning a figure like this into a video adds a camera, cutaways and timing. Each of those is a small project of its own.
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