diff --git a/README.md b/README.md
index a4f2185..d237c1c 100644
--- a/README.md
+++ b/README.md
@@ -81,6 +81,18 @@ Load an STL, OBJ, 3MF, or STEP file, pick a texture, tune the parameters, and ex
- Downloads a **binary STL** with displacement baked in
- Progress reporting through subdivision → displacement → decimation → writing stages
- Configurable edge-length threshold and output triangle limit
+- **Multi-colour 3MF round-trip** — a 3MF whose bodies carry per-part colours and
+ extruder assignments (Bambu Studio / OrcaSlicer / PrusaSlicer) keeps them through
+ texturing: each part is re-exported as its own object with its original name,
+ `displaycolor` and extruder, so no repainting is needed after re-import. STL has
+ no notion of colour, so use 3MF export to preserve it.
+- **Part Colours view** — a viewport toggle (bottom bar, shown only for a
+ multi-colour import) shades each body by the **extruder** it is assigned to,
+ with a legend mapping colour → part name → tool. Colouring by tool rather than
+ by the file's `displaycolor` matches how a slicer presents the plate, and keeps
+ parts distinct even when the file gives several of them the same swatch. Masked
+ and include-only regions keep their part hue under the mask tint rather than
+ going flat orange.
### Other
- **Light / Dark theme** — respects OS preference, persisted per browser
diff --git a/index.html b/index.html
index aa0a151..c6e5ba0 100644
--- a/index.html
+++ b/index.html
@@ -147,6 +147,8 @@
+
+
Support this tool? Shop at CNCKitchen.STORE or send a tip via PayPal / Ko-fi
@@ -163,6 +165,13 @@
Perspective View
+
+
Left drag: orbit · Right drag: pan · Scroll: zoom
diff --git a/js/decimation.js b/js/decimation.js
index 930fa99..f0ca5a4 100644
--- a/js/decimation.js
+++ b/js/decimation.js
@@ -49,6 +49,10 @@
* touch are left completely untouched. If locked faces alone reach the
* triangle target the run degrades to harvest-only and the returned
* geometry carries userData.lockedOverBudget = true.
+ * @param {Uint16Array|null} [faceMaterial] per-face palette slot in the
+ * input's face order (3MF colour preservation). Vertices shared by two
+ * materials are pinned so part boundaries stay crisp, and the surviving
+ * faces' slots are returned on the output's userData.faceMaterial.
* @returns {THREE.BufferGeometry}
*/
@@ -113,12 +117,14 @@ function _yieldFrame() {
// ── Public API ───────────────────────────────────────────────────────────────
-export async function decimate(geometry, targetTriangles, onProgress, harvestFlat = true, harvestTol = DEFAULT_HARVEST_TOL, lockedFaces = null) {
+export async function decimate(geometry, targetTriangles, onProgress, harvestFlat = true, harvestTol = DEFAULT_HARVEST_TOL, lockedFaces = null, faceMaterial = null) {
const { positions, faces, vertCount, faceCount } = buildIndexed(geometry);
// Already at/under the target: nothing to decimate. But if harvesting is on we
// still run — there may be flat faces collapsible for free even below the limit.
- if (faceCount <= targetTriangles && !harvestFlat) return buildOutput(positions, faces, faceCount);
+ if (faceCount <= targetTriangles && !harvestFlat) {
+ return buildOutput(positions, faces, faceCount, faceMaterial);
+ }
// Preserve-untextured (beta): a vertex touching any locked (untextured) face
// may neither move nor be removed, so edges with a locked endpoint are never
@@ -137,6 +143,24 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla
lockedVert[faces[f * 3 + 2]] = 1;
}
}
+
+ // Multi-material (3MF colour preservation): buildIndexed welds coincident
+ // vertices globally, so two touching parts share the vertices along their
+ // contact surface. Collapsing there would drag one filament's geometry into
+ // the other's and visibly ragged the colour boundary, so every vertex seen by
+ // more than one material is pinned — the same treatment locked faces get.
+ if (faceMaterial) {
+ const seenMat = new Int32Array(vertCount).fill(-1);
+ if (!lockedVert) lockedVert = new Uint8Array(vertCount);
+ for (let f = 0; f < faceCount; f++) {
+ const m = faceMaterial[f];
+ for (let k = 0; k < 3; k++) {
+ const v = faces[f * 3 + k];
+ if (seenMat[v] === -1) seenMat[v] = m;
+ else if (seenMat[v] !== m) lockedVert[v] = 1;
+ }
+ }
+ }
// When the locked faces alone meet or exceed the triangle target, the target
// is unreachable without touching untextured geometry. Chasing it anyway
// would grind the textured region down to its guard limit, so instead drop
@@ -146,7 +170,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla
&& lockedFaceCount >= targetTriangles;
if (lockedOverBudget && !harvestFlat) {
if (onProgress) onProgress(1);
- const out = buildOutput(positions, faces, faceCount);
+ const out = buildOutput(positions, faces, faceCount, faceMaterial);
out.userData.lockedOverBudget = true;
return out;
}
@@ -303,7 +327,7 @@ export async function decimate(geometry, targetTriangles, onProgress, harvestFla
}
if (onProgress) onProgress(1);
- const out = buildOutput(positions, faces, faceCount);
+ const out = buildOutput(positions, faces, faceCount, faceMaterial);
if (lockedOverBudget) out.userData.lockedOverBudget = true;
return out;
}
@@ -706,14 +730,18 @@ function buildIndexed(geometry) {
// (adjacency helpers replaced by buildLinkedAdj and _unlinkSlot/_moveSlot above)
-function buildOutput(positions, faces, faceCount) {
+function buildOutput(positions, faces, faceCount, faceMaterial = null) {
let activeFaces = 0;
for (let f = 0; f < faceCount; f++) {
if (faces[f * 3] >= 0) activeFaces++;
}
const posArray = new Float32Array(activeFaces * 9);
+ // Surviving faces keep their own material slot; collapses only delete faces
+ // and move vertices, they never merge two faces into one.
+ const outMaterial = faceMaterial ? new Uint16Array(activeFaces) : null;
let out = 0;
+ let outFace = 0;
for (let f = 0; f < faceCount; f++) {
if (faces[f * 3] < 0) continue;
for (let v = 0; v < 3; v++) {
@@ -722,6 +750,8 @@ function buildOutput(positions, faces, faceCount) {
posArray[out++] = positions[vi * 3 + 1];
posArray[out++] = positions[vi * 3 + 2];
}
+ if (outMaterial) outMaterial[outFace] = faceMaterial[f];
+ outFace++;
}
// Compute exact per-face normals from the final positions so winding order
@@ -744,6 +774,7 @@ function buildOutput(positions, faces, faceCount) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(nrmArray, 3));
+ if (outMaterial) geo.userData.faceMaterial = outMaterial;
return geo;
}
diff --git a/js/exportPipeline.js b/js/exportPipeline.js
index dc5b059..083ae63 100644
--- a/js/exportPipeline.js
+++ b/js/exportPipeline.js
@@ -201,6 +201,12 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(input.positions, 3));
+ // Multi-material 3MF colour preservation: one palette slot per source
+ // triangle. When present it is carried all the way to the exporter so each
+ // body keeps its filament assignment. Absent for STL/OBJ and single-colour
+ // 3MFs, in which case every stage below behaves exactly as before.
+ const srcMaterial = input.triMaterial || null;
+
// Hoist intermediates so the finally block can always dispose them.
let subdivided = null;
let displaced = null;
@@ -226,7 +232,10 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
if (settings.regularizeEnabled) {
onEvent('regularize', 0);
await yieldFrame();
- const regParents = mode === 'bake'
+ // Export mode normally has no use for the parent map, but material
+ // tracking needs it too — thread the real one whenever either wants it.
+ const needParents = mode === 'bake' || srcMaterial !== null;
+ const regParents = needParents
? faceParentId
: new Int32Array(subdivided.attributes.position.count / 3);
const reg = regularizeMesh(subdivided, regParents, settings.refineLength, regularizeOpts);
@@ -239,7 +248,7 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
secondPassWeights, { fast: false }
);
reg.geometry.dispose();
- if (mode === 'bake') {
+ if (needParents) {
const composed = new Int32Array(resubParents.length);
for (let i = 0; i < resubParents.length; i++) {
composed[i] = reg.faceParentId[resubParents[i]];
@@ -250,6 +259,17 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
}
if (shouldAbort()) return null;
+ // Project the source palette slots onto the refined mesh. faceParentId maps
+ // each refined face back to the source triangle it descends from, so this
+ // is exact — no spatial guessing at part boundaries.
+ let faceMaterial = null;
+ if (srcMaterial) {
+ faceMaterial = new Uint16Array(faceParentId.length);
+ for (let i = 0; i < faceParentId.length; i++) {
+ faceMaterial[i] = srcMaterial[faceParentId[i]];
+ }
+ }
+
const subTriCount = subdivided.attributes.position.count / 3;
onEvent('displace', 0, { triCount: subTriCount });
await yieldFrame();
@@ -302,10 +322,12 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
(p) => onEvent('decimate', p, { from: dispTriCount, needsDecimation }),
settings.harvestFlatFaces,
settings.harvestTol,
- lockedFaces
+ lockedFaces,
+ faceMaterial
);
// Capture before repair replaces the geometry (userData isn't carried over).
lockedOverBudget = !!finalGeometry.userData.lockedOverBudget;
+ if (faceMaterial) faceMaterial = finalGeometry.userData.faceMaterial;
// Free pre-decimation geometry — decimate created a separate copy.
displaced.dispose();
displaced = null;
@@ -327,9 +349,10 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
onEvent('repair', 0);
await yieldFrame();
const beforeSlivers = countAreaSlivers(finalGeometry);
- const repaired = resolveTJunctions(finalGeometry);
+ const repaired = resolveTJunctions(finalGeometry, { faceMaterial });
finalGeometry.dispose();
finalGeometry = repaired;
+ if (faceMaterial) faceMaterial = repaired.userData.faceMaterial;
const after = countEdgeDefects(finalGeometry);
repairStats = {
beforeSlivers,
@@ -350,6 +373,7 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
runDecimation,
needsDecimation,
faceParentId: mode === 'bake' ? faceParentId : null,
+ faceMaterial,
repairStats,
};
} finally {
diff --git a/js/exportWorker.js b/js/exportWorker.js
index 34cbe53..b7c10e0 100644
--- a/js/exportWorker.js
+++ b/js/exportWorker.js
@@ -35,6 +35,7 @@ self.onmessage = async (e) => {
const transfers = [result.positions.buffer];
if (result.normals) transfers.push(result.normals.buffer);
if (result.faceParentId) transfers.push(result.faceParentId.buffer);
+ if (result.faceMaterial) transfers.push(result.faceMaterial.buffer);
self.postMessage({ type: 'done', result }, transfers);
} catch (err) {
self.postMessage({ type: 'error', message: (err && err.message) || String(err) });
diff --git a/js/exporter.js b/js/exporter.js
index 80bee5c..0a2e0b7 100644
--- a/js/exporter.js
+++ b/js/exporter.js
@@ -87,6 +87,30 @@ export function exportSTL(geometry, filename = 'textured.stl') {
triggerDownload(buffer, filename);
}
+/**
+ * Escape a string for use inside a double-quoted XML attribute. Part names come
+ * from the imported file, so they can legitimately contain & < > and quotes.
+ */
+function xmlAttr(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ // Strip control characters that are simply illegal in XML 1.0.
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
+}
+
+/**
+ * Render a palette colour as a 3MF displaycolor ("#RRGGBBAA"). Parts imported
+ * without a colour still need a valid value here, so fall back to opaque white.
+ */
+function toDisplayColor(color) {
+ const m = /^#([0-9a-fA-F]{6})$/.exec(color || '');
+ return '#' + (m ? m[1].toUpperCase() : 'FFFFFF') + 'FF';
+}
+
/**
* 3MF exporter — builds a ZIP-packaged XML mesh in the Microsoft 3D
* Manufacturing core format (2015/02).
@@ -95,37 +119,37 @@ export function exportSTL(geometry, filename = 'textured.stl') {
* tolerance) so the output is both smaller than binary STL and round-trippable
* by this project's own 3MF loader.
*
+ * When `materials` is supplied (multi-colour / multi-tool 3MF import), each
+ * part is written as its own