From 221f9ffcc3bff0eb32c0a9b49e4d5cb4e202ea31 Mon Sep 17 00:00:00 2001 From: MartinNYHC Date: Thu, 29 Jan 2026 15:52:46 +0100 Subject: [PATCH] Revert "Feature/stl thumbnail" --- =2.8.0 | 5 - backend/app/api/routes/library.py | 220 ------------ backend/app/schemas/library.py | 29 -- backend/app/services/stl_thumbnail.py | 227 ------------ .../tests/unit/services/test_stl_thumbnail.py | 326 ------------------ frontend/src/api/client.ts | 15 - frontend/src/pages/FileManagerPage.tsx | 61 +--- requirements.txt | 4 - 8 files changed, 1 insertion(+), 886 deletions(-) delete mode 100644 =2.8.0 delete mode 100644 backend/app/services/stl_thumbnail.py delete mode 100644 backend/tests/unit/services/test_stl_thumbnail.py diff --git a/=2.8.0 b/=2.8.0 deleted file mode 100644 index 9f7171c00..000000000 --- a/=2.8.0 +++ /dev/null @@ -1,5 +0,0 @@ -Collecting PyJWT - Downloading PyJWT-2.10.1-py3-none-any.whl.metadata (4.0 kB) -Downloading PyJWT-2.10.1-py3-none-any.whl (22 kB) -Installing collected packages: PyJWT -Successfully installed PyJWT-2.10.1 diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 13ef3ac72..d19bf2ca5 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -25,9 +25,6 @@ from backend.app.schemas.library import ( AddToQueueRequest, AddToQueueResponse, AddToQueueResult, - BatchThumbnailRequest, - BatchThumbnailResponse, - BatchThumbnailResult, BulkDeleteRequest, BulkDeleteResponse, FileDuplicate, @@ -711,18 +708,6 @@ 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) @@ -953,17 +938,6 @@ 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) @@ -1945,197 +1919,3 @@ 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 0c52b6b1a..dacbcc735 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -262,32 +262,3 @@ 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 deleted file mode 100644 index 2861e45d8..000000000 --- a/backend/app/services/stl_thumbnail.py +++ /dev/null @@ -1,227 +0,0 @@ -"""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/unit/services/test_stl_thumbnail.py b/backend/tests/unit/services/test_stl_thumbnail.py deleted file mode 100644 index fe293a74e..000000000 --- a/backend/tests/unit/services/test_stl_thumbnail.py +++ /dev/null @@ -1,326 +0,0 @@ -"""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(" void; onPrint?: (file: LibraryFileListItem) => void; onRename?: (file: LibraryFileListItem) => void; - onRegenerateThumbnail?: (id: number) => void; } -function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onRegenerateThumbnail }: FileCardProps) { +function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) { const [showActions, setShowActions] = useState(false); return ( @@ -937,15 +935,6 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue Rename )} - {onRegenerateThumbnail && ['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && ( - - )} - @@ -1754,7 +1706,6 @@ 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)} /> ))} @@ -1871,16 +1822,6 @@ export function FileManagerPage() { > - {['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && ( - - )}