1/* 2 * Copyright (c) 2026 CNCKitchen (Stefan Hermann) and contributors 3 * SPDX-License-Identifier: AGPL-3.0-only 4 */ 5 6/** 7 * exportPipeline.js â the heavy mesh pipeline behind Export and Bake, 8 * extracted from main.js so it can run EITHER on the main thread (fallback) 9 * OR inside the export Web Worker (exportWorker.js). Pure data in/out: no 10 * DOM, no i18n, no app state. 11 * 12 * Sequence (mirrors the old inline handleExport/bakeTextures exactly): 13 * subdivide â [regularize â re-subdivide] â displace 14 * â [decimate] (export mode only) 15 * â bottom clamp â smooth bottom 16 * â [resolveTJunctions] (export mode, when decimation ran) 17 * 18 * @param {object} input 19 * positions Float32Array non-indexed triangle soup (xyz per vertex) 20 * faceWeights Float32Array|null per-vertex exclusion weights 21 * softExclude Float32Array|null per-vertex soft-brush exclusion amount 22 * (softMask.js), interpolated onto the refined mesh 23 * imageData ImageData-like {data, width, height} 24 * imgWidth, imgHeight texture dimensions 25 * settings plain settings snapshot (structured-clone safe) 26 * bounds {min,max,size,center} as {x,y,z} objects or Vector3s 27 * regularizeOpts opts object for regularizeMesh 28 * mode 'export' | 'bake' 29 * @param {function} [onEvent] (stage, p, info) progress events; the caller 30 * maps stages to progress-bar fractions and translated labels. 31 * @param {function} [shouldAbort] checked between stages; true â return null. 32 * @returns {Promise<null | { 33 * positions: Float32Array, normals: Float32Array|null, 34 * safetyCapHit: boolean, runDecimation: boolean, needsDecimation: boolean, 35 * lockedOverBudget: boolean, // preserve-untextured beta: locked faces ⥠triangle target 36 * faceParentId: Int32Array|null, // bake mode only 37 * repairStats: object|null, // export mode, when repair ran 38 * }>} 39 */ 40 41import { THREE } from './threeCompat.js'; 42import { QuantizedPointMap } from './meshIndex.js'; 43import { subdivide } from './subdivision.js'; 44import { regularizeMesh } from './regularize.js'; 45import { applyDisplacement } from './displacement.js'; 46import { decimate } from './decimation.js'; 47import { resolveTJunctions, countEdgeDefects, countAreaSlivers } from './meshRepair.js'; 48import { interpolateFromParents } from './softMask.js'; 49 50const yieldFrame = () => new Promise(r => setTimeout(r, 0)); 51 52// Revive a structured-cloned bounds object ({x,y,z} plain objects) into real 53// Vector3s â displacement/mapping only read .x/.y/.z, but real vectors keep 54// any future method use safe. 55function reviveBounds(b) { 56 const v = (o) => new THREE.Vector3(o.x, o.y, o.z); 57 return { min: v(b.min), max: v(b.max), size: v(b.size), center: v(b.center) }; 58} 59 60// Flat-bottom clamp (bottomAngleLimit > 0): any vertex that ended up below the 61// original model's bottom layer gets snapped back up to that Z. Single pass 62// with selective normal recomputation. (Verbatim from the old inline code.) 63function clampBelowBottom(geometry, bottomZ) { 64 const pa = geometry.attributes.position.array; 65 const na = geometry.attributes.normal ? geometry.attributes.normal.array : new Float32Array(pa.length); 66 67 for (let i = 0; i < pa.length; i += 9) { 68 let dirty = false; 69 if (pa[i+2] < bottomZ) { pa[i+2] = bottomZ; dirty = true; } 70 if (pa[i+5] < bottomZ) { pa[i+5] = bottomZ; dirty = true; } 71 if (pa[i+8] < bottomZ) { pa[i+8] = bottomZ; dirty = true; } 72 73 if (dirty) { 74 const ux = pa[i+3]-pa[i], uy = pa[i+4]-pa[i+1], uz = pa[i+5]-pa[i+2]; 75 const vx = pa[i+6]-pa[i], vy = pa[i+7]-pa[i+1], vz = pa[i+8]-pa[i+2]; 76 const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; 77 const len = Math.sqrt(nx*nx+ny*ny+nz*nz) || 1; 78 na[i] = na[i+3] = na[i+6] = nx/len; 79 na[i+1] = na[i+4] = na[i+7] = ny/len; 80 na[i+2] = na[i+5] = na[i+8] = nz/len; 81 } 82 } 83 84 geometry.attributes.position.needsUpdate = true; 85 if (!geometry.attributes.normal) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(na, 3)); 86 else geometry.attributes.normal.needsUpdate = true; 87} 88 89// Smooth Bottom: snap near-bottom vertices onto the bottom plane so the 90// bed-contact surface comes out perfectly flat; recompute face normals on 91// touched triangles. 92// 93// Fold gate (June 2026): the original unconditional band-snap flattened ANY 94// geometry hovering within `tol` of the plane â notably the undersides of 95// texture bumps near the base â folding it coplanar INTO the bottom face. 96// Folded faces overlap the plate, so welded edges there pick up 4 incident 97// faces: non-manifold edges and phantom "disconnected shells" on re-import 98// (measured on the parking rack + dots: 40 nm edges / 39 shells, all at the 99// bottom plane; 0 / 2 with the snap off). The snap is now per-position and 100// gated like a regularize/decimation collapse: all copies of a welded 101// position move together, and the move is REJECTED if any incident triangle 102// would become degenerate or rotate its normal by more than ~75°. Genuine 103// bed-contact slivers â the reason this feature exists â rotate by fractions 104// of a degree and still snap; bump undersides would fold ~90° and stay put. 105export function snapBottomToFlat(geometry, bottomZ, tol = 0.1) { 106 const pa = geometry.attributes.position.array; 107 const na = geometry.attributes.normal 108 ? geometry.attributes.normal.array 109 : new Float32Array(pa.length); 110 111 const vertCount = pa.length / 3; 112 const triCount = vertCount / 3; 113 114 // Weld positions (1e6 â the decimation grid; copies of one position are 115 // bit-identical at this point) and build per-position incident corner lists. 116 const weld = new QuantizedPointMap(1e6, Math.min(vertCount, 1 << 22)); 117 const vid = new Uint32Array(vertCount); 118 let nUnique = 0; 119 for (let i = 0; i < vertCount; i++) { 120 const id = weld.getOrSet(pa[i*3], pa[i*3+1], pa[i*3+2], nUnique); 121 if (weld.inserted) nUnique++; 122 vid[i] = id; 123 } 124 const start = new Uint32Array(nUnique + 1); 125 for (let i = 0; i < vertCount; i++) start[vid[i] + 1]++; 126 for (let id = 0; id < nUnique; id++) start[id + 1] += start[id]; 127 const inc = new Uint32Array(vertCount); 128 const cursor = new Uint32Array(nUnique); 129 for (let i = 0; i < vertCount;
129 i++) inc[start[vid[i]] + cursor[vid[i]]++] = i; 130 131 const FOLD_COS = Math.cos(75 * Math.PI / 180); 132 const dirtyTri = new Uint8Array(triCount); 133 const _zs = new Float64Array(3); 134 135 for (let id = 0; id < nUnique; id++) { 136 const first = inc[start[id]]; 137 const z = pa[first * 3 + 2]; 138 if (z === bottomZ || Math.abs(z - bottomZ) > tol) continue; 139 140 // Gate: simulate moving this position to the plane; every incident 141 // triangle must keep positive area and not fold (normal rotation ⤠~75°). 142 let ok = true; 143 for (let k = start[id]; k < start[id + 1] && ok; k++) { 144 const t = (inc[k] / 3) | 0; 145 const b = t * 9; 146 const c0 = t * 3; 147 // Post-move z per corner: corners welded to this id land on the plane. 148 for (let v = 0; v < 3; v++) _zs[v] = vid[c0 + v] === id ? bottomZ : pa[b + v * 3 + 2]; 149 150 const oux = pa[b+3]-pa[b], ouy = pa[b+4]-pa[b+1], ouz = pa[b+5]-pa[b+2]; 151 const ovx = pa[b+6]-pa[b], ovy = pa[b+7]-pa[b+1], ovz = pa[b+8]-pa[b+2]; 152 const onx = ouy*ovz - ouz*ovy, ony = ouz*ovx - oux*ovz, onz = oux*ovy - ouy*ovx; 153 154 const nuz = _zs[1] - _zs[0], nvz = _zs[2] - _zs[0]; 155 const nnx = ouy*nvz - nuz*ovy, nny = nuz*ovx - oux*nvz, nnz = oux*ovy - ouy*ovx; 156 157 const o2 = onx*onx + ony*ony + onz*onz; 158 const n2 = nnx*nnx + nny*nny + nnz*nnz; 159 if (n2 < 1e-20) { ok = false; break; } // would collapse to zero area 160 if (o2 < 1e-20) continue; // already degenerate â can't judge rotation 161 const dot = onx*nnx + ony*nny + onz*nnz; 162 if (dot < 0 || dot * dot < FOLD_COS * FOLD_COS * o2 * n2) ok = false; // would fold 163 } 164 if (!ok) continue; 165 166 // Apply: snap all copies of this position; mark incident triangles dirty. 167 for (let k = start[id]; k < start[id + 1]; k++) { 168 pa[inc[k] * 3 + 2] = bottomZ; 169 dirtyTri[(inc[k] / 3) | 0] = 1; 170 } 171 } 172 173 // Recompute face normals on touched triangles. 174 let dirtyTris = 0; 175 for (let t = 0; t < triCount; t++) { 176 if (!dirtyTri[t]) continue; 177 dirtyTris++; 178 const i = t * 9; 179 const ux = pa[i+3]-pa[i], uy = pa[i+4]-pa[i+1], uz = pa[i+5]-pa[i+2]; 180 const vx = pa[i+6]-pa[i], vy = pa[i+7]-pa[i+1], vz = pa[i+8]-pa[i+2]; 181 const nx = uy*vz-uz*vy, ny = uz*vx-ux*vz, nz = ux*vy-uy*vx; 182 const len = Math.sqrt(nx*nx+ny*ny+nz*nz) || 1; 183 na[i] = na[i+3] = na[i+6] = nx/len; 184 na[i+1] = na[i+4] = na[i+7] = ny/len; 185 na[i+2] = na[i+5] = na[i+8] = nz/len; 186 } 187
188 if (dirtyTris > 0) { 189 geometry.attributes.position.needsUpdate = true; 190 if (!geometry.attributes.normal) { 191 geometry.setAttribute('normal', new THREE.Float32BufferAttribute(na, 3)); 192 } else { 193 geometry.attributes.normal.needsUpdate = true; 194 } 195 } 196 return dirtyTris; 197} 198 199export async function runExportPipeline(input, onEvent = () => {}, shouldAbort = () => false) { 200 const { settings, regularizeOpts } = input; 201 const mode = input.mode === 'bake' ? 'bake' : 'export'; 202 const bounds = reviveBounds(input.bounds); 203 204 const geometry = new THREE.BufferGeometry(); 205 geometry.setAttribute('position', new THREE.BufferAttribute(input.positions, 3)); 206 207 // Hoist intermediates so the finally block can always dispose them. 208 let subdivided = null; 209 let displaced = null; 210 let finalGeometry = null; 211 let done = false; 212 213 try { 214 onEvent('subdivide1', 0); 215 await yieldFrame(); 216 if (shouldAbort()) return null; 217 218 let safetyCapHit, faceParentId; 219 ({ geometry: subdivided, safetyCapHit, faceParentId } = await subdivide( 220 geometry, settings.refineLength, 221 (p, triCount, longestEdge) => onEvent('subdivide1', p, { triCount, longestEdge }), 222 input.faceWeights || null 223 )); 224 if (shouldAbort()) return null; 225 226 // Soft-brush paint reaches the refined mesh through the parent-face map, 227 // so it needs real parents in export mode too. 228 const trackParents = mode === 'bake' || !!input.softExclude; 229 230 // Regularize sub-slivers, then re-subdivide stretched edges. Skipped when 231 // the Advanced toggle is off. Without parent tracking a zero parent map 232 // is passed (nothing consumes it); otherwise the real one is threaded 233 // through and composed. 234 if (settings.regularizeEnabled) { 235 onEvent('regularize', 0); 236 await yieldFrame(); 237 const regParents = trackParents 238 ? faceParentId 239 : new Int32Array(subdivided.attributes.position.count / 3); 240 const reg = regularizeMesh(subdivided, regParents, settings.refineLength, regularizeOpts); 241 subdivided.dispose(); 242 const exclAttr = reg.geometry.attributes.excludeWeight; 243 const secondPassWeights = exclAttr ? exclAttr.array : null; 244 const { geometry: resub, faceParentId: resubParents } = await subdivide( 245 reg.geometry, settings.refineLength * settings.regularizeSecondPassMul, 246 (p, triCount, longestEdge) => onEvent('subdivide2', p, { triCount, longestEdge }), 247 secondPassWeights, { fast: false } 248 ); 249 reg.geometry.dispose(); 250 if (trackParents) { 251 const composed = new Int32Array(resubParents.length); 252 for (let i = 0; i < resubParents.length; i++) { 253 composed[i] = reg.faceParentId[resubParents[i]]; 254 } 255 faceParentId = composed; 256 } 257 subdivided = resub; 258 } 259 if (shouldAbort()) return null; 260 261 if (input.softExclude) { 262 subdivided.setAttribute('softExclude', new THREE.BufferAttribute(interpolateFromParents( 263 subdivided.attributes.position.array, faceParentId, input.positions, input.softExclude 264 ), 1)); 265 } 266 267 const subTriCount = subdivided.attributes.position.count / 3; 268 onEvent('displace', 0, { triCount: subTriCount }); 269 await yieldFrame(); 270 displaced = applyDisplacement( 271 subdivided, 272 input.imageData, 273 input.imgWidth, 274 input.imgHeight, 275 settings, 276 bounds, 277 (p) => onEvent('displace', p, { triCount: subTriCount }) 278 ); 279 if (shouldAbort()) return null; 280 281 // Preserve-untextured (beta): capture the per-face exclusion mask before 282 // the subdivided mesh is freed. Displacement keeps triangle count and 283 // order, so the mask indexes the displaced mesh 1:1 and lets decimation 284 // lock those faces in place. 285 let lockedFaces = null; 286 if (settings.preserveUntextured) { 287 const ew = subdivided.attributes.excludeWeight; 288 if (ew) { 289 const triN = subdivided.attributes.position.count / 3; 290 lockedFaces = new Uint8Array(triN); 291 for (let t = 0; t < triN; t++) { 292 if (ew.array[t * 3] > 0.99) lockedFaces[t] = 1; 293 } 294 } 295 } 296 297 // Free subdivided geometry â displacement created a separate copy. 298 subdivided.dispose(); 299 subdivided = null; 300 301 const dispTriCount = displaced.attributes.position.count / 3;
302 const needsDecimation = dispTriCount > settings.maxTriangles; 303 finalGeometry = displaced; 304 305 // Decimation runs only in export mode (bake keeps the parent-face map, 306 // which decimate drops): when over the target OR when flat-face harvesting 307 // alone is wanted. 308 const runDecimation = mode === 'export' && (needsDecimation || settings.harvestFlatFaces); 309 let lockedOverBudget = false; 310 if (runDecimation) { 311 onEvent('decimate', 0, { from: dispTriCount, needsDecimation }); 312 await yieldFrame(); 313 finalGeometry = await decimate( 314 displaced, 315 settings.maxTriangles, 316 (p) => onEvent('decimate', p, { from: dispTriCount, needsDecimation }), 317 settings.harvestFlatFaces, 318 settings.harvestTol, 319 lockedFaces, 320 // releaseInput: `displaced` is disposed on the next line and never read 321 // again, so decimate may drop its buffers as soon as it has indexed 322 // them instead of holding them for the whole collapse loop. 323 true 324 ); 325 // Capture before repair replaces the geometry (userData isn't carried over). 326 lockedOverBudget = !!finalGeometry.userData.lockedOverBudget;
327 // Free pre-decimation geometry â decimate created a separate copy. 328 displaced.dispose(); 329 displaced = null; 330 if (shouldAbort()) return null; 331 } 332 333 if (settings.bottomAngleLimit > 0) { 334 clampBelowBottom(finalGeometry, bounds.min.z); 335 } 336 // Bottom faces = 0 means the bed face is textured on purpose; the snap 337 // would flatten that texture again (#126). Gate it here, not only in the 338 // UI, so loaded projects with smoothBottom:true + limit 0 behave too. 339 if (settings.smoothBottom && settings.bottomAngleLimit > 0) { 340 snapBottomToFlat(finalGeometry, bounds.min.z, 0.1); 341 } 342 343 // Resolve T-junctions so the export is watertight & manifold. Only on the 344 // decimated (sparse) mesh â welding the dense pre-decimation mesh at the 345 // export grid would collapse fine detail into degenerates. 346 let repairStats = null; 347 if (runDecimation) { 348 onEvent('repair', 0); 349 await yieldFrame(); 350 const beforeSlivers = countAreaSlivers(finalGeometry); 351 const repaired = resolveTJunctions(finalGeometry); 352 finalGeometry.dispose(); 353 finalGeometry = repaired; 354 const after = countEdgeDefects(finalGeometry); 355 repairStats = { 356 beforeSlivers, 357 open: after.open, 358 nonManifold: after.nonManifold, 359 slivers: countAreaSlivers(finalGeometry), 360 tris: after.tris, 361 }; 362 if (shouldAbort()) return null; 363 } 364 365 done = true; 366 return { 367 positions: finalGeometry.attributes.position.array, 368 normals: finalGeometry.attributes.normal ? finalGeometry.attributes.normal.array : null, 369 safetyCapHit, 370 lockedOverBudget, 371 runDecimation, 372 needsDecimation, 373 faceParentId: mode === 'bake' ? faceParentId : null, 374 repairStats, 375 }; 376 } finally { 377 // Dispose intermediates regardless of success, failure, or abort. 378 // finalGeometry may alias displaced (no decimation) â avoid double-dispose. 379 if (subdivided) subdivided.dispose(); 380 if (displaced && displaced !== subdivided) displaced.dispose(); 381 if (!done && finalGeometry && finalGeometry !== displaced && finalGeometry !== subdivided) { 382 finalGeometry.dispose(); 383 } 384 } 385}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.