PageSourceSearch

https://bumpmesh.com/js/exportPipeline.js

js bumpmesh.com collected 2026-09-29 05:49:08 UTC 16,472 bytes, 385 lines download raw bytes

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.