| | |
| | window.currentPlyData = null; |
| | window.currentPlyFilename = null; |
| |
|
| | |
| | const canvas = document.getElementById('particleCanvas'); |
| | const ctx = canvas.getContext('2d'); |
| | let particles = []; |
| | let animationId; |
| | let mouseX = 0, mouseY = 0; |
| |
|
| | function resizeCanvas() { |
| | canvas.width = window.innerWidth; |
| | canvas.height = window.innerHeight; |
| | } |
| |
|
| | class Particle { |
| | constructor() { |
| | this.reset(); |
| | } |
| |
|
| | reset() { |
| | this.x = Math.random() * canvas.width; |
| | this.y = Math.random() * canvas.height; |
| | this.z = Math.random() * 1000; |
| | this.baseSize = Math.random() * 2 + 0.5; |
| | this.color = this.getColor(); |
| | this.vx = (Math.random() - 0.5) * 0.3; |
| | this.vy = (Math.random() - 0.5) * 0.3; |
| | this.vz = (Math.random() - 0.5) * 2; |
| | } |
| |
|
| | getColor() { |
| | const colors = [ |
| | { r: 99, g: 102, b: 241 }, |
| | { r: 168, g: 85, b: 247 }, |
| | { r: 236, g: 72, b: 153 }, |
| | { r: 59, g: 130, b: 246 }, |
| | { r: 139, g: 92, b: 246 }, |
| | ]; |
| | return colors[Math.floor(Math.random() * colors.length)]; |
| | } |
| |
|
| | update() { |
| | |
| | const dx = mouseX - this.x; |
| | const dy = mouseY - this.y; |
| | const dist = Math.sqrt(dx * dx + dy * dy); |
| | if (dist < 150) { |
| | const force = (150 - dist) / 150; |
| | this.vx -= (dx / dist) * force * 0.5; |
| | this.vy -= (dy / dist) * force * 0.5; |
| | } |
| |
|
| | this.x += this.vx; |
| | this.y += this.vy; |
| | this.z += this.vz; |
| |
|
| | |
| | this.vx *= 0.99; |
| | this.vy *= 0.99; |
| |
|
| | |
| | if (this.x < 0) this.x = canvas.width; |
| | if (this.x > canvas.width) this.x = 0; |
| | if (this.y < 0) this.y = canvas.height; |
| | if (this.y > canvas.height) this.y = 0; |
| | if (this.z < 0 || this.z > 1000) this.vz *= -1; |
| | } |
| |
|
| | draw() { |
| | const perspective = 1000 / (1000 + this.z); |
| | const size = this.baseSize * perspective * 3; |
| | const isLightMode = document.documentElement.getAttribute('data-theme') === 'light'; |
| | const alpha = perspective * (isLightMode ? 0.4 : 0.6); |
| |
|
| | ctx.beginPath(); |
| | const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, size * 2); |
| | gradient.addColorStop(0, `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, ${alpha})`); |
| | gradient.addColorStop(1, `rgba(${this.color.r}, ${this.color.g}, ${this.color.b}, 0)`); |
| | ctx.fillStyle = gradient; |
| | ctx.arc(this.x, this.y, size * 2, 0, Math.PI * 2); |
| | ctx.fill(); |
| | } |
| | } |
| |
|
| | function initParticles() { |
| | particles = []; |
| | const count = Math.min(200, Math.floor((canvas.width * canvas.height) / 8000)); |
| | for (let i = 0; i < count; i++) { |
| | particles.push(new Particle()); |
| | } |
| | } |
| |
|
| | function animate() { |
| | ctx.clearRect(0, 0, canvas.width, canvas.height); |
| |
|
| | particles.forEach(p => { |
| | p.update(); |
| | p.draw(); |
| | }); |
| |
|
| | |
| | const isLightMode = document.documentElement.getAttribute('data-theme') === 'light'; |
| | ctx.strokeStyle = isLightMode ? 'rgba(99, 102, 241, 0.08)' : 'rgba(255, 255, 255, 0.02)'; |
| | ctx.lineWidth = 0.5; |
| | for (let i = 0; i < particles.length; i++) { |
| | for (let j = i + 1; j < particles.length; j++) { |
| | const dx = particles[i].x - particles[j].x; |
| | const dy = particles[i].y - particles[j].y; |
| | const dist = Math.sqrt(dx * dx + dy * dy); |
| | if (dist < 100) { |
| | ctx.beginPath(); |
| | ctx.moveTo(particles[i].x, particles[i].y); |
| | ctx.lineTo(particles[j].x, particles[j].y); |
| | ctx.stroke(); |
| | } |
| | } |
| | } |
| |
|
| | animationId = requestAnimationFrame(animate); |
| | } |
| |
|
| | window.addEventListener('resize', () => { |
| | resizeCanvas(); |
| | initParticles(); |
| | }); |
| |
|
| | document.addEventListener('mousemove', (e) => { |
| | mouseX = e.clientX; |
| | mouseY = e.clientY; |
| | }); |
| |
|
| | resizeCanvas(); |
| | initParticles(); |
| | animate(); |
| |
|
| | |
| | const themeToggle = document.getElementById('themeToggle'); |
| | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); |
| |
|
| | function setTheme(theme) { |
| | document.documentElement.setAttribute('data-theme', theme); |
| | localStorage.setItem('theme', theme); |
| | } |
| |
|
| | function getPreferredTheme() { |
| | const stored = localStorage.getItem('theme'); |
| | if (stored) return stored; |
| | return prefersDark.matches ? 'dark' : 'light'; |
| | } |
| |
|
| | |
| | setTheme(getPreferredTheme()); |
| |
|
| | themeToggle.addEventListener('click', () => { |
| | const current = document.documentElement.getAttribute('data-theme'); |
| | setTheme(current === 'light' ? 'dark' : 'light'); |
| | }); |
| |
|
| | |
| | prefersDark.addEventListener('change', (e) => { |
| | if (!localStorage.getItem('theme')) { |
| | setTheme(e.matches ? 'dark' : 'light'); |
| | } |
| | }); |
| |
|
| | |
| | const dropZone = document.getElementById('dropZone'); |
| | const fileInput = document.getElementById('fileInput'); |
| | const fileList = document.getElementById('fileList'); |
| | const form = document.getElementById('uploadForm'); |
| | const loaderContainer = document.getElementById('loaderContainer'); |
| | const results = document.getElementById('results'); |
| | const submitBtn = document.getElementById('submitBtn'); |
| |
|
| | dropZone.addEventListener('click', () => fileInput.click()); |
| |
|
| | dropZone.addEventListener('dragover', (e) => { |
| | e.preventDefault(); |
| | dropZone.classList.add('drag-over'); |
| | }); |
| |
|
| | dropZone.addEventListener('dragleave', () => { |
| | dropZone.classList.remove('drag-over'); |
| | }); |
| |
|
| | dropZone.addEventListener('drop', (e) => { |
| | e.preventDefault(); |
| | dropZone.classList.remove('drag-over'); |
| | fileInput.files = e.dataTransfer.files; |
| | updateFileList(); |
| | }); |
| |
|
| | fileInput.addEventListener('change', updateFileList); |
| |
|
| | function formatFileSize(bytes) { |
| | if (bytes < 1024) return bytes + ' B'; |
| | if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
| | return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; |
| | } |
| |
|
| | function updateFileList() { |
| | fileList.innerHTML = ''; |
| |
|
| | if (fileInput.files.length > 0) { |
| | dropZone.classList.add('has-files'); |
| | } else { |
| | dropZone.classList.remove('has-files'); |
| | } |
| |
|
| | for (const file of fileInput.files) { |
| | const div = document.createElement('div'); |
| | div.className = 'file-item'; |
| |
|
| | |
| | const reader = new FileReader(); |
| | reader.onload = (e) => { |
| | const preview = div.querySelector('.file-preview'); |
| | if (preview) preview.src = e.target.result; |
| | }; |
| | reader.readAsDataURL(file); |
| |
|
| | div.innerHTML = ` |
| | <img class="file-preview" src="" alt=""> |
| | <div class="file-info"> |
| | <div class="file-name">${file.name}</div> |
| | <div class="file-size">${formatFileSize(file.size)}</div> |
| | </div> |
| | `; |
| | fileList.appendChild(div); |
| | } |
| | } |
| |
|
| | form.addEventListener('submit', async (e) => { |
| | e.preventDefault(); |
| | if (fileInput.files.length === 0) return; |
| |
|
| | submitBtn.disabled = true; |
| | dropZone.style.display = 'none'; |
| | fileList.style.display = 'none'; |
| | submitBtn.style.display = 'none'; |
| | loaderContainer.classList.add('active'); |
| | results.innerHTML = ''; |
| |
|
| | const formData = new FormData(); |
| | for (const file of fileInput.files) { |
| | formData.append('files', file); |
| | } |
| |
|
| | try { |
| | const response = await fetch('/predict', { |
| | method: 'POST', |
| | body: formData |
| | }); |
| |
|
| | if (response.ok) { |
| | const data = await response.json(); |
| |
|
| | if (data.results && data.results.length > 0) { |
| | const result = data.results[0]; |
| |
|
| | if (result.error) { |
| | showError(result.error); |
| | } else { |
| | |
| | window.currentPlyData = result.ply_data; |
| | window.currentPlyFilename = result.ply_filename; |
| |
|
| | |
| | if (typeof window.showViewer === 'function') { |
| | window.showViewer(result); |
| | } else { |
| | |
| | showError('3D viewer failed to load. Click the download button to get your PLY file.'); |
| | |
| | downloadPly(result.ply_data, result.ply_filename); |
| | } |
| | } |
| | } |
| | } else { |
| | const error = await response.text(); |
| | showError(error); |
| | |
| | dropZone.style.display = ''; |
| | fileList.style.display = ''; |
| | submitBtn.style.display = ''; |
| | } |
| | } catch (err) { |
| | showError(err.message); |
| | |
| | dropZone.style.display = ''; |
| | fileList.style.display = ''; |
| | submitBtn.style.display = ''; |
| | } finally { |
| | submitBtn.disabled = false; |
| | loaderContainer.classList.remove('active'); |
| | } |
| | }); |
| |
|
| | function showError(message) { |
| | results.innerHTML = ` |
| | <div class="result-item" style="background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.2);"> |
| | <div class="success-icon" style="background: rgba(239, 68, 68, 0.2);"> |
| | <svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| | <line x1="18" y1="6" x2="6" y2="18"></line> |
| | <line x1="6" y1="6" x2="18" y2="18"></line> |
| | </svg> |
| | </div> |
| | <div class="result-text"> |
| | <div class="result-title" style="color: #ef4444;">Error</div> |
| | <div class="result-desc">${message}</div> |
| | </div> |
| | </div> |
| | `; |
| | } |
| |
|
| | function downloadPly(plyData, filename) { |
| | const binaryString = atob(plyData); |
| | const bytes = new Uint8Array(binaryString.length); |
| | for (let i = 0; i < binaryString.length; i++) { |
| | bytes[i] = binaryString.charCodeAt(i); |
| | } |
| | const blob = new Blob([bytes], { type: 'application/octet-stream' }); |
| | const url = URL.createObjectURL(blob); |
| | const a = document.createElement('a'); |
| | a.href = url; |
| | a.download = filename; |
| | a.click(); |
| | URL.revokeObjectURL(url); |
| | } |
| |
|