stl_thumbnail.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """STL thumbnail generation service.
  2. Generates PNG thumbnails from STL files using trimesh and matplotlib.
  3. Supports both ASCII and binary STL formats, handles large meshes via simplification.
  4. """
  5. import io
  6. import logging
  7. from pathlib import Path
  8. logger = logging.getLogger(__name__)
  9. # Maximum vertices before simplification is applied
  10. MAX_VERTICES = 100000
  11. # Default thumbnail size
  12. DEFAULT_SIZE = (256, 256)
  13. def generate_stl_thumbnail(
  14. stl_path: str | Path,
  15. output_path: str | Path,
  16. size: tuple[int, int] = DEFAULT_SIZE,
  17. ) -> bool:
  18. """Generate a PNG thumbnail from an STL file.
  19. Args:
  20. stl_path: Path to the input STL file
  21. output_path: Path where the PNG thumbnail will be saved
  22. size: Tuple of (width, height) for the output image
  23. Returns:
  24. True if thumbnail was generated successfully, False otherwise
  25. """
  26. try:
  27. import matplotlib
  28. matplotlib.use("Agg") # Use non-interactive backend
  29. import matplotlib.pyplot as plt
  30. import numpy as np
  31. import trimesh
  32. # Load the STL file
  33. mesh = trimesh.load(str(stl_path), file_type="stl")
  34. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
  35. logger.warning(f"Failed to load STL or empty mesh: {stl_path}")
  36. return False
  37. # Simplify if mesh is too large
  38. if len(mesh.vertices) > MAX_VERTICES:
  39. logger.info(f"Simplifying mesh with {len(mesh.vertices)} vertices to ~{MAX_VERTICES}")
  40. # Calculate target face count based on vertex ratio
  41. target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices)))
  42. try:
  43. mesh = mesh.simplify_quadric_decimation(target_faces)
  44. except Exception as e:
  45. logger.warning(f"Mesh simplification failed, using original: {e}")
  46. # Create figure with transparent background
  47. fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
  48. ax = fig.add_subplot(111, projection="3d")
  49. # Get mesh vertices and faces
  50. vertices = mesh.vertices
  51. faces = mesh.faces
  52. # Center the mesh
  53. center = vertices.mean(axis=0)
  54. vertices = vertices - center
  55. # Scale to fit in view
  56. max_extent = np.abs(vertices).max()
  57. if max_extent > 0:
  58. vertices = vertices / max_extent
  59. # Create triangles for plotting
  60. triangles = vertices[faces]
  61. # Plot the mesh with a nice color scheme
  62. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  63. collection = Poly3DCollection(
  64. triangles,
  65. alpha=1.0,
  66. facecolor="#00AE42", # Bambu green
  67. edgecolor="#008833", # Darker green for edges
  68. linewidths=0.1,
  69. )
  70. ax.add_collection3d(collection)
  71. # Set axis limits
  72. ax.set_xlim(-1, 1)
  73. ax.set_ylim(-1, 1)
  74. ax.set_zlim(-1, 1)
  75. # Set viewing angle (isometric-ish)
  76. ax.view_init(elev=25, azim=45)
  77. # Remove axes for cleaner look
  78. ax.set_axis_off()
  79. # Set background color
  80. ax.set_facecolor("#1a1a1a")
  81. fig.patch.set_facecolor("#1a1a1a")
  82. # Tight layout to minimize whitespace
  83. plt.tight_layout(pad=0)
  84. # Save the figure
  85. plt.savefig(
  86. str(output_path),
  87. format="png",
  88. dpi=100,
  89. facecolor="#1a1a1a",
  90. bbox_inches="tight",
  91. pad_inches=0.05,
  92. )
  93. plt.close(fig)
  94. logger.info(f"Generated STL thumbnail: {output_path}")
  95. return True
  96. except ImportError as e:
  97. logger.error(f"Missing dependency for STL thumbnails: {e}")
  98. return False
  99. except Exception as e:
  100. logger.error(f"Failed to generate STL thumbnail for {stl_path}: {e}")
  101. return False
  102. def generate_stl_thumbnail_bytes(
  103. stl_data: bytes,
  104. size: tuple[int, int] = DEFAULT_SIZE,
  105. ) -> bytes | None:
  106. """Generate a PNG thumbnail from STL data in memory.
  107. Args:
  108. stl_data: Raw STL file data (binary or ASCII)
  109. size: Tuple of (width, height) for the output image
  110. Returns:
  111. PNG image data as bytes, or None on failure
  112. """
  113. try:
  114. import matplotlib
  115. matplotlib.use("Agg")
  116. import matplotlib.pyplot as plt
  117. import numpy as np
  118. import trimesh
  119. # Load from bytes
  120. mesh = trimesh.load(
  121. file_obj=io.BytesIO(stl_data),
  122. file_type="stl",
  123. )
  124. if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
  125. logger.warning("Failed to load STL from bytes or empty mesh")
  126. return None
  127. # Simplify if mesh is too large
  128. if len(mesh.vertices) > MAX_VERTICES:
  129. target_faces = int(len(mesh.faces) * (MAX_VERTICES / len(mesh.vertices)))
  130. try:
  131. mesh = mesh.simplify_quadric_decimation(target_faces)
  132. except Exception:
  133. pass # Use original if simplification fails
  134. # Create figure
  135. fig = plt.figure(figsize=(size[0] / 100, size[1] / 100), dpi=100)
  136. ax = fig.add_subplot(111, projection="3d")
  137. vertices = mesh.vertices
  138. faces = mesh.faces
  139. # Center and scale
  140. center = vertices.mean(axis=0)
  141. vertices = vertices - center
  142. max_extent = np.abs(vertices).max()
  143. if max_extent > 0:
  144. vertices = vertices / max_extent
  145. triangles = vertices[faces]
  146. from mpl_toolkits.mplot3d.art3d import Poly3DCollection
  147. collection = Poly3DCollection(
  148. triangles,
  149. alpha=1.0,
  150. facecolor="#00AE42",
  151. edgecolor="#008833",
  152. linewidths=0.1,
  153. )
  154. ax.add_collection3d(collection)
  155. ax.set_xlim(-1, 1)
  156. ax.set_ylim(-1, 1)
  157. ax.set_zlim(-1, 1)
  158. ax.view_init(elev=25, azim=45)
  159. ax.set_axis_off()
  160. ax.set_facecolor("#1a1a1a")
  161. fig.patch.set_facecolor("#1a1a1a")
  162. plt.tight_layout(pad=0)
  163. # Save to bytes buffer
  164. buf = io.BytesIO()
  165. plt.savefig(
  166. buf,
  167. format="png",
  168. dpi=100,
  169. facecolor="#1a1a1a",
  170. bbox_inches="tight",
  171. pad_inches=0.05,
  172. )
  173. plt.close(fig)
  174. buf.seek(0)
  175. return buf.read()
  176. except ImportError as e:
  177. logger.error(f"Missing dependency for STL thumbnails: {e}")
  178. return None
  179. except Exception as e:
  180. logger.error(f"Failed to generate STL thumbnail from bytes: {e}")
  181. return None