{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "GridScan-JS-CSS", "title": "GridScan", "description": "Animated grid room 3D scan effect and cool interactions.", "type": "registry:component", "files": [ { "type": "registry:component", "path": "GridScan/GridScan.css", "content": ".gridscan {\n position: relative;\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n\n.gridscan__preview {\n position: absolute;\n right: 12px;\n bottom: 12px;\n width: 220px;\n height: 132px;\n border-radius: 8px;\n overflow: hidden;\n border: 1px solid rgba(255, 255, 255, 0.25);\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);\n background: #000;\n color: #fff;\n font:\n 12px/1.2 system-ui,\n -apple-system,\n Segoe UI,\n Roboto,\n sans-serif;\n pointer-events: none;\n}\n\n.gridscan__video {\n width: 100%;\n height: 100%;\n object-fit: cover;\n transform: scaleX(-1);\n}\n\n.gridscan__badge {\n position: absolute;\n left: 8px;\n top: 8px;\n padding: 2px 6px;\n background: rgba(0, 0, 0, 0.5);\n border-radius: 6px;\n backdrop-filter: blur(4px);\n}\n" }, { "type": "registry:component", "path": "GridScan/GridScan.jsx", "content": "import * as faceapi from 'face-api.js';\nimport { BloomEffect, ChromaticAberrationEffect, EffectComposer, EffectPass, RenderPass } from 'postprocessing';\nimport { useEffect, useRef, useState } from 'react';\nimport * as THREE from 'three';\nimport './GridScan.css';\n\nconst vert = `\nvarying vec2 vUv;\nvoid main(){\n vUv = uv;\n gl_Position = vec4(position.xy, 0.0, 1.0);\n}\n`;\n\nconst frag = `\nprecision highp float;\nuniform vec3 iResolution;\nuniform float iTime;\nuniform vec2 uSkew;\nuniform float uTilt;\nuniform float uYaw;\nuniform float uLineThickness;\nuniform vec3 uLinesColor;\nuniform vec3 uScanColor;\nuniform float uGridScale;\nuniform float uLineStyle;\nuniform float uLineJitter;\nuniform float uScanOpacity;\nuniform float uScanDirection;\nuniform float uNoise;\nuniform float uBloomOpacity;\nuniform float uScanGlow;\nuniform float uScanSoftness;\nuniform float uPhaseTaper;\nuniform float uScanDuration;\nuniform float uScanDelay;\nvarying vec2 vUv;\n\nuniform float uScanStarts[8];\nuniform float uScanCount;\n\nconst int MAX_SCANS = 8;\n\nfloat smoother01(float a, float b, float x){\n float t = clamp((x - a) / max(1e-5, (b - a)), 0.0, 1.0);\n return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);\n}\n\nvoid mainImage(out vec4 fragColor, in vec2 fragCoord)\n{\n vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y;\n\n vec3 ro = vec3(0.0);\n vec3 rd = normalize(vec3(p, 2.0));\n\n float cR = cos(uTilt), sR = sin(uTilt);\n rd.xy = mat2(cR, -sR, sR, cR) * rd.xy;\n\n float cY = cos(uYaw), sY = sin(uYaw);\n rd.xz = mat2(cY, -sY, sY, cY) * rd.xz;\n\n vec2 skew = clamp(uSkew, vec2(-0.7), vec2(0.7));\n rd.xy += skew * rd.z;\n\n vec3 color = vec3(0.0);\n float minT = 1e20;\n float gridScale = max(1e-5, uGridScale);\n float fadeStrength = 2.0;\n vec2 gridUV = vec2(0.0);\n\n float hitIsY = 1.0;\n for (int i = 0; i < 4; i++)\n {\n float isY = float(i < 2);\n float pos = mix(-0.2, 0.2, float(i)) * isY + mix(-0.5, 0.5, float(i - 2)) * (1.0 - isY);\n float num = pos - (isY * ro.y + (1.0 - isY) * ro.x);\n float den = isY * rd.y + (1.0 - isY) * rd.x;\n float t = num / den;\n vec3 h = ro + rd * t;\n\n float depthBoost = smoothstep(0.0, 3.0, h.z);\n h.xy += skew * 0.15 * depthBoost;\n\n bool use = t > 0.0 && t < minT;\n gridUV = use ? mix(h.zy, h.xz, isY) / gridScale : gridUV;\n minT = use ? t : minT;\n hitIsY = use ? isY : hitIsY;\n }\n\n vec3 hit = ro + rd * minT;\n float dist = length(hit - ro);\n\n float jitterAmt = clamp(uLineJitter, 0.0, 1.0);\n if (jitterAmt > 0.0) {\n vec2 j = vec2(\n sin(gridUV.y * 2.7 + iTime * 1.8),\n cos(gridUV.x * 2.3 - iTime * 1.6)\n ) * (0.15 * jitterAmt);\n gridUV += j;\n }\n float fx = fract(gridUV.x);\n float fy = fract(gridUV.y);\n float ax = min(fx, 1.0 - fx);\n float ay = min(fy, 1.0 - fy);\n float wx = fwidth(gridUV.x);\n float wy = fwidth(gridUV.y);\n float halfPx = max(0.0, uLineThickness) * 0.5;\n\n float tx = halfPx * wx;\n float ty = halfPx * wy;\n\n float aax = wx;\n float aay = wy;\n\n float lineX = 1.0 - smoothstep(tx, tx + aax, ax);\n float lineY = 1.0 - smoothstep(ty, ty + aay, ay);\n if (uLineStyle > 0.5) {\n float dashRepeat = 4.0;\n float dashDuty = 0.5;\n float vy = fract(gridUV.y * dashRepeat);\n float vx = fract(gridUV.x * dashRepeat);\n float dashMaskY = step(vy, dashDuty);\n float dashMaskX = step(vx, dashDuty);\n if (uLineStyle < 1.5) {\n lineX *= dashMaskY;\n lineY *= dashMaskX;\n } else {\n float dotRepeat = 6.0;\n float dotWidth = 0.18;\n float cy = abs(fract(gridUV.y * dotRepeat) - 0.5);\n float cx = abs(fract(gridUV.x * dotRepeat) - 0.5);\n float dotMaskY = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.y * dotRepeat), cy);\n float dotMaskX = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.x * dotRepeat), cx);\n lineX *= dotMaskY;\n lineY *= dotMaskX;\n }\n }\n float primaryMask = max(lineX, lineY);\n\n vec2 gridUV2 = (hitIsY > 0.5 ? hit.xz : hit.zy) / gridScale;\n if (jitterAmt > 0.0) {\n vec2 j2 = vec2(\n cos(gridUV2.y * 2.1 - iTime * 1.4),\n sin(gridUV2.x * 2.5 + iTime * 1.7)\n ) * (0.15 * jitterAmt);\n gridUV2 += j2;\n }\n float fx2 = fract(gridUV2.x);\n float fy2 = fract(gridUV2.y);\n float ax2 = min(fx2, 1.0 - fx2);\n float ay2 = min(fy2, 1.0 - fy2);\n float wx2 = fwidth(gridUV2.x);\n float wy2 = fwidth(gridUV2.y);\n float tx2 = halfPx * wx2;\n float ty2 = halfPx * wy2;\n float aax2 = wx2;\n float aay2 = wy2;\n float lineX2 = 1.0 - smoothstep(tx2, tx2 + aax2, ax2);\n float lineY2 = 1.0 - smoothstep(ty2, ty2 + aay2, ay2);\n if (uLineStyle > 0.5) {\n float dashRepeat2 = 4.0;\n float dashDuty2 = 0.5;\n float vy2m = fract(gridUV2.y * dashRepeat2);\n float vx2m = fract(gridUV2.x * dashRepeat2);\n float dashMaskY2 = step(vy2m, dashDuty2);\n float dashMaskX2 = step(vx2m, dashDuty2);\n if (uLineStyle < 1.5) {\n lineX2 *= dashMaskY2;\n lineY2 *= dashMaskX2;\n } else {\n float dotRepeat2 = 6.0;\n float dotWidth2 = 0.18;\n float cy2 = abs(fract(gridUV2.y * dotRepeat2) - 0.5);\n float cx2 = abs(fract(gridUV2.x * dotRepeat2) - 0.5);\n float dotMaskY2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.y * dotRepeat2), cy2);\n float dotMaskX2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.x * dotRepeat2), cx2);\n lineX2 *= dotMaskY2;\n lineY2 *= dotMaskX2;\n }\n }\n float altMask = max(lineX2, lineY2);\n\n float edgeDistX = min(abs(hit.x - (-0.5)), abs(hit.x - 0.5));\n float edgeDistY = min(abs(hit.y - (-0.2)), abs(hit.y - 0.2));\n float edgeDist = mix(edgeDistY, edgeDistX, hitIsY);\n float edgeGate = 1.0 - smoothstep(gridScale * 0.5, gridScale * 2.0, edgeDist);\n altMask *= edgeGate;\n\n float lineMask = max(primaryMask, altMask);\n\n float fade = exp(-dist * fadeStrength);\n\n float dur = max(0.05, uScanDuration);\n float del = max(0.0, uScanDelay);\n float scanZMax = 2.0;\n float widthScale = max(0.1, uScanGlow);\n float sigma = max(0.001, 0.18 * widthScale * uScanSoftness);\n float sigmaA = sigma * 2.0;\n\n float combinedPulse = 0.0;\n float combinedAura = 0.0;\n\n float cycle = dur + del;\n float tCycle = mod(iTime, cycle);\n float scanPhase = clamp((tCycle - del) / dur, 0.0, 1.0);\n float phase = scanPhase;\n if (uScanDirection > 0.5 && uScanDirection < 1.5) {\n phase = 1.0 - phase;\n } else if (uScanDirection > 1.5) {\n float t2 = mod(max(0.0, iTime - del), 2.0 * dur);\n phase = (t2 < dur) ? (t2 / dur) : (1.0 - (t2 - dur) / dur);\n }\n float scanZ = phase * scanZMax;\n float dz = abs(hit.z - scanZ);\n float lineBand = exp(-0.5 * (dz * dz) / (sigma * sigma));\n float taper = clamp(uPhaseTaper, 0.0, 0.49);\n float headW = taper;\n float tailW = taper;\n float headFade = smoother01(0.0, headW, phase);\n float tailFade = 1.0 - smoother01(1.0 - tailW, 1.0, phase);\n float phaseWindow = headFade * tailFade;\n float pulseBase = lineBand * phaseWindow;\n combinedPulse += pulseBase * clamp(uScanOpacity, 0.0, 1.0);\n float auraBand = exp(-0.5 * (dz * dz) / (sigmaA * sigmaA));\n combinedAura += (auraBand * 0.25) * phaseWindow * clamp(uScanOpacity, 0.0, 1.0);\n\n for (int i = 0; i < MAX_SCANS; i++) {\n if (float(i) >= uScanCount) break;\n float tActiveI = iTime - uScanStarts[i];\n float phaseI = clamp(tActiveI / dur, 0.0, 1.0);\n if (uScanDirection > 0.5 && uScanDirection < 1.5) {\n phaseI = 1.0 - phaseI;\n } else if (uScanDirection > 1.5) {\n phaseI = (phaseI < 0.5) ? (phaseI * 2.0) : (1.0 - (phaseI - 0.5) * 2.0);\n }\n float scanZI = phaseI * scanZMax;\n float dzI = abs(hit.z - scanZI);\n float lineBandI = exp(-0.5 * (dzI * dzI) / (sigma * sigma));\n float headFadeI = smoother01(0.0, headW, phaseI);\n float tailFadeI = 1.0 - smoother01(1.0 - tailW, 1.0, phaseI);\n float phaseWindowI = headFadeI * tailFadeI;\n combinedPulse += lineBandI * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0);\n float auraBandI = exp(-0.5 * (dzI * dzI) / (sigmaA * sigmaA));\n combinedAura += (auraBandI * 0.25) * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0);\n }\n\n float lineVis = lineMask;\n vec3 gridCol = uLinesColor * lineVis * fade;\n vec3 scanCol = uScanColor * combinedPulse;\n vec3 scanAura = uScanColor * combinedAura;\n\n color = gridCol + scanCol + scanAura;\n\n float n = fract(sin(dot(gl_FragCoord.xy + vec2(iTime * 123.4), vec2(12.9898,78.233))) * 43758.5453123);\n color += (n - 0.5) * uNoise;\n color = clamp(color, 0.0, 1.0);\n float alpha = clamp(max(lineVis, combinedPulse), 0.0, 1.0);\n float gx = 1.0 - smoothstep(tx * 2.0, tx * 2.0 + aax * 2.0, ax);\n float gy = 1.0 - smoothstep(ty * 2.0, ty * 2.0 + aay * 2.0, ay);\n float halo = max(gx, gy) * fade;\n alpha = max(alpha, halo * clamp(uBloomOpacity, 0.0, 1.0));\n fragColor = vec4(color, alpha);\n}\n\nvoid main(){\n vec4 c;\n mainImage(c, vUv * iResolution.xy);\n gl_FragColor = c;\n}\n`;\n\nexport const GridScan = ({\n enableWebcam = false,\n showPreview = false,\n modelsPath = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights',\n sensitivity = 0.55,\n lineThickness = 1,\n linesColor = '#2F293A',\n scanColor = '#FF9FFC',\n scanOpacity = 0.4,\n gridScale = 0.1,\n lineStyle = 'solid',\n lineJitter = 0.1,\n scanDirection = 'pingpong',\n enablePost = true,\n bloomIntensity = 0,\n bloomThreshold = 0,\n bloomSmoothing = 0,\n chromaticAberration = 0.002,\n noiseIntensity = 0.01,\n scanGlow = 0.5,\n scanSoftness = 2,\n scanPhaseTaper = 0.9,\n scanDuration = 2.0,\n scanDelay = 2.0,\n enableGyro = false,\n scanOnClick = false,\n snapBackDelay = 250,\n className,\n style\n}) => {\n const containerRef = useRef(null);\n const videoRef = useRef(null);\n\n const rendererRef = useRef(null);\n const materialRef = useRef(null);\n const composerRef = useRef(null);\n const bloomRef = useRef(null);\n const chromaRef = useRef(null);\n const rafRef = useRef(null);\n\n const [modelsReady, setModelsReady] = useState(false);\n const [uiFaceActive, setUiFaceActive] = useState(false);\n\n const lookTarget = useRef(new THREE.Vector2(0, 0));\n const tiltTarget = useRef(0);\n const yawTarget = useRef(0);\n\n const lookCurrent = useRef(new THREE.Vector2(0, 0));\n const lookVel = useRef(new THREE.Vector2(0, 0));\n const tiltCurrent = useRef(0);\n const tiltVel = useRef(0);\n const yawCurrent = useRef(0);\n const yawVel = useRef(0);\n\n const MAX_SCANS = 8;\n const scanStartsRef = useRef([]);\n\n const pushScan = t => {\n const arr = scanStartsRef.current.slice();\n if (arr.length >= MAX_SCANS) arr.shift();\n arr.push(t);\n scanStartsRef.current = arr;\n if (materialRef.current) {\n const u = materialRef.current.uniforms;\n const buf = new Array(MAX_SCANS).fill(0);\n for (let i = 0; i < arr.length && i < MAX_SCANS; i++) buf[i] = arr[i];\n u.uScanStarts.value = buf;\n u.uScanCount.value = arr.length;\n }\n };\n\n const bufX = useRef([]);\n const bufY = useRef([]);\n const bufT = useRef([]);\n const bufYaw = useRef([]);\n\n const s = THREE.MathUtils.clamp(sensitivity, 0, 1);\n const skewScale = THREE.MathUtils.lerp(0.06, 0.2, s);\n const tiltScale = THREE.MathUtils.lerp(0.12, 0.3, s);\n const yawScale = THREE.MathUtils.lerp(0.1, 0.28, s);\n const depthResponse = THREE.MathUtils.lerp(0.25, 0.45, s);\n const smoothTime = THREE.MathUtils.lerp(0.45, 0.12, s);\n const maxSpeed = Infinity;\n\n const yBoost = THREE.MathUtils.lerp(1.2, 1.6, s);\n\n useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n let leaveTimer = null;\n const onMove = e => {\n if (uiFaceActive) return;\n if (leaveTimer) {\n clearTimeout(leaveTimer);\n leaveTimer = null;\n }\n const rect = el.getBoundingClientRect();\n const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;\n const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1);\n lookTarget.current.set(nx, ny);\n };\n const onClick = async () => {\n const nowSec = performance.now() / 1000;\n if (scanOnClick) pushScan(nowSec);\n if (\n enableGyro &&\n typeof window !== 'undefined' &&\n window.DeviceOrientationEvent &&\n DeviceOrientationEvent.requestPermission\n ) {\n try {\n await DeviceOrientationEvent.requestPermission();\n } catch {\n // noop\n }\n }\n };\n const onEnter = () => {\n if (leaveTimer) {\n clearTimeout(leaveTimer);\n leaveTimer = null;\n }\n };\n const onLeave = () => {\n if (uiFaceActive) return;\n if (leaveTimer) clearTimeout(leaveTimer);\n leaveTimer = window.setTimeout(\n () => {\n lookTarget.current.set(0, 0);\n tiltTarget.current = 0;\n yawTarget.current = 0;\n },\n Math.max(0, snapBackDelay || 0)\n );\n };\n el.addEventListener('mousemove', onMove);\n el.addEventListener('mouseenter', onEnter);\n if (scanOnClick) el.addEventListener('click', onClick);\n el.addEventListener('mouseleave', onLeave);\n return () => {\n el.removeEventListener('mousemove', onMove);\n el.removeEventListener('mouseenter', onEnter);\n el.removeEventListener('mouseleave', onLeave);\n if (scanOnClick) el.removeEventListener('click', onClick);\n if (leaveTimer) clearTimeout(leaveTimer);\n };\n }, [uiFaceActive, snapBackDelay, scanOnClick, enableGyro]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });\n rendererRef.current = renderer;\n renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));\n renderer.setSize(container.clientWidth, container.clientHeight);\n renderer.outputColorSpace = THREE.SRGBColorSpace;\n renderer.toneMapping = THREE.NoToneMapping;\n renderer.autoClear = false;\n renderer.setClearColor(0x000000, 0);\n container.appendChild(renderer.domElement);\n\n const uniforms = {\n iResolution: {\n value: new THREE.Vector3(container.clientWidth, container.clientHeight, renderer.getPixelRatio())\n },\n iTime: { value: 0 },\n uSkew: { value: new THREE.Vector2(0, 0) },\n uTilt: { value: 0 },\n uYaw: { value: 0 },\n uLineThickness: { value: lineThickness },\n uLinesColor: { value: srgbColor(linesColor) },\n uScanColor: { value: srgbColor(scanColor) },\n uGridScale: { value: gridScale },\n uLineStyle: { value: lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0 },\n uLineJitter: { value: Math.max(0, Math.min(1, lineJitter || 0)) },\n uScanOpacity: { value: scanOpacity },\n uNoise: { value: noiseIntensity },\n uBloomOpacity: { value: bloomIntensity },\n uScanGlow: { value: scanGlow },\n uScanSoftness: { value: scanSoftness },\n uPhaseTaper: { value: scanPhaseTaper },\n uScanDuration: { value: scanDuration },\n uScanDelay: { value: scanDelay },\n uScanDirection: { value: scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0 },\n uScanStarts: { value: new Array(MAX_SCANS).fill(0) },\n uScanCount: { value: 0 }\n };\n\n const material = new THREE.ShaderMaterial({\n uniforms,\n vertexShader: vert,\n fragmentShader: frag,\n transparent: true,\n depthWrite: false,\n depthTest: false\n });\n materialRef.current = material;\n\n const scene = new THREE.Scene();\n const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);\n const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);\n scene.add(quad);\n\n let composer = null;\n if (enablePost) {\n composer = new EffectComposer(renderer);\n composerRef.current = composer;\n const renderPass = new RenderPass(scene, camera);\n composer.addPass(renderPass);\n\n const bloom = new BloomEffect({\n intensity: 1.0,\n luminanceThreshold: bloomThreshold,\n luminanceSmoothing: bloomSmoothing\n });\n bloom.blendMode.opacity.value = Math.max(0, bloomIntensity);\n bloomRef.current = bloom;\n\n const chroma = new ChromaticAberrationEffect({\n offset: new THREE.Vector2(chromaticAberration, chromaticAberration),\n radialModulation: true,\n modulationOffset: 0.0\n });\n chromaRef.current = chroma;\n\n const effectPass = new EffectPass(camera, bloom, chroma);\n effectPass.renderToScreen = true;\n composer.addPass(effectPass);\n }\n\n const onResize = () => {\n renderer.setSize(container.clientWidth, container.clientHeight);\n material.uniforms.iResolution.value.set(container.clientWidth, container.clientHeight, renderer.getPixelRatio());\n if (composerRef.current) composerRef.current.setSize(container.clientWidth, container.clientHeight);\n };\n window.addEventListener('resize', onResize);\n\n let last = performance.now();\n const tick = () => {\n const now = performance.now();\n const dt = Math.max(0, Math.min(0.1, (now - last) / 1000));\n last = now;\n\n lookCurrent.current.copy(\n smoothDampVec2(lookCurrent.current, lookTarget.current, lookVel.current, smoothTime, maxSpeed, dt)\n );\n\n const tiltSm = smoothDampFloat(\n tiltCurrent.current,\n tiltTarget.current,\n { v: tiltVel.current },\n smoothTime,\n maxSpeed,\n dt\n );\n tiltCurrent.current = tiltSm.value;\n tiltVel.current = tiltSm.v;\n\n const yawSm = smoothDampFloat(\n yawCurrent.current,\n yawTarget.current,\n { v: yawVel.current },\n smoothTime,\n maxSpeed,\n dt\n );\n yawCurrent.current = yawSm.value;\n yawVel.current = yawSm.v;\n\n const skew = new THREE.Vector2(lookCurrent.current.x * skewScale, -lookCurrent.current.y * yBoost * skewScale);\n material.uniforms.uSkew.value.set(skew.x, skew.y);\n material.uniforms.uTilt.value = tiltCurrent.current * tiltScale;\n material.uniforms.uYaw.value = THREE.MathUtils.clamp(yawCurrent.current * yawScale, -0.6, 0.6);\n\n material.uniforms.iTime.value = now / 1000;\n renderer.clear(true, true, true);\n if (composerRef.current) {\n composerRef.current.render(dt);\n } else {\n renderer.render(scene, camera);\n }\n rafRef.current = requestAnimationFrame(tick);\n };\n rafRef.current = requestAnimationFrame(tick);\n\n return () => {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n window.removeEventListener('resize', onResize);\n material.dispose();\n quad.geometry.dispose();\n\n if (composerRef.current) {\n composerRef.current.dispose();\n composerRef.current = null;\n }\n renderer.dispose();\n renderer.forceContextLoss();\n container.removeChild(renderer.domElement);\n };\n }, [\n sensitivity,\n lineThickness,\n linesColor,\n scanColor,\n scanOpacity,\n gridScale,\n lineStyle,\n lineJitter,\n scanDirection,\n enablePost,\n noiseIntensity,\n bloomIntensity,\n scanGlow,\n scanSoftness,\n scanPhaseTaper,\n scanDuration,\n scanDelay,\n bloomThreshold,\n bloomSmoothing,\n chromaticAberration,\n smoothTime,\n maxSpeed,\n skewScale,\n yBoost,\n tiltScale,\n yawScale\n ]);\n\n useEffect(() => {\n const m = materialRef.current;\n if (m) {\n const u = m.uniforms;\n u.uLineThickness.value = lineThickness;\n u.uLinesColor.value.copy(srgbColor(linesColor));\n u.uScanColor.value.copy(srgbColor(scanColor));\n u.uGridScale.value = gridScale;\n u.uLineStyle.value = lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0;\n u.uLineJitter.value = Math.max(0, Math.min(1, lineJitter || 0));\n u.uBloomOpacity.value = Math.max(0, bloomIntensity);\n u.uNoise.value = Math.max(0, noiseIntensity);\n u.uScanGlow.value = scanGlow;\n u.uScanOpacity.value = Math.max(0, Math.min(1, scanOpacity));\n u.uScanDirection.value = scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0;\n u.uScanSoftness.value = scanSoftness;\n u.uPhaseTaper.value = scanPhaseTaper;\n u.uScanDuration.value = Math.max(0.05, scanDuration);\n u.uScanDelay.value = Math.max(0.0, scanDelay);\n }\n if (bloomRef.current) {\n bloomRef.current.blendMode.opacity.value = Math.max(0, bloomIntensity);\n bloomRef.current.luminanceMaterial.threshold = bloomThreshold;\n bloomRef.current.luminanceMaterial.smoothing = bloomSmoothing;\n }\n if (chromaRef.current) {\n chromaRef.current.offset.set(chromaticAberration, chromaticAberration);\n }\n }, [\n lineThickness,\n linesColor,\n scanColor,\n gridScale,\n lineStyle,\n lineJitter,\n bloomIntensity,\n bloomThreshold,\n bloomSmoothing,\n chromaticAberration,\n noiseIntensity,\n scanGlow,\n scanOpacity,\n scanDirection,\n scanSoftness,\n scanPhaseTaper,\n scanDuration,\n scanDelay\n ]);\n\n useEffect(() => {\n if (!enableGyro) return;\n const handler = e => {\n if (uiFaceActive) return;\n const gamma = e.gamma ?? 0;\n const beta = e.beta ?? 0;\n const nx = THREE.MathUtils.clamp(gamma / 45, -1, 1);\n const ny = THREE.MathUtils.clamp(-beta / 30, -1, 1);\n lookTarget.current.set(nx, ny);\n tiltTarget.current = THREE.MathUtils.degToRad(gamma) * 0.4;\n };\n window.addEventListener('deviceorientation', handler);\n return () => {\n window.removeEventListener('deviceorientation', handler);\n };\n }, [enableGyro, uiFaceActive]);\n\n useEffect(() => {\n let canceled = false;\n const load = async () => {\n try {\n await Promise.all([\n faceapi.nets.tinyFaceDetector.loadFromUri(modelsPath),\n faceapi.nets.faceLandmark68TinyNet.loadFromUri(modelsPath)\n ]);\n if (!canceled) setModelsReady(true);\n } catch {\n if (!canceled) setModelsReady(false);\n }\n };\n load();\n return () => {\n canceled = true;\n };\n }, [modelsPath]);\n\n useEffect(() => {\n let stop = false;\n let lastDetect = 0;\n const video = videoRef.current;\n\n const start = async () => {\n if (!enableWebcam || !modelsReady) return;\n if (!video) return;\n\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },\n audio: false\n });\n video.srcObject = stream;\n await video.play();\n } catch {\n return;\n }\n\n const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.5 });\n\n const detect = async ts => {\n if (stop) return;\n\n if (ts - lastDetect >= 33) {\n lastDetect = ts;\n try {\n const res = await faceapi.detectSingleFace(video, opts).withFaceLandmarks(true);\n if (res && res.detection) {\n const det = res.detection;\n const box = det.box;\n const vw = video.videoWidth || 1;\n const vh = video.videoHeight || 1;\n\n const cx = box.x + box.width * 0.5;\n const cy = box.y + box.height * 0.5;\n const nx = (cx / vw) * 2 - 1;\n const ny = (cy / vh) * 2 - 1;\n medianPush(bufX.current, nx, 5);\n medianPush(bufY.current, ny, 5);\n const nxm = median(bufX.current);\n const nym = median(bufY.current);\n\n const look = new THREE.Vector2(Math.tanh(nxm), Math.tanh(nym));\n\n const faceSize = Math.min(1, Math.hypot(box.width / vw, box.height / vh));\n const depthScale = 1 + depthResponse * (faceSize - 0.25);\n lookTarget.current.copy(look.multiplyScalar(depthScale));\n\n const leftEye = res.landmarks.getLeftEye();\n const rightEye = res.landmarks.getRightEye();\n const lc = centroid(leftEye);\n const rc = centroid(rightEye);\n const tilt = Math.atan2(rc.y - lc.y, rc.x - lc.x);\n medianPush(bufT.current, tilt, 5);\n tiltTarget.current = median(bufT.current);\n\n const nose = res.landmarks.getNose();\n const tip = nose[nose.length - 1] || nose[Math.floor(nose.length / 2)];\n const jaw = res.landmarks.getJawOutline();\n const leftCheek = jaw[3] || jaw[2];\n const rightCheek = jaw[13] || jaw[14];\n const dL = dist2(tip, leftCheek);\n const dR = dist2(tip, rightCheek);\n const eyeDist = Math.hypot(rc.x - lc.x, rc.y - lc.y) + 1e-6;\n let yawSignal = THREE.MathUtils.clamp((dR - dL) / (eyeDist * 1.6), -1, 1);\n yawSignal = Math.tanh(yawSignal);\n medianPush(bufYaw.current, yawSignal, 5);\n yawTarget.current = median(bufYaw.current);\n\n setUiFaceActive(true);\n } else {\n setUiFaceActive(false);\n }\n } catch {\n setUiFaceActive(false);\n }\n }\n\n if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {\n video.requestVideoFrameCallback(() => detect(performance.now()));\n } else {\n requestAnimationFrame(detect);\n }\n };\n\n requestAnimationFrame(detect);\n };\n\n start();\n\n return () => {\n stop = true;\n if (video) {\n const stream = video.srcObject;\n if (stream) stream.getTracks().forEach(t => t.stop());\n video.pause();\n video.srcObject = null;\n }\n };\n }, [enableWebcam, modelsReady, depthResponse]);\n\n return (\n