diff --git a/.gitignore b/.gitignore index f5eb2dfd3..470b31f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,4 @@ bambutrack.log.* firmware/ # Node modules -node_modules/ \ No newline at end of file +node_modules/ diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index a07e835c8..bbbaa450f 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, @@ -708,6 +711,18 @@ async def upload_file( except Exception as e: logger.warning(f"Failed to extract gcode thumbnail: {e}") + elif ext == ".stl": + # Generate thumbnail from STL file + try: + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + if generate_stl_thumbnail(file_path, thumb_path): + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail: {e}") + elif ext.lower() in IMAGE_EXTENSIONS: # For image files, create a thumbnail from the image itself thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) @@ -907,6 +922,17 @@ async def extract_zip_file( except Exception as e: logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}") + elif ext == ".stl": + try: + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + if generate_stl_thumbnail(file_path, thumb_path): + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail from ZIP: {e}") + elif ext.lower() in IMAGE_EXTENSIONS: thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) @@ -1888,3 +1914,197 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): "disk_total_bytes": disk_total_bytes, "disk_used_bytes": disk_used_bytes, } + + +# ============ Thumbnail Generation Endpoints ============ + + +@router.post("/files/{file_id}/regenerate-thumbnail", response_model=FileResponseSchema) +async def regenerate_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): + """Regenerate thumbnail for a specific file. + + Works for STL, 3MF, gcode, and image files. + """ + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if not file.file_path or not os.path.exists(file.file_path): + raise HTTPException(status_code=404, detail="File not found on disk") + + ext = os.path.splitext(file.filename)[1].lower() + thumbnails_dir = get_library_thumbnails_dir() + file_path = Path(file.file_path) + + # Delete old thumbnail if exists + if file.thumbnail_path and os.path.exists(file.thumbnail_path): + try: + os.remove(file.thumbnail_path) + except Exception as e: + logger.warning(f"Failed to delete old thumbnail: {e}") + + thumbnail_path = None + + if ext == ".3mf": + try: + parser = ThreeMFParser(str(file_path)) + raw_metadata = parser.parse() + thumbnail_data = raw_metadata.get("_thumbnail_data") + thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png") + + if thumbnail_data: + thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}" + thumb_path = thumbnails_dir / thumb_filename + with open(thumb_path, "wb") as f: + f.write(thumbnail_data) + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to extract 3MF thumbnail: {e}") + + elif ext == ".gcode": + try: + thumbnail_data = extract_gcode_thumbnail(file_path) + if thumbnail_data: + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + with open(thumb_path, "wb") as f: + f.write(thumbnail_data) + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to extract gcode thumbnail: {e}") + + elif ext == ".stl": + try: + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + if generate_stl_thumbnail(file_path, thumb_path): + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to generate STL thumbnail: {e}") + + elif ext.lower() in IMAGE_EXTENSIONS: + thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + + # Update database + file.thumbnail_path = thumbnail_path + await db.flush() + await db.refresh(file) + + # Return full response + return await get_file(file_id, db) + + +@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse) +async def batch_generate_stl_thumbnails( + request: BatchThumbnailRequest, + db: AsyncSession = Depends(get_db), +): + """Generate thumbnails for existing STL files. + + Can target specific files, a folder, or all STL files missing thumbnails. + + Args: + request: Batch request specifying which files to process + - file_ids: List of specific file IDs to process + - folder_id: Process all STL files in this folder + - all_missing: Process all STL files without thumbnails + """ + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + results: list[BatchThumbnailResult] = [] + thumbnails_dir = get_library_thumbnails_dir() + + # Build query based on request parameters + query = select(LibraryFile).where(LibraryFile.file_type == "stl") + + if request.file_ids: + # Specific files requested + query = query.where(LibraryFile.id.in_(request.file_ids)) + elif request.folder_id is not None: + # All STL files in a folder + query = query.where(LibraryFile.folder_id == request.folder_id) + elif request.all_missing: + # All STL files without thumbnails + query = query.where(LibraryFile.thumbnail_path.is_(None)) + else: + # No valid filter specified + raise HTTPException( + status_code=400, + detail="Must specify file_ids, folder_id, or all_missing=true", + ) + + result = await db.execute(query) + files = result.scalars().all() + + succeeded = 0 + failed = 0 + + for file in files: + if not file.file_path or not os.path.exists(file.file_path): + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=False, + error="File not found on disk", + ) + ) + failed += 1 + continue + + try: + # Delete old thumbnail if exists + if file.thumbnail_path and os.path.exists(file.thumbnail_path): + try: + os.remove(file.thumbnail_path) + except Exception: + pass + + # Generate new thumbnail + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + + if generate_stl_thumbnail(file.file_path, thumb_path): + file.thumbnail_path = str(thumb_path) + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=True, + ) + ) + succeeded += 1 + else: + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=False, + error="Thumbnail generation failed", + ) + ) + failed += 1 + + except Exception as e: + results.append( + BatchThumbnailResult( + file_id=file.id, + filename=file.filename, + success=False, + error=str(e), + ) + ) + failed += 1 + + await db.commit() + + return BatchThumbnailResponse( + processed=len(results), + succeeded=succeeded, + failed=failed, + results=results, + ) diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index dacbcc735..0c52b6b1a 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] + + +# ============ Batch Thumbnail Generation ============ + + +class BatchThumbnailRequest(BaseModel): + """Schema for batch STL thumbnail generation request.""" + + file_ids: list[int] | None = None # Specific file IDs to process + folder_id: int | None = None # Process all STL files in this folder + all_missing: bool = False # Process all STL files without thumbnails + + +class BatchThumbnailResult(BaseModel): + """Result for a single file thumbnail generation.""" + + file_id: int + filename: str + success: bool + error: str | None = None + + +class BatchThumbnailResponse(BaseModel): + """Schema for batch thumbnail generation response.""" + + processed: int + succeeded: int + failed: int + results: list[BatchThumbnailResult] diff --git a/backend/app/services/stl_thumbnail.py b/backend/app/services/stl_thumbnail.py new file mode 100644 index 000000000..2861e45d8 --- /dev/null +++ b/backend/app/services/stl_thumbnail.py @@ -0,0 +1,227 @@ +"""STL thumbnail generation service. + +Generates PNG thumbnails from STL files using trimesh and matplotlib. +Supports both ASCII and binary STL formats, handles large meshes via simplification. +""" + +import io +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Maximum vertices before simplification is applied +MAX_VERTICES = 100000 + +# Default thumbnail size +DEFAULT_SIZE = (256, 256) + + +def generate_stl_thumbnail( + stl_path: str | Path, + output_path: str | Path, + size: tuple[int, int] = DEFAULT_SIZE, +) -> bool: + """Generate a PNG thumbnail from an STL file. + + Args: + stl_path: Path to the input STL file + output_path: Path where the PNG thumbnail will be saved + size: Tuple of (width, height) for the output image + + Returns: + True if thumbnail was generated successfully, False otherwise + """ + try: + import matplotlib + + matplotlib.use("Agg") # Use non-interactive backend + import matplotlib.pyplot as plt + import numpy as np + import trimesh + + # Load the STL file + mesh = trimesh.load(str(stl_path), file_type="stl") + + if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0: + logger.warning(f"Failed to load STL or empty mesh: {stl_path}") + return False + + # Simplify if mesh is too large + if len(mesh.vertices) > MAX_VERTICES: + logger.info(f"Simplifying mesh with {len(mesh.vertices)} vertices to ~{MAX_VERTICES}") + # Calculate target face count based on vertex ratio + target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices))) + try: + mesh = mesh.simplify_quadric_decimation(target_faces) + except Exception as e: + logger.warning(f"Mesh simplification failed, using original: {e}") + + # Create figure with transparent background + fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100) + ax = fig.add_subplot(111, projection="3d") + + # Get mesh vertices and faces + vertices = mesh.vertices + faces = mesh.faces + + # Center the mesh + center = vertices.mean(axis=0) + vertices = vertices - center + + # Scale to fit in view + max_extent = np.abs(vertices).max() + if max_extent > 0: + vertices = vertices / max_extent + + # Create triangles for plotting + triangles = vertices[faces] + + # Plot the mesh with a nice color scheme + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + collection = Poly3DCollection( + triangles, + alpha=1.0, + facecolor="#00AE42", # Bambu green + edgecolor="#008833", # Darker green for edges + linewidths=0.1, + ) + ax.add_collection3d(collection) + + # Set axis limits + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-1, 1) + + # Set viewing angle (isometric-ish) + ax.view_init(elev=25, azim=45) + + # Remove axes for cleaner look + ax.set_axis_off() + + # Set background color + ax.set_facecolor("#1a1a1a") + fig.patch.set_facecolor("#1a1a1a") + + # Tight layout to minimize whitespace + plt.tight_layout(pad=0) + + # Save the figure + plt.savefig( + str(output_path), + format="png", + dpi=100, + facecolor="#1a1a1a", + bbox_inches="tight", + pad_inches=0.05, + ) + plt.close(fig) + + logger.info(f"Generated STL thumbnail: {output_path}") + return True + + except ImportError as e: + logger.error(f"Missing dependency for STL thumbnails: {e}") + return False + except Exception as e: + logger.error(f"Failed to generate STL thumbnail for {stl_path}: {e}") + return False + + +def generate_stl_thumbnail_bytes( + stl_data: bytes, + size: tuple[int, int] = DEFAULT_SIZE, +) -> bytes | None: + """Generate a PNG thumbnail from STL data in memory. + + Args: + stl_data: Raw STL file data (binary or ASCII) + size: Tuple of (width, height) for the output image + + Returns: + PNG image data as bytes, or None on failure + """ + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import numpy as np + import trimesh + + # Load from bytes + mesh = trimesh.load( + file_obj=io.BytesIO(stl_data), + file_type="stl", + ) + + if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0: + logger.warning("Failed to load STL from bytes or empty mesh") + return None + + # Simplify if mesh is too large + if len(mesh.vertices) > MAX_VERTICES: + target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices))) + try: + mesh = mesh.simplify_quadric_decimation(target_faces) + except Exception: + pass # Use original if simplification fails + + # Create figure + fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100) + ax = fig.add_subplot(111, projection="3d") + + vertices = mesh.vertices + faces = mesh.faces + + # Center and scale + center = vertices.mean(axis=0) + vertices = vertices - center + max_extent = np.abs(vertices).max() + if max_extent > 0: + vertices = vertices / max_extent + + triangles = vertices[faces] + + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + + collection = Poly3DCollection( + triangles, + alpha=1.0, + facecolor="#00AE42", + edgecolor="#008833", + linewidths=0.1, + ) + ax.add_collection3d(collection) + + ax.set_xlim(-1, 1) + ax.set_ylim(-1, 1) + ax.set_zlim(-1, 1) + ax.view_init(elev=25, azim=45) + ax.set_axis_off() + ax.set_facecolor("#1a1a1a") + fig.patch.set_facecolor("#1a1a1a") + plt.tight_layout(pad=0) + + # Save to bytes buffer + buf = io.BytesIO() + plt.savefig( + buf, + format="png", + dpi=100, + facecolor="#1a1a1a", + bbox_inches="tight", + pad_inches=0.05, + ) + plt.close(fig) + + buf.seek(0) + return buf.read() + + except ImportError as e: + logger.error(f"Missing dependency for STL thumbnails: {e}") + return None + except Exception as e: + logger.error(f"Failed to generate STL thumbnail from bytes: {e}") + return None diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index a9ae2e5f3..1113dc189 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -445,3 +445,126 @@ class TestLibraryZipExtractAPI: result = response.json() assert result["extracted"] == 1 # Only real_file.txt assert result["files"][0]["filename"] == "real_file.txt" + + +class TestSTLThumbnailAPI: + """Integration tests for STL thumbnail generation endpoints.""" + + @pytest.fixture + async def stl_file_factory(self, db_session): + """Factory to create test STL files.""" + _counter = [0] + + async def _create_stl_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "filename": f"test_model_{counter}.stl", + "file_path": f"/test/path/test_model_{counter}.stl", + "file_size": 1024, + "file_type": "stl", + } + defaults.update(kwargs) + + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_stl_file + + @pytest.fixture + async def folder_factory(self, db_session): + """Factory to create test folders.""" + _counter = [0] + + async def _create_folder(**kwargs): + from backend.app.models.library import LibraryFolder + + _counter[0] += 1 + counter = _counter[0] + + defaults = {"name": f"Test Folder {counter}"} + defaults.update(kwargs) + + folder = LibraryFolder(**defaults) + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + return folder + + return _create_folder + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_thumbnail_file_not_found(self, async_client: AsyncClient, db_session): + """Verify 404 for non-existent file.""" + response = await async_client.post("/api/v1/library/files/9999/regenerate-thumbnail") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_thumbnail_file_missing_on_disk( + self, async_client: AsyncClient, stl_file_factory, db_session + ): + """Verify error when file exists in DB but not on disk.""" + stl_file = await stl_file_factory() + response = await async_client.post(f"/api/v1/library/files/{stl_file.id}/regenerate-thumbnail") + assert response.status_code == 404 + assert "not found on disk" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_no_filter(self, async_client: AsyncClient, db_session): + """Verify error when no filter is specified.""" + data = {} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 400 + assert "Must specify" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_all_missing_empty(self, async_client: AsyncClient, db_session): + """Verify batch generation with no matching files.""" + data = {"all_missing": True} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 0 + assert result["succeeded"] == 0 + assert result["failed"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_specific_files(self, async_client: AsyncClient, stl_file_factory, db_session): + """Verify batch generation with specific file IDs.""" + stl_file = await stl_file_factory() + data = {"file_ids": [stl_file.id]} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 1 + # Will fail because file doesn't exist on disk + assert result["failed"] == 1 + assert result["results"][0]["error"] == "File not found on disk" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_batch_generate_by_folder( + self, async_client: AsyncClient, stl_file_factory, folder_factory, db_session + ): + """Verify batch generation by folder ID.""" + folder = await folder_factory() + await stl_file_factory(folder_id=folder.id) + await stl_file_factory(folder_id=folder.id) + await stl_file_factory() # File in root, should not be processed + + data = {"folder_id": folder.id} + response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data) + assert response.status_code == 200 + result = response.json() + assert result["processed"] == 2 # Only files in the folder 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..fe293a74e --- /dev/null +++ b/backend/tests/unit/services/test_stl_thumbnail.py @@ -0,0 +1,326 @@ +"""Unit tests for the STL thumbnail service.""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +class TestSTLThumbnailService: + """Tests for STL thumbnail generation.""" + + def test_generate_thumbnail_ascii_stl(self): + """Test generating thumbnail from ASCII STL file.""" + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + # Create a simple ASCII STL cube + ascii_stl = """solid cube + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 1 1 0 + endloop + endfacet + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 1 1 0 + vertex 0 1 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 1 1 + vertex 1 0 1 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 0 1 1 + vertex 1 1 1 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 1 0 1 + vertex 1 0 0 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 0 0 1 + vertex 1 0 1 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 1 1 + vertex 1 1 0 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 1 0 0 + vertex 1 0 1 + vertex 1 1 1 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 0 + vertex 1 1 1 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 0 1 0 + vertex 1 1 1 + vertex 0 1 1 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 0 + vertex 0 1 1 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex 0 0 0 + vertex 0 1 1 + vertex 0 0 1 + endloop + endfacet +endsolid cube +""" + + with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file: + stl_file.write(ascii_stl) + stl_path = stl_file.name + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file: + png_path = png_file.name + + try: + result = generate_stl_thumbnail(stl_path, png_path) + assert result is True + assert os.path.exists(png_path) + # Check it's a valid PNG (starts with PNG magic bytes) + with open(png_path, "rb") as f: + header = f.read(8) + assert header[:4] == b"\x89PNG" + finally: + os.unlink(stl_path) + if os.path.exists(png_path): + os.unlink(png_path) + + def test_generate_thumbnail_binary_stl(self): + """Test generating thumbnail from binary STL file.""" + import struct + + from backend.app.services.stl_thumbnail import generate_stl_thumbnail + + # Create a simple binary STL cube (minimal version) + # Binary STL format: + # - 80 bytes header + # - 4 bytes number of triangles (uint32) + # - For each triangle: + # - 12 bytes normal (3 floats) + # - 36 bytes vertices (9 floats, 3 vertices x 3 coords) + # - 2 bytes attribute byte count (usually 0) + + header = b"\x00" * 80 # Empty header + num_triangles = 12 # A cube has 12 triangles (2 per face) + + # Define cube vertices + vertices = [ + # Bottom face (z=0) + ((0, 0, -1), [(0, 0, 0), (1, 0, 0), (1, 1, 0)]), + ((0, 0, -1), [(0, 0, 0), (1, 1, 0), (0, 1, 0)]), + # Top face (z=1) + ((0, 0, 1), [(0, 0, 1), (1, 1, 1), (1, 0, 1)]), + ((0, 0, 1), [(0, 0, 1), (0, 1, 1), (1, 1, 1)]), + # Front face (y=0) + ((0, -1, 0), [(0, 0, 0), (1, 0, 1), (1, 0, 0)]), + ((0, -1, 0), [(0, 0, 0), (0, 0, 1), (1, 0, 1)]), + # Back face (y=1) + ((0, 1, 0), [(0, 1, 0), (1, 1, 0), (1, 1, 1)]), + ((0, 1, 0), [(0, 1, 0), (1, 1, 1), (0, 1, 1)]), + # Left face (x=0) + ((-1, 0, 0), [(0, 0, 0), (0, 1, 0), (0, 1, 1)]), + ((-1, 0, 0), [(0, 0, 0), (0, 1, 1), (0, 0, 1)]), + # Right face (x=1) + ((1, 0, 0), [(1, 0, 0), (1, 1, 1), (1, 1, 0)]), + ((1, 0, 0), [(1, 0, 0), (1, 0, 1), (1, 1, 1)]), + ] + + binary_data = header + struct.pack("; }>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`), + + // STL Thumbnail Generation + regenerateFileThumbnail: (fileId: number) => + request(`/library/files/${fileId}/regenerate-thumbnail`, { + method: 'POST', + }), + batchGenerateStlThumbnails: (options: { + file_ids?: number[]; + folder_id?: number; + all_missing?: boolean; + }) => + request('/library/generate-stl-thumbnails', { + method: 'POST', + body: JSON.stringify(options), + }), }; // AMS History types @@ -3045,6 +3060,21 @@ export interface ZipExtractResponse { errors: ZipExtractError[]; } +// Batch Thumbnail Generation types +export interface BatchThumbnailResult { + file_id: number; + filename: string; + success: boolean; + error: string | null; +} + +export interface BatchThumbnailResponse { + processed: number; + succeeded: number; + failed: number; + results: BatchThumbnailResult[]; +} + // Library Queue types export interface AddToQueueResult { file_id: number; diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 7ebe87dc6..74512189e 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -35,6 +35,7 @@ import { Printer, Pencil, Play, + ImageIcon, } from 'lucide-react'; import { api } from '../api/client'; import type { @@ -821,9 +822,10 @@ interface FileCardProps { onAddToQueue?: (id: number) => void; onPrint?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; + onRegenerateThumbnail?: (id: number) => void; } -function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) { +function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onRegenerateThumbnail }: FileCardProps) { const [showActions, setShowActions] = useState(false); return ( @@ -924,6 +926,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue Rename )} + {onRegenerateThumbnail && ['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && ( + + )} + @@ -1604,6 +1652,7 @@ export function FileManagerPage() { onAddToQueue={(id) => addToQueueMutation.mutate([id])} onPrint={setPrintFile} onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })} + onRegenerateThumbnail={(id) => regenerateThumbnailMutation.mutate(id)} /> ))} @@ -1720,6 +1769,16 @@ export function FileManagerPage() { > + {['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && ( + + )}