Jelajahi Sumber

Add STL thumbnail generation support

- Add trimesh and matplotlib dependencies for software-based 3D rendering
- Create stl_thumbnail service with generate_stl_thumbnail() function
- Handle mesh simplification for large files (>100k vertices)
- Auto-generate thumbnails during STL file upload and ZIP extraction
- Add POST /library/files/{id}/regenerate-thumbnail endpoint
- Add POST /library/generate-stl-thumbnails batch endpoint
- Add "Generate Thumbnails" button to file manager toolbar
- Add "Regenerate Thumbnail" option to file context menu
- Add unit and integration tests for new functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
MisterBeardy 3 bulan lalu
induk
melakukan
375d5238d0

+ 1 - 1
.gitignore

@@ -55,4 +55,4 @@ bambutrack.log.*
 firmware/
 firmware/
 
 
 # Node modules
 # Node modules
-node_modules/
+node_modules/

+ 220 - 0
backend/app/api/routes/library.py

@@ -25,6 +25,9 @@ from backend.app.schemas.library import (
     AddToQueueRequest,
     AddToQueueRequest,
     AddToQueueResponse,
     AddToQueueResponse,
     AddToQueueResult,
     AddToQueueResult,
+    BatchThumbnailRequest,
+    BatchThumbnailResponse,
+    BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteRequest,
     BulkDeleteResponse,
     BulkDeleteResponse,
     FileDuplicate,
     FileDuplicate,
@@ -708,6 +711,18 @@ async def upload_file(
             except Exception as e:
             except Exception as e:
                 logger.warning(f"Failed to extract gcode thumbnail: {e}")
                 logger.warning(f"Failed to extract gcode thumbnail: {e}")
 
 
+        elif ext == ".stl":
+            # Generate thumbnail from STL file
+            try:
+                from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+                thumb_filename = f"{uuid.uuid4().hex}.png"
+                thumb_path = thumbnails_dir / thumb_filename
+                if generate_stl_thumbnail(file_path, thumb_path):
+                    thumbnail_path = str(thumb_path)
+            except Exception as e:
+                logger.warning(f"Failed to generate STL thumbnail: {e}")
+
         elif ext.lower() in IMAGE_EXTENSIONS:
         elif ext.lower() in IMAGE_EXTENSIONS:
             # For image files, create a thumbnail from the image itself
             # For image files, create a thumbnail from the image itself
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
@@ -907,6 +922,17 @@ async def extract_zip_file(
                         except Exception as e:
                         except Exception as e:
                             logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}")
                             logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}")
 
 
+                    elif ext == ".stl":
+                        try:
+                            from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+                            thumb_filename = f"{uuid.uuid4().hex}.png"
+                            thumb_path = thumbnails_dir / thumb_filename
+                            if generate_stl_thumbnail(file_path, thumb_path):
+                                thumbnail_path = str(thumb_path)
+                        except Exception as e:
+                            logger.warning(f"Failed to generate STL thumbnail from ZIP: {e}")
+
                     elif ext.lower() in IMAGE_EXTENSIONS:
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
 
@@ -1888,3 +1914,197 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
         "disk_total_bytes": disk_total_bytes,
         "disk_total_bytes": disk_total_bytes,
         "disk_used_bytes": disk_used_bytes,
         "disk_used_bytes": disk_used_bytes,
     }
     }
+
+
+# ============ Thumbnail Generation Endpoints ============
+
+
+@router.post("/files/{file_id}/regenerate-thumbnail", response_model=FileResponseSchema)
+async def regenerate_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
+    """Regenerate thumbnail for a specific file.
+
+    Works for STL, 3MF, gcode, and image files.
+    """
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    if not file.file_path or not os.path.exists(file.file_path):
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    ext = os.path.splitext(file.filename)[1].lower()
+    thumbnails_dir = get_library_thumbnails_dir()
+    file_path = Path(file.file_path)
+
+    # Delete old thumbnail if exists
+    if file.thumbnail_path and os.path.exists(file.thumbnail_path):
+        try:
+            os.remove(file.thumbnail_path)
+        except Exception as e:
+            logger.warning(f"Failed to delete old thumbnail: {e}")
+
+    thumbnail_path = None
+
+    if ext == ".3mf":
+        try:
+            parser = ThreeMFParser(str(file_path))
+            raw_metadata = parser.parse()
+            thumbnail_data = raw_metadata.get("_thumbnail_data")
+            thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png")
+
+            if thumbnail_data:
+                thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
+                thumb_path = thumbnails_dir / thumb_filename
+                with open(thumb_path, "wb") as f:
+                    f.write(thumbnail_data)
+                thumbnail_path = str(thumb_path)
+        except Exception as e:
+            logger.warning(f"Failed to extract 3MF thumbnail: {e}")
+
+    elif ext == ".gcode":
+        try:
+            thumbnail_data = extract_gcode_thumbnail(file_path)
+            if thumbnail_data:
+                thumb_filename = f"{uuid.uuid4().hex}.png"
+                thumb_path = thumbnails_dir / thumb_filename
+                with open(thumb_path, "wb") as f:
+                    f.write(thumbnail_data)
+                thumbnail_path = str(thumb_path)
+        except Exception as e:
+            logger.warning(f"Failed to extract gcode thumbnail: {e}")
+
+    elif ext == ".stl":
+        try:
+            from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+            thumb_filename = f"{uuid.uuid4().hex}.png"
+            thumb_path = thumbnails_dir / thumb_filename
+            if generate_stl_thumbnail(file_path, thumb_path):
+                thumbnail_path = str(thumb_path)
+        except Exception as e:
+            logger.warning(f"Failed to generate STL thumbnail: {e}")
+
+    elif ext.lower() in IMAGE_EXTENSIONS:
+        thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
+
+    # Update database
+    file.thumbnail_path = thumbnail_path
+    await db.flush()
+    await db.refresh(file)
+
+    # Return full response
+    return await get_file(file_id, db)
+
+
+@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse)
+async def batch_generate_stl_thumbnails(
+    request: BatchThumbnailRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate thumbnails for existing STL files.
+
+    Can target specific files, a folder, or all STL files missing thumbnails.
+
+    Args:
+        request: Batch request specifying which files to process
+            - file_ids: List of specific file IDs to process
+            - folder_id: Process all STL files in this folder
+            - all_missing: Process all STL files without thumbnails
+    """
+    from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+    results: list[BatchThumbnailResult] = []
+    thumbnails_dir = get_library_thumbnails_dir()
+
+    # Build query based on request parameters
+    query = select(LibraryFile).where(LibraryFile.file_type == "stl")
+
+    if request.file_ids:
+        # Specific files requested
+        query = query.where(LibraryFile.id.in_(request.file_ids))
+    elif request.folder_id is not None:
+        # All STL files in a folder
+        query = query.where(LibraryFile.folder_id == request.folder_id)
+    elif request.all_missing:
+        # All STL files without thumbnails
+        query = query.where(LibraryFile.thumbnail_path.is_(None))
+    else:
+        # No valid filter specified
+        raise HTTPException(
+            status_code=400,
+            detail="Must specify file_ids, folder_id, or all_missing=true",
+        )
+
+    result = await db.execute(query)
+    files = result.scalars().all()
+
+    succeeded = 0
+    failed = 0
+
+    for file in files:
+        if not file.file_path or not os.path.exists(file.file_path):
+            results.append(
+                BatchThumbnailResult(
+                    file_id=file.id,
+                    filename=file.filename,
+                    success=False,
+                    error="File not found on disk",
+                )
+            )
+            failed += 1
+            continue
+
+        try:
+            # Delete old thumbnail if exists
+            if file.thumbnail_path and os.path.exists(file.thumbnail_path):
+                try:
+                    os.remove(file.thumbnail_path)
+                except Exception:
+                    pass
+
+            # Generate new thumbnail
+            thumb_filename = f"{uuid.uuid4().hex}.png"
+            thumb_path = thumbnails_dir / thumb_filename
+
+            if generate_stl_thumbnail(file.file_path, thumb_path):
+                file.thumbnail_path = str(thumb_path)
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=file.id,
+                        filename=file.filename,
+                        success=True,
+                    )
+                )
+                succeeded += 1
+            else:
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=file.id,
+                        filename=file.filename,
+                        success=False,
+                        error="Thumbnail generation failed",
+                    )
+                )
+                failed += 1
+
+        except Exception as e:
+            results.append(
+                BatchThumbnailResult(
+                    file_id=file.id,
+                    filename=file.filename,
+                    success=False,
+                    error=str(e),
+                )
+            )
+            failed += 1
+
+    await db.commit()
+
+    return BatchThumbnailResponse(
+        processed=len(results),
+        succeeded=succeeded,
+        failed=failed,
+        results=results,
+    )

+ 29 - 0
backend/app/schemas/library.py

@@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel):
     folders_created: int
     folders_created: int
     files: list[ZipExtractResult]
     files: list[ZipExtractResult]
     errors: list[ZipExtractError]
     errors: list[ZipExtractError]
+
+
+# ============ Batch Thumbnail Generation ============
+
+
+class BatchThumbnailRequest(BaseModel):
+    """Schema for batch STL thumbnail generation request."""
+
+    file_ids: list[int] | None = None  # Specific file IDs to process
+    folder_id: int | None = None  # Process all STL files in this folder
+    all_missing: bool = False  # Process all STL files without thumbnails
+
+
+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]

+ 227 - 0
backend/app/services/stl_thumbnail.py

@@ -0,0 +1,227 @@
+"""STL thumbnail generation service.
+
+Generates PNG thumbnails from STL files using trimesh and matplotlib.
+Supports both ASCII and binary STL formats, handles large meshes via simplification.
+"""
+
+import io
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Maximum vertices before simplification is applied
+MAX_VERTICES = 100000
+
+# Default thumbnail size
+DEFAULT_SIZE = (256, 256)
+
+
+def generate_stl_thumbnail(
+    stl_path: str | Path,
+    output_path: str | Path,
+    size: tuple[int, int] = DEFAULT_SIZE,
+) -> bool:
+    """Generate a PNG thumbnail from an STL file.
+
+    Args:
+        stl_path: Path to the input STL file
+        output_path: Path where the PNG thumbnail will be saved
+        size: Tuple of (width, height) for the output image
+
+    Returns:
+        True if thumbnail was generated successfully, False otherwise
+    """
+    try:
+        import matplotlib
+
+        matplotlib.use("Agg")  # Use non-interactive backend
+        import matplotlib.pyplot as plt
+        import numpy as np
+        import trimesh
+
+        # Load the STL file
+        mesh = trimesh.load(str(stl_path), file_type="stl")
+
+        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 False
+
+        # Simplify if mesh is too large
+        if len(mesh.vertices) > MAX_VERTICES:
+            logger.info(f"Simplifying mesh with {len(mesh.vertices)} vertices to ~{MAX_VERTICES}")
+            # Calculate target face count based on vertex ratio
+            target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices)))
+            try:
+                mesh = mesh.simplify_quadric_decimation(target_faces)
+            except Exception as e:
+                logger.warning(f"Mesh simplification failed, using original: {e}")
+
+        # Create figure with transparent background
+        fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
+        ax = fig.add_subplot(111, projection="3d")
+
+        # Get mesh vertices and faces
+        vertices = mesh.vertices
+        faces = mesh.faces
+
+        # Center the mesh
+        center = vertices.mean(axis=0)
+        vertices = vertices - center
+
+        # Scale to fit in view
+        max_extent = np.abs(vertices).max()
+        if max_extent > 0:
+            vertices = vertices / max_extent
+
+        # Create triangles for plotting
+        triangles = vertices[faces]
+
+        # Plot the mesh with a nice color scheme
+        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+
+        collection = Poly3DCollection(
+            triangles,
+            alpha=1.0,
+            facecolor="#00AE42",  # Bambu green
+            edgecolor="#008833",  # Darker green for edges
+            linewidths=0.1,
+        )
+        ax.add_collection3d(collection)
+
+        # Set axis limits
+        ax.set_xlim(-1, 1)
+        ax.set_ylim(-1, 1)
+        ax.set_zlim(-1, 1)
+
+        # Set viewing angle (isometric-ish)
+        ax.view_init(elev=25, azim=45)
+
+        # Remove axes for cleaner look
+        ax.set_axis_off()
+
+        # Set background color
+        ax.set_facecolor("#1a1a1a")
+        fig.patch.set_facecolor("#1a1a1a")
+
+        # Tight layout to minimize whitespace
+        plt.tight_layout(pad=0)
+
+        # Save the figure
+        plt.savefig(
+            str(output_path),
+            format="png",
+            dpi=100,
+            facecolor="#1a1a1a",
+            bbox_inches="tight",
+            pad_inches=0.05,
+        )
+        plt.close(fig)
+
+        logger.info(f"Generated STL thumbnail: {output_path}")
+        return True
+
+    except ImportError as e:
+        logger.error(f"Missing dependency for STL thumbnails: {e}")
+        return False
+    except Exception as e:
+        logger.error(f"Failed to generate STL thumbnail for {stl_path}: {e}")
+        return False
+
+
+def generate_stl_thumbnail_bytes(
+    stl_data: bytes,
+    size: tuple[int, int] = DEFAULT_SIZE,
+) -> bytes | None:
+    """Generate a PNG thumbnail from STL data in memory.
+
+    Args:
+        stl_data: Raw STL file data (binary or ASCII)
+        size: Tuple of (width, height) for the output image
+
+    Returns:
+        PNG image data as bytes, or None on failure
+    """
+    try:
+        import matplotlib
+
+        matplotlib.use("Agg")
+        import matplotlib.pyplot as plt
+        import numpy as np
+        import trimesh
+
+        # Load from bytes
+        mesh = trimesh.load(
+            file_obj=io.BytesIO(stl_data),
+            file_type="stl",
+        )
+
+        if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
+            logger.warning("Failed to load STL from bytes or empty mesh")
+            return None
+
+        # Simplify if mesh is too large
+        if len(mesh.vertices) > MAX_VERTICES:
+            target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices)))
+            try:
+                mesh = mesh.simplify_quadric_decimation(target_faces)
+            except Exception:
+                pass  # Use original if simplification fails
+
+        # Create figure
+        fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
+        ax = fig.add_subplot(111, projection="3d")
+
+        vertices = mesh.vertices
+        faces = mesh.faces
+
+        # Center and scale
+        center = vertices.mean(axis=0)
+        vertices = vertices - center
+        max_extent = np.abs(vertices).max()
+        if max_extent > 0:
+            vertices = vertices / max_extent
+
+        triangles = vertices[faces]
+
+        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+
+        collection = Poly3DCollection(
+            triangles,
+            alpha=1.0,
+            facecolor="#00AE42",
+            edgecolor="#008833",
+            linewidths=0.1,
+        )
+        ax.add_collection3d(collection)
+
+        ax.set_xlim(-1, 1)
+        ax.set_ylim(-1, 1)
+        ax.set_zlim(-1, 1)
+        ax.view_init(elev=25, azim=45)
+        ax.set_axis_off()
+        ax.set_facecolor("#1a1a1a")
+        fig.patch.set_facecolor("#1a1a1a")
+        plt.tight_layout(pad=0)
+
+        # Save to bytes buffer
+        buf = io.BytesIO()
+        plt.savefig(
+            buf,
+            format="png",
+            dpi=100,
+            facecolor="#1a1a1a",
+            bbox_inches="tight",
+            pad_inches=0.05,
+        )
+        plt.close(fig)
+
+        buf.seek(0)
+        return buf.read()
+
+    except ImportError as e:
+        logger.error(f"Missing dependency for STL thumbnails: {e}")
+        return None
+    except Exception as e:
+        logger.error(f"Failed to generate STL thumbnail from bytes: {e}")
+        return None

+ 123 - 0
backend/tests/integration/test_library_api.py

@@ -445,3 +445,126 @@ class TestLibraryZipExtractAPI:
         result = response.json()
         result = response.json()
         assert result["extracted"] == 1  # Only real_file.txt
         assert result["extracted"] == 1  # Only real_file.txt
         assert result["files"][0]["filename"] == "real_file.txt"
         assert result["files"][0]["filename"] == "real_file.txt"
+
+
+class TestSTLThumbnailAPI:
+    """Integration tests for STL thumbnail generation endpoints."""
+
+    @pytest.fixture
+    async def stl_file_factory(self, db_session):
+        """Factory to create test STL files."""
+        _counter = [0]
+
+        async def _create_stl_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_stl_file
+
+    @pytest.fixture
+    async def folder_factory(self, db_session):
+        """Factory to create test folders."""
+        _counter = [0]
+
+        async def _create_folder(**kwargs):
+            from backend.app.models.library import LibraryFolder
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {"name": f"Test Folder {counter}"}
+            defaults.update(kwargs)
+
+            folder = LibraryFolder(**defaults)
+            db_session.add(folder)
+            await db_session.commit()
+            await db_session.refresh(folder)
+            return folder
+
+        return _create_folder
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regenerate_thumbnail_file_not_found(self, async_client: AsyncClient, db_session):
+        """Verify 404 for non-existent file."""
+        response = await async_client.post("/api/v1/library/files/9999/regenerate-thumbnail")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_regenerate_thumbnail_file_missing_on_disk(
+        self, async_client: AsyncClient, stl_file_factory, db_session
+    ):
+        """Verify error when file exists in DB but not on disk."""
+        stl_file = await stl_file_factory()
+        response = await async_client.post(f"/api/v1/library/files/{stl_file.id}/regenerate-thumbnail")
+        assert response.status_code == 404
+        assert "not found on disk" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_no_filter(self, async_client: AsyncClient, db_session):
+        """Verify error when no filter is specified."""
+        data = {}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 400
+        assert "Must specify" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_all_missing_empty(self, async_client: AsyncClient, db_session):
+        """Verify batch generation with no matching 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
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_specific_files(self, async_client: AsyncClient, stl_file_factory, db_session):
+        """Verify batch generation with specific file IDs."""
+        stl_file = await stl_file_factory()
+        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
+        # Will fail because file doesn't exist on disk
+        assert result["failed"] == 1
+        assert result["results"][0]["error"] == "File not found on disk"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_by_folder(
+        self, async_client: AsyncClient, stl_file_factory, folder_factory, db_session
+    ):
+        """Verify batch generation by folder ID."""
+        folder = await folder_factory()
+        await stl_file_factory(folder_id=folder.id)
+        await stl_file_factory(folder_id=folder.id)
+        await stl_file_factory()  # File in root, should not be processed
+
+        data = {"folder_id": folder.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"] == 2  # Only files in the folder

+ 326 - 0
backend/tests/unit/services/test_stl_thumbnail.py

@@ -0,0 +1,326 @@
+"""Unit tests for the STL thumbnail service."""
+
+import os
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+class TestSTLThumbnailService:
+    """Tests for STL thumbnail generation."""
+
+    def test_generate_thumbnail_ascii_stl(self):
+        """Test generating thumbnail from ASCII STL file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        # Create a simple ASCII STL cube
+        ascii_stl = """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 1 1
+      vertex 1 1 0
+    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 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
+"""
+
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
+            stl_file.write(ascii_stl)
+            stl_path = stl_file.name
+
+        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
+            png_path = png_file.name
+
+        try:
+            result = generate_stl_thumbnail(stl_path, png_path)
+            assert result is True
+            assert os.path.exists(png_path)
+            # Check it's a valid PNG (starts with PNG magic bytes)
+            with open(png_path, "rb") as f:
+                header = f.read(8)
+                assert header[:4] == b"\x89PNG"
+        finally:
+            os.unlink(stl_path)
+            if os.path.exists(png_path):
+                os.unlink(png_path)
+
+    def test_generate_thumbnail_binary_stl(self):
+        """Test generating thumbnail from binary STL file."""
+        import struct
+
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        # Create a simple binary STL cube (minimal version)
+        # Binary STL format:
+        # - 80 bytes header
+        # - 4 bytes number of triangles (uint32)
+        # - For each triangle:
+        #   - 12 bytes normal (3 floats)
+        #   - 36 bytes vertices (9 floats, 3 vertices x 3 coords)
+        #   - 2 bytes attribute byte count (usually 0)
+
+        header = b"\x00" * 80  # Empty header
+        num_triangles = 12  # A cube has 12 triangles (2 per face)
+
+        # Define cube vertices
+        vertices = [
+            # Bottom face (z=0)
+            ((0, 0, -1), [(0, 0, 0), (1, 0, 0), (1, 1, 0)]),
+            ((0, 0, -1), [(0, 0, 0), (1, 1, 0), (0, 1, 0)]),
+            # Top face (z=1)
+            ((0, 0, 1), [(0, 0, 1), (1, 1, 1), (1, 0, 1)]),
+            ((0, 0, 1), [(0, 0, 1), (0, 1, 1), (1, 1, 1)]),
+            # Front face (y=0)
+            ((0, -1, 0), [(0, 0, 0), (1, 0, 1), (1, 0, 0)]),
+            ((0, -1, 0), [(0, 0, 0), (0, 0, 1), (1, 0, 1)]),
+            # Back face (y=1)
+            ((0, 1, 0), [(0, 1, 0), (1, 1, 0), (1, 1, 1)]),
+            ((0, 1, 0), [(0, 1, 0), (1, 1, 1), (0, 1, 1)]),
+            # Left face (x=0)
+            ((-1, 0, 0), [(0, 0, 0), (0, 1, 0), (0, 1, 1)]),
+            ((-1, 0, 0), [(0, 0, 0), (0, 1, 1), (0, 0, 1)]),
+            # Right face (x=1)
+            ((1, 0, 0), [(1, 0, 0), (1, 1, 1), (1, 1, 0)]),
+            ((1, 0, 0), [(1, 0, 0), (1, 0, 1), (1, 1, 1)]),
+        ]
+
+        binary_data = header + struct.pack("<I", num_triangles)
+        for normal, verts in vertices:
+            binary_data += struct.pack("<fff", *normal)  # Normal
+            for v in verts:
+                binary_data += struct.pack("<fff", *v)  # Vertex
+            binary_data += struct.pack("<H", 0)  # Attribute byte count
+
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="wb") as stl_file:
+            stl_file.write(binary_data)
+            stl_path = stl_file.name
+
+        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
+            png_path = png_file.name
+
+        try:
+            result = generate_stl_thumbnail(stl_path, png_path)
+            assert result is True
+            assert os.path.exists(png_path)
+        finally:
+            os.unlink(stl_path)
+            if os.path.exists(png_path):
+                os.unlink(png_path)
+
+    def test_generate_thumbnail_invalid_file(self):
+        """Test handling of invalid STL file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        # Create invalid STL content
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
+            stl_file.write("This is not valid STL content")
+            stl_path = stl_file.name
+
+        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
+            png_path = png_file.name
+
+        try:
+            result = generate_stl_thumbnail(stl_path, png_path)
+            # Should return False for invalid file
+            assert result is False
+        finally:
+            os.unlink(stl_path)
+            if os.path.exists(png_path):
+                os.unlink(png_path)
+
+    def test_generate_thumbnail_nonexistent_file(self):
+        """Test handling of nonexistent file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        result = generate_stl_thumbnail("/nonexistent/path/file.stl", "/tmp/output.png")
+        assert result is False
+
+    def test_generate_thumbnail_bytes_ascii(self):
+        """Test generating thumbnail from ASCII STL bytes."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail_bytes
+
+        # Simple ASCII STL cube (same as above)
+        ascii_stl = b"""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 0 1
+      vertex 1 1 1
+    endloop
+  endfacet
+endsolid cube
+"""
+
+        result = generate_stl_thumbnail_bytes(ascii_stl)
+        assert result is not None
+        # Check it's a valid PNG
+        assert result[:4] == b"\x89PNG"
+
+    def test_generate_thumbnail_bytes_invalid(self):
+        """Test handling of invalid STL bytes."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail_bytes
+
+        result = generate_stl_thumbnail_bytes(b"not valid stl data")
+        assert result is None
+
+    def test_generate_thumbnail_custom_size(self):
+        """Test generating thumbnail with custom size."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        ascii_stl = """solid cube
+  facet normal 0 0 -1
+    outer loop
+      vertex 0 0 0
+      vertex 1 0 0
+      vertex 1 1 0
+    endloop
+  endfacet
+endsolid cube
+"""
+
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
+            stl_file.write(ascii_stl)
+            stl_path = stl_file.name
+
+        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
+            png_path = png_file.name
+
+        try:
+            result = generate_stl_thumbnail(stl_path, png_path, size=(128, 128))
+            assert result is True
+            assert os.path.exists(png_path)
+        finally:
+            os.unlink(stl_path)
+            if os.path.exists(png_path):
+                os.unlink(png_path)
+
+
+class TestMeshSimplification:
+    """Tests for mesh simplification with large files."""
+
+    def test_simplification_threshold(self):
+        """Test that MAX_VERTICES constant is defined."""
+        from backend.app.services.stl_thumbnail import MAX_VERTICES
+
+        assert MAX_VERTICES == 100000
+
+    def test_large_mesh_handling(self):
+        """Test that large meshes are simplified (mocked)."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        # Create a mock mesh with many vertices
+        with patch("trimesh.load") as mock_load:
+            mock_mesh = MagicMock()
+            mock_mesh.vertices = MagicMock()
+            mock_mesh.vertices.__len__ = MagicMock(return_value=200000)  # Over threshold
+            mock_mesh.faces = MagicMock()
+            mock_mesh.faces.__len__ = MagicMock(return_value=400000)
+            mock_simplified = MagicMock()
+            mock_simplified.vertices = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
+            mock_simplified.faces = [[0, 1, 2]]
+            mock_mesh.simplify_quadric_decimation.return_value = mock_simplified
+            mock_load.return_value = mock_mesh
+
+            with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as stl_file:
+                stl_file.write(b"dummy")
+                stl_path = stl_file.name
+
+            with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
+                png_path = png_file.name
+
+            try:
+                # This will fail but we can verify simplification was called
+                generate_stl_thumbnail(stl_path, png_path)
+                # The mock should have been called for simplification
+                mock_mesh.simplify_quadric_decimation.assert_called()
+            finally:
+                os.unlink(stl_path)
+                if os.path.exists(png_path):
+                    os.unlink(png_path)

+ 30 - 0
frontend/src/api/client.ts

@@ -2821,6 +2821,21 @@ export const api = {
         used_meters: number;
         used_meters: number;
       }>;
       }>;
     }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
     }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
+
+  // STL Thumbnail Generation
+  regenerateFileThumbnail: (fileId: number) =>
+    request<LibraryFile>(`/library/files/${fileId}/regenerate-thumbnail`, {
+      method: 'POST',
+    }),
+  batchGenerateStlThumbnails: (options: {
+    file_ids?: number[];
+    folder_id?: number;
+    all_missing?: boolean;
+  }) =>
+    request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
+      method: 'POST',
+      body: JSON.stringify(options),
+    }),
 };
 };
 
 
 // AMS History types
 // AMS History types
@@ -3045,6 +3060,21 @@ export interface ZipExtractResponse {
   errors: ZipExtractError[];
   errors: ZipExtractError[];
 }
 }
 
 
+// Batch 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
 // Library Queue types
 export interface AddToQueueResult {
 export interface AddToQueueResult {
   file_id: number;
   file_id: number;

+ 60 - 1
frontend/src/pages/FileManagerPage.tsx

@@ -35,6 +35,7 @@ import {
   Printer,
   Printer,
   Pencil,
   Pencil,
   Play,
   Play,
+  ImageIcon,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -821,9 +822,10 @@ interface FileCardProps {
   onAddToQueue?: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
+  onRegenerateThumbnail?: (id: number) => void;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onRegenerateThumbnail }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -924,6 +926,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
                   Rename
                   Rename
                 </button>
                 </button>
               )}
               )}
+              {onRegenerateThumbnail && ['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && (
+                <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={() => { onRegenerateThumbnail(file.id); setShowActions(false); }}
+                >
+                  <ImageIcon className="w-3.5 h-3.5" />
+                  Regenerate Thumbnail
+                </button>
+              )}
               <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"
                 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); }}
                 onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -1212,6 +1223,30 @@ export function FileManagerPage() {
     },
     },
   });
   });
 
 
+  const regenerateThumbnailMutation = useMutation({
+    mutationFn: (fileId: number) => api.regenerateFileThumbnail(fileId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      showToast('Thumbnail regenerated', 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const batchGenerateThumbnailsMutation = useMutation({
+    mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      if (result.succeeded > 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`, 'success');
+      } else if (result.processed === 0) {
+        showToast('No STL files missing thumbnails', 'success');
+      } else {
+        showToast(`Failed to generate thumbnails: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   // Helper to check if a file is sliced (printable)
   // Helper to check if a file is sliced (printable)
   const isSlicedFile = useCallback((filename: string) => {
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
     const lower = filename.toLowerCase();
@@ -1317,6 +1352,19 @@ export function FileManagerPage() {
             <Upload className="w-4 h-4 mr-2" />
             <Upload className="w-4 h-4 mr-2" />
             Upload
             Upload
           </Button>
           </Button>
+          <Button
+            variant="secondary"
+            onClick={() => batchGenerateThumbnailsMutation.mutate()}
+            disabled={batchGenerateThumbnailsMutation.isPending}
+            title="Generate thumbnails for STL files that don't have one"
+          >
+            {batchGenerateThumbnailsMutation.isPending ? (
+              <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+            ) : (
+              <ImageIcon className="w-4 h-4 mr-2" />
+            )}
+            {batchGenerateThumbnailsMutation.isPending ? 'Generating...' : 'Generate Thumbnails'}
+          </Button>
         </div>
         </div>
       </div>
       </div>
 
 
@@ -1604,6 +1652,7 @@ export function FileManagerPage() {
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
+                    onRegenerateThumbnail={(id) => regenerateThumbnailMutation.mutate(id)}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1720,6 +1769,16 @@ export function FileManagerPage() {
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
+                      {['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && (
+                        <button
+                          onClick={() => regenerateThumbnailMutation.mutate(file.id)}
+                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                          title="Regenerate Thumbnail"
+                          disabled={regenerateThumbnailMutation.isPending}
+                        >
+                          <ImageIcon className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                       <button
                         onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
                         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"
                         className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"

+ 4 - 0
requirements.txt

@@ -37,6 +37,10 @@ qrcode[pil]>=7.4.0
 # System monitoring
 # System monitoring
 psutil>=6.0.0
 psutil>=6.0.0
 
 
+# STL thumbnail generation
+trimesh>=4.0.0
+matplotlib>=3.8.0
+
 # Authentication
 # Authentication
 PyJWT>=2.8.0
 PyJWT>=2.8.0
 passlib[bcrypt]>=1.7.4
 passlib[bcrypt]>=1.7.4