mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Merge pull request #179 from maziggy/revert-178-feature/stl-thumbnail
Revert "Feature/stl thumbnail"
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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("<I", num_triangles)
|
||||
for normal, verts in vertices:
|
||||
binary_data += struct.pack("<fff", *normal) # Normal
|
||||
for v in verts:
|
||||
binary_data += struct.pack("<fff", *v) # Vertex
|
||||
binary_data += struct.pack("<H", 0) # Attribute byte count
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="wb") as stl_file:
|
||||
stl_file.write(binary_data)
|
||||
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)
|
||||
finally:
|
||||
os.unlink(stl_path)
|
||||
if os.path.exists(png_path):
|
||||
os.unlink(png_path)
|
||||
|
||||
def test_generate_thumbnail_invalid_file(self):
|
||||
"""Test handling of invalid STL file."""
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
|
||||
|
||||
# Create invalid STL content
|
||||
with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as stl_file:
|
||||
stl_file.write("This is not valid STL content")
|
||||
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)
|
||||
# Should return False for invalid file
|
||||
assert result is False
|
||||
finally:
|
||||
os.unlink(stl_path)
|
||||
if os.path.exists(png_path):
|
||||
os.unlink(png_path)
|
||||
|
||||
def test_generate_thumbnail_nonexistent_file(self):
|
||||
"""Test handling of nonexistent file."""
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
|
||||
|
||||
result = generate_stl_thumbnail("/nonexistent/path/file.stl", "/tmp/output.png")
|
||||
assert result is False
|
||||
|
||||
def test_generate_thumbnail_bytes_ascii(self):
|
||||
"""Test generating thumbnail from ASCII STL bytes."""
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail_bytes
|
||||
|
||||
# Simple ASCII STL cube (same as above)
|
||||
ascii_stl = b"""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 0 1
|
||||
vertex 1 1 1
|
||||
endloop
|
||||
endfacet
|
||||
endsolid cube
|
||||
"""
|
||||
|
||||
result = generate_stl_thumbnail_bytes(ascii_stl)
|
||||
assert result is not None
|
||||
# Check it's a valid PNG
|
||||
assert result[:4] == b"\x89PNG"
|
||||
|
||||
def test_generate_thumbnail_bytes_invalid(self):
|
||||
"""Test handling of invalid STL bytes."""
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail_bytes
|
||||
|
||||
result = generate_stl_thumbnail_bytes(b"not valid stl data")
|
||||
assert result is None
|
||||
|
||||
def test_generate_thumbnail_custom_size(self):
|
||||
"""Test generating thumbnail with custom size."""
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
|
||||
|
||||
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
|
||||
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, size=(128, 128))
|
||||
assert result is True
|
||||
assert os.path.exists(png_path)
|
||||
finally:
|
||||
os.unlink(stl_path)
|
||||
if os.path.exists(png_path):
|
||||
os.unlink(png_path)
|
||||
|
||||
|
||||
class TestMeshSimplification:
|
||||
"""Tests for mesh simplification with large files."""
|
||||
|
||||
def test_simplification_threshold(self):
|
||||
"""Test that MAX_VERTICES constant is defined."""
|
||||
from backend.app.services.stl_thumbnail import MAX_VERTICES
|
||||
|
||||
assert MAX_VERTICES == 100000
|
||||
|
||||
def test_large_mesh_handling(self):
|
||||
"""Test that large meshes are simplified (mocked)."""
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
|
||||
|
||||
# Create a mock mesh with many vertices
|
||||
with patch("trimesh.load") as mock_load:
|
||||
mock_mesh = MagicMock()
|
||||
mock_mesh.vertices = MagicMock()
|
||||
mock_mesh.vertices.__len__ = MagicMock(return_value=200000) # Over threshold
|
||||
mock_mesh.faces = MagicMock()
|
||||
mock_mesh.faces.__len__ = MagicMock(return_value=400000)
|
||||
mock_simplified = MagicMock()
|
||||
mock_simplified.vertices = [[0, 0, 0], [1, 0, 0], [0, 1, 0]]
|
||||
mock_simplified.faces = [[0, 1, 2]]
|
||||
mock_mesh.simplify_quadric_decimation.return_value = mock_simplified
|
||||
mock_load.return_value = mock_mesh
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as stl_file:
|
||||
stl_file.write(b"dummy")
|
||||
stl_path = stl_file.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as png_file:
|
||||
png_path = png_file.name
|
||||
|
||||
try:
|
||||
# This will fail but we can verify simplification was called
|
||||
generate_stl_thumbnail(stl_path, png_path)
|
||||
# The mock should have been called for simplification
|
||||
mock_mesh.simplify_quadric_decimation.assert_called()
|
||||
finally:
|
||||
os.unlink(stl_path)
|
||||
if os.path.exists(png_path):
|
||||
os.unlink(png_path)
|
||||
@@ -3382,21 +3382,6 @@ 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;
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
Printer,
|
||||
Pencil,
|
||||
Play,
|
||||
ImageIcon,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type {
|
||||
@@ -833,10 +832,9 @@ 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, 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
|
||||
</button>
|
||||
)}
|
||||
{onRegenerateThumbnail && ['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && (
|
||||
<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={() => { onRegenerateThumbnail(file.id); setShowActions(false); }}
|
||||
>
|
||||
<ImageIcon className="w-3.5 h-3.5" />
|
||||
Regenerate 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); }}
|
||||
@@ -1283,30 +1272,6 @@ export function FileManagerPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateThumbnailMutation = useMutation({
|
||||
mutationFn: (fileId: number) => api.regenerateFileThumbnail(fileId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
showToast('Thumbnail regenerated', 'success');
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message, 'error'),
|
||||
});
|
||||
|
||||
const batchGenerateThumbnailsMutation = useMutation({
|
||||
mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
if (result.succeeded > 0) {
|
||||
showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`, 'success');
|
||||
} else if (result.processed === 0) {
|
||||
showToast('No STL files missing thumbnails', 'success');
|
||||
} else {
|
||||
showToast(`Failed to generate thumbnails: ${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();
|
||||
@@ -1412,19 +1377,6 @@ export function FileManagerPage() {
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => batchGenerateThumbnailsMutation.mutate()}
|
||||
disabled={batchGenerateThumbnailsMutation.isPending}
|
||||
title="Generate thumbnails for STL files that don't have one"
|
||||
>
|
||||
{batchGenerateThumbnailsMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{batchGenerateThumbnailsMutation.isPending ? 'Generating...' : 'Generate Thumbnails'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1871,16 +1822,6 @@ export function FileManagerPage() {
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
{['stl', '3mf', 'gcode'].includes(file.file_type.toLowerCase()) && (
|
||||
<button
|
||||
onClick={() => regenerateThumbnailMutation.mutate(file.id)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
title="Regenerate Thumbnail"
|
||||
disabled={regenerateThumbnailMutation.isPending}
|
||||
>
|
||||
<ImageIcon 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"
|
||||
|
||||
@@ -37,10 +37,6 @@ qrcode[pil]>=7.4.0
|
||||
# System monitoring
|
||||
psutil>=6.0.0
|
||||
|
||||
# STL thumbnail generation
|
||||
trimesh>=4.0.0
|
||||
matplotlib>=3.8.0
|
||||
|
||||
# Authentication
|
||||
PyJWT>=2.8.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
|
||||
Reference in New Issue
Block a user