From c291e3a0a86725327f304902934e43b4b7799ffb Mon Sep 17 00:00:00 2001 From: MisterBeardy Date: Fri, 30 Jan 2026 09:37:30 -0500 Subject: [PATCH 1/2] feat: Add STL thumbnail generation functionality - Implemented batch STL thumbnail generation API endpoint. - Added Pydantic schemas for batch thumbnail requests and responses. - Created service for generating thumbnails from STL files using trimesh and matplotlib. - Updated file upload and ZIP extraction endpoints to include thumbnail generation option. - Enhanced frontend to support STL thumbnail generation during file uploads and ZIP extractions. - Added integration and unit tests for the new thumbnail generation features. - Updated requirements to include necessary libraries for STL processing. --- backend/app/api/routes/library.py | 129 ++++++++++ backend/app/schemas/library.py | 29 +++ backend/app/services/stl_thumbnail.py | 140 +++++++++++ backend/tests/integration/test_library_api.py | 236 ++++++++++++++++++ .../tests/unit/services/test_stl_thumbnail.py | 204 +++++++++++++++ frontend/src/api/client.ts | 40 ++- frontend/src/pages/FileManagerPage.tsx | 124 ++++++++- requirements.txt | 5 + 8 files changed, 897 insertions(+), 10 deletions(-) create mode 100644 backend/app/services/stl_thumbnail.py create mode 100644 backend/tests/unit/services/test_stl_thumbnail.py diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index d19bf2ca5..0c75b63ef 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -25,6 +25,9 @@ from backend.app.schemas.library import ( AddToQueueRequest, AddToQueueResponse, AddToQueueResult, + BatchThumbnailRequest, + BatchThumbnailResponse, + BatchThumbnailResult, BulkDeleteRequest, BulkDeleteResponse, FileDuplicate, @@ -43,6 +46,7 @@ from backend.app.schemas.library import ( ZipExtractResult, ) from backend.app.services.archive import ArchiveService, ThreeMFParser +from backend.app.services.stl_thumbnail import generate_stl_thumbnail logger = logging.getLogger(__name__) @@ -621,6 +625,7 @@ async def list_files( async def upload_file( file: UploadFile = File(...), folder_id: int | None = None, + generate_stl_thumbnails: bool = Query(default=True), db: AsyncSession = Depends(get_db), ): """Upload a file to the library.""" @@ -712,6 +717,11 @@ async def upload_file( # For image files, create a thumbnail from the image itself thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + elif ext == ".stl": + # Generate STL thumbnail if enabled + if generate_stl_thumbnails: + thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) + # Create database entry library_file = LibraryFile( folder_id=folder_id, @@ -749,6 +759,7 @@ async def extract_zip_file( folder_id: int | None = Query(default=None), preserve_structure: bool = Query(default=True), create_folder_from_zip: bool = Query(default=False), + generate_stl_thumbnails: bool = Query(default=True), db: AsyncSession = Depends(get_db), ): """Upload and extract a ZIP file to the library. @@ -758,6 +769,7 @@ async def extract_zip_file( folder_id: Target folder ID (None = root) preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it + generate_stl_thumbnails: If True, generate thumbnails for STL files """ import tempfile import zipfile @@ -941,6 +953,11 @@ async def extract_zip_file( elif ext.lower() in IMAGE_EXTENSIONS: thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + elif ext == ".stl": + # Generate STL thumbnail if enabled + if generate_stl_thumbnails: + thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) + # Create database entry library_file = LibraryFile( folder_id=target_folder_id, @@ -994,6 +1011,118 @@ async def extract_zip_file( pass +# ============ STL Thumbnail Batch Generation ============ + + +@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse) +async def batch_generate_stl_thumbnails( + request: BatchThumbnailRequest, + db: AsyncSession = Depends(get_db), +): + """Generate thumbnails for STL files in batch. + + Can generate thumbnails for: + - Specific file IDs (file_ids) + - All STL files in a folder (folder_id) + - All STL files missing thumbnails (all_missing=True) + """ + thumbnails_dir = get_library_thumbnails_dir() + results: list[BatchThumbnailResult] = [] + + # Build query based on request + query = select(LibraryFile).where(LibraryFile.file_type == "stl") + + if request.file_ids: + # Specific files + query = query.where(LibraryFile.id.in_(request.file_ids)) + elif request.folder_id is not None: + # All STL files in a specific folder + query = query.where(LibraryFile.folder_id == request.folder_id) + if not request.all_missing: + # If not specifically asking for missing thumbnails, get all + pass + else: + query = query.where(LibraryFile.thumbnail_path.is_(None)) + elif request.all_missing: + # All STL files without thumbnails + query = query.where(LibraryFile.thumbnail_path.is_(None)) + else: + # No criteria specified - return empty + return BatchThumbnailResponse( + processed=0, + succeeded=0, + failed=0, + results=[], + ) + + result = await db.execute(query) + stl_files = result.scalars().all() + + succeeded = 0 + failed = 0 + + for stl_file in stl_files: + file_path = Path(stl_file.file_path) + + if not file_path.exists(): + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=False, + error="File not found on disk", + ) + ) + failed += 1 + continue + + try: + thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) + + if thumbnail_path: + # Update database + stl_file.thumbnail_path = thumbnail_path + await db.flush() + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=True, + ) + ) + succeeded += 1 + else: + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=False, + error="Thumbnail generation failed", + ) + ) + failed += 1 + except Exception as e: + logger.error(f"Failed to generate thumbnail for {stl_file.filename}: {e}") + results.append( + BatchThumbnailResult( + file_id=stl_file.id, + filename=stl_file.filename, + success=False, + error=str(e), + ) + ) + failed += 1 + + await db.commit() + + return BatchThumbnailResponse( + processed=len(stl_files), + succeeded=succeeded, + failed=failed, + results=results, + ) + + # ============ Queue Operations ============ # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index dacbcc735..4926af0f3 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel): folders_created: int files: list[ZipExtractResult] errors: list[ZipExtractError] + + +# ============ STL Thumbnail Generation ============ + + +class BatchThumbnailRequest(BaseModel): + """Schema for batch STL thumbnail generation request.""" + + file_ids: list[int] | None = None + folder_id: int | None = None + all_missing: bool = False + + +class BatchThumbnailResult(BaseModel): + """Result for a single file thumbnail generation.""" + + file_id: int + filename: str + success: bool + error: str | None = None + + +class BatchThumbnailResponse(BaseModel): + """Schema for batch thumbnail generation response.""" + + processed: int + succeeded: int + failed: int + results: list[BatchThumbnailResult] diff --git a/backend/app/services/stl_thumbnail.py b/backend/app/services/stl_thumbnail.py new file mode 100644 index 000000000..83b2a12a5 --- /dev/null +++ b/backend/app/services/stl_thumbnail.py @@ -0,0 +1,140 @@ +"""STL Thumbnail Generation Service. + +Generates thumbnail images from STL files using trimesh and matplotlib. +""" + +import logging +import uuid +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Bambu green color for rendering +BAMBU_GREEN = "#00AE42" +BACKGROUND_COLOR = "#1a1a1a" + +# Maximum vertices before simplification +MAX_VERTICES = 100000 + + +def generate_stl_thumbnail( + stl_path: Path, + thumbnails_dir: Path, + size: int = 256, +) -> str | None: + """Generate a thumbnail image from an STL file. + + Args: + stl_path: Path to the STL file + thumbnails_dir: Directory to save the thumbnail + size: Thumbnail size in pixels (default 256x256) + + Returns: + Path to the generated thumbnail, or None on failure + """ + try: + import matplotlib + import trimesh + + # Use Agg backend for headless rendering + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d import Axes3D # noqa: F401 + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + # Load the STL file + mesh = trimesh.load(str(stl_path), force="mesh") + + if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0: + logger.warning(f"Failed to load STL or empty mesh: {stl_path}") + return None + + # Simplify large meshes for performance + if len(mesh.vertices) > MAX_VERTICES: + logger.info(f"Simplifying mesh from {len(mesh.vertices)} vertices") + try: + # Calculate reduction ratio (0-1 range) + # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20% + keep_ratio = MAX_VERTICES / len(mesh.vertices) + target_reduction = 1.0 - keep_ratio + # Clamp to valid range (0.01 to 0.99) + target_reduction = max(0.01, min(0.99, target_reduction)) + mesh = mesh.simplify_quadric_decimation(target_reduction) + logger.info(f"Simplified mesh to {len(mesh.vertices)} vertices") + except Exception as e: + logger.warning(f"Mesh simplification failed, using original: {e}") + + # Get mesh bounds and center it + vertices = mesh.vertices + bounds_min = vertices.min(axis=0) + bounds_max = vertices.max(axis=0) + center = (bounds_min + bounds_max) / 2 + vertices_centered = vertices - center + + # Scale to fit in view + max_extent = (bounds_max - bounds_min).max() + if max_extent > 0: + scale = 1.0 / max_extent + vertices_scaled = vertices_centered * scale + else: + vertices_scaled = vertices_centered + + # Create figure with dark background + fig = plt.figure(figsize=(size / 100, size / 100), dpi=100) + fig.patch.set_facecolor(BACKGROUND_COLOR) + + ax = fig.add_subplot(111, projection="3d") + ax.set_facecolor(BACKGROUND_COLOR) + + # Create polygon collection from mesh faces + faces = mesh.faces + poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces] + + collection = Poly3DCollection( + poly3d, + facecolors=BAMBU_GREEN, + edgecolors=BAMBU_GREEN, + linewidths=0.1, + alpha=0.9, + ) + ax.add_collection3d(collection) + + # Set axis limits + ax.set_xlim(-0.6, 0.6) + ax.set_ylim(-0.6, 0.6) + ax.set_zlim(-0.6, 0.6) + + # Set view angle (isometric-ish) + ax.view_init(elev=25, azim=45) + + # Remove axes and grid + ax.set_axis_off() + ax.grid(False) + + # Remove margins + plt.subplots_adjust(left=0, right=1, top=1, bottom=0) + + # Save thumbnail + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + + fig.savefig( + thumb_path, + format="png", + facecolor=BACKGROUND_COLOR, + edgecolor="none", + bbox_inches="tight", + pad_inches=0.05, + dpi=100, + ) + plt.close(fig) + + logger.info(f"Generated STL thumbnail: {thumb_path}") + return str(thumb_path) + + except ImportError as e: + logger.warning(f"STL thumbnail generation unavailable (missing dependencies): {e}") + return None + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail for {stl_path}: {e}") + return None diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index 9a7c2d347..3225eb0c0 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -1,5 +1,10 @@ """Integration tests for Library API endpoints.""" +import io +import tempfile +import zipfile +from pathlib import Path + import pytest from httpx import AsyncClient @@ -479,3 +484,234 @@ class TestLibraryZipExtractAPI: assert folder_response.status_code == 200 folder = folder_response.json() assert folder["name"] == "MyProject" + + +class TestLibraryStlThumbnailAPI: + """Integration tests for STL thumbnail generation endpoints.""" + + @pytest.fixture + async def file_factory(self, db_session): + """Factory to create test files.""" + _counter = [0] + + async def _create_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "filename": f"test_model_{counter}.stl", + "file_path": f"/test/path/test_model_{counter}.stl", + "file_size": 1024, + "file_type": "stl", + } + defaults.update(kwargs) + + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_file + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session): + """Verify batch thumbnail generation with no files.""" + data = {"all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 0 + assert result["succeeded"] == 0 + assert result["failed"] == 0 + assert result["results"] == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session): + """Verify batch thumbnail generation with no criteria returns empty.""" + data = {} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_file_not_on_disk( + self, async_client: AsyncClient, file_factory, db_session + ): + """Verify batch thumbnail generation handles missing files gracefully.""" + # Create a file in DB but not on disk + stl_file = await file_factory( + filename="missing.stl", + file_path="/nonexistent/path/missing.stl", + thumbnail_path=None, + ) + + data = {"file_ids": [stl_file.id]} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 1 + assert result["succeeded"] == 0 + assert result["failed"] == 1 + assert result["results"][0]["success"] is False + assert "not found" in result["results"][0]["error"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session): + """Verify batch thumbnail generation with a real STL file.""" + from backend.app.models.library import LibraryFile + + # Create a simple ASCII STL cube + stl_content = """solid cube +facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 1 0 + endloop +endfacet +facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 1 0 1 + endloop +endfacet +endsolid cube""" + + with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f: + f.write(stl_content) + stl_path = f.name + + try: + # Create file in DB pointing to real STL + lib_file = LibraryFile( + filename="test_cube.stl", + file_path=stl_path, + file_size=len(stl_content), + file_type="stl", + thumbnail_path=None, + ) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + + data = {"file_ids": [lib_file.id]} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 1 + # Result depends on whether trimesh/matplotlib are installed + # Either succeeds or fails gracefully + assert result["succeeded"] + result["failed"] == 1 + finally: + import os + + if os.path.exists(stl_path): + os.unlink(stl_path) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session): + """Verify file upload accepts generate_stl_thumbnails parameter.""" + # Create a simple STL file + stl_content = b"solid test\nendsolid test" + + files = {"file": ("test.stl", stl_content, "application/octet-stream")} + params = {"generate_stl_thumbnails": "false"} + response = await async_client.post("/api/v1/library/files", files=files, params=params) + assert response.status_code == 200 + result = response.json() + assert result["filename"] == "test.stl" + assert result["file_type"] == "stl" + # No thumbnail should be generated when disabled + assert result["thumbnail_path"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session): + """Verify ZIP extraction accepts generate_stl_thumbnails parameter.""" + # Create a ZIP file containing an STL + stl_content = b"solid test\nendsolid test" + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("model.stl", stl_content) + zip_buffer.seek(0) + + files = {"file": ("test.zip", zip_buffer.read(), "application/zip")} + params = {"generate_stl_thumbnails": "false"} + response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params) + assert response.status_code == 200 + result = response.json() + assert result["extracted"] == 1 + assert result["files"][0]["filename"] == "model.stl" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session): + """Verify batch thumbnail generation can filter by folder.""" + from backend.app.models.library import LibraryFolder + + # Create a folder + folder = LibraryFolder(name="STL Folder") + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + + # Create STL file in folder (no thumbnail) + stl_in_folder = await file_factory( + filename="in_folder.stl", + folder_id=folder.id, + thumbnail_path=None, + ) + + # Create STL file at root (no thumbnail) + _stl_at_root = await file_factory( + filename="at_root.stl", + folder_id=None, + thumbnail_path=None, + ) + + # Request thumbnails only for files in folder + data = {"folder_id": folder.id, "all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + # Should only process the file in the folder + assert result["processed"] == 1 + assert result["results"][0]["file_id"] == stl_in_folder.id + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session): + """Verify batch thumbnail generation finds all STL files missing thumbnails.""" + # Create files with and without thumbnails + _stl_with_thumb = await file_factory( + filename="with_thumb.stl", + thumbnail_path="/some/path/thumb.png", + ) + stl_without_thumb1 = await file_factory( + filename="without_thumb1.stl", + thumbnail_path=None, + ) + stl_without_thumb2 = await file_factory( + filename="without_thumb2.stl", + thumbnail_path=None, + ) + + data = {"all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + # Should only process files without thumbnails + assert result["processed"] == 2 + file_ids = {r["file_id"] for r in result["results"]} + assert stl_without_thumb1.id in file_ids + assert stl_without_thumb2.id in file_ids diff --git a/backend/tests/unit/services/test_stl_thumbnail.py b/backend/tests/unit/services/test_stl_thumbnail.py new file mode 100644 index 000000000..31b6a0a19 --- /dev/null +++ b/backend/tests/unit/services/test_stl_thumbnail.py @@ -0,0 +1,204 @@ +"""Unit tests for the STL thumbnail service.""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +def _check_trimesh_available(): + """Check if trimesh is available for import.""" + try: + import trimesh + + return True + except ImportError: + return False + + +class TestStlThumbnailService: + """Tests for STL thumbnail generation service.""" + + def test_generate_stl_thumbnail_imports_available(self): + """Test that required imports are available.""" + try: + import matplotlib + import trimesh + + assert trimesh is not None + assert matplotlib is not None + except ImportError as e: + pytest.skip(f"Required dependencies not installed: {e}") + + def test_generate_stl_thumbnail_returns_none_on_missing_deps(self): + """Test graceful degradation when dependencies are missing.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "test.stl" + thumbnails_dir = Path(tmpdir) + + # Create a dummy STL file (will fail to parse) + stl_path.write_text("invalid stl content") + + # Should return None on failure, not raise + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + assert result is None + + @pytest.mark.skipif( + not _check_trimesh_available(), + reason="trimesh not installed", + ) + def test_generate_stl_thumbnail_with_simple_cube(self): + """Test thumbnail generation with a simple cube STL.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "cube.stl" + thumbnails_dir = Path(tmpdir) + + # Create a simple ASCII STL cube + stl_content = """solid cube +facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 1 0 + endloop +endfacet +facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 1 0 + vertex 0 1 0 + endloop +endfacet +facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 1 0 1 + endloop +endfacet +facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 0 1 1 + vertex 1 1 1 + endloop +endfacet +facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 1 0 1 + vertex 1 0 0 + endloop +endfacet +facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 0 0 1 + vertex 1 0 1 + endloop +endfacet +facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 0 1 + vertex 1 1 1 + endloop +endfacet +facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 1 1 + vertex 1 1 0 + endloop +endfacet +facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 0 + vertex 1 1 1 + endloop +endfacet +facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 1 + vertex 0 1 1 + endloop +endfacet +facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 0 + vertex 0 1 1 + endloop +endfacet +facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 1 + vertex 0 0 1 + endloop +endfacet +endsolid cube""" + stl_path.write_text(stl_content) + + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + + # Should return a path to the generated thumbnail + if result: + assert Path(result).exists() + assert Path(result).suffix == ".png" + # If result is None, dependencies might not be fully functional + # which is acceptable + + def test_generate_stl_thumbnail_nonexistent_file(self): + """Test thumbnail generation with nonexistent file.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "nonexistent.stl" + thumbnails_dir = Path(tmpdir) + + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + assert result is None + + def test_generate_stl_thumbnail_empty_file(self): + """Test thumbnail generation with empty file.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = Path(tmpdir) / "empty.stl" + thumbnails_dir = Path(tmpdir) + + # Create empty file + stl_path.write_bytes(b"") + + result = generate_stl_thumbnail(stl_path, thumbnails_dir) + assert result is None + + +class TestStlThumbnailConstants: + """Tests for STL thumbnail service constants.""" + + def test_bambu_green_color(self): + """Test that Bambu green color is defined.""" + from backend.app.services.stl_thumbnail import BAMBU_GREEN + + assert BAMBU_GREEN == "#00AE42" + + def test_background_color(self): + """Test that background color is defined.""" + from backend.app.services.stl_thumbnail import BACKGROUND_COLOR + + assert BACKGROUND_COLOR == "#1a1a1a" + + def test_max_vertices_threshold(self): + """Test that max vertices threshold is defined.""" + from backend.app.services.stl_thumbnail import MAX_VERTICES + + assert MAX_VERTICES == 100000 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 94e403982..e627463d9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3029,11 +3029,17 @@ export const api = { return request(`/library/files?${params}`); }, getLibraryFile: (id: number) => request(`/library/files/${id}`), - uploadLibraryFile: async (file: File, folderId?: number | null): Promise => { + uploadLibraryFile: async ( + file: File, + folderId?: number | null, + generateStlThumbnails: boolean = true + ): Promise => { const formData = new FormData(); formData.append('file', file); - const params = folderId ? `?folder_id=${folderId}` : ''; - const response = await fetch(`${API_BASE}/library/files${params}`, { + const params = new URLSearchParams(); + if (folderId) params.set('folder_id', String(folderId)); + params.set('generate_stl_thumbnails', String(generateStlThumbnails)); + const response = await fetch(`${API_BASE}/library/files?${params}`, { method: 'POST', body: formData, }); @@ -3047,7 +3053,8 @@ export const api = { file: File, folderId?: number | null, preserveStructure: boolean = true, - createFolderFromZip: boolean = false + createFolderFromZip: boolean = false, + generateStlThumbnails: boolean = true ): Promise => { const formData = new FormData(); formData.append('file', file); @@ -3055,6 +3062,7 @@ export const api = { if (folderId) params.set('folder_id', String(folderId)); params.set('preserve_structure', String(preserveStructure)); params.set('create_folder_from_zip', String(createFolderFromZip)); + params.set('generate_stl_thumbnails', String(generateStlThumbnails)); const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, { method: 'POST', body: formData, @@ -3088,6 +3096,15 @@ export const api = { body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }), }), getLibraryStats: () => request('/library/stats'), + batchGenerateStlThumbnails: (options: { + file_ids?: number[]; + folder_id?: number; + all_missing?: boolean; + }) => + request('/library/generate-stl-thumbnails', { + method: 'POST', + body: JSON.stringify(options), + }), addLibraryFilesToQueue: (fileIds: number[]) => request('/library/files/add-to-queue', { method: 'POST', @@ -3412,6 +3429,21 @@ export interface ZipExtractResponse { errors: ZipExtractError[]; } +// STL Thumbnail Generation types +export interface BatchThumbnailResult { + file_id: number; + filename: string; + success: boolean; + error?: string | null; +} + +export interface BatchThumbnailResponse { + processed: number; + succeeded: number; + failed: number; + results: BatchThumbnailResult[]; +} + // Library Queue types export interface AddToQueueResult { file_id: number; diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index dd7fe25d0..ba9834856 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -35,6 +35,7 @@ import { Printer, Pencil, Play, + Image, } from 'lucide-react'; import { api } from '../api/client'; import type { @@ -427,6 +428,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) const [isUploading, setIsUploading] = useState(false); const [preserveZipStructure, setPreserveZipStructure] = useState(true); const [createFolderFromZip, setCreateFolderFromZip] = useState(false); + const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true); const fileInputRef = useRef(null); const handleDragOver = (e: DragEvent) => { @@ -466,6 +468,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) }; const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending'); + const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending'); const handleUpload = async () => { if (files.length === 0) return; @@ -482,7 +485,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) try { if (files[i].isZip) { // Extract ZIP file - const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip); + const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails); setFiles((prev) => prev.map((f, idx) => idx === i @@ -497,7 +500,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) ); } else { // Regular file upload - await api.uploadLibraryFile(files[i].file, folderId); + await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails); setFiles((prev) => prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f)) ); @@ -596,6 +599,32 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) )} + {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */} + {(hasStlFiles || hasZipFiles) && ( +
+
+ +
+

STL thumbnail generation

+

+ {hasZipFiles && !hasStlFiles + ? 'ZIP files may contain STL files. Thumbnails can be generated during extraction.' + : 'Thumbnails can be generated for STL files. Large models may take longer to process.'} +

+ +
+
+
+ )} + {/* File List */} {files.length > 0 && (
@@ -832,9 +861,11 @@ interface FileCardProps { onAddToQueue?: (id: number) => void; onPrint?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; + onGenerateThumbnail?: (file: LibraryFileListItem) => void; + thumbnailVersion?: number; } -function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) { +function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) { const [showActions, setShowActions] = useState(false); return ( @@ -850,7 +881,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
{file.thumbnail_path ? ( {file.filename} @@ -935,6 +966,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue Rename )} + {onGenerateThumbnail && file.file_type === 'stl' && ( + + )}
+
@@ -1745,7 +1847,7 @@ export function FileManagerPage() {
{file.thumbnail_path ? ( @@ -1760,7 +1862,7 @@ export function FileManagerPage() {
{file.filename} @@ -1822,6 +1924,16 @@ export function FileManagerPage() { > + {file.file_type === 'stl' && ( + + )}