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
- 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).
- Copy
scripts/bambu3mf.py + scripts/bambu_profile_template.json into the project (or keep the
project's own copies in sync with this skill).
- 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.
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.
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 + "). 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 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.
1---2name: bambu-3mf-export3description: 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.4---56# Bambu .3mf export (settings baked in)78A plain trimesh/lib3mf `.3mf` carries **geometry only**, so Bambu Studio shows "The 3mf is not9from Bambu Lab, load geometry data only" and you re-enter every setting by hand. This skill writes10the same package layout Bambu Studio itself writes, so the file opens as a real project with the11process / filament / printer + per-object support, infill, and brim already set.1213## How to use14151. Run the project's `build.py` first so the STLs are current and verified watertight + single-body.16 Only PRINTED parts go in (bought parts, motors, bearings, screws, clutches, are excluded).172. Copy `scripts/bambu3mf.py` + `scripts/bambu_profile_template.json` into the project (or keep the18 project's own copies in sync with this skill).193. Adapt `scripts/export_bambu_example.py` → `export_bambu.py`: list parts by **category**, pack each20 category onto its own plate(s), collect **all** plates, then write **one** multi-plate `.3mf`.214. `python3 export_bambu.py` → **a single** project file (e.g. `exports/bambu_klonk.3mf`) with every22 plate as a Studio tab. Gitignore the `.3mf` (regenerable).2324### ALWAYS one multi-plate `.3mf` (do not emit N separate files)2526**Default and required pattern:** one Bambu Studio project containing **all plates**.2728```python29plates = []30for cat, items in CATEGORIES:31 for i, parts in enumerate(shelf_pack(items), 1):32 plates.append({"name": cat if n == 1 else f"{cat} {i} of {n}", "parts": parts})33write_bambu_3mf("exports/bambu_klonk.3mf", plates, PROJECT_SETTINGS)34```3536- **Do not** write `bambu_fine1.3mf`, `bambu_housing1.3mf`, … as separate projects unless the user37 explicitly asks for split files (rare).38- Report a plate **manifest** (tab name + part list) and an **EXCLUDED (bought)** line every export.39- Plate names must be filename-safe: no `/ \ : * ? " < > |` (use `"Housing 2 of 2"`, not `"Housing (2/2)"`).4041`write_bambu_3mf(path, plates, overrides, plate_name=None)` is the one call you need. `plates` is a42list of `{name, parts:[...]}`, where each part is `{name, mesh, pos:(x,y), obj_settings:{...}}`. The43writer lays plates on Bambu's **PartPlate grid** (`cols = round(sqrt(n))`, `stride = bed*1.2`, rows44toward −Y), maps them to plater_ids 1..N, centres each part on its bed, and drops it to z=0.45Backward-compat: pass a flat list of part dicts and it is treated as one plate (named via46`plate_name`). `overrides` are `project_settings.config` keys (one global process/filament/printer47profile per project); `obj_settings` are per-object keys in `model_settings.config`.4849## Keep exports current, and say what's in them (staleness is a recurring user pain)5051Users repeatedly could not tell whether a `.3mf` matched the current geometry ("did you52export?" asked in multiple sessions; a worm STL regenerated after its plate was written;53a `push_pin_x6.stl` that contained ONE pin; bought parts silently absent from plates the54user was scanning for them). Rules:5556- **Export is part of the build.** `make all` runs build THEN export (after the57 `assembly_check.py` gate from the 3d-print-modeling skill); never leave a geometry fix58 un-exported. If exports are expensive, have `export_bambu.py` compare each STL's mtime59 against its `.3mf` and print `STALE` per plate, and check that before handing over.60- **Print a manifest on every export**, and show it to the user:61 which parts landed on which plate/file (with quality + filament group), part counts per62 plate (catches the x6-that-is-x1 class), and an explicit "EXCLUDED (bought): motor,63 608 x2, M3 screws" line. Deliberate exclusions the user can't see read as missing parts.64- **After any geometry change to an already-exported part, name the affected plates** in65 your reply ("worm changed -> re-exported bambu_petg_fine1.3mf; other plates untouched").6667## The two-profile strategy (fast + light bulk)6869Split parts so only detail needs fine layers:70- **FINE**, gears, threads, contact surfaces: 0.16 mm, 4 walls, denser infill (25–50 % on71 load-bearing rims/races).72- **FAST**, housings, enclosures, big simple parts: **0.28 mm layer (~40 % less print TIME)**,73 3 walls, **10 % lightning** infill, with per-object overrides bumping structural parts (bearing/74 motor seats) back to 4 walls / 15 %.7576On one measured leg, FAST-style settings (0 % infill + 0.28 mm + 2 walls) cut **−44 % time / −38 %77filament** vs the 15 %/0.20 default. The layer height is the big time lever; infill + walls are the78material levers.7980A third named profile shows up often enough to keep around:81- **CLEAR**, a see-through PETG lid / window: 0.16 mm, **100 % infill**, and `ironing_type: "top"`82 (+ `ironing_flow`, `ironing_spacing`) for a glassy top face. Transparent PETG only reads clear83 with *solid* infill, sparse infill is opaque and ugly through the wall. (A clear hollow *chamber*84 is the opposite case: 0 % infill + a thin defined shell + a tapered self-supporting roof, see the85 fdm-design-rules hollowing note.) `ironing_type: "top"` is also the move for any flush-engraved86 top surface, e.g. an angle scale recessed into a face, so it reads cleanly.8788## Group plates by filament too, not only by quality8990Quality (FINE/FAST/CLEAR) and **material/color** are independent axes. The A1 prints **one filament91per job** (single nozzle), so when parts span colors or materials the real split is by filament. The92turntable shipped four filament groups: white PLA (top plate), black PLA (shell + cradle), red93PETG-fine (gears, threads), red PETG-med (bulk mechanism). Each group is its own `*_SETTINGS` dict94(own `filament` + nozzle/bed temps + the right quality) fed through the same `emit()`; name the95outputs `bambu_<material><n>.3mf` so you know which spool to load. You usually pick **one filament96group AND one quality per plate** (e.g. red PETG at FINE for gears, black PLA at FAST for the shell).9798True multi-color *within one plate* is a different mechanism: it needs the AMS and a per-object99`filament_id` in `model_settings.config` (more config, and a real wipe-tower time/material cost). For100single-nozzle, one-color-per-plate, don't reach for that, just group the parts and emit separate files.101102## A bigger nozzle (0.6 / 0.8 mm) is not a one-key change103104Setting `nozzle_diameter: "0.8"` alone makes perimeter-heavy parts *slower*: flow stays capped at the1050.4 profile's `filament_max_volumetric_speed` (~12 mm³/s), so wider lines just take longer per mm. To106get the time win: raise `filament_max_volumetric_speed` to ~21, scale the `*_line_width` keys to the107nozzle, go coarser on `layer_height` (0.5–0.6), and set `print_compatible_printers` to the1080.8-nozzle machine or Studio refuses the plate with "not compatible". The catch: a 0.8 nozzle's109±0.25 mm tolerance swamps a designed 0.1–0.2 mm interference, so **press-fits become glue-fits**, keep110bearing seats and snap pins on a 0.4 plate, run the bulk structural parts on the 0.8. (Design side of111this is in the fdm-design-rules reference.)112113## Support: per-object, auto tree, always check114115- **Bambu honors support PER-OBJECT** (`model_settings.config`), NOT the global project flag from a116 hand-built `.3mf`. The example stamps support onto every object via the `SUPPORT` dict, keep that.117- **Auto tree support ON is the safe default.** It builds only under real overhangs and leaves118 self-supporting faces (open-top cavities, ramped catches, flat-top-down lids) bare, and peels off119 easily. Disabling support globally to "save time" silently fails the parts that needed it120 (enclosure roofs, wire holes, recess ceilings). Don't set `enable_support:0` without checking each121 part's overhangs.122123## Orientation + packing124125- Load each part in its **print orientation**: lids and flat-faced detail parts flipped top-down126 (`FLIP_X`) so the smooth face / detail lands on the bed and cavities open upward (self-supporting).127- `shelf_pack` is a first-fit-decreasing nest: turns parts landscape (longest edge along +X), rows128 tallest-first, opens a new plate when full. The export bed-checks every part (flags "OFF PLATE").129- **Pack brim-aware, not bounds-aware.** The global profile gives every part an outer brim, so nest130 the **brim-grown footprint** (`bbox + 2·brim_width` per axis), not the raw mesh bounds, and keep a131 small air gap (~6 mm) between footprints. The part centre = footprint centre (a symmetric outer brim132 stays centred), so the recorded `pos` still lands the mesh in-bed. Skip this and neighbouring brims133 merge into one blob and the bed-check passes a part whose brim actually hangs off the plate.134135## BambuStudio-CLI auto-arrange (and why auto-orient is OFF by default)136137`scripts/bambu_autopack.py` feeds a finished multi-plate project back through BambuStudio's138own orient/arrange engines (the code behind the GUI buttons). Verified on 02.08.00.50.139Two entry points:140141- `autopack(path, orient=, arrange=, deliberate=, categories=)`, global pass, atomically142 replaces the file, renames plates from contents (arrange moves parts across plates, so143 input plate names go stale). Side gain: the CLI output carries real plate thumbnails.144- `packed_plates(path, deliberate=, brim=)`, arrange a SINGLE-CATEGORY temp project and145 return the packed layout (`{name, mesh, pos}` per plate) for your own `write_bambu_3mf`146 final assembly. This keeps plates category-pure (a global arrange mixes coupon strips147 with head shells and kills selective printing) while the packing quality is Bambu's.148149**Findings that must survive (all measured, desk-pi 2026-07-15):**150151- **`--export-3mf` takes a BARE filename and REQUIRES `--outputdir`.** The CLI prefixes152 the export name with the outputdir; an absolute path becomes `<outputdir>/<abs-path>`153 and fails, and without `--outputdir` it exits 0 having written NOTHING. The output154 FILE existing is the only reliable success signal, not the exit code.155- **Auto-orient is blind to your constraints. Never blanket-trust it.** On a project156 where every pose was deliberate it wanted to flip 16 parts: a cosmetic top face into157 tree supports, print-in-place master links onto their floating C-jaw (a known print158 failure), a Ø5x209 rod stood on end. It DOES fix genuinely bad poses (a sideways bolt159 got stood head-down), so it's useful for parts you haven't thought about, and as a160 VALIDATOR. `deliberate=("*",)` (fnmatch patterns) hard-fails if a matching part gets161 TILTED; pure yaw from arrange is always allowed (`_tilt_deg` compares the +Z axis, so162 packing rotations pass the audit).163- **Auto-arrange is outline-aware but BRIM-BLIND (no CLI spacing option):** it nests164 beautifully (threads a rod through a ring's bore) but packs to ~1 mm gaps, so two 5 mm165 brims would merge. Fix baked into `packed_plates(brim=...)`: concatenate a 0.1 mm-thin166 sacrificial pad under each mesh = footprint buffered by the brim width, arrange spaces167 the pads, strip them on read-back (`_strip_pad`, disconnected components with z extent168 ~pad height). The buffer is a Minkowski sum, so the padded bbox centre coincides with169 the mesh bbox centre at any yaw and read-back positions stay exact.170- **Best-of-both, not Bambu-always:** measured on 9 categories, Bambu's engine beat the171 brim-aware shelf nest on many-small-part categories (48 fasteners: 2 plates -> 1;172 8 link strips: 3 -> 2) and LOST on few-big-part ones (its bed margins put a 6-plate173 chassis on 7, a 5-plate head set on 7). Compute both, keep whichever needs fewer174 plates, tie to the naive nest (known layout, zero read-back risk).175- **Output is production-extension format:** meshes split into `3D/Objects/object_N.model`176 (referenced via `<component p:path>`), object ids RENUMBERED from 1. Map objects177 input->output by NAME-OCCURRENCE ORDER (names + per-object settings are preserved in178 `Metadata/model_settings.config`, document order is stable); ids are useless as keys.179- **Read-back grid = the writer's grid:** plate i origin `(col*stride, -row*stride)` with180 `cols = compute_colum_count(n)`, `stride = bed*1.2`, same PartPlate.cpp formula as181 `write_bambu_3mf`; assert every read-back part lands within its plate cell.182- Multi-body objects (print-in-place strips) survive the round trip with hinge gaps and183 body counts intact, but assert counts after read-back anyway.184185## Group + name plates by category (selective printing)186187Don't dump all parts onto sequential "Plate 1/2/3". Group by **function/category** and name each plate188after its category, so the Bambu Studio plate tabs read "Bolts", "Connectors", etc. and a user can189print just one category by printing one plate. In this project the category map lives in the **viewer**190(`tools/viewer.py` `CATEGORIES` + `CAT_ORDER`); the exporter imports it (`from viewer import ...`) so191the two never drift, define the grouping once. Pattern: bucket parts by category, `shelf_pack` each192bucket independently onto its own plate(s), and when a category overflows the bed name the spill193`"Cat (1/2)"`, `"Cat (2/2)"`; a single-plate category stays just `"Cat"`. This composes with the194filament-grouping axis below: group by filament first (one spool per job), by category within that.195196## PETG / water parts197198Override the profile to Generic PETG @ A1: 240/250 °C nozzle, **80 °C textured-PEI bed**, reduced199cooling (none for the first ~3 layers), **8 mm outer brim**, slow ~20 mm/s first layer, ~0.15 mm200elephant-foot comp, and for watertight: 6 wall loops + 8 top / 8 bottom shells, ~40 % infill on201chunky bosses. Print on a clean plate (wipe PEI with IPA, skin oils kill PETG grip). See the202fdm-design-rules reference in the 3d-print-modeling skill for the warp/adhesion story.203204## What makes a `.3mf` "a Bambu project" (reverse-engineered)205206The package (a ZIP) needs:207- `3D/3dmodel.model`, meshes + the production ('p') extension (p:UUID on objects/build items) +208 `<metadata name="Application">BambuStudio-...`.209- `Metadata/project_settings.config`, the ~557-key process/filament/printer JSON. Start from a210 **validated template lifted from a real Studio export** (`bambu_profile_template.json`, an211 "0.20mm Standard @BBL A1 / Generic PLA" baseline) and override only the keys you care about.212- `Metadata/model_settings.config`, object names, the `<plate>` mapping, per-object overrides.213- `Metadata/slice_info.config`, `[Content_Types].xml`, `_rels/.rels`, package glue.214215## lib3mf / .3mf gotchas (the reason we hand-assemble the ZIP)216217- **lib3mf silently DROPS Bambu sidecar configs on write.** A `model.QueryWriter("3mf").WriteToFile()`218 round-trip loses `project_settings.config` / `model_settings.config` / subtypes. So `bambu3mf.py`219 assembles the ZIP by hand (raw XML strings). Use lib3mf write ONLY for geometry-only output.220- A Bambu `.3mf` is standard 3MF **plus** Bambu-only sidecars. Standard geometry (meshes, transforms,221 components, build items) is safe through lib3mf; the Bambu layer (`negative_part`/`modifier_part`222 subtypes, plate config, slicer profile, thumbnails) needs raw ZIP+XML.223- **Object IDs renumber**, Bambu reassigns top-level IDs when objects are added/removed in Studio.224 Don't hardcode them across sessions; re-inspect first. (The writer here assigns ids from 2 up.)225- **Two transform conventions coexist in one file.** `3dmodel.model` `<item>`/`<component>` `transform`226 = **12 floats** (row-major 4×3, translation in the last 3). `model_settings.config`227 `<metadata key="matrix">` = **16 floats** (row-major 4×4, translation in the 4th column of each row).228 Same effective transform, different shape, don't paste between them.229- **`negative_part` only carves as a sub-part of the same `<object>`.** A standalone object marked230 `negative_part` does nothing; it must live inside the target object's `<components>` AND have a231 matching `<part subtype="negative_part">` in `model_settings.config` under that object's id. And the232 slicer does `union(positives) − union(negatives)` in one pass, so a positive feature placed *inside*233 a negative cavity gets subtracted away, redefine the cavity mesh to exclude that volume (do it as234 CSG in `build.py` instead of fighting subtypes).235- **Three.js `3MFLoader` ignores Bambu's multi-file production extension** (separate236 `/3D/Objects/object_N.model` files) → gives an empty scene. For the viewer, bake to GLB.237- **Hand-assembled XML must escape attribute values.** Plate/part/category names get interpolated238 straight into `value="..."` attributes. An unescaped `&`, `<`, `>`, or `"` (e.g. plate name239 "Nuts & inserts") makes `model_settings.config` malformed and Bambu Studio fails to load the whole240 project. Run every interpolated value through `_a()` (`xml.sax.saxutils.escape` + `"`). Numeric241 override values are safe, names are not.242- **Plate/object names are validated as filenames.** Bambu Studio rejects `< > : / \ | ? * "` in a243 plate or object name with "Invalid name, the following characters are not allowed". This bites the244 obvious `"Cat (1/2)"` plate label, the `/` is illegal. Use `"Cat 1 of 2"`. (`&` is allowed by the245 name validator but still needs XML-escaping per the rule above.)246- **Multi-plate: match Bambu's PartPlate grid or parts float off the plates.** Each object's world247 coords decide which plate cell it's drawn in. Bambu lays plates out as a grid, NOT a single row:248 `cols = compute_colum_count(n)` = `round(sqrt(n))` (`+1` when `sqrt(n) > round(sqrt(n))`),249 `stride = bed_size * (1 + 1/5)` (`LOGICAL_PART_PLATE_GAP`), plate `i` origin =250 `(col*stride, -row*stride)` with `row, col = divmod(i, cols)` (rows march toward `-Y`). Place each251 part at `plate_origin + bed_local_pos`. A naive `i*(bed+gap)` along `+X` puts every part in one line252 hovering off the plate grid. (Source: `src/slic3r/GUI/PartPlate.cpp`, `compute_shape_position`.)253254## Headless slicing for measurement (BambuStudio CLI)255256To actually measure print time / filament (not estimate), see the foreign-cad-import skill's257"BambuStudio CLI" section, flatten the `inherits` profile chain, set `curr_bed_type` to258`"Textured PEI Plate"` for PETG, put overrides in the flattened process JSON, then259`BambuStudio --load-settings "m.json;p.json" --load-filaments "f.json" --slice 0 --outputdir OUT model.stl`.260261**Make the headless slice a standing GATE, not just a measurement** (finnish-doors262`tools/slice_check.py`, wired into `make all` after export): slice every exported `.3mf`263and fail the build on slicer errors. The slicer sees a failure class nothing upstream264does — its first run on a fully gate-verified project caught 0.4 mm conduit groove265cheeks that sliced to EMPTY LAYERS, a guaranteed print failure no boolean, wallcheck, or266render had flagged, because that plate had never been sliced before.267268## Filament-change pauses without AMS (verified 2026-07, torque-lever project)269270Single-nozzle two-color (e.g. black engraving floors under a white top face): bake a271pause into the project by appending `Metadata/custom_gcode_per_layer.xml` to the ZIP272after `write_bambu_3mf()` (plain `zipfile.ZipFile(path, "a").writestr(...)`, no273content-types registration needed). Schema from `bbs_3mf.cpp274_add_custom_gcode_per_print_z_file_to_archive`:275276```xml277<?xml version="1.0" encoding="utf-8"?>278<custom_gcodes_per_layer>279<plate>280<plate_info id="1"/>281<layer top_z="5.6" type="1" extruder="1" color="" extra="" gcode="M400 U1"/>282<mode value="SingleExtruder"/>283</plate>284</custom_gcodes_per_layer>285```286287- `type` is the CustomGCode enum: 0 ColorChange, **1 PausePrint**, 2 ToolChange,288 3 Template, 4 Custom. `gcode` for a pause = the profile's `machine_pause_gcode`289 (`M400 U1` on A1).290- The gcode is emitted at the START of the layer whose top equals `top_z` → set291 `top_z` to the FIRST layer of the NEW color (engrave floors at z=F, 0.2 layers →292 `top_z = F + 0.2`). Make engrave depth an exact layer multiple (0.6 = 3 × 0.20).293- One `<plate>` block per plate, `plate_info id` = 1-based plater id; multiple294 `<layer>` entries per plate are allowed.