├── .DS_Store ├── .vscode └── settings.json ├── README.md ├── assets ├── .DS_Store └── siteOGImage.png ├── chromatic-shader.js ├── geometry.js ├── index.html ├── main.js ├── styles.css └── voice-control.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collidingScopes/iron-interface/21400f57c71844a8384ede8050681db9528e9266/.DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveServer.settings.port": 5501 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Iron Interface 2 | 3 | An interactive 3D particle visualization controlled through hand gestures and voice. 4 | 5 | [Live Demo](https://collidingscopes.github.io/iron-interface/) | [Video Example](https://www.instagram.com/reel/DH9oY0sR4sJ/) 6 | 7 | 8 | 9 | ## Features 10 | 11 | - Control the camera with your hands 12 | - Right hand: pinch to zoom in/out 13 | - Left hand: rotate to orbit the camera 14 | - Speak to change to a new pattern ("Jarvis, change to a sphere") 15 | 16 | ## Technology 17 | 18 | Built with Three.js, MediaPipe Hand Tracking, and Web Speech API. 19 | 20 | ## Related Projects 21 | 22 | You might also like some of my other open source projects: 23 | 24 | - [Threejs shape creator](https://collidingScopes.github.io/shape-creator-tutorial) - create / control 3D shapes with threejs and MediaPipe computer vision 25 | - [Threejs hand tracking tutorial](https://collidingScopes.github.io/threejs-handtracking-101) - Basic hand tracking setup with threejs and MediaPipe computer vision 26 | - [Particular Drift](https://collidingScopes.github.io/particular-drift) - Turn photos into flowing particle animations 27 | - [Liquid Logo](https://collidingScopes.github.io/liquid-logo) - Transform logos and icons into liquid metal animations 28 | - [Video-to-ASCII](https://collidingScopes.github.io/ascii) - Convert videos into ASCII pixel art 29 | 30 | ## Contact 31 | 32 | - Instagram: [@stereo.drift](https://www.instagram.com/stereo.drift/) 33 | - Twitter/X: [@measure_plan](https://x.com/measure_plan) 34 | - Email: [stereodriftvisuals@gmail.com](mailto:stereodriftvisuals@gmail.com) 35 | - GitHub: [collidingScopes](https://github.com/collidingScopes) 36 | 37 | ## Donations 38 | 39 | If you found this tool useful, feel free to buy me a coffee. 40 | 41 | My name is Alan, and I enjoy building open source software for computer vision, games, and more. This would be much appreciated during late-night coding sessions! 42 | 43 | [![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/stereoDrift) -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collidingScopes/iron-interface/21400f57c71844a8384ede8050681db9528e9266/assets/.DS_Store -------------------------------------------------------------------------------- /assets/siteOGImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/collidingScopes/iron-interface/21400f57c71844a8384ede8050681db9528e9266/assets/siteOGImage.png -------------------------------------------------------------------------------- /chromatic-shader.js: -------------------------------------------------------------------------------- 1 | // chromatic-shader.js 2 | const ChromaticAberrationShader = { 3 | uniforms: { 4 | "tDiffuse": { value: null }, 5 | "resolution": { value: new THREE.Vector2(1, 1) }, 6 | "strength": { value: 0.5 } // Strength of the chromatic aberration effect 7 | }, 8 | 9 | vertexShader: /* glsl */` 10 | varying vec2 vUv; 11 | void main() { 12 | vUv = uv; 13 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 14 | } 15 | `, 16 | 17 | fragmentShader: /* glsl */` 18 | uniform sampler2D tDiffuse; 19 | uniform vec2 resolution; 20 | uniform float strength; 21 | varying vec2 vUv; 22 | 23 | void main() { 24 | // Calculate distance from center (0.5, 0.5) 25 | vec2 uv = vUv - 0.5; 26 | float dist = length(uv); 27 | 28 | // Apply a smooth falloff near the center 29 | // This creates a "neutral zone" with no chromatic aberration 30 | float factor = smoothstep(0.0, 0.4, dist); 31 | 32 | // Normalize direction vector 33 | vec2 direction = normalize(uv); 34 | 35 | // Scale the offset by both the distance factor and our strength parameter 36 | vec2 redOffset = direction * strength * factor * dist; 37 | vec2 greenOffset = direction * strength * 0.6 * factor * dist; 38 | vec2 blueOffset = direction * strength * 0.3 * factor * dist; 39 | 40 | // Sample each color channel with its offset 41 | float r = texture2D(tDiffuse, vUv - redOffset).r; 42 | float g = texture2D(tDiffuse, vUv - greenOffset).g; 43 | float b = texture2D(tDiffuse, vUv - blueOffset).b; 44 | 45 | gl_FragColor = vec4(r, g, b, 1.0); 46 | } 47 | ` 48 | }; -------------------------------------------------------------------------------- /geometry.js: -------------------------------------------------------------------------------- 1 | // --- PATTERN FUNCTIONS --- 2 | function createGrid(i, count) { 3 | const sideLength = Math.ceil(Math.cbrt(count)); 4 | const spacing = 60 / sideLength; 5 | const halfGrid = (sideLength - 1) * spacing / 2; 6 | 7 | // Determine which side of the cube this particle should be on 8 | const totalSides = 6; // A cube has 6 sides 9 | const pointsPerSide = Math.floor(count / totalSides); 10 | const side = Math.floor(i / pointsPerSide); 11 | const indexOnSide = i % pointsPerSide; 12 | 13 | // Calculate a grid position on a 2D plane 14 | const sideLength2D = Math.ceil(Math.sqrt(pointsPerSide)); 15 | const ix = indexOnSide % sideLength2D; 16 | const iy = Math.floor(indexOnSide / sideLength2D); 17 | 18 | // Map to relative coordinates (0 to 1) 19 | const rx = ix / (sideLength2D - 1 || 1); 20 | const ry = iy / (sideLength2D - 1 || 1); 21 | 22 | // Convert to actual coordinates with proper spacing (-halfGrid to +halfGrid) 23 | const x = rx * spacing * (sideLength - 1) - halfGrid; 24 | const y = ry * spacing * (sideLength - 1) - halfGrid; 25 | 26 | // Place on the appropriate face of the cube 27 | switch(side % totalSides) { 28 | case 0: return new THREE.Vector3(x, y, halfGrid); // Front face 29 | case 1: return new THREE.Vector3(x, y, -halfGrid); // Back face 30 | case 2: return new THREE.Vector3(x, halfGrid, y); // Top face 31 | case 3: return new THREE.Vector3(x, -halfGrid, y); // Bottom face 32 | case 4: return new THREE.Vector3(halfGrid, x, y); // Right face 33 | case 5: return new THREE.Vector3(-halfGrid, x, y); // Left face 34 | default: return new THREE.Vector3(0, 0, 0); 35 | } 36 | } 37 | 38 | function createSphere(i, count) { 39 | // Sphere distribution using spherical coordinates for surface only 40 | const t = i / count; 41 | const phi = Math.acos(2 * t - 1); // Full range from 0 to PI 42 | const theta = 2 * Math.PI * (i / count) * Math.sqrt(count); // Golden ratio distribution 43 | 44 | // Fixed radius for surface-only distribution 45 | const radius = 30; 46 | 47 | return new THREE.Vector3( 48 | Math.sin(phi) * Math.cos(theta) * radius, 49 | Math.sin(phi) * Math.sin(theta) * radius, 50 | Math.cos(phi) * radius 51 | ); 52 | } 53 | 54 | function createSpiral(i, count) { 55 | const t = i / count; 56 | const numArms = 3; 57 | const armIndex = i % numArms; 58 | const angleOffset = (2 * Math.PI / numArms) * armIndex; 59 | const angle = Math.pow(t, 0.7) * 15 + angleOffset; 60 | const radius = t * 40; 61 | 62 | // This is a 2D shape with particles on a thin plane by design 63 | const height = 0; // Set to zero or a very small noise value for thickness 64 | 65 | return new THREE.Vector3( 66 | Math.cos(angle) * radius, 67 | Math.sin(angle) * radius, 68 | height 69 | ); 70 | } 71 | 72 | function createHelix(i, count) { 73 | const numHelices = 2; 74 | const helixIndex = i % numHelices; 75 | const t = Math.floor(i / numHelices) / Math.floor(count / numHelices); 76 | const angle = t * Math.PI * 10; 77 | 78 | // Fixed radius for surface-only distribution 79 | const radius = 15; 80 | const height = (t - 0.5) * 60; 81 | const angleOffset = helixIndex * Math.PI; 82 | 83 | return new THREE.Vector3( 84 | Math.cos(angle + angleOffset) * radius, 85 | Math.sin(angle + angleOffset) * radius, 86 | height 87 | ); 88 | } 89 | 90 | function createTorus(i, count) { 91 | // Torus parameters 92 | const R = 30; // Major radius (distance from center of tube to center of torus) 93 | const r = 10; // Minor radius (radius of the tube) 94 | 95 | // Use a uniform distribution on the torus surface 96 | // by using uniform sampling in the 2 angle parameters 97 | const u = (i / count) * 2 * Math.PI; // Angle around the center of the torus 98 | const v = (i * Math.sqrt(5)) * 2 * Math.PI; // Angle around the tube 99 | 100 | // Parametric equation of a torus 101 | return new THREE.Vector3( 102 | (R + r * Math.cos(v)) * Math.cos(u), 103 | (R + r * Math.cos(v)) * Math.sin(u), 104 | r * Math.sin(v) 105 | ); 106 | } 107 | 108 | function createVortex(i, count) { 109 | // Vortex parameters 110 | const height = 60; // Total height of the vortex 111 | const maxRadius = 35; // Maximum radius at the top 112 | const minRadius = 5; // Minimum radius at the bottom 113 | const numRotations = 3; // Number of full rotations from top to bottom 114 | 115 | // Calculate normalized height position (0 = bottom, 1 = top) 116 | const t = i / count; 117 | 118 | // Add some randomness to distribute particles more naturally 119 | const randomOffset = 0.05 * Math.random(); 120 | const heightPosition = t + randomOffset; 121 | 122 | // Calculate radius that decreases from top to bottom 123 | const radius = minRadius + (maxRadius - minRadius) * heightPosition; 124 | 125 | // Calculate angle with more rotations at the bottom 126 | const angle = numRotations * Math.PI * 2 * (1 - heightPosition) + (i * 0.1); 127 | 128 | // Calculate the vertical position (from bottom to top) 129 | const y = (heightPosition - 0.5) * height; 130 | 131 | return new THREE.Vector3( 132 | Math.cos(angle) * radius, 133 | y, 134 | Math.sin(angle) * radius 135 | ); 136 | } 137 | 138 | function createGalaxy(i, count) { 139 | // Galaxy parameters 140 | const numArms = 4; // Number of spiral arms 141 | const armWidth = 0.15; // Width of each arm (0-1) 142 | const maxRadius = 40; // Maximum radius of the galaxy 143 | const thickness = 5; // Vertical thickness 144 | const twistFactor = 2.5; // How much the arms twist 145 | 146 | // Determine which arm this particle belongs to 147 | const armIndex = i % numArms; 148 | const indexInArm = Math.floor(i / numArms) / Math.floor(count / numArms); 149 | 150 | // Calculate radial distance from center 151 | const radialDistance = indexInArm * maxRadius; 152 | 153 | // Add some randomness for arm width 154 | const randomOffset = (Math.random() * 2 - 1) * armWidth; 155 | 156 | // Calculate angle with twist that increases with distance 157 | const armOffset = (2 * Math.PI / numArms) * armIndex; 158 | const twistAmount = twistFactor * indexInArm; 159 | const angle = armOffset + twistAmount + randomOffset; 160 | 161 | // Add height variation that decreases with distance from center 162 | const verticalPosition = (Math.random() * 2 - 1) * thickness * (1 - indexInArm * 0.8); 163 | 164 | return new THREE.Vector3( 165 | Math.cos(angle) * radialDistance, 166 | verticalPosition, 167 | Math.sin(angle) * radialDistance 168 | ); 169 | } 170 | 171 | function createWave(i, count) { 172 | // Wave/ocean parameters 173 | const width = 60; // Total width of the wave field 174 | const depth = 60; // Total depth of the wave field 175 | const waveHeight = 10; // Maximum height of waves 176 | const waveDensity = 0.1; // Controls wave frequency 177 | 178 | // Create a grid of points (similar to your grid function but for a 2D plane) 179 | const gridSize = Math.ceil(Math.sqrt(count)); 180 | const spacingX = width / gridSize; 181 | const spacingZ = depth / gridSize; 182 | 183 | // Calculate 2D grid position 184 | const ix = i % gridSize; 185 | const iz = Math.floor(i / gridSize); 186 | 187 | // Convert to actual coordinates with proper spacing 188 | const halfWidth = width / 2; 189 | const halfDepth = depth / 2; 190 | const x = ix * spacingX - halfWidth; 191 | const z = iz * spacingZ - halfDepth; 192 | 193 | // Create wave pattern using multiple sine waves for a more natural look 194 | // We use the x and z coordinates to create a position-based wave pattern 195 | const y = Math.sin(x * waveDensity) * Math.cos(z * waveDensity) * waveHeight + 196 | Math.sin(x * waveDensity * 2.5) * Math.cos(z * waveDensity * 2.1) * (waveHeight * 0.3); 197 | 198 | return new THREE.Vector3(x, y, z); 199 | } 200 | 201 | function createMobius(i, count) { 202 | // Möbius strip parameters 203 | const radius = 25; // Major radius of the strip 204 | const width = 10; // Width of the strip 205 | 206 | // Distribute points evenly along the length of the Möbius strip 207 | // and across its width 208 | const lengthSteps = Math.sqrt(count); 209 | const widthSteps = count / lengthSteps; 210 | 211 | // Calculate position along length and width of strip 212 | const lengthIndex = i % lengthSteps; 213 | const widthIndex = Math.floor(i / lengthSteps) % widthSteps; 214 | 215 | // Normalize to 0-1 range 216 | const u = lengthIndex / lengthSteps; // Position around the strip (0 to 1) 217 | const v = (widthIndex / widthSteps) - 0.5; // Position across width (-0.5 to 0.5) 218 | 219 | // Parametric equations for Möbius strip 220 | const theta = u * Math.PI * 2; // Full loop around 221 | 222 | // Calculate the Möbius strip coordinates 223 | // This creates a half-twist in the strip 224 | const x = (radius + width * v * Math.cos(theta / 2)) * Math.cos(theta); 225 | const y = (radius + width * v * Math.cos(theta / 2)) * Math.sin(theta); 226 | const z = width * v * Math.sin(theta / 2); 227 | 228 | return new THREE.Vector3(x, y, z); 229 | } 230 | 231 | function createSupernova(i, count) { 232 | // Supernova parameters 233 | const maxRadius = 40; // Maximum explosion radius 234 | const coreSize = 0.2; // Size of the dense core (0-1) 235 | const outerDensity = 0.7; // Density of particles in outer shell 236 | 237 | // Use golden ratio distribution for even spherical coverage 238 | const phi = Math.acos(1 - 2 * (i / count)); 239 | const theta = Math.PI * 2 * i * (1 + Math.sqrt(5)); 240 | 241 | // Calculate radial distance with more particles near center and at outer shell 242 | let normalizedRadius; 243 | const random = Math.random(); 244 | 245 | if (i < count * coreSize) { 246 | // Dense core - distribute within inner radius 247 | normalizedRadius = Math.pow(random, 0.5) * 0.3; 248 | } else { 249 | // Explosion wave - distribute with more particles at the outer shell 250 | normalizedRadius = 0.3 + Math.pow(random, outerDensity) * 0.7; 251 | } 252 | 253 | // Scale to max radius 254 | const radius = normalizedRadius * maxRadius; 255 | 256 | // Convert spherical to Cartesian coordinates 257 | return new THREE.Vector3( 258 | Math.sin(phi) * Math.cos(theta) * radius, 259 | Math.sin(phi) * Math.sin(theta) * radius, 260 | Math.cos(phi) * radius 261 | ); 262 | } 263 | 264 | function createKleinBottle(i, count) { 265 | // Klein Bottle parameters 266 | const a = 15; // Main radius 267 | const b = 4; // Tube radius 268 | const scale = 2.5; // Overall scale 269 | 270 | // Use uniform distribution across the surface 271 | const lengthSteps = Math.ceil(Math.sqrt(count * 0.5)); 272 | const circSteps = Math.ceil(count / lengthSteps); 273 | 274 | // Calculate position in the parametric space 275 | const lengthIndex = i % lengthSteps; 276 | const circIndex = Math.floor(i / lengthSteps) % circSteps; 277 | 278 | // Normalize to appropriate ranges 279 | const u = (lengthIndex / lengthSteps) * Math.PI * 2; // 0 to 2π 280 | const v = (circIndex / circSteps) * Math.PI * 2; // 0 to 2π 281 | 282 | // Klein Bottle parametric equation 283 | let x, y, z; 284 | 285 | // The Klein Bottle has different regions with different parametric equations 286 | if (u < Math.PI) { 287 | // First half (handle and transition region) 288 | x = scale * (a * (1 - Math.cos(u) / 2) * Math.cos(v) - b * Math.sin(u) / 2); 289 | y = scale * (a * (1 - Math.cos(u) / 2) * Math.sin(v)); 290 | z = scale * (a * Math.sin(u) / 2 + b * Math.sin(u) * Math.cos(v)); 291 | } else { 292 | // Second half (main bottle body) 293 | x = scale * (a * (1 + Math.cos(u) / 2) * Math.cos(v) + b * Math.sin(u) / 2); 294 | y = scale * (a * (1 + Math.cos(u) / 2) * Math.sin(v)); 295 | z = scale * (-a * Math.sin(u) / 2 + b * Math.sin(u) * Math.cos(v)); 296 | } 297 | 298 | return new THREE.Vector3(x, y, z); 299 | } 300 | 301 | function createFlower(i, count) { 302 | // Flower/Dandelion parameters 303 | const numPetals = 12; // Number of petals 304 | const petalLength = 25; // Length of petals 305 | const centerRadius = 10; // Radius of center sphere 306 | const petalWidth = 0.3; // Width of petals (0-1) 307 | const petalCurve = 0.6; // How much petals curve outward (0-1) 308 | 309 | // Calculate whether this particle is in the center or on a petal 310 | const centerParticleCount = Math.floor(count * 0.3); // 30% of particles in center 311 | const isCenter = i < centerParticleCount; 312 | 313 | if (isCenter) { 314 | // Center particles form a sphere 315 | const t = i / centerParticleCount; 316 | const phi = Math.acos(2 * t - 1); 317 | const theta = 2 * Math.PI * i * (1 + Math.sqrt(5)); // Golden ratio distribution 318 | 319 | // Create a sphere for the center 320 | return new THREE.Vector3( 321 | Math.sin(phi) * Math.cos(theta) * centerRadius, 322 | Math.sin(phi) * Math.sin(theta) * centerRadius, 323 | Math.cos(phi) * centerRadius 324 | ); 325 | } else { 326 | // Petal particles 327 | const petalParticleCount = count - centerParticleCount; 328 | const petalIndex = i - centerParticleCount; 329 | 330 | // Determine which petal this particle belongs to 331 | const petalId = petalIndex % numPetals; 332 | const positionInPetal = Math.floor(petalIndex / numPetals) / Math.floor(petalParticleCount / numPetals); 333 | 334 | // Calculate angle of this petal 335 | const petalAngle = (petalId / numPetals) * Math.PI * 2; 336 | 337 | // Calculate radial distance from center 338 | // Use a curve so particles are denser at tip and base 339 | const radialT = Math.pow(positionInPetal, 0.7); // Adjust density along petal 340 | const radialDist = centerRadius + (petalLength * radialT); 341 | 342 | // Calculate width displacement (thicker at base, thinner at tip) 343 | const widthFactor = petalWidth * (1 - radialT * 0.7); 344 | const randomWidth = (Math.random() * 2 - 1) * widthFactor * petalLength; 345 | 346 | // Calculate curve displacement (petals curve outward) 347 | const curveFactor = petalCurve * Math.sin(positionInPetal * Math.PI); 348 | 349 | // Convert to Cartesian coordinates 350 | // Main direction follows the petal angle 351 | const x = Math.cos(petalAngle) * radialDist + 352 | Math.cos(petalAngle + Math.PI/2) * randomWidth; 353 | 354 | const y = Math.sin(petalAngle) * radialDist + 355 | Math.sin(petalAngle + Math.PI/2) * randomWidth; 356 | 357 | // Z coordinate creates the upward curve of petals 358 | const z = curveFactor * petalLength * (1 - Math.cos(positionInPetal * Math.PI)); 359 | 360 | return new THREE.Vector3(x, y, z); 361 | } 362 | } 363 | 364 | function createFractalTree(i, count) { 365 | // Fractal Tree parameters 366 | const trunkLength = 35; // Initial trunk length 367 | const branchRatio = 0.67; // Each branch is this ratio of parent length 368 | const maxDepth = 6; // Maximum branching depth 369 | const branchAngle = Math.PI / 5; // Angle between branches (36 degrees) 370 | 371 | // Pre-calculate the total particles needed per depth level 372 | // Distribute particles more towards deeper levels 373 | const particlesPerLevel = []; 374 | let totalWeight = 0; 375 | 376 | for (let depth = 0; depth <= maxDepth; depth++) { 377 | // More branches at deeper levels, distribute particles accordingly 378 | // Each level has 2^depth branches 379 | const branches = Math.pow(2, depth); 380 | const weight = branches * Math.pow(branchRatio, depth); 381 | totalWeight += weight; 382 | particlesPerLevel.push(weight); 383 | } 384 | 385 | // Normalize to get actual count per level 386 | let cumulativeCount = 0; 387 | const particleCount = []; 388 | 389 | for (let depth = 0; depth <= maxDepth; depth++) { 390 | const levelCount = Math.floor((particlesPerLevel[depth] / totalWeight) * count); 391 | particleCount.push(levelCount); 392 | cumulativeCount += levelCount; 393 | } 394 | 395 | // Adjust the last level to ensure we use exactly count particles 396 | particleCount[maxDepth] += (count - cumulativeCount); 397 | 398 | // Determine which depth level this particle belongs to 399 | let depth = 0; 400 | let levelStartIndex = 0; 401 | 402 | while (depth < maxDepth && i >= levelStartIndex + particleCount[depth]) { 403 | levelStartIndex += particleCount[depth]; 404 | depth++; 405 | } 406 | 407 | // Calculate the relative index within this depth level 408 | const indexInLevel = i - levelStartIndex; 409 | const levelCount = particleCount[depth]; 410 | 411 | // Calculate position parameters 412 | const t = indexInLevel / (levelCount || 1); // Normalized position in level 413 | 414 | // For the trunk (depth 0) 415 | if (depth === 0) { 416 | // Simple line for the trunk 417 | return new THREE.Vector3( 418 | (Math.random() * 2 - 1) * 0.5, // Small random spread for thickness 419 | -trunkLength / 2 + t * trunkLength, 420 | (Math.random() * 2 - 1) * 0.5 // Small random spread for thickness 421 | ); 422 | } 423 | 424 | // For branches at higher depths 425 | // Determine which branch in the current depth 426 | const branchCount = Math.pow(2, depth); 427 | const branchIndex = Math.floor(t * branchCount) % branchCount; 428 | const positionInBranch = (t * branchCount) % 1; 429 | 430 | // Calculate the position based on branch path 431 | let x = 0, y = trunkLength / 2, z = 0; // Start at top of trunk 432 | let currentLength = trunkLength; 433 | let currentAngle = 0; 434 | 435 | // For the first depth level (branching from trunk) 436 | if (depth >= 1) { 437 | currentLength *= branchRatio; 438 | // Determine left or right branch 439 | currentAngle = (branchIndex % 2 === 0) ? branchAngle : -branchAngle; 440 | 441 | // Move up the branch 442 | x += Math.sin(currentAngle) * currentLength * positionInBranch; 443 | y += Math.cos(currentAngle) * currentLength * positionInBranch; 444 | } 445 | 446 | // For higher depths, calculate the full path 447 | for (let d = 2; d <= depth; d++) { 448 | currentLength *= branchRatio; 449 | 450 | // Determine branch direction at this depth 451 | // Use bit operations to determine left vs right at each branch 452 | const pathBit = (branchIndex >> (depth - d)) & 1; 453 | const nextAngle = pathBit === 0 ? branchAngle : -branchAngle; 454 | 455 | // Only apply movement for the branches we've completed 456 | if (d < depth) { 457 | // Rotate the current direction and move full branch length 458 | currentAngle += nextAngle; 459 | x += Math.sin(currentAngle) * currentLength; 460 | y += Math.cos(currentAngle) * currentLength; 461 | } else { 462 | // For the final branch, move partially based on positionInBranch 463 | currentAngle += nextAngle; 464 | x += Math.sin(currentAngle) * currentLength * positionInBranch; 465 | y += Math.cos(currentAngle) * currentLength * positionInBranch; 466 | } 467 | } 468 | 469 | // Add small random offsets for volume 470 | const randomSpread = 0.8 * (1 - Math.pow(branchRatio, depth)); 471 | x += (Math.random() * 2 - 1) * randomSpread; 472 | z += (Math.random() * 2 - 1) * randomSpread; 473 | 474 | return new THREE.Vector3(x, y, z); 475 | } 476 | 477 | function createVoronoi(i, count) { 478 | // Voronoi parameters 479 | const radius = 30; // Maximum radius of the sphere to place points on 480 | const numSites = 25; // Number of Voronoi sites (cells) 481 | const cellThickness = 2.5; // Thickness of the cell boundaries 482 | const jitter = 0.5; // Random jitter to make edges look more natural 483 | 484 | // First, we generate fixed pseudorandom Voronoi sites (cell centers) 485 | // We use a deterministic approach to ensure sites are the same for each call 486 | const sites = []; 487 | for (let s = 0; s < numSites; s++) { 488 | // Use a specific seed formula for each site to ensure repeatability 489 | const seed1 = Math.sin(s * 42.5) * 10000; 490 | const seed2 = Math.cos(s * 15.3) * 10000; 491 | const seed3 = Math.sin(s * 33.7) * 10000; 492 | 493 | // Generate points on a sphere using spherical coordinates 494 | const theta = 2 * Math.PI * (seed1 - Math.floor(seed1)); 495 | const phi = Math.acos(2 * (seed2 - Math.floor(seed2)) - 1); 496 | 497 | sites.push(new THREE.Vector3( 498 | Math.sin(phi) * Math.cos(theta) * radius, 499 | Math.sin(phi) * Math.sin(theta) * radius, 500 | Math.cos(phi) * radius 501 | )); 502 | } 503 | 504 | // Now we generate points that lie primarily along the boundaries between Voronoi cells 505 | 506 | // First, decide if this is a site point (center of a cell) or a boundary point 507 | const sitePoints = Math.min(numSites, Math.floor(count * 0.1)); // 10% of points are sites 508 | 509 | if (i < sitePoints) { 510 | // Place this point at a Voronoi site center 511 | const siteIndex = i % sites.length; 512 | const site = sites[siteIndex]; 513 | 514 | // Return the site position with small random variation 515 | return new THREE.Vector3( 516 | site.x + (Math.random() * 2 - 1) * jitter, 517 | site.y + (Math.random() * 2 - 1) * jitter, 518 | site.z + (Math.random() * 2 - 1) * jitter 519 | ); 520 | } else { 521 | // This is a boundary point 522 | // Generate a random point on the sphere 523 | const u = Math.random(); 524 | const v = Math.random(); 525 | const theta = 2 * Math.PI * u; 526 | const phi = Math.acos(2 * v - 1); 527 | 528 | const point = new THREE.Vector3( 529 | Math.sin(phi) * Math.cos(theta) * radius, 530 | Math.sin(phi) * Math.sin(theta) * radius, 531 | Math.cos(phi) * radius 532 | ); 533 | 534 | // Find the two closest sites to this point 535 | let closestDist = Infinity; 536 | let secondClosestDist = Infinity; 537 | let closestSite = null; 538 | let secondClosestSite = null; 539 | 540 | for (const site of sites) { 541 | const dist = point.distanceTo(site); 542 | 543 | if (dist < closestDist) { 544 | secondClosestDist = closestDist; 545 | secondClosestSite = closestSite; 546 | closestDist = dist; 547 | closestSite = site; 548 | } else if (dist < secondClosestDist) { 549 | secondClosestDist = dist; 550 | secondClosestSite = site; 551 | } 552 | } 553 | 554 | // Check if this point is near the boundary between the two closest cells 555 | const distDiff = Math.abs(closestDist - secondClosestDist); 556 | 557 | if (distDiff < cellThickness) { 558 | // This point is on a boundary 559 | 560 | // Add small random jitter to make the boundary look more natural 561 | point.x += (Math.random() * 2 - 1) * jitter; 562 | point.y += (Math.random() * 2 - 1) * jitter; 563 | point.z += (Math.random() * 2 - 1) * jitter; 564 | 565 | // Project the point back onto the sphere 566 | point.normalize().multiplyScalar(radius); 567 | 568 | return point; 569 | } else { 570 | // Not a boundary point, retry with a different approach 571 | // Move the point slightly toward the boundary 572 | const midpoint = new THREE.Vector3().addVectors(closestSite, secondClosestSite).multiplyScalar(0.5); 573 | const dirToMid = new THREE.Vector3().subVectors(midpoint, point).normalize(); 574 | 575 | // Move point toward the midpoint between cells 576 | point.add(dirToMid.multiplyScalar(distDiff * 0.7)); 577 | 578 | // Add small random jitter 579 | point.x += (Math.random() * 2 - 1) * jitter; 580 | point.y += (Math.random() * 2 - 1) * jitter; 581 | point.z += (Math.random() * 2 - 1) * jitter; 582 | 583 | // Project back onto the sphere 584 | point.normalize().multiplyScalar(radius); 585 | 586 | return point; 587 | } 588 | } 589 | } 590 | 591 | function createParticleTexture() { 592 | const canvas = document.createElement('canvas'); 593 | canvas.width = 64; 594 | canvas.height = 64; 595 | 596 | const context = canvas.getContext('2d'); 597 | const gradient = context.createRadialGradient( 598 | canvas.width / 2, 599 | canvas.height / 2, 600 | 0, 601 | canvas.width / 2, 602 | canvas.height / 2, 603 | canvas.width / 2 604 | ); 605 | 606 | gradient.addColorStop(0, 'rgba(255,255,255,1)'); 607 | gradient.addColorStop(0.2, 'rgba(255,255,255,0.8)'); 608 | gradient.addColorStop(0.4, 'rgba(255,255,255,0.4)'); 609 | gradient.addColorStop(1, 'rgba(255,255,255,0)'); 610 | 611 | context.fillStyle = gradient; 612 | context.fillRect(0, 0, canvas.width, canvas.height); 613 | 614 | const texture = new THREE.Texture(canvas); 615 | texture.needsUpdate = true; 616 | return texture; 617 | } 618 | 619 | // --- COLOR PALETTES --- 620 | const colorPalettes = [ 621 | [ new THREE.Color(0x3399ff), new THREE.Color(0x44ccff), new THREE.Color(0x0055cc) ], 622 | [ new THREE.Color(0xff3399), new THREE.Color(0xcc00ff), new THREE.Color(0x660099), new THREE.Color(0xaa33ff) ], 623 | [ new THREE.Color(0x33ff99), new THREE.Color(0x33ff99), new THREE.Color(0x99ff66), new THREE.Color(0x008844) ], 624 | [ new THREE.Color(0xff9933), new THREE.Color(0xffcc33), new THREE.Color(0xff6600), new THREE.Color(0xffaa55) ], 625 | [ new THREE.Color(0x9933ff), new THREE.Color(0xff66aa), new THREE.Color(0xff0066), new THREE.Color(0xcc0055) ] 626 | ]; 627 | 628 | // --- PARTICLE SYSTEM --- 629 | function createParticleSystem() { 630 | const geometry = new THREE.BufferGeometry(); 631 | const positions = new Float32Array(params.particleCount * 3); 632 | const colors = new Float32Array(params.particleCount * 3); 633 | const sizes = new Float32Array(params.particleCount); 634 | 635 | const initialPattern = patterns[0]; 636 | const initialPalette = colorPalettes[0]; 637 | 638 | for (let i = 0; i < params.particleCount; i++) { 639 | 640 | const pos = initialPattern(i, params.particleCount); 641 | positions[i * 3] = pos.x; 642 | positions[i * 3 + 1] = pos.y; 643 | positions[i * 3 + 2] = pos.z; 644 | 645 | const baseColor = initialPalette[0]; 646 | const variation = 1.0; // Add variation 647 | 648 | colors[i * 3] = baseColor.r * variation; 649 | colors[i * 3 + 1] = baseColor.g * variation; 650 | colors[i * 3 + 2] = baseColor.b * variation; 651 | 652 | sizes[i] = 1.0; // Assign individual size variation 653 | } 654 | 655 | geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); 656 | geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); 657 | geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); // Store base sizes 658 | geometry.userData.currentColors = new Float32Array(colors); // Store initial colors for transitions 659 | 660 | const material = new THREE.PointsMaterial({ 661 | size: params.particleSize, 662 | vertexColors: true, 663 | transparent: true, 664 | opacity: 0.5, 665 | blending: THREE.AdditiveBlending, 666 | sizeAttenuation: true, // Make distant particles smaller 667 | 668 | //map: createParticleTexture() 669 | // depthWrite: false // Often needed with AdditiveBlending if particles overlap strangely 670 | }); 671 | return new THREE.Points(geometry, material); 672 | 673 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Iron Interface 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 |
40 | 52 |
53 | 54 |
55 | 56 |
57 | Right hand: pinch to zoom
58 | Left hand: rotate to orbit camera
59 | Speak to change pattern
60 | (Allow webcam / mic access) 61 |
62 | 63 | 68 | 69 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Different hand motion to control wave intensity 3 | Hand motion to switch colors 4 | Blur shader? 5 | */ 6 | 7 | // main.js 8 | const patterns = [createGrid, createSphere, createSpiral, 9 | createHelix, createTorus, createGalaxy, createWave, 10 | createSupernova, createFlower, createVoronoi,]; 11 | 12 | const patternNames = ["Cube", "Sphere", "Spiral", 13 | "Helix", "Donut", "Galaxy", "Wave", 14 | "Supernova", "Flower", "Cluster", 15 | ]; 16 | 17 | let hands; 18 | let handDetected = false; // Flag if *any* hand is detected 19 | let isLeftHandPresent = false; 20 | let isRightHandPresent = false; 21 | let leftHandLandmarks = null; 22 | let rightHandLandmarks = null; 23 | 24 | let targetCameraZ = 100; // Target Z position for smooth zoom - increased for wider view 25 | const MIN_CAMERA_Z = 20; 26 | const MAX_CAMERA_Z = 200; 27 | const MIN_PINCH_DIST = 0.04; // Minimum distance between thumb and index for max zoom-in 28 | const MAX_PINCH_DIST = 0.16; // Maximum distance for max zoom-out (adjust based on testing) 29 | 30 | let lastPatternChangeTime = 0; 31 | const patternChangeCooldown = 1500; 32 | 33 | const clock = new THREE.Clock(); 34 | 35 | // Clap detection variables 36 | let lastHandDistance = Infinity; 37 | let handsComeCloser = false; 38 | let handsMovingApart = false; 39 | let clapDetected = false; 40 | let lastClapTime = 0; 41 | const MIN_HANDS_DISTANCE = 0.12; // Threshold for hands being close enough 42 | const CLAP_COOLDOWN = 1500; // Cooldown between claps (ms) 43 | 44 | let targetCameraAngleX = 0; // Target horizontal rotation angle (radians) based on hand 45 | let currentCameraAngleX = 0; // Current smoothed horizontal rotation angle 46 | let initialHandAngle = null; // Store initial angle when right hand appears 47 | const rotationSensitivity = 0.8; // Increased sensitivity for more noticeable rotation 48 | const rotationSmoothing = 0.03; // Smoothing factor for rotation (lower = smoother) 49 | 50 | let targetCameraAngleY = 0; // Target vertical rotation angle (radians) 51 | let currentCameraAngleY = 0; // Current smoothed vertical rotation angle 52 | const maxYAngle = Math.PI / 4; // Limit the vertical rotation to prevent flipping (45 degrees) 53 | let initialHandYPosition = null; // Store initial Y position when right hand appears 54 | const yRotationSensitivity = 0.5; // Sensitivity for Y rotation (lower than X for more control) 55 | 56 | let canvasCtx, canvasElement, videoElement; 57 | let scene, camera, renderer, particles; 58 | let composer; 59 | let time = 0; 60 | let currentPattern = 0; 61 | let transitionProgress = 0; 62 | let isTransitioning = false; 63 | let gui; 64 | 65 | const params = { 66 | particleCount: 15000, 67 | transitionSpeed: 0.005, 68 | cameraSpeed: 0.0, // Set to 0 to disable default camera movement 69 | waveIntensity: 0.0, 70 | particleSize: 0.5, 71 | changePattern: function() { 72 | forcePatternChange(); 73 | } 74 | }; 75 | 76 | // --- START Execution --- 77 | // Use DOMContentLoaded to ensure HTML is parsed and elements are available 78 | function startExperience() { 79 | // Basic check for THREE core first 80 | if (typeof THREE === 'undefined') { 81 | console.error("THREE.js core library not found!"); 82 | alert("Error: THREE.js library failed to load."); 83 | return; 84 | } 85 | 86 | // Proceed with initialization 87 | init(); 88 | if (renderer) { 89 | animate(); 90 | } else { 91 | console.error("Renderer initialization failed. Animation cannot start."); 92 | } 93 | } 94 | 95 | window.onload = startExperience; 96 | 97 | // --- INIT THREE.JS --- 98 | function init() { 99 | scene = new THREE.Scene(); 100 | camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1500); 101 | camera.position.z = targetCameraZ; // Starting with a wider default view 102 | 103 | renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); 104 | renderer.setSize(window.innerWidth, window.innerHeight); 105 | renderer.setPixelRatio(window.devicePixelRatio); 106 | 107 | const container = document.getElementById('container'); 108 | if (container) { 109 | container.appendChild(renderer.domElement); 110 | } else { 111 | console.error("HTML element with id 'container' not found!"); 112 | return; 113 | } 114 | 115 | particles = createParticleSystem(); 116 | scene.add(particles); 117 | window.addEventListener('resize', onWindowResize); 118 | initGUI(); 119 | 120 | // --- Get references for drawing --- 121 | videoElement = document.querySelector('.input_video'); 122 | canvasElement = document.querySelector('.output_canvas'); 123 | if (canvasElement) { 124 | canvasCtx = canvasElement.getContext('2d'); 125 | } else { 126 | console.error("Output canvas element not found!"); 127 | } 128 | // --- 129 | 130 | setupBloom(); 131 | setupHandTracking(); // Setup hand tracking last 132 | } 133 | 134 | // --- WINDOW RESIZE --- 135 | function onWindowResize() { 136 | if (!camera || !renderer) return; 137 | 138 | camera.aspect = window.innerWidth / window.innerHeight; 139 | camera.updateProjectionMatrix(); 140 | renderer.setSize(window.innerWidth, window.innerHeight); 141 | renderer.setPixelRatio(window.devicePixelRatio); 142 | 143 | // Update composer size if it exists 144 | if (composer) { 145 | composer.setSize(window.innerWidth, window.innerHeight); 146 | } 147 | } 148 | 149 | // --- PATTERN CHANGE / TRANSITION --- 150 | function forcePatternChange() { 151 | if (isTransitioning) { 152 | completeCurrentTransition(); // Finish current transition instantly 153 | } 154 | const nextPattern = (currentPattern + 1) % patterns.length; 155 | transitionToPattern(nextPattern); 156 | } 157 | 158 | function completeCurrentTransition() { 159 | if (!isTransitioning || !particles || !particles.geometry || !particles.userData.toPositions || !particles.userData.toColors) { 160 | // Clear transition state if data is missing or geometry invalid 161 | isTransitioning = false; 162 | transitionProgress = 0; 163 | if (particles && particles.userData) { 164 | delete particles.userData.fromPositions; 165 | delete particles.userData.toPositions; 166 | delete particles.userData.fromColors; 167 | delete particles.userData.toColors; 168 | delete particles.userData.targetPattern; 169 | } 170 | return; 171 | } 172 | 173 | const positions = particles.geometry.attributes.position.array; 174 | const colors = particles.geometry.attributes.color.array; 175 | 176 | // Ensure arrays are valid before setting 177 | if (positions && colors && 178 | particles.userData.toPositions && particles.userData.toColors && 179 | positions.length === particles.userData.toPositions.length && 180 | colors.length === particles.userData.toColors.length) { 181 | positions.set(particles.userData.toPositions); 182 | colors.set(particles.userData.toColors); 183 | particles.geometry.userData.currentColors = new Float32Array(particles.userData.toColors); // Update stored colors 184 | particles.geometry.attributes.position.needsUpdate = true; 185 | particles.geometry.attributes.color.needsUpdate = true; 186 | currentPattern = particles.userData.targetPattern; // Update current pattern index 187 | } else { 188 | console.error("Transition data length mismatch or invalid data on completion!"); 189 | } 190 | 191 | // Clean up transition data 192 | delete particles.userData.fromPositions; 193 | delete particles.userData.toPositions; 194 | delete particles.userData.fromColors; 195 | delete particles.userData.toColors; 196 | delete particles.userData.targetPattern; 197 | isTransitioning = false; 198 | transitionProgress = 0; 199 | } 200 | 201 | function transitionToPattern(newPattern) { 202 | if (!particles || !particles.geometry || !particles.geometry.attributes.position) return; 203 | 204 | isTransitioning = true; 205 | const posAttr = particles.geometry.attributes.position; 206 | const colAttr = particles.geometry.attributes.color; 207 | 208 | // Ensure current colors are stored correctly before starting 209 | if (!particles.geometry.userData.currentColors || particles.geometry.userData.currentColors.length !== colAttr.array.length) { 210 | particles.geometry.userData.currentColors = new Float32Array(colAttr.array); 211 | } 212 | 213 | const curPos = new Float32Array(posAttr.array); 214 | const curCol = new Float32Array(particles.geometry.userData.currentColors); // Use stored colors as 'from' 215 | 216 | const newPos = new Float32Array(curPos.length); 217 | const patternFn = patterns[newPattern]; 218 | const count = params.particleCount; 219 | 220 | // Generate new positions 221 | for (let i = 0; i < count; i++) { 222 | const p = patternFn(i, count); 223 | newPos[i * 3] = p.x; 224 | newPos[i * 3 + 1] = p.y; 225 | newPos[i * 3 + 2] = p.z; 226 | } 227 | 228 | // Generate new colors 229 | const newCol = new Float32Array(curCol.length); 230 | const palette = colorPalettes[newPattern%colorPalettes.length]; 231 | for (let i = 0; i < count; i++) { 232 | const base = palette[0]; 233 | const variation = 1.0; // Keep color variation consistent 234 | newCol[i * 3] = base.r * variation; 235 | newCol[i * 3 + 1] = base.g * variation; 236 | newCol[i * 3 + 2] = base.b * variation; 237 | } 238 | 239 | // Store transition data 240 | particles.userData.fromPositions = curPos; 241 | particles.userData.toPositions = newPos; 242 | particles.userData.fromColors = curCol; 243 | particles.userData.toColors = newCol; 244 | particles.userData.targetPattern = newPattern; // Store the target pattern index 245 | transitionProgress = 0; // Reset progress 246 | } 247 | 248 | 249 | // --- Helper function to map a value from one range to another --- 250 | function mapRange(value, inMin, inMax, outMin, outMax) { 251 | // Clamp value to input range 252 | value = Math.max(inMin, Math.min(inMax, value)); 253 | return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; 254 | } 255 | 256 | // --- ANIMATION LOOP --- 257 | function animate() { 258 | requestAnimationFrame(animate); 259 | if (!renderer || !camera || !scene) return; 260 | 261 | const deltaTime = clock.getDelta(); 262 | time += deltaTime; // Keep time updating for other potential uses 263 | 264 | // --- Particle Update --- 265 | if (particles && particles.geometry && particles.geometry.attributes.position) { 266 | const positions = particles.geometry.attributes.position.array; 267 | const count = params.particleCount; 268 | 269 | // Apply wave motion (if not transitioning) 270 | if (!isTransitioning) { 271 | for (let i = 0; i < count; i++) { 272 | const idx = i * 3; 273 | const noise1 = Math.cos(time * 0.5 + i * 0.05) * params.waveIntensity; 274 | const noise2 = Math.sin(time * 0.5 + i * 0.05) * params.waveIntensity; 275 | positions[idx] += noise1 * deltaTime * 20; 276 | positions[idx + 1] += noise2 * deltaTime * 20; 277 | } 278 | particles.geometry.attributes.position.needsUpdate = true; 279 | } 280 | 281 | // --- Transition Logic --- 282 | if (isTransitioning && particles.userData.fromPositions && particles.userData.toPositions && particles.userData.fromColors && particles.userData.toColors) { 283 | transitionProgress += params.transitionSpeed * deltaTime * 60; // Scale speed by frame time (approx) 284 | 285 | if (transitionProgress >= 1.0) { 286 | transitionProgress = 1.0; 287 | completeCurrentTransition(); // Finalize positions and clean up 288 | } else { 289 | const colors = particles.geometry.attributes.color.array; // Get color buffer 290 | const fromPos = particles.userData.fromPositions; 291 | const toPos = particles.userData.toPositions; 292 | const fromCol = particles.userData.fromColors; 293 | const toCol = particles.userData.toColors; 294 | const t = transitionProgress; 295 | const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 296 | 297 | // Check array lengths before interpolation 298 | if (positions && colors && fromPos && toPos && fromCol && toCol && 299 | fromPos.length === positions.length && toPos.length === positions.length && 300 | fromCol.length === colors.length && toCol.length === colors.length) { 301 | 302 | for (let i = 0; i < count; i++) { 303 | const index = i * 3; 304 | // Interpolate positions 305 | positions[index] = fromPos[index] * (1 - ease) + toPos[index] * ease; 306 | positions[index + 1] = fromPos[index + 1] * (1 - ease) + toPos[index + 1] * ease; 307 | positions[index + 2] = fromPos[index + 2] * (1 - ease) + toPos[index + 2] * ease; 308 | // Interpolate colors 309 | colors[index] = fromCol[index] * (1 - ease) + toCol[index] * ease; 310 | colors[index + 1] = fromCol[index + 1] * (1 - ease) + toCol[index + 1] * ease; 311 | colors[index + 2] = fromCol[index + 2] * (1 - ease) + toCol[index + 2] * ease; 312 | } 313 | 314 | particles.geometry.attributes.position.needsUpdate = true; 315 | particles.geometry.attributes.color.needsUpdate = true; 316 | particles.geometry.userData.currentColors = new Float32Array(colors); // Update during transition 317 | 318 | } else { 319 | console.error("Transition data length mismatch or invalid data during interpolation!"); 320 | completeCurrentTransition(); // Attempt to recover by completing 321 | } 322 | } 323 | } 324 | } 325 | 326 | if (camera) { 327 | // --- Smooth Zoom Distance (Driven by Left Hand Pinch) --- 328 | const zoomSpeed = 0.04; 329 | const currentDistance = camera.position.length() > 0.1 ? camera.position.length() : targetCameraZ; 330 | let smoothedDistance = currentDistance + (targetCameraZ - currentDistance) * zoomSpeed; 331 | smoothedDistance = Math.max(MIN_CAMERA_Z, Math.min(MAX_CAMERA_Z, smoothedDistance)); 332 | 333 | // --- Smooth Rotation Angle (X and Y axes) --- 334 | // X-axis (horizontal rotation) 335 | let deltaAngleX = targetCameraAngleX - currentCameraAngleX; 336 | while (deltaAngleX > Math.PI) deltaAngleX -= Math.PI * 2; 337 | while (deltaAngleX < -Math.PI) deltaAngleX += Math.PI * 2; 338 | currentCameraAngleX += deltaAngleX * rotationSmoothing; 339 | currentCameraAngleX = (currentCameraAngleX + 3 * Math.PI) % (2 * Math.PI) - Math.PI; 340 | 341 | // Y-axis (vertical rotation) 342 | let deltaAngleY = targetCameraAngleY - currentCameraAngleY; 343 | currentCameraAngleY += deltaAngleY * rotationSmoothing; 344 | 345 | // Clamp Y rotation to prevent flipping 346 | currentCameraAngleY = Math.max(-maxYAngle, Math.min(maxYAngle, currentCameraAngleY)); 347 | 348 | // --- Set Camera Position using Spherical Coordinates --- 349 | // Convert spherical coordinates (distance, xAngle, yAngle) to cartesian (x, y, z) 350 | camera.position.set( 351 | Math.sin(currentCameraAngleX) * Math.cos(currentCameraAngleY) * smoothedDistance, 352 | Math.sin(currentCameraAngleY) * smoothedDistance, 353 | Math.cos(currentCameraAngleX) * Math.cos(currentCameraAngleY) * smoothedDistance 354 | ); 355 | 356 | camera.lookAt(0, 0, 0); // Always look at the center of the scene 357 | } 358 | 359 | if (composer) { 360 | composer.render(); 361 | } else if (renderer && scene && camera) { 362 | renderer.render(scene, camera); 363 | } 364 | } 365 | 366 | // --- DAT.GUI --- 367 | function initGUI() { 368 | // Check if dat exists 369 | if (typeof dat === 'undefined') { 370 | console.warn("dat.GUI library not found. GUI controls will be unavailable."); 371 | return; // Exit if dat.GUI is not loaded 372 | } 373 | 374 | try { 375 | // Create GUI 376 | gui = new dat.GUI({ width: 300 }); 377 | gui.close(); // Start with closed panel 378 | 379 | // --- Animation Parameters --- 380 | const animFolder = gui.addFolder('Animation'); 381 | // Remove cameraSpeed option since we're not using default camera movement 382 | animFolder.add(params, 'waveIntensity', 0, 1, 0.05).name('Wave Intensity'); 383 | animFolder.add(params, 'transitionSpeed', 0.001, 0.05, 0.001).name('Transition Speed'); 384 | animFolder.open(); 385 | 386 | // --- Visual Parameters --- 387 | const visualFolder = gui.addFolder('Visual'); 388 | visualFolder.add(params, 'particleSize', 0.1, 10, 0.1).onChange(function(value) { 389 | if (particles && particles.material) { 390 | particles.material.size = value; 391 | } 392 | }).name('Particle Size'); 393 | visualFolder.open(); 394 | 395 | // --- Pattern Controls --- 396 | gui.add(params, 'changePattern').name('Next Pattern'); 397 | 398 | // Add GUI styling (optional) 399 | const guiElement = document.querySelector('.dg.ac'); 400 | if (guiElement) { 401 | guiElement.style.zIndex = "1000"; // Ensure GUI is above other elements 402 | } 403 | 404 | } catch (error) { 405 | console.error("Error initializing dat.GUI:", error); 406 | if(gui) gui.destroy(); // Clean up partial GUI if error occurred 407 | gui = null; 408 | } 409 | } 410 | 411 | // --- Updated onResults with Pinch and Rotation --- 412 | function onResults(results) { 413 | if (!canvasCtx || !canvasElement || !videoElement || typeof drawConnectors === 'undefined' || typeof drawLandmarks === 'undefined') { 414 | return; 415 | } 416 | 417 | // --- Reset Hand States --- 418 | let wasLeftHandPresent = isLeftHandPresent; // Keep track if hand disappears 419 | let wasRightHandPresent = isRightHandPresent; 420 | isLeftHandPresent = false; 421 | isRightHandPresent = false; 422 | leftHandLandmarks = null; 423 | rightHandLandmarks = null; 424 | handDetected = results.multiHandLandmarks && results.multiHandLandmarks.length > 0; 425 | 426 | // --- Drawing Setup --- 427 | canvasCtx.save(); 428 | canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); 429 | 430 | // --- Process Detected Hands --- 431 | if (handDetected) { 432 | for (let i = 0; i < results.multiHandLandmarks.length; i++) { 433 | // Ensure handedness data exists for the current hand index 434 | if (!results.multiHandedness || !results.multiHandedness[i]) continue; 435 | 436 | const classification = results.multiHandedness[i]; 437 | const landmarks = results.multiHandLandmarks[i]; 438 | const isLeft = classification.label === 'Left'; 439 | 440 | // --- Assign Landmarks based on Handedness --- 441 | if (isLeft) { 442 | isLeftHandPresent = true; 443 | leftHandLandmarks = landmarks; 444 | 445 | // --- Left Hand: Pinch for Zoom --- 446 | if (landmarks && landmarks.length > 8) { // Ensure landmarks are available 447 | const thumbTip = landmarks[4]; 448 | const indexTip = landmarks[8]; 449 | const pinchDist = calculateDistance(thumbTip, indexTip); 450 | 451 | // Map pinch distance to camera Z (zoom) 452 | // Closer pinch = smaller Z (zoom in), wider pinch = larger Z (zoom out) 453 | targetCameraZ = mapRange(pinchDist, MIN_PINCH_DIST, MAX_PINCH_DIST, MIN_CAMERA_Z, MAX_CAMERA_Z); 454 | // Clamp value just in case mapRange doesn't clamp perfectly 455 | targetCameraZ = Math.max(MIN_CAMERA_Z, Math.min(MAX_CAMERA_Z, targetCameraZ)); 456 | 457 | // --- Draw a white line connecting thumb and index finger --- 458 | canvasCtx.beginPath(); 459 | canvasCtx.moveTo(thumbTip.x * canvasElement.width, thumbTip.y * canvasElement.height); 460 | canvasCtx.lineTo(indexTip.x * canvasElement.width, indexTip.y * canvasElement.height); 461 | canvasCtx.strokeStyle = 'red'; 462 | canvasCtx.lineWidth = 5; 463 | canvasCtx.stroke(); 464 | } 465 | 466 | } else { // Right Hand 467 | isRightHandPresent = true; 468 | rightHandLandmarks = landmarks; 469 | 470 | // --- Right Hand: Rotation for Orbit --- 471 | if (landmarks && landmarks.length > 9) { // Ensure landmarks are available 472 | const wrist = landmarks[0]; 473 | const middleMcp = landmarks[9]; 474 | 475 | // Calculate angle of the hand (vector from wrist to middle finger base) 476 | const handAngleRad = Math.atan2(middleMcp.y - wrist.y, middleMcp.x - wrist.x) - (Math.PI / 2); 477 | 478 | // Use the wrist Y position for vertical tilt 479 | const handYPosition = wrist.y; 480 | 481 | // If this is the first frame the right hand is detected, store the initial values 482 | if (!wasRightHandPresent || initialHandAngle === null) { 483 | initialHandAngle = handAngleRad; 484 | initialHandYPosition = handYPosition; 485 | // Reset current camera angles to avoid jumps 486 | currentCameraAngleX = targetCameraAngleX; 487 | currentCameraAngleY = targetCameraAngleY; 488 | } 489 | 490 | // Calculate the change in horizontal angle 491 | let angleDelta = handAngleRad - initialHandAngle; 492 | 493 | // Normalize delta angle to handle wrap-around 494 | while (angleDelta > Math.PI) angleDelta -= Math.PI * 2; 495 | while (angleDelta < -Math.PI) angleDelta += Math.PI * 2; 496 | 497 | // Calculate the change in vertical position 498 | // We invert this because screen Y coordinates increase downward 499 | const yDelta = initialHandYPosition - handYPosition; 500 | 501 | // Update target camera angles based on hand movements 502 | targetCameraAngleX = currentCameraAngleX - (angleDelta * rotationSensitivity); 503 | targetCameraAngleY = currentCameraAngleY + (yDelta * yRotationSensitivity); 504 | 505 | // Clamp Y angle to prevent flipping 506 | targetCameraAngleY = Math.max(-maxYAngle, Math.min(maxYAngle, targetCameraAngleY)); 507 | 508 | // Draw a visual indicator of vertical rotation 509 | if (canvasCtx) { 510 | const yIndicatorX = middleMcp.x * canvasElement.width; 511 | const yIndicatorY = middleMcp.y * canvasElement.height; 512 | const yIndicatorLength = 30 * Math.sin(targetCameraAngleY); 513 | 514 | canvasCtx.beginPath(); 515 | canvasCtx.moveTo(yIndicatorX, yIndicatorY); 516 | canvasCtx.lineTo(yIndicatorX, yIndicatorY + yIndicatorLength); 517 | canvasCtx.strokeStyle = '#FFFF00'; // Yellow 518 | canvasCtx.lineWidth = 4; 519 | canvasCtx.stroke(); 520 | } 521 | } 522 | 523 | // --- Reset initial values if right hand disappears --- 524 | if (!isRightHandPresent) { 525 | initialHandAngle = null; 526 | initialHandYPosition = null; 527 | } 528 | } 529 | 530 | // --- Draw Landmarks (Color-coded: Green=Left, Blue=Right) --- 531 | const color = isLeft ? '#00FF00' : '#0088FF'; // Green lines Left, Blue lines Right 532 | const dotColor = isLeft ? '#FF0044' : '#FFFF00'; // Red dots Left, Yellow dots Right 533 | if (landmarks) { 534 | drawConnectors(canvasCtx, landmarks, HAND_CONNECTIONS, { color: color, lineWidth: 2 }); 535 | drawLandmarks(canvasCtx, landmarks, { color: dotColor, lineWidth: 1, radius: 3 }); 536 | 537 | // --- Draw hand function label --- 538 | const wrist = landmarks[0]; // Use wrist as reference point 539 | const labelText = isLeft ? "Zoom" : "Rotate"; 540 | 541 | // Position label near but slightly offset from the wrist 542 | const labelX = wrist.x * canvasElement.width - (isLeft ? -30 : 30); 543 | const labelY = wrist.y * canvasElement.height - 25; 544 | 545 | // Draw text with shadow for better visibility 546 | canvasCtx.save(); // Save the current state 547 | 548 | // Apply counter-transformation to make text readable (flip horizontally) 549 | canvasCtx.scale(-1, 1); 550 | const flippedX = -labelX; // Negate x position for proper placement after flip 551 | 552 | // Add semi-transparent black rectangle background 553 | canvasCtx.font = "32px 'Courier New', monospace"; // Set font first to measure text 554 | const textMetrics = canvasCtx.measureText(labelText); 555 | const textWidth = textMetrics.width; 556 | const textHeight = 32; // Approximate height based on font size 557 | const padding = 8; // Padding around text 558 | 559 | // Draw the background rectangle 560 | canvasCtx.fillStyle = "rgba(0, 8, 255, 0.6)"; // Semi-transparent black 561 | canvasCtx.fillRect( 562 | flippedX - padding, 563 | labelY - textHeight + padding/2, 564 | textWidth + padding*2, 565 | textHeight + padding 566 | ); 567 | 568 | // Now draw the text on top of the rectangle 569 | canvasCtx.font = "32px 'Courier New', monospace"; 570 | canvasCtx.fillStyle = "white"; // White text 571 | canvasCtx.fillText(labelText, flippedX, labelY); 572 | 573 | canvasCtx.restore(); // Restore the previous state 574 | } 575 | } 576 | 577 | // --- Reset initial angle if right hand disappears --- 578 | if (!isRightHandPresent) { 579 | initialHandAngle = null; 580 | } 581 | 582 | // --- Two-Hand Clap Detection Logic --- 583 | if (isLeftHandPresent && isRightHandPresent && leftHandLandmarks && rightHandLandmarks) { 584 | // Calculate distance between the two index fingertips 585 | const leftIndex = leftHandLandmarks[8]; // Index fingertip 586 | const rightIndex = rightHandLandmarks[8]; // Index fingertip 587 | 588 | // Calculate the Euclidean distance between hands 589 | const dx = leftIndex.x - rightIndex.x; 590 | const dy = leftIndex.y - rightIndex.y; 591 | const handDistance = Math.sqrt(dx * dx + dy * dy); 592 | 593 | const now = Date.now(); 594 | 595 | // Check for the clap gesture pattern: 596 | // 1. Hands coming closer together 597 | // 2. Reaching minimum distance 598 | // 3. Then moving apart again 599 | 600 | // Step 1: Detect hands coming closer 601 | if (handDistance < lastHandDistance - 0.01) { // Added threshold to avoid noise 602 | handsComeCloser = true; 603 | } 604 | 605 | // Step 2: Detect when hands are close enough *after* coming closer 606 | if (handsComeCloser && handDistance < MIN_HANDS_DISTANCE) { 607 | handsMovingApart = true; // Mark that they *were* close 608 | } 609 | 610 | // Step 3: Detect hands moving apart significantly *after* being close 611 | if (handsComeCloser && handsMovingApart && handDistance > lastHandDistance + 0.02) { // Increased threshold 612 | if (now > lastClapTime + CLAP_COOLDOWN) { 613 | console.log("Clap gesture detected!"); 614 | forcePatternChange(); 615 | lastClapTime = now; 616 | 617 | // Reset clap detection state immediately after detection 618 | handsComeCloser = false; 619 | handsMovingApart = false; 620 | lastHandDistance = handDistance; // Update distance to prevent immediate re-trigger 621 | } 622 | } else if (handDistance >= MIN_HANDS_DISTANCE && handsComeCloser && !handsMovingApart) { 623 | // If hands start moving apart before reaching the MIN distance, reset 624 | handsComeCloser = false; 625 | } else if (handDistance >= MIN_HANDS_DISTANCE) { 626 | // Reset if hands are far apart and weren't in the 'close' phase 627 | handsComeCloser = false; 628 | handsMovingApart = false; 629 | } 630 | 631 | // Update for next frame 632 | lastHandDistance = handDistance; 633 | 634 | } else { 635 | // Reset tracking if we don't have both hands 636 | lastHandDistance = Infinity; 637 | handsComeCloser = false; 638 | handsMovingApart = false; 639 | } 640 | } else { 641 | // No hands detected, reset states 642 | initialHandAngle = null; // Reset rotation anchor 643 | lastHandDistance = Infinity; 644 | handsComeCloser = false; 645 | handsMovingApart = false; 646 | } 647 | 648 | canvasCtx.restore(); 649 | } 650 | 651 | function setupHandTracking() { 652 | if (!videoElement || !canvasElement || !canvasCtx) { 653 | console.error("Video or Canvas element not ready for Hand Tracking setup."); 654 | return; 655 | } 656 | 657 | // Check if MediaPipe components are loaded 658 | if (typeof Hands === 'undefined' || typeof Camera === 'undefined' || 659 | typeof drawConnectors === 'undefined' || typeof drawLandmarks === 'undefined') { 660 | console.error("MediaPipe Hands/Camera/Drawing library not found."); 661 | const instructions = document.getElementById('instructions'); 662 | if(instructions) instructions.textContent = "Hand tracking library failed to load."; 663 | return; 664 | } 665 | 666 | // Reset clap detection variables 667 | lastHandDistance = Infinity; 668 | handsComeCloser = false; 669 | handsMovingApart = false; 670 | 671 | try { 672 | hands = new Hands({locateFile: (file) => { 673 | return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; 674 | }}); 675 | 676 | hands.setOptions({ 677 | // --- Track up to two hands --- 678 | maxNumHands: 2, 679 | // --- 680 | modelComplexity: 1, 681 | minDetectionConfidence: 0.6, // Adjusted confidence 682 | minTrackingConfidence: 0.6 683 | }); 684 | 685 | hands.onResults(onResults); 686 | 687 | const camera = new Camera(videoElement, { 688 | onFrame: async () => { 689 | if (videoElement.readyState >= 2) { // HAVE_CURRENT_DATA or more 690 | await hands.send({image: videoElement}); 691 | } 692 | }, 693 | width: 640, // Internal processing resolution 694 | height: 360 695 | }); 696 | 697 | camera.start() 698 | .then(() => console.log("Camera started successfully.")) 699 | .catch(err => { 700 | console.error("Error starting webcam:", err); 701 | const instructions = document.getElementById('instructions'); 702 | if(instructions) instructions.textContent = "Could not access webcam. Please grant permission and reload."; 703 | }); 704 | 705 | console.log("Hand tracking setup complete."); 706 | 707 | } catch (error) { 708 | console.error("Error setting up MediaPipe Hands:", error); 709 | const instructions = document.getElementById('instructions'); 710 | if(instructions) instructions.textContent = "Error initializing hand tracking."; 711 | } 712 | } 713 | 714 | // Modifications to the setupBloom function 715 | function setupBloom() { 716 | // Create a new EffectComposer 717 | composer = new THREE.EffectComposer(renderer); 718 | 719 | // Add the base render pass (required) 720 | const renderPass = new THREE.RenderPass(scene, camera); 721 | composer.addPass(renderPass); 722 | 723 | // Add the UnrealBloomPass with nice default values for particles 724 | const bloomPass = new THREE.UnrealBloomPass( 725 | new THREE.Vector2(window.innerWidth, window.innerHeight), // resolution 726 | 2.0, // strength (intensity of the bloom) 727 | 0.1, // radius (how far the bloom extends) 728 | 0.1, // threshold (minimum brightness to apply bloom) 729 | ); 730 | composer.addPass(bloomPass); 731 | 732 | // Add Chromatic Aberration Effect 733 | const chromaticAberrationPass = new THREE.ShaderPass(ChromaticAberrationShader); 734 | chromaticAberrationPass.uniforms.resolution.value.set(window.innerWidth, window.innerHeight); 735 | chromaticAberrationPass.uniforms.strength.value = 0.5; // Adjust default strength 736 | composer.addPass(chromaticAberrationPass); 737 | 738 | // Add effect controls to the GUI if it exists 739 | if (gui) { 740 | const bloomFolder = gui.addFolder('Bloom Effect'); 741 | bloomFolder.add(bloomPass, 'strength', 0, 3, 0.05).name('Intensity'); 742 | bloomFolder.add(bloomPass, 'radius', 0, 1, 0.05).name('Radius'); 743 | bloomFolder.add(bloomPass, 'threshold', 0, 1, 0.05).name('Threshold'); 744 | bloomFolder.open(); 745 | 746 | // Add Chromatic Aberration controls 747 | const chromaticFolder = gui.addFolder('Chromatic Aberration'); 748 | chromaticFolder.add(chromaticAberrationPass.uniforms.strength, 'value', 0, 0.5, 0.001).name('Strength'); 749 | chromaticFolder.open(); 750 | } 751 | 752 | // Update the resize handler to include the composer 753 | const originalResize = onWindowResize; 754 | onWindowResize = function() { 755 | originalResize(); 756 | if (composer) { 757 | composer.setSize(window.innerWidth, window.innerHeight); 758 | // Update shader resolution uniform when window is resized 759 | if (chromaticAberrationPass) { 760 | chromaticAberrationPass.uniforms.resolution.value.set(window.innerWidth, window.innerHeight); 761 | } 762 | } 763 | }; 764 | } 765 | 766 | function calculateDistance(landmark1, landmark2) { 767 | if (!landmark1 || !landmark2) return Infinity; 768 | const dx = landmark1.x - landmark2.x; 769 | const dy = landmark1.y - landmark2.y; 770 | return Math.sqrt(dx * dx + dy * dy); 771 | } -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: 'Courier New', Courier, monospace; 6 | } 7 | 8 | #container { 9 | position: fixed; 10 | width: 100%; 11 | height: 100%; 12 | background: linear-gradient(180deg, 13 | #00050a 0%, 14 | #000a14 50%, 15 | #001020 100% 16 | ); 17 | overflow: hidden; 18 | } 19 | 20 | .glow { 21 | position: fixed; 22 | top: 0; 23 | left: 0; 24 | width: 100%; 25 | height: 100%; 26 | pointer-events: none; 27 | background: radial-gradient(circle at 50% 50%, 28 | rgba(0, 100, 180, 0.02) 0%, 29 | rgba(30, 0, 100, 0.03) 50%, 30 | transparent 75% 31 | ); 32 | mix-blend-mode: screen; 33 | opacity: 0.5; 34 | } 35 | 36 | #instructions { 37 | position: fixed; 38 | bottom: 10px; 39 | left: 10px; 40 | color: white; 41 | font-size: 13px; 42 | background-color: rgba(0, 0, 0, 0.4); /* Darker background */ 43 | padding: 8px 12px; /* Slightly larger padding */ 44 | border-radius: 5px; /* Rounded corners */ 45 | z-index: 100; 46 | pointer-events: none; 47 | line-height: 1.4; 48 | max-width: 300px; /* Limit width */ 49 | font-family: 'Courier New', Courier, monospace; 50 | } 51 | 52 | .input_video { 53 | /* Make video visible and position top-left */ 54 | display: block; /* Changed from none */ 55 | position: fixed; 56 | top: 10px; 57 | left: 20%; 58 | transform: translateX(50%); 59 | width: 320px; /* Smaller size */ 60 | height: 180px; /* Maintain 16:9 ratio */ 61 | border: 1px solid white; 62 | z-index: 101; /* Ensure it's above the main canvas slightly */ 63 | transform: scaleX(-1); /* Mirror flip */ 64 | } 65 | .output_canvas { 66 | /* Make canvas visible and position over video */ 67 | display: block; /* Changed from none */ 68 | position: fixed; 69 | top: 10px; 70 | left: 20%; 71 | transform: translateX(50%); 72 | width: 320px; /* Smaller size */ 73 | height: 180px; /* Maintain 16:9 ratio */ 74 | z-index: 102; /* Above video */ 75 | pointer-events: none; /* Allow clicks to go through */ 76 | transform: scaleX(-1); /* Mirror flip to match video */ 77 | } 78 | /* dat.gui styling */ 79 | .dg.ac { 80 | z-index: 1000 !important; 81 | position: fixed !important; 82 | top: 0 !important; 83 | right: 0 !important; 84 | margin: 0 !important; 85 | margin: 0 auto; 86 | justify-content: center; 87 | text-align: center; 88 | } 89 | 90 | .dg.main .close-button { 91 | background-color: #000 !important; 92 | margin: 0 auto; 93 | justify-content: center; 94 | text-align: center; 95 | } 96 | 97 | .dg.main { 98 | background-color: rgba(0, 0, 0, 0.8) !important; 99 | margin-right: 0 !important; 100 | max-width: 200px; 101 | text-align: center; 102 | margin: 0 auto; 103 | justify-content: center; 104 | } 105 | 106 | /* Instagram credit link styling */ 107 | #instagram-link { 108 | position: fixed; 109 | bottom: 10px; 110 | right: 10px; 111 | color: white; 112 | font-family: sans-serif; 113 | font-size: 14px; 114 | background-color: rgba(0, 0, 0, 0.4); 115 | padding: 8px 12px; 116 | border-radius: 5px; 117 | z-index: 100; 118 | /* text-decoration: none; */ 119 | transition: all 0.3s ease; 120 | font-family: 'Courier New', Courier, monospace; 121 | } 122 | 123 | a { 124 | color: white; 125 | } 126 | 127 | #instagram-link:hover { 128 | color: #fff; 129 | background-color: rgba(0, 0, 0, 0.6); 130 | /* transform: translateY(-2px); */ 131 | box-shadow: 0 0 15px rgba(255, 255, 255, 0.2); 132 | } 133 | 134 | /* Coffeee link styling */ 135 | #coffee-link { 136 | position: fixed; 137 | bottom: 10px; 138 | left: 50%; 139 | transform: translateX(-50%); 140 | color: white; 141 | font-family: sans-serif; 142 | font-size: 14px; 143 | background-color: rgba(0, 0, 0, 0.4); 144 | padding: 8px 12px; 145 | border-radius: 5px; 146 | z-index: 100; 147 | /* text-decoration: none; */ 148 | transition: all 0.3s ease; 149 | font-family: 'Courier New', Courier, monospace; 150 | } 151 | 152 | a { 153 | color: white; 154 | } 155 | 156 | #coffee-link:hover { 157 | color: #fff; 158 | background-color: rgba(0, 0, 0, 0.6); 159 | /* transform: translateY(-2px); */ 160 | box-shadow: 0 0 15px rgba(255, 255, 255, 0.2); 161 | } 162 | 163 | #patternList { 164 | position: fixed; 165 | top: 20px; /* Positioned below the patternName label */ 166 | left: 20px; 167 | z-index: 104; 168 | background-color: rgba(0, 0, 0, 0.4); 169 | padding: 10px 14px; 170 | border-radius: 5px; 171 | color: white; 172 | font-family: 'Courier New', Courier, monospace; 173 | font-size: 16px; 174 | line-height: 1.6; 175 | } 176 | 177 | #patternList ul { 178 | list-style: none; 179 | padding: 0; 180 | margin: 0; 181 | } 182 | 183 | #patternList li { 184 | margin-bottom: 4px; 185 | transition: all 3s ease; 186 | } 187 | 188 | #speech-output { 189 | position: fixed; 190 | top: 30px; 191 | left: 65%; 192 | transform: translateX(-50%); 193 | background: #c0c0c0; 194 | color: #0000FF; 195 | padding: 12px 24px; 196 | /* font-family: 'Tahoma', 'Geneva', sans-serif; */ 197 | font-family: 'Courier New', Courier, monospace; 198 | font-weight: bold; 199 | font-size: 1.6em; 200 | z-index: 100; 201 | pointer-events: none; 202 | max-width: 220px; 203 | text-align: center; 204 | box-shadow: 205 | inset -2px -2px 0 #fff, 206 | inset 2px 2px 0 #808080, 207 | inset -4px -4px 0 #dfdfdf, 208 | inset 4px 4px 0 #404040; 209 | border: 2px solid #000; 210 | } -------------------------------------------------------------------------------- /voice-control.js: -------------------------------------------------------------------------------- 1 | // Web Speech API Integration for Pattern Voice Control 2 | 3 | // 1. Ask for microphone permission on startup 4 | window.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 5 | const recognition = new SpeechRecognition(); 6 | recognition.continuous = true; 7 | recognition.lang = 'en-US'; 8 | recognition.interimResults = false; 9 | const speechOutput = document.getElementById('speech-output'); 10 | 11 | const patternListElement = document.querySelector('#patternList ul'); 12 | const patternListItems = patternListElement.querySelectorAll('li'); 13 | 14 | function highlightActivePattern(index) { 15 | patternListItems.forEach((item, i) => { 16 | if (i === index) { 17 | item.style.color = '#f700ff'; 18 | item.style.fontSize = '32px'; 19 | item.style.fontWeight = 'bold'; 20 | } else { 21 | item.style.color = '#ffffff'; 22 | item.style.fontSize = '18px'; 23 | item.style.fontWeight = 'normal'; 24 | } 25 | }); 26 | } 27 | 28 | function transitionToNamedPattern(name) { 29 | const index = patternNames.findIndex(p => p.toLowerCase() === name.toLowerCase()); 30 | if (index !== -1 && index !== currentPattern && !isTransitioning) { 31 | transitionToPattern(index); 32 | highlightActivePattern(index); 33 | } 34 | } 35 | 36 | recognition.onresult = function(event) { 37 | for (let i = event.resultIndex; i < event.results.length; ++i) { 38 | if (event.results[i].isFinal) { 39 | const transcript = event.results[i][0].transcript.trim().toLowerCase(); 40 | speechOutput.textContent = transcript; 41 | console.log("Heard:", transcript); 42 | for (const name of patternNames) { 43 | if (transcript.includes(name.toLowerCase())) { 44 | transitionToNamedPattern(name); 45 | break; 46 | } 47 | } 48 | } 49 | } 50 | }; 51 | 52 | recognition.onerror = function(event) { 53 | console.warn('Speech recognition error:', event.error); 54 | }; 55 | 56 | recognition.onend = function() { 57 | // Restart recognition automatically 58 | recognition.start(); 59 | }; 60 | 61 | // 2. Start recognition on window load 62 | window.addEventListener('load', () => { 63 | try { 64 | recognition.start(); 65 | console.log('Speech recognition started'); 66 | } catch (e) { 67 | console.error('Speech recognition failed to start:', e); 68 | } 69 | 70 | highlightActivePattern(currentPattern); // initial highlight 71 | }); --------------------------------------------------------------------------------