import fs from 'fs'; import path from 'path'; import { PNG } from 'pngjs'; import AdmZip from 'adm-zip'; import { parseString } from 'xml2js'; import { promisify } from 'util'; const parseXml = promisify(parseString); /** * Generate a thumbnail for a 3D model file * @param {string} filePath - Path to the 3D model file * @param {string} fileType - File extension (.stl, .3mf, etc.) * @returns {Promise} - Path to the generated thumbnail */ export async function generateThumbnail(filePath, fileType) { try { let vertices = null; // Parse the file based on type if (fileType === '.stl') { vertices = await parseSTLFile(filePath); } else if (fileType === '.3mf') { vertices = await parse3MFFile(filePath); } else if (fileType === '.obj') { vertices = await parseOBJFile(filePath); } else { // For unsupported formats, return null (no thumbnail) return null; } if (!vertices || vertices.length === 0) { return null; } // Generate thumbnail image in images folder const fileName = path.basename(filePath, path.extname(filePath)); const baseDir = path.dirname(path.dirname(filePath)); // Go up to uploads directory const imagesDir = path.join(baseDir, 'images'); // Ensure images directory exists if (!fs.existsSync(imagesDir)) { fs.mkdirSync(imagesDir, { recursive: true }); } const thumbnailPath = path.join(imagesDir, `${fileName}_thumb.png`); await createThumbnailImage(vertices, thumbnailPath); return thumbnailPath; } catch (error) { console.error('Error generating thumbnail:', error); return null; } } /** * Parse STL file and extract vertices (supports both ASCII and binary STL) */ async function parseSTLFile(filePath) { const buffer = fs.readFileSync(filePath); // Check if it's ASCII or binary STL const header = buffer.toString('utf8', 0, 5); if (header.toLowerCase() === 'solid') { // ASCII STL return parseASCIISTL(buffer); } else { // Binary STL return parseBinarySTL(buffer); } } /** * Parse ASCII STL format */ function parseASCIISTL(buffer) { const content = buffer.toString('utf8'); const lines = content.split('\n'); const vertices = []; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('vertex ')) { const parts = trimmed.split(/\s+/); if (parts.length >= 4) { vertices.push({ x: parseFloat(parts[1]) || 0, y: parseFloat(parts[2]) || 0, z: parseFloat(parts[3]) || 0 }); } } } return vertices; } /** * Parse binary STL format */ function parseBinarySTL(buffer) { const vertices = []; // Skip 80-byte header let offset = 80; // Read number of triangles (4 bytes, little-endian) const triangleCount = buffer.readUInt32LE(offset); offset += 4; // Each triangle: 12 floats (normal + 3 vertices) + 2 byte attribute for (let i = 0; i < triangleCount; i++) { // Skip normal vector (3 floats = 12 bytes) offset += 12; // Read 3 vertices (each vertex = 3 floats = 12 bytes) for (let j = 0; j < 3; j++) { const x = buffer.readFloatLE(offset); const y = buffer.readFloatLE(offset + 4); const z = buffer.readFloatLE(offset + 8); vertices.push({ x, y, z }); offset += 12; } // Skip attribute byte count (2 bytes) offset += 2; } return vertices; } /** * Parse 3MF file and extract vertices */ async function parse3MFFile(filePath) { try { const zip = new AdmZip(filePath); const zipEntries = zip.getEntries(); // Find the 3D model XML file const modelEntry = zipEntries.find(entry => entry.entryName.includes('3D/3dmodel.model') || entry.entryName.endsWith('.model') ); if (!modelEntry) { console.error('No 3D model found in 3MF file'); return null; } const xmlContent = modelEntry.getData().toString('utf8'); const result = await parseXml(xmlContent); // Extract vertices from XML structure const vertices = []; // Navigate the 3MF XML structure if (result.model && result.model.resources && result.model.resources[0].object) { const objects = result.model.resources[0].object; for (const obj of objects) { if (obj.mesh && obj.mesh[0].vertices && obj.mesh[0].vertices[0].vertex) { const verts = obj.mesh[0].vertices[0].vertex; for (const v of verts) { if (v.$) { vertices.push({ x: parseFloat(v.$.x) || 0, y: parseFloat(v.$.y) || 0, z: parseFloat(v.$.z) || 0 }); } } } } } return vertices; } catch (error) { console.error('Error parsing 3MF file:', error); return null; } } /** * Parse OBJ file and extract vertices */ async function parseOBJFile(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const lines = content.split('\n'); const vertices = []; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('v ')) { const parts = trimmed.split(/\s+/); if (parts.length >= 4) { vertices.push({ x: parseFloat(parts[1]) || 0, y: parseFloat(parts[2]) || 0, z: parseFloat(parts[3]) || 0 }); } } } return vertices; } catch (error) { console.error('Error parsing OBJ file:', error); return null; } } /** * Create a thumbnail image from vertices using orthographic projection */ async function createThumbnailImage(vertices, outputPath, size = 400) { // Calculate bounding box let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (const v of vertices) { minX = Math.min(minX, v.x); minY = Math.min(minY, v.y); minZ = Math.min(minZ, v.z); maxX = Math.max(maxX, v.x); maxY = Math.max(maxY, v.y); maxZ = Math.max(maxZ, v.z); } const rangeX = maxX - minX; const rangeY = maxY - minY; const rangeZ = maxZ - minZ; const maxRange = Math.max(rangeX, rangeY, rangeZ); // Create PNG image const png = new PNG({ width: size, height: size, colorType: 6 // RGBA }); // Fill with background color (dark gray) for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const idx = (size * y + x) << 2; png.data[idx] = 45; // R png.data[idx + 1] = 45; // G png.data[idx + 2] = 45; // B png.data[idx + 3] = 255; // A } } // Project vertices onto 2D plane (isometric view) const points = new Set(); const padding = size * 0.1; const scale = (size - 2 * padding) / maxRange; for (const v of vertices) { // Isometric projection (rotate 45 degrees around Y axis, then 35.264 degrees around X axis) const iso_x = (v.x - minX - rangeX / 2) * 0.866 - (v.z - minZ - rangeZ / 2) * 0.866; const iso_y = (v.x - minX - rangeX / 2) * 0.5 + (v.y - minY - rangeY / 2) + (v.z - minZ - rangeZ / 2) * 0.5; const px = Math.floor(iso_x * scale + size / 2); const py = Math.floor(size / 2 - iso_y * scale); if (px >= 0 && px < size && py >= 0 && py < size) { points.add(`${px},${py}`); } } // Draw points on the image for (const point of points) { const [px, py] = point.split(',').map(Number); // Draw a small cross or dot for each point for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const x = px + dx; const y = py + dy; if (x >= 0 && x < size && y >= 0 && y < size) { const idx = (size * y + x) << 2; png.data[idx] = 0; // R - Bambu Lab green png.data[idx + 1] = 174; // G png.data[idx + 2] = 66; // B png.data[idx + 3] = 255; // A } } } } // Write PNG to file return new Promise((resolve, reject) => { png.pack() .pipe(fs.createWriteStream(outputPath)) .on('finish', () => resolve(outputPath)) .on('error', reject); }); } /** * Delete a thumbnail file */ export function deleteThumbnail(thumbnailPath) { if (thumbnailPath && fs.existsSync(thumbnailPath)) { try { fs.unlinkSync(thumbnailPath); } catch (error) { console.error('Error deleting thumbnail:', error); } } }