# Asset Orb — Draggable WebGL Image Sphere

## Goal
Build a full-viewport, pitch-black WebGL scene containing a single **"orb" made of 100 small photo planes** arranged on the surface of a sphere via a **Fibonacci-sphere (golden-spiral) distribution**, every plane textured with one of 30 editorial photographs picked at random. The user **drags to spin the orb** (with damped inertia, so it keeps gliding after release) and **scrolls/pinches to zoom** between a near and far limit. A fixed HTML nav ("ORB") and footer ("[ ARCHIVE BEYOND REALITY ]") float over the canvas. This is a pure Three.js piece — **no GSAP at all**; all motion comes from OrbitControls damping inside a `requestAnimationFrame` loop.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`three` (npm) only** — no GSAP, no Lenis, no other libraries. Import:
```js
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
```

## Layout / HTML
Nearly empty — the canvas is injected by JS:
```html
<body>
  <div class="container"></div>
  <nav><h1>Orb</h1></nav>
  <footer><p>[ Archive beyond reality ]</p></footer>
  <script type="module" src="./script.js"></script>
</body>
```
JS appends the WebGL `<canvas>` (`renderer.domElement`) into `.container`.

## Styling
Minimal — the canvas is the whole show.
- Global reset `* { margin: 0; padding: 0; box-sizing: border-box; }`.
- `.container { width: 100vw; height: 100vh; overflow: hidden; }` — hosts the canvas.
- `nav, footer { position: fixed; width: 100vw; display: flex; justify-content: center; align-items: center; padding: 3em; z-index: 2; }` — both are centered overlay strips above the canvas. `nav { top: 0 }`, `footer { bottom: 0 }`.
- `h1 { text-transform: uppercase; font-family: "Gojo", sans-serif; font-size: 18px; font-weight: 900; color: #fff; }` — a heavy display sans; no webfont needed, the bold sans fallback is fine.
- `p { text-transform: uppercase; font-family: "Akkurat Mono", monospace; font-size: 11px; color: #777777; }` — small grey mono caption (again, the monospace fallback is fine).
- Background is not set in CSS — the black comes from the renderer clear color.

## Three.js effect (the important part — be exhaustive)

### Config constants
```js
const totalImages  = 30;   // pool of photo files
const totalItems   = 100;  // planes on the sphere
const baseWidth    = 1;    // max plane width  (world units)
const baseHeight   = 0.6;  // max plane height (world units)
const sphereRadius = 5;
```

### Scene / camera / renderer
- `scene = new THREE.Scene()` — no lights needed (materials are unlit `MeshBasicMaterial`).
- `camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000)`, positioned at `camera.position.z = 10` (so the whole radius-5 orb fits comfortably in frame).
- Renderer:
```js
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  alpha: true,
  preserveDrawingBuffer: true,
  powerPreference: "high-performance",
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x000000);           // the solid black backdrop
renderer.setPixelRatio(window.devicePixelRatio);
document.querySelector(".container").appendChild(renderer.domElement);
```
No tone mapping / color-space tweaks are required — defaults are fine.

### OrbitControls (the interaction — this IS the "animation")
```js
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;   // inertia: the orb keeps drifting after the drag ends
controls.dampingFactor = 0.05;   // long, floaty glide
controls.rotateSpeed   = 1.2;    // slightly faster-than-default drag response
controls.minDistance   = 6;      // zoom-in limit (just outside the radius-5 shell)
controls.maxDistance   = 10;     // zoom-out limit (= initial camera distance)
controls.enableZoom    = true;   // wheel / pinch zooms the dolly distance
controls.enablePan     = false;  // no panning — the orb stays centered
```
Damping only works because `controls.update()` is called **every frame** in the render loop.

### Fibonacci-sphere distribution (exact math)
100 points evenly spread over the sphere surface using the golden-spiral method. For `i` from `0` to `totalItems - 1`:
```js
const phi   = Math.acos(-1 + (2 * i) / totalItems);   // polar angle: pole → pole
const theta = Math.sqrt(totalItems * Math.PI) * phi;  // winding azimuth
```
Then convert to Cartesian on the radius-5 shell (note this exact axis mapping):
```js
mesh.position.x = sphereRadius * Math.cos(theta) * Math.sin(phi);
mesh.position.y = sphereRadius * Math.sin(theta) * Math.sin(phi);
mesh.position.z = sphereRadius * Math.cos(phi);
```

### Texture loading & plane creation (one mesh per point)
For each of the 100 points, load a **random** image from the pool with a shared `THREE.TextureLoader`:
```js
const n = Math.floor(Math.random() * totalImages) + 1;  // 1..30, duplicates allowed
// path e.g. `./img${n}.jpeg` — point at wherever the 30 files live
```
In the load callback:
- Texture filtering: `texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter;` (crisp, no mip blur on the small planes).
- **Aspect-fit plane geometry** inside the 1 × 0.6 box — never crop, shrink one side instead:
```js
const aspect = texture.image.width / texture.image.height;
let width = baseWidth, height = baseHeight;
if (aspect > 1) height = width / aspect;   // landscape: full 1.0 wide, shorter
else            width  = height * aspect;  // portrait/square: full 0.6 tall, narrower
const geometry = new THREE.PlaneGeometry(width, height);
```
- Material — unlit, double-sided (so planes on the far side of the orb are visible through the gaps, which gives the see-through "hollow shell of photos" look):
```js
const material = new THREE.MeshBasicMaterial({
  map: texture,
  side: THREE.DoubleSide,
  transparent: false,
  depthWrite: true,
  depthTest: true,
});
```
- Orientation — every plane faces **outward** from the center:
```js
mesh.lookAt(0, 0, 0);
mesh.rotateY(Math.PI);  // lookAt points the front face inward; flip it back out
```
- `scene.add(mesh)`.

### Deferred start of the render loop
Keep a `loadedCount`; increment it in each texture callback and only start animating **after all 100 textures have loaded**:
```js
loadedCount++;
if (loadedCount === totalItems) animate();
```
So the page shows plain black (with the nav/footer text) for a moment, then the fully-populated orb pops in at once — there is no progressive build-up. Log texture errors with the loader's `onError` callback.

### Render loop
```js
const animate = () => {
  requestAnimationFrame(animate);
  controls.update();              // applies damping/inertia every frame
  renderer.render(scene, camera);
};
```
No autonomous rotation — the orb is perfectly still until the user drags it.

### Resize handler
On `window.resize`: `renderer.setSize(w, h)`, `camera.aspect = w / h`, `camera.updateProjectionMatrix()`.

## Assets / images
**30 editorial fashion / portrait photographs** (`img1.jpeg` … `img30.jpeg`), mixed orientations (portraits, profile shots, full-length figures, extreme close-ups), with a moody, cinematic, high-fashion palette — saturated reds, deep teals and blues, black-and-white shots, dark backdrops — so the tiles glow against the black void. They are interchangeable: each of the 100 planes picks one at random (repeats are expected and fine). Mixed aspect ratios are actually desirable — the aspect-fit sizing turns them into varied landscape/portrait tiles, which gives the orb its collage texture. If fewer files are available, reduce `totalImages` accordingly and repeat.

## Behavior notes
- **Interaction only** — nothing animates on load; drag rotates (in any direction, full 360° including over the poles), release keeps a damped spin, wheel/pinch zooms between distance 6 and 10. No pan.
- The orb is **hollow and see-through**: through the gaps between front planes you see the mirrored backs of the far-side planes (`DoubleSide` shows the texture mirrored on the reverse — correct and intentional).
- Full-viewport canvas, responsive via the resize handler; interaction works with touch as well (OrbitControls handles pointer + touch), though the piece is desktop-oriented and WebGL-heavy (100 textured meshes).
- No GSAP, no ScrollTrigger, no scroll hijacking — the page itself never scrolls; the wheel is captured by OrbitControls for zoom.
- No reduced-motion handling in the original (all motion is user-driven anyway).
