Open-source color customizer for multi-color 3MF files from the Snapmaker U1 / OrcaSlicer / Bambu ecosystem. Recolor zones in a browser, export GLB or 3MF, or convert in Node via the CLI. 100% client-side, MIT-licensed.
18
stars
47
commits
TypeScript
primary language
Jul 26, 2026
updated
See your print before you print it.
The color-preserving .3mf → GLB converter — recolor, preview true-size in
AR, and export, all in your browser.
When you convert a multi-color .3mf to mesh, most converters export a bare
geometry and discard the slicer's paint data. As far as I can find, this is
the first tool that carries those per-face color zones all the way to GLB —
then lets you recolor them in the browser, reorient and scale the model for
AR, and export GLB / USDZ / OBJ / a re-tinted .3mf. Works with any Bambu Studio,
OrcaSlicer, or Snapmaker U1 file; they share the same on-disk encoding.
100% client-side, MIT-licensed, no telemetry.

Open a multi-color
.3mf, recolor any zone in real time, and download a GLB for the web, a USDZ for iOS Quick Look, or a fresh.3mfthat slices in OrcaSlicer / Bambu / Snapmaker U1. Or hit View in AR on a phone to see the recolored model at true print size in your own room — before you spend filament. Everything runs in the browser. Your files never leave your machine.
→ https://samisalah221.github.io/3mf-to-glb/
No sign-up, no upload. If you don't have a .3mf handy, hit
"Try sample · owl (Bambu)" or "Try sample · sea slug (U1)" on the
upload screen, or grab one of the fixtures in samples/ and
drop it on the page.
Snapmaker U1 Innovation Fund submission: project description and checklist →
SNAPMAKER_FUND.md.
Slicers like Bambu Studio, OrcaSlicer, and Snapmaker U1 offer increasingly powerful color authoring tools — per-face painters, gradient fills, image projection. But once a multi-color print is sliced, that color data is effectively stuck inside the slicer file. You can't preview the recolored print on a product page, you can't share it in AR, you can't iterate the palette without reopening the slicer, and you can't hand the model to a designer in Blender. There was no way to actually see a print — in your colors, at true size, in your own space — before committing filament to it.
This project exists to close that gap. It parses the open 3MF archive, decodes the proprietary per-face color extensions layered on top of it, lets you recolor zones live in a browser viewport and preview the result in AR, and emits open formats so the model can move freely through the rest of the 3D ecosystem:
<model-viewer>, and product pages.It's built in the same spirit as OrcaSlicer, Klipper, Moonraker, and Fluidd: open code, open formats, no telemetry, no vendor lock-in.
Every other 3MF-to-mesh converter I've found throws the paint data away — because the per-face color encoding Bambu Studio and its forks use is undocumented, and Snapmaker U1's Full Spectrum mixed-filament blends are a second undocumented layer on top of that. There's no public spec for either. To make color survive the trip to GLB, both formats had to be reverse-engineered first.
The per-face format is a nibble-packed recursive triangle-subdivision tree,
read right-to-left, with state-overflow encoding once you're past 4
extruders. That decoder, and the exact triangle subdivision that reproduces
the slicer's paint zone for zone instead of guessing a dominant color, is
fully documented, down to the bit level.
Full Spectrum mixed filaments are virtual extruders whose color is a pigment
blend of two physical ones, defined in a packed row grammar with enumeration
rules that silently reassign painted faces if you get them wrong — see
docs/SNAPMAKER_FULL_SPECTRUM.md and the
write-up below. That's the actual moat here: once color survives the
conversion, everything else — live recoloring, true-size AR, re-tinted 3MF
export — is just what you can finally do with a print you can now see
before you make it.
mixed_filament_definitions
virtual extruders (pigment blends of two physical filaments), enumerates
only enabled/non-deleted rows the way the slicer itself does, and blends
colors through a pigment-mixing model rather than naive linear-RGB
averaging — so a navy + yellow blend renders the actual green the slicer
would produce, not a muddy average. See
docs/SNAPMAKER_FULL_SPECTRUM.md.v x y z r g b, sRGB), the
extension MeshMixer's coloring mode and Bambu Studio's OBJ import read, so
there is no separate MTL to keep alongside it. Vertices are in millimeters
and the color you picked is the color that loads.paint_color zones intact. The output drops back
into OrcaSlicer / Snapmaker U1 and slices.<model-viewer>) from the live demo, fully client-side. See your print
at true size in your own room before you spend filament.<model unit> from the source 3MF is parsed
(micron, millimeter, centimeter, inch, foot, meter), baked into the
exported vertex positions in meters, and surfaced as asset.extras on
the GLB plus a W × H × D readout in the viewport. AR launchers lock the
model to its true size, so a 100 mm cube shows up as a 10 cm cube.asset.extras.pivot_mode, pivot_offset_m, and up_axis make the choice
recoverable downstream. The 3MF write-back path is deliberately exempt:
shifting the model on the print bed is not what you want when re-slicing.asset.extras.applied_rotation_euler_deg
and applied_rotation_quat; the 3MF write-back is left unrotated so it
re-slices in its original print-bed layout.paint_color
encoding, same plate/object model, no per-vendor branching in the parser.┌────────────┐ ┌──────────────┐ ┌─────────────────────┐ ┌─────────┐
│ Upload 3MF │ ──▶ │ Parse OPC │ ──▶ │ Subdivide painted │ ──▶ │ Render │
│ (ZIP) │ │ + XML files │ │ tris, clip Z zones │ │ 3D view │
└────────────┘ └──────────────┘ └─────────────────────┘ └────┬────┘
│
▼
┌─────────────────────┐
│ Export GLB / USDZ / │
│ OBJ / 3MF │
└─────────────────────┘
git clone https://github.com/SamiSalah221/3mf-to-glb.git
cd 3mf-to-glb
npm install
npm run dev
Then open the Vite URL (usually http://localhost:5173) and drop a .3mf file
on the page.
npm run build # Production build of the web app into dist/
npm run preview # Preview the production build
npm run lint # ESLint
npm run build:lib # Build the headless library + CLI into dist-lib/
npm run test:roundtrip # End-to-end smoke test across samples/
The same parser that drives the web app is also published as a Node CLI for batch jobs, build pipelines, and headless renderers. No backend required.
# Convert a 3MF to GLB, recolor filaments 1 and 2 in the process:
npx 3mf-to-glb model.3mf -o recolored.glb --recolor "1=#cc0000,2=#000000"
# Pick a specific plate from a multi-plate file:
npx 3mf-to-glb model.3mf --plate 2 -o plate2.glb
# Reorient a Z-up print to glTF Y-up while exporting:
npx 3mf-to-glb model.3mf --rotation "90,0,0" -o yup.glb
# Write a per-vertex-color Wavefront OBJ instead (one self-contained file):
npx 3mf-to-glb model.3mf -o model.obj
Flags:
| Flag | Description |
|---|---|
-o, --output <file> | Output path. Defaults to the input name with .glb. An .obj extension writes a single per-vertex-color OBJ (no MTL). |
--plate <id> | 1-based plate id (matches Bambu / Orca / U1 plater_id). Defaults to the first plate. |
--recolor <map> | Comma-separated index=hex pairs applied before export. Hex may include or omit the leading #. |
--pivot <mode> | Export pivot: base-center (default), bbox-center, centroid, original, custom. |
--rotation <x,y,z> | XYZ Euler rotation in degrees, baked into geometry before the pivot. Try "90,0,0" to reorient a Z-up source to glTF Y-up. |
-h, --help | Show full help. |
The package exports a small, framework-agnostic API. Three.js is the only runtime dependency for GLB emission; the parser itself is pure TypeScript.
import { readFile, writeFile } from 'node:fs/promises';
import { DOMParser } from '@xmldom/xmldom';
import {
setDefaultDomParser,
parse3MF,
applyRecolor,
buildSceneFromPlate,
buildGLBBytes,
} from '3mf-to-glb';
// In Node, inject the XML parser once at startup. In the browser, omit
// this — the global DOMParser is used automatically.
setDefaultDomParser(new DOMParser());
const buf = await readFile('model.3mf');
const parsed = await parse3MF(buf);
const recolored = applyRecolor(parsed, { 1: '#FF0000', 2: '#00FF00' });
const scene = buildSceneFromPlate(recolored.plates[0].meshChunks, recolored.filaments);
const bytes = await buildGLBBytes(scene);
await writeFile('out.glb', bytes);
For the one-shot common case there is also a convertToGLB(buffer, options)
convenience that wraps the four calls above, and convertToOBJ(buffer, options)
which returns { obj }: a single per-vertex-color OBJ body as a string.
Swap the last two lines for buildOBJ(scene) to get { obj, stats } directly,
or buildOBJBytes(scene) for the encoded Uint8Array. The OBJ is
self-contained: colors ride on the v lines (x y z r g b, sRGB), so there is
nothing else to write next to it.
paint_color encodingBambuStudio / OrcaSlicer (and other forks using the same encoding) store per-face filament assignments in a proprietary binary format: a nibble-packed recursive triangle-subdivision tree, read right-to-left, with extended state overflow handling for 16+ extruders. No public documentation exists for this format — it was reverse-engineered from hex dumps of known-colored meshes and re-implemented in TypeScript.
The full technical spec lives in
docs/3MF-COLOR-RESOLUTION-TECHNICAL-SPEC.md.
Decoding the tree is only half the problem. The obvious shortcut is to walk a triangle's tree, take whichever color covers most of it, and paint the whole triangle that color. That shortcut is wrong in two ways, and both are visible:
So the parser reproduces the slicer's own subdivision: rotate the triangle so the encoded "special side" leads, insert exact edge midpoints, and emit the children in the same winding and the same order the slicer wrote them (they are serialized last-child-first, a detail a vote can ignore and geometry cannot). Midpoints are deduplicated across the mesh, so triangles that share an edge share the vertex on it and the surface stays watertight. A leaf of the tree becomes a triangle, and its color zone is exact.
By default the tree is decoded exactly: every leaf becomes its own triangle, so the color zones are geometrically identical to what the slicer painted, down to a one-millimeter dot on a large panel. A safety cutoff only engages as a fallback, and only for a mesh whose exact decode would blow a per-mesh face budget (1.5 million pre-repair faces). When it does engage, the parser seeds a floor of about 0.4% of the bounding-box diagonal (capped at a 0.4 mm absolute minimum, roughly one nozzle width), collapses any sub-triangle below it to whichever color covers the most area, and doubles that floor until a face-count estimate (a count-only mirror of the real decode) fits the budget.
The distinction matters in both directions. A densely painted 896k-triangle owl decodes to 18.7 million triangles and about a gigabyte of vertex data, far over budget, so it takes the fallback and coarsens to roughly one million faces with no loss anything could resolve. But a lightly painted panel whose 1 mm to 3 mm slicer dots decode to only 1.08 million faces now stays under budget and renders exactly, where an earlier always-on 0.4 mm floor used to quantize those dots into visible triangle confetti.
Subdividing one triangle but not its neighbour leaves a T-junction: the subdivided side gains a vertex at an edge midpoint that the adjacent face never got, because that face was unpainted, coarser, or collapsed by the budget fallback. A T-junction is a real hole. It shows up as a hairline crack along color boundaries in both the GLB and the OBJ, and it breaks watertightness for anyone who reimports the mesh. After subdivision the parser walks the emitted faces and stitches those seams closed, so each recolored zone meets its neighbour cleanly and the exported mesh stays watertight.
A Bambu model_settings.config lists an object's <part> elements in the
same order as that object's <component> elements, and identifies each part
by the id of the mesh it points at. When a project reuses one mesh several
times, which is exactly what "stamp this shape three times in three colors"
produces, that id repeats. Keying part metadata on the id therefore keeps
only the last instance and paints every copy the same color. The parts are
matched positionally instead, with an id lookup kept as a fallback for files
where the two lists disagree.
Snapmaker U1 Full Spectrum projects add a second layer on top of the shared
paint-tree encoding: mixed filaments, virtual extruders whose color is a
pigment blend of two physical filaments. These are defined in
project_settings.config under mixed_filament_definitions — a ;/,-packed
row grammar (15+ fields in the modern dialect, 4 in the legacy one) with no
public documentation. It was decoded from the open
ratdoux/OrcaSlicer-FullSpectrum
fork's C++ (MixedFilament.{hpp,cpp}) and validated against real U1 sample
files.
Getting it right required matching a few non-obvious rules the slicer itself enforces:
mixed_index_from_filament_id skips disabled rows and d1-tombstoned rows
entirely — they consume no index. One of this project's U1 sample files
leads with 6 disabled tombstone rows; enumerating over every row instead of
just the surviving ones silently reassigns roughly 84,000 painted faces to
the wrong blend recipe.mix_b_percent is component B's share, not A's (ratioA = (100 − mix_b) / 100), and an r<k>/<wA>/<wB> token overrides that percentage with
explicit weights. u<N> is a persistent label, not a slot index — it can
skip values.FilamentMixer, a degree-4 polynomial
approximating Mixbox, ported to
src/lib/filamentMixer.ts. Navy + yellow
renders as the green the slicer actually shows; a naive linear-RGB lerp
gives a muddy khaki instead.Both U1 sample fixtures in samples/ exercise this path, and
scripts/test-roundtrip.mjs asserts specific resolved blend ratios in CI on
every PR. Full write-up (row grammar, field-by-field semantics, and the
scope of what's still unimplemented) in
docs/SNAPMAKER_FULL_SPECTRUM.md.
Some 3MFs color geometry via layer height rather than per-face — the
so-called MultiAsSingle mode, where the slicer injects G-code tool-changes
at specific Z heights. To convert that into static geometry, the tool
implements progressive Z-plane triangle clipping with boundary splitting,
turning each layer-range into an independent colored zone.
3dmodel.model, model_settings.config,
project_settings.config, and custom_gcode_per_layer.xml with
fast-xml-parser.| Layer | Tool |
|---|---|
| Language | TypeScript |
| Frontend | React 19, Vite 7 |
| 3D rendering | Three.js, React Three Fiber, Drei |
| State | Zustand |
| File I/O | JSZip, fast-xml-parser, @gltf-transform/core |
| Styling | Tailwind CSS v4 |
3mf-to-glb/
├── src/
│ ├── lib/ # Core parsing and conversion logic
│ │ ├── parse3MF.ts # 3MF ZIP/XML parsing + paint_color subdivision
│ │ ├── colorConvert.ts # sRGB ↔ linear color space conversion
│ │ ├── glbBuilder.ts # Geometry → glTF document construction
│ │ ├── glbExporter.ts # GLB binary export
│ │ └── objExporter.ts # Per-vertex-color Wavefront OBJ export
│ ├── components/ # React UI
│ ├── viewer/ # Three.js / R3F scene
│ ├── store/ # Zustand state
│ ├── types/ # Shared TypeScript types
│ └── styles/ # CSS
├── samples/ # Example .3mf files
└── docs/ # Technical write-ups
Already shipped:
paint_color per-face subdivision tree. Full spec at
docs/3MF-COLOR-RESOLUTION-TECHNICAL-SPEC.md.docs/SNAPMAKER_FULL_SPECTRUM.md.3mf-to-glb) usable from Node and the browser,
plus a 3mf-to-glb Node CLI for batch jobs and build pipelines.allowsContentScaling=0), an in-page WebXR session on Android via
<model-viewer ar-scale="fixed">, and a GLB download on desktop. True-size
AR: the source 3MF unit is baked into the exported vertex positions in
meters, so AR previews show true print size, not a pinch-zoomable
cartoon.asset.extras, and
are exposed on the CLI as --pivot and --rotation.samples/.<part> metadata to <component> instances, so a
mesh reused several times in one object keeps a different filament per copy.-o model.obj), with sRGB colors on the v lines and vertices
in millimeters. Coincident corners are welded into one shared pool so the
mesh reads as watertight.Near-term work, in rough priority order:
docs/SNAPMAKER_FULL_SPECTRUM.md.docs/LIB3MF_INTEGRATION.md.<model-viewer>, the Three.js editor).gltf-transform mesh-optimization
pass (Draco / Meshopt) for lightweight AR and web delivery.These are honest near-term intentions, not commitments contingent on funding.
Issues and PRs welcome — see CONTRIBUTING.md for dev setup, code layout, and good first-issue ideas. All participation is governed by the Code of Conduct.
If you find a parsing bug, the most helpful bug report includes the
offending .3mf file (or a minimal repro) and a note on how it was produced.
Report vulnerabilities privately via GitHub security advisories. See SECURITY.md for scope.
MIT © 2026 Sami Salah
Built by Sami Salah.
This project is an independent community effort. It is not affiliated with or endorsed by Snapmaker, Bambu Lab, or OrcaSlicer. Trademarks belong to their respective owners; product names are referenced for interoperability.
47 commits
TypeScript
80.3%
JavaScript
17.3%
HTML
1.4%
Open-source color customizer for multi-color 3MF files from the Snapmaker U1 / OrcaSlicer / Bambu ecosystem. Recolor zones in a browser, export GLB or 3MF, or convert in Node via the CLI. 100% client-side, MIT-licensed.
18
stars
47
commits
TypeScript
primary language
Jul 26, 2026
updated
See your print before you print it.
The color-preserving .3mf → GLB converter — recolor, preview true-size in
AR, and export, all in your browser.
When you convert a multi-color .3mf to mesh, most converters export a bare
geometry and discard the slicer's paint data. As far as I can find, this is
the first tool that carries those per-face color zones all the way to GLB —
then lets you recolor them in the browser, reorient and scale the model for
AR, and export GLB / USDZ / OBJ / a re-tinted .3mf. Works with any Bambu Studio,
OrcaSlicer, or Snapmaker U1 file; they share the same on-disk encoding.
100% client-side, MIT-licensed, no telemetry.

Open a multi-color
.3mf, recolor any zone in real time, and download a GLB for the web, a USDZ for iOS Quick Look, or a fresh.3mfthat slices in OrcaSlicer / Bambu / Snapmaker U1. Or hit View in AR on a phone to see the recolored model at true print size in your own room — before you spend filament. Everything runs in the browser. Your files never leave your machine.
→ https://samisalah221.github.io/3mf-to-glb/
No sign-up, no upload. If you don't have a .3mf handy, hit
"Try sample · owl (Bambu)" or "Try sample · sea slug (U1)" on the
upload screen, or grab one of the fixtures in samples/ and
drop it on the page.
Snapmaker U1 Innovation Fund submission: project description and checklist →
SNAPMAKER_FUND.md.
Slicers like Bambu Studio, OrcaSlicer, and Snapmaker U1 offer increasingly powerful color authoring tools — per-face painters, gradient fills, image projection. But once a multi-color print is sliced, that color data is effectively stuck inside the slicer file. You can't preview the recolored print on a product page, you can't share it in AR, you can't iterate the palette without reopening the slicer, and you can't hand the model to a designer in Blender. There was no way to actually see a print — in your colors, at true size, in your own space — before committing filament to it.
This project exists to close that gap. It parses the open 3MF archive, decodes the proprietary per-face color extensions layered on top of it, lets you recolor zones live in a browser viewport and preview the result in AR, and emits open formats so the model can move freely through the rest of the 3D ecosystem:
<model-viewer>, and product pages.It's built in the same spirit as OrcaSlicer, Klipper, Moonraker, and Fluidd: open code, open formats, no telemetry, no vendor lock-in.
Every other 3MF-to-mesh converter I've found throws the paint data away — because the per-face color encoding Bambu Studio and its forks use is undocumented, and Snapmaker U1's Full Spectrum mixed-filament blends are a second undocumented layer on top of that. There's no public spec for either. To make color survive the trip to GLB, both formats had to be reverse-engineered first.
The per-face format is a nibble-packed recursive triangle-subdivision tree,
read right-to-left, with state-overflow encoding once you're past 4
extruders. That decoder, and the exact triangle subdivision that reproduces
the slicer's paint zone for zone instead of guessing a dominant color, is
fully documented, down to the bit level.
Full Spectrum mixed filaments are virtual extruders whose color is a pigment
blend of two physical ones, defined in a packed row grammar with enumeration
rules that silently reassign painted faces if you get them wrong — see
docs/SNAPMAKER_FULL_SPECTRUM.md and the
write-up below. That's the actual moat here: once color survives the
conversion, everything else — live recoloring, true-size AR, re-tinted 3MF
export — is just what you can finally do with a print you can now see
before you make it.
mixed_filament_definitions
virtual extruders (pigment blends of two physical filaments), enumerates
only enabled/non-deleted rows the way the slicer itself does, and blends
colors through a pigment-mixing model rather than naive linear-RGB
averaging — so a navy + yellow blend renders the actual green the slicer
would produce, not a muddy average. See
docs/SNAPMAKER_FULL_SPECTRUM.md.v x y z r g b, sRGB), the
extension MeshMixer's coloring mode and Bambu Studio's OBJ import read, so
there is no separate MTL to keep alongside it. Vertices are in millimeters
and the color you picked is the color that loads.paint_color zones intact. The output drops back
into OrcaSlicer / Snapmaker U1 and slices.<model-viewer>) from the live demo, fully client-side. See your print
at true size in your own room before you spend filament.<model unit> from the source 3MF is parsed
(micron, millimeter, centimeter, inch, foot, meter), baked into the
exported vertex positions in meters, and surfaced as asset.extras on
the GLB plus a W × H × D readout in the viewport. AR launchers lock the
model to its true size, so a 100 mm cube shows up as a 10 cm cube.asset.extras.pivot_mode, pivot_offset_m, and up_axis make the choice
recoverable downstream. The 3MF write-back path is deliberately exempt:
shifting the model on the print bed is not what you want when re-slicing.asset.extras.applied_rotation_euler_deg
and applied_rotation_quat; the 3MF write-back is left unrotated so it
re-slices in its original print-bed layout.paint_color
encoding, same plate/object model, no per-vendor branching in the parser.┌────────────┐ ┌──────────────┐ ┌─────────────────────┐ ┌─────────┐
│ Upload 3MF │ ──▶ │ Parse OPC │ ──▶ │ Subdivide painted │ ──▶ │ Render │
│ (ZIP) │ │ + XML files │ │ tris, clip Z zones │ │ 3D view │
└────────────┘ └──────────────┘ └─────────────────────┘ └────┬────┘
│
▼
┌─────────────────────┐
│ Export GLB / USDZ / │
│ OBJ / 3MF │
└─────────────────────┘
git clone https://github.com/SamiSalah221/3mf-to-glb.git
cd 3mf-to-glb
npm install
npm run dev
Then open the Vite URL (usually http://localhost:5173) and drop a .3mf file
on the page.
npm run build # Production build of the web app into dist/
npm run preview # Preview the production build
npm run lint # ESLint
npm run build:lib # Build the headless library + CLI into dist-lib/
npm run test:roundtrip # End-to-end smoke test across samples/
The same parser that drives the web app is also published as a Node CLI for batch jobs, build pipelines, and headless renderers. No backend required.
# Convert a 3MF to GLB, recolor filaments 1 and 2 in the process:
npx 3mf-to-glb model.3mf -o recolored.glb --recolor "1=#cc0000,2=#000000"
# Pick a specific plate from a multi-plate file:
npx 3mf-to-glb model.3mf --plate 2 -o plate2.glb
# Reorient a Z-up print to glTF Y-up while exporting:
npx 3mf-to-glb model.3mf --rotation "90,0,0" -o yup.glb
# Write a per-vertex-color Wavefront OBJ instead (one self-contained file):
npx 3mf-to-glb model.3mf -o model.obj
Flags:
| Flag | Description |
|---|---|
-o, --output <file> | Output path. Defaults to the input name with .glb. An .obj extension writes a single per-vertex-color OBJ (no MTL). |
--plate <id> | 1-based plate id (matches Bambu / Orca / U1 plater_id). Defaults to the first plate. |
--recolor <map> | Comma-separated index=hex pairs applied before export. Hex may include or omit the leading #. |
--pivot <mode> | Export pivot: base-center (default), bbox-center, centroid, original, custom. |
--rotation <x,y,z> | XYZ Euler rotation in degrees, baked into geometry before the pivot. Try "90,0,0" to reorient a Z-up source to glTF Y-up. |
-h, --help | Show full help. |
The package exports a small, framework-agnostic API. Three.js is the only runtime dependency for GLB emission; the parser itself is pure TypeScript.
import { readFile, writeFile } from 'node:fs/promises';
import { DOMParser } from '@xmldom/xmldom';
import {
setDefaultDomParser,
parse3MF,
applyRecolor,
buildSceneFromPlate,
buildGLBBytes,
} from '3mf-to-glb';
// In Node, inject the XML parser once at startup. In the browser, omit
// this — the global DOMParser is used automatically.
setDefaultDomParser(new DOMParser());
const buf = await readFile('model.3mf');
const parsed = await parse3MF(buf);
const recolored = applyRecolor(parsed, { 1: '#FF0000', 2: '#00FF00' });
const scene = buildSceneFromPlate(recolored.plates[0].meshChunks, recolored.filaments);
const bytes = await buildGLBBytes(scene);
await writeFile('out.glb', bytes);
For the one-shot common case there is also a convertToGLB(buffer, options)
convenience that wraps the four calls above, and convertToOBJ(buffer, options)
which returns { obj }: a single per-vertex-color OBJ body as a string.
Swap the last two lines for buildOBJ(scene) to get { obj, stats } directly,
or buildOBJBytes(scene) for the encoded Uint8Array. The OBJ is
self-contained: colors ride on the v lines (x y z r g b, sRGB), so there is
nothing else to write next to it.
paint_color encodingBambuStudio / OrcaSlicer (and other forks using the same encoding) store per-face filament assignments in a proprietary binary format: a nibble-packed recursive triangle-subdivision tree, read right-to-left, with extended state overflow handling for 16+ extruders. No public documentation exists for this format — it was reverse-engineered from hex dumps of known-colored meshes and re-implemented in TypeScript.
The full technical spec lives in
docs/3MF-COLOR-RESOLUTION-TECHNICAL-SPEC.md.
Decoding the tree is only half the problem. The obvious shortcut is to walk a triangle's tree, take whichever color covers most of it, and paint the whole triangle that color. That shortcut is wrong in two ways, and both are visible:
So the parser reproduces the slicer's own subdivision: rotate the triangle so the encoded "special side" leads, insert exact edge midpoints, and emit the children in the same winding and the same order the slicer wrote them (they are serialized last-child-first, a detail a vote can ignore and geometry cannot). Midpoints are deduplicated across the mesh, so triangles that share an edge share the vertex on it and the surface stays watertight. A leaf of the tree becomes a triangle, and its color zone is exact.
By default the tree is decoded exactly: every leaf becomes its own triangle, so the color zones are geometrically identical to what the slicer painted, down to a one-millimeter dot on a large panel. A safety cutoff only engages as a fallback, and only for a mesh whose exact decode would blow a per-mesh face budget (1.5 million pre-repair faces). When it does engage, the parser seeds a floor of about 0.4% of the bounding-box diagonal (capped at a 0.4 mm absolute minimum, roughly one nozzle width), collapses any sub-triangle below it to whichever color covers the most area, and doubles that floor until a face-count estimate (a count-only mirror of the real decode) fits the budget.
The distinction matters in both directions. A densely painted 896k-triangle owl decodes to 18.7 million triangles and about a gigabyte of vertex data, far over budget, so it takes the fallback and coarsens to roughly one million faces with no loss anything could resolve. But a lightly painted panel whose 1 mm to 3 mm slicer dots decode to only 1.08 million faces now stays under budget and renders exactly, where an earlier always-on 0.4 mm floor used to quantize those dots into visible triangle confetti.
Subdividing one triangle but not its neighbour leaves a T-junction: the subdivided side gains a vertex at an edge midpoint that the adjacent face never got, because that face was unpainted, coarser, or collapsed by the budget fallback. A T-junction is a real hole. It shows up as a hairline crack along color boundaries in both the GLB and the OBJ, and it breaks watertightness for anyone who reimports the mesh. After subdivision the parser walks the emitted faces and stitches those seams closed, so each recolored zone meets its neighbour cleanly and the exported mesh stays watertight.
A Bambu model_settings.config lists an object's <part> elements in the
same order as that object's <component> elements, and identifies each part
by the id of the mesh it points at. When a project reuses one mesh several
times, which is exactly what "stamp this shape three times in three colors"
produces, that id repeats. Keying part metadata on the id therefore keeps
only the last instance and paints every copy the same color. The parts are
matched positionally instead, with an id lookup kept as a fallback for files
where the two lists disagree.
Snapmaker U1 Full Spectrum projects add a second layer on top of the shared
paint-tree encoding: mixed filaments, virtual extruders whose color is a
pigment blend of two physical filaments. These are defined in
project_settings.config under mixed_filament_definitions — a ;/,-packed
row grammar (15+ fields in the modern dialect, 4 in the legacy one) with no
public documentation. It was decoded from the open
ratdoux/OrcaSlicer-FullSpectrum
fork's C++ (MixedFilament.{hpp,cpp}) and validated against real U1 sample
files.
Getting it right required matching a few non-obvious rules the slicer itself enforces:
mixed_index_from_filament_id skips disabled rows and d1-tombstoned rows
entirely — they consume no index. One of this project's U1 sample files
leads with 6 disabled tombstone rows; enumerating over every row instead of
just the surviving ones silently reassigns roughly 84,000 painted faces to
the wrong blend recipe.mix_b_percent is component B's share, not A's (ratioA = (100 − mix_b) / 100), and an r<k>/<wA>/<wB> token overrides that percentage with
explicit weights. u<N> is a persistent label, not a slot index — it can
skip values.FilamentMixer, a degree-4 polynomial
approximating Mixbox, ported to
src/lib/filamentMixer.ts. Navy + yellow
renders as the green the slicer actually shows; a naive linear-RGB lerp
gives a muddy khaki instead.Both U1 sample fixtures in samples/ exercise this path, and
scripts/test-roundtrip.mjs asserts specific resolved blend ratios in CI on
every PR. Full write-up (row grammar, field-by-field semantics, and the
scope of what's still unimplemented) in
docs/SNAPMAKER_FULL_SPECTRUM.md.
Some 3MFs color geometry via layer height rather than per-face — the
so-called MultiAsSingle mode, where the slicer injects G-code tool-changes
at specific Z heights. To convert that into static geometry, the tool
implements progressive Z-plane triangle clipping with boundary splitting,
turning each layer-range into an independent colored zone.
3dmodel.model, model_settings.config,
project_settings.config, and custom_gcode_per_layer.xml with
fast-xml-parser.| Layer | Tool |
|---|---|
| Language | TypeScript |
| Frontend | React 19, Vite 7 |
| 3D rendering | Three.js, React Three Fiber, Drei |
| State | Zustand |
| File I/O | JSZip, fast-xml-parser, @gltf-transform/core |
| Styling | Tailwind CSS v4 |
3mf-to-glb/
├── src/
│ ├── lib/ # Core parsing and conversion logic
│ │ ├── parse3MF.ts # 3MF ZIP/XML parsing + paint_color subdivision
│ │ ├── colorConvert.ts # sRGB ↔ linear color space conversion
│ │ ├── glbBuilder.ts # Geometry → glTF document construction
│ │ ├── glbExporter.ts # GLB binary export
│ │ └── objExporter.ts # Per-vertex-color Wavefront OBJ export
│ ├── components/ # React UI
│ ├── viewer/ # Three.js / R3F scene
│ ├── store/ # Zustand state
│ ├── types/ # Shared TypeScript types
│ └── styles/ # CSS
├── samples/ # Example .3mf files
└── docs/ # Technical write-ups
Already shipped:
paint_color per-face subdivision tree. Full spec at
docs/3MF-COLOR-RESOLUTION-TECHNICAL-SPEC.md.docs/SNAPMAKER_FULL_SPECTRUM.md.3mf-to-glb) usable from Node and the browser,
plus a 3mf-to-glb Node CLI for batch jobs and build pipelines.allowsContentScaling=0), an in-page WebXR session on Android via
<model-viewer ar-scale="fixed">, and a GLB download on desktop. True-size
AR: the source 3MF unit is baked into the exported vertex positions in
meters, so AR previews show true print size, not a pinch-zoomable
cartoon.asset.extras, and
are exposed on the CLI as --pivot and --rotation.samples/.<part> metadata to <component> instances, so a
mesh reused several times in one object keeps a different filament per copy.-o model.obj), with sRGB colors on the v lines and vertices
in millimeters. Coincident corners are welded into one shared pool so the
mesh reads as watertight.Near-term work, in rough priority order:
docs/SNAPMAKER_FULL_SPECTRUM.md.docs/LIB3MF_INTEGRATION.md.<model-viewer>, the Three.js editor).gltf-transform mesh-optimization
pass (Draco / Meshopt) for lightweight AR and web delivery.These are honest near-term intentions, not commitments contingent on funding.
Issues and PRs welcome — see CONTRIBUTING.md for dev setup, code layout, and good first-issue ideas. All participation is governed by the Code of Conduct.
If you find a parsing bug, the most helpful bug report includes the
offending .3mf file (or a minimal repro) and a note on how it was produced.
Report vulnerabilities privately via GitHub security advisories. See SECURITY.md for scope.
MIT © 2026 Sami Salah
Built by Sami Salah.
This project is an independent community effort. It is not affiliated with or endorsed by Snapmaker, Bambu Lab, or OrcaSlicer. Trademarks belong to their respective owners; product names are referenced for interoperability.
47 commits
TypeScript
80.3%
JavaScript
17.3%
HTML
1.4%