# 3D Scroll Scanner Experience

## Goal
Build a full-page, five-viewport-tall scroll story whose star is a **single stylized 3D product model (a beverage-can hero prop)** rendered in Three.js against a clean off-white stage. On load the model **scales up from nothing and floats** (a gentle sine bob) while slowly self-rotating. As you scroll, its **X-rotation is driven by scroll progress** through a Lenis-fed render loop, so it tumbles forward as the page advances. When you reach a **pinned "scanner" section** — an empty rounded rectangle frame with product-ID chip, barcode, and a red "verified" pill — a GSAP `ScrollTrigger.onEnter` handler **plays a scanner-beep sound, spins the model a full 360°, then shrinks the model and the scanner frame to zero** (the product is "scanned away"). Scrolling back up re-grows the frame and restores the model. The whole thing runs on a continuous `requestAnimationFrame` render loop plus two ScrollTriggers.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`three` (npm)**, **`gsap` (npm)** with the **`ScrollTrigger`** plugin, and **`lenis`** for smooth scroll. Import exactly:
```js
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(ScrollTrigger);
```
Motion comes from three sources working together: (a) a per-frame `requestAnimationFrame` loop that floats/rotates the model and maps scroll→rotation, (b) two `ScrollTrigger`s (one pin + reveal, one reset-on-scroll-back), and (c) GSAP tweens fired inside the ScrollTrigger callbacks. Lenis drives the ticker and feeds `ScrollTrigger.update`.

## Layout / HTML
The DOM is tiny — a fixed WebGL host div plus four full-viewport sections. The renderer's `<canvas>` is appended into `.model`; everything else is HTML overlay/copy.
```html
<body>
  <div class="model"></div>

  <section class="hero">
    <h1>Digital <br />Evolution</h1>
    <h2>Transform Your Brand Identity</h2>
    <p>Experience the next generation of digital product design. We craft
       immersive experiences that blend innovation with functionality, helping
       brands stand out in the digital landscape.</p>
  </section>

  <section class="info">
    <div class="tags">
      <p>Brand Strategy</p>
      <p>User Experience</p>
      <p>Digital Products</p>
      <p>Innovation Lab</p>
    </div>
    <h2>We believe in creating digital products that not only look exceptional
        but drive real business growth and user engagement through thoughtful
        design and strategic innovation.</h2>
    <p>Our approach combines cutting-edge technology with human-centered design
       principles. We transform complex challenges into seamless digital
       experiences that resonate with your audience and elevate your brand in
       the digital space.</p>
  </section>

  <section class="scanner">
    <div class="scan-info">
      <div class="product-id"><h2>#2024</h2></div>
      <div class="product-description"><p>Transform Your Digital Identity</p></div>
    </div>

    <div class="scan-container"></div>

    <div class="barcode"><img src="/path/barcode.png" alt="" /></div>

    <div class="purchased"><p>Innovation Verified</p></div>
  </section>

  <section class="outro">
    <h2>Join the revolution where innovative experience meets strategic thinking.
        Let's create products that don't just exist in digital space -
        they define it.</h2>
  </section>

  <script type="module" src="./script.js"></script>
</body>
```
- `.model` — fixed full-viewport WebGL host; the `<canvas>` is appended here in JS. It sits **behind** the sections (they have `z-index:2`), so the 3D model is always visible centered on screen while the copy scrolls over the top of it.
- `.scanner` — the pinned section. `.scan-info` is an absolutely-positioned top bar (product-ID chip left, description right). `.scan-container` is the empty **280×480 bordered frame** the model appears to sit inside. `.barcode` is a bottom-left image; `.purchased` is a bottom-right red "verified" pill.
- All copy is neutral placeholder text — invent your own; no real client brands.

## Styling
Global reset `* { margin:0; padding:0; box-sizing:border-box; }`, and critically **every element** uses `font-family:"Neue Haas Grotesk Display Pro"` (fall back to a clean neutral grotesk like Helvetica/Arial) and `text-transform:uppercase`.

- `html, body`: `width:100vw; height:500vh;` — **the page is five viewports tall**; the extra height beyond the four sections gives the pinned scanner room to scroll.
- `img`: `width:100%; height:100%; object-fit:cover;`
- `canvas`: `position:fixed; top:0; left:0;`
- `h1`: `text-align:center; font-size:10vw; font-weight:300; line-height:100%;` — huge, light hero headline.
- `h2`: `font-size:2.5vw; font-weight:500; line-height:100%;`
- `p`: `font-size:12px; font-weight:500; line-height:100%;` — tiny uppercase body text.
- `.model`: `position:fixed; width:100%; height:100vh; background:#fefdfd;` — off-white full-screen stage behind the content.
- `section`: `position:relative; width:100vw; height:100vh; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:1em; z-index:2;` — each section is exactly one viewport, contents centered.
- `.hero h1`: `margin-bottom:0.25em;` · `.hero p`: `width:50%; text-align:center;`
- `.info`: `gap:8em;` · `.info .tags`: `width:60%; display:flex; gap:2em;` · `.info h2`: `width:75%; text-align:center;` · `.info p`: `width:60%; text-align:center;`
- `.scan-info`: `position:absolute; top:0; width:100vw; display:flex; justify-content:space-between; padding:2em;`
- `.scan-container`: `width:280px; height:480px; border:1px solid #000; border-radius:0.5em;` — the empty scanner frame (no fill; the 3D model shows through from the fixed canvas behind it).
- `.barcode`: `position:absolute; bottom:1em; left:2em; width:200px; height:100px;`
- `.purchased`: `position:absolute; bottom:2em; right:2em; padding:0.5em 4em; color:red; border:1px solid red; border-radius:2em;` — a red-outlined pill.
- `.outro h2`: `width:70%; text-align:center;`
- Include the standard Lenis CSS block (`.lenis.lenis-smooth{scroll-behavior:auto !important} .lenis.lenis-stopped{overflow:clip}` etc.).

Palette: near-white `#fefdfd` stage, black text/border, one **red** accent on the "verified" pill. The renderer clear color is white (`0xffffff`) and the scene background is `#fefdfd`, so canvas and page blend seamlessly.

## Three.js scene & render pipeline (be exhaustive)

### 1. Smooth scroll (Lenis) drives the GSAP ticker
```js
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
```

### 2. Scene / camera / renderer
```js
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xfefdfd);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0xffffff, 1);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.physicallyCorrectLights = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 2.5;               // bright, punchy exposure for the white stage
document.querySelector(".model").appendChild(renderer.domElement);
```
Camera `z` is **not** set here — it is computed after the model loads from the model's bounding-box size (see §5).

### 3. Lighting — four sources (bright, even, product-shot lighting)
```js
const ambientLight = new THREE.AmbientLight(0xffffff, 3);
scene.add(ambientLight);

const mainLight = new THREE.DirectionalLight(0xffffff, 1);
mainLight.position.set(5, 10, 7.5);
scene.add(mainLight);

const fillLight = new THREE.DirectionalLight(0xffffff, 3);
fillLight.position.set(-5, 0, -5);
scene.add(fillLight);

const hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 2);
hemiLight.position.set(0, 25, 0);
scene.add(hemiLight);
```
All white lights, high intensities — the product reads clean and bright on the near-white background.

### 4. Bootstrap render loop (runs until the model is ready)
Before the GLTF resolves, run a minimal loop so the (empty) scene paints:
```js
function basicAnimate() { renderer.render(scene, camera); requestAnimationFrame(basicAnimate); }
basicAnimate();
```
When the model finishes loading, `cancelAnimationFrame(basicAnimate)` and start the full `animate()` loop (§7) instead.

### 5. Load, tune, recenter and frame the GLTF model
```js
let model;
const loader = new GLTFLoader();
loader.load("/path/product.glb", (gltf) => {
  model = gltf.scene;
  model.traverse((node) => {
    if (node.isMesh && node.material) {
      node.material.metalness = 0.3;
      node.material.roughness = 0.4;
      node.material.envMapIntensity = 1.5;
    }
    node.castShadow = true;
    node.receiveShadow = true;
  });

  // recenter on world origin via bounding-box center
  const box = new THREE.Box3().setFromObject(model);
  const center = box.getCenter(new THREE.Vector3());
  model.position.sub(center);
  scene.add(model);

  // frame the camera from the model's size
  const size = box.getSize(new THREE.Vector3());
  const maxDim = Math.max(size.x, size.y, size.z);
  camera.position.z = maxDim * 1.5;              // pull back 1.5× the largest dimension

  model.scale.set(0, 0, 0);                       // start invisible for the reveal
  playInitialAnimation();

  cancelAnimationFrame(basicAnimate);
  animate();
});
```
Every mesh gets a **semi-metallic material** (`metalness 0.3`, `roughness 0.4`, `envMapIntensity 1.5`) and shadow casting/receiving. The `Box3 → getCenter → position.sub(center)` step puts the model's geometric middle at `(0,0,0)`; `camera.position.z = maxDim * 1.5` frames it consistently regardless of authored scale.

### 6. Animation constants + scanner wiring
```js
const floatAmplitude = 0.2;   // vertical bob height
const floatSpeed     = 1.5;   // bob frequency
const rotationSpeed  = 0.3;   // idle self-spin rate
let   isFloating     = true;
let   currentScroll  = 0;

const stickyHeight     = window.innerHeight;
const scannerSection   = document.querySelector(".scanner");
const scannerPosition  = scannerSection.offsetTop;      // scroll offset where scanner starts
const scanContainer    = document.querySelector(".scan-container");
const scanSound        = new Audio("/path/scan-sfx.mp3");
gsap.set(scanContainer, { scale: 0 });                  // frame starts collapsed
```

## GSAP effect (the important part — be exhaustive)

### A. Initial reveal (fires the moment the model loads)
```js
function playInitialAnimation() {
  gsap.to(model.scale,   { x: 1, y: 1, z: 1, duration: 1, ease: "power2.out" });
  gsap.to(scanContainer, { scale: 1,          duration: 1, ease: "power2.out" });
}
```
Model **and** scanner frame both grow `0 → 1` over **1s / `power2.out`**, in parallel.

### B. ScrollTrigger #1 — restore on scroll back to the very top
```js
ScrollTrigger.create({
  trigger: "body",
  start: "top top",
  end: "top -10",
  onEnterBack: () => {
    if (model) {
      gsap.to(model.scale, { x: 1, y: 1, z: 1, duration: 1, ease: "power2.out" });
      isFloating = true;                                 // resume the float
    }
    gsap.to(scanContainer, { scale: 1, duration: 1, ease: "power2.out" });
  },
});
```
A tiny 10px trigger at the top: when the user scrolls **back** into it, the model and frame re-grow to full scale (`1s / power2.out`) and floating turns back on — undoing the scan.

### C. ScrollTrigger #2 — the pinned scan sequence
```js
ScrollTrigger.create({
  trigger: ".scanner",
  start: "top top",
  end: `${stickyHeight}px`,     // pins for one viewport of scroll
  pin: true,
  onEnter: () => {
    if (!model) return;
    isFloating = false;          // stop the bob
    model.position.y = 0;        // settle to center

    setTimeout(() => {           // 500ms after enter, play the scanner beep
      scanSound.currentTime = 0;
      scanSound.play();
    }, 500);

    // 1) spin a full 360° on Y …
    gsap.to(model.rotation, {
      y: model.rotation.y + Math.PI * 2,
      duration: 1,
      ease: "power2.inOut",
      onComplete: () => {
        // 2) … then shrink the model to nothing …
        gsap.to(model.scale, {
          x: 0, y: 0, z: 0,
          duration: 0.5,
          ease: "power2.in",
          onComplete: () => {
            // 3) … then collapse the scanner frame.
            gsap.to(scanContainer, { scale: 0, duration: 0.5, ease: "power2.in" });
          },
        });
      },
    });
  },
  onLeaveBack: () => {
    gsap.set(scanContainer, { scale: 0 });               // snap frame to 0 …
    gsap.to(scanContainer, { scale: 1, duration: 1, ease: "power2.out" }); // … then re-grow it
  },
});
```
This is the signature moment. On entering the pinned scanner:
1. **Full spin** — `model.rotation.y += Math.PI * 2` over **1s / `power2.inOut`**.
2. **On complete → shrink model** — `scale 1 → 0` over **0.5s / `power2.in`**.
3. **On complete → collapse frame** — `scanContainer scale 1 → 0` over **0.5s / `power2.in`**.
   The three tweens are **chained via `onComplete`**, so they run strictly in sequence (spin → model vanishes → frame vanishes), total ~2s. A 500ms `setTimeout` fires the scanner-beep SFX just as the spin begins. Scrolling back out (`onLeaveBack`) snaps the frame to 0 and re-grows it (`1s / power2.out`).

### D. Scroll value + the per-frame loop (scroll → model rotation)
```js
lenis.on("scroll", (e) => { currentScroll = e.scroll; });   // capture Lenis scroll px

function animate() {
  if (model) {
    if (isFloating) {
      const floatOffset = Math.sin(Date.now() * 0.001 * floatSpeed) * floatAmplitude;
      model.position.y = floatOffset;                 // sine bob, ±0.2
    }

    const scrollProgress = Math.min(currentScroll / scannerPosition, 1);   // 0→1 up to the scanner

    if (scrollProgress < 1) {
      model.rotation.x = scrollProgress * Math.PI * 2;   // X-tumble mapped to scroll (0 → 2π)
    }
    if (scrollProgress < 1) {
      model.rotation.y += 0.001 * rotationSpeed;         // slow idle Y drift while approaching
    }
  }
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
```
Two continuous behaviors before the scan:
- **Float** — while `isFloating`, `position.y = sin(Date.now()·0.001·1.5)·0.2`, an endless gentle hover. Turned off the instant the scanner pin fires (§C) and back on at the top (§B).
- **Scroll-mapped tumble** — `scrollProgress = min(currentScroll / scannerPosition, 1)` is 0 at the top and reaches 1 exactly when the scanner section is reached. While `< 1`, **`model.rotation.x = scrollProgress · 2π`** (one full forward tumble across the pre-scanner scroll) plus a tiny `rotation.y += 0.001·0.3` idle drift each frame. Once progress hits 1 (scanner reached) these stop, handing control to the ScrollTrigger spin.

### 8. Resize
```js
window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}, false);
```

## Assets / images
- **One GLTF/GLB 3D model** — a **single stylized hero product: a beverage can** (a tall cylindrical drink can with printed label artwork). Mid-poly, meant to be viewed as one centered object. Any comparable canned-drink / bottle GLB works; no real-world brand or label text required. Materials are overridden in code to a light semi-metallic look.
- **One barcode image** (`barcode.png`) — a **black-and-white product barcode graphic**, roughly **2:1 landscape** (displayed 200×100), bottom-left of the scanner section.
- **One audio file** (`scan-sfx.mp3`) — a short **scanner-beep / checkout-scan sound effect** (~1s), played once when the scan sequence begins.

## Behavior notes
- **Trigger model:** load (initial reveal) + scroll (Lenis smooth scroll → scroll-mapped tumble) + a pinned ScrollTrigger (the scan sequence). No hover/click/mousemove.
- **Heavy, WebGL-required, desktop-first.** The scene renders continuously via `requestAnimationFrame`; not mobile-optimized. `100vw/500vh` fixed layout with `vw`-based type; no responsive breakpoints in the original.
- **Audio needs a user gesture** — the beep may be blocked until the user has interacted with the page (browsers gate autoplay audio); scrolling to the scanner generally satisfies this.
- **Reversible:** scrolling back up to the top re-grows the model and frame and resumes floating; the piece has no explicit reduced-motion path.
