Repository navigation
Expand file tree
/
Copy pathdiag-pipeline.mjs
More file actions
229 lines (214 loc) · 13 KB
/
Copy pathdiag-pipeline.mjs
File metadata and controls
229 lines (214 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
/*
* Copyright (c) 2026 CNCKitchen (Stefan Hermann) and contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
// Faithful full-pipeline reproduction using the REAL app modules.
// Mirrors handleExport() exactly: subdivide → regularize → re-subdivide →
// displace → bottom-clamp → smoothBottom → decimate(harvest) → resolveTJunctions.
// Measures open/non-manifold edges at every stage to find where they appear and
// whether resolveTJunctions clears them.
import { readFileSync } from 'fs';
import { unzlibSync } from 'fflate';
import * as THREE from 'three';
import { subdivide } from './js/subdivision.js';
import { regularizeMesh } from './js/regularize.js';
import { applyDisplacement } from './js/displacement.js';
import { decimate } from './js/decimation.js';
import { resolveTJunctions } from './js/meshRepair.js';
import { buildFaceWeights } from './js/exclusion.js';
// ── settings: the screenshot scenario (rack, dots, 0.2mm, 2M tris, amp 0.5) ──
const refineLength = +(process.argv[3] || 0.2);
const maxTriangles = +(process.argv[4] || 2_000_000);
const settings = {
mappingMode: 5, scaleU: 0.5, scaleV: 0.5, amplitude: 0.5, textureHeight: 0.5,
invertDisplacement: false, offsetU: 0, offsetV: 0, rotation: 0,
refineLength, maxTriangles, lockScale: true,
bottomAngleLimit: 5, topAngleLimit: 0, mappingBlend: 1, seamBandWidth: 0.5,
textureSmoothing: 0, blendNormalSmoothing: 32, capAngle: 20, boundaryFalloff: 0,
symmetricDisplacement: false, noDownwardZ: false, smoothBottom: true,
harvestFlatFaces: true, harvestTol: 0.005, snapSeamlessWrap: true,
cylinderCenterX: null, cylinderCenterY: null, cylinderRadius: null,
regularizeEnabled: true, regularizeAspectThreshold: 5, regularizeSlack: 3.0,
regularizeAggressiveSlack: 8.0, regularizeExtremeAspect: 8,
regularizeNormalDeg: 15, regularizeAggressiveNormalDeg: 25, regularizeSecondPassMul: 1.1,
};
const _regularizeOpts = () => ({
aspectThreshold: settings.regularizeAspectThreshold,
slack: settings.regularizeSlack, aggressiveSlack: settings.regularizeAggressiveSlack,
extremeSliverAspect: settings.regularizeExtremeAspect,
maxNormalDeltaCos: Math.cos(settings.regularizeNormalDeg * Math.PI / 180),
aggressiveNormalDeltaCos: Math.cos(settings.regularizeAggressiveNormalDeg * Math.PI / 180),
});
// ── load binary STL → non-indexed soup ──────────────────────────────────────
function loadSTL(path) {
const b = readFileSync(path);
const n = b.readUInt32LE(80);
const pos = new Float32Array(n * 9);
let o = 84;
for (let i = 0; i < n; i++) {
o += 12; // skip normal
for (let v = 0; v < 9; v++) { pos[i*9+v] = b.readFloatLE(o); o += 4; }
o += 2; // attribute byte count
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
g.computeVertexNormals();
return g;
}
// ── minimal 8-bit PNG decoder → RGBA imageData ──────────────────────────────
function decodePNG(path) {
const d = readFileSync(path);
let p = 8; const idat = [];
let w, h, ct, bd;
while (p < d.length) {
const len = d.readUInt32BE(p); const type = d.toString('ascii', p+4, p+8);
const start = p + 8;
if (type === 'IHDR') {
w = d.readUInt32BE(start); h = d.readUInt32BE(start+4);
bd = d[start+8]; ct = d[start+9];
} else if (type === 'IDAT') {
idat.push(d.subarray(start, start+len));
} else if (type === 'IEND') break;
p = start + len + 4;
}
if (bd !== 8) throw new Error('only 8-bit PNG supported, got bitdepth ' + bd);
const channels = ct === 0 ? 1 : ct === 2 ? 3 : ct === 4 ? 2 : ct === 6 ? 4 : null;
if (channels === null) throw new Error('unsupported color type ' + ct + ' (palette not handled)');
const raw = unzlibSync(Buffer.concat(idat));
const stride = w * channels;
const out = new Uint8ClampedArray(w * h * 4);
const cur = new Uint8Array(stride), prev = new Uint8Array(stride);
let rp = 0;
const paeth = (a,b,c) => { const pp=a+b-c, pa=Math.abs(pp-a), pb=Math.abs(pp-b), pc=Math.abs(pp-c); return pa<=pb&&pa<=pc?a:pb<=pc?b:c; };
for (let y = 0; y < h; y++) {
const f = raw[rp++];
for (let x = 0; x < stride; x++) {
const rawv = raw[rp++];
const a = x >= channels ? cur[x-channels] : 0;
const bb = prev[x];
const c = x >= channels ? prev[x-channels] : 0;
let v;
switch (f) {
case 0: v = rawv; break;
case 1: v = rawv + a; break;
case 2: v = rawv + bb; break;
case 3: v = rawv + ((a + bb) >> 1); break;
case 4: v = rawv + paeth(a, bb, c); break;
default: throw new Error('bad filter ' + f);
}
cur[x] = v & 0xff;
}
for (let x = 0; x < w; x++) {
const si = x * channels, di = (y*w + x) * 4;
let r,gg,b2,al;
if (channels === 1) { r=gg=b2=cur[si]; al=255; }
else if (channels === 2) { r=gg=b2=cur[si]; al=cur[si+1]; }
else if (channels === 3) { r=cur[si]; gg=cur[si+1]; b2=cur[si+2]; al=255; }
else { r=cur[si]; gg=cur[si+1]; b2=cur[si+2]; al=cur[si+3]; }
out[di]=r; out[di+1]=gg; out[di+2]=b2; out[di+3]=al;
}
cur.copyWith ? 0 : 0;
prev.set(cur);
}
return { data: out, width: w, height: h };
}
// ── edge stats ───────────────────────────────────────────────────────────────
function stats(geometry, Q = 1e4) {
const p = geometry.attributes.position.array, n = p.length / 9;
const vmap = new Map(), id = new Int32Array(n*3);
for (let i = 0; i < n*3; i++) {
const k = `${Math.round(p[i*3]*Q)},${Math.round(p[i*3+1]*Q)},${Math.round(p[i*3+2]*Q)}`;
let v = vmap.get(k); if (v===undefined){v=vmap.size; vmap.set(k,v);} id[i]=v;
}
const ec = new Map();
for (let t=0;t<n;t++){const a=id[t*3],b=id[t*3+1],c=id[t*3+2];if(a===b||b===c||a===c)continue;const tri=[a,b,c];for(let e=0;e<3;e++){const x=tri[e],y=tri[(e+1)%3];const k=x<y?x*4294967296+y:y*4294967296+x;ec.set(k,(ec.get(k)||0)+1);}}
let open=0,nm=0; for(const c of ec.values()){if(c===1)open++;else if(c>2)nm++;}
return `tris=${n} open=${open} nonmanifold=${nm}`;
}
// ── buildCombinedFaceWeights (ported from main.js) ──────────────────────────
function buildCombinedFaceWeights(geometry, excludedFaces, invert, settings) {
const weights = buildFaceWeights(geometry, excludedFaces, invert);
const hasAngleMask = settings.bottomAngleLimit > 0 || settings.topAngleLimit > 0;
if (!hasAngleMask) return weights;
const posAttr = geometry.attributes.position, triCount = posAttr.count / 3;
const vA=new THREE.Vector3(),vB=new THREE.Vector3(),vC=new THREE.Vector3(),e1=new THREE.Vector3(),e2=new THREE.Vector3(),fn=new THREE.Vector3();
for (let t=0;t<triCount;t++){
if (weights[t*3] > 0.99) continue;
vA.fromBufferAttribute(posAttr,t*3); vB.fromBufferAttribute(posAttr,t*3+1); vC.fromBufferAttribute(posAttr,t*3+2);
e1.subVectors(vB,vA); e2.subVectors(vC,vA); fn.crossVectors(e1,e2);
const area=fn.length(), nz=area>1e-12?fn.z/area:0, ang=Math.acos(Math.abs(nz))*(180/Math.PI);
const masked = nz<0 ? (settings.bottomAngleLimit>0 && ang<=settings.bottomAngleLimit) : (settings.topAngleLimit>0 && ang<=settings.topAngleLimit);
if (masked) { weights[t*3]=1; weights[t*3+1]=1; weights[t*3+2]=1; }
}
return weights;
}
function snapBottomToFlat(geometry, bottomZ, tol=0.1){
const pa=geometry.attributes.position.array;
for(let i=0;i<pa.length;i+=9){if(Math.abs(pa[i+2]-bottomZ)<=tol)pa[i+2]=bottomZ;if(Math.abs(pa[i+5]-bottomZ)<=tol)pa[i+5]=bottomZ;if(Math.abs(pa[i+8]-bottomZ)<=tol)pa[i+8]=bottomZ;}
}
// ── run pipeline ─────────────────────────────────────────────────────────────
const stlPath = process.argv[2] || 'Parking rack bits_fixed.stl';
const currentGeometry = loadSTL(stlPath);
currentGeometry.computeBoundingBox();
const bb = currentGeometry.boundingBox;
const currentBounds = { 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) };
const img = decodePNG('textures/dots.png');
// scaleU/scaleV are absolute mm; 0.5 × maxDim matches the legacy relative 0.5.
{
const md = Math.max(currentBounds.size.x, currentBounds.size.y, currentBounds.size.z);
settings.scaleU = 0.5 * md;
settings.scaleV = 0.5 * md;
}
console.log(`source: ${stlPath} ${stats(currentGeometry)} | texture ${img.width}x${img.height} | refine=${refineLength} maxTri=${maxTriangles}`);
const faceWeights = buildCombinedFaceWeights(currentGeometry, new Set(), false, settings);
let { geometry: subdivided } = await subdivide(currentGeometry, settings.refineLength, null, faceWeights);
console.log('after subdivide1:', stats(subdivided));
if (settings.regularizeEnabled) {
const reg = regularizeMesh(subdivided, new Int32Array(subdivided.attributes.position.count/3), settings.refineLength, _regularizeOpts());
subdivided.dispose();
const exclAttr = reg.geometry.attributes.excludeWeight;
const w2 = exclAttr ? exclAttr.array : null;
const { geometry: resub } = await subdivide(reg.geometry, settings.refineLength*settings.regularizeSecondPassMul, null, w2, { fast:false });
reg.geometry.dispose();
subdivided = resub;
console.log('after regularize+resub:', stats(subdivided));
}
let displaced = applyDisplacement(subdivided, img, img.width, img.height, settings, currentBounds, null);
subdivided.dispose();
console.log('after displace:', stats(displaced));
// bottom clamp
if (settings.bottomAngleLimit > 0) {
const bz = currentBounds.min.z, pa = displaced.attributes.position.array;
for (let i=0;i<pa.length;i+=9){if(pa[i+2]<bz)pa[i+2]=bz;if(pa[i+5]<bz)pa[i+5]=bz;if(pa[i+8]<bz)pa[i+8]=bz;}
}
if (settings.smoothBottom) snapBottomToFlat(displaced, currentBounds.min.z, 0.1);
console.log('after bottom snaps:', stats(displaced));
const dispTri = displaced.attributes.position.count/3;
const needsDecimation = dispTri > settings.maxTriangles;
const runDecimation = needsDecimation || settings.harvestFlatFaces;
console.log(`needsDecimation=${needsDecimation} runDecimation=${runDecimation}`);
let finalGeometry = displaced;
if (runDecimation) {
finalGeometry = await decimate(displaced, settings.maxTriangles, null, settings.harvestFlatFaces, settings.harvestTol);
console.log('after decimate:', stats(finalGeometry), '| @1e6:', stats(finalGeometry,1e6));
}
if (settings.bottomAngleLimit > 0) {
const bz = currentBounds.min.z, pa = finalGeometry.attributes.position.array;
for (let i=0;i<pa.length;i+=9){if(pa[i+2]<bz)pa[i+2]=bz;if(pa[i+5]<bz)pa[i+5]=bz;if(pa[i+8]<bz)pa[i+8]=bz;}
}
if (settings.smoothBottom) snapBottomToFlat(finalGeometry, currentBounds.min.z, 0.1);
console.log('after final bottom snaps:', stats(finalGeometry));
function importerSlivers(g){const p=g.attributes.position.array,n=p.length/9;let s=0;for(let t=0;t<n;t++){const b=t*9;const ux=p[b+3]-p[b],uy=p[b+4]-p[b+1],uz=p[b+5]-p[b+2];const vx=p[b+6]-p[b],vy=p[b+7]-p[b+1],vz=p[b+8]-p[b+2];const a2=(uy*vz-uz*vy)**2+(uz*vx-ux*vz)**2+(ux*vy-uy*vx)**2;if(a2<1e-24)s++;}return s;}
function openAfterImport(g,Q=1e4){const p=g.attributes.position.array,n=p.length/9;const vmap=new Map(),id=new Int32Array(n*3);for(let i=0;i<n*3;i++){const k=`${Math.round(p[i*3]*Q)},${Math.round(p[i*3+1]*Q)},${Math.round(p[i*3+2]*Q)}`;let v=vmap.get(k);if(v===undefined){v=vmap.size;vmap.set(k,v);}id[i]=v;}const ec=new Map();for(let t=0;t<n;t++){const b=t*9;const ux=p[b+3]-p[b],uy=p[b+4]-p[b+1],uz=p[b+5]-p[b+2];const vx=p[b+6]-p[b],vy=p[b+7]-p[b+1],vz=p[b+8]-p[b+2];const a2=(uy*vz-uz*vy)**2+(uz*vx-ux*vz)**2+(ux*vy-uy*vx)**2;if(a2<1e-24)continue;const A=id[t*3],B=id[t*3+1],C=id[t*3+2];if(A===B||B===C||A===C)continue;const tri=[A,B,C];for(let e=0;e<3;e++){const x=tri[e],y=tri[(e+1)%3];const k=x<y?x*4294967296+y:y*4294967296+x;ec.set(k,(ec.get(k)||0)+1);}}let open=0,nm=0;for(const c of ec.values()){if(c===1)open++;else if(c>2)nm++;}return{open,nm};}
console.log('FINAL decimate (pre-repair): slivers=' + importerSlivers(finalGeometry) + ' open-after-import=' + JSON.stringify(openAfterImport(finalGeometry)));
if (runDecimation) {
const repaired = resolveTJunctions(finalGeometry);
console.log('AFTER resolveTJunctions:', stats(repaired));
console.log(' importer slivers (area<1e-12):', importerSlivers(repaired));
console.log(' >>> open/nm AFTER simulated importer cleanup:', JSON.stringify(openAfterImport(repaired)));
const src=repaired.attributes.position.array;
const rounded=new Float32Array(src.length);
for(let i=0;i<src.length;i++) rounded[i]=Math.fround(parseFloat(src[i].toFixed(4)));
const rg=new THREE.BufferGeometry(); rg.setAttribute('position',new THREE.BufferAttribute(rounded,3));
console.log(' >>> AFTER export round-trip (toFixed4+float32): slivers=' + importerSlivers(rg) + ' open/nm-after-import=' + JSON.stringify(openAfterImport(rg)));
}