Date: January 27, 2026
What's actually inside a Bambu Studio .3mf file and what can we control programmatically. This is the stuff that lets us batch-modify print settings, automate plate naming, and cut waste without touching the GUI.
A .3mf file is just a ZIP archive. Rename it to .zip, unzip it, and you get a folder structure with XML, JSON, and image files. Everything Bambu Studio knows about your project lives in these files. Which means everything is scriptable.
Here's what's inside:
| File | Format | What It Does |
|---|---|---|
Metadata/project_settings.config |
JSON | ALL print settings. The motherload. |
Metadata/model_settings.config |
XML | Object definitions, plate assignments, plate names |
Metadata/plate_N.json |
JSON | Per-plate layout data (bounding boxes, positions). Only exists for sliced plates. |
Metadata/plate_N.png |
PNG | Plate thumbnail images |
Metadata/plate_no_light_N.png |
PNG | Thumbnails without lighting |
Metadata/top_N.png |
PNG | Top-down view thumbnails |
Metadata/pick_N.png |
PNG | Pick thumbnails |
Metadata/slice_info.config |
XML | Slicer version info |
3D/3dmodel.model |
XML | 3D geometry and build items |
3D/Objects/object_N.model |
XML | Individual object geometry files |
Metadata/project_settings.config is where the action is. It's a JSON file with every print setting Bambu Studio exposes. Here are the ones that matter for us:
flush_multiplier- Currently['1']. Controls overall purge waste scaling. We proved0.8works on Paw Pals heads. Less purge = less waste = faster prints.flush_volumes_matrix- The exact flush volumes between each filament pair. This is the matrix you see in Bambu Studio's flushing dialog. Every color-to-color transition has a specific purge amount.flush_into_infill- Currently0(OFF). This is a big one. When enabled, purge waste gets deposited INTO the model's infill instead of building a separate purge tower. Huge potential waste reduction.prime_tower_width- Currently35. Width of the purge tower.
sparse_infill_density- Currently13%, tested11%successfully. Lower = less filament + faster prints.sparse_infill_pattern- Currentlygyroid.
filament_cost- Array of costs per filament slot. Currently WRONG in our project files. Needs actual prices so the slicer gives us accurate cost estimates.filament_colour- Hex color values for each filament slot.
Plate names live in Metadata/model_settings.config. The structure looks like this:
<plate>
<metadata key="plater_name" value="MICRO & EARRINGS"/>
<model_instance>
<metadata key="object_id" value="2"/>
<!-- ... -->
</model_instance>
</plate>Important gotcha: the XML key is plater_name, not plate_name. Typo in the source code that stuck. Reference: PlateData.plate_name in BambuStudio's bbs_3mf.hpp.
Plate 1 with an empty name shows no label. Named plates get a green text label in Bambu Studio's UI.
Each object carries the name from its original STL file:
<metadata key="name" value="beasty_Pixie_3MF.stl"/>The chain works like this: Plate → model_instance → object_id → STL name. You can programmatically figure out what's on each plate.
Example from a real file: Plate 2 "MICRO & EARRINGS" had 44x beasty_Pixie_3MF.stl (micros) and 9x beasty_Pixie_Keychain_3MF.stl (earrings).
Each build item in 3D/3dmodel.model has a transform attribute — a 3x4 affine matrix. The column vector magnitudes give you X/Y/Z scale. So you can programmatically tell how big each object is relative to its original STL.
Example from the Nightbloom Pixie file:
| Plate | Objects | Scale |
|---|---|---|
| Plate 1 (unnamed) | Pixies + Keychains | 100% (full size) |
| Plate 2 "MICRO & EARRINGS" | Pixies + Keychains | 54% (micros/earrings) |
Same STL on both plates, just scaled differently. The transform matrix tells you exactly what percentage each object is at. This means a script can look at any 3MF and know what size each model is printing at — useful for COGS calculations where the same model prints at different sizes for different product lines (sips vs micros vs earrings).
# Scale extraction from transform matrix
# Transform is 12 floats: row-major 3x4
# sx = sqrt(m[0]² + m[3]² + m[6]²)
# sy = sqrt(m[1]² + m[4]² + m[7]²)
# sz = sqrt(m[2]² + m[5]² + m[8]²)
Plates are laid out in a 2-column grid. Coordinates are absolute across all plates — not local to each plate. X increases to the right, Y decreases downward.
Grid layout:
Plate 1 (0,0) Plate 2 (1,0)
Plate 3 (0,1) Plate 4 (1,1)
Plate 5 (0,2) Plate 6 (1,2)
...
Offset between plates: ~303mm in each direction.
| Plate | Grid Position | X Offset | Y Offset |
|---|---|---|---|
| Plate 1 | (0,0) | 0 | 0 |
| Plate 2 | (1,0) | +303 | 0 |
| Plate 3 | (0,1) | 0 | -303 |
| Plate 4 | (1,1) | +303 | -303 |
Measured from the Nightbloom Pixie file:
- Plate 1: X=[39–229], Y=[43–184]
- Plate 2: X=[342–548], Y=[24–215]
- Plate 3: X=[244–], Y=[−240–]
When programmatically adding objects to a new plate, you take the coordinates from an existing plate and apply the grid offset. We successfully created Plate 3 "MICRO ONLY" by cloning Plate 2's pixie objects and shifting X by −303 and Y by −303 (moving from grid column 1 back to column 0, and down one row).
Metadata/model_settings.config: Add new<object>entries (deep copy from source objects) with fresh IDs, plus a new<plate>element withplater_id,plater_name, and<model_instance>children referencing the new object IDs3D/3dmodel.model: Add new<object>elements in<resources>(deep copy — must include<components>referencing the mesh sub-objects), plus new<item>entries in<build>with the grid-offset transforms- Re-zip and you're done
The object <resources> entries are critical — each object is component-based, referencing shared mesh sub-objects (e.g., object 20 references components 9–14 for body parts). Deep-copying preserves these references.
This one bit us. When you change a setting in project_settings.config programmatically, Bambu Studio may ignore it unless the setting name is also listed in different_settings_to_system. This is a semicolon-delimited list (per process profile slot) that tells Bambu "these settings override the system profile." Without the entry, Bambu falls back to the profile default and silently discards your value.
Example: We set prime_tower_width from '35' to '20' in the JSON. Bambu ignored it. When the same change was made through the GUI, Bambu added prime_tower_width and prime_tower_rib_width to different_settings_to_system:
"different_settings_to_system": [
"brim_type;initial_layer_print_height;...;prime_tower_rib_width;prime_tower_width;...",
...
]Rule: Any programmatic setting change must also be registered in different_settings_to_system. The array slots map like this:
different_settings_to_system: [
"[0] process profile overrides",
"[1] filament slot 1 overrides",
"[2] filament slot 2 overrides",
"[3] filament slot 3 overrides",
"[4] filament slot 4 overrides",
"[5] ???"
]
For process settings (prime tower width, infill, etc): add the setting name to slot [0].
For filament settings (prime volume, cost, etc): add the setting name to slots [1] through [4] (one per filament).
Entries are semicolon-separated and alphabetical. Example for filament prime volume on all 4 slots:
"different_settings_to_system": [
"brim_type;...;prime_tower_width;...",
"filament_prime_volume;supertack_plate_temp;...",
"filament_prime_volume;supertack_plate_temp;...",
"filament_prime_volume",
"filament_prime_volume",
""
]- All values are strings.
'35'not35. Wrong type = silently ignored. - Wipe tower positions are per-plate.
wipe_tower_xandwipe_tower_yare arrays — one entry per plate. Adding a plate means adding a position entry. - Filament prime volume rule of thumb: Set
filament_prime_volumeto prime tower width + 5mm. So width 20 → prime volume 25 mm³. - Filament config files use inheritance.
Metadata/filament_settings_N.configonly stores overrides from the parent profile (inheritskey). If a setting isn't listed, it uses the parent's default. But for programmatic changes, you don't need to touch these files — just set the value inproject_settings.configand register it indifferent_settings_to_system. Bambu reads the project-level array. - Filament config file numbering ≠ slot numbering. There may be only 2 filament config files for 4 slots. The mapping isn't straightforward. Stick to
project_settings.configarrays for programmatic changes.
Per-filament settings are arrays in project_settings.config, indexed by filament slot:
| Setting | Description | Current Values |
|---|---|---|
filament_prime_volume |
Purge on filament change (mm³) | ['25','25','25','25'] |
filament_prime_volume_nc |
Purge on hotend change (mm³) | ['60','60','60','60'] |
filament_cost |
Price per kg | ['24.52','24.52','29.99','24.15'] |
filament_colour |
Hex colors | ['#D1B2EC','#443089','#A03CF7','#FFFFFF'] |
filament_cooling_before_tower |
Cool before tower | ['0','0','0','0'] |
filament_minimal_purge_on_wipe_tower |
Min purge (mm) | ['15','15','15','15'] |
Per-filament config files also exist at Metadata/filament_settings_N.config but these use inheritance and only store overrides. For batch scripting, the project_settings.config arrays + different_settings_to_system registration is the reliable path.
- sip-hole-resizer-script-spec.md — Script spec for automatically finding sip-hole models in a 3MF and creating a new plate with resized copies (different sip sizes) while keeping the hole at exactly 10.2x10.2x55mm. Builds on the negative volume detection, selective scaling math, and joint awareness documented below.
- Turn on
flush_into_infilland test the impact on waste. This could be the single biggest waste reduction lever we haven't pulled yet. - Fix
filament_costvalues in all project files. Without accurate costs, the slicer's estimates are useless. - Test lower
flush_multipliervalues - try 0.75 and 0.7 on more products beyond Paw Pals heads. - Batch modification script - Unzip 3MF, modify
project_settings.config, rezip. Apply settings changes across dozens of project files at once. - Auto-name plates - Read the object IDs on each plate, map to STL names, generate a descriptive plate name automatically.
1. Copy the .3mf file
2. Unzip it (it's just a ZIP)
3. Edit the JSON/XML files you need
4. Re-zip with the same structure
5. Rename back to .3mf
6. Open in Bambu Studio to verify
That's it. No special tools needed beyond a script that handles zip/unzip and JSON/XML parsing.
Date: February 10, 2026
Discovered while inspecting the BeastyPixie DarkMagic 3MF. Some models on Plate 1 have cylindrical holes cut into them (for magnets/pins), others don't. The difference is visible in the XML.
In 3D/3dmodel.model, each top-level object has <components> that reference sub-objects from shared mesh files. The sub-objects have a type attribute:
type="model"— Normal additive geometry (the body, wings, eyes, etc.)type="other"— Negative volume. BambuStudio subtracts this mesh from the model via boolean difference. This is how holes are cut.
Example from object_130.model:
<!-- Normal body part -->
<object id="8" type="model">
<mesh>...</mesh>
</object>
<!-- Negative cylinder (cuts a hole) -->
<object id="14" type="other">
<mesh>
<vertices>
<vertex x="0" y="0" z="-12.8"/>
<vertex x="0" y="0" z="12.8"/>
<vertex x="0" y="12.8" z="-12.8"/>
<!-- ... circle of vertices at z=-12.8 and z=+12.8 -->
</vertices>
</mesh>
</object>The cylinder mesh is defined at a base size of radius=12.8mm, height=25.6mm (z +/-12.8). It gets scaled by the component transform to the actual hole dimensions.
The component transform for the cylinder (objectid 14) within an assembly:
<component objectid="14"
transform="0.398437494 0 0 0 0.398437494 0 0 0 2.14843747 0.0337081218 -11.4074222 -0.297823911"/>Breaking down the 3x4 affine matrix (m00 m01 m02 m10 m11 m12 m20 m21 m22 tx ty tz):
| Axis | Scale factor | Mesh base | Result |
|---|---|---|---|
| X | 0.398437494 | 12.8mm radius × 2 | 10.2mm width |
| Y | 0.398437494 | 12.8mm radius × 2 | 10.2mm length |
| Z | 2.14843747 | 12.8mm half-height × 2 | 55.0mm height |
Translation (0.03, -11.4, -0.3) positions the cylinder relative to the assembly origin, so it punches through the model with some sticking out below the build plate.
Two shared mesh files on the BeastyPixie:
object_104.model— 6 sub-objects (ids 1-6), alltype="model". No hole.object_130.model— 7 sub-objects (ids 8-14), where id 14 istype="other". Has hole.
Some assemblies reference object_130 but only include 6 of its 7 sub-objects (ids 8-13, skipping 14). These also have no hole — the negative volume is simply not included in their component list.
So the pattern is: if an assembly's <components> list includes objectid 14, it has the cylinder hole. If it stops at objectid 13, it doesn't.
Plate 1 of DarkMagic BeastyPixie:
| Object IDs | Mesh source | Components | Has hole? |
|---|---|---|---|
| 7, 16, 18, 20, 22 | object_104 | 6 (ids 1-6) | No |
| 76, 77, 78 | object_104 | 6 (ids 1-6) | No |
| 36 | object_130 | 6 (ids 8-13) | No |
| 26, 28, 32, 34, 79 | object_130 | 7 (ids 8-14) | Yes |
Object IDs do NOT map sequentially to plates. Odd/even IDs can land on different plates. The authoritative plate assignment is in Metadata/model_settings.config:
<plate>
<metadata key="plater_id" value="1"/>
<model_instance>
<metadata key="object_id" value="7"/>
<metadata key="instance_id" value="0"/>
</model_instance>
<!-- more model_instances... -->
</plate>The build item X/Y coordinates in 3dmodel.model use the plate grid coordinate system (see above), but model_settings.config is the definitive source for "which object is on which plate."
Date: February 10, 2026
Problem: You want to shrink a figurine by 10% but keep the magnet/pin hole the same diameter so the same hardware fits.
Solution: Scale the build item transform by 0.9 (everything shrinks), then counter-scale the cylinder component transform by 1/0.9 (cylinder grows back to original size). The two scales cancel for the cylinder: (1/0.9) × 0.9 = 1.0.
The transform pipeline for a vertex v in component c of build item b:
v_final = v × M_component × M_build
Where each M is a 3x4 affine matrix (3x3 rotation/scale + translation).
Step 1: Scale the build item's 3x3 by 0.9
Original build transform for object 26:
-6.123234e-17 1 0 -1 -6.123234e-17 0 0 0 1 127.985 80.435 13.098
This is a 90-degree Z rotation + translation. Multiply the 3x3 portion by 0.9:
-5.5109106e-17 0.9 0 -0.9 -5.5109106e-17 0 0 0 0.9 127.985 80.435 13.098
Translation stays the same — model stays in the same spot on the plate, just shrinks around that point.
Step 2: Counter-scale the cylinder component's 3x3 by 1/0.9
Original cylinder component transform:
0.398437494 0 0 0 0.398437494 0 0 0 2.14843747 0.034 -11.407 -0.298
Divide the 3x3 by 0.9:
0.442708327 0 0 0 0.442708327 0 0 0 2.387152744 0.034 -11.407 -0.298
Translation stays the same — the hole's position relative to the assembly origin doesn't change. Since the build transform scales positions proportionally, the hole ends up in the correct relative spot on the shrunken model.
Result per component:
| Component | Build 3x3 | Component 3x3 | Net effect |
|---|---|---|---|
| Body parts | × 0.9 | × 1.0 (untouched) | Shrinks 10% |
| Cylinder hole | × 0.9 | × 1/0.9 = 1.111 | Stays original size |
Applied to object 26 (Plate 1, BeastyPixie DarkMagic). Two edits to 3D/3dmodel.model:
- Build item (line 1234): scale 3x3 by 0.9
- Cylinder component within object 26's definition (line 156): scale 3x3 by 1/0.9
Output: A1-BeastyPixie-DarkMagic-RedTwinkle-BlackTwinkle-White-90pct-test.3mf
After opening in Bambu Studio, the model may float slightly above the build plate (Z origin shifted from scaling). Use "Drop to build plate" to fix.
For any scale factor S:
- Build item 3x3: multiply by
S - Cylinder component 3x3: multiply by
1/S - All translations: leave untouched
This works for any component you want to "lock" at a fixed size while the rest of the model scales. Could also apply to screw holes, snap-fit connectors, or any mechanical interface that needs to stay at a specific dimension.
Date: February 10, 2026
Our models are articulated (print-in-place ball joints). When placing negative volume holes (sip holes, magnet holes), we need to know where the joints are so we don't cut through them and break articulation. The joint locations change from model to model, but the technique for finding them is the same.
Each articulated model is an assembly of multiple sub-objects (body, head, wings, legs, etc.) positioned by component transforms. Where two components' bounding boxes overlap in world space, there's a joint. The overlap region is the danger zone for hole placement.
Each assembly's <components> list in 3dmodel.model tells you which mesh sub-objects make up the model and where they sit. The component transform translations position each part relative to the assembly origin:
<object id="26">
<components>
<component objectid="8" transform="1 0 0 0 1 0 0 0 1 0.0 -15.08 -1.31"/> <!-- body -->
<component objectid="9" transform="1 0 0 0 1 0 0 0 1 -14.50 -11.50 -8.59"/> <!-- left wing -->
<component objectid="10" transform="1 0 0 0 1 0 0 0 1 14.50 -11.50 -8.59"/> <!-- right wing -->
<component objectid="11" transform="1 0 0 0 1 0 0 0 1 -9.96 6.38 4.87"/> <!-- left leg -->
<component objectid="12" transform="1 0 0 0 1 0 0 0 1 9.96 6.38 4.88"/> <!-- right leg -->
<component objectid="13" transform="1 0 0 0 1 0 0 0 1 0.0 14.99 0.0"/> <!-- head -->
<component objectid="14" transform="0.398 0 0 0 0.398 0 0 0 2.148 0.03 -11.41 -0.30"/> <!-- neg cylinder -->
</components>
</object>Symmetric translations (±14.5, ±9.96) are mirrored left/right pairs. A component at the origin-ish is the central body. The component furthest in one direction is the head/tail.
Parse the vertex data from the shared mesh file (e.g., object_130.model). For each sub-object, find min/max X, Y, Z:
import re
with open('object_130.model', 'r') as f:
content = f.read()
for obj_id in [8, 9, 10, 11, 12, 13]:
match = re.search(rf'<object id="{obj_id}".*?</object>', content, re.DOTALL)
verts = re.findall(r'vertex x="([^"]+)" y="([^"]+)" z="([^"]+)"', match.group())
xs = [float(v[0]) for v in verts]
ys = [float(v[1]) for v in verts]
zs = [float(v[2]) for v in verts]
print(f"ID {obj_id}: X[{min(xs):.1f}, {max(xs):.1f}] "
f"Y[{min(ys):.1f}, {max(ys):.1f}] Z[{min(zs):.1f}, {max(zs):.1f}]")Add each component's translation offset to its local bounding box:
world_min = local_min + translation
world_max = local_max + translation
For components with identity rotation (scale=1 in the 3x3), this is a straight addition. If the component has rotation or non-uniform scale in its 3x3, you need to transform the bounding box corners properly.
Two components share a joint where their world bounding boxes overlap in all three axes:
def overlap(a_min, a_max, b_min, b_max):
o_min = max(a_min, b_min)
o_max = min(a_max, b_max)
return (o_min, o_max) if o_min < o_max else None
# For each pair of components, check X, Y, Z overlap
# If all three axes overlap, there's a joint in that regionThe overlap center is the approximate joint center. The overlap extent tells you how big the danger zone is.
Hints that help identify which component is which, without needing to visually inspect:
- Vertex count correlates with part complexity. The body and head have 30k+ vertices. Wings have ~4.5k. Legs have ~2k.
- Mirrored translations (same magnitude, opposite X sign) are left/right pairs — wings, legs, ears, etc.
- Component near the assembly origin (small translation) is usually the central body.
- Largest bounding box is usually the body or head.
- Smallest bounding box with small vertex count is an extremity (leg, tail, ear).
Not every bounding-box overlap is a ball joint — bounding boxes are coarse. But for articulated print-in-place models:
- Large overlaps (~8-24mm) at the center → main ball joint (head-body). This is the big circular socket visible from the bottom.
- Medium overlaps (~9-10mm) at symmetric X offsets → wing/arm joints.
- Small overlaps (~1.5-7mm) → leg/extremity joints. These are tighter fits and more fragile.
- No overlap between non-adjacent parts (e.g., head ↔ wings) → no joint, as expected.
The negative cylinder hole should go in the body, away from all joint overlap zones. General rules:
- Stay in the central body component's bounding box
- Avoid any region where another component's bounding box overlaps
- The current BeastyPixie placement (Y≈-11.4 in assembly space) sits between the wing joints and well away from the head joint and legs — a good example of surgical placement
- When in doubt, bias toward the "back" of the model (away from the head joint, which tends to be the largest and most visible)