drones / index.html
aaurelions's picture
Update index.html
22ce57e verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Definitive 3D Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://rsms.me/inter/inter.css');
html { font-family: 'Inter', sans-serif; }
body { background-color: #111827; }
.glass-ui {
background-color: rgba(23, 31, 47, 0.75);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-overlay {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
}
#loading-overlay .spinner {
border-top-color: #3b82f6;
}
.side-panel {
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.side-panel.left { transform: translateX(-100%); }
.side-panel.right { transform: translateX(100%); }
.side-panel.is-open { transform: translateX(0); }
.panel-trigger {
transition: background-color 0.2s ease;
}
.panel-trigger:hover { background-color: rgba(59, 130, 246, 0.5); }
.list-item {
transition: all 0.2s ease-in-out;
border: 2px solid transparent;
}
.list-item:hover {
background-color: rgba(59, 130, 246, 0.1);
transform: scale(1.03);
}
.list-item.active {
background-color: rgba(59, 130, 246, 0.2);
border-color: #3b82f6;
}
.panel-content::-webkit-scrollbar { width: 6px; }
.panel-content::-webkit-scrollbar-track { background: transparent; }
.panel-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 10px; }
.drop-zone {
border: 2px dashed #4b5563;
transition: background-color 0.2s, border-color 0.2s;
}
.drop-zone.drag-over {
background-color: rgba(59, 130, 246, 0.15);
border-color: #3b82f6;
}
</style>
</head>
<body class="text-gray-100 select-none overflow-hidden">
<canvas id="bg-canvas" class="absolute top-0 left-0 w-full h-full outline-none z-10"></canvas>
<div id="loading-overlay" class="glass-ui absolute inset-0 z-[51] flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300">
<div class="spinner w-12 h-12 border-4 border-gray-600 rounded-full animate-spin"></div>
</div>
<div id="main-loader" class="absolute inset-0 bg-gray-900 flex flex-col items-center justify-center z-50 transition-opacity duration-500">
<div class="w-16 h-16 border-4 border-dashed rounded-full animate-spin border-blue-500"></div>
<p class="mt-4 text-xl tracking-wider text-white">Initializing Viewer...</p>
</div>
<!-- Panel Triggers -->
<button id="model-panel-trigger" class="panel-trigger glass-ui absolute top-1/2 -translate-y-1/2 left-0 z-20 rounded-r-lg p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7l8 4"/></svg>
</button>
<button id="panorama-panel-trigger" class="panel-trigger glass-ui absolute top-1/2 -translate-y-1/2 right-0 z-20 rounded-l-lg p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</button>
<!-- Panels -->
<aside id="model-panel" class="side-panel left glass-ui fixed top-0 left-0 h-full w-80 max-w-[80vw] z-30 flex flex-col">
<div class="flex-shrink-0 flex justify-between items-center p-4 border-b border-white/10">
<h2 class="text-xl font-bold text-white">Models</h2>
<div>
<button id="add-model-btn" class="p-2 rounded-full hover:bg-blue-500/50" title="Add new model">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
</button>
<button id="close-model-panel" class="p-2 rounded-full hover:bg-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<div id="model-selector" class="panel-content flex-grow overflow-y-auto p-4 grid grid-cols-2 gap-4"></div>
</aside>
<aside id="panorama-panel" class="side-panel right glass-ui fixed top-0 right-0 h-full w-80 max-w-[80vw] z-30 flex flex-col">
<div class="flex-shrink-0 flex justify-between items-center p-4 border-b border-white/10">
<h2 class="text-xl font-bold text-white">Panoramas</h2>
<div>
<button id="add-panorama-btn" class="p-2 rounded-full hover:bg-blue-500/50" title="Add new panorama">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
</button>
<button id="close-panorama-panel" class="p-2 rounded-full hover:bg-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<div id="panorama-gallery" class="panel-content flex-grow overflow-y-auto p-4 grid grid-cols-2 gap-4"></div>
</aside>
<!-- Upload Modals -->
<div id="upload-model-modal" class="modal-overlay fixed inset-0 z-40 hidden items-center justify-center">
<div class="glass-ui p-6 rounded-lg w-full max-w-md mx-4 flex flex-col gap-4">
<h3 class="text-2xl font-bold text-center">Upload New Model</h3>
<div id="model-drop-zone" class="drop-zone p-6 rounded-lg text-center cursor-pointer">
<p class="text-gray-300 pointer-events-none">Drag & Drop .glb file</p>
<p class="text-gray-400 text-sm pointer-events-none">or click to browse</p>
<input type="file" id="model-file-input" class="hidden" accept=".glb,.gltf">
</div>
<div class="flex items-center text-gray-400"><hr class="flex-grow border-white/10"><span class="mx-2 text-sm">OR</span><hr class="flex-grow border-white/10"></div>
<input type="text" id="model-url-input" class="w-full bg-gray-900/50 p-2 rounded-md border-2 border-gray-600 focus:border-blue-500 outline-none" placeholder="Enter model URL (.glb, .gltf)">
<div class="flex justify-end gap-3 mt-2">
<button onclick="document.getElementById('upload-model-modal').style.display='none'" class="bg-gray-600 px-4 py-2 rounded-md font-semibold hover:bg-gray-700">Cancel</button>
<button id="load-model-url-btn" class="bg-blue-600 px-4 py-2 rounded-md font-semibold hover:bg-blue-700 min-w-[80px] flex justify-center items-center">Load</button>
</div>
</div>
</div>
<div id="upload-panorama-modal" class="modal-overlay fixed inset-0 z-40 hidden items-center justify-center">
<div class="glass-ui p-6 rounded-lg w-full max-w-md mx-4 flex flex-col gap-4">
<h3 class="text-2xl font-bold text-center">Upload New Panorama</h3>
<div id="panorama-drop-zone" class="drop-zone p-6 rounded-lg text-center cursor-pointer">
<p class="text-gray-300 pointer-events-none">Drag & Drop image</p>
<p class="text-gray-400 text-sm pointer-events-none">or click to browse</p>
<input type="file" id="panorama-file-input" class="hidden" accept="image/*">
</div>
<div class="flex items-center text-gray-400"><hr class="flex-grow border-white/10"><span class="mx-2 text-sm">OR</span><hr class="flex-grow border-white/10"></div>
<input type="text" id="panorama-url-input" class="w-full bg-gray-900/50 p-2 rounded-md border-2 border-gray-600 focus:border-blue-500 outline-none" placeholder="Enter panorama image URL">
<div class="flex justify-end gap-3 mt-2">
<button onclick="document.getElementById('upload-panorama-modal').style.display='none'" class="bg-gray-600 px-4 py-2 rounded-md font-semibold hover:bg-gray-700">Cancel</button>
<button id="load-panorama-url-btn" class="bg-blue-600 px-4 py-2 rounded-md font-semibold hover:bg-blue-700 min-w-[80px] flex justify-center items-center">Load</button>
</div>
</div>
</div>
<script type="importmap">{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const defaultPanoramaFiles = Array.from({ length: 10 }, (_, i) => ({ name: `bg${i + 1}.jpg`, url: `/jpg/bg${i + 1}.jpg`, thumb: `/jpg/thumbnails/bg${i + 1}.jpg` }));
const defaultModelFiles = [ 'gerbera.glb', 'shahed1.glb', 'shahed2.glb', 'shahed3.glb', 'supercam.glb', 'zala.glb', 'beaver.glb' ].map(name => ({ name, url: `/glb/${name}`, thumb: `/glb/thumbnails/${name.replace('.glb', '.png')}` }));
let scene, camera, renderer, controls;
let isInitialized = false;
const loadedModels = new Map();
const mainLoader = document.getElementById('main-loader');
const loadingOverlay = document.getElementById('loading-overlay');
const textureLoader = new THREE.TextureLoader();
const gltfLoader = new GLTFLoader();
async function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 1.5, 5);
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('bg-canvas'), antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.outputEncoding = THREE.sRGBEncoding;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 1, 0);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight.position.set(8, 15, 10);
scene.add(dirLight);
setupUI();
setupEventListeners();
await setPanorama(defaultPanoramaFiles[0]);
await loadAndSwitchModel(defaultModelFiles[0]);
mainLoader.style.opacity = '0';
setTimeout(() => {
mainLoader.style.display = 'none';
isInitialized = true;
}, 500);
window.addEventListener('resize', onWindowResize);
animate();
}
function setPanorama(panoData) {
return new Promise((resolve, reject) => {
showLoadingOverlay(true);
textureLoader.load(panoData.url, texture => {
texture.encoding = THREE.sRGBEncoding;
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.background = texture;
scene.environment = texture;
document.querySelectorAll('#panorama-gallery .list-item').forEach(c => c.classList.remove('active'));
const activeItem = document.querySelector(`#panorama-gallery [data-name="${panoData.name}"]`);
if(activeItem) activeItem.classList.add('active');
showLoadingOverlay(false);
resolve();
}, undefined, (err) => {
console.error('Error loading panorama:', err);
alert(`Error: Could not load panorama "${panoData.name}".`);
showLoadingOverlay(false);
reject(err);
});
});
}
function switchActiveModel(modelName) {
scene.children.forEach(child => {
if (child.isGroup) child.visible = (child.name === modelName);
});
document.querySelectorAll('#model-selector .list-item').forEach(b => b.classList.toggle('active', b.dataset.name === modelName));
}
function loadAndSwitchModel(modelData) {
return new Promise((resolve, reject) => {
if (loadedModels.has(modelData.name)) {
switchActiveModel(modelData.name);
resolve();
return;
}
showLoadingOverlay(true);
gltfLoader.load(modelData.url, gltf => {
const model = gltf.scene;
model.name = modelData.name;
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 3.0 / maxDim;
model.scale.set(scale, scale, scale);
model.position.y += size.y * scale / 2;
scene.add(model);
loadedModels.set(modelData.name, model);
switchActiveModel(modelData.name);
showLoadingOverlay(false);
resolve();
}, undefined, (err) => {
console.error('Error loading model:', err);
alert(`Error: Could not load model "${modelData.name}".`);
showLoadingOverlay(false);
reject(err);
});
});
}
function createModelCard(modelData) {
const container = document.createElement('div');
container.className = 'list-item flex flex-col rounded-lg cursor-pointer overflow-hidden bg-white/5';
container.dataset.name = modelData.name;
container.onclick = () => loadAndSwitchModel(modelData);
const thumb = document.createElement('img');
thumb.src = modelData.thumb;
thumb.alt = `Thumbnail for ${modelData.name}`;
thumb.className = 'w-full h-auto aspect-square object-contain';
thumb.onerror = () => {
thumb.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2E1YjRjYyIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHBhdGggZD0iTTUgMyBsIDE0IDAgYSBUbyAwIDAgMSAyIDIgTCAyMSAxOSBhIDIgMiAwIDAgMSAtMiAyIEwgNSAyMSBhIDIgMiAwIDAgMSAtMiAtMiBMIDMgNSBhIDIgMiAwIDAgMSAyIC0yIFoiPjwvcGF0aD48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSI0Ij48L2NpcmNsZT48L3N2Zz4=';
thumb.classList.add('bg-gray-800', 'p-4');
};
const nameWrapper = document.createElement('div');
nameWrapper.className = 'p-2 w-full bg-black/20';
const name = document.createElement('span');
name.textContent = modelData.name.replace(/\.[^/.]+$/, "").replace(/(\d)/, ' $1').toUpperCase();
name.className = 'text-white text-xs font-medium text-center block truncate';
nameWrapper.appendChild(name);
container.appendChild(thumb);
container.appendChild(nameWrapper);
document.getElementById('model-selector').appendChild(container);
}
function createPanoramaCard(panoData) {
const container = document.createElement('div');
container.className = 'list-item aspect-video rounded-lg overflow-hidden cursor-pointer bg-white/5';
container.dataset.name = panoData.name;
container.onclick = () => setPanorama(panoData);
const thumb = document.createElement('img');
thumb.src = panoData.thumb;
thumb.className = 'w-full h-full object-cover';
thumb.alt = `Thumbnail for ${panoData.name}`;
thumb.onerror = () => { thumb.classList.add('bg-gray-800'); }
container.appendChild(thumb);
document.getElementById('panorama-gallery').appendChild(container);
}
function setupUI() {
defaultModelFiles.forEach(createModelCard);
defaultPanoramaFiles.forEach(createPanoramaCard);
}
function toggleButtonLoading(button, isLoading) {
if (isLoading) {
button.disabled = true;
button.innerHTML = `<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>`;
} else {
button.disabled = false;
button.innerHTML = 'Load';
}
}
function setupEventListeners() {
const modelPanel = document.getElementById('model-panel');
const panoramaPanel = document.getElementById('panorama-panel');
const uploadModelModal = document.getElementById('upload-model-modal');
const uploadPanoramaModal = document.getElementById('upload-panorama-modal');
const closeModelPanel = () => modelPanel.classList.remove('is-open');
const closePanoramaPanel = () => panoramaPanel.classList.remove('is-open');
document.getElementById('model-panel-trigger').addEventListener('click', e => { e.stopPropagation(); modelPanel.classList.toggle('is-open'); closePanoramaPanel(); });
document.getElementById('panorama-panel-trigger').addEventListener('click', e => { e.stopPropagation(); panoramaPanel.classList.toggle('is-open'); closeModelPanel(); });
document.getElementById('close-model-panel').addEventListener('click', closeModelPanel);
document.getElementById('close-panorama-panel').addEventListener('click', closePanoramaPanel);
document.getElementById('bg-canvas').addEventListener('click', () => { closeModelPanel(); closePanoramaPanel(); });
document.getElementById('add-model-btn').addEventListener('click', () => uploadModelModal.style.display = 'flex');
document.getElementById('add-panorama-btn').addEventListener('click', () => uploadPanoramaModal.style.display = 'flex');
const modelFileInput = document.getElementById('model-file-input');
const panoramaFileInput = document.getElementById('panorama-file-input');
modelFileInput.addEventListener('change', e => handleModelFile(e.target.files[0]));
panoramaFileInput.addEventListener('change', e => handlePanoramaFile(e.target.files[0]));
const loadModelUrlBtn = document.getElementById('load-model-url-btn');
const modelUrlInput = document.getElementById('model-url-input');
loadModelUrlBtn.addEventListener('click', () => {
const url = modelUrlInput.value.trim();
if(url) {
toggleButtonLoading(loadModelUrlBtn, true);
handleModelFile(url).finally(() => toggleButtonLoading(loadModelUrlBtn, false));
}
});
const loadPanoramaUrlBtn = document.getElementById('load-panorama-url-btn');
const panoramaUrlInput = document.getElementById('panorama-url-input');
loadPanoramaUrlBtn.addEventListener('click', () => {
const url = panoramaUrlInput.value.trim();
if(url) {
toggleButtonLoading(loadPanoramaUrlBtn, true);
handlePanoramaFile(url).finally(() => toggleButtonLoading(loadPanoramaUrlBtn, false));
}
});
setupDragDrop('model-drop-zone', handleModelFile, modelFileInput);
setupDragDrop('panorama-drop-zone', handlePanoramaFile, panoramaFileInput);
}
async function handleModelFile(fileOrUrl) {
if(!fileOrUrl) return;
const isUrl = typeof fileOrUrl === 'string';
const url = isUrl ? fileOrUrl : URL.createObjectURL(fileOrUrl);
const name = isUrl ? fileOrUrl.split('/').pop().split('?')[0] : fileOrUrl.name;
const modelData = { name, url, thumb: `glb/thumbnails/${name.replace('.glb', '.png')}` };
document.getElementById('upload-model-modal').style.display = 'none';
document.getElementById('model-url-input').value = '';
createModelCard(modelData);
await loadAndSwitchModel(modelData);
}
async function handlePanoramaFile(fileOrUrl) {
if(!fileOrUrl) return;
const isUrl = typeof fileOrUrl === 'string';
const url = isUrl ? fileOrUrl : URL.createObjectURL(fileOrUrl);
const name = isUrl ? fileOrUrl.split('/').pop().split('?')[0] : fileOrUrl.name;
const panoData = { name, url, thumb: url };
document.getElementById('upload-panorama-modal').style.display = 'none';
document.getElementById('panorama-url-input').value = '';
createPanoramaCard(panoData);
await setPanorama(panoData);
}
function setupDragDrop(zoneId, fileHandler, fileInput) {
const dropZone = document.getElementById(zoneId);
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); });
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
if(e.dataTransfer.files.length) {
fileHandler(e.dataTransfer.files[0]);
}
});
}
function showLoadingOverlay(show) {
if (show && !isInitialized) return;
loadingOverlay.style.opacity = show ? '1' : '0';
loadingOverlay.style.pointerEvents = show ? 'auto' : 'none';
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
init();
</script>
</body>
</html>