dataset / core.js
aaurelions's picture
Update core.js
0c29830 verified
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 = `<i data-feather="package" class="w-12 h-12 text-stone-500"></i>`;
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' ? `<div class="notification-spinner"></div>` : `<i data-feather="${icons[type]}" class="w-5 h-5"></i>`;
el.innerHTML = `${iconHtml}<span>${text}</span>`;
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();