Merge pull request #196 from MisterBeardy/feature/stl_thumbnail

Feature/stl thumbnail
This commit is contained in:
MartinNYHC
2026-01-30 16:46:43 +01:00
committed by GitHub
11 changed files with 1103 additions and 11 deletions
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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