SamiSalah221/3mf-to-glb
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% clie...
12 stars · 1 forks
README
Printproof 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 .3mf that 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.
Try it now → 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.
Why this project exists 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:
Khronos glTF / GLB for Blender, Three.js, Unity, <model-viewer>, and product pages. USDZ for Apple Quick Look and iOS AR. 3MF with refreshed filament colors so the file drops straight back into OrcaSlicer / Snapmaker U1 and slices.
It's built in the same spirit as OrcaSlicer, Klipper, Moonraker, and Fluidd: open code, open formats, no telemetry, no vendor lock-in. Why nothing else does this 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. Features
Decodes the proprietary per-face paint extension used by Bambu Studio, OrcaSlicer, and Snapmaker U1: a nibble-packed recursive triangle-subdivision tree, reverse-engineered and fully documented. Rebuilds painted geometry exactly, rather than approximating it. Each painted triangle is subdivided the way the slicer subdivides it, so a brush stroke covering four percent of one triangle survives as four percent of one triangle. The decode is slicer-identical by default, and color-boundary T-junctions are stitched closed so the mesh stays watertight; a face budget only coarsens meshes so densely painted they would otherwise explode. Tools that roll a triangle up to a single "dominant" color silently erase small paint on coarse meshes and can only place a color boundary along an edge that already exists in the mesh. Handles layer-mode coloring, including U1 Full Spectrum gradients and Bambu's MultiAsSingle mode, via Z-plane triangle clipping. Tool-change G-code becomes real geometry zones with no slicer in the loop. Snapmaker U1 Full Spectrum mixed filaments. Parses 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. Interactive 3D editor. Recolor any zone with a color picker; changes update live in the Three.js viewport. PBR GLB export. One glTF material per color zone, correct sRGB to linear conversion, ready for Blender, Unity, WebGL, and product pages. Per-vertex-color OBJ export. The same scene as a single self-contained Wavefront OBJ: colors ride on the vertices (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. 3MF re-tint round-trip. Re-emit the source 3MF with new filament colors, geometry and paint_color zones intact. The output drops back into OrcaSlicer / Snapmaker U1 and slices. USDZ export and View-in-AR. Preview the recolored model in iOS Quick Look (USDZ) or an in-page WebXR session on Android (GLB via <model-viewer>) from the live demo, fully client-side. See your print at true size in your own room before you spend filament. Real-world scale. The <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. Configurable export pivot. Choose where the model origin should sit in the exported GLB/USDZ (base-center for AR floor placement, bbox-center, area-weighted centroid, original 3MF origin, or a custom mm offset). The translation is baked into vertex positions so the file is self-contained; 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. Export-time reorientation. Rotate the model before export with world-axis ±90° snaps or numeric XYZ degrees, then "lay flat on floor" to re-rest it on the AR ground plane. The rotation is baked into the GLB/USDZ vertex positions and normals, and the pivot is recomputed from the rotated bounding box, so a Z-up print ships as a correctly-standing, floor-anchored AR model. The orientation is recorded as 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. Works across the Bambu / OrcaSlicer / Snapmaker U1 family. Same paint_color encoding, same plate/object model, no per-vendor branching in the parser. 100% client-side. Pure static site, no backend, no telemetry. Works offline after first load.
How it works ┌────────────┐ ┌──────────────┐ ┌─────────────────────┐ ┌─────────┐ │ Upload 3MF │ ──▶ │ Parse OPC │ ──▶ │ Subdivide painted │ ──▶ │ Render │ │ (ZIP) │ │ + XML files │ │ tris, clip Z zones │ │ 3D view │ └────────────┘ └──────────────┘ └─────────────────────┘ └────┬────┘ │ ▼ ┌─────────────────────┐ │ Export GLB / USDZ / │ │ OBJ / 3MF │ └─────────────────────┘
Quick start (local dev) 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/ Use it from Node (CLI) 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.glbReorient a Z-up print to glTF Y-up while exporting:
npx 3mf-to-glb model.3mf --rotation "90,0,0" -o yup.glbWrite 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.
Use it from your own code (library) 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. Under the hood Reverse-engineering the slicer paint_color encoding BambuStudio / 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. Subdividing painted triangles instead of voting on them 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:
On a coarse mesh the paint disappears. A cube face is two triangles; text written across it covers a few percent of each. "Mostly unpainted" wins every vote, and the cube renders blank. A color boundary can only ever land on an edge the mesh already has, so painted regions bleed into their neighbours along the original triangulation. This produced a white-seam artifact that took a while to attribute to the decoder rather than the renderer.
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. Stitching color-boundary cracks 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. Instanced parts and their extruders 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. Decoding U1 Full Spectrum mixed filaments 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:
Virtual extruder ids count only enabled, non-deleted rows. The slicer's 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. Blending uses a pigment-mixing model, not a linear-RGB average. The slicer runs blends through 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. Z-plane triangle clipping 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. 3MF → glTF material pipeline
ZIP/OPC archive parsing via JSZip. XML correlation across 3dmodel.model, model_settings.config, project_settings.config, and custom_gcode_per_layer.xml with fast-xml-parser. Affine transform composition across component → object → plate coordinate spaces. sRGB → linear color-space conversion for PBR-correct rendering. Per-zone PBR material generation via @gltf-transform/core.
Tech stack
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
Project structure 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
Roadmap Already shipped:
Open browser-based preview and recolor tool with a face-on viewport, per-zone color picke
