# Outline to Solid

A wireframe or light-drawn outline appears first, then fills into a solid shape or material.

- Position in a launch film: Product reveal
- Slug: outline-to-solid
- Preview video: https://media.motionboard.app/outline-to-solid.mp4
- Recipe page: https://motionboard.app/recipes/outline-to-solid/
- License: MIT. Free to use in your own projects, including commercial launch films. https://github.com/krisyangdev/motionboard/blob/main/LICENSE

## How to use this recipe

You are a coding agent. The user wants this shot for their own product. Follow these steps.

1. **Know the product.** If the product is clear from the user's repo or conversation, ask nothing and start. Otherwise ask once for: product name, logo file, brand colors.
2. **Pick the output.** If the user's message says a video or their website, do that. Otherwise ask once which one they want.
   - **Video (HyperFrames).** Create a folder named `outline-to-solid`. Download every file under "Source files" below with the curl commands, keeping relative paths. Adapt `index.html` (see "What to adapt"). Run `npx --yes hyperframes@0.8.209 check` on the untouched copy first and note its result. Preview with `npx --yes hyperframes@0.8.209 preview`, render an MP4 with `npx --yes hyperframes@0.8.209 render` (into `renders/`, named with a timestamp). The same commands are in `package.json` as `dev`, `check`, `render`; pass flags after `--`, as in `npm run render -- --quality draft`. Rendering needs network access for web fonts and GSAP.
   - **Website motion.** Port the GSAP timeline in `index.html` into the user's site, using their stack (React, Vue, Svelte, plain HTML). Build the same DOM structure, keep the timeline's durations, delays and easings exactly, and start it when the element scrolls into view (IntersectionObserver or ScrollTrigger). Respect `prefers-reduced-motion: reduce`: show the final frame as a static state and skip the movement.
3. **What to adapt.**
   - **Brand layer** (product name, logo, colors, fonts, copy): replace directly with the user's.
   - **UI layer** (the product screens, cards, windows, controls shown in the shot): rebuild the user's real UI as clean DOM, from their codebase (components, design tokens) or from a screenshot used as reference only. Never paste a screenshot into the shot.
   - **Motion** (timing, easing, camera moves, layering, stagger, order of events): keep exactly as in the source. This is what the recipe is.
   - The timeline targets UI elements by id and class, and camera moves land on fixed coordinates. Keep the ids the timeline uses, keep row counts and text lengths close to the source, and when the layout moves, update the target coordinates without changing the times.
4. **Fonts.** HyperFrames fetches Google Fonts such as Inter on its own. Some sources use macOS local fonts (PingFang, Songti via `local()`). Use the user's brand font when it can be bundled; for a system font such as SF Pro, use Inter. Check that text still fits its boxes.
5. **Verify.** Compare the render with the preview video above at the beat times in the Timing row. The beats, timing and camera should match; only brand and UI content differ.

## Spec

| Item | Value |
| --- | --- |
| Timing | Film 11s. Product shot 0–8.2s: light point fades in 0.45–1.0s, outline draw 1.0–4.0s (3.0s, 90 frames), solid body fades in 3.3–4.8s (1.5s, 45 frames, overlapping the last 0.7s of the draw), wide highlight 3.8–5.3s (peaks 0.5s, 15 frames, after the body), outline fades to 14% 4.6–5.7s, LED 5.5–6.1s, still, then push 6.3–8.2s. Short logo version 8.2–11.0s (2.8s): wireframe 8.22–8.62s (0.4s) + fill wipe 8.66–9.26s (0.6s) |
| Easing | Outline draw `cubic-bezier(0.65,0,0.35,1)` (power2.inOut: slow, then fast, then slow); body and highlight fade in on the same curve; light point and outline fade sine.inOut; push power2.in; the fill wipe uses power2.inOut |
| Camera | Perspective, 18° fov, fixed camera; still until 6.3s (1.0s after the highlight peak), then a slow push-in of 5% (scale 1.00→1.05) to 8.2s |
| Subject | A light point starts at the top center and two heads extend symmetrically to both sides, drawing the full outline down to the bottom center; line about 1.5px (tube radius 0.0105 units) with bloom and a halo on the heads; the solid body fades in inside the outline, and the brightest wide highlight (a separate light pass) comes last |
| Style | Pure black background, cool white line (`rgb(214,235,255)`), ACES tone mapping at exposure 1.2, bloom, 0.016 grain; the logo version uses a white background with a gray wireframe (`#b4b4b6`, 1.5px), a black solid (`#0b0b0c`) wiping in from left to right, and construction lines at 10% opacity |

## Why it works

The shape comes before the material, so the first thing viewers remember is the silhouette. The light stays the only light source, and when the solid appears its highlight sits where the light comes from. The wireframe stage has very low opacity, so it does not compete with the solid that follows.

## Prompt

> Build a product opening called Outline to Solid. Pure black background, 18° fov fixed camera. A point of light fades in at the top center over 0.45–1.0 seconds, then from 1.0s two heads extend symmetrically to both sides, tracing the full outline of your product down to the bottom center, with line width about 1.5px and bloom plus a halo on the heads. The outline draw uses power2.inOut (cubic-bezier(0.65,0,0.35,1)) and takes 3 seconds. When the outline is nearly closed (3.3s), the solid product fades in inside it over 1.5 seconds, and the wide highlight, a separate light pass from above, reaches full brightness 0.5 seconds after the body (3.8–5.3s). The outline fades to 14% over 4.6–5.7s and a small LED comes on at 5.5–6.1s. Then hold still until 6.3s and push in slowly by 5% (power2.in) until 8.2s. For a logo (a 2.8-second cut on white): fade in a gray wireframe and 10%-opacity construction lines over 0.4 seconds, then wipe in a black solid from left to right over 0.6 seconds with power2.inOut, fade the wireframe and construction lines out, and scale the lockup 1.00→1.03 across the cut.

## Often confused with

- [Light Reveal](https://motionboard.app/r/light-reveal.md): Light Reveal raises overall exposure; Outline to Solid shows the line first, then the solid.
- [Rotate to Lockup](https://motionboard.app/r/rotate-to-lockup.md): Rotate to Lockup aligns to the target by rotating; Outline to Solid changes the material from line to surface.

## Source files

Runnable HyperFrames project, 3 files.

- https://motionboard.app/source/outline-to-solid/hyperframes.json (318 B)
- https://motionboard.app/source/outline-to-solid/index.html (48.4 KB)
- https://motionboard.app/source/outline-to-solid/package.json (305 B)

Download all of them:

```sh
curl -fsSL --create-dirs -o "outline-to-solid/hyperframes.json" "https://motionboard.app/source/outline-to-solid/hyperframes.json"
curl -fsSL --create-dirs -o "outline-to-solid/index.html" "https://motionboard.app/source/outline-to-solid/index.html"
curl -fsSL --create-dirs -o "outline-to-solid/package.json" "https://motionboard.app/source/outline-to-solid/package.json"
```

## index.html

```html
<!doctype html>
<html lang="en" data-resolution="landscape">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/","delaunator":"https://cdn.jsdelivr.net/npm/delaunator@5.0.1/+esm"}}</script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body { width: 1920px; height: 1080px; overflow: hidden; background: #000; }
      #root { position: relative; width: 1920px; height: 1080px; background: #000; font-family: "Inter", sans-serif; }
      #stage { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #gl { display: block; width: 1920px; height: 1080px; }

      #cap { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #capIn { position: absolute; left: 0; top: 936px; width: 1920px; text-align: center; opacity: 0; }
      #rays, #wire { opacity: 0; }
      #capTitle { font-family: "Inter", sans-serif; font-weight: 600; font-size: 32px; line-height: 1; letter-spacing: -0.01em; color: #ecebe8; }
      #capSub { margin-top: 14px; font-family: "Inter", sans-serif; font-weight: 400; font-size: 28px; line-height: 1; color: #86878c; }

      #logo { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; background: #ffffff; overflow: hidden; }
      #lock { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #lock svg { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #lock text { font-family: "Inter", sans-serif; font-weight: 600; font-size: 116px; letter-spacing: -3.5px; }
      #avail { position: absolute; left: 0; top: 662px; width: 1920px; text-align: center; font-family: "Inter", sans-serif; font-weight: 400; font-size: 28px; line-height: 1; color: #6e6f74; opacity: 0; }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="11" data-width="1920" data-height="1080">
      <div id="stage" data-hold class="clip" data-start="0" data-duration="8.2" data-track-index="0">
        <canvas id="gl" width="1920" height="1080"></canvas>
      </div>

      <div id="cap" class="clip" data-start="0" data-duration="8.2" data-track-index="1">
        <div id="capIn">
          <div id="capTitle">Corvid One</div>
          <div id="capSub">Hall-effect sticks. 40-hour battery.</div>
        </div>
      </div>

      <div id="logo" class="clip" data-start="8.2" data-duration="2.8" data-track-index="2" data-layout-allow-overlap>
        <div id="lock" data-layout-allow-overlap>
          <svg viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
            <defs>
              <clipPath id="wipeClip"><rect id="wipeRect" x="660" y="300" width="0" height="480" /></clipPath>
            </defs>
            <!-- construction: rays through the head centre, head circle, crown tangent, eye circle -->
            <g id="rays" stroke="#000" stroke-opacity="0.1" stroke-width="1.2" fill="none">
              <line x1="-200" y1="540" x2="2120" y2="540" />
              <line x1="713.5" y1="-100" x2="713.5" y2="1180" />
              <line x1="73.5" y1="-100" x2="1353.5" y2="1180" />
              <line x1="73.5" y1="1180" x2="1353.5" y2="-100" />
              <circle cx="713.5" cy="540" r="150" />
              <g transform="translate(713.5 540) scale(1)">
                <circle cx="0" cy="0" r="46" stroke-dasharray="4 6" vector-effect="non-scaling-stroke" />
                <line x1="-40" y1="-73.4" x2="180" y2="43.6" vector-effect="non-scaling-stroke" />
                <line x1="-8" y1="45.3" x2="-80" y2="-27.6" vector-effect="non-scaling-stroke" />
                <circle cx="14" cy="-10" r="20" stroke-dasharray="2 5" vector-effect="non-scaling-stroke" />
              </g>
              <line x1="-200" y1="498" x2="2120" y2="498" stroke-dasharray="2 6" />
              <line x1="-200" y1="582" x2="2120" y2="582" stroke-dasharray="2 6" />
            </g>
            <!-- grey wireframe -->
            <g id="wire" stroke="#b4b4b6" stroke-width="1.5" fill="none">
              <path transform="translate(713.5 540) scale(1)" vector-effect="non-scaling-stroke" d="M 21.61 -40.61 L 128 16 L 37.68 26.38 A 46 46 0 0 1 -7.99 45.3 L -45.3 -7.99 A 46 46 0 0 1 21.61 -40.61 Z M 44 6 L 120 15.2 L 44 10 Z M 20.5 -10 A 6.5 6.5 0 1 0 7.5 -10 A 6.5 6.5 0 1 0 20.5 -10 Z" />
              <text x="891.5" y="582">Corvid</text>
            </g>
            <!-- black solid, wiped in left to right -->
            <g id="solid" fill="#0b0b0c" clip-path="url(#wipeClip)">
              <path transform="translate(713.5 540) scale(1)" fill-rule="evenodd" d="M 21.61 -40.61 L 128 16 L 37.68 26.38 A 46 46 0 0 1 -7.99 45.3 L -45.3 -7.99 A 46 46 0 0 1 21.61 -40.61 Z M 44 6 L 120 15.2 L 44 10 Z M 20.5 -10 A 6.5 6.5 0 1 0 7.5 -10 A 6.5 6.5 0 1 0 20.5 -10 Z" />
              <text x="891.5" y="582">Corvid</text>
            </g>
          </svg>
        </div>
        <div id="avail">Corvid One. Ships November 12.</div>
      </div>
    </div>

    <script>
      const tl = gsap.timeline({ paused: true });
      // caption under the controller during the hold and slow push
      tl.set("#capIn", { opacity: 0, y: 10 }, 0);
      tl.to("#capIn", { opacity: 1, y: 0, duration: 0.8, ease: "power2.out" }, 5.75);
      // logo short version: wireframe 0.4s, fill wipe 0.6s (power2.inOut), construction fades
      tl.set("#rays", { opacity: 0 }, 0);
      tl.set("#wire", { opacity: 0 }, 0);
      tl.set("#wipeRect", { attr: { width: 0 } }, 0);
      tl.set("#avail", { opacity: 0 }, 0);
      tl.to("#rays", { opacity: 1, duration: 0.4, ease: "power1.out" }, 8.22);
      tl.to("#wire", { opacity: 1, duration: 0.4, ease: "power1.out" }, 8.22);
      tl.to("#wipeRect", { attr: { width: 620 }, duration: 0.6, ease: "power2.inOut" }, 8.66);
      tl.to("#wire", { opacity: 0, duration: 0.35, ease: "power1.inOut" }, 9.3);
      tl.to("#rays", { opacity: 0, duration: 0.5, ease: "power1.inOut" }, 9.3);
      tl.to("#avail", { opacity: 1, duration: 0.3, ease: "power2.out" }, 8.65);
      tl.set("#lock", { scale: 1, transformOrigin: "960px 540px" }, 8.2);
      tl.to("#lock", { scale: 1.03, duration: 2.8, ease: "none" }, 8.2);
      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = tl;
    </script>

    <script type="module">
      import * as THREE from "three";
      import Delaunator from "delaunator";
      import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
      import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
      import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
      import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
      import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

      /* ================= helpers ================= */
      const clamp01 = (x) => Math.min(1, Math.max(0, x));
      const seg = (t, a, b) => clamp01((t - a) / (b - a));
      const power2InOut = (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
      const sineInOut = (k) => -(Math.cos(Math.PI * k) - 1) / 2;
      const power2In = (k) => k * k;
      const smooth = (a, b, x) => { const k = clamp01((x - a) / (b - a)); return k * k * (3 - 2 * k); };

      /* ================= renderer ================= */
      const OW = 1920, OH = 1080, SS = 2;
      const RW = OW * SS, RH = OH * SS;
      const canvas = document.getElementById("gl");
      const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, preserveDrawingBuffer: true, powerPreference: "high-performance" });
      renderer.setPixelRatio(1);
      renderer.setSize(OW, OH, false);
      renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 1.2;
      renderer.outputColorSpace = THREE.SRGBColorSpace;

      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x000000);
      const camera = new THREE.PerspectiveCamera(18, OW / OH, 1, 400);

      function canvasTex(w, h, draw, srgb = false) {
        const c = document.createElement("canvas");
        c.width = w; c.height = h;
        draw(c.getContext("2d"), w, h);
        const t = new THREE.CanvasTexture(c);
        t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
        t.anisotropy = 8;
        t.needsUpdate = true;
        return t;
      }
      let seed = 0x2545f491;
      const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };

      // closed polyline utilities (points {x,y}); outward normals for clockwise loops
      function withNormalsCW(pts) {
        const N = pts.length;
        for (let i = 0; i < N; i++) {
          const a = pts[(i - 1 + N) % N], b = pts[(i + 1) % N];
          const tx = b.x - a.x, ty = b.y - a.y, l = Math.hypot(tx, ty) || 1;
          pts[i].nx = -ty / l; pts[i].ny = tx / l;
        }
        return pts;
      }
      function pointInPoly(x, y, poly) {
        let inside = false;
        for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
          const a = poly[i], b = poly[j];
          if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) inside = !inside;
        }
        return inside;
      }
      function distToPoly(x, y, poly) {
        let d = 1e9;
        for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
          const a = poly[j], b = poly[i];
          const vx = b.x - a.x, vy = b.y - a.y, wx = x - a.x, wy = y - a.y;
          const t = clamp01((vx * wx + vy * wy) / (vx * vx + vy * vy || 1));
          d = Math.min(d, Math.hypot(wx - vx * t, wy - vy * t));
        }
        return d;
      }

      /* ================= body outline =================
         right half, top centre -> bottom centre (clockwise, y up). Left half mirrors it. */
      const RIGHT = [
        [0, 3.0], [2.0, 3.08], [3.9, 3.42], [5.4, 3.52], [6.6, 3.18], [7.3, 2.2], [7.6, 0.6],
        [7.75, -1.3], [7.7, -3.3], [7.32, -4.95], [6.48, -5.86], [5.42, -5.76], [4.62, -4.92],
        [3.76, -3.52], [2.66, -2.72], [1.3, -2.52], [0, -2.48],
      ];
      const ctrlPts = [...RIGHT];
      for (let i = RIGHT.length - 2; i >= 1; i--) ctrlPts.push([-RIGHT[i][0], RIGHT[i][1]]);
      const outlineCurve = new THREE.CatmullRomCurve3(ctrlPts.map(([x, y]) => new THREE.Vector3(x, y, 0)), true, "centripetal");
      const NS = 1440, HALF = NS / 2;
      const raw = outlineCurve.getSpacedPoints(NS).slice(0, NS);
      const BODY = [];
      for (let i = 0; i <= HALF; i++) BODY.push({ x: raw[i].x, y: raw[i].y });
      BODY[0].x = 0; BODY[HALF].x = 0;
      for (let i = HALF + 1; i < NS; i++) { const m = BODY[NS - i]; BODY.push({ x: -m.x, y: m.y }); }
      withNormalsCW(BODY);
      let XMIN = 1e9, XMAX = -1e9, YMIN = 1e9, YMAX = -1e9;
      for (const p of BODY) { XMIN = Math.min(XMIN, p.x); XMAX = Math.max(XMAX, p.x); YMIN = Math.min(YMIN, p.y); YMAX = Math.max(YMAX, p.y); }
      const BW = XMAX - XMIN, BH = YMAX - YMIN;
      const U = (x, y) => [(x - XMIN) / BW, (y - YMIN) / BH];

      /* ================= sculpt: the face domes, the grips fall away down and back ================= */
      function F(x, y) {
        const ax = Math.abs(x);
        let z = 0.2 * Math.exp(-((x / 5.5) ** 2) - (((y - 0.2) / 3.0) ** 2));
        const g = Math.max(0, -1.0 - y); z -= 0.05 * g * g;
        const s = Math.max(0, ax - 5.6); z -= 0.035 * s * s;
        const t = Math.max(0, y - 2.4); z -= 0.06 * t * t;
        return z;
      }
      function gradF(x, y) { const e = 0.004; return [(F(x + e, y) - F(x - e, y)) / (2 * e), (F(x, y + e) - F(x, y - e)) / (2 * e)]; }
      const gripW = (y) => smooth(-1.0, -4.6, y);

      const ZT = 0.62, ZB = -0.62;
      const STICKS = [{ x: -2.65, y: -0.6 }, { x: 2.65, y: -0.6 }];
      const WELL_R = 1.0, WELL_D = 0.34;
      const DPAD = { x: -4.95, y: 1.05 };
      const FACE = { x: 4.95, y: 1.05, d: 0.8, R: 0.4 };
      const HOME = { x: 0, y: 0.78, R: 0.44 };
      const SMALL = [{ x: -1.45, y: 1.72 }, { x: 1.45, y: 1.72 }];

      function bodyGeometry() {
        const out = [...BODY].reverse(); // CCW
        const N = out.length;
        const S = 16;
        const pos = [], nor = [], uv = [], idx = [];
        const rings = [];
        const silRing = [];
        // per-point fillet sizes: the grips get rounder
        const sz = out.map((q) => { const w = gripW(q.y); return { rto: 0.55 + 0.35 * w, rtz: 0.45 + 0.3 * w, rbo: 0.5 + 0.2 * w, rbz: 0.42 + 0.2 * w, back: 0.55 * w }; });
        const prof = [];
        for (let i = 0; i <= S; i++) prof.push({ kind: 0, th: (i / S) * Math.PI / 2 });
        for (let i = 0; i <= S; i++) prof.push({ kind: 1, th: (i / S) * Math.PI / 2 });
        const P = prof.length;
        for (let j = 0; j < P; j++) {
          const p = prof[j];
          for (let i = 0; i < N; i++) {
            const q = out[i], s = sz[i];
            let o, z, nr, nz;
            if (p.kind === 0) {
              o = s.rto * (1 - Math.sin(p.th)); z = ZT - s.rtz * (1 - Math.cos(p.th));
              const n = [s.rtz * Math.sin(p.th), s.rto * Math.cos(p.th)], l = Math.hypot(n[0], n[1]); nr = n[0] / l; nz = n[1] / l;
            } else {
              o = s.rbo * (1 - Math.cos(p.th)); z = ZB - s.back + s.rbz * (1 - Math.sin(p.th));
              const n = [s.rbz * Math.cos(p.th), -s.rbo * Math.sin(p.th)], l = Math.hypot(n[0], n[1]); nr = n[0] / l; nz = n[1] / l;
            }
            const x = q.x - q.nx * o, y = q.y - q.ny * o;
            const zz = z + F(x, y);
            pos.push(x, y, zz);
            const [fx, fy] = gradF(x, y);
            let nx = q.nx * nr, ny = q.ny * nr;
            nx -= fx * nz; ny -= fy * nz;
            const l = Math.hypot(nx, ny, nz);
            nor.push(nx / l, ny / l, nz / l);
            uv.push(...U(x, y));
            if (p.kind === 0 && j === S) silRing.push({ x, y, z: zz });
          }
        }
        for (let j = 0; j < P - 1; j++) {
          for (let i = 0; i < N; i++) {
            const a = j * N + i, b = j * N + ((i + 1) % N), c = (j + 1) * N + i, d = (j + 1) * N + ((i + 1) % N);
            idx.push(a, c, b, b, c, d);
          }
        }
        // sculpted face cap: Delaunay over the inner contour, the stick wells and an interior grid
        const inner = [];
        for (let i = 0; i < N; i++) inner.push({ x: pos[i * 3], y: pos[i * 3 + 1] });
        const innerLo = inner.filter((_, i) => i % 4 === 0);
        const holes = STICKS.map(({ x, y }) => {
          const hp = [];
          for (let i = 0; i < 128; i++) { const a = (i / 128) * Math.PI * 2; hp.push({ x: x + WELL_R * Math.cos(a), y: y + WELL_R * Math.sin(a) }); }
          return hp;
        });
        const cpts = [...inner, ...holes.flat()];
        const step = 0.17;
        for (let y = YMIN; y <= YMAX; y += step) {
          for (let x = XMIN; x <= XMAX; x += step) {
            const xx = x + ((Math.round((y - YMIN) / step) % 2) ? step / 2 : 0);
            if (!pointInPoly(xx, y, innerLo)) continue;
            if (STICKS.some((st) => Math.hypot(xx - st.x, y - st.y) < WELL_R + 0.08)) continue;
            if (distToPoly(xx, y, innerLo) < 0.07) continue;
            cpts.push({ x: xx, y });
          }
        }
        const del = new Delaunator(cpts.flatMap((p) => [p.x, p.y]));
        const base = pos.length / 3;
        for (const p of cpts) {
          const z = ZT + F(p.x, p.y);
          pos.push(p.x, p.y, z);
          const [fx, fy] = gradF(p.x, p.y), l = Math.hypot(fx, fy, 1);
          nor.push(-fx / l, -fy / l, 1 / l);
          uv.push(...U(p.x, p.y));
        }
        const T = del.triangles;
        for (let k = 0; k < T.length; k += 3) {
          const A = cpts[T[k]], B = cpts[T[k + 1]], C = cpts[T[k + 2]];
          const cx = (A.x + B.x + C.x) / 3, cy = (A.y + B.y + C.y) / 3;
          if (!pointInPoly(cx, cy, innerLo)) continue;
          if (STICKS.some((s) => Math.hypot(cx - s.x, cy - s.y) < WELL_R)) continue;
          const area = (B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y);
          if (area > 0) idx.push(base + T[k], base + T[k + 1], base + T[k + 2]);
          else idx.push(base + T[k], base + T[k + 2], base + T[k + 1]);
        }
        const g = new THREE.BufferGeometry();
        g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
        g.setAttribute("normal", new THREE.Float32BufferAttribute(nor, 3));
        g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
        g.setIndex(idx);
        return { geo: g, sil: silRing };
      }

      // simple part slab (bumpers, triggers): CCW outline, uniform fillets, flat face
      function partSlab(outCCW, zt, zb, rf) {
        const N = outCCW.length, S = 10;
        const prof = [];
        for (let i = 0; i <= S; i++) { const th = (i / S) * Math.PI / 2; prof.push({ o: rf * (1 - Math.sin(th)), z: zt - rf * (1 - Math.cos(th)), nr: Math.sin(th), nz: Math.cos(th) }); }
        prof.push({ o: 0, z: zb, nr: 1, nz: 0 });
        const pos = [], nor = [], idx = [];
        for (const p of prof) for (const q of outCCW) {
          pos.push(q.x - q.nx * p.o, q.y - q.ny * p.o, p.z);
          const nx = q.nx * p.nr, ny = q.ny * p.nr, l = Math.hypot(nx, ny, p.nz);
          nor.push(nx / l, ny / l, p.nz / l);
        }
        for (let j = 0; j < prof.length - 1; j++) for (let i = 0; i < N; i++) {
          const a = j * N + i, b = j * N + ((i + 1) % N), c = (j + 1) * N + i, d = (j + 1) * N + ((i + 1) % N);
          idx.push(a, c, b, b, c, d);
        }
        const contour = [];
        for (let i = 0; i < N; i++) contour.push(new THREE.Vector2(pos[i * 3], pos[i * 3 + 1]));
        const tris = THREE.ShapeUtils.triangulateShape(contour, []);
        const base = pos.length / 3;
        for (const v of contour) { pos.push(v.x, v.y, zt); nor.push(0, 0, 1); }
        for (const [a, b, c] of tris) {
          const A = contour[a], B = contour[b], C = contour[c];
          const area = (B.x - A.x) * (C.y - A.y) - (C.x - A.x) * (B.y - A.y);
          if (area > 0) idx.push(base + a, base + b, base + c); else idx.push(base + a, base + c, base + b);
        }
        const g = new THREE.BufferGeometry();
        g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
        g.setAttribute("normal", new THREE.Float32BufferAttribute(nor, 3));
        g.setIndex(idx);
        const sil = outCCW.map((q) => ({ x: q.x, y: q.y, z: zt - rf }));
        return { geo: g, sil };
      }
      // a band riding along a stretch of the body edge, with round ends (clockwise)
      function bandCW(segPts, outD, inD) {
        const top = segPts.map((p) => ({ x: p.x + p.nx * outD, y: p.y + p.ny * outD }));
        const bot = segPts.map((p) => ({ x: p.x - p.nx * inD, y: p.y - p.ny * inD }));
        const rr = (outD + inD) / 2, off = (outD - inD) / 2;
        const cap = (p, sgn) => {
          const tx = p.ny * sgn, ty = -p.nx * sgn; // CW tangent is (ny,-nx); the start cap runs backwards
          const pts = [];
          for (let k = 1; k < 16; k++) {
            const ph = (k / 16) * Math.PI;
            const cx = p.x + p.nx * off, cy = p.y + p.ny * off;
            const nn = sgn > 0 ? 1 : -1;
            pts.push({ x: cx + rr * (Math.cos(ph) * p.nx * nn + Math.sin(ph) * tx), y: cy + rr * (Math.cos(ph) * p.ny * nn + Math.sin(ph) * ty) });
          }
          return pts;
        };
        const e = segPts[segPts.length - 1], s = segPts[0];
        const loop = [...top, ...cap(e, 1), ...bot.reverse(), ...cap(s, -1)];
        return withNormalsCW(loop);
      }
      function mirrorCW(loop) { return withNormalsCW(loop.map((p) => ({ x: -p.x, y: p.y })).reverse()); }

      /* ================= textures ================= */
      const TEX = {};
      const AOW = 2560, AOH = Math.round(2560 * BH / BW);
      const toC = (x, y) => [((x - XMIN) / BW) * AOW, (1 - (y - YMIN) / BH) * AOH];
      const SC = AOW / BW;
      // two-tone split: textured grips below a parting line that sweeps around the sticks
      const SEAM = [[8.6, -0.5], [5.95, -0.72], [4.5, -1.8], [3.55, -3.45]];
      function gripPath(g, side) {
        const P = SEAM.map(([x, y]) => toC(side * x, y));
        g.beginPath();
        g.moveTo(...P[0]);
        g.bezierCurveTo(...P[1], ...P[2], ...P[3]);
        g.lineTo(...toC(side * 2.4, -8)); g.lineTo(...toC(side * 9, -8)); g.closePath();
      }
      function seamPath(g, side) {
        const P = SEAM.map(([x, y]) => toC(side * x, y));
        g.beginPath(); g.moveTo(...P[0]); g.bezierCurveTo(...P[1], ...P[2], ...P[3]);
      }
      TEX.ao = canvasTex(AOW, AOH, (g, w, h) => {
        const lg = g.createLinearGradient(0, 0, 0, h);
        lg.addColorStop(0.0, "#ffffff"); lg.addColorStop(0.35, "#ededed"); lg.addColorStop(1.0, "#c4c4c4");
        g.fillStyle = lg; g.fillRect(0, 0, w, h);
        // face-plate seam just inside the top fillet
        g.beginPath();
        BODY.forEach((p, i) => { const r = 0.55 + 0.35 * gripW(p.y); const [cx, cy] = toC(p.x - p.nx * r * 0.97, p.y - p.ny * r * 0.97); if (i) g.lineTo(cx, cy); else g.moveTo(cx, cy); });
        g.closePath();
        g.filter = "blur(2px)"; g.strokeStyle = "rgba(0,0,0,0.7)"; g.lineWidth = 5; g.stroke();
        // grip parting lines
        for (const sd of [1, -1]) { seamPath(g, sd); g.strokeStyle = "rgba(0,0,0,0.8)"; g.lineWidth = 6; g.stroke(); }
        const softCircle = (x, y, r, a, blur) => {
          const [cx, cy] = toC(x, y);
          g.filter = `blur(${blur}px)`;
          g.fillStyle = `rgba(0,0,0,${a})`;
          g.beginPath(); g.arc(cx, cy, r * SC, 0, Math.PI * 2); g.fill();
        };
        for (const s of STICKS) softCircle(s.x, s.y, WELL_R + 0.1, 0.5, 26);
        softCircle(DPAD.x, DPAD.y, 0.98, 0.4, 22);
        for (const [dx, dy] of [[0, 1], [1, 0], [0, -1], [-1, 0]]) softCircle(FACE.x + dx * FACE.d, FACE.y + dy * FACE.d, FACE.R + 0.08, 0.5, 14);
        softCircle(HOME.x, HOME.y, HOME.R + 0.12, 0.4, 14);
        for (const s of SMALL) { const [cx, cy] = toC(s.x, s.y); g.filter = "blur(10px)"; g.fillStyle = "rgba(0,0,0,0.4)"; g.fillRect(cx - 0.3 * SC, cy - 0.17 * SC, 0.6 * SC, 0.34 * SC); }
        g.filter = "none";
        g.fillStyle = "rgba(0,0,0,0.85)";
        for (let r = 0; r < 4; r++) for (let c = 0; c < 15; c++) {
          const [cx, cy] = toC(-0.7 + c * 0.1 + (r % 2) * 0.05, -1.25 - r * 0.09);
          g.beginPath(); g.arc(cx, cy, 0.021 * SC, 0, Math.PI * 2); g.fill();
        }
      });
      // albedo: satin graphite face, slightly lighter stippled grips
      TEX.albedo = canvasTex(AOW, AOH, (g, w, h) => {
        g.fillStyle = "#2a2b2f"; g.fillRect(0, 0, w, h);
        g.fillStyle = "#38393e";
        for (const sd of [1, -1]) { gripPath(g, sd); g.fill(); }
      }, true);
      // R: clearcoat (face only)  G: roughness
      TEX.rough = canvasTex(AOW, AOH, (g, w, h) => {
        g.fillStyle = "rgb(255,97,0)"; g.fillRect(0, 0, w, h);
        g.fillStyle = "rgb(0,186,0)";
        for (const sd of [1, -1]) { gripPath(g, sd); g.fill(); }
      });
      // bump: fine grain everywhere, raised stipple on the grips
      TEX.bump = canvasTex(AOW, AOH, (g, w, h) => {
        const tile = document.createElement("canvas"); tile.width = tile.height = 256;
        const tg = tile.getContext("2d"), img = tg.createImageData(256, 256);
        for (let i = 0; i < 256 * 256; i++) { const v = 128 + (rnd() - 0.5) * 22; img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = v; img.data[i * 4 + 3] = 255; }
        tg.putImageData(img, 0, 0);
        g.fillStyle = g.createPattern(tile, "repeat"); g.fillRect(0, 0, w, h);
        g.save();
        for (const sd of [1, -1]) { gripPath(g, sd); g.save(); g.clip(); g.fillStyle = "rgb(70,70,70)"; g.fillRect(0, 0, w, h);
          g.fillStyle = "rgb(220,220,220)";
          const sp = 0.062 * SC;
          for (let y = 0, r = 0; y < h; y += sp * 0.866, r++) for (let x = (r % 2) * sp / 2; x < w; x += sp) { g.beginPath(); g.arc(x, y, sp * 0.3, 0, Math.PI * 2); g.fill(); }
          g.restore(); }
        g.restore();
      });
      TEX.knurl = canvasTex(1024, 256, (g, w, h) => {
        g.fillStyle = "rgb(128,128,128)"; g.fillRect(0, 0, w, h);
        const v0 = 3 / 12, v1 = 7 / 12;
        const y0 = (1 - v1) * h, y1 = (1 - v0) * h;
        for (let i = 0; i < 128; i++) {
          const x = (i / 128) * w;
          g.fillStyle = "rgb(40,40,40)"; g.fillRect(x, y0, w / 128 * 0.45, y1 - y0);
          g.fillStyle = "rgb(210,210,210)"; g.fillRect(x + w / 128 * 0.5, y0, w / 128 * 0.3, y1 - y0);
        }
      });
      // home button: the Corvid crow mark printed on the cap
      TEX.home = canvasTex(512, 512, (g, w, h) => {
        g.fillStyle = "#0c0c0e"; g.fillRect(0, 0, w, h);
        g.save();
        g.translate(w / 2 - 41 * 2.1, h / 2 - 13 * 2.1);
        g.scale(2.1, 2.1);
        g.fillStyle = "#a3a5ab";
        const p = new Path2D("M 21.61 -40.61 L 128 16 L 37.68 26.38 A 46 46 0 0 1 -7.99 45.3 L -45.3 -7.99 A 46 46 0 0 1 21.61 -40.61 Z M 44 6 L 120 15.2 L 44 10 Z M 20.5 -10 A 6.5 6.5 0 1 0 7.5 -10 A 6.5 6.5 0 1 0 20.5 -10 Z");
        g.fill(p, "evenodd");
        g.restore();
      }, true);
      TEX.glow = canvasTex(256, 256, (g, w, h) => {
        const img = g.createImageData(w, h);
        for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
          const r = Math.hypot(x - w / 2 + 0.5, y - h / 2 + 0.5) / (w / 2);
          const v = Math.max(0, Math.exp(-r * r * 9) * 0.85 + Math.exp(-r * 4.5) * 0.25 - 0.012) * (r < 1 ? 1 : 0);
          const k = (y * w + x) * 4;
          img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * Math.min(1, v)); img.data[k + 3] = 255;
        }
        g.putImageData(img, 0, 0);
      }, true);

      /* ================= materials ================= */
      const M = {};
      M.shell = new THREE.MeshPhysicalMaterial({
        color: 0xffffff, map: TEX.albedo, metalness: 0, roughness: 1, roughnessMap: TEX.rough,
        clearcoat: 0.4, clearcoatMap: TEX.rough, clearcoatRoughness: 0.3,
        aoMap: TEX.ao, aoMapIntensity: 1, bumpMap: TEX.bump, bumpScale: 0.35,
      });
      M.bumper = new THREE.MeshPhysicalMaterial({ color: 0x1d1e21, metalness: 0, roughness: 0.28, clearcoat: 0.6, clearcoatRoughness: 0.15 });
      M.trigger = new THREE.MeshPhysicalMaterial({ color: 0x232428, metalness: 0, roughness: 0.48 });
      M.well = new THREE.MeshPhysicalMaterial({ color: 0x0a0a0b, metalness: 0, roughness: 0.6, side: THREE.DoubleSide });
      M.boot = new THREE.MeshPhysicalMaterial({ color: 0x141416, metalness: 0, roughness: 0.55, clearcoat: 0.2 });
      M.rubber = new THREE.MeshPhysicalMaterial({ color: 0x1b1c1f, metalness: 0, roughness: 0.62, bumpMap: TEX.knurl, bumpScale: 1.2 });
      M.collar = new THREE.MeshPhysicalMaterial({ color: 0x9a9ca2, metalness: 1, roughness: 0.22 });
      M.gloss = new THREE.MeshPhysicalMaterial({ color: 0x0b0c0e, metalness: 0, roughness: 0.1, clearcoat: 1, clearcoatRoughness: 0.04 });
      M.gap = new THREE.MeshBasicMaterial({ color: 0x000000 });
      M.dpad = new THREE.MeshPhysicalMaterial({ color: 0x6a6d73, metalness: 1, roughness: 0.26 });
      M.home = new THREE.MeshPhysicalMaterial({ color: 0xffffff, map: TEX.home, metalness: 0, roughness: 0.18, clearcoat: 1, clearcoatRoughness: 0.05 });
      M.led = new THREE.MeshBasicMaterial({ color: new THREE.Color(0.7, 0.82, 1.0) });

      /* ================= geometry helpers ================= */
      function lathe(profile, segs = 160) {
        const g = new THREE.LatheGeometry(profile.map(([r, z]) => new THREE.Vector2(r, z)), segs);
        g.rotateX(Math.PI / 2);
        return g;
      }
      function arcPts(cx, cz, rad, a0, a1, n) {
        const p = [];
        for (let i = 0; i <= n; i++) { const a = a0 + (a1 - a0) * (i / n); p.push([cx + rad * Math.cos(a), cz + rad * Math.sin(a)]); }
        return p;
      }
      function disc(r, z, segs = 128) { const g = new THREE.CircleGeometry(r, segs); g.translate(0, 0, z); return g; }
      function ring(r0, r1, z, segs = 128) { const g = new THREE.RingGeometry(r0, r1, segs, 1); g.translate(0, 0, z); return g; }
      // component group sitting on the sculpted face, aligned to the surface normal
      const Z_AXIS = new THREE.Vector3(0, 0, 1);
      function onSurface(x, y) {
        const g = new THREE.Group();
        g.position.set(x, y, ZT + F(x, y));
        const [fx, fy] = gradF(x, y);
        g.quaternion.setFromUnitVectors(Z_AXIS, new THREE.Vector3(-fx, -fy, 1).normalize());
        return g;
      }

      /* ================= controller assembly ================= */
      const product = new THREE.Group();
      scene.add(product);
      const body = bodyGeometry();
      product.add(new THREE.Mesh(body.geo, M.shell));

      // bumpers and triggers along the shoulders
      const RHALF = BODY.slice(0, HALF + 1);
      const nAng = (p) => Math.atan2(p.ny, p.nx) * 180 / Math.PI;
      const segBetween = (x0, angEnd) => {
        const out = [];
        for (const p of RHALF) { if (p.x < x0) continue; if (out.length && nAng(p) < angEnd) break; out.push(p); }
        return out;
      };
      const bumperR = bandCW(segBetween(2.35, 32), 0.32, 0.36);
      const triggerR = bandCW(segBetween(3.85, 52), 0.78, -0.12);
      const parts = [];
      for (const [loop, zt, zb, rf, mat] of [
        [bumperR, 0.24, -0.6, 0.13, M.bumper], [mirrorCW(bumperR), 0.24, -0.6, 0.13, M.bumper],
        [triggerR, -0.22, -1.05, 0.16, M.trigger], [mirrorCW(triggerR), -0.22, -1.05, 0.16, M.trigger],
      ]) {
        const s = partSlab([...loop].reverse(), zt, zb, rf);
        product.add(new THREE.Mesh(s.geo, mat));
        parts.push(s.sil);
      }

      for (const s of STICKS) {
        const g = onSurface(s.x, s.y);
        g.add(new THREE.Mesh(lathe([[WELL_R, -WELL_D], [WELL_R, 0.02]]), M.well));
        g.add(new THREE.Mesh(disc(WELL_R, -WELL_D), M.well));
        const boot = [[0.97, -WELL_D + 0.01], ...arcPts(0.62, -WELL_D + 0.01, 0.35, 0, Math.PI / 2, 12), [0.3, -WELL_D + 0.36]];
        g.add(new THREE.Mesh(lathe(boot), M.boot));
        g.add(new THREE.Mesh(lathe([[0.27, -0.05], [0.27, 0.32]]), M.boot));
        g.add(new THREE.Mesh(lathe([[0.62, 0.28], [0.66, 0.31], [0.64, 0.34]]), M.collar));
        const h = 0.62;
        const cap = [
          [0.0001, h - 0.075], [0.15, h - 0.07], [0.3, h - 0.055], [0.42, h - 0.032], [0.52, h - 0.008],
          [0.58, h], [0.64, h - 0.006], [0.69, h - 0.03], [0.72, h - 0.08], [0.73, h - 0.16],
          [0.72, h - 0.23], [0.67, h - 0.28], [0.45, h - 0.3],
        ].reverse();
        g.add(new THREE.Mesh(lathe(cap, 192), M.rubber));
        product.add(g);
      }
      {
        const g = onSurface(DPAD.x, DPAD.y);
        g.add(new THREE.Mesh(ring(0.84, 0.9, 0.003), M.gap));
        g.add(new THREE.Mesh(lathe([[0.86, 0.0], [0.86, 0.025], [0.84, 0.035], [0.0001, 0.035]]), M.gloss));
        const sh = new THREE.Shape();
        const a = 0.2, L = 0.7;
        const pts = [[a, L], [-a, L], [-a, a], [-L, a], [-L, -a], [-a, -a], [-a, -L], [a, -L], [a, -a], [L, -a], [L, a], [a, a]];
        sh.moveTo(pts[0][0], pts[0][1]);
        for (let i = 1; i < pts.length; i++) sh.lineTo(pts[i][0], pts[i][1]);
        sh.closePath();
        const eg = new THREE.ExtrudeGeometry(sh, { depth: 0.08, bevelEnabled: true, bevelThickness: 0.05, bevelSize: 0.05, bevelSegments: 6, curveSegments: 4 });
        const cross = new THREE.Mesh(eg, M.dpad);
        cross.position.z = 0.06;
        g.add(cross);
        product.add(g);
      }
      const GLYPH = {};
      for (const L of ["Y", "B", "A", "X"]) {
        GLYPH[L] = new THREE.MeshPhysicalMaterial({
          color: 0xffffff, transparent: true, depthWrite: false, roughness: 0.3, metalness: 0,
          map: canvasTex(128, 128, (g, w, h) => {
            g.clearRect(0, 0, w, h);
            g.fillStyle = "#8f9298";
            g.font = "600 64px Inter, Helvetica, Arial, sans-serif";
            g.textAlign = "center"; g.textBaseline = "middle";
            g.fillText(L, w / 2, h / 2 + 4);
          }, true),
        });
      }
      const GL = ["Y", "B", "A", "X"];
      for (const [bi, [dx, dy]] of [[0, 1], [1, 0], [0, -1], [-1, 0]].entries()) {
        const g = onSurface(FACE.x + dx * FACE.d, FACE.y + dy * FACE.d);
        const R = FACE.R;
        g.add(new THREE.Mesh(ring(R, R + 0.05, 0.003), M.gap));
        const prof = [[R, -0.02], [R, 0.1], ...arcPts(R - 0.06, 0.1, 0.06, 0, Math.PI / 2, 8).slice(1)];
        for (let i = 1; i <= 16; i++) { const r = (R - 0.06) * (1 - i / 16); prof.push([Math.max(r, 1e-4), 0.16 + 0.05 * (1 - (r / (R - 0.06)) ** 2)]); }
        g.add(new THREE.Mesh(lathe(prof, 128), M.gloss));
        const decal = new THREE.Mesh(new THREE.CircleGeometry(0.17, 48), GLYPH[GL[bi]]);
        decal.position.z = 0.214;
        g.add(decal);
        product.add(g);
      }
      for (const s of SMALL) {
        const g = onSurface(s.x, s.y);
        const m = new THREE.Mesh(new RoundedBoxGeometry(0.5, 0.24, 0.16, 5, 0.08), M.gloss);
        m.position.z = 0.03;
        g.add(m);
        product.add(g);
      }
      {
        const g = onSurface(HOME.x, HOME.y);
        g.add(new THREE.Mesh(ring(HOME.R + 0.02, HOME.R + 0.075, 0.004), M.gap));
        const ledRing = new THREE.Mesh(ring(HOME.R + 0.035, HOME.R + 0.052, 0.006), M.led);
        g.add(ledRing);
        g.add(new THREE.Mesh(lathe([[HOME.R, -0.01], [HOME.R, 0.06], ...arcPts(HOME.R - 0.03, 0.06, 0.03, 0, Math.PI / 2, 6).slice(1)]), M.gloss));
        const top = new THREE.Mesh(new THREE.CircleGeometry(HOME.R - 0.03, 96), M.home);
        top.position.z = 0.09;
        g.add(top);
        product.add(g);
        M.ledMesh = ledRing;
      }

      /* ================= camera ================= */
      const TOPY = Math.max(...parts.flat().map((p) => p.y));
      const TARGET = new THREE.Vector3(0, (TOPY + YMIN) / 2 - 0.36, 0);
      const DIST = 48.6;

      /* ================= silhouette: union of body + bumpers + triggers, as the camera sees it =================
         every part's silhouette ring is projected onto the plane z = ZREF, rasterised, then traced with marching squares */
      const ZREF = 0.0;
      const proj = (p) => { const k = (DIST - ZREF) / (DIST - p.z); return { x: TARGET.x + (p.x - TARGET.x) * k, y: TARGET.y + (p.y - TARGET.y) * k }; };
      const SIL = (() => {
        const RX0 = -9.0, RX1 = 9.0, RY0 = -7.0, RY1 = 6.0, PX = 120;
        const W = Math.round((RX1 - RX0) * PX), H = Math.round((RY1 - RY0) * PX);
        const c = document.createElement("canvas"); c.width = W; c.height = H;
        const g = c.getContext("2d");
        g.fillStyle = "#fff";
        for (const loop of [body.sil, ...parts]) {
          g.beginPath();
          loop.forEach((p, i) => { const q = proj(p); const cx = (q.x - RX0) * PX, cy = (RY1 - q.y) * PX; if (i) g.lineTo(cx, cy); else g.moveTo(cx, cy); });
          g.closePath(); g.fill();
        }
        const data = g.getImageData(0, 0, W, H).data;
        const v = (i, j) => data[(j * W + i) * 4 + 3] / 255;
        const adj = new Map();
        const ptOf = new Map();
        const keyH = (i, j) => (j * W + i) * 2, keyV = (i, j) => (j * W + i) * 2 + 1;
        const link = (a, b) => { (adj.get(a) || adj.set(a, []).get(a)).push(b); (adj.get(b) || adj.set(b, []).get(b)).push(a); };
        const lerpPt = (k, i, j, horiz, va, vb) => { if (!ptOf.has(k)) { const t = (0.5 - va) / (vb - va); ptOf.set(k, horiz ? [i + t, j] : [i, j + t]); } };
        for (let j = 0; j < H - 1; j++) for (let i = 0; i < W - 1; i++) {
          const a = v(i, j), b = v(i + 1, j), cc = v(i + 1, j + 1), d = v(i, j + 1);
          const ia = a > 0.5, ib = b > 0.5, ic = cc > 0.5, id = d > 0.5;
          if (ia === ib && ib === ic && ic === id) continue;
          const e = [];
          if (ia !== ib) { const k = keyH(i, j); lerpPt(k, i, j, true, a, b); e.push(k); }
          if (ib !== ic) { const k = keyV(i + 1, j); lerpPt(k, i + 1, j, false, b, cc); e.push(k); }
          if (id !== ic) { const k = keyH(i, j + 1); lerpPt(k, i, j + 1, true, d, cc); e.push(k); }
          if (ia !== id) { const k = keyV(i, j); lerpPt(k, i, j, false, a, d); e.push(k); }
          if (e.length === 2) link(e[0], e[1]);
          else if (e.length === 4) { link(e[0], e[1]); link(e[2], e[3]); }
        }
        // walk the longest loop
        const seen = new Set();
        let best = [];
        for (const start of adj.keys()) {
          if (seen.has(start)) continue;
          const loop = [];
          let prev = -1, cur = start;
          while (cur !== undefined && !seen.has(cur)) {
            seen.add(cur); loop.push(cur);
            const nb = adj.get(cur);
            const nxt = nb[0] !== prev ? nb[0] : nb[1];
            prev = cur; cur = nxt;
          }
          if (loop.length > best.length) best = loop;
        }
        const pts = best.map((k) => { const [x, y] = ptOf.get(k); return { x: RX0 + (x + 0.5) / PX, y: RY1 - (y + 0.5) / PX }; });
        // orient clockwise (y up)
        let area = 0;
        for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) area += (pts[j].x - pts[i].x) * (pts[j].y + pts[i].y);
        if (area > 0) pts.reverse();
        // start at the top centre crossing, take the right half to the bottom centre crossing
        const n = pts.length;
        let top = -1, topY = -1e9;
        for (let i = 0; i < n; i++) { const a = pts[i], b = pts[(i + 1) % n]; if ((a.x <= 0) !== (b.x <= 0)) { const y = (a.y + b.y) / 2; if (y > topY) { topY = y; top = i; } } }
        const half = [{ x: 0, y: topY }];
        for (let k = 1; k < n; k++) {
          const a = pts[(top + k) % n], b = pts[(top + k + 1) % n];
          half.push(a);
          if (a.x > 0 && b.x <= 0) { const t = a.x / (a.x - b.x); half.push({ x: 0, y: a.y + (b.y - a.y) * t }); break; }
        }
        return half;
      })();

      /* ================= light line (two mirrored halves) ================= */
      const LINE_R = 0.0105;
      const lineMat = new THREE.ShaderMaterial({
        uniforms: { head: { value: 0 }, gain: { value: 0 }, boost: { value: 1 }, col: { value: new THREE.Color(0.84, 0.92, 1.0) } },
        vertexShader: `attribute float s; varying float vS; void main(){ vS = s; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
        fragmentShader: `uniform float head; uniform float gain; uniform float boost; uniform vec3 col; varying float vS;
          void main(){ if (vS > head) discard; float d = head - vS; float k = 0.7 + boost * 1.4 * exp(-d * 18.0); gl_FragColor = vec4(col * gain * k, 1.0); }`,
      });
      const lineGrp = new THREE.Group();
      scene.add(lineGrp);
      const halves = [];
      {
        const curve0 = new THREE.CatmullRomCurve3(SIL.map((p) => new THREE.Vector3(p.x, p.y, ZREF)), false, "centripetal");
        const sp = curve0.getSpacedPoints(1600).map((v) => ({ x: v.x, y: v.y }));
        sp[0].x = 0; sp[sp.length - 1].x = 0;
        // outward normals (clockwise path)
        for (let i = 0; i < sp.length; i++) {
          const a = sp[Math.max(0, i - 2)], b = sp[Math.min(sp.length - 1, i + 2)];
          const tx = b.x - a.x, ty = b.y - a.y, l = Math.hypot(tx, ty) || 1;
          sp[i].nx = -ty / l; sp[i].ny = tx / l;
        }
        for (const side of [1, -1]) {
          const pts = sp.map((p) => new THREE.Vector3(side * (p.x + p.nx * LINE_R * 1.2), p.y + p.ny * LINE_R * 1.2, ZREF));
          const curve = new THREE.CatmullRomCurve3(pts, false, "centripetal");
          const TS = 1600, RS = 8;
          const geo = new THREE.TubeGeometry(curve, TS, LINE_R, RS, false);
          const sAttr = new Float32Array(geo.attributes.position.count);
          for (let i = 0; i < sAttr.length; i++) sAttr[i] = Math.floor(i / (RS + 1)) / TS;
          geo.setAttribute("s", new THREE.BufferAttribute(sAttr, 1));
          lineGrp.add(new THREE.Mesh(geo, lineMat));
          halves.push(curve);
        }
      }
      const mkSprite = (scale) => {
        const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.glow, color: 0xffffff, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, transparent: true }));
        sp.scale.set(scale, scale, 1);
        lineGrp.add(sp);
        return sp;
      };
      const heads = [0, 1].map(() => ({ halo: mkSprite(0.6), core: mkSprite(0.11) }));

      /* ================= environments ================= */
      const pmrem = new THREE.PMREMGenerator(renderer);
      function softTex(soft) {
        return canvasTex(256, 256, (g, w, h) => {
          const img = g.createImageData(w, h);
          for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
            const u = Math.abs(x / (w - 1) * 2 - 1), v = Math.abs(y / (h - 1) * 2 - 1);
            const f = (a) => { const e = clamp01((1 - a) / soft); return e * e * (3 - 2 * e); };
            const val = Math.round(255 * f(u) * f(v));
            const k = (y * w + x) * 4;
            img.data[k] = img.data[k + 1] = img.data[k + 2] = val; img.data[k + 3] = 255;
          }
          g.putImageData(img, 0, 0);
        }, true);
      }
      const SOFT = { hard: softTex(0.06), med: softTex(0.35), soft: softTex(0.9) };
      function makeEnv(panels) {
        const s = new THREE.Scene();
        s.background = new THREE.Color(0x000000);
        for (const p of panels) {
          const m = new THREE.MeshBasicMaterial({ color: new THREE.Color(...p.c).multiplyScalar(p.i), map: SOFT[p.soft || "hard"], side: THREE.DoubleSide });
          const mesh = new THREE.Mesh(new THREE.PlaneGeometry(p.w, p.h), m);
          mesh.position.set(...p.p);
          mesh.lookAt(0, 0, 0);
          s.add(mesh);
        }
        return pmrem.fromScene(s, 0, 0.1, 100).texture;
      }
      const COOL = [0.92, 0.96, 1.0];
      const ENV_BODY = makeEnv([
        { p: [0, 12, 10], w: 30, h: 9, i: 1.6, c: COOL, soft: "soft" },          // soft key above
        { p: [0, 3, 16], w: 24, h: 10, i: 0.32, c: COOL, soft: "soft" },         // face sheen
        { p: [-14, 1, 2], w: 1.4, h: 14, i: 1.2, c: COOL, soft: "med" },
        { p: [14, 1, 2], w: 1.4, h: 14, i: 1.2, c: COOL, soft: "med" },
        { p: [0, -11, 4.5], w: 30, h: 3.6, i: 1.5, c: COOL, soft: "med" },       // low bounce: lower edges of the grips
        { p: [0, -6, -12], w: 30, h: 5, i: 3.2, c: COOL, soft: "med" },          // low rim from behind
        { p: [-10, -4, -9], w: 2, h: 10, i: 2.2, c: COOL, soft: "med" },
        { p: [10, -4, -9], w: 2, h: 10, i: 2.2, c: COOL, soft: "med" },
      ]);
      const ENV_HI = makeEnv([
        { p: [0, 12, 5.5], w: 34, h: 7, i: 30, c: COOL, soft: "med" },
      ]);

      /* ================= post ================= */
      const rtOpts = { type: THREE.HalfFloatType, samples: 4, colorSpace: THREE.LinearSRGBColorSpace };
      const rtL = new THREE.WebGLRenderTarget(RW, RH, rtOpts);
      const rtB = new THREE.WebGLRenderTarget(RW, RH, rtOpts);
      const rtH = new THREE.WebGLRenderTarget(RW, RH, rtOpts);
      const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(RW, RH, { type: THREE.HalfFloatType }));
      composer.renderToScreen = false;
      composer.setPixelRatio(SS);
      const combine = new ShaderPass({
        uniforms: { tDiffuse: { value: null }, tL: { value: null }, tB: { value: null }, tH: { value: null }, kL: { value: 0 }, kB: { value: 0 }, kH: { value: 0 } },
        vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
        fragmentShader: `uniform sampler2D tL; uniform sampler2D tB; uniform sampler2D tH; uniform float kL; uniform float kB; uniform float kH; varying vec2 vUv;
          void main(){ vec3 c = kL * texture2D(tL, vUv).rgb + kB * texture2D(tB, vUv).rgb + kH * texture2D(tH, vUv).rgb; c = clamp(c, vec3(0.0), vec3(64.0)); gl_FragColor = vec4(c, 1.0); }`,
      });
      combine.uniforms.tL.value = rtL.texture;
      combine.uniforms.tB.value = rtB.texture;
      combine.uniforms.tH.value = rtH.texture;
      composer.addPass(combine);
      const bloom = new UnrealBloomPass(new THREE.Vector2(RW, RH), 0.38, 0.3, 1.2);
      composer.addPass(bloom);
      composer.addPass(new OutputPass());
      composer.setPixelRatio(SS);

      const finalMat = new THREE.ShaderMaterial({
        uniforms: { tSrc: { value: null }, texel: { value: new THREE.Vector2(1 / RW, 1 / RH) }, frame: { value: 0 }, grain: { value: 0.016 }, vig: { value: 0.3 } },
        vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
        fragmentShader: `uniform sampler2D tSrc; uniform vec2 texel; uniform float frame; uniform float grain; uniform float vig; varying vec2 vUv;
          float h12(vec2 p){ vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
          void main(){
            vec3 c = vec3(0.0);
            c += texture2D(tSrc, vUv + texel * vec2(-0.75, -0.75)).rgb;
            c += texture2D(tSrc, vUv + texel * vec2( 0.75, -0.75)).rgb;
            c += texture2D(tSrc, vUv + texel * vec2(-0.75,  0.75)).rgb;
            c += texture2D(tSrc, vUv + texel * vec2( 0.75,  0.75)).rgb;
            c *= 0.25;
            vec2 q = vUv - 0.5; q.x *= 1.777;
            c *= 1.0 - vig * smoothstep(0.35, 1.05, length(q));
            vec2 px = floor(gl_FragCoord.xy);
            float n = (h12(px + frame * 17.31) + h12(px * 1.37 + frame * 3.7 + 11.0)) - 1.0;
            float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
            c += n * grain * (0.2 + 0.8 * sqrt(clamp(lum, 0.0, 1.0)));
            gl_FragColor = vec4(max(c, vec3(0.0)), 1.0);
          }`,
        depthTest: false, depthWrite: false,
      });
      const fsScene = new THREE.Scene();
      const fsCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
      fsScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), finalMat));

      /* ================= direction ================= */
      const T = {
        dotIn: [0.45, 1.0],
        draw: [1.0, 4.0],      // 3.0s power2.inOut, top centre -> both sides -> bottom centre
        body: [3.3, 4.8],      // 1.5s, overlaps the last 0.7s of the stroke
        hi: [3.8, 5.3],        // the wide highlight peaks 0.5s after the body
        lineOut: [4.6, 5.7],
        led: [5.5, 6.1],
        push: [6.3, 8.2],      // still for ~1s after the highlight, then a slow push
        cut: 8.2,
      };

      function renderAt(t) {
        if (t >= T.cut) return;
        const push = 1 + 0.05 * power2In(seg(t, T.push[0], T.push[1]));
        camera.position.set(TARGET.x, TARGET.y, DIST / push);
        camera.lookAt(TARGET);
        camera.updateProjectionMatrix();

        const head = power2InOut(seg(t, T.draw[0], T.draw[1]));
        const dot = sineInOut(seg(t, T.dotIn[0], T.dotIn[1]));
        const lineOut = 1 - 0.86 * sineInOut(seg(t, T.lineOut[0], T.lineOut[1]));
        const settle = 1 - 0.35 * sineInOut(seg(t, T.draw[1] - 0.1, T.draw[1] + 0.6));
        const headFade = 1 - sineInOut(seg(t, T.draw[1] - 0.15, T.draw[1] + 0.45));
        lineMat.uniforms.head.value = t < T.draw[0] ? -1 : head;
        lineMat.uniforms.gain.value = 2.6 * settle;
        lineMat.uniforms.boost.value = headFade;
        halves.forEach((c, i) => {
          const p = c.getPointAt(Math.min(1, Math.max(0, head)));
          const h = heads[i];
          h.halo.position.set(p.x, p.y, ZREF + 0.05);
          h.core.position.copy(h.halo.position);
          const k = dot * headFade * (i === 0 ? 1 : (t < T.draw[0] ? 0 : 1));
          h.halo.material.color.setScalar(1.0 * k);
          h.core.material.color.setScalar(9 * k);
        });

        const kB = power2InOut(seg(t, T.body[0], T.body[1]));
        const kH = power2InOut(seg(t, T.hi[0], T.hi[1]));
        M.led.color.setRGB(0.7, 0.82, 1.0).multiplyScalar(1.2 * sineInOut(seg(t, T.led[0], T.led[1])));

        product.visible = false; lineGrp.visible = true;
        renderer.setRenderTarget(rtL); renderer.clear(); renderer.render(scene, camera);
        product.visible = true; lineGrp.visible = false;
        M.ledMesh.visible = true;
        scene.environment = ENV_BODY;
        renderer.setRenderTarget(rtB); renderer.clear(); renderer.render(scene, camera);
        M.ledMesh.visible = false;
        scene.environment = ENV_HI;
        renderer.setRenderTarget(rtH); renderer.clear(); renderer.render(scene, camera);
        renderer.setRenderTarget(null);

        combine.uniforms.kL.value = lineOut;
        combine.uniforms.kB.value = kB;
        combine.uniforms.kH.value = kH;
        composer.render();
        finalMat.uniforms.tSrc.value = composer.readBuffer.texture;
        finalMat.uniforms.frame.value = Math.round(t * 30);
        renderer.setRenderTarget(null);
        renderer.render(fsScene, fsCam);
      }

      window.__ots = { renderAt, T, SIL };
      window.addEventListener("hf-seek", (e) => renderAt(e.detail.time));
      renderAt(window.__hfThreeTime || 0);
    </script>
  </body>
</html>
```
