import concaveman from 'concaveman'; import gsap from "gsap"; import JSZip from "jszip"; import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { TransformControls } from 'three/addons/controls/TransformControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'; const ASSET_BASE_URL = 'https://huggingface.co/spaces/aaurelions/drones/resolve/main'; const defaultPanoramas = Array.from({ length: 10 }, (_, i) => ({ name: `bg${i + 1}.jpg`, url: `${ASSET_BASE_URL}/jpg/bg${i + 1}.jpg`, thumb: `${ASSET_BASE_URL}/jpg/thumbnails/bg${i + 1}.jpg` })); const defaultModels = ['gerbera.glb', 'shahed1.glb', 'shahed2.glb', 'shahed3.glb', 'supercam.glb', 'zala.glb', 'beaver.glb'].map(n => ({ name: n, url: `${ASSET_BASE_URL}/glb/${n}`, thumb: `${ASSET_BASE_URL}/glb/thumbnails/${n.replace('.glb', '.png')}` })); let panoramaAssets = [...defaultPanoramas]; let modelAssets = [...defaultModels]; let scene, camera, renderer, orbitControls, transformControls, pmremGenerator, panoramaSphere; let selectedObject = null; let activeControlMode = null; let activePanorama = null; let maskScene, maskMaterial; const loaderOverlay = document.getElementById('loader-overlay'); const loaderText = document.getElementById('loader-text'); const canvas = document.getElementById('main-canvas'); async function init() { loaderText.textContent = 'Setting up 3D scene...'; scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 2000); camera.position.set(0, 1.5, 8); renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.outputColorSpace = THREE.SRGBColorSpace; pmremGenerator = new THREE.PMREMGenerator(renderer); pmremGenerator.compileEquirectangularShader(); const panoGeometry = new THREE.SphereGeometry(1000, 60, 40); panoGeometry.scale(-1, 1, 1); const panoMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); panoramaSphere = new THREE.Mesh(panoGeometry, panoMaterial); scene.add(panoramaSphere); orbitControls = new OrbitControls(camera, renderer.domElement); orbitControls.enableDamping = true; orbitControls.target.set(0, 1, 0); transformControls = new TransformControls(camera, renderer.domElement); transformControls.enabled = false; scene.add(transformControls); const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); dirLight.position.set(8, 15, 10); scene.add(ambientLight, dirLight); maskScene = new THREE.Scene(); maskMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); setupUI(); setupEventListeners(); animate(); try { await loadAsset(panoramaAssets[6], 'panorama'); await loadAsset(modelAssets[1], 'model'); setControlMode('translate'); } catch (error) { showNotification({ text: `Initialization failed: ${error.message}`, type: 'error' }); console.error("Initialization failed:", error); } finally { gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') }); } } async function loadAsset(assetData, assetType, button = null) { const isInitialLoad = !loaderOverlay.classList.contains('hidden'); let spinner, textSpan; if (button) { spinner = button.querySelector('.btn-spinner'); textSpan = button.querySelector('.btn-text'); spinner.classList.remove('hidden'); textSpan.classList.add('hidden'); button.disabled = true; } else if (!isInitialLoad) { loaderText.textContent = `Loading ${assetType}...`; loaderOverlay.classList.remove('hidden'); gsap.to(loaderOverlay, { opacity: 1, duration: 0.3 }); canvas.classList.add('loading'); } else { loaderText.textContent = `Loading ${assetType}: ${assetData.name}`; } try { if (assetType === 'panorama') { await new Promise((resolve, reject) => { const isHDR = assetData.url.endsWith('.hdr'); const loader = isHDR ? new RGBELoader() : new THREE.TextureLoader(); loader.load(assetData.url, (texture) => { texture.mapping = THREE.EquirectangularReflectionMapping; if (!isHDR) texture.colorSpace = THREE.SRGBColorSpace; if (panoramaSphere) { panoramaSphere.material.map = texture; panoramaSphere.material.needsUpdate = true; } scene.environment = pmremGenerator.fromEquirectangular(texture).texture; scene.background = texture; scene.environment = pmremGenerator.fromEquirectangular(texture).texture; pmremGenerator.dispose(); texture.dispose(); activePanorama = assetData; setActiveCard('panorama-gallery', assetData.name); resolve(); }, undefined, reject); }); } else { if (selectedObject) transformControls.detach(); const existingAsset = modelAssets.find(m => m.name === assetData.name && m.mesh); if (existingAsset) { modelAssets.forEach(m => { if (m.mesh) m.mesh.visible = false; }); existingAsset.mesh.visible = true; selectedObject = existingAsset.mesh; } else { await new Promise((resolve, reject) => { const loader = new GLTFLoader(); loader.load(assetData.url, (gltf) => { modelAssets.forEach(m => { if (m.mesh) m.mesh.visible = false; }); const model = gltf.scene; model.name = assetData.name; const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); const maxDim = Math.max(size.x, size.y, size.z); model.scale.setScalar(4.0 / maxDim); scene.add(model); const assetToUpdate = modelAssets.find(m => m.name === assetData.name); if (assetToUpdate) { assetToUpdate.mesh = model; assetToUpdate.originalMaterials = new Map(); model.traverse(node => { if (node.isMesh) assetToUpdate.originalMaterials.set(node, node.material); }); } selectedObject = model; resolve(); }, undefined, reject); }); } if (activeControlMode) transformControls.attach(selectedObject); setActiveCard('model-selector', assetData.name); } } catch (error) { showNotification({ text: `Failed to load ${assetType}: ${assetData.name}`, type: 'error', duration: 4000 }); console.error(`Failed to load ${assetType}:`, error); } finally { if (button) { spinner.classList.add('hidden'); textSpan.classList.remove('hidden'); button.disabled = false; } else if (!isInitialLoad) { gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') }); canvas.classList.remove('loading'); } } } function createAssetCard(type, assetData) { const container = document.createElement('div'); container.className = 'asset-card flex flex-col rounded-lg cursor-pointer overflow-hidden bg-stone-900/50'; container.dataset.name = assetData.name; const thumbWrapper = document.createElement('div'); thumbWrapper.className = 'w-full h-28 flex items-center justify-center'; const thumb = document.createElement('img'); thumb.className = 'max-w-full max-h-full object-contain'; container.onclick = () => loadAsset(assetData, type); if (type === 'model') { thumbWrapper.classList.add('p-2'); thumb.src = assetData.thumb; const nameWrapper = document.createElement('div'); nameWrapper.textContent = assetData.name.replace(/\.[^/.]+$/, "").substring(0, 15); nameWrapper.className = 'text-white text-xs font-semibold text-center block truncate p-2 bg-black/20 mt-auto'; thumbWrapper.appendChild(thumb); container.append(thumbWrapper, nameWrapper); } else { thumb.className = 'w-full h-full object-cover'; thumb.src = assetData.thumb; thumbWrapper.appendChild(thumb); container.appendChild(thumbWrapper); } thumb.onerror = () => { thumbWrapper.innerHTML = ``; feather.replace(); thumbWrapper.parentElement.classList.add('bg-stone-800'); } document.getElementById(type === 'model' ? 'model-selector' : 'panorama-gallery').appendChild(container); } function setupUI() { feather.replace(); modelAssets.forEach(m => createAssetCard('model', m)); panoramaAssets.forEach(p => createAssetCard('panorama', p)); } function setControlMode(mode) { if (mode === activeControlMode) { activeControlMode = null; transformControls.detach(); transformControls.enabled = false; } else { activeControlMode = mode; transformControls.setMode(activeControlMode); transformControls.enabled = true; if (selectedObject) transformControls.attach(selectedObject); } document.querySelectorAll('.control-btn').forEach(b => b.classList.toggle('bg-blue-600', b.dataset.mode === activeControlMode)); } function setupEventListeners() { ['model', 'panorama'].forEach(type => { document.getElementById(`${type}-panel-trigger`).addEventListener('click', e => { e.stopPropagation(); document.getElementById(`${type}-panel`).classList.add('is-open'); }); document.getElementById(`close-${type}-panel`).addEventListener('click', () => document.getElementById(`${type}-panel`).classList.remove('is-open')); }); document.getElementById('day-night-toggle').addEventListener('change', (e) => { const isNight = e.target.checked; const tintColor = isNight ? 0x202040 : 0xffffff; if (panoramaSphere) { gsap.to(panoramaSphere.material.color, { r: (tintColor >> 16 & 255) / 255, g: (tintColor >> 8 & 255) / 255, b: (tintColor & 255) / 255, duration: 0.5 }); } gsap.to(renderer, { toneMappingExposure: isNight ? 0.3 : 1.0, duration: 0.5 }); gsap.to(scene.children.find(c => c.isDirectionalLight), { intensity: isNight ? 0.2 : 1.0, duration: 0.5 }); }); document.querySelectorAll('.control-btn').forEach(btn => btn.addEventListener('click', () => setControlMode(btn.dataset.mode))); transformControls.addEventListener('dragging-changed', e => { orbitControls.enabled = !e.value; }); canvas.addEventListener('click', (e) => { if (transformControls.dragging) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, camera); const visibleMeshObjects = modelAssets.map(m => m.mesh).filter(m => m && m.visible); if (visibleMeshObjects.length === 0) return; const intersects = raycaster.intersectObjects(visibleMeshObjects, true); if (intersects.length > 0) { const object = findTopLevelGroup(intersects[0].object); if (object && object.isGroup && object !== selectedObject) { const modelData = modelAssets.find(m => m.name === object.name); if (modelData) loadAsset(modelData, 'model'); } } }); window.addEventListener('keydown', (e) => { if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'SELECT') return; const key = e.key.toLowerCase(); if (key === 'w' || key === 'e' || key === 'r') { e.preventDefault(); setControlMode(key === 'w' ? 'translate' : key === 'e' ? 'rotate' : 'scale'); } if (key === 'g') { e.preventDefault(); document.getElementById('generate-dataset-btn').click(); } if (key === 'escape') { document.querySelectorAll('.side-panel.is-open, .modal-overlay.flex').forEach(p => p.classList.add('hidden')); } }); setupUploadModal(); setupDatasetModal(); } function setupUploadModal() { const modal = document.getElementById('upload-modal'); const title = document.getElementById('upload-title'); const fileInput = document.getElementById('upload-file-input'); const urlInput = document.getElementById('upload-url-input'); const dropZone = document.getElementById('upload-drop-zone'); const loadBtn = document.getElementById('upload-load-btn'); let currentType, currentHandler; const openModal = (type, titleText, accept, placeholder, handler) => { currentType = type; title.textContent = titleText; fileInput.value = ''; urlInput.value = ''; fileInput.accept = accept; urlInput.placeholder = placeholder; currentHandler = handler; modal.classList.remove('hidden'); modal.classList.add('flex'); }; document.getElementById('add-model-btn').addEventListener('click', () => openModal('model', 'Upload New Model', '.glb,.gltf', 'Enter .glb URL', (type, data) => handleNewAsset(type, data, loadBtn))); document.getElementById('add-panorama-btn').addEventListener('click', () => openModal('panorama', 'Upload New Panorama', 'image/*,.hdr', 'Enter image URL', (type, data) => handleNewAsset(type, data, loadBtn))); document.getElementById('upload-cancel-btn').addEventListener('click', () => modal.classList.add('hidden')); loadBtn.addEventListener('click', () => { const url = urlInput.value.trim(); if (url) currentHandler(currentType, url, loadBtn); }); dropZone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', e => { if (e.target.files.length) currentHandler(currentType, e.target.files[0], loadBtn) }); 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'); if (e.dataTransfer.files.length) currentHandler(currentType, e.dataTransfer.files[0], loadBtn); }); } async function handleNewAsset(type, fileOrUrl, button) { document.getElementById('upload-modal').classList.add('hidden'); const isUrl = typeof fileOrUrl === 'string'; const url = isUrl ? fileOrUrl : URL.createObjectURL(fileOrUrl); const name = (isUrl ? new URL(url).pathname.split('/').pop() : fileOrUrl.name).substring(0, 25); const newAssetData = { name, url, mesh: null }; if (type === 'model') { if (modelAssets.some(m => m.name === name)) { showNotification({ text: `Model "${name}" already exists.`, type: 'error' }); return; } newAssetData.thumb = 'placeholder'; modelAssets.push(newAssetData); createAssetCard('model', newAssetData); await loadAsset(newAssetData, 'model', button); } else { if (panoramaAssets.some(p => p.name === name)) { showNotification({ text: `Panorama "${name}" already exists.`, type: 'error' }); return; } newAssetData.thumb = url; panoramaAssets.push(newAssetData); createAssetCard('panorama', newAssetData); await loadAsset(newAssetData, 'panorama', button); } } function setupDatasetModal() { const modal = document.getElementById('dataset-modal'); document.getElementById('generate-dataset-btn').addEventListener('click', () => { modal.classList.remove('hidden'); modal.classList.add('flex'); feather.replace(); updateDatasetModalUI(); }); document.getElementById('cancel-dataset').addEventListener('click', () => modal.classList.add('hidden')); document.getElementById('start-dataset').addEventListener('click', handleDatasetGeneration); document.querySelectorAll('#dataset-modal input[type="radio"], #dataset-modal input[type="checkbox"]').forEach(input => { input.addEventListener('change', updateDatasetModalUI); }); const setupSlider = (sliderId, displayId, unit) => { const slider = document.getElementById(sliderId); const display = document.getElementById(displayId); if (slider && display) { const update = () => { display.textContent = slider.value + unit; }; update(); slider.addEventListener('input', update); } }; setupSlider('position-variance', 'position-variance-value', '%'); setupSlider('rotation-variance', 'rotation-variance-value', '%'); setupSlider('scale-variance', 'scale-variance-value', '%'); setupSlider('horizontal-variance', 'horizontal-variance-value', '°'); setupSlider('vertical-variance', 'vertical-variance-value', '°'); setupSlider('concave-hull-concavity', 'concave-hull-concavity-value', ''); setupSlider('background-ratio-slider', 'background-ratio-value', '%'); setupSlider('mask-simplification-slider', 'mask-simplification-value', ''); const trainSlider = document.getElementById('split-train-ratio'); const valSlider = document.getElementById('split-val-ratio'); const bringToFront = (el, topZ, bottomZ) => { el.style.zIndex = topZ; (el === trainSlider ? valSlider : trainSlider).style.zIndex = bottomZ; } trainSlider.addEventListener('input', () => { if (parseInt(trainSlider.value) >= parseInt(valSlider.value)) valSlider.value = parseInt(trainSlider.value); updateSplitRatios(); }); valSlider.addEventListener('input', () => { if (parseInt(valSlider.value) <= parseInt(trainSlider.value)) trainSlider.value = parseInt(valSlider.value); updateSplitRatios(); }); trainSlider.addEventListener('mousedown', () => bringToFront(trainSlider, 25, 20)); trainSlider.addEventListener('touchstart', () => bringToFront(trainSlider, 25, 20)); valSlider.addEventListener('mousedown', () => bringToFront(valSlider, 25, 20)); valSlider.addEventListener('touchstart', () => bringToFront(valSlider, 25, 20)); } function updateDatasetModalUI() { const useCurrentModel = document.querySelector('input[name="model-source"]:checked')?.value === 'current'; const modelInfo = document.getElementById('model-source-current-info'); modelInfo.textContent = `Using: ${selectedObject ? selectedObject.name : 'None'}`; modelInfo.classList.toggle('hidden', !useCurrentModel); const useCurrentPano = document.querySelector('input[name="pano-source"]:checked')?.value === 'current'; const panoInfo = document.getElementById('pano-source-current-info'); panoInfo.textContent = `Using: ${activePanorama ? activePanorama.name : 'None'}`; panoInfo.classList.toggle('hidden', !useCurrentPano); const randomizeModel = document.getElementById('randomize-model-toggle')?.checked; gsap.to("#model-sliders", { maxHeight: randomizeModel ? 200 : 0, opacity: randomizeModel ? 1 : 0, paddingTop: randomizeModel ? '0.75rem' : 0, duration: 0.4 }); const cameraSection = document.getElementById('camera-randomization-section'); const cameraToggle = document.getElementById('randomize-camera-toggle'); cameraSection.style.opacity = useCurrentPano ? '1' : '0.5'; cameraToggle.disabled = !useCurrentPano; if (!useCurrentPano) { cameraToggle.checked = false; } const cameraToggleLabel = cameraToggle.closest('label.flex.items-center.gap-2'); if (cameraToggleLabel) cameraToggleLabel.style.cursor = useCurrentPano ? 'pointer' : 'not-allowed'; const randomizeCamera = useCurrentPano && cameraToggle.checked; gsap.to("#camera-sliders", { maxHeight: randomizeCamera ? 150 : 0, opacity: randomizeCamera ? 1 : 0, paddingTop: randomizeCamera ? '0.75rem' : 0, duration: 0.4 }); const task = document.querySelector('input[name="dataset-task"]:checked').value; const segGroup = document.getElementById('segmentation-method-group'); const segMethod = document.querySelector('input[name="segmentation-method"]:checked').value; const concaveOptions = document.getElementById('concave-hull-options'); const renderMaskOptions = document.getElementById('render-mask-options'); const showSegGroup = task === 'segmentation'; gsap.to(segGroup, { maxHeight: showSegGroup ? 300 : 0, opacity: showSegGroup ? 1 : 0, paddingTop: showSegGroup ? '0.5rem' : 0, marginTop: showSegGroup ? '1rem' : 0, duration: 0.4 }); const showConcaveOptions = showSegGroup && segMethod === 'concave'; gsap.to(concaveOptions, { maxHeight: showConcaveOptions ? 100 : 0, opacity: showConcaveOptions ? 1 : 0, paddingTop: showConcaveOptions ? '0.75rem' : 0, duration: 0.4 }); const includeBackgrounds = document.getElementById('include-background-toggle').checked; gsap.to("#background-ratio-container", { maxHeight: includeBackgrounds ? 100 : 0, opacity: includeBackgrounds ? 1 : 0, paddingTop: includeBackgrounds ? '0.5rem' : 0, duration: 0.4 }); const showRenderMaskOptions = showSegGroup && segMethod === 'render'; gsap.to(renderMaskOptions, { maxHeight: showRenderMaskOptions ? 100 : 0, opacity: showRenderMaskOptions ? 1 : 0, paddingTop: showRenderMaskOptions ? '0.75rem' : 0, duration: 0.4 }); updateSplitRatios(); } function updateSplitRatios() { const valToggle = document.getElementById('include-val-set'); const testToggle = document.getElementById('include-test-set'); const trainSlider = document.getElementById('split-train-ratio'); const valSlider = document.getElementById('split-val-ratio'); valSlider.disabled = !valToggle.checked; testToggle.disabled = !valToggle.checked; if (!valToggle.checked) testToggle.checked = false; valSlider.style.visibility = testToggle.checked ? 'visible' : 'hidden'; valSlider.parentElement.style.opacity = valToggle.checked ? '1' : '0.4'; const trainPctRaw = parseInt(trainSlider.value); let valEndPctRaw = valToggle.checked ? parseInt(valSlider.value) : trainPctRaw; const trainPct = Math.max(0, Math.min(100, trainPctRaw)); let valEndPct = valToggle.checked ? Math.max(trainPct, Math.min(100, valEndPctRaw)) : trainPct; if (!testToggle.checked) { valEndPct = 100; } const valPct = valToggle.checked ? valEndPct - trainPct : 0; const testPct = testToggle.checked ? 100 - valEndPct : 0; const trainDisplayPct = 100 - valPct - testPct; document.getElementById('split-train-display').textContent = `${trainDisplayPct}%`; document.getElementById('split-val-display').textContent = `${valPct}%`; document.getElementById('split-test-display').textContent = `${testPct}%`; document.getElementById('split-val-display').style.visibility = valToggle.checked ? 'visible' : 'hidden'; document.getElementById('split-test-display').style.visibility = testToggle.checked ? 'visible' : 'hidden'; const total = trainDisplayPct + valPct + testPct; document.getElementById('split-ratio-sum').textContent = total; document.getElementById('split-bg-train').style.width = `${trainDisplayPct}%`; const valBg = document.getElementById('split-bg-val'); valBg.style.left = `${trainDisplayPct}%`; valBg.style.width = `${valPct}%`; valBg.style.display = valToggle.checked ? 'block' : 'none'; const testBg = document.getElementById('split-bg-test'); testBg.style.left = `${trainDisplayPct + valPct}%`; testBg.style.width = `${testPct}%`; testBg.style.display = testToggle.checked ? 'block' : 'none'; } async function handleDatasetGeneration() { const startBtn = document.getElementById('start-dataset'); const cancelBtn = document.getElementById('cancel-dataset'); const btnText = startBtn.querySelector('.btn-text'); const btnSpinner = startBtn.querySelector('.btn-spinner'); btnText.classList.add('hidden'); btnSpinner.classList.remove('hidden'); startBtn.disabled = true; cancelBtn.disabled = true; try { const trainSlider = document.getElementById('split-train-ratio'); const valSlider = document.getElementById('split-val-ratio'); const options = { task: document.querySelector('input[name="dataset-task"]:checked').value, segmentationMethod: document.querySelector('input[name="segmentation-method"]:checked').value, concavity: parseFloat(document.getElementById('concave-hull-concavity').value), rdpEpsilon: parseFloat(document.getElementById('mask-simplification-slider').value), samples: parseInt(document.getElementById('dataset-samples').value), name: document.getElementById('dataset-name').value.trim(), includeBackgrounds: document.getElementById('include-background-toggle').checked, backgroundRatio: parseInt(document.getElementById('background-ratio-slider').value) / 100, useCurrentModel: document.querySelector('input[name="model-source"]:checked').value === 'current', randomizeModel: document.getElementById('randomize-model-toggle').checked, posVar: parseInt(document.getElementById('position-variance').value) / 100, rotVar: parseInt(document.getElementById('rotation-variance').value) / 100, scaleVar: parseInt(document.getElementById('scale-variance').value) / 100, useCurrentPanorama: document.querySelector('input[name="pano-source"]:checked').value === 'current', randomizeCamera: document.getElementById('randomize-camera-toggle').checked, hVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('horizontal-variance').value)), vVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('vertical-variance').value)), includeVal: document.getElementById('include-val-set').checked, includeTest: document.getElementById('include-test-set').checked, trainSplit: parseInt(trainSlider.value), valSplit: parseInt(valSlider.value), initial: { model: { position: selectedObject ? selectedObject.position.clone() : new THREE.Vector3(), rotation: selectedObject ? selectedObject.rotation.clone() : new THREE.Euler(), scale: selectedObject ? selectedObject.scale.x : 1 }, camera: { position: camera.position.clone() } } }; if (!options.name) { showNotification({ text: "Dataset name cannot be empty.", type: "error" }); return; } if ((options.task === 'detection' || options.task === 'segmentation') && !options.includeVal) { showNotification({ text: "Validation set is required for YOLO formats.", type: "error" }); document.getElementById('include-val-set').checked = true; updateDatasetModalUI(); return; } document.getElementById('dataset-modal').classList.add('hidden'); const progressContainer = document.getElementById('progress-container'); progressContainer.classList.remove('hidden'); progressContainer.classList.add('flex'); const zip = new JSZip(); const rootFolderName = options.name; const usedClasses = new Set(); const originalModelAsset = selectedObject ? modelAssets.find(m => m.name === selectedObject.name) : null; const originalPanoramaAsset = activePanorama; const transformWasVisible = transformControls.visible; if (transformWasVisible) transformControls.visible = false; for (let i = 0; i < options.samples; i++) { updateProgress(i + 1, options.samples); const currentModelAsset = await randomizeSceneForGeneration(options, usedClasses); renderer.render(scene, camera); const imageName = `sample_${String(i).padStart(5, '0')}.jpg`; const labelName = imageName.replace('.jpg', '.txt'); const modelClassName = currentModelAsset ? currentModelAsset.name.replace(/\.[^/.]+$/, "") : 'background_only'; const classIndex = currentModelAsset ? modelAssets.findIndex(m => m.name === currentModelAsset.name) : -1; const { imageBlob, labelData } = await generateSampleData(options, classIndex); if (imageBlob) { const randomVal = Math.random() * 100; let splitFolder = 'train'; const valPct = options.includeVal ? options.valSplit - options.trainSplit : 0; const testPct = options.includeTest ? 100 - options.valSplit : 0; const trainPct = 100 - valPct - testPct; if (options.includeTest && randomVal > (trainPct + valPct)) { splitFolder = 'test'; } else if (options.includeVal && randomVal > trainPct) { splitFolder = 'val'; } if (options.task === 'classification') { const classFolder = currentModelAsset && selectedObject.visible ? modelClassName : 'background_only'; zip.folder(rootFolderName).folder(splitFolder).folder(classFolder).file(imageName, imageBlob); } else { zip.folder(rootFolderName).folder('images').folder(splitFolder).file(imageName, imageBlob); if (labelData) { zip.folder(rootFolderName).folder('labels').folder(splitFolder).file(labelName, labelData); } } } if (i % 10 === 0) await new Promise(resolve => setTimeout(resolve, 1)); } if (options.task !== 'classification') { const classMap = {}; // Find the original array index for each model that was actually used. for (const modelName of usedClasses) { const index = modelAssets.findIndex(m => m.name === modelName); if (index !== -1) { // Map the index to the clean class name. classMap[index] = modelName.replace(/\.[^/.]+$/, ""); } } // Sort the classes by their original index to create the final list. const sortedKeys = Object.keys(classMap).map(Number).sort((a, b) => a - b); const classNames = sortedKeys.map(key => ` ${key}: ${classMap[key]}`).join('\n'); // Build the final YAML content. let yamlContent = `path: ${rootFolderName}\ntrain: images/train\nval: images/val\n`; if (options.includeTest) yamlContent += `test: images/test\n`; yamlContent += `\nnames:\n${classNames}`; zip.folder(rootFolderName).file(`${options.name}.yaml`, yamlContent); } if (transformWasVisible) transformControls.visible = true; updateProgress(options.samples, options.samples, "Compressing ZIP..."); const content = await zip.generateAsync({ type: "blob" }); const link = document.createElement('a'); link.href = URL.createObjectURL(content); link.download = `${rootFolderName}.zip`; link.click(); URL.revokeObjectURL(link.href); progressContainer.classList.add('hidden'); showNotification({ text: 'Dataset generation complete!', type: 'success' }); await loadAsset(originalPanoramaAsset || panoramaAssets[0], 'panorama'); await loadAsset(originalModelAsset || modelAssets[0], 'model'); } finally { btnText.classList.remove('hidden'); btnSpinner.classList.add('hidden'); startBtn.disabled = false; cancelBtn.disabled = false; } } async function randomizeSceneForGeneration(options, usedClasses) { let currentModelAsset; if (!options.useCurrentPanorama) { const randomPano = panoramaAssets[Math.floor(Math.random() * panoramaAssets.length)]; await loadAsset(randomPano, 'panorama'); } if (!options.useCurrentModel) { currentModelAsset = modelAssets[Math.floor(Math.random() * modelAssets.length)]; await loadAsset(currentModelAsset, 'model'); } else { currentModelAsset = modelAssets.find(m => m.name === selectedObject.name); } if (!currentModelAsset || !selectedObject) return null; if (options.includeBackgrounds && Math.random() < options.backgroundRatio) { selectedObject.visible = false; } else { selectedObject.visible = true; } if (selectedObject.visible) { usedClasses.add(currentModelAsset.name); } if (!selectedObject.visible) return null; if (options.randomizeModel) { if (options.useCurrentModel) { const { position: basePos, rotation: baseRot, scale: baseScale } = options.initial.model; selectedObject.position.copy(basePos).add(new THREE.Vector3().randomDirection().multiplyScalar(Math.random() * 5)); const baseQuaternion = new THREE.Quaternion().setFromEuler(baseRot); const rotOffset = new THREE.Euler((Math.random() - 0.5) * 2 * Math.PI * options.rotVar, (Math.random() - 0.5) * 2 * Math.PI * options.rotVar, (Math.random() - 0.5) * 2 * Math.PI * options.rotVar); baseQuaternion.multiply(new THREE.Quaternion().setFromEuler(rotOffset)); selectedObject.quaternion.copy(baseQuaternion); selectedObject.scale.setScalar(baseScale * (1 + (Math.random() - 0.5) * 2 * options.scaleVar)); } else { selectedObject.position.set((Math.random() - 0.5) * 10, Math.random() * 5, (Math.random() - 0.5) * 10); selectedObject.rotation.set(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2); const box = new THREE.Box3().setFromObject(selectedObject); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const baseScale = maxDim > 0 ? 4.0 / maxDim : 1.0; selectedObject.scale.setScalar(baseScale * (0.5 + Math.random())); } } if (options.useCurrentPanorama && options.randomizeCamera) { const spherical = new THREE.Spherical().setFromCartesianCoords(options.initial.camera.position.x, options.initial.camera.position.y, options.initial.camera.position.z); spherical.theta += (Math.random() - 0.5) * 2 * options.hVar; spherical.phi += (Math.random() - 0.5) * 2 * options.vVar; spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)); camera.position.setFromSpherical(spherical); } else if (!options.useCurrentPanorama) { camera.position.set((Math.random() - 0.5) * 20, Math.random() * 8 + 1, (Math.random() - 0.5) * 15 + 5); } // --- START: FIX FOR MODEL CENTERING --- const lookAtTarget = selectedObject.position.clone(); // If Position Variance is > 0, apply a 2D-like offset to the camera's target point. if (options.randomizeModel && options.posVar > 0) { const toObject = new THREE.Vector3().subVectors(selectedObject.position, camera.position); const distance = toObject.length(); // Get camera's local right and up vectors to define the offset plane const right = new THREE.Vector3().crossVectors(camera.up, toObject).normalize(); const up = new THREE.Vector3().crossVectors(toObject, right).normalize(); // Calculate the dimensions of the visible area (frustum) at the object's distance const fovInRadians = THREE.MathUtils.degToRad(camera.fov); const frustumHeight = 2.0 * distance * Math.tan(fovInRadians / 2.0); const frustumWidth = frustumHeight * camera.aspect; // Convert the user's percentage (e.g., 90% -> 0.9) to a max shift ratio (e.g., 0.45) const maxShiftRatio = options.posVar / 2.0; // Calculate random offsets in world units const offsetX = (Math.random() - 0.5) * 2 * (frustumWidth * maxShiftRatio); const offsetY = (Math.random() - 0.5) * 2 * (frustumHeight * maxShiftRatio); // Apply the calculated offsets to the target point lookAtTarget.addScaledVector(right, offsetX); lookAtTarget.addScaledVector(up, offsetY); } // Point the camera at the final target (which may be offset) camera.lookAt(lookAtTarget); // --- END: FIX FOR MODEL CENTERING --- selectedObject.updateMatrixWorld(true); return currentModelAsset; } function findTopLevelGroup(object) { let current = object; while (current.parent && current.parent !== scene && current.parent !== maskScene) { current = current.parent; } return current; } function getCanvasBlob() { return new Promise(resolve => renderer.domElement.toBlob(resolve, 'image/jpeg', 0.9)); } function updateProgress(current, total, text = 'Processing...') { document.getElementById('progress-bar').style.width = `${total > 0 ? (current / total) * 100 : 0}%`; document.getElementById('progress-label').textContent = text; document.getElementById('progress-count').textContent = `(${current}/${total})`; } function setActiveCard(containerId, name) { document.querySelectorAll(`#${containerId} .asset-card`).forEach(c => c.classList.toggle('active', c.dataset.name === name)); } function showNotification({ text, type = 'success', duration = 3000, id = null }) { const container = document.getElementById('notification-container'); if (id) { const existing = document.getElementById(id); if (existing) { existing.querySelector('span').textContent = text; return; } } const el = document.createElement('div'); el.id = id || `notif-${Date.now()}`; el.className = `notification glass-ui p-3 px-4 rounded-lg flex items-center gap-3`; const colors = { success: 'bg-green-500/50', error: 'bg-red-500/50', info: 'bg-blue-500/50', loading: 'bg-stone-500/50' }; const icons = { success: 'check-circle', error: 'alert-triangle', info: 'info', loading: null }; el.classList.add(colors[type]); let iconHtml = type === 'loading' ? `
` : ``; el.innerHTML = `${iconHtml}${text}`; container.appendChild(el); if (icons[type]) feather.replace(); setTimeout(() => el.classList.add('show'), 10); if (type !== 'loading') { setTimeout(() => hideNotification(el.id), duration); } } function hideNotification(id) { const el = document.getElementById(id); if (el) { el.classList.remove('show'); setTimeout(() => el.remove(), 500); } } function animate() { requestAnimationFrame(animate); orbitControls.update(); renderer.render(scene, camera); } window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); async function generateSampleData(options, classIndex = 0) { let labelData = null; if (selectedObject && selectedObject.visible && classIndex !== -1 && options.task !== 'classification') { if (options.task === 'segmentation' && options.segmentationMethod === 'render') { labelData = await generateRenderMaskData(classIndex, options); } else { const points = getProjected2dVertices(); if (points && points.length > 3) { if (options.task === 'detection') { const hull = computeConvexHull(points); if (hull.length > 0) labelData = getDetectionLabel(hull, classIndex); } else if (options.task === 'segmentation') { if (options.segmentationMethod === 'concave') { labelData = generateConcaveHullData(points, classIndex, options.concavity); } else { const hull = computeConvexHull(points); if (hull.length > 0) labelData = getSegmentationLabel(hull, classIndex); } } } } } renderer.render(scene, camera); const imageBlob = await getCanvasBlob(); return { imageBlob, labelData }; } function getDetectionLabel(hull, classIndex) { let minX = 1, maxX = -1, minY = 1, maxY = -1; for (const p of hull) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); } if (maxX <= minX || maxY <= minY) return null; const normCenterX = (((minX + maxX) / 2) + 1) / 2; const normCenterY = (-((minY + maxY) / 2) + 1) / 2; const normWidth = (maxX - minX) / 2; const normHeight = (maxY - minY) / 2; if (normWidth < 0.001 || normHeight < 0.001) return null; return `${classIndex} ${normCenterX.toFixed(6)} ${normCenterY.toFixed(6)} ${normWidth.toFixed(6)} ${normHeight.toFixed(6)}`; } function getSegmentationLabel(points, classIndex) { if (points.length < 3) return null; const yoloPoints = points.map(p => `${((p.x + 1) / 2).toFixed(6)} ${((-p.y + 1) / 2).toFixed(6)}`).join(' '); return `${classIndex} ${yoloPoints}`; } function generateConcaveHullData(points, classIndex, concavity) { const screenPoints = points.map(p => [p.x, p.y]); const hull = concaveman(screenPoints, concavity); if (hull.length < 3) return getSegmentationLabel(computeConvexHull(points), classIndex); const mappedHull = hull.map(p => ({ x: p[0], y: p[1] })); return getSegmentationLabel(mappedHull, classIndex); } async function generateRenderMaskData(classIndex, options) { const assetInfo = modelAssets.find(m => m.name === selectedObject.name); if (!assetInfo || !assetInfo.originalMaterials) return null; const originalBackground = scene.background; panoramaSphere.visible = false; scene.background = new THREE.Color(0x000000); selectedObject.traverse(node => { if (node.isMesh) node.material = maskMaterial; }); renderer.render(scene, camera); const { width, height } = renderer.domElement; const context = renderer.getContext(); const pixelData = new Uint8Array(width * height * 4); context.readPixels(0, 0, width, height, context.RGBA, context.UNSIGNED_BYTE, pixelData); scene.background = originalBackground; selectedObject.traverse(node => { if (node.isMesh) node.material = assetInfo.originalMaterials.get(node); }); panoramaSphere.visible = true; const contours = findContours(pixelData, width, height); if (contours.length === 0) return null; contours.sort((a, b) => b.length - a.length); const mainContour = contours[0]; const simplifiedContour = rdp(mainContour, options.rdpEpsilon); if (simplifiedContour.length < 3) return null; const yoloPoints = simplifiedContour.map(p => `${(p.x / width).toFixed(6)} ${((height - p.y) / height).toFixed(6)}`).join(' '); return `${classIndex} ${yoloPoints}`; } function computeConvexHull(points) { if (points.length <= 3) return points; points.sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x); const cross_product = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); const lower = []; for (const p of points) { while (lower.length >= 2 && cross_product(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); lower.push(p); } const upper = []; for (let i = points.length - 1; i >= 0; i--) { const p = points[i]; while (upper.length >= 2 && cross_product(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop(); upper.push(p); } upper.pop(); lower.pop(); return lower.concat(upper); } function getProjected2dVertices() { if (!selectedObject || !selectedObject.visible) return null; const projectedVertices = []; selectedObject.updateMatrixWorld(true); selectedObject.traverse(node => { if (node.isMesh) { const geometry = node.geometry; const positionAttribute = geometry.attributes.position; if (!positionAttribute) return; const vertex = new THREE.Vector3(); for (let i = 0; i < positionAttribute.count; i++) { vertex.fromBufferAttribute(positionAttribute, i); vertex.applyMatrix4(node.matrixWorld); vertex.project(camera); if (vertex.z < 1) projectedVertices.push({ x: vertex.x, y: vertex.y }); } } }); return projectedVertices.length > 0 ? projectedVertices : null; } function rdp(points, epsilon) { if (points.length < 3) return points; let dmax = 0; let index = 0; const end = points.length - 1; for (let i = 1; i < end; i++) { const d = perpendicularDistance(points[i], points[0], points[end]); if (d > dmax) { index = i; dmax = d; } } if (dmax > epsilon) { const recResults1 = rdp(points.slice(0, index + 1), epsilon); const recResults2 = rdp(points.slice(index, end + 1), epsilon); return recResults1.slice(0, recResults1.length - 1).concat(recResults2); } else { return [points[0], points[end]]; } } function perpendicularDistance(point, lineStart, lineEnd) { let dx = lineEnd.x - lineStart.x; let dy = lineEnd.y - lineStart.y; if (dx === 0 && dy === 0) return Math.sqrt(Math.pow(point.x - lineStart.x, 2) + Math.pow(point.y - lineStart.y, 2)); const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy); const projectionX = lineStart.x + t * dx; const projectionY = lineStart.y + t * dy; return Math.sqrt(Math.pow(point.x - projectionX, 2) + Math.pow(point.y - projectionY, 2)); } function findContours(data, width, height) { const visited = new Uint8Array(width * height); const contours = []; const isWhite = (x, y) => { if (x < 0 || x >= width || y < 0 || y >= height) return false; return data[(y * width + x) * 4] > 128; }; const directions = [[0, -1], [1, 0], [0, 1], [-1, 0]]; // N, E, S, W // Corrected loops to scan the entire image from (0,0). for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { // The condition `!isWhite(x, y - 1)` finds the top edge of a shape. if (isWhite(x, y) && !isWhite(x, y - 1) && !visited[y * width + x]) { const path = []; let cx = x, cy = y; // Start by looking East (1) from the initial point. let dir = 1; while (true) { path.push({ x: cx, y: cy }); visited[cy * width + cx] = 1; let foundNext = false; // Check directions starting from the one to the left of the current direction. for (let i = 0; i < 4; i++) { const nextDir = (dir + 3 + i) % 4; // Turn left, then straight, then right const [dx, dy] = directions[nextDir]; const nx = cx + dx, ny = cy + dy; if (isWhite(nx, ny)) { cx = nx; cy = ny; dir = nextDir; foundNext = true; break; } } if (!foundNext || (cx === x && cy === y)) { // Break if we're stuck or returned to the start break; } } if (path.length > 10) { // Keep a minimum length to avoid noise contours.push(path); } } } } return contours; } init();