Animate an isometric machine in plain SVG: a conveyor belt that loops with no seam

Change one number in this conveyor animation, the roller radius from 0.6366 to 0.6, and the spoke on every roller jumps 22 degrees each time the loop restarts. Nothing throws, and the crates still look right. You only see a small twitch every 4 seconds, and you can spend a long time looking for it.

This is a common kind of looping bug: some part doesn't quite return to where it started. This tutorial builds a looping isometric machine in under 90 lines of JavaScript and plain SVG, with crates, belt slats, turning rollers and a press that comes down on each crate, in a form where you can test the seam instead of watching for it.

1. Make each frame a function of time

The one rule that makes everything else easy: nothing moves by adding a little each tick. Each frame is computed from t alone:

function frame(t) { /* returns the whole SVG for time t */ }

There's no state to drift or reset. You can jump to any moment, which also means you can render any frame on its own later. And the seam test becomes one line, because a perfect loop means the two frames are the same string:

loop: 4 s; frame(0) === frame(LOOP): true

That check is strict enough to catch real mistakes. It failed on my first run. The picture was fine, but the slats came out in a different order in the SVG. I sorted them by position and it passed. Setting the roller radius to 0.6 makes it print false too, so the bug in the opening doesn't get past it.

2. Every part's period has to divide the loop

The crates set the loop: they're 4 units apart and move at 1 unit per second, so the picture should repeat every 4 seconds. Every other moving part must then fit a whole number of repeats into those 4 seconds.

  • Slats are 1 unit apart, and 1 divides 4, so they fit.
  • The press runs once per loop by construction.
  • Rollers are where it goes wrong. A roller that doesn't slip against the belt turns by distance / radius. Over one loop that's 4 / r radians, which has to be a whole number of turns.

So the radius isn't a free choice. For one turn per loop, it comes out of the spacing:

const ROLLER_R = SPACING / (2 * Math.PI);    // 0.6366

The script prints what the wrong value does:

roller radius 0.6366: spoke is off by 0.0 degrees at the seam
roller radius 0.6000: spoke is off by 22.0 degrees at the seam

3. Hide the wrap inside the machine

Crates have to go back to the start somewhere, and that jump must not be visible. So the belt runs between two boxes: a feeder where crates come out and an exit machine where they go in. A crate's x wraps while it's out of sight.

Those two ends need different handling, and the reason is which faces you can see. The exit machine's low-x side faces away from the viewer, so if it's painted after the crates, a crate just slides behind it. The feeder is the opposite. Its x + w face points at the viewer and the crates come out of that face, so a crate painted over it would float in front of the wall.

The fix is to cut the crate in world space. It's a shorter box, not an SVG clip path, so its faces stay correct:

const cut = Math.max(x, 0);
out.push({ x: cut, /* ... */ w: x + CRATE.w - cut });

4. The press: ease, timing and no overlap

A press that moves at constant speed looks like a glitch. Smoothstep, u * u * (3 - 2 * u), starts and ends at zero speed, so it settles.

Timing comes from the same arithmetic as everything else. A crate that starts at x = 0 reaches the press at PRESS.x / SPEED, and the crates are spaced one loop apart, so one press per loop meets every crate. And since a crate's colour depends only on whether its x has passed the press, the "stamped" change needs no flag and no memory.

"It looks like it touches" isn't a check, so the script walks 2 full loops at 60 fps. It measures the smallest gap between the press and any crate under it, and where the nearest crate is at the moment the press is fully down:

smallest press-to-crate gap in 2 loops: 0.0000; samples exactly at full press: 2; crate offset from the press there: 0.0000

Be clear about what those numbers prove. The gap can't go negative here, because PRESS.lo is defined as the top of a crate and pressZ never goes below it, so "no overlap" comes from the construction. What the gap of 0 shows is that the press does reach a crate. The offset of 0 is the real timing test: the crate is centred under the press when it lands, and a wrong touch time would make that number grow. There are only 2 samples because the count needs exact equality with PRESS.lo, which happens only when a sample falls on the touch time, once per loop. At another frame rate a sample might miss it.

5. Paint order, on one axis

Crates move, so a fixed draw order won't do. Here the shape of the scene helps: the crates and the press share the same y range, so depth comes down to one axis. A crate that starts before the press ends is under it or behind it, and a crate past it is in front. That's a one-line sort key:

const items = [...crates(t).map(c => ({ key: c.key, svg: box(c) })), press].sort((a, b) => a.key - b.key);

This only holds because everything on the belt shares a y range. Add a second belt alongside and you need a proper depth sort.

The whole script

Save it as belt.mjs. I ran it on Node 26; there are no dependencies:

// belt.mjs: a looping isometric conveyor in plain SVG. Every frame is a pure function of time. No libraries.
// Run: node belt.mjs    prints the checks, writes belt.html (the animation) and frame.svg (one still)
const COS30 = Math.cos(Math.PI / 6), SIN30 = 0.5, S = 26, OX = 150, OY = 170;
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(2)).join(',')).join(' ');
const poly = (p, fill, line = 'none') => `<polygon points="${pts(p)}" fill="${fill}" stroke="${line}" stroke-width="0.8" stroke-linejoin="round"/>`;
const tone = (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('');
function box({ x, y, z, w, d, h, color }) {
  const line = tone(color, 0.45);
  return poly([[x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]], tone(color, 0.8), line)
    + poly([[x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]], tone(color, 0.6), line)
    + poly([[x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]], tone(color, 1.15), line);
}

// The loop. Crates are SPACING apart and move at SPEED, so the picture repeats every LOOP seconds.
const SPEED = 1, SPACING = 4, LOOP = SPACING / SPEED, MOD = (a, n) => ((a % n) + n) % n;
const BELT = { end: 12, d: 3, top: 1 }, CRATE = { y: 0.7, w: 1.6, d: 1.6, h: 1.2 };
const ROLLER_R = SPACING / (2 * Math.PI);    // one full turn per loop, with no slip against the belt
const PRESS = { x: 5.2, y: 0.7, w: 1.6, d: 1.6, h: 1, hi: 3.6, lo: BELT.top + CRATE.h };

const ease = u => u * u * (3 - 2 * u);       // smoothstep: starts and stops with zero speed
// The press comes down and back up inside one loop and touches when a crate sits exactly under it.
function pressZ(t) {
  const touch = PRESS.x / SPEED, half = 0.6;   // a crate that starts at x = 0 lines up with the press at this time
  const dt = Math.abs(MOD(t - touch + LOOP / 2, LOOP) - LOOP / 2);
  return PRESS.lo + (PRESS.hi - PRESS.lo) * ease(Math.min(dt / half, 1));
}

// A crate travels from x = -SPACING to x = BELT.end. It is cut at the feeder's wall in world space, so it slides out of the wall.
function crates(t) {
  const out = [];
  for (let i = 0; i < 4; i++) {
    const x = MOD(i * SPACING + SPEED * t, 16) - SPACING;
    if (x + CRATE.w <= 0 || x >= BELT.end) continue;
    const cut = Math.max(x, 0), stamped = x > PRESS.x;
    out.push({ x: cut, y: CRATE.y, z: BELT.top, w: x + CRATE.w - cut, d: CRATE.d, h: CRATE.h, color: stamped ? '#22a06b' : '#d58a2f', key: x });
  }
  return out;
}

// A disc on the near side of the belt with one spoke, turned by the distance the belt has travelled.
function roller(x, t) {
  const a = -SPEED * t / ROLLER_R, c = q => [x + ROLLER_R * Math.cos(q), BELT.d + 0.01, 0.35 + ROLLER_R * Math.sin(q)];
  const ring = Array.from({ length: 24 }, (_, i) => c(i / 24 * 2 * Math.PI));
  return poly(ring, '#2a3140', '#10151e') + poly([[x, BELT.d + 0.01, 0.35], c(a - 0.14), c(a + 0.14)], '#d7dee9');
}

function frame(t) {
  const slats = [];   // slats are 1 apart, which divides SPACING, so they repeat with the loop
  for (let k = 0; k < 12; k++) {
    const x = MOD(k + SPEED * t, 12);
    slats.push([x, poly([[x, 0, 1], [x + 0.08, 0, 1], [x + 0.08, 3, 1], [x, 3, 1]], '#3b4557')]);
  }
  // Everything on the belt shares one y range, so order is one axis: a crate that starts before the press ends goes under it.
  const z = pressZ(t), top = 6.4;
  const press = { key: PRESS.x + PRESS.w, svg: box({ x: PRESS.x + 0.55, y: PRESS.y + 0.55, z: z + PRESS.h, w: 0.5, d: 0.5, h: top - z - PRESS.h, color: '#9aa7b8' }) + box({ ...PRESS, z, color: '#c8504b' }) };
  const items = [...crates(t).map(c => ({ key: c.key, svg: box(c) })), press].sort((a, b) => a.key - b.key);
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 460" width="560" height="460"><rect width="560" height="460" fill="#0f1420"/>`
    + box({ x: -3, y: -0.5, z: -0.3, w: 3, d: 4, h: 3.7, color: '#46536a' })
    + box({ x: PRESS.x + 0.5, y: -0.6, z: 1, w: 0.6, d: 0.5, h: 5.4, color: '#5b6b82' })
    + box({ x: 0, y: 0, z: -0.3, w: BELT.end, d: BELT.d, h: BELT.top + 0.3, color: '#2a3140' }) + slats.sort((a, b) => a[0] - b[0]).map(s => s[1]).join('')
    + [2, 4, 6, 8, 10].map(x => roller(x, t)).join('')
    + items.map(i => i.svg).join('')
    + box({ x: PRESS.x + 0.5, y: -0.6, z: top, w: 0.6, d: 3, h: 0.5, color: '#5b6b82' })
    + box({ x: BELT.end, y: -0.5, z: -0.3, w: 3, d: 4, h: 3.7, color: '#2f6bd8' }) + '</svg>';
}

if (typeof document !== 'undefined') {
  const paint = ms => { document.body.innerHTML = frame((ms / 1000) % LOOP); requestAnimationFrame(paint); };
  requestAnimationFrame(paint);
} else {
  const { readFileSync, writeFileSync } = await import('node:fs');
  const me = readFileSync(new URL(import.meta.url), 'utf8');
  writeFileSync('belt.html', `<!doctype html><body style="margin:0;background:#0f1420;overflow:hidden"><script type="module">\n${me}\n<` + `/script>\n`);
  writeFileSync('frame.svg', frame(Number(process.argv[2] ?? 5.2)));
  console.log(`loop: ${LOOP} s; frame(0) === frame(LOOP): ${frame(0) === frame(LOOP)}`);
  // Walk 2 full loops at 60 fps: how close the press comes to a crate, and where that crate is when the press is fully down.
  let gap = Infinity, hits = 0, off = Infinity;
  for (let f = 0; f < 2 * LOOP * 60; f++) {
    const t = f / 60, z = pressZ(t);
    for (const c of crates(t)) if (c.key < PRESS.x + PRESS.w && c.key + CRATE.w > PRESS.x) gap = Math.min(gap, z - (c.z + c.h));
    if (z === PRESS.lo) { hits++; for (const c of crates(t)) off = Math.min(off, Math.abs(c.key - PRESS.x)); }
  }
  console.log(`smallest press-to-crate gap in 2 loops: ${gap.toFixed(4)}; samples exactly at full press: ${hits}; crate offset from the press there: ${off.toFixed(4)}`);
  // What a wrong roller radius does: the spoke no longer returns to its start when the loop restarts.
  for (const r of [ROLLER_R, 0.6]) console.log(`roller radius ${r.toFixed(4)}: spoke is off by ${(MOD(SPEED * LOOP / r, 2 * Math.PI) * 180 / Math.PI).toFixed(1)} degrees at the seam`);
}
node belt.mjs

It writes two files and prints the checks shown above. frame.svg is a still at 5.2 seconds, and you can pass another time, as in node belt.mjs 3.1. belt.html is the animation: open it in a browser. It contains the same source file, which runs its requestAnimationFrame branch there, so the page and the checks use the same frame function. The page wraps time with (ms / 1000) % LOOP, and that wrap is where a bad seam would show, as it would in a looping GIF or video. I looked at stills and the page in headless Chrome only, so I haven't watched it in other browsers.

Rewriting the whole SVG on every frame is simple and slow. It's fine for this one small figure, but for a page with several you'd update attributes instead.

Where this stops being enough

This belt has one idea, and a how-it-works figure usually needs several: live readouts, callouts that follow a moving part, cutaways, and a camera. Those have to stay in sync with each other and with narration if the figure becomes a video, and that's where named cues on one timeline earn their keep. Because every frame here is a function of t, those parts have one clock to share, which is a good place to start.

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

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