15

Platonic Solid Morpher

drag to orbit ยท click to pause

The five Platonic solids โ€” tetrahedron (4 triangular faces), cube (6 squares), octahedron (8 triangles), dodecahedron (12 pentagons), icosahedron (20 triangles) โ€” are the only convex polyhedra whose faces are congruent regular polygons meeting the same number at every vertex. Euclid proved this in Book XIII of the *Elements* (c. 300 BCE) by a vertex-angle argument: at each vertex at least three faces must meet, and the angles around a vertex must sum to strictly less than . For an -gon face with meeting at a vertex, this gives , i.e. โ€” admitting only the five integer pairs . They are duals in pairs (cube โ†” octahedron, dodecahedron โ†” icosahedron, tetrahedron self-dual). Here each is rendered as edge wireframes via and crossfaded on a continuous loop at s per pair.

idle
186 lines ยท three
view source
// Three.js Platonic solid morpher โ€” the five Platonic solids rendered as
// wireframes, crossfading between each other on a continuous loop.
//
// Contract: this sim runs in the main-thread iframe with three.js loaded
// as window.THREE. init() builds the scene; tick() updates fades and
// renders. The runtime auto-calls renderer.render(scene, camera) if you
// don't, but we call it explicitly here.

const SOLIDS = [
  { name: "Tetrahedron",  make: () => new THREE.TetrahedronGeometry(10),  color: 0xff8855 },
  { name: "Cube",         make: () => new THREE.BoxGeometry(14, 14, 14),  color: 0xffc24a },
  { name: "Octahedron",   make: () => new THREE.OctahedronGeometry(11),   color: 0xffe566 },
  { name: "Dodecahedron", make: () => new THREE.DodecahedronGeometry(10), color: 0xff9bd2 },
  { name: "Icosahedron",  make: () => new THREE.IcosahedronGeometry(10),  color: 0xff5a88 },
];

const MORPH_DURATION = 4.0; // seconds per pair

let currentIdx = 0;
let nextIdx = 1;
let morphT = 0;          // 0..1
let paused = false;

let currentLine = null;
let nextLine = null;

let group;               // holds the two wireframes (rotated by spin)
let labelGroup;          // holds the bottom ring of labels (counter-rotates with camera)
let labels = [];         // { sprite, idx }

let userYaw = 0, userPitch = 0.25;
let isDragging = false;
let mouseDownAt = 0;
let mouseDownX = 0, mouseDownY = 0;
let lastMouseX = 0, lastMouseY = 0;
let idleTime = 0;

function buildWireframe(idx) {
  const spec = SOLIDS[idx];
  const geom = new THREE.EdgesGeometry(spec.make());
  const mat = new THREE.LineBasicMaterial({
    color: spec.color,
    transparent: true,
    opacity: 1.0,
    linewidth: 1, // most browsers cap this at 1, but set it anyway
  });
  return new THREE.LineSegments(geom, mat);
}

function makeLabelSprite(text, color) {
  const w = 256, h = 64;
  const cv = document.createElement("canvas");
  cv.width = w; cv.height = h;
  const cx = cv.getContext("2d");
  cx.clearRect(0, 0, w, h);
  cx.font = "bold 28px ui-sans-serif, system-ui, sans-serif";
  cx.textAlign = "center";
  cx.textBaseline = "middle";
  cx.fillStyle = color;
  cx.fillText(text, w / 2, h / 2);
  const tex = new THREE.CanvasTexture(cv);
  tex.minFilter = THREE.LinearFilter;
  tex.magFilter = THREE.LinearFilter;
  const mat = new THREE.SpriteMaterial({
    map: tex,
    transparent: true,
    opacity: 0.55,
    depthWrite: false,
  });
  const sprite = new THREE.Sprite(mat);
  sprite.scale.set(8, 2, 1);
  return { sprite, tex };
}

function colorToCss(c) {
  return "#" + c.toString(16).padStart(6, "0");
}

function init({ scene, camera, renderer }) {
  renderer.setClearColor(0x07080d, 1);
  camera.position.set(0, 0, 38);
  camera.lookAt(0, 0, 0);

  group = new THREE.Group();
  scene.add(group);

  currentLine = buildWireframe(currentIdx);
  nextLine = buildWireframe(nextIdx);
  nextLine.material.opacity = 0;
  nextLine.scale.setScalar(0.6);
  group.add(currentLine);
  group.add(nextLine);

  // Soft glow halo behind: a faint sphere to give the wires depth.
  const haloGeo = new THREE.SphereGeometry(2.2, 16, 12);
  const haloMat = new THREE.MeshBasicMaterial({
    color: 0xffffff,
    transparent: true,
    opacity: 0.04,
  });
  const halo = new THREE.Mesh(haloGeo, haloMat);
  group.add(halo);

  // Labels โ€” five names on a ring at the bottom.
  labelGroup = new THREE.Group();
  labelGroup.position.set(0, -16, 0);
  scene.add(labelGroup);
  const R = 14;
  for (let i = 0; i < SOLIDS.length; i++) {
    const ang = (i / SOLIDS.length) * Math.PI * 2;
    const { sprite, tex } = makeLabelSprite(SOLIDS[i].name, colorToCss(SOLIDS[i].color));
    sprite.position.set(Math.cos(ang) * R, 0, Math.sin(ang) * R);
    labelGroup.add(sprite);
    labels.push({ sprite, tex, idx: i });
  }

  return { scene, camera };
}

function easeInOutCubic(t) {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

function tick({ dt, time, scene, camera, renderer, width, height, input }) {
  // ----- input: drag to orbit, click to pause/resume -----
  const justClicked = input.consumeClicks() > 0;

  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      mouseDownAt = time;
      mouseDownX = input.mouseX;
      mouseDownY = input.mouseY;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      if (Math.abs(dx) + Math.abs(dy) > 0.5) idleTime = 0;
      userYaw -= dx * 0.006;
      userPitch -= dy * 0.006;
      userPitch = Math.max(-1.2, Math.min(1.2, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
  } else {
    isDragging = false;
  }

  // A click counts as a pause toggle if the pointer didn't travel far.
  if (justClicked) {
    const moved =
      Math.hypot(input.mouseX - mouseDownX, input.mouseY - mouseDownY) > 6;
    if (!moved) paused = !paused;
  }

  // Auto-rotate when idle (no drag).
  if (!input.mouseDown) {
    idleTime += dt;
    if (idleTime > 0.4) userYaw += dt * 0.25;
  } else {
    idleTime = 0;
  }

  // ----- camera -----
  const r = 38;
  const cy = Math.cos(userYaw), sy = Math.sin(userYaw);
  const cp = Math.cos(userPitch), sp = Math.sin(userPitch);
  camera.position.set(r * cp * sy, r * sp, r * cp * cy);
  camera.lookAt(0, 0, 0);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // ----- morph progress -----
  if (!paused) {
    morphT += dt / MORPH_DURATION;
    while (morphT >= 1) {
      morphT -= 1;
      // promote next -> current
      group.remove(currentLine);
      currentLine.geometry.dispose();
      currentLine.material.dispose();
      currentLine = nextLine;
      currentLine.material.opacity = 1;
      currentLine.scale.setScalar(1);
      currentIdx = nextIdx;
      // pick a new next (different from current)
      let n = (currentIdx + 1) % SOLIDS.length;
      // small variety: rotate through, but occasionally skip one
      if (Math.random() < 0.35) n = (currentIdx + 2) % SOLIDS.length;
      nextIdx = n;
      nextLine = buildWireframe(nextIdx);
      nextLine.material.opacity = 0;
      nextLine.scale.setScalar(0.6);
      group.add(nextLine);
    }
  }

  const e = easeInOutCubic(morphT);
  currentLine.material.opacity = 1 - e;
  nextLine.material.opacity = e;
  // scale crossfade gives a "blooming" feel
  const sCur = 1 + 0.25 * e;
  const sNext = 0.6 + 0.4 * e;
  currentLine.scale.setScalar(sCur);
  nextLine.scale.setScalar(sNext);

  // Spin the wireframe group on its own axes (independent of camera orbit).
  group.rotation.y += dt * 0.35;
  group.rotation.x += dt * 0.18;

  // ----- labels: ring spins gently; current pair highlighted -----
  labelGroup.rotation.y += dt * 0.15;
  for (const lab of labels) {
    let target = 0.32; // dim
    if (lab.idx === currentIdx) target = 0.95 - 0.55 * e; // fading out
    else if (lab.idx === nextIdx) target = 0.32 + 0.63 * e; // fading in
    // smooth toward target
    const o = lab.sprite.material.opacity;
    lab.sprite.material.opacity = o + (target - o) * Math.min(1, dt * 6);
    // current pair: slight scale boost
    const wantScale =
      lab.idx === currentIdx || lab.idx === nextIdx ? 1.15 : 1.0;
    const cs = lab.sprite.scale.x / 8;
    const ns = cs + (wantScale - cs) * Math.min(1, dt * 6);
    lab.sprite.scale.set(8 * ns, 2 * ns, 1);
  }

  // A subtle pause indicator: when paused, halve label ring spin and freeze morph (already).
  if (paused) {
    labelGroup.rotation.y -= dt * 0.10; // counteract some of the spin
  }

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.