Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@
</button>
</div>
</div>
<!-- Tool/extruder legend for a multi-colour 3MF; populated by main.js. -->
<div id="part-legend" class="part-legend hidden" aria-live="polite"></div>
<div id="store-cta-wrapper">
<span id="store-cta">
<span data-i18n-html="cta.store">Support this tool? Shop at <a href="https://geni.us/CNCStoreTexture" target="_blank" rel="noopener noreferrer">CNCKitchen.STORE</a> or send a tip via <a href="https://www.paypal.me/CNCKitchen" target="_blank" rel="noopener noreferrer">PayPal</a> / <a href="https://ko-fi.com/cnckitchen" target="_blank" rel="noopener noreferrer">Ko-fi</a></span><button id="store-cta-dismiss" aria-label="Dismiss">&times;</button>
Expand All @@ -163,6 +165,13 @@
<input type="checkbox" id="projection-toggle" />
<span data-i18n="ui.perspective">Perspective View</span>
</label>
<!-- Only shown for a multi-colour 3MF; main.js unhides it on import. -->
<label class="wireframe-toggle hidden" id="part-colors-row"
data-i18n-title="ui.partColorsTitle"
title="Shade the model with the part colours from the imported 3MF">
<input type="checkbox" id="part-colors-toggle" />
<span data-i18n="ui.partColors">Part Colours</span>
</label>

<div class="viewport-controls-hint" data-i18n="ui.controlsHint">Left drag: orbit &nbsp;·&nbsp; Right drag: pan &nbsp;·&nbsp; Scroll: zoom</div>
</div>
Expand Down
41 changes: 36 additions & 5 deletions js/decimation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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++) {
Expand All @@ -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
Expand All @@ -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;
}

Expand Down
32 changes: 28 additions & 4 deletions js/exportPipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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]];
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -350,6 +373,7 @@ export async function runExportPipeline(input, onEvent = () => {}, shouldAbort =
runDecimation,
needsDecimation,
faceParentId: mode === 'bake' ? faceParentId : null,
faceMaterial,
repairStats,
};
} finally {
Expand Down
1 change: 1 addition & 0 deletions js/exportWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
Loading