Spaces:
Running
Running
LogicalTrue commited on
Commit ·
b787014
0
Parent(s):
Initial deploy
Browse files- .agents/AGENTS.md +37 -0
- .agents/skills/gradio_space_integration/SKILL.md +40 -0
- .gitignore +5 -0
- Dockerfile +41 -0
- app.js +927 -0
- clean_mesh_blender.py +351 -0
- dummy.png +0 -0
- index.html +394 -0
- requirements.txt +1 -0
- rig_mesh_blender.py +296 -0
- rig_quadruped_blender.py +260 -0
- server.py +676 -0
- styles.css +976 -0
- test_shape.png +0 -0
.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">×</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">×</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
|