15
Platonic Solid Morpher
drag to orbit ยท click to pause
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.