{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["cards-grids","skeuomorphism"],"dependencies":["three"],"description":"An endless strip of paper unrolls behind a heavy roll, printing a portfolio in its wake","devDependencies":["@types/three"],"files":[{"content":"\"use client\";\n\n// Ported from https://github.com/thebuggeddev/paper-roll (VX — The Printing Roll).\n// An endless strip of paper unrolls behind a heavy roll, printing portfolio\n// pages in its wake. One texture atlas, one ribbon mesh, fixed vertex budget,\n// zero per-frame allocations. Everything procedural, nothing external.\n\nimport { useEffect, useRef } from \"react\";\nimport * as THREE from \"three\";\n\n// paper width\nconst RIBBON_W = 1.5;\n// pages in the atlas\nconst ATLAS_N = 8;\nconst ROLL_R = 1.75;\n// one full revolution lays down exactly one atlas — this is what makes the\n// print on the barrel hand off to the floor with zero drift, forever\nconst CARD_LEN = (2 * Math.PI * ROLL_R) / ATLAS_N;\nconst INNER_R = ROLL_R * 0.52;\n// path sampling distance\nconst STEP = 0.12;\n// fixed history budget (~91 world units of trail)\nconst MAX_PTS = 760;\n// segments peeling off the roll\nconst CURL_SEG = 16;\nconst MAX_SEG = MAX_PTS + CURL_SEG + 2;\nconst FLOOR_RGB = \"vec3(0.905, 0.905, 0.912)\";\n\nexport const PaperRoll = () => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const container = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!container || !canvas) {\n      return;\n    }\n\n    let seed = 7;\n    const rand = () => {\n      seed = (seed * 16_807) % 2_147_483_647;\n      return (seed - 1) / 2_147_483_646;\n    };\n\n    // ---------- Renderer / scene ----------\n    const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));\n    renderer.setSize(container.clientWidth, container.clientHeight);\n    renderer.shadowMap.enabled = true;\n    renderer.shadowMap.type = THREE.PCFShadowMap;\n\n    const scene = new THREE.Scene();\n    scene.background = new THREE.Color(0xea_ea_ec);\n    scene.fog = new THREE.Fog(0xea_ea_ec, 24, 58);\n\n    const camera = new THREE.PerspectiveCamera(\n      32,\n      container.clientWidth / container.clientHeight,\n      0.5,\n      200,\n    );\n\n    // ---------- Lighting ----------\n    const hemi = new THREE.HemisphereLight(0xff_ff_ff, 0xd6_d6_da, 0.95);\n    scene.add(hemi);\n\n    const sun = new THREE.DirectionalLight(0xff_ff_ff, 0.85);\n    sun.castShadow = true;\n    sun.shadow.mapSize.set(2048, 2048);\n    sun.shadow.camera.left = -9;\n    sun.shadow.camera.right = 9;\n    sun.shadow.camera.top = 9;\n    sun.shadow.camera.bottom = -9;\n    sun.shadow.camera.near = 1;\n    sun.shadow.camera.far = 40;\n    sun.shadow.bias = -0.0004;\n    sun.shadow.normalBias = 0.02;\n    scene.add(sun);\n    scene.add(sun.target);\n\n    const fill = new THREE.DirectionalLight(0xff_ff_ff, 0.22);\n    fill.position.set(-6, 4, -8);\n    scene.add(fill);\n\n    // ---------- Floor ----------\n    const floorGeo = new THREE.PlaneGeometry(400, 400);\n    const floorMat = new THREE.MeshStandardMaterial({\n      color: 0xe7_e7_ea,\n      metalness: 0,\n      roughness: 1,\n    });\n    const floor = new THREE.Mesh(floorGeo, floorMat);\n    floor.rotation.x = -Math.PI / 2;\n    floor.receiveShadow = true;\n    scene.add(floor);\n\n    // ============================================================\n    // Procedural texture atlas — 8 portfolio pages, one canvas\n    // ============================================================\n    const buildAtlas = () => {\n      const CELL = 512;\n      const cv = document.createElement(\"canvas\");\n      cv.width = CELL * ATLAS_N;\n      cv.height = CELL;\n      const g = cv.getContext(\"2d\");\n      if (!g) {\n        throw new Error(\"PaperRoll requires a 2D canvas context\");\n      }\n\n      const INK = \"#161616\";\n      const PAPER = \"#fbfaf7\";\n      const ORANGE = \"#e4551f\";\n      const AMBER = \"#f0a32e\";\n      const BLUE = \"#1e4fd6\";\n\n      // paper strip base\n      g.fillStyle = \"#f6f5f1\";\n      g.fillRect(0, 0, cv.width, cv.height);\n      // faint fibre grain\n      for (let i = 0; i < 2600; i += 1) {\n        g.fillStyle = `rgba(120,116,105,${0.015 + rand() * 0.03})`;\n        g.fillRect(rand() * cv.width, rand() * cv.height, 1 + rand() * 2, 1);\n      }\n\n      const bars = (x: number, y: number, w: number, n: number, lh: number, color?: string) => {\n        g.fillStyle = color || \"#c9c8c2\";\n        for (let r = 0; r < n; r += 1) {\n          const bw = w * (0.55 + rand() * 0.45);\n          g.fillRect(x, y + r * lh, bw, Math.max(2, lh * 0.42));\n        }\n      };\n      const label = (x: number, y: number, txt: string, color?: string, size?: number) => {\n        g.fillStyle = color || INK;\n        g.font = `700 ${size || 13}px -apple-system, \"Segoe UI\", Helvetica, Arial, sans-serif`;\n        g.fillText(txt, x, y);\n      };\n      const mono = (x: number, y: number, txt: string, color?: string, size?: number) => {\n        g.fillStyle = color || \"#8a8a86\";\n        g.font = `600 ${size || 11}px \"SF Mono\", Menlo, Consolas, monospace`;\n        g.fillText(txt, x, y);\n      };\n      // fake photo: layered gradient blocks\n      const photo = (x: number, y: number, w: number, h: number, tint: [string, string]) => {\n        const gr = g.createLinearGradient(x, y, x + w, y + h);\n        gr.addColorStop(0, tint[0]);\n        gr.addColorStop(1, tint[1]);\n        g.fillStyle = gr;\n        g.fillRect(x, y, w, h);\n        for (let b = 0; b < 9; b += 1) {\n          const bw = w * (0.08 + rand() * 0.3);\n          const bh = h * (0.1 + rand() * 0.5);\n          const bx = x + rand() * (w - bw);\n          const by = y + h - bh - rand() * h * 0.25;\n          g.fillStyle = `rgba(${rand() < 0.5 ? \"20,20,22\" : \"245,244,240\"},${0.12 + rand() * 0.3})`;\n          g.fillRect(bx, by, bw, bh);\n        }\n        const hl = g.createLinearGradient(x, y, x, y + h * 0.5);\n        hl.addColorStop(0, \"rgba(255,255,255,0.28)\");\n        hl.addColorStop(1, \"rgba(255,255,255,0)\");\n        g.fillStyle = hl;\n        g.fillRect(x, y, w, h * 0.5);\n      };\n\n      // card inset inside cell\n      const M = 30;\n\n      interface Frame {\n        x: number;\n        y: number;\n        w: number;\n        h: number;\n      }\n\n      const cardFrame = (cx: number): Frame => {\n        const x = cx + M;\n        const y = M;\n        const w = CELL - M * 2;\n        const h = CELL - M * 2;\n        g.save();\n        g.shadowColor = \"rgba(30,30,30,0.10)\";\n        g.shadowBlur = 14;\n        g.shadowOffsetY = 3;\n        g.fillStyle = PAPER;\n        g.fillRect(x, y, w, h);\n        g.restore();\n        g.strokeStyle = \"rgba(20,20,20,0.08)\";\n        g.lineWidth = 1;\n        g.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);\n        return { h, w, x, y };\n      };\n      const indexTag = (f: Frame, n: number) => {\n        g.save();\n        g.translate(f.x + f.w - 12, f.y + f.h - 14);\n        g.rotate(-Math.PI / 2);\n        mono(0, 0, `0${n}/008`, \"#9a9994\", 11);\n        g.restore();\n      };\n\n      const draws: ((f: Frame) => void)[] = [\n        // 01 — orange field, dark disc, editorial header\n        (f) => {\n          label(f.x + 22, f.y + 36, \"KYH PRESS\", INK, 15);\n          mono(f.x + f.w - 96, f.y + 34, \"A/W 26\");\n          g.fillStyle = ORANGE;\n          g.fillRect(f.x + 22, f.y + 54, f.w - 44, f.h - 150);\n          g.fillStyle = \"rgba(20,16,12,0.85)\";\n          g.beginPath();\n          g.arc(f.x + f.w / 2, f.y + 54 + (f.h - 150) / 2, (f.h - 150) * 0.31, 0, Math.PI * 2);\n          g.fill();\n          g.fillStyle = \"rgba(255,255,255,0.9)\";\n          g.fillRect(f.x + 44, f.y + 54 + (f.h - 150) * 0.68, f.w - 200, 5);\n          bars(f.x + 22, f.y + f.h - 78, f.w * 0.5, 3, 15);\n          label(f.x + f.w - 130, f.y + f.h - 30, \"ATELIER 041\", \"#8a8a86\", 11);\n        },\n        // 02 — black and white architecture photo\n        (f) => {\n          photo(f.x + 20, f.y + 20, f.w - 40, f.h * 0.62, [\"#3a3a3e\", \"#0e0e10\"]);\n          label(f.x + 22, f.y + f.h * 0.62 + 52, \"CONCRETE INDEX\", INK, 17);\n          bars(f.x + 22, f.y + f.h * 0.62 + 70, f.w - 60, 3, 15);\n          mono(f.x + 22, f.y + f.h - 26, \"PP. 112 — 139\");\n        },\n        // 03 — typographic cover\n        (f) => {\n          g.fillStyle = INK;\n          g.font = '800 italic 128px -apple-system, \"Segoe UI\", Helvetica, Arial, sans-serif';\n          g.fillText(\"KYH\", f.x + 26, f.y + 148);\n          g.fillStyle = ORANGE;\n          g.fillRect(f.x + 30, f.y + 176, 66, 10);\n          bars(f.x + 30, f.y + 214, f.w - 90, 7, 22, \"#b9b8b2\");\n          label(f.x + 30, f.y + f.h - 34, \"MANIFESTO — TEN RULES OF PRINT\", \"#5c5c60\", 12);\n        },\n        // 04 — grid of six studies\n        (f) => {\n          label(f.x + 22, f.y + 36, \"STUDIES\", INK, 14);\n          mono(f.x + f.w - 108, f.y + 34, \"GRID 3×2\");\n          const tints: [string, string][] = [\n            [\"#c9c9ce\", \"#8f8f96\"],\n            [AMBER, \"#c77e14\"],\n            [\"#2c2c30\", \"#141416\"],\n            [\"#dcd9d2\", \"#b0aca2\"],\n            [BLUE, \"#123089\"],\n            [\"#7d7d84\", \"#4c4c52\"],\n          ];\n          const gw = (f.w - 44 - 24) / 3;\n          const gh = (f.h - 170) / 2;\n          for (const [i, tint] of tints.entries()) {\n            const gx = f.x + 22 + (i % 3) * (gw + 12);\n            const gy = f.y + 56 + Math.floor(i / 3) * (gh + 12);\n            photo(gx, gy, gw, gh, tint);\n          }\n          bars(f.x + 22, f.y + f.h - 84, f.w * 0.6, 3, 15);\n        },\n        // 05 — amber arcs\n        (f) => {\n          g.fillStyle = AMBER;\n          g.fillRect(f.x + 20, f.y + 20, f.w - 40, f.h - 40);\n          g.strokeStyle = \"rgba(251,250,247,0.9)\";\n          g.lineWidth = 7;\n          for (let a = 0; a < 5; a += 1) {\n            g.beginPath();\n            g.arc(f.x + f.w * 0.5, f.y + f.h * 0.86, 46 + a * 34, Math.PI, Math.PI * 2);\n            g.stroke();\n          }\n          g.fillStyle = \"rgba(22,18,10,0.9)\";\n          g.fillRect(f.x + 42, f.y + 46, 88, 12);\n          label(f.x + 42, f.y + 84, \"SOUND / SHAPE\", \"#161616\", 15);\n          mono(f.x + 42, f.y + f.h - 44, \"INSTALLATION — HALL B\", \"rgba(22,18,10,0.75)\");\n        },\n        // 06 — device mock over blue band\n        (f) => {\n          g.fillStyle = BLUE;\n          g.fillRect(f.x + 20, f.y + f.h * 0.58, f.w - 40, f.h * 0.42 - 20);\n          label(f.x + 24, f.y + 42, \"INTERFACE No.9\", INK, 15);\n          bars(f.x + 24, f.y + 58, f.w * 0.55, 2, 14);\n          // device\n          const dw = f.w * 0.44;\n          const dh = f.h * 0.52;\n          const dx = f.x + f.w / 2 - dw / 2;\n          const dy = f.y + f.h * 0.3;\n          g.save();\n          g.shadowColor = \"rgba(10,20,60,0.35)\";\n          g.shadowBlur = 18;\n          g.shadowOffsetY = 8;\n          g.fillStyle = \"#101014\";\n          g.fillRect(dx, dy, dw, dh);\n          g.restore();\n          g.fillStyle = \"#fbfaf7\";\n          g.fillRect(dx + 8, dy + 8, dw - 16, dh - 16);\n          photo(dx + 8, dy + 8, dw - 16, (dh - 16) * 0.5, [\"#9aa8d8\", \"#5468b8\"]);\n          bars(dx + 16, dy + 12 + (dh - 16) * 0.5 + 8, dw - 44, 4, 12);\n          mono(f.x + 26, f.y + f.h - 32, \"CASE — LEDGER APP\", \"rgba(251,250,247,0.85)\");\n        },\n        // 07 — quiet white, small centered plate\n        (f) => {\n          const pw = f.w * 0.42;\n          const ph = f.h * 0.4;\n          photo(f.x + (f.w - pw) / 2, f.y + 72, pw, ph, [\"#d9d6cd\", \"#a8a49a\"]);\n          g.fillStyle = ORANGE;\n          g.beginPath();\n          g.arc(f.x + (f.w + pw) / 2 - 8, f.y + 72 + 8, 9, 0, Math.PI * 2);\n          g.fill();\n          label(f.x + f.w / 2 - 62, f.y + 96 + ph + 26, \"STILL LIFE, 04\", INK, 14);\n          bars(f.x + f.w / 2 - 84, f.y + 96 + ph + 44, 168, 3, 14);\n          mono(f.x + f.w / 2 - 46, f.y + f.h - 30, \"EDITION 2026\");\n        },\n        // 08 — dark closing plate\n        (f) => {\n          g.fillStyle = \"#141416\";\n          g.fillRect(f.x + 20, f.y + 20, f.w - 40, f.h - 40);\n          g.strokeStyle = \"rgba(251,250,247,0.35)\";\n          g.lineWidth = 1;\n          for (let l = 0; l < 6; l += 1) {\n            g.beginPath();\n            g.moveTo(f.x + 44, f.y + 70 + l * 26);\n            g.lineTo(f.x + f.w - 44, f.y + 70 + l * 26);\n            g.stroke();\n          }\n          g.fillStyle = ORANGE;\n          g.beginPath();\n          g.arc(f.x + f.w - 78, f.y + 96, 16, 0, Math.PI * 2);\n          g.fill();\n          g.fillStyle = \"#fbfaf7\";\n          g.font = '800 italic 44px -apple-system, \"Segoe UI\", Helvetica, Arial, sans-serif';\n          g.fillText(\"END / LOOP\", f.x + 44, f.y + f.h - 96);\n          mono(f.x + 44, f.y + f.h - 52, \"THE ROLL CONTINUES\", \"rgba(251,250,247,0.6)\");\n        },\n      ];\n\n      if (draws.length !== ATLAS_N) {\n        throw new Error(\"Paper atlas page count does not match its layout\");\n      }\n      for (const [c, draw] of draws.entries()) {\n        const frame = cardFrame(c * CELL);\n        draw(frame);\n        indexTag(frame, c + 1);\n      }\n\n      const tex = new THREE.CanvasTexture(cv);\n      tex.wrapS = THREE.RepeatWrapping;\n      tex.wrapT = THREE.ClampToEdgeWrapping;\n      tex.colorSpace = THREE.SRGBColorSpace;\n      tex.anisotropy = renderer.capabilities.getMaxAnisotropy();\n      return tex;\n    };\n\n    // Spiral cap: hundreds of wound paper layers, drawn once\n    const buildCapTexture = () => {\n      const S = 1024;\n      const cv = document.createElement(\"canvas\");\n      cv.width = S;\n      cv.height = S;\n      const g = cv.getContext(\"2d\");\n      if (!g) {\n        throw new Error(\"PaperRoll requires a 2D canvas context\");\n      }\n      const cx = S / 2;\n      const innerPx = (INNER_R / ROLL_R) * (S / 2);\n\n      g.fillStyle = \"#f2f1ec\";\n      g.fillRect(0, 0, S, S);\n\n      for (let r = innerPx; r < S / 2 - 1; r += 2.1) {\n        const a = 0.045 + rand() * 0.1 + (r % 29 < 2.2 ? 0.12 : 0);\n        g.strokeStyle = `rgba(112,110,102,${a.toFixed(3)})`;\n        g.lineWidth = rand() < 0.12 ? 1.6 : 0.8;\n        g.beginPath();\n        g.arc(cx, cx, r, 0, Math.PI * 2);\n        g.stroke();\n      }\n      // spiral cut line\n      g.strokeStyle = \"rgba(90,88,80,0.35)\";\n      g.lineWidth = 1.4;\n      g.beginPath();\n      const turns = 26;\n      for (let t = 0; t <= 1; t += 0.002) {\n        const rr = innerPx + t * (S / 2 - innerPx - 2);\n        const an = t * turns * Math.PI * 2;\n        const px = cx + Math.cos(an) * rr;\n        const py = cx + Math.sin(an) * rr;\n        if (t === 0) {\n          g.moveTo(px, py);\n        } else {\n          g.lineTo(px, py);\n        }\n      }\n      g.stroke();\n      // inner shading\n      const sh = g.createRadialGradient(cx, cx, innerPx, cx, cx, innerPx + 90);\n      sh.addColorStop(0, \"rgba(60,58,52,0.32)\");\n      sh.addColorStop(1, \"rgba(60,58,52,0)\");\n      g.fillStyle = sh;\n      g.beginPath();\n      g.arc(cx, cx, S / 2, 0, Math.PI * 2);\n      g.fill();\n      // outer rim\n      const rim = g.createRadialGradient(cx, cx, S / 2 - 26, cx, cx, S / 2);\n      rim.addColorStop(0, \"rgba(60,58,52,0)\");\n      rim.addColorStop(1, \"rgba(60,58,52,0.22)\");\n      g.fillStyle = rim;\n      g.beginPath();\n      g.arc(cx, cx, S / 2, 0, Math.PI * 2);\n      g.fill();\n\n      const tex = new THREE.CanvasTexture(cv);\n      tex.colorSpace = THREE.SRGBColorSpace;\n      tex.anisotropy = renderer.capabilities.getMaxAnisotropy();\n      return tex;\n    };\n\n    // Blob shadow under the roll\n    const buildBlobTexture = () => {\n      const S = 256;\n      const cv = document.createElement(\"canvas\");\n      cv.width = S;\n      cv.height = S;\n      const g = cv.getContext(\"2d\");\n      if (!g) {\n        throw new Error(\"PaperRoll requires a 2D canvas context\");\n      }\n      const gr = g.createRadialGradient(S / 2, S / 2, 6, S / 2, S / 2, S / 2);\n      gr.addColorStop(0, \"rgba(20,20,22,0.34)\");\n      gr.addColorStop(0.55, \"rgba(20,20,22,0.14)\");\n      gr.addColorStop(1, \"rgba(20,20,22,0)\");\n      g.fillStyle = gr;\n      g.fillRect(0, 0, S, S);\n      return new THREE.CanvasTexture(cv);\n    };\n\n    const atlasTex = buildAtlas();\n    const capTex = buildCapTexture();\n    const blobTex = buildBlobTexture();\n\n    // ============================================================\n    // The roll\n    // ============================================================\n    // yaw + position\n    const rollGroup = new THREE.Group();\n    // rolls about local X\n    const spinner = new THREE.Group();\n    rollGroup.add(spinner);\n    scene.add(rollGroup);\n\n    // The barrel wears the same atlas. With circumference == atlas length the\n    // texture is glued to the mesh (repeat 1, constant 0.25 phase) and the card\n    // touching the floor is always the exact card the ribbon prints there.\n    const barrelTex = atlasTex.clone();\n    barrelTex.needsUpdate = true;\n    barrelTex.wrapS = THREE.RepeatWrapping;\n    barrelTex.wrapT = THREE.ClampToEdgeWrapping;\n    barrelTex.repeat.set(1, 1);\n    barrelTex.offset.x = 0.25;\n    const paperMat = new THREE.MeshStandardMaterial({\n      map: barrelTex,\n      metalness: 0,\n      roughness: 0.92,\n    });\n\n    const barrelGeo = new THREE.CylinderGeometry(ROLL_R, ROLL_R, RIBBON_W, 96, 1, true);\n    barrelGeo.rotateZ(Math.PI / 2);\n    const barrel = new THREE.Mesh(barrelGeo, paperMat);\n    barrel.castShadow = true;\n    spinner.add(barrel);\n\n    const capMat = new THREE.MeshStandardMaterial({\n      map: capTex,\n      metalness: 0,\n      roughness: 0.95,\n    });\n    const capGeo = new THREE.RingGeometry(INNER_R, ROLL_R, 96, 1);\n    const capR = new THREE.Mesh(capGeo, capMat);\n    capR.rotation.y = Math.PI / 2;\n    capR.position.x = RIBBON_W / 2 + 0.001;\n    capR.castShadow = true;\n    spinner.add(capR);\n    const capL = new THREE.Mesh(capGeo, capMat);\n    capL.rotation.y = -Math.PI / 2;\n    capL.position.x = -RIBBON_W / 2 - 0.001;\n    capL.castShadow = true;\n    spinner.add(capL);\n\n    const coreGeo = new THREE.CylinderGeometry(INNER_R, INNER_R, RIBBON_W * 1.002, 48, 1, true);\n    coreGeo.rotateZ(Math.PI / 2);\n    const coreMat = new THREE.MeshStandardMaterial({\n      color: 0xda_d7_cf,\n      metalness: 0,\n      roughness: 1,\n      side: THREE.DoubleSide,\n    });\n    const core = new THREE.Mesh(coreGeo, coreMat);\n    spinner.add(core);\n\n    const blobGeo = new THREE.PlaneGeometry(ROLL_R * 3.4, RIBBON_W * 2.2);\n    const blobMat = new THREE.MeshBasicMaterial({\n      depthWrite: false,\n      map: blobTex,\n      transparent: true,\n    });\n    const blob = new THREE.Mesh(blobGeo, blobMat);\n    blob.rotation.x = -Math.PI / 2;\n    blob.renderOrder = 1;\n    scene.add(blob);\n\n    // ============================================================\n    // Ribbon — one fixed-budget mesh, rebuilt in place each frame\n    // ============================================================\n    const VERTS = (MAX_SEG + 1) * 2;\n    const posArr = new Float32Array(VERTS * 3);\n    const nrmArr = new Float32Array(VERTS * 3);\n    const uvArr = new Float32Array(VERTS * 2);\n    const sArr = new Float32Array(VERTS);\n    const idxArr = new Uint16Array(MAX_SEG * 6);\n    for (let iq = 0; iq < MAX_SEG; iq += 1) {\n      const v0 = iq * 2;\n      idxArr[iq * 6 + 0] = v0;\n      idxArr[iq * 6 + 1] = v0 + 1;\n      idxArr[iq * 6 + 2] = v0 + 2;\n      idxArr[iq * 6 + 3] = v0 + 1;\n      idxArr[iq * 6 + 4] = v0 + 3;\n      idxArr[iq * 6 + 5] = v0 + 2;\n    }\n\n    const posAttr = new THREE.BufferAttribute(posArr, 3).setUsage(THREE.DynamicDrawUsage);\n    const nrmAttr = new THREE.BufferAttribute(nrmArr, 3).setUsage(THREE.DynamicDrawUsage);\n    const uvAttr = new THREE.BufferAttribute(uvArr, 2).setUsage(THREE.DynamicDrawUsage);\n    const sAttr = new THREE.BufferAttribute(sArr, 1).setUsage(THREE.DynamicDrawUsage);\n\n    const ribbonGeo = new THREE.BufferGeometry();\n    ribbonGeo.setAttribute(\"position\", posAttr);\n    ribbonGeo.setAttribute(\"normal\", nrmAttr);\n    ribbonGeo.setAttribute(\"uv\", uvAttr);\n    ribbonGeo.setAttribute(\"aS\", sAttr);\n    ribbonGeo.setIndex(new THREE.BufferAttribute(idxArr, 1));\n    ribbonGeo.setDrawRange(0, 0);\n\n    const uTailS = { value: 0 };\n\n    const ribbonMat = new THREE.MeshStandardMaterial({\n      map: atlasTex,\n      metalness: 0,\n      roughness: 0.9,\n      side: THREE.DoubleSide,\n    });\n    ribbonMat.onBeforeCompile = (shader) => {\n      shader.uniforms.uTailS = uTailS;\n      shader.vertexShader = shader.vertexShader\n        .replace(\"#include <common>\", \"#include <common>\\nattribute float aS;\\nvarying float vS;\")\n        .replace(\"#include <begin_vertex>\", \"#include <begin_vertex>\\nvS = aS;\");\n      shader.fragmentShader = shader.fragmentShader\n        .replace(\"#include <common>\", \"#include <common>\\nvarying float vS;\\nuniform float uTailS;\")\n        .replace(\n          \"#include <map_fragment>\",\n          \"#include <map_fragment>\\n\" +\n            // tail dissolves into the floor before recycling\n            \"float tail = smoothstep(uTailS, uTailS + 3.0, vS);\\n\" +\n            `diffuseColor.rgb = mix(${FLOOR_RGB}, diffuseColor.rgb, tail);`,\n        );\n    };\n\n    const ribbon = new THREE.Mesh(ribbonGeo, ribbonMat);\n    ribbon.frustumCulled = false;\n    ribbon.receiveShadow = true;\n    scene.add(ribbon);\n\n    // ============================================================\n    // Motion solver — heavy spring-damper, real rolling\n    // ============================================================\n    const pos = new THREE.Vector2(0, 0);\n    const vel = new THREE.Vector2(0, 0);\n    const target = new THREE.Vector2(0, 0);\n    let yaw = 0;\n    let sTotal = 0;\n    const REV = 2 * Math.PI * ROLL_R;\n\n    const SPRING = 16;\n    const DAMP = 5.4;\n    const MAX_SPEED = 9;\n\n    // path history — preallocated ring of plain records\n    const hx = new Float32Array(MAX_PTS);\n    const hz = new Float32Array(MAX_PTS);\n    const hs = new Float32Array(MAX_PTS);\n    // index of newest\n    let head = -1;\n    let count = 0;\n\n    const pushPoint = (x: number, z: number, s: number) => {\n      head = (head + 1) % MAX_PTS;\n      hx[head] = x;\n      hz[head] = z;\n      hs[head] = s;\n      if (count < MAX_PTS) {\n        count += 1;\n      }\n    };\n    interface Pt {\n      x: number;\n      z: number;\n      s: number;\n    }\n    const getPt = (i: number, out: Pt) => {\n      // i = 0 oldest … count-1 newest\n      const k = (head - (count - 1) + i + MAX_PTS * 2) % MAX_PTS;\n      const x = hx[k];\n      const z = hz[k];\n      const s = hs[k];\n      if (x === undefined || z === undefined || s === undefined) {\n        throw new RangeError(\"Paper path sample is outside its ring buffer\");\n      }\n      out.x = x;\n      out.z = z;\n      out.s = s;\n    };\n\n    pushPoint(0, 0, 0);\n\n    const angleLerp = (a: number, b: number, t: number) => {\n      let d = b - a;\n      while (d > Math.PI) {\n        d -= Math.PI * 2;\n      }\n      while (d < -Math.PI) {\n        d += Math.PI * 2;\n      }\n      return a + d * t;\n    };\n\n    const acceleration = new THREE.Vector2();\n    const positionDelta = new THREE.Vector2();\n\n    const stepMotion = (dt: number) => {\n      acceleration.copy(target).sub(pos).multiplyScalar(SPRING);\n      acceleration.addScaledVector(vel, -DAMP);\n      vel.addScaledVector(acceleration, dt);\n      const sp = vel.length();\n      if (sp > MAX_SPEED) {\n        vel.multiplyScalar(MAX_SPEED / sp);\n      }\n      positionDelta.copy(vel).multiplyScalar(dt);\n      const ds = positionDelta.length();\n      if (ds > 1e-6) {\n        pos.add(positionDelta);\n        sTotal += ds;\n        if (sp > 0.06) {\n          // vel.y is world z\n          const ty = Math.atan2(vel.x, vel.y);\n          yaw = angleLerp(yaw, ty, 1 - Math.exp(-7 * dt));\n        }\n        // sample the path by distance, never by time\n        const lx = hx[head];\n        const lz = hz[head];\n        if (lx === undefined || lz === undefined) {\n          throw new RangeError(\"Paper path head is outside its ring buffer\");\n        }\n        const ddx = pos.x - lx;\n        const ddz = pos.y - lz;\n        if (ddx * ddx + ddz * ddz >= STEP * STEP) {\n          pushPoint(pos.x, pos.y, sTotal);\n        }\n      }\n    };\n\n    // ============================================================\n    // Ribbon rebuild — zero allocations\n    // ============================================================\n    const springStart: Pt = { s: 0, x: 0, z: 0 };\n    const springMiddle: Pt = { s: 0, x: 0, z: 0 };\n    const springEnd: Pt = { s: 0, x: 0, z: 0 };\n    // radians of peel wrapped onto the barrel\n    const CURL_MAX = 0.85;\n\n    const writeVert = (\n      vi: number,\n      x: number,\n      y: number,\n      z: number,\n      nx: number,\n      ny: number,\n      nz: number,\n      u: number,\n      vv: number,\n      s: number,\n    ) => {\n      const p3 = vi * 3;\n      const p2 = vi * 2;\n      posArr[p3] = x;\n      posArr[p3 + 1] = y;\n      posArr[p3 + 2] = z;\n      nrmArr[p3] = nx;\n      nrmArr[p3 + 1] = ny;\n      nrmArr[p3 + 2] = nz;\n      uvArr[p2] = u;\n      uvArr[p2 + 1] = vv;\n      sArr[vi] = s;\n    };\n\n    const rebuildRibbon = () => {\n      const n = count;\n      if (n < 2) {\n        ribbonGeo.setDrawRange(0, 0);\n        return;\n      }\n\n      getPt(0, springStart);\n      const sTail = springStart.s;\n      const half = RIBBON_W / 2;\n      let vi = 0;\n      const uSpan = CARD_LEN * ATLAS_N;\n      const uBase = Math.floor(sTail / uSpan) * uSpan;\n\n      // smoothed forward from yaw — stable even when velocity crosses zero\n      const fx = Math.sin(yaw);\n      const fz = Math.cos(yaw);\n      const sxc = fz;\n      const szc = -fx;\n\n      // ---- flat printed trail ----\n      let ptx = 0;\n      let ptz = 0;\n      let hasPrev = false;\n      for (let i = 0; i < n; i += 1) {\n        getPt(i, springMiddle);\n        let tx: number;\n        let tz: number;\n        if (i === n - 1) {\n          // head tangent from smoothed yaw, never from noisy point deltas\n          tx = fx;\n          tz = fz;\n        } else {\n          const i0 = i > 0 ? i - 1 : 0;\n          const i1 = i + 1;\n          getPt(i0, springStart);\n          getPt(i1, springEnd);\n          tx = springEnd.x - springStart.x;\n          tz = springEnd.z - springStart.z;\n        }\n        const tl = Math.hypot(tx, tz);\n        if (tl < 1e-4) {\n          // degenerate delta at a reversal: reuse the previous tangent\n          tx = hasPrev ? ptx : fx;\n          tz = hasPrev ? ptz : fz;\n        } else {\n          tx /= tl;\n          tz /= tl;\n        }\n        // continuity guard: never let the strip twist through a flip\n        if (hasPrev && tx * ptx + tz * ptz < 0) {\n          tx = ptx;\n          tz = ptz;\n        }\n        ptx = tx;\n        ptz = tz;\n        hasPrev = true;\n        const sx = tz;\n        // side vector on the floor\n        const sz = -tx;\n\n        // width taper at the tail so recycling is invisible\n        let w = half;\n        const fromTail = springMiddle.s - sTail;\n        if (fromTail < 3) {\n          w *= fromTail / 3;\n        }\n\n        // newer paper lies on top; the head gets an extra ramp so fresh paper\n        // laid over a just-reversed spot never z-fights with itself\n        let y = 0.012 + (springMiddle.s - sTail) * 0.0008;\n        const headBlend = 1 - (sTotal - springMiddle.s) / 1.5;\n        if (headBlend > 0) {\n          y += 0.0035 * headBlend;\n        }\n        const u = (springMiddle.s - uBase) / uSpan;\n        writeVert(\n          vi,\n          springMiddle.x + sx * w,\n          y,\n          springMiddle.z + sz * w,\n          0,\n          1,\n          0,\n          u,\n          0,\n          springMiddle.s - uBase,\n        );\n        vi += 1;\n        writeVert(\n          vi,\n          springMiddle.x - sx * w,\n          y,\n          springMiddle.z - sz * w,\n          0,\n          1,\n          0,\n          u,\n          1,\n          springMiddle.s - uBase,\n        );\n        vi += 1;\n      }\n\n      // ---- bridge to the live contact point ----\n      const yTop = 0.012 + (sTotal - sTail) * 0.0008 + 0.0035;\n      const uC = (sTotal - uBase) / uSpan;\n      writeVert(vi, pos.x + sxc * half, yTop, pos.y + szc * half, 0, 1, 0, uC, 0, sTotal - uBase);\n      vi += 1;\n      writeVert(vi, pos.x - sxc * half, yTop, pos.y - szc * half, 0, 1, 0, uC, 1, sTotal - uBase);\n      vi += 1;\n\n      // ---- peel: unprinted paper coming down the front of the barrel ----\n      // this is the physically correct side — the card rolling down the front\n      // is exactly the card the atlas shows there, so the hand-off is seamless\n      for (let j = 1; j <= CURL_SEG; j += 1) {\n        const th = (j / CURL_SEG) * CURL_MAX;\n        const rr = ROLL_R + 0.012;\n        const px = pos.x + fx * Math.sin(th) * rr;\n        const pz = pos.y + fz * Math.sin(th) * rr;\n        const py = yTop + rr * (1 - Math.cos(th));\n        // paper-face normal, continuous with the flat trail at th = 0\n        const nx = -fx * Math.sin(th);\n        const nyv = Math.cos(th);\n        const nz = -fz * Math.sin(th);\n        const sHere = sTotal + th * ROLL_R;\n        const uH = (sHere - uBase) / uSpan;\n        writeVert(vi, px + sxc * half, py, pz + szc * half, nx, nyv, nz, uH, 0, sHere - uBase);\n        vi += 1;\n        writeVert(vi, px - sxc * half, py, pz - szc * half, nx, nyv, nz, uH, 1, sHere - uBase);\n        vi += 1;\n      }\n\n      const segs = vi / 2 - 1;\n      ribbonGeo.setDrawRange(0, segs * 6);\n      posAttr.needsUpdate = true;\n      nrmAttr.needsUpdate = true;\n      uvAttr.needsUpdate = true;\n      sAttr.needsUpdate = true;\n\n      uTailS.value = sTail - uBase;\n    };\n\n    // ============================================================\n    // Input — pointer on the floor plane, with idle autopilot\n    // ============================================================\n    const raycaster = new THREE.Raycaster();\n    const ndc = new THREE.Vector2(0, 0);\n    const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);\n    const hit = new THREE.Vector3();\n    let pointerActive = false;\n    let lastPointerT = -1e9;\n    let autoAngle = Math.PI * 0.25;\n\n    const onPointer = (e: PointerEvent) => {\n      const rect = container.getBoundingClientRect();\n      ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;\n      ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;\n      pointerActive = true;\n      lastPointerT = performance.now();\n    };\n    container.addEventListener(\"pointermove\", onPointer, { passive: true });\n    container.addEventListener(\"pointerdown\", onPointer, { passive: true });\n\n    // ---- click toggles the roll; drags don't ----\n    let paused = false;\n    let downX = 0;\n    let downY = 0;\n    let downT = 0;\n\n    const onPointerDown = (e: PointerEvent) => {\n      downX = e.clientX;\n      downY = e.clientY;\n      downT = performance.now();\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      const dx = e.clientX - downX;\n      const dy = e.clientY - downY;\n      if (dx * dx + dy * dy < 64 && performance.now() - downT < 450) {\n        paused = !paused;\n      }\n    };\n    container.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n    container.addEventListener(\"pointerup\", onPointerUp, { passive: true });\n\n    const updateTarget = (t: number, dt: number) => {\n      const idle = performance.now() - lastPointerT > 3200;\n      if (pointerActive && !idle) {\n        raycaster.setFromCamera(ndc, camera);\n        if (raycaster.ray.intersectPlane(floorPlane, hit)) {\n          target.set(hit.x, hit.z);\n        }\n      } else {\n        // wandering autopilot — always in motion, never a straight line\n        autoAngle += dt * (0.34 + 0.5 * Math.sin(t * 0.31) + 0.3 * Math.sin(t * 0.113 + 2.1));\n        target.set(pos.x + Math.sin(autoAngle) * 5.2, pos.y + Math.cos(autoAngle) * 5.2);\n      }\n    };\n\n    // ============================================================\n    // Camera\n    // ============================================================\n    const camOffset = new THREE.Vector3(7.6, 8.8, 10.8);\n    const camPos = new THREE.Vector3();\n    const lookAt = new THREE.Vector3(0, 0.6, 0);\n    const desiredPosition = new THREE.Vector3();\n    const INTRO_DUR = 2.2;\n    let introT = 0;\n\n    const introZoom = () => {\n      // gsap power3.out: 1.5 → 1 over INTRO_DUR\n      const t = Math.min(introT / INTRO_DUR, 1);\n      const e = 1 - (1 - t) ** 3;\n      return 1.5 - 0.5 * e;\n    };\n\n    const updateCamera = (dt: number) => {\n      desiredPosition.set(pos.x, 0, pos.y).addScaledVector(camOffset, introZoom());\n      const k = 1 - Math.exp(-2.6 * dt);\n      camPos.lerp(desiredPosition, k);\n      desiredPosition.set(pos.x, 0.55, pos.y);\n      lookAt.lerp(desiredPosition, k);\n      camera.position.copy(camPos);\n      camera.lookAt(lookAt);\n    };\n\n    // ============================================================\n    // Pre-roll: lay a trail before the first frame\n    // ============================================================\n    let t0 = 0;\n    for (let i = 0; i < 560; i += 1) {\n      t0 += 1 / 60;\n      autoAngle += (1 / 60) * (0.34 + 0.5 * Math.sin(t0 * 0.31) + 0.3 * Math.sin(t0 * 0.113 + 2.1));\n      target.set(pos.x + Math.sin(autoAngle) * 5.2, pos.y + Math.cos(autoAngle) * 5.2);\n      stepMotion(1 / 60);\n    }\n    camPos.set(pos.x, 0, pos.y).addScaledVector(camOffset, 1.5);\n    lookAt.set(pos.x, 0.55, pos.y);\n\n    // ============================================================\n    // Main loop — fixed order, no allocations\n    // ============================================================\n    const timer = new THREE.Timer();\n    timer.connect(document);\n    let elapsed = 0;\n    let animationFrameId = 0;\n\n    const frame = (now: number) => {\n      animationFrameId = requestAnimationFrame(frame);\n      timer.update(now);\n      // sub-step the spring solver so slow renderers stay real-time instead of\n      // going slow-motion; each sub-step is small enough to keep Euler stable\n      const dt = Math.min(timer.getDelta(), 1 / 8);\n      elapsed += dt;\n      introT += dt;\n\n      if (!paused) {\n        updateTarget(elapsed, dt);\n        const steps = Math.min(8, Math.ceil(dt * 60));\n        const h = dt / steps;\n        for (let s = 0; s < steps; s += 1) {\n          stepMotion(h);\n        }\n\n        rollGroup.position.set(pos.x, ROLL_R, pos.y);\n        rollGroup.rotation.y = yaw;\n        spinner.rotation.x = (sTotal % REV) / ROLL_R;\n\n        rebuildRibbon();\n\n        blob.position.set(pos.x, 0.006, pos.y);\n        blob.rotation.z = yaw - Math.PI / 2;\n\n        floor.position.set(pos.x, 0, pos.y);\n        sun.position.set(pos.x + 5, 10, pos.y + 4);\n        sun.target.position.set(pos.x, 0, pos.y);\n      }\n\n      updateCamera(dt);\n\n      renderer.render(scene, camera);\n    };\n    animationFrameId = requestAnimationFrame(frame);\n\n    const resizeObserver = new ResizeObserver(() => {\n      const width = container.clientWidth;\n      const height = container.clientHeight;\n      if (!width || !height) {\n        return;\n      }\n      camera.aspect = width / height;\n      camera.updateProjectionMatrix();\n      renderer.setSize(width, height);\n    });\n    resizeObserver.observe(container);\n\n    return () => {\n      cancelAnimationFrame(animationFrameId);\n      timer.dispose();\n      resizeObserver.disconnect();\n      container.removeEventListener(\"pointermove\", onPointer);\n      container.removeEventListener(\"pointerdown\", onPointer);\n      container.removeEventListener(\"pointerdown\", onPointerDown);\n      container.removeEventListener(\"pointerup\", onPointerUp);\n      barrelGeo.dispose();\n      capGeo.dispose();\n      coreGeo.dispose();\n      blobGeo.dispose();\n      floorGeo.dispose();\n      ribbonGeo.dispose();\n      paperMat.dispose();\n      capMat.dispose();\n      coreMat.dispose();\n      blobMat.dispose();\n      floorMat.dispose();\n      ribbonMat.dispose();\n      atlasTex.dispose();\n      barrelTex.dispose();\n      capTex.dispose();\n      blobTex.dispose();\n      renderer.dispose();\n    };\n  }, []);\n\n  return (\n    <div\n      ref={rootRef}\n      className=\"relative size-full cursor-grab touch-none overflow-hidden bg-[#eaeaec] select-none active:cursor-grabbing\"\n    >\n      <canvas ref={canvasRef} className=\"absolute inset-0 block\" />\n    </div>\n  );\n};\n","path":"/paper-roll.tsx","target":"uicapsule/paper-roll/paper-roll.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { PaperRoll } from \"./paper-roll\";\n\nconst Preview = () => (\n  <div className=\"h-dvh w-full\">\n    <PaperRoll />\n  </div>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/paper-roll/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/paper-roll","name":"paper-roll","registryDependencies":[],"title":"Paper Roll","type":"registry:block"}