|
|
<!DOCTYPE html> |
|
|
<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); |
|
|
opacity: 0.8; |
|
|
} |
|
|
.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; |
|
|
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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<script type="module"> |
|
|
import * as THREE from 'three'; |
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
|
|
|
|
|
|
|
|
let scene, camera, renderer, heart; |
|
|
let video, hands; |
|
|
let rotationSpeed = 0.01; |
|
|
let canvasElement, canvasCtx; |
|
|
|
|
|
|
|
|
let leftHand = null; |
|
|
let rightHand = null; |
|
|
let isPinching = false; |
|
|
let startPinchRotation = 0; |
|
|
let currentRotation = 0; |
|
|
|
|
|
|
|
|
let loadingText; |
|
|
|
|
|
|
|
|
let pulsingEnabled = false; |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
const webcamButton = document.getElementById('webcamButton'); |
|
|
webcamButton.addEventListener('click', initApp); |
|
|
}); |
|
|
|
|
|
|
|
|
async function initApp() { |
|
|
console.log("Initializing application"); |
|
|
|
|
|
|
|
|
loadingText = document.getElementById('loading-text'); |
|
|
|
|
|
const webcamButton = document.getElementById('webcamButton'); |
|
|
webcamButton.disabled = true; |
|
|
|
|
|
|
|
|
canvasElement = document.querySelector('.output_canvas'); |
|
|
canvasCtx = canvasElement.getContext('2d'); |
|
|
|
|
|
|
|
|
setupScene(); |
|
|
|
|
|
|
|
|
addDebugCube(); |
|
|
|
|
|
|
|
|
loadHeartModel(); |
|
|
|
|
|
|
|
|
await setupMediaPipe(); |
|
|
|
|
|
|
|
|
animate(); |
|
|
|
|
|
console.log("Initialization complete"); |
|
|
webcamButton.textContent = 'Webcam Enabled'; |
|
|
webcamButton.style.backgroundColor = '#4CAF50'; |
|
|
} |
|
|
|
|
|
function addDebugCube() { |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
scene.remove(cube); |
|
|
console.log("Debug cube removed"); |
|
|
}, 5000); |
|
|
} |
|
|
|
|
|
function setupScene() { |
|
|
|
|
|
scene = new THREE.Scene(); |
|
|
scene.background = null; |
|
|
|
|
|
|
|
|
const aspect = window.innerWidth / window.innerHeight; |
|
|
camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000); |
|
|
camera.position.z = 5; |
|
|
|
|
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
|
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
|
renderer.setClearColor(0x000000, 0); |
|
|
document.getElementById('scene-container').appendChild(renderer.domElement); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
window.addEventListener('resize', onWindowResize); |
|
|
} |
|
|
|
|
|
function createHeartPlaceholder() { |
|
|
console.log("Creating heart placeholder"); |
|
|
|
|
|
|
|
|
const baseScale = 3; |
|
|
|
|
|
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); |
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
loadingText.textContent = 'Loading heart model...'; |
|
|
|
|
|
|
|
|
const baseScale = 50; |
|
|
|
|
|
loader.load( |
|
|
'stylizedhumanheart.glb', |
|
|
|
|
|
function(gltf) { |
|
|
console.log("Model loaded successfully:", gltf); |
|
|
heart = gltf.scene; |
|
|
heart.scale.set(baseScale, baseScale, baseScale); |
|
|
scene.add(heart); |
|
|
|
|
|
const box = new THREE.Box3().setFromObject(heart); |
|
|
const center = box.getCenter(new THREE.Vector3()); |
|
|
const size = box.getSize(new THREE.Vector3()); |
|
|
|
|
|
heart.position.x = -center.x; |
|
|
heart.position.y = -center.y + (size.y / 2 - center.y); |
|
|
heart.position.z = -center.z; |
|
|
|
|
|
heart.traverse((node) => { |
|
|
if (node.isMesh) { |
|
|
node.material.transparent = false; |
|
|
node.material.opacity = 1.0; |
|
|
node.material.needsUpdate = true; |
|
|
} |
|
|
}); |
|
|
|
|
|
const pulseToggle = document.getElementById('pulseToggle'); |
|
|
if (pulseToggle) pulsingEnabled = pulseToggle.checked; |
|
|
if (pulsingEnabled) addPulsingEffect(heart, baseScale); |
|
|
loadingText.style.display = 'none'; |
|
|
}, |
|
|
|
|
|
function(xhr) { |
|
|
const percent = xhr.loaded / xhr.total * 100; |
|
|
loadingText.textContent = `Loading: ${Math.round(percent)}%`; |
|
|
}, |
|
|
|
|
|
function(error) { |
|
|
console.error('Error loading heart model:', error); |
|
|
loadingText.textContent = 'Error loading model. Creating placeholder...'; |
|
|
createHeartPlaceholder(); |
|
|
} |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
let mediapipeActive = false; |
|
|
|
|
|
async function mediapipeFrameLoop() { |
|
|
if (!mediapipeActive) return; |
|
|
if (video && video.readyState === 4 && hands) { |
|
|
await hands.send({ image: video }); |
|
|
} |
|
|
|
|
|
if (mediapipeActive) requestAnimationFrame(mediapipeFrameLoop); |
|
|
} |
|
|
|
|
|
async function setupMediaPipe() { |
|
|
video = document.getElementById('video'); |
|
|
|
|
|
try { |
|
|
console.log("Requesting camera permission..."); |
|
|
loadingText.textContent = 'Requesting camera permission...'; |
|
|
|
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ |
|
|
video: { facingMode: 'user' }, |
|
|
audio: false |
|
|
}); |
|
|
console.log("Camera permission granted:", stream); |
|
|
video.srcObject = stream; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
minTrackingConfidence: 0.5, |
|
|
selfieMode: true |
|
|
}); |
|
|
|
|
|
hands.onResults(onHandResults); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
function onHandResults(results) { |
|
|
leftHand = null; |
|
|
rightHand = null; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
if (handedness === 'Left') { |
|
|
rightHand = handLandmarks; |
|
|
} else if (handedness === 'Right') { |
|
|
leftHand = handLandmarks; |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
processHandGestures(); |
|
|
} |
|
|
|
|
|
|
|
|
let currentGesture = null; |
|
|
|
|
|
|
|
|
function updateGestureIndicator(gesture) { |
|
|
|
|
|
} |
|
|
|
|
|
function processHandGestures() { |
|
|
|
|
|
function isPeaceSign(hand) { |
|
|
if (!hand) return false; |
|
|
const palm = hand[0]; |
|
|
|
|
|
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++; |
|
|
}); |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
let gestureActive = false; |
|
|
|
|
|
|
|
|
let leftIndex = leftHand ? leftHand[8] : null; |
|
|
|
|
|
|
|
|
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 ((leftHand && isFist(leftHand)) || (rightHand && isFist(rightHand))) { |
|
|
|
|
|
heart.rotation.set(0, 0, 0); |
|
|
heart.position.set(0, 0, 0); |
|
|
camera.position.set(0, 0, 5); |
|
|
|
|
|
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 (leftIndex) { |
|
|
gestureActive = true; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const deltaX = leftIndex.x - processHandGestures.lastLeftIndex.x; |
|
|
const deltaY = leftIndex.y - processHandGestures.lastLeftIndex.y; |
|
|
|
|
|
|
|
|
const ROTATE_SENS = 7.5; |
|
|
const TILT_SENS = 7.5; |
|
|
|
|
|
|
|
|
let targetY = heart.rotation.y + deltaX * ROTATE_SENS; |
|
|
|
|
|
smoothing.velocity = (targetY - smoothing.lastRotation) * 0.4; |
|
|
smoothing.lastRotation += smoothing.velocity; |
|
|
heart.rotation.y = smoothing.lastRotation; |
|
|
|
|
|
|
|
|
let targetX = heart.rotation.x + deltaY * TILT_SENS; |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
processHandGestures.lastLeftIndex.x = leftIndex.x; |
|
|
processHandGestures.lastLeftIndex.y = leftIndex.y; |
|
|
} else { |
|
|
|
|
|
if (processHandGestures.pinchSmoothing && heart) processHandGestures.pinchSmoothing.lastRotation = heart.rotation.y; |
|
|
if (processHandGestures.xSmoothing && heart) processHandGestures.xSmoothing.lastX = heart.rotation.x; |
|
|
|
|
|
processHandGestures.lastLeftIndex = null; |
|
|
} |
|
|
|
|
|
|
|
|
function isFist(hand) { |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
if (leftHand) { |
|
|
gestureActive = true; |
|
|
|
|
|
|
|
|
const thumb = leftHand[4]; |
|
|
const pinky = leftHand[20]; |
|
|
|
|
|
if (thumb && pinky) { |
|
|
const handSpread = calculateDistance(thumb, pinky); |
|
|
|
|
|
|
|
|
const zoomFactor = THREE.MathUtils.lerp(10, 3, handSpread * 2); |
|
|
|
|
|
|
|
|
camera.position.z = THREE.MathUtils.lerp( |
|
|
camera.position.z, |
|
|
zoomFactor, |
|
|
0.1 |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
model.userData.update = function(delta) { |
|
|
time += delta; |
|
|
|
|
|
const freq = 1.4; |
|
|
const t = (time * freq) % 1.0; |
|
|
|
|
|
let beat = Math.exp(-60 * (t - 0.12) * (t - 0.12)) * 2.2; |
|
|
beat += 0.22 * Math.max(0, Math.sin(Math.PI * t)); |
|
|
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); |
|
|
|
|
|
|
|
|
if (canvasElement && video) { |
|
|
canvasElement.width = video.videoWidth; |
|
|
canvasElement.height = video.videoHeight; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let handsDetectedTime = 0; |
|
|
let lastHandsDetectedState = false; |
|
|
|
|
|
function animate() { |
|
|
requestAnimationFrame(animate); |
|
|
|
|
|
|
|
|
if (pulsingEnabled && heart && heart.userData.update) { |
|
|
heart.userData.update(0.01); |
|
|
} |
|
|
|
|
|
|
|
|
const handsDetected = leftHand || rightHand; |
|
|
|
|
|
|
|
|
if (handsDetected !== lastHandsDetectedState) { |
|
|
lastHandsDetectedState = handsDetected; |
|
|
handsDetectedTime = Date.now(); |
|
|
|
|
|
|
|
|
if (!handsDetected) { |
|
|
updateGestureIndicator("No Hands Detected"); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderer.render(scene, camera); |
|
|
} |
|
|
</script> |
|
|
|
|
|
<script> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
const pulseToggle = document.getElementById('pulseToggle'); |
|
|
if (pulseToggle) { |
|
|
pulseToggle.checked = false; |
|
|
pulseToggle.addEventListener('change', (e) => { |
|
|
pulsingEnabled = e.target.checked; |
|
|
|
|
|
if (heart) { |
|
|
if (pulsingEnabled) { |
|
|
|
|
|
let scale = heart.scale.x; |
|
|
addPulsingEffect(heart, scale); |
|
|
} else { |
|
|
|
|
|
delete heart.userData.update; |
|
|
|
|
|
let scale = heart.scale.x; |
|
|
heart.scale.set(scale, scale, scale); |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|