mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Merge pull request #196 from MisterBeardy/feature/stl_thumbnail
Feature/stl thumbnail
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user