aaurelions commited on
Commit
22a2915
·
verified ·
1 Parent(s): 54bdc04

Upload 3 files

Browse files
Files changed (3) hide show
  1. core.js +1002 -0
  2. index.html +41 -1230
  3. style.css +266 -17
core.js ADDED
@@ -0,0 +1,1002 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import concaveman from 'concaveman';
2
+ import gsap from "gsap";
3
+ import JSZip from "jszip";
4
+ import * as THREE from 'three';
5
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
6
+ import { TransformControls } from 'three/addons/controls/TransformControls.js';
7
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
8
+ import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
9
+
10
+ const ASSET_BASE_URL = 'https://huggingface.co/spaces/aaurelions/drones/resolve/main';
11
+ 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` }));
12
+ 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')}` }));
13
+
14
+ let panoramaAssets = [...defaultPanoramas];
15
+ let modelAssets = [...defaultModels];
16
+
17
+ let scene, camera, renderer, orbitControls, transformControls, pmremGenerator, panoramaSphere;
18
+ let selectedObject = null;
19
+ let activeControlMode = null;
20
+ let activePanorama = null;
21
+
22
+ let maskScene, maskMaterial;
23
+
24
+ const loaderOverlay = document.getElementById('loader-overlay');
25
+ const loaderText = document.getElementById('loader-text');
26
+ const canvas = document.getElementById('main-canvas');
27
+
28
+ async function init() {
29
+ loaderText.textContent = 'Setting up 3D scene...';
30
+ scene = new THREE.Scene();
31
+ camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 2000);
32
+ camera.position.set(0, 1.5, 8);
33
+
34
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
35
+ renderer.setPixelRatio(window.devicePixelRatio);
36
+ renderer.setSize(window.innerWidth, window.innerHeight);
37
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
38
+ renderer.toneMappingExposure = 1.0;
39
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
40
+
41
+ pmremGenerator = new THREE.PMREMGenerator(renderer);
42
+ pmremGenerator.compileEquirectangularShader();
43
+
44
+ const panoGeometry = new THREE.SphereGeometry(1000, 60, 40);
45
+ panoGeometry.scale(-1, 1, 1);
46
+ const panoMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
47
+ panoramaSphere = new THREE.Mesh(panoGeometry, panoMaterial);
48
+ scene.add(panoramaSphere);
49
+
50
+ orbitControls = new OrbitControls(camera, renderer.domElement);
51
+ orbitControls.enableDamping = true;
52
+ orbitControls.target.set(0, 1, 0);
53
+
54
+ transformControls = new TransformControls(camera, renderer.domElement);
55
+ transformControls.enabled = false;
56
+ scene.add(transformControls);
57
+
58
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
59
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
60
+ dirLight.position.set(8, 15, 10);
61
+ scene.add(ambientLight, dirLight);
62
+
63
+ maskScene = new THREE.Scene();
64
+ maskMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
65
+
66
+ setupUI();
67
+ setupEventListeners();
68
+ animate();
69
+
70
+ try {
71
+ await loadAsset(panoramaAssets[6], 'panorama');
72
+ await loadAsset(modelAssets[1], 'model');
73
+ setControlMode('translate');
74
+ } catch (error) {
75
+ showNotification({ text: `Initialization failed: ${error.message}`, type: 'error' });
76
+ console.error("Initialization failed:", error);
77
+ } finally {
78
+ gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') });
79
+ }
80
+ }
81
+
82
+ async function loadAsset(assetData, assetType, button = null) {
83
+ const isInitialLoad = !loaderOverlay.classList.contains('hidden');
84
+ let spinner, textSpan;
85
+ if (button) {
86
+ spinner = button.querySelector('.btn-spinner');
87
+ textSpan = button.querySelector('.btn-text');
88
+ spinner.classList.remove('hidden');
89
+ textSpan.classList.add('hidden');
90
+ button.disabled = true;
91
+ } else if (!isInitialLoad) {
92
+ loaderText.textContent = `Loading ${assetType}...`;
93
+ loaderOverlay.classList.remove('hidden');
94
+ gsap.to(loaderOverlay, { opacity: 1, duration: 0.3 });
95
+ canvas.classList.add('loading');
96
+ } else {
97
+ loaderText.textContent = `Loading ${assetType}: ${assetData.name}`;
98
+ }
99
+
100
+ try {
101
+ if (assetType === 'panorama') {
102
+ await new Promise((resolve, reject) => {
103
+ const isHDR = assetData.url.endsWith('.hdr');
104
+ const loader = isHDR ? new RGBELoader() : new THREE.TextureLoader();
105
+ loader.load(assetData.url, (texture) => {
106
+ texture.mapping = THREE.EquirectangularReflectionMapping;
107
+ if (!isHDR) texture.colorSpace = THREE.SRGBColorSpace;
108
+
109
+ if (panoramaSphere) {
110
+ panoramaSphere.material.map = texture;
111
+ panoramaSphere.material.needsUpdate = true;
112
+ }
113
+
114
+ scene.environment = pmremGenerator.fromEquirectangular(texture).texture;
115
+
116
+ scene.background = texture;
117
+ scene.environment = pmremGenerator.fromEquirectangular(texture).texture;
118
+ pmremGenerator.dispose();
119
+ texture.dispose();
120
+ activePanorama = assetData;
121
+ setActiveCard('panorama-gallery', assetData.name);
122
+ resolve();
123
+ }, undefined, reject);
124
+ });
125
+ } else {
126
+ if (selectedObject) transformControls.detach();
127
+
128
+ const existingAsset = modelAssets.find(m => m.name === assetData.name && m.mesh);
129
+ if (existingAsset) {
130
+ modelAssets.forEach(m => { if (m.mesh) m.mesh.visible = false; });
131
+ existingAsset.mesh.visible = true;
132
+ selectedObject = existingAsset.mesh;
133
+ } else {
134
+ await new Promise((resolve, reject) => {
135
+ const loader = new GLTFLoader();
136
+ loader.load(assetData.url, (gltf) => {
137
+ modelAssets.forEach(m => { if (m.mesh) m.mesh.visible = false; });
138
+ const model = gltf.scene;
139
+ model.name = assetData.name;
140
+ const box = new THREE.Box3().setFromObject(model);
141
+ const size = box.getSize(new THREE.Vector3());
142
+ const center = box.getCenter(new THREE.Vector3());
143
+ model.position.sub(center);
144
+ const maxDim = Math.max(size.x, size.y, size.z);
145
+ model.scale.setScalar(4.0 / maxDim);
146
+ scene.add(model);
147
+
148
+ const assetToUpdate = modelAssets.find(m => m.name === assetData.name);
149
+ if (assetToUpdate) {
150
+ assetToUpdate.mesh = model;
151
+
152
+ assetToUpdate.originalMaterials = new Map();
153
+ model.traverse(node => {
154
+ if (node.isMesh) assetToUpdate.originalMaterials.set(node, node.material);
155
+ });
156
+ }
157
+
158
+ selectedObject = model;
159
+ resolve();
160
+ }, undefined, reject);
161
+ });
162
+ }
163
+ if (activeControlMode) transformControls.attach(selectedObject);
164
+ setActiveCard('model-selector', assetData.name);
165
+ }
166
+ } catch (error) {
167
+ showNotification({ text: `Failed to load ${assetType}: ${assetData.name}`, type: 'error', duration: 4000 });
168
+ console.error(`Failed to load ${assetType}:`, error);
169
+ } finally {
170
+ if (button) {
171
+ spinner.classList.add('hidden');
172
+ textSpan.classList.remove('hidden');
173
+ button.disabled = false;
174
+ } else if (!isInitialLoad) {
175
+ gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') });
176
+ canvas.classList.remove('loading');
177
+ }
178
+ }
179
+ }
180
+
181
+ function createAssetCard(type, assetData) {
182
+ const container = document.createElement('div');
183
+ container.className = 'asset-card flex flex-col rounded-lg cursor-pointer overflow-hidden bg-stone-900/50';
184
+ container.dataset.name = assetData.name;
185
+ const thumbWrapper = document.createElement('div');
186
+ thumbWrapper.className = 'w-full h-28 flex items-center justify-center';
187
+ const thumb = document.createElement('img');
188
+ thumb.className = 'max-w-full max-h-full object-contain';
189
+
190
+ container.onclick = () => loadAsset(assetData, type);
191
+
192
+ if (type === 'model') {
193
+ thumbWrapper.classList.add('p-2');
194
+ thumb.src = assetData.thumb;
195
+ const nameWrapper = document.createElement('div');
196
+ nameWrapper.textContent = assetData.name.replace(/\.[^/.]+$/, "").substring(0, 15);
197
+ nameWrapper.className = 'text-white text-xs font-semibold text-center block truncate p-2 bg-black/20 mt-auto';
198
+ thumbWrapper.appendChild(thumb);
199
+ container.append(thumbWrapper, nameWrapper);
200
+ } else {
201
+ thumb.className = 'w-full h-full object-cover';
202
+ thumb.src = assetData.thumb;
203
+ thumbWrapper.appendChild(thumb);
204
+ container.appendChild(thumbWrapper);
205
+ }
206
+
207
+ thumb.onerror = () => {
208
+ thumbWrapper.innerHTML = `<i data-feather="package" class="w-12 h-12 text-stone-500"></i>`;
209
+ feather.replace();
210
+ thumbWrapper.parentElement.classList.add('bg-stone-800');
211
+ }
212
+ document.getElementById(type === 'model' ? 'model-selector' : 'panorama-gallery').appendChild(container);
213
+ }
214
+
215
+ function setupUI() {
216
+ feather.replace();
217
+ modelAssets.forEach(m => createAssetCard('model', m));
218
+ panoramaAssets.forEach(p => createAssetCard('panorama', p));
219
+ }
220
+
221
+ function setControlMode(mode) {
222
+ if (mode === activeControlMode) {
223
+ activeControlMode = null;
224
+ transformControls.detach();
225
+ transformControls.enabled = false;
226
+ } else {
227
+ activeControlMode = mode;
228
+ transformControls.setMode(activeControlMode);
229
+ transformControls.enabled = true;
230
+ if (selectedObject) transformControls.attach(selectedObject);
231
+ }
232
+ document.querySelectorAll('.control-btn').forEach(b => b.classList.toggle('bg-blue-600', b.dataset.mode === activeControlMode));
233
+ }
234
+
235
+ function setupEventListeners() {
236
+ ['model', 'panorama'].forEach(type => {
237
+ document.getElementById(`${type}-panel-trigger`).addEventListener('click', e => { e.stopPropagation(); document.getElementById(`${type}-panel`).classList.add('is-open'); });
238
+ document.getElementById(`close-${type}-panel`).addEventListener('click', () => document.getElementById(`${type}-panel`).classList.remove('is-open'));
239
+ });
240
+
241
+ document.getElementById('day-night-toggle').addEventListener('change', (e) => {
242
+ const isNight = e.target.checked;
243
+ const tintColor = isNight ? 0x202040 : 0xffffff;
244
+
245
+ if (panoramaSphere) {
246
+ gsap.to(panoramaSphere.material.color, {
247
+ r: (tintColor >> 16 & 255) / 255,
248
+ g: (tintColor >> 8 & 255) / 255,
249
+ b: (tintColor & 255) / 255,
250
+ duration: 0.5
251
+ });
252
+ }
253
+
254
+ gsap.to(renderer, { toneMappingExposure: isNight ? 0.3 : 1.0, duration: 0.5 });
255
+ gsap.to(scene.children.find(c => c.isDirectionalLight), { intensity: isNight ? 0.2 : 1.0, duration: 0.5 });
256
+ });
257
+
258
+ document.querySelectorAll('.control-btn').forEach(btn => btn.addEventListener('click', () => setControlMode(btn.dataset.mode)));
259
+ transformControls.addEventListener('dragging-changed', e => { orbitControls.enabled = !e.value; });
260
+
261
+ canvas.addEventListener('click', (e) => {
262
+ if (transformControls.dragging) return;
263
+ const rect = renderer.domElement.getBoundingClientRect();
264
+ const mouse = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
265
+ const raycaster = new THREE.Raycaster();
266
+ raycaster.setFromCamera(mouse, camera);
267
+ const visibleMeshObjects = modelAssets.map(m => m.mesh).filter(m => m && m.visible);
268
+ if (visibleMeshObjects.length === 0) return;
269
+ const intersects = raycaster.intersectObjects(visibleMeshObjects, true);
270
+
271
+ if (intersects.length > 0) {
272
+ const object = findTopLevelGroup(intersects[0].object);
273
+ if (object && object.isGroup && object !== selectedObject) {
274
+ const modelData = modelAssets.find(m => m.name === object.name);
275
+ if (modelData) loadAsset(modelData, 'model');
276
+ }
277
+ }
278
+ });
279
+
280
+ window.addEventListener('keydown', (e) => {
281
+ if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'SELECT') return;
282
+ const key = e.key.toLowerCase();
283
+ if (key === 'w' || key === 'e' || key === 'r') { e.preventDefault(); setControlMode(key === 'w' ? 'translate' : key === 'e' ? 'rotate' : 'scale'); }
284
+ if (key === 'g') { e.preventDefault(); document.getElementById('generate-dataset-btn').click(); }
285
+ if (key === 'escape') { document.querySelectorAll('.side-panel.is-open, .modal-overlay.flex').forEach(p => p.classList.add('hidden')); }
286
+ });
287
+
288
+ setupUploadModal();
289
+ setupDatasetModal();
290
+ }
291
+
292
+ function setupUploadModal() {
293
+ const modal = document.getElementById('upload-modal');
294
+ const title = document.getElementById('upload-title');
295
+ const fileInput = document.getElementById('upload-file-input');
296
+ const urlInput = document.getElementById('upload-url-input');
297
+ const dropZone = document.getElementById('upload-drop-zone');
298
+ const loadBtn = document.getElementById('upload-load-btn');
299
+ let currentType, currentHandler;
300
+
301
+ const openModal = (type, titleText, accept, placeholder, handler) => {
302
+ currentType = type;
303
+ title.textContent = titleText;
304
+ fileInput.value = ''; urlInput.value = '';
305
+ fileInput.accept = accept;
306
+ urlInput.placeholder = placeholder;
307
+ currentHandler = handler;
308
+ modal.classList.remove('hidden'); modal.classList.add('flex');
309
+ };
310
+
311
+ document.getElementById('add-model-btn').addEventListener('click', () => openModal('model', 'Upload New Model', '.glb,.gltf', 'Enter .glb URL', (type, data) => handleNewAsset(type, data, loadBtn)));
312
+ document.getElementById('add-panorama-btn').addEventListener('click', () => openModal('panorama', 'Upload New Panorama', 'image/*,.hdr', 'Enter image URL', (type, data) => handleNewAsset(type, data, loadBtn)));
313
+ document.getElementById('upload-cancel-btn').addEventListener('click', () => modal.classList.add('hidden'));
314
+
315
+ loadBtn.addEventListener('click', () => {
316
+ const url = urlInput.value.trim();
317
+ if (url) currentHandler(currentType, url, loadBtn);
318
+ });
319
+ dropZone.addEventListener('click', () => fileInput.click());
320
+ fileInput.addEventListener('change', e => { if (e.target.files.length) currentHandler(currentType, e.target.files[0], loadBtn) });
321
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
322
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
323
+ dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files.length) currentHandler(currentType, e.dataTransfer.files[0], loadBtn); });
324
+ }
325
+
326
+ async function handleNewAsset(type, fileOrUrl, button) {
327
+ document.getElementById('upload-modal').classList.add('hidden');
328
+ const isUrl = typeof fileOrUrl === 'string';
329
+ const url = isUrl ? fileOrUrl : URL.createObjectURL(fileOrUrl);
330
+ const name = (isUrl ? new URL(url).pathname.split('/').pop() : fileOrUrl.name).substring(0, 25);
331
+
332
+ const newAssetData = { name, url, mesh: null };
333
+ if (type === 'model') {
334
+ if (modelAssets.some(m => m.name === name)) {
335
+ showNotification({ text: `Model "${name}" already exists.`, type: 'error' });
336
+ return;
337
+ }
338
+ newAssetData.thumb = 'placeholder';
339
+ modelAssets.push(newAssetData);
340
+ createAssetCard('model', newAssetData);
341
+ await loadAsset(newAssetData, 'model', button);
342
+ } else {
343
+ if (panoramaAssets.some(p => p.name === name)) {
344
+ showNotification({ text: `Panorama "${name}" already exists.`, type: 'error' });
345
+ return;
346
+ }
347
+ newAssetData.thumb = url;
348
+ panoramaAssets.push(newAssetData);
349
+ createAssetCard('panorama', newAssetData);
350
+ await loadAsset(newAssetData, 'panorama', button);
351
+ }
352
+ }
353
+
354
+ function setupDatasetModal() {
355
+ const modal = document.getElementById('dataset-modal');
356
+
357
+ document.getElementById('generate-dataset-btn').addEventListener('click', () => {
358
+ modal.classList.remove('hidden');
359
+ modal.classList.add('flex');
360
+ feather.replace();
361
+ updateDatasetModalUI();
362
+ });
363
+
364
+ document.getElementById('cancel-dataset').addEventListener('click', () => modal.classList.add('hidden'));
365
+ document.getElementById('start-dataset').addEventListener('click', handleDatasetGeneration);
366
+
367
+ document.querySelectorAll('#dataset-modal input[type="radio"], #dataset-modal input[type="checkbox"]').forEach(input => {
368
+ input.addEventListener('change', updateDatasetModalUI);
369
+ });
370
+
371
+ const setupSlider = (sliderId, displayId, unit) => {
372
+ const slider = document.getElementById(sliderId);
373
+ const display = document.getElementById(displayId);
374
+ if (slider && display) {
375
+ const update = () => { display.textContent = slider.value + unit; };
376
+ update();
377
+ slider.addEventListener('input', update);
378
+ }
379
+ };
380
+ setupSlider('position-variance', 'position-variance-value', '%');
381
+ setupSlider('rotation-variance', 'rotation-variance-value', '%');
382
+ setupSlider('scale-variance', 'scale-variance-value', '%');
383
+ setupSlider('horizontal-variance', 'horizontal-variance-value', '°');
384
+ setupSlider('vertical-variance', 'vertical-variance-value', '°');
385
+ setupSlider('concave-hull-concavity', 'concave-hull-concavity-value', '');
386
+ setupSlider('background-ratio-slider', 'background-ratio-value', '%');
387
+ setupSlider('mask-simplification-slider', 'mask-simplification-value', '');
388
+
389
+ const trainSlider = document.getElementById('split-train-ratio');
390
+ const valSlider = document.getElementById('split-val-ratio');
391
+
392
+ const bringToFront = (el, topZ, bottomZ) => {
393
+ el.style.zIndex = topZ;
394
+ (el === trainSlider ? valSlider : trainSlider).style.zIndex = bottomZ;
395
+ }
396
+
397
+ trainSlider.addEventListener('input', () => { if (parseInt(trainSlider.value) >= parseInt(valSlider.value)) valSlider.value = parseInt(trainSlider.value); updateSplitRatios(); });
398
+ valSlider.addEventListener('input', () => { if (parseInt(valSlider.value) <= parseInt(trainSlider.value)) trainSlider.value = parseInt(valSlider.value); updateSplitRatios(); });
399
+
400
+ trainSlider.addEventListener('mousedown', () => bringToFront(trainSlider, 25, 20));
401
+ trainSlider.addEventListener('touchstart', () => bringToFront(trainSlider, 25, 20));
402
+ valSlider.addEventListener('mousedown', () => bringToFront(valSlider, 25, 20));
403
+ valSlider.addEventListener('touchstart', () => bringToFront(valSlider, 25, 20));
404
+ }
405
+
406
+ function updateDatasetModalUI() {
407
+
408
+ const useCurrentModel = document.querySelector('input[name="model-source"]:checked')?.value === 'current';
409
+ const modelInfo = document.getElementById('model-source-current-info');
410
+ modelInfo.textContent = `Using: ${selectedObject ? selectedObject.name : 'None'}`;
411
+ modelInfo.classList.toggle('hidden', !useCurrentModel);
412
+
413
+ const useCurrentPano = document.querySelector('input[name="pano-source"]:checked')?.value === 'current';
414
+ const panoInfo = document.getElementById('pano-source-current-info');
415
+ panoInfo.textContent = `Using: ${activePanorama ? activePanorama.name : 'None'}`;
416
+ panoInfo.classList.toggle('hidden', !useCurrentPano);
417
+
418
+ const randomizeModel = document.getElementById('randomize-model-toggle')?.checked;
419
+ gsap.to("#model-sliders", { maxHeight: randomizeModel ? 200 : 0, opacity: randomizeModel ? 1 : 0, paddingTop: randomizeModel ? '0.75rem' : 0, duration: 0.4 });
420
+
421
+ const cameraSection = document.getElementById('camera-randomization-section');
422
+ const cameraToggle = document.getElementById('randomize-camera-toggle');
423
+ cameraSection.style.opacity = useCurrentPano ? '1' : '0.5';
424
+ cameraToggle.disabled = !useCurrentPano;
425
+ if (!useCurrentPano) { cameraToggle.checked = false; }
426
+ const cameraToggleLabel = cameraToggle.closest('label.flex.items-center.gap-2');
427
+ if (cameraToggleLabel) cameraToggleLabel.style.cursor = useCurrentPano ? 'pointer' : 'not-allowed';
428
+
429
+ const randomizeCamera = useCurrentPano && cameraToggle.checked;
430
+ gsap.to("#camera-sliders", { maxHeight: randomizeCamera ? 150 : 0, opacity: randomizeCamera ? 1 : 0, paddingTop: randomizeCamera ? '0.75rem' : 0, duration: 0.4 });
431
+
432
+ const task = document.querySelector('input[name="dataset-task"]:checked').value;
433
+ const segGroup = document.getElementById('segmentation-method-group');
434
+ const segMethod = document.querySelector('input[name="segmentation-method"]:checked').value;
435
+ const concaveOptions = document.getElementById('concave-hull-options');
436
+ const renderMaskOptions = document.getElementById('render-mask-options');
437
+
438
+ const showSegGroup = task === 'segmentation';
439
+ gsap.to(segGroup, {
440
+ maxHeight: showSegGroup ? 300 : 0,
441
+ opacity: showSegGroup ? 1 : 0,
442
+ paddingTop: showSegGroup ? '0.5rem' : 0,
443
+ marginTop: showSegGroup ? '1rem' : 0,
444
+ duration: 0.4
445
+ });
446
+
447
+ const showConcaveOptions = showSegGroup && segMethod === 'concave';
448
+ gsap.to(concaveOptions, {
449
+ maxHeight: showConcaveOptions ? 100 : 0,
450
+ opacity: showConcaveOptions ? 1 : 0,
451
+ paddingTop: showConcaveOptions ? '0.75rem' : 0,
452
+ duration: 0.4
453
+ });
454
+
455
+ const includeBackgrounds = document.getElementById('include-background-toggle').checked;
456
+ gsap.to("#background-ratio-container", {
457
+ maxHeight: includeBackgrounds ? 100 : 0,
458
+ opacity: includeBackgrounds ? 1 : 0,
459
+ paddingTop: includeBackgrounds ? '0.5rem' : 0,
460
+ duration: 0.4
461
+ });
462
+
463
+ const showRenderMaskOptions = showSegGroup && segMethod === 'render';
464
+ gsap.to(renderMaskOptions, {
465
+ maxHeight: showRenderMaskOptions ? 100 : 0,
466
+ opacity: showRenderMaskOptions ? 1 : 0,
467
+ paddingTop: showRenderMaskOptions ? '0.75rem' : 0,
468
+ duration: 0.4
469
+ });
470
+
471
+ updateSplitRatios();
472
+ }
473
+
474
+ function updateSplitRatios() {
475
+ const valToggle = document.getElementById('include-val-set');
476
+ const testToggle = document.getElementById('include-test-set');
477
+ const trainSlider = document.getElementById('split-train-ratio');
478
+ const valSlider = document.getElementById('split-val-ratio');
479
+
480
+ valSlider.disabled = !valToggle.checked;
481
+ testToggle.disabled = !valToggle.checked;
482
+ if (!valToggle.checked) testToggle.checked = false;
483
+
484
+ valSlider.style.visibility = testToggle.checked ? 'visible' : 'hidden';
485
+
486
+ valSlider.parentElement.style.opacity = valToggle.checked ? '1' : '0.4';
487
+
488
+ const trainPctRaw = parseInt(trainSlider.value);
489
+ let valEndPctRaw = valToggle.checked ? parseInt(valSlider.value) : trainPctRaw;
490
+
491
+ const trainPct = Math.max(0, Math.min(100, trainPctRaw));
492
+ let valEndPct = valToggle.checked ? Math.max(trainPct, Math.min(100, valEndPctRaw)) : trainPct;
493
+
494
+ if (!testToggle.checked) {
495
+ valEndPct = 100;
496
+ }
497
+
498
+ const valPct = valToggle.checked ? valEndPct - trainPct : 0;
499
+ const testPct = testToggle.checked ? 100 - valEndPct : 0;
500
+ const trainDisplayPct = 100 - valPct - testPct;
501
+
502
+ document.getElementById('split-train-display').textContent = `${trainDisplayPct}%`;
503
+ document.getElementById('split-val-display').textContent = `${valPct}%`;
504
+ document.getElementById('split-test-display').textContent = `${testPct}%`;
505
+
506
+ document.getElementById('split-val-display').style.visibility = valToggle.checked ? 'visible' : 'hidden';
507
+ document.getElementById('split-test-display').style.visibility = testToggle.checked ? 'visible' : 'hidden';
508
+
509
+ const total = trainDisplayPct + valPct + testPct;
510
+ document.getElementById('split-ratio-sum').textContent = total;
511
+
512
+ document.getElementById('split-bg-train').style.width = `${trainDisplayPct}%`;
513
+ const valBg = document.getElementById('split-bg-val');
514
+ valBg.style.left = `${trainDisplayPct}%`;
515
+ valBg.style.width = `${valPct}%`;
516
+ valBg.style.display = valToggle.checked ? 'block' : 'none';
517
+
518
+ const testBg = document.getElementById('split-bg-test');
519
+ testBg.style.left = `${trainDisplayPct + valPct}%`;
520
+ testBg.style.width = `${testPct}%`;
521
+ testBg.style.display = testToggle.checked ? 'block' : 'none';
522
+ }
523
+
524
+ async function handleDatasetGeneration() {
525
+ const startBtn = document.getElementById('start-dataset');
526
+ const cancelBtn = document.getElementById('cancel-dataset');
527
+ const btnText = startBtn.querySelector('.btn-text');
528
+ const btnSpinner = startBtn.querySelector('.btn-spinner');
529
+
530
+ btnText.classList.add('hidden');
531
+ btnSpinner.classList.remove('hidden');
532
+ startBtn.disabled = true;
533
+ cancelBtn.disabled = true;
534
+
535
+ try {
536
+ const trainSlider = document.getElementById('split-train-ratio');
537
+ const valSlider = document.getElementById('split-val-ratio');
538
+
539
+ const options = {
540
+ task: document.querySelector('input[name="dataset-task"]:checked').value,
541
+ segmentationMethod: document.querySelector('input[name="segmentation-method"]:checked').value,
542
+ concavity: parseFloat(document.getElementById('concave-hull-concavity').value),
543
+ rdpEpsilon: parseFloat(document.getElementById('mask-simplification-slider').value),
544
+ samples: parseInt(document.getElementById('dataset-samples').value),
545
+ name: document.getElementById('dataset-name').value.trim(),
546
+
547
+ includeBackgrounds: document.getElementById('include-background-toggle').checked,
548
+ backgroundRatio: parseInt(document.getElementById('background-ratio-slider').value) / 100,
549
+
550
+ useCurrentModel: document.querySelector('input[name="model-source"]:checked').value === 'current',
551
+ randomizeModel: document.getElementById('randomize-model-toggle').checked,
552
+ posVar: parseInt(document.getElementById('position-variance').value) / 100,
553
+ rotVar: parseInt(document.getElementById('rotation-variance').value) / 100,
554
+ scaleVar: parseInt(document.getElementById('scale-variance').value) / 100,
555
+ useCurrentPanorama: document.querySelector('input[name="pano-source"]:checked').value === 'current',
556
+ randomizeCamera: document.getElementById('randomize-camera-toggle').checked,
557
+ hVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('horizontal-variance').value)),
558
+ vVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('vertical-variance').value)),
559
+ includeVal: document.getElementById('include-val-set').checked,
560
+ includeTest: document.getElementById('include-test-set').checked,
561
+ trainSplit: parseInt(trainSlider.value),
562
+ valSplit: parseInt(valSlider.value),
563
+ initial: {
564
+ model: {
565
+ position: selectedObject ? selectedObject.position.clone() : new THREE.Vector3(),
566
+ rotation: selectedObject ? selectedObject.rotation.clone() : new THREE.Euler(),
567
+ scale: selectedObject ? selectedObject.scale.x : 1
568
+ },
569
+ camera: { position: camera.position.clone() }
570
+ }
571
+ };
572
+
573
+ if (!options.name) { showNotification({ text: "Dataset name cannot be empty.", type: "error" }); return; }
574
+ if ((options.task === 'detection' || options.task === 'segmentation') && !options.includeVal) {
575
+ showNotification({ text: "Validation set is required for YOLO formats.", type: "error" });
576
+ document.getElementById('include-val-set').checked = true;
577
+ updateDatasetModalUI();
578
+ return;
579
+ }
580
+
581
+ document.getElementById('dataset-modal').classList.add('hidden');
582
+ const progressContainer = document.getElementById('progress-container');
583
+ progressContainer.classList.remove('hidden'); progressContainer.classList.add('flex');
584
+
585
+ const zip = new JSZip();
586
+ const rootFolderName = options.name;
587
+ const usedClasses = new Set();
588
+
589
+ const originalModelAsset = selectedObject ? modelAssets.find(m => m.name === selectedObject.name) : null;
590
+ const originalPanoramaAsset = activePanorama;
591
+ const transformWasVisible = transformControls.visible;
592
+ if (transformWasVisible) transformControls.visible = false;
593
+
594
+ for (let i = 0; i < options.samples; i++) {
595
+ updateProgress(i + 1, options.samples);
596
+ const currentModelAsset = await randomizeSceneForGeneration(options, usedClasses);
597
+
598
+ renderer.render(scene, camera);
599
+ const imageName = `sample_${String(i).padStart(5, '0')}.jpg`;
600
+ const labelName = imageName.replace('.jpg', '.txt');
601
+
602
+ const modelClassName = currentModelAsset ? currentModelAsset.name.replace(/\.[^/.]+$/, "") : 'background_only';
603
+ const classIndex = currentModelAsset ? modelAssets.findIndex(m => m.name === currentModelAsset.name) : -1;
604
+
605
+ const { imageBlob, labelData } = await generateSampleData(options, classIndex);
606
+
607
+ if (imageBlob) {
608
+ const randomVal = Math.random() * 100;
609
+ let splitFolder = 'train';
610
+
611
+ const valPct = options.includeVal ? options.valSplit - options.trainSplit : 0;
612
+ const testPct = options.includeTest ? 100 - options.valSplit : 0;
613
+ const trainPct = 100 - valPct - testPct;
614
+
615
+ if (options.includeTest && randomVal > (trainPct + valPct)) {
616
+ splitFolder = 'test';
617
+ } else if (options.includeVal && randomVal > trainPct) {
618
+ splitFolder = 'val';
619
+ }
620
+
621
+ if (options.task === 'classification') {
622
+ const classFolder = currentModelAsset && selectedObject.visible ? modelClassName : 'background_only';
623
+ zip.folder(rootFolderName).folder(splitFolder).folder(classFolder).file(imageName, imageBlob);
624
+ } else {
625
+ zip.folder(rootFolderName).folder('images').folder(splitFolder).file(imageName, imageBlob);
626
+ if (labelData) { zip.folder(rootFolderName).folder('labels').folder(splitFolder).file(labelName, labelData); }
627
+ }
628
+ }
629
+ if (i % 10 === 0) await new Promise(resolve => setTimeout(resolve, 1));
630
+ }
631
+
632
+ if (options.task !== 'classification') {
633
+ const classNames = Array.from(usedClasses).sort().map((name, index) => ` ${index}: ${name.replace(/\.[^/.]+$/, "")}`).join('\n');
634
+ let yamlContent = `path: ${rootFolderName}\ntrain: images/train\nval: images/val\n`;
635
+ if (options.includeTest) yamlContent += `test: images/test\n`;
636
+ yamlContent += `\nnames:\n${classNames}`;
637
+ zip.folder(rootFolderName).file(`${options.name}.yaml`, yamlContent);
638
+ }
639
+
640
+ if (transformWasVisible) transformControls.visible = true;
641
+
642
+ updateProgress(options.samples, options.samples, "Compressing ZIP...");
643
+ const content = await zip.generateAsync({ type: "blob" });
644
+ const link = document.createElement('a');
645
+ link.href = URL.createObjectURL(content);
646
+ link.download = `${rootFolderName}.zip`;
647
+ link.click();
648
+ URL.revokeObjectURL(link.href);
649
+ progressContainer.classList.add('hidden');
650
+ showNotification({ text: 'Dataset generation complete!', type: 'success' });
651
+
652
+ await loadAsset(originalPanoramaAsset || panoramaAssets[0], 'panorama');
653
+ await loadAsset(originalModelAsset || modelAssets[0], 'model');
654
+
655
+ } finally {
656
+ btnText.classList.remove('hidden');
657
+ btnSpinner.classList.add('hidden');
658
+ startBtn.disabled = false;
659
+ cancelBtn.disabled = false;
660
+ }
661
+ }
662
+
663
+ async function randomizeSceneForGeneration(options, usedClasses) {
664
+ let currentModelAsset;
665
+ if (!options.useCurrentPanorama) {
666
+ const randomPano = panoramaAssets[Math.floor(Math.random() * panoramaAssets.length)];
667
+ await loadAsset(randomPano, 'panorama');
668
+ }
669
+ if (!options.useCurrentModel) {
670
+ currentModelAsset = modelAssets[Math.floor(Math.random() * modelAssets.length)];
671
+ await loadAsset(currentModelAsset, 'model');
672
+ } else {
673
+ currentModelAsset = modelAssets.find(m => m.name === selectedObject.name);
674
+ }
675
+
676
+ if (!currentModelAsset || !selectedObject) return null;
677
+
678
+ if (options.includeBackgrounds && Math.random() < options.backgroundRatio) {
679
+ selectedObject.visible = false;
680
+ } else {
681
+ selectedObject.visible = true;
682
+ }
683
+
684
+ if (selectedObject.visible) {
685
+ usedClasses.add(currentModelAsset.name);
686
+ }
687
+
688
+ if (!selectedObject.visible) return null;
689
+
690
+ if (options.randomizeModel) {
691
+ if (options.useCurrentModel) {
692
+ const { position: basePos, rotation: baseRot, scale: baseScale } = options.initial.model;
693
+ selectedObject.position.copy(basePos).add(new THREE.Vector3().randomDirection().multiplyScalar(Math.random() * 5));
694
+
695
+ const baseQuaternion = new THREE.Quaternion().setFromEuler(baseRot);
696
+ 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);
697
+ baseQuaternion.multiply(new THREE.Quaternion().setFromEuler(rotOffset));
698
+ selectedObject.quaternion.copy(baseQuaternion);
699
+
700
+ selectedObject.scale.setScalar(baseScale * (1 + (Math.random() - 0.5) * 2 * options.scaleVar));
701
+ } else {
702
+ selectedObject.position.set((Math.random() - 0.5) * 10, Math.random() * 5, (Math.random() - 0.5) * 10);
703
+ selectedObject.rotation.set(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2);
704
+ const box = new THREE.Box3().setFromObject(selectedObject);
705
+ const size = box.getSize(new THREE.Vector3());
706
+ const maxDim = Math.max(size.x, size.y, size.z);
707
+ const baseScale = maxDim > 0 ? 4.0 / maxDim : 1.0;
708
+ selectedObject.scale.setScalar(baseScale * (0.5 + Math.random()));
709
+ }
710
+ }
711
+
712
+ if (options.useCurrentPanorama && options.randomizeCamera) {
713
+ const spherical = new THREE.Spherical().setFromCartesianCoords(options.initial.camera.position.x, options.initial.camera.position.y, options.initial.camera.position.z);
714
+ spherical.theta += (Math.random() - 0.5) * 2 * options.hVar;
715
+ spherical.phi += (Math.random() - 0.5) * 2 * options.vVar;
716
+ spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
717
+ camera.position.setFromSpherical(spherical);
718
+ } else if (!options.useCurrentPanorama) {
719
+ camera.position.set((Math.random() - 0.5) * 20, Math.random() * 8 + 1, (Math.random() - 0.5) * 15 + 5);
720
+ }
721
+
722
+ // --- START: FIX FOR MODEL CENTERING ---
723
+ const lookAtTarget = selectedObject.position.clone();
724
+
725
+ // If Position Variance is > 0, apply a 2D-like offset to the camera's target point.
726
+ if (options.randomizeModel && options.posVar > 0) {
727
+ const toObject = new THREE.Vector3().subVectors(selectedObject.position, camera.position);
728
+ const distance = toObject.length();
729
+
730
+ // Get camera's local right and up vectors to define the offset plane
731
+ const right = new THREE.Vector3().crossVectors(camera.up, toObject).normalize();
732
+ const up = new THREE.Vector3().crossVectors(toObject, right).normalize();
733
+
734
+ // Calculate the dimensions of the visible area (frustum) at the object's distance
735
+ const fovInRadians = THREE.MathUtils.degToRad(camera.fov);
736
+ const frustumHeight = 2.0 * distance * Math.tan(fovInRadians / 2.0);
737
+ const frustumWidth = frustumHeight * camera.aspect;
738
+
739
+ // Convert the user's percentage (e.g., 90% -> 0.9) to a max shift ratio (e.g., 0.45)
740
+ const maxShiftRatio = options.posVar / 2.0;
741
+
742
+ // Calculate random offsets in world units
743
+ const offsetX = (Math.random() - 0.5) * 2 * (frustumWidth * maxShiftRatio);
744
+ const offsetY = (Math.random() - 0.5) * 2 * (frustumHeight * maxShiftRatio);
745
+
746
+ // Apply the calculated offsets to the target point
747
+ lookAtTarget.addScaledVector(right, offsetX);
748
+ lookAtTarget.addScaledVector(up, offsetY);
749
+ }
750
+
751
+ // Point the camera at the final target (which may be offset)
752
+ camera.lookAt(lookAtTarget);
753
+ // --- END: FIX FOR MODEL CENTERING ---
754
+
755
+ selectedObject.updateMatrixWorld(true);
756
+ return currentModelAsset;
757
+ }
758
+
759
+ function findTopLevelGroup(object) { let current = object; while (current.parent && current.parent !== scene && current.parent !== maskScene) { current = current.parent; } return current; }
760
+ function getCanvasBlob() { return new Promise(resolve => renderer.domElement.toBlob(resolve, 'image/jpeg', 0.9)); }
761
+ function updateProgress(current, total, text = 'Processing...') {
762
+ document.getElementById('progress-bar').style.width = `${total > 0 ? (current / total) * 100 : 0}%`;
763
+ document.getElementById('progress-label').textContent = text;
764
+ document.getElementById('progress-count').textContent = `(${current}/${total})`;
765
+ }
766
+ function setActiveCard(containerId, name) { document.querySelectorAll(`#${containerId} .asset-card`).forEach(c => c.classList.toggle('active', c.dataset.name === name)); }
767
+
768
+ function showNotification({ text, type = 'success', duration = 3000, id = null }) {
769
+ const container = document.getElementById('notification-container');
770
+ if (id) { const existing = document.getElementById(id); if (existing) { existing.querySelector('span').textContent = text; return; } }
771
+ const el = document.createElement('div');
772
+ el.id = id || `notif-${Date.now()}`;
773
+ el.className = `notification glass-ui p-3 px-4 rounded-lg flex items-center gap-3`;
774
+ const colors = { success: 'bg-green-500/50', error: 'bg-red-500/50', info: 'bg-blue-500/50', loading: 'bg-stone-500/50' };
775
+ const icons = { success: 'check-circle', error: 'alert-triangle', info: 'info', loading: null };
776
+ el.classList.add(colors[type]);
777
+ let iconHtml = type === 'loading' ? `<div class="notification-spinner"></div>` : `<i data-feather="${icons[type]}" class="w-5 h-5"></i>`;
778
+ el.innerHTML = `${iconHtml}<span>${text}</span>`;
779
+ container.appendChild(el);
780
+ if (icons[type]) feather.replace();
781
+ setTimeout(() => el.classList.add('show'), 10);
782
+ if (type !== 'loading') { setTimeout(() => hideNotification(el.id), duration); }
783
+ }
784
+
785
+ function hideNotification(id) {
786
+ const el = document.getElementById(id);
787
+ if (el) { el.classList.remove('show'); setTimeout(() => el.remove(), 500); }
788
+ }
789
+
790
+ function animate() {
791
+ requestAnimationFrame(animate);
792
+ orbitControls.update();
793
+ renderer.render(scene, camera);
794
+ }
795
+ window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
796
+
797
+ async function generateSampleData(options, classIndex = 0) {
798
+ let labelData = null;
799
+ if (selectedObject && selectedObject.visible && classIndex !== -1 && options.task !== 'classification') {
800
+ if (options.task === 'segmentation' && options.segmentationMethod === 'render') {
801
+ labelData = await generateRenderMaskData(classIndex, options);
802
+ } else {
803
+ const points = getProjected2dVertices();
804
+ if (points && points.length > 3) {
805
+ if (options.task === 'detection') {
806
+ const hull = computeConvexHull(points);
807
+ if (hull.length > 0) labelData = getDetectionLabel(hull, classIndex);
808
+ } else if (options.task === 'segmentation') {
809
+ if (options.segmentationMethod === 'concave') {
810
+ labelData = generateConcaveHullData(points, classIndex, options.concavity);
811
+ } else {
812
+ const hull = computeConvexHull(points);
813
+ if (hull.length > 0) labelData = getSegmentationLabel(hull, classIndex);
814
+ }
815
+ }
816
+ }
817
+ }
818
+ }
819
+ renderer.render(scene, camera);
820
+ const imageBlob = await getCanvasBlob();
821
+ return { imageBlob, labelData };
822
+ }
823
+
824
+ function getDetectionLabel(hull, classIndex) {
825
+ let minX = 1, maxX = -1, minY = 1, maxY = -1;
826
+ for (const p of hull) {
827
+ minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
828
+ minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
829
+ }
830
+ if (maxX <= minX || maxY <= minY) return null;
831
+ const normCenterX = (((minX + maxX) / 2) + 1) / 2;
832
+ const normCenterY = (-((minY + maxY) / 2) + 1) / 2;
833
+ const normWidth = (maxX - minX) / 2;
834
+ const normHeight = (maxY - minY) / 2;
835
+ if (normWidth < 0.001 || normHeight < 0.001) return null;
836
+ return `${classIndex} ${normCenterX.toFixed(6)} ${normCenterY.toFixed(6)} ${normWidth.toFixed(6)} ${normHeight.toFixed(6)}`;
837
+ }
838
+
839
+ function getSegmentationLabel(points, classIndex) {
840
+ if (points.length < 3) return null;
841
+ const yoloPoints = points.map(p => `${((p.x + 1) / 2).toFixed(6)} ${((-p.y + 1) / 2).toFixed(6)}`).join(' ');
842
+ return `${classIndex} ${yoloPoints}`;
843
+ }
844
+
845
+ function generateConcaveHullData(points, classIndex, concavity) {
846
+ const screenPoints = points.map(p => [p.x, p.y]);
847
+ const hull = concaveman(screenPoints, concavity);
848
+ if (hull.length < 3) return getSegmentationLabel(computeConvexHull(points), classIndex);
849
+ const mappedHull = hull.map(p => ({ x: p[0], y: p[1] }));
850
+ return getSegmentationLabel(mappedHull, classIndex);
851
+ }
852
+
853
+ async function generateRenderMaskData(classIndex, options) {
854
+ const assetInfo = modelAssets.find(m => m.name === selectedObject.name);
855
+ if (!assetInfo || !assetInfo.originalMaterials) return null;
856
+
857
+ const originalBackground = scene.background;
858
+
859
+ panoramaSphere.visible = false;
860
+
861
+ scene.background = new THREE.Color(0x000000);
862
+ selectedObject.traverse(node => { if (node.isMesh) node.material = maskMaterial; });
863
+ renderer.render(scene, camera);
864
+
865
+ const { width, height } = renderer.domElement;
866
+ const context = renderer.getContext();
867
+ const pixelData = new Uint8Array(width * height * 4);
868
+ context.readPixels(0, 0, width, height, context.RGBA, context.UNSIGNED_BYTE, pixelData);
869
+
870
+ scene.background = originalBackground;
871
+ selectedObject.traverse(node => { if (node.isMesh) node.material = assetInfo.originalMaterials.get(node); });
872
+
873
+ panoramaSphere.visible = true;
874
+
875
+ const contours = findContours(pixelData, width, height);
876
+ if (contours.length === 0) return null;
877
+
878
+ contours.sort((a, b) => b.length - a.length);
879
+ const mainContour = contours[0];
880
+
881
+ const simplifiedContour = rdp(mainContour, options.rdpEpsilon);
882
+ if (simplifiedContour.length < 3) return null;
883
+
884
+ const yoloPoints = simplifiedContour.map(p => `${(p.x / width).toFixed(6)} ${((height - p.y) / height).toFixed(6)}`).join(' ');
885
+ return `${classIndex} ${yoloPoints}`;
886
+ }
887
+
888
+ function computeConvexHull(points) {
889
+ if (points.length <= 3) return points;
890
+ points.sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);
891
+ const cross_product = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
892
+ const lower = [];
893
+ for (const p of points) {
894
+ while (lower.length >= 2 && cross_product(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
895
+ lower.push(p);
896
+ }
897
+ const upper = [];
898
+ for (let i = points.length - 1; i >= 0; i--) {
899
+ const p = points[i];
900
+ while (upper.length >= 2 && cross_product(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
901
+ upper.push(p);
902
+ }
903
+ upper.pop(); lower.pop();
904
+ return lower.concat(upper);
905
+ }
906
+
907
+ function getProjected2dVertices() {
908
+ if (!selectedObject || !selectedObject.visible) return null;
909
+ const projectedVertices = [];
910
+ selectedObject.updateMatrixWorld(true);
911
+ selectedObject.traverse(node => {
912
+ if (node.isMesh) {
913
+ const geometry = node.geometry;
914
+ const positionAttribute = geometry.attributes.position;
915
+ if (!positionAttribute) return;
916
+ const vertex = new THREE.Vector3();
917
+ for (let i = 0; i < positionAttribute.count; i++) {
918
+ vertex.fromBufferAttribute(positionAttribute, i);
919
+ vertex.applyMatrix4(node.matrixWorld);
920
+ vertex.project(camera);
921
+ if (vertex.z < 1) projectedVertices.push({ x: vertex.x, y: vertex.y });
922
+ }
923
+ }
924
+ });
925
+ return projectedVertices.length > 0 ? projectedVertices : null;
926
+ }
927
+
928
+ function rdp(points, epsilon) {
929
+ if (points.length < 3) return points;
930
+ let dmax = 0; let index = 0;
931
+ const end = points.length - 1;
932
+ for (let i = 1; i < end; i++) {
933
+ const d = perpendicularDistance(points[i], points[0], points[end]);
934
+ if (d > dmax) { index = i; dmax = d; }
935
+ }
936
+ if (dmax > epsilon) {
937
+ const recResults1 = rdp(points.slice(0, index + 1), epsilon);
938
+ const recResults2 = rdp(points.slice(index, end + 1), epsilon);
939
+ return recResults1.slice(0, recResults1.length - 1).concat(recResults2);
940
+ } else { return [points[0], points[end]]; }
941
+ }
942
+ function perpendicularDistance(point, lineStart, lineEnd) {
943
+ let dx = lineEnd.x - lineStart.x; let dy = lineEnd.y - lineStart.y;
944
+ if (dx === 0 && dy === 0) return Math.sqrt(Math.pow(point.x - lineStart.x, 2) + Math.pow(point.y - lineStart.y, 2));
945
+ const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy);
946
+ const projectionX = lineStart.x + t * dx; const projectionY = lineStart.y + t * dy;
947
+ return Math.sqrt(Math.pow(point.x - projectionX, 2) + Math.pow(point.y - projectionY, 2));
948
+ }
949
+
950
+ function findContours(data, width, height) {
951
+ const visited = new Uint8Array(width * height);
952
+ const contours = [];
953
+ const isWhite = (x, y) => {
954
+ if (x < 0 || x >= width || y < 0 || y >= height) return false;
955
+ return data[(y * width + x) * 4] > 128;
956
+ };
957
+ const directions = [[0, -1], [1, 0], [0, 1], [-1, 0]]; // N, E, S, W
958
+
959
+ // Corrected loops to scan the entire image from (0,0).
960
+ for (let y = 0; y < height; y++) {
961
+ for (let x = 0; x < width; x++) {
962
+ // The condition `!isWhite(x, y - 1)` finds the top edge of a shape.
963
+ if (isWhite(x, y) && !isWhite(x, y - 1) && !visited[y * width + x]) {
964
+ const path = [];
965
+ let cx = x, cy = y;
966
+ // Start by looking East (1) from the initial point.
967
+ let dir = 1;
968
+
969
+ while (true) {
970
+ path.push({ x: cx, y: cy });
971
+ visited[cy * width + cx] = 1;
972
+
973
+ let foundNext = false;
974
+ // Check directions starting from the one to the left of the current direction.
975
+ for (let i = 0; i < 4; i++) {
976
+ const nextDir = (dir + 3 + i) % 4; // Turn left, then straight, then right
977
+ const [dx, dy] = directions[nextDir];
978
+ const nx = cx + dx, ny = cy + dy;
979
+
980
+ if (isWhite(nx, ny)) {
981
+ cx = nx;
982
+ cy = ny;
983
+ dir = nextDir;
984
+ foundNext = true;
985
+ break;
986
+ }
987
+ }
988
+ if (!foundNext || (cx === x && cy === y)) {
989
+ // Break if we're stuck or returned to the start
990
+ break;
991
+ }
992
+ }
993
+ if (path.length > 10) { // Keep a minimum length to avoid noise
994
+ contours.push(path);
995
+ }
996
+ }
997
+ }
998
+ }
999
+ return contours;
1000
+ }
1001
+
1002
+ init();
index.html CHANGED
@@ -4,288 +4,11 @@
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Syntho-Animator Suite: Production Data Factory</title>
8
 
9
  <script src="https://cdn.tailwindcss.com"></script>
10
  <script src="https://unpkg.com/feather-icons"></script>
11
- <style>
12
- @import url('https://rsms.me/inter/inter.css');
13
-
14
- html {
15
- font-family: 'Inter', sans-serif;
16
- }
17
-
18
- body {
19
- background-color: #0c0a09;
20
- color: #e7e5e4;
21
- }
22
-
23
- .glass-ui {
24
- background-color: rgba(28, 25, 23, 0.7);
25
- backdrop-filter: blur(16px);
26
- -webkit-backdrop-filter: blur(16px);
27
- border: 1px solid rgba(255, 255, 255, 0.1);
28
- }
29
-
30
- .modal-overlay {
31
- background-color: rgba(0, 0, 0, 0.6);
32
- backdrop-filter: blur(8px);
33
- -webkit-backdrop-filter: blur(8px);
34
- }
35
-
36
- .hidden {
37
- display: none !important;
38
- }
39
-
40
- .side-panel {
41
- transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
42
- }
43
-
44
- .side-panel.left {
45
- transform: translateX(-100%);
46
- }
47
-
48
- .side-panel.right {
49
- transform: translateX(100%);
50
- }
51
-
52
- .side-panel.is-open {
53
- transform: translateX(0);
54
- }
55
-
56
- .panel-trigger {
57
- transition: background-color 0.2s ease;
58
- }
59
-
60
- .panel-trigger:hover {
61
- background-color: rgba(59, 130, 246, 0.5);
62
- }
63
-
64
- .asset-card {
65
- transition: all 0.2s ease-in-out;
66
- border: 2px solid transparent;
67
- }
68
-
69
- .asset-card:hover {
70
- transform: scale(1.03);
71
- background-color: rgba(59, 130, 246, 0.1);
72
- }
73
-
74
- .asset-card.active {
75
- background-color: rgba(59, 130, 246, 0.2);
76
- border-color: #3b82f6;
77
- }
78
-
79
- .panel-content::-webkit-scrollbar {
80
- width: 6px;
81
- }
82
-
83
- .panel-content::-webkit-scrollbar-track {
84
- background: transparent;
85
- }
86
-
87
- .panel-content::-webkit-scrollbar-thumb {
88
- background: rgba(255, 255, 255, 0.2);
89
- border-radius: 10px;
90
- }
91
-
92
- .drop-zone {
93
- border: 2px dashed #44403c;
94
- transition: background-color 0.2s, border-color 0.2s;
95
- }
96
-
97
- .drop-zone.drag-over {
98
- background-color: rgba(59, 130, 246, 0.15);
99
- border-color: #3b82f6;
100
- }
101
-
102
- .loader-spinner,
103
- .btn-spinner {
104
- width: 48px;
105
- height: 48px;
106
- border: 4px solid #44403c;
107
- border-top-color: #3b82f6;
108
- border-radius: 50%;
109
- animation: spin 1s linear infinite;
110
- }
111
-
112
- .notification-spinner,
113
- .btn-spinner {
114
- width: 20px;
115
- height: 20px;
116
- border: 2px solid #e7e5e4;
117
- border-top-color: transparent;
118
- border-radius: 50%;
119
- animation: spin 0.8s linear infinite;
120
- }
121
-
122
- @keyframes spin {
123
- to {
124
- transform: rotate(360deg);
125
- }
126
- }
127
-
128
- .toggle-bg:after {
129
- content: '';
130
- position: absolute;
131
- top: 2px;
132
- left: 2px;
133
- background: white;
134
- border-radius: 9999px;
135
- height: 1.25rem;
136
- width: 1.25rem;
137
- transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
138
- }
139
-
140
- input:checked+.toggle-bg {
141
- background-color: #3b82f6;
142
- }
143
-
144
- input:checked+.toggle-bg:after {
145
- transform: translateX(1.25rem);
146
- }
147
-
148
- input:disabled+.toggle-bg {
149
- background-color: #44403c;
150
- cursor: not-allowed;
151
- }
152
-
153
- input:disabled+.toggle-bg:after {
154
- background-color: #57534e;
155
- }
156
-
157
- #main-canvas {
158
- transition: filter 0.5s cubic-bezier(0.4, 0, 0.2, 1);
159
- }
160
-
161
- #main-canvas.loading {
162
- filter: blur(8px) brightness(0.6);
163
- }
164
-
165
- .notification {
166
- transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
167
- transform: translateY(150%) scale(0.9);
168
- opacity: 0;
169
- }
170
-
171
- .notification.show {
172
- transform: translateY(0) scale(1);
173
- opacity: 1;
174
- }
175
-
176
- .control-section {
177
- transition: opacity 0.3s ease;
178
- }
179
-
180
- .sliders-container,
181
- .accordion-content,
182
- #concave-hull-options,
183
- #background-ratio-container {
184
- transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s, padding-top 0.4s, margin-top 0.4s, padding-bottom 0.4s;
185
- overflow: hidden;
186
- }
187
-
188
- input[type="range"] {
189
- -webkit-appearance: none;
190
- appearance: none;
191
- width: 100%;
192
- height: 6px;
193
- background: #44403c;
194
- border-radius: 5px;
195
- outline: none;
196
- transition: background 0.3s;
197
- padding: 0;
198
- margin: 0;
199
- }
200
-
201
- input[type="range"]::-webkit-slider-thumb {
202
- -webkit-appearance: none;
203
- appearance: none;
204
- width: 20px;
205
- height: 20px;
206
- background: #e7e5e4;
207
- border-radius: 50%;
208
- cursor: pointer;
209
- border: 4px solid #3b82f6;
210
- transition: all 0.2s ease-in-out;
211
- margin-top: -7px;
212
- }
213
-
214
- input[type="range"]:hover::-webkit-slider-thumb {
215
- background: #fff;
216
- box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
217
- }
218
-
219
- input[type="range"]::-moz-range-thumb {
220
- width: 14px;
221
- height: 14px;
222
- background: #e7e5e4;
223
- border-radius: 50%;
224
- cursor: pointer;
225
- border: 4px solid #3b82f6;
226
- }
227
-
228
- .tooltip-trigger {
229
- position: relative;
230
- cursor: help;
231
- }
232
-
233
- .tooltip-trigger .tooltip-content {
234
- visibility: hidden;
235
- opacity: 0;
236
- position: absolute;
237
- bottom: 125%;
238
- left: 50%;
239
- transform: translateX(-50%);
240
- background-color: #1c1917;
241
- color: #e7e5e4;
242
- text-align: center;
243
- padding: 8px 12px;
244
- border-radius: 6px;
245
- z-index: 50;
246
- width: 240px;
247
- font-size: 0.8rem;
248
- line-height: 1.4;
249
- transition: opacity 0.2s;
250
- pointer-events: none;
251
- }
252
-
253
- .tooltip-trigger:hover .tooltip-content {
254
- visibility: visible;
255
- opacity: 1;
256
- }
257
-
258
- .method-radio-label,
259
- .task-radio-label {
260
- border: 2px solid #57534e;
261
- transition: all 0.2s ease;
262
- }
263
-
264
- .method-radio-label:hover,
265
- .task-radio-label:hover {
266
- border-color: #a8a29e;
267
- }
268
-
269
- input[type="radio"]:checked+.method-radio-label,
270
- input[type="radio"]:checked+.task-radio-label {
271
- border-color: #3b82f6;
272
- background-color: rgba(59, 130, 246, 0.2);
273
- }
274
-
275
- input[type=range].split-slider {
276
- position: absolute;
277
- background: transparent;
278
- pointer-events: none;
279
- }
280
-
281
- input[type=range].split-slider::-webkit-slider-thumb {
282
- pointer-events: auto;
283
- }
284
-
285
- input[type=range].split-slider::-moz-range-thumb {
286
- pointer-events: auto;
287
- }
288
- </style>
289
  </head>
290
 
291
  <body class="select-none overflow-hidden">
@@ -326,7 +49,7 @@
326
  <label for="dataset-samples"
327
  class="block text-sm font-medium text-stone-300 mb-2">Total Number of
328
  Samples</label>
329
- <input type="number" id="dataset-samples" value="1000" min="10" max="10000"
330
  class="w-full bg-stone-800/70 p-2 rounded-md border-2 border-stone-600 focus:border-blue-500 outline-none transition">
331
  </div>
332
  </div>
@@ -341,7 +64,7 @@
341
  <div class="space-y-2">
342
  <div>
343
  <input type="radio" name="dataset-task" value="detection"
344
- id="task-detection" class="sr-only" checked>
345
  <label for="task-detection"
346
  class="task-radio-label p-3 rounded-lg block cursor-pointer text-left flex items-center gap-3">
347
  <i data-feather="box" class="w-5 h-5 text-blue-400"></i>
@@ -355,7 +78,7 @@
355
  </div>
356
  <div>
357
  <input type="radio" name="dataset-task" value="segmentation"
358
- id="task-segmentation" class="sr-only">
359
  <label for="task-segmentation"
360
  class="task-radio-label p-3 rounded-lg block cursor-pointer text-left flex items-center gap-3">
361
  <i data-feather="pen-tool" class="w-5 h-5 text-blue-400"></i>
@@ -388,7 +111,7 @@
388
  <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
389
  <div>
390
  <input type="radio" name="segmentation-method" value="convex"
391
- id="method-convex" class="sr-only" checked>
392
  <label for="method-convex"
393
  class="method-radio-label p-3 rounded-lg block cursor-pointer text-center">
394
  <h5 class="font-semibold text-sm">Convex Hull</h5>
@@ -406,7 +129,7 @@
406
  </div>
407
  <div>
408
  <input type="radio" name="segmentation-method" value="render"
409
- id="method-render" class="sr-only">
410
  <label for="method-render"
411
  class="method-radio-label p-3 rounded-lg block cursor-pointer text-center">
412
  <h5 class="font-semibold text-sm">Render Mask</h5>
@@ -429,6 +152,23 @@
429
  <input type="range" id="concave-hull-concavity" min="1" max="10" value="2">
430
  </div>
431
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  </div>
433
  </div>
434
  </div>
@@ -456,7 +196,7 @@
456
  class="font-medium text-stone-300 select-none cursor-pointer">Include Test
457
  Set</label>
458
  <label class="relative inline-flex items-center cursor-pointer">
459
- <input type="checkbox" id="include-test-set" class="sr-only peer">
460
  <div
461
  class="w-11 h-6 bg-stone-700 rounded-full peer-checked:bg-blue-600 toggle-bg">
462
  </div>
@@ -614,8 +354,8 @@
614
  current panorama.</span></span>
615
  </label>
616
  <label class="relative inline-flex items-center cursor-pointer">
617
- <input type="checkbox" id="randomize-camera-toggle"
618
- class="sr-only peer">
619
  <div
620
  class="w-11 h-6 bg-stone-700 rounded-full peer-checked:bg-blue-600 toggle-bg">
621
  </div>
@@ -625,13 +365,13 @@
625
  style="max-height: 0; opacity: 0; overflow: hidden;">
626
  <div><label for="horizontal-variance"
627
  class="text-sm flex justify-between">Horizontal Var. <span
628
- id="horizontal-variance-value">20°</span></label><input
629
- type="range" id="horizontal-variance" min="0" max="180" value="20">
630
  </div>
631
  <div><label for="vertical-variance"
632
  class="text-sm flex justify-between">Vertical Var. <span
633
- id="vertical-variance-value">0°</span></label><input
634
- type="range" id="vertical-variance" min="0" max="90" value="0">
635
  </div>
636
  </div>
637
  </div>
@@ -752,948 +492,19 @@
752
  class="panel-content flex-grow overflow-y-auto p-4 grid grid-cols-1 md:grid-cols-2 gap-4"></div>
753
  </aside>
754
 
755
- <script type="importmap">{ "imports": {
756
- "three": "https://unpkg.com/[email protected]/build/three.module.js",
757
- "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
758
- "concaveman": "https://cdn.skypack.dev/concaveman"
759
- } }</script>
760
-
761
- <script type="module">
762
- import * as THREE from 'three';
763
- import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
764
- import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
765
- import { TransformControls } from 'three/addons/controls/TransformControls.js';
766
- import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
767
- import JSZip from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
768
- import gsap from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
769
- import concaveman from 'concaveman';
770
-
771
- const ASSET_BASE_URL = 'https://huggingface.co/spaces/aaurelions/drones/resolve/main';
772
- 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` }));
773
- 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')}` }));
774
-
775
- let panoramaAssets = [...defaultPanoramas];
776
- let modelAssets = [...defaultModels];
777
-
778
- let scene, camera, renderer, orbitControls, transformControls, pmremGenerator, panoramaSphere;
779
- let selectedObject = null;
780
- let activeControlMode = null;
781
- let activePanorama = null;
782
-
783
- let maskScene, maskMaterial;
784
-
785
- const loaderOverlay = document.getElementById('loader-overlay');
786
- const loaderText = document.getElementById('loader-text');
787
- const canvas = document.getElementById('main-canvas');
788
-
789
- async function init() {
790
- loaderText.textContent = 'Setting up 3D scene...';
791
- scene = new THREE.Scene();
792
- camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 2000);
793
- camera.position.set(0, 1.5, 8);
794
-
795
- renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
796
- renderer.setPixelRatio(window.devicePixelRatio);
797
- renderer.setSize(window.innerWidth, window.innerHeight);
798
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
799
- renderer.toneMappingExposure = 1.0;
800
- renderer.outputColorSpace = THREE.SRGBColorSpace;
801
-
802
- pmremGenerator = new THREE.PMREMGenerator(renderer);
803
- pmremGenerator.compileEquirectangularShader();
804
-
805
- const panoGeometry = new THREE.SphereGeometry(1000, 60, 40);
806
- panoGeometry.scale(-1, 1, 1);
807
- const panoMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
808
- panoramaSphere = new THREE.Mesh(panoGeometry, panoMaterial);
809
- scene.add(panoramaSphere);
810
-
811
- orbitControls = new OrbitControls(camera, renderer.domElement);
812
- orbitControls.enableDamping = true;
813
- orbitControls.target.set(0, 1, 0);
814
-
815
- transformControls = new TransformControls(camera, renderer.domElement);
816
- transformControls.enabled = false;
817
- scene.add(transformControls);
818
-
819
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
820
- const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
821
- dirLight.position.set(8, 15, 10);
822
- scene.add(ambientLight, dirLight);
823
-
824
- maskScene = new THREE.Scene();
825
- maskMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
826
-
827
- setupUI();
828
- setupEventListeners();
829
- animate();
830
-
831
- try {
832
- await loadAsset(panoramaAssets[6], 'panorama');
833
- await loadAsset(modelAssets[1], 'model');
834
- setControlMode('translate');
835
- } catch (error) {
836
- showNotification({ text: `Initialization failed: ${error.message}`, type: 'error' });
837
- console.error("Initialization failed:", error);
838
- } finally {
839
- gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') });
840
- }
841
- }
842
-
843
- async function loadAsset(assetData, assetType, button = null) {
844
- const isInitialLoad = !loaderOverlay.classList.contains('hidden');
845
- let spinner, textSpan;
846
- if (button) {
847
- spinner = button.querySelector('.btn-spinner');
848
- textSpan = button.querySelector('.btn-text');
849
- spinner.classList.remove('hidden');
850
- textSpan.classList.add('hidden');
851
- button.disabled = true;
852
- } else if (!isInitialLoad) {
853
- loaderText.textContent = `Loading ${assetType}...`;
854
- loaderOverlay.classList.remove('hidden');
855
- gsap.to(loaderOverlay, { opacity: 1, duration: 0.3 });
856
- canvas.classList.add('loading');
857
- } else {
858
- loaderText.textContent = `Loading ${assetType}: ${assetData.name}`;
859
- }
860
-
861
- try {
862
- if (assetType === 'panorama') {
863
- await new Promise((resolve, reject) => {
864
- const isHDR = assetData.url.endsWith('.hdr');
865
- const loader = isHDR ? new RGBELoader() : new THREE.TextureLoader();
866
- loader.load(assetData.url, (texture) => {
867
- texture.mapping = THREE.EquirectangularReflectionMapping;
868
- if (!isHDR) texture.colorSpace = THREE.SRGBColorSpace;
869
-
870
- if (panoramaSphere) {
871
- panoramaSphere.material.map = texture;
872
- panoramaSphere.material.needsUpdate = true;
873
- }
874
-
875
- scene.environment = pmremGenerator.fromEquirectangular(texture).texture;
876
-
877
- scene.background = texture;
878
- scene.environment = pmremGenerator.fromEquirectangular(texture).texture;
879
- pmremGenerator.dispose();
880
- texture.dispose();
881
- activePanorama = assetData;
882
- setActiveCard('panorama-gallery', assetData.name);
883
- resolve();
884
- }, undefined, reject);
885
- });
886
- } else {
887
- if (selectedObject) transformControls.detach();
888
-
889
- const existingAsset = modelAssets.find(m => m.name === assetData.name && m.mesh);
890
- if (existingAsset) {
891
- modelAssets.forEach(m => { if (m.mesh) m.mesh.visible = false; });
892
- existingAsset.mesh.visible = true;
893
- selectedObject = existingAsset.mesh;
894
- } else {
895
- await new Promise((resolve, reject) => {
896
- const loader = new GLTFLoader();
897
- loader.load(assetData.url, (gltf) => {
898
- modelAssets.forEach(m => { if (m.mesh) m.mesh.visible = false; });
899
- const model = gltf.scene;
900
- model.name = assetData.name;
901
- const box = new THREE.Box3().setFromObject(model);
902
- const size = box.getSize(new THREE.Vector3());
903
- const center = box.getCenter(new THREE.Vector3());
904
- model.position.sub(center);
905
- const maxDim = Math.max(size.x, size.y, size.z);
906
- model.scale.setScalar(4.0 / maxDim);
907
- scene.add(model);
908
-
909
- const assetToUpdate = modelAssets.find(m => m.name === assetData.name);
910
- if (assetToUpdate) {
911
- assetToUpdate.mesh = model;
912
-
913
- assetToUpdate.originalMaterials = new Map();
914
- model.traverse(node => {
915
- if (node.isMesh) assetToUpdate.originalMaterials.set(node, node.material);
916
- });
917
- }
918
-
919
- selectedObject = model;
920
- resolve();
921
- }, undefined, reject);
922
- });
923
- }
924
- if (activeControlMode) transformControls.attach(selectedObject);
925
- setActiveCard('model-selector', assetData.name);
926
- }
927
- } catch (error) {
928
- showNotification({ text: `Failed to load ${assetType}: ${assetData.name}`, type: 'error', duration: 4000 });
929
- console.error(`Failed to load ${assetType}:`, error);
930
- } finally {
931
- if (button) {
932
- spinner.classList.add('hidden');
933
- textSpan.classList.remove('hidden');
934
- button.disabled = false;
935
- } else if (!isInitialLoad) {
936
- gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') });
937
- canvas.classList.remove('loading');
938
- }
939
- }
940
- }
941
-
942
- function createAssetCard(type, assetData) {
943
- const container = document.createElement('div');
944
- container.className = 'asset-card flex flex-col rounded-lg cursor-pointer overflow-hidden bg-stone-900/50';
945
- container.dataset.name = assetData.name;
946
- const thumbWrapper = document.createElement('div');
947
- thumbWrapper.className = 'w-full h-28 flex items-center justify-center';
948
- const thumb = document.createElement('img');
949
- thumb.className = 'max-w-full max-h-full object-contain';
950
-
951
- container.onclick = () => loadAsset(assetData, type);
952
-
953
- if (type === 'model') {
954
- thumbWrapper.classList.add('p-2');
955
- thumb.src = assetData.thumb;
956
- const nameWrapper = document.createElement('div');
957
- nameWrapper.textContent = assetData.name.replace(/\.[^/.]+$/, "").substring(0, 15);
958
- nameWrapper.className = 'text-white text-xs font-semibold text-center block truncate p-2 bg-black/20 mt-auto';
959
- thumbWrapper.appendChild(thumb);
960
- container.append(thumbWrapper, nameWrapper);
961
- } else {
962
- thumb.className = 'w-full h-full object-cover';
963
- thumb.src = assetData.thumb;
964
- thumbWrapper.appendChild(thumb);
965
- container.appendChild(thumbWrapper);
966
- }
967
-
968
- thumb.onerror = () => {
969
- thumbWrapper.innerHTML = `<i data-feather="package" class="w-12 h-12 text-stone-500"></i>`;
970
- feather.replace();
971
- thumbWrapper.parentElement.classList.add('bg-stone-800');
972
- }
973
- document.getElementById(type === 'model' ? 'model-selector' : 'panorama-gallery').appendChild(container);
974
- }
975
-
976
- function setupUI() {
977
- feather.replace();
978
- modelAssets.forEach(m => createAssetCard('model', m));
979
- panoramaAssets.forEach(p => createAssetCard('panorama', p));
980
- }
981
-
982
- function setControlMode(mode) {
983
- if (mode === activeControlMode) {
984
- activeControlMode = null;
985
- transformControls.detach();
986
- transformControls.enabled = false;
987
- } else {
988
- activeControlMode = mode;
989
- transformControls.setMode(activeControlMode);
990
- transformControls.enabled = true;
991
- if (selectedObject) transformControls.attach(selectedObject);
992
  }
993
- document.querySelectorAll('.control-btn').forEach(b => b.classList.toggle('bg-blue-600', b.dataset.mode === activeControlMode));
994
  }
995
-
996
- function setupEventListeners() {
997
- ['model', 'panorama'].forEach(type => {
998
- document.getElementById(`${type}-panel-trigger`).addEventListener('click', e => { e.stopPropagation(); document.getElementById(`${type}-panel`).classList.add('is-open'); });
999
- document.getElementById(`close-${type}-panel`).addEventListener('click', () => document.getElementById(`${type}-panel`).classList.remove('is-open'));
1000
- });
1001
-
1002
- document.getElementById('day-night-toggle').addEventListener('change', (e) => {
1003
- const isNight = e.target.checked;
1004
- const tintColor = isNight ? 0x202040 : 0xffffff;
1005
-
1006
- if (panoramaSphere) {
1007
- gsap.to(panoramaSphere.material.color, {
1008
- r: (tintColor >> 16 & 255) / 255,
1009
- g: (tintColor >> 8 & 255) / 255,
1010
- b: (tintColor & 255) / 255,
1011
- duration: 0.5
1012
- });
1013
- }
1014
-
1015
- gsap.to(renderer, { toneMappingExposure: isNight ? 0.3 : 1.0, duration: 0.5 });
1016
- gsap.to(scene.children.find(c => c.isDirectionalLight), { intensity: isNight ? 0.2 : 1.0, duration: 0.5 });
1017
- });
1018
-
1019
- document.querySelectorAll('.control-btn').forEach(btn => btn.addEventListener('click', () => setControlMode(btn.dataset.mode)));
1020
- transformControls.addEventListener('dragging-changed', e => { orbitControls.enabled = !e.value; });
1021
-
1022
- canvas.addEventListener('click', (e) => {
1023
- if (transformControls.dragging) return;
1024
- const rect = renderer.domElement.getBoundingClientRect();
1025
- const mouse = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
1026
- const raycaster = new THREE.Raycaster();
1027
- raycaster.setFromCamera(mouse, camera);
1028
- const visibleMeshObjects = modelAssets.map(m => m.mesh).filter(m => m && m.visible);
1029
- if (visibleMeshObjects.length === 0) return;
1030
- const intersects = raycaster.intersectObjects(visibleMeshObjects, true);
1031
-
1032
- if (intersects.length > 0) {
1033
- const object = findTopLevelGroup(intersects[0].object);
1034
- if (object && object.isGroup && object !== selectedObject) {
1035
- const modelData = modelAssets.find(m => m.name === object.name);
1036
- if (modelData) loadAsset(modelData, 'model');
1037
- }
1038
- }
1039
- });
1040
-
1041
- window.addEventListener('keydown', (e) => {
1042
- if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'SELECT') return;
1043
- const key = e.key.toLowerCase();
1044
- if (key === 'w' || key === 'e' || key === 'r') { e.preventDefault(); setControlMode(key === 'w' ? 'translate' : key === 'e' ? 'rotate' : 'scale'); }
1045
- if (key === 'g') { e.preventDefault(); document.getElementById('generate-dataset-btn').click(); }
1046
- if (key === 'escape') { document.querySelectorAll('.side-panel.is-open, .modal-overlay.flex').forEach(p => p.classList.add('hidden')); }
1047
- });
1048
-
1049
- setupUploadModal();
1050
- setupDatasetModal();
1051
- }
1052
-
1053
- function setupUploadModal() {
1054
- const modal = document.getElementById('upload-modal');
1055
- const title = document.getElementById('upload-title');
1056
- const fileInput = document.getElementById('upload-file-input');
1057
- const urlInput = document.getElementById('upload-url-input');
1058
- const dropZone = document.getElementById('upload-drop-zone');
1059
- const loadBtn = document.getElementById('upload-load-btn');
1060
- let currentType, currentHandler;
1061
-
1062
- const openModal = (type, titleText, accept, placeholder, handler) => {
1063
- currentType = type;
1064
- title.textContent = titleText;
1065
- fileInput.value = ''; urlInput.value = '';
1066
- fileInput.accept = accept;
1067
- urlInput.placeholder = placeholder;
1068
- currentHandler = handler;
1069
- modal.classList.remove('hidden'); modal.classList.add('flex');
1070
- };
1071
-
1072
- document.getElementById('add-model-btn').addEventListener('click', () => openModal('model', 'Upload New Model', '.glb,.gltf', 'Enter .glb URL', (type, data) => handleNewAsset(type, data, loadBtn)));
1073
- document.getElementById('add-panorama-btn').addEventListener('click', () => openModal('panorama', 'Upload New Panorama', 'image/*,.hdr', 'Enter image URL', (type, data) => handleNewAsset(type, data, loadBtn)));
1074
- document.getElementById('upload-cancel-btn').addEventListener('click', () => modal.classList.add('hidden'));
1075
-
1076
- loadBtn.addEventListener('click', () => {
1077
- const url = urlInput.value.trim();
1078
- if (url) currentHandler(currentType, url, loadBtn);
1079
- });
1080
- dropZone.addEventListener('click', () => fileInput.click());
1081
- fileInput.addEventListener('change', e => { if (e.target.files.length) currentHandler(currentType, e.target.files[0], loadBtn) });
1082
- dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
1083
- dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
1084
- dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files.length) currentHandler(currentType, e.dataTransfer.files[0], loadBtn); });
1085
- }
1086
-
1087
- async function handleNewAsset(type, fileOrUrl, button) {
1088
- document.getElementById('upload-modal').classList.add('hidden');
1089
- const isUrl = typeof fileOrUrl === 'string';
1090
- const url = isUrl ? fileOrUrl : URL.createObjectURL(fileOrUrl);
1091
- const name = (isUrl ? new URL(url).pathname.split('/').pop() : fileOrUrl.name).substring(0, 25);
1092
-
1093
- const newAssetData = { name, url, mesh: null };
1094
- if (type === 'model') {
1095
- if (modelAssets.some(m => m.name === name)) {
1096
- showNotification({ text: `Model "${name}" already exists.`, type: 'error' });
1097
- return;
1098
- }
1099
- newAssetData.thumb = 'placeholder';
1100
- modelAssets.push(newAssetData);
1101
- createAssetCard('model', newAssetData);
1102
- await loadAsset(newAssetData, 'model', button);
1103
- } else {
1104
- if (panoramaAssets.some(p => p.name === name)) {
1105
- showNotification({ text: `Panorama "${name}" already exists.`, type: 'error' });
1106
- return;
1107
- }
1108
- newAssetData.thumb = url;
1109
- panoramaAssets.push(newAssetData);
1110
- createAssetCard('panorama', newAssetData);
1111
- await loadAsset(newAssetData, 'panorama', button);
1112
- }
1113
- }
1114
-
1115
- function setupDatasetModal() {
1116
- const modal = document.getElementById('dataset-modal');
1117
-
1118
- document.getElementById('generate-dataset-btn').addEventListener('click', () => {
1119
- modal.classList.remove('hidden');
1120
- modal.classList.add('flex');
1121
- feather.replace();
1122
- updateDatasetModalUI();
1123
- });
1124
-
1125
- document.getElementById('cancel-dataset').addEventListener('click', () => modal.classList.add('hidden'));
1126
- document.getElementById('start-dataset').addEventListener('click', handleDatasetGeneration);
1127
-
1128
- document.querySelectorAll('#dataset-modal input[type="radio"], #dataset-modal input[type="checkbox"]').forEach(input => {
1129
- input.addEventListener('change', updateDatasetModalUI);
1130
- });
1131
-
1132
- const setupSlider = (sliderId, displayId, unit) => {
1133
- const slider = document.getElementById(sliderId);
1134
- const display = document.getElementById(displayId);
1135
- if (slider && display) {
1136
- const update = () => { display.textContent = slider.value + unit; };
1137
- update();
1138
- slider.addEventListener('input', update);
1139
- }
1140
- };
1141
- setupSlider('position-variance', 'position-variance-value', '%');
1142
- setupSlider('rotation-variance', 'rotation-variance-value', '%');
1143
- setupSlider('scale-variance', 'scale-variance-value', '%');
1144
- setupSlider('horizontal-variance', 'horizontal-variance-value', '°');
1145
- setupSlider('vertical-variance', 'vertical-variance-value', '°');
1146
- setupSlider('concave-hull-concavity', 'concave-hull-concavity-value', '');
1147
- setupSlider('background-ratio-slider', 'background-ratio-value', '%');
1148
-
1149
- const trainSlider = document.getElementById('split-train-ratio');
1150
- const valSlider = document.getElementById('split-val-ratio');
1151
-
1152
- const bringToFront = (el, topZ, bottomZ) => {
1153
- el.style.zIndex = topZ;
1154
- (el === trainSlider ? valSlider : trainSlider).style.zIndex = bottomZ;
1155
- }
1156
-
1157
- trainSlider.addEventListener('input', () => { if (parseInt(trainSlider.value) >= parseInt(valSlider.value)) valSlider.value = parseInt(trainSlider.value); updateSplitRatios(); });
1158
- valSlider.addEventListener('input', () => { if (parseInt(valSlider.value) <= parseInt(trainSlider.value)) trainSlider.value = parseInt(valSlider.value); updateSplitRatios(); });
1159
-
1160
- trainSlider.addEventListener('mousedown', () => bringToFront(trainSlider, 25, 20));
1161
- trainSlider.addEventListener('touchstart', () => bringToFront(trainSlider, 25, 20));
1162
- valSlider.addEventListener('mousedown', () => bringToFront(valSlider, 25, 20));
1163
- valSlider.addEventListener('touchstart', () => bringToFront(valSlider, 25, 20));
1164
- }
1165
-
1166
- function updateDatasetModalUI() {
1167
-
1168
- const useCurrentModel = document.querySelector('input[name="model-source"]:checked')?.value === 'current';
1169
- const modelInfo = document.getElementById('model-source-current-info');
1170
- modelInfo.textContent = `Using: ${selectedObject ? selectedObject.name : 'None'}`;
1171
- modelInfo.classList.toggle('hidden', !useCurrentModel);
1172
-
1173
- const useCurrentPano = document.querySelector('input[name="pano-source"]:checked')?.value === 'current';
1174
- const panoInfo = document.getElementById('pano-source-current-info');
1175
- panoInfo.textContent = `Using: ${activePanorama ? activePanorama.name : 'None'}`;
1176
- panoInfo.classList.toggle('hidden', !useCurrentPano);
1177
-
1178
- const randomizeModel = document.getElementById('randomize-model-toggle')?.checked;
1179
- gsap.to("#model-sliders", { maxHeight: randomizeModel ? 200 : 0, opacity: randomizeModel ? 1 : 0, paddingTop: randomizeModel ? '0.75rem' : 0, duration: 0.4 });
1180
-
1181
- const cameraSection = document.getElementById('camera-randomization-section');
1182
- const cameraToggle = document.getElementById('randomize-camera-toggle');
1183
- cameraSection.style.opacity = useCurrentPano ? '1' : '0.5';
1184
- cameraToggle.disabled = !useCurrentPano;
1185
- if (!useCurrentPano) { cameraToggle.checked = false; }
1186
- const cameraToggleLabel = cameraToggle.closest('label.flex.items-center.gap-2');
1187
- if (cameraToggleLabel) cameraToggleLabel.style.cursor = useCurrentPano ? 'pointer' : 'not-allowed';
1188
-
1189
- const randomizeCamera = useCurrentPano && cameraToggle.checked;
1190
- gsap.to("#camera-sliders", { maxHeight: randomizeCamera ? 150 : 0, opacity: randomizeCamera ? 1 : 0, paddingTop: randomizeCamera ? '0.75rem' : 0, duration: 0.4 });
1191
-
1192
- const task = document.querySelector('input[name="dataset-task"]:checked').value;
1193
- const segGroup = document.getElementById('segmentation-method-group');
1194
- const segMethod = document.querySelector('input[name="segmentation-method"]:checked').value;
1195
- const concaveOptions = document.getElementById('concave-hull-options');
1196
-
1197
- const showSegGroup = task === 'segmentation';
1198
- gsap.to(segGroup, {
1199
- maxHeight: showSegGroup ? 300 : 0,
1200
- opacity: showSegGroup ? 1 : 0,
1201
- paddingTop: showSegGroup ? '0.5rem' : 0,
1202
- marginTop: showSegGroup ? '1rem' : 0,
1203
- duration: 0.4
1204
- });
1205
-
1206
- const showConcaveOptions = showSegGroup && segMethod === 'concave';
1207
- gsap.to(concaveOptions, {
1208
- maxHeight: showConcaveOptions ? 100 : 0,
1209
- opacity: showConcaveOptions ? 1 : 0,
1210
- paddingTop: showConcaveOptions ? '0.75rem' : 0,
1211
- duration: 0.4
1212
- });
1213
-
1214
- const includeBackgrounds = document.getElementById('include-background-toggle').checked;
1215
- gsap.to("#background-ratio-container", {
1216
- maxHeight: includeBackgrounds ? 100 : 0,
1217
- opacity: includeBackgrounds ? 1 : 0,
1218
- paddingTop: includeBackgrounds ? '0.5rem' : 0,
1219
- duration: 0.4
1220
- });
1221
-
1222
- updateSplitRatios();
1223
- }
1224
-
1225
- function updateSplitRatios() {
1226
- const valToggle = document.getElementById('include-val-set');
1227
- const testToggle = document.getElementById('include-test-set');
1228
- const trainSlider = document.getElementById('split-train-ratio');
1229
- const valSlider = document.getElementById('split-val-ratio');
1230
-
1231
- valSlider.disabled = !valToggle.checked;
1232
- testToggle.disabled = !valToggle.checked;
1233
- if (!valToggle.checked) testToggle.checked = false;
1234
-
1235
- valSlider.style.visibility = testToggle.checked ? 'visible' : 'hidden';
1236
-
1237
- valSlider.parentElement.style.opacity = valToggle.checked ? '1' : '0.4';
1238
-
1239
- const trainPctRaw = parseInt(trainSlider.value);
1240
- let valEndPctRaw = valToggle.checked ? parseInt(valSlider.value) : trainPctRaw;
1241
-
1242
- const trainPct = Math.max(0, Math.min(100, trainPctRaw));
1243
- let valEndPct = valToggle.checked ? Math.max(trainPct, Math.min(100, valEndPctRaw)) : trainPct;
1244
-
1245
- if (!testToggle.checked) {
1246
- valEndPct = 100;
1247
- }
1248
-
1249
- const valPct = valToggle.checked ? valEndPct - trainPct : 0;
1250
- const testPct = testToggle.checked ? 100 - valEndPct : 0;
1251
- const trainDisplayPct = 100 - valPct - testPct;
1252
-
1253
- document.getElementById('split-train-display').textContent = `${trainDisplayPct}%`;
1254
- document.getElementById('split-val-display').textContent = `${valPct}%`;
1255
- document.getElementById('split-test-display').textContent = `${testPct}%`;
1256
-
1257
- document.getElementById('split-val-display').style.visibility = valToggle.checked ? 'visible' : 'hidden';
1258
- document.getElementById('split-test-display').style.visibility = testToggle.checked ? 'visible' : 'hidden';
1259
-
1260
- const total = trainDisplayPct + valPct + testPct;
1261
- document.getElementById('split-ratio-sum').textContent = total;
1262
-
1263
- document.getElementById('split-bg-train').style.width = `${trainDisplayPct}%`;
1264
- const valBg = document.getElementById('split-bg-val');
1265
- valBg.style.left = `${trainDisplayPct}%`;
1266
- valBg.style.width = `${valPct}%`;
1267
- valBg.style.display = valToggle.checked ? 'block' : 'none';
1268
-
1269
- const testBg = document.getElementById('split-bg-test');
1270
- testBg.style.left = `${trainDisplayPct + valPct}%`;
1271
- testBg.style.width = `${testPct}%`;
1272
- testBg.style.display = testToggle.checked ? 'block' : 'none';
1273
- }
1274
-
1275
- async function handleDatasetGeneration() {
1276
- const startBtn = document.getElementById('start-dataset');
1277
- const cancelBtn = document.getElementById('cancel-dataset');
1278
- const btnText = startBtn.querySelector('.btn-text');
1279
- const btnSpinner = startBtn.querySelector('.btn-spinner');
1280
-
1281
- btnText.classList.add('hidden');
1282
- btnSpinner.classList.remove('hidden');
1283
- startBtn.disabled = true;
1284
- cancelBtn.disabled = true;
1285
-
1286
- try {
1287
- const trainSlider = document.getElementById('split-train-ratio');
1288
- const valSlider = document.getElementById('split-val-ratio');
1289
-
1290
- const options = {
1291
- task: document.querySelector('input[name="dataset-task"]:checked').value,
1292
- segmentationMethod: document.querySelector('input[name="segmentation-method"]:checked').value,
1293
- concavity: parseFloat(document.getElementById('concave-hull-concavity').value),
1294
- samples: parseInt(document.getElementById('dataset-samples').value),
1295
- name: document.getElementById('dataset-name').value.trim(),
1296
-
1297
- includeBackgrounds: document.getElementById('include-background-toggle').checked,
1298
- backgroundRatio: parseInt(document.getElementById('background-ratio-slider').value) / 100,
1299
-
1300
- useCurrentModel: document.querySelector('input[name="model-source"]:checked').value === 'current',
1301
- randomizeModel: document.getElementById('randomize-model-toggle').checked,
1302
- posVar: parseInt(document.getElementById('position-variance').value) / 100,
1303
- rotVar: parseInt(document.getElementById('rotation-variance').value) / 100,
1304
- scaleVar: parseInt(document.getElementById('scale-variance').value) / 100,
1305
- useCurrentPanorama: document.querySelector('input[name="pano-source"]:checked').value === 'current',
1306
- randomizeCamera: document.getElementById('randomize-camera-toggle').checked,
1307
- hVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('horizontal-variance').value)),
1308
- vVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('vertical-variance').value)),
1309
- includeVal: document.getElementById('include-val-set').checked,
1310
- includeTest: document.getElementById('include-test-set').checked,
1311
- trainSplit: parseInt(trainSlider.value),
1312
- valSplit: parseInt(valSlider.value),
1313
- initial: {
1314
- model: {
1315
- position: selectedObject ? selectedObject.position.clone() : new THREE.Vector3(),
1316
- rotation: selectedObject ? selectedObject.rotation.clone() : new THREE.Euler(),
1317
- scale: selectedObject ? selectedObject.scale.x : 1
1318
- },
1319
- camera: { position: camera.position.clone() }
1320
- }
1321
- };
1322
-
1323
- if (!options.name) { showNotification({ text: "Dataset name cannot be empty.", type: "error" }); return; }
1324
- if ((options.task === 'detection' || options.task === 'segmentation') && !options.includeVal) {
1325
- showNotification({ text: "Validation set is required for YOLO formats.", type: "error" });
1326
- document.getElementById('include-val-set').checked = true;
1327
- updateDatasetModalUI();
1328
- return;
1329
- }
1330
-
1331
- document.getElementById('dataset-modal').classList.add('hidden');
1332
- const progressContainer = document.getElementById('progress-container');
1333
- progressContainer.classList.remove('hidden'); progressContainer.classList.add('flex');
1334
-
1335
- const zip = new JSZip();
1336
- const rootFolderName = options.name;
1337
- const usedClasses = new Set();
1338
-
1339
- const originalModelAsset = selectedObject ? modelAssets.find(m => m.name === selectedObject.name) : null;
1340
- const originalPanoramaAsset = activePanorama;
1341
- const transformWasVisible = transformControls.visible;
1342
- if (transformWasVisible) transformControls.visible = false;
1343
-
1344
- for (let i = 0; i < options.samples; i++) {
1345
- updateProgress(i + 1, options.samples);
1346
- const currentModelAsset = await randomizeSceneForGeneration(options, usedClasses);
1347
-
1348
- renderer.render(scene, camera);
1349
- const imageName = `sample_${String(i).padStart(5, '0')}.jpg`;
1350
- const labelName = imageName.replace('.jpg', '.txt');
1351
-
1352
- const modelClassName = currentModelAsset ? currentModelAsset.name.replace(/\.[^/.]+$/, "") : 'background_only';
1353
- const classIndex = currentModelAsset ? modelAssets.findIndex(m => m.name === currentModelAsset.name) : -1;
1354
-
1355
- const { imageBlob, labelData } = await generateSampleData(options, classIndex);
1356
-
1357
- if (imageBlob) {
1358
- const randomVal = Math.random() * 100;
1359
- let splitFolder = 'train';
1360
-
1361
- const valPct = options.includeVal ? options.valSplit - options.trainSplit : 0;
1362
- const testPct = options.includeTest ? 100 - options.valSplit : 0;
1363
- const trainPct = 100 - valPct - testPct;
1364
-
1365
- if (options.includeTest && randomVal > (trainPct + valPct)) {
1366
- splitFolder = 'test';
1367
- } else if (options.includeVal && randomVal > trainPct) {
1368
- splitFolder = 'val';
1369
- }
1370
-
1371
- if (options.task === 'classification') {
1372
- const classFolder = currentModelAsset && selectedObject.visible ? modelClassName : 'background_only';
1373
- zip.folder(rootFolderName).folder(splitFolder).folder(classFolder).file(imageName, imageBlob);
1374
- } else {
1375
- zip.folder(rootFolderName).folder('images').folder(splitFolder).file(imageName, imageBlob);
1376
- if (labelData) { zip.folder(rootFolderName).folder('labels').folder(splitFolder).file(labelName, labelData); }
1377
- }
1378
- }
1379
- if (i % 10 === 0) await new Promise(resolve => setTimeout(resolve, 1));
1380
- }
1381
-
1382
- if (options.task !== 'classification') {
1383
- const classNames = Array.from(usedClasses).sort().map((name, index) => ` ${index}: ${name.replace(/\.[^/.]+$/, "")}`).join('\n');
1384
- let yamlContent = `path: ${rootFolderName}\ntrain: images/train\nval: images/val\n`;
1385
- if (options.includeTest) yamlContent += `test: images/test\n`;
1386
- yamlContent += `\nnames:\n${classNames}`;
1387
- zip.folder(rootFolderName).file(`${options.name}.yaml`, yamlContent);
1388
- }
1389
-
1390
- if (transformWasVisible) transformControls.visible = true;
1391
-
1392
- updateProgress(options.samples, options.samples, "Compressing ZIP...");
1393
- const content = await zip.generateAsync({ type: "blob" });
1394
- const link = document.createElement('a');
1395
- link.href = URL.createObjectURL(content);
1396
- link.download = `${rootFolderName}.zip`;
1397
- link.click();
1398
- URL.revokeObjectURL(link.href);
1399
- progressContainer.classList.add('hidden');
1400
- showNotification({ text: 'Dataset generation complete!', type: 'success' });
1401
-
1402
- await loadAsset(originalPanoramaAsset || panoramaAssets[0], 'panorama');
1403
- await loadAsset(originalModelAsset || modelAssets[0], 'model');
1404
-
1405
- } finally {
1406
- btnText.classList.remove('hidden');
1407
- btnSpinner.classList.add('hidden');
1408
- startBtn.disabled = false;
1409
- cancelBtn.disabled = false;
1410
- }
1411
- }
1412
-
1413
- async function randomizeSceneForGeneration(options, usedClasses) {
1414
- let currentModelAsset;
1415
- if (!options.useCurrentPanorama) {
1416
- const randomPano = panoramaAssets[Math.floor(Math.random() * panoramaAssets.length)];
1417
- await loadAsset(randomPano, 'panorama');
1418
- }
1419
- if (!options.useCurrentModel) {
1420
- currentModelAsset = modelAssets[Math.floor(Math.random() * modelAssets.length)];
1421
- await loadAsset(currentModelAsset, 'model');
1422
- } else {
1423
- currentModelAsset = modelAssets.find(m => m.name === selectedObject.name);
1424
- }
1425
-
1426
- if (!currentModelAsset || !selectedObject) return null;
1427
-
1428
- if (options.includeBackgrounds && Math.random() < options.backgroundRatio) {
1429
- selectedObject.visible = false;
1430
- } else {
1431
- selectedObject.visible = true;
1432
- }
1433
-
1434
- if (selectedObject.visible) {
1435
- usedClasses.add(currentModelAsset.name);
1436
- }
1437
-
1438
- if (!selectedObject.visible) return null;
1439
-
1440
- if (options.randomizeModel) {
1441
- if (options.useCurrentModel) {
1442
- const { position: basePos, rotation: baseRot, scale: baseScale } = options.initial.model;
1443
- selectedObject.position.copy(basePos).add(new THREE.Vector3().randomDirection().multiplyScalar(Math.random() * options.posVar * 5));
1444
-
1445
- const baseQuaternion = new THREE.Quaternion().setFromEuler(baseRot);
1446
- 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);
1447
- baseQuaternion.multiply(new THREE.Quaternion().setFromEuler(rotOffset));
1448
- selectedObject.quaternion.copy(baseQuaternion);
1449
-
1450
- selectedObject.scale.setScalar(baseScale * (1 + (Math.random() - 0.5) * 2 * options.scaleVar));
1451
- } else {
1452
- selectedObject.position.set((Math.random() - 0.5) * 10, Math.random() * 5, (Math.random() - 0.5) * 10);
1453
- selectedObject.rotation.set(Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2);
1454
- const box = new THREE.Box3().setFromObject(selectedObject);
1455
- const size = box.getSize(new THREE.Vector3());
1456
- const maxDim = Math.max(size.x, size.y, size.z);
1457
- const baseScale = maxDim > 0 ? 4.0 / maxDim : 1.0;
1458
- selectedObject.scale.setScalar(baseScale * (0.5 + Math.random()));
1459
- }
1460
- }
1461
-
1462
- if (options.useCurrentPanorama && options.randomizeCamera) {
1463
- const spherical = new THREE.Spherical().setFromCartesianCoords(options.initial.camera.position.x, options.initial.camera.position.y, options.initial.camera.position.z);
1464
- spherical.theta += (Math.random() - 0.5) * 2 * options.hVar;
1465
- spherical.phi += (Math.random() - 0.5) * 2 * options.vVar;
1466
- spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
1467
- camera.position.setFromSpherical(spherical);
1468
- } else if (!options.useCurrentPanorama) {
1469
- camera.position.set((Math.random() - 0.5) * 20, Math.random() * 8 + 1, (Math.random() - 0.5) * 15 + 5);
1470
- }
1471
- camera.lookAt(selectedObject.position);
1472
- selectedObject.updateMatrixWorld(true);
1473
- return currentModelAsset;
1474
- }
1475
-
1476
- function findTopLevelGroup(object) { let current = object; while (current.parent && current.parent !== scene && current.parent !== maskScene) { current = current.parent; } return current; }
1477
- function getCanvasBlob() { return new Promise(resolve => renderer.domElement.toBlob(resolve, 'image/jpeg', 0.9)); }
1478
- function updateProgress(current, total, text = 'Processing...') {
1479
- document.getElementById('progress-bar').style.width = `${total > 0 ? (current / total) * 100 : 0}%`;
1480
- document.getElementById('progress-label').textContent = text;
1481
- document.getElementById('progress-count').textContent = `(${current}/${total})`;
1482
- }
1483
- function setActiveCard(containerId, name) { document.querySelectorAll(`#${containerId} .asset-card`).forEach(c => c.classList.toggle('active', c.dataset.name === name)); }
1484
-
1485
- function showNotification({ text, type = 'success', duration = 3000, id = null }) {
1486
- const container = document.getElementById('notification-container');
1487
- if (id) { const existing = document.getElementById(id); if (existing) { existing.querySelector('span').textContent = text; return; } }
1488
- const el = document.createElement('div');
1489
- el.id = id || `notif-${Date.now()}`;
1490
- el.className = `notification glass-ui p-3 px-4 rounded-lg flex items-center gap-3`;
1491
- const colors = { success: 'bg-green-500/50', error: 'bg-red-500/50', info: 'bg-blue-500/50', loading: 'bg-stone-500/50' };
1492
- const icons = { success: 'check-circle', error: 'alert-triangle', info: 'info', loading: null };
1493
- el.classList.add(colors[type]);
1494
- let iconHtml = type === 'loading' ? `<div class="notification-spinner"></div>` : `<i data-feather="${icons[type]}" class="w-5 h-5"></i>`;
1495
- el.innerHTML = `${iconHtml}<span>${text}</span>`;
1496
- container.appendChild(el);
1497
- if (icons[type]) feather.replace();
1498
- setTimeout(() => el.classList.add('show'), 10);
1499
- if (type !== 'loading') { setTimeout(() => hideNotification(el.id), duration); }
1500
- }
1501
-
1502
- function hideNotification(id) {
1503
- const el = document.getElementById(id);
1504
- if (el) { el.classList.remove('show'); setTimeout(() => el.remove(), 500); }
1505
- }
1506
-
1507
- function animate() {
1508
- requestAnimationFrame(animate);
1509
- orbitControls.update();
1510
- renderer.render(scene, camera);
1511
- }
1512
- window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
1513
-
1514
- async function generateSampleData(options, classIndex = 0) {
1515
- let labelData = null;
1516
- if (selectedObject && selectedObject.visible && classIndex !== -1 && options.task !== 'classification') {
1517
- if (options.task === 'segmentation' && options.segmentationMethod === 'render') {
1518
- labelData = await generateRenderMaskData(classIndex);
1519
- } else {
1520
- const points = getProjected2dVertices();
1521
- if (points && points.length > 3) {
1522
- if (options.task === 'detection') {
1523
- const hull = computeConvexHull(points);
1524
- if (hull.length > 0) labelData = getDetectionLabel(hull, classIndex);
1525
- } else if (options.task === 'segmentation') {
1526
- if (options.segmentationMethod === 'concave') {
1527
- labelData = generateConcaveHullData(points, classIndex, options.concavity);
1528
- } else {
1529
- const hull = computeConvexHull(points);
1530
- if (hull.length > 0) labelData = getSegmentationLabel(hull, classIndex);
1531
- }
1532
- }
1533
- }
1534
- }
1535
- }
1536
- renderer.render(scene, camera);
1537
- const imageBlob = await getCanvasBlob();
1538
- return { imageBlob, labelData };
1539
- }
1540
-
1541
- function getDetectionLabel(hull, classIndex) {
1542
- let minX = 1, maxX = -1, minY = 1, maxY = -1;
1543
- for (const p of hull) {
1544
- minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
1545
- minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
1546
- }
1547
- if (maxX <= minX || maxY <= minY) return null;
1548
- const normCenterX = (((minX + maxX) / 2) + 1) / 2;
1549
- const normCenterY = (-((minY + maxY) / 2) + 1) / 2;
1550
- const normWidth = (maxX - minX) / 2;
1551
- const normHeight = (maxY - minY) / 2;
1552
- if (normWidth < 0.001 || normHeight < 0.001) return null;
1553
- return `${classIndex} ${normCenterX.toFixed(6)} ${normCenterY.toFixed(6)} ${normWidth.toFixed(6)} ${normHeight.toFixed(6)}`;
1554
- }
1555
-
1556
- function getSegmentationLabel(points, classIndex) {
1557
- if (points.length < 3) return null;
1558
- const yoloPoints = points.map(p => `${((p.x + 1) / 2).toFixed(6)} ${((-p.y + 1) / 2).toFixed(6)}`).join(' ');
1559
- return `${classIndex} ${yoloPoints}`;
1560
- }
1561
-
1562
- function generateConcaveHullData(points, classIndex, concavity) {
1563
- const screenPoints = points.map(p => [p.x, p.y]);
1564
- const hull = concaveman(screenPoints, concavity);
1565
- if (hull.length < 3) return getSegmentationLabel(computeConvexHull(points), classIndex);
1566
- const mappedHull = hull.map(p => ({ x: p[0], y: p[1] }));
1567
- return getSegmentationLabel(mappedHull, classIndex);
1568
- }
1569
-
1570
- async function generateRenderMaskData(classIndex) {
1571
- const assetInfo = modelAssets.find(m => m.name === selectedObject.name);
1572
- if (!assetInfo || !assetInfo.originalMaterials) return null;
1573
-
1574
- const originalBackground = scene.background;
1575
- scene.background = new THREE.Color(0x000000);
1576
- selectedObject.traverse(node => { if (node.isMesh) node.material = maskMaterial; });
1577
- renderer.render(scene, camera);
1578
-
1579
- const { width, height } = renderer.domElement;
1580
- const context = renderer.getContext();
1581
- const pixelData = new Uint8Array(width * height * 4);
1582
- context.readPixels(0, 0, width, height, context.RGBA, context.UNSIGNED_BYTE, pixelData);
1583
-
1584
- scene.background = originalBackground;
1585
- selectedObject.traverse(node => { if (node.isMesh) node.material = assetInfo.originalMaterials.get(node); });
1586
-
1587
- const contours = findContours(pixelData, width, height);
1588
- if (contours.length === 0) return null;
1589
-
1590
- contours.sort((a, b) => b.length - a.length);
1591
- const mainContour = contours[0];
1592
-
1593
- const simplifiedContour = rdp(mainContour, 1.5);
1594
- if (simplifiedContour.length < 3) return null;
1595
-
1596
- const yoloPoints = simplifiedContour.map(p => `${(p.x / width).toFixed(6)} ${((height - p.y) / height).toFixed(6)}`).join(' ');
1597
- return `${classIndex} ${yoloPoints}`;
1598
- }
1599
-
1600
- function computeConvexHull(points) {
1601
- if (points.length <= 3) return points;
1602
- points.sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);
1603
- const cross_product = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
1604
- const lower = [];
1605
- for (const p of points) {
1606
- while (lower.length >= 2 && cross_product(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
1607
- lower.push(p);
1608
- }
1609
- const upper = [];
1610
- for (let i = points.length - 1; i >= 0; i--) {
1611
- const p = points[i];
1612
- while (upper.length >= 2 && cross_product(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
1613
- upper.push(p);
1614
- }
1615
- upper.pop(); lower.pop();
1616
- return lower.concat(upper);
1617
- }
1618
-
1619
- function getProjected2dVertices() {
1620
- if (!selectedObject || !selectedObject.visible) return null;
1621
- const projectedVertices = [];
1622
- selectedObject.updateMatrixWorld(true);
1623
- selectedObject.traverse(node => {
1624
- if (node.isMesh) {
1625
- const geometry = node.geometry;
1626
- const positionAttribute = geometry.attributes.position;
1627
- if (!positionAttribute) return;
1628
- const vertex = new THREE.Vector3();
1629
- for (let i = 0; i < positionAttribute.count; i++) {
1630
- vertex.fromBufferAttribute(positionAttribute, i);
1631
- vertex.applyMatrix4(node.matrixWorld);
1632
- vertex.project(camera);
1633
- if (vertex.z < 1) projectedVertices.push({ x: vertex.x, y: vertex.y });
1634
- }
1635
- }
1636
- });
1637
- return projectedVertices.length > 0 ? projectedVertices : null;
1638
- }
1639
-
1640
- function rdp(points, epsilon) {
1641
- if (points.length < 3) return points;
1642
- let dmax = 0; let index = 0;
1643
- const end = points.length - 1;
1644
- for (let i = 1; i < end; i++) {
1645
- const d = perpendicularDistance(points[i], points[0], points[end]);
1646
- if (d > dmax) { index = i; dmax = d; }
1647
- }
1648
- if (dmax > epsilon) {
1649
- const recResults1 = rdp(points.slice(0, index + 1), epsilon);
1650
- const recResults2 = rdp(points.slice(index, end + 1), epsilon);
1651
- return recResults1.slice(0, recResults1.length - 1).concat(recResults2);
1652
- } else { return [points[0], points[end]]; }
1653
- }
1654
- function perpendicularDistance(point, lineStart, lineEnd) {
1655
- let dx = lineEnd.x - lineStart.x; let dy = lineEnd.y - lineStart.y;
1656
- if (dx === 0 && dy === 0) return Math.sqrt(Math.pow(point.x - lineStart.x, 2) + Math.pow(point.y - lineStart.y, 2));
1657
- const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy);
1658
- const projectionX = lineStart.x + t * dx; const projectionY = lineStart.y + t * dy;
1659
- return Math.sqrt(Math.pow(point.x - projectionX, 2) + Math.pow(point.y - projectionY, 2));
1660
- }
1661
-
1662
- function findContours(data, width, height) {
1663
- const visited = new Uint8Array(width * height);
1664
- const contours = [];
1665
- const isWhite = (x, y) => data[(y * width + x) * 4] > 128;
1666
- const directions = [[0, -1], [1, 0], [0, 1], [-1, 0]];
1667
- for (let y = 1; y < height - 1; y++) {
1668
- for (let x = 1; x < width - 1; x++) {
1669
- if (isWhite(x, y) && !isWhite(x, y - 1) && !visited[y * width + x]) {
1670
- const path = [];
1671
- let cx = x, cy = y, dir = 1;
1672
- while (true) {
1673
- path.push({ x: cx, y: cy });
1674
- visited[cy * width + cx] = 1;
1675
- let foundNext = false;
1676
- for (let i = 0; i < 4; i++) {
1677
- const nextDir = (dir + 3 + i) % 4;
1678
- const [dx, dy] = directions[nextDir];
1679
- const nx = cx + dx, ny = cy + dy;
1680
- if (isWhite(nx, ny)) {
1681
- cx = nx; cy = ny; dir = nextDir;
1682
- foundNext = true;
1683
- break;
1684
- }
1685
- }
1686
- if (!foundNext || (cx === x && cy === y)) break;
1687
- }
1688
- if (path.length > 10) contours.push(path);
1689
- }
1690
- }
1691
- }
1692
- return contours;
1693
- }
1694
-
1695
- init();
1696
  </script>
 
 
1697
  </body>
1698
 
1699
  </html>
 
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>YOLOSet: Data Factory</title>
8
 
9
  <script src="https://cdn.tailwindcss.com"></script>
10
  <script src="https://unpkg.com/feather-icons"></script>
11
+ <link rel="stylesheet" href="style.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  </head>
13
 
14
  <body class="select-none overflow-hidden">
 
49
  <label for="dataset-samples"
50
  class="block text-sm font-medium text-stone-300 mb-2">Total Number of
51
  Samples</label>
52
+ <input type="number" id="dataset-samples" value="100" min="10" max="100000"
53
  class="w-full bg-stone-800/70 p-2 rounded-md border-2 border-stone-600 focus:border-blue-500 outline-none transition">
54
  </div>
55
  </div>
 
64
  <div class="space-y-2">
65
  <div>
66
  <input type="radio" name="dataset-task" value="detection"
67
+ id="task-detection" class="sr-only">
68
  <label for="task-detection"
69
  class="task-radio-label p-3 rounded-lg block cursor-pointer text-left flex items-center gap-3">
70
  <i data-feather="box" class="w-5 h-5 text-blue-400"></i>
 
78
  </div>
79
  <div>
80
  <input type="radio" name="dataset-task" value="segmentation"
81
+ id="task-segmentation" class="sr-only" checked>
82
  <label for="task-segmentation"
83
  class="task-radio-label p-3 rounded-lg block cursor-pointer text-left flex items-center gap-3">
84
  <i data-feather="pen-tool" class="w-5 h-5 text-blue-400"></i>
 
111
  <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
112
  <div>
113
  <input type="radio" name="segmentation-method" value="convex"
114
+ id="method-convex" class="sr-only">
115
  <label for="method-convex"
116
  class="method-radio-label p-3 rounded-lg block cursor-pointer text-center">
117
  <h5 class="font-semibold text-sm">Convex Hull</h5>
 
129
  </div>
130
  <div>
131
  <input type="radio" name="segmentation-method" value="render"
132
+ id="method-render" class="sr-only" checked>
133
  <label for="method-render"
134
  class="method-radio-label p-3 rounded-lg block cursor-pointer text-center">
135
  <h5 class="font-semibold text-sm">Render Mask</h5>
 
152
  <input type="range" id="concave-hull-concavity" min="1" max="10" value="2">
153
  </div>
154
  </div>
155
+ <div id="render-mask-options" style="max-height: 0; opacity: 0; overflow: hidden;">
156
+ <div class="pt-3 space-y-2">
157
+ <label for="mask-simplification-slider"
158
+ class="text-sm font-medium text-stone-300 flex justify-between">Mask
159
+ Simplification
160
+ <span id="mask-simplification-value">0.1</span>
161
+ <span class="tooltip-trigger">
162
+ <i data-feather="help-circle" class="w-4 h-4 text-stone-400"></i>
163
+ <span class="tooltip-content">Controls mask accuracy. Lower values
164
+ create more detailed masks (more vertices). Higher values create
165
+ simpler masks (fewer vertices).</span>
166
+ </span>
167
+ </label>
168
+ <input type="range" id="mask-simplification-slider" min="0.1" max="3"
169
+ step="0.1" value="0.1">
170
+ </div>
171
+ </div>
172
  </div>
173
  </div>
174
  </div>
 
196
  class="font-medium text-stone-300 select-none cursor-pointer">Include Test
197
  Set</label>
198
  <label class="relative inline-flex items-center cursor-pointer">
199
+ <input type="checkbox" id="include-test-set" class="sr-only peer" checked>
200
  <div
201
  class="w-11 h-6 bg-stone-700 rounded-full peer-checked:bg-blue-600 toggle-bg">
202
  </div>
 
354
  current panorama.</span></span>
355
  </label>
356
  <label class="relative inline-flex items-center cursor-pointer">
357
+ <input type="checkbox" id="randomize-camera-toggle" class="sr-only peer"
358
+ checked>
359
  <div
360
  class="w-11 h-6 bg-stone-700 rounded-full peer-checked:bg-blue-600 toggle-bg">
361
  </div>
 
365
  style="max-height: 0; opacity: 0; overflow: hidden;">
366
  <div><label for="horizontal-variance"
367
  class="text-sm flex justify-between">Horizontal Var. <span
368
+ id="horizontal-variance-value">180°</span></label><input
369
+ type="range" id="horizontal-variance" min="0" max="180" value="180">
370
  </div>
371
  <div><label for="vertical-variance"
372
  class="text-sm flex justify-between">Vertical Var. <span
373
+ id="vertical-variance-value">20°</span></label><input
374
+ type="range" id="vertical-variance" min="0" max="90" value="20">
375
  </div>
376
  </div>
377
  </div>
 
492
  class="panel-content flex-grow overflow-y-auto p-4 grid grid-cols-1 md:grid-cols-2 gap-4"></div>
493
  </aside>
494
 
495
+ <script type="importmap">
496
+ {
497
+ "imports": {
498
+ "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
499
+ "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
500
+ "concaveman": "https://cdn.skypack.dev/concaveman",
501
+ "gsap": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
502
+ "jszip": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  }
 
504
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  </script>
506
+
507
+ <script type="module" src="core.js"></script>
508
  </body>
509
 
510
  </html>
style.css CHANGED
@@ -1,28 +1,277 @@
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  margin-top: 0;
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://rsms.me/inter/inter.css');
2
+
3
+ html {
4
+ font-family: 'Inter', sans-serif;
5
+ }
6
+
7
  body {
8
+ background-color: #0c0a09;
9
+ color: #e7e5e4;
10
+ }
11
+
12
+ .glass-ui {
13
+ background-color: rgba(28, 25, 23, 0.7);
14
+ backdrop-filter: blur(16px);
15
+ -webkit-backdrop-filter: blur(16px);
16
+ border: 1px solid rgba(255, 255, 255, 0.1);
17
+ }
18
+
19
+ .modal-overlay {
20
+ background-color: rgba(0, 0, 0, 0.6);
21
+ backdrop-filter: blur(8px);
22
+ -webkit-backdrop-filter: blur(8px);
23
+ }
24
+
25
+ .hidden {
26
+ display: none !important;
27
+ }
28
+
29
+ .side-panel {
30
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
31
+ }
32
+
33
+ .side-panel.left {
34
+ transform: translateX(-100%);
35
+ }
36
+
37
+ .side-panel.right {
38
+ transform: translateX(100%);
39
+ }
40
+
41
+ .side-panel.is-open {
42
+ transform: translateX(0);
43
+ }
44
+
45
+ .panel-trigger {
46
+ transition: background-color 0.2s ease;
47
+ }
48
+
49
+ .panel-trigger:hover {
50
+ background-color: rgba(59, 130, 246, 0.5);
51
+ }
52
+
53
+ .asset-card {
54
+ transition: all 0.2s ease-in-out;
55
+ border: 2px solid transparent;
56
+ }
57
+
58
+ .asset-card:hover {
59
+ transform: scale(1.03);
60
+ background-color: rgba(59, 130, 246, 0.1);
61
+ }
62
+
63
+ .asset-card.active {
64
+ background-color: rgba(59, 130, 246, 0.2);
65
+ border-color: #3b82f6;
66
+ }
67
+
68
+ .panel-content::-webkit-scrollbar {
69
+ width: 6px;
70
+ }
71
+
72
+ .panel-content::-webkit-scrollbar-track {
73
+ background: transparent;
74
+ }
75
+
76
+ .panel-content::-webkit-scrollbar-thumb {
77
+ background: rgba(255, 255, 255, 0.2);
78
+ border-radius: 10px;
79
+ }
80
+
81
+ .drop-zone {
82
+ border: 2px dashed #44403c;
83
+ transition: background-color 0.2s, border-color 0.2s;
84
+ }
85
+
86
+ .drop-zone.drag-over {
87
+ background-color: rgba(59, 130, 246, 0.15);
88
+ border-color: #3b82f6;
89
+ }
90
+
91
+ .loader-spinner,
92
+ .btn-spinner {
93
+ width: 48px;
94
+ height: 48px;
95
+ border: 4px solid #44403c;
96
+ border-top-color: #3b82f6;
97
+ border-radius: 50%;
98
+ animation: spin 1s linear infinite;
99
+ }
100
+
101
+ .notification-spinner,
102
+ .btn-spinner {
103
+ width: 20px;
104
+ height: 20px;
105
+ border: 2px solid #e7e5e4;
106
+ border-top-color: transparent;
107
+ border-radius: 50%;
108
+ animation: spin 0.8s linear infinite;
109
  }
110
 
111
+ @keyframes spin {
112
+ to {
113
+ transform: rotate(360deg);
114
+ }
115
+ }
116
+
117
+ .toggle-bg:after {
118
+ content: '';
119
+ position: absolute;
120
+ top: 2px;
121
+ left: 2px;
122
+ background: white;
123
+ border-radius: 9999px;
124
+ height: 1.25rem;
125
+ width: 1.25rem;
126
+ transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
127
+ }
128
+
129
+ input:checked+.toggle-bg {
130
+ background-color: #3b82f6;
131
+ }
132
+
133
+ input:checked+.toggle-bg:after {
134
+ transform: translateX(1.25rem);
135
+ }
136
+
137
+ input:disabled+.toggle-bg {
138
+ background-color: #44403c;
139
+ cursor: not-allowed;
140
+ }
141
+
142
+ input:disabled+.toggle-bg:after {
143
+ background-color: #57534e;
144
+ }
145
+
146
+ #main-canvas {
147
+ transition: filter 0.5s cubic-bezier(0.4, 0, 0.2, 1);
148
+ }
149
+
150
+ #main-canvas.loading {
151
+ filter: blur(8px) brightness(0.6);
152
+ }
153
+
154
+ .notification {
155
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
156
+ transform: translateY(150%) scale(0.9);
157
+ opacity: 0;
158
+ }
159
+
160
+ .notification.show {
161
+ transform: translateY(0) scale(1);
162
+ opacity: 1;
163
+ }
164
+
165
+ .control-section {
166
+ transition: opacity 0.3s ease;
167
+ }
168
+
169
+ .sliders-container,
170
+ .accordion-content,
171
+ #concave-hull-options,
172
+ #render-mask-options,
173
+ #background-ratio-container {
174
+ transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s, padding-top 0.4s, margin-top 0.4s, padding-bottom 0.4s;
175
+ overflow: hidden;
176
+ }
177
+
178
+ input[type="range"] {
179
+ -webkit-appearance: none;
180
+ appearance: none;
181
+ width: 100%;
182
+ height: 6px;
183
+ background: #44403c;
184
+ border-radius: 10px;
185
+ outline: none;
186
+ transition: background 0.3s;
187
+ padding: 10px 4px;
188
+ margin: 0;
189
+ }
190
+
191
+ input[type="range"]::-webkit-slider-thumb {
192
+ -webkit-appearance: none;
193
+ appearance: none;
194
+ width: 20px;
195
+ height: 20px;
196
+ background: #e7e5e4;
197
+ border-radius: 50%;
198
+ cursor: pointer;
199
+ border: 4px solid #3b82f6;
200
+ transition: all 0.2s ease-in-out;
201
  margin-top: 0;
202
  }
203
 
204
+ input[type="range"]:hover::-webkit-slider-thumb {
205
+ background: #fff;
206
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
207
+ }
208
+
209
+ input[type="range"]::-moz-range-thumb {
210
+ width: 14px;
211
+ height: 14px;
212
+ background: #e7e5e4;
213
+ border-radius: 50%;
214
+ cursor: pointer;
215
+ border: 4px solid #3b82f6;
216
+ }
217
+
218
+ .tooltip-trigger {
219
+ position: relative;
220
+ cursor: help;
221
+ }
222
+
223
+ .tooltip-trigger .tooltip-content {
224
+ visibility: hidden;
225
+ opacity: 0;
226
+ position: absolute;
227
+ bottom: 125%;
228
+ left: 50%;
229
+ transform: translateX(-50%);
230
+ background-color: #1c1917;
231
+ color: #e7e5e4;
232
+ text-align: center;
233
+ padding: 8px 12px;
234
+ border-radius: 6px;
235
+ z-index: 50;
236
+ width: 240px;
237
+ font-size: 0.8rem;
238
+ line-height: 1.4;
239
+ transition: opacity 0.2s;
240
+ pointer-events: none;
241
+ }
242
+
243
+ .tooltip-trigger:hover .tooltip-content {
244
+ visibility: visible;
245
+ opacity: 1;
246
+ }
247
+
248
+ .method-radio-label,
249
+ .task-radio-label {
250
+ border: 2px solid #57534e;
251
+ transition: all 0.2s ease;
252
  }
253
 
254
+ .method-radio-label:hover,
255
+ .task-radio-label:hover {
256
+ border-color: #a8a29e;
 
 
 
257
  }
258
 
259
+ input[type="radio"]:checked+.method-radio-label,
260
+ input[type="radio"]:checked+.task-radio-label {
261
+ border-color: #3b82f6;
262
+ background-color: rgba(59, 130, 246, 0.2);
263
  }
264
+
265
+ input[type=range].split-slider {
266
+ position: absolute;
267
+ background: transparent;
268
+ pointer-events: none;
269
+ }
270
+
271
+ input[type=range].split-slider::-webkit-slider-thumb {
272
+ pointer-events: auto;
273
+ }
274
+
275
+ input[type=range].split-slider::-moz-range-thumb {
276
+ pointer-events: auto;
277
+ }