stl_thumbnail.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. """STL Thumbnail Generation Service.
  2. Generates thumbnail images from STL files using trimesh and matplotlib.
  3. """
  4. import logging
  5. import uuid
  6. from pathlib import Path
  7. logger = logging.getLogger(__name__)
  8. # Bambu green color for rendering
  9. BAMBU_GREEN = "#00AE42"
  10. BACKGROUND_COLOR = "#1a1a1a"
  11. # Maximum vertices before simplification
  12. MAX_VERTICES = 100000
  13. def generate_stl_thumbnail(
  14. stl_path: Path,
  15. thumbnails_dir: Path,
  16. size: int = 256,
  17. ) -> str | None:
  18. """Generate a thumbnail image from an STL file.
  19. Args:
  20. stl_path: Path to the STL file
  21. thumbnails_dir: Directory to save the thumbnail
  22. size: Thumbnail size in pixels (default 256x256)
  23. Returns:
  24. Path to the generated thumbnail, or None on failure
  25. """
  26. # Callers historically pass either Path or str; coerce so the `thumbnails_dir
  27. # / thumb_filename` join at the end of this function can't fail with the
  28. # str-divided-by-str TypeError (see #1299).
  29. stl_path = Path(stl_path)
  30. thumbnails_dir = Path(thumbnails_dir)
  31. try:
  32. import matplotlib
  33. import trimesh
  34. # Use Agg backend for headless rendering
  35. matplotlib.use("Agg")
  36. import matplotlib.pyplot as plt
  37. from mpl_toolkits.mplot3d import Axes3D # noqa: F401
  38. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  39. # Load the STL file
  40. mesh = trimesh.load(str(stl_path), force="mesh")
  41. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
  42. logger.warning("Failed to load STL or empty mesh: %s", stl_path)
  43. return None
  44. # Simplify large meshes for performance
  45. if len(mesh.vertices) > MAX_VERTICES:
  46. logger.info("Simplifying mesh from %s vertices", len(mesh.vertices))
  47. try:
  48. # Calculate reduction ratio (0-1 range)
  49. # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
  50. keep_ratio = MAX_VERTICES / len(mesh.vertices)
  51. target_reduction = 1.0 - keep_ratio
  52. # Clamp to valid range (0.01 to 0.99)
  53. target_reduction = max(0.01, min(0.99, target_reduction))
  54. mesh = mesh.simplify_quadric_decimation(target_reduction)
  55. logger.info("Simplified mesh to %s vertices", len(mesh.vertices))
  56. except Exception as e:
  57. logger.warning("Mesh simplification failed, using original: %s", e)
  58. # Get mesh bounds and center it
  59. vertices = mesh.vertices
  60. bounds_min = vertices.min(axis=0)
  61. bounds_max = vertices.max(axis=0)
  62. center = (bounds_min + bounds_max) / 2
  63. vertices_centered = vertices - center
  64. # Scale to fit in view
  65. max_extent = (bounds_max - bounds_min).max()
  66. if max_extent > 0:
  67. scale = 1.0 / max_extent
  68. vertices_scaled = vertices_centered * scale
  69. else:
  70. vertices_scaled = vertices_centered
  71. # Create figure with dark background
  72. fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
  73. fig.patch.set_facecolor(BACKGROUND_COLOR)
  74. ax = fig.add_subplot(111, projection="3d")
  75. ax.set_facecolor(BACKGROUND_COLOR)
  76. # Create polygon collection from mesh faces
  77. faces = mesh.faces
  78. poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
  79. collection = Poly3DCollection(
  80. poly3d,
  81. facecolors=BAMBU_GREEN,
  82. edgecolors=BAMBU_GREEN,
  83. linewidths=0.1,
  84. alpha=0.9,
  85. )
  86. ax.add_collection3d(collection)
  87. # Set axis limits
  88. ax.set_xlim(-0.6, 0.6)
  89. ax.set_ylim(-0.6, 0.6)
  90. ax.set_zlim(-0.6, 0.6)
  91. # Set view angle (isometric-ish)
  92. ax.view_init(elev=25, azim=45)
  93. # Remove axes and grid
  94. ax.set_axis_off()
  95. ax.grid(False)
  96. # Remove margins
  97. plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
  98. # Save thumbnail
  99. thumb_filename = f"{uuid.uuid4().hex}.png"
  100. thumb_path = thumbnails_dir / thumb_filename
  101. fig.savefig(
  102. thumb_path,
  103. format="png",
  104. facecolor=BACKGROUND_COLOR,
  105. edgecolor="none",
  106. bbox_inches="tight",
  107. pad_inches=0.05,
  108. dpi=100,
  109. )
  110. plt.close(fig)
  111. logger.info("Generated STL thumbnail: %s", thumb_path)
  112. return str(thumb_path)
  113. except ImportError as e:
  114. logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
  115. return None
  116. except Exception as e:
  117. # Log the traceback, not just the message: a bare
  118. # "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
  119. # which line failed, and the fault is data-/environment-specific
  120. # enough that it can't be reproduced from a clean STL — the traceback
  121. # in the next support bundle is what pinpoints it (#1480).
  122. logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
  123. return None