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:
Consultoria AS
2026-04-27 09:14:58 +00:00
commit 14b307110d
31 changed files with 5386 additions and 0 deletions

18
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
3.12

23
Dockerfile Normal file
View 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
View 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
View File

20
app/database.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

739
app/routers/models.py Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from stl-repo!")
if __name__ == "__main__":
main()

23
pyproject.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

274
tests/test_api.py Normal file
View 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
View 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
View 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" },
]