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