# Bambu 3mf Export

> Export finished STL meshes to a real Bambu Studio .3mf PROJECT with print settings baked in, no "not from Bambu Lab, load geometry data only" warning, support/infill/profile already set. Use when the user wants a sliceable/printable file for a Bambu Lab printer (A1/A1 Mini/P1/X1), to "export to 3mf", bake print settings, split parts across plates, or set up FINE/FAST print profiles. Also the reference for Bambu .3mf internals and lib3mf gotchas. Pairs with the 3d-print-modeling skill.

- Skill: `m-esm/bambu-3mf-export` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add m-esm/bambu-3mf-export`
- Raw SKILL.md: https://api.skillmd.com/api/skills/m-esm/bambu-3mf-export/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: m-esm (https://skillmd.com/u/m-esm)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/m-esm/bambu-3mf-export

---


# Bambu .3mf export (settings baked in)

A plain trimesh/lib3mf `.3mf` carries **geometry only**, so Bambu Studio shows "The 3mf is not
from Bambu Lab, load geometry data only" and you re-enter every setting by hand. This skill writes
the same package layout Bambu Studio itself writes, so the file opens as a real project with the
process / filament / printer + per-object support, infill, and brim already set.

## How to use

1. Run the project's `build.py` first so the STLs are current and verified watertight + single-body.
   Only PRINTED parts go in (bought parts, motors, bearings, screws, clutches, are excluded).
2. Copy `scripts/bambu3mf.py` + `scripts/bambu_profile_template.json` into the project (or keep the
   project's own copies in sync with this skill).
3. Adapt `scripts/export_bambu_example.py` → `export_bambu.py`: list parts by **category**, pack each
   category onto its own plate(s), collect **all** plates, then write **one** multi-plate `.3mf`.
4. `python3 export_bambu.py` → **a single** project file (e.g. `exports/bambu_klonk.3mf`) with every
   plate as a Studio tab. Gitignore the `.3mf` (regenerable).

### ALWAYS one multi-plate `.3mf` (do not emit N separate files)

**Default and required pattern:** one Bambu Studio project containing **all plates**.

```python
plates = []
for cat, items in CATEGORIES:
    for i, parts in enumerate(shelf_pack(items), 1):
        plates.append({"name": cat if n == 1 else f"{cat} {i} of {n}", "parts": parts})
write_bambu_3mf("exports/bambu_klonk.3mf", plates, PROJECT_SETTINGS)
```

- **Do not** write `bambu_fine1.3mf`, `bambu_housing1.3mf`, … as separate projects unless the user
  explicitly asks for split files (rare).
- Report a plate **manifest** (tab name + part list) and an **EXCLUDED (bought)** line every export.
- Plate names must be filename-safe: no `/ \ : * ? " < > |` (use `"Housing 2 of 2"`, not `"Housing (2/2)"`).

`write_bambu_3mf(path, plates, overrides, plate_name=None)` is the one call you need. `plates` is a
list of `{name, parts:[...]}`, where each part is `{name, mesh, pos:(x,y), obj_settings:{...}}`. The
writer lays plates on Bambu's **PartPlate grid** (`cols = round(sqrt(n))`, `stride = bed*1.2`, rows
toward −Y), maps them to plater_ids 1..N, centres each part on its bed, and drops it to z=0.
Backward-compat: pass a flat list of part dicts and it is treated as one plate (named via
`plate_name`). `overrides` are `project_settings.config` keys (one global process/filament/printer
profile per project); `obj_settings` are per-object keys in `model_settings.config`.

## Keep exports current, and say what's in them (staleness is a recurring user pain)

Users repeatedly could not tell whether a `.3mf` matched the current geometry ("did you
export?" asked in multiple sessions; a worm STL regenerated after its plate was written;
a `push_pin_x6.stl` that contained ONE pin; bought parts silently absent from plates the
user was scanning for them). Rules:

- **Export is part of the build.** `make all` runs build THEN export (after the
  `assembly_check.py` gate from the 3d-print-modeling skill); never leave a geometry fix
  un-exported. If exports are expensive, have `export_bambu.py` compare each STL's mtime
  against its `.3mf` and print `STALE` per plate, and check that before handing over.
- **Print a manifest on every export**, and show it to the user:
  which parts landed on which plate/file (with quality + filament group), part counts per
  plate (catches the x6-that-is-x1 class), and an explicit "EXCLUDED (bought): motor,
  608 x2, M3 screws" line. Deliberate exclusions the user can't see read as missing parts.
- **After any geometry change to an already-exported part, name the affected plates** in
  your reply ("worm changed -> re-exported bambu_petg_fine1.3mf; other plates untouched").

## The two-profile strategy (fast + light bulk)

Split parts so only detail needs fine layers:
- **FINE**, gears, threads, contact surfaces: 0.16 mm, 4 walls, denser infill (25–50 % on
  load-bearing rims/races).
- **FAST**, housings, enclosures, big simple parts: **0.28 mm layer (~40 % less print TIME)**,
  3 walls, **10 % lightning** infill, with per-object overrides bumping structural parts (bearing/
  motor seats) back to 4 walls / 15 %.

On one measured leg, FAST-style settings (0 % infill + 0.28 mm + 2 walls) cut **−44 % time / −38 %
filament** vs the 15 %/0.20 default. The layer height is the big time lever; infill + walls are the
material levers.

A third named profile shows up often enough to keep around:
- **CLEAR**, a see-through PETG lid / window: 0.16 mm, **100 % infill**, and `ironing_type: "top"`
  (+ `ironing_flow`, `ironing_spacing`) for a glassy top face. Transparent PETG only reads clear
  with *solid* infill, sparse infill is opaque and ugly through the wall. (A clear hollow *chamber*
  is the opposite case: 0 % infill + a thin defined shell + a tapered self-supporting roof, see the
  fdm-design-rules hollowing note.) `ironing_type: "top"` is also the move for any flush-engraved
  top surface, e.g. an angle scale recessed into a face, so it reads cleanly.

## Group plates by filament too, not only by quality

Quality (FINE/FAST/CLEAR) and **material/color** are independent axes. The A1 prints **one filament
per job** (single nozzle), so when parts span colors or materials the real split is by filament. The
turntable shipped four filament groups: white PLA (top plate), black PLA (shell + cradle), red
PETG-fine (gears, threads), red PETG-med (bulk mechanism). Each group is its own `*_SETTINGS` dict
(own `filament` + nozzle/bed temps + the right quality) fed through the same `emit()`; name the
outputs `bambu_<material><n>.3mf` so you know which spool to load. You usually pick **one filament
group AND one quality per plate** (e.g. red PETG at FINE for gears, black PLA at FAST for the shell).

True multi-color *within one plate* is a different mechanism: it needs the AMS and a per-object
`filament_id` in `model_settings.config` (more config, and a real wipe-tower time/material cost). For
single-nozzle, one-color-per-plate, don't reach for that, just group the parts and emit separate files.

## A bigger nozzle (0.6 / 0.8 mm) is not a one-key change

Setting `nozzle_diameter: "0.8"` alone makes perimeter-heavy parts *slower*: flow stays capped at the
0.4 profile's `filament_max_volumetric_speed` (~12 mm³/s), so wider lines just take longer per mm. To
get the time win: raise `filament_max_volumetric_speed` to ~21, scale the `*_line_width` keys to the
nozzle, go coarser on `layer_height` (0.5–0.6), and set `print_compatible_printers` to the
0.8-nozzle machine or Studio refuses the plate with "not compatible". The catch: a 0.8 nozzle's
±0.25 mm tolerance swamps a designed 0.1–0.2 mm interference, so **press-fits become glue-fits**, keep
bearing seats and snap pins on a 0.4 plate, run the bulk structural parts on the 0.8. (Design side of
this is in the fdm-design-rules reference.)

## Support: per-object, auto tree, always check

- **Bambu honors support PER-OBJECT** (`model_settings.config`), NOT the global project flag from a
  hand-built `.3mf`. The example stamps support onto every object via the `SUPPORT` dict, keep that.
- **Auto tree support ON is the safe default.** It builds only under real overhangs and leaves
  self-supporting faces (open-top cavities, ramped catches, flat-top-down lids) bare, and peels off
  easily. Disabling support globally to "save time" silently fails the parts that needed it
  (enclosure roofs, wire holes, recess ceilings). Don't set `enable_support:0` without checking each
  part's overhangs.

## Orientation + packing

- Load each part in its **print orientation**: lids and flat-faced detail parts flipped top-down
  (`FLIP_X`) so the smooth face / detail lands on the bed and cavities open upward (self-supporting).
- `shelf_pack` is a first-fit-decreasing nest: turns parts landscape (longest edge along +X), rows
  tallest-first, opens a new plate when full. The export bed-checks every part (flags "OFF PLATE").
- **Pack brim-aware, not bounds-aware.** The global profile gives every part an outer brim, so nest
  the **brim-grown footprint** (`bbox + 2·brim_width` per axis), not the raw mesh bounds, and keep a
  small air gap (~6 mm) between footprints. The part centre = footprint centre (a symmetric outer brim
  stays centred), so the recorded `pos` still lands the mesh in-bed. Skip this and neighbouring brims
  merge into one blob and the bed-check passes a part whose brim actually hangs off the plate.

## BambuStudio-CLI auto-arrange (and why auto-orient is OFF by default)

`scripts/bambu_autopack.py` feeds a finished multi-plate project back through BambuStudio's
own orient/arrange engines (the code behind the GUI buttons). Verified on 02.08.00.50.
Two entry points:

- `autopack(path, orient=, arrange=, deliberate=, categories=)`, global pass, atomically
  replaces the file, renames plates from contents (arrange moves parts across plates, so
  input plate names go stale). Side gain: the CLI output carries real plate thumbnails.
- `packed_plates(path, deliberate=, brim=)`, arrange a SINGLE-CATEGORY temp project and
  return the packed layout (`{name, mesh, pos}` per plate) for your own `write_bambu_3mf`
  final assembly. This keeps plates category-pure (a global arrange mixes coupon strips
  with head shells and kills selective printing) while the packing quality is Bambu's.

**Findings that must survive (all measured, desk-pi 2026-07-15):**

- **`--export-3mf` takes a BARE filename and REQUIRES `--outputdir`.** The CLI prefixes
  the export name with the outputdir; an absolute path becomes `<outputdir>/<abs-path>`
  and fails, and without `--outputdir` it exits 0 having written NOTHING. The output
  FILE existing is the only reliable success signal, not the exit code.
- **Auto-orient is blind to your constraints. Never blanket-trust it.** On a project
  where every pose was deliberate it wanted to flip 16 parts: a cosmetic top face into
  tree supports, print-in-place master links onto their floating C-jaw (a known print
  failure), a Ø5x209 rod stood on end. It DOES fix genuinely bad poses (a sideways bolt
  got stood head-down), so it's useful for parts you haven't thought about, and as a
  VALIDATOR. `deliberate=("*",)` (fnmatch patterns) hard-fails if a matching part gets
  TILTED; pure yaw from arrange is always allowed (`_tilt_deg` compares the +Z axis, so
  packing rotations pass the audit).
- **Auto-arrange is outline-aware but BRIM-BLIND (no CLI spacing option):** it nests
  beautifully (threads a rod through a ring's bore) but packs to ~1 mm gaps, so two 5 mm
  brims would merge. Fix baked into `packed_plates(brim=...)`: concatenate a 0.1 mm-thin
  sacrificial pad under each mesh = footprint buffered by the brim width, arrange spaces
  the pads, strip them on read-back (`_strip_pad`, disconnected components with z extent
  ~pad height). The buffer is a Minkowski sum, so the padded bbox centre coincides with
  the mesh bbox centre at any yaw and read-back positions stay exact.
- **Best-of-both, not Bambu-always:** measured on 9 categories, Bambu's engine beat the
  brim-aware shelf nest on many-small-part categories (48 fasteners: 2 plates -> 1;
  8 link strips: 3 -> 2) and LOST on few-big-part ones (its bed margins put a 6-plate
  chassis on 7, a 5-plate head set on 7). Compute both, keep whichever needs fewer
  plates, tie to the naive nest (known layout, zero read-back risk).
- **Output is production-extension format:** meshes split into `3D/Objects/object_N.model`
  (referenced via `<component p:path>`), object ids RENUMBERED from 1. Map objects
  input->output by NAME-OCCURRENCE ORDER (names + per-object settings are preserved in
  `Metadata/model_settings.config`, document order is stable); ids are useless as keys.
- **Read-back grid = the writer's grid:** plate i origin `(col*stride, -row*stride)` with
  `cols = compute_colum_count(n)`, `stride = bed*1.2`, same PartPlate.cpp formula as
  `write_bambu_3mf`; assert every read-back part lands within its plate cell.
- Multi-body objects (print-in-place strips) survive the round trip with hinge gaps and
  body counts intact, but assert counts after read-back anyway.

## Group + name plates by category (selective printing)

Don't dump all parts onto sequential "Plate 1/2/3". Group by **function/category** and name each plate
after its category, so the Bambu Studio plate tabs read "Bolts", "Connectors", etc. and a user can
print just one category by printing one plate. In this project the category map lives in the **viewer**
(`tools/viewer.py` `CATEGORIES` + `CAT_ORDER`); the exporter imports it (`from viewer import ...`) so
the two never drift, define the grouping once. Pattern: bucket parts by category, `shelf_pack` each
bucket independently onto its own plate(s), and when a category overflows the bed name the spill
`"Cat (1/2)"`, `"Cat (2/2)"`; a single-plate category stays just `"Cat"`. This composes with the
filament-grouping axis below: group by filament first (one spool per job), by category within that.

## PETG / water parts

Override the profile to Generic PETG @ A1: 240/250 °C nozzle, **80 °C textured-PEI bed**, reduced
cooling (none for the first ~3 layers), **8 mm outer brim**, slow ~20 mm/s first layer, ~0.15 mm
elephant-foot comp, and for watertight: 6 wall loops + 8 top / 8 bottom shells, ~40 % infill on
chunky bosses. Print on a clean plate (wipe PEI with IPA, skin oils kill PETG grip). See the
fdm-design-rules reference in the 3d-print-modeling skill for the warp/adhesion story.

## What makes a `.3mf` "a Bambu project" (reverse-engineered)

The package (a ZIP) needs:
- `3D/3dmodel.model`, meshes + the production ('p') extension (p:UUID on objects/build items) +
  `<metadata name="Application">BambuStudio-...`.
- `Metadata/project_settings.config`, the ~557-key process/filament/printer JSON. Start from a
  **validated template lifted from a real Studio export** (`bambu_profile_template.json`, an
  "0.20mm Standard @BBL A1 / Generic PLA" baseline) and override only the keys you care about.
- `Metadata/model_settings.config`, object names, the `<plate>` mapping, per-object overrides.
- `Metadata/slice_info.config`, `[Content_Types].xml`, `_rels/.rels`, package glue.

## lib3mf / .3mf gotchas (the reason we hand-assemble the ZIP)

- **lib3mf silently DROPS Bambu sidecar configs on write.** A `model.QueryWriter("3mf").WriteToFile()`
  round-trip loses `project_settings.config` / `model_settings.config` / subtypes. So `bambu3mf.py`
  assembles the ZIP by hand (raw XML strings). Use lib3mf write ONLY for geometry-only output.
- A Bambu `.3mf` is standard 3MF **plus** Bambu-only sidecars. Standard geometry (meshes, transforms,
  components, build items) is safe through lib3mf; the Bambu layer (`negative_part`/`modifier_part`
  subtypes, plate config, slicer profile, thumbnails) needs raw ZIP+XML.
- **Object IDs renumber**, Bambu reassigns top-level IDs when objects are added/removed in Studio.
  Don't hardcode them across sessions; re-inspect first. (The writer here assigns ids from 2 up.)
- **Two transform conventions coexist in one file.** `3dmodel.model` `<item>`/`<component>` `transform`
  = **12 floats** (row-major 4×3, translation in the last 3). `model_settings.config`
  `<metadata key="matrix">` = **16 floats** (row-major 4×4, translation in the 4th column of each row).
  Same effective transform, different shape, don't paste between them.
- **`negative_part` only carves as a sub-part of the same `<object>`.** A standalone object marked
  `negative_part` does nothing; it must live inside the target object's `<components>` AND have a
  matching `<part subtype="negative_part">` in `model_settings.config` under that object's id. And the
  slicer does `union(positives) − union(negatives)` in one pass, so a positive feature placed *inside*
  a negative cavity gets subtracted away, redefine the cavity mesh to exclude that volume (do it as
  CSG in `build.py` instead of fighting subtypes).
- **Three.js `3MFLoader` ignores Bambu's multi-file production extension** (separate
  `/3D/Objects/object_N.model` files) → gives an empty scene. For the viewer, bake to GLB.
- **Hand-assembled XML must escape attribute values.** Plate/part/category names get interpolated
  straight into `value="..."` attributes. An unescaped `&`, `<`, `>`, or `"` (e.g. plate name
  "Nuts & inserts") makes `model_settings.config` malformed and Bambu Studio fails to load the whole
  project. Run every interpolated value through `_a()` (`xml.sax.saxutils.escape` + `&quot;`). Numeric
  override values are safe, names are not.
- **Plate/object names are validated as filenames.** Bambu Studio rejects `< > : / \ | ? * "` in a
  plate or object name with "Invalid name, the following characters are not allowed". This bites the
  obvious `"Cat (1/2)"` plate label, the `/` is illegal. Use `"Cat 1 of 2"`. (`&` is allowed by the
  name validator but still needs XML-escaping per the rule above.)
- **Multi-plate: match Bambu's PartPlate grid or parts float off the plates.** Each object's world
  coords decide which plate cell it's drawn in. Bambu lays plates out as a grid, NOT a single row:
  `cols = compute_colum_count(n)` = `round(sqrt(n))` (`+1` when `sqrt(n) > round(sqrt(n))`),
  `stride = bed_size * (1 + 1/5)` (`LOGICAL_PART_PLATE_GAP`), plate `i` origin =
  `(col*stride, -row*stride)` with `row, col = divmod(i, cols)` (rows march toward `-Y`). Place each
  part at `plate_origin + bed_local_pos`. A naive `i*(bed+gap)` along `+X` puts every part in one line
  hovering off the plate grid. (Source: `src/slic3r/GUI/PartPlate.cpp`, `compute_shape_position`.)

## Headless slicing for measurement (BambuStudio CLI)

To actually measure print time / filament (not estimate), see the foreign-cad-import skill's
"BambuStudio CLI" section, flatten the `inherits` profile chain, set `curr_bed_type` to
`"Textured PEI Plate"` for PETG, put overrides in the flattened process JSON, then
`BambuStudio --load-settings "m.json;p.json" --load-filaments "f.json" --slice 0 --outputdir OUT model.stl`.

**Make the headless slice a standing GATE, not just a measurement** (finnish-doors
`tools/slice_check.py`, wired into `make all` after export): slice every exported `.3mf`
and fail the build on slicer errors. The slicer sees a failure class nothing upstream
does — its first run on a fully gate-verified project caught 0.4 mm conduit groove
cheeks that sliced to EMPTY LAYERS, a guaranteed print failure no boolean, wallcheck, or
render had flagged, because that plate had never been sliced before.

## Filament-change pauses without AMS (verified 2026-07, torque-lever project)

Single-nozzle two-color (e.g. black engraving floors under a white top face): bake a
pause into the project by appending `Metadata/custom_gcode_per_layer.xml` to the ZIP
after `write_bambu_3mf()` (plain `zipfile.ZipFile(path, "a").writestr(...)`, no
content-types registration needed). Schema from `bbs_3mf.cpp
_add_custom_gcode_per_print_z_file_to_archive`:

```xml
<?xml version="1.0" encoding="utf-8"?>
<custom_gcodes_per_layer>
<plate>
<plate_info id="1"/>
<layer top_z="5.6" type="1" extruder="1" color="" extra="" gcode="M400 U1"/>
<mode value="SingleExtruder"/>
</plate>
</custom_gcodes_per_layer>
```

- `type` is the CustomGCode enum: 0 ColorChange, **1 PausePrint**, 2 ToolChange,
  3 Template, 4 Custom. `gcode` for a pause = the profile's `machine_pause_gcode`
  (`M400 U1` on A1).
- The gcode is emitted at the START of the layer whose top equals `top_z` → set
  `top_z` to the FIRST layer of the NEW color (engrave floors at z=F, 0.2 layers →
  `top_z = F + 0.2`). Make engrave depth an exact layer multiple (0.6 = 3 × 0.20).
- One `<plate>` block per plate, `plate_info id` = 1-based plater id; multiple
  `<layer>` entries per plate are allowed.

