DEV Community

Cover image for How Bambu Studio stores multi-color painting in a 3MF — decoding paint_color in the browser
Bubu Dong
Bubu Dong

Posted on

How Bambu Studio stores multi-color painting in a 3MF — decoding paint_color in the browser

Download a multi-color model from MakerWorld, open the .3mf in almost any viewer other than Bambu Studio, and you get a single gray (or single-color) blob. The colors are in the file, though. They just aren't stored the way most 3D software expects.

I recently added support for this to a browser-based 3MF viewer I work on, and the format turned out to be compact and pretty neat. Here's how it works.

Background: in an earlier post I covered why PrusaSlicer can't open Bambu 3MFs at all: the geometry is split across 3D/Objects/*.model files using the 3MF production extension. This post assumes you've already flattened that and have the triangles.

Two kinds of color in a Bambu 3MF

1. Per-object filament. Each object (or part) is assigned to a filament slot. That lives in Metadata/model_settings.config:

<object id="2">
  <metadata key="name" value="Cat"/>
  <metadata key="extruder" value="3"/>
</object>
Enter fullscreen mode Exit fullscreen mode

The actual colors are in Metadata/project_settings.config, which is JSON despite the extension:

"filament_colour": ["#202020", "#C12E1F", "#FF6A13", ...],
"filament_type":   ["PLA", "PLA", "PLA", ...]
Enter fullscreen mode Exit fullscreen mode

So extruder = 3 means "use filament_colour[2]". That alone gets you a correctly tinted model when each color is a separate part.

2. Per-triangle painting. When someone uses the paint brush in Bambu Studio (or Orca), the color goes on the triangles themselves, as an extra attribute:

<triangle v1="10" v2="11" v3="12" paint_color="4"/>
<triangle v1="12" v2="13" v3="10" paint_color="8"/>
<triangle v1="14" v2="15" v3="16" paint_color="4C"/>
<triangle v1="16" v2="17" v3="18"/>
Enter fullscreen mode Exit fullscreen mode

A triangle without the attribute uses the object's own filament. PrusaSlicer writes exactly the same encoding under a different name, slic3rpe:mmu_segmentation, because Bambu Studio forked it from PrusaSlicer's TriangleSelector.

Decoding the hex string

It isn't a color, and it isn't a plain slot number either. It's a serialized subdivision tree. When you paint across part of a triangle, the slicer splits that triangle into smaller ones and paints those. The string stores the whole tree, one hex digit (nibble) per node.

Two things are unusual:

  • You read the string from the last character backwards.
  • Each nibble packs two fields: the low 2 bits are the number of split sides, the high 2 bits are the color (for leaves).
nibble = 0b SS CC         (read right to left through the string)
        CC = low 2 bits  = split sides
        SS = high 2 bits = state, when this node is a leaf

split == 0  → leaf
   SS < 3   → state = SS
   SS == 3  → state = (next nibble) + 3     // escape for slots ≥ 3
split > 0   → node has split + 1 children; decode them next, recursively
Enter fullscreen mode Exit fullscreen mode

State 0 means "unpainted, use the object's filament". State n means filament slot n.

Some real values from a MakerWorld model:

paint_color read as slot
4 0100: leaf, SS=1 1
8 1000: leaf, SS=2 2
0C C=1100: leaf, SS=3, escape → next nibble 0 + 3 3
4C escape → 4 + 3 7
44043 3: split into 4 children → 4,0,4,4 mixed: 1,0,1,1

In TypeScript:

export function decodePaintState(hex: string): number {
  let pos = hex.length - 1;
  const next = () => (pos < 0 ? 0 : parseInt(hex[pos--], 16) || 0);
  const weight = new Map<number, number>();

  const visit = (w: number, depth: number) => {
    const code = next();
    const split = code & 0b11;
    if (split === 0 || depth > 16) {
      const state = (code & 0b1100) === 0b1100 ? next() + 3 : code >> 2;
      weight.set(state, (weight.get(state) ?? 0) + w);
      return;
    }
    for (let i = 0; i < split + 1; i++) visit(w / (split + 1), depth + 1);
  };

  visit(1, 0);
  // Dominant color wins
  let best = 0, bestW = -1;
  for (const [s, w] of weight) if (w > bestW) { best = s; bestW = w; }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

The simplification I made (and why)

A faithful renderer would rebuild the subdivided triangles and color each one. For a viewer I didn't need that. I give each original triangle the color of whichever leaf state covers most of it, weighting children equally at each split level. It's approximate, but:

  • On the model I tested (221,124 triangles, 7 filaments) only a small share of triangles are split at all; most are single leaves like 4 or 8.
  • Decoding the whole file takes about 60 ms in the browser.
  • Visually you lose only the sub-triangle detail along color borders, so a border can look slightly jagged.

Wiring it into three.js

ThreeMFLoader drops unknown attributes, so I scan the XML myself with a regex pass (fast enough at this size), collect a Uint8Array of states per object, and then match each painted object to the mesh the loader produced:

  1. Match by triangle count plus the first vertex position (the loader keeps vertex order).
  2. Convert the geometry to non-indexed so each triangle owns its 3 vertices.
  3. Write a color attribute: state 0 → object filament, state n → filament_colour[n-1].
  4. Use a white material with vertexColors: true.

One gotcha: three.js expects the color attribute in linear space, but filament colors are sRGB hex. new THREE.Color("#C12E1F") does the conversion for you (color management is on by default since r152). If you parse the hex bytes yourself and divide by 255, you skip that step and the colors come out washed out.

Try it

The viewer is at flip3d.app/3mf-viewer. Drop a Bambu or PrusaSlicer 3MF on it and you get the painted colors, plates, filament list and the slicer's own thumbnail. It runs entirely in your browser, so the file never gets uploaded. The decoder is in the repo at lib/converters/3mf-paint.ts.

If you have a 3MF that renders wrong, I'd like to see it. Leave a comment.

Top comments (0)