Spaces:
Running
Running
| // Global variables | |
| let scene, camera, renderer, controls; | |
| let model, cuttingPlaneMesh; | |
| let rotationSlider, autoRotateCheckbox, cuttingPlaneSlider; | |
| let sliceCanvas, sliceCtx; | |
| let isRecording = false; | |
| let mediaRecorder; | |
| let recordedChunks = []; | |
| let animationId; | |
| let sliceData = []; | |
| // Initialize Three.js scene | |
| function init3DScene() { | |
| const container = document.getElementById('canvas3d'); | |
| // Scene setup | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x1a1a2e); | |
| // Camera setup | |
| camera = new THREE.PerspectiveCamera( | |
| 75, | |
| container.clientWidth / container.clientHeight, | |
| 0.1, | |
| 1000 | |
| ); | |
| camera.position.set(0, 0, 100); | |
| // Renderer setup | |
| renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); | |
| renderer.setSize(container.clientWidth, container.clientHeight); | |
| renderer.localClippingEnabled = true; | |
| container.appendChild(renderer.domElement); | |
| // Controls | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| // Lighting | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(10, 10, 10); | |
| scene.add(directionalLight); | |
| // Create 3D model (composite sphere with inner structures) | |
| createCompositeModel(); | |
| // Create cutting plane visualization | |
| createCuttingPlane(); | |
| // Event listeners | |
| setupEventListeners(); | |
| // Initialize slice canvas | |
| initSliceCanvas(); | |
| // Start render loop | |
| animate(); | |
| } | |
| // Create a composite 3D model | |
| function createCompositeModel() { | |
| // Main outer shell | |
| const outerGeometry = new THREE.SphereGeometry(30, 32, 32); | |
| const outerMaterial = new THREE.MeshPhongMaterial({ | |
| color: 0x4a90e2, | |
| transparent: true, | |
| opacity: 0.7, | |
| side: THREE.DoubleSide | |
| }); | |
| model = new THREE.Mesh(outerGeometry, outerMaterial); | |
| model.name = 'compositeModel'; | |
| scene.add(model); | |
| // Inner structures | |
| const innerGeometry1 = new THREE.SphereGeometry(15, 16, 16); | |
| const innerMaterial1 = new THREE.MeshPhongMaterial({ | |
| color: 0x7ed321, | |
| transparent: true, | |
| opacity: 0.8 | |
| }); | |
| const innerSphere1 = new THREE.Mesh(innerGeometry1, innerMaterial1); | |
| innerSphere1.position.set(5, 5, -5); | |
| model.add(innerSphere1); | |
| const innerGeometry2 = new THREE.BoxGeometry(15, 15, 15); | |
| const innerMaterial2 = new THREE.MeshPhongMaterial({ | |
| color: 0xf5a623, | |
| transparent: true, | |
| opacity: 0.8 | |
| }); | |
| const innerBox = new THREE.Mesh(innerGeometry2, innerMaterial2); | |
| innerBox.position.set(-8, -3, 8); | |
| innerBox.rotation.set(0.5, 0.3, 0.7); | |
| model.add(innerBox); | |
| // Additional small structures for complexity | |
| for (let i = 0; i < 5; i++) { | |
| const smallGeometry = new THREE.SphereGeometry(3 + Math.random() * 5, 8, 8); | |
| const smallMaterial = new THREE.MeshPhongMaterial({ | |
| color: new THREE.Color().setHSL(0.5 + Math.random() * 0.3, 0.8, 0.6), | |
| transparent: true, | |
| opacity: 0.9 | |
| }); | |
| const smallSphere = new THREE.Mesh(smallGeometry, smallMaterial); | |
| smallSphere.position.set( | |
| (Math.random() - 0.5) * 40, | |
| (Math.random() - 0.5) * 40, | |
| (Math.random() - 0.5) * 40 | |
| ); | |
| model.add(smallSphere); | |
| } | |
| // Update slice data for all z positions | |
| updateSliceData(); | |
| } | |
| // Generate slice data for 2D view | |
| function updateSliceData() { | |
| sliceData = []; | |
| const resolution = parseInt(document.getElementById('sliceResolution').value); | |
| const totalSlices = 100; | |
| for (let z = 0; z < totalSlices; z++) { | |
| const zPos = (z - totalSlices / 2) / (totalSlices / 100); | |
| const sliceArray = []; | |
| for (let y = 0; y < resolution; y++) { | |
| const row = []; | |
| for (let x = 0; x < resolution; x++) { | |
| // Convert canvas coordinates to 3D space | |
| const xPos = (x - resolution / 2) / (resolution / 100); | |
| const yPos = (y - resolution / 2) / (resolution / 100); | |
| // Calculate density based on distance from center for outer sphere | |
| const distanceFromCenter = Math.sqrt(xPos * xPos + yPos * yPos + zPos * zPos); | |
| let density = 0; | |
| // Outer sphere (main shell) | |
| if (distanceFromCenter <= 30) { | |
| density = 0.5 + (1 - distanceFromCenter / 30) * 0.5; | |
| // Inner sphere 1 | |
| const dist1 = Math.sqrt( | |
| (xPos - 5) * (xPos - 5) + | |
| (yPos - 5) * (yPos - 5) + | |
| (zPos + 5) * (zPos + 5) | |
| ); | |
| if (dist1 <= 15) { | |
| density = Math.max(density, 0.8 + (1 - dist1 / 15) * 0.2); | |
| } | |
| // Inner box (approximated) | |
| const boxDistX = Math.abs(xPos + 8); | |
| const boxDistY = Math.abs(yPos + 3); | |
| const boxDistZ = Math.abs(zPos - 8); | |
| if (boxDistX <= 12 && boxDistY <= 12 && boxDistZ <= 12) { | |
| density = Math.max(density, 0.7 + (1 - boxDistX / 12) * 0.3 * 0.3); | |
| density = Math.max(density, 0.7 + (1 - boxDistY / 12) * 0.3 * 0.3); | |
| density = Math.max(density, 0.7 + (1 - boxDistZ / 12) * 0.3 * 0.3); | |
| } | |
| // Small random structures | |
| for (let i = 0; i < 5; i++) { | |
| const posX = (Math.sin(i * 1.5) - 0.5) * 40; | |
| const posY = (Math.cos(i * 1.3) - 0.5) * 40; | |
| const posZ = (Math.sin(i) - 0.5) * 40; | |
| const smallDist = Math.sqrt( | |
| (xPos - posX) * (xPos - posX) + | |
| (yPos - posY) * (yPos - posY) + | |
| (zPos - posZ) * (zPos - posZ) | |
| ); | |
| const radius = 8 + i * 2; | |
| if (smallDist <= radius) { | |
| density = Math.max(density, 0.6 + (1 - smallDist / radius) * 0.4); | |
| } | |
| } | |
| } | |
| row.push(density); | |
| } | |
| sliceArray.push(row); | |
| } | |
| sliceData.push(sliceArray); | |
| } | |
| } | |
| // Initialize slice canvas | |
| function initSliceCanvas() { | |
| sliceCanvas = document.getElementById('sliceCanvas'); | |
| sliceCtx = sliceCanvas.getContext('2d'); | |
| const resolution = parseInt(document.getElementById('sliceResolution').value); | |
| sliceCanvas.width = resolution; | |
| sliceCanvas.height = resolution; | |
| } | |
| // Create cutting plane visualization | |
| function createCuttingPlane() { | |
| const planeGeometry = new THREE.PlaneGeometry(100, 100); | |
| const planeMaterial = new THREE.MeshBasicMaterial({ | |
| color: 0xff0000, | |
| transparent: true, | |
| opacity: 0.3, | |
| side: THREE.DoubleSide | |
| }); | |
| cuttingPlaneMesh = new THREE.Mesh(planeGeometry, planeMaterial); | |
| cuttingPlaneMesh.rotation.x = -Math.PI / 2; | |
| cuttingPlaneMesh.visible = true; | |
| scene.add(cuttingPlaneMesh); | |
| } | |
| // Update 2D slice view | |
| function updateSliceView(zPosition) { | |
| if (!sliceCtx || sliceData.length === 0) return; | |
| const resolution = parseInt(document.getElementById('sliceResolution').value); | |
| const sliceIndex = Math.floor((zPosition + 50) / 100 * (sliceData.length - 1)); | |
| if (sliceIndex < 0 || sliceIndex >= sliceData.length) return; | |
| const slice = sliceData[sliceIndex]; | |
| const imageData = sliceCtx.createImageData(resolution, resolution); | |
| const data = imageData.data; | |
| for (let y = 0; y < resolution; y++) { | |
| for (let x = 0; x < resolution; x++) { | |
| const density = slice[y][x]; | |
| const index = (y * resolution + x) * 4; | |
| if (density > 0) { | |
| // Color based on density | |
| const hue = 0.6 - density * 0.3; // Blue to green to yellow | |
| const rgb = hslToRgb(hue, 0.8, 0.5 + density * 0.3); | |
| data[index] = rgb[0]; // R | |
| data[index + 1] = rgb[1]; // G | |
| data[index + 2] = rgb[2]; // B | |
| data[index + 3] = Math.floor(density * 255); // A | |
| } else { | |
| data[index] = 0; | |
| data[index + 1] = 0; | |
| data[index + 2] = 0; | |
| data[index + 3] = 0; | |
| } | |
| } | |
| } | |
| sliceCtx.putImageData(imageData, 0, 0); | |
| // Update slice info | |
| document.getElementById('sliceInfo').textContent = `Z = ${zPosition.toFixed(1)}`; | |
| } | |
| // Convert HSL to RGB | |
| function hslToRgb(h, s, l) { | |
| const c = (1 - Math.abs(2 * l - 1)) * s; | |
| const x = c * (1 - Math.abs((h * 6) % 2 - 1)); | |
| const m = l - c / 2; | |
| let r, g, b; | |
| if (h < 1/6) { | |
| r = c; g = x; b = 0; | |
| } else if (h < 2/6) { | |
| r = x; g = c; b = 0; | |
| } else if (h < 3/6) { | |
| r = 0; g = c; b = x; | |
| } else if (h < 4/6) { | |
| r = 0; g = x; b = c; | |
| } else if (h < 5/6) { | |
| r = x; g = 0; b = c; | |
| } else { | |
| r = c; g = 0; b = x; | |
| } | |
| return [ | |
| Math.floor((r + m) * 255), | |
| Math.floor((g + m) * 255), | |
| Math.floor((b + m) * 255) | |
| ]; | |
| } | |
| // Setup event listeners | |
| function setupEventListeners() { | |
| rotationSlider = document.getElementById('rotationSlider'); | |
| autoRotateCheckbox = document.getElementById('autoRotate'); | |
| cuttingPlaneSlider = document.getElementById('cuttingPlane'); | |
| // Rotation slider | |
| rotationSlider.addEventListener('input', (e) => { | |
| const value = e.target.value; | |
| document.getElementById('rotationValue').textContent = value + '°'; | |
| if (model) { | |
| model.rotation.y = (value * Math.PI) / 180; | |
| } | |
| }); | |
| // Auto rotate checkbox | |
| autoRotateCheckbox.addEventListener('change', (e) => { | |
| controls.autoRotate = e.target.checked; | |
| }); | |
| // Cutting plane slider | |
| cuttingPlaneSlider.addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| document.getElementById('planeValue').textContent = value.toFixed(1); | |
| updateCuttingPlane(value); | |
| updateSliceView(value); | |
| }); | |
| // Speed slider | |
| const speedSlider = document.getElementById('scanSpeed'); | |
| speedSlider.addEventListener('input', (e) => { | |
| document.getElementById('speedValue').textContent = e.target.value + 'x'; | |
| }); | |
| // Resolution selector | |
| document.getElementById('sliceResolution').addEventListener('change', () => { | |
| updateSliceData(); | |
| initSliceCanvas(); | |
| updateSliceView(parseFloat(cuttingPlaneSlider.value)); | |
| }); | |
| // Export button | |
| document.getElementById('exportBtn').addEventListener('click', exportScan); | |
| } | |
| // Update cutting plane position | |
| function updateCuttingPlane(zPosition) { | |
| if (cuttingPlaneMesh) { | |
| cuttingPlaneMesh.position.z = zPosition; | |
| } | |
| } | |
| // Animation loop | |
| function animate() { | |
| animationId = requestAnimationFrame(animate); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| // Export scan as video | |
| async function exportScan() { | |
| if (isRecording) return; | |
| const exportBtn = document.getElementById('exportBtn'); | |
| const progressBar = document.getElementById('exportProgress'); | |
| const progressFill = progressBar.querySelector('.progress-fill'); | |
| const progressText = progressBar.querySelector('.progress-text'); | |
| exportBtn.disabled = true; | |
| progressBar.classList.remove('hidden'); | |
| isRecording = true; | |
| // Setup MediaRecorder | |
| const stream = sliceCanvas.captureStream(30); | |
| const options = { | |
| mimeType: 'video/webm;codecs=vp8,opus', | |
| videoBitsPerSecond: 2500000 | |
| }; | |
| try { | |
| mediaRecorder = new MediaRecorder(stream, options); | |
| recordedChunks = []; | |
| mediaRecorder.ondataavailable = (event) => { | |
| if (event.data.size > 0) { | |
| recordedChunks.push(event.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(recordedChunks, { type: 'video/webm' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `3d-scan-${Date.now()}.webm`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| // Reset UI | |
| exportBtn.disabled = false; | |
| progressBar.classList.add('hidden'); | |
| isRecording = false; | |
| // Reset cutting plane | |
| cuttingPlaneSlider.value = 0; | |
| updateCuttingPlane(0); | |
| updateSliceView(0); | |
| }; | |
| // Start recording | |
| mediaRecorder.start(); | |
| // Animate scan from top to bottom | |
| const scanSpeed = parseFloat(document.getElementById('scanSpeed').value); | |
| const totalSteps = 100; | |
| const stepDelay = 1000 / scanSpeed / totalSteps; | |
| for (let i = 0; i <= totalSteps; i++) { | |
| const zPosition = 50 - (i * 100 / totalSteps); | |
| cuttingPlaneSlider.value = zPosition; | |
| updateCuttingPlane(zPosition); | |
| updateSliceView(zPosition); | |
| // Update progress | |
| const progress = (i / totalSteps) * 100; | |
| progressFill.style.width = progress + '%'; | |
| progressText.textContent = Math.floor(progress) + '%'; | |
| await new Promise(resolve => setTimeout(resolve, stepDelay)); | |
| } | |
| // Stop recording | |
| mediaRecorder.stop(); | |
| } catch (err) { | |
| console.error('Recording failed:', err); | |
| alert('Video recording failed. Please check browser permissions.'); | |
| exportBtn.disabled = false; | |
| progressBar.classList.add('hidden'); | |
| isRecording = false; | |
| } | |
| } | |
| // Initialize on load | |
| window.addEventListener('load', init3DScene); | |
| // Handle window resize | |
| window.addEventListener('resize', () => { | |
| const container = document.getElementById('canvas3d'); | |
| if (camera && renderer) { | |
| camera.aspect = container.clientWidth / container.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(container.clientWidth, container.clientHeight); | |
| } | |
| }); |