makerstash/server/services/thumbnailGenerator.js

316 lines
8.2 KiB
JavaScript

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<string>} - 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);
}
}
}