mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
FileManagerModal ran its file-listing query with refetchInterval 30000, opening a fresh FTPS connection (full TLS handshake) to the printer every 30s while the modal was open. On fragile controllers like the P1S that load tipped MQTT, FTP and the camera into simultaneous timeouts. Drop the interval; the listing still refreshes on open, directory change, after upload/delete, and via the manual Refresh button. Also log STL thumbnail failures with a traceback (exc_info) so a data-specific "str / str" TypeError can be located from a support bundle.
152 lines
5.0 KiB
Python
152 lines
5.0 KiB
Python
"""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
|
|
"""
|
|
# Callers historically pass either Path or str; coerce so the `thumbnails_dir
|
|
# / thumb_filename` join at the end of this function can't fail with the
|
|
# str-divided-by-str TypeError (see #1299).
|
|
stl_path = Path(stl_path)
|
|
thumbnails_dir = Path(thumbnails_dir)
|
|
|
|
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("Failed to load STL or empty mesh: %s", stl_path)
|
|
return None
|
|
|
|
# Simplify large meshes for performance
|
|
if len(mesh.vertices) > MAX_VERTICES:
|
|
logger.info("Simplifying mesh from %s vertices", len(mesh.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("Simplified mesh to %s vertices", len(mesh.vertices))
|
|
except Exception as e:
|
|
logger.warning("Mesh simplification failed, using original: %s", 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("Generated STL thumbnail: %s", thumb_path)
|
|
return str(thumb_path)
|
|
|
|
except ImportError as e:
|
|
logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
|
|
return None
|
|
except Exception as e:
|
|
# Log the traceback, not just the message: a bare
|
|
# "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
|
|
# which line failed, and the fault is data-/environment-specific
|
|
# enough that it can't be reproduced from a clean STL — the traceback
|
|
# in the next support bundle is what pinpoints it (#1480).
|
|
logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
|
|
return None
|