Spaces:
Running
Running
Update index.html
Browse files- index.html +879 -19
index.html
CHANGED
@@ -1,19 +1,879 @@
|
|
1 |
-
<!
|
2 |
-
<html>
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Syntho-Animator Suite: Production Data Factory</title>
|
7 |
+
<!--
|
8 |
+
PRODUCTION DEPLOYMENT ADVISORY:
|
9 |
+
This project uses the Tailwind CSS CDN and ES Module imports from CDNs for ease of use
|
10 |
+
and demonstration purposes. This is ideal for development and quick testing.
|
11 |
+
|
12 |
+
For a production environment, it is STRONGLY RECOMMENDED to set up a build process:
|
13 |
+
1. Install Tailwind CSS via npm/yarn and generate a static, purged CSS file. This will
|
14 |
+
significantly reduce the CSS file size and improve load performance.
|
15 |
+
2. Bundle JavaScript modules using a tool like Vite, Webpack, or Rollup. This optimizes
|
16 |
+
dependencies and reduces the number of network requests.
|
17 |
+
|
18 |
+
See Tailwind's official guide for setup: https://tailwindcss.com/docs/installation
|
19 |
+
-->
|
20 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
21 |
+
<script src="https://unpkg.com/feather-icons"></script>
|
22 |
+
<style>
|
23 |
+
@import url('https://rsms.me/inter/inter.css');
|
24 |
+
html { font-family: 'Inter', sans-serif; }
|
25 |
+
body { background-color: #0c0a09; color: #e7e5e4; }
|
26 |
+
.glass-ui { background-color: rgba(28, 25, 23, 0.7); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.1); }
|
27 |
+
.modal-overlay { background-color: rgba(0, 0, 0, 0.6); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
28 |
+
.hidden { display: none !important; }
|
29 |
+
.side-panel { transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
|
30 |
+
.side-panel.left { transform: translateX(-100%); }
|
31 |
+
.side-panel.right { transform: translateX(100%); }
|
32 |
+
.side-panel.is-open { transform: translateX(0); }
|
33 |
+
.panel-trigger { transition: background-color 0.2s ease; }
|
34 |
+
.panel-trigger:hover { background-color: rgba(59, 130, 246, 0.5); }
|
35 |
+
.asset-card { transition: all 0.2s ease-in-out; border: 2px solid transparent; }
|
36 |
+
.asset-card:hover { transform: scale(1.03); background-color: rgba(59, 130, 246, 0.1); }
|
37 |
+
.asset-card.active { background-color: rgba(59, 130, 246, 0.2); border-color: #3b82f6; }
|
38 |
+
.panel-content::-webkit-scrollbar { width: 6px; }
|
39 |
+
.panel-content::-webkit-scrollbar-track { background: transparent; }
|
40 |
+
.panel-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 10px; }
|
41 |
+
.drop-zone { border: 2px dashed #44403c; transition: background-color 0.2s, border-color 0.2s; }
|
42 |
+
.drop-zone.drag-over { background-color: rgba(59, 130, 246, 0.15); border-color: #3b82f6; }
|
43 |
+
.loader-spinner { width: 48px; height: 48px; border: 4px solid #44403c; border-top-color: #3b82f6; border-radius: 50%; animation: spin 1s linear infinite; }
|
44 |
+
.notification-spinner { width: 20px; height: 20px; border: 2px solid #e7e5e4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
45 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
46 |
+
.toggle-bg:after { content: ''; position: absolute; top: 2px; left: 2px; background: white; border-radius: 9999px; height: 1.25rem; width: 1.25rem; transition: all .3s; }
|
47 |
+
input:checked + .toggle-bg:after { transform: translateX(100%); }
|
48 |
+
input:checked + .toggle-bg { background-color: #3b82f6; }
|
49 |
+
#main-canvas { transition: filter 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
|
50 |
+
#main-canvas.loading { filter: blur(8px) brightness(0.6); }
|
51 |
+
.notification { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transform: translateY(150%) scale(0.9); opacity: 0; }
|
52 |
+
.notification.show { transform: translateY(0) scale(1); opacity: 1; }
|
53 |
+
.control-section { transition: opacity 0.3s ease; }
|
54 |
+
.sliders-container { transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s, padding-top 0.4s, margin-top 0.4s; overflow: hidden; }
|
55 |
+
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #3b82f6; border-radius: 50%; cursor: pointer; margin-top: -6px; transition: background-color 0.2s; }
|
56 |
+
input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; background: #3b82f6; border-radius: 50%; cursor: pointer; }
|
57 |
+
input[type="range"]:hover::-webkit-slider-thumb { background: #2563eb; }
|
58 |
+
.tooltip-trigger { position: relative; cursor: help; }
|
59 |
+
.tooltip-trigger .tooltip-content { visibility: hidden; opacity: 0; position: absolute; bottom: 125%; left: 50%; transform: translateX(-50%); background-color: #1c1917; color: #e7e5e4; text-align: center; padding: 8px 12px; border-radius: 6px; z-index: 10; width: 220px; font-size: 0.8rem; line-height: 1.4; transition: opacity 0.2s; pointer-events: none; }
|
60 |
+
.tooltip-trigger:hover .tooltip-content { visibility: visible; opacity: 1; }
|
61 |
+
</style>
|
62 |
+
</head>
|
63 |
+
<body class="select-none overflow-hidden">
|
64 |
+
|
65 |
+
<canvas id="main-canvas" class="absolute top-0 left-0 w-full h-full outline-none z-0"></canvas>
|
66 |
+
|
67 |
+
<!-- OVERLAYS -->
|
68 |
+
<div id="loader-overlay" class="absolute inset-0 bg-stone-950 flex flex-col items-center justify-center z-50 transition-opacity duration-500">
|
69 |
+
<div class="loader-spinner"></div>
|
70 |
+
<p id="loader-text" class="mt-4 text-xl tracking-wider text-stone-300">Initializing...</p>
|
71 |
+
</div>
|
72 |
+
<div id="notification-container" class="fixed bottom-5 left-1/2 -translate-x-1/2 z-[60] flex flex-col items-center gap-2 w-[90vw] max-w-md"></div>
|
73 |
+
|
74 |
+
<!-- MODALS -->
|
75 |
+
<div id="dataset-modal" class="modal-overlay fixed inset-0 z-40 hidden items-center justify-center p-4">
|
76 |
+
<div class="glass-ui rounded-lg w-full max-w-2xl mx-auto flex flex-col max-h-[90vh]">
|
77 |
+
<header class="p-6 pb-4 flex-shrink-0">
|
78 |
+
<h3 class="text-2xl font-bold text-center">Generate Synthetic Dataset</h3>
|
79 |
+
</header>
|
80 |
+
|
81 |
+
<div class="overflow-y-auto px-6 pb-6 space-y-6 flex-grow">
|
82 |
+
|
83 |
+
<!-- SECTION 1: CORE SETTINGS -->
|
84 |
+
<div class="space-y-4">
|
85 |
+
<h4 class="text-lg font-semibold text-stone-300 border-b border-white/10 pb-2">1. Core Settings</h4>
|
86 |
+
<div>
|
87 |
+
<label for="dataset-task" class="block text-sm font-medium text-stone-300 mb-1">Task Type</label>
|
88 |
+
<select id="dataset-task" class="w-full bg-stone-800/70 p-2 rounded-md border-2 border-stone-600 focus:border-blue-500 outline-none">
|
89 |
+
<option value="detection">Object Detection (YOLO)</option>
|
90 |
+
<option value="segmentation">Instance Segmentation (YOLO)</option>
|
91 |
+
<option value="classification">Image Classification</option>
|
92 |
+
</select>
|
93 |
+
</div>
|
94 |
+
<div>
|
95 |
+
<label for="dataset-samples" class="block text-sm font-medium text-stone-300 mb-1">Number of Samples</label>
|
96 |
+
<input type="number" id="dataset-samples" value="100" min="1" max="10000" class="w-full bg-stone-800/70 p-2 rounded-md border-2 border-stone-600 focus:border-blue-500 outline-none">
|
97 |
+
</div>
|
98 |
+
<div>
|
99 |
+
<label for="dataset-name" class="block text-sm font-medium text-stone-300 mb-1">Dataset Name</label>
|
100 |
+
<input type="text" id="dataset-name" value="my_synthetic_dataset" class="w-full bg-stone-800/70 p-2 rounded-md border-2 border-stone-600 focus:border-blue-500 outline-none">
|
101 |
+
</div>
|
102 |
+
</div>
|
103 |
+
|
104 |
+
<!-- SECTION 2: SCENE COMPOSITION -->
|
105 |
+
<div class="space-y-4">
|
106 |
+
<h4 class="text-lg font-semibold text-stone-300 border-b border-white/10 pb-2">2. Scene Composition</h4>
|
107 |
+
|
108 |
+
<!-- MODEL CONFIGURATION -->
|
109 |
+
<div class="bg-stone-900/50 p-4 rounded-lg space-y-3">
|
110 |
+
<label class="font-medium text-stone-200 block">3D Model Source</label>
|
111 |
+
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
112 |
+
<label class="flex items-center gap-2 p-2 rounded-md bg-stone-800/50 hover:bg-stone-700/50 cursor-pointer flex-1"><input type="radio" name="model-source" value="current" class="h-4 w-4 rounded-full text-blue-500 bg-stone-700 border-stone-500 focus:ring-blue-600"> Use Current Model</label>
|
113 |
+
<label class="flex items-center gap-2 p-2 rounded-md bg-stone-800/50 hover:bg-stone-700/50 cursor-pointer flex-1"><input type="radio" name="model-source" value="random" checked class="h-4 w-4 rounded-full text-blue-500 bg-stone-700 border-stone-500 focus:ring-blue-600"> Randomize from Library</label>
|
114 |
+
</div>
|
115 |
+
<div id="model-source-current-info" class="text-sm text-blue-400 pl-2 hidden"></div>
|
116 |
+
</div>
|
117 |
+
|
118 |
+
<!-- PANORAMA CONFIGURATION -->
|
119 |
+
<div class="bg-stone-900/50 p-4 rounded-lg space-y-3">
|
120 |
+
<label class="font-medium text-stone-200 block">Background Source</label>
|
121 |
+
<div class="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
122 |
+
<label class="flex items-center gap-2 p-2 rounded-md bg-stone-800/50 hover:bg-stone-700/50 cursor-pointer flex-1"><input type="radio" name="pano-source" value="current" class="h-4 w-4 rounded-full text-blue-500 bg-stone-700 border-stone-500 focus:ring-blue-600"> Use Current Panorama</label>
|
123 |
+
<label class="flex items-center gap-2 p-2 rounded-md bg-stone-800/50 hover:bg-stone-700/50 cursor-pointer flex-1"><input type="radio" name="pano-source" value="random" checked class="h-4 w-4 rounded-full text-blue-500 bg-stone-700 border-stone-500 focus:ring-blue-600"> Randomize from Library</label>
|
124 |
+
</div>
|
125 |
+
<div id="pano-source-current-info" class="text-sm text-blue-400 pl-2 hidden"></div>
|
126 |
+
</div>
|
127 |
+
</div>
|
128 |
+
|
129 |
+
<!-- SECTION 3: RANDOMIZATION CONTROLS -->
|
130 |
+
<div class="space-y-4">
|
131 |
+
<h4 class="text-lg font-semibold text-stone-300 border-b border-white/10 pb-2">3. Randomization Controls</h4>
|
132 |
+
|
133 |
+
<!-- Model Transformation Controls -->
|
134 |
+
<div class="control-section space-y-3 bg-stone-900/50 p-4 rounded-lg">
|
135 |
+
<div class="flex items-center justify-between">
|
136 |
+
<label for="randomize-model-toggle" class="font-medium text-stone-300 select-none cursor-pointer flex items-center gap-2">Randomize Model Transform
|
137 |
+
<span class="tooltip-trigger"><i data-feather="help-circle" class="w-4 h-4 text-stone-400"></i><span class="tooltip-content">Applies random position, rotation, and scale. If 'Use Current Model' is selected, randomization is relative to its current state. Otherwise, it's fully random.</span></span>
|
138 |
+
</label>
|
139 |
+
<input type="checkbox" id="randomize-model-toggle" checked class="h-5 w-5 rounded text-blue-500 bg-stone-700 border-stone-500 focus:ring-blue-600 cursor-pointer">
|
140 |
+
</div>
|
141 |
+
<div id="model-sliders" class="sliders-container pl-4 border-l-2 border-stone-700 space-y-3">
|
142 |
+
<div>
|
143 |
+
<label for="position-variance" class="text-sm flex justify-between">Position Variance <span id="position-variance-value">90%</span></label>
|
144 |
+
<input type="range" id="position-variance" min="0" max="100" value="90" class="w-full h-2 bg-stone-700 rounded-lg appearance-none cursor-pointer">
|
145 |
+
</div>
|
146 |
+
<div>
|
147 |
+
<label for="rotation-variance" class="text-sm flex justify-between">Rotation Variance <span id="rotation-variance-value">100%</span></label>
|
148 |
+
<input type="range" id="rotation-variance" min="0" max="100" value="100" class="w-full h-2 bg-stone-700 rounded-lg appearance-none cursor-pointer">
|
149 |
+
</div>
|
150 |
+
<div>
|
151 |
+
<label for="scale-variance" class="text-sm flex justify-between">Scale Variance <span id="scale-variance-value">10%</span></label>
|
152 |
+
<input type="range" id="scale-variance" min="0" max="100" value="10" class="w-full h-2 bg-stone-700 rounded-lg appearance-none cursor-pointer">
|
153 |
+
</div>
|
154 |
+
</div>
|
155 |
+
</div>
|
156 |
+
<!-- Camera Angle Controls -->
|
157 |
+
<div id="camera-randomization-section" class="control-section space-y-3 bg-stone-900/50 p-4 rounded-lg transition-opacity duration-300">
|
158 |
+
<div class="flex items-center justify-between">
|
159 |
+
<label for="randomize-camera-toggle" class="font-medium text-stone-300 select-none cursor-pointer flex items-center gap-2">Randomize Camera Viewpoint
|
160 |
+
<span class="tooltip-trigger"><i data-feather="help-circle" class="w-4 h-4 text-stone-400"></i><span class="tooltip-content">Generate images from viewpoints similar to the current camera angle. This option is only available when 'Use Current Panorama' is selected.</span></span>
|
161 |
+
</label>
|
162 |
+
<input type="checkbox" id="randomize-camera-toggle" class="h-5 w-5 rounded text-blue-500 bg-stone-700 border-stone-500 focus:ring-blue-600 cursor-pointer">
|
163 |
+
</div>
|
164 |
+
<div id="camera-sliders" class="sliders-container pl-4 border-l-2 border-stone-700 space-y-3">
|
165 |
+
<div>
|
166 |
+
<label for="horizontal-variance" class="text-sm flex justify-between">Horizontal Variance <span id="horizontal-variance-value">20°</span></label>
|
167 |
+
<input type="range" id="horizontal-variance" min="0" max="180" value="20" class="w-full h-2 bg-stone-700 rounded-lg appearance-none cursor-pointer">
|
168 |
+
</div>
|
169 |
+
<div>
|
170 |
+
<label for="vertical-variance" class="text-sm flex justify-between">Vertical Variance <span id="vertical-variance-value">0°</span></label>
|
171 |
+
<input type="range" id="vertical-variance" min="0" max="90" value="0" class="w-full h-2 bg-stone-700 rounded-lg appearance-none cursor-pointer">
|
172 |
+
</div>
|
173 |
+
</div>
|
174 |
+
</div>
|
175 |
+
</div>
|
176 |
+
|
177 |
+
</div>
|
178 |
+
|
179 |
+
<!-- FOOTER -->
|
180 |
+
<div class="flex-shrink-0 flex justify-end gap-3 p-4 border-t border-white/10 bg-stone-900/30">
|
181 |
+
<button id="cancel-dataset" class="bg-stone-600 px-4 py-2 rounded-md font-semibold hover:bg-stone-700 transition-colors">Cancel</button>
|
182 |
+
<button id="start-dataset" class="bg-blue-600 px-4 py-2 rounded-md font-semibold hover:bg-blue-700 min-w-[80px] flex justify-center items-center transition-colors">Generate</button>
|
183 |
+
</div>
|
184 |
+
</div>
|
185 |
+
</div>
|
186 |
+
<div id="progress-container" class="modal-overlay fixed inset-0 z-40 hidden items-center justify-center p-4">
|
187 |
+
<div class="glass-ui w-full max-w-sm p-6 rounded-lg">
|
188 |
+
<h3 id="progress-title" class="text-lg font-bold mb-4">Generating Dataset</h3>
|
189 |
+
<div id="progress-label" class="mb-2 text-sm text-stone-300">Processing...</div>
|
190 |
+
<div class="w-full bg-stone-700 rounded-full h-2.5"><div id="progress-bar" class="bg-blue-600 h-2.5 rounded-full transition-all" style="width: 0%"></div></div>
|
191 |
+
</div>
|
192 |
+
</div>
|
193 |
+
<div id="upload-modal" class="modal-overlay fixed inset-0 z-40 hidden items-center justify-center p-4">
|
194 |
+
<div class="glass-ui p-6 rounded-lg w-full max-w-md mx-auto flex flex-col gap-4">
|
195 |
+
<h3 id="upload-title" class="text-2xl font-bold text-center">Upload New Asset</h3>
|
196 |
+
<div id="upload-drop-zone" class="drop-zone p-6 rounded-lg text-center cursor-pointer">
|
197 |
+
<p class="text-stone-300 pointer-events-none">Drag & Drop File</p>
|
198 |
+
<p class="text-stone-400 text-sm pointer-events-none">or click to browse</p>
|
199 |
+
<input type="file" id="upload-file-input" class="hidden">
|
200 |
+
</div>
|
201 |
+
<div class="flex items-center text-stone-400"><hr class="flex-grow border-white/10"><span class="mx-2 text-sm">OR</span><hr class="flex-grow border-white/10"></div>
|
202 |
+
<input type="text" id="upload-url-input" class="w-full bg-stone-900/50 p-2 rounded-md border-2 border-stone-600 focus:border-blue-500 outline-none" placeholder="Enter Asset URL">
|
203 |
+
<div class="flex justify-end gap-3 mt-2">
|
204 |
+
<button id="upload-cancel-btn" class="bg-stone-600 px-4 py-2 rounded-md font-semibold hover:bg-stone-700 transition-colors">Cancel</button>
|
205 |
+
<button id="upload-load-btn" class="bg-blue-600 px-4 py-2 rounded-md font-semibold hover:bg-blue-700 min-w-[80px] flex justify-center items-center transition-colors">Load</button>
|
206 |
+
</div>
|
207 |
+
</div>
|
208 |
+
</div>
|
209 |
+
|
210 |
+
<!-- MAIN UI -->
|
211 |
+
<div id="controls-bar" class="glass-ui fixed top-4 left-1/2 -translate-x-1/2 z-20 p-2 rounded-lg flex items-center gap-2">
|
212 |
+
<button id="translate-btn" class="control-btn p-2 rounded hover:bg-white/10" data-mode="translate" title="Translate (W)"><i data-feather="move"></i></button>
|
213 |
+
<button id="rotate-btn" class="control-btn p-2 rounded hover:bg-white/10" data-mode="rotate" title="Rotate (E)"><i data-feather="rotate-cw"></i></button>
|
214 |
+
<button id="scale-btn" class="control-btn p-2 rounded hover:bg-white/10" data-mode="scale" title="Scale (R)"><i data-feather="maximize"></i></button>
|
215 |
+
<div class="w-px h-6 bg-white/10 mx-1"></div>
|
216 |
+
<div class="flex items-center gap-2 px-2" title="Toggle Day/Night">
|
217 |
+
<i data-feather="sun" class="w-5 h-5 text-stone-400"></i>
|
218 |
+
<label class="relative inline-flex items-center cursor-pointer">
|
219 |
+
<input type="checkbox" id="day-night-toggle" class="sr-only peer">
|
220 |
+
<div class="w-11 h-6 bg-stone-700 rounded-full peer peer-checked:bg-blue-600 toggle-bg"></div>
|
221 |
+
</label>
|
222 |
+
<i data-feather="moon" class="w-5 h-5 text-stone-400"></i>
|
223 |
+
</div>
|
224 |
+
<div class="w-px h-6 bg-white/10 mx-1"></div>
|
225 |
+
<button id="generate-dataset-btn" class="p-2 rounded hover:bg-blue-600/50" title="Generate Dataset (G)"><i data-feather="database"></i></button>
|
226 |
+
</div>
|
227 |
+
|
228 |
+
<button id="model-panel-trigger" class="panel-trigger glass-ui absolute top-1/2 -translate-y-1/2 left-0 z-20 rounded-r-lg p-3"><i data-feather="box"></i></button>
|
229 |
+
<button id="panorama-panel-trigger" class="panel-trigger glass-ui absolute top-1/2 -translate-y-1/2 right-0 z-20 rounded-l-lg p-3"><i data-feather="image"></i></button>
|
230 |
+
|
231 |
+
<aside id="model-panel" class="side-panel left glass-ui fixed top-0 left-0 h-full w-[80vw] sm:w-80 z-30 flex flex-col">
|
232 |
+
<header class="flex-shrink-0 flex justify-between items-center p-4 border-b border-white/10">
|
233 |
+
<h2 class="text-xl font-bold">Models</h2>
|
234 |
+
<div>
|
235 |
+
<button id="add-model-btn" class="p-2 rounded-full hover:bg-blue-600/50" title="Add new model"><i data-feather="plus"></i></button>
|
236 |
+
<button id="close-model-panel" class="p-2 rounded-full hover:bg-stone-700"><i data-feather="x"></i></button>
|
237 |
+
</div>
|
238 |
+
</header>
|
239 |
+
<div id="model-selector" class="panel-content flex-grow overflow-y-auto p-4 grid grid-cols-1 md:grid-cols-2 gap-4"></div>
|
240 |
+
</aside>
|
241 |
+
|
242 |
+
<aside id="panorama-panel" class="side-panel right glass-ui fixed top-0 right-0 h-full w-[80vw] sm:w-80 z-30 flex flex-col">
|
243 |
+
<header class="flex-shrink-0 flex justify-between items-center p-4 border-b border-white/10">
|
244 |
+
<h2 class="text-xl font-bold">Panoramas</h2>
|
245 |
+
<div>
|
246 |
+
<button id="add-panorama-btn" class="p-2 rounded-full hover:bg-blue-600/50" title="Add new panorama"><i data-feather="plus"></i></button>
|
247 |
+
<button id="close-panorama-panel" class="p-2 rounded-full hover:bg-stone-700"><i data-feather="x"></i></button>
|
248 |
+
</div>
|
249 |
+
</header>
|
250 |
+
<div id="panorama-gallery" class="panel-content flex-grow overflow-y-auto p-4 grid grid-cols-1 md:grid-cols-2 gap-4"></div>
|
251 |
+
</aside>
|
252 |
+
|
253 |
+
<script type="importmap">{ "imports": {
|
254 |
+
"three": "https://unpkg.com/[email protected]/build/three.module.js",
|
255 |
+
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
|
256 |
+
} }</script>
|
257 |
+
|
258 |
+
<script type="module">
|
259 |
+
import * as THREE from 'three';
|
260 |
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
261 |
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
262 |
+
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
263 |
+
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
|
264 |
+
import JSZip from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
|
265 |
+
import gsap from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
|
266 |
+
|
267 |
+
// --- CONFIG & STATE ---
|
268 |
+
const ASSET_BASE_URL = 'https://huggingface.co/spaces/aaurelions/drones/resolve/main';
|
269 |
+
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` }));
|
270 |
+
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')}` }));
|
271 |
+
|
272 |
+
let panoramaAssets = [...defaultPanoramas];
|
273 |
+
let modelAssets = [...defaultModels];
|
274 |
+
|
275 |
+
let scene, camera, renderer, orbitControls, transformControls, pmremGenerator;
|
276 |
+
let selectedObject = null;
|
277 |
+
let activeControlMode = null;
|
278 |
+
let activePanorama = null;
|
279 |
+
|
280 |
+
const loaderOverlay = document.getElementById('loader-overlay');
|
281 |
+
const loaderText = document.getElementById('loader-text');
|
282 |
+
const canvas = document.getElementById('main-canvas');
|
283 |
+
|
284 |
+
// --- INITIALIZATION ---
|
285 |
+
async function init() {
|
286 |
+
loaderText.textContent = 'Setting up 3D scene...';
|
287 |
+
scene = new THREE.Scene();
|
288 |
+
camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 2000);
|
289 |
+
camera.position.set(0, 1.5, 8);
|
290 |
+
|
291 |
+
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
|
292 |
+
renderer.setPixelRatio(window.devicePixelRatio);
|
293 |
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
294 |
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
295 |
+
renderer.toneMappingExposure = 1.0;
|
296 |
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
297 |
+
|
298 |
+
pmremGenerator = new THREE.PMREMGenerator(renderer);
|
299 |
+
pmremGenerator.compileEquirectangularShader();
|
300 |
+
|
301 |
+
orbitControls = new OrbitControls(camera, renderer.domElement);
|
302 |
+
orbitControls.enableDamping = true;
|
303 |
+
orbitControls.target.set(0, 1, 0);
|
304 |
+
|
305 |
+
transformControls = new TransformControls(camera, renderer.domElement);
|
306 |
+
transformControls.enabled = false;
|
307 |
+
scene.add(transformControls);
|
308 |
+
|
309 |
+
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
|
310 |
+
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
311 |
+
dirLight.position.set(8, 15, 10);
|
312 |
+
scene.add(ambientLight, dirLight);
|
313 |
+
|
314 |
+
setupUI();
|
315 |
+
setupEventListeners();
|
316 |
+
animate();
|
317 |
+
|
318 |
+
// Initial asset loading sequence
|
319 |
+
try {
|
320 |
+
await loadAsset(panoramaAssets[0], 'panorama');
|
321 |
+
await loadAsset(modelAssets[0], 'model');
|
322 |
+
setControlMode('translate');
|
323 |
+
} catch (error) {
|
324 |
+
showNotification({ text: `Initialization failed: ${error.message}`, type: 'error' });
|
325 |
+
console.error("Initialization failed:", error);
|
326 |
+
} finally {
|
327 |
+
gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') });
|
328 |
+
}
|
329 |
+
}
|
330 |
+
|
331 |
+
/**
|
332 |
+
* Generic asset loading execution wrapper.
|
333 |
+
* @param {object} assetData - The asset data object {name, url, ...}.
|
334 |
+
* @param {'model'|'panorama'} assetType - The type of the asset.
|
335 |
+
*/
|
336 |
+
async function loadAsset(assetData, assetType) {
|
337 |
+
const isInitialLoad = !loaderOverlay.classList.contains('hidden');
|
338 |
+
if (!isInitialLoad) {
|
339 |
+
loaderText.textContent = `Loading ${assetType}...`;
|
340 |
+
loaderOverlay.classList.remove('hidden');
|
341 |
+
gsap.to(loaderOverlay, { opacity: 1, duration: 0.3 });
|
342 |
+
canvas.classList.add('loading');
|
343 |
+
} else {
|
344 |
+
loaderText.textContent = `Loading ${assetType}: ${assetData.name}`;
|
345 |
+
}
|
346 |
+
|
347 |
+
try {
|
348 |
+
if (assetType === 'panorama') {
|
349 |
+
await new Promise((resolve, reject) => {
|
350 |
+
const isHDR = assetData.url.endsWith('.hdr');
|
351 |
+
const loader = isHDR ? new RGBELoader() : new THREE.TextureLoader();
|
352 |
+
loader.load(assetData.url, (texture) => {
|
353 |
+
texture.mapping = THREE.EquirectangularReflectionMapping;
|
354 |
+
if (!isHDR) texture.colorSpace = THREE.SRGBColorSpace;
|
355 |
+
scene.background = texture;
|
356 |
+
scene.environment = pmremGenerator.fromEquirectangular(texture).texture;
|
357 |
+
pmremGenerator.dispose();
|
358 |
+
texture.dispose();
|
359 |
+
activePanorama = assetData;
|
360 |
+
setActiveCard('panorama-gallery', assetData.name);
|
361 |
+
resolve();
|
362 |
+
}, undefined, reject);
|
363 |
+
});
|
364 |
+
} else { // model
|
365 |
+
await new Promise((resolve, reject) => {
|
366 |
+
if (selectedObject) transformControls.detach();
|
367 |
+
|
368 |
+
const existingAsset = modelAssets.find(m => m.name === assetData.name && m.mesh);
|
369 |
+
if (existingAsset) {
|
370 |
+
modelAssets.forEach(m => { if(m.mesh) m.mesh.visible = false; });
|
371 |
+
existingAsset.mesh.visible = true;
|
372 |
+
selectedObject = existingAsset.mesh;
|
373 |
+
if (activeControlMode) transformControls.attach(selectedObject);
|
374 |
+
setActiveCard('model-selector', assetData.name);
|
375 |
+
return resolve();
|
376 |
+
}
|
377 |
+
|
378 |
+
const loader = new GLTFLoader();
|
379 |
+
loader.load(assetData.url, (gltf) => {
|
380 |
+
modelAssets.forEach(m => { if(m.mesh) m.mesh.visible = false; });
|
381 |
+
const model = gltf.scene;
|
382 |
+
model.name = assetData.name;
|
383 |
+
const box = new THREE.Box3().setFromObject(model);
|
384 |
+
const size = box.getSize(new THREE.Vector3());
|
385 |
+
const center = box.getCenter(new THREE.Vector3());
|
386 |
+
model.position.sub(center);
|
387 |
+
const maxDim = Math.max(size.x, size.y, size.z);
|
388 |
+
model.scale.setScalar(4.0 / maxDim);
|
389 |
+
scene.add(model);
|
390 |
+
|
391 |
+
const assetToUpdate = modelAssets.find(m => m.name === assetData.name);
|
392 |
+
if (assetToUpdate) assetToUpdate.mesh = model;
|
393 |
+
else modelAssets.push({ ...assetData, mesh: model });
|
394 |
+
|
395 |
+
selectedObject = model;
|
396 |
+
if (activeControlMode) transformControls.attach(selectedObject);
|
397 |
+
setActiveCard('model-selector', assetData.name);
|
398 |
+
resolve();
|
399 |
+
}, undefined, reject);
|
400 |
+
});
|
401 |
+
}
|
402 |
+
} catch (error) {
|
403 |
+
showNotification({ text: `Failed to load ${assetType}: ${assetData.name}`, type: 'error', duration: 4000 });
|
404 |
+
console.error(`Failed to load ${assetType}:`, error);
|
405 |
+
} finally {
|
406 |
+
if (!isInitialLoad) {
|
407 |
+
gsap.to(loaderOverlay, { opacity: 0, duration: 0.5, onComplete: () => loaderOverlay.classList.add('hidden') });
|
408 |
+
canvas.classList.remove('loading');
|
409 |
+
}
|
410 |
+
}
|
411 |
+
}
|
412 |
+
|
413 |
+
function createAssetCard(type, assetData) {
|
414 |
+
const container = document.createElement('div');
|
415 |
+
container.className = 'asset-card flex flex-col rounded-lg cursor-pointer overflow-hidden bg-stone-900/50';
|
416 |
+
container.dataset.name = assetData.name;
|
417 |
+
const thumbWrapper = document.createElement('div');
|
418 |
+
thumbWrapper.className = 'w-full h-28 flex items-center justify-center';
|
419 |
+
const thumb = document.createElement('img');
|
420 |
+
thumb.className = 'max-w-full max-h-full object-contain';
|
421 |
+
|
422 |
+
container.onclick = () => loadAsset(assetData, type);
|
423 |
+
|
424 |
+
if (type === 'model') {
|
425 |
+
thumbWrapper.classList.add('p-2');
|
426 |
+
thumb.src = assetData.thumb;
|
427 |
+
const nameWrapper = document.createElement('div');
|
428 |
+
nameWrapper.textContent = assetData.name.replace(/\.[^/.]+$/, "").substring(0, 15);
|
429 |
+
nameWrapper.className = 'text-white text-xs font-semibold text-center block truncate p-2 bg-black/20 mt-auto';
|
430 |
+
thumbWrapper.appendChild(thumb);
|
431 |
+
container.append(thumbWrapper, nameWrapper);
|
432 |
+
} else { // Panorama
|
433 |
+
thumb.className = 'w-full h-full object-cover';
|
434 |
+
thumb.src = assetData.thumb;
|
435 |
+
thumbWrapper.appendChild(thumb);
|
436 |
+
container.appendChild(thumbWrapper);
|
437 |
+
}
|
438 |
+
|
439 |
+
thumb.onerror = () => {
|
440 |
+
thumbWrapper.innerHTML = `<i data-feather="package" class="w-12 h-12 text-stone-500"></i>`;
|
441 |
+
feather.replace();
|
442 |
+
thumbWrapper.parentElement.classList.add('bg-stone-800');
|
443 |
+
}
|
444 |
+
document.getElementById(type === 'model' ? 'model-selector' : 'panorama-gallery').appendChild(container);
|
445 |
+
}
|
446 |
+
|
447 |
+
// --- UI & EVENT LISTENERS ---
|
448 |
+
function setupUI() {
|
449 |
+
feather.replace();
|
450 |
+
modelAssets.forEach(m => createAssetCard('model', m));
|
451 |
+
panoramaAssets.forEach(p => createAssetCard('panorama', p));
|
452 |
+
}
|
453 |
+
|
454 |
+
function setControlMode(mode) {
|
455 |
+
if (mode === activeControlMode) {
|
456 |
+
activeControlMode = null;
|
457 |
+
transformControls.detach();
|
458 |
+
transformControls.enabled = false;
|
459 |
+
} else {
|
460 |
+
activeControlMode = mode;
|
461 |
+
transformControls.setMode(activeControlMode);
|
462 |
+
transformControls.enabled = true;
|
463 |
+
if (selectedObject) transformControls.attach(selectedObject);
|
464 |
+
}
|
465 |
+
document.querySelectorAll('.control-btn').forEach(b => b.classList.toggle('bg-blue-600', b.dataset.mode === activeControlMode));
|
466 |
+
}
|
467 |
+
|
468 |
+
function setupEventListeners() {
|
469 |
+
['model', 'panorama'].forEach(type => {
|
470 |
+
document.getElementById(`${type}-panel-trigger`).addEventListener('click', e => { e.stopPropagation(); document.getElementById(`${type}-panel`).classList.add('is-open'); });
|
471 |
+
document.getElementById(`close-${type}-panel`).addEventListener('click', () => document.getElementById(`${type}-panel`).classList.remove('is-open'));
|
472 |
+
});
|
473 |
+
|
474 |
+
document.getElementById('day-night-toggle').addEventListener('change', (e) => {
|
475 |
+
const isNight = e.target.checked;
|
476 |
+
gsap.to(renderer, { toneMappingExposure: isNight ? 0.3 : 1.0, duration: 0.5 });
|
477 |
+
gsap.to(scene.children.find(c => c.isDirectionalLight), { intensity: isNight ? 0.2 : 1.0, duration: 0.5 });
|
478 |
+
});
|
479 |
+
|
480 |
+
document.querySelectorAll('.control-btn').forEach(btn => btn.addEventListener('click', () => setControlMode(btn.dataset.mode)));
|
481 |
+
transformControls.addEventListener('dragging-changed', e => { orbitControls.enabled = !e.value; });
|
482 |
+
|
483 |
+
canvas.addEventListener('click', (e) => {
|
484 |
+
if (transformControls.dragging) return;
|
485 |
+
const rect = renderer.domElement.getBoundingClientRect();
|
486 |
+
const mouse = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
487 |
+
const raycaster = new THREE.Raycaster();
|
488 |
+
raycaster.setFromCamera(mouse, camera);
|
489 |
+
const visibleMeshObjects = modelAssets.map(m => m.mesh).filter(m => m && m.visible);
|
490 |
+
if (visibleMeshObjects.length === 0) return;
|
491 |
+
const intersects = raycaster.intersectObjects(visibleMeshObjects, true);
|
492 |
+
|
493 |
+
if (intersects.length > 0) {
|
494 |
+
const object = findTopLevelGroup(intersects[0].object);
|
495 |
+
if (object && object.isGroup && object !== selectedObject) {
|
496 |
+
const modelData = modelAssets.find(m => m.name === object.name);
|
497 |
+
if (modelData) loadAsset(modelData, 'model');
|
498 |
+
}
|
499 |
+
}
|
500 |
+
});
|
501 |
+
|
502 |
+
window.addEventListener('keydown', (e) => {
|
503 |
+
if(document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'SELECT') return;
|
504 |
+
const key = e.key.toLowerCase();
|
505 |
+
if (key === 'w' || key === 'e' || key === 'r') { e.preventDefault(); setControlMode(key === 'w' ? 'translate' : key === 'e' ? 'rotate' : 'scale'); }
|
506 |
+
if (key === 'g') { e.preventDefault(); document.getElementById('generate-dataset-btn').click(); }
|
507 |
+
if (key === 'escape') { document.querySelectorAll('.side-panel.is-open').forEach(p => p.classList.remove('is-open')); }
|
508 |
+
});
|
509 |
+
|
510 |
+
setupUploadModal();
|
511 |
+
setupDatasetModal();
|
512 |
+
}
|
513 |
+
|
514 |
+
function setupUploadModal() {
|
515 |
+
const modal = document.getElementById('upload-modal');
|
516 |
+
const title = document.getElementById('upload-title');
|
517 |
+
const fileInput = document.getElementById('upload-file-input');
|
518 |
+
const urlInput = document.getElementById('upload-url-input');
|
519 |
+
const dropZone = document.getElementById('upload-drop-zone');
|
520 |
+
let currentType, currentHandler;
|
521 |
+
const openModal = (type, titleText, accept, placeholder, handler) => {
|
522 |
+
currentType = type;
|
523 |
+
title.textContent = titleText;
|
524 |
+
fileInput.value = ''; urlInput.value = '';
|
525 |
+
fileInput.accept = accept;
|
526 |
+
urlInput.placeholder = placeholder;
|
527 |
+
currentHandler = handler;
|
528 |
+
modal.classList.remove('hidden'); modal.classList.add('flex');
|
529 |
+
};
|
530 |
+
document.getElementById('add-model-btn').addEventListener('click', () => openModal('model', 'Upload New Model', '.glb,.gltf', 'Enter .glb URL', handleNewAsset));
|
531 |
+
document.getElementById('add-panorama-btn').addEventListener('click', () => openModal('panorama', 'Upload New Panorama', 'image/*,.hdr', 'Enter image URL', handleNewAsset));
|
532 |
+
document.getElementById('upload-cancel-btn').addEventListener('click', () => modal.classList.add('hidden'));
|
533 |
+
document.getElementById('upload-load-btn').addEventListener('click', () => { const url = urlInput.value.trim(); if(url) currentHandler(currentType, url); });
|
534 |
+
dropZone.addEventListener('click', () => fileInput.click());
|
535 |
+
fileInput.addEventListener('change', e => { if (e.target.files.length) currentHandler(currentType, e.target.files[0]) });
|
536 |
+
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
537 |
+
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
538 |
+
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files.length) currentHandler(currentType, e.dataTransfer.files[0]); });
|
539 |
+
}
|
540 |
+
|
541 |
+
async function handleNewAsset(type, fileOrUrl) {
|
542 |
+
document.getElementById('upload-modal').classList.add('hidden');
|
543 |
+
const isUrl = typeof fileOrUrl === 'string';
|
544 |
+
const url = isUrl ? fileOrUrl : URL.createObjectURL(fileOrUrl);
|
545 |
+
const name = (isUrl ? new URL(url).pathname.split('/').pop() : fileOrUrl.name).substring(0,25);
|
546 |
+
|
547 |
+
const newAssetData = { name, url, mesh: null };
|
548 |
+
if (type === 'model') {
|
549 |
+
newAssetData.thumb = 'placeholder'; // Placeholder, will be replaced by icon
|
550 |
+
modelAssets.push(newAssetData);
|
551 |
+
createAssetCard('model', newAssetData);
|
552 |
+
await loadAsset(newAssetData, 'model');
|
553 |
+
} else {
|
554 |
+
newAssetData.thumb = url; // For panoramas, the URL itself is the thumb
|
555 |
+
panoramaAssets.push(newAssetData);
|
556 |
+
createAssetCard('panorama', newAssetData);
|
557 |
+
await loadAsset(newAssetData, 'panorama');
|
558 |
+
}
|
559 |
+
}
|
560 |
+
|
561 |
+
function setupDatasetModal() {
|
562 |
+
const modal = document.getElementById('dataset-modal');
|
563 |
+
document.getElementById('generate-dataset-btn').addEventListener('click', () => {
|
564 |
+
modal.classList.remove('hidden');
|
565 |
+
modal.classList.add('flex');
|
566 |
+
feather.replace();
|
567 |
+
updateDatasetModalUI();
|
568 |
+
});
|
569 |
+
document.getElementById('cancel-dataset').addEventListener('click', () => modal.classList.add('hidden'));
|
570 |
+
document.getElementById('start-dataset').addEventListener('click', handleDatasetGeneration);
|
571 |
+
|
572 |
+
document.querySelectorAll('#dataset-modal input[type="radio"], #dataset-modal input[type="checkbox"]').forEach(input => {
|
573 |
+
input.addEventListener('change', updateDatasetModalUI);
|
574 |
+
});
|
575 |
+
|
576 |
+
const setupSlider = (sliderId, displayId, unit) => {
|
577 |
+
const slider = document.getElementById(sliderId);
|
578 |
+
const display = document.getElementById(displayId);
|
579 |
+
if(slider && display) slider.addEventListener('input', () => { display.textContent = slider.value + unit; });
|
580 |
+
};
|
581 |
+
setupSlider('position-variance', 'position-variance-value', '%');
|
582 |
+
setupSlider('rotation-variance', 'rotation-variance-value', '%');
|
583 |
+
setupSlider('scale-variance', 'scale-variance-value', '%');
|
584 |
+
setupSlider('horizontal-variance', 'horizontal-variance-value', '°');
|
585 |
+
setupSlider('vertical-variance', 'vertical-variance-value', '°');
|
586 |
+
}
|
587 |
+
|
588 |
+
function updateDatasetModalUI() {
|
589 |
+
// Model Source UI
|
590 |
+
const useCurrentModel = document.querySelector('input[name="model-source"]:checked').value === 'current';
|
591 |
+
const modelInfo = document.getElementById('model-source-current-info');
|
592 |
+
if (useCurrentModel) {
|
593 |
+
modelInfo.textContent = `Using: ${selectedObject ? selectedObject.name : 'None'}`;
|
594 |
+
modelInfo.classList.remove('hidden');
|
595 |
+
} else {
|
596 |
+
modelInfo.classList.add('hidden');
|
597 |
+
}
|
598 |
+
|
599 |
+
// Panorama Source UI
|
600 |
+
const useCurrentPano = document.querySelector('input[name="pano-source"]:checked').value === 'current';
|
601 |
+
const panoInfo = document.getElementById('pano-source-current-info');
|
602 |
+
if (useCurrentPano) {
|
603 |
+
panoInfo.textContent = `Using: ${activePanorama ? activePanorama.name : 'None'}`;
|
604 |
+
panoInfo.classList.remove('hidden');
|
605 |
+
} else {
|
606 |
+
panoInfo.classList.add('hidden');
|
607 |
+
}
|
608 |
+
|
609 |
+
// Model Randomization Sliders
|
610 |
+
const randomizeModel = document.getElementById('randomize-model-toggle').checked;
|
611 |
+
const modelSliders = document.getElementById('model-sliders');
|
612 |
+
gsap.to(modelSliders, {
|
613 |
+
maxHeight: randomizeModel ? 200 : 0,
|
614 |
+
opacity: randomizeModel ? 1 : 0,
|
615 |
+
paddingTop: randomizeModel ? '0.75rem' : 0,
|
616 |
+
marginTop: randomizeModel ? '0.75rem' : 0,
|
617 |
+
duration: 0.4,
|
618 |
+
ease: 'power2.inOut'
|
619 |
+
});
|
620 |
+
|
621 |
+
// Camera Randomization Controls
|
622 |
+
const cameraSection = document.getElementById('camera-randomization-section');
|
623 |
+
const cameraToggle = document.getElementById('randomize-camera-toggle');
|
624 |
+
|
625 |
+
cameraSection.style.opacity = useCurrentPano ? '1' : '0.5';
|
626 |
+
cameraToggle.disabled = !useCurrentPano;
|
627 |
+
cameraToggle.closest('label').style.cursor = useCurrentPano ? 'pointer' : 'not-allowed';
|
628 |
+
|
629 |
+
if (!useCurrentPano) cameraToggle.checked = false;
|
630 |
+
|
631 |
+
const randomizeCamera = useCurrentPano && cameraToggle.checked;
|
632 |
+
const cameraSliders = document.getElementById('camera-sliders');
|
633 |
+
gsap.to(cameraSliders, {
|
634 |
+
maxHeight: randomizeCamera ? 150 : 0,
|
635 |
+
opacity: randomizeCamera ? 1 : 0,
|
636 |
+
paddingTop: randomizeCamera ? '0.75rem' : 0,
|
637 |
+
marginTop: randomizeCamera ? '0.75rem' : 0,
|
638 |
+
duration: 0.4,
|
639 |
+
ease: 'power2.inOut'
|
640 |
+
});
|
641 |
+
}
|
642 |
+
|
643 |
+
async function handleDatasetGeneration() {
|
644 |
+
const modal = document.getElementById('dataset-modal');
|
645 |
+
const options = {
|
646 |
+
task: document.getElementById('dataset-task').value,
|
647 |
+
samples: parseInt(document.getElementById('dataset-samples').value),
|
648 |
+
name: document.getElementById('dataset-name').value.trim(),
|
649 |
+
useCurrentModel: document.querySelector('input[name="model-source"]:checked').value === 'current',
|
650 |
+
randomizeModel: document.getElementById('randomize-model-toggle').checked,
|
651 |
+
posVar: parseInt(document.getElementById('position-variance').value) / 100,
|
652 |
+
rotVar: parseInt(document.getElementById('rotation-variance').value) / 100,
|
653 |
+
scaleVar: parseInt(document.getElementById('scale-variance').value) / 100,
|
654 |
+
useCurrentPanorama: document.querySelector('input[name="pano-source"]:checked').value === 'current',
|
655 |
+
randomizeCamera: document.getElementById('randomize-camera-toggle').checked,
|
656 |
+
hVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('horizontal-variance').value)),
|
657 |
+
vVar: THREE.MathUtils.degToRad(parseInt(document.getElementById('vertical-variance').value)),
|
658 |
+
initial: {
|
659 |
+
model: {
|
660 |
+
position: selectedObject ? selectedObject.position.clone() : new THREE.Vector3(),
|
661 |
+
rotation: selectedObject ? selectedObject.rotation.clone() : new THREE.Euler(),
|
662 |
+
scale: selectedObject ? selectedObject.scale.x : 1
|
663 |
+
},
|
664 |
+
camera: { position: camera.position.clone() }
|
665 |
+
}
|
666 |
+
};
|
667 |
+
if (!options.name) { showNotification({ text: "Dataset name cannot be empty.", type: "error" }); return; }
|
668 |
+
modal.classList.add('hidden');
|
669 |
+
const progressContainer = document.getElementById('progress-container');
|
670 |
+
progressContainer.classList.remove('hidden'); progressContainer.classList.add('flex');
|
671 |
+
|
672 |
+
const zip = new JSZip();
|
673 |
+
const originalModelAsset = selectedObject ? modelAssets.find(m => m.name === selectedObject.name) : null;
|
674 |
+
const originalPanoramaAsset = activePanorama;
|
675 |
+
|
676 |
+
const transformWasVisible = transformControls.visible;
|
677 |
+
if (transformWasVisible) transformControls.visible = false;
|
678 |
+
|
679 |
+
for (let i = 0; i < options.samples; i++) {
|
680 |
+
updateProgress(i + 1, options.samples, `Generating sample ${i + 1} of ${options.samples}`);
|
681 |
+
await randomizeSceneForGeneration(options);
|
682 |
+
renderer.render(scene, camera);
|
683 |
+
const imageName = `sample_${String(i).padStart(5, '0')}.jpg`;
|
684 |
+
const labelName = imageName.replace('.jpg', '.txt');
|
685 |
+
const { imageBlob, labelData } = await generateSampleData(options.task);
|
686 |
+
|
687 |
+
if (imageBlob && selectedObject) {
|
688 |
+
const classFolder = selectedObject.visible ? selectedObject.name.replace(/\.[^/.]+$/, "") : 'background_only';
|
689 |
+
if (options.task === 'classification') {
|
690 |
+
zip.folder(options.name).folder('train').folder(classFolder).file(imageName, imageBlob);
|
691 |
+
} else {
|
692 |
+
zip.folder(options.name).folder('images').folder('train').file(imageName, imageBlob);
|
693 |
+
if (labelData) { zip.folder(options.name).folder('labels').folder('train').file(labelName, labelData); }
|
694 |
+
}
|
695 |
+
}
|
696 |
+
await new Promise(resolve => setTimeout(resolve, 5));
|
697 |
+
}
|
698 |
+
|
699 |
+
if (transformWasVisible) transformControls.visible = true;
|
700 |
+
|
701 |
+
updateProgress(options.samples, options.samples, "Compressing ZIP file...");
|
702 |
+
const content = await zip.generateAsync({ type: "blob" });
|
703 |
+
const link = document.createElement('a');
|
704 |
+
link.href = URL.createObjectURL(content);
|
705 |
+
link.download = `${options.name}.zip`;
|
706 |
+
link.click();
|
707 |
+
URL.revokeObjectURL(link.href);
|
708 |
+
progressContainer.classList.add('hidden');
|
709 |
+
showNotification({ text: 'Dataset generation complete!', type: 'success' });
|
710 |
+
|
711 |
+
// Restore original scene
|
712 |
+
if (originalPanoramaAsset) await loadAsset(originalPanoramaAsset, 'panorama');
|
713 |
+
if (originalModelAsset) await loadAsset(originalModelAsset, 'model');
|
714 |
+
}
|
715 |
+
|
716 |
+
async function randomizeSceneForGeneration(options) {
|
717 |
+
// Select and load assets
|
718 |
+
if (!options.useCurrentPanorama) {
|
719 |
+
const randomPano = panoramaAssets[Math.floor(Math.random() * panoramaAssets.length)];
|
720 |
+
await loadAsset(randomPano, 'panorama');
|
721 |
+
}
|
722 |
+
if (!options.useCurrentModel) {
|
723 |
+
const randomModel = modelAssets[Math.floor(Math.random() * modelAssets.length)];
|
724 |
+
await loadAsset(randomModel, 'model');
|
725 |
+
}
|
726 |
+
|
727 |
+
if (!selectedObject) return;
|
728 |
+
|
729 |
+
// Handle classification case with background-only images
|
730 |
+
selectedObject.visible = !(options.task === 'classification' && Math.random() < 0.2); // 20% chance of background only
|
731 |
+
if (!selectedObject.visible) return;
|
732 |
+
|
733 |
+
// Randomize Model
|
734 |
+
if (options.randomizeModel) {
|
735 |
+
if(options.useCurrentModel) { // Relative randomization
|
736 |
+
const { position: basePos, rotation: baseRot, scale: baseScale } = options.initial.model;
|
737 |
+
selectedObject.position.copy(basePos).add(new THREE.Vector3().randomDirection().multiplyScalar(Math.random() * options.posVar * 5));
|
738 |
+
|
739 |
+
const baseQuaternion = new THREE.Quaternion().setFromEuler(baseRot);
|
740 |
+
const rotOffset = new THREE.Euler(
|
741 |
+
(Math.random() - 0.5) * 2 * Math.PI * options.rotVar,
|
742 |
+
(Math.random() - 0.5) * 2 * Math.PI * options.rotVar,
|
743 |
+
(Math.random() - 0.5) * 2 * Math.PI * options.rotVar
|
744 |
+
);
|
745 |
+
const offsetQuaternion = new THREE.Quaternion().setFromEuler(rotOffset);
|
746 |
+
baseQuaternion.multiply(offsetQuaternion);
|
747 |
+
selectedObject.quaternion.copy(baseQuaternion);
|
748 |
+
|
749 |
+
const scaleFactor = 1 + (Math.random() - 0.5) * 2 * options.scaleVar;
|
750 |
+
selectedObject.scale.setScalar(baseScale * scaleFactor);
|
751 |
+
} else { // Absolute randomization
|
752 |
+
selectedObject.position.set((Math.random() - 0.5) * 10 * options.posVar, Math.random() * 5 * options.posVar, (Math.random() - 0.5) * 10 * options.posVar);
|
753 |
+
selectedObject.rotation.set(Math.random() * Math.PI * 2 * options.rotVar, Math.random() * Math.PI * 2 * options.rotVar, Math.random() * Math.PI * 2 * options.rotVar);
|
754 |
+
const baseScale = selectedObject.scale.x; // Use its default scale as a base
|
755 |
+
const scaleFactor = 0.5 + Math.random() * 1.5 * (1 + options.scaleVar);
|
756 |
+
selectedObject.scale.setScalar(baseScale * scaleFactor);
|
757 |
+
}
|
758 |
+
}
|
759 |
+
|
760 |
+
// Randomize Camera
|
761 |
+
if (options.useCurrentPanorama && options.randomizeCamera) { // Relative randomization
|
762 |
+
const baseCamPos = options.initial.camera.position;
|
763 |
+
const spherical = new THREE.Spherical().setFromCartesianCoords(baseCamPos.x, baseCamPos.y, baseCamPos.z);
|
764 |
+
spherical.theta += (Math.random() - 0.5) * 2 * options.hVar;
|
765 |
+
spherical.phi += (Math.random() - 0.5) * 2 * options.vVar;
|
766 |
+
spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi));
|
767 |
+
camera.position.setFromSpherical(spherical);
|
768 |
+
} else if (!options.useCurrentPanorama) { // Absolute randomization
|
769 |
+
camera.position.set((Math.random() - 0.5) * 20, Math.random() * 8 + 1, (Math.random() - 0.5) * 15 + 5);
|
770 |
+
}
|
771 |
+
camera.lookAt(selectedObject.position);
|
772 |
+
selectedObject.updateMatrixWorld(true);
|
773 |
+
}
|
774 |
+
|
775 |
+
async function generateSampleData(task) {
|
776 |
+
let imageBlob = null, labelData = null;
|
777 |
+
if (task === 'segmentation') {
|
778 |
+
const { colorBlob, segLabel } = await getSegmentationLabelAndImage();
|
779 |
+
imageBlob = colorBlob; labelData = segLabel;
|
780 |
+
} else {
|
781 |
+
imageBlob = await getCanvasBlob();
|
782 |
+
if (task === 'detection') { labelData = getDetectionLabel(); }
|
783 |
+
}
|
784 |
+
return { imageBlob, labelData };
|
785 |
+
}
|
786 |
+
|
787 |
+
function getDetectionLabel() {
|
788 |
+
if (!selectedObject || !selectedObject.visible) return null;
|
789 |
+
const box3 = new THREE.Box3().setFromObject(selectedObject);
|
790 |
+
if (box3.isEmpty()) return null;
|
791 |
+
const corners = [ new THREE.Vector3(box3.min.x, box3.min.y, box3.min.z), new THREE.Vector3(box3.min.x, box3.min.y, box3.max.z), new THREE.Vector3(box3.min.x, box3.max.y, box3.min.z), new THREE.Vector3(box3.min.x, box3.max.y, box3.max.z), new THREE.Vector3(box3.max.x, box3.min.y, box3.min.z), new THREE.Vector3(box3.max.x, box3.min.y, box3.max.z), new THREE.Vector3(box3.max.x, box3.max.y, box3.min.z), new THREE.Vector3(box3.max.x, box3.max.y, box3.max.z) ];
|
792 |
+
let minX = 1, maxX = -1, minY = 1, maxY = -1;
|
793 |
+
let visibleCorners = 0;
|
794 |
+
corners.forEach(corner => {
|
795 |
+
corner.project(camera);
|
796 |
+
if (corner.z < 1) { // Check if the corner is in front of the camera's near plane
|
797 |
+
minX = Math.min(minX, corner.x); maxX = Math.max(maxX, corner.x);
|
798 |
+
minY = Math.min(minY, corner.y); maxY = Math.max(maxY, corner.y);
|
799 |
+
visibleCorners++;
|
800 |
+
}
|
801 |
+
});
|
802 |
+
if (visibleCorners === 0 || maxX < -1 || minX > 1 || maxY < -1 || minY > 1) return null;
|
803 |
+
minX = Math.max(-1, minX); maxX = Math.min(1, maxX); minY = Math.max(-1, minY); maxY = Math.min(1, maxY);
|
804 |
+
const normCenterX = ((minX + maxX) / 2 + 1) / 2; const normCenterY = (-(minY + maxY) / 2 + 1) / 2;
|
805 |
+
const normWidth = (maxX - minX) / 2; const normHeight = (maxY - minY) / 2;
|
806 |
+
if (normWidth < 0.01 || normHeight < 0.01) return null; // Filter out tiny boxes
|
807 |
+
return `0 ${normCenterX.toFixed(6)} ${normCenterY.toFixed(6)} ${normWidth.toFixed(6)} ${normHeight.toFixed(6)}`;
|
808 |
+
}
|
809 |
+
|
810 |
+
async function getSegmentationLabelAndImage() {
|
811 |
+
if (!selectedObject || !selectedObject.visible) {
|
812 |
+
const colorBlob = await getCanvasBlob();
|
813 |
+
return { colorBlob, segLabel: null };
|
814 |
+
}
|
815 |
+
const originalBackground = scene.background;
|
816 |
+
renderer.render(scene, camera);
|
817 |
+
const colorBlob = await getCanvasBlob();
|
818 |
+
const maskMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
819 |
+
const originalMaterials = new Map();
|
820 |
+
selectedObject.traverse(node => { if (node.isMesh) { originalMaterials.set(node, node.material); node.material = maskMaterial; } });
|
821 |
+
scene.background = new THREE.Color(0x000000);
|
822 |
+
renderer.render(scene, camera);
|
823 |
+
const maskCanvas = document.createElement('canvas');
|
824 |
+
maskCanvas.width = renderer.domElement.width; maskCanvas.height = renderer.domElement.height;
|
825 |
+
const maskCtx = maskCanvas.getContext('2d');
|
826 |
+
maskCtx.drawImage(renderer.domElement, 0, 0);
|
827 |
+
const imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
828 |
+
scene.background = originalBackground;
|
829 |
+
selectedObject.traverse(node => { if (node.isMesh && originalMaterials.has(node)) { node.material = originalMaterials.get(node); } });
|
830 |
+
const contours = findContours(imageData);
|
831 |
+
if (contours.length === 0) return { colorBlob, segLabel: null };
|
832 |
+
contours.sort((a, b) => b.length - a.length);
|
833 |
+
const simplificationRate = Math.max(1, Math.floor(contours[0].length / 100)); // Simplify based on point count
|
834 |
+
const simplifiedContour = contours[0].filter((_, i) => i % simplificationRate === 0);
|
835 |
+
if (simplifiedContour.length < 3) return { colorBlob, segLabel: null };
|
836 |
+
const yoloPoints = simplifiedContour.map(p => `${(p.x/maskCanvas.width).toFixed(6)} ${(p.y/maskCanvas.height).toFixed(6)}`).join(' ');
|
837 |
+
return { colorBlob, segLabel: `0 ${yoloPoints}` };
|
838 |
+
}
|
839 |
+
|
840 |
+
// --- UTILS ---
|
841 |
+
function findTopLevelGroup(object) { let current = object; while (current.parent && current.parent !== scene) { current = current.parent; } return current; }
|
842 |
+
function findContours(imageData) { const { data, width, height } = imageData; const points = []; const step = 4; for (let y = 0; y < height; y+=step) { for (let x = 0; x < width; x+=step) { const i = (y * width + x) * 4; if (data[i] > 128) { const isEdge = x <= step || x >= width - step || y <= step || y >= height - step || data[i - (step*4)] === 0 || data[i + (step*4)] === 0 || data[i - width * (step*4)] === 0 || data[i + width * (step*4)] === 0; if (isEdge) points.push({ x, y }); } } } return points.length > 0 ? [points] : []; }
|
843 |
+
function getCanvasBlob() { return new Promise(resolve => renderer.domElement.toBlob(resolve, 'image/jpeg')); }
|
844 |
+
function updateProgress(current, total, text) { document.getElementById('progress-bar').style.width = `${total > 0 ? (current / total) * 100 : 0}%`; document.getElementById('progress-label').textContent = text; }
|
845 |
+
function setActiveCard(containerId, name) { document.querySelectorAll(`#${containerId} .asset-card`).forEach(c => c.classList.toggle('active', c.dataset.name === name)); }
|
846 |
+
|
847 |
+
function showNotification({ text, type = 'success', duration = 3000, id = null }) {
|
848 |
+
const container = document.getElementById('notification-container');
|
849 |
+
if (id) { const existing = document.getElementById(id); if (existing) { existing.querySelector('span').textContent = text; return; } }
|
850 |
+
const el = document.createElement('div');
|
851 |
+
el.id = id || `notif-${Date.now()}`;
|
852 |
+
el.className = `notification glass-ui p-3 px-4 rounded-lg flex items-center gap-3`;
|
853 |
+
const colors = { success: 'bg-green-500/50', error: 'bg-red-500/50', info: 'bg-blue-500/50', loading: 'bg-stone-500/50' };
|
854 |
+
const icons = { success: 'check-circle', error: 'alert-triangle', info: 'info', loading: null };
|
855 |
+
el.classList.add(colors[type]);
|
856 |
+
let iconHtml = type === 'loading' ? `<div class="notification-spinner"></div>` : `<i data-feather="${icons[type]}" class="w-5 h-5"></i>`;
|
857 |
+
el.innerHTML = `${iconHtml}<span>${text}</span>`;
|
858 |
+
container.appendChild(el);
|
859 |
+
if (icons[type]) feather.replace();
|
860 |
+
setTimeout(() => el.classList.add('show'), 10);
|
861 |
+
if (type !== 'loading') { setTimeout(() => hideNotification(el.id), duration); }
|
862 |
+
}
|
863 |
+
|
864 |
+
function hideNotification(id) {
|
865 |
+
const el = document.getElementById(id);
|
866 |
+
if (el) { el.classList.remove('show'); setTimeout(() => el.remove(), 500); }
|
867 |
+
}
|
868 |
+
|
869 |
+
function animate() {
|
870 |
+
requestAnimationFrame(animate);
|
871 |
+
orbitControls.update();
|
872 |
+
renderer.render(scene, camera);
|
873 |
+
}
|
874 |
+
window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
|
875 |
+
|
876 |
+
init();
|
877 |
+
</script>
|
878 |
+
</body>
|
879 |
+
</html>
|