jacquardSnapshot

← snapshot

8899 bytes
"use client";

import type * as THREE from "three";
import { useEffect, useRef } from "react";
import type { LabState } from "./lab-types";

/**
 * Three.js variants. Loaded dynamically so the ~150KB engine only lands on
 * the lab route and never on the app the visitor actually uses.
 */
export type ThreeVariantId =
  | "three-points"
  | "three-wire-ico"
  | "three-ribbons"
  | "three-shell";

export function ThreeTile({
  variant,
  size,
  state,
  active,
}: {
  variant: ThreeVariantId;
  size: number;
  state: React.RefObject<LabState>;
  active: boolean;
}) {
  const ref = useRef<HTMLCanvasElement | null>(null);
  const activeRef = useRef(active);
  activeRef.current = active;

  useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    let dispose = () => {};
    let cancelled = false;

    (async () => {
      const THREE = await import("three");
      if (cancelled) return;

      const renderer = new THREE.WebGLRenderer({
        canvas,
        alpha: true,
        antialias: true,
        // Same reason as the GLSL tiles: keep the buffer sampleable so the
        // lab check can tell "drew nothing" from "drew and swapped".
        preserveDrawingBuffer: true,
      });
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
      renderer.setSize(size, size, false);

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
      camera.position.set(0, 0.5, 4.2);
      camera.lookAt(0, 0, 0);

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

      const color = new THREE.Color(0x8aa6e0);
      let update: (t: number, s: LabState) => void = () => {};

      if (variant === "three-points") {
        // A Fibonacci-sphere point cloud that breathes with the voice.
        const N = 2600;
        const pos = new Float32Array(N * 3);
        const base = new Float32Array(N * 3);
        const golden = Math.PI * (3 - Math.sqrt(5));
        for (let i = 0; i < N; i++) {
          const y = 1 - (i / (N - 1)) * 2;
          const r = Math.sqrt(Math.max(0, 1 - y * y));
          const th = golden * i;
          base[i * 3] = Math.cos(th) * r;
          base[i * 3 + 1] = y;
          base[i * 3 + 2] = Math.sin(th) * r;
        }
        pos.set(base);
        const geo = new THREE.BufferGeometry();
        geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
        const mat = new THREE.PointsMaterial({
          size: 0.022,
          color,
          transparent: true,
          opacity: 0.9,
          blending: THREE.AdditiveBlending,
          depthWrite: false,
        });
        const pts = new THREE.Points(geo, mat);
        group.add(pts);
        update = (t, s) => {
          const amp = 0.1 + s.level * 0.34;
          for (let i = 0; i < N; i++) {
            const x = base[i * 3] as number;
            const y = base[i * 3 + 1] as number;
            const z = base[i * 3 + 2] as number;
            const w =
              Math.sin(x * 3 + t) * Math.sin(y * 3 + t * 0.8) * Math.sin(z * 3);
            const k = 1 + amp * w;
            pos[i * 3] = x * k;
            pos[i * 3 + 1] = y * k;
            pos[i * 3 + 2] = z * k;
          }
          geo.attributes.position.needsUpdate = true;
          group.rotation.y = t * 0.25;
        };
      } else if (variant === "three-wire-ico") {
        const geo = new THREE.IcosahedronGeometry(1.25, 3);
        const wire = new THREE.WireframeGeometry(geo);
        const mat = new THREE.LineBasicMaterial({
          color,
          transparent: true,
          opacity: 0.55,
        });
        const lines = new THREE.LineSegments(wire, mat);
        group.add(lines);
        const inner = new THREE.Mesh(
          new THREE.IcosahedronGeometry(1.02, 2),
          new THREE.MeshBasicMaterial({
            color,
            transparent: true,
            opacity: 0.12,
          }),
        );
        group.add(inner);
        update = (t, s) => {
          const k = 1 + s.level * 0.16;
          group.scale.setScalar(k);
          group.rotation.y = t * 0.3;
          group.rotation.x = Math.sin(t * 0.2) * 0.25;
          mat.opacity = 0.45 + s.attract * 0.35;
        };
      } else if (variant === "three-ribbons") {
        const ribbons: THREE.Mesh[] = [];
        for (let i = 0; i < 4; i++) {
          const curve = new THREE.TorusGeometry(1.05 + i * 0.16, 0.012, 8, 220);
          const m = new THREE.MeshBasicMaterial({
            color,
            transparent: true,
            opacity: 0.7,
            blending: THREE.AdditiveBlending,
            depthWrite: false,
          });
          const mesh = new THREE.Mesh(curve, m);
          mesh.rotation.x = 0.4 + i * 0.5;
          mesh.rotation.z = i * 0.7;
          group.add(mesh);
          ribbons.push(mesh);
        }
        update = (t, s) => {
          ribbons.forEach((r, i) => {
            const dir = i % 2 === 0 ? 1 : -1;
            r.rotation.y = t * (0.3 + i * 0.12) * dir;
            r.scale.setScalar(1 + s.level * 0.1);
          });
          group.rotation.y = t * 0.08;
        };
      } else {
        // three-shell: a translucent displaced shell over a bright core.
        const geo = new THREE.SphereGeometry(1.15, 64, 44);
        const basePos = geo.attributes.position.array.slice() as Float32Array;
        const mat = new THREE.MeshPhongMaterial({
          color,
          transparent: true,
          opacity: 0.5,
          // Hard specular against flat facets is what stops this reading as
          // grey putty — the shell needs edges to catch light on.
          shininess: 140,
          specular: new THREE.Color(0xffffff),
          side: THREE.DoubleSide,
          flatShading: true,
        });
        const shell = new THREE.Mesh(geo, mat);
        group.add(shell);
        const core = new THREE.Mesh(
          new THREE.SphereGeometry(0.55, 32, 24),
          new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.85 }),
        );
        group.add(core);
        scene.add(new THREE.AmbientLight(0xffffff, 0.4));
        const key = new THREE.PointLight(0xffffff, 40);
        key.position.set(3, 4, 3);
        scene.add(key);
        const arr = geo.attributes.position.array as Float32Array;
        update = (t, s) => {
          const amp = 0.12 + s.level * 0.3;
          for (let i = 0; i < arr.length; i += 3) {
            const x = basePos[i] as number;
            const y = basePos[i + 1] as number;
            const z = basePos[i + 2] as number;
            const w =
              Math.sin(x * 2.4 + t) * Math.sin(y * 2.4 + t * 0.7) * Math.sin(z * 2.4) +
              0.45 * Math.sin(x * 5.1 - t * 1.3) * Math.sin(z * 5.1 + t);
            const k = 1 + amp * w;
            arr[i] = x * k;
            arr[i + 1] = y * k;
            arr[i + 2] = z * k;
          }
          geo.attributes.position.needsUpdate = true;
          geo.computeVertexNormals();
          group.rotation.y = t * 0.2;
          core.scale.setScalar(1 + s.level * 0.3);
        };
      }

      let raf = 0;
      let clock = 0;
      let last = performance.now();
      const loop = (now: number) => {
        const dt = Math.min(0.05, (now - last) / 1000);
        last = now;
        if (activeRef.current) {
          const s = state.current ?? {
            level: 0,
            attract: 0,
            leanX: 0,
            leanY: 0,
            tempo: 1,
            thread: [0.54, 0.65, 0.88] as [number, number, number],
          };
          clock += dt * s.tempo;
          color.setRGB(s.thread[0], s.thread[1], s.thread[2]);
          group.traverse((o) => {
            const m = (o as THREE.Mesh).material as
              | THREE.Material & { color?: THREE.Color }
              | undefined;
            if (m?.color) m.color.copy(color);
          });
          // Lean: the whole group turns toward the pointer.
          group.rotation.z = -s.leanX * 0.2;
          camera.position.x = s.leanX * 0.9;
          camera.position.y = 0.5 - s.leanY * 0.7;
          camera.lookAt(0, 0, 0);
          update(clock, s);
          renderer.render(scene, camera);
        }
        raf = requestAnimationFrame(loop);
      };
      raf = requestAnimationFrame(loop);

      dispose = () => {
        cancelAnimationFrame(raf);
        renderer.forceContextLoss();
        renderer.dispose();
        scene.traverse((o) => {
          const mesh = o as THREE.Mesh;
          mesh.geometry?.dispose?.();
          const m = mesh.material as THREE.Material | THREE.Material[] | undefined;
          if (Array.isArray(m)) for (const x of m) x.dispose();
          else m?.dispose?.();
        });
      };
    })();

    return () => {
      cancelled = true;
      dispose();
    };
  }, [variant, size, state]);

  return <canvas ref={ref} style={{ width: size, height: size, display: "block" }} />;
}