{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "Lanyard-TS-TW",
	"title": "Lanyard",
	"description": "Swinging 3D badge on a stretchy woven band that you can drag, throw or click to flip.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "Lanyard/Lanyard.tsx",
			"content": "'use client';\n\nimport { useEffect, useRef } from 'react';\nimport type { CSSProperties } from 'react';\nimport * as THREE from 'three';\n\ntype Orientation = 'portrait' | 'landscape';\ntype Finish = 'glossy' | 'matte' | 'holographic' | 'metallic';\ntype Metal = 'graphite' | 'silver' | 'gold';\ntype Anchor = 'left' | 'center' | 'right';\ntype ImageFit = 'cover' | 'contain';\ntype Rgb = [number, number, number];\ntype Point = { clientX: number; clientY: number };\n\ninterface PathLike {\n  moveTo(x: number, y: number): unknown;\n  lineTo(x: number, y: number): unknown;\n  bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): unknown;\n}\n\nexport interface LanyardProps {\n  frontImage?: string;\n  backImage?: string;\n  imageFit?: ImageFit;\n  cardColor?: string;\n  orientation?: Orientation;\n  finish?: Finish;\n  cornerRadius?: number;\n  size?: number;\n  anchor?: Anchor;\n  strapLength?: number;\n  strapImage?: string;\n  strapColor?: string;\n  strapWidth?: number;\n  metal?: Metal;\n  gravity?: number;\n  damping?: number;\n  elasticity?: number;\n  breeze?: number;\n  interactive?: boolean;\n  intro?: boolean;\n  className?: string;\n  style?: CSSProperties;\n}\n\ninterface Settings {\n  frontImage?: string;\n  backImage?: string;\n  imageFit: ImageFit;\n  cardColor: string;\n  orientation: Orientation;\n  finish: Finish;\n  cornerRadius: number;\n  size: number;\n  anchor: Anchor;\n  strapLength: number;\n  strapImage?: string;\n  strapColor: string;\n  strapWidth: number;\n  metal: Metal;\n  gravity: number;\n  damping: number;\n  elasticity: number;\n  breeze: number;\n  interactive: boolean;\n  intro: boolean;\n}\n\ninterface Images {\n  front: HTMLImageElement | null;\n  back: HTMLImageElement | null;\n  strap: HTMLImageElement | null;\n}\n\ninterface CardLayout {\n  width: number;\n  height: number;\n  radius: number;\n  slot: { width: number; height: number; y: number };\n  ringY: number;\n  hangY: number;\n}\n\ninterface FinishLook {\n  roughness: number;\n  metalness: number;\n  clearcoat: number;\n  clearcoatRoughness: number;\n  foil: number;\n  anisotropy: number;\n}\n\ninterface Body {\n  position: THREE.Vector3;\n  previous: THREE.Vector3;\n  velocity: THREE.Vector3;\n  quaternion: THREE.Quaternion;\n  previousQuaternion: THREE.Quaternion;\n  angular: THREE.Vector3;\n  mass: number;\n  inverseInertia: THREE.Vector3;\n  hang: THREE.Vector3;\n}\n\ninterface Grab {\n  local: THREE.Vector3;\n  rotation: THREE.Quaternion;\n  reach: number;\n  from: THREE.Vector3;\n  target: THREE.Vector3;\n}\n\ninterface Simulation {\n  nodes: THREE.Vector3[];\n  previous: THREE.Vector3[];\n  velocities: THREE.Vector3[];\n  lengths: Float64Array;\n  impulses: Float64Array;\n  rest: number;\n  anchor: THREE.Vector3;\n  body: Body;\n  grab: Grab | null;\n  turn: number;\n  twist: number;\n  twistRaw: number;\n  calm: number;\n}\n\ninterface Physics {\n  gravity: number;\n  hold: number;\n  stiffness: number;\n  spring: number;\n  bandDamping: number;\n  linearDrag: number;\n  angularDrag: number;\n  air: number;\n  broadside: number;\n  spinAir: number;\n  nodeDrag: number;\n  nodeAir: number;\n  twist: number;\n  twistDamping: number;\n  grip: number;\n  gripDamping: number;\n  breeze: number;\n}\n\ninterface Applied {\n  layoutKey?: string;\n  framingKey?: string;\n  imageKey?: string;\n  faceKey?: string;\n  strapColor?: string;\n  strapScale?: number;\n}\n\ninterface Press {\n  x: number;\n  y: number;\n  time: number;\n  point: THREE.Vector3;\n}\n\nconst CARD_SIZES: Record<string, [number, number]> = {\n  portrait: [1.6, 2.25],\n  landscape: [2.25, 1.6]\n};\nconst ANCHORS: Record<string, number> = { left: 0.27, center: 0.5, right: 0.73 };\nconst THICKNESS = 0.018;\nconst BEVEL = 0.007;\nconst JOINTS = 4;\nconst NODE_WEIGHT = 1 / 0.05;\nconst MAX_STEP = 1 / 960;\nconst ITERATIONS = 2;\nconst SAMPLES = 72;\nconst FOV = 24;\nconst STRAP_WIDTH = 0.36;\nconst RING_RADIUS = 0.095;\nconst RING_TUBE = 0.0135;\nconst EYELET_RADIUS = 0.036;\nconst CLAMP_BODY = 0.15;\nconst FACE_WIDTH = 1024;\n\nconst FINISHES: Record<string, FinishLook> = {\n  glossy: { roughness: 0.42, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.06, foil: 0, anisotropy: 0 },\n  matte: { roughness: 0.85, metalness: 0, clearcoat: 0, clearcoatRoughness: 0.6, foil: 0, anisotropy: 0 },\n  holographic: { roughness: 0.3, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.04, foil: 1, anisotropy: 0 },\n  metallic: { roughness: 0.36, metalness: 0.75, clearcoat: 0.5, clearcoatRoughness: 0.18, foil: 0, anisotropy: 0.7 }\n};\n\nconst BAND_COLUMNS = [0, 0.07, 0.93, 1];\nconst BAND_BEND = [-0.95, -0.28, 0.28, 0.95];\nconst FLIP_SPIN = 9;\n\nconst FOIL_VERTEX_HEAD = `\nvarying vec2 vFoilUv;\nvarying vec3 vFoilX;\nvarying vec3 vFoilY;\n`;\n\nconst FOIL_VERTEX_BODY = `\nvFoilUv = uv;\nvFoilX = normalize((modelViewMatrix * vec4(1.0, 0.0, 0.0, 0.0)).xyz);\nvFoilY = normalize((modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz);\n`;\n\nconst FOIL_FRAGMENT_HEAD = `\nuniform float foilStrength;\nuniform float foilAspect;\nuniform vec3 foilKey;\nuniform vec3 foilFill;\nuniform vec3 foilTop;\nvarying vec2 vFoilUv;\nvarying vec3 vFoilX;\nvarying vec3 vFoilY;\n\nfloat foilHash(vec2 p) {\n  p = fract(p * vec2(234.34, 435.345));\n  p += dot(p, p + 34.23);\n  return fract(p.x * p.y);\n}\n\nfloat foilBump(float t, float center, float width) {\n  float x = (t - center) / width;\n  return max(0.0, 1.0 - x * x);\n}\n\nvec3 foilSpectrum(float t) {\n  return vec3(\n    foilBump(t, 0.82, 0.32) + 0.4 * foilBump(t, 0.02, 0.12),\n    foilBump(t, 0.5, 0.26),\n    foilBump(t, 0.18, 0.24)\n  );\n}\n\nvec3 foilGrating(float u, float spacing) {\n  vec3 color = vec3(0.0);\n  for (int m = 1; m <= 2; m++) {\n    float order = float(m);\n    float t = (spacing * abs(u) / order - 0.38) / 0.32;\n    color += foilSpectrum(t) / order;\n  }\n  return color;\n}\n\nvec3 foilShard(vec2 p) {\n  vec2 base = floor(p);\n  float best = 9.0;\n  float edge = 9.0;\n  vec2 id = base;\n  for (int j = -1; j <= 1; j++) {\n    for (int i = -1; i <= 1; i++) {\n      vec2 cell = base + vec2(float(i), float(j));\n      vec2 site = cell + vec2(foilHash(cell), foilHash(cell + 7.31)) * 0.9 + 0.05;\n      float d = length(site - p);\n      if (d < best) {\n        edge = best;\n        best = d;\n        id = cell;\n      } else if (d < edge) {\n        edge = d;\n      }\n    }\n  }\n  return vec3(foilHash(id + 3.17), foilHash(id + 11.73), edge - best);\n}\n`;\n\nconst FOIL_FRAGMENT_BODY = `\nif (foilStrength > 0.0) {\n  vec3 foilView = normalize(vViewPosition);\n  vec3 foilNormal = normalize(normal);\n  vec2 foilP = vec2(vFoilUv.x, vFoilUv.y * foilAspect);\n  vec3 shard = foilShard(foilP * 26.0);\n  vec2 ray = foilP - vec2(-1.6, 2.6 * foilAspect);\n  float angle = atan(ray.y, ray.x) + (shard.x - 0.5) * 0.16;\n  vec3 grating = normalize(vFoilX * cos(angle) + vFoilY * sin(angle));\n  float spacing = 1.7 * (0.95 + shard.y * 0.1);\n  vec3 rainbow = foilGrating(dot(foilKey + foilView, grating), spacing);\n  rainbow += 0.6 * foilGrating(dot(foilTop + foilView, grating), spacing);\n  rainbow *= 0.8 + 0.2 * smoothstep(0.0, 0.04, shard.z);\n  rainbow = max(rainbow - 0.35 * min(rainbow.r, min(rainbow.g, rainbow.b)), 0.0);\n  vec2 grid = foilP * 64.0;\n  vec2 cell = floor(grid);\n  vec2 jitter = vec2(foilHash(cell + 1.7), foilHash(cell + 9.2));\n  float flake = smoothstep(0.32, 0.06, length(fract(grid) - jitter * 0.6 - 0.2)) * step(0.45, foilHash(cell + 4.4));\n  vec3 facet = normalize(foilNormal + (vFoilX * (jitter.x - 0.5) + vFoilY * (jitter.y - 0.5)) * 0.8);\n  float glint = pow(max(dot(reflect(-foilKey, facet), foilView), 0.0), 28.0);\n  glint += 0.7 * pow(max(dot(reflect(-foilTop, facet), foilView), 0.0), 28.0);\n  float fine = 1.0 - smoothstep(0.35, 0.9, max(fwidth(grid.x), fwidth(grid.y)));\n  vec3 sparkle = flake * glint * fine * (vec3(0.7) + foilGrating(dot(foilKey + foilView, grating) + jitter.x * 0.4, spacing));\n  float ink = dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722));\n  float foil = foilStrength * (0.08 + 0.92 * smoothstep(0.04, 0.7, ink));\n  float facing = clamp(dot(foilNormal, foilView), 0.0, 1.0);\n  float tint = clamp(dot(rainbow, vec3(0.4)), 0.0, 1.0);\n  outgoingLight = outgoingLight * (1.0 - foil * (0.3 + 0.32 * tint)) + (rainbow * 0.62 * (0.4 + 0.6 * facing) + sparkle * 1.6) * foil;\n}\n`;\n\nconst METALS: Record<string, { color: string; roughness: number }> = {\n  silver: { color: '#d9dce2', roughness: 0.16 },\n  gold: { color: '#e4b965', roughness: 0.2 },\n  graphite: { color: '#4d4f55', roughness: 0.28 }\n};\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst mix = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst parseColor = (value: string, fallback: Rgb): Rgb => {\n  try {\n    const ctx = document.createElement('canvas').getContext('2d');\n    if (!ctx) return fallback;\n    ctx.fillStyle = '#000000';\n    ctx.fillStyle = value;\n    const resolved = ctx.fillStyle;\n    if (resolved.startsWith('#')) {\n      const n = parseInt(resolved.slice(1), 16);\n      return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n    }\n    const parts = resolved.match(/[\\d.]+/g);\n    if (!parts || parts.length < 3) return fallback;\n    return [Number(parts[0]) / 255, Number(parts[1]) / 255, Number(parts[2]) / 255];\n  } catch {\n    return fallback;\n  }\n};\n\nconst luminance = (rgb: Rgb) => 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];\nconst toCss = (rgb: Rgb, alpha = 1) =>\n  `rgba(${Math.round(rgb[0] * 255)}, ${Math.round(rgb[1] * 255)}, ${Math.round(rgb[2] * 255)}, ${alpha})`;\n\nconst seeded = (start: number) => {\n  let state = start >>> 0;\n  return () => {\n    state = (state + 0x6d2b79f5) >>> 0;\n    let t = state;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n};\n\nconst traceRoundedRect = (path: PathLike, x: number, y: number, width: number, height: number, radius: number) => {\n  const r = Math.max(0, Math.min(radius, width / 2, height / 2));\n  const k = r * 0.4477;\n  path.moveTo(x + r, y);\n  path.lineTo(x + width - r, y);\n  path.bezierCurveTo(x + width - k, y, x + width, y + k, x + width, y + r);\n  path.lineTo(x + width, y + height - r);\n  path.bezierCurveTo(x + width, y + height - k, x + width - k, y + height, x + width - r, y + height);\n  path.lineTo(x + r, y + height);\n  path.bezierCurveTo(x + k, y + height, x, y + height - k, x, y + height - r);\n  path.lineTo(x, y + r);\n  path.bezierCurveTo(x, y + k, x + k, y, x + r, y);\n};\n\nconst loadImage = (\n  cache: Map<string, Promise<HTMLImageElement | null>>,\n  url?: string\n): Promise<HTMLImageElement | null> => {\n  if (!url) return Promise.resolve(null);\n  const cached = cache.get(url);\n  if (cached) return cached;\n  const promise = new Promise<HTMLImageElement | null>(resolve => {\n    const image = new Image();\n    image.crossOrigin = 'anonymous';\n    image.decoding = 'async';\n    image.onload = () => resolve(image);\n    image.onerror = () => resolve(null);\n    image.src = url;\n  });\n  cache.set(url, promise);\n  return promise;\n};\n\nconst cardLayout = (orientation: Orientation, cornerRadius: number): CardLayout => {\n  const [width, height] = CARD_SIZES[orientation] || CARD_SIZES.portrait;\n  const radius = mix(0.03, Math.min(width, height) * 0.2, clamp(cornerRadius, 0, 1));\n  const slot = { width: 0.34, height: 0.07, y: height / 2 - 0.13 };\n  const ringY = height / 2 - 0.055;\n  return { width, height, radius, slot, ringY, hangY: ringY + RING_RADIUS };\n};\n\nconst buildCardGeometry = (layout: CardLayout) => {\n  const { width, height, radius, slot } = layout;\n  const outline = new THREE.Shape();\n  traceRoundedRect(\n    outline,\n    -width / 2 + BEVEL,\n    -height / 2 + BEVEL,\n    width - BEVEL * 2,\n    height - BEVEL * 2,\n    Math.max(radius - BEVEL, 0.005)\n  );\n  const hole = new THREE.Path();\n  traceRoundedRect(\n    hole,\n    -slot.width / 2 - BEVEL,\n    slot.y - slot.height / 2 - BEVEL,\n    slot.width + BEVEL * 2,\n    slot.height + BEVEL * 2,\n    slot.height / 2 + BEVEL\n  );\n  outline.holes.push(hole);\n  const body = new THREE.ExtrudeGeometry(outline, {\n    depth: THICKNESS,\n    bevelEnabled: true,\n    bevelThickness: BEVEL,\n    bevelSize: BEVEL,\n    bevelSegments: 5,\n    curveSegments: 24\n  });\n  body.translate(0, 0, -THICKNESS / 2);\n  const face = new THREE.ShapeGeometry(outline, 24);\n  const position = face.getAttribute('position');\n  const uv = face.getAttribute('uv');\n  for (let i = 0; i < position.count; i++) {\n    uv.setXY(i, (position.getX(i) + width / 2) / width, (position.getY(i) + height / 2) / height);\n  }\n  uv.needsUpdate = true;\n  return { body, face };\n};\n\nconst buildClampGeometry = (width: number) => {\n  const shape = new THREE.Shape();\n  const w = width * 1.16;\n  traceRoundedRect(shape, -w / 2 + 0.014, -CLAMP_BODY / 2 + 0.014, w - 0.028, CLAMP_BODY - 0.028, 0.024);\n  const geometry = new THREE.ExtrudeGeometry(shape, {\n    depth: 0.02,\n    bevelEnabled: true,\n    bevelThickness: 0.014,\n    bevelSize: 0.014,\n    bevelSegments: 6,\n    curveSegments: 10\n  });\n  geometry.translate(0, 0, -0.01);\n  return geometry;\n};\n\nconst buildBandGeometry = (count: number) => {\n  const columns = BAND_COLUMNS.length;\n  const geometry = new THREE.BufferGeometry();\n  geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(count * columns * 3), 3));\n  geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(count * columns * 3), 3));\n  geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(count * columns * 2), 2));\n  const index: number[] = [];\n  for (let i = 0; i < count - 1; i++) {\n    for (let c = 0; c < columns - 1; c++) {\n      const a = i * columns + c;\n      index.push(a, a + columns, a + 1, a + 1, a + columns, a + columns + 1);\n    }\n  }\n  geometry.setIndex(index);\n  return geometry;\n};\n\nconst buildEnvironment = (renderer: THREE.WebGLRenderer) => {\n  const scene = new THREE.Scene();\n  scene.background = new THREE.Color(0.035, 0.035, 0.04);\n  const geometry = new THREE.PlaneGeometry(1, 1);\n  const materials: THREE.Material[] = [];\n  const panel = (intensity: number, position: [number, number, number], scale: [number, number], rotation = 0) => {\n    const material = new THREE.MeshBasicMaterial({\n      color: new THREE.Color(intensity, intensity, intensity),\n      side: THREE.DoubleSide\n    });\n    materials.push(material);\n    const mesh = new THREE.Mesh(geometry, material);\n    mesh.position.set(...position);\n    mesh.scale.set(scale[0], scale[1], 1);\n    mesh.lookAt(0, 0, 0);\n    mesh.rotateZ(rotation);\n    scene.add(mesh);\n  };\n  panel(2.6, [0, 4.5, 8], [9, 4]);\n  panel(6, [-5.5, 0.5, 7], [0.9, 14]);\n  panel(4, [5, -0.5, 8], [0.6, 14]);\n  panel(3.5, [0, -1, 9], [24, 0.35], Math.PI / 3);\n  panel(2.5, [1, 2.5, 9], [24, 0.2], Math.PI / 3);\n  panel(1.4, [0, 6, -6], [12, 4]);\n  panel(0.5, [0, -7, 1], [16, 6]);\n  panel(0.9, [8, 0, -2], [4, 10]);\n  const pmrem = new THREE.PMREMGenerator(renderer);\n  const target = pmrem.fromScene(scene, 0.035);\n  pmrem.dispose();\n  geometry.dispose();\n  materials.forEach(material => material.dispose());\n  return target;\n};\n\nconst makeCanvasTexture = (canvas: HTMLCanvasElement, anisotropy: number) => {\n  const texture = new THREE.CanvasTexture(canvas);\n  texture.colorSpace = THREE.SRGBColorSpace;\n  texture.anisotropy = anisotropy;\n  return texture;\n};\n\nconst buildGrainTexture = () => {\n  const size = 128;\n  const canvas = document.createElement('canvas');\n  canvas.width = size;\n  canvas.height = size;\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return new THREE.CanvasTexture(canvas);\n  const random = seeded(7);\n  const field = Float32Array.from({ length: size * size }, () => random());\n  const height = (x: number, y: number) => {\n    let sum = 0;\n    for (let j = -1; j <= 1; j++) {\n      for (let i = -1; i <= 1; i++) sum += field[((y + j + size) % size) * size + ((x + i + size) % size)];\n    }\n    return (sum / 9) * 1.6;\n  };\n  const image = ctx.createImageData(size, size);\n  for (let y = 0; y < size; y++) {\n    for (let x = 0; x < size; x++) {\n      const nx = height((x - 1 + size) % size, y) - height((x + 1) % size, y);\n      const ny = height(x, (y - 1 + size) % size) - height(x, (y + 1) % size);\n      const length = Math.hypot(nx, ny, 1);\n      const i = (y * size + x) * 4;\n      image.data[i] = ((nx / length) * 0.5 + 0.5) * 255;\n      image.data[i + 1] = ((ny / length) * 0.5 + 0.5) * 255;\n      image.data[i + 2] = ((1 / length) * 0.5 + 0.5) * 255;\n      image.data[i + 3] = 255;\n    }\n  }\n  ctx.putImageData(image, 0, 0);\n  const texture = new THREE.CanvasTexture(canvas);\n  texture.wrapS = THREE.RepeatWrapping;\n  texture.wrapT = THREE.RepeatWrapping;\n  texture.repeat.set(5, 7);\n  return texture;\n};\n\nconst buildWeaveTexture = () => {\n  const size = 128;\n  const threads = 8;\n  const random = seeded(19);\n  const fiber = Float32Array.from({ length: size * size }, () => random());\n  const profile = (f: number) => Math.sqrt(Math.max(0, 1 - 4 * f * f));\n  const height = (x: number, y: number) => {\n    const px = ((x % size) + size) % size;\n    const py = ((y % size) + size) % size;\n    const u = (px / size) * threads;\n    const v = (py / size) * threads;\n    const cu = Math.floor(u);\n    const cv = Math.floor(v);\n    const fu = u - cu - 0.5;\n    const fv = v - cv - 0.5;\n    const lift = (cu + cv) % 2 === 0;\n    const warp = profile(fu) * (0.55 + 0.45 * Math.cos(fv * Math.PI * (lift ? 1 : 0.6)));\n    const weft = profile(fv) * (0.55 + 0.45 * Math.cos(fu * Math.PI * (lift ? 0.6 : 1)));\n    return (lift ? Math.max(warp, weft * 0.7) : Math.max(weft, warp * 0.7)) + fiber[py * size + px] * 0.08;\n  };\n  const data = new Uint8Array(size * size * 4);\n  for (let y = 0; y < size; y++) {\n    for (let x = 0; x < size; x++) {\n      const nx = (height(x - 1, y) - height(x + 1, y)) * 1.6;\n      const ny = (height(x, y - 1) - height(x, y + 1)) * 1.6;\n      const length = Math.hypot(nx, ny, 1);\n      const i = (y * size + x) * 4;\n      data[i] = ((nx / length) * 0.5 + 0.5) * 255;\n      data[i + 1] = ((ny / length) * 0.5 + 0.5) * 255;\n      data[i + 2] = ((1 / length) * 0.5 + 0.5) * 255;\n      data[i + 3] = 255;\n    }\n  }\n  const texture = new THREE.DataTexture(data, size, size);\n  texture.wrapS = THREE.RepeatWrapping;\n  texture.wrapT = THREE.RepeatWrapping;\n  texture.magFilter = THREE.LinearFilter;\n  texture.minFilter = THREE.LinearMipmapLinearFilter;\n  texture.generateMipmaps = true;\n  texture.needsUpdate = true;\n  return texture;\n};\n\nconst drawFitted = (\n  ctx: CanvasRenderingContext2D,\n  image: HTMLImageElement,\n  width: number,\n  height: number,\n  fit: ImageFit\n) => {\n  const scale = (fit === 'contain' ? Math.min : Math.max)(width / image.width, height / image.height);\n  const w = image.width * scale;\n  const h = image.height * scale;\n  ctx.drawImage(image, (width - w) / 2, (height - h) / 2, w, h);\n};\n\nconst paintFace = (canvas: HTMLCanvasElement, image: HTMLImageElement | null, color: Rgb, fit: ImageFit) => {\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return;\n  ctx.fillStyle = toCss(color);\n  ctx.fillRect(0, 0, canvas.width, canvas.height);\n  if (image) drawFitted(ctx, image, canvas.width, canvas.height, fit);\n};\n\nconst paintStrap = (canvas: HTMLCanvasElement, image: HTMLImageElement | null, color: Rgb) => {\n  const across = 192;\n  const along = image ? Math.max(64, Math.round((image.width / image.height) * across)) : across * 2;\n  canvas.width = across;\n  canvas.height = along;\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return along / across;\n  ctx.fillStyle = toCss(color);\n  ctx.fillRect(0, 0, across, along);\n  if (image) {\n    ctx.save();\n    ctx.translate(across, 0);\n    ctx.rotate(Math.PI / 2);\n    ctx.drawImage(image, 0, 0, along, across);\n    ctx.restore();\n    return along / across;\n  }\n  const ink: Rgb = luminance(color) > 0.5 ? [0, 0, 0] : [1, 1, 1];\n  ctx.fillStyle = toCss(ink, 0.05);\n  for (let y = 0; y < along; y += 4) ctx.fillRect(0, y, across, 1);\n  ctx.fillStyle = toCss(ink, 0.2);\n  for (let y = 0; y < along; y += 14) {\n    ctx.fillRect(across * 0.075, y, 2, 8);\n    ctx.fillRect(across * 0.925 - 2, y, 2, 8);\n  }\n  return along / across;\n};\n\nconst createSimulation = (): Simulation => {\n  const vectors = () => Array.from({ length: JOINTS + 1 }, () => new THREE.Vector3());\n  return {\n    nodes: vectors(),\n    previous: vectors(),\n    velocities: vectors(),\n    lengths: new Float64Array(JOINTS),\n    impulses: new Float64Array(JOINTS),\n    rest: 1,\n    anchor: new THREE.Vector3(),\n    body: {\n      position: new THREE.Vector3(),\n      previous: new THREE.Vector3(),\n      velocity: new THREE.Vector3(),\n      quaternion: new THREE.Quaternion(),\n      previousQuaternion: new THREE.Quaternion(),\n      angular: new THREE.Vector3(),\n      mass: 1,\n      inverseInertia: new THREE.Vector3(1, 1, 1),\n      hang: new THREE.Vector3()\n    },\n    grab: null,\n    turn: 0,\n    twist: 0,\n    twistRaw: 0,\n    calm: 0\n  };\n};\n\nconst scratch = {\n  a: new THREE.Vector3(),\n  b: new THREE.Vector3(),\n  c: new THREE.Vector3(),\n  d: new THREE.Vector3(),\n  e: new THREE.Vector3(),\n  f: new THREE.Vector3(),\n  g: new THREE.Vector3(),\n  pin: new THREE.Vector3(),\n  q: new THREE.Quaternion(),\n  r: new THREE.Quaternion()\n};\n\nconst applyInverseInertia = (body: Body, vector: THREE.Vector3, out: THREE.Vector3) => {\n  scratch.r.copy(body.quaternion).invert();\n  out.copy(vector).applyQuaternion(scratch.r);\n  out.set(out.x * body.inverseInertia.x, out.y * body.inverseInertia.y, out.z * body.inverseInertia.z);\n  return out.applyQuaternion(body.quaternion);\n};\n\nconst rotateBody = (q: THREE.Quaternion, w: THREE.Vector3) => {\n  const { x: qx, y: qy, z: qz, w: qw } = q;\n  q.x += 0.5 * (w.x * qw + w.y * qz - w.z * qy);\n  q.y += 0.5 * (w.y * qw + w.z * qx - w.x * qz);\n  q.z += 0.5 * (w.z * qw + w.x * qy - w.y * qx);\n  q.w += 0.5 * (-w.x * qx - w.y * qy - w.z * qz);\n  q.normalize();\n};\n\nconst bodyWeight = (body: Body, r: THREE.Vector3, normal: THREE.Vector3) => {\n  const rn = scratch.c.crossVectors(r, normal);\n  return 1 / body.mass + rn.dot(applyInverseInertia(body, rn, scratch.d));\n};\n\nconst pushBody = (body: Body, r: THREE.Vector3, impulse: THREE.Vector3) => {\n  body.position.addScaledVector(impulse, 1 / body.mass);\n  rotateBody(body.quaternion, applyInverseInertia(body, scratch.e.crossVectors(r, impulse), scratch.f));\n};\n\nconst solveSegment = (sim: Simulation, i: number, h: number, physics: Physics) => {\n  const { nodes, body } = sim;\n  const a = nodes[i];\n  const wa = i === 0 ? 0 : NODE_WEIGHT;\n  const last = i === JOINTS - 1;\n  const r = scratch.g;\n  let b = nodes[i + 1];\n  if (last) {\n    r.copy(body.hang).applyQuaternion(body.quaternion);\n    b = scratch.b.copy(body.position).add(r);\n  }\n  const delta = scratch.a.subVectors(b, a);\n  const length = delta.length();\n  const stretch = length - sim.rest;\n  if (stretch <= 0 || length < 1e-9) return;\n  const normal = delta.divideScalar(length);\n  const wb = last ? bodyWeight(body, r, normal) : NODE_WEIGHT;\n  const rate = (length - sim.lengths[i]) / h;\n  const tension =\n    physics.hold * Math.tanh((physics.stiffness * stretch) / physics.hold) +\n    physics.spring * stretch +\n    physics.bandDamping * Math.max(0, rate);\n  const room = tension * h * h - sim.impulses[i];\n  if (room <= 0) return;\n  const lambda = Math.min(stretch / (wa + wb), room);\n  sim.impulses[i] += lambda;\n  if (wa) a.addScaledVector(normal, lambda * wa);\n  if (last) pushBody(body, r, normal.multiplyScalar(-lambda));\n  else b.addScaledVector(normal, -lambda * wb);\n};\n\nconst solvePin = (body: Body, grab: Grab, target: THREE.Vector3) => {\n  const r = scratch.g.copy(grab.local).applyQuaternion(body.quaternion);\n  const delta = scratch.a.copy(target).sub(body.position).sub(r);\n  const distance = delta.length();\n  if (distance < 1e-9) return;\n  const normal = delta.divideScalar(distance);\n  const lambda = distance / bodyWeight(body, r, normal);\n  pushBody(body, r, normal.multiplyScalar(lambda));\n};\n\nconst placeHanging = (sim: Simulation, layout: CardLayout, intro: boolean) => {\n  const { nodes, previous, velocities, body, anchor } = sim;\n  nodes[0].copy(anchor);\n  for (let i = 1; i <= JOINTS; i++) {\n    nodes[i].copy(nodes[i - 1]);\n    if (intro) nodes[i].x += sim.rest;\n    else nodes[i].y -= sim.rest;\n  }\n  body.quaternion.identity();\n  if (intro) body.quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2);\n  body.hang.set(0, layout.hangY, 0);\n  const offset = scratch.a.copy(body.hang).applyQuaternion(body.quaternion);\n  body.position.copy(nodes[JOINTS]).sub(offset);\n  body.previous.copy(body.position);\n  body.previousQuaternion.copy(body.quaternion);\n  body.velocity.set(0, 0, 0);\n  body.angular.set(0, 0, 0);\n  for (let i = 0; i <= JOINTS; i++) {\n    previous[i].copy(nodes[i]);\n    velocities[i].set(0, 0, 0);\n  }\n  sim.lengths.fill(sim.rest);\n  sim.turn = 0;\n  sim.twist = 0;\n  sim.twistRaw = 0;\n  sim.calm = 0;\n};\n\nconst configureSimulation = (sim: Simulation, layout: CardLayout, length: number) => {\n  sim.rest = length / JOINTS;\n  const { width, height } = layout;\n  const body = sim.body;\n  const t = THICKNESS + BEVEL * 2;\n  body.mass = 1;\n  body.inverseInertia.set(\n    12 / (height * height + t * t),\n    12 / (width * width + t * t),\n    12 / (width * width + height * height)\n  );\n  body.hang.set(0, layout.hangY, 0);\n};\n\nconst stepSimulation = (sim: Simulation, dt: number, physics: Physics, time: number) => {\n  const steps = Math.max(1, Math.ceil(dt / MAX_STEP - 1e-6));\n  const h = dt / steps;\n  const { nodes, previous, velocities, body } = sim;\n  const windX = physics.breeze * (Math.sin(time * 0.53) * 0.7 + Math.sin(time * 1.31 + 1.7) * 0.3) * 3;\n  const windZ = physics.breeze * Math.sin(time * 0.37 + 0.6) * 2;\n  const grab = sim.grab;\n  const pin = scratch.pin;\n  const nodeDrag = Math.exp(-physics.nodeDrag * h);\n  const linearDrag = Math.exp(-physics.linearDrag * h);\n  const angularDrag = Math.exp(-physics.angularDrag * h);\n\n  for (let s = 0; s < steps; s++) {\n    for (let i = 1; i < JOINTS; i++) {\n      const v = velocities[i];\n      v.y -= physics.gravity * h;\n      v.x += windX * h;\n      v.z += windZ * h;\n      previous[i].copy(nodes[i]);\n      nodes[i].addScaledVector(v, h);\n    }\n\n    body.previous.copy(body.position);\n    body.previousQuaternion.copy(body.quaternion);\n    const q = body.quaternion;\n    if (grab) {\n      const turn = scratch.q.copy(grab.rotation).multiply(scratch.r.copy(q).invert());\n      if (turn.w < 0) turn.set(-turn.x, -turn.y, -turn.z, -turn.w);\n      const torque = scratch.a.set(turn.x, turn.y, turn.z).multiplyScalar(2 * physics.grip);\n      torque.addScaledVector(body.angular, -physics.gripDamping);\n      body.angular.addScaledVector(applyInverseInertia(body, torque, scratch.b), h);\n    }\n    const up = scratch.a.set(0, 1, 0).applyQuaternion(q);\n    const front = scratch.b.set(-up.x * up.z, -up.y * up.z, 1 - up.z * up.z);\n    if (front.lengthSq() > 1e-3) {\n      const facing = scratch.d.set(0, 0, 1).applyQuaternion(q);\n      const raw = Math.atan2(scratch.e.crossVectors(front, facing).dot(up), front.dot(facing));\n      let turned = raw - sim.twistRaw;\n      if (turned > Math.PI) turned -= Math.PI * 2;\n      else if (turned < -Math.PI) turned += Math.PI * 2;\n      sim.twist += turned;\n      sim.twistRaw = raw;\n    }\n    if (!grab) {\n      const spin = body.angular.dot(up);\n      body.angular.addScaledVector(up, (physics.twist * (sim.turn - sim.twist) - physics.twistDamping * spin) * h);\n    }\n    body.velocity.y -= physics.gravity * h;\n    body.velocity.x += windX * 0.35 * h;\n    body.velocity.z += windZ * 0.35 * h;\n    body.position.addScaledVector(body.velocity, h);\n    rotateBody(body.quaternion, scratch.f.copy(body.angular).multiplyScalar(h));\n\n    sim.impulses.fill(0);\n    if (grab) pin.lerpVectors(grab.from, grab.target, (s + 1) / steps);\n    for (let iteration = 0; iteration < ITERATIONS; iteration++) {\n      for (let i = 0; i < JOINTS; i++) solveSegment(sim, i, h, physics);\n      if (grab) solvePin(body, grab, pin);\n    }\n\n    for (let i = 1; i < JOINTS; i++) {\n      const v = velocities[i].subVectors(nodes[i], previous[i]).divideScalar(h).multiplyScalar(nodeDrag);\n      v.multiplyScalar(1 / (1 + physics.nodeAir * v.length() * h));\n    }\n    body.velocity.subVectors(body.position, body.previous).divideScalar(h);\n    const dq = scratch.q.copy(body.previousQuaternion).invert().premultiply(body.quaternion);\n    body.angular.set(dq.x, dq.y, dq.z).multiplyScalar((2 / h) * (dq.w < 0 ? -1 : 1));\n\n    const normal = scratch.a.set(0, 0, 1).applyQuaternion(body.quaternion);\n    const across = body.velocity.dot(normal);\n    const along = scratch.b.copy(body.velocity).addScaledVector(normal, -across);\n    along.multiplyScalar(1 / (1 + physics.air * along.length() * h));\n    body.velocity\n      .copy(along)\n      .addScaledVector(normal, across / (1 + physics.broadside * Math.abs(across) * h))\n      .multiplyScalar(linearDrag);\n    body.angular.multiplyScalar(angularDrag / (1 + physics.spinAir * body.angular.length() * h));\n    if (body.velocity.lengthSq() > 3600) body.velocity.setLength(60);\n    if (body.angular.lengthSq() > 1600) body.angular.setLength(40);\n\n    nodes[JOINTS].copy(body.hang).applyQuaternion(body.quaternion).add(body.position);\n    for (let i = 0; i < JOINTS; i++) sim.lengths[i] = nodes[i].distanceTo(nodes[i + 1]);\n  }\n\n  if (grab) grab.from.copy(grab.target);\n\n  let energy = body.velocity.lengthSq() + body.angular.lengthSq() * 0.3;\n  for (let i = 1; i < JOINTS; i++) energy += velocities[i].lengthSq() * 0.05;\n  sim.calm = energy < 2e-4 ? sim.calm + dt : 0;\n};\n\nconst Lanyard = ({\n  frontImage,\n  backImage,\n  imageFit = 'cover',\n  cardColor = '#ffffff',\n  orientation = 'portrait',\n  finish = 'glossy',\n  cornerRadius = 0.3,\n  size = 0.6,\n  anchor = 'center',\n  strapLength = 0.5,\n  strapImage,\n  strapColor = '#111111',\n  strapWidth = 0.65,\n  metal = 'silver',\n  gravity = 1,\n  damping = 0.5,\n  elasticity = 0.5,\n  breeze = 0.5,\n  interactive = true,\n  intro = true,\n  className = '',\n  style\n}: LanyardProps) => {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const settingsRef = useRef<Settings | null>(null);\n  const applyRef = useRef<(() => void) | null>(null);\n\n  settingsRef.current = {\n    frontImage,\n    backImage,\n    imageFit: imageFit === 'contain' ? 'contain' : 'cover',\n    cardColor,\n    orientation: orientation === 'landscape' ? 'landscape' : 'portrait',\n    finish,\n    cornerRadius,\n    size,\n    anchor,\n    strapLength,\n    strapImage,\n    strapColor,\n    strapWidth,\n    metal,\n    gravity,\n    damping,\n    elasticity,\n    breeze,\n    interactive,\n    intro\n  };\n\n  useEffect(() => {\n    applyRef.current?.();\n  });\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return undefined;\n\n    let renderer: THREE.WebGLRenderer;\n    try {\n      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });\n    } catch {\n      return undefined;\n    }\n    renderer.setClearColor(0x000000, 0);\n    renderer.outputColorSpace = THREE.SRGBColorSpace;\n    renderer.toneMapping = THREE.NeutralToneMapping;\n    renderer.toneMappingExposure = 1;\n    const canvas = renderer.domElement;\n    canvas.className = 'block w-full h-full';\n    canvas.setAttribute('aria-hidden', 'true');\n    container.appendChild(canvas);\n\n    const anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());\n    const scene = new THREE.Scene();\n    const environment = buildEnvironment(renderer);\n    scene.environment = environment.texture;\n    const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 200);\n    const keyLight = new THREE.DirectionalLight(0xffffff, 1.6);\n    keyLight.position.set(-2.5, 4, 6);\n    const fillLight = new THREE.DirectionalLight(0xffffff, 0.35);\n    fillLight.position.set(3, -1, 4);\n    scene.add(keyLight, fillLight);\n\n    const imageCache = new Map<string, Promise<HTMLImageElement | null>>();\n    const images: Images = { front: null, back: null, strap: null };\n    const frontCanvas = document.createElement('canvas');\n    const backCanvas = document.createElement('canvas');\n    const strapCanvas = document.createElement('canvas');\n    const frontTexture = makeCanvasTexture(frontCanvas, anisotropy);\n    const backTexture = makeCanvasTexture(backCanvas, anisotropy);\n    const strapTexture = makeCanvasTexture(strapCanvas, anisotropy);\n    strapTexture.wrapS = THREE.ClampToEdgeWrapping;\n    strapTexture.wrapT = THREE.RepeatWrapping;\n    const grain = buildGrainTexture();\n    const weave = buildWeaveTexture();\n    const foilUniforms = {\n      foilStrength: { value: 0 },\n      foilAspect: { value: 1.4 },\n      foilKey: { value: new THREE.Vector3() },\n      foilFill: { value: new THREE.Vector3() },\n      foilTop: { value: new THREE.Vector3() }\n    };\n    const injectFoil = (shader: THREE.WebGLProgramParametersWithUniforms) => {\n      Object.assign(shader.uniforms, foilUniforms);\n      shader.vertexShader = shader.vertexShader\n        .replace('#include <common>', `#include <common>\\n${FOIL_VERTEX_HEAD}`)\n        .replace('#include <project_vertex>', `#include <project_vertex>\\n${FOIL_VERTEX_BODY}`);\n      shader.fragmentShader = shader.fragmentShader\n        .replace('#include <common>', `#include <common>\\n${FOIL_FRAGMENT_HEAD}`)\n        .replace('#include <opaque_fragment>', `${FOIL_FRAGMENT_BODY}\\n#include <opaque_fragment>`);\n    };\n\n    const frontMaterial = new THREE.MeshPhysicalMaterial({\n      map: frontTexture,\n      normalMap: grain,\n      normalScale: new THREE.Vector2(0.06, 0.06)\n    });\n    const backMaterial = new THREE.MeshPhysicalMaterial({\n      map: backTexture,\n      normalMap: grain,\n      normalScale: new THREE.Vector2(0.06, 0.06)\n    });\n    frontMaterial.onBeforeCompile = injectFoil;\n    backMaterial.onBeforeCompile = injectFoil;\n    const edgeMaterial = new THREE.MeshPhysicalMaterial();\n    const metalMaterial = new THREE.MeshStandardMaterial({ metalness: 1 });\n    const bandMaterial = new THREE.MeshPhysicalMaterial({\n      map: strapTexture,\n      normalMap: weave,\n      normalScale: new THREE.Vector2(0.7, 0.7),\n      roughness: 0.68,\n      sheen: 1,\n      sheenRoughness: 0.42,\n      sheenColor: new THREE.Color(0.32, 0.32, 0.34),\n      side: THREE.DoubleSide\n    });\n\n    const cardGroup = new THREE.Group();\n    const bodyMesh = new THREE.Mesh(undefined, edgeMaterial);\n    const frontMesh = new THREE.Mesh(undefined, frontMaterial);\n    const backMesh = new THREE.Mesh(undefined, backMaterial);\n    const ring = new THREE.Mesh(new THREE.TorusGeometry(RING_RADIUS, RING_TUBE, 18, 72), metalMaterial);\n    ring.rotation.y = Math.PI / 2 - 0.7;\n    cardGroup.add(bodyMesh, frontMesh, backMesh, ring);\n    scene.add(cardGroup);\n\n    const bandGeometry = buildBandGeometry(SAMPLES);\n    const band = new THREE.Mesh(bandGeometry, bandMaterial);\n    band.frustumCulled = false;\n    scene.add(band);\n\n    const clampGroup = new THREE.Group();\n    const clampMesh = new THREE.Mesh(buildClampGeometry(STRAP_WIDTH), metalMaterial);\n    clampMesh.position.y = EYELET_RADIUS + CLAMP_BODY / 2 - 0.004;\n    const eyelet = new THREE.Mesh(new THREE.TorusGeometry(EYELET_RADIUS, 0.0105, 14, 40), metalMaterial);\n    clampGroup.add(clampMesh, eyelet);\n    scene.add(clampGroup);\n\n    const sim = createSimulation();\n    const controls: THREE.Vector3[] = [];\n    const mouth = new THREE.Vector3();\n    const tuck = new THREE.Vector3();\n    const curve = new THREE.CatmullRomCurve3(controls, false, 'centripetal');\n    const view = { width: 1, height: 1 };\n    let layout = cardLayout('portrait', 0.3);\n    let applied: Applied = {};\n    let strapScale = STRAP_WIDTH;\n    let tileRatio = 2;\n    let raf = 0;\n    let last = performance.now();\n    let time = 0;\n    let visible = true;\n    let alive = true;\n    let placed = false;\n    let hovering = false;\n    let press: Press | null = null;\n    let imageToken = 0;\n    const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n\n    const paintFaces = () => {\n      const s = settingsRef.current!;\n      const color = parseColor(s.cardColor, [1, 1, 1]);\n      const width = FACE_WIDTH;\n      const height = Math.round((FACE_WIDTH * layout.height) / layout.width);\n      if (frontCanvas.width !== width || frontCanvas.height !== height) {\n        frontCanvas.width = width;\n        frontCanvas.height = height;\n        backCanvas.width = width;\n        backCanvas.height = height;\n        frontTexture.dispose();\n        backTexture.dispose();\n      }\n      paintFace(frontCanvas, images.front, color, s.imageFit);\n      paintFace(backCanvas, images.back || images.front, color, s.imageFit);\n      frontTexture.needsUpdate = true;\n      backTexture.needsUpdate = true;\n      start();\n    };\n\n    const repaintStrap = () => {\n      const s = settingsRef.current!;\n      tileRatio = paintStrap(strapCanvas, images.strap, parseColor(s.strapColor, [0.07, 0.07, 0.07]));\n      weave.repeat.set(2, 2 * tileRatio);\n      strapTexture.dispose();\n      strapTexture.needsUpdate = true;\n      start();\n    };\n\n    const frameView = () => {\n      const s = settingsRef.current!;\n      const aspect = view.width / view.height;\n      let viewHeight = layout.height / clamp(s.size, 0.15, 0.9);\n      const minimumWidth = layout.width / 0.72;\n      if (viewHeight * aspect < minimumWidth) viewHeight = minimumWidth / aspect;\n      const viewWidth = viewHeight * aspect;\n      camera.aspect = aspect;\n      camera.position.set(0, 0, viewHeight / 2 / Math.tan((FOV * Math.PI) / 360));\n      camera.lookAt(0, 0, 0);\n      camera.updateProjectionMatrix();\n      camera.updateMatrixWorld();\n      foilUniforms.foilKey.value.set(-2.5, 4, 6).normalize().transformDirection(camera.matrixWorldInverse);\n      foilUniforms.foilFill.value.set(3, -1, 4).normalize().transformDirection(camera.matrixWorldInverse);\n      foilUniforms.foilTop.value.set(0.5, 4.5, 8).normalize().transformDirection(camera.matrixWorldInverse);\n      const anchorX = ((ANCHORS[s.anchor] ?? 0.5) - 0.5) * viewWidth;\n      const anchorY = viewHeight / 2 + 0.2;\n      const cardTop = viewHeight / 2 - viewHeight * mix(0.12, 0.42, clamp(s.strapLength, 0, 1));\n      const hangTop = cardTop + (layout.hangY - layout.height / 2);\n      sim.anchor.set(anchorX, anchorY, 0);\n      configureSimulation(sim, layout, anchorY - hangTop);\n      sim.nodes[0].copy(sim.anchor);\n      sim.previous[0].copy(sim.anchor);\n    };\n\n    const apply = () => {\n      const s = settingsRef.current!;\n      const layoutKey = `${s.orientation}|${s.cornerRadius}`;\n      if (layoutKey !== applied.layoutKey) {\n        layout = cardLayout(s.orientation, s.cornerRadius);\n        const geometry = buildCardGeometry(layout);\n        bodyMesh.geometry.dispose();\n        frontMesh.geometry.dispose();\n        backMesh.geometry.dispose();\n        bodyMesh.geometry = geometry.body;\n        frontMesh.geometry = geometry.face;\n        backMesh.geometry = geometry.face.clone();\n        frontMesh.position.z = THICKNESS / 2 + BEVEL + 0.0006;\n        backMesh.rotation.y = Math.PI;\n        backMesh.position.z = -(THICKNESS / 2 + BEVEL + 0.0006);\n        ring.position.set(0, layout.ringY, 0);\n        applied.framingKey = '';\n      }\n      const framingKey = `${layoutKey}|${s.size}|${s.anchor}|${s.strapLength}|${view.width}|${view.height}`;\n      if (framingKey !== applied.framingKey) {\n        frameView();\n        if (!placed) {\n          placeHanging(sim, layout, s.intro && !reducedMotion);\n          placed = true;\n        } else if (layoutKey !== applied.layoutKey) {\n          placeHanging(sim, layout, false);\n        }\n      }\n\n      const imageKey = `${s.frontImage}|${s.backImage}|${s.strapImage}`;\n      if (imageKey !== applied.imageKey) {\n        const token = ++imageToken;\n        Promise.all([\n          loadImage(imageCache, s.frontImage),\n          loadImage(imageCache, s.backImage),\n          loadImage(imageCache, s.strapImage)\n        ]).then(([front, back, strap]) => {\n          if (!alive || token !== imageToken) return;\n          images.front = front;\n          images.back = back;\n          images.strap = strap;\n          paintFaces();\n          repaintStrap();\n        });\n      }\n      const faceKey = `${layoutKey}|${s.cardColor}|${s.imageFit}`;\n      if (faceKey !== applied.faceKey) paintFaces();\n      if (s.strapColor !== applied.strapColor) repaintStrap();\n\n      const finish = FINISHES[s.finish] || FINISHES.glossy;\n      const card = parseColor(s.cardColor, [1, 1, 1]);\n      [frontMaterial, backMaterial].forEach(material => {\n        material.roughness = finish.roughness;\n        material.metalness = finish.metalness;\n        material.clearcoat = finish.clearcoat;\n        material.clearcoatRoughness = finish.clearcoatRoughness;\n        material.anisotropy = finish.anisotropy;\n      });\n      foilUniforms.foilStrength.value = finish.foil;\n      foilUniforms.foilAspect.value = layout.height / layout.width;\n      edgeMaterial.color.setRGB(card[0] * 0.92, card[1] * 0.92, card[2] * 0.92, THREE.SRGBColorSpace);\n      edgeMaterial.roughness = Math.min(finish.roughness, 0.35);\n      edgeMaterial.metalness = finish.metalness;\n      edgeMaterial.clearcoat = 1;\n      edgeMaterial.clearcoatRoughness = 0.08;\n      const metalLook = METALS[s.metal] || METALS.silver;\n      metalMaterial.color.set(metalLook.color);\n      metalMaterial.roughness = metalLook.roughness;\n      strapScale = STRAP_WIDTH * clamp(s.strapWidth, 0.4, 2);\n      if (strapScale !== applied.strapScale) {\n        clampMesh.geometry.dispose();\n        clampMesh.geometry = buildClampGeometry(strapScale);\n      }\n      canvas.style.touchAction = s.interactive ? 'pan-y' : 'auto';\n\n      applied = { layoutKey, framingKey, imageKey, faceKey, strapColor: s.strapColor, strapScale };\n      start();\n    };\n\n    const points = Array.from({ length: SAMPLES }, () => new THREE.Vector3());\n    const sides = Array.from({ length: SAMPLES }, () => new THREE.Vector3());\n    const bends = BAND_BEND.map((angle): [number, number] => [Math.cos(angle), Math.sin(angle)]);\n    const work = {\n      tangent: new THREE.Vector3(),\n      toCamera: new THREE.Vector3(),\n      basis: new THREE.Matrix4(),\n      up: new THREE.Vector3(),\n      across: new THREE.Vector3(),\n      facing: new THREE.Vector3(),\n      end: new THREE.Vector3()\n    };\n\n    const updateBand = () => {\n      const hang = sim.nodes[JOINTS];\n      const reach = EYELET_RADIUS + CLAMP_BODY + 0.05;\n      let guide = JOINTS - 1;\n      while (guide > 0 && sim.nodes[guide].distanceTo(hang) < reach * 1.1) guide--;\n      const axis = work.up.subVectors(sim.nodes[guide], hang);\n      if (axis.lengthSq() < 1e-8) axis.set(0, 1, 0).applyQuaternion(sim.body.quaternion);\n      axis.normalize();\n      mouth.copy(hang).addScaledVector(axis, reach);\n      tuck.copy(hang).addScaledVector(axis, EYELET_RADIUS + CLAMP_BODY * 0.35);\n      controls.length = 0;\n      for (let i = 0; i <= guide; i++) controls.push(sim.nodes[i]);\n      controls.push(mouth, tuck);\n      curve.updateArcLengths();\n      for (let i = 0; i < SAMPLES; i++) curve.getPointAt(i / (SAMPLES - 1), points[i]);\n      const length = curve.getLength();\n      const held = 1 - mouth.distanceTo(tuck) / Math.max(length, 0.001);\n      const position = bandGeometry.getAttribute('position');\n      const normal = bandGeometry.getAttribute('normal');\n      const uv = bandGeometry.getAttribute('uv');\n      const columns = BAND_COLUMNS.length;\n      const restLength = sim.rest * JOINTS;\n      const repeats = restLength / (strapScale * tileRatio);\n      const width = strapScale / Math.pow(Math.max(1, length / Math.max(restLength, 0.001)), 0.35);\n      const { tangent, toCamera, across, facing } = work;\n      for (let i = 0; i < SAMPLES; i++) {\n        tangent.subVectors(points[Math.min(SAMPLES - 1, i + 1)], points[Math.max(0, i - 1)]);\n        if (tangent.lengthSq() < 1e-12) tangent.set(0, -1, 0);\n        tangent.normalize();\n        toCamera.subVectors(camera.position, points[i]).normalize();\n        const side = sides[i].crossVectors(toCamera, tangent);\n        if (side.lengthSq() < 1e-10) side.copy(i > 0 ? sides[i - 1] : scratch.a.set(1, 0, 0));\n        side.normalize();\n        if (i > 0 && side.dot(sides[i - 1]) < 0) side.negate();\n        const angle = -sim.twist * Math.pow(Math.min(1, i / (SAMPLES - 1) / held), 1.3);\n        facing.crossVectors(tangent, side);\n        across.copy(side).multiplyScalar(Math.cos(angle)).addScaledVector(facing, Math.sin(angle));\n        facing.crossVectors(tangent, across);\n        const p = points[i];\n        const v = (1 - i / (SAMPLES - 1)) * repeats;\n        for (let c = 0; c < columns; c++) {\n          const k = i * columns + c;\n          const offset = (BAND_COLUMNS[c] - 0.5) * width;\n          const [bendCos, bendSin] = bends[c];\n          position.setXYZ(k, p.x + across.x * offset, p.y + across.y * offset, p.z + across.z * offset);\n          normal.setXYZ(\n            k,\n            facing.x * bendCos + across.x * bendSin,\n            facing.y * bendCos + across.y * bendSin,\n            facing.z * bendCos + across.z * bendSin\n          );\n          uv.setXY(k, BAND_COLUMNS[c], v);\n        }\n      }\n      work.end.copy(across);\n      position.needsUpdate = true;\n      normal.needsUpdate = true;\n      uv.needsUpdate = true;\n\n      across.copy(work.end).addScaledVector(axis, -work.end.dot(axis)).normalize();\n      facing.crossVectors(across, axis).normalize();\n      work.basis.makeBasis(across, axis, facing);\n      clampGroup.quaternion.setFromRotationMatrix(work.basis);\n      clampGroup.position.copy(hang);\n    };\n\n    const render = () => {\n      cardGroup.position.copy(sim.body.position);\n      cardGroup.quaternion.copy(sim.body.quaternion);\n      updateBand();\n      renderer.render(scene, camera);\n    };\n\n    const physics = (): Physics => {\n      const s = settingsRef.current!;\n      const gravity = 40 * clamp(s.gravity, 0, 3);\n      const weight = Math.max(gravity, 20);\n      const springy = clamp(s.elasticity, 0, 1);\n      const settle = clamp(s.damping, 0, 1);\n      return {\n        gravity,\n        hold: weight * mix(2.2, 5.5, springy),\n        stiffness: weight * 10 * JOINTS,\n        spring: weight * mix(0.1, 0.4, springy) * JOINTS,\n        bandDamping: weight * mix(0.3, 0.08, springy) * JOINTS,\n        linearDrag: 0.25 * Math.pow(16, settle),\n        angularDrag: 0.5 * Math.pow(12, settle),\n        air: 0.15,\n        broadside: 0.5,\n        spinAir: 0.03,\n        nodeDrag: 2,\n        nodeAir: 0.2,\n        twist: 30,\n        twistDamping: 3,\n        grip: 40,\n        gripDamping: 6,\n        breeze: reducedMotion ? 0 : clamp(s.breeze, 0, 1)\n      };\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!alive) return;\n      const dt = Math.min(1 / 30, Math.max(1 / 240, (now - last) / 1000));\n      last = now;\n      time += dt;\n      const current = physics();\n      stepSimulation(sim, dt, current, time);\n      const body = sim.body;\n      if (!Number.isFinite(body.position.x + body.position.y + body.position.z + body.quaternion.w)) {\n        sim.grab = null;\n        placeHanging(sim, layout, false);\n      }\n      render();\n      const resting = sim.calm > 1.2 && !sim.grab && current.breeze === 0;\n      if (visible && !resting) raf = requestAnimationFrame(tick);\n    };\n\n    const start = () => {\n      if (raf || !visible || !alive) return;\n      sim.calm = 0;\n      last = performance.now();\n      raf = requestAnimationFrame(tick);\n    };\n\n    const raycaster = new THREE.Raycaster();\n    const pointer = new THREE.Vector2();\n\n    const toPointer = (event: Point) => {\n      const rect = canvas.getBoundingClientRect();\n      pointer.set(\n        ((event.clientX - rect.left) / rect.width) * 2 - 1,\n        -((event.clientY - rect.top) / rect.height) * 2 + 1\n      );\n      raycaster.setFromCamera(pointer, camera);\n    };\n\n    const pickCard = () => {\n      cardGroup.updateMatrixWorld();\n      const hits = raycaster.intersectObjects([bodyMesh, frontMesh, backMesh], false);\n      return hits[0] || null;\n    };\n\n    const setCursor = (value: string) => {\n      if (canvas.style.cursor !== value) canvas.style.cursor = value;\n    };\n\n    const flip = (point: THREE.Vector3) => {\n      const body = sim.body;\n      const up = scratch.a.set(0, 1, 0).applyQuaternion(body.quaternion);\n      let direction = sim.turn > 0 ? -1 : 1;\n      if (sim.turn === 0) {\n        const away = scratch.c.subVectors(point, camera.position).normalize();\n        direction = scratch.b.subVectors(point, body.position).cross(away).dot(up) < 0 ? -1 : 1;\n      }\n      sim.turn = sim.turn === 0 ? Math.PI * direction : 0;\n      body.angular.addScaledVector(up, FLIP_SPIN * direction);\n    };\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (!settingsRef.current!.interactive || event.button > 0) return;\n      toPointer(event);\n      const hit = pickCard();\n      if (!hit) return;\n      sim.grab = {\n        local: hit.point.clone().sub(sim.body.position).applyQuaternion(sim.body.quaternion.clone().invert()),\n        rotation: sim.body.quaternion.clone(),\n        reach: hit.distance,\n        from: hit.point.clone(),\n        target: hit.point.clone()\n      };\n      press = { x: event.clientX, y: event.clientY, time: performance.now(), point: hit.point.clone() };\n      canvas.setPointerCapture?.(event.pointerId);\n      setCursor('grabbing');\n      event.preventDefault();\n      start();\n    };\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (!settingsRef.current!.interactive) return;\n      toPointer(event);\n      if (sim.grab) {\n        if (press && Math.hypot(event.clientX - press.x, event.clientY - press.y) > 6) press = null;\n        raycaster.ray.at(sim.grab.reach, sim.grab.target);\n        start();\n        return;\n      }\n      if (event.pointerType === 'touch') return;\n      hovering = !!pickCard();\n      setCursor(hovering ? 'grab' : '');\n    };\n\n    const onPointerUp = (event: PointerEvent) => {\n      if (!sim.grab) return;\n      sim.grab = null;\n      if (press && event.type === 'pointerup' && performance.now() - press.time < 450) flip(press.point);\n      press = null;\n      canvas.releasePointerCapture?.(event.pointerId);\n      setCursor(hovering && event.pointerType !== 'touch' ? 'grab' : '');\n      start();\n    };\n\n    const onTouchStart = (event: TouchEvent) => {\n      if (!settingsRef.current!.interactive || event.touches.length !== 1) return;\n      toPointer(event.touches[0]);\n      if (pickCard()) event.preventDefault();\n    };\n\n    canvas.addEventListener('pointerdown', onPointerDown);\n    canvas.addEventListener('pointermove', onPointerMove);\n    canvas.addEventListener('pointerup', onPointerUp);\n    canvas.addEventListener('pointercancel', onPointerUp);\n    canvas.addEventListener('lostpointercapture', onPointerUp);\n    canvas.addEventListener('touchstart', onTouchStart, { passive: false });\n\n    const resize = () => {\n      view.width = Math.max(1, container.clientWidth);\n      view.height = Math.max(1, container.clientHeight);\n      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));\n      renderer.setSize(view.width, view.height, false);\n      apply();\n      render();\n    };\n\n    const resizeObserver = new ResizeObserver(resize);\n    resizeObserver.observe(container);\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) start();\n    });\n    intersectionObserver.observe(container);\n    const onVisibility = () => {\n      if (!document.hidden) start();\n    };\n    document.addEventListener('visibilitychange', onVisibility);\n\n    applyRef.current = apply;\n    resize();\n\n    return () => {\n      alive = false;\n      cancelAnimationFrame(raf);\n      applyRef.current = null;\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      document.removeEventListener('visibilitychange', onVisibility);\n      canvas.removeEventListener('pointerdown', onPointerDown);\n      canvas.removeEventListener('pointermove', onPointerMove);\n      canvas.removeEventListener('pointerup', onPointerUp);\n      canvas.removeEventListener('pointercancel', onPointerUp);\n      canvas.removeEventListener('lostpointercapture', onPointerUp);\n      canvas.removeEventListener('touchstart', onTouchStart);\n      [bodyMesh, frontMesh, backMesh, ring, clampMesh, eyelet, band].forEach(mesh => mesh.geometry?.dispose());\n      [frontMaterial, backMaterial, edgeMaterial, metalMaterial, bandMaterial].forEach(material => material.dispose());\n      [frontTexture, backTexture, strapTexture, grain, weave].forEach(texture => texture.dispose());\n      environment.dispose();\n      renderer.dispose();\n      if (canvas.parentNode) canvas.parentNode.removeChild(canvas);\n    };\n  }, []);\n\n  return (\n    <div ref={containerRef} className={`relative w-full h-full overflow-hidden ${className}`.trim()} style={style} />\n  );\n};\n\nexport default Lanyard;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"three@^0.180.0"
	],
	"devDependencies": [
		"@types/three@^0.180.0"
	]
}