
Blog
3MF File Format: How Bambu Studio and Orca Slicer Structure Your Prints
April 13, 2026
A complete breakdown of the 3MF file format as used by Bambu Studio and Orca Slicer — the ZIP structure, every metadata file, how to extract filament usage, print time, slicer version, and the gotchas we hit building Printago's cloud slicing pipeline.
The 3MF format is an open standard, but Bambu Lab and Orca Slicer extend it heavily with proprietary metadata files that aren't documented anywhere. If you're building tooling that reads or writes 3MF files — a cloud slicer, a print farm management system, a file processing pipeline — you'll eventually need to understand what's actually inside these files.
This is the reference we wish had existed when we started building Printago.
The 3MF Is Just a ZIP
Start here: rename any .3mf file to .zip and open it. The 3MF container format is a ZIP archive. Everything inside is plain text (XML or JSON) or binary image data. You don't need a 3MF SDK to work with it — any ZIP library works.
import JSZip from "jszip";
const zip = await JSZip.loadAsync(buffer);
const files = Object.keys(zip.files);
// → ['3D/3dmodel.model', 'Metadata/project_settings.config', ...]A Bambu/Orca 3MF project file (not yet sliced) looks roughly like this:
3D/
3dmodel.model ← geometry + application metadata
Metadata/
project_settings.config ← JSON: all slicer settings
model_settings.config ← XML: plate layout, object positions
custom_gcode_per_layer.xml ← XML: tool change events
[thumbnail images]
[Content_Types].xml
_rels/.relsA sliced .gcode.3mf (the output from --export-3mf) adds one set of files per plate, where N is the 1-based plate index:
Metadata/
plate_1.gcode ← the actual G-code (plate 1)
plate_1.png ← plate thumbnail (blank if sliced headlessly)
plate_1_no_light.png ← no-lighting thumbnail variant
top_plate_1.png ← top-down view
pick_plate_1.png ← isometric pick view
plate_2.gcode ← G-code for plate 2 (if multi-plate)
plate_2.png
...
slice_info.config ← XML: filament usage + time estimates for all plates3D/3dmodel.model — Geometry and Slicer Version
The main geometry file is standard 3MF XML. For our purposes, the useful part is the <metadata> block near the top, which tells you which application created the file:
<metadata name="Application">OrcaSlicer-2.2.0</metadata>
<!-- or -->
<metadata name="Application">BambuStudio-01.10.00.89</metadata>This is one of two ways to detect the slicer version (the other is slice_info.config, covered below). The format is AppName-Version where the version string differs between the two slicers: Orca Slicer uses semantic version (2.2.0), Bambu Studio uses a zero-padded build number (01.10.00.89).
const xmlContent = await zip.files["3D/3dmodel.model"].async("text");
const match = xmlContent.match(/<metadata\s+name="Application">([^<]+)<\/metadata>/);
// match[1] → "OrcaSlicer-2.3.2" or "BambuStudio-01.10.00.89"Metadata/project_settings.config — The Settings JSON
This is the most important file in the archive. It's a flat JSON object containing every resolved slicer setting used for this project. The full file can be thousands of keys, but the ones you actually need for programmatic use are:
Printer identity
{
"printer_model": "Bambu Lab X1 Carbon",
"printer_variant": "0.4"
}printer_model is the canonical machine identifier. printer_variant is the nozzle diameter as a string ("0.4", "0.6", etc.). When routing a 3MF to a different printer than it was sliced for, you need to patch printer_model in this file before re-slicing — otherwise the slicer rejects the mismatch or produces incorrect output.
Filament configuration
{
"filament_type": ["PLA", "PLA", "PETG"],
"filament_colour": ["#FF0000", "#FFFFFF", "#0000FF"],
"filament_ids": ["GFB98A2CA6", "GFB98A2CA6", "GFB98A3A2F"],
"filament_density": ["1.24", "1.24", "1.27"],
"filament_diameter": ["1.75", "1.75", "1.75"]
}All of these are parallel arrays indexed by filament slot (0-based). A 4-slot AMS setup will have 4 entries. filament_ids are the profile identifiers used for version-aware profile lookup.
Important: These represent the filaments configured in the project, not necessarily the filaments used on every plate. A 4-filament project might only use 2 filaments on a given plate — use slice_info.config for per-plate actual usage.
Printer compatibility
{
"print_compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab P1S 0.4 nozzle"],
"upward_compatible_machine": ["Bambu Lab X1E 0.4 nozzle"]
}These lists tell you which printers can safely print this file. print_compatible_printers are machines the slicer certified as fully compatible. upward_compatible_machine are machines that are upward-compatible (better specs, can handle this G-code safely). The sliced-for machine is ${printer_model} ${printer_variant} nozzle and won't appear in either list.
Support filaments
{
"enable_support": "1",
"support_filament": "2",
"support_interface_filament": "3"
}When supports are enabled, support_filament and support_interface_filament are 1-indexed slot numbers indicating which filament to use for support structures. A value of "0" means use the object filament. Important for correctly determining which filament slots a plate will actually consume — the slice_info.config filament entries won't include support filament if it matches the object filament.
Metadata/model_settings.config — Plates and Object Layout
This XML file defines the plate structure. Each <plate> block contains <metadata> key-value pairs:
<config>
<plate>
<metadata key="plater_id" value="1"/>
<metadata key="plater_name" value=""/>
<metadata key="gcode_file" value="Metadata/plate_1.gcode"/>
<metadata key="thumbnail_file" value="Metadata/plate_1.png"/>
<metadata key="thumbnail_no_light_file" value="Metadata/plate_1_no_light.png"/>
<metadata key="top_file" value="Metadata/top_plate_1.png"/>
<metadata key="pick_file" value="Metadata/pick_plate_1.png"/>
</plate>
<plate>
<metadata key="plater_id" value="2"/>
...
</plate>
</config>Key fields:
| Key | Description |
|---|---|
plater_id |
1-based plate index. This is what you pass to --slice on the CLI. |
plater_name |
User-defined plate name. |
gcode_file |
Path within the ZIP to the G-code for this plate. Empty if not yet sliced. |
thumbnail_file |
Path to the plate thumbnail PNG. |
thumbnail_no_light_file |
Flat-lit variant of the thumbnail. |
top_file |
Top-down orthographic view. |
pick_file |
Isometric pick/preview image. |
A plate with an empty gcode_file has not been sliced. Use this to determine which plates in a project file still need slicing.
Detecting pre-sliced files by content
Checking for .gcode entries in the ZIP is more reliable than relying on filenames, since OS file renaming (duplicate detection) can produce names like model.gcode (1).3mf:
const isSliced = Object.keys(zip.files).some((f) =>
/^metadata\/.*\.gcode$/i.test(f)
);Metadata/slice_info.config — Print Time and Filament Usage
This file exists only in sliced .gcode.3mf files. It's XML and contains the most useful post-slice data: time estimates, filament consumption, and warnings per plate.
<config>
<header>
<header_item key="X-BBL-Client-Type" value="slicer"/>
<header_item key="X-BBL-Client-Version" value="01.10.00.89"/>
</header>
<plate>
<metadata key="index" value="1"/>
<metadata key="prediction" value="4823"/>
<metadata key="weight" value="18.46"/>
<metadata key="support_used" value="false"/>
<metadata key="outside" value="false"/>
<metadata key="printer_model_id" value="Bambu Lab X1 Carbon"/>
<metadata key="nozzle_diameters" value="0.4"/>
<filament id="1" tray_info_idx="GFB98A2CA6" type="PLA" color="#FF0000" used_m="4.823" used_g="14.21"/>
<filament id="2" tray_info_idx="GFB98A2CA6" type="PLA" color="#FFFFFF" used_m="1.432" used_g="4.25"/>
<warning msg="Filament PLA_CONTACT not available" level="1" errorCode="1000C001"/>
</plate>
</config>Slicer version from the header
The X-BBL-Client-Version header is the most reliable way to detect the slicer version in a sliced file. It's present in both Bambu Studio and Orca Slicer output:
const versionMatch = xmlContent.match(
/<header_item\s+key="X-BBL-Client-Version"\s+value="([^"]+)"/
);
// versionMatch[1] → "01.10.00.89" (BambuStudio) or "2.3.2" (OrcaSlicer)Per-plate metadata keys
| Key | Type | Description |
|---|---|---|
index |
integer | 1-based plate number |
prediction |
float | Estimated print time in seconds |
weight |
float | Total filament weight in grams |
support_used |
bool | Whether supports are present on this plate |
outside |
bool | Whether any objects on this plate are outside the build volume |
printer_model_id |
string | Printer model this was sliced for |
nozzle_diameters |
string | Nozzle diameter(s) used |
label_object_enabled |
bool | Whether object labeling is enabled |
filament_maps |
string | Internal filament remapping data |
Filament usage per slot
Each <filament> element represents one slot's usage on that plate:
| Attribute | Description |
|---|---|
id |
1-based slot index. Subtract 1 for 0-based array indexing. |
tray_info_idx |
Filament profile identifier (matches filament_ids in project_settings) |
type |
Filament material type (e.g., "PLA", "PETG", "ABS") |
color |
Filament color as hex string |
used_m |
Filament used in meters. Multiply by 1000 to get millimeters. |
used_g |
Filament used in grams |
The id → slot mapping is 1-indexed. Slot "1" is logical slot 0. This trips up almost everyone the first time.
for (const filament of plateInfo.filaments) {
const logicalSlot = parseInt(filament.id) - 1; // Convert to 0-based
const usedMm = filament.usedM * 1000; // Convert meters to mm
const usedGrams = filament.usedG;
}Computing grams-per-mm for cost estimation
If you have the filament density and diameter from project_settings.config, you can compute grams-per-mm for cost/weight estimation without relying on the slicer's pre-computed value:
function gramsPerMm(density: number, diameterMm: number): number {
const radius = diameterMm / 2;
return (density * Math.PI * radius * radius) / 1000;
}
// density from filament_density[slot], diameter from filament_diameter[slot]
const ratio = gramsPerMm(1.24, 1.75); // → ~0.002954 g/mm for standard PLAMetadata/custom_gcode_per_layer.xml — Tool Changes
For multi-color prints, this file records the tool change events that happen at specific layers. It's the authoritative source for which extruders a plate actually uses — more reliable than project_settings.config for detecting active slots, because it records the actual layer-level extruder transitions.
<custom_gcodes_per_layer>
<plate>
<plate_info id="1"/>
<layer top_z="0.2" type="1" extruder="1" color="#FF0000" gcode="tool_change"/>
<layer top_z="4.8" type="1" extruder="2" color="#FFFFFF" gcode="tool_change"/>
<layer top_z="9.6" type="1" extruder="1" color="#FF0000" gcode="tool_change"/>
</plate>
</custom_gcodes_per_layer>One important caveat: the color attribute on each tool change event is stale — it reflects the color at the time the tool change was configured in the GUI, not the current filament color. Always use project_settings.config's filament_colour array as the authoritative color source.
Extracting Colors from an Unsliced 3MF
Determining what filaments a 3MF actually needs — before slicing — requires reading from four different places, each covering a different way Bambu Studio assigns colors to geometry.
Source 1: Configured filament slots (project_settings.config)
The starting point: what filament slots exist in the project and what colors are assigned to them.
const filamentColors: string[] = configData.filament_colour; // ["#FF0000", "#FFFFFF", "#0000FF"]
const filamentTypes: string[] = configData.filament_type; // ["PLA", "PLA", "PETG"]These are 0-based parallel arrays. A 4-slot project has 4 entries, but not every slot is necessarily used on every plate. This is the authoritative source for color hex values — never use any other file for the actual color.
Source 2: Per-object base filament (model_settings.config)
Each object in the model has a base filament slot assigned via the extruder metadata key:
<object id="3">
<metadata key="name" value="Body"/>
<metadata key="extruder" value="1"/> <!-- 1-based slot index -->
</object>For models without any paint, this is the only color signal you need. Multi-part objects (assembled from separate body/support meshes) can have different extruder values per part:
<object id="5">
<part id="6" subtype="normal_part">
<metadata key="name" value="Cap"/>
<metadata key="extruder" value="2"/>
</part>
<part id="7" subtype="normal_part">
<metadata key="name" value="Base"/>
<metadata key="extruder" value="1"/>
</part>
</object>Note that negative parts (subtype other than "normal_part") don't consume filament and should be skipped when enumerating active slots.
Source 3: Triangle paint attributes in nested model files
This is the most obscure part. When you use Bambu Studio's multi-color painting brush, the paint data is stored as a paint_color attribute directly on individual <triangle> elements inside the object's .model file:
<mesh>
<vertices>...</vertices>
<triangles>
<triangle v1="0" v2="1" v3="2" paint_color="4"/> <!-- painted with slot 1 -->
<triangle v1="3" v2="4" v3="5" paint_color="8"/> <!-- painted with slot 2 -->
<triangle v1="6" v2="7" v3="8"/> <!-- unpainted — uses object base filament -->
</triangles>
</mesh>These .model files are not in the root — they live in 3D/Objects/ and are referenced from the main 3D/3dmodel.model via p:path attributes on component elements. You have to load each referenced file separately.
The encoding: paint_color is not a hex color — it's a bitmask string encoding which filament slots are painted onto that triangle. Bambu Studio uses a fixed set of code strings, one per slot:
| Slot | Code | Slot | Code |
|---|---|---|---|
| 1 | 4 |
9 | 8C |
| 2 | 8 |
10 | 9C |
| 3 | 0C |
11 | AC |
| 4 | 1C |
12 | BC |
| 5 | 2C |
13 | CC |
| 6 | 3C |
14 | DC |
| 7 | 4C |
15 | EC |
| 8 | 5C |
16 | FC |
To decode which slots are active for a given triangle, iterate from highest index to lowest and strip each code from the string as you find it. The ordering matters — stripping from highest to lowest prevents partial matches (e.g., "8C" being caught by the "8" pattern for slot 2):
const SLOT_CODES = ["4", "8", "0C", "1C", "2C", "3C", "4C", "5C",
"6C", "7C", "8C", "9C", "AC", "BC", "CC", "DC"];
function decodePaintColor(paintColor: string): number[] {
const slots: number[] = [];
for (let i = SLOT_CODES.length - 1; i >= 0; i--) {
if (paintColor.includes(SLOT_CODES[i])) {
paintColor = paintColor.replaceAll(SLOT_CODES[i], "");
slots.push(i + 1); // 1-based slot index
}
}
return slots;
}Handling the base filament: count total triangles vs. triangles with paint_color. If any triangles are unpainted, the object's base filament (from model_settings.config) is also needed. If every triangle has a paint_color, the base filament is not consumed.
const totalTriangles = (xmlContent.match(/<triangle\s[^>]*>/g) ?? []).length;
const paintedTriangles = (xmlContent.match(/<triangle\s[^>]*paint_color="[^"]+"/g) ?? []).length;
const needsBaseFilament = totalTriangles > paintedTriangles;Source 4: Tool change events (custom_gcode_per_layer.xml)
A supplementary signal — this records which extruders activate at which layers on each plate. Useful as a cross-check for which slots are active:
const activeSlots = new Set<number>();
for (const layer of plateElement.layers) {
if (layer.gcode === "tool_change") {
activeSlots.add(parseInt(layer.extruder)); // 1-based
}
}The color attribute on each <layer> element is stale — it reflects the color at the time the tool change was added to the file, not the current project color. Never use it as the actual color value.
Don't forget support filaments from project_settings.config:
if (configData.enable_support !== "0") {
const supportSlot = parseInt(configData.support_filament ?? "0");
if (supportSlot > 0) activeSlots.add(supportSlot);
const interfaceSlot = parseInt(configData.support_interface_filament ?? "0");
if (interfaceSlot > 0) activeSlots.add(interfaceSlot);
}Putting it together
The full pre-slice color detection algorithm, in priority order:
- Load
project_settings.config→ build slot index (0-based) mapping slot →{ color, type } - Load
model_settings.config→ map each object and part to its base extruder slot (1-based) - Load
3D/3dmodel.model→ find object IDs and their componentp:pathreferences to nested.modelfiles - For each nested
.modelfile, scan trianglepaint_colorattributes → decode which slots are painted; if any triangles are unpainted, also include the object's base extruder - Load
custom_gcode_per_layer.xml→ extract tool change extruder values per plate as a supplementary signal - For each plate, union the slots from steps 2–5; look up the actual hex color from step 1
The color hex values always come from filament_colour in project_settings.config. Every other source tells you which slots are active, not what color those slots are.
Thumbnails
Thumbnail images are stored as PNG files directly in the ZIP. In a sliced .gcode.3mf, there are up to four thumbnail variants per plate:
| File | Description |
|---|---|
Metadata/plate_N.png |
Main plate thumbnail (perspective, with lighting) |
Metadata/plate_N_no_light.png |
Flat-shaded variant |
Metadata/top_plate_N.png |
Top-down orthographic view |
Metadata/pick_plate_N.png |
Isometric "pick" preview |
Thumbnails are blank when sliced headlessly. The Orca Slicer CLI cannot render thumbnails because it requires OpenGL. If you slice via the CLI and need thumbnails, you'll need to generate them separately or extract them from the original unsliced 3MF.
const thumbnailFile = zip.files["Metadata/plate_1.png"];
if (thumbnailFile) {
const buffer = await thumbnailFile.async("nodebuffer");
const base64 = buffer.toString("base64");
const dataUrl = `data:image/png;base64,${base64}`;
}Gotchas
printer_model must match when re-routing prints
The printer_model value in project_settings.config must match your machine settings JSON when slicing. If you're sending a 3MF originally prepared for an X1C to a P1S, patch the printer_model inside the 3MF before invoking the slicer:
const config = JSON.parse(await configFile.async("text"));
config.printer_model = targetMachineSettings.printer_model;
zip.file("Metadata/project_settings.config", JSON.stringify(config, null, 2));used_m is in meters
The used_m attribute in slice_info.config filament elements is meters. Multiply by 1000 to get millimeters before comparing against or storing alongside G-code extrusion distances, which are always in mm.
Filament slot IDs are 1-indexed
Every filament slot reference in slice_info (id="1" means slot 1, logical index 0) and in the assemble list for CLI slicing (filaments: [1] means slot 1) is 1-based. The filament_colour and filament_type arrays in project_settings.config are 0-based. These coordinate systems collide constantly.
The output format is .gcode.3mf, not .gcode
orca-slicer --export-3mf produces a 3MF archive that contains G-code at Metadata/plate_N.gcode. It is not a raw .gcode file. Bambu printers accept .gcode.3mf natively — in fact they prefer it because it includes metadata and print time estimates. If your downstream process expects raw G-code, unzip the archive and extract the G-code file directly.
XML entity encoding in metadata values
Values in model_settings.config XML attributes are entity-encoded. A file path containing & will appear as &. Always decode before using:
function decodeXmlEntities(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'");
}Quick Reference: Reading a 3MF in Node.js
import JSZip from "jszip";
import fs from "fs";
async function read3MF(filePath: string) {
const buffer = fs.readFileSync(filePath);
const zip = await JSZip.loadAsync(buffer);
// 1. Is it already sliced?
const isSliced = Object.keys(zip.files).some((f) =>
/^metadata\/.*\.gcode$/i.test(f)
);
// 2. Slicer identity — try slice_info first (sliced files only)
let slicerVersion: string | null = null;
const sliceInfoFile = zip.files["Metadata/slice_info.config"];
if (sliceInfoFile) {
const xml = await sliceInfoFile.async("text");
const m = xml.match(/<header_item\s+key="X-BBL-Client-Version"\s+value="([^"]+)"/);
if (m) slicerVersion = m[1];
}
// Fallback: read from 3dmodel.model
if (!slicerVersion) {
const modelFile = zip.files["3D/3dmodel.model"];
if (modelFile) {
const xml = await modelFile.async("text");
const m = xml.match(/<metadata\s+name="Application">([^<]+)<\/metadata>/);
if (m) slicerVersion = m[1]; // e.g. "OrcaSlicer-2.3.2"
}
}
// 3. Core settings
const settingsFile = zip.files["Metadata/project_settings.config"];
const settings = settingsFile
? JSON.parse(await settingsFile.async("text"))
: null;
const printerModel = settings?.printer_model;
const nozzleDiameter = settings?.printer_variant;
const filamentTypes: string[] = settings?.filament_type ?? [];
const filamentColors: string[] = settings?.filament_colour ?? [];
// 4. Per-plate filament usage (sliced files only)
if (sliceInfoFile) {
const xml = await sliceInfoFile.async("text");
const plateRegex = /<plate>([\s\S]*?)<\/plate>/g;
let plateMatch;
while ((plateMatch = plateRegex.exec(xml)) !== null) {
const content = plateMatch[1];
const indexMatch = content.match(/<metadata\s+key="index"\s+value="(\d+)"/);
const predMatch = content.match(/<metadata\s+key="prediction"\s+value="([^"]+)"/);
const filamentRegex = /id="(\d+)".+?used_m="([^"]+)"\s+used_g="([^"]+)"/g;
let fm;
console.log(`Plate ${indexMatch?.[1]}: ~${Math.round(Number(predMatch?.[1]) / 60)}min`);
while ((fm = filamentRegex.exec(content)) !== null) {
const slot = parseInt(fm[1]) - 1; // Convert to 0-based
const usedMm = parseFloat(fm[2]) * 1000; // Convert m → mm
const usedG = parseFloat(fm[3]);
console.log(` Slot ${slot}: ${usedMm.toFixed(1)}mm / ${usedG.toFixed(2)}g`);
}
}
}
return { isSliced, slicerVersion, printerModel, nozzleDiameter, filamentTypes, filamentColors };
}This file format is how Printago reads the output of every cloud slice — extracting filament usage for cost tracking, print time for queue scheduling, and compatibility data for routing jobs to the right printer. If you're building anything in this space, JSZip plus a few regexes gets you most of the way there.
Sign up for free today
No credit card required. Connect unlimited printers and get production automation running in minutes.