← Mike Higgins Home
Custom Code Experiment •

Hexahedron

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hexahedron</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100vh; background:#000; overflow:hidden; font-family:sans-serif; }
canvas#bg { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; }

/* Controls sit at the bottom so they never fight the centred circle */
#controls {
  position:absolute; bottom:20px; left:50%; transform:translateX(-50%);
  display:flex; align-items:center; gap:18px;
  background:rgba(0,0,0,0.65); border:0.5px solid rgba(200,100,255,0.3);
  border-radius:14px; padding:12px 22px; z-index:10; white-space:nowrap;
}
.ctrl { display:flex; align-items:center; gap:10px; }
label {
  color:rgba(255,255,255,0.45); font-size:11px; letter-spacing:0.08em;
  user-select:none; -webkit-user-select:none; white-space:nowrap;
}
input[type=range] {
  -webkit-appearance:none; appearance:none;
  width:100px; height:40px; background:transparent;
  cursor:pointer; touch-action:pan-y;
}
input[type=range]::-webkit-slider-runnable-track {
  height:3px; border-radius:2px; background:rgba(200,100,255,0.3);
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance:none; appearance:none;
  width:24px; height:24px; border-radius:50%; margin-top:-10.5px;
  background:#cc44ff; border:2px solid rgba(255,255,255,0.25);
  box-shadow:0 0 10px rgba(200,80,255,0.55);
}
input[type=range]::-moz-range-track {
  height:3px; border-radius:2px; background:rgba(200,100,255,0.3);
}
input[type=range]::-moz-range-thumb {
  width:24px; height:24px; border-radius:50%;
  background:#cc44ff; border:2px solid rgba(255,255,255,0.25);
  box-shadow:0 0 10px rgba(200,80,255,0.55);
}
.val { color:#cc66ff; font-size:11px; min-width:32px; text-align:right; }

/* Mobile: stack vertically */
@media (max-width:580px) {
  #controls {
    flex-direction:column; gap:4px; align-items:stretch;
    bottom:12px; left:12px; right:12px;
    transform:none; width:auto; padding:14px 16px; border-radius:16px;
  }
  .ctrl { justify-content:space-between; gap:10px; }
  label { font-size:12px; min-width:58px; }
  input[type=range] { flex:1; width:auto; }
  input[type=range]::-webkit-slider-thumb { width:28px; height:28px; margin-top:-12.5px; }
  input[type=range]::-moz-range-thumb { width:28px; height:28px; }
  .val { font-size:12px; }
}

#info {
  position:absolute; top:14px; left:50%; transform:translateX(-50%);
  color:rgba(255,255,255,0.15); font-size:10px; letter-spacing:0.09em;
  pointer-events:none; white-space:nowrap;
}
</style>
</head>
<body>
<canvas id="bg"></canvas>

<div id="controls">
  <div class="ctrl">
    <label>ENERGY</label>
    <input id="energy" type="range" min="0" max="1" value="0.7" step="0.01">
    <span class="val" id="ev">0.70</span>
  </div>
  <div class="ctrl">
    <label>SPEED</label>
    <input id="speed" type="range" min="0.1" max="2" value="1" step="0.05">
    <span class="val" id="sv">1.00</span>
  </div>
  <div class="ctrl">
    <label>CONNECT</label>
    <input id="connect" type="range" min="0" max="1" value="0.6" step="0.01">
    <span class="val" id="cv">0.60</span>
  </div>
</div>
<div id="info">drag to orbit · scroll to zoom</div>

<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const bg         = document.getElementById('bg');
const ctx        = bg.getContext('2d');
const controlsEl = document.getElementById('controls');
const energyEl   = document.getElementById('energy');
const speedEl    = document.getElementById('speed');
const connectEl  = document.getElementById('connect');

energyEl.addEventListener('input',  () => { document.getElementById('ev').textContent = parseFloat(energyEl.value).toFixed(2); });
speedEl.addEventListener('input',   () => { document.getElementById('sv').textContent = parseFloat(speedEl.value).toFixed(2); });
connectEl.addEventListener('input', () => { document.getElementById('cv').textContent = parseFloat(connectEl.value).toFixed(2); });

function resize() { bg.width = window.innerWidth; bg.height = window.innerHeight; }
resize();

// ── Circle layout ──────────────────────────────────────────────────────────
// The 3D cube always renders centred at W/2, H/2 (full viewport).
// The 2D circle matches that centre exactly.
// We shrink R so the circle doesn't overlap the bottom controls bar.
const CIRCLE_PAD = 24; // min gap between circle edge and controls bar

function circleLayout() {
  const W = window.innerWidth, H = window.innerHeight;
  const cx = W / 2, cy = H / 2;
  // Controls bar top edge (in CSS px from top of viewport)
  const navTop = H - controlsEl.getBoundingClientRect().height - 20; // approx top of bar
  const ctrlRect = controlsEl.getBoundingClientRect();
  // Max radius so circle bottom (cy + R) + pad <= controls top
  const maxR = ctrlRect.top - cy - CIRCLE_PAD;
  // Also cap so circle top doesn't clip screen
  const R = Math.min(maxR, cy - CIRCLE_PAD, W * 0.46);
  return { cx, cy, R: Math.max(R, 60) };
}

// ── Pulses ─────────────────────────────────────────────────────────────────
const pulses = [];
function spawnPulse(x1,y1,x2,y2,hue,spd) {
  pulses.push({x1,y1,x2,y2,t:0,speed:spd*(0.006+Math.random()*0.01),hue,size:2+Math.random()*3});
}

// ── Outer ring nodes ───────────────────────────────────────────────────────
const OUTER_N = 18;
let outerNodes = [];
function buildOuterNodes() {
  outerNodes = [];
  for (let i=0; i<OUTER_N; i++) {
    outerNodes.push({
      baseAngle: (i/OUTER_N)*Math.PI*2,
      drift:      (Math.random()-0.5)*0.4,
      driftSpeed: 0.3+Math.random()*0.4,
      driftPhase: Math.random()*Math.PI*2,
      radialPulse:Math.random()*Math.PI*2,
      radialAmp:  0.04+Math.random()*0.07,
      hue:        260+Math.floor(i/OUTER_N*140),
      pulseTimer: Math.random()*200,
      x:0, y:0
    });
  }
}

// ── Inner orbital nodes ────────────────────────────────────────────────────
const INNER_N = 8;
let innerNodes = [];
function buildInnerNodes(r) {
  innerNodes = [];
  for (let i=0; i<INNER_N; i++) {
    innerNodes.push({
      baseAngle:  (i/INNER_N)*Math.PI*2,
      orbitSpeed: (i%2===0?1:-1)*(0.2+Math.random()*0.15),
      orbitR:     r*(0.9+Math.random()*0.3),
      phase:      Math.random()*Math.PI*2,
      hue:        180+Math.floor(i/INNER_N*120),
      pulseTimer: Math.random()*200,
      x:0, y:0
    });
  }
}

function updateOuterNodes(t,spd,cx,cy,R) {
  outerNodes.forEach(n => {
    n.r = R;
    const wobble = n.drift*Math.sin(t*n.driftSpeed*spd+n.driftPhase);
    const radial  = 1+n.radialAmp*Math.sin(t*1.2*spd+n.radialPulse);
    const a = n.baseAngle+wobble+t*0.012*spd;
    n.x = cx+n.r*radial*Math.cos(a);
    n.y = cy+n.r*radial*Math.sin(a);
    n.pulseTimer += spd;
  });
}

function updateInnerNodes(t,spd,cx,cy) {
  innerNodes.forEach(n => {
    const a = n.baseAngle+t*n.orbitSpeed*spd+n.phase;
    n.x = cx+n.orbitR*Math.cos(a);
    n.y = cy+n.orbitR*Math.sin(a);
    n.pulseTimer += spd;
  });
}

// ── 2D draw ────────────────────────────────────────────────────────────────
let projectedVerts = [];

function draw2D(t,spd,energy,connectStrength) {
  const W = bg.width, H = bg.height;
  const {cx,cy,R} = circleLayout();
  const r = R*0.28;

  ctx.fillStyle = `rgba(0,0,0,${0.14+(1-energy)*0.22})`;
  ctx.fillRect(0,0,W,H);

  updateOuterNodes(t,spd,cx,cy,R);
  updateInnerNodes(t,spd,cx,cy);

  // Outer glow ring
  const breathe = 1+0.015*Math.sin(t*1.4*spd);
  ctx.beginPath(); ctx.arc(cx,cy,R*breathe,0,Math.PI*2);
  ctx.strokeStyle = `hsl(${300+30*Math.sin(t*0.4)},90%,60%)`;
  ctx.lineWidth = 1.5; ctx.stroke();

  // Rainbow dot belt
  for (let i=0;i<90;i++) {
    const a=(i/90)*Math.PI*2+t*0.018*spd;
    const rx=cx+(R+4)*Math.cos(a), ry=cy+(R+4)*Math.sin(a);
    ctx.beginPath(); ctx.arc(rx,ry,1.3,0,Math.PI*2);
    ctx.fillStyle=`hsla(${(i/90)*360},100%,80%,${0.3+0.3*Math.sin(t*2.5+i*0.45)})`; ctx.fill();
  }

  // 2D geo nodes (centre + 2 hex rings)
  const rot = t*0.035*spd;
  const geoNodes = [{x:cx,y:cy,size:5,hue:300}];
  for (let i=0;i<6;i++) {
    const a=(i/6)*Math.PI*2+rot;
    geoNodes.push({x:cx+r*Math.cos(a),y:cy+r*Math.sin(a),size:4,hue:260+(i/6)*80});
  }
  for (let i=0;i<6;i++) {
    const a=(i/6)*Math.PI*2+Math.PI/6-rot;
    geoNodes.push({x:cx+r*1.9*Math.cos(a),y:cy+r*1.9*Math.sin(a),size:3.5,hue:200+(i/6)*80});
  }

  // Geo web
  ctx.lineWidth = 0.35;
  for (let i=0;i<geoNodes.length;i++) for (let j=i+1;j<geoNodes.length;j++) {
    const d=Math.hypot(geoNodes[i].x-geoNodes[j].x,geoNodes[i].y-geoNodes[j].y);
    if (d<r*2.2) {
      ctx.strokeStyle=`rgba(200,150,255,${(0.05+0.05*Math.sin(t+i+j))*energy})`;
      ctx.beginPath(); ctx.moveTo(geoNodes[i].x,geoNodes[i].y);
      ctx.lineTo(geoNodes[j].x,geoNodes[j].y); ctx.stroke();
    }
  }

  // Cube verts → outer ring
  if (projectedVerts.length>0 && connectStrength>0) {
    projectedVerts.forEach((pv,vi) => {
      const sorted=outerNodes
        .map((on,oi)=>({oi,d:Math.hypot(pv.sx-on.x,pv.sy-on.y)}))
        .sort((a,b)=>a.d-b.d);
      for (let k=0;k<3;k++) {
        if (!sorted[k]) continue;
        const on=outerNodes[sorted[k].oi];
        const maxDist=R*1.6; if (sorted[k].d>maxDist) continue;
        const prox=1-sorted[k].d/maxDist;
        const alpha=connectStrength*energy*prox*(0.5+0.3*Math.sin(t*1.5+vi*0.7+k));
        const hue=260+(vi/8)*120+k*20;
        const g=ctx.createLinearGradient(pv.sx,pv.sy,on.x,on.y);
        g.addColorStop(0,`hsla(${hue+40},100%,80%,${alpha})`);
        g.addColorStop(0.5,`hsla(${hue+20},100%,70%,${alpha*0.6})`);
        g.addColorStop(1,`hsla(${on.hue},100%,70%,${alpha*0.3})`);
        ctx.strokeStyle=g; ctx.lineWidth=0.5+prox*0.8;
        ctx.beginPath(); ctx.moveTo(pv.sx,pv.sy); ctx.lineTo(on.x,on.y); ctx.stroke();
      }
      innerNodes.forEach((inn,ii) => {
        const d=Math.hypot(pv.sx-inn.x,pv.sy-inn.y);
        const maxD=r*2.2; if (d>maxD) return;
        const prox=1-d/maxD;
        ctx.strokeStyle=`hsla(${180+(vi/8)*80},90%,75%,${connectStrength*energy*prox*0.6*(0.4+0.4*Math.sin(t*2+vi+ii))})`;
        ctx.lineWidth=0.4+prox*0.5;
        ctx.beginPath(); ctx.moveTo(pv.sx,pv.sy); ctx.lineTo(inn.x,inn.y); ctx.stroke();
      });
    });
  }

  // Inner → geo + outer
  innerNodes.forEach((inn,ii) => {
    geoNodes.forEach((gn,gi) => {
      const d=Math.hypot(inn.x-gn.x,inn.y-gn.y);
      if (d<r*1.4) {
        ctx.strokeStyle=`hsla(${inn.hue},80%,70%,${connectStrength*(0.3-d/(r*5))*(0.5+0.5*Math.sin(t+ii+gi))*energy})`;
        ctx.lineWidth=0.4;
        ctx.beginPath(); ctx.moveTo(inn.x,inn.y); ctx.lineTo(gn.x,gn.y); ctx.stroke();
      }
    });
    const s2=outerNodes.map((on,oi)=>({oi,d:Math.hypot(inn.x-on.x,inn.y-on.y)})).sort((a,b)=>a.d-b.d);
    for (let k=0;k<2;k++) {
      const on=outerNodes[s2[k].oi]; const maxD=R*1.1; if (s2[k].d>maxD) continue;
      const prox=1-s2[k].d/maxD;
      ctx.strokeStyle=`hsla(${(inn.hue+on.hue)/2},85%,72%,${connectStrength*energy*prox*(0.25+0.2*Math.sin(t*1.8+ii+k))})`;
      ctx.lineWidth=0.35; ctx.beginPath(); ctx.moveTo(inn.x,inn.y); ctx.lineTo(on.x,on.y); ctx.stroke();
    }
  });

  // Outer ring neighbour web
  outerNodes.forEach((n,i) => {
    [outerNodes[(i+1)%OUTER_N],outerNodes[(i+OUTER_N-1)%OUTER_N]].forEach(nb => {
      ctx.strokeStyle=`hsla(${n.hue},80%,65%,${energy*(0.12+0.08*Math.sin(t*0.8+i))})`;
      ctx.lineWidth=0.4; ctx.beginPath(); ctx.moveTo(n.x,n.y); ctx.lineTo(nb.x,nb.y); ctx.stroke();
    });
  });

  // Pulses
  for (let i=pulses.length-1;i>=0;i--) {
    const p=pulses[i]; p.t+=p.speed;
    if (p.t>=1) { pulses.splice(i,1); continue; }
    const px=p.x1+(p.x2-p.x1)*p.t, py=p.y1+(p.y2-p.y1)*p.t;
    const alpha=energy*Math.sin(p.t*Math.PI)*0.95;
    const g=ctx.createRadialGradient(px,py,0,px,py,p.size*2);
    g.addColorStop(0,`hsla(${p.hue},100%,90%,${alpha})`);
    g.addColorStop(1,`hsla(${p.hue},100%,60%,0)`);
    ctx.beginPath(); ctx.arc(px,py,p.size*2,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
  }

  // Outer node glows
  outerNodes.forEach((n,i) => {
    if (Math.floor(n.pulseTimer)%55===0) {
      const gn=geoNodes[Math.floor(Math.random()*geoNodes.length)];
      spawnPulse(n.x,n.y,gn.x,gn.y,n.hue,spd);
      if (Math.random()<0.5) { const inn=innerNodes[Math.floor(Math.random()*INNER_N)]; spawnPulse(n.x,n.y,inn.x,inn.y,n.hue+40,spd); }
    }
    const pulse=0.6+0.4*Math.sin(t*2.2+i);
    const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,7*pulse);
    g.addColorStop(0,`hsla(${n.hue},100%,88%,${energy*0.95})`);
    g.addColorStop(0.5,`hsla(${n.hue},100%,65%,${energy*0.45})`);
    g.addColorStop(1,`hsla(${n.hue},100%,50%,0)`);
    ctx.beginPath(); ctx.arc(n.x,n.y,7*pulse,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
    ctx.beginPath(); ctx.arc(n.x,n.y,2.5,0,Math.PI*2);
    ctx.fillStyle=`rgba(255,255,255,${energy*0.9})`; ctx.fill();
  });

  // Inner node glows
  innerNodes.forEach((n,i) => {
    if (Math.floor(n.pulseTimer)%70===0) { const on=outerNodes[Math.floor(Math.random()*OUTER_N)]; spawnPulse(n.x,n.y,on.x,on.y,n.hue,spd); }
    const pulse=0.7+0.3*Math.sin(t*2.5+i*0.8);
    const g=ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,9*pulse);
    g.addColorStop(0,`hsla(${n.hue},100%,85%,${energy*0.9})`);
    g.addColorStop(0.5,`hsla(${n.hue},100%,60%,${energy*0.4})`);
    g.addColorStop(1,`hsla(${n.hue},100%,45%,0)`);
    ctx.beginPath(); ctx.arc(n.x,n.y,9*pulse,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
    ctx.beginPath(); ctx.arc(n.x,n.y,3,0,Math.PI*2);
    ctx.fillStyle=`rgba(255,255,255,${energy*0.85})`; ctx.fill();
  });

  // Geo node glows
  geoNodes.forEach((n,i) => {
    const pulse=0.7+0.3*Math.sin(t*1.6+i*0.9);
    ctx.beginPath(); ctx.arc(n.x,n.y,n.size*pulse,0,Math.PI*2);
    ctx.fillStyle=`hsla(${n.hue},100%,85%,${0.75+0.25*pulse})`; ctx.fill();
  });

  // Projected vertex glows (cube corners)
  projectedVerts.forEach((pv,i) => {
    const pulse=0.6+0.4*Math.sin(t*3+i);
    const hue=240+(i/8)*120;
    const g=ctx.createRadialGradient(pv.sx,pv.sy,0,pv.sx,pv.sy,12*pulse);
    g.addColorStop(0,`hsla(${hue},100%,90%,${energy*0.85})`);
    g.addColorStop(0.5,`hsla(${hue},100%,70%,${energy*0.4})`);
    g.addColorStop(1,`hsla(${hue},100%,50%,0)`);
    ctx.beginPath(); ctx.arc(pv.sx,pv.sy,12*pulse,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
    ctx.beginPath(); ctx.arc(pv.sx,pv.sy,3,0,Math.PI*2);
    ctx.fillStyle=`rgba(255,255,255,${energy})`; ctx.fill();
  });
}

// ── THREE.js ───────────────────────────────────────────────────────────────
const scene  = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.set(0,0,6);

const wireMat = new THREE.ShaderMaterial({
  uniforms:{uTime:{value:0},uEnergy:{value:0.7}},
  wireframe:true, transparent:true,
  vertexShader:`uniform float uTime;uniform float uEnergy;varying vec2 vUv;
    void main(){vUv=uv;vec3 pos=position;float amp=uEnergy*0.09;
      pos.x+=sin(uTime*0.9+position.y*1.4+position.z*0.6)*amp;
      pos.y+=sin(uTime*1.1+position.x*1.3+position.z*0.8)*amp;
      pos.z+=sin(uTime*0.8+position.z*1.5+position.y*0.5)*amp;
      gl_Position=projectionMatrix*modelViewMatrix*vec4(pos,1.0);}`,
  fragmentShader:`uniform float uTime;varying vec2 vUv;
    void main(){float h=mod(uTime*0.08+vUv.x*0.5+vUv.y*0.3,1.0);
      gl_FragColor=vec4(abs(sin(h*6.28))*0.8+0.2,abs(sin(h*6.28+2.09))*0.5+0.2,abs(sin(h*6.28+4.19))*0.9+0.1,0.55+0.35*sin(uTime*1.8+vUv.x*6.28));}`
});

const solidMat = new THREE.MeshBasicMaterial({color:0x880066,transparent:true,opacity:0.4,side:THREE.DoubleSide,depthWrite:false});
const boxGeo   = new THREE.BoxGeometry(2.2,2.2,2.2);
const solidMesh= new THREE.Mesh(boxGeo,solidMat); scene.add(solidMesh);
const wireMesh = new THREE.Mesh(boxGeo,wireMat);  scene.add(wireMesh);

const SV=1.1;
const verts8=[[-SV,-SV,-SV],[SV,-SV,-SV],[SV,SV,-SV],[-SV,SV,-SV],[-SV,-SV,SV],[SV,-SV,SV],[SV,SV,SV],[-SV,SV,SV]];
const spheres=verts8.map((v,i)=>{
  const color=new THREE.Color().setHSL((240+(i/8)*120)/360,1,0.7);
  const m=new THREE.Mesh(new THREE.SphereGeometry(0.09,10,10),new THREE.MeshBasicMaterial({color}));
  m.position.set(...v); m.userData.base=[...v]; scene.add(m); return m;
});

const vlPos=new Float32Array(8*2*3);
const vlGeo=new THREE.BufferGeometry();
vlGeo.setAttribute('position',new THREE.BufferAttribute(vlPos,3));
scene.add(new THREE.LineSegments(vlGeo,new THREE.LineBasicMaterial({color:0xff88ff,transparent:true,opacity:0.18})));

const edgePairs=[];
for(let i=0;i<8;i++) for(let j=i+1;j<8;j++) {
  if(Math.hypot(...verts8[i].map((v,k)=>v-verts8[j][k]))<2.6) edgePairs.push([i,j]);
}
const edgePos=[]; edgePairs.forEach(([a,b])=>{edgePos.push(...verts8[a],...verts8[b]);});
const edgeGeo=new THREE.BufferGeometry();
edgeGeo.setAttribute('position',new THREE.Float32BufferAttribute(edgePos,3));
const edgeMat=new THREE.ShaderMaterial({
  uniforms:{uTime:{value:0}},transparent:true,
  vertexShader:`varying vec3 vPos;void main(){vPos=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
  fragmentShader:`uniform float uTime;varying vec3 vPos;void main(){float h=mod(vPos.x*0.3+vPos.y*0.2+uTime*0.06,1.0);gl_FragColor=vec4(abs(sin(h*6.28)),abs(sin(h*6.28+2.09))*0.6+0.2,abs(sin(h*6.28+4.19)),0.6);}`
});
scene.add(new THREE.LineSegments(edgeGeo,edgeMat));

const wireMat2=new THREE.ShaderMaterial({
  uniforms:{uTime:{value:0},uEnergy:{value:0.7}},wireframe:true,transparent:true,
  vertexShader:`uniform float uTime;uniform float uEnergy;varying vec2 vUv;void main(){vUv=uv;vec3 pos=position;pos+=sin(uTime*0.7+pos.yzx*1.3)*uEnergy*0.05;gl_Position=projectionMatrix*modelViewMatrix*vec4(pos,1.0);}`,
  fragmentShader:`uniform float uTime;varying vec2 vUv;void main(){float h=mod(uTime*0.05+vUv.y*0.4,1.0);gl_FragColor=vec4(abs(sin(h*6.28+1.0))*0.6,abs(sin(h*6.28+3.0))*0.4,abs(sin(h*6.28+5.0))*0.9,0.25+0.15*sin(uTime*2.0+vUv.x*6.28));}`
});
const outerBox=new THREE.Mesh(new THREE.BoxGeometry(3.2,3.2,3.2),wireMat2); scene.add(outerBox);

const innerMat=new THREE.ShaderMaterial({
  uniforms:{uTime:{value:0}},wireframe:true,transparent:true,
  vertexShader:`uniform float uTime;varying vec2 vUv;void main(){vUv=uv;vec3 p=position;p+=sin(uTime*1.5+p.zxy*2.0)*0.06;gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.0);}`,
  fragmentShader:`uniform float uTime;varying vec2 vUv;void main(){float h=mod(uTime*0.1+vUv.x*0.6,1.0);gl_FragColor=vec4(abs(sin(h*6.28)),abs(sin(h*6.28+2.0)),abs(sin(h*6.28+4.0)),0.3+0.2*sin(uTime*3.0+vUv.y*6.28));}`
});
const innerCube=new THREE.Mesh(new THREE.BoxGeometry(1.2,1.2,1.2),innerMat); scene.add(innerCube);

// ── Renderer — full viewport, cube always at W/2 H/2 ──────────────────────
const renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});
renderer.setClearColor(0,0);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.domElement.style.cssText='position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:auto;';
document.body.appendChild(renderer.domElement);

const orbitCtrls=new OrbitControls(camera,renderer.domElement);
orbitCtrls.enableDamping=true; orbitCtrls.dampingFactor=0.05;
orbitCtrls.minDistance=2; orbitCtrls.maxDistance=20;
orbitCtrls.target.set(0,0,0);

buildOuterNodes();
buildInnerNodes(80); // placeholder, recalculated each frame via circleLayout

window.addEventListener('resize',()=>{
  resize();
  camera.aspect=window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth,window.innerHeight);
});

// Project 3D world pos → 2D screen px (full-viewport render, simple NDC)
function project3DtoScreen(pos3d) {
  const v    = pos3d.clone().project(camera);
  const W    = window.innerWidth, H = window.innerHeight;
  return { sx: (v.x+1)/2*W, sy: (-v.y+1)/2*H };
}

let t=0;
function animate() {
  requestAnimationFrame(animate);
  const energy          = parseFloat(energyEl.value);
  const spd             = parseFloat(speedEl.value);
  const connectStrength = parseFloat(connectEl.value);
  t += 0.007*spd;

  // Rebuild inner node radii from live circle size
  const {R} = circleLayout();
  innerNodes.forEach(n => { n.orbitR = R*0.28*(0.9 + 0.3*(innerNodes.indexOf(n)/INNER_N)); });

  const uT=t*10;
  wireMat.uniforms.uTime.value=uT;  wireMat.uniforms.uEnergy.value=energy;
  wireMat2.uniforms.uTime.value=uT; wireMat2.uniforms.uEnergy.value=energy;
  innerMat.uniforms.uTime.value=uT; edgeMat.uniforms.uTime.value=uT;

  solidMat.opacity=0.25+energy*0.35;
  solidMat.color.setHSL(0.82+0.06*Math.sin(t*0.3),0.9,0.3);

  const baseRot=t*0.003*spd;
  wireMesh.rotation.y+=baseRot*1.4; wireMesh.rotation.x+=baseRot*0.5;
  solidMesh.rotation.y=wireMesh.rotation.y; solidMesh.rotation.x=wireMesh.rotation.x;
  outerBox.rotation.y=-t*0.0025*spd; outerBox.rotation.z=t*0.0018*spd;
  innerCube.rotation.y=t*0.006*spd;  innerCube.rotation.x=-t*0.004*spd;

  spheres.forEach((sp,i)=>{
    const s2=1+0.2*energy*Math.sin(t*3.5+i*0.7);
    sp.position.set(sp.userData.base[0]*s2,sp.userData.base[1]*s2,sp.userData.base[2]*s2);
    sp.material.color.setHSL((240+(i/8)*120)/360,1,0.65+0.2*Math.sin(t+i));
    sp.rotation.y=t*2.5;
  });

  const arr=vlGeo.attributes.position.array;
  spheres.forEach((sp,i)=>{
    const dir=sp.position.clone().normalize().multiplyScalar(4+energy*2*Math.sin(t*1.8+i));
    arr[i*6]=sp.position.x; arr[i*6+1]=sp.position.y; arr[i*6+2]=sp.position.z;
    arr[i*6+3]=dir.x; arr[i*6+4]=dir.y; arr[i*6+5]=dir.z;
  });
  vlGeo.attributes.position.needsUpdate=true;

  // Render 3D to full viewport — cube is always at screen centre
  renderer.render(scene,camera);
  camera.updateMatrixWorld();

  // Project cube vertices using simple full-viewport NDC
  projectedVerts = spheres.map(sp => project3DtoScreen(sp.position));

  // Draw 2D overlay — circle centred at W/2,H/2, radius shrunk to clear controls
  draw2D(t,spd,energy,connectStrength);

  orbitCtrls.update();
  renderer.render(scene,camera);
}
animate();
</script>
</body>
</html>