316 lines
8.2 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|