Archives - ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background into archive_entries (7-Zip compiled to WASM in a worker thread; node-tar for tar). macOS/Windows clutter and paths that could escape the extraction folder are skipped. - Models inside archives are extracted once per archive to hash and geometry-fingerprint them, so duplicates are found inside packs too. Pictures, PDFs and readmes are listed but never matched. - New routes list an archive's entries and serve/convert a single entry, extracted on first use and cached under extracted/. Viewer - Model detail lists what's inside each archive with view/download links; opening an entry adds a viewer tab. - New 3D formats: FBX (with sibling textures), OBJ with its MTL and textures, STEP/STP via occt-import-js, and Rhino 3DM via rhino3dm — the WASM readers are served from node_modules, loaded on first use. - Pictures, PDFs and text files (jpg/png/gif/webp/bmp/svg, pdf, txt/md) can be uploaded and preview in the viewer; a model without an embedded 3MF preview uses its first picture as the card thumbnail. - Inline file responses send nosniff, and SVGs are sandboxed. - GLB conversions write to a temp file and rename, so a half-written preview is never served; a later tab can't be overdrawn by a slow load. - Better 3MF card image: the project thumbnail or plate 1 render, not a slicer pick map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
3.6 KiB
JavaScript
96 lines
3.6 KiB
JavaScript
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,
|
|
};
|