feat: implementar 12 mejoras, tests, docs y optimizaciones
- Fase A: license templates, search history, cost estimator - Fase B: import URL, bulk ZIP, batch download - Fase C: comparison mode, mesh validation, measurement tool - Fase D: cross-section clipping, overhang heatmap, layer animation - Refactor Pydantic/SQLAlchemy warnings - 24 tests pytest - README actualizado - WebP thumbnails, lazy loading, cache headers
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.git/
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
*.md
|
||||
*.log
|
||||
.stl_repo.db
|
||||
stl_repo.db
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Database & logs
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
server.log
|
||||
nohup.out
|
||||
|
||||
# Uploaded/generated assets
|
||||
uploads/
|
||||
thumbnails/
|
||||
images/
|
||||
|
||||
# Test artifacts
|
||||
*.stl
|
||||
test.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml .python-version ./
|
||||
COPY app/ ./app/
|
||||
COPY static/ ./static/
|
||||
|
||||
# Create directories for data
|
||||
RUN mkdir -p uploads thumbnails images
|
||||
|
||||
# Install dependencies and project
|
||||
RUN uv sync --no-dev
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run server
|
||||
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
139
README.md
Normal file
139
README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# STL Repository
|
||||
|
||||
Aplicacion web completa para gestionar, organizar, visualizar y compartir modelos 3D en formato STL y 3MF.
|
||||
|
||||
## Caracteristicas principales
|
||||
|
||||
### Gestion de modelos
|
||||
- **Subida multi-archivo**: STL, 3MF e imagenes de referencia (JPG/PNG)
|
||||
- **Importacion desde URL**: Descarga directa de archivos .stl/.3mf desde cualquier URL
|
||||
- **Subida masiva ZIP**: Procesa automaticamente todos los STL/3MF dentro de un ZIP
|
||||
- **Deteccion de duplicados**: Por hash SHA256
|
||||
- **Metadatos enriquecidos**: Titulo, autor, descripcion, categoria, tags (autocompletado)
|
||||
- **Templates de licencia**: CC0, CC-BY, CC-BY-SA, CC-BY-NC, GPL-3.0, MIT
|
||||
|
||||
### Galeria y busqueda
|
||||
- **Galeria responsive** con grid animado y glassmorphism
|
||||
- **Busqueda avanzada**: Por texto, categoria, tag, rango de caras, dimensiones
|
||||
- **Ordenamiento**: Mas nuevos, mas descargados, mas grandes, mejor valorados
|
||||
- **Historial de busqueda**: Persistido en localStorage
|
||||
- **Nube de tags** con conteo
|
||||
- **Paginacion** con "cargar mas"
|
||||
|
||||
### Visualizacion 3D (Three.js)
|
||||
- **Viewer interactivo** multi-parte con controles orbitales
|
||||
- **Modos de vista**: Solido, wireframe, ejes, bounding box
|
||||
- **Vistas rapidas**: Frontal, superior, lateral, isometrica
|
||||
- **Herramienta de medicion**: Click en dos puntos para distancia en mm
|
||||
- **Corte transversal**: Plano de clipping con slider interactivo
|
||||
- **Mapa de voladizos**: Heatmap de angulos de impresion (verde/amarillo/rojo)
|
||||
- **Animacion de capas**: Simulacion de construccion capa por capa
|
||||
- **Modo comparacion**: Abrir dos modelos lado a lado
|
||||
|
||||
### Social y organizacion
|
||||
- **Valoraciones**: 1-5 estrellas con promedio calculado
|
||||
- **Comentarios**: Con nombre de autor y fecha
|
||||
- **Colecciones**: Crear listas y agregar/quitar modelos
|
||||
- **QR Code**: Compartir modelo escaneando codigo
|
||||
- **Descarga batch**: Seleccionar multiples modelos y descargar ZIP
|
||||
|
||||
### Analisis y validacion
|
||||
- **Validacion de malla** (trimesh): Watertight, volumen, area, Euler, agujeros
|
||||
- **Estimador de impresion**: Volumen, peso en gramos, costo y tiempo estimado
|
||||
- **Thumbnails automaticos** generados server-side
|
||||
|
||||
### Infraestructura
|
||||
- **Backup completo**: Endpoint que exporta DB + archivos + metadatos JSON como ZIP
|
||||
- **Docker**: Dockerfile + docker-compose.yml listos
|
||||
- **Tests automatizados**: 24 tests pytest (API + parsers)
|
||||
|
||||
## Stack tecnologico
|
||||
|
||||
- **Backend**: Python 3.12 + FastAPI + SQLAlchemy + SQLite + trimesh
|
||||
- **Frontend**: HTML5 + Vanilla JS + Tailwind CSS (CDN) + Three.js r128
|
||||
- **Procesamiento**: numpy + Pillow
|
||||
- **Empaquetado**: uv (gestor de dependencias)
|
||||
|
||||
## Instalacion y uso
|
||||
|
||||
### Requisitos
|
||||
- Python 3.12+
|
||||
- `uv` instalado (`pip install uv` o ver https://docs.astral.sh/uv/)
|
||||
|
||||
### Iniciar servidor
|
||||
```bash
|
||||
cd /root/stl-repo
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
Abrir en navegador: `http://localhost:8000`
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```
|
||||
app/
|
||||
main.py # Punto de entrada FastAPI
|
||||
models.py # Modelos SQLAlchemy (ORM)
|
||||
schemas.py # Esquemas Pydantic
|
||||
database.py # Conexion SQLite
|
||||
parsers.py # Parser STL/3MF + generacion de thumbnails
|
||||
routers/
|
||||
models.py # Endpoints principales (CRUD, busqueda, descargas, etc.)
|
||||
migrate.py # Migraciones de DB
|
||||
static/
|
||||
index.html # Galeria
|
||||
upload.html # Formulario de subida (archivos/URL/ZIP)
|
||||
detail.html # Vista detalle con viewer 3D
|
||||
css/style.css
|
||||
js/
|
||||
app.js # Logica de galeria
|
||||
upload.js # Logica de subida
|
||||
detail.js # Viewer 3D + herramientas avanzadas
|
||||
api.js # Cliente HTTP
|
||||
theme.js # Tema oscuro/claro
|
||||
uploads/ # Archivos 3D subidos
|
||||
thumbnails/ # Thumbnails PNG generados
|
||||
images/ # Imagenes de referencia subidas
|
||||
tests/
|
||||
test_api.py # Tests de API (24 tests)
|
||||
test_parsers.py # Tests de parsing y thumbnails
|
||||
```
|
||||
|
||||
## API Endpoints principales
|
||||
|
||||
| Metodo | Endpoint | Descripcion |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/models/` | Listar modelos (con filtros y paginacion) |
|
||||
| POST | `/api/models/` | Subir modelo nuevo |
|
||||
| POST | `/api/models/import-url` | Importar desde URL |
|
||||
| POST | `/api/models/bulk-zip` | Subir ZIP con multiples modelos |
|
||||
| POST | `/api/models/batch-download` | Descargar seleccionados como ZIP |
|
||||
| GET | `/api/models/{id}` | Detalle completo de modelo |
|
||||
| PUT | `/api/models/{id}` | Actualizar metadatos |
|
||||
| DELETE | `/api/models/{id}` | Eliminar modelo |
|
||||
| GET | `/api/models/{id}/validate` | Validar malla (trimesh) |
|
||||
| GET | `/api/models/{id}/estimate` | Estimar costo/tiempo de impresion |
|
||||
| GET | `/api/models/{id}/download` | Descargar archivo principal |
|
||||
| GET | `/api/models/{id}/download-all` | Descargar todas las partes en ZIP |
|
||||
| GET | `/api/models/{id}/thumbnail` | Thumbnail PNG |
|
||||
| GET | `/api/models/{id}/qr` | Codigo QR para compartir |
|
||||
| POST | `/api/models/{id}/ratings` | Valorar modelo |
|
||||
| POST | `/api/models/{id}/comments` | Comentar |
|
||||
| GET | `/api/models/tags` | Listar tags con conteo |
|
||||
| GET/POST | `/api/models/collections/...` | CRUD de colecciones |
|
||||
| GET | `/api/models/system/backup` | Backup completo ZIP |
|
||||
|
||||
## Version
|
||||
|
||||
v2.2.0 — Abril 2026
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
20
app/database.py
Normal file
20
app/database.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
import os
|
||||
|
||||
DATABASE_URL = "sqlite:///./stl_repo.db"
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
38
app/main.py
Normal file
38
app/main.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from app.database import engine, Base
|
||||
from app.routers import models
|
||||
from app.migrate import run_migrations
|
||||
import os
|
||||
|
||||
# Create tables and run migrations
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
|
||||
app = FastAPI(title="STL Repository", version="2.1.0")
|
||||
|
||||
app.include_router(models.router)
|
||||
|
||||
# Serve static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Serve uploads, thumbnails and images directly
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
app.mount("/thumbnails", StaticFiles(directory="thumbnails"), name="thumbnails")
|
||||
app.mount("/images", StaticFiles(directory="images"), name="images")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.get("/upload")
|
||||
def upload_page():
|
||||
return FileResponse("static/upload.html")
|
||||
|
||||
|
||||
@app.get("/model/{model_id}")
|
||||
def detail_page(model_id: int):
|
||||
return FileResponse("static/detail.html")
|
||||
59
app/migrate.py
Normal file
59
app/migrate.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import os
|
||||
from sqlalchemy import text
|
||||
from app.database import engine, SessionLocal
|
||||
from app.models import Base, Tag, ModelFile
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Run manual migrations for SQLite."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check if we need to migrate old tags (CSV string) to new tag structure
|
||||
result = db.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='tags'"))
|
||||
if result.fetchone():
|
||||
# Check if there are models with old-style tags (comma separated) and no model_tags entries
|
||||
from app.models import Model3D
|
||||
models = db.query(Model3D).all()
|
||||
for model in models:
|
||||
# Migrate tags if model has tags string but no tag relationships
|
||||
if not model.tags and model.__dict__.get('tags_col'):
|
||||
tag_names = [t.strip().lower() for t in model.tags_col.split(',') if t.strip()]
|
||||
for name in tag_names:
|
||||
tag = db.query(Tag).filter(Tag.name == name).first()
|
||||
if not tag:
|
||||
tag = Tag(name=name)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
if tag not in model.tags:
|
||||
model.tags.append(tag)
|
||||
|
||||
# Check if we need to migrate files to model_files
|
||||
result = db.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='model_files'"))
|
||||
if result.fetchone():
|
||||
from app.models import Model3D
|
||||
models = db.query(Model3D).all()
|
||||
for model in models:
|
||||
# Check if model already has file records
|
||||
if not model.files:
|
||||
file_path = os.path.join("uploads", model.filename)
|
||||
if os.path.exists(file_path):
|
||||
mf = ModelFile(
|
||||
model_id=model.id,
|
||||
filename=model.filename,
|
||||
file_path=file_path,
|
||||
file_type='stl',
|
||||
is_primary=True,
|
||||
file_size=model.file_size,
|
||||
file_hash=model.file_hash,
|
||||
)
|
||||
db.add(mf)
|
||||
|
||||
db.commit()
|
||||
print("Migrations completed successfully")
|
||||
except Exception as e:
|
||||
print(f"Migration error: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
105
app/models.py
Normal file
105
app/models.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean, Table, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, timezone
|
||||
from app.database import Base
|
||||
|
||||
# Many-to-many association tables
|
||||
model_tags = Table(
|
||||
'model_tags',
|
||||
Base.metadata,
|
||||
Column('model_id', Integer, ForeignKey('models.id'), primary_key=True),
|
||||
Column('tag_id', Integer, ForeignKey('tags.id'), primary_key=True)
|
||||
)
|
||||
|
||||
collection_models = Table(
|
||||
'collection_models',
|
||||
Base.metadata,
|
||||
Column('collection_id', Integer, ForeignKey('collections.id'), primary_key=True),
|
||||
Column('model_id', Integer, ForeignKey('models.id'), primary_key=True)
|
||||
)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
|
||||
models = relationship("Model3D", secondary=model_tags, back_populates="tags")
|
||||
|
||||
|
||||
class Model3D(Base):
|
||||
__tablename__ = "models"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String, nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
author = Column(String, nullable=True)
|
||||
license = Column(String, nullable=True)
|
||||
category = Column(String, nullable=True)
|
||||
file_size = Column(Integer, nullable=True)
|
||||
file_hash = Column(String, nullable=True, index=True)
|
||||
width = Column(Float, nullable=True)
|
||||
height = Column(Float, nullable=True)
|
||||
depth = Column(Float, nullable=True)
|
||||
faces = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.now(timezone.utc))
|
||||
thumbnail_path = Column(String, nullable=True)
|
||||
download_count = Column(Integer, default=0)
|
||||
|
||||
tags = relationship("Tag", secondary=model_tags, back_populates="models")
|
||||
files = relationship("ModelFile", back_populates="model", cascade="all, delete-orphan")
|
||||
ratings = relationship("Rating", back_populates="model", cascade="all, delete-orphan")
|
||||
comments = relationship("Comment", back_populates="model", cascade="all, delete-orphan")
|
||||
collections = relationship("Collection", secondary=collection_models, back_populates="models")
|
||||
|
||||
|
||||
class ModelFile(Base):
|
||||
__tablename__ = "model_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
model_id = Column(Integer, ForeignKey("models.id"), nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
file_type = Column(String, nullable=False, default='stl')
|
||||
part_name = Column(String, nullable=True)
|
||||
is_primary = Column(Boolean, default=False)
|
||||
file_size = Column(Integer, nullable=True)
|
||||
file_hash = Column(String, nullable=True)
|
||||
|
||||
model = relationship("Model3D", back_populates="files")
|
||||
|
||||
|
||||
class Rating(Base):
|
||||
__tablename__ = "ratings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
model_id = Column(Integer, ForeignKey("models.id"), nullable=False)
|
||||
stars = Column(Integer, nullable=False) # 1-5
|
||||
created_at = Column(DateTime, default=datetime.now(timezone.utc))
|
||||
|
||||
model = relationship("Model3D", back_populates="ratings")
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.now(timezone.utc))
|
||||
|
||||
models = relationship("Model3D", secondary=collection_models, back_populates="collections")
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
model_id = Column(Integer, ForeignKey("models.id"), nullable=False)
|
||||
author_name = Column(String, nullable=True)
|
||||
text = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now(timezone.utc))
|
||||
|
||||
model = relationship("Model3D", back_populates="comments")
|
||||
208
app/parsers.py
Normal file
208
app/parsers.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import struct
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def parse_stl_file(file_path: str):
|
||||
"""Parse an STL file (binary or ASCII) and return mesh data + metadata."""
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(80)
|
||||
|
||||
is_binary = False
|
||||
if not header.startswith(b'solid'):
|
||||
is_binary = True
|
||||
else:
|
||||
with open(file_path, 'rb') as f:
|
||||
f.read(80)
|
||||
tri_count_bytes = f.read(4)
|
||||
if len(tri_count_bytes) == 4:
|
||||
tri_count = struct.unpack('<I', tri_count_bytes)[0]
|
||||
file_size = os.path.getsize(file_path)
|
||||
expected = 80 + 4 + tri_count * 50
|
||||
if file_size == expected:
|
||||
is_binary = True
|
||||
|
||||
if is_binary:
|
||||
return _parse_binary(file_path)
|
||||
else:
|
||||
return _parse_ascii(file_path)
|
||||
|
||||
|
||||
def _parse_binary(file_path: str):
|
||||
with open(file_path, 'rb') as f:
|
||||
f.read(80)
|
||||
tri_count = struct.unpack('<I', f.read(4))[0]
|
||||
vertices = []
|
||||
for _ in range(tri_count):
|
||||
f.read(12) # normal
|
||||
v1 = struct.unpack('<3f', f.read(12))
|
||||
v2 = struct.unpack('<3f', f.read(12))
|
||||
v3 = struct.unpack('<3f', f.read(12))
|
||||
f.read(2)
|
||||
vertices.extend([v1, v2, v3])
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
return _compute_metadata(vertices, tri_count)
|
||||
|
||||
|
||||
def _parse_ascii(file_path: str):
|
||||
vertices = []
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip().lower()
|
||||
if line.startswith('vertex'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
v = [float(parts[1]), float(parts[2]), float(parts[3])]
|
||||
vertices.append(v)
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
tri_count = len(vertices) // 3
|
||||
return _compute_metadata(vertices, tri_count)
|
||||
|
||||
|
||||
def _compute_metadata(vertices: np.ndarray, tri_count: int):
|
||||
if len(vertices) == 0:
|
||||
return {
|
||||
'vertices': vertices,
|
||||
'faces': 0,
|
||||
'width': 0.0,
|
||||
'height': 0.0,
|
||||
'depth': 0.0,
|
||||
}
|
||||
min_v = vertices.min(axis=0)
|
||||
max_v = vertices.max(axis=0)
|
||||
dims = max_v - min_v
|
||||
return {
|
||||
'vertices': vertices,
|
||||
'faces': tri_count,
|
||||
'width': float(dims[0]),
|
||||
'height': float(dims[1]),
|
||||
'depth': float(dims[2]),
|
||||
}
|
||||
|
||||
|
||||
def parse_3mf_file(file_path: str):
|
||||
"""Parse a 3MF file (zip with XML) and return mesh data + metadata."""
|
||||
vertices = []
|
||||
tri_count = 0
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, 'r') as zf:
|
||||
model_path = None
|
||||
for name in zf.namelist():
|
||||
if name.endswith('3dmodel.model') or name.endswith('.model'):
|
||||
model_path = name
|
||||
break
|
||||
if not model_path:
|
||||
raise ValueError("No 3D model found in 3MF archive")
|
||||
|
||||
with zf.open(model_path) as mf:
|
||||
tree = ET.parse(mf)
|
||||
root = tree.getroot()
|
||||
|
||||
# Find namespace
|
||||
ns = {'m': root.tag.split('}')[0].strip('{') if '}' in root.tag else ''}
|
||||
if ns['m']:
|
||||
ns = {'m': ns['m']}
|
||||
mesh = root.find('.//m:mesh', ns)
|
||||
else:
|
||||
mesh = root.find('.//mesh')
|
||||
|
||||
if mesh is None:
|
||||
raise ValueError("No mesh found in 3MF model")
|
||||
|
||||
verts_elem = mesh.find('m:vertices', ns) if ns else mesh.find('vertices')
|
||||
if verts_elem is not None:
|
||||
tag = 'm:vertex' if ns else 'vertex'
|
||||
for v in verts_elem.findall(tag, ns) if ns else verts_elem.findall(tag):
|
||||
x = float(v.get('x', 0))
|
||||
y = float(v.get('y', 0))
|
||||
z = float(v.get('z', 0))
|
||||
vertices.append([x, y, z])
|
||||
|
||||
tris_elem = mesh.find('m:triangles', ns) if ns else mesh.find('triangles')
|
||||
if tris_elem is not None:
|
||||
tag = 'm:triangle' if ns else 'triangle'
|
||||
tri_verts = []
|
||||
for t in tris_elem.findall(tag, ns) if ns else tris_elem.findall(tag):
|
||||
v1 = int(t.get('v1', 0))
|
||||
v2 = int(t.get('v2', 0))
|
||||
v3 = int(t.get('v3', 0))
|
||||
tri_verts.extend([vertices[v1], vertices[v2], vertices[v3]])
|
||||
vertices = tri_verts
|
||||
tri_count = len(tris_verts) // 3
|
||||
else:
|
||||
vertices = []
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse 3MF: {str(e)}")
|
||||
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
return _compute_metadata(vertices, tri_count)
|
||||
|
||||
|
||||
def parse_model_file(file_path: str):
|
||||
"""Auto-detect format and parse."""
|
||||
lower = file_path.lower()
|
||||
if lower.endswith('.stl'):
|
||||
return parse_stl_file(file_path)
|
||||
elif lower.endswith('.3mf'):
|
||||
return parse_3mf_file(file_path)
|
||||
else:
|
||||
raise ValueError("Unsupported file format. Only STL and 3MF are supported.")
|
||||
|
||||
|
||||
def generate_thumbnail(vertices: np.ndarray, output_path: str, size: int = 256):
|
||||
"""Generate a simple orthographic thumbnail from vertices."""
|
||||
if len(vertices) == 0:
|
||||
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
||||
img.save(output_path)
|
||||
return
|
||||
|
||||
min_v = vertices.min(axis=0)
|
||||
max_v = vertices.max(axis=0)
|
||||
dims = max_v - min_v
|
||||
scale = max(dims[0], dims[1])
|
||||
if scale == 0:
|
||||
scale = 1.0
|
||||
|
||||
margin = 20
|
||||
img_size = size - 2 * margin
|
||||
|
||||
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for i in range(0, len(vertices), 3):
|
||||
tri = vertices[i:i+3]
|
||||
pts = []
|
||||
for v in tri:
|
||||
x = margin + int(((v[0] - min_v[0]) / scale) * img_size)
|
||||
y = margin + int(((1.0 - (v[1] - min_v[1]) / scale)) * img_size)
|
||||
pts.append((x, y))
|
||||
if len(pts) == 3:
|
||||
z_avg = sum(v[2] for v in tri) / 3.0
|
||||
z_norm = (z_avg - min_v[2]) / (dims[2] if dims[2] > 0 else 1)
|
||||
brightness = int(80 + z_norm * 120)
|
||||
color = (brightness, brightness, int(brightness * 1.1))
|
||||
draw.polygon(pts, fill=color, outline=(50, 50, 60))
|
||||
|
||||
fmt = 'WEBP' if output_path.lower().endswith('.webp') else 'PNG'
|
||||
img.save(output_path, fmt)
|
||||
|
||||
|
||||
def generate_generic_thumbnail(output_path: str, size: int = 256, label: str = "3D"):
|
||||
"""Generate a generic thumbnail for unsupported previews."""
|
||||
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([40, 40, size-40, size-40], outline=(6, 182, 212), width=3)
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0,0), label, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
draw.text(((size - text_w) // 2, (size - text_h) // 2), label, fill=(6, 182, 212), font=font)
|
||||
img.save(output_path, 'PNG')
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
739
app/routers/models.py
Normal file
739
app/routers/models.py
Normal file
@@ -0,0 +1,739 @@
|
||||
import os
|
||||
import shutil
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
import qrcode
|
||||
import io
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException, Query, Request, Body
|
||||
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
|
||||
def _cache_headers(seconds: int):
|
||||
return {"Cache-Control": f"public, max-age={seconds}"}
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Model3D, Tag, ModelFile, Rating, Comment, Collection
|
||||
from app.schemas import (
|
||||
Model3DResponse, Model3DUpdate, RatingResponse, CommentResponse,
|
||||
CollectionResponse, CollectionCreate, CollectionDetailResponse
|
||||
)
|
||||
from app.parsers import parse_model_file, generate_thumbnail, generate_generic_thumbnail
|
||||
|
||||
router = APIRouter(prefix="/api/models", tags=["models"])
|
||||
|
||||
UPLOAD_DIR = "uploads"
|
||||
THUMBNAIL_DIR = "thumbnails"
|
||||
IMAGES_DIR = "images"
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
os.makedirs(THUMBNAIL_DIR, exist_ok=True)
|
||||
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _file_hash(file_path: str) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
while chunk := f.read(8192):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _get_or_create_tags(db: Session, tag_names: List[str]) -> List[Tag]:
|
||||
tags = []
|
||||
for name in tag_names:
|
||||
name = name.strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
tag = db.query(Tag).filter(Tag.name == name).first()
|
||||
if not tag:
|
||||
tag = Tag(name=name)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
def _calc_avg_rating(model: Model3D) -> Optional[float]:
|
||||
if not model.ratings:
|
||||
return None
|
||||
return round(sum(r.stars for r in model.ratings) / len(model.ratings), 1)
|
||||
|
||||
|
||||
# --- Cost estimator helpers ---
|
||||
def _estimate_print_time_seconds(volume_cm3: float) -> int:
|
||||
# Rough estimate: ~1 hour per 10cm3 at standard speed
|
||||
return int(volume_cm3 * 360)
|
||||
|
||||
|
||||
# --- Process a single STL file into a model ---
|
||||
def _process_single_file(db: Session, file_path: str, title: str, description: str,
|
||||
author: str, license_str: str, tags_str: str, category: str,
|
||||
part_name: Optional[str] = None, is_primary: bool = False) -> Model3D:
|
||||
file_hash = _file_hash(file_path)
|
||||
existing = db.query(Model3D).filter(Model3D.file_hash == file_hash).first()
|
||||
if existing:
|
||||
os.remove(file_path)
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"File already exists as '{existing.title}' (ID: {existing.id})")
|
||||
|
||||
try:
|
||||
model_data = parse_model_file(file_path)
|
||||
except Exception as e:
|
||||
os.remove(file_path)
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse: {str(e)}")
|
||||
|
||||
thumbnail_path = os.path.join(THUMBNAIL_DIR, f"{os.path.splitext(os.path.basename(file_path))[0]}.webp")
|
||||
generate_thumbnail(model_data['vertices'], thumbnail_path)
|
||||
|
||||
db_model = Model3D(
|
||||
title=title,
|
||||
filename=os.path.basename(file_path),
|
||||
description=description,
|
||||
author=author,
|
||||
license=license_str,
|
||||
category=category,
|
||||
file_size=os.path.getsize(file_path),
|
||||
file_hash=file_hash,
|
||||
width=model_data['width'],
|
||||
height=model_data['height'],
|
||||
depth=model_data['depth'],
|
||||
faces=model_data['faces'],
|
||||
thumbnail_path=thumbnail_path,
|
||||
)
|
||||
db.add(db_model)
|
||||
db.flush()
|
||||
|
||||
db_file = ModelFile(
|
||||
model_id=db_model.id,
|
||||
filename=os.path.basename(file_path),
|
||||
file_path=file_path,
|
||||
file_type='stl' if file_path.lower().endswith('.stl') else '3mf',
|
||||
part_name=part_name,
|
||||
is_primary=is_primary,
|
||||
file_size=os.path.getsize(file_path),
|
||||
file_hash=file_hash,
|
||||
)
|
||||
db.add(db_file)
|
||||
|
||||
tag_names = [t.strip() for t in (tags_str or '').split(',') if t.strip()]
|
||||
db_model.tags = _get_or_create_tags(db, tag_names)
|
||||
db.commit()
|
||||
db.refresh(db_model)
|
||||
return db_model
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATIC ROUTES (no path parameters) - MUST come before dynamic routes
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/", response_model=List[Model3DResponse])
|
||||
def list_models(
|
||||
search: Optional[str] = None, category: Optional[str] = None, tag: Optional[str] = None,
|
||||
min_width: Optional[float] = None, max_width: Optional[float] = None,
|
||||
min_height: Optional[float] = None, max_height: Optional[float] = None,
|
||||
min_depth: Optional[float] = None, max_depth: Optional[float] = None,
|
||||
min_faces: Optional[int] = None, max_faces: Optional[int] = None,
|
||||
date_from: Optional[str] = None, date_to: Optional[str] = None,
|
||||
sort_by: Optional[str] = Query("newest", enum=["newest", "oldest", "most_downloaded", "largest", "most_faces", "highest_rated"]),
|
||||
skip: int = Query(0, ge=0), limit: int = Query(24, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
query = db.query(Model3D)
|
||||
if search: query = query.filter(Model3D.title.contains(search))
|
||||
if category: query = query.filter(Model3D.category == category)
|
||||
if tag: query = query.join(Model3D.tags).filter(Tag.name.contains(tag.lower()))
|
||||
if min_width: query = query.filter(Model3D.width >= min_width)
|
||||
if max_width: query = query.filter(Model3D.width <= max_width)
|
||||
if min_height: query = query.filter(Model3D.height >= min_height)
|
||||
if max_height: query = query.filter(Model3D.height <= max_height)
|
||||
if min_depth: query = query.filter(Model3D.depth >= min_depth)
|
||||
if max_depth: query = query.filter(Model3D.depth <= max_depth)
|
||||
if min_faces: query = query.filter(Model3D.faces >= min_faces)
|
||||
if max_faces: query = query.filter(Model3D.faces <= max_faces)
|
||||
if date_from: query = query.filter(Model3D.created_at >= date_from)
|
||||
if date_to: query = query.filter(Model3D.created_at <= date_to)
|
||||
|
||||
if sort_by == "newest": query = query.order_by(Model3D.created_at.desc())
|
||||
elif sort_by == "oldest": query = query.order_by(Model3D.created_at.asc())
|
||||
elif sort_by == "most_downloaded": query = query.order_by(Model3D.download_count.desc())
|
||||
elif sort_by == "largest": query = query.order_by(Model3D.file_size.desc())
|
||||
elif sort_by == "most_faces": query = query.order_by(Model3D.faces.desc())
|
||||
elif sort_by == "highest_rated":
|
||||
models = query.all()
|
||||
models.sort(key=lambda m: _calc_avg_rating(m) or 0, reverse=True)
|
||||
return models[skip:skip+limit]
|
||||
|
||||
models = query.offset(skip).limit(limit).all()
|
||||
return JSONResponse(content=[Model3DResponse.model_validate(m).model_dump(mode='json') for m in models], headers=_cache_headers(60))
|
||||
|
||||
|
||||
@router.get("/tags", response_model=List[dict])
|
||||
def list_tags(search: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
query = db.query(Tag)
|
||||
if search: query = query.filter(Tag.name.contains(search.lower()))
|
||||
tags = query.order_by(Tag.name).all()
|
||||
data = [{"id": t.id, "name": t.name, "count": len(t.models)} for t in tags]
|
||||
return JSONResponse(content=data, headers=_cache_headers(300))
|
||||
|
||||
|
||||
@router.get("/collections/all", response_model=List[CollectionResponse])
|
||||
def list_collections(db: Session = Depends(get_db)):
|
||||
collections = db.query(Collection).order_by(Collection.created_at.desc()).all()
|
||||
result = []
|
||||
for c in collections:
|
||||
resp = CollectionResponse.model_validate(c)
|
||||
resp.model_count = len(c.models)
|
||||
result.append(resp)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/collections", response_model=CollectionResponse)
|
||||
def create_collection(data: CollectionCreate, db: Session = Depends(get_db)):
|
||||
coll = Collection(name=data.name, description=data.description)
|
||||
db.add(coll)
|
||||
db.commit()
|
||||
db.refresh(coll)
|
||||
resp = CollectionResponse.model_validate(coll)
|
||||
resp.model_count = 0
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/system/backup")
|
||||
def backup_all(db: Session = Depends(get_db)):
|
||||
from datetime import datetime
|
||||
import glob
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
db_path = "stl_repo.db"
|
||||
if os.path.exists(db_path): zf.write(db_path, arcname="backup/stl_repo.db")
|
||||
for f in glob.glob("uploads/*"):
|
||||
if os.path.isfile(f): zf.write(f, arcname=f"backup/{f}")
|
||||
for f in glob.glob("thumbnails/*"):
|
||||
if os.path.isfile(f): zf.write(f, arcname=f"backup/{f}")
|
||||
for f in glob.glob("images/*"):
|
||||
if os.path.isfile(f): zf.write(f, arcname=f"backup/{f}")
|
||||
models = db.query(Model3D).all()
|
||||
metadata = []
|
||||
for m in models:
|
||||
metadata.append({
|
||||
"id": m.id, "title": m.title, "filename": m.filename,
|
||||
"description": m.description, "author": m.author, "license": m.license,
|
||||
"category": m.category, "tags": [t.name for t in m.tags],
|
||||
"faces": m.faces, "width": m.width, "height": m.height, "depth": m.depth,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
"download_count": m.download_count,
|
||||
"files": [{"filename": f.filename, "file_type": f.file_type, "part_name": f.part_name, "is_primary": f.is_primary} for f in m.files]
|
||||
})
|
||||
zf.writestr("backup/metadata.json", json.dumps(metadata, indent=2, default=str))
|
||||
zip_buffer.seek(0)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return StreamingResponse(zip_buffer, media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename=stl_repo_backup_{timestamp}.zip"})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODEL CREATION
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/", response_model=Model3DResponse)
|
||||
async def create_model(
|
||||
title: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
author: Optional[str] = Form(None),
|
||||
license: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
category: Optional[str] = Form(None),
|
||||
files: List[UploadFile] = File(default=[]),
|
||||
images: List[UploadFile] = File(default=[]),
|
||||
part_names: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="At least one 3D file is required")
|
||||
|
||||
part_names_map = {}
|
||||
if part_names:
|
||||
try:
|
||||
part_names_map = json.loads(part_names)
|
||||
except:
|
||||
pass
|
||||
|
||||
saved_3d_files = []
|
||||
total_faces = 0
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
if not file.filename:
|
||||
continue
|
||||
lower_name = file.filename.lower()
|
||||
if not (lower_name.endswith('.stl') or lower_name.endswith('.3mf')):
|
||||
for sf in saved_3d_files:
|
||||
if os.path.exists(sf['path']): os.remove(sf['path'])
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported format: {file.filename}")
|
||||
|
||||
file_path = os.path.join(UPLOAD_DIR, file.filename)
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
file_hash = _file_hash(file_path)
|
||||
existing = db.query(Model3D).filter(Model3D.file_hash == file_hash).first()
|
||||
if existing:
|
||||
os.remove(file_path)
|
||||
for sf in saved_3d_files:
|
||||
if os.path.exists(sf['path']): os.remove(sf['path'])
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"File '{file.filename}' already exists as '{existing.title}' (ID: {existing.id})")
|
||||
|
||||
try:
|
||||
model_data = parse_model_file(file_path)
|
||||
except Exception as e:
|
||||
os.remove(file_path)
|
||||
for sf in saved_3d_files:
|
||||
if os.path.exists(sf['path']): os.remove(sf['path'])
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse {file.filename}: {str(e)}")
|
||||
|
||||
saved_3d_files.append({
|
||||
'path': file_path, 'filename': file.filename, 'hash': file_hash,
|
||||
'size': os.path.getsize(file_path), 'data': model_data,
|
||||
'is_primary': idx == 0, 'part_name': part_names_map.get(str(idx), None),
|
||||
})
|
||||
if idx == 0:
|
||||
total_faces += model_data['faces']
|
||||
|
||||
if not saved_3d_files:
|
||||
raise HTTPException(status_code=400, detail="No valid 3D files provided")
|
||||
|
||||
primary = saved_3d_files[0]
|
||||
thumbnail_path = os.path.join(THUMBNAIL_DIR, f"{os.path.splitext(primary['filename'])[0]}.webp")
|
||||
generate_thumbnail(primary['data']['vertices'], thumbnail_path)
|
||||
|
||||
db_model = Model3D(
|
||||
title=title, filename=primary['filename'], description=description,
|
||||
author=author, license=license, category=category,
|
||||
file_size=sum(f['size'] for f in saved_3d_files),
|
||||
file_hash=primary['hash'], width=primary['data']['width'],
|
||||
height=primary['data']['height'], depth=primary['data']['depth'],
|
||||
faces=total_faces, thumbnail_path=thumbnail_path,
|
||||
)
|
||||
db.add(db_model)
|
||||
db.flush()
|
||||
|
||||
for sf in saved_3d_files:
|
||||
db_file = ModelFile(
|
||||
model_id=db_model.id, filename=sf['filename'], file_path=sf['path'],
|
||||
file_type='stl' if sf['filename'].lower().endswith('.stl') else '3mf',
|
||||
part_name=sf['part_name'], is_primary=sf['is_primary'],
|
||||
file_size=sf['size'], file_hash=sf['hash'],
|
||||
)
|
||||
db.add(db_file)
|
||||
|
||||
for img in images:
|
||||
if not img.filename: continue
|
||||
lower_name = img.filename.lower()
|
||||
if not (lower_name.endswith('.jpg') or lower_name.endswith('.jpeg') or lower_name.endswith('.png')):
|
||||
continue
|
||||
img_path = os.path.join(IMAGES_DIR, img.filename)
|
||||
with open(img_path, "wb") as buffer:
|
||||
shutil.copyfileobj(img.file, buffer)
|
||||
db.add(ModelFile(
|
||||
model_id=db_model.id, filename=img.filename, file_path=img_path,
|
||||
file_type='image', is_primary=False, file_size=os.path.getsize(img_path),
|
||||
))
|
||||
|
||||
tag_names = [t.strip() for t in (tags or '').split(',') if t.strip()]
|
||||
db_model.tags = _get_or_create_tags(db, tag_names)
|
||||
db.commit()
|
||||
db.refresh(db_model)
|
||||
response_data = Model3DResponse.model_validate(db_model)
|
||||
response_data.avg_rating = _calc_avg_rating(db_model)
|
||||
return response_data
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IMPORT / BULK / BATCH
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/import-url", response_model=Model3DResponse)
|
||||
async def import_from_url(
|
||||
url: str = Form(...),
|
||||
title: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
author: Optional[str] = Form(None),
|
||||
license: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
category: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
MAX_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
if len(r.content) > MAX_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 50MB)")
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Download failed: {str(e)}")
|
||||
|
||||
filename = os.path.basename(parsed.path) or "downloaded.stl"
|
||||
if not filename.lower().endswith(('.stl', '.3mf')):
|
||||
filename += ".stl"
|
||||
|
||||
file_path = os.path.join(UPLOAD_DIR, filename)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(r.content)
|
||||
|
||||
db_model = _process_single_file(db, file_path, title, description, author, license, tags, category, is_primary=True)
|
||||
response_data = Model3DResponse.model_validate(db_model)
|
||||
response_data.avg_rating = _calc_avg_rating(db_model)
|
||||
return response_data
|
||||
|
||||
|
||||
@router.post("/bulk-zip", response_model=List[Model3DResponse])
|
||||
async def bulk_zip_upload(
|
||||
zip_file: UploadFile = File(...),
|
||||
description: Optional[str] = Form(None),
|
||||
author: Optional[str] = Form(None),
|
||||
license: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
category: Optional[str] = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not zip_file.filename or not zip_file.filename.lower().endswith('.zip'):
|
||||
raise HTTPException(status_code=400, detail="Only ZIP files are allowed")
|
||||
|
||||
tmp_path = os.path.join(tempfile.gettempdir(), zip_file.filename)
|
||||
with open(tmp_path, 'wb') as f:
|
||||
shutil.copyfileobj(zip_file.file, f)
|
||||
|
||||
extract_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, 'r') as zf:
|
||||
zf.extractall(extract_dir)
|
||||
except zipfile.BadZipFile:
|
||||
os.remove(tmp_path)
|
||||
raise HTTPException(status_code=400, detail="Invalid ZIP file")
|
||||
|
||||
results = []
|
||||
for root, dirs, files in os.walk(extract_dir):
|
||||
for fname in files:
|
||||
lower = fname.lower()
|
||||
if lower.endswith('.stl') or lower.endswith('.3mf'):
|
||||
src = os.path.join(root, fname)
|
||||
dst = os.path.join(UPLOAD_DIR, fname)
|
||||
# avoid collisions
|
||||
if os.path.exists(dst):
|
||||
base, ext = os.path.splitext(fname)
|
||||
fname = f"{base}_{hashlib.md5(src.encode()).hexdigest()[:6]}{ext}"
|
||||
dst = os.path.join(UPLOAD_DIR, fname)
|
||||
shutil.move(src, dst)
|
||||
try:
|
||||
db_model = _process_single_file(
|
||||
db, dst, os.path.splitext(fname)[0],
|
||||
description, author, license, tags, category, is_primary=True
|
||||
)
|
||||
response_data = Model3DResponse.model_validate(db_model)
|
||||
response_data.avg_rating = _calc_avg_rating(db_model)
|
||||
results.append(response_data)
|
||||
except HTTPException:
|
||||
pass # skip duplicates
|
||||
|
||||
os.remove(tmp_path)
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/batch-download")
|
||||
def batch_download(
|
||||
model_ids: List[int] = Body(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not model_ids:
|
||||
raise HTTPException(status_code=400, detail="No model IDs provided")
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for mid in model_ids:
|
||||
model = db.query(Model3D).filter(Model3D.id == mid).first()
|
||||
if not model:
|
||||
continue
|
||||
for mf in model.files:
|
||||
if mf.file_type in ('stl', '3mf') and os.path.exists(mf.file_path):
|
||||
zf.write(mf.file_path, arcname=f"{model.title.replace(' ', '_')}/{mf.filename}")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return StreamingResponse(
|
||||
zip_buffer, media_type="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=batch_download.zip"}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DYNAMIC MODEL ROUTES
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{model_id}", response_model=Model3DResponse)
|
||||
def get_model(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
response_data = Model3DResponse.model_validate(model)
|
||||
response_data.avg_rating = _calc_avg_rating(model)
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{model_id}", response_model=Model3DResponse)
|
||||
def update_model(model_id: int, data: Model3DUpdate, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
if data.title is not None: model.title = data.title
|
||||
if data.description is not None: model.description = data.description
|
||||
if data.author is not None: model.author = data.author
|
||||
if data.license is not None: model.license = data.license
|
||||
if data.category is not None: model.category = data.category
|
||||
if data.tag_names is not None: model.tags = _get_or_create_tags(db, data.tag_names)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
response_data = Model3DResponse.model_validate(model)
|
||||
response_data.avg_rating = _calc_avg_rating(model)
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
def delete_model(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
for mf in model.files:
|
||||
if os.path.exists(mf.file_path): os.remove(mf.file_path)
|
||||
if model.thumbnail_path and os.path.exists(model.thumbnail_path): os.remove(model.thumbnail_path)
|
||||
db.delete(model)
|
||||
db.commit()
|
||||
return {"detail": "Model deleted"}
|
||||
|
||||
|
||||
# --- Validation & Estimation ---
|
||||
|
||||
@router.get("/{model_id}/validate")
|
||||
def validate_mesh(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="Model not found")
|
||||
|
||||
primary = next((f for f in model.files if f.is_primary and f.file_type in ('stl', '3mf')), None)
|
||||
if not primary:
|
||||
raise HTTPException(status_code=404, detail="No 3D file found")
|
||||
|
||||
try:
|
||||
import trimesh
|
||||
mesh = trimesh.load(primary.file_path, force='mesh')
|
||||
volume = abs(float(mesh.volume)) if mesh.is_watertight else 0.0
|
||||
area = float(mesh.area)
|
||||
is_watertight = bool(mesh.is_watertight)
|
||||
is_winding_consistent = bool(mesh.is_winding_consistent)
|
||||
bounds = mesh.bounds.tolist()
|
||||
euler = int(mesh.euler_number)
|
||||
|
||||
# Count holes via euler characteristic
|
||||
holes = 0 if is_watertight else max(0, 2 - euler)
|
||||
|
||||
return {
|
||||
"is_watertight": is_watertight,
|
||||
"is_winding_consistent": is_winding_consistent,
|
||||
"volume_cm3": round(volume / 1000, 3) if volume else 0,
|
||||
"surface_area_cm2": round(area / 100, 2),
|
||||
"bounds_mm": bounds,
|
||||
"euler_number": euler,
|
||||
"estimated_holes": holes,
|
||||
"face_count": len(mesh.faces),
|
||||
"vertex_count": len(mesh.vertices),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{model_id}/estimate")
|
||||
def estimate_print(model_id: int, price_per_kg: float = Query(20.0), material_density: float = Query(1.24), db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model:
|
||||
raise HTTPException(status_code=404, detail="Model not found")
|
||||
|
||||
primary = next((f for f in model.files if f.is_primary and f.file_type in ('stl', '3mf')), None)
|
||||
if not primary:
|
||||
raise HTTPException(status_code=404, detail="No 3D file found")
|
||||
|
||||
try:
|
||||
import trimesh
|
||||
mesh = trimesh.load(primary.file_path, force='mesh')
|
||||
volume_cm3 = abs(float(mesh.volume)) / 1000.0
|
||||
grams = volume_cm3 * material_density
|
||||
cost = (grams / 1000.0) * price_per_kg
|
||||
seconds = _estimate_print_time_seconds(volume_cm3)
|
||||
hours = seconds // 3600
|
||||
mins = (seconds % 3600) // 60
|
||||
|
||||
return {
|
||||
"volume_cm3": round(volume_cm3, 2),
|
||||
"grams": round(grams, 2),
|
||||
"cost": round(cost, 2),
|
||||
"estimated_time": f"{hours}h {mins}m",
|
||||
"estimated_seconds": seconds,
|
||||
"price_per_kg": price_per_kg,
|
||||
"material_density": material_density,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Estimation failed: {str(e)}")
|
||||
|
||||
|
||||
# --- Ratings ---
|
||||
|
||||
@router.post("/{model_id}/ratings", response_model=RatingResponse)
|
||||
def create_rating(model_id: int, stars: int = Query(..., ge=1, le=5), db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
rating = Rating(model_id=model_id, stars=stars)
|
||||
db.add(rating)
|
||||
db.commit()
|
||||
db.refresh(rating)
|
||||
return rating
|
||||
|
||||
|
||||
@router.get("/{model_id}/ratings", response_model=List[RatingResponse])
|
||||
def list_ratings(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
return db.query(Rating).filter(Rating.model_id == model_id).order_by(Rating.created_at.desc()).all()
|
||||
|
||||
|
||||
# --- Comments ---
|
||||
|
||||
@router.post("/{model_id}/comments", response_model=CommentResponse)
|
||||
def create_comment(model_id: int, text: str = Query(..., min_length=1), author_name: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
comment = Comment(model_id=model_id, text=text, author_name=author_name)
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
return comment
|
||||
|
||||
|
||||
@router.get("/{model_id}/comments", response_model=List[CommentResponse])
|
||||
def list_comments(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
return db.query(Comment).filter(Comment.model_id == model_id).order_by(Comment.created_at.desc()).all()
|
||||
|
||||
|
||||
# --- Collections (dynamic) ---
|
||||
|
||||
@router.get("/collections/{collection_id}", response_model=CollectionDetailResponse)
|
||||
def get_collection(collection_id: int, db: Session = Depends(get_db)):
|
||||
coll = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not coll: raise HTTPException(status_code=404, detail="Collection not found")
|
||||
resp = CollectionDetailResponse.model_validate(coll)
|
||||
resp.model_count = len(coll.models)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/add/{model_id}")
|
||||
def add_to_collection(collection_id: int, model_id: int, db: Session = Depends(get_db)):
|
||||
coll = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not coll: raise HTTPException(status_code=404, detail="Collection not found")
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
if model not in coll.models:
|
||||
coll.models.append(model)
|
||||
db.commit()
|
||||
return {"detail": "Model added to collection"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/remove/{model_id}")
|
||||
def remove_from_collection(collection_id: int, model_id: int, db: Session = Depends(get_db)):
|
||||
coll = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not coll: raise HTTPException(status_code=404, detail="Collection not found")
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
if model in coll.models:
|
||||
coll.models.remove(model)
|
||||
db.commit()
|
||||
return {"detail": "Model removed from collection"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
def delete_collection(collection_id: int, db: Session = Depends(get_db)):
|
||||
coll = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not coll: raise HTTPException(status_code=404, detail="Collection not found")
|
||||
db.delete(coll)
|
||||
db.commit()
|
||||
return {"detail": "Collection deleted"}
|
||||
|
||||
|
||||
# --- Downloads & QR ---
|
||||
|
||||
@router.get("/{model_id}/download")
|
||||
def download_model(model_id: int, file_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
if file_id:
|
||||
mf = db.query(ModelFile).filter(ModelFile.id == file_id, ModelFile.model_id == model_id).first()
|
||||
if not mf: raise HTTPException(status_code=404, detail="File not found")
|
||||
if not os.path.exists(mf.file_path): raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
model.download_count += 1; db.commit()
|
||||
return FileResponse(path=mf.file_path, filename=mf.filename, media_type="application/octet-stream")
|
||||
primary = next((f for f in model.files if f.is_primary and f.file_type in ('stl', '3mf')), None)
|
||||
file_path = primary.file_path if primary else os.path.join(UPLOAD_DIR, model.filename)
|
||||
if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
model.download_count += 1; db.commit()
|
||||
return FileResponse(path=file_path, filename=model.filename, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/{model_id}/download-all")
|
||||
def download_all(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
model_files = [f for f in model.files if f.file_type in ('stl', '3mf')]
|
||||
if not model_files: raise HTTPException(status_code=404, detail="No 3D files found")
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for mf in model_files:
|
||||
if os.path.exists(mf.file_path): zf.write(mf.file_path, arcname=mf.filename)
|
||||
zip_buffer.seek(0)
|
||||
model.download_count += 1; db.commit()
|
||||
return StreamingResponse(zip_buffer, media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename={model.title.replace(' ', '_')}_all_parts.zip"})
|
||||
|
||||
|
||||
@router.get("/{model_id}/thumbnail")
|
||||
def get_thumbnail(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
# Try WEBP first, fallback to PNG
|
||||
if model.thumbnail_path:
|
||||
if os.path.exists(model.thumbnail_path):
|
||||
media_type = "image/webp" if model.thumbnail_path.lower().endswith('.webp') else "image/png"
|
||||
return FileResponse(path=model.thumbnail_path, media_type=media_type, headers=_cache_headers(86400))
|
||||
# Fallback to PNG with same base name
|
||||
png_path = os.path.splitext(model.thumbnail_path)[0] + '.png'
|
||||
if os.path.exists(png_path):
|
||||
return FileResponse(path=png_path, media_type="image/png", headers=_cache_headers(86400))
|
||||
raise HTTPException(status_code=404, detail="Thumbnail not found")
|
||||
|
||||
|
||||
@router.get("/{model_id}/qr")
|
||||
def get_qr(model_id: int, db: Session = Depends(get_db)):
|
||||
model = db.query(Model3D).filter(Model3D.id == model_id).first()
|
||||
if not model: raise HTTPException(status_code=404, detail="Model not found")
|
||||
url = f"http://192.168.10.104:8000/model/{model_id}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=2)
|
||||
qr.add_data(url); qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="#0f172a", back_color="#ffffff")
|
||||
buf = io.BytesIO(); img.save(buf, format='PNG'); buf.seek(0)
|
||||
return StreamingResponse(buf, media_type="image/png")
|
||||
108
app/schemas.py
Normal file
108
app/schemas.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TagBase(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class TagCreate(TagBase):
|
||||
pass
|
||||
|
||||
|
||||
class TagResponse(TagBase):
|
||||
id: int
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ModelFileBase(BaseModel):
|
||||
filename: str
|
||||
file_type: str = 'stl'
|
||||
part_name: Optional[str] = None
|
||||
is_primary: bool = False
|
||||
file_size: Optional[int] = None
|
||||
|
||||
|
||||
class ModelFileResponse(ModelFileBase):
|
||||
id: int
|
||||
file_path: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RatingResponse(BaseModel):
|
||||
id: int
|
||||
stars: int
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
id: int
|
||||
author_name: Optional[str]
|
||||
text: str
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CollectionBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CollectionCreate(CollectionBase):
|
||||
pass
|
||||
|
||||
|
||||
class CollectionResponse(CollectionBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
model_count: int = 0
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CollectionDetailResponse(CollectionResponse):
|
||||
models: List['Model3DResponse'] = []
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class Model3DBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class Model3DCreate(Model3DBase):
|
||||
tag_names: Optional[List[str]] = []
|
||||
|
||||
|
||||
class Model3DUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
tag_names: Optional[List[str]] = None
|
||||
|
||||
|
||||
class Model3DResponse(Model3DBase):
|
||||
id: int
|
||||
filename: str
|
||||
file_size: Optional[int]
|
||||
file_hash: Optional[str]
|
||||
width: Optional[float]
|
||||
height: Optional[float]
|
||||
depth: Optional[float]
|
||||
faces: Optional[int]
|
||||
created_at: datetime
|
||||
thumbnail_path: Optional[str]
|
||||
download_count: int
|
||||
avg_rating: Optional[float] = None
|
||||
tags: List[TagResponse] = []
|
||||
files: List[ModelFileResponse] = []
|
||||
ratings: List[RatingResponse] = []
|
||||
comments: List[CommentResponse] = []
|
||||
collections: List[CollectionResponse] = []
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
127
app/stl_parser.py
Normal file
127
app/stl_parser.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import struct
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
import os
|
||||
|
||||
|
||||
def parse_stl(file_path: str):
|
||||
"""Parse an STL file (binary or ASCII) and return mesh data + metadata."""
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(80)
|
||||
|
||||
is_binary = False
|
||||
if not header.startswith(b'solid'):
|
||||
is_binary = True
|
||||
else:
|
||||
# Some binary files also start with 'solid', check further
|
||||
with open(file_path, 'rb') as f:
|
||||
f.read(80)
|
||||
tri_count_bytes = f.read(4)
|
||||
if len(tri_count_bytes) == 4:
|
||||
tri_count = struct.unpack('<I', tri_count_bytes)[0]
|
||||
file_size = os.path.getsize(file_path)
|
||||
expected = 80 + 4 + tri_count * 50
|
||||
if file_size == expected:
|
||||
is_binary = True
|
||||
|
||||
if is_binary:
|
||||
return _parse_binary(file_path)
|
||||
else:
|
||||
return _parse_ascii(file_path)
|
||||
|
||||
|
||||
def _parse_binary(file_path: str):
|
||||
with open(file_path, 'rb') as f:
|
||||
f.read(80) # skip header
|
||||
tri_count = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
vertices = []
|
||||
for _ in range(tri_count):
|
||||
f.read(12) # normal
|
||||
v1 = struct.unpack('<3f', f.read(12))
|
||||
v2 = struct.unpack('<3f', f.read(12))
|
||||
v3 = struct.unpack('<3f', f.read(12))
|
||||
f.read(2) # attribute byte count
|
||||
vertices.extend([v1, v2, v3])
|
||||
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
return _compute_metadata(vertices, tri_count)
|
||||
|
||||
|
||||
def _parse_ascii(file_path: str):
|
||||
vertices = []
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for line in f:
|
||||
line = line.strip().lower()
|
||||
if line.startswith('vertex'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
v = [float(parts[1]), float(parts[2]), float(parts[3])]
|
||||
vertices.append(v)
|
||||
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
tri_count = len(vertices) // 3
|
||||
return _compute_metadata(vertices, tri_count)
|
||||
|
||||
|
||||
def _compute_metadata(vertices: np.ndarray, tri_count: int):
|
||||
if len(vertices) == 0:
|
||||
return {
|
||||
'vertices': vertices,
|
||||
'faces': 0,
|
||||
'width': 0.0,
|
||||
'height': 0.0,
|
||||
'depth': 0.0,
|
||||
}
|
||||
|
||||
min_v = vertices.min(axis=0)
|
||||
max_v = vertices.max(axis=0)
|
||||
dims = max_v - min_v
|
||||
|
||||
return {
|
||||
'vertices': vertices,
|
||||
'faces': tri_count,
|
||||
'width': float(dims[0]),
|
||||
'height': float(dims[1]),
|
||||
'depth': float(dims[2]),
|
||||
}
|
||||
|
||||
|
||||
def generate_thumbnail(vertices: np.ndarray, output_path: str, size: int = 256):
|
||||
"""Generate a simple orthographic thumbnail from vertices."""
|
||||
if len(vertices) == 0:
|
||||
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
||||
img.save(output_path)
|
||||
return
|
||||
|
||||
# Project to XY plane, normalize to image coords
|
||||
min_v = vertices.min(axis=0)
|
||||
max_v = vertices.max(axis=0)
|
||||
dims = max_v - min_v
|
||||
scale = max(dims[0], dims[1])
|
||||
if scale == 0:
|
||||
scale = 1.0
|
||||
|
||||
margin = 20
|
||||
img_size = size - 2 * margin
|
||||
|
||||
img = Image.new('RGB', (size, size), color=(30, 30, 30))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw triangles
|
||||
for i in range(0, len(vertices), 3):
|
||||
tri = vertices[i:i+3]
|
||||
pts = []
|
||||
for v in tri:
|
||||
x = margin + int(((v[0] - min_v[0]) / scale) * img_size)
|
||||
y = margin + int(((1.0 - (v[1] - min_v[1]) / scale)) * img_size)
|
||||
pts.append((x, y))
|
||||
if len(pts) == 3:
|
||||
# Simple shading based on Z
|
||||
z_avg = sum(v[2] for v in tri) / 3.0
|
||||
z_norm = (z_avg - min_v[2]) / (dims[2] if dims[2] > 0 else 1)
|
||||
brightness = int(80 + z_norm * 120)
|
||||
color = (brightness, brightness, int(brightness * 1.1))
|
||||
draw.polygon(pts, fill=color, outline=(50, 50, 60))
|
||||
|
||||
img.save(output_path, 'PNG')
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
stl-repo:
|
||||
build: .
|
||||
container_name: stl-repository
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
- ./thumbnails:/app/thumbnails
|
||||
- ./images:/app/images
|
||||
environment:
|
||||
- UVICORN_HOST=0.0.0.0
|
||||
- UVICORN_PORT=8000
|
||||
restart: unless-stopped
|
||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from stl-repo!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "stl-repo"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.136.1",
|
||||
"numpy>=2.4.4",
|
||||
"pillow>=12.2.0",
|
||||
"pydantic>=2.13.3",
|
||||
"python-multipart>=0.0.26",
|
||||
"qrcode[pil]>=8.2",
|
||||
"sqlalchemy>=2.0.49",
|
||||
"trimesh>=4.12.1",
|
||||
"uvicorn>=0.46.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.3",
|
||||
]
|
||||
101
static/css/style.css
Normal file
101
static/css/style.css
Normal file
@@ -0,0 +1,101 @@
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.light-mode .glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-6px) scale(1.01);
|
||||
box-shadow: 0 20px 40px -10px rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
|
||||
.light-mode .card-hover:hover {
|
||||
box-shadow: 0 20px 40px -10px rgba(6, 182, 212, 0.25);
|
||||
}
|
||||
|
||||
.drop-active {
|
||||
border-color: #06b6d4 !important;
|
||||
background: rgba(6, 182, 212, 0.08) !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: #0f172a; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
|
||||
.light-mode ::-webkit-scrollbar-track { background: #f1f5f9; }
|
||||
.light-mode ::-webkit-scrollbar-thumb { background: #cbd5e1; }
|
||||
.light-mode ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
min-width: 280px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
.toast.success { background: #059669; }
|
||||
.toast.error { background: #dc2626; }
|
||||
.toast.info { background: #2563eb; }
|
||||
|
||||
/* Light mode overrides via class on html */
|
||||
.light-mode body {
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.light-mode .bg-slate-950 { background-color: #f8fafc !important; }
|
||||
.light-mode .bg-slate-900 { background-color: #e2e8f0 !important; }
|
||||
.light-mode .bg-slate-900\/40 { background-color: rgba(226, 232, 240, 0.4) !important; }
|
||||
.light-mode .bg-slate-900\/50 { background-color: rgba(226, 232, 240, 0.5) !important; }
|
||||
.light-mode .bg-slate-900\/60 { background-color: rgba(226, 232, 240, 0.6) !important; }
|
||||
.light-mode .bg-slate-800 { background-color: #cbd5e1 !important; }
|
||||
.light-mode .bg-slate-800\/80 { background-color: rgba(203, 213, 225, 0.8) !important; }
|
||||
.light-mode .text-slate-100 { color: #1e293b !important; }
|
||||
.light-mode .text-slate-200 { color: #334155 !important; }
|
||||
.light-mode .text-slate-300 { color: #475569 !important; }
|
||||
.light-mode .text-slate-400 { color: #64748b !important; }
|
||||
.light-mode .text-slate-500 { color: #94a3b8 !important; }
|
||||
.light-mode .text-slate-600 { color: #cbd5e1 !important; }
|
||||
.light-mode .border-white\/5 { border-color: rgba(0,0,0,0.05) !important; }
|
||||
.light-mode .border-white\/10 { border-color: rgba(0,0,0,0.1) !important; }
|
||||
.light-mode .border-slate-700 { border-color: #cbd5e1 !important; }
|
||||
332
static/detail.html
Normal file
332
static/detail.html
Normal file
@@ -0,0 +1,332 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Detalle - STL Repository</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#172033', 950: '#020617' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-xl shadow-lg group-hover:scale-110 transition-transform">
|
||||
🖨️
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">STL Repository</h1>
|
||||
<p class="text-xs text-slate-400 -mt-0.5">Modelos 3D para imprimir</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center">
|
||||
<button id="theme-toggle" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-yellow-400 transition-colors mr-2" title="Cambiar tema">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-sm font-medium transition-colors">
|
||||
Volver a Galeria
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-8">
|
||||
<!-- Viewer -->
|
||||
<div class="lg:col-span-3">
|
||||
<div class="glass rounded-2xl overflow-hidden border border-white/5 relative" style="height: 520px;">
|
||||
<div id="viewer" class="w-full h-full relative">
|
||||
<div id="viewer-loading" class="absolute inset-0 flex flex-col items-center justify-center text-slate-500 z-10">
|
||||
<div class="w-12 h-12 border-4 border-slate-700 border-t-cyan-500 rounded-full animate-spin mb-4"></div>
|
||||
<p class="text-sm">Cargando modelo 3D...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Viewer Controls Overlay -->
|
||||
<div class="absolute top-4 left-4 flex flex-col gap-2 z-20">
|
||||
<div class="glass rounded-xl p-2 flex flex-col gap-1.5">
|
||||
<button onclick="setViewMode('solid')" id="btn-solid" class="p-2 rounded-lg bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 transition-colors" title="Solido">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
|
||||
</button>
|
||||
<button onclick="setViewMode('wireframe')" id="btn-wireframe" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Wireframe">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleAxes()" id="btn-axes" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Mostrar ejes">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16M4 4h16M4 4l16 16"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleBoundingBox()" id="btn-bbox" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Bounding box">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-2 flex flex-col gap-1.5">
|
||||
<button onclick="toggleMeasure()" id="btn-measure" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Medir">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleClip()" id="btn-clip" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Corte transversal">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleOverhang()" id="btn-overhang" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Mapa de voladizos">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</button>
|
||||
<button onclick="toggleLayerAnimation()" id="btn-layers" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Animacion de capas">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</button>
|
||||
<button onclick="openCompareModal()" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 transition-colors" title="Comparar">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-2 flex flex-col gap-1.5">
|
||||
<button onclick="setCameraView('front')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Frontal">F</button>
|
||||
<button onclick="setCameraView('top')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Superior">S</button>
|
||||
<button onclick="setCameraView('side')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Lateral">L</button>
|
||||
<button onclick="setCameraView('iso')" class="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-cyan-400 transition-colors text-xs font-bold" title="Isometrico">I</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Clip Slider -->
|
||||
<div id="clip-controls" class="absolute bottom-4 left-1/2 -translate-x-1/2 glass rounded-xl px-4 py-2 border border-white/10 hidden flex items-center gap-3 z-20">
|
||||
<span class="text-xs text-slate-400 whitespace-nowrap">Plano de corte</span>
|
||||
<input type="range" id="clip-slider" min="-50" max="50" value="0" step="0.5" class="w-48 accent-cyan-500">
|
||||
<button onclick="toggleClip()" class="text-xs text-red-400 hover:text-red-300">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3 text-xs text-slate-500 px-1">
|
||||
<span>Arrastra para rotar • Rueda para zoom</span>
|
||||
<span id="viewer-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Panel -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="glass rounded-2xl p-6 border border-white/5">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 id="meta-title" class="text-2xl font-bold mb-1">—</h2>
|
||||
<p id="meta-author" class="text-slate-400 text-sm">—</p>
|
||||
<div id="meta-rating" class="mt-1"></div>
|
||||
</div>
|
||||
<button id="btn-edit" class="p-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-cyan-400 transition-colors" title="Editar modelo">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Descripcion</label>
|
||||
<p id="meta-desc" class="text-sm text-slate-300 mt-1 leading-relaxed">—</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Categoria</label>
|
||||
<p id="meta-category" class="text-sm font-medium text-cyan-400 mt-1">—</p>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Licencia</label>
|
||||
<p id="meta-license" class="text-sm font-medium text-slate-300 mt-1">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider">Tags</label>
|
||||
<div id="meta-tags" class="flex flex-wrap gap-2 mt-2">—</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block">Caras</label>
|
||||
<p id="meta-faces" class="text-lg font-bold text-slate-200 mt-1">—</p>
|
||||
</div>
|
||||
<div class="p-3.5 rounded-xl bg-slate-900/50 border border-white/5 text-center col-span-2">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block">Dimensiones</label>
|
||||
<p id="meta-dims" class="text-sm font-bold text-slate-200 mt-1">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation & Estimation -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button onclick="runValidation()" class="p-3 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all text-center">
|
||||
<span class="text-xs text-slate-500 uppercase block mb-1">Validacion</span>
|
||||
<span class="text-sm font-medium text-cyan-400">Validar malla</span>
|
||||
</button>
|
||||
<button onclick="runEstimation()" class="p-3 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 hover:bg-cyan-500/5 transition-all text-center">
|
||||
<span class="text-xs text-slate-500 uppercase block mb-1">Impresion</span>
|
||||
<span class="text-sm font-medium text-cyan-400">Estimar costo</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="validation-result" class="hidden p-4 rounded-xl bg-slate-900/50 border border-white/5 text-sm space-y-1"></div>
|
||||
<div id="estimation-result" class="hidden p-4 rounded-xl bg-slate-900/50 border border-white/5 text-sm space-y-1"></div>
|
||||
|
||||
<div class="pt-4 flex gap-3">
|
||||
<a id="btn-download" href="#" class="flex-1 px-5 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] text-center flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||
Descargar
|
||||
</a>
|
||||
<button id="btn-download-all" class="hidden px-5 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-white font-bold text-sm transition-all hover:scale-[1.02] flex items-center gap-2" title="Descargar todas las partes">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>
|
||||
ZIP
|
||||
</button>
|
||||
<button id="btn-share" class="px-5 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-300 font-bold text-sm transition-all hover:scale-[1.02] flex items-center gap-2" title="Compartir QR">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
QR
|
||||
</button>
|
||||
<button id="btn-delete" class="px-5 py-3 rounded-xl bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 font-bold text-sm transition-all hover:scale-[1.02] flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collections -->
|
||||
<div class="glass rounded-2xl p-5 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-3">Colecciones</label>
|
||||
<select id="collection-select" class="w-full px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm mb-3 cursor-pointer">
|
||||
<option value="">Cargando...</option>
|
||||
</select>
|
||||
<form id="collection-form" class="flex gap-2">
|
||||
<input type="text" id="collection-name" placeholder="Nueva coleccion" class="flex-1 px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<input type="text" id="collection-desc" placeholder="Desc" class="w-24 px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<button type="submit" class="px-3 py-2 rounded-xl bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30 font-bold text-sm transition-colors">+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Ratings -->
|
||||
<div id="ratings-section" class="glass rounded-2xl p-5 border border-white/5"></div>
|
||||
|
||||
<form id="rating-form" class="glass rounded-2xl p-5 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Valorar modelo</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="rating-stars" class="px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm cursor-pointer">
|
||||
<option value="5">★★★★★ (5)</option>
|
||||
<option value="4">★★★★☆ (4)</option>
|
||||
<option value="3">★★★☆☆ (3)</option>
|
||||
<option value="2">★★☆☆☆ (2)</option>
|
||||
<option value="1">★☆☆☆☆ (1)</option>
|
||||
</select>
|
||||
<button type="submit" class="px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all">Enviar</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="glass rounded-2xl p-5 border border-white/5">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-3">Comentarios</label>
|
||||
<div id="comments-list" class="space-y-3 mb-4 max-h-64 overflow-y-auto pr-1"></div>
|
||||
<form id="comment-form" class="space-y-2">
|
||||
<input type="text" id="comment-author" placeholder="Tu nombre (opcional)" class="w-full px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<textarea id="comment-text" rows="2" placeholder="Escribe un comentario..." required class="w-full px-3 py-2 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600 resize-none"></textarea>
|
||||
<button type="submit" class="w-full px-4 py-2 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-sm shadow-lg shadow-cyan-500/20 transition-all">Comentar</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Parts list -->
|
||||
<div id="parts-list"></div>
|
||||
|
||||
<!-- Images gallery -->
|
||||
<div id="images-gallery"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div id="edit-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-lg border border-white/10 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold">Editar Modelo</h3>
|
||||
<button id="edit-close" class="p-2 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="edit-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Titulo</label>
|
||||
<input type="text" id="edit-title" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Descripcion</label>
|
||||
<textarea id="edit-description" rows="3" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all resize-none"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Autor</label>
|
||||
<input type="text" id="edit-author" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Licencia</label>
|
||||
<input type="text" id="edit-license" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Categoria</label>
|
||||
<select id="edit-category" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Tags</label>
|
||||
<input type="text" id="edit-tags" placeholder="Separados por comas" class="w-full px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4 flex gap-3">
|
||||
<button type="submit" class="flex-1 px-5 py-3 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02]">Guardar Cambios</button>
|
||||
<button type="button" id="edit-cancel" class="px-5 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 font-medium transition-colors">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Modal -->
|
||||
<div id="qr-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-sm border border-white/10 animate-fade-in text-center">
|
||||
<h3 class="text-xl font-bold mb-4">Compartir modelo</h3>
|
||||
<p class="text-sm text-slate-400 mb-4">Escanea el codigo QR para abrir este modelo en tu dispositivo.</p>
|
||||
<img id="qr-image" src="" alt="QR Code" class="mx-auto rounded-xl border border-white/10 mb-4">
|
||||
<p id="qr-url" class="text-xs text-slate-500 break-all mb-4"></p>
|
||||
<button id="qr-close" class="px-6 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 font-medium transition-colors">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compare Modal -->
|
||||
<div id="compare-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-lg border border-white/10 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">Comparar modelo</h3>
|
||||
<button onclick="closeCompareModal()" class="p-2 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-3">Selecciona otro modelo para abrir una vista de comparacion lado a lado.</p>
|
||||
<div id="compare-list" class="max-h-64 overflow-y-auto space-y-2 pr-1">
|
||||
<p class="text-sm text-slate-500">Cargando modelos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
180
static/index.html
Normal file
180
static/index.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>STL Repository</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#172033', 950: '#020617' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-xl shadow-lg group-hover:scale-110 transition-transform">
|
||||
🖨️
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">STL Repository</h1>
|
||||
<p class="text-xs text-slate-400 -mt-0.5">Modelos 3D para imprimir</p>
|
||||
</div>
|
||||
</a>
|
||||
<button id="theme-toggle" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-yellow-400 transition-colors" title="Cambiar tema">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
<a href="/upload" class="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-semibold text-sm shadow-lg shadow-cyan-500/20 transition-all hover:scale-105 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
Subir Modelo
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8 flex gap-8">
|
||||
<!-- Sidebar -->
|
||||
<aside class="hidden lg:block w-64 shrink-0">
|
||||
<div class="glass rounded-2xl p-5 border border-white/5 sticky top-24">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-sm uppercase tracking-wider text-slate-400">Filtros</h3>
|
||||
<button onclick="resetFilters()" class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors">Limpiar</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 mb-1.5 block">Ordenar por</label>
|
||||
<select id="sort-by" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm cursor-pointer">
|
||||
<option value="newest">Mas nuevos</option>
|
||||
<option value="oldest">Mas antiguos</option>
|
||||
<option value="most_downloaded">Mas descargados</option>
|
||||
<option value="largest">Mas grandes</option>
|
||||
<option value="most_faces">Mas caras</option>
|
||||
<option value="highest_rated">Mejor valorados</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 mb-1.5 block">Caras</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" id="min-faces" placeholder="Min" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<input type="number" id="max-faces" placeholder="Max" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 mb-1.5 block">Dimension max (mm)</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="number" id="min-dim" placeholder="Min" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
<input type="number" id="max-dim" placeholder="Max" class="w-full px-3 py-2 rounded-lg bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none text-sm placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-5 border-t border-white/5">
|
||||
<h3 class="font-bold text-sm uppercase tracking-wider text-slate-400 mb-3">Tags</h3>
|
||||
<div id="tag-cloud" class="flex flex-wrap gap-2">
|
||||
<p class="text-xs text-slate-600">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold mb-1">Galeria de Modelos</h2>
|
||||
<p class="text-slate-400 text-sm">Explora, visualiza y descarga modelos 3D listos para imprimir.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters bar -->
|
||||
<div class="glass rounded-2xl p-4 mb-6 flex flex-wrap gap-3 items-center">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||
<input type="text" id="search" placeholder="Buscar modelos..." class="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 text-sm transition-all placeholder:text-slate-500">
|
||||
<div id="search-history" class="absolute left-0 right-0 top-full mt-1 z-20"></div>
|
||||
</div>
|
||||
<select id="category" class="px-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 text-sm cursor-pointer min-w-[150px]">
|
||||
<option value="">Todas las categorias</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
<div class="relative min-w-[160px]">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path></svg>
|
||||
<input type="text" id="tag" placeholder="Filtrar por tag..." class="w-full pl-9 pr-4 py-2.5 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 text-sm transition-all placeholder:text-slate-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Actions -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-sm text-slate-400"><span id="model-count" class="font-bold text-slate-200">0</span> modelos</span>
|
||||
<button id="btn-select-mode" onclick="toggleSelectionMode()" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-white/10 text-xs font-medium transition-colors">
|
||||
Seleccionar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selection Bar -->
|
||||
<div id="selection-bar" class="hidden mb-4 glass rounded-xl p-3 border border-cyan-500/20 flex items-center justify-between">
|
||||
<span class="text-sm text-slate-300"><span id="selection-count" class="font-bold text-cyan-400">0</span> seleccionados</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="batchDownload()" class="px-4 py-2 rounded-lg bg-gradient-to-r from-cyan-500 to-blue-600 text-white text-xs font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-105">
|
||||
Descargar ZIP
|
||||
</button>
|
||||
<button onclick="clearSelection()" class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-white/10 text-xs font-medium transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div id="grid" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
|
||||
<div class="w-12 h-12 border-4 border-slate-700 border-t-cyan-500 rounded-full animate-spin mb-4"></div>
|
||||
<p>Cargando modelos...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
<div class="mt-8 flex justify-center">
|
||||
<button id="load-more" onclick="loadMore()" class="hidden px-6 py-3 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-sm font-medium transition-colors">
|
||||
Cargar mas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Estimator Modal -->
|
||||
<div id="estimator-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-sm border border-white/10 animate-fade-in">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">Estimacion de impresion</h3>
|
||||
<button onclick="closeEstimator()" class="p-2 rounded-lg hover:bg-slate-800 text-slate-400 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="estimator-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
static/js/api.js
Normal file
59
static/js/api.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(20px)';
|
||||
toast.style.transition = 'all 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(API_BASE + path);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const res = await fetch(API_BASE + path, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPostForm(path, formData) {
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPut(path, data) {
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
391
static/js/app.js
Normal file
391
static/js/app.js
Normal file
@@ -0,0 +1,391 @@
|
||||
let currentSkip = 0;
|
||||
const limit = 24;
|
||||
let currentFilters = {};
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
let allTags = [];
|
||||
let selectionMode = false;
|
||||
let selectedIds = new Set();
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function renderGrid(models, append = false) {
|
||||
const grid = document.getElementById('grid');
|
||||
const countEl = document.getElementById('model-count');
|
||||
|
||||
if (!append) grid.innerHTML = '';
|
||||
|
||||
if (models.length === 0 && !append) {
|
||||
grid.innerHTML = `
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-20 text-slate-500 animate-fade-in">
|
||||
<div class="w-16 h-16 rounded-full bg-slate-900 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<p class="text-lg font-medium mb-1">No se encontraron modelos</p>
|
||||
<p class="text-sm">Intenta con otros terminos de busqueda o <a href="/upload" class="text-cyan-400 hover:underline">sube uno nuevo</a>.</p>
|
||||
</div>`;
|
||||
if (countEl) countEl.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
models.forEach((m, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'glass rounded-2xl overflow-hidden cursor-pointer card-hover animate-fade-in border border-white/5 relative group';
|
||||
card.style.animationDelay = `${i * 0.05}s`;
|
||||
card.onclick = (e) => {
|
||||
if (selectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleSelection(m.id);
|
||||
} else {
|
||||
window.location.href = '/model/' + m.id;
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = selectedIds.has(m.id);
|
||||
const tagBadges = (m.tags || []).slice(0, 3).map(t =>
|
||||
`<span class="px-1.5 py-0.5 rounded bg-cyan-500/10 text-cyan-400 text-[10px] font-medium">${t.name}</span>`
|
||||
).join('');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="relative h-52 bg-slate-900 overflow-hidden">
|
||||
${selectionMode ? `
|
||||
<div class="absolute top-3 left-3 z-20">
|
||||
<div class="w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors ${isSelected ? 'bg-cyan-500 border-cyan-500' : 'border-white/50 bg-black/30'}">
|
||||
${isSelected ? '<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"></path></svg>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<img src="/api/models/${m.id}/thumbnail" alt="${m.title}" loading="lazy" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" onerror="this.style.display='none'; this.parentElement.innerHTML='<div class=\'w-full h-full flex items-center justify-center text-slate-600\'><svg class=\'w-10 h-10\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'1.5\' d=\'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4\'/></svg></div>';">
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="px-2.5 py-1 rounded-lg bg-black/50 backdrop-blur text-xs font-medium text-white border border-white/10">${m.faces ?? '?'} caras</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<h3 class="font-bold text-slate-100 mb-1 truncate">${m.title}</h3>
|
||||
<p class="text-sm text-slate-400 mb-2">${m.author || 'Autor desconocido'}</p>
|
||||
<div class="flex items-center gap-2 mb-3">${tagBadges}</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span class="px-2 py-1 rounded-md bg-slate-800/80 border border-white/5">${m.category || 'Sin categoria'}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
${m.avg_rating ? `<span class="text-yellow-400">★ ${m.avg_rating.toFixed(1)}</span>` : ''}
|
||||
<span>${formatSize(m.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
allTags = await apiGet('/models/tags');
|
||||
renderTagCloud();
|
||||
} catch (e) {
|
||||
console.error('Error loading tags', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagCloud() {
|
||||
const container = document.getElementById('tag-cloud');
|
||||
if (!container) return;
|
||||
if (allTags.length === 0) {
|
||||
container.innerHTML = '<p class="text-xs text-slate-600">No hay tags aun</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = allTags.map(t =>
|
||||
`<button onclick="filterByTag('${t.name}')" class="px-2.5 py-1 rounded-lg bg-slate-800/60 hover:bg-cyan-500/20 border border-white/5 hover:border-cyan-500/30 text-xs text-slate-400 hover:text-cyan-400 transition-all">${t.name} (${t.count})</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function filterByTag(tagName) {
|
||||
const tagInput = document.getElementById('tag');
|
||||
if (tagInput) tagInput.value = tagName;
|
||||
currentSkip = 0;
|
||||
hasMore = true;
|
||||
loadModels();
|
||||
}
|
||||
|
||||
async function loadModels(append = false) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
|
||||
const searchEl = document.getElementById('search');
|
||||
const categoryEl = document.getElementById('category');
|
||||
const tagEl = document.getElementById('tag');
|
||||
const sortEl = document.getElementById('sort-by');
|
||||
const minFacesEl = document.getElementById('min-faces');
|
||||
const maxFacesEl = document.getElementById('max-faces');
|
||||
const minDimEl = document.getElementById('min-dim');
|
||||
const maxDimEl = document.getElementById('max-dim');
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('skip', String(currentSkip));
|
||||
params.append('limit', String(limit));
|
||||
|
||||
if (searchEl && searchEl.value) params.append('search', searchEl.value);
|
||||
if (categoryEl && categoryEl.value) params.append('category', categoryEl.value);
|
||||
if (tagEl && tagEl.value) params.append('tag', tagEl.value);
|
||||
if (sortEl && sortEl.value) params.append('sort_by', sortEl.value);
|
||||
if (minFacesEl && minFacesEl.value) params.append('min_faces', minFacesEl.value);
|
||||
if (maxFacesEl && maxFacesEl.value) params.append('max_faces', maxFacesEl.value);
|
||||
if (minDimEl && minDimEl.value) params.append('min_width', minDimEl.value);
|
||||
if (maxDimEl && maxDimEl.value) params.append('max_width', maxDimEl.value);
|
||||
if (minDimEl && minDimEl.value) params.append('min_height', minDimEl.value);
|
||||
if (maxDimEl && maxDimEl.value) params.append('max_height', maxDimEl.value);
|
||||
if (minDimEl && minDimEl.value) params.append('min_depth', minDimEl.value);
|
||||
if (maxDimEl && maxDimEl.value) params.append('max_depth', maxDimEl.value);
|
||||
|
||||
try {
|
||||
const models = await apiGet('/models/?' + params.toString());
|
||||
if (models.length < limit) hasMore = false;
|
||||
renderGrid(models, append);
|
||||
|
||||
const countEl = document.getElementById('model-count');
|
||||
if (countEl && !append) countEl.textContent = models.length + (hasMore ? '+' : '');
|
||||
|
||||
const loadMoreBtn = document.getElementById('load-more');
|
||||
if (loadMoreBtn) loadMoreBtn.style.display = hasMore ? 'flex' : 'none';
|
||||
|
||||
if (!append && searchEl && searchEl.value.trim()) {
|
||||
saveSearchHistory(searchEl.value.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (!append) {
|
||||
const grid = document.getElementById('grid');
|
||||
if (grid) {
|
||||
grid.innerHTML = `
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-20 text-red-400">
|
||||
<p class="text-lg font-medium">Error al cargar modelos</p>
|
||||
<p class="text-sm text-slate-500 mt-1">${e.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
currentSkip += limit;
|
||||
loadModels(true);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
['search', 'category', 'tag', 'min-faces', 'max-faces', 'min-dim', 'max-dim'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
const sortEl = document.getElementById('sort-by');
|
||||
if (sortEl) sortEl.value = 'newest';
|
||||
currentSkip = 0;
|
||||
hasMore = true;
|
||||
loadModels();
|
||||
renderSearchHistory();
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ====== Search History ======
|
||||
const SEARCH_HISTORY_KEY = 'stl_search_history';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
function getSearchHistory() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]');
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveSearchHistory(query) {
|
||||
if (!query) return;
|
||||
let history = getSearchHistory();
|
||||
history = history.filter(h => h.toLowerCase() !== query.toLowerCase());
|
||||
history.unshift(query);
|
||||
if (history.length > MAX_HISTORY) history.pop();
|
||||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
|
||||
renderSearchHistory();
|
||||
}
|
||||
|
||||
function renderSearchHistory() {
|
||||
const container = document.getElementById('search-history');
|
||||
if (!container) return;
|
||||
const history = getSearchHistory();
|
||||
if (history.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="flex items-center gap-2 flex-wrap mt-2">
|
||||
<span class="text-xs text-slate-500">Recientes:</span>
|
||||
${history.map(h => `
|
||||
<button onclick="applyHistory('${h.replace(/'/g, "\\'")}')" class="px-2 py-1 rounded-md bg-slate-800/60 hover:bg-cyan-500/20 border border-white/5 text-xs text-slate-400 hover:text-cyan-400 transition-all">${h}</button>
|
||||
`).join('')}
|
||||
<button onclick="clearHistory()" class="text-xs text-slate-600 hover:text-red-400 transition-colors ml-auto">Limpiar</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.applyHistory = function(query) {
|
||||
const searchEl = document.getElementById('search');
|
||||
if (searchEl) searchEl.value = query;
|
||||
currentSkip = 0; hasMore = true;
|
||||
loadModels();
|
||||
};
|
||||
|
||||
window.clearHistory = function() {
|
||||
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||
renderSearchHistory();
|
||||
};
|
||||
|
||||
// ====== Batch Selection ======
|
||||
window.toggleSelectionMode = function() {
|
||||
selectionMode = !selectionMode;
|
||||
const btn = document.getElementById('btn-select-mode');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', selectionMode);
|
||||
btn.classList.toggle('text-cyan-400', selectionMode);
|
||||
btn.textContent = selectionMode ? 'Cancelar' : 'Seleccionar';
|
||||
}
|
||||
loadModels(false);
|
||||
updateSelectionBar();
|
||||
};
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
loadModels(false);
|
||||
updateSelectionBar();
|
||||
}
|
||||
|
||||
function updateSelectionBar() {
|
||||
const bar = document.getElementById('selection-bar');
|
||||
if (!bar) return;
|
||||
if (selectedIds.size === 0) {
|
||||
bar.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
bar.classList.remove('hidden');
|
||||
const countEl = document.getElementById('selection-count');
|
||||
if (countEl) countEl.textContent = selectedIds.size;
|
||||
}
|
||||
|
||||
window.batchDownload = async function() {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
const ids = Array.from(selectedIds);
|
||||
const response = await fetch('/api/models/batch-download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ids)
|
||||
});
|
||||
if (!response.ok) throw new Error('Error al descargar');
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'batch_download.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Descargando ${ids.length} modelo(s)`, 'success');
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.clearSelection = function() {
|
||||
selectedIds.clear();
|
||||
selectionMode = false;
|
||||
const btn = document.getElementById('btn-select-mode');
|
||||
if (btn) {
|
||||
btn.classList.remove('bg-cyan-500/20', 'text-cyan-400');
|
||||
btn.textContent = 'Seleccionar';
|
||||
}
|
||||
loadModels(false);
|
||||
updateSelectionBar();
|
||||
};
|
||||
|
||||
// ====== Cost Estimator Modal ======
|
||||
window.openEstimator = async function(modelId) {
|
||||
const modal = document.getElementById('estimator-modal');
|
||||
const content = document.getElementById('estimator-content');
|
||||
if (!modal || !content) return;
|
||||
content.innerHTML = '<div class="flex items-center justify-center py-8"><div class="w-8 h-8 border-4 border-slate-700 border-t-cyan-500 rounded-full animate-spin"></div></div>';
|
||||
modal.classList.remove('hidden');
|
||||
try {
|
||||
const data = await apiGet(`/models/${modelId}/estimate`);
|
||||
content.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Volumen</p>
|
||||
<p class="text-lg font-bold text-slate-200">${data.volume_cm3} cm³</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Peso</p>
|
||||
<p class="text-lg font-bold text-slate-200">${data.grams} g</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Costo estimado</p>
|
||||
<p class="text-lg font-bold text-cyan-400">$${data.cost}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5 text-center">
|
||||
<p class="text-xs text-slate-500 uppercase">Tiempo</p>
|
||||
<p class="text-lg font-bold text-slate-200">${data.estimated_time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-4 text-center">Basado en $${data.price_per_kg}/kg, densidad ${data.material_density} g/cm³</p>
|
||||
`;
|
||||
} catch (err) {
|
||||
content.innerHTML = `<p class="text-red-400 text-center py-4">Error: ${err.message}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
window.closeEstimator = function() {
|
||||
const modal = document.getElementById('estimator-modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTags();
|
||||
loadModels();
|
||||
renderSearchHistory();
|
||||
|
||||
const searchEl = document.getElementById('search');
|
||||
const categoryEl = document.getElementById('category');
|
||||
const tagEl = document.getElementById('tag');
|
||||
const sortEl = document.getElementById('sort-by');
|
||||
const minFacesEl = document.getElementById('min-faces');
|
||||
const maxFacesEl = document.getElementById('max-faces');
|
||||
const minDimEl = document.getElementById('min-dim');
|
||||
const maxDimEl = document.getElementById('max-dim');
|
||||
|
||||
if (searchEl) searchEl.addEventListener('input', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (categoryEl) categoryEl.addEventListener('change', () => { currentSkip = 0; hasMore = true; loadModels(); });
|
||||
if (tagEl) tagEl.addEventListener('input', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (sortEl) sortEl.addEventListener('change', () => { currentSkip = 0; hasMore = true; loadModels(); });
|
||||
if (minFacesEl) minFacesEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (maxFacesEl) maxFacesEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (minDimEl) minDimEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
if (maxDimEl) maxDimEl.addEventListener('change', debounce(() => { currentSkip = 0; hasMore = true; loadModels(); }, 300));
|
||||
});
|
||||
941
static/js/detail.js
Normal file
941
static/js/detail.js
Normal file
@@ -0,0 +1,941 @@
|
||||
let currentModel = null;
|
||||
let viewerMeshes = [];
|
||||
let scene, camera, renderer, controls, axesHelper, bboxHelper;
|
||||
let currentViewMode = 'solid';
|
||||
let axesVisible = false;
|
||||
let bboxVisible = false;
|
||||
let clipPlane = null;
|
||||
let clipEnabled = false;
|
||||
let measureMode = false;
|
||||
let measurePoints = [];
|
||||
let measureLine = null;
|
||||
let measureLabel = null;
|
||||
let overhangMode = false;
|
||||
let layerAnimating = false;
|
||||
let layerAnimationId = null;
|
||||
let originalColors = new Map();
|
||||
|
||||
function getModelId() {
|
||||
const parts = window.location.pathname.split('/');
|
||||
const id = parts.pop();
|
||||
return parseInt(id) || null;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modelId = getModelId();
|
||||
if (!modelId) {
|
||||
showToast('ID de modelo invalido', 'error');
|
||||
return;
|
||||
}
|
||||
loadDetail(modelId);
|
||||
|
||||
const editBtn = document.getElementById('btn-edit');
|
||||
const editModal = document.getElementById('edit-modal');
|
||||
const editClose = document.getElementById('edit-close');
|
||||
const editForm = document.getElementById('edit-form');
|
||||
const editCancel = document.getElementById('edit-cancel');
|
||||
|
||||
if (editBtn && editModal) {
|
||||
editBtn.addEventListener('click', () => {
|
||||
fillEditForm();
|
||||
editModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
if (editClose && editModal) {
|
||||
editClose.addEventListener('click', () => editModal.classList.add('hidden'));
|
||||
editModal.addEventListener('click', (e) => {
|
||||
if (e.target === editModal) editModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
if (editCancel && editModal) {
|
||||
editCancel.addEventListener('click', () => editModal.classList.add('hidden'));
|
||||
}
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveEdit();
|
||||
});
|
||||
}
|
||||
|
||||
// QR modal
|
||||
const shareBtn = document.getElementById('btn-share');
|
||||
const qrModal = document.getElementById('qr-modal');
|
||||
const qrClose = document.getElementById('qr-close');
|
||||
if (shareBtn && qrModal) {
|
||||
shareBtn.addEventListener('click', () => {
|
||||
const qrImg = document.getElementById('qr-image');
|
||||
const qrUrl = document.getElementById('qr-url');
|
||||
const url = `${window.location.origin}/model/${modelId}`;
|
||||
if (qrImg) qrImg.src = `/api/models/${modelId}/qr`;
|
||||
if (qrUrl) qrUrl.textContent = url;
|
||||
qrModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
if (qrClose && qrModal) {
|
||||
qrClose.addEventListener('click', () => qrModal.classList.add('hidden'));
|
||||
qrModal.addEventListener('click', (e) => {
|
||||
if (e.target === qrModal) qrModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Rating form
|
||||
const ratingForm = document.getElementById('rating-form');
|
||||
if (ratingForm) {
|
||||
ratingForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const stars = parseInt(document.getElementById('rating-stars').value);
|
||||
if (!stars || stars < 1 || stars > 5) return;
|
||||
try {
|
||||
await apiPostForm(`/models/${modelId}/ratings?stars=${stars}`, new FormData());
|
||||
showToast('Valoracion enviada', 'success');
|
||||
loadDetail(modelId);
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Comment form
|
||||
const commentForm = document.getElementById('comment-form');
|
||||
if (commentForm) {
|
||||
commentForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
const author = document.getElementById('comment-author').value.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
const params = new URLSearchParams({ text });
|
||||
if (author) params.append('author_name', author);
|
||||
await apiPostForm(`/models/${modelId}/comments?${params.toString()}`, new FormData());
|
||||
showToast('Comentario agregado', 'success');
|
||||
document.getElementById('comment-text').value = '';
|
||||
loadDetail(modelId);
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Collection form
|
||||
const collForm = document.getElementById('collection-form');
|
||||
if (collForm) {
|
||||
collForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('collection-name').value.trim();
|
||||
const desc = document.getElementById('collection-desc').value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await apiPut('/models/collections', { name, description: desc });
|
||||
showToast('Coleccion creada', 'success');
|
||||
document.getElementById('collection-name').value = '';
|
||||
document.getElementById('collection-desc').value = '';
|
||||
loadCollections();
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Theme listener
|
||||
window.addEventListener('themechange', (e) => {
|
||||
if (scene) {
|
||||
scene.background = new THREE.Color(e.detail === 'light' ? 0xf8fafc : 0x0f172a);
|
||||
if (window.gridHelper) window.gridHelper.material.color.setHex(e.detail === 'light' ? 0xcbd5e1 : 0x1e293b);
|
||||
}
|
||||
});
|
||||
|
||||
// Clip slider
|
||||
const clipSlider = document.getElementById('clip-slider');
|
||||
if (clipSlider) {
|
||||
clipSlider.addEventListener('input', () => {
|
||||
if (clipPlane) {
|
||||
clipPlane.constant = parseFloat(clipSlider.value);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDetail(modelId) {
|
||||
try {
|
||||
const model = await apiGet('/models/' + modelId);
|
||||
currentModel = model;
|
||||
renderMeta(model);
|
||||
renderParts(model);
|
||||
renderImages(model);
|
||||
renderRatings(model);
|
||||
renderComments(model);
|
||||
loadCollections();
|
||||
initViewer(model);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.querySelector('main').innerHTML = `
|
||||
<div class="max-w-xl mx-auto text-center py-20">
|
||||
<div class="w-20 h-20 rounded-full bg-slate-900 flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-2">Modelo no encontrado</h2>
|
||||
<p class="text-slate-400 mb-6">El modelo que buscas no existe o ha sido eliminado.</p>
|
||||
<a href="/" class="px-6 py-3 rounded-xl bg-cyan-500 hover:bg-cyan-400 text-white font-bold transition-colors">Volver a la galeria</a>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStars(avg, count) {
|
||||
if (!avg) return '<span class="text-xs text-slate-500">Sin valoraciones</span>';
|
||||
const full = Math.floor(avg);
|
||||
const half = avg - full >= 0.5;
|
||||
let html = '<span class="flex items-center gap-1">';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i < full) {
|
||||
html += '<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
} else if (i === full && half) {
|
||||
html += '<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><defs><linearGradient id="half"><stop offset="50%" stop-color="currentColor"/><stop offset="50%" stop-color="transparent"/></linearGradient></defs><path fill="url(#half)" d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
} else {
|
||||
html += '<svg class="w-4 h-4 text-slate-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>';
|
||||
}
|
||||
}
|
||||
html += `<span class="text-xs text-slate-500 ml-1">${avg.toFixed(1)} (${count})</span></span>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderMeta(m) {
|
||||
const setText = (id, text) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text || '—';
|
||||
};
|
||||
|
||||
setText('meta-title', m.title);
|
||||
setText('meta-desc', m.description);
|
||||
setText('meta-author', m.author ? 'Por ' + m.author : 'Autor desconocido');
|
||||
setText('meta-license', m.license);
|
||||
setText('meta-category', m.category);
|
||||
setText('meta-faces', m.faces ?? '—');
|
||||
|
||||
const ratingEl = document.getElementById('meta-rating');
|
||||
if (ratingEl) ratingEl.innerHTML = renderStars(m.avg_rating, (m.ratings || []).length);
|
||||
|
||||
const dimsEl = document.getElementById('meta-dims');
|
||||
if (dimsEl) {
|
||||
dimsEl.textContent = (m.width && m.height && m.depth)
|
||||
? `${m.width.toFixed(1)} x ${m.height.toFixed(1)} x ${m.depth.toFixed(1)} mm`
|
||||
: '—';
|
||||
}
|
||||
|
||||
const tagsEl = document.getElementById('meta-tags');
|
||||
if (tagsEl) {
|
||||
if (m.tags && m.tags.length > 0) {
|
||||
tagsEl.innerHTML = m.tags.map(t =>
|
||||
`<a href="/?tag=${encodeURIComponent(t.name)}" class="px-2.5 py-1 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-medium border border-cyan-500/20 hover:bg-cyan-500/20 transition-colors">${t.name}</a>`
|
||||
).join('');
|
||||
} else {
|
||||
tagsEl.innerHTML = '<span class="text-slate-500 text-sm">—</span>';
|
||||
}
|
||||
}
|
||||
|
||||
const dlBtn = document.getElementById('btn-download');
|
||||
if (dlBtn) dlBtn.href = '/api/models/' + m.id + '/download';
|
||||
|
||||
const delBtn = document.getElementById('btn-delete');
|
||||
if (delBtn) {
|
||||
delBtn.onclick = async () => {
|
||||
if (!confirm('Eliminar este modelo permanentemente?')) return;
|
||||
try {
|
||||
await apiDelete('/models/' + m.id);
|
||||
showToast('Modelo eliminado', 'success');
|
||||
setTimeout(() => window.location.href = '/', 800);
|
||||
} catch (e) {
|
||||
showToast('Error al eliminar: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const dlAllBtn = document.getElementById('btn-download-all');
|
||||
if (dlAllBtn) {
|
||||
const modelFiles = (m.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf');
|
||||
if (modelFiles.length > 1) {
|
||||
dlAllBtn.classList.remove('hidden');
|
||||
dlAllBtn.onclick = () => {
|
||||
window.location.href = '/api/models/' + m.id + '/download-all';
|
||||
};
|
||||
} else {
|
||||
dlAllBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderParts(m) {
|
||||
const container = document.getElementById('parts-list');
|
||||
if (!container) return;
|
||||
const modelFiles = (m.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf');
|
||||
if (modelFiles.length <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const colors = ['#22d3ee', '#f472b6', '#a3e635', '#fbbf24', '#a78bfa', '#fb923c'];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Partes (${modelFiles.length})</label>
|
||||
<div class="space-y-2">
|
||||
${modelFiles.map((f, i) => `
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<input type="checkbox" id="part-${f.id}" checked onchange="togglePart(${f.id})" class="w-4 h-4 rounded border-slate-600 text-cyan-500 focus:ring-cyan-500/20 bg-slate-800">
|
||||
<div class="w-3 h-3 rounded-full" style="background:${colors[i % colors.length]}"></div>
|
||||
<span class="text-sm flex-1 truncate">${f.part_name || f.filename}</span>
|
||||
<a href="/api/models/${m.id}/download?file_id=${f.id}" class="text-xs text-cyan-400 hover:text-cyan-300 transition-colors" title="Descargar">⬇</a>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderImages(m) {
|
||||
const container = document.getElementById('images-gallery');
|
||||
if (!container) return;
|
||||
const images = (m.files || []).filter(f => f.file_type === 'image');
|
||||
if (images.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div class="mb-3">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Imagenes de referencia</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
${images.map(img => `
|
||||
<a href="/images/${encodeURIComponent(img.filename)}" target="_blank" class="rounded-xl overflow-hidden border border-white/5 hover:border-cyan-500/30 transition-colors">
|
||||
<img src="/images/${encodeURIComponent(img.filename)}" alt="${img.filename}" class="w-full h-24 object-cover hover:scale-105 transition-transform">
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRatings(m) {
|
||||
const container = document.getElementById('ratings-section');
|
||||
if (!container) return;
|
||||
const ratings = m.ratings || [];
|
||||
container.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<label class="text-xs font-medium text-slate-500 uppercase tracking-wider block mb-2">Valoraciones</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
${renderStars(m.avg_rating, ratings.length)}
|
||||
</div>
|
||||
${ratings.length > 0 ? `
|
||||
<div class="space-y-2 max-h-32 overflow-y-auto pr-1">
|
||||
${ratings.slice(0, 5).map(r => `
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-yellow-400">${'★'.repeat(r.stars)}${'☆'.repeat(5 - r.stars)}</span>
|
||||
<span class="text-slate-500 text-xs">${new Date(r.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '<p class="text-sm text-slate-500">Aun no hay valoraciones</p>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderComments(m) {
|
||||
const container = document.getElementById('comments-list');
|
||||
if (!container) return;
|
||||
const comments = m.comments || [];
|
||||
if (comments.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-slate-500">Aun no hay comentarios. Se el primero!</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = comments.map(c => `
|
||||
<div class="p-3 rounded-xl bg-slate-900/50 border border-white/5">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium">${c.author_name || 'Anonimo'}</span>
|
||||
<span class="text-xs text-slate-500">${new Date(c.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-300">${c.text}</p>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadCollections() {
|
||||
const select = document.getElementById('collection-select');
|
||||
if (!select) return;
|
||||
try {
|
||||
const collections = await apiGet('/models/collections/all');
|
||||
const modelId = getModelId();
|
||||
select.innerHTML = '<option value="">Seleccionar coleccion...</option>' +
|
||||
collections.map(c => `<option value="${c.id}">${c.name} (${c.model_count})</option>`).join('');
|
||||
select.onchange = async () => {
|
||||
if (!select.value) return;
|
||||
try {
|
||||
await apiPostForm(`/models/collections/${select.value}/add/${modelId}`, new FormData());
|
||||
showToast('Agregado a coleccion', 'success');
|
||||
select.value = '';
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function fillEditForm() {
|
||||
if (!currentModel) return;
|
||||
const fields = ['title', 'description', 'author', 'license', 'category'];
|
||||
fields.forEach(f => {
|
||||
const el = document.getElementById('edit-' + f);
|
||||
if (el) el.value = currentModel[f] || '';
|
||||
});
|
||||
const tagsEl = document.getElementById('edit-tags');
|
||||
if (tagsEl) tagsEl.value = (currentModel.tags || []).map(t => t.name).join(', ');
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!currentModel) return;
|
||||
const data = {};
|
||||
['title', 'description', 'author', 'license', 'category'].forEach(f => {
|
||||
const el = document.getElementById('edit-' + f);
|
||||
if (el) data[f] = el.value || null;
|
||||
});
|
||||
const tagsEl = document.getElementById('edit-tags');
|
||||
if (tagsEl) {
|
||||
data.tag_names = tagsEl.value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await apiPut('/models/' + currentModel.id, data);
|
||||
currentModel = updated;
|
||||
renderMeta(updated);
|
||||
document.getElementById('edit-modal').classList.add('hidden');
|
||||
showToast('Modelo actualizado', 'success');
|
||||
} catch (e) {
|
||||
showToast('Error al guardar: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function initViewer(model) {
|
||||
const container = document.getElementById('viewer');
|
||||
const loadingEl = document.getElementById('viewer-loading');
|
||||
const statusEl = document.getElementById('viewer-status');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const isLight = document.documentElement.classList.contains('light-mode');
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(isLight ? 0xf8fafc : 0x0f172a);
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 0, 50);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.localClippingEnabled = true;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 1.0;
|
||||
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
|
||||
scene.add(ambient);
|
||||
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
mainLight.position.set(10, 20, 15);
|
||||
mainLight.castShadow = true;
|
||||
mainLight.shadow.mapSize.width = 1024;
|
||||
mainLight.shadow.mapSize.height = 1024;
|
||||
scene.add(mainLight);
|
||||
|
||||
const fillLight = new THREE.DirectionalLight(0x60a5fa, 0.3);
|
||||
fillLight.position.set(-10, 5, -10);
|
||||
scene.add(fillLight);
|
||||
|
||||
const rimLight = new THREE.DirectionalLight(0xf472b6, 0.2);
|
||||
rimLight.position.set(0, -10, -5);
|
||||
scene.add(rimLight);
|
||||
|
||||
window.gridHelper = new THREE.GridHelper(60, 60, isLight ? 0xcbd5e1 : 0x1e293b, isLight ? 0xcbd5e1 : 0x1e293b);
|
||||
window.gridHelper.position.y = -15;
|
||||
scene.add(window.gridHelper);
|
||||
|
||||
axesHelper = new THREE.AxesHelper(20);
|
||||
axesHelper.visible = false;
|
||||
scene.add(axesHelper);
|
||||
|
||||
const colors = [0x22d3ee, 0xf472b6, 0xa3e635, 0xfbbf24, 0xa78bfa, 0xfb923c];
|
||||
const modelFiles = (model.files || []).filter(f => f.file_type === 'stl' || f.file_type === '3mf');
|
||||
let loadedCount = 0;
|
||||
let totalFaces = 0;
|
||||
let globalBox = new THREE.Box3();
|
||||
|
||||
modelFiles.forEach((mf, idx) => {
|
||||
const loader = new THREE.STLLoader();
|
||||
const fileUrl = '/uploads/' + encodeURIComponent(mf.filename);
|
||||
|
||||
loader.load(fileUrl, (geometry) => {
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: colors[idx % colors.length],
|
||||
metalness: 0.1,
|
||||
roughness: 0.4,
|
||||
emissive: colors[idx % colors.length],
|
||||
emissiveIntensity: 0.05,
|
||||
side: THREE.DoubleSide,
|
||||
clippingPlanes: [],
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.fileId = mf.id;
|
||||
mesh.userData.originalMaterial = material.clone();
|
||||
scene.add(mesh);
|
||||
viewerMeshes.push(mesh);
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
const center = new THREE.Vector3();
|
||||
geometry.boundingBox.getCenter(center);
|
||||
mesh.position.sub(center);
|
||||
|
||||
globalBox.expandByObject(mesh);
|
||||
|
||||
const size = new THREE.Vector3();
|
||||
geometry.boundingBox.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
if (maxDim > 0) {
|
||||
const scale = 25 / maxDim;
|
||||
mesh.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
totalFaces += geometry.attributes.position.count / 3;
|
||||
loadedCount++;
|
||||
|
||||
if (loadedCount === modelFiles.length) {
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (statusEl) statusEl.textContent = `${Math.round(totalFaces)} caras · ${formatSize(model.file_size)}`;
|
||||
|
||||
const boxCenter = new THREE.Vector3();
|
||||
globalBox.getCenter(boxCenter);
|
||||
controls.target.copy(boxCenter);
|
||||
camera.position.set(boxCenter.x, boxCenter.y, boxCenter.z + 50);
|
||||
controls.update();
|
||||
|
||||
const bottom = globalBox.min.y;
|
||||
window.gridHelper.position.y = bottom - 2;
|
||||
}
|
||||
}, undefined, (err) => {
|
||||
console.error('Error cargando STL:', err);
|
||||
loadedCount++;
|
||||
});
|
||||
});
|
||||
|
||||
if (modelFiles.length === 0) {
|
||||
if (loadingEl) loadingEl.innerHTML = '<p class="text-red-400">No hay archivos 3D para mostrar</p>';
|
||||
}
|
||||
|
||||
controls.addEventListener('start', () => { controls.autoRotate = false; });
|
||||
|
||||
// Raycaster for measurement
|
||||
renderer.domElement.addEventListener('pointerdown', onViewerPointerDown);
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
});
|
||||
}
|
||||
|
||||
function togglePart(fileId) {
|
||||
const mesh = viewerMeshes.find(m => m.userData.fileId === fileId);
|
||||
if (mesh) {
|
||||
mesh.visible = !mesh.visible;
|
||||
}
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.wireframe = mode === 'wireframe';
|
||||
});
|
||||
|
||||
const btnSolid = document.getElementById('btn-solid');
|
||||
const btnWire = document.getElementById('btn-wireframe');
|
||||
if (btnSolid) {
|
||||
btnSolid.classList.toggle('bg-cyan-500/20', mode === 'solid');
|
||||
btnSolid.classList.toggle('text-cyan-400', mode === 'solid');
|
||||
}
|
||||
if (btnWire) {
|
||||
btnWire.classList.toggle('bg-cyan-500/20', mode === 'wireframe');
|
||||
btnWire.classList.toggle('text-cyan-400', mode === 'wireframe');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAxes() {
|
||||
axesVisible = !axesVisible;
|
||||
if (axesHelper) axesHelper.visible = axesVisible;
|
||||
const btn = document.getElementById('btn-axes');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', axesVisible);
|
||||
btn.classList.toggle('text-cyan-400', axesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBoundingBox() {
|
||||
bboxVisible = !bboxVisible;
|
||||
if (!bboxHelper && viewerMeshes.length > 0) {
|
||||
const box = new THREE.Box3();
|
||||
viewerMeshes.forEach(m => { if (m.visible) box.expandByObject(m); });
|
||||
bboxHelper = new THREE.Box3Helper(box, 0x06b6d4);
|
||||
scene.add(bboxHelper);
|
||||
}
|
||||
if (bboxHelper) bboxHelper.visible = bboxVisible;
|
||||
const btn = document.getElementById('btn-bbox');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', bboxVisible);
|
||||
btn.classList.toggle('text-cyan-400', bboxVisible);
|
||||
}
|
||||
}
|
||||
|
||||
function setCameraView(view) {
|
||||
if (!controls || !camera) return;
|
||||
const target = controls.target.clone();
|
||||
const dist = camera.position.distanceTo(target);
|
||||
|
||||
switch(view) {
|
||||
case 'front':
|
||||
camera.position.set(target.x, target.y, target.z + dist);
|
||||
break;
|
||||
case 'top':
|
||||
camera.position.set(target.x, target.y + dist, target.z);
|
||||
break;
|
||||
case 'side':
|
||||
camera.position.set(target.x + dist, target.y, target.z);
|
||||
break;
|
||||
case 'iso':
|
||||
camera.position.set(target.x + dist * 0.7, target.y + dist * 0.7, target.z + dist * 0.7);
|
||||
break;
|
||||
}
|
||||
camera.lookAt(target);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
// ====== MEASUREMENT TOOL ======
|
||||
function toggleMeasure() {
|
||||
measureMode = !measureMode;
|
||||
const btn = document.getElementById('btn-measure');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', measureMode);
|
||||
btn.classList.toggle('text-cyan-400', measureMode);
|
||||
}
|
||||
if (!measureMode) {
|
||||
clearMeasurement();
|
||||
} else {
|
||||
showToast('Haz clic en dos puntos del modelo para medir', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function clearMeasurement() {
|
||||
measurePoints = [];
|
||||
if (measureLine) { scene.remove(measureLine); measureLine = null; }
|
||||
if (measureLabel) { scene.remove(measureLabel); measureLabel = null; }
|
||||
}
|
||||
|
||||
function onViewerPointerDown(event) {
|
||||
if (!measureMode || !renderer || !camera) return;
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const mouse = new THREE.Vector2();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const visibleMeshes = viewerMeshes.filter(m => m.visible);
|
||||
const intersects = raycaster.intersectObjects(visibleMeshes);
|
||||
if (intersects.length === 0) return;
|
||||
|
||||
const point = intersects[0].point;
|
||||
measurePoints.push(point);
|
||||
|
||||
// Add small sphere marker
|
||||
const marker = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.3, 8, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff0000 })
|
||||
);
|
||||
marker.position.copy(point);
|
||||
scene.add(marker);
|
||||
measurePoints.push(marker); // store marker at odd indices
|
||||
|
||||
if (measurePoints.length >= 4) { // 2 points + 2 markers
|
||||
const p1 = measurePoints[0];
|
||||
const p2 = measurePoints[2];
|
||||
const dist = p1.distanceTo(p2);
|
||||
|
||||
if (measureLine) scene.remove(measureLine);
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||
measureLine = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 }));
|
||||
scene.add(measureLine);
|
||||
|
||||
if (measureLabel) scene.remove(measureLabel);
|
||||
const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = 256; canvas.height = 64;
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||
ctx.roundRect(0, 0, 256, 64, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 24px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(dist.toFixed(2) + ' mm', 128, 32);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({ map: tex });
|
||||
measureLabel = new THREE.Sprite(spriteMat);
|
||||
measureLabel.position.copy(mid);
|
||||
measureLabel.scale.set(4, 1, 1);
|
||||
scene.add(measureLabel);
|
||||
|
||||
showToast(`Distancia: ${dist.toFixed(2)} mm`, 'success');
|
||||
measureMode = false;
|
||||
const btn = document.getElementById('btn-measure');
|
||||
if (btn) {
|
||||
btn.classList.remove('bg-cyan-500/20', 'text-cyan-400');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== CLIPPING ======
|
||||
function toggleClip() {
|
||||
clipEnabled = !clipEnabled;
|
||||
const controlsDiv = document.getElementById('clip-controls');
|
||||
const btn = document.getElementById('btn-clip');
|
||||
if (controlsDiv) controlsDiv.classList.toggle('hidden', !clipEnabled);
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', clipEnabled);
|
||||
btn.classList.toggle('text-cyan-400', clipEnabled);
|
||||
}
|
||||
|
||||
if (clipEnabled) {
|
||||
if (!clipPlane) {
|
||||
clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
}
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [clipPlane];
|
||||
});
|
||||
const slider = document.getElementById('clip-slider');
|
||||
if (slider) clipPlane.constant = parseFloat(slider.value);
|
||||
} else {
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ====== OVERHANG HEATMAP ======
|
||||
function toggleOverhang() {
|
||||
overhangMode = !overhangMode;
|
||||
const btn = document.getElementById('btn-overhang');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', overhangMode);
|
||||
btn.classList.toggle('text-cyan-400', overhangMode);
|
||||
}
|
||||
|
||||
viewerMeshes.forEach(mesh => {
|
||||
const geo = mesh.geometry;
|
||||
if (!geo.attributes.position) return;
|
||||
|
||||
if (!overhangMode) {
|
||||
// Restore original
|
||||
if (mesh.userData.originalMaterial) {
|
||||
mesh.material = mesh.userData.originalMaterial.clone();
|
||||
if (clipEnabled) mesh.material.clippingPlanes = [clipPlane];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = geo.attributes.position;
|
||||
const count = pos.count;
|
||||
const colors = new Float32Array(count * 3);
|
||||
const colorAttr = new THREE.BufferAttribute(colors, 3);
|
||||
|
||||
// Compute face normals and assign per-vertex
|
||||
const up = new THREE.Vector3(0, 0, 1);
|
||||
const p1 = new THREE.Vector3(), p2 = new THREE.Vector3(), p3 = new THREE.Vector3();
|
||||
const n = new THREE.Vector3();
|
||||
|
||||
for (let i = 0; i < count; i += 3) {
|
||||
p1.fromBufferAttribute(pos, i);
|
||||
p2.fromBufferAttribute(pos, i + 1);
|
||||
p3.fromBufferAttribute(pos, i + 2);
|
||||
n.crossVectors(
|
||||
new THREE.Vector3().subVectors(p2, p1),
|
||||
new THREE.Vector3().subVectors(p3, p1)
|
||||
).normalize();
|
||||
const angle = Math.acos(Math.abs(n.dot(up))) * (180 / Math.PI);
|
||||
let r, g, b;
|
||||
if (angle > 60) { r = 1; g = 0; b = 0; }
|
||||
else if (angle > 45) { r = 1; g = 1; b = 0; }
|
||||
else { r = 0; g = 1; b = 0; }
|
||||
colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b;
|
||||
colors[(i + 1) * 3] = r; colors[(i + 1) * 3 + 1] = g; colors[(i + 1) * 3 + 2] = b;
|
||||
colors[(i + 2) * 3] = r; colors[(i + 2) * 3 + 1] = g; colors[(i + 2) * 3 + 2] = b;
|
||||
}
|
||||
|
||||
geo.setAttribute('color', colorAttr);
|
||||
mesh.material = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
metalness: 0.1,
|
||||
roughness: 0.5,
|
||||
side: THREE.DoubleSide,
|
||||
clippingPlanes: clipEnabled ? [clipPlane] : [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ====== LAYER BUILD ANIMATION ======
|
||||
function toggleLayerAnimation() {
|
||||
layerAnimating = !layerAnimating;
|
||||
const btn = document.getElementById('btn-layers');
|
||||
if (btn) {
|
||||
btn.classList.toggle('bg-cyan-500/20', layerAnimating);
|
||||
btn.classList.toggle('text-cyan-400', layerAnimating);
|
||||
}
|
||||
|
||||
if (layerAnimating) {
|
||||
if (!clipPlane) {
|
||||
clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
}
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [clipPlane];
|
||||
});
|
||||
const box = new THREE.Box3();
|
||||
viewerMeshes.forEach(m => { if (m.visible) box.expandByObject(m); });
|
||||
const minY = box.min.y;
|
||||
const maxY = box.max.y;
|
||||
let current = minY;
|
||||
const step = (maxY - minY) / 100;
|
||||
|
||||
if (layerAnimationId) cancelAnimationFrame(layerAnimationId);
|
||||
function stepAnim() {
|
||||
if (!layerAnimating) return;
|
||||
current += step;
|
||||
if (current > maxY) current = minY;
|
||||
clipPlane.constant = current;
|
||||
layerAnimationId = requestAnimationFrame(stepAnim);
|
||||
}
|
||||
stepAnim();
|
||||
showToast('Animacion de capas iniciada', 'success');
|
||||
} else {
|
||||
if (layerAnimationId) cancelAnimationFrame(layerAnimationId);
|
||||
if (!clipEnabled) {
|
||||
viewerMeshes.forEach(mesh => {
|
||||
mesh.material.clippingPlanes = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== VALIDATION ======
|
||||
async function runValidation() {
|
||||
const container = document.getElementById('validation-result');
|
||||
if (!container) return;
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '<p class="text-slate-400">Analizando malla...</p>';
|
||||
try {
|
||||
const data = await apiGet(`/models/${getModelId()}/validate`);
|
||||
container.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-slate-500">Watertight:</span> <span class="${data.is_watertight ? 'text-green-400' : 'text-red-400'}">${data.is_watertight ? 'Si' : 'No'}</span></div>
|
||||
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${data.volume_cm3} cm³</span></div>
|
||||
<div><span class="text-slate-500">Area:</span> <span class="text-slate-200">${data.surface_area_cm2} cm²</span></div>
|
||||
<div><span class="text-slate-500">Caras:</span> <span class="text-slate-200">${data.face_count}</span></div>
|
||||
<div><span class="text-slate-500">Vertices:</span> <span class="text-slate-200">${data.vertex_count}</span></div>
|
||||
<div><span class="text-slate-500">Euler:</span> <span class="text-slate-200">${data.euler_number}</span></div>
|
||||
<div><span class="text-slate-500">Agujeros estimados:</span> <span class="text-slate-200">${data.estimated_holes}</span></div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="text-red-400">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== ESTIMATION ======
|
||||
async function runEstimation() {
|
||||
const container = document.getElementById('estimation-result');
|
||||
if (!container) return;
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '<p class="text-slate-400">Calculando...</p>';
|
||||
try {
|
||||
const data = await apiGet(`/models/${getModelId()}/estimate`);
|
||||
container.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-slate-500">Volumen:</span> <span class="text-slate-200">${data.volume_cm3} cm³</span></div>
|
||||
<div><span class="text-slate-500">Peso:</span> <span class="text-slate-200">${data.grams} g</span></div>
|
||||
<div><span class="text-slate-500">Costo:</span> <span class="text-cyan-400 font-bold">$${data.cost}</span></div>
|
||||
<div><span class="text-slate-500">Tiempo:</span> <span class="text-slate-200">${data.estimated_time}</span></div>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="text-red-400">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ====== COMPARISON ======
|
||||
async function openCompareModal() {
|
||||
const modal = document.getElementById('compare-modal');
|
||||
const list = document.getElementById('compare-list');
|
||||
if (!modal || !list) return;
|
||||
modal.classList.remove('hidden');
|
||||
list.innerHTML = '<p class="text-sm text-slate-500">Cargando...</p>';
|
||||
try {
|
||||
const models = await apiGet('/models/?limit=100');
|
||||
const currentId = getModelId();
|
||||
const others = models.filter(m => m.id !== currentId);
|
||||
if (others.length === 0) {
|
||||
list.innerHTML = '<p class="text-sm text-slate-500">No hay otros modelos para comparar.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = others.map(m => `
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-xl bg-slate-900/50 border border-white/5 hover:border-cyan-500/30 cursor-pointer transition-all" onclick="compareWith(${m.id})">
|
||||
<img src="/api/models/${m.id}/thumbnail" class="w-10 h-10 rounded-lg object-cover bg-slate-800" onerror="this.style.display='none'">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">${m.title}</p>
|
||||
<p class="text-xs text-slate-500">${m.faces ?? '?'} caras · ${m.author || 'Anonimo'}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<p class="text-sm text-red-400">Error cargando modelos.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeCompareModal() {
|
||||
const modal = document.getElementById('compare-modal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function compareWith(otherId) {
|
||||
const currentId = getModelId();
|
||||
window.open(`/model/${otherId}?compare=${currentId}`, '_blank');
|
||||
closeCompareModal();
|
||||
}
|
||||
39
static/js/theme.js
Normal file
39
static/js/theme.js
Normal file
@@ -0,0 +1,39 @@
|
||||
(function() {
|
||||
const STORAGE_KEY = 'stl-repo-theme';
|
||||
|
||||
function getTheme() {
|
||||
return localStorage.getItem(STORAGE_KEY) || 'dark';
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const html = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
html.classList.add('light-mode');
|
||||
} else {
|
||||
html.classList.remove('light-mode');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('themechange', { detail: theme }));
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = getTheme();
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.getTheme = getTheme;
|
||||
window.setTheme = setTheme;
|
||||
window.toggleTheme = toggleTheme;
|
||||
|
||||
// Apply on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyTheme(getTheme());
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', toggleTheme);
|
||||
});
|
||||
})();
|
||||
407
static/js/upload.js
Normal file
407
static/js/upload.js
Normal file
@@ -0,0 +1,407 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Tabs
|
||||
const tabUpload = document.getElementById('tab-upload');
|
||||
const tabUrl = document.getElementById('tab-url');
|
||||
const tabZip = document.getElementById('tab-zip');
|
||||
const panelUpload = document.getElementById('panel-upload');
|
||||
const panelUrl = document.getElementById('panel-url');
|
||||
const panelZip = document.getElementById('panel-zip');
|
||||
|
||||
function showPanel(name) {
|
||||
[panelUpload, panelUrl, panelZip].forEach(p => p && p.classList.add('hidden'));
|
||||
[tabUpload, tabUrl, tabZip].forEach(t => {
|
||||
if (!t) return;
|
||||
t.classList.remove('bg-cyan-500/20', 'text-cyan-400', 'border-cyan-500/30');
|
||||
t.classList.add('bg-slate-800', 'border-white/10', 'text-slate-400');
|
||||
});
|
||||
const activeTab = name === 'upload' ? tabUpload : name === 'url' ? tabUrl : tabZip;
|
||||
const activePanel = name === 'upload' ? panelUpload : name === 'url' ? panelUrl : panelZip;
|
||||
if (activeTab) {
|
||||
activeTab.classList.remove('bg-slate-800', 'border-white/10', 'text-slate-400');
|
||||
activeTab.classList.add('bg-cyan-500/20', 'text-cyan-400', 'border-cyan-500/30');
|
||||
}
|
||||
if (activePanel) activePanel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
if (tabUpload) tabUpload.addEventListener('click', () => showPanel('upload'));
|
||||
if (tabUrl) tabUrl.addEventListener('click', () => showPanel('url'));
|
||||
if (tabZip) tabZip.addEventListener('click', () => showPanel('zip'));
|
||||
|
||||
// License template selector
|
||||
const licenseSelect = document.getElementById('license-select');
|
||||
const licenseInput = document.getElementById('license');
|
||||
if (licenseSelect && licenseInput) {
|
||||
licenseSelect.addEventListener('change', () => {
|
||||
if (licenseSelect.value === 'custom') {
|
||||
licenseInput.classList.remove('hidden');
|
||||
licenseInput.focus();
|
||||
} else {
|
||||
licenseInput.classList.add('hidden');
|
||||
licenseInput.value = licenseSelect.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ====== NORMAL UPLOAD ======
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const fileNameDisplay = document.getElementById('file-name');
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const btnText = document.getElementById('btn-text');
|
||||
const btnIcon = document.getElementById('btn-icon');
|
||||
const tagsInput = document.getElementById('tags');
|
||||
const tagsSuggestions = document.getElementById('tags-suggestions');
|
||||
const partsList = document.getElementById('parts-list');
|
||||
const imagesDropZone = document.getElementById('images-drop-zone');
|
||||
const imagesInput = document.getElementById('images-input');
|
||||
const imagesNameDisplay = document.getElementById('images-name');
|
||||
|
||||
let selectedFiles = [];
|
||||
let selectedImages = [];
|
||||
let allTags = [];
|
||||
|
||||
loadExistingTags();
|
||||
|
||||
async function loadExistingTags() {
|
||||
try {
|
||||
allTags = await apiGet('/models/tags');
|
||||
} catch (e) {
|
||||
console.error('Could not load tags', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag autocomplete
|
||||
if (tagsInput && tagsSuggestions) {
|
||||
tagsInput.addEventListener('input', () => {
|
||||
const val = tagsInput.value.toLowerCase();
|
||||
const lastTag = val.split(',').pop().trim();
|
||||
if (!lastTag || lastTag.length < 1) {
|
||||
tagsSuggestions.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const matches = allTags.filter(t => t.name.includes(lastTag) && !val.includes(t.name)).slice(0, 5);
|
||||
if (matches.length === 0) {
|
||||
tagsSuggestions.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
tagsSuggestions.innerHTML = matches.map(t =>
|
||||
`<div class="px-3 py-2 hover:bg-cyan-500/20 cursor-pointer text-sm text-slate-300 hover:text-cyan-400 transition-colors" onclick="selectTag('${t.name}')">${t.name}</div>`
|
||||
).join('');
|
||||
tagsSuggestions.style.display = 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!tagsInput.contains(e.target) && !tagsSuggestions.contains(e.target)) {
|
||||
tagsSuggestions.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.selectTag = function(name) {
|
||||
if (!tagsInput) return;
|
||||
const parts = tagsInput.value.split(',');
|
||||
parts.pop();
|
||||
parts.push(name);
|
||||
tagsInput.value = parts.join(', ') + ', ';
|
||||
if (tagsSuggestions) tagsSuggestions.style.display = 'none';
|
||||
tagsInput.focus();
|
||||
};
|
||||
|
||||
// 3D Files drop zone
|
||||
if (dropZone && fileInput) {
|
||||
dropZone.addEventListener('click', (e) => {
|
||||
if (e.target !== fileInput) fileInput.click();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.add('drop-active'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => dropZone.classList.remove('drop-active'), false);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
const files = Array.from(e.dataTransfer.files).filter(f =>
|
||||
f.name.toLowerCase().endsWith('.stl') || f.name.toLowerCase().endsWith('.3mf')
|
||||
);
|
||||
if (files.length) handleFiles(files);
|
||||
}, false);
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) handleFiles(Array.from(fileInput.files));
|
||||
});
|
||||
}
|
||||
|
||||
function handleFiles(files) {
|
||||
selectedFiles = files;
|
||||
renderPartsList();
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
if (fileNameDisplay) {
|
||||
fileNameDisplay.innerHTML = `<span class="text-cyan-400">${files.length} archivo(s)</span> <span class="text-slate-500">(${(totalSize / 1024).toFixed(1)} KB)</span>`;
|
||||
}
|
||||
showToast(`${files.length} archivo(s) 3D seleccionado(s)`, 'success');
|
||||
}
|
||||
|
||||
function renderPartsList() {
|
||||
if (!partsList) return;
|
||||
if (selectedFiles.length === 0) {
|
||||
partsList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
partsList.innerHTML = selectedFiles.map((f, i) => `
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl bg-slate-900/40 border border-white/5">
|
||||
<div class="w-8 h-8 rounded-lg bg-cyan-500/10 flex items-center justify-center text-cyan-400 text-xs font-bold">${i + 1}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">${f.name}</p>
|
||||
<p class="text-xs text-slate-500">${(f.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
<input type="text" placeholder="Nombre de parte" data-idx="${i}" class="part-name-input px-3 py-1.5 rounded-lg bg-slate-800 border border-white/10 text-sm w-32 focus:border-cyan-500 focus:outline-none placeholder:text-slate-600">
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Images drop zone
|
||||
if (imagesDropZone && imagesInput) {
|
||||
imagesDropZone.addEventListener('click', (e) => {
|
||||
if (e.target !== imagesInput) imagesInput.click();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
imagesDropZone.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
imagesDropZone.addEventListener(eventName, () => imagesDropZone.classList.add('drop-active'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
imagesDropZone.addEventListener(eventName, () => imagesDropZone.classList.remove('drop-active'), false);
|
||||
});
|
||||
|
||||
imagesDropZone.addEventListener('drop', (e) => {
|
||||
const files = Array.from(e.dataTransfer.files).filter(f =>
|
||||
f.name.toLowerCase().endsWith('.jpg') || f.name.toLowerCase().endsWith('.jpeg') || f.name.toLowerCase().endsWith('.png')
|
||||
);
|
||||
if (files.length) handleImages(files);
|
||||
}, false);
|
||||
|
||||
imagesInput.addEventListener('change', () => {
|
||||
if (imagesInput.files.length) handleImages(Array.from(imagesInput.files));
|
||||
});
|
||||
}
|
||||
|
||||
function handleImages(files) {
|
||||
selectedImages = files;
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
if (imagesNameDisplay) {
|
||||
imagesNameDisplay.innerHTML = `<span class="text-cyan-400">${files.length} imagen(es)</span> <span class="text-slate-500">(${(totalSize / 1024).toFixed(1)} KB)</span>`;
|
||||
}
|
||||
showToast(`${files.length} imagen(es) seleccionada(s)`, 'success');
|
||||
}
|
||||
|
||||
if (uploadForm) {
|
||||
uploadForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedFiles.length) {
|
||||
showToast('Selecciona al menos un archivo 3D', 'error');
|
||||
dropZone.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
dropZone.classList.add('animate-pulse');
|
||||
setTimeout(() => dropZone.classList.remove('animate-pulse'), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const titleEl = document.getElementById('title');
|
||||
if (!titleEl || !titleEl.value.trim()) {
|
||||
showToast('El titulo es obligatorio', 'error');
|
||||
titleEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
selectedFiles.forEach(f => formData.append('files', f));
|
||||
selectedImages.forEach(f => formData.append('images', f));
|
||||
formData.append('title', titleEl.value.trim());
|
||||
|
||||
const descEl = document.getElementById('description');
|
||||
const authorEl = document.getElementById('author');
|
||||
const licenseEl = document.getElementById('license');
|
||||
const tagsEl = document.getElementById('tags');
|
||||
const catEl = document.getElementById('category');
|
||||
|
||||
if (descEl) formData.append('description', descEl.value);
|
||||
if (authorEl) formData.append('author', authorEl.value);
|
||||
if (licenseEl) formData.append('license', licenseEl.value);
|
||||
if (tagsEl) formData.append('tags', tagsEl.value);
|
||||
if (catEl) formData.append('category', catEl.value);
|
||||
|
||||
// Collect part names
|
||||
const partNameInputs = document.querySelectorAll('.part-name-input');
|
||||
const partNames = {};
|
||||
partNameInputs.forEach(input => {
|
||||
const idx = input.getAttribute('data-idx');
|
||||
if (input.value.trim()) {
|
||||
partNames[idx] = input.value.trim();
|
||||
}
|
||||
});
|
||||
if (Object.keys(partNames).length > 0) {
|
||||
formData.append('part_names', JSON.stringify(partNames));
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiPostForm('/models/', formData);
|
||||
showToast('Modelo subido correctamente', 'success');
|
||||
window.location.href = '/model/' + result.id;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.message.includes('already exists')) {
|
||||
showToast('Este archivo ya existe en el repositorio', 'error');
|
||||
} else {
|
||||
showToast('Error al subir: ' + err.message, 'error');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ====== IMPORT URL ======
|
||||
const urlForm = document.getElementById('url-form');
|
||||
if (urlForm) {
|
||||
urlForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const urlEl = document.getElementById('url-input');
|
||||
const titleEl = document.getElementById('url-title');
|
||||
if (!urlEl || !urlEl.value.trim() || !titleEl || !titleEl.value.trim()) {
|
||||
showToast('URL y titulo son obligatorios', 'error');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('url', urlEl.value.trim());
|
||||
formData.append('title', titleEl.value.trim());
|
||||
const desc = document.getElementById('url-description');
|
||||
const author = document.getElementById('url-author');
|
||||
const license = document.getElementById('url-license');
|
||||
const tags = document.getElementById('url-tags');
|
||||
const cat = document.getElementById('url-category');
|
||||
if (desc) formData.append('description', desc.value);
|
||||
if (author) formData.append('author', author.value);
|
||||
if (license) formData.append('license', license.value);
|
||||
if (tags) formData.append('tags', tags.value);
|
||||
if (cat) formData.append('category', cat.value);
|
||||
|
||||
const btn = urlForm.querySelector('button[type="submit"]');
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = 'Importando...';
|
||||
try {
|
||||
const result = await apiPostForm('/models/import-url', formData);
|
||||
showToast('Modelo importado correctamente', 'success');
|
||||
window.location.href = '/model/' + result.id;
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ====== BULK ZIP ======
|
||||
const zipDropZone = document.getElementById('zip-drop-zone');
|
||||
const zipInput = document.getElementById('zip-input');
|
||||
const zipNameDisplay = document.getElementById('zip-name');
|
||||
const zipForm = document.getElementById('zip-form');
|
||||
let selectedZip = null;
|
||||
|
||||
if (zipDropZone && zipInput) {
|
||||
zipDropZone.addEventListener('click', (e) => {
|
||||
if (e.target !== zipInput) zipInput.click();
|
||||
});
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
zipDropZone.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }, false);
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
zipDropZone.addEventListener(eventName, () => zipDropZone.classList.add('drop-active'), false);
|
||||
});
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
zipDropZone.addEventListener(eventName, () => zipDropZone.classList.remove('drop-active'), false);
|
||||
});
|
||||
zipDropZone.addEventListener('drop', (e) => {
|
||||
const f = Array.from(e.dataTransfer.files).find(fi => fi.name.toLowerCase().endsWith('.zip'));
|
||||
if (f) handleZip(f);
|
||||
}, false);
|
||||
zipInput.addEventListener('change', () => {
|
||||
if (zipInput.files.length) handleZip(zipInput.files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function handleZip(file) {
|
||||
selectedZip = file;
|
||||
if (zipNameDisplay) {
|
||||
zipNameDisplay.innerHTML = `<span class="text-cyan-400">${file.name}</span> <span class="text-slate-500">(${(file.size / 1024).toFixed(1)} KB)</span>`;
|
||||
}
|
||||
showToast('ZIP seleccionado', 'success');
|
||||
}
|
||||
|
||||
if (zipForm) {
|
||||
zipForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedZip) {
|
||||
showToast('Selecciona un archivo ZIP', 'error');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('zip_file', selectedZip);
|
||||
const desc = document.getElementById('zip-description');
|
||||
const author = document.getElementById('zip-author');
|
||||
const license = document.getElementById('zip-license');
|
||||
const tags = document.getElementById('zip-tags');
|
||||
const cat = document.getElementById('zip-category');
|
||||
if (desc) formData.append('description', desc.value);
|
||||
if (author) formData.append('author', author.value);
|
||||
if (license) formData.append('license', license.value);
|
||||
if (tags) formData.append('tags', tags.value);
|
||||
if (cat) formData.append('category', cat.value);
|
||||
|
||||
const btn = zipForm.querySelector('button[type="submit"]');
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = 'Procesando...';
|
||||
try {
|
||||
const results = await apiPostForm('/models/bulk-zip', formData);
|
||||
showToast(`${results.length} modelo(s) importado(s)`, 'success');
|
||||
setTimeout(() => window.location.href = '/', 1000);
|
||||
} catch (err) {
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setLoading(v) {
|
||||
if (!submitBtn) return;
|
||||
submitBtn.disabled = v;
|
||||
if (v) {
|
||||
btnText.textContent = 'Subiendo...';
|
||||
btnIcon.innerHTML = '<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>';
|
||||
btnIcon.classList.add('animate-spin');
|
||||
} else {
|
||||
btnText.textContent = 'Subir Modelo';
|
||||
btnIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>';
|
||||
btnIcon.classList.remove('animate-spin');
|
||||
}
|
||||
}
|
||||
});
|
||||
269
static/upload.html
Normal file
269
static/upload.html
Normal file
@@ -0,0 +1,269 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Subir Modelo - STL Repository</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: { 850: '#172033', 950: '#020617' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen">
|
||||
<!-- Navbar -->
|
||||
<nav class="glass sticky top-0 z-50 border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-xl shadow-lg group-hover:scale-110 transition-transform">
|
||||
🖨️
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent">STL Repository</h1>
|
||||
<p class="text-xs text-slate-400 -mt-0.5">Modelos 3D para imprimir</p>
|
||||
</div>
|
||||
</a>
|
||||
<button id="theme-toggle" class="p-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-slate-400 hover:text-yellow-400 transition-colors mr-2" title="Cambiar tema">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
|
||||
</button>
|
||||
<a href="/" class="px-5 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 border border-white/10 text-sm font-medium transition-colors">
|
||||
Volver a Galeria
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-3xl mx-auto px-6 py-10">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold mb-2">Subir nuevo modelo</h2>
|
||||
<p class="text-slate-400">Selecciona uno o mas archivos STL/3MF, importa desde URL o sube un ZIP.</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button id="tab-upload" class="flex-1 px-4 py-3 rounded-xl bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-medium text-sm transition-all">
|
||||
📁 Archivos
|
||||
</button>
|
||||
<button id="tab-url" class="flex-1 px-4 py-3 rounded-xl bg-slate-800 border border-white/10 text-slate-400 font-medium text-sm transition-all hover:bg-slate-700">
|
||||
🌐 URL
|
||||
</button>
|
||||
<button id="tab-zip" class="flex-1 px-4 py-3 rounded-xl bg-slate-800 border border-white/10 text-slate-400 font-medium text-sm transition-all hover:bg-slate-700">
|
||||
🗜️ ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Panel: Upload -->
|
||||
<div id="panel-upload">
|
||||
<!-- 3D Files Drop Zone -->
|
||||
<div id="drop-zone" class="border-2 border-dashed border-slate-700 rounded-2xl p-10 text-center cursor-pointer transition-all hover:border-cyan-500/50 hover:bg-slate-900/40 mb-4 group">
|
||||
<div class="w-16 h-16 rounded-2xl bg-slate-900 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform group-hover:bg-cyan-500/10">
|
||||
<svg class="w-8 h-8 text-slate-400 group-hover:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold mb-1">Archivos 3D (STL / 3MF)</h3>
|
||||
<p class="text-sm text-slate-500 mb-2">Arrastra uno o mas archivos aqui</p>
|
||||
<p id="file-name" class="text-cyan-400 font-medium text-sm min-h-[20px]"></p>
|
||||
<input type="file" id="file-input" accept=".stl,.3mf" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- Parts list -->
|
||||
<div id="parts-list" class="space-y-2 mb-6"></div>
|
||||
|
||||
<!-- Images Drop Zone -->
|
||||
<div id="images-drop-zone" class="border-2 border-dashed border-slate-700 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-fuchsia-500/50 hover:bg-slate-900/40 mb-8 group">
|
||||
<div class="w-14 h-14 rounded-2xl bg-slate-900 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform group-hover:bg-fuchsia-500/10">
|
||||
<svg class="w-7 h-7 text-slate-400 group-hover:text-fuchsia-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold mb-1">Imagenes de referencia</h3>
|
||||
<p class="text-xs text-slate-500 mb-2">Opcional - JPG o PNG</p>
|
||||
<p id="images-name" class="text-fuchsia-400 font-medium text-sm min-h-[20px]"></p>
|
||||
<input type="file" id="images-input" accept=".jpg,.jpeg,.png" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form id="upload-form" class="glass rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Titulo <span class="text-cyan-500">*</span></label>
|
||||
<input type="text" id="title" required placeholder="Ej: Soporte para telefono" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Descripcion</label>
|
||||
<textarea id="description" rows="3" placeholder="Describe el modelo, materiales recomendados, etc." class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Autor</label>
|
||||
<input type="text" id="author" placeholder="Tu nombre o alias" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Licencia</label>
|
||||
<select id="license-select" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300 mb-2">
|
||||
<option value="">Seleccionar licencia...</option>
|
||||
<option value="CC0 1.0 Universal">CC0 - Dominio Publico</option>
|
||||
<option value="CC-BY 4.0">CC-BY 4.0 - Atribucion</option>
|
||||
<option value="CC-BY-SA 4.0">CC-BY-SA 4.0 - Compartir Igual</option>
|
||||
<option value="CC-BY-NC 4.0">CC-BY-NC 4.0 - No Comercial</option>
|
||||
<option value="CC-BY-NC-SA 4.0">CC-BY-NC-SA 4.0 - NC + SA</option>
|
||||
<option value="CC-BY-ND 4.0">CC-BY-ND 4.0 - Sin Derivadas</option>
|
||||
<option value="GPL-3.0">GPL-3.0</option>
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="custom">Otra (personalizada)</option>
|
||||
</select>
|
||||
<input type="text" id="license" placeholder="Ej: Mi licencia personal" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 hidden">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Categoria</label>
|
||||
<select id="category" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Tags</label>
|
||||
<input type="text" id="tags" placeholder="robot, articulado, util" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
<div id="tags-suggestions" class="absolute left-0 right-0 top-full mt-1 glass rounded-xl border border-white/10 overflow-hidden z-20 hidden"></div>
|
||||
<p class="text-xs text-slate-500 mt-1">Separados por comas. Escribe para ver sugerencias.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<button type="submit" id="submit-btn" class="w-full md:w-auto px-8 py-3.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||||
<svg id="btn-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||
<span id="btn-text">Subir Modelo</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Panel: URL -->
|
||||
<div id="panel-url" class="hidden">
|
||||
<form id="url-form" class="glass rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">URL del archivo <span class="text-cyan-500">*</span></label>
|
||||
<input type="url" id="url-input" required placeholder="https://ejemplo.com/modelo.stl" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
<p class="text-xs text-slate-500 mt-1">Soporta archivos .stl y .3mf directos. Maximo 50MB.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Titulo <span class="text-cyan-500">*</span></label>
|
||||
<input type="text" id="url-title" required placeholder="Ej: Soporte para telefono" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Descripcion</label>
|
||||
<textarea id="url-description" rows="3" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Autor</label>
|
||||
<input type="text" id="url-author" placeholder="Tu nombre o alias" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Licencia</label>
|
||||
<input type="text" id="url-license" placeholder="Ej: CC-BY-SA 4.0" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Categoria</label>
|
||||
<select id="url-category" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Tags</label>
|
||||
<input type="text" id="url-tags" placeholder="robot, articulado, util" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="w-full md:w-auto px-8 py-3.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||
Importar desde URL
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Panel: ZIP -->
|
||||
<div id="panel-zip" class="hidden">
|
||||
<div id="zip-drop-zone" class="border-2 border-dashed border-slate-700 rounded-2xl p-10 text-center cursor-pointer transition-all hover:border-cyan-500/50 hover:bg-slate-900/40 mb-6 group">
|
||||
<div class="w-16 h-16 rounded-2xl bg-slate-900 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform group-hover:bg-cyan-500/10">
|
||||
<svg class="w-8 h-8 text-slate-400 group-hover:text-cyan-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold mb-1">Archivo ZIP</h3>
|
||||
<p class="text-sm text-slate-500 mb-2">Arrastra un ZIP con archivos STL/3MF</p>
|
||||
<p id="zip-name" class="text-cyan-400 font-medium text-sm min-h-[20px]"></p>
|
||||
<input type="file" id="zip-input" accept=".zip" class="hidden">
|
||||
</div>
|
||||
|
||||
<form id="zip-form" class="glass rounded-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Descripcion (aplicada a todos)</label>
|
||||
<textarea id="zip-description" rows="2" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600 resize-none"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Autor</label>
|
||||
<input type="text" id="zip-author" placeholder="Tu nombre o alias" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Licencia</label>
|
||||
<input type="text" id="zip-license" placeholder="Ej: CC-BY-SA 4.0" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Categoria</label>
|
||||
<select id="zip-category" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all cursor-pointer text-slate-300">
|
||||
<option value="">Sin categoria</option>
|
||||
<option value="Arte">Arte</option>
|
||||
<option value="Herramientas">Herramientas</option>
|
||||
<option value="Juguetes">Juguetes</option>
|
||||
<option value="Piezas">Piezas</option>
|
||||
<option value="Decoracion">Decoracion</option>
|
||||
<option value="Otros">Otros</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1.5">Tags (aplicados a todos)</label>
|
||||
<input type="text" id="zip-tags" placeholder="robot, articulado, util" class="w-full px-4 py-3 rounded-xl bg-slate-900/60 border border-white/10 focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/20 transition-all placeholder:text-slate-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<button type="submit" class="w-full md:w-auto px-8 py-3.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-500/20 transition-all hover:scale-[1.02] flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
|
||||
Procesar ZIP
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/upload.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
274
tests/test_api.py
Normal file
274
tests/test_api.py
Normal file
@@ -0,0 +1,274 @@
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
Base.metadata.create_all(bind=engine)
|
||||
client = TestClient(app)
|
||||
|
||||
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_DIR = os.path.dirname(TEST_DIR)
|
||||
|
||||
_upload_counter = 0
|
||||
|
||||
|
||||
def _get_unique_stl():
|
||||
global _upload_counter
|
||||
_upload_counter += 1
|
||||
fd, path = tempfile.mkstemp(suffix='.stl')
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(f"""solid test{_upload_counter}
|
||||
facet normal 0 0 1
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex {_upload_counter} 0 0
|
||||
vertex {_upload_counter} {_upload_counter} 0
|
||||
endloop
|
||||
endfacet
|
||||
endsolid test{_upload_counter}
|
||||
""")
|
||||
return path
|
||||
|
||||
|
||||
def test_list_empty_models():
|
||||
response = client.get("/api/models/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_list_tags_empty():
|
||||
response = client.get("/api/models/tags")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def _upload():
|
||||
stl_path = _get_unique_stl()
|
||||
with open(stl_path, 'rb') as f:
|
||||
response = client.post(
|
||||
"/api/models/",
|
||||
data={
|
||||
"title": "Test Cube",
|
||||
"description": "A test cube",
|
||||
"author": "Tester",
|
||||
"tags": "test, cube",
|
||||
"category": "Piezas"
|
||||
},
|
||||
files={"files": (os.path.basename(stl_path), f, "application/octet-stream")}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()['id']
|
||||
|
||||
|
||||
def test_upload_model():
|
||||
model_id = _upload()
|
||||
assert isinstance(model_id, int)
|
||||
|
||||
|
||||
def test_get_model():
|
||||
model_id = _upload()
|
||||
response = client.get(f"/api/models/{model_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['id'] == model_id
|
||||
assert data['title'] == "Test Cube"
|
||||
|
||||
|
||||
def test_update_model():
|
||||
model_id = _upload()
|
||||
response = client.put(
|
||||
f"/api/models/{model_id}",
|
||||
json={"title": "Updated Cube", "tag_names": ["updated"]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['title'] == "Updated Cube"
|
||||
assert len(data['tags']) == 1
|
||||
|
||||
|
||||
def test_download_model():
|
||||
model_id = _upload()
|
||||
response = client.get(f"/api/models/{model_id}/download")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_thumbnail():
|
||||
model_id = _upload()
|
||||
response = client.get(f"/api/models/{model_id}/thumbnail")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_qr_endpoint():
|
||||
model_id = _upload()
|
||||
response = client.get(f"/api/models/{model_id}/qr")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_pagination():
|
||||
response = client.get("/api/models/?skip=0&limit=10")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_search_filter():
|
||||
response = client.get("/api/models/?search=Cube")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_delete_model():
|
||||
model_id = _upload()
|
||||
response = client.delete(f"/api/models/{model_id}")
|
||||
assert response.status_code == 200
|
||||
response = client.get(f"/api/models/{model_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_backup_endpoint():
|
||||
response = client.get("/api/models/system/backup")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# Phase 5: Social features
|
||||
|
||||
def test_create_rating():
|
||||
model_id = _upload()
|
||||
response = client.post(f"/api/models/{model_id}/ratings?stars=5")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['stars'] == 5
|
||||
|
||||
response = client.get(f"/api/models/{model_id}")
|
||||
assert response.json()['avg_rating'] == 5.0
|
||||
|
||||
|
||||
def test_create_comment():
|
||||
model_id = _upload()
|
||||
response = client.post(f"/api/models/{model_id}/comments?text=Great%20model&author_name=User1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['text'] == "Great model"
|
||||
assert data['author_name'] == "User1"
|
||||
|
||||
|
||||
def test_list_comments():
|
||||
model_id = _upload()
|
||||
client.post(f"/api/models/{model_id}/comments?text=Comment1")
|
||||
client.post(f"/api/models/{model_id}/comments?text=Comment2")
|
||||
response = client.get(f"/api/models/{model_id}/comments")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
|
||||
def _create_collection():
|
||||
response = client.post("/api/models/collections", json={"name": "My Collection", "description": "Test"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['name'] == "My Collection"
|
||||
return data['id']
|
||||
|
||||
|
||||
def test_add_to_collection():
|
||||
model_id = _upload()
|
||||
coll_id = _create_collection()
|
||||
response = client.post(f"/api/models/collections/{coll_id}/add/{model_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(f"/api/models/collections/{coll_id}")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()['models']) == 1
|
||||
|
||||
|
||||
# Phase 6: Import / Bulk / Batch / Validation / Estimate
|
||||
|
||||
def test_import_from_url():
|
||||
stl_path = _get_unique_stl()
|
||||
with open(stl_path, 'rb') as f:
|
||||
stl_bytes = f.read()
|
||||
|
||||
from unittest.mock import Mock
|
||||
mock_response = Mock()
|
||||
mock_response.content = stl_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
async_mock_client = AsyncMock()
|
||||
async_mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch('app.routers.models.httpx.AsyncClient', return_value=async_mock_client):
|
||||
async_mock_client.__aenter__ = AsyncMock(return_value=async_mock_client)
|
||||
async_mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
response = client.post(
|
||||
"/api/models/import-url",
|
||||
data={"url": "http://example.com/model.stl", "title": "Imported Model"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['title'] == "Imported Model"
|
||||
|
||||
|
||||
def test_bulk_zip_upload():
|
||||
stl_path = _get_unique_stl()
|
||||
zip_path = tempfile.mktemp(suffix='.zip')
|
||||
with zipfile.ZipFile(zip_path, 'w') as zf:
|
||||
zf.write(stl_path, os.path.basename(stl_path))
|
||||
|
||||
with open(zip_path, 'rb') as f:
|
||||
response = client.post(
|
||||
"/api/models/bulk-zip",
|
||||
data={"author": "BulkTester"},
|
||||
files={"zip_file": ("models.zip", f, "application/zip")}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
os.remove(zip_path)
|
||||
|
||||
|
||||
def test_batch_download():
|
||||
model_id1 = _upload()
|
||||
model_id2 = _upload()
|
||||
response = client.post(
|
||||
"/api/models/batch-download",
|
||||
json=[model_id1, model_id2]
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers['content-type'] == 'application/zip'
|
||||
|
||||
|
||||
def test_validate_mesh():
|
||||
model_id = _upload()
|
||||
response = client.get(f"/api/models/{model_id}/validate")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert 'is_watertight' in data
|
||||
assert 'volume_cm3' in data
|
||||
|
||||
|
||||
def test_estimate_print():
|
||||
model_id = _upload()
|
||||
response = client.get(f"/api/models/{model_id}/estimate")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert 'volume_cm3' in data
|
||||
assert 'cost' in data
|
||||
assert 'grams' in data
|
||||
122
tests/test_parsers.py
Normal file
122
tests/test_parsers.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import numpy as np
|
||||
from app.parsers import parse_stl_file, parse_3mf_file, generate_thumbnail, generate_generic_thumbnail
|
||||
|
||||
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_DIR = os.path.dirname(TEST_DIR)
|
||||
|
||||
|
||||
def test_parse_ascii_stl():
|
||||
path = os.path.join(PROJECT_DIR, 'test_cube.stl')
|
||||
if not os.path.exists(path):
|
||||
# Create a simple ASCII STL for testing
|
||||
with open(path, 'w') as f:
|
||||
f.write("""solid cube
|
||||
facet normal 0 0 -1
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 0 0
|
||||
vertex 10 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 0 -1
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 10 0
|
||||
vertex 0 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 0 1
|
||||
outer loop
|
||||
vertex 0 0 10
|
||||
vertex 10 10 10
|
||||
vertex 10 0 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 0 1
|
||||
outer loop
|
||||
vertex 0 0 10
|
||||
vertex 0 10 10
|
||||
vertex 10 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal -1 0 0
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 0 10 10
|
||||
vertex 0 0 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal -1 0 0
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 0 10 0
|
||||
vertex 0 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 1 0 0
|
||||
outer loop
|
||||
vertex 10 0 0
|
||||
vertex 10 0 10
|
||||
vertex 10 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 1 0 0
|
||||
outer loop
|
||||
vertex 10 0 0
|
||||
vertex 10 10 10
|
||||
vertex 10 10 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 -1 0
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 0 0 10
|
||||
vertex 10 0 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 -1 0
|
||||
outer loop
|
||||
vertex 0 0 0
|
||||
vertex 10 0 10
|
||||
vertex 10 0 0
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 1 0
|
||||
outer loop
|
||||
vertex 0 10 0
|
||||
vertex 10 10 0
|
||||
vertex 10 10 10
|
||||
endloop
|
||||
endfacet
|
||||
facet normal 0 1 0
|
||||
outer loop
|
||||
vertex 0 10 0
|
||||
vertex 10 10 10
|
||||
vertex 0 10 10
|
||||
endloop
|
||||
endfacet
|
||||
endsolid cube
|
||||
""")
|
||||
result = parse_stl_file(path)
|
||||
assert result['faces'] == 12
|
||||
assert abs(result['width'] - 10.0) < 0.1
|
||||
assert abs(result['height'] - 10.0) < 0.1
|
||||
assert abs(result['depth'] - 10.0) < 0.1
|
||||
assert len(result['vertices']) == 36 # 12 triangles * 3 vertices
|
||||
|
||||
|
||||
def test_generate_thumbnail():
|
||||
path = os.path.join(PROJECT_DIR, 'test_cube.stl')
|
||||
result = parse_stl_file(path)
|
||||
out_path = os.path.join(PROJECT_DIR, 'test_thumb.png')
|
||||
generate_thumbnail(result['vertices'], out_path, size=128)
|
||||
assert os.path.exists(out_path)
|
||||
os.remove(out_path)
|
||||
|
||||
|
||||
def test_generate_generic_thumbnail():
|
||||
out_path = os.path.join(PROJECT_DIR, 'test_generic.png')
|
||||
generate_generic_thumbnail(out_path, size=128, label='3MF')
|
||||
assert os.path.exists(out_path)
|
||||
os.remove(out_path)
|
||||
609
uv.lock
generated
Normal file
609
uv.lock
generated
Normal file
@@ -0,0 +1,609 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8b/3669ad3b3f247a791b2b4aceb3aa5a31f5f6817bf547e4e1ff712338145a/greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a", size = 286902, upload-time = "2026-04-08T15:52:12.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/3e/3c0e19b82900873e2d8469b590a6c4b3dfd2b316d0591f1c26b38a4879a5/greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97", size = 606099, upload-time = "2026-04-08T16:24:38.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/33/99fef65e7754fc76a4ed14794074c38c9ed3394a5bd129d7f61b705f3168/greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996", size = 618837, upload-time = "2026-04-08T16:30:58.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f7/229f3aed6948faa20e0616a0b8568da22e365ede6a54d7d369058b128afd/greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc", size = 615062, upload-time = "2026-04-08T15:56:33.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/97/d988180011aa40135c46cd0d0cf01dd97f7162bae14139b4a3ef54889ba5/greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de", size = 1573511, upload-time = "2026-04-08T16:26:20.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/0f/a5a26fe152fb3d12e6a474181f6e9848283504d0afd095f353d85726374b/greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08", size = 1640396, upload-time = "2026-04-08T15:57:30.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/cf/bb2c32d9a100e36ee9f6e38fad6b1e082b8184010cb06259b49e1266ca01/greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2", size = 238892, upload-time = "2026-04-08T17:03:10.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/47/6c41314bac56e71436ce551c7fbe3cc830ed857e6aa9708dbb9c65142eb6/greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e", size = 235599, upload-time = "2026-04-08T15:52:54.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/75/7e9cd1126a1e1f0cd67b0eda02e5221b28488d352684704a78ed505bd719/greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1", size = 285856, upload-time = "2026-04-08T15:52:45.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c4/3e2df392e5cb199527c4d9dbcaa75c14edcc394b45040f0189f649631e3c/greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1", size = 610208, upload-time = "2026-04-08T16:24:39.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/af/750cdfda1d1bd30a6c28080245be8d0346e669a98fdbae7f4102aa95fff3/greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82", size = 621269, upload-time = "2026-04-08T16:30:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/78/0cbc693622cd54ebe25207efbb3a0eb07c2639cb8594f6e3aaaa0bb077a8/greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf", size = 617549, upload-time = "2026-04-08T15:56:34.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c0/8966767de01343c1ff47e8b855dc78e7d1a8ed2b7b9c83576a57e289f81d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729", size = 1575310, upload-time = "2026-04-08T16:26:21.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/38/bcdc71ba05e9a5fda87f63ffc2abcd1f15693b659346df994a48c968003d/greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c", size = 1640435, upload-time = "2026-04-08T15:57:32.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/c2/19b664b7173b9e4ef5f77e8cef9f14c20ec7fce7920dc1ccd7afd955d093/greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940", size = 238760, upload-time = "2026-04-08T17:04:03.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/96/795619651d39c7fbd809a522f881aa6f0ead504cc8201c3a5b789dfaef99/greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a", size = 235498, upload-time = "2026-04-08T17:05:00.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pil = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.49"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stl-repo"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "qrcode", extra = ["pil"] },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "trimesh" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "numpy", specifier = ">=2.4.4" },
|
||||
{ name = "pillow", specifier = ">=12.2.0" },
|
||||
{ name = "pydantic", specifier = ">=2.13.3" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.26" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.2" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.49" },
|
||||
{ name = "trimesh", specifier = ">=4.12.1" },
|
||||
{ name = "uvicorn", specifier = ">=0.46.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trimesh"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/b0/22da077f19a4cd5566225e920113abfa99d6558a9b61a5cb409f9d9227ae/trimesh-4.12.1.tar.gz", hash = "sha256:09e919824ffe3de7f4ea4c72fa18c94a109fb522f140094311dc9f2cf917320b", size = 842049, upload-time = "2026-04-24T20:59:17.486Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/05/8d9dd4a8cbaf4ff5af7a2161c26c29cc5d816fa50a5a6a3476b67587eea1/trimesh-4.12.1-py3-none-any.whl", hash = "sha256:2efa261d93edad91c82ecebd9bfb709e211ad99589b29b8f14868e6009386c08", size = 741040, upload-time = "2026-04-24T20:59:15.308Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.46.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user