|
|
|
|
|
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); |
|
|
} |
|
|
|