Explorar el Código

Merge pull request #196 from MisterBeardy/feature/stl_thumbnail

Feature/stl thumbnail
MartinNYHC hace 3 meses
padre
commit
a1064d077c

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 
 ### New Features
+- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
+  - Checkbox option when uploading STL files to generate thumbnails automatically
+  - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
+  - Individual file thumbnail generation via context menu (three-dot menu)
+  - Works with ZIP extraction (generates thumbnails for all STL files in archive)
+  - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
+  - Thumbnails auto-refresh in UI after generation
+  - Graceful handling of complex/invalid STL files
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Useful for users who prefer to manage firmware manually or have network restrictions

+ 2 - 1
README.md

@@ -87,7 +87,8 @@
 - Auto power-off after cooldown
 
 ### 📁 File Manager (Library)
-- Upload and organize sliced files (3MF, gcode)
+- Upload and organize sliced files (3MF, gcode, STL)
+- **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - ZIP file extraction with folder structure preservation
 - Option to create folder from ZIP filename
 - Folder structure with drag-and-drop

+ 129 - 0
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
 

+ 29 - 0
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]

+ 140 - 0
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

+ 236 - 0
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

+ 204 - 0
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

+ 196 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -476,4 +476,200 @@ describe('FileManagerPage', () => {
       });
     });
   });
+
+  describe('STL thumbnail generation', () => {
+    it('shows Generate Thumbnails button', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+    });
+
+    it('Generate Thumbnails button has correct title', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        const button = screen.getByTitle('Generate thumbnails for STL files missing them');
+        expect(button).toBeInTheDocument();
+      });
+    });
+
+    it('can click Generate Thumbnails button', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.post('/api/v1/library/generate-stl-thumbnails', () => {
+          return HttpResponse.json({
+            processed: 1,
+            succeeded: 1,
+            failed: 0,
+            results: [{ file_id: 2, success: true }],
+          });
+        })
+      );
+
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+
+      const button = screen.getByText('Generate Thumbnails');
+      await user.click(button);
+
+      // Button should work without error
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL file without thumbnail in file list', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        // bracket.stl has no thumbnail_path
+        expect(screen.getByText('bracket.stl')).toBeInTheDocument();
+        expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  describe('upload modal STL options', () => {
+    it('opens upload modal', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when STL file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Create a mock STL file
+      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+
+      // Get the hidden file input
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput).toBeInTheDocument();
+
+      // Simulate file selection
+      await user.upload(fileInput, stlFile);
+
+      // STL thumbnail option should appear
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+    });
+
+    it('STL thumbnail checkbox is checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Add an STL file
+      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+
+      // Checkbox should be checked by default
+      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
+      expect(checkbox).toBeChecked();
+    });
+
+    it('can toggle STL thumbnail checkbox', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Add an STL file
+      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+
+      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
+      expect(checkbox).toBeChecked();
+
+      // Toggle off
+      await user.click(checkbox);
+      expect(checkbox).not.toBeChecked();
+
+      // Toggle back on
+      await user.click(checkbox);
+      expect(checkbox).toBeChecked();
+    });
+
+    it('shows STL thumbnail option for ZIP files', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Create a mock ZIP file
+      const zipFile = new File(['PK'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      // STL thumbnail option should appear for ZIP files (may contain STLs)
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/ZIP files may contain STL files/)).toBeInTheDocument();
+      });
+    });
+  });
 });

+ 36 - 4
frontend/src/api/client.ts

@@ -3029,11 +3029,17 @@ export const api = {
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
-  uploadLibraryFile: async (file: File, folderId?: number | null): Promise<LibraryFileUploadResponse> => {
+  uploadLibraryFile: async (
+    file: File,
+    folderId?: number | null,
+    generateStlThumbnails: boolean = true
+  ): Promise<LibraryFileUploadResponse> => {
     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<ZipExtractResponse> => {
     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<LibraryStats>('/library/stats'),
+  batchGenerateStlThumbnails: (options: {
+    file_ids?: number[];
+    folder_id?: number;
+    all_missing?: boolean;
+  }) =>
+    request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
+      method: 'POST',
+      body: JSON.stringify(options),
+    }),
   addLibraryFilesToQueue: (fileIds: number[]) =>
     request<AddToQueueResponse>('/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;

+ 118 - 6
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<HTMLInputElement>(null);
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -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)
             </div>
           )}
 
+          {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
+          {(hasStlFiles || hasZipFiles) && (
+            <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-bambu-green font-medium">STL thumbnail generation</p>
+                  <p className="text-xs text-bambu-green/70 mt-1">
+                    {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.'}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={generateStlThumbnails}
+                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">Generate thumbnails for STL files</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* File List */}
           {files.length > 0 && (
             <div className="max-h-48 overflow-y-auto space-y-2">
@@ -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
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
         {file.thumbnail_path ? (
           <img
-            src={api.getLibraryFileThumbnailUrl(file.id)}
+            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
             alt={file.filename}
             className="w-full h-full object-cover"
           />
@@ -935,6 +966,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
                   Rename
                 </button>
               )}
+              {onGenerateThumbnail && file.file_type === 'stl' && (
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onGenerateThumbnail(file); setShowActions(false); }}
+                >
+                  <Image className="w-3.5 h-3.5" />
+                  Generate Thumbnail
+                </button>
+              )}
               <button
                 className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
                 onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -979,6 +1019,7 @@ export function FileManagerPage() {
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
+  const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
@@ -1272,6 +1313,52 @@ export function FileManagerPage() {
     },
   });
 
+  const batchThumbnailMutation = useMutation({
+    mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Update thumbnail versions for cache busting
+      if (result.succeeded > 0) {
+        const now = Date.now();
+        const newVersions: Record<number, number> = {};
+        result.results.forEach((r) => {
+          if (r.success) {
+            newVersions[r.file_id] = now;
+          }
+        });
+        setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
+      }
+      if (result.succeeded > 0 && result.failed === 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}`, 'success');
+      } else if (result.succeeded > 0 && result.failed > 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}, ${result.failed} failed`, 'success');
+      } else if (result.processed === 0) {
+        showToast('No STL files missing thumbnails', 'info');
+      } else {
+        showToast(`Failed to generate thumbnails: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const singleThumbnailMutation = useMutation({
+    mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Update thumbnail version for cache busting
+      if (result.succeeded > 0) {
+        const fileId = result.results[0]?.file_id;
+        if (fileId) {
+          setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
+        }
+        showToast('Thumbnail generated', 'success');
+      } else {
+        showToast(`Failed to generate thumbnail: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   // Helper to check if a file is sliced (printable)
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
@@ -1369,6 +1456,19 @@ export function FileManagerPage() {
               <List className="w-4 h-4" />
             </button>
           </div>
+          <Button
+            variant="secondary"
+            onClick={() => batchThumbnailMutation.mutate()}
+            disabled={batchThumbnailMutation.isPending}
+            title="Generate thumbnails for STL files missing them"
+          >
+            {batchThumbnailMutation.isPending ? (
+              <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+            ) : (
+              <Image className="w-4 h-4 mr-2" />
+            )}
+            Generate Thumbnails
+          </Button>
           <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
             <FolderPlus className="w-4 h-4 mr-2" />
             New Folder
@@ -1738,6 +1838,8 @@ export function FileManagerPage() {
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onPrint={setPrintFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
+                    onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
+                    thumbnailVersion={thumbnailVersions[file.id]}
                   />
                 ))}
               </div>
@@ -1777,7 +1879,7 @@ export function FileManagerPage() {
                         <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
                           {file.thumbnail_path ? (
                             <img
-                              src={api.getLibraryFileThumbnailUrl(file.id)}
+                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                               alt=""
                               className="w-full h-full object-cover"
                             />
@@ -1792,7 +1894,7 @@ export function FileManagerPage() {
                           <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
                             <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                               <img
-                                src={api.getLibraryFileThumbnailUrl(file.id)}
+                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                                 alt={file.filename}
                                 className="w-full h-full object-contain"
                               />
@@ -1854,6 +1956,16 @@ export function FileManagerPage() {
                       >
                         <Pencil className="w-4 h-4" />
                       </button>
+                      {file.file_type === 'stl' && (
+                        <button
+                          onClick={() => singleThumbnailMutation.mutate(file.id)}
+                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
+                          title="Generate Thumbnail"
+                          disabled={singleThumbnailMutation.isPending}
+                        >
+                          <Image className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                         onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
                         className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"

+ 5 - 0
requirements.txt

@@ -34,6 +34,11 @@ aiofiles>=23.0.0
 # QR Code generation
 qrcode[pil]>=7.4.0
 
+# STL Thumbnail Generation
+trimesh>=4.0.0
+matplotlib>=3.8.0
+fast-simplification>=0.1.0
+
 # System monitoring
 psutil>=6.0.0