Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Interactive 3D Heart with Hand Gestures</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: Arial, sans-serif; | |
| background-color: #000020; | |
| } | |
| #info { | |
| position: absolute; | |
| top: 10px; | |
| width: 100%; | |
| text-align: center; | |
| color: white; | |
| z-index: 100; | |
| font-size: 16px; | |
| text-shadow: 1px 1px 2px black; | |
| } | |
| #scene-container { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #video { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| z-index: -1; | |
| transform: scaleX(-1); /* Mirror the video */ | |
| opacity: 0.8; /* Make video slightly transparent */ | |
| } | |
| .loading { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: #00ffff; | |
| font-size: 24px; | |
| text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); | |
| z-index: 100; /* Ensure it's visible */ | |
| transition: opacity 1s ease; | |
| background-color: rgba(0, 0, 32, 0.7); | |
| padding: 20px; | |
| border-radius: 10px; | |
| border: 1px solid #00ffff; | |
| box-shadow: 0 0 15px rgba(0, 255, 255, 0.5); | |
| } | |
| .sci-fi-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: radial-gradient(ellipse at center, rgba(0,20,80,0.2) 0%, rgba(0,10,40,0.6) 100%); | |
| pointer-events: none; | |
| z-index: 1; | |
| } | |
| .grid { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-image: | |
| linear-gradient(rgba(0, 100, 255, 0.1) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(0, 100, 255, 0.1) 1px, transparent 1px); | |
| background-size: 40px 40px; | |
| pointer-events: none; | |
| z-index: 2; | |
| opacity: 0.5; | |
| } | |
| #webcamButton { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: #ff4757; | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 50px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| z-index: 100; | |
| transition: all 0.3s ease; | |
| } | |
| #webcamButton:hover { | |
| background-color: #ff6b81; | |
| transform: translateX(-50%) scale(1.05); | |
| } | |
| #webcamButton:disabled { | |
| background-color: #555; | |
| cursor: not-allowed; | |
| } | |
| .output_canvas { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| left: 0; | |
| top: 0; | |
| z-index: 20; | |
| pointer-events: none; | |
| } | |
| .gesture-indicator { | |
| position: absolute; | |
| bottom: 80px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(0, 0, 0, 0.7); | |
| color: #00ffff; | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| font-size: 16px; | |
| z-index: 100; | |
| transition: opacity 0.3s ease; | |
| border: 1px solid #00ffff; | |
| box-shadow: 0 0 10px rgba(0, 255, 255, 0.5); | |
| opacity: 0; | |
| } | |
| .controls-guide { | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| z-index: 100; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .guide-item { | |
| display: flex; | |
| align-items: center; | |
| background-color: rgba(0, 0, 0, 0.7); | |
| padding: 8px; | |
| border-radius: 10px; | |
| border: 1px solid #00ffff; | |
| } | |
| .guide-icon { | |
| font-size: 24px; | |
| margin-right: 10px; | |
| } | |
| .guide-text { | |
| color: white; | |
| font-size: 14px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info">Interactive 3D Heart</div> | |
| <div class="sci-fi-overlay"></div> | |
| <div class="grid"></div> | |
| <video id="video" playsinline></video> | |
| <div id="scene-container"></div> | |
| <div class="loading" id="loading-text">Loading...</div> | |
| <button id="webcamButton">Enable Webcam</button> | |
| <canvas class="output_canvas"></canvas> | |
| <div style="position:fixed;top:10px;right:10px;z-index:200;"> | |
| <label style="color:#00ffff;font-weight:bold;background:rgba(0,0,32,0.7);padding:6px 12px;border-radius:8px;"> | |
| <input type="checkbox" id="pulseToggle" style="vertical-align:middle;margin-right:6px;"> Heart Pulsing | |
| </label> | |
| </div> | |
| <!-- Legend for gestures --> | |
| <div id="gesture-legend" style="position:fixed;top:80px;right:10px;z-index:201;background:rgba(0,0,32,0.85);border-radius:12px;padding:18px 22px 18px 18px;box-shadow:0 0 16px #00ffff44;border:1.5px solid #00ffff;max-width:270px;min-width:200px;"> | |
| <div style="color:#00ffff;font-size:18px;font-weight:bold;margin-bottom:10px;text-align:left;">How to Control the Heart</div> | |
| <div style="display:flex;align-items:flex-start;margin-bottom:12px;"> | |
| <span style="font-size:2em;margin-right:12px;">☝️</span> | |
| <div> | |
| <span style="color:#fff;font-weight:bold;">Point One Index Finger</span><br> | |
| <span style="color:#aaa;font-size:14px;">Rotate & Tilt<br><span style="font-size:12px;">(Like dragging with a mouse, use either hand)</span></span> | |
| </div> | |
| </div> | |
| <div style="display:flex;align-items:flex-start;margin-bottom:12px;"> | |
| <span style="font-size:2em;margin-right:12px;">🖐️</span> | |
| <div> | |
| <span style="color:#fff;font-weight:bold;">Spread Hand</span><br> | |
| <span style="color:#aaa;font-size:14px;">Zoom in/out<br><span style="font-size:12px;">(Like mouse wheel, use either hand)</span></span> | |
| </div> | |
| </div> | |
| <div style="display:flex;align-items:flex-start;margin-bottom:12px;"> | |
| <span style="font-size:2em;margin-right:12px;">✊</span> | |
| <div> | |
| <span style="color:#fff;font-weight:bold;">Make a Fist</span><br> | |
| <span style="color:#aaa;font-size:14px;">Reset Heart<br><span style="font-size:12px;">(Return to starting view)</span></span> | |
| </div> | |
| </div> | |
| <div style="display:flex;align-items:flex-start;margin-bottom:12px;"> | |
| <span style="font-size:2em;margin-right:12px;">✌️</span> | |
| <div> | |
| <span style="color:#fff;font-weight:bold;">Peace Sign</span><br> | |
| <span style="color:#aaa;font-size:14px;">Toggle Pulsing<br><span style="font-size:12px;">(Turn heart pulsing on/off)</span></span> | |
| </div> | |
| </div> | |
| <div style="margin-top:16px;color:#00ffff;font-size:13px;">Tip: Point one finger to rotate/tilt, spread your hand to zoom, make a fist to reset, peace sign to pulse!</div> | |
| </div> | |
| <!-- Import Map for ES Modules --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.150.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.150.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <!-- MediaPipe --> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils@0.3.1632795355/drawing_utils.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4.1646424915/hands.js"></script> | |
| <!-- Main Script (using ES modules) --> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| // Scene setup | |
| let scene, camera, renderer, heart; | |
| let video, hands; | |
| let rotationSpeed = 0.01; | |
| let canvasElement, canvasCtx; | |
| // Hand tracking variables | |
| let leftHand = null; | |
| let rightHand = null; | |
| let isPinching = false; | |
| let startPinchRotation = 0; | |
| let currentRotation = 0; | |
| // UI elements | |
| let loadingText; | |
| // Option to enable/disable pulsing | |
| let pulsingEnabled = false; | |
| // Wait for DOM content to load before accessing elements | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const webcamButton = document.getElementById('webcamButton'); | |
| webcamButton.addEventListener('click', initApp); | |
| }); | |
| // Initialize the app when the button is clicked | |
| async function initApp() { | |
| console.log("Initializing application"); | |
| // Initialize DOM elements | |
| loadingText = document.getElementById('loading-text'); | |
| const webcamButton = document.getElementById('webcamButton'); | |
| webcamButton.disabled = true; | |
| // Setup canvas for hand tracking visualization | |
| canvasElement = document.querySelector('.output_canvas'); | |
| canvasCtx = canvasElement.getContext('2d'); | |
| // Initialize Three.js scene | |
| setupScene(); | |
| // Add a debug object to ensure rendering works | |
| addDebugCube(); | |
| // Load the heart model | |
| loadHeartModel(); | |
| // Setup video and MediaPipe | |
| await setupMediaPipe(); | |
| // Start animation loop | |
| animate(); | |
| console.log("Initialization complete"); | |
| webcamButton.textContent = 'Webcam Enabled'; | |
| webcamButton.style.backgroundColor = '#4CAF50'; | |
| } | |
| function addDebugCube() { | |
| // Add a simple cube to verify rendering pipeline | |
| const geometry = new THREE.BoxGeometry(1, 1, 1); | |
| const material = new THREE.MeshPhongMaterial({ color: 0x00ffff }); | |
| const cube = new THREE.Mesh(geometry, material); | |
| cube.position.set(0, 0, 0); | |
| scene.add(cube); | |
| // Auto-remove after 5 seconds | |
| setTimeout(() => { | |
| scene.remove(cube); | |
| console.log("Debug cube removed"); | |
| }, 5000); | |
| } | |
| function setupScene() { | |
| // Create scene | |
| scene = new THREE.Scene(); | |
| scene.background = null; // Transparent background | |
| // Create camera | |
| const aspect = window.innerWidth / window.innerHeight; | |
| camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); | |
| camera.position.z = 5; | |
| // Create renderer with alpha: true for transparency | |
| renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setClearColor(0x000000, 0); // Fully transparent | |
| document.getElementById('scene-container').appendChild(renderer.domElement); | |
| // Add lights | |
| const ambientLight = new THREE.AmbientLight(0x404040, 2); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 2); | |
| directionalLight.position.set(1, 1, 1); | |
| scene.add(directionalLight); | |
| const bluePointLight = new THREE.PointLight(0x0044ff, 1.5, 20); | |
| bluePointLight.position.set(-3, 2, 3); | |
| scene.add(bluePointLight); | |
| const redPointLight = new THREE.PointLight(0xff4400, 1.5, 20); | |
| redPointLight.position.set(3, -2, 3); | |
| scene.add(redPointLight); | |
| // Setup window resize handler | |
| window.addEventListener('resize', onWindowResize); | |
| } | |
| function createHeartPlaceholder() { | |
| console.log("Creating heart placeholder"); | |
| // Set your desired base scale here for the placeholder | |
| const baseScale = 3; | |
| // Create a simple heart-like shape using a sphere | |
| const geometry = new THREE.SphereGeometry(1.5, 32, 32); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: 0xff0066, | |
| shininess: 100, | |
| emissive: 0x330000 | |
| }); | |
| heart = new THREE.Mesh(geometry, material); | |
| heart.scale.set(baseScale, baseScale, baseScale); | |
| scene.add(heart); | |
| // Sync pulsingEnabled with checkbox state | |
| const pulseToggle = document.getElementById('pulseToggle'); | |
| if (pulseToggle) pulsingEnabled = pulseToggle.checked; | |
| if (pulsingEnabled) addPulsingEffect(heart, baseScale); | |
| loadingText.textContent = 'Using placeholder heart (models failed to load)'; | |
| setTimeout(() => { | |
| loadingText.style.display = 'none'; | |
| }, 3000); | |
| } | |
| function loadHeartModel() { | |
| console.log("Loading heart model..."); | |
| const loader = new GLTFLoader(); | |
| // Show that we're loading | |
| loadingText.textContent = 'Loading heart model...'; | |
| // Set your desired base scale here | |
| const baseScale = 50; // Try 2, 5, 10, etc. for different sizes | |
| // Load directly with the available model | |
| loader.load( | |
| 'stylizedhumanheart.glb', | |
| // Success callback | |
| function(gltf) { | |
| console.log("Model loaded successfully:", gltf); | |
| heart = gltf.scene; | |
| heart.scale.set(baseScale, baseScale, baseScale); | |
| scene.add(heart); | |
| // Center the model precisely in the scene | |
| const box = new THREE.Box3().setFromObject(heart); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const size = box.getSize(new THREE.Vector3()); | |
| // Center at (0,0,0) and adjust for any vertical offset | |
| heart.position.x = -center.x; | |
| heart.position.y = -center.y + (size.y / 2 - center.y); // shift so geometric center is at 0 | |
| heart.position.z = -center.z; | |
| // Make sure model is visible | |
| heart.traverse((node) => { | |
| if (node.isMesh) { | |
| node.material.transparent = false; | |
| node.material.opacity = 1.0; | |
| node.material.needsUpdate = true; | |
| } | |
| }); | |
| // Sync pulsingEnabled with checkbox state | |
| const pulseToggle = document.getElementById('pulseToggle'); | |
| if (pulseToggle) pulsingEnabled = pulseToggle.checked; | |
| if (pulsingEnabled) addPulsingEffect(heart, baseScale); | |
| loadingText.style.display = 'none'; | |
| }, | |
| // Progress callback | |
| function(xhr) { | |
| const percent = xhr.loaded / xhr.total * 100; | |
| loadingText.textContent = `Loading: ${Math.round(percent)}%`; | |
| }, | |
| // Error callback | |
| function(error) { | |
| console.error('Error loading heart model:', error); | |
| loadingText.textContent = 'Error loading model. Creating placeholder...'; | |
| createHeartPlaceholder(); | |
| } | |
| ); | |
| } | |
| // Use a callback-based loop for MediaPipe Hands | |
| let mediapipeActive = false; | |
| async function mediapipeFrameLoop() { | |
| if (!mediapipeActive) return; | |
| if (video && video.readyState === 4 && hands) { | |
| await hands.send({ image: video }); | |
| } | |
| // Only request the next frame after the previous one is processed | |
| if (mediapipeActive) requestAnimationFrame(mediapipeFrameLoop); | |
| } | |
| async function setupMediaPipe() { | |
| video = document.getElementById('video'); | |
| try { | |
| console.log("Requesting camera permission..."); | |
| loadingText.textContent = 'Requesting camera permission...'; | |
| // Access the webcam with explicit permissions | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'user' }, | |
| audio: false | |
| }); | |
| console.log("Camera permission granted:", stream); | |
| video.srcObject = stream; | |
| // Set canvas dimensions to match video | |
| video.onloadedmetadata = () => { | |
| console.log("Video metadata loaded"); | |
| video.play(); | |
| canvasElement.width = video.videoWidth; | |
| canvasElement.height = video.videoHeight; | |
| loadingText.textContent = 'Camera enabled. Hand tracking active.'; | |
| setTimeout(() => { | |
| loadingText.style.opacity = '0'; | |
| setTimeout(() => loadingText.style.display = 'none', 1000); | |
| }, 3000); | |
| // Start MediaPipe hand tracking loop | |
| mediapipeActive = true; | |
| mediapipeFrameLoop(); | |
| }; | |
| } catch (error) { | |
| console.error("Camera permission denied or error:", error); | |
| loadingText.textContent = "Please allow camera access for hand tracking to work!"; | |
| webcamButton.disabled = false; | |
| } | |
| // Initialize MediaPipe Hands | |
| hands = new Hands({ | |
| locateFile: (file) => { | |
| return `https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4.1646424915/${file}`; | |
| } | |
| }); | |
| hands.setOptions({ | |
| maxNumHands: 2, | |
| modelComplexity: 1, | |
| minDetectionConfidence: 0.6, // Increased for more reliable detection | |
| minTrackingConfidence: 0.5, | |
| selfieMode: true // Correctly handle mirrored camera feed | |
| }); | |
| hands.onResults(onHandResults); | |
| // Remove the old event listener for loadeddata | |
| // video.addEventListener('loadeddata', async () => { | |
| // await hands.send({ image: video }); | |
| // }); | |
| } | |
| function onHandResults(results) { | |
| leftHand = null; | |
| rightHand = null; | |
| // Clear canvas | |
| canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); | |
| if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { | |
| for (let i = 0; i < results.multiHandLandmarks.length; i++) { | |
| const handLandmarks = results.multiHandLandmarks[i]; | |
| const handedness = results.multiHandedness[i].label; | |
| // Note: MediaPipe hand detection is mirrored, so "Left" hand is actually right hand in the video | |
| if (handedness === 'Left') { | |
| rightHand = handLandmarks; // Mapping correctly to user's perspective | |
| } else if (handedness === 'Right') { | |
| leftHand = handLandmarks; // Mapping correctly to user's perspective | |
| } | |
| // Draw only index fingertip as a small dot for clarity | |
| const indexTip = handLandmarks[8]; | |
| canvasCtx.beginPath(); | |
| canvasCtx.arc( | |
| indexTip.x * canvasElement.width, | |
| indexTip.y * canvasElement.height, | |
| 5, | |
| 0, | |
| 2 * Math.PI | |
| ); | |
| canvasCtx.fillStyle = '#00ffff'; | |
| canvasCtx.fill(); | |
| } | |
| } | |
| // Process hand gestures and get activity status (no on-screen gesture indicator) | |
| processHandGestures(); | |
| } | |
| // Track current gesture for UI updates | |
| let currentGesture = null; | |
| // Remove gesture indicator text and hide the indicator (no-op) | |
| function updateGestureIndicator(gesture) { | |
| // No operation: all gesture indicator UI is removed | |
| } | |
| function processHandGestures() { | |
| // Peace sign (index and middle fingers extended, others folded) toggles pulsing (debounced) | |
| function isPeaceSign(hand) { | |
| if (!hand) return false; | |
| const palm = hand[0]; | |
| // Index and middle tips far, others close | |
| const indexTip = hand[8]; | |
| const middleTip = hand[12]; | |
| if (!indexTip || !middleTip) return false; | |
| const indexDist = calculateDistance(palm, indexTip); | |
| const middleDist = calculateDistance(palm, middleTip); | |
| let folded = 0; | |
| [4,16,20].forEach(i => { | |
| const tip = hand[i]; | |
| if (!tip) return; | |
| const d = calculateDistance(palm, tip); | |
| if (d < 0.08) folded++; | |
| }); | |
| // Both index and middle extended, at least 2 others folded | |
| return (indexDist > 0.16 && middleDist > 0.16 && folded >= 2); | |
| } | |
| if (!processHandGestures.lastPeace) processHandGestures.lastPeace = false; | |
| const peaceNow = (leftHand && isPeaceSign(leftHand)) || (rightHand && isPeaceSign(rightHand)); | |
| if (peaceNow && !processHandGestures.lastPeace) { | |
| pulsingEnabled = !pulsingEnabled; | |
| if (heart) { | |
| let scale = heart.scale.x; | |
| if (pulsingEnabled) { | |
| addPulsingEffect(heart, scale); | |
| } else { | |
| delete heart.userData.update; | |
| heart.scale.set(scale, scale, scale); | |
| } | |
| } | |
| } | |
| processHandGestures.lastPeace = peaceNow; | |
| if (!heart) return; | |
| // Keep track of whether any gesture is active | |
| let gestureActive = false; | |
| // Minimal gestures: left index finger = rotate/tilt, left hand spread = zoom, fist = reset | |
| let leftIndex = leftHand ? leftHand[8] : null; | |
| // Helper: detect fist (all tips close to palm) | |
| function isFist(hand) { | |
| if (!hand) return false; | |
| const palm = hand[0]; | |
| let closed = 0; | |
| [4,8,12,16,20].forEach(i => { | |
| const tip = hand[i]; | |
| if (!tip) return; | |
| const d = calculateDistance(palm, tip); | |
| if (d < 0.08) closed++; | |
| }); | |
| return closed >= 4; | |
| } | |
| // If either hand makes a fist, reset heart position (rotation, tilt, zoom, and pulsing off) | |
| if ((leftHand && isFist(leftHand)) || (rightHand && isFist(rightHand))) { | |
| // Reset to starting position: facing forward, upright, default zoom | |
| heart.rotation.set(0, 0, 0); | |
| heart.position.set(0, 0, 0); | |
| camera.position.set(0, 0, 5); | |
| // Turn off pulsing | |
| pulsingEnabled = false; | |
| if (heart) { | |
| delete heart.userData.update; | |
| let scale = heart.scale.x; | |
| heart.scale.set(scale, scale, scale); | |
| } | |
| processHandGestures.lastLeftIndex = null; | |
| processHandGestures.pinchSmoothing = null; | |
| processHandGestures.xSmoothing = null; | |
| return true; | |
| } | |
| // If left index finger is visible, allow rotation/tilt (like mouse drag) | |
| if (leftIndex) { | |
| gestureActive = true; | |
| // Smoothing variables (static across calls) | |
| if (!processHandGestures.lastLeftIndex) { | |
| processHandGestures.lastLeftIndex = { x: leftIndex.x, y: leftIndex.y }; | |
| } | |
| if (!processHandGestures.pinchSmoothing) { | |
| processHandGestures.pinchSmoothing = { lastRotation: heart ? heart.rotation.y : 0, velocity: 0 }; | |
| } | |
| if (!processHandGestures.xSmoothing) { | |
| processHandGestures.xSmoothing = { lastX: heart ? heart.rotation.x : 0, velocity: 0 }; | |
| } | |
| const smoothing = processHandGestures.pinchSmoothing; | |
| const xSmooth = processHandGestures.xSmoothing; | |
| // Calculate movement deltas (in screen space) | |
| const deltaX = leftIndex.x - processHandGestures.lastLeftIndex.x; | |
| const deltaY = leftIndex.y - processHandGestures.lastLeftIndex.y; | |
| // Sensitivity (tweak as needed) | |
| const ROTATE_SENS = 7.5; // More sensitive for easier rotation | |
| const TILT_SENS = 7.5; | |
| // Horizontal rotation (Y axis): move left = rotate right, move right = rotate left (mirrored webcam) | |
| let targetY = heart.rotation.y + deltaX * ROTATE_SENS; | |
| // Remove clamping for full 360 rotation | |
| smoothing.velocity = (targetY - smoothing.lastRotation) * 0.4; | |
| smoothing.lastRotation += smoothing.velocity; | |
| heart.rotation.y = smoothing.lastRotation; | |
| // Vertical tilt (X axis): move up = tilt up, move down = tilt down (natural) | |
| let targetX = heart.rotation.x + deltaY * TILT_SENS; | |
| // Clamp only X axis (tilt), not Y (rotation) | |
| targetX = THREE.MathUtils.clamp(targetX, -Math.PI/2, Math.PI/2); | |
| xSmooth.velocity = (targetX - xSmooth.lastX) * 0.4; | |
| xSmooth.lastX += xSmooth.velocity; | |
| heart.rotation.x = xSmooth.lastX; | |
| // Update last position | |
| processHandGestures.lastLeftIndex.x = leftIndex.x; | |
| processHandGestures.lastLeftIndex.y = leftIndex.y; | |
| } else { | |
| // Not rotating, keep last rotation (locked), but allow zoom | |
| if (processHandGestures.pinchSmoothing && heart) processHandGestures.pinchSmoothing.lastRotation = heart.rotation.y; | |
| if (processHandGestures.xSmoothing && heart) processHandGestures.xSmoothing.lastX = heart.rotation.x; | |
| // Reset lastLeftIndex so next time we don't get a big jump | |
| processHandGestures.lastLeftIndex = null; | |
| } | |
| // Helper: detect fist (all tips close to palm) | |
| function isFist(hand) { | |
| // Compare tip (4,8,12,16,20) to palm (0) | |
| const palm = hand[0]; | |
| let closed = 0; | |
| [4,8,12,16,20].forEach(i => { | |
| const tip = hand[i]; | |
| const d = calculateDistance(palm, tip); | |
| if (d < 0.08) closed++; | |
| }); | |
| return closed >= 4; | |
| } | |
| // Process open hand gesture (for zoom) | |
| if (leftHand) { | |
| gestureActive = true; | |
| // Improved method: use distance between thumb and pinky | |
| const thumb = leftHand[4]; | |
| const pinky = leftHand[20]; | |
| if (thumb && pinky) { | |
| const handSpread = calculateDistance(thumb, pinky); | |
| // Map the hand spread to camera zoom with better sensitivity | |
| const zoomFactor = THREE.MathUtils.lerp(10, 3, handSpread * 2); | |
| // Smooth camera movement | |
| camera.position.z = THREE.MathUtils.lerp( | |
| camera.position.z, | |
| zoomFactor, | |
| 0.1 // Smoothing factor | |
| ); | |
| } | |
| } | |
| // Return whether any gesture is active to control auto-rotation | |
| return gestureActive; | |
| } | |
| function calculateDistance(point1, point2) { | |
| return Math.sqrt( | |
| Math.pow(point1.x - point2.x, 2) + | |
| Math.pow(point1.y - point2.y, 2) | |
| ); | |
| } | |
| function addPulsingEffect(model, baseScale = 1.0) { | |
| let time = 0; | |
| // Heartbeat: very sharp up, very quick drop, more visible | |
| model.userData.update = function(delta) { | |
| time += delta; | |
| // 1.4 Hz = ~84 bpm (children's heart rate, slightly faster) | |
| const freq = 1.4; | |
| const t = (time * freq) % 1.0; | |
| // Sharper, more prominent heartbeat: very sharp peak, quick drop | |
| let beat = Math.exp(-60 * (t - 0.12) * (t - 0.12)) * 2.2; // much sharper, higher | |
| beat += 0.22 * Math.max(0, Math.sin(Math.PI * t)); // more visible base pulse | |
| const pulseFactor = 1.0 + 0.13 * beat; | |
| model.scale.set( | |
| baseScale * pulseFactor, | |
| baseScale * pulseFactor, | |
| baseScale * pulseFactor | |
| ); | |
| }; | |
| } | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| // Update canvas size if needed | |
| if (canvasElement && video) { | |
| canvasElement.width = video.videoWidth; | |
| canvasElement.height = video.videoHeight; | |
| } | |
| } | |
| // Track hand presence for auto-rotation | |
| let handsDetectedTime = 0; | |
| let lastHandsDetectedState = false; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Apply heart pulsing effect only if enabled | |
| if (pulsingEnabled && heart && heart.userData.update) { | |
| heart.userData.update(0.01); | |
| } | |
| // Check if hands are detected | |
| const handsDetected = leftHand || rightHand; | |
| // Track when hands state changes for smoother transitions | |
| if (handsDetected !== lastHandsDetectedState) { | |
| lastHandsDetectedState = handsDetected; | |
| handsDetectedTime = Date.now(); | |
| // Update gesture indicator when hands disappear | |
| if (!handsDetected) { | |
| updateGestureIndicator("No Hands Detected"); | |
| } | |
| } | |
| // Remove automatic rotation and wobble | |
| // if (heart) { | |
| // if (!handsDetected) { | |
| // // Auto rotation only when no hands detected | |
| // const timeSinceHandsGone = Date.now() - handsDetectedTime; | |
| // // Start auto-rotation only after hands have been absent for 1.5 seconds | |
| // if (timeSinceHandsGone > 1500) { | |
| // // Gradually increase rotation speed | |
| // const speedFactor = Math.min(1.0, (timeSinceHandsGone - 1500) / 2000); | |
| // heart.rotation.y += rotationSpeed * speedFactor; | |
| // } | |
| // } | |
| // // Add slight wobble for more dynamic presentation when no specific interaction | |
| // if (!isPinching && !handsDetected) { | |
| // const wobble = Math.sin(Date.now() * 0.001) * 0.005; | |
| // heart.rotation.x = wobble; | |
| // } | |
| // } | |
| renderer.render(scene, camera); | |
| } | |
| </script> | |
| <script> | |
| // UI for toggling pulsing | |
| // This script must run after the main script so pulsingEnabled and heart are available | |
| // If you want the heart to pulse by default, set pulsingEnabled = true above | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const pulseToggle = document.getElementById('pulseToggle'); | |
| if (pulseToggle) { | |
| pulseToggle.checked = false; | |
| pulseToggle.addEventListener('change', (e) => { | |
| pulsingEnabled = e.target.checked; | |
| // Remove/update pulsing effect on the fly | |
| if (heart) { | |
| if (pulsingEnabled) { | |
| // Re-add pulsing effect with current scale | |
| let scale = heart.scale.x; | |
| addPulsingEffect(heart, scale); | |
| } else { | |
| // Remove pulsing effect | |
| delete heart.userData.update; | |
| // Reset to current scale | |
| let scale = heart.scale.x; | |
| heart.scale.set(scale, scale, scale); | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |