@fdm-monster/server
Version:
FDM Monster is a bulk OctoPrint, Klipper, PrusaLink and BambuLab manager to set up, configure and monitor 3D printers. Our aim is to provide neat overview over your farm.
300 lines (299 loc) • 14.8 kB
JavaScript
import { getImageDimensions } from "../image-dimensions.js";
import * as path$1 from "node:path";
import * as fs$1 from "node:fs/promises";
import AdmZip from "adm-zip";
//#region src/utils/parsers/3mf.parser.ts
/**
* 3MF parser for extracting metadata from .3mf files
* Supports both single and multi-plate 3MF files (Bambu Lab format)
*/
var ThreeMFParser = class {
async parse(filePath) {
const stats = await fs$1.stat(filePath);
const fileName = path$1.basename(filePath);
const zipEntries = new AdmZip(filePath).getEntries();
const metadataJsonEntry = zipEntries.find((e) => e.entryName === "metadata.json");
let metadata = {};
if (metadataJsonEntry) {
const jsonContent = metadataJsonEntry.getData().toString("utf8");
const jsonData = JSON.parse(jsonContent);
metadata = this.normalizeJsonMetadata(jsonData);
} else {
const modelEntry = zipEntries.find((e) => e.entryName === "3D/3dmodel.model" || e.entryName === "Metadata/model_settings.config");
metadata = modelEntry ? this.extractMetadataFromXML(modelEntry.getData().toString("utf8")) : {};
}
const plates = this.extractPlates(zipEntries);
const isMultiPlate = plates.length > 1;
const thumbnails = this.extractThumbnails(zipEntries);
let topLevelPrintTime = this.parseTime(metadata.printTime);
let topLevelFilamentWeight = this.parseFloat(metadata.totalFilamentWeight || metadata.filamentWeight);
let topLevelLayers = this.parseInt(metadata.layerCount);
let topLevelFilamentUsedMm = this.parseFloat(metadata.filamentUsed);
let topLevelFilamentUsedCm3 = this.parseFloat(metadata.filamentVolume);
let topLevelFilamentDensity = this.parseFloat(metadata.filamentDensity);
let topLevelMaxZ = this.parseFloat(metadata.maxZ);
let topLevelSlicerVersion = metadata.slicerVersion || metadata.generator;
let topLevelNozzleDiameter = this.parseFloat(metadata.nozzleDiameter);
let topLevelLayerHeight = this.parseFloat(metadata.layerHeight || metadata.layer_height);
let topLevelFirstLayerHeight = this.parseFloat(metadata.firstLayerHeight || metadata.first_layer_height);
let topLevelBedTemp = this.parseFloat(metadata.bedTemp || metadata.bed_temperature);
let topLevelNozzleTemp = this.parseFloat(metadata.nozzleTemp || metadata.nozzle_temperature);
let topLevelFillDensity = metadata.infillDensity || metadata.infill_density || metadata.fill_density || null;
let topLevelFilamentType = metadata.filamentType || metadata.filament_type || null;
let topLevelPrinterModel = metadata.printerModel || metadata.printer_model || null;
let topLevelFilamentDiameter = this.parseFloat(metadata.filamentDiameter || metadata.filament_diameter) || 1.75;
if (plates.length >= 1 && plates[0]) if (plates.length === 1) {
const plate = plates[0];
topLevelPrintTime = plate.gcodePrintTimeSeconds ?? topLevelPrintTime;
topLevelFilamentWeight = plate.filamentUsedGrams ?? topLevelFilamentWeight;
topLevelLayers = plate.totalLayers ?? topLevelLayers;
topLevelFilamentUsedMm = plate.filamentUsedMm ?? topLevelFilamentUsedMm;
topLevelFilamentUsedCm3 = plate.filamentUsedCm3 ?? topLevelFilamentUsedCm3;
topLevelFilamentDensity = plate.filamentDensityGramsCm3 ?? topLevelFilamentDensity;
topLevelMaxZ = plate.maxLayerZ ?? topLevelMaxZ;
topLevelSlicerVersion = plate.slicerVersion ?? topLevelSlicerVersion;
topLevelNozzleDiameter = plate.nozzleDiameterMm ?? topLevelNozzleDiameter;
topLevelLayerHeight = plate.layerHeight ?? topLevelLayerHeight;
topLevelFirstLayerHeight = plate.firstLayerHeight ?? topLevelFirstLayerHeight;
topLevelBedTemp = plate.bedTemperature ?? topLevelBedTemp;
topLevelNozzleTemp = plate.nozzleTemperature ?? topLevelNozzleTemp;
topLevelFillDensity = plate.fillDensity ?? topLevelFillDensity;
topLevelFilamentType = plate.filamentType ?? topLevelFilamentType;
topLevelPrinterModel = plate.printerModel ?? topLevelPrinterModel;
topLevelFilamentDiameter = plate.filamentDiameterMm ?? topLevelFilamentDiameter;
} else {
topLevelPrintTime = plates.reduce((sum, p) => sum + (p.gcodePrintTimeSeconds || 0), 0);
topLevelFilamentWeight = plates.reduce((sum, p) => sum + (p.filamentUsedGrams || 0), 0);
topLevelFilamentUsedMm = plates.reduce((sum, p) => sum + (p.filamentUsedMm || 0), 0);
topLevelFilamentUsedCm3 = plates.reduce((sum, p) => sum + (p.filamentUsedCm3 || 0), 0);
topLevelLayers = Math.max(...plates.map((p) => p.totalLayers || 0));
topLevelMaxZ = Math.max(...plates.map((p) => p.maxLayerZ || 0));
const firstPlate = plates[0];
topLevelFilamentDensity = firstPlate.filamentDensityGramsCm3 ?? topLevelFilamentDensity;
topLevelSlicerVersion = firstPlate.slicerVersion ?? topLevelSlicerVersion;
topLevelNozzleDiameter = firstPlate.nozzleDiameterMm ?? topLevelNozzleDiameter;
topLevelLayerHeight = firstPlate.layerHeight ?? topLevelLayerHeight;
topLevelFirstLayerHeight = firstPlate.firstLayerHeight ?? topLevelFirstLayerHeight;
topLevelBedTemp = firstPlate.bedTemperature ?? topLevelBedTemp;
topLevelNozzleTemp = firstPlate.nozzleTemperature ?? topLevelNozzleTemp;
topLevelFillDensity = firstPlate.fillDensity ?? topLevelFillDensity;
topLevelFilamentType = firstPlate.filamentType ?? topLevelFilamentType;
topLevelPrinterModel = firstPlate.printerModel ?? topLevelPrinterModel;
topLevelFilamentDiameter = firstPlate.filamentDiameterMm ?? topLevelFilamentDiameter;
}
const normalized = {
fileName,
fileFormat: "3mf",
fileSize: stats.size,
isMultiPlate,
totalPlates: plates.length || 1,
gcodePrintTimeSeconds: topLevelPrintTime,
nozzleDiameterMm: topLevelNozzleDiameter,
filamentDiameterMm: topLevelFilamentDiameter,
filamentDensityGramsCm3: topLevelFilamentDensity,
filamentUsedMm: topLevelFilamentUsedMm,
filamentUsedCm3: topLevelFilamentUsedCm3,
filamentUsedGrams: topLevelFilamentWeight,
totalFilamentUsedGrams: topLevelFilamentWeight,
layerHeight: topLevelLayerHeight,
firstLayerHeight: topLevelFirstLayerHeight,
bedTemperature: topLevelBedTemp,
nozzleTemperature: topLevelNozzleTemp,
fillDensity: topLevelFillDensity,
filamentType: topLevelFilamentType,
printerModel: topLevelPrinterModel,
slicerVersion: topLevelSlicerVersion,
maxLayerZ: topLevelMaxZ,
totalLayers: topLevelLayers,
thumbnails: thumbnails.length > 0 ? thumbnails.map((t) => ({
width: t.width,
height: t.height,
format: t.format,
dataLength: t.data?.length || 0
})) : void 0,
plates: plates.length > 0 ? plates : void 0
};
return {
raw: {
_thumbnails: thumbnails,
plates: plates.length > 0 ? plates : void 0
},
normalized,
plates: plates.length > 0 ? plates : void 0
};
}
normalizeJsonMetadata(jsonData) {
const metadata = {};
if (jsonData.nozzleDiameter !== void 0) metadata.nozzleDiameter = String(jsonData.nozzleDiameter);
if (jsonData.estimatedPrintTimeSec !== void 0) metadata.printTime = String(jsonData.estimatedPrintTimeSec);
if (jsonData.filamentDiameter !== void 0) metadata.filamentDiameter = String(jsonData.filamentDiameter);
if (jsonData.filamentDensity !== void 0) metadata.filamentDensity = String(jsonData.filamentDensity);
if (jsonData.filamentUsedGrams !== void 0) metadata.filamentWeight = String(jsonData.filamentUsedGrams);
if (jsonData.layerHeight !== void 0) metadata.layerHeight = String(jsonData.layerHeight);
if (jsonData.firstLayerHeight !== void 0) metadata.firstLayerHeight = String(jsonData.firstLayerHeight);
if (jsonData.bedTemp !== void 0) metadata.bedTemp = String(jsonData.bedTemp);
if (jsonData.nozzleTemp !== void 0) metadata.nozzleTemp = String(jsonData.nozzleTemp);
if (jsonData.fillDensity !== void 0) metadata.infillDensity = String(jsonData.fillDensity);
if (jsonData.filamentType !== void 0) metadata.filamentType = jsonData.filamentType;
if (jsonData.printerModel !== void 0) metadata.printerModel = jsonData.printerModel;
return metadata;
}
extractMetadataFromXML(xml) {
const metadata = {};
for (const pattern of [
/<printtime>([^<]+)<\/printtime>/i,
/<layerheight>([^<]+)<\/layerheight>/i,
/<filamentused>([^<]+)<\/filamentused>/i,
/<filamenttype>([^<]+)<\/filamenttype>/i,
/<nozzlediameter>([^<]+)<\/nozzlediameter>/i,
/<bedtemperature>([^<]+)<\/bedtemperature>/i,
/<nozzletemperature>([^<]+)<\/nozzletemperature>/i
]) {
const match = xml.match(pattern);
if (match) {
const key = pattern.source.match(/<([^>]+)>/)?.[1] || "";
metadata[key] = match[1];
}
}
const generatorMatch = xml.match(/generator="([^"]+)"/);
if (generatorMatch) {
metadata.generator = generatorMatch[1];
metadata.slicerVersion = generatorMatch[1];
}
return metadata;
}
extractPlates(zipEntries) {
const plates = [];
const plateEntries = zipEntries.filter((e) => e.entryName.match(/Metadata\/plate_\d+\.gcode$/) && !e.entryName.endsWith(".md5"));
if (plateEntries.length === 0) return [];
for (const entry of plateEntries) {
const plateMatch = entry.entryName.match(/plate_(\d+)\.gcode/);
if (!plateMatch) continue;
const plateNumber = Number.parseInt(plateMatch[1]);
const gcodeContent = entry.getData().toString("utf8", 0, Math.min(5e4, entry.getData().length));
const metadata = this.parseGCodeHeader(gcodeContent);
const plateThumbnails = zipEntries.filter((e) => e.entryName.includes(`plate_${plateMatch[1]}`) && (e.entryName.endsWith(".png") || e.entryName.endsWith(".jpg"))).map((t) => {
const format = t.entryName.endsWith(".png") ? "PNG" : "JPG";
const imageData = t.getData();
const sizeMatch = t.entryName.match(/(\d+)x(\d+)/);
let width = sizeMatch ? Number.parseInt(sizeMatch[1]) : 0;
let height = sizeMatch ? Number.parseInt(sizeMatch[2]) : 0;
if (width === 0 || height === 0) {
const dimensions = getImageDimensions(imageData, format);
width = dimensions.width;
height = dimensions.height;
}
return {
width,
height,
format,
data: imageData.toString("base64")
};
});
const plateMetadata = {
plateNumber,
gcodePrintTimeSeconds: this.parseTime(metadata.model_printing_time || metadata.total_estimated_time || metadata.print_time),
filamentUsedGrams: this.parseFloat(metadata.total_filament_weight_g || metadata.filament_weight || metadata.total_filament_weight),
totalLayers: this.parseInt(metadata.total_layer_number || metadata.layer_count || metadata.total_layers),
filamentUsedMm: this.parseFloat(metadata.total_filament_length_mm || metadata.filament_length_mm || metadata.filament_used_mm),
filamentUsedCm3: this.parseFloat(metadata.total_filament_volume_cm3 || metadata.filament_volume_cm3),
filamentDensityGramsCm3: this.parseFloat(metadata.filament_density),
filamentDiameterMm: this.parseFloat(metadata.filament_diameter) || 1.75,
maxLayerZ: this.parseFloat(metadata.max_z_height || metadata.max_layer_z),
slicerVersion: metadata.bambustudio || metadata.slicer_version || metadata.slicer || null,
nozzleDiameterMm: this.parseFloat(metadata.nozzle_diameter),
layerHeight: this.parseFloat(metadata.layer_height),
firstLayerHeight: this.parseFloat(metadata.first_layer_height || metadata.initial_layer_height || metadata.initial_layer_print_height),
bedTemperature: this.parseFloat(metadata.bed_temperature_actual || metadata.bed_temperature || metadata.bed_temp || metadata.bed_temperature_initial_layer),
nozzleTemperature: this.parseFloat(metadata.nozzle_temperature || metadata.nozzle_temp || metadata.nozzle_temperature_initial_layer),
fillDensity: metadata.sparse_infill_density || metadata.infill_density || metadata.fill_density || null,
filamentType: metadata.filament_type || null,
printerModel: metadata.printer_model || null,
objects: [],
thumbnails: plateThumbnails.length > 0 ? plateThumbnails.map((t) => ({
width: t.width,
height: t.height,
format: t.format,
dataLength: t.data?.length || 0
})) : void 0
};
plates.push(plateMetadata);
}
return plates.sort((a, b) => a.plateNumber - b.plateNumber);
}
parseGCodeHeader(gcode) {
const metadata = {};
const lines = gcode.split("\n").slice(0, 1e3);
for (const line of lines) if (line.startsWith(";")) {
if (line.includes("_BLOCK_START") || line.includes("_BLOCK_END")) continue;
const bambuMatch = line.match(/;\s*BambuStudio\s+([\d.]+)/i);
if (bambuMatch) {
metadata.bambustudio = `BambuStudio ${bambuMatch[1]}`;
continue;
}
const match = line.match(/;\s*([^=:]+?)\s*[:=]\s*(.+)/);
if (match) {
let key = match[1].trim().toLowerCase().replace(/\s+/g, "_");
let value = match[2].trim();
key = key.replace(/\[([^\]]+)\]/g, (_, unit) => unit.replace(/\^/g, ""));
value = value.split(";")[0].trim();
value = value.replace(/\s*\[.*?\]\s*/g, "");
if (!metadata[key]) metadata[key] = value;
}
} else if (!metadata.bed_temperature_actual) {
const bedTempMatch = line.match(/^M1(40|90)\s+S(\d+)/);
if (bedTempMatch && Number.parseInt(bedTempMatch[2]) > 0) metadata.bed_temperature_actual = bedTempMatch[2];
}
return metadata;
}
extractThumbnails(zipEntries) {
const thumbnails = [];
const thumbEntries = zipEntries.filter((e) => e.entryName.match(/Metadata\/.*\.(png|jpg|jpeg)/i) || e.entryName.match(/Thumbnails\/.*/i));
for (const entry of thumbEntries) {
const format = entry.entryName.match(/\.(png|jpg|jpeg)$/i)?.[1].toUpperCase() || "PNG";
const imageData = entry.getData();
const sizeMatch = entry.entryName.match(/(\d+)x(\d+)/);
let width = sizeMatch ? Number.parseInt(sizeMatch[1]) : 0;
let height = sizeMatch ? Number.parseInt(sizeMatch[2]) : 0;
if (width === 0 || height === 0) {
const dimensions = getImageDimensions(imageData, format);
width = dimensions.width;
height = dimensions.height;
}
const base64Data = imageData.toString("base64");
thumbnails.push({
width,
height,
format,
data: base64Data
});
}
return thumbnails;
}
parseFloat(value) {
if (!value) return null;
const num = Number.parseFloat(value);
return Number.isNaN(num) ? null : num;
}
parseInt(value) {
if (!value) return null;
const num = Number.parseInt(value, 10);
return Number.isNaN(num) ? null : num;
}
parseTime(value) {
if (!value) return null;
const match = value.match(/(?:(\d+)h)?(?:\s*(\d+)m)?(?:\s*(\d+)s)?/);
if (match && (match[1] || match[2] || match[3])) {
const hours = Number.parseInt(match[1] || "0");
const minutes = Number.parseInt(match[2] || "0");
const secs = Number.parseInt(match[3] || "0");
return hours * 3600 + minutes * 60 + secs;
}
const seconds = Number.parseFloat(value);
if (!Number.isNaN(seconds)) return seconds;
return null;
}
};
//#endregion
export { ThreeMFParser };
//# sourceMappingURL=3mf.parser.js.map