LogicalTrue commited on
Commit
b787014
·
0 Parent(s):

Initial deploy

Browse files
.agents/AGENTS.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Instrucciones del Proyecto: 23DFactory
2
+
3
+ Este archivo ayuda a que los agentes de IA comprendan el contexto del proyecto y el flujo de trabajo actual de inmediato al iniciar el espacio de trabajo.
4
+
5
+ ## 📋 Contexto General
6
+ * **Nombre:** 23DFactory
7
+ * **Propósito:** Herramienta web local para transformar imágenes 2D a modelos 3D usando modelos de IA (principalmente Hugging Face Spaces como TRELLIS).
8
+
9
+ ## 🛠️ Arquitectura y Configuración
10
+ * **Frontend:** Desarrollado en HTML/CSS/JS nativos (`index.html`, `styles.css`, `app.js`).
11
+ * **Backend:** Servidor en Python (`server.py`) que cumple doble función:
12
+ 1. Servir los archivos estáticos en el puerto configurado (ej. `8000`).
13
+ 2. Actuar como proxy HTTP para evitar bloqueos de CORS y resolver problemas de DNS.
14
+ * **Endpoints del Proxy backend:**
15
+ * `POST /api/generate-2d`: Envía la petición de imagen 2D a la API de inferencia de Hugging Face usando el dominio corto y compatible `api-inference.hf.co` (útil si el dominio largo está bloqueado por el DNS).
16
+ * `POST /api/generate-3d`: Se conecta al Space privado de Hugging Face (por defecto `LogicalTrue/TRELLIS.2`) usando `gradio_client` para la generación del modelo 3D.
17
+ * **Archivos clave:**
18
+ * `server.py`: Servidor HTTP y proxy.
19
+ * `app.js`: Lógica del cliente. Cuenta con soporte automático para resolver las peticiones de forma absoluta a `http://localhost:8000` en caso de que el usuario abra el archivo localmente como `file://`.
20
+ * `.env`: Define `HF_TOKEN`, `HF_SPACE` y `PORT`.
21
+
22
+ ## 🚀 Cómo Iniciar el Proyecto (En PC Personal)
23
+ 1. Abrir la terminal en la raíz del proyecto.
24
+ 2. Ejecutar el servidor Python:
25
+ ```bash
26
+ python server.py
27
+ ```
28
+ 3. Abrir la aplicación en el navegador accediendo a: **http://localhost:8000**
29
+ 4. (Opcional) Si se abre directamente el archivo `index.html` (protocolo `file://`), la aplicación redirigirá las peticiones de API de forma absoluta al puerto `8000` local.
30
+
31
+ ## 🎨 Flujo de Trabajo en Blender (Siguiente Paso)
32
+ El usuario cuenta con **Blender** instalado y realiza el siguiente tratamiento a los modelos `.glb` generados:
33
+ 1. Importación del `.glb` generado (ubicado en `generated_models/`).
34
+ 2. Limpieza básica en Edit Mode (`Merge by Distance` para eliminar vértices duplicados y `Clean Up` para borrar geometría suelta).
35
+ 3. Reducción de polígonos usando el modificador `Decimate`.
36
+ 4. Re-topología (Voxel/QuadriFlow Remesh) para conseguir bucles de animación limpios.
37
+ 5. Esculpido y suavizado de ruido en Sculpt Mode con el pincel `Smooth`.
.agents/skills/gradio_space_integration/SKILL.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: gradio_space_integration
3
+ description: Guía de integración y depuración para conectar con Hugging Face Spaces y ZeroGPU mediante gradio_client de Python, evitando errores de validación de esquemas (Pydantic), CORS de navegador y fallos de limpieza de sesión.
4
+ ---
5
+
6
+ # Integración con Hugging Face Spaces vía Gradio Client
7
+
8
+ Este skill provee guías de implementación para conectar de forma robusta con Hugging Face Spaces, especialmente aquellos que utilizan ZeroGPU.
9
+
10
+ ## 1. Conexión y Autenticación en Python
11
+ * El constructor de `Client` en el SDK de Python utiliza el parámetro `token` (mientras que en JS es `hf_token`).
12
+ * Los Spaces privados o basados en ZeroGPU requieren autenticación para asignación de cuota de GPU.
13
+ ```python
14
+ from gradio_client import Client
15
+ client = Client("LogicalTrue/TRELLIS.2", token=HF_TOKEN)
16
+ ```
17
+
18
+ ## 2. Inicialización de Sesiones (Evitar FileNotFoundError en end_session)
19
+ * Muchos Spaces oficiales (como Trellis.2) ejecutan un hook de limpieza al cerrar el socket (`shutil.rmtree`). Si la sesión no se inicializa, las carpetas no existen y el servidor remoto crashea devolviendo un error 500.
20
+ * **Solución**: Ejecutar `/start_session` inmediatamente tras conectar:
21
+ ```python
22
+ client.predict(api_name="/start_session")
23
+ ```
24
+
25
+ ## 3. Validación de Esquemas (Evitar ValidationError de Pydantic)
26
+ * Los componentes de tipo `Image` en los Spaces modernos (Gradio 4+) validan las entradas usando Pydantic. Enviar un string con la ruta local (`/tmp/image.png`) causará una falla de tipo de datos.
27
+ * **Solución**: Importar y envolver todas las rutas de archivos con `handle_file`:
28
+ ```python
29
+ from gradio_client import handle_file
30
+
31
+ # Enviar al preprocesador
32
+ preprocess_result = client.predict(handle_file(local_path), api_name="/preprocess_image")
33
+
34
+ # Enviar al modelo principal (los resultados devueltos son strings, hay que envolverlos de nuevo)
35
+ job = client.submit(handle_file(preprocess_result), seed, ..., api_name="/image_to_3d")
36
+ ```
37
+
38
+ ## 4. Arquitectura de Proxy Local para Evitar CORS
39
+ * Los navegadores bloquean peticiones HTTP o WebSockets directos a Hugging Face debido a políticas de origen cruzado (CORS).
40
+ * **Solución**: Utilizar un servidor backend local (en Python) que procese las llamadas usando `gradio_client`, guarde los resultados `.glb` en un directorio estático local (p. ej., `generated_models/`) y devuelva rutas relativas que el navegador pueda consumir directamente.
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ scratch/
2
+ generated_models/
3
+ generated_images/
4
+ .env
5
+ __pycache__/
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Usar una imagen base de Python con soporte para Debian/Ubuntu
2
+ FROM python:3.10-slim
3
+
4
+ # Instalar dependencias necesarias para Blender y X11 (para que corra en modo background sin pantalla)
5
+ RUN apt-get update && apt-get install -y \
6
+ curl \
7
+ libxrender1 \
8
+ libxi6 \
9
+ libgconf-2-4 \
10
+ libxxf86vm1 \
11
+ libfontconfig1 \
12
+ libgl1-mesa-glx \
13
+ libglib2.0-0 \
14
+ xz-utils \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Descargar e Instalar Blender (versión recomendada 3.6 LTS o superior)
18
+ RUN curl -L "https://download.blender.org/release/Blender3.6/blender-3.6.13-linux-x64.tar.xz" -o blender.tar.xz \
19
+ && tar -xf blender.tar.xz -C /opt \
20
+ && mv /opt/blender-3.6.13-linux-x64 /opt/blender \
21
+ && ln -s /opt/blender/blender /usr/local/bin/blender \
22
+ && rm blender.tar.xz
23
+
24
+ # Crear directorio de trabajo
25
+ WORKDIR /app
26
+
27
+ # Copiar dependencias de Python e instalarlas
28
+ COPY requirements.txt .
29
+ RUN pip install --no-cache-dir -r requirements.txt
30
+
31
+ # Copiar el código del proyecto al contenedor
32
+ COPY . .
33
+
34
+ # Crear carpetas temporales y de almacenamiento con permisos correctos
35
+ RUN mkdir -p generated_images generated_models && chmod -R 777 /app
36
+
37
+ # Indicar que usaremos el puerto de Hugging Face
38
+ EXPOSE 7860
39
+
40
+ # Comando para arrancar el servidor
41
+ CMD ["python", "server.py"]
app.js ADDED
@@ -0,0 +1,927 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@latest/dist/index.js";
2
+
3
+ // DOM Elements
4
+ const openSettingsBtn = document.getElementById('openSettingsBtn');
5
+ const closeSettingsBtn = document.getElementById('closeSettingsBtn');
6
+ const settingsModal = document.getElementById('settingsModal');
7
+ const saveSettingsBtn = document.getElementById('saveSettingsBtn');
8
+ const hfTokenInput = document.getElementById('hfTokenInput');
9
+
10
+ const tabBtns = document.querySelectorAll('.tab-btn');
11
+ const tabContents = document.querySelectorAll('.tab-content');
12
+
13
+ const dropzone = document.getElementById('dropzone');
14
+ const fileInput = document.getElementById('fileInput');
15
+ const previewContainer = document.getElementById('previewContainer');
16
+ const imagePreview = document.getElementById('imagePreview');
17
+ const removePreviewBtn = document.getElementById('removePreviewBtn');
18
+
19
+ // Source selector elements
20
+ const sourceUploadBtn = document.getElementById('sourceUploadBtn');
21
+ const sourceLibraryBtn = document.getElementById('sourceLibraryBtn');
22
+ const uploadSourceContainer = document.getElementById('uploadSourceContainer');
23
+ const librarySourceContainer = document.getElementById('librarySourceContainer');
24
+ const libraryThumbnailGrid = document.getElementById('libraryThumbnailGrid');
25
+
26
+ const promptInput = document.getElementById('promptInput');
27
+ const negativePromptInput = document.getElementById('negativePromptInput');
28
+ const generatorModelSelect = document.getElementById('generatorModelSelect');
29
+ const generate2dBtn = document.getElementById('generate2dBtn');
30
+
31
+ const generate3dBtn = document.getElementById('generate3dBtn');
32
+ const statusOverlay = document.getElementById('statusOverlay');
33
+ const loaderPercentage = document.getElementById('loaderPercentage');
34
+ const statusTitle = document.getElementById('statusTitle');
35
+ const statusDesc = document.getElementById('statusDesc');
36
+
37
+ const viewerDisplay = document.getElementById('viewerDisplay');
38
+ const emptyState = document.getElementById('emptyState');
39
+ const viewerOverlay = document.getElementById('viewerOverlay');
40
+ const toggleAutoRotateBtn = document.getElementById('toggleAutoRotate');
41
+ const resetCameraBtn = document.getElementById('resetCameraBtn');
42
+ const downloadGlbBtn = document.getElementById('downloadGlbBtn');
43
+ const downloadFbxBtn = document.getElementById('downloadFbxBtn');
44
+ const rigModelBtn = document.getElementById('rigModelBtn');
45
+
46
+ // Trellis Parameters
47
+ const trellisVersionSelect = document.getElementById('trellisVersionSelect');
48
+ const resolutionSelect = document.getElementById('resolutionSelect');
49
+ const seedInput = document.getElementById('seedInput');
50
+ const randomSeedCheck = document.getElementById('randomSeedCheck');
51
+ const decimateInput = document.getElementById('decimateInput');
52
+ const decimateVal = document.getElementById('decimateVal');
53
+ const textureSizeSelect = document.getElementById('textureSizeSelect');
54
+ const ssGuidanceInput = document.getElementById('ssGuidanceInput');
55
+ const ssGuidanceVal = document.getElementById('ssGuidanceVal');
56
+ const ssStepsInput = document.getElementById('ssStepsInput');
57
+ const ssStepsVal = document.getElementById('ssStepsVal');
58
+ const slatGuidanceInput = document.getElementById('slatGuidanceInput');
59
+ const slatGuidanceVal = document.getElementById('slatGuidanceVal');
60
+ const slatStepsInput = document.getElementById('slatStepsInput');
61
+ const slatStepsVal = document.getElementById('slatStepsVal');
62
+ const autoOptimizeCheck = document.getElementById('autoOptimizeCheck');
63
+ const quadFacesContainer = document.getElementById('quadFacesContainer');
64
+ const quadFacesInput = document.getElementById('quadFacesInput');
65
+ const quadFacesVal = document.getElementById('quadFacesVal');
66
+ const remeshMethodSelect = document.getElementById('remeshMethodSelect');
67
+ const rigMethodSelect = document.getElementById('rigMethodSelect');
68
+
69
+ // App State
70
+ let active2dImageBlob = null;
71
+ let active2dImageURL = null;
72
+ let currentGltfUrl = null;
73
+ let activeModelRelativeUrl = null;
74
+ let hfToken = localStorage.getItem('hf_token') || '';
75
+
76
+ const apiBase = window.location.protocol === 'file:' ? 'http://localhost:8000' : '';
77
+
78
+ // Load Settings on Boot
79
+ if (hfToken) {
80
+ hfTokenInput.value = hfToken;
81
+ }
82
+
83
+ // Slider live value update helper
84
+ function setupSliderValueUpdate(slider, labelEl) {
85
+ slider.addEventListener('input', (e) => {
86
+ labelEl.textContent = e.target.value;
87
+ });
88
+ }
89
+ setupSliderValueUpdate(decimateInput, decimateVal);
90
+ setupSliderValueUpdate(ssGuidanceInput, ssGuidanceVal);
91
+ setupSliderValueUpdate(ssStepsInput, ssStepsVal);
92
+ setupSliderValueUpdate(slatGuidanceInput, slatGuidanceVal);
93
+ setupSliderValueUpdate(slatStepsInput, slatStepsVal);
94
+ if (quadFacesInput && quadFacesVal) {
95
+ setupSliderValueUpdate(quadFacesInput, quadFacesVal);
96
+ }
97
+
98
+ // Toggle visibility of the Blender quad faces slider based on checkbox state
99
+ if (autoOptimizeCheck && quadFacesContainer) {
100
+ autoOptimizeCheck.addEventListener('change', () => {
101
+ quadFacesContainer.style.display = autoOptimizeCheck.checked ? 'block' : 'none';
102
+ });
103
+ }
104
+
105
+ // Settings Modal Handlers
106
+ openSettingsBtn.addEventListener('click', () => {
107
+ settingsModal.classList.add('active');
108
+ });
109
+
110
+ closeSettingsBtn.addEventListener('click', () => {
111
+ settingsModal.classList.remove('active');
112
+ });
113
+
114
+ saveSettingsBtn.addEventListener('click', () => {
115
+ hfToken = hfTokenInput.value.trim();
116
+ localStorage.setItem('hf_token', hfToken);
117
+ settingsModal.classList.remove('active');
118
+
119
+ // Feedback animation
120
+ const span = saveSettingsBtn.querySelector('span');
121
+ const originalText = span.textContent;
122
+ span.textContent = '¡Guardado!';
123
+ setTimeout(() => {
124
+ span.textContent = originalText;
125
+ }, 1500);
126
+ });
127
+
128
+ // Tab Switching
129
+ tabBtns.forEach(btn => {
130
+ btn.addEventListener('click', () => {
131
+ const targetTab = btn.getAttribute('data-tab');
132
+
133
+ tabBtns.forEach(b => b.classList.remove('active'));
134
+ tabContents.forEach(c => c.classList.remove('active'));
135
+
136
+ btn.classList.add('active');
137
+ document.getElementById(targetTab).classList.add('active');
138
+
139
+ // Toggle visibility of the Main 3D Build Button at the bottom
140
+ if (targetTab === 'upload-tab') {
141
+ generate3dBtn.style.display = 'flex';
142
+ } else {
143
+ generate3dBtn.style.display = 'none';
144
+ }
145
+ });
146
+ });
147
+
148
+ // Image Source Selection Toggles
149
+ if (sourceUploadBtn && sourceLibraryBtn) {
150
+ sourceUploadBtn.addEventListener('click', () => {
151
+ sourceUploadBtn.classList.add('active');
152
+ sourceLibraryBtn.classList.remove('active');
153
+ uploadSourceContainer.style.display = 'block';
154
+ librarySourceContainer.style.display = 'none';
155
+ dropzone.style.display = active2dImageBlob ? 'none' : 'flex';
156
+ });
157
+
158
+ sourceLibraryBtn.addEventListener('click', () => {
159
+ sourceUploadBtn.classList.remove('active');
160
+ sourceLibraryBtn.classList.add('active');
161
+ uploadSourceContainer.style.display = 'none';
162
+ librarySourceContainer.style.display = 'block';
163
+ refreshLibraryThumbnails();
164
+ });
165
+ }
166
+
167
+ let cachedGalleryItems = [];
168
+
169
+ async function refreshLibraryThumbnails() {
170
+ try {
171
+ const response = await fetch(`${apiBase}/api/gallery`);
172
+ if (!response.ok) throw new Error('Error al cargar la galería.');
173
+ const data = await response.json();
174
+ cachedGalleryItems = data.items || [];
175
+ renderLibraryThumbnails(cachedGalleryItems);
176
+ } catch (e) {
177
+ console.error('Error loading library thumbnails:', e);
178
+ }
179
+ }
180
+
181
+ function renderLibraryThumbnails(items) {
182
+ if (!libraryThumbnailGrid) return;
183
+
184
+ // Filter only 2D images
185
+ const images = items.filter(item => item.type === 'image');
186
+
187
+ if (images.length === 0) {
188
+ libraryThumbnailGrid.innerHTML = `
189
+ <div class="library-thumb-empty">
190
+ No hay imágenes 2D en la biblioteca. Genera una primero en "Generar 2D AI".
191
+ </div>
192
+ `;
193
+ return;
194
+ }
195
+
196
+ libraryThumbnailGrid.innerHTML = '';
197
+ images.forEach(img => {
198
+ const thumb = document.createElement('div');
199
+ thumb.className = 'library-thumb-item';
200
+ thumb.title = img.name;
201
+ thumb.dataset.filename = img.name;
202
+ thumb.dataset.url = img.url;
203
+
204
+ // Determine if this is the active preview
205
+ const isCurrentActive = active2dImageBlob && active2dImageBlob.name === img.name;
206
+ if (isCurrentActive) {
207
+ thumb.classList.add('active');
208
+ }
209
+
210
+ thumb.innerHTML = `<img src="${apiBase}${img.url}" alt="${img.name}">`;
211
+
212
+ thumb.addEventListener('click', async () => {
213
+ document.querySelectorAll('.library-thumb-item').forEach(el => el.classList.remove('active'));
214
+ thumb.classList.add('active');
215
+
216
+ try {
217
+ const res = await fetch(`${apiBase}${img.url}`);
218
+ const blob = await res.blob();
219
+ const file = new File([blob], img.name, { type: blob.type });
220
+ handleImageFile(file);
221
+ } catch (error) {
222
+ console.error('Error setting library image:', error);
223
+ }
224
+ });
225
+
226
+ libraryThumbnailGrid.appendChild(thumb);
227
+ });
228
+ }
229
+
230
+ // 2D Image Upload Handlers
231
+ function handleImageFile(file) {
232
+ if (!file || !file.type.startsWith('image/')) return;
233
+
234
+ active2dImageBlob = file;
235
+
236
+ if (active2dImageURL) {
237
+ URL.revokeObjectURL(active2dImageURL);
238
+ }
239
+
240
+ active2dImageURL = URL.createObjectURL(file);
241
+ imagePreview.src = active2dImageURL;
242
+
243
+ dropzone.style.display = 'none';
244
+ previewContainer.style.display = 'block';
245
+ generate3dBtn.disabled = false;
246
+ }
247
+
248
+ dropzone.addEventListener('click', () => fileInput.click());
249
+
250
+ fileInput.addEventListener('change', (e) => {
251
+ if (e.target.files.length > 0) {
252
+ handleImageFile(e.target.files[0]);
253
+ }
254
+ });
255
+
256
+ // Drag and Drop
257
+ dropzone.addEventListener('dragover', (e) => {
258
+ e.preventDefault();
259
+ dropzone.classList.add('dragover');
260
+ });
261
+
262
+ dropzone.addEventListener('dragleave', () => {
263
+ dropzone.classList.remove('dragover');
264
+ });
265
+
266
+ dropzone.addEventListener('drop', (e) => {
267
+ e.preventDefault();
268
+ dropzone.classList.remove('dragover');
269
+ if (e.dataTransfer.files.length > 0) {
270
+ handleImageFile(e.dataTransfer.files[0]);
271
+ }
272
+ });
273
+
274
+ removePreviewBtn.addEventListener('click', (e) => {
275
+ e.stopPropagation();
276
+ active2dImageBlob = null;
277
+ if (active2dImageURL) {
278
+ URL.revokeObjectURL(active2dImageURL);
279
+ active2dImageURL = null;
280
+ }
281
+ imagePreview.src = '';
282
+ previewContainer.style.display = 'none';
283
+ dropzone.style.display = 'flex';
284
+ fileInput.value = '';
285
+ generate3dBtn.disabled = true;
286
+ });
287
+
288
+ // 2D Image Generation
289
+ generate2dBtn.addEventListener('click', async () => {
290
+ const prompt = promptInput.value.trim();
291
+ if (!prompt) {
292
+ alert('Por favor introduce un prompt para la imagen.');
293
+ return;
294
+ }
295
+
296
+ generate2dBtn.disabled = true;
297
+ const originalText = generate2dBtn.querySelector('span').textContent;
298
+ generate2dBtn.querySelector('span').textContent = 'Generando 2D...';
299
+
300
+ const selectedModel = generatorModelSelect.value;
301
+
302
+ // Create or get inline feedback element
303
+ let feedbackEl = document.getElementById('generate2dFeedback');
304
+ if (!feedbackEl) {
305
+ feedbackEl = document.createElement('div');
306
+ feedbackEl.id = 'generate2dFeedback';
307
+ feedbackEl.style.fontSize = '0.75rem';
308
+ feedbackEl.style.marginTop = '0.75rem';
309
+ feedbackEl.style.padding = '0.6rem';
310
+ feedbackEl.style.borderRadius = '6px';
311
+ feedbackEl.style.background = 'rgba(255, 255, 255, 0.04)';
312
+ feedbackEl.style.border = '1px solid var(--border)';
313
+ feedbackEl.style.color = 'var(--text-muted)';
314
+ feedbackEl.style.lineHeight = '1.4';
315
+ generate2dBtn.parentNode.appendChild(feedbackEl);
316
+ }
317
+
318
+ feedbackEl.style.display = 'block';
319
+ feedbackEl.style.background = 'rgba(255, 255, 255, 0.04)';
320
+ feedbackEl.style.borderColor = 'var(--border)';
321
+ feedbackEl.style.color = 'var(--text-muted)';
322
+ feedbackEl.innerHTML = `
323
+ <div style="display: flex; align-items: center; gap: 8px;">
324
+ <div class="spinner-small" style="width: 12px; height: 12px; border: 2px solid rgba(255,255,255,0.2); border-top-color: var(--primary-light); border-radius: 50%; animation: spin 1s linear infinite;"></div>
325
+ <span>Generando imagen. Esto puede tardar 1-2 minutos si el modelo de Hugging Face está inactivo...</span>
326
+ </div>
327
+ `;
328
+
329
+ try {
330
+ const response = await fetch(`${apiBase}/api/generate-2d`, {
331
+ method: 'POST',
332
+ headers: {
333
+ 'Content-Type': 'application/json'
334
+ },
335
+ body: JSON.stringify({
336
+ prompt: prompt,
337
+ model: selectedModel,
338
+ negative_prompt: negativePromptInput.value,
339
+ token: hfToken
340
+ })
341
+ });
342
+
343
+ if (!response.ok) {
344
+ const errJson = await response.json().catch(() => ({}));
345
+ throw new Error(errJson.error || 'Error en la respuesta del servidor de generación de imágenes.');
346
+ }
347
+
348
+ const resJson = await response.json();
349
+ const base64Data = resJson.image;
350
+ const modelUsed = resJson.model_used || selectedModel;
351
+
352
+ // Fetch the base64 data URL to convert it to a Blob
353
+ const res = await fetch(base64Data);
354
+ const imageBlob = await res.blob();
355
+ const generatedFile = new File([imageBlob], "generated.png", { type: "image/png" });
356
+
357
+ // Load the generated image into the uploader tab
358
+ handleImageFile(generatedFile);
359
+
360
+ feedbackEl.style.background = 'rgba(76, 175, 80, 0.1)';
361
+ feedbackEl.style.borderColor = 'rgba(76, 175, 80, 0.3)';
362
+ feedbackEl.style.color = '#81c784';
363
+ feedbackEl.innerHTML = `¡Éxito usando: <strong>${modelUsed.split('/').pop()}</strong>!`;
364
+
365
+ // Switch back to upload tab to show preview
366
+ document.querySelector('[data-tab="upload-tab"]').click();
367
+
368
+ // Refresh gallery list to show the new image
369
+ refreshGallery();
370
+
371
+ // Hide feedback after 5 seconds
372
+ setTimeout(() => {
373
+ feedbackEl.style.display = 'none';
374
+ }, 5000);
375
+
376
+ } catch (error) {
377
+ console.error(error);
378
+ feedbackEl.style.background = 'rgba(244, 67, 54, 0.1)';
379
+ feedbackEl.style.borderColor = 'rgba(244, 67, 54, 0.3)';
380
+ feedbackEl.style.color = '#e57373';
381
+ feedbackEl.innerHTML = `Error: ${error.message}`;
382
+ alert(`Error al generar imagen 2D: ${error.message}. Asegúrate de tener configurado tu HF Token si el modelo requiere autenticación.`);
383
+ } finally {
384
+ generate2dBtn.disabled = false;
385
+ generate2dBtn.querySelector('span').textContent = originalText;
386
+ }
387
+ });
388
+
389
+ // 3D Generation (Trellis)
390
+ generate3dBtn.addEventListener('click', async () => {
391
+ if (!active2dImageBlob) return;
392
+
393
+ const version = trellisVersionSelect.value;
394
+
395
+ // Show Loading state
396
+ statusOverlay.classList.add('active');
397
+ updateLoader(10, 'Preparando imagen...', 'Convirtiendo la imagen 2D para enviar al servidor.');
398
+
399
+ // Convert image blob to base64
400
+ const reader = new FileReader();
401
+ reader.readAsDataURL(active2dImageBlob);
402
+ reader.onloadend = async () => {
403
+ const base64Image = reader.result;
404
+
405
+ updateLoader(25, 'Conectando con el servidor...', 'Enviando datos al motor Trellis a través de tu servidor local. Esto evita problemas de CORS.');
406
+
407
+ try {
408
+ let seedValNum = parseInt(seedInput.value) || 0;
409
+ if (randomSeedCheck.checked) {
410
+ seedValNum = Math.floor(Math.random() * 2147483647);
411
+ seedInput.value = seedValNum; // display the seed used
412
+ }
413
+
414
+ const resolution = resolutionSelect.value;
415
+ const decimate = parseInt(decimateInput.value) || 300000;
416
+ const textureSize = parseInt(textureSizeSelect.value) || 2048;
417
+ const ssGuidanceNum = parseFloat(ssGuidanceInput.value);
418
+ const ssStepsNum = parseInt(ssStepsInput.value);
419
+ const slatGuidanceNum = parseFloat(slatGuidanceInput.value);
420
+ const slatStepsNum = parseInt(slatStepsInput.value);
421
+ const autoOptimize = autoOptimizeCheck ? autoOptimizeCheck.checked : false;
422
+ const quadTargetFaces = quadFacesInput ? parseInt(quadFacesInput.value) : 8000;
423
+ const remeshMethod = remeshMethodSelect ? remeshMethodSelect.value : 'tris_to_quads';
424
+
425
+ const response = await fetch(`${apiBase}/api/generate-3d`, {
426
+ method: 'POST',
427
+ headers: {
428
+ 'Content-Type': 'application/json'
429
+ },
430
+ body: JSON.stringify({
431
+ version: version,
432
+ image: base64Image,
433
+ token: hfToken,
434
+ seed: seedValNum,
435
+ resolution: resolution,
436
+ decimation_target: decimate,
437
+ texture_size: textureSize,
438
+ ss_guidance: ssGuidanceNum,
439
+ ss_steps: ssStepsNum,
440
+ slat_guidance: slatGuidanceNum,
441
+ slat_steps: slatStepsNum,
442
+ auto_optimize: autoOptimize,
443
+ quad_target_faces: quadTargetFaces,
444
+ remeshMethod: remeshMethod
445
+ })
446
+ });
447
+
448
+ if (!response.ok) {
449
+ const errData = await response.json().catch(() => ({ error: 'Error de red o timeout del servidor.' }));
450
+ throw new Error(errData.error || 'Error interno en el servidor local.');
451
+ }
452
+
453
+ updateLoader(90, 'Procesando modelo...', 'Cargando malla tridimensional en el visualizador.');
454
+ const data = await response.json();
455
+
456
+ if (data.gltfUrl && data.glbUrl) {
457
+ // Save model info to sessionStorage and refresh via reload to ensure visual sync and bypass cache
458
+ sessionStorage.setItem('pendingModelToLoad', JSON.stringify({
459
+ gltfUrl: data.gltfUrl,
460
+ downloadUrl: data.glbUrl,
461
+ fbxUrl: data.fbxUrl
462
+ }));
463
+ window.location.reload();
464
+ } else {
465
+ throw new Error('La respuesta del servidor no contiene las URLs de modelo esperadas.');
466
+ }
467
+
468
+ } catch (error) {
469
+ console.error(error);
470
+ alert(`Error al generar modelo 3D: ${error.message}`);
471
+ statusOverlay.classList.remove('active');
472
+ }
473
+ };
474
+ });
475
+
476
+ function updateLoader(percent, title, desc) {
477
+ loaderPercentage.textContent = `${percent}%`;
478
+ statusTitle.textContent = title;
479
+ statusDesc.textContent = desc;
480
+ }
481
+
482
+ // 3D Model Render and Viewer Interactions
483
+ function load3DModel(gltfUrl, downloadUrl, fbxUrl) {
484
+ // Resolve URLs using apiBase for file:// compatibility
485
+ const absoluteGltfUrl = gltfUrl.startsWith('http') ? gltfUrl : `${apiBase}${gltfUrl}`;
486
+ const absoluteDownloadUrl = downloadUrl ? (downloadUrl.startsWith('http') ? downloadUrl : `${apiBase}${downloadUrl}`) : absoluteGltfUrl;
487
+
488
+ currentGltfUrl = absoluteDownloadUrl; // Store the high-quality GLB download url
489
+ activeModelRelativeUrl = gltfUrl; // Store relative path for rigging
490
+
491
+ // Configure or hide FBX button based on fbxUrl availability
492
+ if (fbxUrl) {
493
+ const absoluteFbxUrl = fbxUrl.startsWith('http') ? fbxUrl : `${apiBase}${fbxUrl}`;
494
+ downloadFbxBtn.style.display = 'inline-flex';
495
+ downloadFbxBtn.onclick = () => {
496
+ const link = document.createElement('a');
497
+ link.href = absoluteFbxUrl;
498
+ link.download = absoluteFbxUrl.split('/').pop();
499
+ document.body.appendChild(link);
500
+ link.click();
501
+ document.body.removeChild(link);
502
+ };
503
+ } else {
504
+ downloadFbxBtn.style.display = 'none';
505
+ downloadFbxBtn.onclick = null;
506
+ }
507
+
508
+ // Show Rigging button for active model
509
+ if (gltfUrl) {
510
+ rigModelBtn.style.display = 'inline-flex';
511
+ } else {
512
+ rigModelBtn.style.display = 'none';
513
+ }
514
+
515
+ // Clear previous viewer or empty state
516
+ viewerDisplay.innerHTML = '';
517
+
518
+ // Create new model-viewer element
519
+ const modelViewer = document.createElement('model-viewer');
520
+ modelViewer.id = 'activeModelViewer';
521
+ modelViewer.src = `${absoluteGltfUrl}?t=${Date.now()}`;
522
+ modelViewer.setAttribute('alt', 'Modelo 3D generado por Trellis');
523
+ modelViewer.setAttribute('camera-controls', '');
524
+ modelViewer.setAttribute('auto-rotate', '');
525
+ modelViewer.setAttribute('shadow-intensity', '1.8');
526
+ modelViewer.setAttribute('shadow-softness', '0.5');
527
+ modelViewer.setAttribute('environment-image', 'neutral');
528
+ modelViewer.setAttribute('exposure', '1.15');
529
+ modelViewer.setAttribute('tone-mapping', 'commerce');
530
+ modelViewer.setAttribute('interaction-prompt', 'none');
531
+ modelViewer.style.width = '100%';
532
+ modelViewer.style.height = '100%';
533
+
534
+ viewerDisplay.appendChild(modelViewer);
535
+
536
+ // Enable toolbar overlay
537
+ viewerOverlay.classList.add('active');
538
+ toggleAutoRotateBtn.classList.add('active'); // auto-rotate is enabled by default
539
+
540
+ // Hide status loading overlay
541
+ statusOverlay.classList.remove('active');
542
+ }
543
+
544
+ // Toolbar actions
545
+ toggleAutoRotateBtn.addEventListener('click', () => {
546
+ const viewer = document.getElementById('activeModelViewer');
547
+ if (!viewer) return;
548
+
549
+ if (viewer.hasAttribute('auto-rotate')) {
550
+ viewer.removeAttribute('auto-rotate');
551
+ toggleAutoRotateBtn.classList.remove('active');
552
+ } else {
553
+ viewer.setAttribute('auto-rotate', '');
554
+ toggleAutoRotateBtn.classList.add('active');
555
+ }
556
+ });
557
+
558
+ resetCameraBtn.addEventListener('click', () => {
559
+ const viewer = document.getElementById('activeModelViewer');
560
+ if (!viewer) return;
561
+
562
+ // Reset camera orbit to default
563
+ viewer.cameraOrbit = 'unset unset unset';
564
+ viewer.cameraTarget = 'unset unset unset';
565
+ viewer.fieldOfView = 'unset';
566
+
567
+ // Temporary flash class on button for visual feedback
568
+ resetCameraBtn.style.color = 'var(--primary-light)';
569
+ setTimeout(() => {
570
+ resetCameraBtn.style.color = '';
571
+ }, 300);
572
+ });
573
+
574
+ downloadGlbBtn.addEventListener('click', () => {
575
+ if (!currentGltfUrl) return;
576
+
577
+ // Trigger download
578
+ const link = document.createElement('a');
579
+ link.href = currentGltfUrl;
580
+ link.download = '23dfactory_model.glb';
581
+ document.body.appendChild(link);
582
+ link.click();
583
+ document.body.removeChild(link);
584
+ });
585
+
586
+ // Rigging action from main viewer
587
+ rigModelBtn.addEventListener('click', async () => {
588
+ if (!activeModelRelativeUrl) return;
589
+
590
+ statusOverlay.classList.add('active');
591
+
592
+ const rigMethod = rigMethodSelect ? rigMethodSelect.value : 'ai';
593
+ if (rigMethod === 'ai') {
594
+ updateLoader(40, 'Rigging por IA (Nube)...', 'Conectando con la IA de Hugging Face para calcular los pesos y el esqueleto...');
595
+ } else if (rigMethod === 'local_biped') {
596
+ updateLoader(40, 'Rigging local humanoide...', 'Blender está construyendo el esqueleto humanoide y calculando los pesos de influencia...');
597
+ } else if (rigMethod === 'local_quadruped') {
598
+ updateLoader(40, 'Rigging local cuadrúpedo...', 'Blender está construyendo el esqueleto de 4 patas y calculando los pesos de influencia...');
599
+ }
600
+
601
+ try {
602
+ const response = await fetch(`${apiBase}/api/rig-3d`, {
603
+ method: 'POST',
604
+ headers: {
605
+ 'Content-Type': 'application/json'
606
+ },
607
+ body: JSON.stringify({
608
+ modelUrl: activeModelRelativeUrl,
609
+ rigMethod: rigMethod,
610
+ token: hfToken
611
+ })
612
+ });
613
+
614
+ if (!response.ok) {
615
+ const errData = await response.json().catch(() => ({ error: 'Error al riggear.' }));
616
+ throw new Error(errData.error || 'Error interno del servidor.');
617
+ }
618
+
619
+ const data = await response.json();
620
+ if (data.riggedFbxUrl) {
621
+ const absoluteRiggedFbxUrl = data.riggedFbxUrl.startsWith('http') ? data.riggedFbxUrl : `${apiBase}${data.riggedFbxUrl}`;
622
+
623
+ // Download rigged FBX
624
+ const link = document.createElement('a');
625
+ link.href = absoluteRiggedFbxUrl;
626
+ link.download = absoluteRiggedFbxUrl.split('/').pop();
627
+ document.body.appendChild(link);
628
+ link.click();
629
+ document.body.removeChild(link);
630
+
631
+ alert('¡Esqueleto generado con éxito! El archivo FBX riggeado se ha descargado.');
632
+ } else {
633
+ throw new Error('No se devolvió la URL del FBX riggeado.');
634
+ }
635
+ } catch (error) {
636
+ console.error(error);
637
+ alert(`Error al riggear el modelo: ${error.message}`);
638
+ } finally {
639
+ statusOverlay.classList.remove('active');
640
+ }
641
+ });
642
+
643
+ // Gallery / Library Management
644
+ const galleryList = document.getElementById('galleryList');
645
+ const galleryTabBtn = document.getElementById('galleryTabBtn');
646
+
647
+ async function refreshGallery() {
648
+ try {
649
+ const response = await fetch(`${apiBase}/api/gallery`);
650
+ if (!response.ok) throw new Error('Error al cargar la galería.');
651
+
652
+ const data = await response.json();
653
+ cachedGalleryItems = data.items || [];
654
+ renderGallery(cachedGalleryItems);
655
+ } catch (error) {
656
+ console.error('Error refreshing gallery:', error);
657
+ }
658
+ }
659
+
660
+ let currentFilter = 'all';
661
+
662
+ function renderGallery(items) {
663
+ if (!galleryList) return;
664
+
665
+ if (items.length === 0) {
666
+ galleryList.innerHTML = `
667
+ <div class="gallery-empty-state">
668
+ <i data-lucide="inbox" style="opacity: 0.5; margin-bottom: 0.5rem; display: block; margin: 0 auto; text-align: center;"></i>
669
+ <p style="font-size: 0.8rem; color: var(--text-dark); text-align: center;">Biblioteca vacía</p>
670
+ </div>
671
+ `;
672
+ lucide.createIcons();
673
+ return;
674
+ }
675
+
676
+ // Filter items based on active filter state
677
+ const filteredItems = items.filter(item => {
678
+ if (currentFilter === 'all') return true;
679
+ return item.type === currentFilter;
680
+ });
681
+
682
+ if (filteredItems.length === 0) {
683
+ galleryList.innerHTML = `
684
+ <div class="gallery-empty-state">
685
+ <i data-lucide="inbox" style="opacity: 0.5; margin-bottom: 0.5rem; display: block; margin: 0 auto; text-align: center;"></i>
686
+ <p style="font-size: 0.8rem; color: var(--text-dark); text-align: center;">No hay elementos que coincidan con el filtro</p>
687
+ </div>
688
+ `;
689
+ lucide.createIcons();
690
+ return;
691
+ }
692
+
693
+ galleryList.innerHTML = '';
694
+
695
+ filteredItems.forEach((item, index) => {
696
+ const itemEl = document.createElement('div');
697
+ itemEl.className = 'gallery-item';
698
+ itemEl.style.animationDelay = `${index * 0.03}s`;
699
+
700
+ const isImage = item.type === 'image';
701
+ const formattedTime = new Date(item.mtime * 1000).toLocaleString('es-ES', {
702
+ day: '2-digit',
703
+ month: '2-digit',
704
+ hour: '2-digit',
705
+ minute: '2-digit'
706
+ });
707
+
708
+ const thumbnailHtml = isImage
709
+ ? `<img src="${apiBase}${item.url}" alt="${item.name}">`
710
+ : `<i data-lucide="box" style="width: 24px; height: 24px;"></i>`;
711
+
712
+ const actionButtonHtml = isImage
713
+ ? `<button class="gallery-btn load-img-btn" title="Cargar como origen 2D">
714
+ <i data-lucide="arrow-left-right" style="width: 14px; height: 14px;"></i>
715
+ </button>`
716
+ : `<button class="gallery-btn load-model-btn" title="Visualizar modelo 3D">
717
+ <i data-lucide="eye" style="width: 14px; height: 14px;"></i>
718
+ </button>`;
719
+
720
+ itemEl.innerHTML = `
721
+ <div class="gallery-thumbnail">${thumbnailHtml}</div>
722
+ <div class="gallery-info">
723
+ <span class="gallery-name" title="${item.name}">${item.name}</span>
724
+ <span class="gallery-meta">
725
+ <i data-lucide="calendar" style="width: 10px; height: 10px;"></i>
726
+ <span>${formattedTime}</span>
727
+ </span>
728
+ </div>
729
+ <div class="gallery-actions">
730
+ ${actionButtonHtml}
731
+ ${!isImage ? `
732
+ <button class="gallery-btn optimize-item-btn" title="Optimizar malla con Blender">
733
+ <i data-lucide="sparkles" style="width: 14px; height: 14px;"></i>
734
+ </button>
735
+ <button class="gallery-btn rig-item-btn" title="Generar Rigging humanoide" style="background: rgba(233, 30, 99, 0.15); color: #e91e63; border-color: rgba(233, 30, 99, 0.3);">
736
+ <i data-lucide="accessibility" style="width: 14px; height: 14px;"></i>
737
+ </button>
738
+ <a href="${apiBase}${item.url}" download="${item.name}" class="gallery-btn" title="Descargar archivo GLB" style="text-decoration: none;">
739
+ <i data-lucide="download" style="width: 14px; height: 14px;"></i>
740
+ </a>
741
+ ${item.fbxUrl ? `
742
+ <a href="${apiBase}${item.fbxUrl}" download="${item.name.replace('.glb', '.fbx')}" class="gallery-btn" title="Descargar archivo FBX (Quads y Texturas)" style="text-decoration: none; background: rgba(0, 150, 136, 0.15); color: #009688; border-color: rgba(0, 150, 136, 0.3);">
743
+ <i data-lucide="file-code" style="width: 14px; height: 14px;"></i>
744
+ </a>` : ''}
745
+ ` : ''}
746
+ <button class="gallery-btn delete-btn" title="Eliminar del disco">
747
+ <i data-lucide="trash-2" style="width: 14px; height: 14px;"></i>
748
+ </button>
749
+ </div>
750
+ `;
751
+
752
+ // Event listeners for actions
753
+ if (isImage) {
754
+ itemEl.querySelector('.load-img-btn').addEventListener('click', async () => {
755
+ try {
756
+ const fileUrl = `${apiBase}${item.url}`;
757
+ const res = await fetch(fileUrl);
758
+ const blob = await res.blob();
759
+ const file = new File([blob], item.name, { type: blob.type });
760
+ handleImageFile(file);
761
+
762
+ // Activate library source selector and switch tab
763
+ if (sourceLibraryBtn) sourceLibraryBtn.click();
764
+ document.querySelector('[data-tab="upload-tab"]').click();
765
+ } catch (e) {
766
+ alert('Error al cargar la imagen de la galería: ' + e.message);
767
+ }
768
+ });
769
+ } else {
770
+ itemEl.querySelector('.load-model-btn').addEventListener('click', () => {
771
+ load3DModel(`${apiBase}${item.url}`, `${apiBase}${item.url}`, item.fbxUrl);
772
+ });
773
+
774
+ itemEl.querySelector('.optimize-item-btn').addEventListener('click', async () => {
775
+ statusOverlay.classList.add('active');
776
+
777
+ const quadTargetFaces = quadFacesInput ? parseInt(quadFacesInput.value) : 8000;
778
+ const remeshMethod = remeshMethodSelect ? remeshMethodSelect.value : 'tris_to_quads';
779
+
780
+ if (remeshMethod === 'cleanup') {
781
+ updateLoader(50, 'Limpieza rápida de malla...', 'Blender está fusionando vértices duplicados y eliminando componentes flotantes aislados...');
782
+ } else {
783
+ updateLoader(50, 'Optimización local...', 'Blender está limpiando la malla, remallando a quads y proyectando texturas...');
784
+ }
785
+
786
+ try {
787
+ const response = await fetch(`${apiBase}/api/optimize-3d`, {
788
+ method: 'POST',
789
+ headers: {
790
+ 'Content-Type': 'application/json'
791
+ },
792
+ body: JSON.stringify({
793
+ modelUrl: item.url,
794
+ quad_target_faces: quadTargetFaces,
795
+ remeshMethod: remeshMethod
796
+ })
797
+ });
798
+
799
+ if (!response.ok) {
800
+ const errData = await response.json().catch(() => ({ error: 'Error al optimizar.' }));
801
+ throw new Error(errData.error || 'Error interno del servidor.');
802
+ }
803
+
804
+ const data = await response.json();
805
+ if (data.gltfUrl && data.glbUrl) {
806
+ // Save model info to sessionStorage and refresh via reload to ensure visual sync and bypass cache
807
+ sessionStorage.setItem('pendingModelToLoad', JSON.stringify({
808
+ gltfUrl: data.gltfUrl,
809
+ downloadUrl: data.glbUrl,
810
+ fbxUrl: data.fbxUrl
811
+ }));
812
+ window.location.reload();
813
+ } else {
814
+ throw new Error('No se devolvió la URL del modelo optimizado.');
815
+ }
816
+ } catch (error) {
817
+ console.error(error);
818
+ alert(`Error de optimización local: ${error.message}`);
819
+ } finally {
820
+ statusOverlay.classList.remove('active');
821
+ }
822
+ });
823
+
824
+ itemEl.querySelector('.rig-item-btn').addEventListener('click', async () => {
825
+ statusOverlay.classList.add('active');
826
+
827
+ const rigMethod = rigMethodSelect ? rigMethodSelect.value : 'ai';
828
+ if (rigMethod === 'ai') {
829
+ updateLoader(40, 'Rigging por IA (Nube)...', 'Conectando con la IA de Hugging Face para calcular los pesos y el esqueleto...');
830
+ } else if (rigMethod === 'local_biped') {
831
+ updateLoader(40, 'Rigging local humanoide...', 'Blender está construyendo el esqueleto humanoide y calculando los pesos de influencia...');
832
+ } else if (rigMethod === 'local_quadruped') {
833
+ updateLoader(40, 'Rigging local cuadrúpedo...', 'Blender está construyendo el esqueleto de 4 patas y calculando los pesos de influencia...');
834
+ }
835
+
836
+ try {
837
+ const response = await fetch(`${apiBase}/api/rig-3d`, {
838
+ method: 'POST',
839
+ headers: { 'Content-Type': 'application/json' },
840
+ body: JSON.stringify({
841
+ modelUrl: item.url,
842
+ rigMethod: rigMethod,
843
+ token: hfToken
844
+ })
845
+ });
846
+
847
+ if (!response.ok) {
848
+ const errData = await response.json().catch(() => ({ error: 'Error al riggear.' }));
849
+ throw new Error(errData.error || 'Error interno del servidor.');
850
+ }
851
+
852
+ const data = await response.json();
853
+ if (data.riggedFbxUrl) {
854
+ // Download rigged FBX
855
+ const link = document.createElement('a');
856
+ link.href = `${apiBase}${data.riggedFbxUrl}`;
857
+ link.download = data.riggedFbxUrl.split('/').pop();
858
+ document.body.appendChild(link);
859
+ link.click();
860
+ document.body.removeChild(link);
861
+
862
+ alert('¡Esqueleto generado con éxito! El archivo FBX riggeado se ha descargado.');
863
+ } else {
864
+ throw new Error('No se devolvió la URL del FBX riggeado.');
865
+ }
866
+ } catch (error) {
867
+ console.error(error);
868
+ alert(`Error de rigging local: ${error.message}`);
869
+ } finally {
870
+ statusOverlay.classList.remove('active');
871
+ }
872
+ });
873
+ }
874
+
875
+ itemEl.querySelector('.delete-btn').addEventListener('click', async () => {
876
+ if (confirm(`¿Estás seguro de que quieres eliminar ${item.name} de forma permanente del disco?`)) {
877
+ try {
878
+ const response = await fetch(`${apiBase}/api/delete-gallery`, {
879
+ method: 'POST',
880
+ headers: { 'Content-Type': 'application/json' },
881
+ body: JSON.stringify({ name: item.name, type: item.type })
882
+ });
883
+
884
+ if (!response.ok) throw new Error('Error al eliminar el archivo.');
885
+ refreshGallery();
886
+ } catch (e) {
887
+ alert('Error al eliminar item: ' + e.message);
888
+ }
889
+ }
890
+ });
891
+
892
+ galleryList.appendChild(itemEl);
893
+ });
894
+
895
+ lucide.createIcons();
896
+ }
897
+
898
+ // Bind gallery filter toggles
899
+ const filterBtns = document.querySelectorAll('.filter-group .toggle-btn');
900
+ filterBtns.forEach(btn => {
901
+ btn.addEventListener('click', () => {
902
+ filterBtns.forEach(b => b.classList.remove('active'));
903
+ btn.classList.add('active');
904
+ currentFilter = btn.getAttribute('data-filter');
905
+ renderGallery(cachedGalleryItems);
906
+ });
907
+ });
908
+
909
+ // Bind navigation click to refresh gallery
910
+ if (galleryTabBtn) {
911
+ galleryTabBtn.addEventListener('click', refreshGallery);
912
+ }
913
+
914
+ // Initial gallery refresh
915
+ refreshGallery();
916
+
917
+ // Check if there is a pending model to load after page reload (F5)
918
+ const pendingModel = sessionStorage.getItem('pendingModelToLoad');
919
+ if (pendingModel) {
920
+ try {
921
+ const { gltfUrl, downloadUrl, fbxUrl } = JSON.parse(pendingModel);
922
+ sessionStorage.removeItem('pendingModelToLoad');
923
+ load3DModel(gltfUrl, downloadUrl, fbxUrl);
924
+ } catch (e) {
925
+ console.error('Error loading pending model after reload:', e);
926
+ }
927
+ }
clean_mesh_blender.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ try:
5
+ import bpy
6
+ except ImportError:
7
+ print("[Error] This script must be run inside Blender's python environment.")
8
+ sys.exit(1)
9
+
10
+ def delete_isolated_components(obj, threshold_vertices=100):
11
+ # Store original name
12
+ orig_name = obj.name
13
+
14
+ # Deselect all, select obj, make active
15
+ bpy.ops.object.select_all(action='DESELECT')
16
+ obj.select_set(True)
17
+ bpy.context.view_layer.objects.active = obj
18
+
19
+ # Enter Edit Mode to separate
20
+ bpy.ops.object.mode_set(mode='EDIT')
21
+ bpy.ops.mesh.select_all(action='SELECT')
22
+
23
+ # Separate loose parts
24
+ bpy.ops.mesh.separate(type='LOOSE')
25
+
26
+ # Go back to Object Mode to process parts
27
+ bpy.ops.object.mode_set(mode='OBJECT')
28
+
29
+ # The separated parts will be named like orig_name.001, orig_name.002, etc.
30
+ # Collect all mesh objects that are part of this separation
31
+ separated_objs = [o for o in bpy.context.scene.objects if o.type == 'MESH' and (o.name == orig_name or o.name.startswith(orig_name + "."))]
32
+
33
+ if len(separated_objs) <= 1:
34
+ print("[Blender] Mesh is already a single component.")
35
+ return obj
36
+
37
+ print(f"[Blender] Separated into {len(separated_objs)} loose components.")
38
+
39
+ max_verts = max(len(o.data.vertices) for o in separated_objs)
40
+
41
+ keep_objs = []
42
+ delete_objs = []
43
+
44
+ for o in separated_objs:
45
+ num_verts = len(o.data.vertices)
46
+ # Keep if it has at least threshold_vertices, or is at least 5% of the largest component size
47
+ if num_verts >= threshold_vertices or num_verts >= (max_verts * 0.05):
48
+ keep_objs.append(o)
49
+ else:
50
+ delete_objs.append(o)
51
+
52
+ if not keep_objs:
53
+ largest = max(separated_objs, key=lambda o: len(o.data.vertices))
54
+ keep_objs = [largest]
55
+ delete_objs = [o for o in separated_objs if o != largest]
56
+
57
+ if delete_objs:
58
+ print(f"[Blender] Deleting {len(delete_objs)} isolated noise components with vertex count < {threshold_vertices}.")
59
+ bpy.ops.object.select_all(action='DESELECT')
60
+ for o in delete_objs:
61
+ o.select_set(True)
62
+ bpy.ops.object.delete()
63
+
64
+ # Re-join remaining ones
65
+ bpy.ops.object.select_all(action='DESELECT')
66
+ for o in keep_objs:
67
+ o.select_set(True)
68
+ bpy.context.view_layer.objects.active = keep_objs[0]
69
+ bpy.ops.object.join()
70
+
71
+ joined_obj = bpy.context.active_object
72
+ joined_obj.name = orig_name
73
+ return joined_obj
74
+
75
+ def run_blender_cleanup(input_path, output_path, target_faces=8000, remesh_method="tris_to_quads"):
76
+ print(f"[Blender] Loading model: {input_path} (target_faces={target_faces}, remesh_method={remesh_method})")
77
+
78
+ # 1. Reset scene to factory default empty scene
79
+ bpy.ops.wm.read_factory_settings(use_empty=True)
80
+
81
+ # 2. Import GLTF/GLB
82
+ bpy.ops.import_scene.gltf(filepath=input_path)
83
+
84
+ # 3. Locate the imported mesh
85
+ mesh_objs = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
86
+ if not mesh_objs:
87
+ print("[Error] No mesh found in imported GLTF.")
88
+ sys.exit(1)
89
+
90
+ dirty_obj = mesh_objs[0]
91
+ dirty_obj.name = "DirtyMesh"
92
+ print(f"[Blender] Found source mesh: {dirty_obj.name} with {len(dirty_obj.data.polygons)} polygons")
93
+
94
+ # DIAGNOSTIC: Print material and texture details of DirtyMesh
95
+ print("[Blender] --- DirtyMesh Material Diagnostics ---")
96
+ for i, mat in enumerate(dirty_obj.data.materials):
97
+ if not mat:
98
+ print(f" Material {i}: None")
99
+ continue
100
+ print(f" Material {i}: {mat.name} (use_nodes={mat.use_nodes})")
101
+ if mat.use_nodes:
102
+ for node in mat.node_tree.nodes:
103
+ print(f" Node: {node.name} (type={node.type})")
104
+ if node.type == 'TEX_IMAGE':
105
+ img = node.image
106
+ if img:
107
+ print(f" Image: {img.name}, size={img.size[:]}, filepath='{img.filepath}', has_data={img.has_data}")
108
+ else:
109
+ print(" Image: None")
110
+ for link in mat.node_tree.links:
111
+ print(f" Link: {link.from_node.name} ({link.from_socket.name}) -> {link.to_node.name} ({link.to_socket.name})")
112
+ print("[Blender] ---------------------------------------")
113
+
114
+ # Fast non-destructive cleanup option
115
+ if remesh_method == "cleanup":
116
+ print("[Blender] Running fast cleanup (merging duplicates and removing isolated parts)...")
117
+ # 1. Clean up mesh (remove doubles & loose)
118
+ bpy.ops.object.select_all(action='DESELECT')
119
+ dirty_obj.select_set(True)
120
+ bpy.context.view_layer.objects.active = dirty_obj
121
+ bpy.ops.object.mode_set(mode='EDIT')
122
+ bpy.ops.mesh.select_all(action='SELECT')
123
+ bpy.ops.mesh.remove_doubles(threshold=0.001)
124
+ bpy.ops.mesh.delete_loose()
125
+ bpy.ops.mesh.normals_make_consistent(inside=False)
126
+ bpy.ops.object.mode_set(mode='OBJECT')
127
+
128
+ # 2. Delete isolated components
129
+ dirty_obj = delete_isolated_components(dirty_obj, threshold_vertices=100)
130
+
131
+ # 3. Export directly (retaining original materials/textures)
132
+ bpy.ops.object.select_all(action='DESELECT')
133
+ dirty_obj.select_set(True)
134
+ bpy.context.view_layer.objects.active = dirty_obj
135
+
136
+ print(f"[Blender] Exporting cleaned model to: {output_path}")
137
+ bpy.ops.export_scene.gltf(
138
+ filepath=output_path,
139
+ export_format='GLB',
140
+ use_selection=True
141
+ )
142
+
143
+ # Also export FBX
144
+ fbx_path = output_path.replace(".glb", ".fbx")
145
+ print(f"[Blender] Exporting cleaned model as FBX: {fbx_path}")
146
+ try:
147
+ bpy.ops.export_scene.fbx(
148
+ filepath=fbx_path,
149
+ use_selection=True,
150
+ path_mode='COPY',
151
+ embed_textures=True
152
+ )
153
+ print("[Blender] ✓ FBX export completed successfully.")
154
+ except Exception as fe:
155
+ print(f"[Blender] Error: FBX export failed: {fe}")
156
+
157
+ print("[Blender] Fast cleanup completed successfully.")
158
+ return
159
+
160
+ # 4. Duplicate the mesh to create the clean version
161
+ bpy.ops.object.select_all(action='DESELECT')
162
+ dirty_obj.select_set(True)
163
+ bpy.context.view_layer.objects.active = dirty_obj
164
+
165
+ bpy.ops.object.duplicate(linked=False)
166
+ clean_obj = bpy.context.active_object
167
+ clean_obj.name = "CleanMesh"
168
+
169
+ # 5. Clean up initial mesh (remove doubles)
170
+ bpy.context.view_layer.objects.active = clean_obj
171
+ bpy.ops.object.mode_set(mode='EDIT')
172
+ bpy.ops.mesh.select_all(action='SELECT')
173
+ # Merge close vertices (Merge by distance)
174
+ bpy.ops.mesh.remove_doubles(threshold=0.001)
175
+ bpy.ops.mesh.delete_loose()
176
+ # Recalculate normals outwards
177
+ bpy.ops.mesh.normals_make_consistent(inside=False)
178
+ bpy.ops.object.mode_set(mode='OBJECT')
179
+
180
+ # Delete isolated components before remeshing/decimating
181
+ clean_obj = delete_isolated_components(clean_obj, threshold_vertices=100)
182
+
183
+ # 6. Apply Decimate Modifier or QuadriFlow Remesh
184
+ if remesh_method == "quadriflow":
185
+ print(f"[Blender] Running QuadriFlow Remesh to target {target_faces} faces...")
186
+ try:
187
+ bpy.context.view_layer.objects.active = clean_obj
188
+ bpy.ops.object.quadriflow_remesh(
189
+ use_preserve_sharp=True,
190
+ use_preserve_boundary=True,
191
+ target_faces=target_faces
192
+ )
193
+ print(f"[Blender] ✓ QuadriFlow Remesh completed. New polygon count: {len(clean_obj.data.polygons)}")
194
+ except Exception as qfe:
195
+ print(f"[Blender] Error: QuadriFlow Remesh failed: {qfe}. Falling back to decimation.")
196
+ remesh_method = "tris_to_quads"
197
+
198
+ if remesh_method == "tris_to_quads":
199
+ poly_count_before = len(clean_obj.data.polygons)
200
+ if poly_count_before > target_faces:
201
+ ratio = target_faces / poly_count_before
202
+ print(f"[Blender] Decimating mesh from {poly_count_before} to {target_faces} polygons (ratio={ratio:.4f})...")
203
+ try:
204
+ dec_mod = clean_obj.modifiers.new(name="AutoDecimate", type='DECIMATE')
205
+ dec_mod.ratio = ratio
206
+ dec_mod.use_symmetry = True
207
+ dec_mod.symmetry_axis = 'X'
208
+ bpy.ops.object.modifier_apply(modifier="AutoDecimate")
209
+ print(f"[Blender] ✓ Decimation completed. New polygon count: {len(clean_obj.data.polygons)}")
210
+ except Exception as de:
211
+ print(f"[Blender] Warning: Decimation failed: {de}")
212
+ else:
213
+ print(f"[Blender] Mesh already has fewer polygons ({poly_count_before}) than target ({target_faces}). Skipping decimation.")
214
+
215
+ # 7. Convert triangles to quads with high angle threshold (1.57 rad / 90 degrees)
216
+ # This converts almost 100% of flat areas to quads while keeping the mesh completely watertight and sharp
217
+ print("[Blender] Converting triangles to quads with high threshold...")
218
+ try:
219
+ bpy.ops.object.mode_set(mode='EDIT')
220
+ bpy.ops.mesh.select_all(action='SELECT')
221
+ bpy.ops.mesh.tris_convert_to_quads(face_threshold=1.570796, shape_threshold=1.570796)
222
+ bpy.ops.object.mode_set(mode='OBJECT')
223
+ print("[Blender] ✓ Conversion to quads completed.")
224
+ except Exception as tq:
225
+ print(f"[Blender] Warning: Triangles to quads conversion failed: {tq}")
226
+ if clean_obj.mode != 'OBJECT':
227
+ bpy.ops.object.mode_set(mode='OBJECT')
228
+
229
+ # 8. Unwrap UVs on CleanMesh
230
+ print("[Blender] Unwrapping UV coordinates...")
231
+ bpy.ops.object.mode_set(mode='EDIT')
232
+ bpy.ops.mesh.select_all(action='SELECT')
233
+ bpy.ops.uv.smart_project(island_margin=0.002)
234
+ bpy.ops.object.mode_set(mode='OBJECT')
235
+
236
+ # 9. Setup Baking Material & Target Image Node
237
+ print("[Blender] Setting up material for texture projection...")
238
+ clean_mat = bpy.data.materials.new(name="CleanMaterial")
239
+ clean_mat.use_nodes = True
240
+ clean_obj.data.materials.clear()
241
+ clean_obj.data.materials.append(clean_mat)
242
+
243
+ bake_image = bpy.data.images.new(name="BakedTexture", width=2048, height=2048)
244
+
245
+ nodes = clean_mat.node_tree.nodes
246
+ texture_node = nodes.new(type='ShaderNodeTexImage')
247
+ texture_node.image = bake_image
248
+ texture_node.select = True
249
+ nodes.active = texture_node
250
+
251
+ # 10. Configure Cycles and Bake from Selected (DirtyMesh) to Active (CleanMesh)
252
+ print("[Blender] Configuring Cycles baking engine...")
253
+ bpy.context.scene.render.engine = 'CYCLES'
254
+ bpy.context.scene.cycles.device = 'CPU'
255
+
256
+ print("[Blender] Bypassing shader nodes for unlit EMIT baking...")
257
+ for mat in dirty_obj.data.materials:
258
+ if mat and mat.use_nodes:
259
+ output_node = next((n for n in mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL'), None)
260
+ bsdf_node = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
261
+ if output_node and bsdf_node:
262
+ base_color_input = bsdf_node.inputs['Base Color']
263
+ if base_color_input.is_linked:
264
+ from_node = base_color_input.links[0].from_node
265
+ from_socket = base_color_input.links[0].from_socket
266
+ mat.node_tree.links.new(from_socket, output_node.inputs['Surface'])
267
+ print(f" [Bypass] Linked {from_node.name} directly to Material Output Surface on {mat.name}")
268
+
269
+ bpy.context.scene.cycles.bake_type = 'EMIT'
270
+ bpy.context.scene.render.bake.use_selected_to_active = True
271
+ bpy.context.scene.render.bake.margin = 16
272
+ bpy.context.scene.render.bake.margin_type = 'EXTEND'
273
+ bpy.context.scene.render.bake.cage_extrusion = 0.02
274
+ bpy.context.scene.render.bake.max_ray_distance = 0.05
275
+
276
+ # Select DirtyMesh and CleanMesh, making CleanMesh active
277
+ bpy.ops.object.select_all(action='DESELECT')
278
+ dirty_obj.select_set(True)
279
+ clean_obj.select_set(True)
280
+ bpy.context.view_layer.objects.active = clean_obj
281
+
282
+ print("[Blender] Projecting (baking) texture map using EMIT. Please wait...")
283
+ try:
284
+ bpy.ops.object.bake(type='EMIT')
285
+ print("[Blender] ✓ Texture projection completed successfully.")
286
+
287
+ bake_image.pack()
288
+
289
+ # Link texture node outputs to material shader inputs
290
+ bsdf_node = next(n for n in nodes if n.type == 'BSDF_PRINCIPLED')
291
+ clean_mat.node_tree.links.new(texture_node.outputs['Color'], bsdf_node.inputs['Base Color'])
292
+ except Exception as be:
293
+ print(f"[Blender] Error: Texture baking failed: {be}")
294
+
295
+ # 10. Delete the original dirty mesh before export
296
+ bpy.ops.object.select_all(action='DESELECT')
297
+ dirty_obj.select_set(True)
298
+ bpy.ops.object.delete()
299
+
300
+ # 11. Export CleanMesh as GLB (preserving original textures and materials)
301
+ bpy.ops.object.select_all(action='DESELECT')
302
+ clean_obj.select_set(True)
303
+ bpy.context.view_layer.objects.active = clean_obj
304
+
305
+ print(f"[Blender] Exporting clean model to: {output_path}")
306
+ bpy.ops.export_scene.gltf(
307
+ filepath=output_path,
308
+ export_format='GLB',
309
+ use_selection=True
310
+ )
311
+
312
+ # 12. Also export as FBX
313
+ fbx_path = output_path.replace(".glb", ".fbx")
314
+ print(f"[Blender] Exporting clean model as FBX: {fbx_path}")
315
+ try:
316
+ bpy.ops.export_scene.fbx(
317
+ filepath=fbx_path,
318
+ use_selection=True,
319
+ path_mode='COPY',
320
+ embed_textures=True
321
+ )
322
+ print("[Blender] ✓ FBX export completed successfully.")
323
+ except Exception as fe:
324
+ print(f"[Blender] Error: FBX export failed: {fe}")
325
+
326
+ print("[Blender] Process completed successfully.")
327
+
328
+ if __name__ == "__main__":
329
+ # Extract arguments passed after '--'
330
+ try:
331
+ args_idx = sys.argv.index("--")
332
+ args = sys.argv[args_idx + 1:]
333
+ except ValueError:
334
+ args = []
335
+
336
+ if len(args) < 2:
337
+ print("[Error] Usage: blender --background --python clean_mesh_blender.py -- <input_glb> <output_glb> [target_faces] [remesh_method]")
338
+ sys.exit(1)
339
+
340
+ target_faces = 60000
341
+ if len(args) >= 3:
342
+ try:
343
+ target_faces = int(args[2])
344
+ except ValueError:
345
+ pass
346
+
347
+ remesh_method = "tris_to_quads"
348
+ if len(args) >= 4:
349
+ remesh_method = args[3]
350
+
351
+ run_blender_cleanup(args[0], args[1], target_faces, remesh_method)
dummy.png ADDED
index.html ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>23DFactory - Transform 2D Images to 3D Models</title>
7
+
8
+ <!-- Google Fonts -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@400;600;700;800&display=swap" rel="stylesheet">
12
+
13
+ <!-- Lucide Icons -->
14
+ <script src="https://unpkg.com/lucide@latest"></script>
15
+
16
+ <!-- Import Model Viewer for 3D Rendering -->
17
+ <script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
18
+
19
+ <!-- Custom Style -->
20
+ <link rel="stylesheet" href="styles.css">
21
+ </head>
22
+ <body>
23
+
24
+ <div class="app-container">
25
+ <!-- Header -->
26
+ <header class="app-header">
27
+ <div class="logo-container">
28
+ <h1 class="logo-text">23D<span>Factory</span></h1>
29
+ </div>
30
+ <div class="header-actions">
31
+ <button class="btn btn-secondary" id="openSettingsBtn" title="Configuración de API">
32
+ <i data-lucide="settings"></i>
33
+ <span>Configuración</span>
34
+ </button>
35
+ </div>
36
+ </header>
37
+
38
+ <!-- Main Workspace -->
39
+ <main class="main-workspace">
40
+ <!-- Sidebar (Controls) -->
41
+ <aside class="sidebar">
42
+ <div class="sidebar-scroll">
43
+ <!-- Image Source Selection Tab -->
44
+ <nav class="tab-nav">
45
+ <button class="tab-btn active" data-tab="upload-tab">
46
+ <i data-lucide="box"></i>
47
+ <span>Generar 3D AI</span>
48
+ </button>
49
+ <button class="tab-btn" data-tab="generate-tab">
50
+ <i data-lucide="sparkles"></i>
51
+ <span>Generar 2D AI</span>
52
+ </button>
53
+ <button class="tab-btn" data-tab="gallery-tab" id="galleryTabBtn">
54
+ <i data-lucide="library"></i>
55
+ <span>Biblioteca</span>
56
+ </button>
57
+ </nav>
58
+
59
+ <!-- Tab content: Upload / Select Image for 3D generation -->
60
+ <div class="tab-content active" id="upload-tab">
61
+ <div class="control-card">
62
+ <div class="card-title">
63
+ <i data-lucide="image"></i>
64
+ <span>Imagen 2D de Origen</span>
65
+ </div>
66
+
67
+ <!-- Source Selector -->
68
+ <div class="toggle-group" style="margin-bottom: 1.25rem;">
69
+ <button type="button" class="toggle-btn active" id="sourceUploadBtn">Subir Archivo</button>
70
+ <button type="button" class="toggle-btn" id="sourceLibraryBtn">Usar Biblioteca</button>
71
+ </div>
72
+
73
+ <!-- Upload Option -->
74
+ <div id="uploadSourceContainer">
75
+ <div class="dropzone" id="dropzone">
76
+ <i data-lucide="image-up" class="dropzone-icon"></i>
77
+ <p>Arrastra tu imagen aquí o haz clic para buscar</p>
78
+ <span style="font-size: 0.75rem; color: var(--text-dark);">Soporta PNG, JPG, WEBP (Fondo transparente preferible)</span>
79
+ <input type="file" id="fileInput" accept="image/*">
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Library Selection Option -->
84
+ <div id="librarySourceContainer" style="display: none;">
85
+ <div class="library-thumbnail-grid" id="libraryThumbnailGrid">
86
+ <!-- Populated dynamically with 2D images -->
87
+ </div>
88
+ </div>
89
+
90
+ <div class="preview-container" id="previewContainer" style="margin-top: 1rem;">
91
+ <img src="" alt="Preview" class="preview-img" id="imagePreview">
92
+ <button class="remove-preview" id="removePreviewBtn" title="Quitar imagen">&times;</button>
93
+ </div>
94
+ </div>
95
+
96
+ <!-- Trellis 3D Parameters Card -->
97
+ <div class="control-card">
98
+ <div class="card-title">
99
+ <i data-lucide="sliders"></i>
100
+ <span>Ajustes Trellis 3D</span>
101
+ </div>
102
+
103
+ <div class="input-group">
104
+ <label class="input-label" for="trellisVersionSelect">Versión de Trellis</label>
105
+ <select id="trellisVersionSelect" class="custom-select">
106
+ <option value="v2">Trellis v2 (Microsoft - Recomendado)</option>
107
+ <option value="v1">Trellis v1 (Comunidad)</option>
108
+ </select>
109
+ </div>
110
+
111
+ <div class="input-group" style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; margin-bottom: 0.75rem;">
112
+ <input type="checkbox" id="autoOptimizeCheck" style="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-light);">
113
+ <label for="autoOptimizeCheck" class="input-label" style="margin-bottom: 0; cursor: pointer; user-select: none; font-size: 0.85rem; color: var(--text-light);">
114
+ Optimizar malla a Quads (Blender local)
115
+ </label>
116
+ </div>
117
+
118
+ <!-- Target Faces for Blender Remesh -->
119
+ <div id="quadFacesContainer" class="slider-container" style="display: none; margin-bottom: 1.25rem;">
120
+ <div class="slider-header" style="margin-bottom: 0.5rem;">
121
+ <span>Caras objetivo (Quads Blender)</span>
122
+ <span id="quadFacesVal">60000</span>
123
+ </div>
124
+ <input type="range" id="quadFacesInput" class="range-slider" min="2000" max="200000" step="1000" value="60000" style="margin-bottom: 0.75rem;">
125
+
126
+ <div class="input-group" style="margin-bottom: 0;">
127
+ <label class="input-label" for="remeshMethodSelect" style="font-size: 0.75rem; opacity: 0.8; margin-bottom: 0.25rem;">Método de Quads</label>
128
+ <select id="remeshMethodSelect" class="custom-select" style="padding: 0.35rem 0.5rem; font-size: 0.8rem; height: auto;">
129
+ <option value="cleanup">Sólo Limpieza (Eliminar ruido y flotantes)</option>
130
+ <option value="tris_to_quads" selected>Conversión Rápida (Tris to Quads)</option>
131
+ <option value="quadriflow">Retopología Completa (QuadriFlow)</option>
132
+ </select>
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Resolution -->
137
+ <div class="input-group">
138
+ <label class="input-label" for="resolutionSelect">Resolución</label>
139
+ <select id="resolutionSelect" class="custom-select">
140
+ <option value="512">512 (Rápido)</option>
141
+ <option value="1024" selected>1024 (Recomendado)</option>
142
+ <option value="1536">1536 (Alta Calidad)</option>
143
+ </select>
144
+ </div>
145
+
146
+ <!-- Seed -->
147
+ <div class="input-group">
148
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem;">
149
+ <label class="input-label" for="seedInput" style="margin-bottom: 0;">Semilla (Seed)</label>
150
+ <div style="display: flex; align-items: center; gap: 0.25rem;">
151
+ <input type="checkbox" id="randomSeedCheck" checked style="width: 14px; height: 14px; cursor: pointer;">
152
+ <label for="randomSeedCheck" style="font-size: 0.75rem; color: var(--text-muted); cursor: pointer; user-select: none;">Aleatoria</label>
153
+ </div>
154
+ </div>
155
+ <input type="number" id="seedInput" class="text-input" value="0" min="0" max="2147483647">
156
+ </div>
157
+
158
+ <!-- Decimation Target -->
159
+ <div class="slider-container">
160
+ <div class="slider-header">
161
+ <span>Decimation Target (Malla original)</span>
162
+ <span id="decimateVal">300000</span>
163
+ </div>
164
+ <input type="range" id="decimateInput" class="range-slider" min="100000" max="500000" step="50000" value="300000">
165
+ </div>
166
+
167
+ <!-- Texture Size -->
168
+ <div class="input-group">
169
+ <label class="input-label" for="textureSizeSelect">Tamaño de Textura</label>
170
+ <select id="textureSizeSelect" class="custom-select">
171
+ <option value="1024">1024x1024</option>
172
+ <option value="2048" selected>2048x2048</option>
173
+ <option value="4096">4096x4096</option>
174
+ </select>
175
+ </div>
176
+
177
+ <!-- SS Guidance Strength -->
178
+ <div class="slider-container">
179
+ <div class="slider-header">
180
+ <span>SS Fuerza de Guía (SS Guidance)</span>
181
+ <span id="ssGuidanceVal">7.5</span>
182
+ </div>
183
+ <input type="range" id="ssGuidanceInput" class="range-slider" min="1.0" max="15.0" step="0.5" value="7.5">
184
+ </div>
185
+
186
+ <!-- SS Sampling Steps -->
187
+ <div class="slider-container">
188
+ <div class="slider-header">
189
+ <span>SS Pasos de Muestreo</span>
190
+ <span id="ssStepsVal">12</span>
191
+ </div>
192
+ <input type="range" id="ssStepsInput" class="range-slider" min="4" max="25" step="1" value="12">
193
+ </div>
194
+
195
+ <!-- SLAT Guidance Strength -->
196
+ <div class="slider-container">
197
+ <div class="slider-header">
198
+ <span>SLAT Fuerza de Guía</span>
199
+ <span id="slatGuidanceVal">7.5</span>
200
+ </div>
201
+ <input type="range" id="slatGuidanceInput" class="range-slider" min="1.0" max="10.0" step="0.5" value="7.5">
202
+ </div>
203
+
204
+ <!-- SLAT Steps -->
205
+ <div class="slider-container">
206
+ <div class="slider-header">
207
+ <span>SLAT Pasos de Muestreo</span>
208
+ <span id="slatStepsVal">12</span>
209
+ </div>
210
+ <input type="range" id="slatStepsInput" class="range-slider" min="4" max="25" step="1" value="12">
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <!-- Tab content: Generate 2D AI -->
216
+ <div class="tab-content" id="generate-tab">
217
+ <div class="control-card">
218
+ <div class="card-title">
219
+ <i data-lucide="message-square"></i>
220
+ <span>Generador de Imágenes 2D</span>
221
+ </div>
222
+ <div class="input-group">
223
+ <label class="input-label" for="promptInput">Prompt de Imagen</label>
224
+ <textarea id="promptInput" class="text-input" rows="3" placeholder="Ej. Un juguete robot cyberpunk detallado, fondo blanco, modelo 3D, estilo Pixar..."></textarea>
225
+ </div>
226
+
227
+ <div class="input-group">
228
+ <label class="input-label" for="negativePromptInput">Prompt Negativo</label>
229
+ <input type="text" id="negativePromptInput" class="text-input" placeholder="Ej. blurry, low quality, detailed background" value="blurry, detailed background, shadows, realistic environment">
230
+ </div>
231
+
232
+ <div class="input-group">
233
+ <label class="input-label" for="generatorModelSelect">Modelo de IA</label>
234
+ <select id="generatorModelSelect" class="custom-select">
235
+ <option value="black-forest-labs/FLUX.1-schnell">FLUX.1 Schnell (Ultra Rápido)</option>
236
+ <option value="stabilityai/stable-diffusion-3-medium">Stable Diffusion 3 Medium</option>
237
+ <option value="black-forest-labs/FLUX.1-dev">FLUX.1 Dev (Alta Calidad)</option>
238
+ </select>
239
+ </div>
240
+
241
+ <button class="btn btn-accent" id="generate2dBtn">
242
+ <i data-lucide="wand-2"></i>
243
+ <span>Crear Imagen 2D</span>
244
+ </button>
245
+ </div>
246
+ </div>
247
+
248
+ <!-- Tab content: Gallery / Library -->
249
+ <div class="tab-content" id="gallery-tab">
250
+ <div class="control-card">
251
+ <div class="card-title">
252
+ <i data-lucide="library"></i>
253
+ <span>Galería de Creaciones</span>
254
+ </div>
255
+ <p style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.75rem;">
256
+ Modelos 3D (.glb) e imágenes 2D generadas y almacenadas localmente.
257
+ </p>
258
+
259
+ <div class="input-group" style="margin-top: 0.5rem; margin-bottom: 1.25rem; background: rgba(255, 255, 255, 0.03); padding: 0.6rem; border-radius: 8px; border: 1px solid var(--border);">
260
+ <label for="rigMethodSelect" class="input-label" style="margin-bottom: 0.35rem; cursor: pointer; user-select: none; font-size: 0.82rem; color: var(--text-light); display: flex; align-items: center; gap: 0.35rem;">
261
+ <i data-lucide="sparkles" style="width: 13.5px; height: 13.5px; color: #e91e63;"></i>
262
+ Método de Rigging
263
+ </label>
264
+ <select id="rigMethodSelect" class="custom-select" style="font-size: 0.8rem; padding: 0.3rem 0.5rem; height: auto; margin-top: 0.25rem; background: var(--bg-dark); border: 1px solid var(--border); color: var(--text-light); border-radius: 4px; width: 100%; cursor: pointer;">
265
+ <option value="ai" selected>Rigging por IA (Cualquier forma - HF)</option>
266
+ <option value="local_biped">Blender Local - Humanoide</option>
267
+ <option value="local_quadruped">Blender Local - Cuadrúpedo (4 patas)</option>
268
+ </select>
269
+ </div>
270
+
271
+ <!-- Gallery Filter Selector -->
272
+ <div class="toggle-group filter-group" style="margin-bottom: 1.25rem;">
273
+ <button type="button" class="toggle-btn active" data-filter="all">Todos</button>
274
+ <button type="button" class="toggle-btn" data-filter="image">Imágenes 2D</button>
275
+ <button type="button" class="toggle-btn" data-filter="model">Modelos 3D</button>
276
+ </div>
277
+
278
+ <div class="gallery-list" id="galleryList">
279
+ <!-- Items will be dynamically loaded here -->
280
+ <div class="gallery-empty-state">
281
+ <i data-lucide="inbox" style="opacity: 0.5; margin-bottom: 0.5rem; display: block; margin: 0 auto; text-align: center;"></i>
282
+ <p style="font-size: 0.8rem; color: var(--text-dark); text-align: center;">Biblioteca vacía</p>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ </div>
287
+
288
+ </div> <!-- End sidebar-scroll -->
289
+
290
+ <div class="sidebar-actions">
291
+ <!-- Main Build Trigger -->
292
+ <button class="btn btn-accent" id="generate3dBtn" style="width: 100%;" disabled>
293
+ <i data-lucide="box"></i>
294
+ <span>Construir Malla 3D</span>
295
+ </button>
296
+
297
+ <!-- Footer -->
298
+ <footer class="sidebar-footer">
299
+ <span>23DFactory v1.0.0</span>
300
+ <a href="https://huggingface.co/spaces/microsoft/TRELLIS.2" target="_blank" rel="noopener">Motor Trellis</a>
301
+ </footer>
302
+ </div>
303
+ </aside>
304
+
305
+ <!-- Viewer Container -->
306
+ <section class="viewer-container">
307
+ <!-- Loading Overlay -->
308
+ <div class="status-overlay" id="statusOverlay">
309
+ <div class="cyber-loader">
310
+ <div class="loader-ring"></div>
311
+ <div class="loader-ring"></div>
312
+ <div class="loader-ring"></div>
313
+ <div class="loader-text-pct" id="loaderPercentage">--</div>
314
+ </div>
315
+ <div class="status-info">
316
+ <h4 class="status-title" id="statusTitle">Inicializando...</h4>
317
+ <p class="status-desc" id="statusDesc">Por favor, espera unos instantes mientras el motor procesa.</p>
318
+ </div>
319
+ </div>
320
+
321
+ <!-- Interactive 3D Viewer Display -->
322
+ <div class="viewer-display" id="viewerDisplay">
323
+ <!-- Empty State -->
324
+ <div class="empty-state" id="emptyState">
325
+ <div class="empty-state-icon">
326
+ <i data-lucide="box"></i>
327
+ </div>
328
+ <h3>Visualizador 3D</h3>
329
+ <p>Carga o genera una imagen 2D en el panel izquierdo y haz clic en "Construir Malla 3D" para ver el resultado tridimensional aquí.</p>
330
+ </div>
331
+
332
+ <!-- Dynamic Model Viewer (will be injected here or toggled) -->
333
+ </div>
334
+
335
+ <!-- Visualizer Floating Overlay Toolbar -->
336
+ <div class="viewer-overlay" id="viewerOverlay">
337
+ <button class="overlay-btn" id="toggleAutoRotate" title="Auto-Rotar">
338
+ <i data-lucide="rotate-cw"></i>
339
+ </button>
340
+ <button class="overlay-btn" id="resetCameraBtn" title="Restablecer Vista">
341
+ <i data-lucide="focus"></i>
342
+ </button>
343
+ <div style="width: 1px; background: var(--border); margin: 0.25rem 0;"></div>
344
+ <button class="btn btn-accent" id="downloadGlbBtn" style="padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.85rem;" title="Descargar modelo .GLB">
345
+ <i data-lucide="download"></i>
346
+ <span>Descargar GLB</span>
347
+ </button>
348
+ <button class="btn" id="downloadFbxBtn" style="padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.85rem; background: rgba(0, 150, 136, 0.15); color: #009688; border: 1px solid rgba(0, 150, 136, 0.3); display: none; align-items: center; gap: 0.5rem;" title="Descargar modelo .FBX (Quads)">
349
+ <i data-lucide="download"></i>
350
+ <span>Descargar FBX (Quads)</span>
351
+ </button>
352
+ <button class="btn" id="rigModelBtn" style="padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.85rem; background: rgba(233, 30, 99, 0.15); color: #e91e63; border: 1px solid rgba(233, 30, 99, 0.3); display: none; align-items: center; gap: 0.5rem;" title="Generar Rigging humanoide automático">
353
+ <i data-lucide="accessibility"></i>
354
+ <span>Generar Rigging</span>
355
+ </button>
356
+ </div>
357
+ </section>
358
+ </main>
359
+ </div>
360
+
361
+ <!-- API settings Modal -->
362
+ <div class="modal-overlay" id="settingsModal">
363
+ <div class="modal-content">
364
+ <div class="modal-header">
365
+ <h3 class="modal-title">Configuración de API</h3>
366
+ <button class="close-modal" id="closeSettingsBtn">&times;</button>
367
+ </div>
368
+
369
+ <p style="font-size: 0.85rem; color: var(--text-muted);">
370
+ Para utilizar las APIs de Hugging Face de forma ilimitada o más veloz, ingresa tu HF Token. De lo contrario, se utilizará la cuota de uso público por defecto.
371
+ </p>
372
+
373
+ <div class="input-group">
374
+ <label class="input-label" for="hfTokenInput">Hugging Face API Token</label>
375
+ <input type="password" id="hfTokenInput" class="text-input" placeholder="hf_...">
376
+ <span style="font-size: 0.7rem; color: var(--text-dark);">
377
+ Puedes crear un token en <a href="https://huggingface.co/settings/tokens" target="_blank" style="color: var(--primary-light);">huggingface.co/settings/tokens</a>
378
+ </span>
379
+ </div>
380
+
381
+ <button class="btn" id="saveSettingsBtn">
382
+ <i data-lucide="save"></i>
383
+ <span>Guardar Configuración</span>
384
+ </button>
385
+ </div>
386
+ </div>
387
+
388
+ <script type="module" src="app.js?v=9"></script>
389
+ <script>
390
+ // Initialize Lucide icons on page load
391
+ lucide.createIcons();
392
+ </script>
393
+ </body>
394
+ </html>
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio_client
rig_mesh_blender.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ try:
5
+ import bpy
6
+ import mathutils
7
+ except ImportError:
8
+ print("[Error] This script must be run inside Blender's python environment.")
9
+ sys.exit(1)
10
+
11
+ def run_blender_rigging(input_path, output_path):
12
+ print(f"[Blender Rigging] Loading model: {input_path}")
13
+
14
+ # 1. Reset scene
15
+ bpy.ops.wm.read_factory_settings(use_empty=True)
16
+
17
+ # 2. Import GLB
18
+ bpy.ops.import_scene.gltf(filepath=input_path)
19
+
20
+ # 3. Find all mesh objects and join them into a single character mesh
21
+ mesh_objs = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
22
+ if not mesh_objs:
23
+ print("[Error] No mesh found in imported GLTF.")
24
+ sys.exit(1)
25
+
26
+ print(f"[Blender Rigging] Found {len(mesh_objs)} meshes. Joining them...")
27
+ bpy.ops.object.select_all(action='DESELECT')
28
+ for obj in mesh_objs:
29
+ obj.select_set(True)
30
+ bpy.context.view_layer.objects.active = mesh_objs[0]
31
+ bpy.ops.object.join()
32
+
33
+ mesh_obj = bpy.context.active_object
34
+ mesh_obj.name = "CharacterMesh"
35
+ print(f"[Blender Rigging] Joined mesh: {mesh_obj.name}")
36
+
37
+ # Ensure transforms are applied so bounding box calculations are correct
38
+ bpy.ops.object.select_all(action='DESELECT')
39
+ mesh_obj.select_set(True)
40
+ bpy.context.view_layer.objects.active = mesh_obj
41
+ bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
42
+
43
+ # 4. Calculate bounding box
44
+ bbox = [mesh_obj.matrix_world @ mathutils.Vector(corner) for corner in mesh_obj.bound_box]
45
+ min_x = min([v.x for v in bbox])
46
+ max_x = max([v.x for v in bbox])
47
+ min_y = min([v.y for v in bbox])
48
+ max_y = max([v.y for v in bbox])
49
+ min_z = min([v.z for v in bbox])
50
+ max_z = max([v.z for v in bbox])
51
+
52
+ width = max_x - min_x
53
+ depth = max_y - min_y
54
+ height = max_z - min_z
55
+ center_x = (min_x + max_x) / 2.0
56
+ center_y = (min_y + max_y) / 2.0
57
+
58
+ print(f"[Blender Rigging] Mesh Bounds: Height={height:.4f}, Width={width:.4f}, Depth={depth:.4f}")
59
+ print(f"[Blender Rigging] Mesh Center: X={center_x:.4f}, Y={center_y:.4f}, Min Z={min_z:.4f}")
60
+
61
+ # 5. Define relative humanoid bone joints
62
+ # Let's map joints relative to the bounding box
63
+ # z positions
64
+ z_hips = min_z + height * 0.53
65
+ z_spine = min_z + height * 0.63
66
+ z_chest = min_z + height * 0.75
67
+ z_neck = min_z + height * 0.83
68
+ z_head = min_z + height * 0.95
69
+
70
+ # Arm z positions
71
+ z_shoulder = min_z + height * 0.77
72
+ z_elbow = min_z + height * 0.75
73
+ z_wrist = min_z + height * 0.73
74
+ z_hand = min_z + height * 0.71
75
+
76
+ # Leg z positions
77
+ z_hip_joint = min_z + height * 0.49
78
+ z_knee = min_z + height * 0.27
79
+ z_ankle = min_z + height * 0.07
80
+ z_foot_tip = min_z + height * 0.01
81
+
82
+ # X offsets (width-based) relative to center_x
83
+ x_offset_leg = width * 0.14
84
+ x_offset_shoulder = width * 0.12
85
+ x_offset_elbow = width * 0.32
86
+ x_offset_wrist = width * 0.47
87
+ x_offset_hand = width * 0.54
88
+
89
+ # Left side X coordinates (negative X direction in standard front view)
90
+ x_leg_l = center_x - x_offset_leg
91
+ x_shoulder_l = center_x - x_offset_shoulder
92
+ x_elbow_l = center_x - x_offset_elbow
93
+ x_wrist_l = center_x - x_offset_wrist
94
+ x_hand_l = center_x - x_offset_hand
95
+
96
+ # Right side X coordinates (positive X direction in standard front view)
97
+ x_leg_r = center_x + x_offset_leg
98
+ x_shoulder_r = center_x + x_offset_shoulder
99
+ x_elbow_r = center_x + x_offset_elbow
100
+ x_wrist_r = center_x + x_offset_wrist
101
+ x_hand_r = center_x + x_offset_hand
102
+
103
+ # Y offsets (depth-based)
104
+ y_center = center_y
105
+ y_foot_tip = center_y - depth * 0.25 # foot goes forward (negative Y in Blender)
106
+
107
+ # Joint positions dictionaries
108
+ joints = {
109
+ "Hips": ((center_x, y_center, z_hips), (center_x, y_center, z_spine)),
110
+ "Spine": ((center_x, y_center, z_spine), (center_x, y_center, z_chest)),
111
+ "Chest": ((center_x, y_center, z_chest), (center_x, y_center, z_neck)),
112
+ "Neck": ((center_x, y_center, z_neck), (center_x, y_center, z_head)),
113
+
114
+ # Left Arm
115
+ "LeftShoulder": ((center_x, y_center, z_shoulder), (x_shoulder_l, y_center, z_shoulder)),
116
+ "LeftArm": ((x_shoulder_l, y_center, z_shoulder), (x_elbow_l, y_center, z_elbow)),
117
+ "LeftForeArm": ((x_elbow_l, y_center, z_elbow), (x_wrist_l, y_center, z_wrist)),
118
+ "LeftHand": ((x_wrist_l, y_center, z_wrist), (x_hand_l, y_center, z_hand)),
119
+
120
+ # Right Arm
121
+ "RightShoulder": ((center_x, y_center, z_shoulder), (x_shoulder_r, y_center, z_shoulder)),
122
+ "RightArm": ((x_shoulder_r, y_center, z_shoulder), (x_elbow_r, y_center, z_elbow)),
123
+ "RightForeArm": ((x_elbow_r, y_center, z_elbow), (x_wrist_r, y_center, z_wrist)),
124
+ "RightHand": ((x_wrist_r, y_center, z_wrist), (x_hand_r, y_center, z_hand)),
125
+
126
+ # Left Leg
127
+ "LeftUpLeg": ((x_leg_l, y_center, z_hip_joint), (x_leg_l, y_center, z_knee)),
128
+ "LeftLeg": ((x_leg_l, y_center, z_knee), (x_leg_l, y_center, z_ankle)),
129
+ "LeftFoot": ((x_leg_l, y_center, z_ankle), (x_leg_l, y_foot_tip, z_foot_tip)),
130
+
131
+ # Right Leg
132
+ "RightUpLeg": ((x_leg_r, y_center, z_hip_joint), (x_leg_r, y_center, z_knee)),
133
+ "RightLeg": ((x_leg_r, y_center, z_knee), (x_leg_r, y_center, z_ankle)),
134
+ "RightFoot": ((x_leg_r, y_center, z_ankle), (x_leg_r, y_foot_tip, z_foot_tip))
135
+ }
136
+
137
+ # 6. Create Armature
138
+ print("[Blender Rigging] Creating Armature...")
139
+ arm_data = bpy.data.armatures.new(name="HumanoidArmature")
140
+ rig_obj = bpy.data.objects.new(name="HumanoidRig", object_data=arm_data)
141
+ bpy.context.scene.collection.objects.link(rig_obj)
142
+
143
+ bpy.context.view_layer.objects.active = rig_obj
144
+ bpy.ops.object.mode_set(mode='EDIT')
145
+
146
+ # Build bones
147
+ edit_bones = arm_data.edit_bones
148
+ bone_objects = {}
149
+ for name, (head, tail) in joints.items():
150
+ bone = edit_bones.new(name)
151
+ bone.head = head
152
+ bone.tail = tail
153
+ bone_objects[name] = bone
154
+
155
+ # Setup hierarchy
156
+ bone_objects["Spine"].parent = bone_objects["Hips"]
157
+ bone_objects["Chest"].parent = bone_objects["Spine"]
158
+ bone_objects["Neck"].parent = bone_objects["Chest"]
159
+
160
+ # Left Arm hierarchy
161
+ bone_objects["LeftShoulder"].parent = bone_objects["Chest"]
162
+ bone_objects["LeftArm"].parent = bone_objects["LeftShoulder"]
163
+ bone_objects["LeftForeArm"].parent = bone_objects["LeftArm"]
164
+ bone_objects["LeftHand"].parent = bone_objects["LeftForeArm"]
165
+
166
+ # Right Arm hierarchy
167
+ bone_objects["RightShoulder"].parent = bone_objects["Chest"]
168
+ bone_objects["RightArm"].parent = bone_objects["RightShoulder"]
169
+ bone_objects["RightForeArm"].parent = bone_objects["RightArm"]
170
+ bone_objects["RightHand"].parent = bone_objects["RightForeArm"]
171
+
172
+ # Left Leg hierarchy
173
+ bone_objects["LeftUpLeg"].parent = bone_objects["Hips"]
174
+ bone_objects["LeftLeg"].parent = bone_objects["LeftUpLeg"]
175
+ bone_objects["LeftFoot"].parent = bone_objects["LeftLeg"]
176
+
177
+ # Right Leg hierarchy
178
+ bone_objects["RightUpLeg"].parent = bone_objects["Hips"]
179
+ bone_objects["RightLeg"].parent = bone_objects["RightUpLeg"]
180
+ bone_objects["RightFoot"].parent = bone_objects["RightLeg"]
181
+
182
+ bpy.ops.object.mode_set(mode='OBJECT')
183
+
184
+ # 7. Parent mesh to Armature using a Voxel Proxy for 100% reliable weighting
185
+ print("[Blender Rigging] Creating watertight Voxel Proxy mesh for auto-weighting calculation...")
186
+
187
+ # Create a duplicate of the mesh to act as proxy
188
+ bpy.ops.object.select_all(action='DESELECT')
189
+ mesh_obj.select_set(True)
190
+ bpy.context.view_layer.objects.active = mesh_obj
191
+ bpy.ops.object.duplicate(linked=False)
192
+ proxy_obj = bpy.context.active_object
193
+ proxy_obj.name = "VoxelProxyMesh"
194
+
195
+ # Apply Voxel Remesh on the proxy to make it a single manifold shell
196
+ bbox_size = max(proxy_obj.dimensions)
197
+ voxel_size = max(0.003, bbox_size / 150.0)
198
+ print(f"[Blender Rigging] Remeshing proxy with voxel size: {voxel_size:.4f}")
199
+
200
+ try:
201
+ proxy_obj.data.remesh_voxel_size = voxel_size
202
+ bpy.ops.object.voxel_remesh()
203
+ print("[Blender Rigging] ✓ Voxel Remesh completed on proxy.")
204
+ except Exception as re_err:
205
+ print(f"[Blender Rigging] Voxel Remesh failed on proxy: {re_err}")
206
+
207
+ # Parent the proxy mesh to the armature with automatic weights
208
+ bpy.ops.object.select_all(action='DESELECT')
209
+ proxy_obj.select_set(True)
210
+ rig_obj.select_set(True)
211
+ bpy.context.view_layer.objects.active = rig_obj
212
+
213
+ proxy_rig_success = False
214
+ try:
215
+ bpy.ops.object.parent_set(type='ARMATURE_AUTO')
216
+ print("[Blender Rigging] ✓ Parented Voxel Proxy with automatic weights successfully!")
217
+ proxy_rig_success = True
218
+ except Exception as parent_err:
219
+ print(f"[Blender Rigging] Error: Auto weighting failed even on proxy: {parent_err}")
220
+
221
+ if proxy_rig_success:
222
+ # Transfer the weights from the proxy mesh to the original mesh_obj
223
+ print("[Blender Rigging] Transferring skin weights from Voxel Proxy to original detailed mesh...")
224
+
225
+ # Parent to Armature with empty groups (creates all vertex groups named after bones)
226
+ # Armature MUST be the active object for this parenting to work and create vertex groups
227
+ bpy.ops.object.select_all(action='DESELECT')
228
+ mesh_obj.select_set(True)
229
+ rig_obj.select_set(True)
230
+ bpy.context.view_layer.objects.active = rig_obj
231
+ bpy.ops.object.parent_set(type='ARMATURE_NAME')
232
+
233
+ # Now make mesh_obj the active object to configure and apply the modifier
234
+ bpy.ops.object.select_all(action='DESELECT')
235
+ mesh_obj.select_set(True)
236
+ bpy.context.view_layer.objects.active = mesh_obj
237
+
238
+ # Create Data Transfer modifier on original mesh
239
+ dt_mod = mesh_obj.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
240
+ dt_mod.object = proxy_obj
241
+ dt_mod.use_vert_data = True
242
+ dt_mod.data_types_verts = {'VGROUP_WEIGHTS'}
243
+ dt_mod.vert_mapping = 'POLYINTERP_NEAREST'
244
+
245
+ # Generate data layout (ensures vertex groups are populated)
246
+ bpy.ops.object.datalayout_transfer(modifier="WeightTransfer")
247
+
248
+ # Apply the Data Transfer modifier to bake the weights into the mesh_obj's vertex groups
249
+ bpy.ops.object.modifier_apply(modifier="WeightTransfer")
250
+ print("[Blender Rigging] ✓ Weights transferred successfully.")
251
+
252
+ # Delete the proxy object
253
+ bpy.ops.object.select_all(action='DESELECT')
254
+ proxy_obj.select_set(True)
255
+ bpy.ops.object.delete()
256
+ print("[Blender Rigging] Deleted temporary Voxel Proxy.")
257
+ else:
258
+ # Fallback to direct parenting with empty weights if proxy rigging failed
259
+ print("[Blender Rigging] Falling back to default empty weights parenting on original mesh...")
260
+ bpy.ops.object.select_all(action='DESELECT')
261
+ mesh_obj.select_set(True)
262
+ rig_obj.select_set(True)
263
+ bpy.context.view_layer.objects.active = rig_obj
264
+ bpy.ops.object.parent_set(type='ARMATURE')
265
+
266
+ # 8. Export rigged model to FBX
267
+ print(f"[Blender Rigging] Exporting rigged model to FBX: {output_path}")
268
+ bpy.ops.object.select_all(action='DESELECT')
269
+ mesh_obj.select_set(True)
270
+ rig_obj.select_set(True)
271
+
272
+ try:
273
+ bpy.ops.export_scene.fbx(
274
+ filepath=output_path,
275
+ use_selection=True,
276
+ path_mode='COPY',
277
+ embed_textures=True,
278
+ add_leaf_bones=False
279
+ )
280
+ print("[Blender Rigging] ✓ Rigged FBX exported successfully.")
281
+ except Exception as fbx_err:
282
+ print(f"[Blender Rigging] Error: FBX export failed: {fbx_err}")
283
+ sys.exit(1)
284
+
285
+ if __name__ == "__main__":
286
+ try:
287
+ args_idx = sys.argv.index("--")
288
+ args = sys.argv[args_idx + 1:]
289
+ except ValueError:
290
+ args = []
291
+
292
+ if len(args) < 2:
293
+ print("[Error] Usage: blender --background --python rig_mesh_blender.py -- <input_glb> <output_fbx>")
294
+ sys.exit(1)
295
+
296
+ run_blender_rigging(args[0], args[1])
rig_quadruped_blender.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ try:
5
+ import bpy
6
+ import mathutils
7
+ except ImportError:
8
+ print("[Error] This script must be run inside Blender's python environment.")
9
+ sys.exit(1)
10
+
11
+ def run_blender_rigging(input_path, output_path):
12
+ print(f"[Blender Rigging] Loading model: {input_path}")
13
+
14
+ # 1. Reset scene
15
+ bpy.ops.wm.read_factory_settings(use_empty=True)
16
+
17
+ # 2. Import GLB
18
+ bpy.ops.import_scene.gltf(filepath=input_path)
19
+
20
+ # 3. Find all mesh objects and join them into a single character mesh
21
+ mesh_objs = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
22
+ if not mesh_objs:
23
+ print("[Error] No mesh found in imported GLTF.")
24
+ sys.exit(1)
25
+
26
+ print(f"[Blender Rigging] Found {len(mesh_objs)} meshes. Joining them...")
27
+ bpy.ops.object.select_all(action='DESELECT')
28
+ for obj in mesh_objs:
29
+ obj.select_set(True)
30
+ bpy.context.view_layer.objects.active = mesh_objs[0]
31
+ bpy.ops.object.join()
32
+
33
+ mesh_obj = bpy.context.active_object
34
+ mesh_obj.name = "CharacterMesh"
35
+ print(f"[Blender Rigging] Joined mesh: {mesh_obj.name}")
36
+
37
+ # Ensure transforms are applied so bounding box calculations are correct
38
+ bpy.ops.object.select_all(action='DESELECT')
39
+ mesh_obj.select_set(True)
40
+ bpy.context.view_layer.objects.active = mesh_obj
41
+ bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
42
+
43
+ # 4. Calculate bounding box
44
+ bbox = [mesh_obj.matrix_world @ mathutils.Vector(corner) for corner in mesh_obj.bound_box]
45
+ min_x = min([v.x for v in bbox])
46
+ max_x = max([v.x for v in bbox])
47
+ min_y = min([v.y for v in bbox])
48
+ max_y = max([v.y for v in bbox])
49
+ min_z = min([v.z for v in bbox])
50
+ max_z = max([v.z for v in bbox])
51
+
52
+ width = max_x - min_x
53
+ depth = max_y - min_y
54
+ height = max_z - min_z
55
+ center_x = (min_x + max_x) / 2.0
56
+ center_y = (min_y + max_y) / 2.0
57
+
58
+ print(f"[Blender Rigging] Mesh Bounds: Height={height:.4f}, Width={width:.4f}, Depth={depth:.4f}")
59
+ print(f"[Blender Rigging] Mesh Center: X={center_x:.4f}, Y={center_y:.4f}, Min Z={min_z:.4f}")
60
+
61
+ # 5. Define relative quadruped bone joints
62
+ # positive Y in Blender goes back, negative Y goes front
63
+ z_spine = min_z + height * 0.65
64
+ z_head = min_z + height * 0.85
65
+ z_neck = min_z + height * 0.75
66
+ z_rear_hip = min_z + height * 0.60
67
+ z_rear_knee = min_z + height * 0.35
68
+ z_rear_ankle = min_z + height * 0.12
69
+ z_rear_foot = min_z + height * 0.02
70
+
71
+ z_front_shoulder = min_z + height * 0.62
72
+ z_front_elbow = min_z + height * 0.35
73
+ z_front_wrist = min_z + height * 0.12
74
+ z_front_foot = min_z + height * 0.02
75
+
76
+ y_rear = center_y + depth * 0.28
77
+ y_front = center_y - depth * 0.25
78
+ y_head = center_y - depth * 0.42
79
+ y_neck = center_y - depth * 0.30
80
+ y_tail_base = center_y + depth * 0.32
81
+ y_tail_tip = center_y + depth * 0.55
82
+
83
+ x_offset_rear = width * 0.22
84
+ x_offset_front = width * 0.22
85
+
86
+ x_rear_l = center_x - x_offset_rear
87
+ x_rear_r = center_x + x_offset_rear
88
+ x_front_l = center_x - x_offset_front
89
+ x_front_r = center_x + x_offset_front
90
+
91
+ joints = {
92
+ # Spine & Head
93
+ "Pelvis": ((center_x, y_rear, z_spine), (center_x, center_y, z_spine)),
94
+ "Spine": ((center_x, center_y, z_spine), (center_x, y_front, z_spine)),
95
+ "Neck": ((center_x, y_front, z_spine), (center_x, y_neck, z_neck)),
96
+ "Head": ((center_x, y_neck, z_neck), (center_x, y_head, z_head)),
97
+ "Tail": ((center_x, y_tail_base, z_spine), (center_x, y_tail_tip, z_spine - height * 0.1)),
98
+
99
+ # Left Rear Leg
100
+ "LeftUpLegRear": ((x_rear_l, y_rear, z_rear_hip), (x_rear_l, y_rear, z_rear_knee)),
101
+ "LeftLegRear": ((x_rear_l, y_rear, z_rear_knee), (x_rear_l, y_rear, z_rear_ankle)),
102
+ "LeftFootRear": ((x_rear_l, y_rear, z_rear_ankle), (x_rear_l, y_rear - depth * 0.05, z_rear_foot)),
103
+
104
+ # Right Rear Leg
105
+ "RightUpLegRear": ((x_rear_r, y_rear, z_rear_hip), (x_rear_r, y_rear, z_rear_knee)),
106
+ "RightLegRear": ((x_rear_r, y_rear, z_rear_knee), (x_rear_r, y_rear, z_rear_ankle)),
107
+ "RightFootRear": ((x_rear_r, y_rear, z_rear_ankle), (x_rear_r, y_rear - depth * 0.05, z_rear_foot)),
108
+
109
+ # Left Front Leg
110
+ "LeftUpLegFront": ((x_front_l, y_front, z_front_shoulder), (x_front_l, y_front, z_front_elbow)),
111
+ "LeftLegFront": ((x_front_l, y_front, z_front_elbow), (x_front_l, y_front, z_front_wrist)),
112
+ "LeftFootFront": ((x_front_l, y_front, z_front_wrist), (x_front_l, y_front - depth * 0.05, z_front_foot)),
113
+
114
+ # Right Front Leg
115
+ "RightUpLegFront": ((x_front_r, y_front, z_front_shoulder), (x_front_r, y_front, z_front_elbow)),
116
+ "RightLegFront": ((x_front_r, y_front, z_front_elbow), (x_front_r, y_front, z_front_wrist)),
117
+ "RightFootFront": ((x_front_r, y_front, z_front_wrist), (x_front_r, y_front - depth * 0.05, z_front_foot))
118
+ }
119
+
120
+ # 6. Create Armature
121
+ print("[Blender Rigging] Creating Armature...")
122
+ arm_data = bpy.data.armatures.new(name="QuadrupedArmature")
123
+ rig_obj = bpy.data.objects.new(name="QuadrupedRig", object_data=arm_data)
124
+ bpy.context.scene.collection.objects.link(rig_obj)
125
+
126
+ bpy.context.view_layer.objects.active = rig_obj
127
+ bpy.ops.object.mode_set(mode='EDIT')
128
+
129
+ edit_bones = arm_data.edit_bones
130
+ bone_objects = {}
131
+ for name, (head, tail) in joints.items():
132
+ bone = edit_bones.new(name)
133
+ bone.head = head
134
+ bone.tail = tail
135
+ bone_objects[name] = bone
136
+
137
+ # Setup hierarchy
138
+ bone_objects["Spine"].parent = bone_objects["Pelvis"]
139
+ bone_objects["Neck"].parent = bone_objects["Spine"]
140
+ bone_objects["Head"].parent = bone_objects["Neck"]
141
+ bone_objects["Tail"].parent = bone_objects["Pelvis"]
142
+
143
+ # Rear legs hierarchy
144
+ bone_objects["LeftUpLegRear"].parent = bone_objects["Pelvis"]
145
+ bone_objects["LeftLegRear"].parent = bone_objects["LeftUpLegRear"]
146
+ bone_objects["LeftFootRear"].parent = bone_objects["LeftLegRear"]
147
+
148
+ bone_objects["RightUpLegRear"].parent = bone_objects["Pelvis"]
149
+ bone_objects["RightLegRear"].parent = bone_objects["RightUpLegRear"]
150
+ bone_objects["RightFootRear"].parent = bone_objects["RightLegRear"]
151
+
152
+ # Front legs hierarchy
153
+ bone_objects["LeftUpLegFront"].parent = bone_objects["Spine"]
154
+ bone_objects["LeftLegFront"].parent = bone_objects["LeftUpLegFront"]
155
+ bone_objects["LeftFootFront"].parent = bone_objects["LeftLegFront"]
156
+
157
+ bone_objects["RightUpLegFront"].parent = bone_objects["Spine"]
158
+ bone_objects["RightLegFront"].parent = bone_objects["RightUpLegFront"]
159
+ bone_objects["RightFootFront"].parent = bone_objects["RightLegFront"]
160
+
161
+ bpy.ops.object.mode_set(mode='OBJECT')
162
+
163
+ # 7. Parent mesh to Armature using a Voxel Proxy for 100% reliable weighting
164
+ print("[Blender Rigging] Creating watertight Voxel Proxy mesh for auto-weighting calculation...")
165
+ bpy.ops.object.select_all(action='DESELECT')
166
+ mesh_obj.select_set(True)
167
+ bpy.context.view_layer.objects.active = mesh_obj
168
+ bpy.ops.object.duplicate(linked=False)
169
+ proxy_obj = bpy.context.active_object
170
+ proxy_obj.name = "VoxelProxyMesh"
171
+
172
+ bbox_size = max(proxy_obj.dimensions)
173
+ voxel_size = max(0.003, bbox_size / 150.0)
174
+ print(f"[Blender Rigging] Remeshing proxy with voxel size: {voxel_size:.4f}")
175
+
176
+ try:
177
+ proxy_obj.data.remesh_voxel_size = voxel_size
178
+ bpy.ops.object.voxel_remesh()
179
+ print("[Blender Rigging] ✓ Voxel Remesh completed on proxy.")
180
+ except Exception as re_err:
181
+ print(f"[Blender Rigging] Voxel Remesh failed on proxy: {re_err}")
182
+
183
+ bpy.ops.object.select_all(action='DESELECT')
184
+ proxy_obj.select_set(True)
185
+ rig_obj.select_set(True)
186
+ bpy.context.view_layer.objects.active = rig_obj
187
+
188
+ proxy_rig_success = False
189
+ try:
190
+ bpy.ops.object.parent_set(type='ARMATURE_AUTO')
191
+ print("[Blender Rigging] ✓ Parented Voxel Proxy with automatic weights successfully!")
192
+ proxy_rig_success = True
193
+ except Exception as parent_err:
194
+ print(f"[Blender Rigging] Error: Auto weighting failed even on proxy: {parent_err}")
195
+
196
+ if proxy_rig_success:
197
+ print("[Blender Rigging] Transferring skin weights from Voxel Proxy to original detailed mesh...")
198
+ bpy.ops.object.select_all(action='DESELECT')
199
+ mesh_obj.select_set(True)
200
+ rig_obj.select_set(True)
201
+ bpy.context.view_layer.objects.active = rig_obj
202
+ bpy.ops.object.parent_set(type='ARMATURE_NAME')
203
+
204
+ bpy.ops.object.select_all(action='DESELECT')
205
+ mesh_obj.select_set(True)
206
+ bpy.context.view_layer.objects.active = mesh_obj
207
+
208
+ dt_mod = mesh_obj.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
209
+ dt_mod.object = proxy_obj
210
+ dt_mod.use_vert_data = True
211
+ dt_mod.data_types_verts = {'VGROUP_WEIGHTS'}
212
+ dt_mod.vert_mapping = 'POLYINTERP_NEAREST'
213
+
214
+ bpy.ops.object.datalayout_transfer(modifier="WeightTransfer")
215
+ bpy.ops.object.modifier_apply(modifier="WeightTransfer")
216
+ print("[Blender Rigging] ✓ Weights transferred successfully.")
217
+
218
+ bpy.ops.object.select_all(action='DESELECT')
219
+ proxy_obj.select_set(True)
220
+ bpy.ops.object.delete()
221
+ print("[Blender Rigging] Deleted temporary Voxel Proxy.")
222
+ else:
223
+ print("[Blender Rigging] Falling back to default empty weights parenting on original mesh...")
224
+ bpy.ops.object.select_all(action='DESELECT')
225
+ mesh_obj.select_set(True)
226
+ rig_obj.select_set(True)
227
+ bpy.context.view_layer.objects.active = rig_obj
228
+ bpy.ops.object.parent_set(type='ARMATURE')
229
+
230
+ # 8. Export rigged model to FBX
231
+ print(f"[Blender Rigging] Exporting rigged model to FBX: {output_path}")
232
+ bpy.ops.object.select_all(action='DESELECT')
233
+ mesh_obj.select_set(True)
234
+ rig_obj.select_set(True)
235
+
236
+ try:
237
+ bpy.ops.export_scene.fbx(
238
+ filepath=output_path,
239
+ use_selection=True,
240
+ path_mode='COPY',
241
+ embed_textures=True,
242
+ add_leaf_bones=False
243
+ )
244
+ print("[Blender Rigging] ✓ Rigged FBX exported successfully.")
245
+ except Exception as fbx_err:
246
+ print(f"[Blender Rigging] Error: FBX export failed: {fbx_err}")
247
+ sys.exit(1)
248
+
249
+ if __name__ == "__main__":
250
+ try:
251
+ args_idx = sys.argv.index("--")
252
+ args = sys.argv[args_idx + 1:]
253
+ except ValueError:
254
+ args = []
255
+
256
+ if len(args) < 2:
257
+ print("[Error] Usage: blender --background --python rig_quadruped_blender.py -- <input_glb> <output_fbx>")
258
+ sys.exit(1)
259
+
260
+ run_blender_rigging(args[0], args[1])
server.py ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import base64
5
+ import tempfile
6
+ import shutil
7
+ import time
8
+ from http.server import SimpleHTTPRequestHandler, HTTPServer
9
+ from gradio_client import Client, handle_file
10
+
11
+ if hasattr(sys.stdout, "reconfigure"):
12
+ sys.stdout.reconfigure(encoding="utf-8")
13
+
14
+ # Simple environment loader to avoid external dependencies
15
+ def load_dotenv():
16
+ env_path = '.env'
17
+ if os.path.exists(env_path):
18
+ print("[Backend] Loading configuration from .env...")
19
+ with open(env_path, 'r', encoding='utf-8') as f:
20
+ for line in f:
21
+ line = line.strip()
22
+ if line and not line.startswith('#') and '=' in line:
23
+ key, val = line.split('=', 1)
24
+ os.environ[key.strip()] = val.strip()
25
+
26
+ # Initialize configuration
27
+ load_dotenv()
28
+ HF_TOKEN = os.environ.get('HF_TOKEN', '')
29
+ HF_SPACE = os.environ.get('HF_SPACE', 'LogicalTrue/TRELLIS.2')
30
+ PORT = int(os.environ.get('PORT', '8000'))
31
+
32
+ if not HF_TOKEN or HF_TOKEN == 'PON_TU_TOKEN_AQUI':
33
+ print("\n[⚠️ WARNING] HF_TOKEN is not configured or has default placeholder value in .env file.")
34
+ print("Please open the '.env' file and insert your Hugging Face Token (hf_...) to access your private Space.\n")
35
+
36
+ class F23DHTTPRequestHandler(SimpleHTTPRequestHandler):
37
+ def end_headers(self):
38
+ self.send_header('Access-Control-Allow-Origin', '*')
39
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
40
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
41
+ self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
42
+ self.send_header('Pragma', 'no-cache')
43
+ self.send_header('Expires', '0')
44
+ super().end_headers()
45
+
46
+ def do_OPTIONS(self):
47
+ self.send_response(200, "OK")
48
+ self.end_headers()
49
+
50
+ def do_GET(self):
51
+ if self.path == '/api/gallery':
52
+ self.handle_get_gallery()
53
+ else:
54
+ super().do_GET()
55
+
56
+ def do_POST(self):
57
+ if self.path == '/api/generate-3d':
58
+ self.handle_generate_3d()
59
+ elif self.path == '/api/optimize-3d':
60
+ self.handle_optimize_3d()
61
+ elif self.path == '/api/rig-3d':
62
+ self.handle_rig_3d()
63
+ elif self.path == '/api/generate-2d':
64
+ self.handle_generate_2d()
65
+ elif self.path == '/api/delete-gallery':
66
+ self.handle_delete_gallery()
67
+ else:
68
+ self.send_error(404, "Endpoint not found")
69
+
70
+ def handle_generate_3d(self):
71
+ try:
72
+ load_dotenv() # Reload env dynamically to pick up any changes (like BLENDER_PATH) without restarting
73
+ content_length = int(self.headers['Content-Length'])
74
+ post_data = self.rfile.read(content_length)
75
+ params = json.loads(post_data.decode('utf-8'))
76
+
77
+ version = params.get('version', 'v2')
78
+ image_data_b64 = params.get('image') # Base64 encoded image
79
+ hf_token = params.get('token', '')
80
+ seed = float(params.get('seed', 0))
81
+ resolution = params.get('resolution', '1024')
82
+ decimation_target = int(params.get('decimation_target', 300000))
83
+ texture_size = int(params.get('texture_size', 2048))
84
+ ss_guidance = float(params.get('ss_guidance', 7.5))
85
+ ss_steps = int(params.get('ss_steps', 12))
86
+ slat_guidance = float(params.get('slat_guidance', 3.0))
87
+ slat_steps = int(params.get('slat_steps', 12))
88
+ auto_optimize = params.get('auto_optimize', False)
89
+ quad_target_faces = int(params.get('quad_target_faces', 60000))
90
+ remesh_method = params.get('remeshMethod', 'tris_to_quads')
91
+
92
+ if not image_data_b64:
93
+ self.send_error_response(400, "No image data provided")
94
+ return
95
+
96
+ # Extract base64 binary
97
+ if ',' in image_data_b64:
98
+ image_data_b64 = image_data_b64.split(',')[1]
99
+ image_bytes = base64.b64decode(image_data_b64)
100
+
101
+ # Save to temporary file
102
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as temp_img:
103
+ temp_img.write(image_bytes)
104
+ temp_img_path = temp_img.name
105
+
106
+ # Setup Connection options using request token (takes precedence) or environment token fallback
107
+ connect_options = {}
108
+ current_token = os.environ.get('HF_TOKEN', '')
109
+ token_to_use = hf_token if hf_token else (current_token if (current_token and current_token != 'PON_TU_TOKEN_AQUI') else '')
110
+ if token_to_use:
111
+ print(f"[Backend] Authenticating with HF Token (ends with ...{token_to_use[-6:] if len(token_to_use) > 6 else ''})")
112
+ connect_options['token'] = token_to_use
113
+ else:
114
+ print("[Backend] WARNING: No HF Token provided. Connecting anonymously (shared IP quota).")
115
+
116
+ client = None
117
+ try:
118
+ # We use the user's private Space as configured in .env
119
+ target_space = os.environ.get('HF_SPACE', 'LogicalTrue/TRELLIS.2')
120
+ print(f"[Backend] Connecting to Private Hugging Face Space: '{target_space}'")
121
+
122
+ client = Client(target_space, **connect_options)
123
+
124
+ # Initialize remote session (mandatory for TRELLIS to create session directories)
125
+ try:
126
+ print("[Backend] Initializing remote session...")
127
+ client.predict(api_name="/start_session")
128
+ print("[Backend] Remote session initialized successfully.")
129
+ except Exception as se:
130
+ print(f"[Backend] Remote session initialization warning: {se}")
131
+
132
+ # Defensive Preprocessing
133
+ preprocessed_img_path = temp_img_path
134
+ try:
135
+ print("[Backend] Step 0: Preprocessing image...")
136
+ preprocess_result = client.predict(handle_file(temp_img_path), api_name="/preprocess_image")
137
+ path_val = preprocess_result.get('path') if isinstance(preprocess_result, dict) else preprocess_result
138
+ if path_val:
139
+ preprocessed_img_path = path_val
140
+ print(f"[Backend] Preprocess successful: {preprocessed_img_path}")
141
+ except Exception as pe:
142
+ print(f"[Backend] Preprocessing failed: {pe}. Using raw image path instead.")
143
+
144
+ print("[Backend] Step 1: Converting image to 3D representation...")
145
+ job = client.submit(
146
+ handle_file(preprocessed_img_path), # wrapped in handle_file for schema validation
147
+ seed,
148
+ resolution, # resolution
149
+ ss_guidance,
150
+ 0.7, # ss_guidance_rescale
151
+ ss_steps,
152
+ 5.0, # ss_rescale_t
153
+ slat_guidance, # shape_slat_guidance_strength
154
+ 0.5, # shape_slat_guidance_rescale
155
+ slat_steps, # shape_slat_sampling_steps
156
+ 3.0, # shape_slat_rescale_t
157
+ 1.0, # tex_slat_guidance_strength
158
+ 0.0, # tex_slat_guidance_rescale
159
+ 12, # tex_slat_sampling_steps
160
+ 3.0, # tex_slat_rescale_t
161
+ api_name="/image_to_3d"
162
+ )
163
+ job.result() # Wait for step 1 to complete in background task
164
+
165
+ print("[Backend] Step 2: Extracting PBR textures and GLB file...")
166
+ extract_job = client.submit(
167
+ decimation_target, # decimation_target
168
+ texture_size, # texture_size
169
+ api_name="/extract_glb"
170
+ )
171
+ extract_result = extract_job.result()
172
+
173
+ # Check data structure in result object
174
+ if hasattr(extract_result, 'data') and extract_result.data and len(extract_result.data) >= 2:
175
+ gltf_file = extract_result.data[0]
176
+ glb_file = extract_result.data[1]
177
+ elif isinstance(extract_result, (list, tuple)) and len(extract_result) >= 2:
178
+ gltf_file = extract_result[0]
179
+ glb_file = extract_result[1]
180
+ else:
181
+ print(f"[Backend] Extract result: {extract_result}")
182
+ raise Exception("Private Space extract_glb did not return files in expected format")
183
+
184
+ # Define local output target for serving files statically
185
+ output_dir = os.path.join(os.path.dirname(__file__), "generated_models")
186
+ os.makedirs(output_dir, exist_ok=True)
187
+
188
+ # Extract local paths
189
+ gltf_local_path = gltf_file.get('path') if isinstance(gltf_file, dict) else (gltf_file if isinstance(gltf_file, str) else None)
190
+ glb_local_path = glb_file.get('path') if isinstance(glb_file, dict) else (glb_file if isinstance(glb_file, str) else None)
191
+
192
+ # Copy GLB to static directory to make it accessible to browser
193
+ import time
194
+ filename = f"model_{int(time.time())}.glb"
195
+ dest_path = os.path.join(output_dir, filename)
196
+
197
+ if glb_local_path and os.path.exists(glb_local_path):
198
+ shutil.copy(glb_local_path, dest_path)
199
+ print(f"[Backend] Saved generated model to: {dest_path}")
200
+
201
+ # Automated background Blender quad-remeshing post-processing
202
+ if auto_optimize:
203
+ blender_path = os.environ.get('BLENDER_PATH', '')
204
+ if not blender_path or not os.path.exists(blender_path):
205
+ blender_path = shutil.which("blender") or ""
206
+ if blender_path and os.path.exists(blender_path):
207
+ print(f"[Backend] Auto-optimizing mesh in background with Blender...")
208
+ import subprocess
209
+ temp_dest_path = dest_path.replace(".glb", "_dirty.glb")
210
+ try:
211
+ os.rename(dest_path, temp_dest_path)
212
+ script_path = os.path.join(os.path.dirname(__file__), "clean_mesh_blender.py")
213
+ cmd = [blender_path, "--background", "--python", script_path, "--", temp_dest_path, dest_path, str(quad_target_faces), remesh_method]
214
+ print(f"[Backend] Executing: {' '.join(cmd)}")
215
+ result = subprocess.run(cmd, capture_output=True, text=True)
216
+
217
+ if result.returncode == 0 and os.path.exists(dest_path):
218
+ print(f"[Backend] ✓ Mesh optimized and remeshed to quads successfully!")
219
+ print(f"[Backend] Blender Output:\n{result.stdout}")
220
+ # Keep the raw mesh on disk so the user can re-run optimization locally
221
+ # if os.path.exists(temp_dest_path):
222
+ # os.remove(temp_dest_path)
223
+ else:
224
+ print(f"[Backend] ✗ Blender mesh optimization failed (exit code {result.returncode})")
225
+ print(f"[Backend] Blender Output:\n{result.stdout}\n{result.stderr}")
226
+ if os.path.exists(temp_dest_path):
227
+ if os.path.exists(dest_path):
228
+ os.remove(dest_path)
229
+ os.rename(temp_dest_path, dest_path)
230
+ except Exception as ex:
231
+ print(f"[Backend] ✗ Error running Blender post-processing: {ex}")
232
+ if os.path.exists(temp_dest_path):
233
+ if os.path.exists(dest_path):
234
+ os.remove(dest_path)
235
+ os.rename(temp_dest_path, dest_path)
236
+ else:
237
+ print("[Backend] BLENDER_PATH is not configured or executable not found. Skipping quad-remesh optimization.")
238
+ else:
239
+ print("[Backend] Auto-optimization not requested by client. Skipping quad-remesh.")
240
+
241
+ gltf_url = f"/generated_models/{filename}"
242
+ glb_url = f"/generated_models/{filename}"
243
+ fbx_filename = filename.replace(".glb", ".fbx")
244
+ fbx_url = f"/generated_models/{fbx_filename}" if os.path.exists(os.path.join(output_dir, fbx_filename)) else None
245
+ else:
246
+ gltf_url = gltf_file.get('url') if isinstance(gltf_file, dict) else gltf_local_path
247
+ glb_url = glb_file.get('url') if isinstance(glb_file, dict) else glb_local_path
248
+ fbx_url = None
249
+
250
+ response_data = {
251
+ "gltfUrl": gltf_url,
252
+ "glbUrl": glb_url,
253
+ "fbxUrl": fbx_url
254
+ }
255
+
256
+ # Clean up temp file
257
+ try:
258
+ os.unlink(temp_img_path)
259
+ except:
260
+ pass
261
+
262
+ # Respond with success
263
+ self.send_response(200)
264
+ self.send_header('Content-Type', 'application/json')
265
+ self.end_headers()
266
+ self.wfile.write(json.dumps(response_data).encode('utf-8'))
267
+ finally:
268
+ if client:
269
+ try:
270
+ client.close()
271
+ print("[Backend] Private Space 3D Gradio client closed.")
272
+ except Exception as ce:
273
+ print(f"[Backend] Warning: could not close 3D gradio client: {ce}")
274
+
275
+ except Exception as e:
276
+ print(f"[Backend] Error during 3D generation: {e}")
277
+ self.send_error_response(500, str(e))
278
+
279
+ def handle_optimize_3d(self):
280
+ try:
281
+ load_dotenv()
282
+ content_length = int(self.headers['Content-Length'])
283
+ post_data = self.rfile.read(content_length)
284
+ params = json.loads(post_data.decode('utf-8'))
285
+
286
+ model_url = params.get('modelUrl', '') # e.g. "/generated_models/model_1782268665.glb"
287
+ quad_target_faces = int(params.get('quad_target_faces', 60000))
288
+ remesh_method = params.get('remeshMethod', 'tris_to_quads')
289
+ if not model_url:
290
+ self.send_error_response(400, "No modelUrl provided")
291
+ return
292
+
293
+ filename = os.path.basename(model_url)
294
+ output_dir = os.path.join(os.path.dirname(__file__), "generated_models")
295
+ dest_path = os.path.join(output_dir, filename)
296
+
297
+ if not os.path.exists(dest_path):
298
+ self.send_error_response(404, f"Model file {filename} not found")
299
+ return
300
+
301
+ # Output to a new file to keep the source mesh intact and bypass browser caching
302
+ clean_filename = filename.replace(".glb", "_clean.glb")
303
+ if "_clean" in filename:
304
+ clean_filename = filename
305
+
306
+ clean_dest_path = os.path.join(output_dir, clean_filename)
307
+
308
+ blender_path = os.environ.get('BLENDER_PATH', '')
309
+ if not blender_path or not os.path.exists(blender_path):
310
+ blender_path = shutil.which("blender") or ""
311
+ if blender_path and os.path.exists(blender_path):
312
+ print(f"[Backend] Local optimization requested. Running Blender...")
313
+ import subprocess
314
+ script_path = os.path.join(os.path.dirname(__file__), "clean_mesh_blender.py")
315
+ cmd = [blender_path, "--background", "--python", script_path, "--", dest_path, clean_dest_path, str(quad_target_faces), remesh_method]
316
+ print(f"[Backend] Executing: {' '.join(cmd)}")
317
+ result = subprocess.run(cmd, capture_output=True, text=True)
318
+
319
+ print(f"[Backend] Blender Output:\n{result.stdout}")
320
+ if result.stderr:
321
+ print(f"[Backend] Blender Errors:\n{result.stderr}")
322
+
323
+ fbx_filename = clean_filename.replace(".glb", ".fbx")
324
+ fbx_url = f"/generated_models/{fbx_filename}" if os.path.exists(os.path.join(output_dir, fbx_filename)) else None
325
+
326
+ if result.returncode == 0 and os.path.exists(clean_dest_path):
327
+ response_data = {
328
+ "success": True,
329
+ "glbUrl": f"/generated_models/{clean_filename}",
330
+ "gltfUrl": f"/generated_models/{clean_filename}",
331
+ "fbxUrl": fbx_url
332
+ }
333
+ else:
334
+ raise Exception(f"Blender failed with exit status {result.returncode}")
335
+ else:
336
+ raise Exception("BLENDER_PATH is not configured or executable not found locally.")
337
+
338
+ self.send_response(200)
339
+ self.send_header('Content-Type', 'application/json')
340
+ self.end_headers()
341
+ self.wfile.write(json.dumps(response_data).encode('utf-8'))
342
+
343
+ except Exception as e:
344
+ print(f"[Backend] Error during 3D optimization: {e}")
345
+ self.send_error_response(500, str(e))
346
+
347
+ def handle_rig_3d(self):
348
+ try:
349
+ load_dotenv()
350
+ content_length = int(self.headers['Content-Length'])
351
+ post_data = self.rfile.read(content_length)
352
+ params = json.loads(post_data.decode('utf-8'))
353
+
354
+ model_url = params.get('modelUrl', '') # e.g. "/generated_models/model_1782268665.glb"
355
+ rig_method = params.get('rigMethod', 'ai')
356
+ hf_token = params.get('token', '')
357
+
358
+ if not model_url:
359
+ self.send_error_response(400, "No modelUrl provided")
360
+ return
361
+
362
+ filename = os.path.basename(model_url)
363
+ output_dir = os.path.join(os.path.dirname(__file__), "generated_models")
364
+ dest_path = os.path.join(output_dir, filename)
365
+
366
+ if not os.path.exists(dest_path):
367
+ self.send_error_response(404, f"Model file {filename} not found")
368
+ return
369
+
370
+ # Rigging target paths
371
+ base_name, _ = os.path.splitext(filename)
372
+ rigged_fbx_filename = f"{base_name}_rigged.fbx"
373
+ rigged_fbx_path = os.path.join(output_dir, rigged_fbx_filename)
374
+
375
+ if rig_method == 'ai':
376
+ rigged_glb_filename = f"{base_name}_rigged.glb"
377
+ rigged_glb_path = os.path.join(output_dir, rigged_glb_filename)
378
+
379
+ print(f"[Backend] AI Rigging requested via Hugging Face...")
380
+ # Get the correct token
381
+ current_token = os.environ.get('HF_TOKEN', '')
382
+ # Clean token from spaces/quotes
383
+ hf_token_clean = str(hf_token).strip() if hf_token else ''
384
+ if hf_token_clean in ('null', 'undefined'):
385
+ hf_token_clean = ''
386
+
387
+ token_to_use = hf_token_clean if hf_token_clean else (current_token if (current_token and current_token != 'PON_TU_TOKEN_AQUI') else '')
388
+ token_to_use = token_to_use.strip()
389
+
390
+ print(f"[Backend] Client token length: {len(hf_token_clean)}, Env token length: {len(current_token)}, Token to use length: {len(token_to_use)}")
391
+
392
+ connect_options = {}
393
+ if token_to_use:
394
+ connect_options['token'] = token_to_use
395
+
396
+ # UniRig API call
397
+ from gradio_client import Client, handle_file
398
+
399
+ print(f"[Backend] Connecting to 'LogicalTrue/Unirig'...")
400
+ client = Client("LogicalTrue/Unirig", **connect_options)
401
+
402
+ print(f"[Backend] Submitting {filename} to UniRig...")
403
+ res_path = client.predict(
404
+ handle_file(dest_path), # archivo_3d
405
+ 12345, # seed
406
+ api_name="/rig_mesh"
407
+ )
408
+
409
+ if res_path and os.path.exists(res_path):
410
+ shutil.copy(res_path, rigged_glb_path)
411
+ print(f"[Backend] ✓ AI Rigging completed successfully. Saved to: {rigged_glb_path}")
412
+ response_data = {
413
+ "success": True,
414
+ "riggedFbxUrl": f"/generated_models/{rigged_glb_filename}"
415
+ }
416
+ else:
417
+ raise Exception("AI Rigging failed: could not retrieve the generated rigged GLB model from Hugging Face Space.")
418
+ else:
419
+ # Local procedural rigging using Blender
420
+ blender_path = os.environ.get('BLENDER_PATH', '')
421
+ if not blender_path or not os.path.exists(blender_path):
422
+ blender_path = shutil.which("blender") or ""
423
+ if blender_path and os.path.exists(blender_path):
424
+ script_name = "rig_quadruped_blender.py" if rig_method == "local_quadruped" else "rig_mesh_blender.py"
425
+ print(f"[Backend] Local procedural rigging ({rig_method}) requested. Running Blender with {script_name}...")
426
+ import subprocess
427
+ script_path = os.path.join(os.path.dirname(__file__), script_name)
428
+ cmd = [blender_path, "--background", "--python", script_path, "--", dest_path, rigged_fbx_path]
429
+ print(f"[Backend] Executing: {' '.join(cmd)}")
430
+ result = subprocess.run(cmd, capture_output=True, text=True)
431
+
432
+ print(f"[Backend] Blender Output:\n{result.stdout}")
433
+ if result.stderr:
434
+ print(f"[Backend] Blender Errors:\n{result.stderr}")
435
+
436
+ if result.returncode == 0 and os.path.exists(rigged_fbx_path):
437
+ response_data = {
438
+ "success": True,
439
+ "riggedFbxUrl": f"/generated_models/{rigged_fbx_filename}"
440
+ }
441
+ else:
442
+ raise Exception(f"Blender rigging failed with exit status {result.returncode}")
443
+ else:
444
+ raise Exception("BLENDER_PATH is not configured or executable not found locally.")
445
+
446
+ self.send_response(200)
447
+ self.send_header('Content-Type', 'application/json')
448
+ self.end_headers()
449
+ self.wfile.write(json.dumps(response_data).encode('utf-8'))
450
+
451
+ except Exception as e:
452
+ print(f"[Backend] Error during rigging: {e}")
453
+ self.send_error_response(500, str(e))
454
+
455
+ def handle_generate_2d(self):
456
+ try:
457
+ content_length = int(self.headers['Content-Length'])
458
+ post_data = self.rfile.read(content_length)
459
+ params = json.loads(post_data.decode('utf-8'))
460
+
461
+ prompt = params.get('prompt', '')
462
+ requested_model = params.get('model', 'black-forest-labs/FLUX.1-schnell')
463
+ hf_token = params.get('token', '')
464
+
465
+ if not prompt:
466
+ self.send_error_response(400, "No prompt provided")
467
+ return
468
+
469
+ # Determine token to use (prioritize hf_token from client)
470
+ token_to_use = hf_token if hf_token else (HF_TOKEN if (HF_TOKEN and HF_TOKEN != 'PON_TU_TOKEN_AQUI') else '')
471
+
472
+ from gradio_client import Client as GradioClient
473
+ import shutil
474
+
475
+ # We use public Gradio Spaces running FLUX on hf.space (unblocked by local SNI firewalls)
476
+ spaces_to_try = [
477
+ "black-forest-labs/FLUX.1-schnell",
478
+ "multimodalart/FLUX.1-schnell"
479
+ ]
480
+
481
+ connect_options = {}
482
+ if token_to_use:
483
+ print(f"[Backend 2D] Authenticating with HF Token (ends with ...{token_to_use[-6:] if len(token_to_use) > 6 else ''})")
484
+ connect_options['token'] = token_to_use
485
+ else:
486
+ print("[Backend 2D] WARNING: No HF Token provided. Connecting anonymously.")
487
+
488
+ success = False
489
+ response_data = None
490
+ last_error = None
491
+
492
+ for space_name in spaces_to_try:
493
+ print(f"[Backend] Attempting 2D generation via Gradio Space: {space_name}")
494
+ client = None
495
+ try:
496
+ client = GradioClient(space_name, **connect_options)
497
+
498
+ # Call FLUX Space inference (standard FLUX Schnell API parameters)
499
+ result = client.predict(
500
+ prompt=prompt,
501
+ seed=0,
502
+ randomize_seed=True,
503
+ width=1024,
504
+ height=1024,
505
+ num_inference_steps=4, # FLUX.1-schnell works with 4 steps
506
+ api_name="/infer"
507
+ )
508
+
509
+ # Extract local file path from prediction result
510
+ if isinstance(result, (list, tuple)) and len(result) > 0:
511
+ img_local_path = result[0]
512
+ elif isinstance(result, dict) and 'path' in result:
513
+ img_local_path = result['path']
514
+ else:
515
+ img_local_path = result
516
+
517
+ if img_local_path and os.path.exists(img_local_path):
518
+ with open(img_local_path, "rb") as img_file:
519
+ img_bytes = img_file.read()
520
+
521
+ # Save generated 2D image to local disk
522
+ images_dir = os.path.join(os.path.dirname(__file__), "generated_images")
523
+ os.makedirs(images_dir, exist_ok=True)
524
+ img_filename = f"image_{int(time.time())}.png"
525
+ img_dest_path = os.path.join(images_dir, img_filename)
526
+ with open(img_dest_path, "wb") as f:
527
+ f.write(img_bytes)
528
+ print(f"[Backend] Saved generated image to: {img_dest_path}")
529
+
530
+ img_b64 = base64.b64encode(img_bytes).decode('utf-8')
531
+ response_data = {
532
+ "image": f"data:image/png;base64,{img_b64}",
533
+ "imageUrl": f"/generated_images/{img_filename}",
534
+ "model_used": space_name
535
+ }
536
+ success = True
537
+ break
538
+ else:
539
+ raise Exception(f"File path returned by Space does not exist: {img_local_path}")
540
+ except Exception as ex:
541
+ print(f"[Backend] Failed generating via Space {space_name}: {ex}")
542
+ last_error = str(ex)
543
+ finally:
544
+ if client:
545
+ try:
546
+ client.close()
547
+ print(f"[Backend] Gradio client for {space_name} closed.")
548
+ except:
549
+ pass
550
+
551
+ if success and response_data:
552
+ self.send_response(200)
553
+ self.send_header('Content-Type', 'application/json')
554
+ self.end_headers()
555
+ self.wfile.write(json.dumps(response_data).encode('utf-8'))
556
+ else:
557
+ self.send_error_response(500, f"All FLUX Spaces failed. Last error: {last_error}")
558
+
559
+ except Exception as e:
560
+ print(f"[Backend] Error during 2D generation: {e}")
561
+ self.send_error_response(500, str(e))
562
+
563
+ def handle_get_gallery(self):
564
+ try:
565
+ images_dir = os.path.join(os.path.dirname(__file__), "generated_images")
566
+ models_dir = os.path.join(os.path.dirname(__file__), "generated_models")
567
+
568
+ os.makedirs(images_dir, exist_ok=True)
569
+ os.makedirs(models_dir, exist_ok=True)
570
+
571
+ items = []
572
+
573
+ # Read 2D images
574
+ for f in os.listdir(images_dir):
575
+ if f.endswith(('.png', '.jpg', '.jpeg', '.webp')):
576
+ path = os.path.join(images_dir, f)
577
+ mtime = os.path.getmtime(path)
578
+ items.append({
579
+ "name": f,
580
+ "type": "image",
581
+ "url": f"/generated_images/{f}",
582
+ "mtime": mtime
583
+ })
584
+
585
+ # Read 3D models
586
+ for f in os.listdir(models_dir):
587
+ if f.endswith('.glb') and not f.endswith('_dirty.glb') and not f.endswith('_rigged.glb'):
588
+ path = os.path.join(models_dir, f)
589
+ mtime = os.path.getmtime(path)
590
+ fbx_filename = f.replace('.glb', '.fbx')
591
+ has_fbx = os.path.exists(os.path.join(models_dir, fbx_filename))
592
+ items.append({
593
+ "name": f,
594
+ "type": "model",
595
+ "url": f"/generated_models/{f}",
596
+ "fbxUrl": f"/generated_models/{fbx_filename}" if has_fbx else None,
597
+ "mtime": mtime
598
+ })
599
+
600
+ # Sort items by creation time (newest first)
601
+ items.sort(key=lambda x: x["mtime"], reverse=True)
602
+
603
+ self.send_response(200)
604
+ self.send_header('Content-Type', 'application/json')
605
+ self.end_headers()
606
+ self.wfile.write(json.dumps({"items": items}).encode('utf-8'))
607
+ except Exception as e:
608
+ print(f"[Backend] Error getting gallery: {e}")
609
+ self.send_error_response(500, str(e))
610
+
611
+ def handle_delete_gallery(self):
612
+ try:
613
+ content_length = int(self.headers['Content-Length'])
614
+ post_data = self.rfile.read(content_length)
615
+ params = json.loads(post_data.decode('utf-8'))
616
+
617
+ filename = params.get('name', '')
618
+ item_type = params.get('type', '')
619
+
620
+ if not filename or not item_type:
621
+ self.send_error_response(400, "Missing name or type")
622
+ return
623
+
624
+ if item_type == "image":
625
+ target_dir = os.path.join(os.path.dirname(__file__), "generated_images")
626
+ elif item_type == "model":
627
+ target_dir = os.path.join(os.path.dirname(__file__), "generated_models")
628
+ else:
629
+ self.send_error_response(400, "Invalid type")
630
+ return
631
+
632
+ # Security check: avoid directory traversal
633
+ clean_name = os.path.basename(filename)
634
+ file_path = os.path.join(target_dir, clean_name)
635
+
636
+ if os.path.exists(file_path):
637
+ os.remove(file_path)
638
+ print(f"[Backend] Deleted file: {file_path}")
639
+
640
+ # If it was a 3D model, clean up associated files (.obj, .mtl, .fbx, _dirty.glb, _texture.png)
641
+ if item_type == "model" and clean_name.endswith(".glb"):
642
+ prefix = clean_name.replace(".glb", "")
643
+ for ext in [".obj", ".mtl", ".fbx", "_dirty.glb", "_texture.png", "_rigged.fbx", "_rigged.glb"]:
644
+ assoc_file = os.path.join(target_dir, prefix + ext)
645
+ if os.path.exists(assoc_file):
646
+ os.remove(assoc_file)
647
+ print(f"[Backend] Deleted associated file: {assoc_file}")
648
+
649
+ self.send_response(200)
650
+ self.send_header('Content-Type', 'application/json')
651
+ self.end_headers()
652
+ self.wfile.write(json.dumps({"success": True}).encode('utf-8'))
653
+ else:
654
+ self.send_error_response(404, "File not found")
655
+ except Exception as e:
656
+ print(f"[Backend] Error deleting gallery item: {e}")
657
+ self.send_error_response(500, str(e))
658
+
659
+ def send_error_response(self, code, message):
660
+ self.send_response(code)
661
+ self.send_header('Content-Type', 'application/json')
662
+ self.end_headers()
663
+ self.wfile.write(json.dumps({"error": message}).encode('utf-8'))
664
+
665
+ def run_server():
666
+ server_address = ('', PORT)
667
+ httpd = HTTPServer(server_address, F23DHTTPRequestHandler)
668
+ print(f"[Backend] 23DFactory server running at http://localhost:{PORT}")
669
+ try:
670
+ httpd.serve_forever()
671
+ except KeyboardInterrupt:
672
+ print("\n[Backend] Server shutting down.")
673
+ httpd.server_close()
674
+
675
+ if __name__ == '__main__':
676
+ run_server()
styles.css ADDED
@@ -0,0 +1,976 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 23DFactory Global Design System */
2
+ :root {
3
+ --bg-main: #060608;
4
+ --bg-panel: rgba(13, 13, 18, 0.7);
5
+ --bg-panel-hover: rgba(20, 20, 30, 0.85);
6
+ --bg-card: rgba(22, 22, 33, 0.4);
7
+
8
+ --primary: #9d4edd;
9
+ --primary-glow: rgba(157, 78, 221, 0.5);
10
+ --primary-light: #c77dff;
11
+
12
+ --accent: #00f5ff;
13
+ --accent-glow: rgba(0, 245, 255, 0.35);
14
+
15
+ --text-main: #f3f4f6;
16
+ --text-muted: #9ca3af;
17
+ --text-dark: #4b5563;
18
+
19
+ --border: rgba(255, 255, 255, 0.06);
20
+ --border-glow: rgba(157, 78, 221, 0.2);
21
+
22
+ --font-heading: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
23
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24
+
25
+ --transition-smooth: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
26
+ --transition-fast: all 0.15s ease;
27
+ }
28
+
29
+ /* Base Styles */
30
+ * {
31
+ box-sizing: border-box;
32
+ margin: 0;
33
+ padding: 0;
34
+ }
35
+
36
+ body {
37
+ background-color: var(--bg-main);
38
+ background-image:
39
+ radial-gradient(at 0% 0%, rgba(157, 78, 221, 0.15) 0px, transparent 50%),
40
+ radial-gradient(at 100% 100%, rgba(0, 245, 255, 0.1) 0px, transparent 50%),
41
+ radial-gradient(at 50% 50%, rgba(6, 6, 8, 1) 0px, rgba(13, 13, 18, 0.9) 100%);
42
+ background-attachment: fixed;
43
+ color: var(--text-main);
44
+ font-family: var(--font-body);
45
+ min-height: 100vh;
46
+ overflow-x: hidden;
47
+ line-height: 1.6;
48
+ }
49
+
50
+ h1, h2, h3, h4 {
51
+ font-family: var(--font-heading);
52
+ font-weight: 700;
53
+ letter-spacing: -0.02em;
54
+ }
55
+
56
+ /* App Layout */
57
+ .app-container {
58
+ display: grid;
59
+ grid-template-rows: 70px 1fr;
60
+ height: 100vh;
61
+ width: 100vw;
62
+ }
63
+
64
+ /* Header */
65
+ .app-header {
66
+ display: flex;
67
+ justify-content: space-between;
68
+ align-items: center;
69
+ padding: 0 2rem;
70
+ background: rgba(6, 6, 8, 0.65);
71
+ backdrop-filter: blur(12px);
72
+ -webkit-backdrop-filter: blur(12px);
73
+ border-bottom: 1px solid var(--border);
74
+ z-index: 100;
75
+ }
76
+
77
+ .logo-container {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 0.75rem;
81
+ }
82
+
83
+ .logo-icon {
84
+ width: 32px;
85
+ height: 32px;
86
+ background: linear-gradient(135deg, var(--primary), var(--accent));
87
+ border-radius: 8px;
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ font-weight: 800;
92
+ font-family: var(--font-heading);
93
+ font-size: 1.2rem;
94
+ box-shadow: 0 0 15px var(--primary-glow);
95
+ }
96
+
97
+ .logo-text {
98
+ font-size: 1.4rem;
99
+ background: linear-gradient(to right, #ffffff, #c77dff);
100
+ -webkit-background-clip: text;
101
+ -webkit-text-fill-color: transparent;
102
+ }
103
+
104
+ .logo-text span {
105
+ font-weight: 300;
106
+ color: var(--accent);
107
+ -webkit-text-fill-color: var(--accent);
108
+ }
109
+
110
+ .header-actions {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 1rem;
114
+ }
115
+
116
+ /* Main Area */
117
+ .main-workspace {
118
+ display: grid;
119
+ grid-template-columns: 420px 1fr;
120
+ height: calc(100vh - 70px);
121
+ overflow: hidden;
122
+ }
123
+
124
+ .sidebar {
125
+ background: var(--bg-panel);
126
+ backdrop-filter: blur(20px);
127
+ -webkit-backdrop-filter: blur(20px);
128
+ border-right: 1px solid var(--border);
129
+ display: flex;
130
+ flex-direction: column;
131
+ overflow: hidden;
132
+ height: 100%;
133
+ }
134
+
135
+ .sidebar-scroll {
136
+ flex: 1;
137
+ overflow-y: auto;
138
+ padding: 1.5rem;
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 1.5rem;
142
+ }
143
+
144
+ .sidebar-actions {
145
+ padding: 1.5rem;
146
+ border-top: 1px solid var(--border);
147
+ background: rgba(10, 10, 15, 0.85);
148
+ backdrop-filter: blur(10px);
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 1rem;
152
+ z-index: 5;
153
+ }
154
+
155
+ /* Content Area - Viewer */
156
+ .viewer-container {
157
+ position: relative;
158
+ display: flex;
159
+ flex-direction: column;
160
+ height: 100%;
161
+ background: radial-gradient(circle at 50% 50%, rgba(20, 20, 30, 0.4) 0%, rgba(6, 6, 8, 1) 100%);
162
+ overflow: hidden;
163
+ }
164
+
165
+ /* Sidebar Sections & Tabs */
166
+ .tab-nav {
167
+ display: flex;
168
+ background: rgba(0, 0, 0, 0.3);
169
+ border-radius: 10px;
170
+ padding: 0.25rem;
171
+ border: 1px solid var(--border);
172
+ }
173
+
174
+ .tab-btn {
175
+ flex: 1;
176
+ background: transparent;
177
+ border: none;
178
+ padding: 0.6rem;
179
+ color: var(--text-muted);
180
+ font-family: var(--font-heading);
181
+ font-weight: 600;
182
+ font-size: 0.9rem;
183
+ cursor: pointer;
184
+ border-radius: 8px;
185
+ transition: var(--transition-fast);
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ gap: 0.5rem;
190
+ }
191
+
192
+ .tab-btn.active {
193
+ background: var(--primary);
194
+ color: var(--text-main);
195
+ box-shadow: 0 4px 12px var(--primary-glow);
196
+ }
197
+
198
+ .tab-content {
199
+ display: none;
200
+ flex-direction: column;
201
+ gap: 1.25rem;
202
+ }
203
+
204
+ .tab-content.active {
205
+ display: flex;
206
+ animation: fadeIn 0.4s ease;
207
+ }
208
+
209
+ /* Card layout */
210
+ .control-card {
211
+ background: var(--bg-card);
212
+ border: 1px solid var(--border);
213
+ border-radius: 12px;
214
+ padding: 1.25rem;
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 1rem;
218
+ transition: var(--transition-smooth);
219
+ }
220
+
221
+ .control-card:hover {
222
+ border-color: var(--border-glow);
223
+ box-shadow: 0 4px 20px rgba(157, 78, 221, 0.05);
224
+ }
225
+
226
+ .card-title {
227
+ font-size: 1rem;
228
+ color: var(--text-main);
229
+ display: flex;
230
+ align-items: center;
231
+ gap: 0.5rem;
232
+ border-bottom: 1px solid var(--border);
233
+ padding-bottom: 0.5rem;
234
+ }
235
+
236
+ /* Inputs & Buttons */
237
+ .input-group {
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: 0.5rem;
241
+ }
242
+
243
+ .input-label {
244
+ font-size: 0.8rem;
245
+ color: var(--text-muted);
246
+ font-weight: 500;
247
+ text-transform: uppercase;
248
+ letter-spacing: 0.05em;
249
+ }
250
+
251
+ .text-input {
252
+ background: rgba(0, 0, 0, 0.4);
253
+ border: 1px solid var(--border);
254
+ border-radius: 8px;
255
+ padding: 0.75rem 1rem;
256
+ color: var(--text-main);
257
+ font-family: var(--font-body);
258
+ font-size: 0.9rem;
259
+ transition: var(--transition-fast);
260
+ outline: none;
261
+ resize: none;
262
+ }
263
+
264
+ .text-input:focus {
265
+ border-color: var(--primary);
266
+ box-shadow: 0 0 8px var(--primary-glow);
267
+ }
268
+
269
+ /* Drag and Drop Zone */
270
+ .dropzone {
271
+ border: 2px dashed var(--border);
272
+ border-radius: 12px;
273
+ padding: 2.5rem 1.5rem;
274
+ text-align: center;
275
+ cursor: pointer;
276
+ transition: var(--transition-smooth);
277
+ background: rgba(0, 0, 0, 0.2);
278
+ display: flex;
279
+ flex-direction: column;
280
+ align-items: center;
281
+ gap: 0.75rem;
282
+ position: relative;
283
+ overflow: hidden;
284
+ }
285
+
286
+ .dropzone:hover, .dropzone.dragover {
287
+ border-color: var(--primary);
288
+ background: rgba(157, 78, 221, 0.05);
289
+ box-shadow: 0 0 15px rgba(157, 78, 221, 0.1);
290
+ }
291
+
292
+ .dropzone-icon {
293
+ font-size: 2rem;
294
+ color: var(--primary-light);
295
+ transition: var(--transition-smooth);
296
+ }
297
+
298
+ .dropzone:hover .dropzone-icon {
299
+ transform: translateY(-4px);
300
+ color: var(--accent);
301
+ }
302
+
303
+ .dropzone p {
304
+ font-size: 0.85rem;
305
+ color: var(--text-muted);
306
+ }
307
+
308
+ .dropzone input[type="file"] {
309
+ display: none;
310
+ }
311
+
312
+ /* Image Preview in Drag and Drop */
313
+ .preview-container {
314
+ width: 100%;
315
+ aspect-ratio: 1;
316
+ border-radius: 8px;
317
+ overflow: hidden;
318
+ position: relative;
319
+ border: 1px solid var(--border);
320
+ background: #0f0f14;
321
+ display: none;
322
+ }
323
+
324
+ .preview-img {
325
+ width: 100%;
326
+ height: 100%;
327
+ object-fit: contain;
328
+ }
329
+
330
+ .remove-preview {
331
+ position: absolute;
332
+ top: 8px;
333
+ right: 8px;
334
+ background: rgba(0, 0, 0, 0.7);
335
+ color: white;
336
+ border: none;
337
+ border-radius: 50%;
338
+ width: 24px;
339
+ height: 24px;
340
+ cursor: pointer;
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: center;
344
+ font-weight: bold;
345
+ font-size: 0.8rem;
346
+ transition: var(--transition-fast);
347
+ }
348
+
349
+ .remove-preview:hover {
350
+ background: #ef4444;
351
+ }
352
+
353
+ /* Buttons */
354
+ .btn {
355
+ background: linear-gradient(135deg, var(--primary), #7b2cbf);
356
+ color: var(--text-main);
357
+ border: none;
358
+ border-radius: 8px;
359
+ padding: 0.8rem 1.5rem;
360
+ font-family: var(--font-heading);
361
+ font-weight: 600;
362
+ font-size: 0.95rem;
363
+ cursor: pointer;
364
+ transition: var(--transition-smooth);
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: center;
368
+ gap: 0.6rem;
369
+ box-shadow: 0 4px 15px rgba(157, 78, 221, 0.3);
370
+ }
371
+
372
+ .btn:hover {
373
+ transform: translateY(-2px);
374
+ box-shadow: 0 6px 20px rgba(157, 78, 221, 0.45);
375
+ }
376
+
377
+ .btn:active {
378
+ transform: translateY(0);
379
+ }
380
+
381
+ .btn:disabled {
382
+ background: var(--text-dark);
383
+ color: var(--text-muted);
384
+ cursor: not-allowed;
385
+ transform: none;
386
+ box-shadow: none;
387
+ }
388
+
389
+ .btn-secondary {
390
+ background: rgba(255, 255, 255, 0.05);
391
+ border: 1px solid var(--border);
392
+ box-shadow: none;
393
+ }
394
+
395
+ .btn-secondary:hover {
396
+ background: rgba(255, 255, 255, 0.1);
397
+ border-color: var(--text-muted);
398
+ box-shadow: none;
399
+ }
400
+
401
+ .btn-accent {
402
+ background: linear-gradient(135deg, var(--accent), #00b4d8);
403
+ color: #060608;
404
+ font-weight: 700;
405
+ box-shadow: 0 4px 15px rgba(0, 245, 255, 0.25);
406
+ }
407
+
408
+ .btn-accent:hover {
409
+ box-shadow: 0 6px 20px rgba(0, 245, 255, 0.4);
410
+ }
411
+
412
+ /* Sliders */
413
+ .slider-container {
414
+ display: flex;
415
+ flex-direction: column;
416
+ gap: 0.25rem;
417
+ }
418
+
419
+ .slider-header {
420
+ display: flex;
421
+ justify-content: space-between;
422
+ font-size: 0.75rem;
423
+ color: var(--text-muted);
424
+ }
425
+
426
+ .range-slider {
427
+ -webkit-appearance: none;
428
+ width: 100%;
429
+ height: 6px;
430
+ border-radius: 3px;
431
+ background: rgba(255, 255, 255, 0.1);
432
+ outline: none;
433
+ }
434
+
435
+ .range-slider::-webkit-slider-thumb {
436
+ -webkit-appearance: none;
437
+ appearance: none;
438
+ width: 16px;
439
+ height: 16px;
440
+ border-radius: 50%;
441
+ background: var(--primary-light);
442
+ cursor: pointer;
443
+ transition: var(--transition-fast);
444
+ box-shadow: 0 0 8px var(--primary-glow);
445
+ }
446
+
447
+ .range-slider::-webkit-slider-thumb:hover {
448
+ transform: scale(1.2);
449
+ background: var(--accent);
450
+ }
451
+
452
+ /* Select element styling */
453
+ .custom-select {
454
+ background: rgba(0, 0, 0, 0.4);
455
+ border: 1px solid var(--border);
456
+ border-radius: 8px;
457
+ padding: 0.75rem;
458
+ color: var(--text-main);
459
+ outline: none;
460
+ font-family: var(--font-body);
461
+ }
462
+
463
+ .custom-select option {
464
+ background: #121217;
465
+ color: var(--text-main);
466
+ }
467
+
468
+ /* 3D Visualizer Area */
469
+ .viewer-display {
470
+ flex: 1;
471
+ position: relative;
472
+ width: 100%;
473
+ height: 100%;
474
+ display: flex;
475
+ align-items: center;
476
+ justify-content: center;
477
+ }
478
+
479
+ model-viewer {
480
+ width: 100%;
481
+ height: 100%;
482
+ background-color: transparent;
483
+ --poster-color: transparent;
484
+ }
485
+
486
+ /* Visualizer overlay controls */
487
+ .viewer-overlay {
488
+ position: absolute;
489
+ bottom: 2rem;
490
+ left: 50%;
491
+ transform: translateX(-50%);
492
+ background: rgba(13, 13, 18, 0.8);
493
+ backdrop-filter: blur(12px);
494
+ -webkit-backdrop-filter: blur(12px);
495
+ border: 1px solid var(--border);
496
+ border-radius: 30px;
497
+ padding: 0.5rem 1rem;
498
+ display: flex;
499
+ gap: 0.75rem;
500
+ z-index: 10;
501
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
502
+ opacity: 0;
503
+ transform: translate(-50%, 20px);
504
+ transition: var(--transition-smooth);
505
+ pointer-events: none;
506
+ }
507
+
508
+ .viewer-overlay.active {
509
+ opacity: 1;
510
+ transform: translate(-50%, 0);
511
+ pointer-events: auto;
512
+ }
513
+
514
+ .overlay-btn {
515
+ background: transparent;
516
+ border: none;
517
+ color: var(--text-muted);
518
+ cursor: pointer;
519
+ padding: 0.5rem;
520
+ border-radius: 50%;
521
+ width: 38px;
522
+ height: 38px;
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: center;
526
+ transition: var(--transition-fast);
527
+ }
528
+
529
+ .overlay-btn:hover {
530
+ color: var(--text-main);
531
+ background: rgba(255, 255, 255, 0.05);
532
+ }
533
+
534
+ .overlay-btn.active {
535
+ color: var(--accent);
536
+ background: rgba(0, 245, 255, 0.1);
537
+ }
538
+
539
+ /* Empty State / Prompt info */
540
+ .empty-state {
541
+ display: flex;
542
+ flex-direction: column;
543
+ align-items: center;
544
+ justify-content: center;
545
+ gap: 1.5rem;
546
+ text-align: center;
547
+ max-width: 400px;
548
+ z-index: 2;
549
+ animation: float 4s ease-in-out infinite;
550
+ }
551
+
552
+ .empty-state-icon {
553
+ font-size: 4rem;
554
+ background: linear-gradient(135deg, var(--primary), var(--accent));
555
+ -webkit-background-clip: text;
556
+ -webkit-text-fill-color: transparent;
557
+ opacity: 0.8;
558
+ }
559
+
560
+ .empty-state h3 {
561
+ font-size: 1.5rem;
562
+ color: var(--text-main);
563
+ }
564
+
565
+ .empty-state p {
566
+ color: var(--text-muted);
567
+ font-size: 0.9rem;
568
+ }
569
+
570
+ /* Generation Status overlay */
571
+ .status-overlay {
572
+ position: absolute;
573
+ inset: 0;
574
+ background: rgba(6, 6, 8, 0.85);
575
+ backdrop-filter: blur(10px);
576
+ -webkit-backdrop-filter: blur(10px);
577
+ display: none;
578
+ flex-direction: column;
579
+ align-items: center;
580
+ justify-content: center;
581
+ gap: 2rem;
582
+ z-index: 20;
583
+ opacity: 0;
584
+ transition: opacity 0.4s ease;
585
+ }
586
+
587
+ .status-overlay.active {
588
+ display: flex;
589
+ opacity: 1;
590
+ }
591
+
592
+ /* Cybernetic Loading Spinner */
593
+ .cyber-loader {
594
+ position: relative;
595
+ width: 120px;
596
+ height: 120px;
597
+ }
598
+
599
+ .loader-ring {
600
+ position: absolute;
601
+ width: 100%;
602
+ height: 100%;
603
+ border-radius: 50%;
604
+ border: 3px solid transparent;
605
+ border-top-color: var(--primary);
606
+ animation: spin 2s linear infinite;
607
+ }
608
+
609
+ .loader-ring:nth-child(2) {
610
+ width: 80%;
611
+ height: 80%;
612
+ top: 10%;
613
+ left: 10%;
614
+ border-top-color: transparent;
615
+ border-bottom-color: var(--accent);
616
+ animation: spin-reverse 1.5s linear infinite;
617
+ }
618
+
619
+ .loader-ring:nth-child(3) {
620
+ width: 60%;
621
+ height: 60%;
622
+ top: 20%;
623
+ left: 20%;
624
+ border-top-color: var(--primary-light);
625
+ border-bottom-color: transparent;
626
+ animation: spin 1s linear infinite;
627
+ }
628
+
629
+ .loader-text-pct {
630
+ position: absolute;
631
+ inset: 0;
632
+ display: flex;
633
+ align-items: center;
634
+ justify-content: center;
635
+ font-family: var(--font-heading);
636
+ font-size: 1.1rem;
637
+ font-weight: 700;
638
+ color: var(--text-main);
639
+ }
640
+
641
+ .status-info {
642
+ text-align: center;
643
+ max-width: 350px;
644
+ }
645
+
646
+ .status-title {
647
+ font-size: 1.25rem;
648
+ margin-bottom: 0.5rem;
649
+ color: var(--text-main);
650
+ }
651
+
652
+ .status-desc {
653
+ color: var(--text-muted);
654
+ font-size: 0.9rem;
655
+ }
656
+
657
+ /* Modal styling */
658
+ .modal-overlay {
659
+ position: fixed;
660
+ inset: 0;
661
+ background: rgba(0, 0, 0, 0.8);
662
+ backdrop-filter: blur(15px);
663
+ -webkit-backdrop-filter: blur(15px);
664
+ display: none;
665
+ align-items: center;
666
+ justify-content: center;
667
+ z-index: 200;
668
+ opacity: 0;
669
+ transition: opacity 0.3s ease;
670
+ }
671
+
672
+ .modal-overlay.active {
673
+ display: flex;
674
+ opacity: 1;
675
+ }
676
+
677
+ .modal-content {
678
+ background: var(--bg-panel);
679
+ border: 1px solid var(--border);
680
+ border-radius: 16px;
681
+ width: 90%;
682
+ max-width: 480px;
683
+ padding: 2rem;
684
+ display: flex;
685
+ flex-direction: column;
686
+ gap: 1.5rem;
687
+ box-shadow: 0 10px 40px rgba(0,0,0,0.6);
688
+ transform: scale(0.95);
689
+ transition: transform 0.3s ease;
690
+ }
691
+
692
+ .modal-overlay.active .modal-content {
693
+ transform: scale(1);
694
+ }
695
+
696
+ .modal-header {
697
+ display: flex;
698
+ justify-content: space-between;
699
+ align-items: center;
700
+ border-bottom: 1px solid var(--border);
701
+ padding-bottom: 0.75rem;
702
+ }
703
+
704
+ .modal-title {
705
+ font-size: 1.2rem;
706
+ font-weight: 700;
707
+ }
708
+
709
+ .close-modal {
710
+ background: transparent;
711
+ border: none;
712
+ color: var(--text-muted);
713
+ cursor: pointer;
714
+ font-size: 1.2rem;
715
+ }
716
+
717
+ /* Footer info */
718
+ .sidebar-footer {
719
+ margin-top: auto;
720
+ font-size: 0.75rem;
721
+ color: var(--text-dark);
722
+ display: flex;
723
+ justify-content: space-between;
724
+ border-top: 1px solid var(--border);
725
+ padding-top: 1rem;
726
+ }
727
+
728
+ .sidebar-footer a {
729
+ color: var(--text-muted);
730
+ text-decoration: none;
731
+ transition: var(--transition-fast);
732
+ }
733
+
734
+ .sidebar-footer a:hover {
735
+ color: var(--accent);
736
+ }
737
+
738
+ /* Animations */
739
+ @keyframes fadeIn {
740
+ from { opacity: 0; transform: translateY(10px); }
741
+ to { opacity: 1; transform: translateY(0); }
742
+ }
743
+
744
+ @keyframes spin {
745
+ 0% { transform: rotate(0deg); }
746
+ 100% { transform: rotate(360deg); }
747
+ }
748
+
749
+ @keyframes spin-reverse {
750
+ 0% { transform: rotate(0deg); }
751
+ 100% { transform: rotate(-360deg); }
752
+ }
753
+
754
+ @keyframes float {
755
+ 0%, 100% { transform: translateY(0); }
756
+ 50% { transform: translateY(-10px); }
757
+ }
758
+
759
+ /* Gallery / Library Styling */
760
+ .gallery-list {
761
+ display: flex;
762
+ flex-direction: column;
763
+ gap: 0.75rem;
764
+ max-height: 450px;
765
+ overflow-y: auto;
766
+ padding-right: 0.25rem;
767
+ }
768
+
769
+ .gallery-list::-webkit-scrollbar {
770
+ width: 4px;
771
+ }
772
+
773
+ .gallery-list::-webkit-scrollbar-thumb {
774
+ background: var(--border);
775
+ border-radius: 2px;
776
+ }
777
+
778
+ .gallery-item {
779
+ display: flex;
780
+ align-items: center;
781
+ gap: 0.75rem;
782
+ padding: 0.6rem;
783
+ background: rgba(255, 255, 255, 0.02);
784
+ border: 1px solid var(--border);
785
+ border-radius: 8px;
786
+ transition: var(--transition-fast);
787
+ animation: itemFadeIn 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) both;
788
+ }
789
+
790
+ @keyframes itemFadeIn {
791
+ from {
792
+ opacity: 0;
793
+ transform: translateY(8px);
794
+ }
795
+ to {
796
+ opacity: 1;
797
+ transform: translateY(0);
798
+ }
799
+ }
800
+
801
+ .gallery-item:hover {
802
+ background: rgba(255, 255, 255, 0.05);
803
+ border-color: var(--border-glow);
804
+ transform: translateX(2px);
805
+ }
806
+
807
+ .gallery-thumbnail {
808
+ width: 46px;
809
+ height: 46px;
810
+ border-radius: 6px;
811
+ background: #0f0f14;
812
+ display: flex;
813
+ align-items: center;
814
+ justify-content: center;
815
+ overflow: hidden;
816
+ border: 1px solid rgba(255, 255, 255, 0.04);
817
+ }
818
+
819
+ .gallery-thumbnail img {
820
+ width: 100%;
821
+ height: 100%;
822
+ object-fit: cover;
823
+ }
824
+
825
+ .gallery-thumbnail i {
826
+ color: var(--primary-light);
827
+ }
828
+
829
+ .gallery-info {
830
+ flex: 1;
831
+ min-width: 0; /* allows text truncation */
832
+ display: flex;
833
+ flex-direction: column;
834
+ gap: 0.15rem;
835
+ }
836
+
837
+ .gallery-name {
838
+ font-size: 0.8rem;
839
+ font-weight: 600;
840
+ color: var(--text-main);
841
+ white-space: nowrap;
842
+ overflow: hidden;
843
+ text-overflow: ellipsis;
844
+ }
845
+
846
+ .gallery-meta {
847
+ font-size: 0.65rem;
848
+ color: var(--text-muted);
849
+ display: flex;
850
+ align-items: center;
851
+ gap: 0.25rem;
852
+ }
853
+
854
+ .gallery-actions {
855
+ display: flex;
856
+ gap: 0.35rem;
857
+ }
858
+
859
+ .gallery-btn {
860
+ background: rgba(255, 255, 255, 0.05);
861
+ color: var(--text-muted);
862
+ border: 1px solid var(--border);
863
+ border-radius: 6px;
864
+ width: 28px;
865
+ height: 28px;
866
+ display: flex;
867
+ align-items: center;
868
+ justify-content: center;
869
+ cursor: pointer;
870
+ transition: var(--transition-fast);
871
+ }
872
+
873
+ .gallery-btn:hover {
874
+ color: var(--bg-main);
875
+ background: var(--accent);
876
+ border-color: var(--accent);
877
+ }
878
+
879
+ .gallery-btn.delete-btn:hover {
880
+ color: #ffffff;
881
+ background: #ef4444;
882
+ border-color: #ef4444;
883
+ }
884
+
885
+ .gallery-empty-state {
886
+ padding: 2rem 1rem;
887
+ text-align: center;
888
+ color: var(--text-dark);
889
+ display: flex;
890
+ flex-direction: column;
891
+ align-items: center;
892
+ gap: 0.5rem;
893
+ }
894
+
895
+ /* Source Selector and Thumbnail Grid Styles */
896
+ .toggle-group {
897
+ display: flex;
898
+ background: rgba(255, 255, 255, 0.03);
899
+ border: 1px solid var(--border);
900
+ border-radius: 8px;
901
+ padding: 0.25rem;
902
+ gap: 0.25rem;
903
+ }
904
+
905
+ .toggle-btn {
906
+ flex: 1;
907
+ background: transparent;
908
+ border: none;
909
+ color: var(--text-muted);
910
+ padding: 0.5rem;
911
+ font-size: 0.85rem;
912
+ font-weight: 500;
913
+ border-radius: 6px;
914
+ cursor: pointer;
915
+ transition: var(--transition-fast);
916
+ }
917
+
918
+ .toggle-btn:hover {
919
+ color: var(--text-main);
920
+ background: rgba(255, 255, 255, 0.05);
921
+ }
922
+
923
+ .toggle-btn.active {
924
+ background: var(--primary);
925
+ color: #ffffff;
926
+ box-shadow: 0 2px 8px var(--primary-glow);
927
+ }
928
+
929
+ .library-thumbnail-grid {
930
+ display: grid;
931
+ grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
932
+ gap: 0.5rem;
933
+ max-height: 200px;
934
+ overflow-y: auto;
935
+ padding: 0.25rem;
936
+ border: 1px solid var(--border);
937
+ border-radius: 8px;
938
+ background: rgba(0, 0, 0, 0.2);
939
+ }
940
+
941
+ .library-thumb-item {
942
+ aspect-ratio: 1;
943
+ border-radius: 6px;
944
+ overflow: hidden;
945
+ border: 2px solid transparent;
946
+ cursor: pointer;
947
+ transition: var(--transition-fast);
948
+ background: var(--bg-main);
949
+ display: flex;
950
+ align-items: center;
951
+ justify-content: center;
952
+ position: relative;
953
+ }
954
+
955
+ .library-thumb-item img {
956
+ width: 100%;
957
+ height: 100%;
958
+ object-fit: cover;
959
+ }
960
+
961
+ .library-thumb-item:hover {
962
+ border-color: var(--primary-light);
963
+ }
964
+
965
+ .library-thumb-item.active {
966
+ border-color: var(--accent);
967
+ box-shadow: 0 0 10px var(--accent-glow);
968
+ }
969
+
970
+ .library-thumb-empty {
971
+ grid-column: 1 / -1;
972
+ text-align: center;
973
+ padding: 1.5rem 0.5rem;
974
+ color: var(--text-muted);
975
+ font-size: 0.8rem;
976
+ }
test_shape.png ADDED