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/[email protected]/build/three.module.js", | |
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/" | |
} | |
} | |
</script> | |
<!-- MediaPipe --> | |
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/drawing_utils.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/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/[email protected]/${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> | |