From 14b307110def377d2610baf61474bb6e0c505bca Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Mon, 27 Apr 2026 09:14:58 +0000 Subject: [PATCH] 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 --- .dockerignore | 18 + .gitignore | 31 ++ .python-version | 1 + Dockerfile | 23 + README.md | 139 ++++++ app/__init__.py | 0 app/database.py | 20 + app/main.py | 38 ++ app/migrate.py | 59 +++ app/models.py | 105 +++++ app/parsers.py | 208 +++++++++ app/routers/__init__.py | 0 app/routers/models.py | 739 +++++++++++++++++++++++++++++++ app/schemas.py | 108 +++++ app/stl_parser.py | 127 ++++++ docker-compose.yml | 17 + main.py | 6 + pyproject.toml | 23 + static/css/style.css | 101 +++++ static/detail.html | 332 ++++++++++++++ static/index.html | 180 ++++++++ static/js/api.js | 59 +++ static/js/app.js | 391 +++++++++++++++++ static/js/detail.js | 941 ++++++++++++++++++++++++++++++++++++++++ static/js/theme.js | 39 ++ static/js/upload.js | 407 +++++++++++++++++ static/upload.html | 269 ++++++++++++ tests/__init__.py | 0 tests/test_api.py | 274 ++++++++++++ tests/test_parsers.py | 122 ++++++ uv.lock | 609 ++++++++++++++++++++++++++ 31 files changed, 5386 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/migrate.py create mode 100644 app/models.py create mode 100644 app/parsers.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/models.py create mode 100644 app/schemas.py create mode 100644 app/stl_parser.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 static/css/style.css create mode 100644 static/detail.html create mode 100644 static/index.html create mode 100644 static/js/api.js create mode 100644 static/js/app.js create mode 100644 static/js/detail.js create mode 100644 static/js/theme.js create mode 100644 static/js/upload.js create mode 100644 static/upload.html create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py create mode 100644 tests/test_parsers.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1664276 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9324c8 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55aec04 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..33fa463 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..f0d2547 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..86f387c --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/migrate.py b/app/migrate.py new file mode 100644 index 0000000..3a8d333 --- /dev/null +++ b/app/migrate.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..057ea96 --- /dev/null +++ b/app/models.py @@ -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") diff --git a/app/parsers.py b/app/parsers.py new file mode 100644 index 0000000..c65508a --- /dev/null +++ b/app/parsers.py @@ -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('= 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') diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/models.py b/app/routers/models.py new file mode 100644 index 0000000..840aa1d --- /dev/null +++ b/app/routers/models.py @@ -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") diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..45565ac --- /dev/null +++ b/app/schemas.py @@ -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) diff --git a/app/stl_parser.py b/app/stl_parser.py new file mode 100644 index 0000000..d7bb81f --- /dev/null +++ b/app/stl_parser.py @@ -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('= 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') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..84dfea8 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..6570196 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from stl-repo!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..76767ef --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..e29277e --- /dev/null +++ b/static/css/style.css @@ -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; } diff --git a/static/detail.html b/static/detail.html new file mode 100644 index 0000000..7b11f7b --- /dev/null +++ b/static/detail.html @@ -0,0 +1,332 @@ + + + + + + Detalle - STL Repository + + + + + + + + + + + + +
+
+ +
+
+
+
+
+

Cargando modelo 3D...

+
+
+ +
+
+ + + + +
+
+ + + + + +
+
+ + + + +
+
+ + +
+
+
+ Arrastra para rotar • Rueda para zoom + +
+
+ + +
+
+
+
+

+

+
+
+ +
+ +
+
+ +

+
+ +
+
+ +

+
+
+ +

+
+
+ +
+ +
+
+ +
+
+ +

+
+
+ +

+
+
+ + +
+ + +
+ + + +
+ + + Descargar + + + + +
+
+
+ + +
+ + +
+ + + +
+
+ + +
+ +
+ +
+ + +
+
+ + +
+ +
+
+ + + +
+
+ + +
+ + + +
+ +
+ + + + + + + + + + +
+ + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..82035b9 --- /dev/null +++ b/static/index.html @@ -0,0 +1,180 @@ + + + + + + STL Repository + + + + + + + + + +
+ + + + +
+ +
+

Galeria de Modelos

+

Explora, visualiza y descarga modelos 3D listos para imprimir.

+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+ 0 modelos + +
+ + + + + +
+
+
+

Cargando modelos...

+
+
+ + +
+ +
+
+
+ + + + +
+ + + + + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..2e4ea84 --- /dev/null +++ b/static/js/api.js @@ -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(); +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..a9a937b --- /dev/null +++ b/static/js/app.js @@ -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 = ` +
+
+ +
+

No se encontraron modelos

+

Intenta con otros terminos de busqueda o sube uno nuevo.

+
`; + 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 => + `${t.name}` + ).join(''); + + card.innerHTML = ` +
+ ${selectionMode ? ` +
+
+ ${isSelected ? '' : ''} +
+
+ ` : ''} + ${m.title} +
+ ${m.faces ?? '?'} caras +
+
+
+

${m.title}

+

${m.author || 'Autor desconocido'}

+
${tagBadges}
+
+ ${m.category || 'Sin categoria'} +
+ ${m.avg_rating ? `★ ${m.avg_rating.toFixed(1)}` : ''} + ${formatSize(m.file_size)} +
+
+
+ `; + 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 = '

No hay tags aun

'; + return; + } + container.innerHTML = allTags.map(t => + `` + ).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 = ` +
+

Error al cargar modelos

+

${e.message}

+
`; + } + } + } 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 = ` +
+ Recientes: + ${history.map(h => ` + + `).join('')} + +
+ `; +} + +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 = '
'; + modal.classList.remove('hidden'); + try { + const data = await apiGet(`/models/${modelId}/estimate`); + content.innerHTML = ` +
+
+

Volumen

+

${data.volume_cm3} cm³

+
+
+

Peso

+

${data.grams} g

+
+
+

Costo estimado

+

$${data.cost}

+
+
+

Tiempo

+

${data.estimated_time}

+
+
+

Basado en $${data.price_per_kg}/kg, densidad ${data.material_density} g/cm³

+ `; + } catch (err) { + content.innerHTML = `

Error: ${err.message}

`; + } +}; + +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)); +}); diff --git a/static/js/detail.js b/static/js/detail.js new file mode 100644 index 0000000..b2d7372 --- /dev/null +++ b/static/js/detail.js @@ -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 = ` +
+
+ +
+

Modelo no encontrado

+

El modelo que buscas no existe o ha sido eliminado.

+ Volver a la galeria +
`; + } +} + +function renderStars(avg, count) { + if (!avg) return 'Sin valoraciones'; + const full = Math.floor(avg); + const half = avg - full >= 0.5; + let html = ''; + for (let i = 0; i < 5; i++) { + if (i < full) { + html += ''; + } else if (i === full && half) { + html += ''; + } else { + html += ''; + } + } + html += `${avg.toFixed(1)} (${count})`; + 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 => + `${t.name}` + ).join(''); + } else { + tagsEl.innerHTML = ''; + } + } + + 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 = ` +
+ +
+ ${modelFiles.map((f, i) => ` +
+ +
+ ${f.part_name || f.filename} + +
+ `).join('')} +
+
+ `; +} + +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 = ` +
+ +
+ ${images.map(img => ` + + ${img.filename} + + `).join('')} +
+
+ `; +} + +function renderRatings(m) { + const container = document.getElementById('ratings-section'); + if (!container) return; + const ratings = m.ratings || []; + container.innerHTML = ` +
+ +
+ ${renderStars(m.avg_rating, ratings.length)} +
+ ${ratings.length > 0 ? ` +
+ ${ratings.slice(0, 5).map(r => ` +
+ ${'★'.repeat(r.stars)}${'☆'.repeat(5 - r.stars)} + ${new Date(r.created_at).toLocaleDateString()} +
+ `).join('')} +
+ ` : '

Aun no hay valoraciones

'} +
+ `; +} + +function renderComments(m) { + const container = document.getElementById('comments-list'); + if (!container) return; + const comments = m.comments || []; + if (comments.length === 0) { + container.innerHTML = '

Aun no hay comentarios. Se el primero!

'; + return; + } + container.innerHTML = comments.map(c => ` +
+
+ ${c.author_name || 'Anonimo'} + ${new Date(c.created_at).toLocaleDateString()} +
+

${c.text}

+
+ `).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 = '' + + collections.map(c => ``).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 = '

No hay archivos 3D para mostrar

'; + } + + 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 = '

Analizando malla...

'; + try { + const data = await apiGet(`/models/${getModelId()}/validate`); + container.innerHTML = ` +
+
Watertight: ${data.is_watertight ? 'Si' : 'No'}
+
Volumen: ${data.volume_cm3} cm³
+
Area: ${data.surface_area_cm2} cm²
+
Caras: ${data.face_count}
+
Vertices: ${data.vertex_count}
+
Euler: ${data.euler_number}
+
Agujeros estimados: ${data.estimated_holes}
+
+ `; + } catch (err) { + container.innerHTML = `

Error: ${err.message}

`; + } +} + +// ====== ESTIMATION ====== +async function runEstimation() { + const container = document.getElementById('estimation-result'); + if (!container) return; + container.classList.remove('hidden'); + container.innerHTML = '

Calculando...

'; + try { + const data = await apiGet(`/models/${getModelId()}/estimate`); + container.innerHTML = ` +
+
Volumen: ${data.volume_cm3} cm³
+
Peso: ${data.grams} g
+
Costo: $${data.cost}
+
Tiempo: ${data.estimated_time}
+
+ `; + } catch (err) { + container.innerHTML = `

Error: ${err.message}

`; + } +} + +// ====== 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 = '

Cargando...

'; + 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 = '

No hay otros modelos para comparar.

'; + return; + } + list.innerHTML = others.map(m => ` +
+ +
+

${m.title}

+

${m.faces ?? '?'} caras · ${m.author || 'Anonimo'}

+
+
+ `).join(''); + } catch (e) { + list.innerHTML = '

Error cargando modelos.

'; + } +} + +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(); +} diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..25fb7b7 --- /dev/null +++ b/static/js/theme.js @@ -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); + }); +})(); diff --git a/static/js/upload.js b/static/js/upload.js new file mode 100644 index 0000000..98429e7 --- /dev/null +++ b/static/js/upload.js @@ -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 => + `
${t.name}
` + ).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 = `${files.length} archivo(s) (${(totalSize / 1024).toFixed(1)} KB)`; + } + 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) => ` +
+
${i + 1}
+
+

${f.name}

+

${(f.size / 1024).toFixed(1)} KB

+
+ +
+ `).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 = `${files.length} imagen(es) (${(totalSize / 1024).toFixed(1)} KB)`; + } + 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 = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`; + } + 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 = ''; + btnIcon.classList.add('animate-spin'); + } else { + btnText.textContent = 'Subir Modelo'; + btnIcon.innerHTML = ''; + btnIcon.classList.remove('animate-spin'); + } + } +}); diff --git a/static/upload.html b/static/upload.html new file mode 100644 index 0000000..0b6c0a2 --- /dev/null +++ b/static/upload.html @@ -0,0 +1,269 @@ + + + + + + Subir Modelo - STL Repository + + + + + + + + + +
+
+

Subir nuevo modelo

+

Selecciona uno o mas archivos STL/3MF, importa desde URL o sube un ZIP.

+
+ + +
+ + + +
+ + +
+ +
+
+ +
+

Archivos 3D (STL / 3MF)

+

Arrastra uno o mas archivos aqui

+

+ +
+ + +
+ + +
+
+ +
+

Imagenes de referencia

+

Opcional - JPG o PNG

+

+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ + + +

Separados por comas. Escribe para ver sugerencias.

+
+
+ +
+ +
+
+
+ + + + + + +
+ +
+ + + + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2c2a883 --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..be14b08 --- /dev/null +++ b/tests/test_parsers.py @@ -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) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ce7bef8 --- /dev/null +++ b/uv.lock @@ -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" }, +]