Skip to content

glass-shard-title

by HeyGen · official repository

Glass shards fly in through fog and tile themselves into the headline. HyperFrames block, 1920×1080, 12.16s, 19 variables.

OfficialRuns commands3 scripts1 finding

Labels from its files: runs commands. 14 files, read Oct 21, 2026. Nothing was run.

npx skills add https://github.com/heygen-com/hyperframes --skill glass-shard-title

What it lets an agent do

Each label, and the line in the skill's files that set it.

  • Runs commands

    Allows a shell or ships scripts the agent runs.

    • It ships 3 scripts the agent can run.

Findings (1)

Matches of fixed checks, with the text they matched.

  • warnbuild-bundle.mjs

    build-bundle.mjs runs a command string through a shell, or evaluates code it builds at run time.

    …t { execFileSync } from "node:child_process"; const SRC = "assets/glass-…

    What this check looks for

Not a safety rating, and no finding is not a safety guarantee. These are fixed checks of the files as read on Oct 21, 2026. Nothing was run. How skills are checked.

SKILL.md

As the publisher wrote it. Shown as text, never rendered.

Publisher text
---
name: glass-shard-title
description: Glass shards fly in through fog and tile themselves into the headline. HyperFrames block, 1920×1080, 12.16s, 19 variables.
---

# Glass Shard Title

Voronoi-cut glass panes fly in from depth, rotating, and land as a tiled silhouette of the headline, then fly out past the camera. Each shard is a Sutherland–Hodgman clip of a jittered blob against its Voronoi cell, so the pieces always cover the type. Glass and matcap surfaces get per-fragment fog; the z-flight is decoupled from the ease so shards visibly traverse it. Tile count 1 gives a single pane.

Composition id: `glass-shard-title`. Duration 12.16 s at 30 fps, 1920×1080.

## Files

- `glass-shard-title.html` (16 KB)
- `assets/ferndale_studio_01_1k.hdr` (1.6 MB)
- `assets/fonts/Geist-Bold.ttf` (65 KB)
- `assets/fonts/Geist-Regular.ttf` (65 KB)
- `assets/fonts/Geist-SemiBold.ttf` (65 KB)
- `assets/fonts/Geist-OFL.txt` (4 KB)
- `assets/fonts/cormorant-garamond.woff2` (37 KB)
- `assets/fonts/CormorantGaramond-OFL.txt` (4 KB)
- `assets/glass-main.js` (549 KB)
- `assets/Three-LICENSE.txt` (1 KB)
- `assets/matcap-1.png` (fetched from the CDN at install)

## Install

Install with `npx hyperframes add glass-shard-title`; by default the files above land under `compositions/glass-shard-title/`. Then mount the block from the host `index.html`:

```html
<div
  data-composition-id="glass-shard-title"
  data-composition-src="compositions/glass-shard-title/glass-shard-title.html"
  data-start="0"
  data-duration="12.16"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Render with custom values by targeting the composition file directly:

```sh
npx --yes hyperframes@0.8.12 render 'compositions/glass-shard-title/glass-shard-title.html' --variables '{"headline":"Designed in glass","tileCount":8}'
```

## Variables

Read at runtime via `window.__hyperframes.getVariables()`; declared on the composition root as `data-composition-variables` (single-quoted attribute, plain JSON).

| id               | type   | default               | label / range                                 |
| ---------------- | ------ | --------------------- | --------------------------------------------- |
| `headline`       | string | `"Designed in glass"` | Headline max 40 chars                         |
| `tileCount`      | number | `8`                   | Tiles 1–400 step 1                            |
| `roundness`      | number | `0.4`                 | Roundness (0-1) 0–1 step 0.01                 |
| `bevel`          | number | `0.35`                | Bevel (0-1) 0–1 step 0.01                     |
| `meshSmooth`     | number | `0.6`                 | Surface smoothness (0-1) 0–1 step 0.01        |
| `flyInTime`      | number | `2.3`                 | Fly-in time (s) 0.2–6 step 0.05               |
| `flyOutTime`     | number | `2.4`                 | Fly-out time (s) 0.2–6 step 0.05              |
| `stagger`        | number | `0.27`                | Stagger (0-1) 0–1 step 0.01                   |
| `flyInRotation`  | number | `5`                   | Fly-in rotation (0-60) 0–60 step 0.1          |
| `flyOutRotation` | number | `6`                   | Fly-out rotation (0-6) 0–6 step 0.1           |
| `chaos`          | number | `0.25`                | Outline chaos (0-1) 0–1 step 0.01             |
| `padding`        | number | `1`                   | Edge padding (0-1) 0–1 step 0.01              |
| `sizeVariance`   | number | `0.35`                | Size variance (0-1) 0–1 step 0.01             |
| `gap`            | number | `0.03`                | Tile gap 0–0.3 step 0.002                     |
| `easePow`        | number | `10`                  | Fly-in position ease (1-10) 1–10 step 0.1     |
| `sideDist`       | number | `3`                   | Side fly distance 0–60 step 0.5               |
| `zDist`          | number | `-42`                 | Z fly-from (negative) -400–0 step 2           |
| `zOut`           | number | `14`                  | Z fly-out to (+ = past camera) -400–60 step 2 |
| `fog`            | number | `36`                  | Fog distance (0 = off) 0–400 step 2           |

## Runtime contract

- One paused GSAP timeline registered as `window.__timelines["glass-shard-title"]`.
- Re-syncs on the `hf-seek` CustomEvent; every frame is a closed-form function of time (seeded PRNG only, no rAF loops, no Date.now).
- Renderer: three.js 0.181.2 (bundled), GSAP 3.14.2, Canvas 2D, GLSL shaders, HDR environment, Matcap, Seeded PRNG, d3-delaunay, Shadow maps. Budget roughly 350 MB per live instance; run one at a time.
- External runtime dependencies: `https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js`, `https://cdn.jsdelivr.net/npm/d3-delaunay@6.0.4/dist/d3-delaunay.min.js`.
- Local fonts: assets/fonts/Geist-Regular.ttf, assets/fonts/Geist-SemiBold.ttf, assets/fonts/Geist-Bold.ttf.
- Bundled code is inlined into the composition (assets/glass-main.js); rebuild with esbuild and re-inline after editing the .mjs source.

## Editing rules (from the source project)

1. Keep `data-composition-variables` a single-quoted attribute with plain `"` JSON. Never save it through Studio's Design panel.
2. Do not put `<canvas>` in static markup; create it at runtime.
3. Keep every visual state a function of t; seek-safety is what makes the block renderable.

Scripts (2)

Read as text. anlyon.dev never runs them.

assets/glass-main.mjsShow
/* ================================================================
         Three.js module: rasterise the headline, break its footprint into
         seeded Voronoi glass shards, and render purely from scripted state.
         ================================================================ */
import * as THREE from "three";
import { HDRLoader } from "three/addons/loaders/HDRLoader.js";

const G = window.__GST;
const st = G.st;

/* ---- One instance per document -------------------------------------
         This composition is mounted in short-lived documents all over the
         place: Studio's preview stage, the sidebar's hover previews, a
         composition-stack push, the gallery's per-block tiles. One live
         instance costs roughly 350 MB (a 1920x1080 WebGL context with
         antialias + preserveDrawingBuffer, a 4096x2304 headline raster with
         mipmaps, a PMREM environment, a transmission render target and 34
         extruded shard geometries), so a stale one is not a rounding error.
         Never build two on the same document, and always hand the host an
         explicit way to release one. */
if (window.__gstInstance && window.__gstInstance.dispose) {
  try {
    window.__gstInstance.dispose();
  } catch (e) {
    /* best effort */
  }
}

const canvas = document.getElementById("gst-canvas");
const renderer = new THREE.WebGLRenderer({
  canvas,
  alpha: true,
  antialias: true,
  preserveDrawingBuffer: true,
});
renderer.setSize(1920, 1080, false);
renderer.setPixelRatio(1);
renderer.setClearColor(0x0a0a0f, 1); /* flat dark — no gradient wash */
renderer.toneMapping = THREE.ACESFilmicToneMapping;

const FOV = 42;
const CAM_Z = 5.2;
const PANE_Z = 0.6;
const scene = new THREE.Scene();
/* Include the full configured flight, 15% seeded depth jitter, lateral
         offsets rotated by the pane group, and shard extents. The old fixed
         far=60 clipped distant starts even with fog disabled. */
const FAR = Math.max(
  60,
  CAM_Z + Math.max(-G.vars.zDist, -G.vars.zOut, 0) * 1.15 + G.vars.sideDist * 1.5 + 12,
);
const camera = new THREE.PerspectiveCamera(FOV, 1920 / 1080, 0.1, FAR);
camera.position.set(0, 0, CAM_Z);

/* Frustum size at the shard plane: world <-> texture mapping basis. */
const distPane = CAM_Z - PANE_Z;
const VH = 2 * distPane * Math.tan((FOV * Math.PI) / 360);
const VW = VH * (1920 / 1080);

/* ---- Lights ---- */
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const spot = new THREE.SpotLight(0xffffff, 7, 0, Math.PI / 4, 0.5, 1);
spot.position.set(0, 7.1, 2);
scene.add(spot);
const point = new THREE.PointLight(0xccddff, 4, 0, 1);
point.position.set(0, -3, 1);
scene.add(point);

/* ---- Environment + matcap (glass look) ---- */
const hdrTex = await new HDRLoader().loadAsync(document.getElementById("gst-hdr").href);
const pmrem = new THREE.PMREMGenerator(renderer);
const envRT = pmrem.fromEquirectangular(hdrTex);
scene.environment = envRT.texture;
scene.environmentRotation = new THREE.Euler(0, 0.6, 0);
hdrTex.dispose();
pmrem.dispose();

const matcapTex = await new THREE.TextureLoader().loadAsync(
  document.getElementById("gst-matcap").href,
);
matcapTex.colorSpace = THREE.SRGBColorSpace;
matcapTex.wrapS = matcapTex.wrapT = THREE.RepeatWrapping;
const mk = 1 / 0.2;
matcapTex.repeat.set(mk, mk);
matcapTex.offset.set((1 - mk) * 0.5, (1 - mk) * 0.5);

/* Fonts must be resident BEFORE rasterising: shard selection samples
         the text pixels. */
try {
  await Promise.all([
    document.fonts.load('600 100px "Geist"'),
    document.fonts.load('400 100px "Geist"'),
  ]);
  await document.fonts.ready;
} catch (e) {
  /* system fallback */
}

/* ---- Headline raster (the ONLY content). Canvas spans the camera
         frustum at the shard plane: world (x,y) -> px via VW/VH. ---- */
const TEXW = 4096;
const TEXH = Math.round(TEXW * (VH / VW));
const headCanvas = document.createElement("canvas");
headCanvas.width = TEXW;
headCanvas.height = TEXH;
const hctx = headCanvas.getContext("2d");
{
  hctx.clearRect(0, 0, TEXW, TEXH);
  hctx.textAlign = "center";
  hctx.textBaseline = "middle";
  /* Fit headline to 70% of frame width so the blob silhouette + padding stays inside frame. */
  let fpx = 300;
  hctx.font = `600 ${fpx}px "Geist", ui-sans-serif, sans-serif`;
  hctx.letterSpacing = `${-0.035 * fpx}px`;
  const w0 = hctx.measureText(G.vars.headline).width;
  const maxW = TEXW * 0.7;
  if (w0 > maxW) fpx = Math.floor((fpx * maxW) / w0);
  hctx.font = `600 ${fpx}px "Geist", ui-sans-serif, sans-serif`;
  hctx.letterSpacing = `${-0.035 * fpx}px`;
  hctx.fillStyle = "#f4f4f8";
  hctx.fillText(G.vars.headline, TEXW / 2, TEXH / 2);
  G.fontPx = fpx;
}
const headTex = new THREE.CanvasTexture(headCanvas);
headTex.colorSpace = THREE.SRGBColorSpace;
headTex.anisotropy = 16;

/* ---- Fattened mask for cell selection (thick stroke = keep shards
         that hug the letterforms, not just ones dead-centre on strokes) ---- */
const maskCanvas = document.createElement("canvas");
maskCanvas.width = 1280;
maskCanvas.height = Math.round(1280 * (VH / VW));
const mctx = maskCanvas.getContext("2d", { willReadFrequently: true });
{
  const ms = maskCanvas.width / TEXW;
  mctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
  mctx.textAlign = "center";
  mctx.textBaseline = "middle";
  mctx.font = `600 ${G.fontPx * ms}px "Geist", ui-sans-serif, sans-serif`;
  mctx.letterSpacing = `${-0.035 * G.fontPx * ms}px`;
  mctx.fillStyle = "#fff";
  mctx.strokeStyle = "#fff";
  mctx.lineWidth = G.fontPx * ms * 0.22;
  mctx.lineJoin = "round";
  mctx.strokeText(G.vars.headline, maskCanvas.width / 2, maskCanvas.height / 2);
  mctx.fillText(G.vars.headline, maskCanvas.width / 2, maskCanvas.height / 2);
}
/* Setup-only: released the moment the shard set is final (see below). */
let maskData = mctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height).data;
let maskW = maskCanvas.width,
  maskH = maskCanvas.height;
function maskHit(wx, wy) {
  if (!maskData) return false;
  const px = Math.round((wx / VW + 0.5) * (maskW - 1));
  const py = Math.round((0.5 - wy / VH) * (maskH - 1));
  if (px < 0 || py < 0 || px >= maskW || py >= maskH) return false;
  return maskData[(py * maskW + px) * 4 + 3] > 24;
}

/* ---- Headline band in world units (from the raster metrics) ---- */
{
  hctx.font = `600 ${G.fontPx}px "Geist", ui-sans-serif, sans-serif`;
  hctx.letterSpacing = `${-0.035 * G.fontPx}px`;
  const m = hctx.measureText(G.vars.headline);
  G.bandW = ((m.width + G.fontPx * 0.2) / TEXW) * VW;
  G.bandH =
    ((m.actualBoundingBoxAscent + m.actualBoundingBoxDescent + G.fontPx * 0.34) / TEXH) * VH;
}

/* ---- Organic silhouette: the assembled cluster reads as one shard of
         a larger broken pane, not a rounded rectangle. A seeded harmonic blob
         around the headline defines the region; chaos scales the noise,
         padding sets how far the glass extends past the glyphs. The jagged
         outer edge comes from natural Voronoi cell borders against a ring of
         sacrificial outside sites (never rectangle clipping). ---- */
const SEED = 11;
const rnd = gstMulberry32(SEED);
const HW = G.bandW / 2,
  HH = G.bandH / 2;

const PADW = G.bandH * (0.18 + G.vars.padding * 0.85);
const A0 = HW + PADW,
  B0 = HH + PADW;
const randBlob = gstMulberry32(SEED * 511 + 3);
const HARM = [];
for (let k = 2; k <= 9; k++)
  HARM.push({ k, amp: (randBlob() * 2 - 1) / Math.sqrt(k), ph: randBlob() * Math.PI * 2 });
function ellipseR(a, b, th) {
  return (a * b) / Math.hypot(b * Math.cos(th), a * Math.sin(th));
}
function blobR(th) {
  let n = 0;
  for (const h of HARM) n += h.amp * Math.sin(h.k * th + h.ph);
  /* chaos is deliberately violent at the top end — 1.0 is ~10x the old
           deformation, so the outline can go properly feral. */
  let r = ellipseR(A0, B0, th) * (1 + G.vars.chaos * 2.4 * n);
  /* Floor on the text band RECT (not an ellipse): the whole headline plus
           a padding margin is always inside the blob, so tiling the blob always
           covers the text — at any tile count. */
  const rc = Math.abs(Math.cos(th)),
    rs = Math.abs(Math.sin(th));
  const rectR = Math.min(
    rc > 1e-6 ? (HW + PADW * 0.3) / rc : 1e9,
    rs > 1e-6 ? (HH + PADW * 0.3) / rs : 1e9,
  );
  r = Math.max(r, rectR);
  /* Hard frustum cap: whatever the sliders say, the silhouette never
           spills past the frame and gets viewport-cut. */
  const acx = Math.abs(Math.cos(th)),
    asy = Math.abs(Math.sin(th));
  const lim = Math.min(acx > 1e-6 ? (VW * 0.44) / acx : 1e9, asy > 1e-6 ? (VH * 0.43) / asy : 1e9);
  return Math.min(r, lim);
}
function inBlob(x, y) {
  return Math.hypot(x, y) <= blobR(Math.atan2(y, x));
}

/* Domain + density: keep tileCount as shard density over the blob. */
let blobArea = 0,
  maxRX = 0,
  maxRY = 0;
for (let i = 0; i < 96; i++) {
  const th = (i / 96) * Math.PI * 2;
  const r = blobR(th);
  blobArea += 0.5 * r * r * ((Math.PI * 2) / 96);
  maxRX = Math.max(maxRX, Math.abs(r * Math.cos(th)));
  maxRY = Math.max(maxRY, Math.abs(r * Math.sin(th)));
}
/* tileCount is the exact number of pieces — no density inflation, no
         minimum. tileCount 1 = the whole silhouette as one pane of glass. */
const N_INT = Math.max(1, Math.min(400, Math.round(G.vars.tileCount)));
void blobArea;
const bounds = [-maxRX * 1.3, -maxRY * 1.3, maxRX * 1.3, maxRY * 1.3];

/* Size variance: 0 = uniform cells (full Lloyd relaxation, even spread);
         1 = chaotic — sites cluster around seeded attractors (dense regions =
         small shards, sparse = huge ones) and relaxation is skipped. */
const VAR = G.vars.sizeVariance;
const randVar = gstMulberry32(SEED * 977 + 5);
const centres = [];
let cGuard = 0;
while (centres.length < 4 && cGuard++ < 400) {
  const x = (randVar() * 2 - 1) * maxRX;
  const y = (randVar() * 2 - 1) * maxRY;
  if (inBlob(x, y)) centres.push([x, y]);
}
function gauss(r) {
  /* Box-Muller from the seeded stream */
  const u = Math.max(1e-9, r()),
    v = r();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
let points = [];
let guard = 0;
const sigma = Math.max(maxRX, maxRY) * 0.14;
while (points.length < N_INT && guard++ < N_INT * 200) {
  let x, y;
  if (centres.length && rnd() < 0.85 * VAR) {
    const c = centres[Math.floor(rnd() * centres.length) % centres.length];
    x = c[0] + gauss(rnd) * sigma;
    y = c[1] + gauss(rnd) * sigma;
  } else {
    x = (rnd() * 2 - 1) * maxRX;
    y = (rnd() * 2 - 1) * maxRY;
  }
  if (inBlob(x, y)) points.push([x, y]);
}
const interiorCount = points.length;
/* The blob polygon itself is the outer boundary — every cell is clipped
         against it, so the union of pieces IS the silhouette at any count. */
/* The silhouette polygon is COARSE and jittered on purpose: broken
         glass has straight facet edges meeting at sharp vertices. A smooth
         densely-sampled outline gave the boundary shards melted, scooped
         edges — nothing like the experiment's fragments. */
const randEdge = gstMulberry32(SEED * 733 + 9);
const BLOB_VERTS = 22;
const blobPoly = [];
for (let j = 0; j < BLOB_VERTS; j++) {
  const th = ((j + (randEdge() - 0.5) * 0.72) / BLOB_VERTS) * Math.PI * 2;
  const r = blobR(th) * (1 + (randEdge() - 0.5) * 0.09);
  blobPoly.push([r * Math.cos(th), r * Math.sin(th)]);
}
const RELAX = Math.round(3 * (1 - VAR));
for (let iter = 0; iter < RELAX; iter++) {
  const vorR = d3.Delaunay.from(points).voronoi(bounds);
  points = points.map((p, idx) => {
    const cell = vorR.cellPolygon(idx);
    if (!cell || cell.length < 3) return p;
    let cx = 0,
      cy = 0;
    for (const pt of cell) {
      cx += pt[0];
      cy += pt[1];
    }
    return [cx / cell.length, cy / cell.length];
  });
}

/* Polygon inset (seams between shards). */
function lineIntersect(p1, p2, p3, p4) {
  const d = (p1[0] - p2[0]) * (p3[1] - p4[1]) - (p1[1] - p2[1]) * (p3[0] - p4[0]);
  if (Math.abs(d) < 1e-9) return null;
  const t = ((p1[0] - p3[0]) * (p3[1] - p4[1]) - (p1[1] - p3[1]) * (p3[0] - p4[0])) / d;
  return [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])];
}
function insetPolygon(poly, inset) {
  if (inset <= 0) return poly;
  const n = poly.length;
  if (n < 3) return null;
  const edges = [];
  for (let i = 0; i < n; i++) {
    const a = poly[i],
      b = poly[(i + 1) % n];
    const dx = b[0] - a[0],
      dy = b[1] - a[1];
    const len = Math.hypot(dx, dy);
    if (len < 1e-9) continue;
    const nx = -dy / len,
      ny = dx / len;
    edges.push({
      p1: [a[0] + nx * inset, a[1] + ny * inset],
      p2: [b[0] + nx * inset, b[1] + ny * inset],
    });
  }
  const m = edges.length;
  if (m < 3) return null;
  const out = [];
  for (let k = 0; k < m; k++) {
    const e1 = edges[(k + m - 1) % m],
      e2 = edges[k];
    out.push(lineIntersect(e1.p1, e1.p2, e2.p1, e2.p2) || e2.p1);
  }
  let area = 0;
  for (let q = 0; q < out.length; q++) {
    const [x1, y1] = out[q];
    const [x2, y2] = out[(q + 1) % out.length];
    area += x1 * y2 - x2 * y1;
  }
  return Math.abs(area) < 1e-6 ? null : out;
}

function pointInPoly(x, y, poly) {
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [xi, yi] = poly[i],
      [xj, yj] = poly[j];
    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
  }
  return inside;
}
function cellTouchesText(poly) {
  let minX = 1e9,
    minY = 1e9,
    maxX = -1e9,
    maxY = -1e9;
  for (const [x, y] of poly) {
    if (x < minX) minX = x;
    if (x > maxX) maxX = x;
    if (y < minY) minY = y;
    if (y > maxY) maxY = y;
  }
  const N = 7;
  for (let i = 0; i < N; i++) {
    for (let j = 0; j < N; j++) {
      const x = minX + ((i + 0.5) / N) * (maxX - minX);
      const y = minY + ((j + 0.5) / N) * (maxY - minY);
      if (pointInPoly(x, y, poly) && maskHit(x, y)) return true;
    }
  }
  return false;
}

/* ---- Cells -> shard descriptors (seeded choreography per shard) ---- */
const GAP = Math.max(0, G.vars.gap);
const CORNER_R = 0.01 + G.vars.roundness * 0.16;
const PANE_DEPTH = 0.09;

function polyArea2(poly) {
  let a = 0;
  for (let i = 0; i < poly.length; i++) {
    const [x1, y1] = poly[i],
      [x2, y2] = poly[(i + 1) % poly.length];
    a += x1 * y2 - x2 * y1;
  }
  return a / 2;
}
/* Sutherland-Hodgman: clip any polygon against a CONVEX Voronoi cell. */
function clipToConvex(subject, clip) {
  const ccw = polyArea2(clip) > 0 ? 1 : -1;
  let out = subject;
  for (let i = 0; i < clip.length && out.length; i++) {
    const a = clip[i],
      b = clip[(i + 1) % clip.length];
    const input = out;
    out = [];
    const inside = (pt) =>
      ccw * ((b[0] - a[0]) * (pt[1] - a[1]) - (b[1] - a[1]) * (pt[0] - a[0])) >= -1e-9;
    for (let j = 0; j < input.length; j++) {
      const P = input[j],
        Q = input[(j + 1) % input.length];
      const Pin = inside(P),
        Qin = inside(Q);
      if (Pin) {
        out.push(P);
        if (!Qin) out.push(lineIntersect(P, Q, a, b) || Q);
      } else if (Qin) {
        out.push(lineIntersect(P, Q, a, b) || P);
      }
    }
  }
  return out;
}
/* Merge near-collinear boundary samples so corner rounding keeps scale. */
function simplifyPoly(poly, angTol) {
  let out = poly;
  for (let pass = 0; pass < 3 && out.length > 6; pass++) {
    const next = [];
    let dropped = false;
    for (let i = 0; i < out.length; i++) {
      const A = out[(i + out.length - 1) % out.length];
      const B = out[i];
      const C = out[(i + 1) % out.length];
      const v1x = B[0] - A[0],
        v1y = B[1] - A[1];
      const v2x = C[0] - B[0],
        v2y = C[1] - B[1];
      const ang = Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y));
      if (ang < angTol && !dropped && out.length - 1 > 5) {
        dropped = true;
        continue;
      }
      dropped = false;
      next.push(B);
    }
    if (next.length === out.length) break;
    out = next;
  }
  return out;
}

const vor = d3.Delaunay.from(points).voronoi(bounds);
/* ---- polygon hygiene: the blob clip can emit degenerate results
         (duplicate points, collinear runs, self-intersecting "bowties").
         Those extrude into folded shards with broken reflections, so every
         piece is sanitised and must be a simple polygon before it becomes
         geometry. ---- */
function sanitizePoly(poly) {
  if (!poly || poly.length < 3) return null;
  let out = [];
  for (const pt of poly) {
    const last = out[out.length - 1];
    if (last && Math.hypot(pt[0] - last[0], pt[1] - last[1]) < 1e-4) continue;
    out.push(pt);
  }
  while (
    out.length > 1 &&
    Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < 1e-4
  )
    out.pop();
  if (out.length < 3) return null;
  /* drop collinear vertices */
  const clean = [];
  for (let i = 0; i < out.length; i++) {
    const a = out[(i + out.length - 1) % out.length];
    const b = out[i];
    const c = out[(i + 1) % out.length];
    const cr = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
    const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]);
    const l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
    if (Math.abs(cr) < 1e-6 * Math.max(1e-6, l1 * l2)) continue;
    clean.push(b);
  }
  if (clean.length < 3) return null;
  /* enforce CCW (the source inset assumes it) */
  if (polyArea2(clean) < 0) clean.reverse();
  return clean;
}
function segsIntersect(a, b, c, d) {
  const o = (p, q, r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
  const o1 = o(a, b, c),
    o2 = o(a, b, d),
    o3 = o(c, d, a),
    o4 = o(c, d, b);
  return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;
}
function isSimplePolygon(poly) {
  const n = poly.length;
  for (let i = 0; i < n; i++) {
    const a = poly[i],
      b = poly[(i + 1) % n];
    for (let j = i + 1; j < n; j++) {
      if (j === i || (j + 1) % n === i || j === (i + 1) % n) continue;
      const c = poly[j],
        d = poly[(j + 1) % n];
      if (segsIntersect(a, b, c, d)) return false;
    }
  }
  return true;
}
function goodPoly(poly, minArea) {
  const sp = sanitizePoly(poly);
  if (!sp) return null;
  if (polyArea2(sp) < minArea) return null;
  if (!isSimplePolygon(sp)) return null;
  return sp;
}

const randFly = gstMulberry32(SEED * 31337 + 7);
const randTilt = gstMulberry32(SEED * 9973 + 1);
let cells = [];
let globalMinEdge = Infinity;
for (let ci = 0; ci < interiorCount; ci++) {
  const cellPoly = vor.cellPolygon(ci);
  if (!cellPoly) continue;
  const cell = cellPoly.slice(0, -1);
  /* The piece is the blob ∩ this cell: pieces always tile the whole
           silhouette, so the text is always fully covered — tileCount only
           decides how many pieces share the job. */
  let raw = goodPoly(simplifyPoly(clipToConvex(blobPoly, cell), 0.14), 3e-4);
  if (!raw) {
    /* Degenerate clip. A text-bearing cell must not vanish (that is a
             hole in a letter) — fall back to the unclipped Voronoi cell;
             a non-text sliver is simply dropped. */
    const fallback = goodPoly(cell, 3e-4);
    if (!fallback || !cellTouchesText(fallback)) continue;
    raw = fallback;
  }
  let off = insetPolygon(raw, GAP / 2);
  off = (off && goodPoly(off, 2e-4)) || raw;
  let cellMinEdge = Infinity;
  for (let ei = 0; ei < off.length; ei++) {
    const pa = off[ei],
      pb = off[(ei + 1) % off.length];
    const el = Math.hypot(pb[0] - pa[0], pb[1] - pa[1]);
    if (el > 1e-6 && el < cellMinEdge) cellMinEdge = el;
  }
  let ccx = 0,
    ccy = 0;
  for (const pt of off) {
    ccx += pt[0];
    ccy += pt[1];
  }
  ccx /= off.length;
  ccy /= off.length;
  /* Per-corner clamping happens inside buildPieceGeometry (each corner
           is limited by its own adjacent edges), so the radius is passed
           through — a single short edge no longer disables rounding for the
           whole shard. */
  cells.push({
    cx: ccx,
    cy: ccy,
    radius: CORNER_R,
    local: off.map((pt… [18010 more characters not shown]
build-bundle.mjsShow
/* Build assets/glass-main.js from assets/glass-main.mjs.
   The source uses top-level await (HDR + matcap + font loads), which esbuild
   cannot emit in the classic-IIFE format that HyperFrames sub-compositions
   require, so the body is wrapped in an async IIFE with the imports hoisted
   before bundling. Run `npm install esbuild three` first (three 0.185.1 or
   later — HDRLoader is not in older three.js releases). */
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { execFileSync } from "node:child_process";

const SRC = "assets/glass-main.mjs";
const TMP = "assets/.glass-main.entry.mjs";
const OUT = "assets/glass-main.js";

const src = readFileSync(SRC, "utf8");
const lines = src.split("\n");
const imports = [];
const body = [];
for (const line of lines) {
  if (/^\s*import\s/.test(line)) imports.push(line.trim());
  else body.push(line);
}
writeFileSync(TMP, `${imports.join("\n")}\n(async () => {\n${body.join("\n")}\n})();\n`);
try {
  execFileSync(
    "npx",
    [
      "esbuild",
      TMP,
      "--bundle",
      "--format=iife",
      "--minify",
      `--outfile=${OUT}`,
      "--alias:three/addons=three/examples/jsm",
    ],
    { stdio: "inherit" },
  );
} finally {
  unlinkSync(TMP);
}
console.log("built", OUT);