From adba040395d3a5ab09450341d92f517943f07176 Mon Sep 17 00:00:00 2001 From: themantler Date: Sun, 10 May 2026 19:21:21 -0400 Subject: [PATCH] 3MF-Multi-Body Support Added support for multiple bodies when using 3MF files. Bodies will retain coordinate/naming data when being imported/baked/textured/exported as well as during save/load project operations. This will make it easier to apply textures to multiple bodies and then import them into the Slicer for use with different filaments/settings. --- js/exporter.js | 348 +++++++++++++------------- js/main.js | 642 +++++++++++++++++++++++++++++++++++++++--------- js/stlLoader.js | 472 ++++++++++++++--------------------- 3 files changed, 882 insertions(+), 580 deletions(-) diff --git a/js/exporter.js b/js/exporter.js index 64a92ba..925d33e 100644 --- a/js/exporter.js +++ b/js/exporter.js @@ -1,232 +1,214 @@ import { zipSync, strToU8 } from 'fflate'; -/** - * Trigger a browser download for a binary buffer. - * @param {ArrayBuffer|Uint8Array} buffer - * @param {string} filename - * @param {string} [mime] - */ function triggerDownload(buffer, filename, mime = 'application/octet-stream') { const blob = new Blob([buffer], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + a.href=url; a.download=filename; a.style.display='none'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 10000); } -/** - * Fast binary STL exporter — writes directly from BufferGeometry arrays. - * - * Eliminates Three.js STLExporter overhead: - * - No Mesh/Material creation - * - No identity matrix multiplication per vertex - * - No redundant normal recomputation - * - Bulk Uint8Array.set() instead of per-float DataView calls - * - * @param {THREE.BufferGeometry} geometry – non-indexed with position + normal - * @param {string} [filename] - */ +// ── STL exporter ───────────────────────────────────────────────────────────── + export function exportSTL(geometry, filename = 'textured.stl') { const posArr = geometry.attributes.position.array; - const norArr = geometry.attributes.normal - ? geometry.attributes.normal.array - : null; + const norArr = geometry.attributes.normal ? geometry.attributes.normal.array : null; const triCount = (posArr.length / 9) | 0; - - // Binary STL: 80-byte header + 4-byte tri count + 50 bytes per triangle - const bufLen = 84 + 50 * triCount; - const buffer = new ArrayBuffer(bufLen); + const buffer = new ArrayBuffer(84 + 50 * triCount); const bytes = new Uint8Array(buffer); const view = new DataView(buffer); - - // Header: 80 bytes (already zero-filled) view.setUint32(80, triCount, true); - - // Reinterpret source arrays as raw bytes for bulk copy const posSrc = new Uint8Array(posArr.buffer, posArr.byteOffset, posArr.byteLength); - const norSrc = norArr - ? new Uint8Array(norArr.buffer, norArr.byteOffset, norArr.byteLength) - : null; - - for (let i = 0; i < triCount; i++) { - const dst = 84 + i * 50; - const srcOff = i * 36; // 9 floats * 4 bytes - + const norSrc = norArr ? new Uint8Array(norArr.buffer, norArr.byteOffset, norArr.byteLength) : null; + for (let i=0; i 0) ? bodies : null; + _3mfCenterOffset = centerOffset || null; } -/** - * 3MF exporter — builds a ZIP-packaged XML mesh in the Microsoft 3D - * Manufacturing core format (2015/02). - * - * Vertices are deduplicated (positions quantized to 4 decimals, i.e. 0.0001 mm - * tolerance) so the output is both smaller than binary STL and round-trippable - * by this project's own 3MF loader. - * - * @param {THREE.BufferGeometry} geometry – non-indexed with position attribute - * @param {string} [filename] - */ -export function export3MF(geometry, filename = 'textured.3mf') { - const posArr = geometry.attributes.position.array; - const triCount = (posArr.length / 9) | 0; +export function clear3mfBodies() { + _3mfBodies = null; + _3mfCenterOffset = null; +} + +export function get3mfBodies() { + return _3mfBodies; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fmt4(n) { + if (typeof n !== 'number' || !isFinite(n)) return '0'; + let s = n.toFixed(4); + if (s.indexOf('.') !== -1) s = s.replace(/0+$/, '').replace(/\.$/, ''); + return s; +} + +function escapeXml(s) { + return String(s) + .replace(/&/g,'&').replace(/"/g,'"') + .replace(/'/g,''').replace(//g,'>'); +} - // ── Deduplicate vertices ───────────────────────────────────────────────── - // Key on fixed-precision position strings. 4 decimals = 0.0001 mm, safely - // below the resolution of any FDM/SLA printer and far tighter than float32 - // rounding noise from the displacement pipeline. - const indexMap = new Map(); - const uniqueXYZ = []; // flat [x,y,z,x,y,z,...] - const triIdx = new Uint32Array(triCount * 3); - - for (let i = 0; i < triCount; i++) { - for (let j = 0; j < 3; j++) { - const b = i * 9 + j * 3; - const x = posArr[b]; - const y = posArr[b + 1]; - const z = posArr[b + 2]; - const key = x.toFixed(4) + ',' + y.toFixed(4) + ',' + z.toFixed(4); - let idx = indexMap.get(key); - if (idx === undefined) { - idx = uniqueXYZ.length / 3; - uniqueXYZ.push(x, y, z); - indexMap.set(key, idx); - } - triIdx[i * 3 + j] = idx; +function makeEmitter() { + const enc=new TextEncoder(), chunks=[]; + let total=0, pending=''; + const FLUSH=1<<20; + const flush=()=>{ if(!pending)return; const b=enc.encode(pending); chunks.push(b); total+=b.length; pending=''; }; + const emit=(s)=>{ pending+=s; if(pending.length>=FLUSH)flush(); }; + const finish=()=>{ flush(); const out=new Uint8Array(total); let off=0; for(const b of chunks){out.set(b,off);off+=b.length;} return out; }; + return {emit,finish}; +} + +function emitObjectXml(emitter, geometry, objectId, name) { + const { emit } = emitter; + const posArr = geometry.attributes.position.array; + const triCount = (posArr.length / 9) | 0; + if (triCount === 0) return; + + const indexMap = new Map(); + const xyz = []; + const triIdx = new Uint32Array(triCount * 3); + + for (let i=0; i\n\n\n`); + for (let i=0;i\n'); } - function emit(s) { - pending += s; - if (pending.length >= FLUSH_THRESHOLD) flush(); + emit('\n\n'); + for (let i=0;i\n'); } + emit('\n\n\n'); +} - emit( - '\n' + - '\n' + - '\n' + - '\n' + - '\n' + - '\n' - ); - - // Vertices: trim trailing zeros to keep the file compact. - const fmt = (n) => { - // 4 decimals matches the dedup precision; strip trailing zeros and ".". - let s = n.toFixed(4); - if (s.indexOf('.') !== -1) s = s.replace(/0+$/, '').replace(/\.$/, ''); - return s; - }; - for (let i = 0; i < vertCount; i++) { - const b = i * 3; - emit( - '\n' - ); +function buildTransformAttr(matrix, centerOffset) { + if (!matrix || !matrix.isMatrix4 || !matrix.elements || matrix.elements.length < 16) return null; + const e=matrix.elements; + const m00=e[0],m10=e[1],m20=e[2]; + const m01=e[4],m11=e[5],m21=e[6]; + const m02=e[8],m12=e[9],m22=e[10]; + let tx=e[12],ty=e[13],tz=e[14]; + if (centerOffset) { + tx+=isFinite(centerOffset.x)?centerOffset.x:0; + ty+=isFinite(centerOffset.y)?centerOffset.y:0; + tz+=isFinite(centerOffset.z)?centerOffset.z:0; } + const eps=1e-5; + if (Math.abs(m00-1)parseFloat((isFinite(v)?v:0).toFixed(6))).join(' '); +} - emit('\n\n'); +// ── Core 3MF byte builder (no download) ────────────────────────────────────── +// Used by both export3MF (adds download) and project save (_bodiesToRaw3MF). - for (let i = 0; i < triCount; i++) { - const b = i * 3; - emit( - '\n' - ); - } +export function build3MFBytes(bodyResultsOrGeometry) { + const emitter = makeEmitter(); + const { emit, finish } = emitter; emit( - '\n' + - '\n' + - '\n' + - '\n' + - '\n\n\n' + - '\n' + '\n'+ + '\n'+ + '\n' ); - flush(); - const modelBytes = new Uint8Array(totalBytes); - { - let off = 0; - for (const b of byteChunks) { modelBytes.set(b, off); off += b.length; } + const isMultiBody = Array.isArray(bodyResultsOrGeometry) && bodyResultsOrGeometry.length > 0; + + if (isMultiBody) { + const bodyResults = bodyResultsOrGeometry; + const nonEmpty = bodyResults.filter(b => (b.geometry.attributes.position.array.length/9|0) > 0); + for (let i=0; i\n\n'); + for (let i=0; i\n` + : `\n`); + } + emit('\n\n'); + } else { + const geometry = bodyResultsOrGeometry; + const name = (_3mfBodies && _3mfBodies.length===1) ? (_3mfBodies[0].name||'') : ''; + const matrix = (_3mfBodies && _3mfBodies.length===1) ? _3mfBodies[0].matrix : null; + emitObjectXml(emitter, geometry, 1, name); + emit('\n\n'); + const txAttr = buildTransformAttr(matrix, _3mfCenterOffset); + emit(txAttr ? `\n` : '\n'); + emit('\n\n'); } - // ── Static package files ───────────────────────────────────────────────── + const modelBytes = finish(); + const contentTypesXml = - '\n' + - '\n' + - '\n' + - '\n' + + '\n'+ + '\n'+ + '\n'+ + '\n'+ '\n'; - const relsXml = - '\n' + - '\n' + - '\n' + + '\n'+ + '\n'+ + '\n'+ '\n'; - // ── Zip and download ───────────────────────────────────────────────────── - const zipped = zipSync({ + return zipSync({ '[Content_Types].xml': strToU8(contentTypesXml), '_rels/.rels': strToU8(relsXml), '3D/3dmodel.model': modelBytes, - }, { level: 6 }); - - triggerDownload( - zipped, - filename, - 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml' - ); + }, { level:6 }); } + +// ── Public 3MF exporter (builds bytes then triggers download) ───────────────── + +export function export3MF(bodyResultsOrGeometry, filename = 'textured.3mf') { + const zipped = build3MFBytes(bodyResultsOrGeometry); + triggerDownload(zipped, filename, 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml'); +} \ No newline at end of file diff --git a/js/main.js b/js/main.js index 4929786..9b7e584 100644 --- a/js/main.js +++ b/js/main.js @@ -5,7 +5,7 @@ import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWirefram setProjection, requestRender, clearDiagOverlays, setDiagEdges, addDiagFaces, setRotationGizmo, isGizmoDragging } from './viewer.js'; -import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; +import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { computeSmartResolution } from './smartResolution.js'; import { loadAllThumbnails, loadFullPreset, loadCustomTexture, IMAGE_PRESETS } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; @@ -13,7 +13,7 @@ import { subdivide } from './subdivision.js'; import { regularizeMesh } from './regularize.js'; import { applyDisplacement } from './displacement.js'; import { decimate } from './decimation.js'; -import { exportSTL, export3MF } from './exporter.js'; +import { exportSTL, export3MF, get3mfBodies, build3MFBytes } from './exporter.js'; import { buildAdjacency, bucketFill, buildExclusionOverlayGeo, buildFaceWeights } from './exclusion.js'; import { runFastDiagnostics, runExpensiveDiagnostics, @@ -2943,6 +2943,16 @@ async function handleModelFile(file) { triangleCentroids = adjData.centroids; triangleFaceNormals = adjData.faceNormals; updateMeshDiagnostics(adjData, currentGeometry.attributes.position.count / 3); + // Clear per-body bake state from any previous session + const oldBodies = get3mfBodies(); + if (oldBodies) { + for (const body of oldBodies) { + body.wasPainted = false; + body.excludedFaces = null; + body._origStartTri = null; + body._origTriCount = null; + } + } // Carry scale, offset, rotation, and all other tuning across model swaps — // they're normalized to the bounding box so they apply meaningfully to the @@ -4378,26 +4388,204 @@ async function handleExport(format = 'stl') { export3mfBtn.classList.add('busy'); exportProgress.classList.remove('hidden'); - // If precision masking is active, bake the refined mesh before exporting if (precisionMaskingEnabled) { deactivatePrecisionMasking(); } - // Hoist intermediate geometries so the finally block can always dispose them let subdivided = null; let displaced = null; let finalGeometry = null; - let exportSucceeded = false; // set true only after exportSTL so finally can clean up on abort/error + let exportSucceeded = false; try { + const bodies = (format === '3mf') ? get3mfBodies() : null; + + if (bodies && bodies.length > 1) { + setProgress(0.02, t('progress.subdividing')); + await yieldFrame(); + if (exportToken !== myToken) return; + + const exportEntry = getEffectiveMapEntry(); + const bodyResults = []; + const perBodyBudget = settings.maxTriangles; + + for (let bi = 0; bi < bodies.length; bi++) { + const body = bodies[bi]; + const bodyGeo = body.geometry; + + if (!bodyGeo || bodyGeo.attributes.position.array.length === 0) continue; + + const frac = bi / bodies.length; + + setProgress( + 0.02 + frac * 0.93, + t('progress.subdividing') + ` (${bi + 1}/${bodies.length})` + ); + await yieldFrame(); + if (exportToken !== myToken) return; + + let bodySubdivided = null, bodyDisplaced = null, bodyFinal = null; + try { + const alreadyBaked = !!body.wasPainted; + + if (alreadyBaked) { + // Body was painted and baked — write the baked geometry directly + bodyFinal = bodyGeo.clone(); + } else { + // Not baked — apply current texture. + // Map excludedFaces from merged currentGeometry to this body's + // local triangle indices using body.startTri. + // After baking, startTri was updated to reflect the post-bake + // merged geometry, so this mapping is correct. + const bodyLocalTriCount = bodyGeo.attributes.position.count / 3; + const bodyExcludedLocal = new Set(); + if (excludedFaces.size > 0 && body.startTri != null) { + for (const mergedIdx of excludedFaces) { + const localIdx = mergedIdx - body.startTri; + if (localIdx >= 0 && localIdx < bodyLocalTriCount) { + bodyExcludedLocal.add(localIdx); + } + } + } + + const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0; + const faceWeights = (bodyExcludedLocal.size > 0 || selectionMode || hasAngleMask) + ? buildCombinedFaceWeights(bodyGeo, bodyExcludedLocal, selectionMode, settings) + : null; + + let safetyCapHit; + ({ geometry: bodySubdivided, safetyCapHit } = await subdivide( + bodyGeo, + settings.refineLength, + (p) => setProgress( + 0.02 + (frac + p / bodies.length) * 0.40, + t('progress.subdividing') + ` (${bi + 1}/${bodies.length})` + ), + faceWeights + )); + if (exportToken !== myToken) return; + + if (settings.regularizeEnabled) { + setProgress(0.02 + frac * 0.93, t('progress.regularizing')); + await yieldFrame(); + const reg = regularizeMesh( + bodySubdivided, + new Int32Array(bodySubdivided.attributes.position.count / 3), + settings.refineLength, + _regularizeOpts() + ); + bodySubdivided.dispose(); + const exclAttr = reg.geometry.attributes.excludeWeight; + const { geometry: resub } = await subdivide( + reg.geometry, + settings.refineLength * settings.regularizeSecondPassMul, + null, + exclAttr ? exclAttr.array : null, + { fast: false } + ); + reg.geometry.dispose(); + bodySubdivided = resub; + if (exportToken !== myToken) return; + } + + bodyDisplaced = await runAsync(() => + applyDisplacement( + bodySubdivided, + exportEntry.imageData, + exportEntry.width, + exportEntry.height, + settings, + currentBounds, + (p) => setProgress( + 0.02 + (frac + p / bodies.length) * 0.70, + t('progress.displacingVertices') + ` (${bi + 1}/${bodies.length})` + ) + ) + ); + if (exportToken !== myToken) return; + + bodySubdivided.dispose(); bodySubdivided = null; + + const dispTris = bodyDisplaced.attributes.position.count / 3; + if (dispTris > perBodyBudget) { + setProgress( + 0.02 + frac * 0.93, + t('progress.decimatingTo', { + from: dispTris.toLocaleString(), + to: perBodyBudget.toLocaleString(), + }) + ); + bodyFinal = await runAsync(() => decimate(bodyDisplaced, perBodyBudget, null)); + bodyDisplaced.dispose(); bodyDisplaced = null; + if (exportToken !== myToken) return; + } else { + bodyFinal = bodyDisplaced; bodyDisplaced = null; + } + } + + // Flat-bottom clamp + if (settings.bottomAngleLimit > 0) { + const bottomZ = currentBounds.min.z; + const pa = bodyFinal.attributes.position.array; + const na = bodyFinal.attributes.normal + ? bodyFinal.attributes.normal.array + : new Float32Array(pa.length); + for (let i = 0; i < pa.length; i += 9) { + let dirty = false; + if (pa[i+2] { exportProgress.classList.add('hidden'); setProgress(0, ''); }, 1500); + return; + } + + // ── Single-body path ───────────────────────────────────────────────────── + setProgress(0.02, t('progress.subdividing')); await yieldFrame(); if (exportToken !== myToken) return; - // Build per-vertex exclusion weights combining user-painted exclusion + angle masking. - // Faces masked by top/bottom angle limits are treated the same as user-excluded faces - // so subdivision skips their interior edges too, saving triangles where no - // displacement will be applied. const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0; const faceWeights = (excludedFaces.size > 0 || selectionMode || hasAngleMask) ? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings) @@ -4416,12 +4604,6 @@ async function handleExport(format = 'stl') { )); if (exportToken !== myToken) return; - // Regularize sub-slivers, then re-subdivide stretched edges — see preview - // pipeline for rationale. Skipped entirely when the Advanced toggle is - // off. Faceweight mapping isn't propagated through regularize on this - // branch because user-painted exclusions were already baked into the - // first subdivide via faceWeights → excludeWeight, which regularize then - // copies through and we can pass straight to the second subdivide. if (settings.regularizeEnabled) { setProgress(0.30, t('progress.regularizing')); await yieldFrame(); @@ -4460,7 +4642,6 @@ async function handleExport(format = 'stl') { ); if (exportToken !== myToken) return; - // Free subdivided geometry — displacement created a separate copy subdivided.dispose(); const dispTriCount = displaced.attributes.position.count / 3; @@ -4484,50 +4665,37 @@ async function handleExport(format = 'stl') { } ) ); - // Free pre-decimation geometry — decimate created a separate copy displaced.dispose(); - if (exportToken !== myToken) return; + if (exportToken !== myToken) return; } - // Flat-bottom clamp: when bottom faces are masked (bottomAngleLimit > 0), - // any vertex that ended up below the original model's bottom layer gets - // snapped back up to that Z. Single pass with selective normal recomputation. if (settings.bottomAngleLimit > 0) { const bottomZ = currentBounds.min.z; const pa = finalGeometry.attributes.position.array; const na = finalGeometry.attributes.normal ? finalGeometry.attributes.normal.array : new Float32Array(pa.length); - for (let i = 0; i < pa.length; i += 9) { let dirty = false; - if (pa[i+2] < bottomZ) { pa[i+2] = bottomZ; dirty = true; } - if (pa[i+5] < bottomZ) { pa[i+5] = bottomZ; dirty = true; } - if (pa[i+8] < bottomZ) { pa[i+8] = bottomZ; dirty = true; } - + if (pa[i+2] { - exportProgress.classList.add('hidden'); - setProgress(0, ''); - }, 1500); + setTimeout(() => { exportProgress.classList.add('hidden'); setProgress(0, ''); }, 1500); + } catch (err) { console.error('Export failed:', err); if (/maximum size|out of memory|alloc/i.test(err.message)) { @@ -4559,12 +4725,9 @@ async function handleExport(format = 'stl') { alert(t('alerts.exportFailed', { msg: err.message })); } } finally { - // Dispose all intermediate geometries regardless of success, failure, or abort. - // finalGeometry may alias displaced (no decimation) — avoid double-dispose. if (subdivided) subdivided.dispose(); if (displaced && displaced !== subdivided) displaced.dispose(); if (finalGeometry && finalGeometry !== displaced && finalGeometry !== subdivided) finalGeometry.dispose(); - // Hide progress immediately on error or stale abort; success hides it after 1500 ms. if (!exportSucceeded) exportProgress.classList.add('hidden'); isExporting = false; exportBtn.classList.remove('busy'); @@ -4642,6 +4805,7 @@ function setBakeProgress(fraction, label) { // Decimation is intentionally skipped — decimate() drops the per-face parent // mapping needed to translate "which input faces were textured" into the new // mesh's triangle indices. Final decimation still happens on Export. + async function bakeTextures() { if (!currentGeometry || !activeMapEntry || isBaking || isExporting) return; isBaking = true; @@ -4659,8 +4823,6 @@ async function bakeTextures() { setBakeProgress(0.02, t('progress.subdividing')); await yieldFrame(); - // Mirror handleExport's pre-flight: combine user mask + angle masking - // into per-vertex weights for subdivision. const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0; const faceWeights = (excludedFaces.size > 0 || selectionMode || hasAngleMask) ? buildCombinedFaceWeights(currentGeometry, excludedFaces, selectionMode, settings) @@ -4678,10 +4840,6 @@ async function bakeTextures() { faceWeights )); - // Regularize sub-slivers, then re-subdivide stretched edges — see preview - // pipeline for rationale. Skipped entirely when the Advanced toggle is - // off. Compose the two parent maps so faceParentId still points at - // original-mesh faces (used below to remap user exclusions onto baked output). if (settings.regularizeEnabled) { setBakeProgress(0.36, t('progress.regularizing')); await yieldFrame(); @@ -4724,78 +4882,290 @@ async function bakeTextures() { ) ); - // Free pre-displacement subdivision — applyDisplacement returns a separate copy. subdivided.dispose(); subdivided = null; - // Mirror the export-side flat-bottom clamp. if (settings.bottomAngleLimit > 0) { const bottomZ = currentBounds.min.z; const pa = displaced.attributes.position.array; const na = displaced.attributes.normal ? displaced.attributes.normal.array : new Float32Array(pa.length); - for (let i = 0; i < pa.length; i += 9) { let dirty = false; - if (pa[i+2] < bottomZ) { pa[i+2] = bottomZ; dirty = true; } - if (pa[i+5] < bottomZ) { pa[i+5] = bottomZ; dirty = true; } - if (pa[i+8] < bottomZ) { pa[i+8] = bottomZ; dirty = true; } - + if (pa[i+2] 0.99 captures all - // three exclusion paths in a single check (it's the same predicate - // subdivide uses to skip subdividing those faces). + // Build the merged preExcluded list (triangles that were just baked). + // These become the new excludedFaces after adoptBakedGeometry(). let preExcluded = null; if (bakeMaskChk.checked) { preExcluded = []; const wasParentExcluded = faceWeights ? (parentIdx) => faceWeights[parentIdx * 3] > 0.99 - : () => false; // no exclusions at all → every face was textured + : () => false; for (let i = 0; i < faceParentId.length; i++) { if (!wasParentExcluded(faceParentId[i])) preExcluded.push(i); } } - // Compute new bounds from the displaced geometry. Do NOT re-center — - // the displaced mesh is approximately at the same location, and - // re-centering would shift the user's frame of reference. - displaced.computeBoundingBox(); - const bb = displaced.boundingBox; - const newBounds = { - min: bb.min.clone(), - max: bb.max.clone(), - size: new THREE.Vector3().subVectors(bb.max, bb.min), - center: new THREE.Vector3().addVectors(bb.min, bb.max).multiplyScalar(0.5), - }; + // ── Per-body bake ───────────────────────────────────────────────────────── + const bodies = get3mfBodies(); + if (bodies && bodies.length > 1) { + const exportEntry_bake = getEffectiveMapEntry(); + const perBodyFraction = 0.08 / bodies.length; + const bakeBodyBudget = settings.maxTriangles; + + // Build triToBody: for each triangle in the post-bake displaced geometry, + // which body index does it belong to? + // faceParentId[i] = index of the pre-bake triangle in currentGeometry + // that produced displaced triangle i. + // body.startTri / body.triCount describe ranges in the pre-bake currentGeometry. + const postBakeTrisTotal = faceParentId ? faceParentId.length : 0; + for (const body of bodies) { + body._origStartTri = body.startTri; + body._origTriCount = body.triCount; + } + const triToBody = new Int32Array(postBakeTrisTotal).fill(-1); + if (faceParentId) { + for (let i = 0; i < postBakeTrisTotal; i++) { + const origTri = faceParentId[i]; + for (let bi = 0; bi < bodies.length; bi++) { + const b = bodies[bi]; + if (b.startTri != null && origTri >= b.startTri && origTri < b.startTri + b.triCount) { + triToBody[i] = bi; + break; + } + } + } + } + + // Update body.startTri and body.triCount to reflect the post-bake + // displaced geometry, so painting after the bake maps correctly. + // We walk triToBody to count how many post-bake triangles belong to each body + // and assign contiguous ranges in the order bodies appear. + const postBakeCount = new Array(bodies.length).fill(0); + for (let i = 0; i < postBakeTrisTotal; i++) { + const bi = triToBody[i]; + if (bi >= 0) postBakeCount[bi]++; + } + let runningPostBake = 0; + for (let bi = 0; bi < bodies.length; bi++) { + bodies[bi].startTri = runningPostBake; + bodies[bi].triCount = postBakeCount[bi]; + runningPostBake += postBakeCount[bi]; + console.log(`baking body ${bi} with refineLength=${settings.refineLength}`); + } + + // Per-body bake: only process bodies that had paint applied + for (let bi = 0; bi < bodies.length; bi++) { + const body = bodies[bi]; + const bodyGeo = body.geometry; + if (!bodyGeo || bodyGeo.attributes.position.array.length === 0) continue; + + // Check if this body had any painted faces in the merged excludedFaces, + // using original startTri before we updated it above. + // We already updated startTri, so use triToBody instead. + let bodyWasPainted = false; + if (excludedFaces.size > 0 && faceParentId) { + // Check if any excluded (painted) merged face maps to this body + // via the original faceParentId → original body range. + // Since we've already updated startTri, we need the original ranges. + // Use triToBody on the excluded faces. + // excludedFaces contains indices into currentGeometry (pre-bake). + // We need to check if any pre-bake triangle belongs to this body. + // The original startTri was already updated, so check body index directly. + // Actually excludedFaces are pre-bake indices — check original body ranges. + // Store original ranges before the update above... we didn't. + // Fall back: check if any post-bake triangle maps to this body AND + // its pre-bake parent was in excludedFaces. + for (let i = 0; i < postBakeTrisTotal; i++) { + if (triToBody[i] === bi && faceParentId && excludedFaces.has(faceParentId[i])) { + bodyWasPainted = true; + break; + } + } + } + body.wasPainted = bodyWasPainted; + + // Skip baking bodies that weren't painted + if (!bodyWasPainted) continue; + + // Compute refineLength that keeps this body within bakeBodyBudget + const bodyTriCount = bodyGeo.attributes.position.count / 3; + const bodyPosAttr = bodyGeo.attributes.position; + let totalEdge = 0; + const sampleCount = Math.min(bodyTriCount, 500); + for (let t = 0; t < sampleCount; t++) { + const b = t * 3; + const ax=bodyPosAttr.getX(b), ay=bodyPosAttr.getY(b), az=bodyPosAttr.getZ(b); + const bx=bodyPosAttr.getX(b+1),by=bodyPosAttr.getY(b+1),bz=bodyPosAttr.getZ(b+1); + totalEdge += Math.sqrt((bx-ax)**2+(by-ay)**2+(bz-az)**2); + } + const avgEdge = totalEdge / sampleCount; + const bodyRefineLength = settings.refineLength; + + const origStart = body._origStartTri; + const origCount = body._origTriCount; + const bodyPaintedLocal = new Set(); + if (excludedFaces.size > 0 && origStart != null) { + for (const mergedIdx of excludedFaces) { + const localIdx = mergedIdx - origStart; + if (localIdx >= 0 && localIdx < origCount) { + bodyPaintedLocal.add(localIdx); + } + } + } + + // Build face weights: exclude everything NOT in the painted set. + // If nothing painted on this body, skip it (handled by wasPainted check). + const bodyExcluded = body.excludedFaces || new Set(); + let bodyFaceWeights = null; + if (bodyPaintedLocal.size > 0) { + // Use selection mode (include only painted faces) + bodyFaceWeights = buildCombinedFaceWeights(bodyGeo, bodyPaintedLocal, true, { + ...settings, bottomAngleLimit: 0, topAngleLimit: 0, + }); + } else if (bodyExcluded.size > 0) { + bodyFaceWeights = buildCombinedFaceWeights(bodyGeo, bodyExcluded, false, { + ...settings, bottomAngleLimit: 0, topAngleLimit: 0, + }); + } + + let bodySubdivided = null, bodyDisplaced = null; + try { + let bodyFaceParentId; + ({ geometry: bodySubdivided, faceParentId: bodyFaceParentId } = await subdivide( + bodyGeo, bodyRefineLength, null, bodyFaceWeights + )); + + if (settings.regularizeEnabled) { + const reg = regularizeMesh( + bodySubdivided, + new Int32Array(bodySubdivided.attributes.position.count / 3), + bodyRefineLength, _regularizeOpts() + ); + bodySubdivided.dispose(); + const exclAttr = reg.geometry.attributes.excludeWeight; + const { geometry: resub, faceParentId: resubParents } = await subdivide( + reg.geometry, + bodyRefineLength * settings.regularizeSecondPassMul, + null, exclAttr ? exclAttr.array : null, { fast: false } + ); + reg.geometry.dispose(); + const composedBody = new Int32Array(resubParents.length); + for (let i = 0; i < resubParents.length; i++) { + composedBody[i] = reg.faceParentId[resubParents[i]]; + } + bodySubdivided = resub; + bodyFaceParentId = composedBody; + } + + bodyDisplaced = await runAsync(() => + applyDisplacement( + bodySubdivided, + exportEntry_bake.imageData, + exportEntry_bake.width, + exportEntry_bake.height, + settings, + currentBounds, + null + ) + ); + + bodySubdivided.dispose(); bodySubdivided = null; + + if (settings.bottomAngleLimit > 0) { + const bottomZ = currentBounds.min.z; + const pa = bodyDisplaced.attributes.position.array; + const na = bodyDisplaced.attributes.normal + ? bodyDisplaced.attributes.normal.array + : new Float32Array(pa.length); + for (let i = 0; i < pa.length; i += 9) { + let dirty = false; + if (pa[i+2] bodyFaceWeights[parentIdx * 3] > 0.99 + : () => false; + for (let i = 0; i < bodyFaceParentId.length; i++) { + if (!wasBodyParentExcluded(bodyFaceParentId[i])) { + newBodyExcluded.add(i); + } + } + body.excludedFaces = newBodyExcluded; + } + + if (bodyDisplaced) { + body.geometry.dispose(); + body.geometry = bodyDisplaced; + bodyDisplaced = null; + } + + } catch (bodyErr) { + console.error(`Per-body bake failed for body ${bi} (${body.name}):`, bodyErr); + } finally { + if (bodySubdivided) bodySubdivided.dispose(); + if (bodyDisplaced) bodyDisplaced.dispose(); + } + + setBakeProgress(0.91 + (bi + 1) * perBodyFraction, t('progress.finalizing')); + await yieldFrame(); + } + } + // ── end per-body bake ──────────────────────────────────────────────────── adoptBakedGeometry(displaced, newBounds, { preExcludedFaces: preExcluded }); - displaced = null; // ownership transferred to currentGeometry + displaced = null; succeeded = true; setBakeProgress(1.0, t('progress.done')); @@ -4817,6 +5187,7 @@ async function bakeTextures() { } } + // Replace currentGeometry with `geometry` and reset per-model state without // touching the user's texture/settings. Mirrors the relevant subset of // handleModelFile but keeps activeMapEntry, settings, and refineLength as-is, @@ -5264,13 +5635,29 @@ exportGoBtn.addEventListener('click', async () => { if (includeTexture) payload.activeMapName = customSource.name; const zipFiles = { 'settings.json': strToU8(JSON.stringify(payload, null, 2)) }; - if (includeModel) { - zipFiles['model.stl'] = _geometryToBinarySTL(currentGeometry); - // Mask indices reference the base geometry's triangles, so they only make - // sense when shipped alongside the model that produced them. - const mask = _collectCurrentMask(); - if (mask) zipFiles['mask.json'] = strToU8(JSON.stringify(mask)); - } +if (includeModel) { + const bodiesForSave = get3mfBodies(); + if (bodiesForSave && bodiesForSave.length > 1) { + const modelResults = bodiesForSave.map(b => ({ + geometry: b.wasPainted ? b.geometry : (b.origGeometry || b.geometry), + name: b.name, + matrix: b.matrix, + })); + zipFiles['model.3mf'] = _bodiesToRaw3MF(modelResults); + const bodyMask = { + selectionMode, + bodies: bodiesForSave.map(b => ({ + wasPainted: b.wasPainted || false, + excludedFaces: b.excludedFaces ? [...b.excludedFaces] : null, + })), + }; + zipFiles['bodymask.json'] = strToU8(JSON.stringify(bodyMask)); + } else { + zipFiles['model.stl'] = _geometryToBinarySTL(currentGeometry); + } + const mask = _collectCurrentMask(); + if (mask) zipFiles['mask.json'] = strToU8(JSON.stringify(mask)); +} if (includeTexture) { const blob = await new Promise(r => customSource.fullCanvas.toBlob(r, 'image/png')); zipFiles['texture.png'] = new Uint8Array(await blob.arrayBuffer()); @@ -5314,6 +5701,10 @@ function _geometryToBinarySTL(geo) { return bytes; } +function _bodiesToRaw3MF(bodyResults) { + return build3MFBytes(bodyResults); +} + /** * Snapshot the current paint mask (selection mode + excluded face indices into * the *base* geometry). Returns null when there's nothing meaningful to save — @@ -5324,6 +5715,13 @@ function _geometryToBinarySTL(geo) { * happens when the user disables precision (line 3193). */ function _collectCurrentMask() { + const bodies = get3mfBodies(); + if (bodies && bodies.length > 1) { + // For multi-body projects, save per-body paint state instead of + // merged face indices which are unstable across save/load cycles. + return null; + } + // ... existing single-body logic let liveExcluded; if (precisionMaskingEnabled && precisionParentMap && precisionExcludedFaces.size > 0) { liveExcluded = new Set(); @@ -5403,12 +5801,37 @@ async function importProject(file) { // 1) Load model first — handleModelFile resets scaleU/scaleV/offsets/refineLength // AND clears any existing paint mask, so applied settings + restored mask // below will correctly override those resets. - const hasModel = !!unzipped['model.stl']; - if (hasModel) { - const stlFile = new File([unzipped['model.stl']], 'model.stl', { type: 'application/octet-stream' }); - await handleModelFile(stlFile); + const hasModel3mf = !!unzipped['model.3mf']; + const hasModelStl = !!unzipped['model.stl']; + const hasModel = hasModel3mf || hasModelStl; + if (hasModel3mf) { + const f = new File([unzipped['model.3mf']], 'model.3mf', { type: 'application/octet-stream' }); + await handleModelFile(f); + } else if (hasModelStl) { + const f = new File([unzipped['model.stl']], 'model.stl', { type: 'application/octet-stream' }); + await handleModelFile(f); + } + if (hasModel3mf && unzipped['bodymask.json']) { + try { + const bodyMask = JSON.parse(strFromU8(unzipped['bodymask.json'])); + const loadedBodies = get3mfBodies(); + if (loadedBodies && bodyMask) { + // Restore selection mode + if (bodyMask.selectionMode === true) setSelectionMode(true); + else if (bodyMask.selectionMode === false) setSelectionMode(false); + // Restore per-body paint state + const bodies = bodyMask.bodies || []; + bodies.forEach((bm, i) => { + if (loadedBodies[i]) { + loadedBodies[i].wasPainted = bm.wasPainted || false; + if (bm.excludedFaces) { + loadedBodies[i].excludedFaces = new Set(bm.excludedFaces); + } + } + }); + } + } catch (err) { console.warn('Could not restore body mask:', err); } } - // 2) Apply settings after any model reset. if (data) applySettingsSnapshot(data); @@ -5420,7 +5843,6 @@ async function importProject(file) { _restoreMask(mask); } catch (err) { console.warn('Could not restore paint mask:', err); } } - // 3) Texture: custom PNG wins over named preset. if (unzipped['texture.png']) { const texName = (data && data.activeMapName) || 'imported-texture.png'; diff --git a/js/stlLoader.js b/js/stlLoader.js index 3db0834..8e77866 100644 --- a/js/stlLoader.js +++ b/js/stlLoader.js @@ -2,23 +2,20 @@ import { STLLoader } from 'three/addons/loaders/STLLoader.js'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; import { unzipSync } from 'fflate'; import * as THREE from 'three'; +import { set3mfBodies, clear3mfBodies } from './exporter.js'; -const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB +const MAX_FILE_SIZE = 500 * 1024 * 1024; const stlLoader = new STLLoader(); const objLoader = new OBJLoader(); -/** - * Load an STL from a File object. - * Returns { geometry, bounds } where bounds = { min, max, center, size } (THREE.Vector3). - * The geometry is translated so its bounding-box centre is at the world origin. - */ export function loadSTLFile(file) { if (file.size > MAX_FILE_SIZE) { return Promise.reject(new Error( 'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.' )); } + clear3mfBodies(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { @@ -27,34 +24,17 @@ export function loadSTLFile(file) { const { nanCount, degenerateCount } = setupGeometry(geometry); const bounds = computeBounds(geometry); resolve({ geometry, bounds, nanCount, degenerateCount }); - } catch (err) { - reject(err); - } + } catch (err) { reject(err); } }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsArrayBuffer(file); }); } -/** - * Scan a non-indexed geometry's position array and remove: - * - triangles with any non-finite (NaN / ±Infinity) coordinate - * - degenerate triangles whose area is below 1e-12 mm² - * - * Operates in-place by compacting the Float32Array and replacing the - * BufferAttribute. Any existing normal attribute is deleted so that - * setupGeometry will recompute it on the clean data. - * - * Returns { nanCount, degenerateCount } so callers can warn the user. - */ function validateAndCleanGeometry(geometry) { - const pos = geometry.attributes.position; - const src = pos.array; // Float32Array, 9 floats per triangle - const triCount = src.length / 9; - - let writeIdx = 0; - let nanCount = 0; - let degenerateCount = 0; + const src = geometry.attributes.position.array; + const triCount = (src.length / 9) | 0; + let writeIdx = 0, nanCount = 0, degenerateCount = 0; for (let t = 0; t < triCount; t++) { const b = t * 9; @@ -62,137 +42,84 @@ function validateAndCleanGeometry(geometry) { const bx = src[b+3], by = src[b+4], bz = src[b+5]; const cx = src[b+6], cy = src[b+7], cz = src[b+8]; - if (!isFinite(ax) || !isFinite(ay) || !isFinite(az) || - !isFinite(bx) || !isFinite(by) || !isFinite(bz) || - !isFinite(cx) || !isFinite(cy) || !isFinite(cz)) { - nanCount++; - continue; - } + if (!isFinite(ax)||!isFinite(ay)||!isFinite(az)|| + !isFinite(bx)||!isFinite(by)||!isFinite(bz)|| + !isFinite(cx)||!isFinite(cy)||!isFinite(cz)) { nanCount++; continue; } - // Cross product of (B−A) × (C−A); skip if area² < 1e-24 (area < 1e-12) - const ux = bx-ax, uy = by-ay, uz = bz-az; - const vx = cx-ax, vy = cy-ay, vz = cz-az; - const area2 = (uy*vz-uz*vy)**2 + (uz*vx-ux*vz)**2 + (ux*vy-uy*vx)**2; - if (area2 < 1e-24) { - degenerateCount++; - continue; - } + const ux=bx-ax,uy=by-ay,uz=bz-az, vx=cx-ax,vy=cy-ay,vz=cz-az; + if ((uy*vz-uz*vy)**2+(uz*vx-ux*vz)**2+(ux*vy-uy*vx)**2 < 1e-24) { degenerateCount++; continue; } - if (writeIdx !== b) { - src[writeIdx] = ax; src[writeIdx+1] = ay; src[writeIdx+2] = az; - src[writeIdx+3] = bx; src[writeIdx+4] = by; src[writeIdx+5] = bz; - src[writeIdx+6] = cx; src[writeIdx+7] = cy; src[writeIdx+8] = cz; + if (writeIdx !== t) { + const outB = writeIdx * 9; + src[outB]=ax; src[outB+1]=ay; src[outB+2]=az; + src[outB+3]=bx; src[outB+4]=by; src[outB+5]=bz; + src[outB+6]=cx; src[outB+7]=cy; src[outB+8]=cz; } - writeIdx += 9; + writeIdx++; } - const removed = nanCount + degenerateCount; - if (removed > 0) { - geometry.setAttribute('position', new THREE.BufferAttribute(src.slice(0, writeIdx), 3)); - geometry.deleteAttribute('normal'); // stale — recomputed below - } - - if (writeIdx === 0) { - throw new Error( - `All ${triCount} triangles in the mesh are invalid (${nanCount} NaN, ${degenerateCount} degenerate). Cannot load file.` - ); + if (nanCount + degenerateCount > 0) { + geometry.setAttribute('position', new THREE.BufferAttribute(src.slice(0, writeIdx * 9), 3)); + geometry.deleteAttribute('normal'); } - + if (writeIdx === 0) throw new Error( + `All ${triCount} triangles invalid (${nanCount} NaN, ${degenerateCount} degenerate). Cannot load file.` + ); return { nanCount, degenerateCount }; } -/** - * Validate, centre, and compute normals for a freshly parsed geometry. - * Returns { nanCount, degenerateCount } removed-triangle counts for caller warnings. - */ function setupGeometry(geometry) { - const { nanCount, degenerateCount } = validateAndCleanGeometry(geometry); + const result = validateAndCleanGeometry(geometry); geometry.computeBoundingBox(); - const box = geometry.boundingBox; const centre = new THREE.Vector3(); - box.getCenter(centre); + geometry.boundingBox.getCenter(centre); geometry.translate(-centre.x, -centre.y, -centre.z); geometry.computeBoundingBox(); if (!geometry.attributes.normal) geometry.computeVertexNormals(); - return { nanCount, degenerateCount }; + return result; } -/** - * Compute the bounds object that all UV mapping functions depend on. - * Must be called after the geometry has been centred. - */ export function computeBounds(geometry) { geometry.computeBoundingBox(); const box = geometry.boundingBox; - const min = box.min.clone(); - const max = box.max.clone(); - const size = new THREE.Vector3(); - box.getSize(size); - const center = new THREE.Vector3(); - box.getCenter(center); + const min = box.min.clone(), max = box.max.clone(); + const size = new THREE.Vector3(); box.getSize(size); + const center = new THREE.Vector3(); box.getCenter(center); return { min, max, center, size }; } -/** - * Triangle count helper. - */ export function getTriangleCount(geometry) { const pos = geometry.attributes.position; - return geometry.index - ? geometry.index.count / 3 - : pos.count / 3; + return geometry.index ? geometry.index.count / 3 : pos.count / 3; } -/** - * Total surface area of a geometry, in the same units as the position attribute. - * Sums ½‖(v1 − v0) × (v2 − v0)‖ over every triangle. Handles both indexed and - * non-indexed BufferGeometries. - */ export function computeSurfaceArea(geometry) { const posAttr = geometry.attributes.position; if (!posAttr) return 0; const pos = posAttr.array; const idx = geometry.index ? geometry.index.array : null; let area = 0; - - const get = (vi, out) => { - const o = vi * 3; - out[0] = pos[o]; out[1] = pos[o + 1]; out[2] = pos[o + 2]; - }; - const a = [0, 0, 0], b = [0, 0, 0], c = [0, 0, 0]; - - const triCount = idx ? idx.length / 3 : pos.length / 9; - for (let t = 0; t < triCount; t++) { - if (idx) { - get(idx[t * 3], a); - get(idx[t * 3 + 1], b); - get(idx[t * 3 + 2], c); - } else { - const o = t * 9; - a[0] = pos[o]; a[1] = pos[o + 1]; a[2] = pos[o + 2]; - b[0] = pos[o + 3]; b[1] = pos[o + 4]; b[2] = pos[o + 5]; - c[0] = pos[o + 6]; c[1] = pos[o + 7]; c[2] = pos[o + 8]; - } - const e1x = b[0] - a[0], e1y = b[1] - a[1], e1z = b[2] - a[2]; - const e2x = c[0] - a[0], e2y = c[1] - a[1], e2z = c[2] - a[2]; - const cx = e1y * e2z - e1z * e2y; - const cy = e1z * e2x - e1x * e2z; - const cz = e1x * e2y - e1y * e2x; - area += 0.5 * Math.sqrt(cx * cx + cy * cy + cz * cz); + const a=[0,0,0],b=[0,0,0],c=[0,0,0]; + const get=(vi,o)=>{const p=vi*3;o[0]=pos[p];o[1]=pos[p+1];o[2]=pos[p+2];}; + const triCount = idx ? idx.length/3 : pos.length/9; + for (let t=0;t MAX_FILE_SIZE) { return Promise.reject(new Error( 'File too large (' + Math.round(file.size / 1024 / 1024) + ' MB). Maximum supported: ' + (MAX_FILE_SIZE / 1024 / 1024) + ' MB.' )); } + clear3mfBodies(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { @@ -202,22 +129,13 @@ export function loadOBJFile(file) { const { nanCount, degenerateCount } = setupGeometry(geometry); const bounds = computeBounds(geometry); resolve({ geometry, bounds, nanCount, degenerateCount }); - } catch (err) { - reject(err); - } + } catch (err) { reject(err); } }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsText(file); }); } -/** - * Load a 3MF from a File object. - * Custom parser that handles Bambu Studio / PrusaSlicer multi-file 3MF - * where meshes live in 3D/Objects/ subfiles referenced via the production - * extension (p:path on elements). - * Returns { geometry, bounds }. - */ export function load3MFFile(file) { if (file.size > MAX_FILE_SIZE) { return Promise.reject(new Error( @@ -228,13 +146,12 @@ export function load3MFFile(file) { const reader = new FileReader(); reader.onload = (e) => { try { - const geometry = parse3MF(new Uint8Array(e.target.result)); + const { geometry, bodyGeometries, centerOffset } = parse3MF(new Uint8Array(e.target.result)); const { nanCount, degenerateCount } = setupGeometry(geometry); + set3mfBodies(bodyGeometries, centerOffset); const bounds = computeBounds(geometry); resolve({ geometry, bounds, nanCount, degenerateCount }); - } catch (err) { - reject(err); - } + } catch (err) { reject(err); } }; reader.onerror = () => reject(new Error('Could not read file')); reader.readAsArrayBuffer(file); @@ -244,14 +161,11 @@ export function load3MFFile(file) { const MAX_3MF_TRIANGLES = 10_000_000; const MAX_3MF_DEPTH = 32; -// ── Custom 3MF parser ──────────────────────────────────────────────────────── - function parse3MF(data) { - const files = unzipSync(data); + const files = unzipSync(data); const decoder = new TextDecoder(); const parser = new DOMParser(); - // Helper: read a file from the zip (keys may have or lack leading slash) function readXML(path) { const clean = path.replace(/^\//, ''); const bytes = files[clean] || files['/' + clean]; @@ -259,29 +173,11 @@ function parse3MF(data) { return parser.parseFromString(decoder.decode(bytes), 'application/xml'); } - // Namespace-aware element queries const NS_CORE = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'; const NS_PROD = 'http://schemas.microsoft.com/3dmanufacturing/production/2015/06'; + const UNIT_TO_MM = { micron:0.001, millimeter:1, centimeter:10, inch:25.4, foot:304.8, meter:1000 }; - // 3MF Core Spec unit values → millimeters. Used to normalise incoming models - // to this project's internal mm convention. Note: in multi-file production - // 3MFs, per-spec each .model file could theoretically declare its own unit, - // but in practice slicers always use one unit globally — we use the root - // model's unit for the whole build. - const UNIT_TO_MM = { - micron: 0.001, - millimeter: 1, - centimeter: 10, - inch: 25.4, - foot: 304.8, - meter: 1000, - }; - - // Parse all model files and collect objects by (filePath, id) - // objectMap: "path#id" → { vertices: Float32Array, triangles: Uint32Array } - const objectMap = new Map(); - - // Find all .model files in the zip + const objectMap = new Map(); const modelPaths = Object.keys(files).filter(f => f.endsWith('.model')); for (const path of modelPaths) { @@ -289,33 +185,28 @@ function parse3MF(data) { if (!doc) continue; const objects = doc.getElementsByTagNameNS(NS_CORE, 'object'); for (const obj of objects) { - const id = obj.getAttribute('id'); + const id = obj.getAttribute('id'); const meshEl = obj.getElementsByTagNameNS(NS_CORE, 'mesh')[0]; - if (!meshEl) continue; // component-only object, no inline mesh - - const vertEls = meshEl.getElementsByTagNameNS(NS_CORE, 'vertex'); - const triEls = meshEl.getElementsByTagNameNS(NS_CORE, 'triangle'); - const vertices = new Float32Array(vertEls.length * 3); - for (let i = 0; i < vertEls.length; i++) { - vertices[i * 3] = parseFloat(vertEls[i].getAttribute('x')); - vertices[i * 3 + 1] = parseFloat(vertEls[i].getAttribute('y')); - vertices[i * 3 + 2] = parseFloat(vertEls[i].getAttribute('z')); - } + if (!meshEl) continue; + const vertEls = meshEl.getElementsByTagNameNS(NS_CORE, 'vertex'); + const triEls = meshEl.getElementsByTagNameNS(NS_CORE, 'triangle'); + const vertices = new Float32Array(vertEls.length * 3); const triangles = new Uint32Array(triEls.length * 3); - for (let i = 0; i < triEls.length; i++) { - triangles[i * 3] = parseInt(triEls[i].getAttribute('v1'), 10); - triangles[i * 3 + 1] = parseInt(triEls[i].getAttribute('v2'), 10); - triangles[i * 3 + 2] = parseInt(triEls[i].getAttribute('v3'), 10); + for (let i=0;i= vertCount || isNaN(triangles[i])) { + for (let i=0;i=vc||isNaN(triangles[i])) throw new Error('Invalid triangle index in 3MF file'); - } } - - // Normalise path for lookup (strip leading slash, use forward slashes) const normPath = path.replace(/^\//, '').replace(/\\/g, '/'); objectMap.set(normPath + '#' + id, { vertices, triangles }); } @@ -323,146 +214,169 @@ function parse3MF(data) { if (objectMap.size === 0) throw new Error('No mesh data found in 3MF file'); - // Resolve the root model's build items → collect (objectRef, transform) pairs - // Then recursively expand components to get final (meshRef, worldTransform) list. - const rootPath = modelPaths.find(p => /^3D\/3dmodel\.model$/i.test(p.replace(/^\//, ''))) - || modelPaths[0]; + const rootPath = modelPaths.find(p => /^3D\/3dmodel\.model$/i.test(p.replace(/^\//, ''))) || modelPaths[0]; const rootDoc = readXML(rootPath); - - // Read the model unit and build a uniform scale matrix that converts the - // file's coordinates to millimeters. Pre-multiplying this into each build - // item's transform propagates the scale through every nested component - // transform — both rotation/scale parts and translation parts. const rootUnit = (rootDoc.documentElement.getAttribute('unit') || 'millimeter').toLowerCase(); const unitScale = UNIT_TO_MM[rootUnit] ?? 1; const unitMatrix = new THREE.Matrix4().makeScale(unitScale, unitScale, unitScale); - // Collect final mesh instances: { meshKey, matrix } - const instances = []; + const instances = []; + const itemTransforms = new Map(); function parseTransform(str) { if (!str) return new THREE.Matrix4(); const v = str.trim().split(/\s+/).map(Number); - if (v.length === 12) { - // 3MF row-major 3×4: m00 m01 m02 m10 m11 m12 m20 m21 m22 tx ty tz - return new THREE.Matrix4().set( - v[0], v[3], v[6], v[9], - v[1], v[4], v[7], v[10], - v[2], v[5], v[8], v[11], - 0, 0, 0, 1, - ); - } + if (v.length === 12) return new THREE.Matrix4().set( + v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1 + ); return new THREE.Matrix4(); } - function resolveObject(filePath, objectId, parentMatrix, visiting = new Set(), depth = 0) { - if (depth > MAX_3MF_DEPTH) { - throw new Error('3MF component hierarchy too deep — possible cyclic reference'); - } - + function resolveObject(filePath, objectId, parentMatrix, buildItemIndex, objectName, visiting=new Set(), depth=0) { + if (depth > MAX_3MF_DEPTH) throw new Error('3MF component hierarchy too deep'); const normFile = filePath.replace(/^\//, '').replace(/\\/g, '/'); const key = normFile + '#' + objectId; - - if (visiting.has(key)) { - throw new Error(`Cyclic component reference detected in 3MF file (${key})`); - } + if (visiting.has(key)) throw new Error(`Cyclic component reference in 3MF (${key})`); visiting.add(key); - - // If this object has a mesh, emit an instance - if (objectMap.has(key)) { - instances.push({ meshKey: key, matrix: parentMatrix.clone() }); - } - - // Also check for components (the object may have both mesh + components, - // or only components referencing other objects) + if (objectMap.has(key)) + instances.push({ meshKey:key, matrix:parentMatrix.clone(), buildItemIndex, label:objectName||'' }); const doc = readXML(filePath); if (!doc) { visiting.delete(key); return; } - const objects = doc.getElementsByTagNameNS(NS_CORE, 'object'); - for (const obj of objects) { + for (const obj of doc.getElementsByTagNameNS(NS_CORE, 'object')) { if (obj.getAttribute('id') !== objectId) continue; - const components = obj.getElementsByTagNameNS(NS_CORE, 'component'); - for (const comp of components) { + const thisName = objectName || obj.getAttribute('name') || ''; + for (const comp of obj.getElementsByTagNameNS(NS_CORE, 'component')) { const compObjId = comp.getAttribute('objectid'); - // p:path attribute tells us which file the referenced object lives in - let compPath = comp.getAttributeNS(NS_PROD, 'path') - || comp.getAttribute('p:path') - || filePath; - if (!compPath.startsWith('/') && !compPath.startsWith('3D')) { - compPath = '/' + compPath; - } - const compTransform = parseTransform(comp.getAttribute('transform')); - const combined = parentMatrix.clone().multiply(compTransform); - resolveObject(compPath, compObjId, combined, visiting, depth + 1); + let compPath = comp.getAttributeNS(NS_PROD,'path') || comp.getAttribute('p:path') || filePath; + if (!compPath.startsWith('/')&&!compPath.startsWith('3D')) compPath='/'+compPath; + resolveObject(compPath, compObjId, + parentMatrix.clone().multiply(parseTransform(comp.getAttribute('transform'))), + buildItemIndex, thisName, visiting, depth+1); } } - visiting.delete(key); } - // Start from items in root model const buildItems = rootDoc.getElementsByTagNameNS(NS_CORE, 'item'); if (buildItems.length > 0) { - for (const item of buildItems) { - const objId = item.getAttribute('objectid'); - const itemTransform = parseTransform(item.getAttribute('transform')); - const seedMatrix = unitMatrix.clone().multiply(itemTransform); - resolveObject(rootPath, objId, seedMatrix); + for (let bi=0; biMAX_3MF_TRIANGLES) + throw new Error(`3MF contains ${totalTris.toLocaleString()} triangles, exceeding the ${MAX_3MF_TRIANGLES.toLocaleString()} limit`); - if (totalTris > MAX_3MF_TRIANGLES) { - throw new Error( - `3MF file contains ${totalTris.toLocaleString()} triangles, exceeding the ${MAX_3MF_TRIANGLES.toLocaleString()} limit` - ); - } + const mergedPositions = new Float32Array(totalTris * 9); + + const bodyOrder = []; + const bodyLabel = new Map(); + const bodyChunks = new Map(); + const bodyTriCount = new Map(); - const positions = new Float32Array(totalTris * 9); let writeOffset = 0; const tmpV = new THREE.Vector3(); for (const inst of instances) { const mesh = objectMap.get(inst.meshKey); if (!mesh) continue; + const bi = inst.buildItemIndex; + if (!bodyChunks.has(bi)) { + bodyOrder.push(bi); + bodyLabel.set(bi, inst.label); + bodyChunks.set(bi, []); + bodyTriCount.set(bi, 0); + } const { vertices, triangles } = mesh; - for (let t = 0; t < triangles.length; t += 3) { - for (let v = 0; v < 3; v++) { - const vi = triangles[t + v]; - tmpV.set(vertices[vi * 3], vertices[vi * 3 + 1], vertices[vi * 3 + 2]); + const tc = triangles.length / 3; + const chunk = new Float32Array(tc * 9); + let chunkOffset = 0; + for (let t=0; t { + const chunks = bodyChunks.get(bi); + const tc = bodyTriCount.get(bi); + const startTri = runningTri; + runningTri += tc; + + const pos = new Float32Array(tc * 9); + let off = 0; + for (const chunk of chunks) { pos.set(chunk, off); off += chunk.length; } + for (let i=0; i { if (child.isMesh && child.geometry) { - // Apply the mesh's world transform to the geometry const geo = child.geometry.clone(); child.updateWorldMatrix(true, false); geo.applyMatrix4(child.matrixWorld); - // Convert indexed → non-indexed so vertex layout matches our pipeline - if (geo.index) { - geometries.push(geo.toNonIndexed()); - geo.dispose(); - } else { - geometries.push(geo); - } + geometries.push(geo.index ? geo.toNonIndexed() : geo); + if (geo.index) geo.dispose(); } }); if (geometries.length === 0) throw new Error('No mesh data found in file'); if (geometries.length === 1) return geometries[0]; - - // Merge multiple geometries into one - const totalVerts = geometries.reduce((sum, g) => sum + g.attributes.position.count, 0); - const mergedPos = new Float32Array(totalVerts * 3); - let mergedNrm = null; + const totalVerts = geometries.reduce((s,g) => s+g.attributes.position.count, 0); + const mergedPos = new Float32Array(totalVerts * 3); const hasNormals = geometries.every(g => g.attributes.normal); - if (hasNormals) mergedNrm = new Float32Array(totalVerts * 3); + const mergedNrm = hasNormals ? new Float32Array(totalVerts * 3) : null; let offset = 0; for (const g of geometries) { - const posArr = g.attributes.position.array; - mergedPos.set(posArr, offset * 3); - if (hasNormals && mergedNrm) { - mergedNrm.set(g.attributes.normal.array, offset * 3); - } + mergedPos.set(g.attributes.position.array, offset*3); + if (hasNormals&&mergedNrm) mergedNrm.set(g.attributes.normal.array, offset*3); offset += g.attributes.position.count; g.dispose(); }