| <!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> |
|
|