const fs = require('fs'); const path = require('path'); const NodeStl = require('node-stl'); const AdmZip = require('adm-zip'); const THUMBNAIL_DIR = './thumbnails'; const MODEL_FORMATS = ['stl', '3mf', 'obj', 'fbx', 'step', 'stp', '3dm', 'gcode', 'gco']; const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz']; const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; // Kept with a model and viewable, but never matched as duplicates. const COMPANION_FORMATS = [...IMAGE_FORMATS, 'pdf', 'txt', 'md', 'mtl']; const ALLOWED_FORMATS = [...MODEL_FORMATS, ...ARCHIVE_FORMATS, ...COMPANION_FORMATS]; function ensureThumbnailDir() { if (!fs.existsSync(THUMBNAIL_DIR)) fs.mkdirSync(THUMBNAIL_DIR, { recursive: true }); } function formatOf(filename) { const lower = filename.toLowerCase(); if (lower.endsWith('.tar.gz')) return 'tgz'; return path.extname(lower).slice(1); } function isAllowed(filename) { return ALLOWED_FORMATS.includes(formatOf(filename)); } // Geometry metadata. Only STL is computed natively today; others return nulls // (3MF/OBJ/STEP volume can be added later via a Python/trimesh sidecar). function extractMetadata(filePath, format) { if (format !== 'stl') return { volume: null, bbox: null, facets: null }; try { const stl = new NodeStl(filePath); const bb = stl.boundingBox; // [x, y, z] return { volume: Number.isFinite(stl.volume) ? stl.volume : null, bbox: Array.isArray(bb) ? { x: bb[0], y: bb[1], z: bb[2] } : null, facets: null, // node-stl does not expose facet count }; } catch (err) { console.error('STL metadata extraction failed:', err.message); return { volume: null, bbox: null, facets: null }; } } // Which embedded PNG makes the best card image: the project thumbnail, then the // first plate render. Slicer pick maps, top views and small/unlit variants last. function thumbnailRank(name) { if (name === 'metadata/thumbnail.png') return 0; if (/metadata\/plate_1\.png$/.test(name)) return 1; if (/metadata\/plate_\d+\.png$/.test(name)) return 2; if (/(pick_|top_|no_light|_small)/.test(name)) return 9; return 5; } // A .3mf is a zip; slicers embed a PNG preview under /Metadata/. If present, // pull it out as the model thumbnail. Returns a path under thumbnails/ or null. function extract3mfThumbnail(filePath, baseName) { try { ensureThumbnailDir(); const zip = new AdmZip(filePath); const entry = zip.getEntries() .filter((e) => { const n = e.entryName.toLowerCase(); return n.endsWith('.png') && (n.includes('thumbnail') || n.includes('metadata/')); }) .sort((a, b) => thumbnailRank(a.entryName.toLowerCase()) - thumbnailRank(b.entryName.toLowerCase()))[0]; if (!entry) return null; const out = path.join(THUMBNAIL_DIR, `${baseName}.png`); fs.writeFileSync(out, entry.getData()); return out; } catch (err) { console.error('3MF thumbnail extraction failed:', err.message); return null; } } // Copy a picture that came with a model into thumbnails/ (a copy, so replacing // the thumbnail later never deletes the model's own file). function copyAsThumbnail(filePath, baseName) { try { ensureThumbnailDir(); const out = path.join(THUMBNAIL_DIR, `${baseName}-image${path.extname(filePath).toLowerCase()}`); fs.copyFileSync(filePath, out); return out; } catch (err) { console.error('Image thumbnail copy failed:', err.message); return null; } } module.exports = { MODEL_FORMATS, ARCHIVE_FORMATS, IMAGE_FORMATS, COMPANION_FORMATS, ALLOWED_FORMATS, formatOf, isAllowed, extractMetadata, extract3mfThumbnail, copyAsThumbnail, };