UNPKG

@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
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