diff --git a/src/io/export_3mf.ts b/src/io/export_3mf.ts
new file mode 100644
index 0000000..2c5806f
--- /dev/null
+++ b/src/io/export_3mf.ts
@@ -0,0 +1,86 @@
+import UZIP from "uzip";
+import { IndexedPolyhedron } from "./common";
+import { v4 as uuidv4 } from "uuid";
+import chroma from "chroma-js";
+
+function getColorMapping(colors: chroma.Color[], projectedColors: chroma.Color[]): number[] {
+ const projectedLabs = projectedColors.map(c => c.lab());
+
+ return colors.map((targetColor, i) => {
+ const targetLab = targetColor.lab();
+
+ let closestIndex = 0;
+ let minDelta = Infinity;
+ projectedLabs.forEach((projectedLab, index) => {
+ const deltaL = targetLab[0] - projectedLab[0];
+ const deltaA = targetLab[1] - projectedLab[1];
+ const deltaB = targetLab[2] - projectedLab[2];
+ const d = Math.sqrt(deltaL * deltaL + deltaA * deltaA + deltaB * deltaB);
+ if (d < minDelta) {
+ minDelta = d;
+ closestIndex = index;
+ }
+ });
+ return closestIndex;
+ });
+}
+
+// Reverse-engineered from PrusaSlicer / BambuStudio's output.
+const PAINT_COLOR_MAP = ['', '8', '0C', '1C', '2C', '3C', '4C', '5C', '6C', '7C', '8C', '9C', 'AC', 'BC', 'CC', 'DC'];
+
+export function exportOffTo3MF(data: IndexedPolyhedron, extruderColors?: chroma.Color[]): Blob {
+ const objectUuid = uuidv4();
+ const buildUuid = uuidv4();
+ const paintColorByColorIndex = extruderColors &&
+ getColorMapping(data.colors.map(c => chroma.rgb(...c)), extruderColors).map(i => PAINT_COLOR_MAP[i]);
+
+ const archive = {
+ // _rels/.rels
+ '[Content Types].xml': new TextEncoder().encode([
+ '',
+ '',
+ '',
+ '',
+ ].join('\n')),
+ '3D/3dmodel.model': new TextEncoder().encode([
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ ...data.colors.map((color, i) => ``),
+ '',
+ `',
+ '',
+ ``,
+ ` `,
+ '',
+ '',
+ ].join('\n')),
+ };
+ return new Blob([UZIP.encode(archive)], {type: 'model/3mf'});
+}
+