brep-io-kernel 1.0.26 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist-kernel/brep-kernel.js +20819 -19752
- package/package.json +6 -5
- package/src/BREP/SolidMethods/fillet copy.js +1532 -0
- package/src/UI/fileManagerWidget.js +1 -10
- package/src/UI/startupTour.js +659 -0
- package/src/UI/toolbarButtons/newButton.js +21 -0
- package/src/UI/toolbarButtons/registerDefaultButtons.js +2 -0
- package/src/UI/toolbarButtons/saveButton.js +1 -1
- package/src/UI/viewer.js +4 -0
- package/src/fs.proxy.js +119 -25
- package/src/generated/licenseBundle.js +2 -0
- package/src/idbStorage.js +275 -105
- package/src/index.js +7 -0
- package/src/licenseInfo.js +71 -0
- package/src/services/componentLibrary.js +1 -1
|
@@ -0,0 +1,1532 @@
|
|
|
1
|
+
// Solid.fillet implementation: consolidates fillet logic so features call this API.
|
|
2
|
+
// Usage: solid.fillet({ radius, edgeNames, featureID, direction, inflate, resolution, debug, showTangentOverlays, combineEdges })
|
|
3
|
+
import { Manifold } from '../SolidShared.js';
|
|
4
|
+
import { resolveEdgesFromInputs } from './edgeResolution.js';
|
|
5
|
+
import { computeFaceAreaFromTriangles } from '../fillets/filletGeometry.js';
|
|
6
|
+
import { createQuantizer, deriveTolerance } from '../../utils/geometryTolerance.js';
|
|
7
|
+
|
|
8
|
+
const debugMode = false;
|
|
9
|
+
|
|
10
|
+
// Threshold for collapsing tiny end caps into the round face.
|
|
11
|
+
const END_CAP_AREA_RATIO_THRESHOLD = 0.05;
|
|
12
|
+
|
|
13
|
+
function computeFaceAreaByName(solid, faceName) {
|
|
14
|
+
if (!solid || typeof solid.getFace !== 'function' || !faceName) return 0;
|
|
15
|
+
try {
|
|
16
|
+
const tris = solid.getFace(faceName);
|
|
17
|
+
return computeFaceAreaFromTriangles(tris);
|
|
18
|
+
} catch {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildFaceAreaCache(solid) {
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
return {
|
|
26
|
+
get(name) {
|
|
27
|
+
if (!name) return 0;
|
|
28
|
+
if (cache.has(name)) return cache.get(name);
|
|
29
|
+
const area = computeFaceAreaByName(solid, name);
|
|
30
|
+
cache.set(name, area);
|
|
31
|
+
return area;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function findNeighborRoundFace(resultSolid, capName, areaCache, boundaryCache) {
|
|
37
|
+
if (!resultSolid || !capName) return null;
|
|
38
|
+
const boundaries = boundaryCache.current || resultSolid.getBoundaryEdgePolylines() || [];
|
|
39
|
+
boundaryCache.current = boundaries;
|
|
40
|
+
let best = null;
|
|
41
|
+
let bestArea = 0;
|
|
42
|
+
for (const poly of boundaries) {
|
|
43
|
+
const a = poly?.faceA;
|
|
44
|
+
const b = poly?.faceB;
|
|
45
|
+
if (a !== capName && b !== capName) continue;
|
|
46
|
+
const other = (a === capName) ? b : a;
|
|
47
|
+
if (!other || typeof other !== 'string') continue;
|
|
48
|
+
if (!other.includes('TUBE_Outer')) continue;
|
|
49
|
+
const aVal = areaCache.get(other);
|
|
50
|
+
if (aVal > bestArea) {
|
|
51
|
+
bestArea = aVal;
|
|
52
|
+
best = other;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return best;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findLargestRoundFace(resultSolid, areaCache) {
|
|
59
|
+
if (!resultSolid || typeof resultSolid.getFaceNames !== 'function') return null;
|
|
60
|
+
let best = null;
|
|
61
|
+
let bestArea = 0;
|
|
62
|
+
for (const name of resultSolid.getFaceNames()) {
|
|
63
|
+
if (typeof name !== 'string' || !name.includes('TUBE_Outer')) continue;
|
|
64
|
+
const a = areaCache.get(name);
|
|
65
|
+
if (a > bestArea) {
|
|
66
|
+
bestArea = a;
|
|
67
|
+
best = name;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return best;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getFilletMergeCandidateNames(filletSolid) {
|
|
74
|
+
if (!filletSolid || typeof filletSolid.getFaceNames !== 'function') return [];
|
|
75
|
+
const names = filletSolid.getFaceNames();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const n of names) {
|
|
78
|
+
if (typeof n !== 'string') continue;
|
|
79
|
+
const meta = (typeof filletSolid.getFaceMetadata === 'function') ? filletSolid.getFaceMetadata(n) : {};
|
|
80
|
+
if (meta && (meta.filletRoundFace || meta.filletSourceArea || meta.filletEndCap)) {
|
|
81
|
+
out.push(n);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (n.includes('_END_CAP') || n.includes('_CapStart') || n.includes('_CapEnd') || n.includes('_WEDGE_A') || n.includes('_WEDGE_B')) {
|
|
85
|
+
out.push(n);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function guessRoundFaceName(filletSolid, filletName) {
|
|
92
|
+
const faces = (filletSolid && typeof filletSolid.getFaceNames === 'function')
|
|
93
|
+
? filletSolid.getFaceNames()
|
|
94
|
+
: [];
|
|
95
|
+
const explicitOuter = faces.find(n => typeof n === 'string' && n.includes('_TUBE_Outer'));
|
|
96
|
+
if (explicitOuter) return explicitOuter;
|
|
97
|
+
if (filletName) {
|
|
98
|
+
const guess = `${filletName}_TUBE_Outer`;
|
|
99
|
+
if (faces.includes(guess)) return guess;
|
|
100
|
+
return guess;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mergeFaceIntoTarget(resultSolid, sourceFaceName, targetFaceName) {
|
|
106
|
+
if (!resultSolid || !sourceFaceName || !targetFaceName) return false;
|
|
107
|
+
const faceToId = resultSolid._faceNameToID instanceof Map ? resultSolid._faceNameToID : new Map();
|
|
108
|
+
const idToFace = resultSolid._idToFaceName instanceof Map ? resultSolid._idToFaceName : new Map();
|
|
109
|
+
const sourceID = faceToId.get(sourceFaceName);
|
|
110
|
+
if (sourceID === undefined) return false;
|
|
111
|
+
const targetID = faceToId.get(targetFaceName);
|
|
112
|
+
|
|
113
|
+
// If target doesn't exist yet, just relabel the source.
|
|
114
|
+
if (targetID === undefined) {
|
|
115
|
+
idToFace.set(sourceID, targetFaceName);
|
|
116
|
+
faceToId.delete(sourceFaceName);
|
|
117
|
+
faceToId.set(targetFaceName, sourceID);
|
|
118
|
+
if (resultSolid._faceMetadata instanceof Map) {
|
|
119
|
+
const meta = resultSolid._faceMetadata;
|
|
120
|
+
if (!meta.has(targetFaceName) && meta.has(sourceFaceName)) {
|
|
121
|
+
meta.set(targetFaceName, meta.get(sourceFaceName));
|
|
122
|
+
}
|
|
123
|
+
meta.delete(sourceFaceName);
|
|
124
|
+
}
|
|
125
|
+
resultSolid._idToFaceName = idToFace;
|
|
126
|
+
resultSolid._faceNameToID = faceToId;
|
|
127
|
+
resultSolid._faceIndex = null;
|
|
128
|
+
resultSolid._dirty = true;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (targetID === sourceID) return false;
|
|
133
|
+
|
|
134
|
+
const triIDs = Array.isArray(resultSolid._triIDs) ? resultSolid._triIDs : null;
|
|
135
|
+
let replaced = 0;
|
|
136
|
+
if (triIDs) {
|
|
137
|
+
for (let i = 0; i < triIDs.length; i++) {
|
|
138
|
+
if ((triIDs[i] >>> 0) === sourceID) {
|
|
139
|
+
triIDs[i] = targetID;
|
|
140
|
+
replaced++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
resultSolid._triIDs = triIDs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
idToFace.delete(sourceID);
|
|
147
|
+
faceToId.delete(sourceFaceName);
|
|
148
|
+
if (resultSolid._faceMetadata instanceof Map) {
|
|
149
|
+
const meta = resultSolid._faceMetadata;
|
|
150
|
+
if (!meta.has(targetFaceName) && meta.has(sourceFaceName)) {
|
|
151
|
+
meta.set(targetFaceName, meta.get(sourceFaceName));
|
|
152
|
+
}
|
|
153
|
+
meta.delete(sourceFaceName);
|
|
154
|
+
}
|
|
155
|
+
resultSolid._idToFaceName = idToFace;
|
|
156
|
+
resultSolid._faceNameToID = faceToId;
|
|
157
|
+
resultSolid._faceIndex = null;
|
|
158
|
+
resultSolid._dirty = true;
|
|
159
|
+
return replaced > 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function mergeTinyFacesIntoRoundFace(resultSolid, filletSolid, candidateNames, roundFaceName, featureID, boundaryCache, resultAreaCache) {
|
|
163
|
+
if (!resultSolid || !filletSolid || !Array.isArray(candidateNames) || candidateNames.length === 0) return;
|
|
164
|
+
const areaCacheResult = resultAreaCache || buildFaceAreaCache(resultSolid);
|
|
165
|
+
const areaCacheFillet = buildFaceAreaCache(filletSolid);
|
|
166
|
+
|
|
167
|
+
for (const capName of candidateNames) {
|
|
168
|
+
const capMeta = (typeof resultSolid.getFaceMetadata === 'function') ? resultSolid.getFaceMetadata(capName) : {};
|
|
169
|
+
const referenceArea = Number(capMeta?.filletSourceArea) > 0 ? Number(capMeta.filletSourceArea) : areaCacheFillet.get(capName);
|
|
170
|
+
if (!(referenceArea > 0)) continue;
|
|
171
|
+
const finalArea = areaCacheResult.get(capName);
|
|
172
|
+
if (!(finalArea > 0)) continue;
|
|
173
|
+
if (finalArea < referenceArea * END_CAP_AREA_RATIO_THRESHOLD) {
|
|
174
|
+
let targetFace = capMeta?.filletRoundFace || roundFaceName;
|
|
175
|
+
const neighborRound = findNeighborRoundFace(resultSolid, capName, areaCacheResult, boundaryCache);
|
|
176
|
+
if (neighborRound) targetFace = neighborRound;
|
|
177
|
+
if (!targetFace) targetFace = findLargestRoundFace(resultSolid, areaCacheResult);
|
|
178
|
+
if (!targetFace) continue;
|
|
179
|
+
const merged = mergeFaceIntoTarget(resultSolid, capName, targetFace);
|
|
180
|
+
if (merged) {
|
|
181
|
+
consoleLogReplacement('[Solid.fillet] Merged tiny fillet face into round face', {
|
|
182
|
+
featureID,
|
|
183
|
+
capName,
|
|
184
|
+
roundFaceName: targetFace,
|
|
185
|
+
ratio: finalArea / referenceArea,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mergeSideFacesIntoRoundFace(resultSolid, filletName, roundFaceName) {
|
|
193
|
+
if (!resultSolid || !filletName || !roundFaceName) return;
|
|
194
|
+
const sideA = `${filletName}_SIDE_A`;
|
|
195
|
+
const sideB = `${filletName}_SIDE_B`;
|
|
196
|
+
const surfaceCA = `${filletName}_SURFACE_CA`;
|
|
197
|
+
const surfaceCB = `${filletName}_SURFACE_CB`;
|
|
198
|
+
mergeFaceIntoTarget(resultSolid, sideA, roundFaceName);
|
|
199
|
+
mergeFaceIntoTarget(resultSolid, sideB, roundFaceName);
|
|
200
|
+
mergeFaceIntoTarget(resultSolid, surfaceCA, roundFaceName);
|
|
201
|
+
mergeFaceIntoTarget(resultSolid, surfaceCB, roundFaceName);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getEdgePolylineLocal(edgeObj) {
|
|
205
|
+
if (!edgeObj) return [];
|
|
206
|
+
const cached = edgeObj?.userData?.polylineLocal;
|
|
207
|
+
if (Array.isArray(cached) && cached.length >= 2) {
|
|
208
|
+
return cached.map(p => [Number(p[0]) || 0, Number(p[1]) || 0, Number(p[2]) || 0]);
|
|
209
|
+
}
|
|
210
|
+
if (typeof edgeObj.points === 'function') {
|
|
211
|
+
const pts = edgeObj.points(false);
|
|
212
|
+
if (Array.isArray(pts) && pts.length >= 2) {
|
|
213
|
+
return pts.map(p => [Number(p.x) || 0, Number(p.y) || 0, Number(p.z) || 0]);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const pos = edgeObj?.geometry?.getAttribute?.('position');
|
|
217
|
+
if (pos && pos.itemSize === 3 && pos.count >= 2) {
|
|
218
|
+
const out = [];
|
|
219
|
+
for (let i = 0; i < pos.count; i++) {
|
|
220
|
+
out.push([pos.getX(i), pos.getY(i), pos.getZ(i)]);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dedupePoints(points, tol = 1e-5) {
|
|
228
|
+
const out = [];
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
const { q, k } = createQuantizer(tol);
|
|
231
|
+
for (const p of points || []) {
|
|
232
|
+
if (!Array.isArray(p) || p.length < 3) continue;
|
|
233
|
+
const qp = q(p);
|
|
234
|
+
const key = k(qp);
|
|
235
|
+
if (seen.has(key)) continue;
|
|
236
|
+
seen.add(key);
|
|
237
|
+
out.push(qp);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function collectFacePoints(solid, faceName, out) {
|
|
243
|
+
if (!solid || typeof solid.getFace !== 'function' || !faceName) return out;
|
|
244
|
+
const tris = solid.getFace(faceName);
|
|
245
|
+
if (!Array.isArray(tris) || tris.length === 0) return out;
|
|
246
|
+
const dst = Array.isArray(out) ? out : [];
|
|
247
|
+
for (const tri of tris) {
|
|
248
|
+
const p1 = tri?.p1;
|
|
249
|
+
const p2 = tri?.p2;
|
|
250
|
+
const p3 = tri?.p3;
|
|
251
|
+
if (Array.isArray(p1) && p1.length >= 3) dst.push([Number(p1[0]) || 0, Number(p1[1]) || 0, Number(p1[2]) || 0]);
|
|
252
|
+
if (Array.isArray(p2) && p2.length >= 3) dst.push([Number(p2[0]) || 0, Number(p2[1]) || 0, Number(p2[2]) || 0]);
|
|
253
|
+
if (Array.isArray(p3) && p3.length >= 3) dst.push([Number(p3[0]) || 0, Number(p3[1]) || 0, Number(p3[2]) || 0]);
|
|
254
|
+
}
|
|
255
|
+
return dst;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildHullSolidFromPoints(points, name, SolidCtor, tol = 1e-5) {
|
|
259
|
+
const unique = dedupePoints(points, tol);
|
|
260
|
+
if (unique.length < 4) return null;
|
|
261
|
+
let hull = null;
|
|
262
|
+
try {
|
|
263
|
+
hull = Manifold.hull(unique);
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const solid = SolidCtor._fromManifold(hull, new Map([[0, name]]));
|
|
269
|
+
try { solid.name = name; } catch { }
|
|
270
|
+
const faceNames = (typeof solid.getFaceNames === 'function') ? solid.getFaceNames() : [];
|
|
271
|
+
for (const faceName of faceNames) {
|
|
272
|
+
if (!faceName || faceName === name) continue;
|
|
273
|
+
mergeFaceIntoTarget(solid, faceName, name);
|
|
274
|
+
}
|
|
275
|
+
return solid;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function averageFaceNormalSimple(solid, faceName) {
|
|
282
|
+
if (!solid || typeof solid.getFace !== 'function' || !faceName) return null;
|
|
283
|
+
const tris = solid.getFace(faceName);
|
|
284
|
+
if (!Array.isArray(tris) || tris.length === 0) return null;
|
|
285
|
+
let nx = 0, ny = 0, nz = 0;
|
|
286
|
+
for (const tri of tris) {
|
|
287
|
+
const p1 = tri?.p1;
|
|
288
|
+
const p2 = tri?.p2;
|
|
289
|
+
const p3 = tri?.p3;
|
|
290
|
+
if (!Array.isArray(p1) || !Array.isArray(p2) || !Array.isArray(p3)) continue;
|
|
291
|
+
const ax = Number(p1[0]) || 0, ay = Number(p1[1]) || 0, az = Number(p1[2]) || 0;
|
|
292
|
+
const bx = Number(p2[0]) || 0, by = Number(p2[1]) || 0, bz = Number(p2[2]) || 0;
|
|
293
|
+
const cx = Number(p3[0]) || 0, cy = Number(p3[1]) || 0, cz = Number(p3[2]) || 0;
|
|
294
|
+
const ux = bx - ax, uy = by - ay, uz = bz - az;
|
|
295
|
+
const vx = cx - ax, vy = cy - ay, vz = cz - az;
|
|
296
|
+
const cxn = uy * vz - uz * vy;
|
|
297
|
+
const cyn = uz * vx - ux * vz;
|
|
298
|
+
const czn = ux * vy - uy * vx;
|
|
299
|
+
nx += cxn;
|
|
300
|
+
ny += cyn;
|
|
301
|
+
nz += czn;
|
|
302
|
+
}
|
|
303
|
+
const len = Math.hypot(nx, ny, nz);
|
|
304
|
+
if (!(len > 1e-12)) return null;
|
|
305
|
+
return [nx / len, ny / len, nz / len];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function deriveSolidToleranceFromVerts(solid, baseTol = 1e-5) {
|
|
309
|
+
const vp = Array.isArray(solid?._vertProperties) ? solid._vertProperties : null;
|
|
310
|
+
if (!vp || vp.length < 6) return baseTol;
|
|
311
|
+
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
|
312
|
+
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
|
313
|
+
for (let i = 0; i < vp.length; i += 3) {
|
|
314
|
+
const x = vp[i + 0];
|
|
315
|
+
const y = vp[i + 1];
|
|
316
|
+
const z = vp[i + 2];
|
|
317
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
318
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
319
|
+
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
|
320
|
+
}
|
|
321
|
+
const dx = maxX - minX;
|
|
322
|
+
const dy = maxY - minY;
|
|
323
|
+
const dz = maxZ - minZ;
|
|
324
|
+
const diag = Math.hypot(dx, dy, dz) || 1;
|
|
325
|
+
return Math.max(baseTol, diag * 1e-6);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function toArrayPoint(p) {
|
|
329
|
+
if (Array.isArray(p) && p.length >= 3) {
|
|
330
|
+
const x = Number(p[0]); const y = Number(p[1]); const z = Number(p[2]);
|
|
331
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) return [x, y, z];
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (p && typeof p === 'object') {
|
|
335
|
+
const x = Number(p.x); const y = Number(p.y); const z = Number(p.z);
|
|
336
|
+
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) return [x, y, z];
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function dist2(a, b) {
|
|
342
|
+
const dx = a[0] - b[0];
|
|
343
|
+
const dy = a[1] - b[1];
|
|
344
|
+
const dz = a[2] - b[2];
|
|
345
|
+
return dx * dx + dy * dy + dz * dz;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function buildPolylineSampler(points, tol = 1e-9) {
|
|
349
|
+
if (!Array.isArray(points) || points.length < 2) return null;
|
|
350
|
+
const eps = Math.max(1e-12, Math.abs(tol || 0));
|
|
351
|
+
const eps2 = eps * eps;
|
|
352
|
+
const pts = [];
|
|
353
|
+
let prev = null;
|
|
354
|
+
for (const p of points) {
|
|
355
|
+
const q = toArrayPoint(p);
|
|
356
|
+
if (!q) continue;
|
|
357
|
+
if (prev && dist2(prev, q) <= eps2) continue;
|
|
358
|
+
pts.push(q);
|
|
359
|
+
prev = q;
|
|
360
|
+
}
|
|
361
|
+
if (pts.length < 2) return null;
|
|
362
|
+
|
|
363
|
+
const segCount = pts.length - 1;
|
|
364
|
+
const segLen = new Array(segCount);
|
|
365
|
+
const segLen2 = new Array(segCount);
|
|
366
|
+
const segDx = new Array(segCount);
|
|
367
|
+
const segDy = new Array(segCount);
|
|
368
|
+
const segDz = new Array(segCount);
|
|
369
|
+
const cum = new Array(segCount + 1);
|
|
370
|
+
cum[0] = 0;
|
|
371
|
+
for (let i = 0; i < segCount; i++) {
|
|
372
|
+
const a = pts[i];
|
|
373
|
+
const b = pts[i + 1];
|
|
374
|
+
const dx = b[0] - a[0];
|
|
375
|
+
const dy = b[1] - a[1];
|
|
376
|
+
const dz = b[2] - a[2];
|
|
377
|
+
const len2 = dx * dx + dy * dy + dz * dz;
|
|
378
|
+
const len = Math.sqrt(len2);
|
|
379
|
+
segLen[i] = len;
|
|
380
|
+
segLen2[i] = len2;
|
|
381
|
+
segDx[i] = dx;
|
|
382
|
+
segDy[i] = dy;
|
|
383
|
+
segDz[i] = dz;
|
|
384
|
+
cum[i + 1] = cum[i] + len;
|
|
385
|
+
}
|
|
386
|
+
const totalLen = cum[segCount];
|
|
387
|
+
if (!(totalLen > eps)) return null;
|
|
388
|
+
|
|
389
|
+
const project = (p) => {
|
|
390
|
+
let bestDist2 = Infinity;
|
|
391
|
+
let bestS = 0;
|
|
392
|
+
let bestQ = pts[0];
|
|
393
|
+
for (let i = 0; i < segCount; i++) {
|
|
394
|
+
const len2 = segLen2[i];
|
|
395
|
+
if (len2 <= eps2) continue;
|
|
396
|
+
const a = pts[i];
|
|
397
|
+
const dx = segDx[i];
|
|
398
|
+
const dy = segDy[i];
|
|
399
|
+
const dz = segDz[i];
|
|
400
|
+
const tRaw = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy + (p[2] - a[2]) * dz) / len2;
|
|
401
|
+
const t = tRaw < 0 ? 0 : (tRaw > 1 ? 1 : tRaw);
|
|
402
|
+
const qx = a[0] + dx * t;
|
|
403
|
+
const qy = a[1] + dy * t;
|
|
404
|
+
const qz = a[2] + dz * t;
|
|
405
|
+
const ddx = p[0] - qx;
|
|
406
|
+
const ddy = p[1] - qy;
|
|
407
|
+
const ddz = p[2] - qz;
|
|
408
|
+
const d2 = ddx * ddx + ddy * ddy + ddz * ddz;
|
|
409
|
+
if (d2 < bestDist2) {
|
|
410
|
+
bestDist2 = d2;
|
|
411
|
+
bestS = cum[i] + segLen[i] * t;
|
|
412
|
+
bestQ = [qx, qy, qz];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { s: bestS, point: bestQ, dist2: bestDist2 };
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const pointAt = (s) => {
|
|
419
|
+
const t = Math.max(0, Math.min(totalLen, Number.isFinite(s) ? s : 0));
|
|
420
|
+
let lo = 0;
|
|
421
|
+
let hi = segCount - 1;
|
|
422
|
+
while (lo < hi) {
|
|
423
|
+
const mid = (lo + hi) >> 1;
|
|
424
|
+
if (cum[mid + 1] < t) lo = mid + 1; else hi = mid;
|
|
425
|
+
}
|
|
426
|
+
const i = lo;
|
|
427
|
+
const len = segLen[i];
|
|
428
|
+
if (!(len > eps)) return [pts[i][0], pts[i][1], pts[i][2]];
|
|
429
|
+
const u = (t - cum[i]) / len;
|
|
430
|
+
return [
|
|
431
|
+
pts[i][0] + segDx[i] * u,
|
|
432
|
+
pts[i][1] + segDy[i] * u,
|
|
433
|
+
pts[i][2] + segDz[i] * u
|
|
434
|
+
];
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
points: pts,
|
|
439
|
+
totalLen,
|
|
440
|
+
avgSegLen: totalLen / Math.max(1, segCount),
|
|
441
|
+
project,
|
|
442
|
+
pointAt,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function monotonicPenalty(seq, tol = 0) {
|
|
447
|
+
let penalty = 0;
|
|
448
|
+
for (let i = 1; i < seq.length; i++) {
|
|
449
|
+
const d = seq[i] - seq[i - 1];
|
|
450
|
+
if (d < -tol) penalty += -d;
|
|
451
|
+
}
|
|
452
|
+
return penalty;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isotonicRegressionNonDecreasing(values) {
|
|
456
|
+
const n = values.length;
|
|
457
|
+
if (n <= 1) return values.slice();
|
|
458
|
+
const blocks = [];
|
|
459
|
+
for (let i = 0; i < n; i++) {
|
|
460
|
+
blocks.push({ start: i, end: i, sum: values[i], weight: 1 });
|
|
461
|
+
while (blocks.length >= 2) {
|
|
462
|
+
const b = blocks[blocks.length - 1];
|
|
463
|
+
const a = blocks[blocks.length - 2];
|
|
464
|
+
if ((a.sum / a.weight) <= (b.sum / b.weight)) break;
|
|
465
|
+
blocks.pop();
|
|
466
|
+
blocks.pop();
|
|
467
|
+
blocks.push({
|
|
468
|
+
start: a.start,
|
|
469
|
+
end: b.end,
|
|
470
|
+
sum: a.sum + b.sum,
|
|
471
|
+
weight: a.weight + b.weight,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const out = new Array(n);
|
|
476
|
+
for (const block of blocks) {
|
|
477
|
+
const avg = block.sum / block.weight;
|
|
478
|
+
for (let i = block.start; i <= block.end; i++) out[i] = avg;
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function rotateArray(arr, start) {
|
|
484
|
+
const n = arr.length;
|
|
485
|
+
if (!n || start <= 0) return arr.slice();
|
|
486
|
+
const out = new Array(n);
|
|
487
|
+
let idx = 0;
|
|
488
|
+
for (let i = start; i < n; i++) out[idx++] = arr[i];
|
|
489
|
+
for (let i = 0; i < start; i++) out[idx++] = arr[i];
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function unrotateArray(arr, start) {
|
|
494
|
+
const n = arr.length;
|
|
495
|
+
if (!n || start <= 0) return arr.slice();
|
|
496
|
+
const out = new Array(n);
|
|
497
|
+
for (let i = 0; i < n; i++) {
|
|
498
|
+
out[(i + start) % n] = arr[i];
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function findWorstDropIndex(seq) {
|
|
504
|
+
let worst = 0;
|
|
505
|
+
let idx = 0;
|
|
506
|
+
for (let i = 1; i < seq.length; i++) {
|
|
507
|
+
const d = seq[i] - seq[i - 1];
|
|
508
|
+
if (d < worst) { worst = d; idx = i; }
|
|
509
|
+
}
|
|
510
|
+
const wrap = seq[0] - seq[seq.length - 1];
|
|
511
|
+
if (wrap < worst) idx = 0;
|
|
512
|
+
return idx;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function computeBoundaryLength(indices, vp) {
|
|
516
|
+
if (!Array.isArray(indices) || indices.length < 2 || !vp) return 0;
|
|
517
|
+
let len = 0;
|
|
518
|
+
for (let i = 1; i < indices.length; i++) {
|
|
519
|
+
const a = indices[i - 1] >>> 0;
|
|
520
|
+
const b = indices[i] >>> 0;
|
|
521
|
+
const ax = vp[a * 3 + 0], ay = vp[a * 3 + 1], az = vp[a * 3 + 2];
|
|
522
|
+
const bx = vp[b * 3 + 0], by = vp[b * 3 + 1], bz = vp[b * 3 + 2];
|
|
523
|
+
const dx = ax - bx, dy = ay - by, dz = az - bz;
|
|
524
|
+
len += Math.hypot(dx, dy, dz);
|
|
525
|
+
}
|
|
526
|
+
return len;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function resolveFilletRoundFaceName(resultSolid, entry) {
|
|
530
|
+
if (!resultSolid || !entry) return null;
|
|
531
|
+
const faceNames = (typeof resultSolid.getFaceNames === 'function')
|
|
532
|
+
? resultSolid.getFaceNames()
|
|
533
|
+
: [];
|
|
534
|
+
if (entry.roundFaceName && faceNames.includes(entry.roundFaceName)) return entry.roundFaceName;
|
|
535
|
+
if (entry.filletName) {
|
|
536
|
+
const expected = `${entry.filletName}_TUBE_Outer`;
|
|
537
|
+
if (faceNames.includes(expected)) return expected;
|
|
538
|
+
}
|
|
539
|
+
let best = null;
|
|
540
|
+
for (const name of faceNames) {
|
|
541
|
+
const meta = (typeof resultSolid.getFaceMetadata === 'function')
|
|
542
|
+
? resultSolid.getFaceMetadata(name)
|
|
543
|
+
: null;
|
|
544
|
+
if (!meta || meta.source !== 'FilletFeature') continue;
|
|
545
|
+
if (entry.filletName && meta.featureID !== entry.filletName) continue;
|
|
546
|
+
if (meta.type === 'pipe' || (typeof name === 'string' && name.includes('TUBE_Outer'))) {
|
|
547
|
+
best = name;
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
if (!best) best = name;
|
|
551
|
+
}
|
|
552
|
+
return best;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isFilletGeneratedFace(resultSolid, faceName) {
|
|
556
|
+
if (!resultSolid || !faceName || typeof faceName !== 'string') return false;
|
|
557
|
+
if (typeof resultSolid.getFaceMetadata === 'function') {
|
|
558
|
+
const meta = resultSolid.getFaceMetadata(faceName);
|
|
559
|
+
if (meta && (meta.source === 'FilletFeature' || meta.filletRoundFace || meta.filletSourceArea || meta.filletEndCap)) {
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (faceName.includes('_END_CAP_') || faceName.includes('_CapStart') || faceName.includes('_CapEnd')) return true;
|
|
564
|
+
if (faceName.includes('_WEDGE_') || faceName.includes('_SURFACE_') || faceName.includes('_SIDE_')) return true;
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function collectFaceVertexIndices(resultSolid, faceNames) {
|
|
569
|
+
const out = new Set();
|
|
570
|
+
if (!resultSolid || typeof resultSolid.getFace !== 'function') return out;
|
|
571
|
+
if (!Array.isArray(faceNames) || faceNames.length === 0) return out;
|
|
572
|
+
for (const name of faceNames) {
|
|
573
|
+
if (!name) continue;
|
|
574
|
+
const tris = resultSolid.getFace(name);
|
|
575
|
+
if (!Array.isArray(tris) || tris.length === 0) continue;
|
|
576
|
+
for (const tri of tris) {
|
|
577
|
+
const idx = tri?.indices;
|
|
578
|
+
if (Array.isArray(idx) && idx.length >= 3) {
|
|
579
|
+
out.add(idx[0] >>> 0);
|
|
580
|
+
out.add(idx[1] >>> 0);
|
|
581
|
+
out.add(idx[2] >>> 0);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function collectFilletEndcapIndices(resultSolid) {
|
|
589
|
+
if (!resultSolid || typeof resultSolid.getFaceNames !== 'function') return new Set();
|
|
590
|
+
const faceNames = resultSolid.getFaceNames();
|
|
591
|
+
const endcaps = [];
|
|
592
|
+
for (const name of faceNames) {
|
|
593
|
+
if (!name || typeof name !== 'string') continue;
|
|
594
|
+
let isEndcap = false;
|
|
595
|
+
if (name.includes('_END_CAP_') || name.includes('_CapStart') || name.includes('_CapEnd')) {
|
|
596
|
+
isEndcap = true;
|
|
597
|
+
} else if (typeof resultSolid.getFaceMetadata === 'function') {
|
|
598
|
+
const meta = resultSolid.getFaceMetadata(name);
|
|
599
|
+
if (meta && meta.filletEndCap) isEndcap = true;
|
|
600
|
+
}
|
|
601
|
+
if (isEndcap) endcaps.push(name);
|
|
602
|
+
}
|
|
603
|
+
return collectFaceVertexIndices(resultSolid, endcaps);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function dist2PointTriangle(p, a, b, c) {
|
|
607
|
+
const abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2];
|
|
608
|
+
const acx = c[0] - a[0], acy = c[1] - a[1], acz = c[2] - a[2];
|
|
609
|
+
const apx = p[0] - a[0], apy = p[1] - a[1], apz = p[2] - a[2];
|
|
610
|
+
const d1 = abx * apx + aby * apy + abz * apz;
|
|
611
|
+
const d2 = acx * apx + acy * apy + acz * apz;
|
|
612
|
+
if (d1 <= 0 && d2 <= 0) return apx * apx + apy * apy + apz * apz;
|
|
613
|
+
|
|
614
|
+
const bpx = p[0] - b[0], bpy = p[1] - b[1], bpz = p[2] - b[2];
|
|
615
|
+
const d3 = abx * bpx + aby * bpy + abz * bpz;
|
|
616
|
+
const d4 = acx * bpx + acy * bpy + acz * bpz;
|
|
617
|
+
if (d3 >= 0 && d4 <= d3) return bpx * bpx + bpy * bpy + bpz * bpz;
|
|
618
|
+
|
|
619
|
+
const vc = d1 * d4 - d3 * d2;
|
|
620
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
|
|
621
|
+
const v = d1 / (d1 - d3);
|
|
622
|
+
const qx = a[0] + v * abx;
|
|
623
|
+
const qy = a[1] + v * aby;
|
|
624
|
+
const qz = a[2] + v * abz;
|
|
625
|
+
const dx = p[0] - qx, dy = p[1] - qy, dz = p[2] - qz;
|
|
626
|
+
return dx * dx + dy * dy + dz * dz;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const cpx = p[0] - c[0], cpy = p[1] - c[1], cpz = p[2] - c[2];
|
|
630
|
+
const d5 = abx * cpx + aby * cpy + abz * cpz;
|
|
631
|
+
const d6 = acx * cpx + acy * cpy + acz * cpz;
|
|
632
|
+
if (d6 >= 0 && d5 <= d6) return cpx * cpx + cpy * cpy + cpz * cpz;
|
|
633
|
+
|
|
634
|
+
const vb = d5 * d2 - d1 * d6;
|
|
635
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
|
|
636
|
+
const w = d2 / (d2 - d6);
|
|
637
|
+
const qx = a[0] + w * acx;
|
|
638
|
+
const qy = a[1] + w * acy;
|
|
639
|
+
const qz = a[2] + w * acz;
|
|
640
|
+
const dx = p[0] - qx, dy = p[1] - qy, dz = p[2] - qz;
|
|
641
|
+
return dx * dx + dy * dy + dz * dz;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const va = d3 * d6 - d5 * d4;
|
|
645
|
+
if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) {
|
|
646
|
+
const w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
|
|
647
|
+
const qx = b[0] + w * (c[0] - b[0]);
|
|
648
|
+
const qy = b[1] + w * (c[1] - b[1]);
|
|
649
|
+
const qz = b[2] + w * (c[2] - b[2]);
|
|
650
|
+
const dx = p[0] - qx, dy = p[1] - qy, dz = p[2] - qz;
|
|
651
|
+
return dx * dx + dy * dy + dz * dz;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const denom = 1 / (va + vb + vc);
|
|
655
|
+
const v = vb * denom;
|
|
656
|
+
const w = vc * denom;
|
|
657
|
+
const qx = a[0] + abx * v + acx * w;
|
|
658
|
+
const qy = a[1] + aby * v + acy * w;
|
|
659
|
+
const qz = a[2] + abz * v + acz * w;
|
|
660
|
+
const dx = p[0] - qx, dy = p[1] - qy, dz = p[2] - qz;
|
|
661
|
+
return dx * dx + dy * dy + dz * dz;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function averageDistanceToTris(boundary, tris, vp, maxSamples = 12) {
|
|
665
|
+
const indicesRaw = Array.isArray(boundary?.indices) ? boundary.indices : [];
|
|
666
|
+
if (!indicesRaw.length || !Array.isArray(tris) || tris.length === 0 || !vp) return Infinity;
|
|
667
|
+
const closed = !!boundary?.closedLoop || (indicesRaw[0] === indicesRaw[indicesRaw.length - 1]);
|
|
668
|
+
const indices = closed ? indicesRaw.slice(0, -1) : indicesRaw.slice();
|
|
669
|
+
if (indices.length === 0) return Infinity;
|
|
670
|
+
const stride = Math.max(1, Math.floor(indices.length / Math.max(1, maxSamples)));
|
|
671
|
+
let sum = 0;
|
|
672
|
+
let count = 0;
|
|
673
|
+
for (let i = 0; i < indices.length; i += stride) {
|
|
674
|
+
const idx = indices[i] >>> 0;
|
|
675
|
+
const base = idx * 3;
|
|
676
|
+
const p = [vp[base + 0], vp[base + 1], vp[base + 2]];
|
|
677
|
+
let best = Infinity;
|
|
678
|
+
for (let t = 0; t < tris.length; t++) {
|
|
679
|
+
const tri = tris[t];
|
|
680
|
+
const a = tri?.p1;
|
|
681
|
+
const b = tri?.p2;
|
|
682
|
+
const c = tri?.p3;
|
|
683
|
+
if (!Array.isArray(a) || !Array.isArray(b) || !Array.isArray(c)) continue;
|
|
684
|
+
const d2 = dist2PointTriangle(p, a, b, c);
|
|
685
|
+
if (d2 < best) best = d2;
|
|
686
|
+
}
|
|
687
|
+
if (Number.isFinite(best)) {
|
|
688
|
+
sum += Math.sqrt(best);
|
|
689
|
+
count++;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (!count) return Infinity;
|
|
693
|
+
return sum / count;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function filterCandidatesByOtherFace(candidates, roundFace, otherFace) {
|
|
697
|
+
if (!otherFace) return [];
|
|
698
|
+
return candidates.filter(c => {
|
|
699
|
+
const a = c.boundary?.faceA;
|
|
700
|
+
const b = c.boundary?.faceB;
|
|
701
|
+
const other = (a === roundFace) ? b : (b === roundFace ? a : null);
|
|
702
|
+
return other === otherFace;
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function buildVertexFaceMap(resultSolid) {
|
|
707
|
+
const out = new Map();
|
|
708
|
+
if (!resultSolid || !Array.isArray(resultSolid._triVerts) || !Array.isArray(resultSolid._triIDs)) return out;
|
|
709
|
+
const tv = resultSolid._triVerts;
|
|
710
|
+
const ids = resultSolid._triIDs;
|
|
711
|
+
const idToName = resultSolid._idToFaceName instanceof Map ? resultSolid._idToFaceName : null;
|
|
712
|
+
const triCount = (tv.length / 3) | 0;
|
|
713
|
+
if (!idToName || triCount === 0) return out;
|
|
714
|
+
for (let t = 0; t < triCount; t++) {
|
|
715
|
+
const faceName = idToName.get(ids[t]);
|
|
716
|
+
if (!faceName) continue;
|
|
717
|
+
const base = t * 3;
|
|
718
|
+
for (let k = 0; k < 3; k++) {
|
|
719
|
+
const vi = tv[base + k] >>> 0;
|
|
720
|
+
let set = out.get(vi);
|
|
721
|
+
if (!set) { set = new Set(); out.set(vi, set); }
|
|
722
|
+
set.add(faceName);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function collectVerticesOutsideFacePair(vertexFaceMap, allowedFaces) {
|
|
729
|
+
const out = new Set();
|
|
730
|
+
if (!vertexFaceMap || !allowedFaces) return out;
|
|
731
|
+
for (const [vi, faces] of vertexFaceMap.entries()) {
|
|
732
|
+
let ok = true;
|
|
733
|
+
for (const f of faces) {
|
|
734
|
+
if (!allowedFaces.has(f)) { ok = false; break; }
|
|
735
|
+
}
|
|
736
|
+
if (!ok) out.add(vi);
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function pickBestBoundaryForTangent(candidates, sampler, vp, assigned, opts) {
|
|
742
|
+
if (!Array.isArray(candidates) || !sampler) return null;
|
|
743
|
+
const tol = opts?.tol || 0;
|
|
744
|
+
const maxSnapDist = Number.isFinite(opts?.maxSnapDist)
|
|
745
|
+
? opts.maxSnapDist
|
|
746
|
+
: Math.max(sampler.avgSegLen * 0.5, tol * 500);
|
|
747
|
+
let best = null;
|
|
748
|
+
for (const item of candidates) {
|
|
749
|
+
const boundary = item.boundary;
|
|
750
|
+
const id = item.id;
|
|
751
|
+
if (assigned && assigned.has(id)) continue;
|
|
752
|
+
const indices = Array.isArray(boundary?.indices) ? boundary.indices : [];
|
|
753
|
+
if (indices.length < 2) continue;
|
|
754
|
+
let sum = 0;
|
|
755
|
+
let count = 0;
|
|
756
|
+
for (let i = 0; i < indices.length; i++) {
|
|
757
|
+
const idx = indices[i] >>> 0;
|
|
758
|
+
const base = idx * 3;
|
|
759
|
+
const p = [vp[base + 0], vp[base + 1], vp[base + 2]];
|
|
760
|
+
const proj = sampler.project(p);
|
|
761
|
+
const d = Math.sqrt(proj.dist2);
|
|
762
|
+
if (!Number.isFinite(d)) continue;
|
|
763
|
+
sum += d;
|
|
764
|
+
count++;
|
|
765
|
+
}
|
|
766
|
+
if (!count) continue;
|
|
767
|
+
const avg = sum / count;
|
|
768
|
+
if (avg > maxSnapDist) continue;
|
|
769
|
+
const boundaryLen = computeBoundaryLength(indices, vp);
|
|
770
|
+
const lengthRatio = sampler.totalLen > 0 ? Math.abs(boundaryLen - sampler.totalLen) / sampler.totalLen : 0;
|
|
771
|
+
const cost = avg + lengthRatio * sampler.avgSegLen * 0.5;
|
|
772
|
+
if (!best || cost < best.cost) {
|
|
773
|
+
best = { boundary, id, avg, maxSnapDist, cost };
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return best;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function snapBoundaryToTangent(boundary, sampler, vp, opts = {}) {
|
|
780
|
+
const indicesRaw = Array.isArray(boundary?.indices) ? boundary.indices : [];
|
|
781
|
+
if (!indicesRaw.length || !sampler || !vp) return 0;
|
|
782
|
+
const closed = !!boundary?.closedLoop || (indicesRaw[0] === indicesRaw[indicesRaw.length - 1]);
|
|
783
|
+
const indices = closed ? indicesRaw.slice(0, -1) : indicesRaw.slice();
|
|
784
|
+
if (indices.length < 2) return 0;
|
|
785
|
+
|
|
786
|
+
const tol = Number.isFinite(opts.tol) ? opts.tol : 0;
|
|
787
|
+
const locked = opts.lockedIndices instanceof Set ? opts.lockedIndices : null;
|
|
788
|
+
|
|
789
|
+
const sVals = new Array(indices.length);
|
|
790
|
+
const snapped = new Array(indices.length);
|
|
791
|
+
let totalLen = 0;
|
|
792
|
+
for (let i = 1; i < indices.length; i++) {
|
|
793
|
+
const a = indices[i - 1] >>> 0;
|
|
794
|
+
const b = indices[i] >>> 0;
|
|
795
|
+
const ba = a * 3;
|
|
796
|
+
const bb = b * 3;
|
|
797
|
+
totalLen += Math.hypot(
|
|
798
|
+
vp[bb + 0] - vp[ba + 0],
|
|
799
|
+
vp[bb + 1] - vp[ba + 1],
|
|
800
|
+
vp[bb + 2] - vp[ba + 2],
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (!(totalLen > 1e-12)) return 0;
|
|
804
|
+
|
|
805
|
+
for (let i = 0; i < indices.length; i++) {
|
|
806
|
+
const idx = indices[i] >>> 0;
|
|
807
|
+
const base = idx * 3;
|
|
808
|
+
const p = [vp[base + 0], vp[base + 1], vp[base + 2]];
|
|
809
|
+
const proj = sampler.project(p);
|
|
810
|
+
let s = proj.s;
|
|
811
|
+
if (!Number.isFinite(s)) s = 0;
|
|
812
|
+
if (s < 0) s = 0;
|
|
813
|
+
if (s > sampler.totalLen) s = sampler.totalLen;
|
|
814
|
+
sVals[i] = s;
|
|
815
|
+
snapped[i] = proj.point ? proj.point : sampler.pointAt(s);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const minMove2 = Number.isFinite(opts.minMove) ? opts.minMove * opts.minMove : 0;
|
|
819
|
+
const maxMoveRatio = Number.isFinite(opts.maxMoveRatio) ? opts.maxMoveRatio : 0.1;
|
|
820
|
+
const maxMove = (Number.isFinite(maxMoveRatio) && maxMoveRatio > 0)
|
|
821
|
+
? maxMoveRatio * totalLen
|
|
822
|
+
: Infinity;
|
|
823
|
+
|
|
824
|
+
let moved = 0;
|
|
825
|
+
for (let i = 0; i < indices.length; i++) {
|
|
826
|
+
const idx = indices[i] >>> 0;
|
|
827
|
+
if (locked && locked.has(idx)) continue;
|
|
828
|
+
const base = idx * 3;
|
|
829
|
+
const pos = snapped[i];
|
|
830
|
+
if (!pos) continue;
|
|
831
|
+
const nx = pos[0], ny = pos[1], nz = pos[2];
|
|
832
|
+
const dx = nx - vp[base + 0];
|
|
833
|
+
const dy = ny - vp[base + 1];
|
|
834
|
+
const dz = nz - vp[base + 2];
|
|
835
|
+
let adjX = nx, adjY = ny, adjZ = nz;
|
|
836
|
+
if (Number.isFinite(maxMove) && maxMove >= 0) {
|
|
837
|
+
const dLen = Math.hypot(dx, dy, dz);
|
|
838
|
+
if (dLen > maxMove && dLen > 1e-12) {
|
|
839
|
+
const scale = maxMove / dLen;
|
|
840
|
+
adjX = vp[base + 0] + dx * scale;
|
|
841
|
+
adjY = vp[base + 1] + dy * scale;
|
|
842
|
+
adjZ = vp[base + 2] + dz * scale;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const ddx = adjX - vp[base + 0];
|
|
846
|
+
const ddy = adjY - vp[base + 1];
|
|
847
|
+
const ddz = adjZ - vp[base + 2];
|
|
848
|
+
if (ddx * ddx + ddy * ddy + ddz * ddz > minMove2) moved++;
|
|
849
|
+
vp[base + 0] = adjX;
|
|
850
|
+
vp[base + 1] = adjY;
|
|
851
|
+
vp[base + 2] = adjZ;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Fix reversed segments by swapping coordinates in decreasing-s runs.
|
|
855
|
+
const eps = Math.max(1e-8, tol * 10);
|
|
856
|
+
const isLockedAt = (i) => {
|
|
857
|
+
const idx = indices[i] >>> 0;
|
|
858
|
+
return !!(locked && locked.has(idx));
|
|
859
|
+
};
|
|
860
|
+
let i = 1;
|
|
861
|
+
while (i < sVals.length) {
|
|
862
|
+
if (sVals[i] < sVals[i - 1] - eps) {
|
|
863
|
+
let start = i - 1;
|
|
864
|
+
let end = i;
|
|
865
|
+
while (end + 1 < sVals.length && sVals[end + 1] < sVals[end] - eps) {
|
|
866
|
+
end++;
|
|
867
|
+
}
|
|
868
|
+
let runStart = start;
|
|
869
|
+
for (let k = start; k <= end; k++) {
|
|
870
|
+
if (isLockedAt(k)) {
|
|
871
|
+
if (runStart < k - 1) {
|
|
872
|
+
for (let a = runStart, b = k - 1; a < b; a++, b--) {
|
|
873
|
+
const ia = indices[a] >>> 0;
|
|
874
|
+
const ib = indices[b] >>> 0;
|
|
875
|
+
const ba = ia * 3;
|
|
876
|
+
const bb = ib * 3;
|
|
877
|
+
const tx = vp[ba + 0], ty = vp[ba + 1], tz = vp[ba + 2];
|
|
878
|
+
vp[ba + 0] = vp[bb + 0]; vp[ba + 1] = vp[bb + 1]; vp[ba + 2] = vp[bb + 2];
|
|
879
|
+
vp[bb + 0] = tx; vp[bb + 1] = ty; vp[bb + 2] = tz;
|
|
880
|
+
const ts = sVals[a]; sVals[a] = sVals[b]; sVals[b] = ts;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
runStart = k + 1;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (runStart < end) {
|
|
887
|
+
for (let a = runStart, b = end; a < b; a++, b--) {
|
|
888
|
+
const ia = indices[a] >>> 0;
|
|
889
|
+
const ib = indices[b] >>> 0;
|
|
890
|
+
const ba = ia * 3;
|
|
891
|
+
const bb = ib * 3;
|
|
892
|
+
const tx = vp[ba + 0], ty = vp[ba + 1], tz = vp[ba + 2];
|
|
893
|
+
vp[ba + 0] = vp[bb + 0]; vp[ba + 1] = vp[bb + 1]; vp[ba + 2] = vp[bb + 2];
|
|
894
|
+
vp[bb + 0] = tx; vp[bb + 1] = ty; vp[bb + 2] = tz;
|
|
895
|
+
const ts = sVals[a]; sVals[a] = sVals[b]; sVals[b] = ts;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
i = end + 1;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
i++;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return moved;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function snapFilletEdgesToTangents(resultSolid, filletEntries, opts = {}) {
|
|
908
|
+
const vp = Array.isArray(resultSolid?._vertProperties) ? resultSolid._vertProperties : null;
|
|
909
|
+
if (!vp || !Array.isArray(filletEntries) || filletEntries.length === 0) {
|
|
910
|
+
return { movedVertices: 0, snappedEdges: 0, skipped: 0 };
|
|
911
|
+
}
|
|
912
|
+
if (typeof resultSolid.getBoundaryEdgePolylines !== 'function') {
|
|
913
|
+
return { movedVertices: 0, snappedEdges: 0, skipped: filletEntries.length };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const boundaries = resultSolid.getBoundaryEdgePolylines() || [];
|
|
917
|
+
if (!boundaries.length) {
|
|
918
|
+
return { movedVertices: 0, snappedEdges: 0, skipped: filletEntries.length };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const tol = Number.isFinite(opts.tol) ? opts.tol : deriveSolidToleranceFromVerts(resultSolid, 1e-5);
|
|
922
|
+
const candidatesAll = boundaries.map((boundary, id) => ({ boundary, id }));
|
|
923
|
+
let movedVertices = 0;
|
|
924
|
+
let snappedEdges = 0;
|
|
925
|
+
let skipped = 0;
|
|
926
|
+
|
|
927
|
+
const endcapLocked = collectFilletEndcapIndices(resultSolid);
|
|
928
|
+
const vertexFaceMap = buildVertexFaceMap(resultSolid);
|
|
929
|
+
|
|
930
|
+
for (const entry of filletEntries) {
|
|
931
|
+
const roundFace = resolveFilletRoundFaceName(resultSolid, entry);
|
|
932
|
+
if (!roundFace) { skipped++; continue; }
|
|
933
|
+
const candidates = candidatesAll.filter(c => {
|
|
934
|
+
const a = c.boundary?.faceA;
|
|
935
|
+
const b = c.boundary?.faceB;
|
|
936
|
+
if (a !== roundFace && b !== roundFace) return false;
|
|
937
|
+
const other = (a === roundFace) ? b : a;
|
|
938
|
+
if (!other) return false;
|
|
939
|
+
if (isFilletGeneratedFace(resultSolid, other)) return false;
|
|
940
|
+
return true;
|
|
941
|
+
});
|
|
942
|
+
if (!candidates.length) { skipped++; continue; }
|
|
943
|
+
|
|
944
|
+
const tangents = [entry?.tangentASeam, entry?.tangentBSeam];
|
|
945
|
+
const targetFaces = [entry?.edgeFaceAName || null, entry?.edgeFaceBName || null];
|
|
946
|
+
const sourceSolid = entry?.edgeObj?.parentSolid || entry?.edgeObj?.parent || resultSolid;
|
|
947
|
+
const trisA = (targetFaces[0] && sourceSolid && typeof sourceSolid.getFace === 'function')
|
|
948
|
+
? sourceSolid.getFace(targetFaces[0])
|
|
949
|
+
: null;
|
|
950
|
+
const trisB = (targetFaces[1] && sourceSolid && typeof sourceSolid.getFace === 'function')
|
|
951
|
+
? sourceSolid.getFace(targetFaces[1])
|
|
952
|
+
: null;
|
|
953
|
+
const assigned = new Set();
|
|
954
|
+
const picks = [];
|
|
955
|
+
for (let t = 0; t < tangents.length; t++) {
|
|
956
|
+
const points = tangents[t];
|
|
957
|
+
if (!Array.isArray(points) || points.length < 2) { picks.push(null); continue; }
|
|
958
|
+
const sampler = buildPolylineSampler(points, tol);
|
|
959
|
+
if (!sampler) { picks.push(null); continue; }
|
|
960
|
+
|
|
961
|
+
let candidatesForT = candidates;
|
|
962
|
+
const targetFace = targetFaces[t];
|
|
963
|
+
const filteredByFace = filterCandidatesByOtherFace(candidates, roundFace, targetFace);
|
|
964
|
+
if (filteredByFace.length) {
|
|
965
|
+
candidatesForT = filteredByFace;
|
|
966
|
+
} else {
|
|
967
|
+
const tris = (t === 0 ? trisA : trisB);
|
|
968
|
+
if (Array.isArray(tris) && tris.length) {
|
|
969
|
+
const scored = candidates
|
|
970
|
+
.map(c => ({ c, score: averageDistanceToTris(c.boundary, tris, vp, 12) }))
|
|
971
|
+
.filter(item => Number.isFinite(item.score))
|
|
972
|
+
.sort((a, b) => a.score - b.score);
|
|
973
|
+
const first = scored.find(item => !assigned.has(item.c.id));
|
|
974
|
+
if (first) {
|
|
975
|
+
const pick = first.c;
|
|
976
|
+
picks.push({ pick, sampler });
|
|
977
|
+
assigned.add(pick.id);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const pick = pickBestBoundaryForTangent(candidatesForT, sampler, vp, assigned, { tol });
|
|
984
|
+
if (!pick) { picks.push(null); continue; }
|
|
985
|
+
picks.push({ pick, sampler });
|
|
986
|
+
assigned.add(pick.id);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const locked = new Set(endcapLocked);
|
|
990
|
+
if (picks[0]?.pick && picks[1]?.pick) {
|
|
991
|
+
const idxA = new Set(Array.isArray(picks[0].pick.boundary?.indices) ? picks[0].pick.boundary.indices : []);
|
|
992
|
+
const idxB = Array.isArray(picks[1].pick.boundary?.indices) ? picks[1].pick.boundary.indices : [];
|
|
993
|
+
for (const idx of idxB) {
|
|
994
|
+
const v = idx >>> 0;
|
|
995
|
+
if (idxA.has(v)) locked.add(v);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
for (const item of picks) {
|
|
1000
|
+
if (!item) continue;
|
|
1001
|
+
const otherFace = (() => {
|
|
1002
|
+
const a = item.pick.boundary?.faceA;
|
|
1003
|
+
const b = item.pick.boundary?.faceB;
|
|
1004
|
+
return (a === roundFace) ? b : (b === roundFace ? a : null);
|
|
1005
|
+
})();
|
|
1006
|
+
if (otherFace) {
|
|
1007
|
+
const allowed = new Set([roundFace, otherFace]);
|
|
1008
|
+
const outside = collectVerticesOutsideFacePair(vertexFaceMap, allowed);
|
|
1009
|
+
for (const v of outside) locked.add(v);
|
|
1010
|
+
}
|
|
1011
|
+
const moved = snapBoundaryToTangent(item.pick.boundary, item.sampler, vp, { tol, minMove: tol * 0.1, maxMoveRatio: 0.1, lockedIndices: locked });
|
|
1012
|
+
if (moved > 0) {
|
|
1013
|
+
movedVertices += moved;
|
|
1014
|
+
snappedEdges += 1;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (movedVertices > 0) {
|
|
1020
|
+
resultSolid._vertKeyToIndex = new Map();
|
|
1021
|
+
for (let i = 0; i < vp.length; i += 3) {
|
|
1022
|
+
const x = vp[i], y = vp[i + 1], z = vp[i + 2];
|
|
1023
|
+
resultSolid._vertKeyToIndex.set(`${x},${y},${z}`, (i / 3) | 0);
|
|
1024
|
+
}
|
|
1025
|
+
resultSolid._dirty = true;
|
|
1026
|
+
resultSolid._faceIndex = null;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return { movedVertices, snappedEdges, skipped };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function buildAdjacencyFromBoundaryPolylines(solid) {
|
|
1033
|
+
const map = new Map();
|
|
1034
|
+
if (!solid || typeof solid.getBoundaryEdgePolylines !== 'function') return map;
|
|
1035
|
+
const boundaries = solid.getBoundaryEdgePolylines() || [];
|
|
1036
|
+
for (const poly of boundaries) {
|
|
1037
|
+
const a = poly?.faceA;
|
|
1038
|
+
const b = poly?.faceB;
|
|
1039
|
+
if (!a || !b) continue;
|
|
1040
|
+
if (!map.has(a)) map.set(a, new Set());
|
|
1041
|
+
if (!map.has(b)) map.set(b, new Set());
|
|
1042
|
+
map.get(a).add(b);
|
|
1043
|
+
map.get(b).add(a);
|
|
1044
|
+
}
|
|
1045
|
+
return map;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function buildAdjacencyFromFaceEdges(solid, faceNames, tol) {
|
|
1049
|
+
const { q, k } = createQuantizer(tol);
|
|
1050
|
+
const edgeKey = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`);
|
|
1051
|
+
const edgeToFaces = new Map();
|
|
1052
|
+
const faceToEdges = new Map();
|
|
1053
|
+
for (const faceName of faceNames) {
|
|
1054
|
+
const tris = solid.getFace(faceName);
|
|
1055
|
+
if (!Array.isArray(tris) || tris.length === 0) continue;
|
|
1056
|
+
const counts = new Map();
|
|
1057
|
+
for (const tri of tris) {
|
|
1058
|
+
const p1 = tri?.p1;
|
|
1059
|
+
const p2 = tri?.p2;
|
|
1060
|
+
const p3 = tri?.p3;
|
|
1061
|
+
if (!Array.isArray(p1) || !Array.isArray(p2) || !Array.isArray(p3)) continue;
|
|
1062
|
+
const v1 = k(q(p1));
|
|
1063
|
+
const v2 = k(q(p2));
|
|
1064
|
+
const v3 = k(q(p3));
|
|
1065
|
+
const e12 = edgeKey(v1, v2);
|
|
1066
|
+
const e23 = edgeKey(v2, v3);
|
|
1067
|
+
const e31 = edgeKey(v3, v1);
|
|
1068
|
+
counts.set(e12, (counts.get(e12) || 0) + 1);
|
|
1069
|
+
counts.set(e23, (counts.get(e23) || 0) + 1);
|
|
1070
|
+
counts.set(e31, (counts.get(e31) || 0) + 1);
|
|
1071
|
+
}
|
|
1072
|
+
const boundary = new Set();
|
|
1073
|
+
for (const [key, count] of counts.entries()) {
|
|
1074
|
+
if (count === 1) boundary.add(key);
|
|
1075
|
+
}
|
|
1076
|
+
faceToEdges.set(faceName, boundary);
|
|
1077
|
+
for (const key of boundary) {
|
|
1078
|
+
let set = edgeToFaces.get(key);
|
|
1079
|
+
if (!set) { set = new Set(); edgeToFaces.set(key, set); }
|
|
1080
|
+
set.add(faceName);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const adj = new Map();
|
|
1084
|
+
for (const [faceName, edges] of faceToEdges.entries()) {
|
|
1085
|
+
const set = new Set();
|
|
1086
|
+
for (const key of edges) {
|
|
1087
|
+
const faces = edgeToFaces.get(key);
|
|
1088
|
+
if (!faces) continue;
|
|
1089
|
+
for (const f of faces) {
|
|
1090
|
+
if (f !== faceName) set.add(f);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
adj.set(faceName, set);
|
|
1094
|
+
}
|
|
1095
|
+
return adj;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function mergeInsetEndCapsByNormal(resultSolid, featureID, direction, dotThreshold = 0.999) {
|
|
1099
|
+
if (!resultSolid || String(direction).toUpperCase() !== 'INSET') return;
|
|
1100
|
+
if (typeof resultSolid.getFaceNames !== 'function') return;
|
|
1101
|
+
const faceNames = resultSolid.getFaceNames() || [];
|
|
1102
|
+
if (!Array.isArray(faceNames) || faceNames.length === 0) return;
|
|
1103
|
+
const faceHasTris = (name) => {
|
|
1104
|
+
if (!name || typeof resultSolid.getFace !== 'function') return false;
|
|
1105
|
+
const tris = resultSolid.getFace(name);
|
|
1106
|
+
return Array.isArray(tris) && tris.length > 0;
|
|
1107
|
+
};
|
|
1108
|
+
const activeFaceNames = faceNames.filter(faceHasTris);
|
|
1109
|
+
const prefix = featureID ? `${featureID}_FILLET_` : '';
|
|
1110
|
+
const endCapFaces = activeFaceNames.filter((name) => {
|
|
1111
|
+
if (typeof name !== 'string') return false;
|
|
1112
|
+
if (prefix && !name.startsWith(prefix)) return false;
|
|
1113
|
+
return /_END_CAP_\d+$/.test(name);
|
|
1114
|
+
});
|
|
1115
|
+
consoleLogReplacement('[Solid.fillet] Inset end cap scan', {
|
|
1116
|
+
featureID,
|
|
1117
|
+
direction,
|
|
1118
|
+
endCapFaces,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
if (!endCapFaces.length) return;
|
|
1122
|
+
|
|
1123
|
+
const adjacentMap = buildAdjacencyFromBoundaryPolylines(resultSolid);
|
|
1124
|
+
const tol = deriveSolidToleranceFromVerts(resultSolid, 1e-5);
|
|
1125
|
+
const edgeAdjMap = buildAdjacencyFromFaceEdges(resultSolid, activeFaceNames, tol);
|
|
1126
|
+
|
|
1127
|
+
const normalCache = new Map();
|
|
1128
|
+
const getNormal = (name) => {
|
|
1129
|
+
if (normalCache.has(name)) return normalCache.get(name);
|
|
1130
|
+
const n = averageFaceNormalSimple(resultSolid, name);
|
|
1131
|
+
normalCache.set(name, n);
|
|
1132
|
+
return n;
|
|
1133
|
+
};
|
|
1134
|
+
const fmtNormal = (n) => (Array.isArray(n) && n.length >= 3)
|
|
1135
|
+
? [Number(n[0].toFixed(6)), Number(n[1].toFixed(6)), Number(n[2].toFixed(6))]
|
|
1136
|
+
: null;
|
|
1137
|
+
|
|
1138
|
+
const tryMergeWithAdj = (capName, adj) => {
|
|
1139
|
+
if (!adj || adj.size === 0) return false;
|
|
1140
|
+
const nCap = getNormal(capName);
|
|
1141
|
+
if (!nCap) return false;
|
|
1142
|
+
for (const neighbor of adj) {
|
|
1143
|
+
if (neighbor === capName) continue;
|
|
1144
|
+
const nAdj = getNormal(neighbor);
|
|
1145
|
+
if (!nAdj) continue;
|
|
1146
|
+
const dot = (nCap[0] * nAdj[0]) + (nCap[1] * nAdj[1]) + (nCap[2] * nAdj[2]);
|
|
1147
|
+
if (dot >= dotThreshold) {
|
|
1148
|
+
mergeFaceIntoTarget(resultSolid, capName, neighbor);
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return false;
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
let mergedCount = 0;
|
|
1156
|
+
for (const capName of endCapFaces) {
|
|
1157
|
+
const adj = adjacentMap.get(capName);
|
|
1158
|
+
const adjEdge = edgeAdjMap.get(capName);
|
|
1159
|
+
const adjAll = new Set([
|
|
1160
|
+
...(adj ? Array.from(adj) : []),
|
|
1161
|
+
...(adjEdge ? Array.from(adjEdge) : []),
|
|
1162
|
+
]);
|
|
1163
|
+
consoleLogReplacement('[Solid.fillet] Inset end cap normals', {
|
|
1164
|
+
featureID,
|
|
1165
|
+
capName,
|
|
1166
|
+
capNormal: fmtNormal(getNormal(capName)),
|
|
1167
|
+
adjacent: Array.from(adjAll).map((name) => ({
|
|
1168
|
+
name,
|
|
1169
|
+
normal: fmtNormal(getNormal(name)),
|
|
1170
|
+
})),
|
|
1171
|
+
});
|
|
1172
|
+
if (tryMergeWithAdj(capName, adj)) {
|
|
1173
|
+
consoleLogReplacement('[Solid.fillet] Inset end cap merged', { featureID, capName });
|
|
1174
|
+
mergedCount++;
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (tryMergeWithAdj(capName, edgeAdjMap.get(capName))) {
|
|
1178
|
+
consoleLogReplacement('[Solid.fillet] Inset end cap merged', { featureID, capName });
|
|
1179
|
+
mergedCount++;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
consoleLogReplacement('[Solid.fillet] Inset end cap merge summary', { featureID, mergedCount });
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Apply fillets to this Solid and return a new Solid with the result.
|
|
1187
|
+
* Accepts either `edgeNames` (preferred) or explicit `edges` objects.
|
|
1188
|
+
*
|
|
1189
|
+
* @param {Object} opts
|
|
1190
|
+
* @param {number} opts.radius Required fillet radius (> 0)
|
|
1191
|
+
* @param {string[]} [opts.edgeNames] Optional edge names to fillet (resolved from this Solid's children)
|
|
1192
|
+
* @param {any[]} [opts.edges] Optional pre-resolved Edge objects (must belong to this Solid)
|
|
1193
|
+
* @param {'INSET'|'OUTSET'|string} [opts.direction='INSET'] Boolean behavior (subtract vs union)
|
|
1194
|
+
* @param {number} [opts.inflate=0.1] Inflation for cutting tube
|
|
1195
|
+
* @param {number} [opts.resolution=32] Tube resolution (segments around circumference)
|
|
1196
|
+
* @param {boolean} [opts.combineEdges=false] Combine connected edges that share face pairs into single paths
|
|
1197
|
+
* @param {boolean} [opts.debug=false] Enable debug visuals in fillet builder
|
|
1198
|
+
* @param {boolean} [opts.showTangentOverlays=false] Show pre-inflate tangent overlays on the fillet tube
|
|
1199
|
+
* @param {boolean} [opts.snapTangentOverlays=true] Snap resulting fillet edge vertices onto the tangent overlays
|
|
1200
|
+
* @param {string} [opts.featureID='FILLET'] For naming of intermediates and result
|
|
1201
|
+
* @param {number} [opts.cleanupTinyFaceIslandsArea=0.001] area threshold for face-island relabeling (<= 0 disables)
|
|
1202
|
+
* @returns {import('../BetterSolid.js').Solid}
|
|
1203
|
+
*/
|
|
1204
|
+
export async function fillet(opts = {}) {
|
|
1205
|
+
const { filletSolid } = await import("../fillets/fillet.js");
|
|
1206
|
+
const radius = Number(opts.radius);
|
|
1207
|
+
if (!Number.isFinite(radius) || radius <= 0) {
|
|
1208
|
+
throw new Error(`Solid.fillet: radius must be > 0, got ${opts.radius}`);
|
|
1209
|
+
}
|
|
1210
|
+
const dir = String(opts.direction || 'INSET').toUpperCase();
|
|
1211
|
+
const inflate = Number.isFinite(opts.inflate) ? Number(opts.inflate) : 0.1;
|
|
1212
|
+
const debug = !!opts.debug;
|
|
1213
|
+
const resolutionRaw = Number(opts.resolution);
|
|
1214
|
+
const resolution = (Number.isFinite(resolutionRaw) && resolutionRaw > 0)
|
|
1215
|
+
? Math.max(8, Math.floor(resolutionRaw))
|
|
1216
|
+
: 32;
|
|
1217
|
+
const combineEdges = (dir !== 'INSET') && !!opts.combineEdges;
|
|
1218
|
+
const showTangentOverlays = !!opts.showTangentOverlays;
|
|
1219
|
+
const snapTangentOverlays = opts.snapTangentOverlays !== false;
|
|
1220
|
+
const featureID = opts.featureID || 'FILLET';
|
|
1221
|
+
const cleanupTinyFaceIslandsAreaRaw = Number(opts.cleanupTinyFaceIslandsArea);
|
|
1222
|
+
const cleanupTinyFaceIslandsArea = Number.isFinite(cleanupTinyFaceIslandsAreaRaw)
|
|
1223
|
+
? cleanupTinyFaceIslandsAreaRaw
|
|
1224
|
+
: 0.001;
|
|
1225
|
+
const SolidCtor = this?.constructor;
|
|
1226
|
+
consoleLogReplacement('[Solid.fillet] Begin', {
|
|
1227
|
+
featureID,
|
|
1228
|
+
solid: this?.name,
|
|
1229
|
+
radius,
|
|
1230
|
+
direction: dir,
|
|
1231
|
+
inflate,
|
|
1232
|
+
resolution,
|
|
1233
|
+
debug,
|
|
1234
|
+
showTangentOverlays,
|
|
1235
|
+
snapTangentOverlays,
|
|
1236
|
+
combineEdges,
|
|
1237
|
+
cleanupTinyFaceIslandsArea,
|
|
1238
|
+
requestedEdgeNames: Array.isArray(opts.edgeNames) ? opts.edgeNames : [],
|
|
1239
|
+
providedEdgeCount: Array.isArray(opts.edges) ? opts.edges.length : 0,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// Resolve edges from names and/or provided objects
|
|
1243
|
+
const unique = resolveEdgesFromInputs(this, { edgeNames: opts.edgeNames, edges: opts.edges });
|
|
1244
|
+
if (unique.length === 0) {
|
|
1245
|
+
console.warn('[Solid.fillet] No edges resolved on target solid; returning clone.', { featureID, solid: this?.name });
|
|
1246
|
+
// Nothing to do - return an unchanged clone so caller can replace scene node safely
|
|
1247
|
+
const c = this.clone();
|
|
1248
|
+
try { c.name = this.name; } catch { }
|
|
1249
|
+
return c;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const combineCornerHulls = combineEdges && unique.length > 1;
|
|
1253
|
+
let filletEdges = unique;
|
|
1254
|
+
if (combineCornerHulls) {
|
|
1255
|
+
consoleLogReplacement('[Solid.fillet] combineEdges enabled: using corner hulls for shared endpoints.');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Build fillet solids per edge using existing core implementation
|
|
1259
|
+
const filletEntries = [];
|
|
1260
|
+
let idx = 0;
|
|
1261
|
+
const debugAdded = [];
|
|
1262
|
+
const attachDebugSolids = (target, reason = '') => {
|
|
1263
|
+
if (!target || debugAdded.length === 0) return;
|
|
1264
|
+
try { target.__debugAddedSolids = debugAdded; } catch { }
|
|
1265
|
+
const prefix = debug ? '🐛 Debug' : '⚠️ Failure Debug';
|
|
1266
|
+
const suffix = reason ? ` (${reason})` : '';
|
|
1267
|
+
consoleLogReplacement(`${prefix}: Added ${debugAdded.length} debug solids to result${suffix}`);
|
|
1268
|
+
};
|
|
1269
|
+
for (const e of filletEdges) {
|
|
1270
|
+
const name = `${featureID}_FILLET_${idx++}`;
|
|
1271
|
+
const res = filletSolid({ edgeToFillet: e, radius, sideMode: dir, inflate, resolution, debug, name, showTangentOverlays }) || {};
|
|
1272
|
+
|
|
1273
|
+
// Handle debug solids even on failure
|
|
1274
|
+
if (debug || !res.finalSolid) {
|
|
1275
|
+
try { if (res.tube) debugAdded.push(res.tube); } catch { }
|
|
1276
|
+
try { if (res.wedge) debugAdded.push(res.wedge); } catch { }
|
|
1277
|
+
|
|
1278
|
+
// If there was an error, log it and add debug info
|
|
1279
|
+
if (res.error) {
|
|
1280
|
+
console.warn(`Fillet failed for edge ${e?.name || idx}: ${res.error}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
if (!res.finalSolid) {
|
|
1284
|
+
console.warn('[Solid.fillet] Fillet builder returned no finalSolid.', {
|
|
1285
|
+
featureID,
|
|
1286
|
+
edge: e?.name,
|
|
1287
|
+
error: res.error,
|
|
1288
|
+
hasTube: !!res.tube,
|
|
1289
|
+
hasWedge: !!res.wedge,
|
|
1290
|
+
});
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const mergeCandidates = getFilletMergeCandidateNames(res.finalSolid);
|
|
1295
|
+
const roundFaceName = guessRoundFaceName(res.finalSolid, name);
|
|
1296
|
+
filletEntries.push({
|
|
1297
|
+
filletSolid: res.finalSolid,
|
|
1298
|
+
filletName: name,
|
|
1299
|
+
mergeCandidates,
|
|
1300
|
+
roundFaceName,
|
|
1301
|
+
wedgeSolid: res.wedge || null,
|
|
1302
|
+
tubeSolid: res.tube || null,
|
|
1303
|
+
edgeObj: e,
|
|
1304
|
+
edgePoints: Array.isArray(res.edge) ? res.edge : [],
|
|
1305
|
+
edgeFaceAName: e?.faces?.[0]?.name || e?.userData?.faceA || null,
|
|
1306
|
+
edgeFaceBName: e?.faces?.[1]?.name || e?.userData?.faceB || null,
|
|
1307
|
+
tangentASeam: (Array.isArray(res.tangentASeam) && res.tangentASeam.length)
|
|
1308
|
+
? res.tangentASeam
|
|
1309
|
+
: (Array.isArray(res.tangentA) ? res.tangentA : []),
|
|
1310
|
+
tangentBSeam: (Array.isArray(res.tangentBSeam) && res.tangentBSeam.length)
|
|
1311
|
+
? res.tangentBSeam
|
|
1312
|
+
: (Array.isArray(res.tangentB) ? res.tangentB : []),
|
|
1313
|
+
});
|
|
1314
|
+
if (debug) {
|
|
1315
|
+
try { if (res.tube) debugAdded.push(res.tube); } catch { }
|
|
1316
|
+
try { if (res.wedge) debugAdded.push(res.wedge); } catch { }
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (filletEntries.length === 0) {
|
|
1320
|
+
console.error('[Solid.fillet] All edge fillets failed; returning clone.', { featureID, edgeCount: unique.length });
|
|
1321
|
+
const c = this.clone();
|
|
1322
|
+
try { c.name = this.name; } catch { }
|
|
1323
|
+
attachDebugSolids(c, 'all fillets failed');
|
|
1324
|
+
return c;
|
|
1325
|
+
}
|
|
1326
|
+
consoleLogReplacement('[Solid.fillet] Built fillet solids for edges', filletEntries.length);
|
|
1327
|
+
|
|
1328
|
+
const cornerWedgeHulls = [];
|
|
1329
|
+
const cornerTubeHulls = [];
|
|
1330
|
+
let combinedFilletSolid = null;
|
|
1331
|
+
if (combineCornerHulls && SolidCtor && filletEntries.length > 1) {
|
|
1332
|
+
try {
|
|
1333
|
+
const polylines = [];
|
|
1334
|
+
for (const entry of filletEntries) {
|
|
1335
|
+
const poly = getEdgePolylineLocal(entry.edgeObj);
|
|
1336
|
+
if (poly.length >= 2) polylines.push(poly);
|
|
1337
|
+
}
|
|
1338
|
+
const cornerTol = deriveTolerance(polylines, 1e-5);
|
|
1339
|
+
const { q, k } = createQuantizer(cornerTol);
|
|
1340
|
+
const groups = new Map();
|
|
1341
|
+
|
|
1342
|
+
const addEndpoint = (pt, entry, cap) => {
|
|
1343
|
+
if (!Array.isArray(pt) || pt.length < 3) return;
|
|
1344
|
+
const qp = q(pt);
|
|
1345
|
+
const key = k(qp);
|
|
1346
|
+
if (!groups.has(key)) groups.set(key, { point: qp, items: [] });
|
|
1347
|
+
groups.get(key).items.push({ entry, cap });
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
for (const entry of filletEntries) {
|
|
1351
|
+
let poly = getEdgePolylineLocal(entry.edgeObj);
|
|
1352
|
+
if (poly.length < 2 && Array.isArray(entry.edgePoints) && entry.edgePoints.length >= 2) {
|
|
1353
|
+
poly = entry.edgePoints.map(p => [Number(p.x) || 0, Number(p.y) || 0, Number(p.z) || 0]);
|
|
1354
|
+
}
|
|
1355
|
+
if (poly.length < 2) continue;
|
|
1356
|
+
addEndpoint(poly[0], entry, 'start');
|
|
1357
|
+
addEndpoint(poly[poly.length - 1], entry, 'end');
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
let cornerIdx = 0;
|
|
1361
|
+
for (const group of groups.values()) {
|
|
1362
|
+
if (!group || !Array.isArray(group.items) || group.items.length < 2) continue;
|
|
1363
|
+
const wedgePoints = [];
|
|
1364
|
+
const tubePoints = [];
|
|
1365
|
+
for (const item of group.items) {
|
|
1366
|
+
const entry = item.entry;
|
|
1367
|
+
if (!entry) continue;
|
|
1368
|
+
const filletName = entry.filletName;
|
|
1369
|
+
const wedge = entry.wedgeSolid;
|
|
1370
|
+
const tube = entry.tubeSolid;
|
|
1371
|
+
const capSuffix = (item.cap === 'start') ? '_END_CAP_1' : '_END_CAP_2';
|
|
1372
|
+
const tubeSuffix = (item.cap === 'start') ? '_TUBE_CapStart' : '_TUBE_CapEnd';
|
|
1373
|
+
if (wedge) collectFacePoints(wedge, `${filletName}${capSuffix}`, wedgePoints);
|
|
1374
|
+
if (tube) collectFacePoints(tube, `${filletName}${tubeSuffix}`, tubePoints);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const wedgeHull = buildHullSolidFromPoints(wedgePoints, `${featureID}_CORNER_${cornerIdx}_WEDGE_HULL`, SolidCtor, cornerTol);
|
|
1378
|
+
const tubeHull = buildHullSolidFromPoints(tubePoints, `${featureID}_CORNER_${cornerIdx}_TUBE_HULL`, SolidCtor, cornerTol);
|
|
1379
|
+
if (!wedgeHull || !tubeHull) {
|
|
1380
|
+
cornerIdx++;
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
cornerWedgeHulls.push(wedgeHull);
|
|
1384
|
+
cornerTubeHulls.push(tubeHull);
|
|
1385
|
+
if (debug) {
|
|
1386
|
+
debugAdded.push(wedgeHull);
|
|
1387
|
+
debugAdded.push(tubeHull);
|
|
1388
|
+
}
|
|
1389
|
+
cornerIdx++;
|
|
1390
|
+
}
|
|
1391
|
+
const wedgeParts = [];
|
|
1392
|
+
const tubeParts = [];
|
|
1393
|
+
for (const entry of filletEntries) {
|
|
1394
|
+
if (entry.wedgeSolid) wedgeParts.push(entry.wedgeSolid);
|
|
1395
|
+
if (entry.tubeSolid) tubeParts.push(entry.tubeSolid);
|
|
1396
|
+
}
|
|
1397
|
+
if (cornerWedgeHulls.length) wedgeParts.push(...cornerWedgeHulls);
|
|
1398
|
+
if (cornerTubeHulls.length) tubeParts.push(...cornerTubeHulls);
|
|
1399
|
+
|
|
1400
|
+
const unionAll = (parts) => {
|
|
1401
|
+
let acc = null;
|
|
1402
|
+
for (const solid of parts) {
|
|
1403
|
+
acc = acc ? acc.union(solid) : solid;
|
|
1404
|
+
}
|
|
1405
|
+
return acc;
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
const combinedWedge = unionAll(wedgeParts);
|
|
1409
|
+
const combinedTube = unionAll(tubeParts);
|
|
1410
|
+
if (combinedWedge && combinedTube) {
|
|
1411
|
+
combinedFilletSolid = combinedWedge.subtract(combinedTube);
|
|
1412
|
+
try { combinedFilletSolid.name = `${featureID}_FILLET_COMBINED`; } catch { }
|
|
1413
|
+
if (debug) {
|
|
1414
|
+
debugAdded.push(combinedWedge);
|
|
1415
|
+
debugAdded.push(combinedTube);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
console.warn('[Solid.fillet] Corner hull build failed', { featureID, error: err?.message || err });
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Apply to base solid (union for OUTSET, subtract for INSET)
|
|
1424
|
+
let result = this;
|
|
1425
|
+
const solidsToApply = combinedFilletSolid ? [combinedFilletSolid] : filletEntries.map(entry => entry.filletSolid);
|
|
1426
|
+
try {
|
|
1427
|
+
for (const filletSolid of solidsToApply) {
|
|
1428
|
+
const operation = (dir === 'OUTSET') ? 'union' : 'subtract';
|
|
1429
|
+
result = (operation === 'union') ? result.union(filletSolid) : result.subtract(filletSolid);
|
|
1430
|
+
|
|
1431
|
+
result.visualize();
|
|
1432
|
+
|
|
1433
|
+
// Name the result for scene grouping/debugging
|
|
1434
|
+
try { result.name = this.name; } catch { }
|
|
1435
|
+
}
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
console.error('[Solid.fillet] Fillet boolean failed; returning clone.', { featureID, error: err?.message || err });
|
|
1438
|
+
const fallback = this.clone();
|
|
1439
|
+
try { fallback.name = this.name; } catch { }
|
|
1440
|
+
attachDebugSolids(fallback, 'boolean failure');
|
|
1441
|
+
return fallback;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
try {
|
|
1445
|
+
const boundaryCache = { current: null };
|
|
1446
|
+
const resultAreaCache = buildFaceAreaCache(result);
|
|
1447
|
+
for (const entry of filletEntries) {
|
|
1448
|
+
const { filletSolid, filletName } = entry;
|
|
1449
|
+
const mergeSolid = combinedFilletSolid || filletSolid;
|
|
1450
|
+
const roundFaceName = entry.roundFaceName || guessRoundFaceName(mergeSolid, filletName);
|
|
1451
|
+
const candidateNames = (Array.isArray(entry.mergeCandidates) && entry.mergeCandidates.length)
|
|
1452
|
+
? entry.mergeCandidates
|
|
1453
|
+
: getFilletMergeCandidateNames(mergeSolid);
|
|
1454
|
+
mergeTinyFacesIntoRoundFace(result, mergeSolid, candidateNames, roundFaceName, featureID, boundaryCache, resultAreaCache);
|
|
1455
|
+
mergeSideFacesIntoRoundFace(result, filletName, roundFaceName);
|
|
1456
|
+
}
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
console.warn('[Solid.fillet] Tiny fillet face merge failed', { featureID, error: err?.message || err });
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
try {
|
|
1462
|
+
if (cleanupTinyFaceIslandsArea > 0 && typeof result.cleanupTinyFaceIslands === 'function') {
|
|
1463
|
+
await result.cleanupTinyFaceIslands(cleanupTinyFaceIslandsArea);
|
|
1464
|
+
}
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
console.warn('[Solid.fillet] cleanupTinyFaceIslands failed', { featureID, error: err?.message || err });
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
try {
|
|
1470
|
+
if (snapTangentOverlays) {
|
|
1471
|
+
const stats = snapFilletEdgesToTangents(result, filletEntries, { tol: deriveSolidToleranceFromVerts(result, 1e-5) });
|
|
1472
|
+
if (debug && stats?.movedVertices) {
|
|
1473
|
+
console.log('[Solid.fillet] Snapped fillet tangents', stats);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
} catch (err) {
|
|
1477
|
+
console.warn('[Solid.fillet] tangent snap failed', { featureID, error: err?.message || err });
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
try {
|
|
1481
|
+
if (typeof result.mergeTinyFaces === 'function') {
|
|
1482
|
+
await result.mergeTinyFaces(0.1);
|
|
1483
|
+
}
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
console.warn('[Solid.fillet] mergeTinyFaces failed', { featureID, error: err?.message || err });
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
try {
|
|
1489
|
+
await mergeInsetEndCapsByNormal(result, featureID, dir);
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
console.warn('[Solid.fillet] Inset end cap merge failed', { featureID, error: err?.message || err });
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
try {
|
|
1495
|
+
await result.collapseTinyTriangles(0.0009);
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
console.warn('[Solid.fillet] collapseTinyTriangles failed', { featureID, error: err?.message || err });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Attach debug artifacts for callers that want to add them to the scene
|
|
1501
|
+
attachDebugSolids(result);
|
|
1502
|
+
|
|
1503
|
+
// Simplify the final result in place to clean up artifacts from booleans.
|
|
1504
|
+
try {
|
|
1505
|
+
await result.removeSmallIslands();
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
console.warn('[Solid.fillet] simplify failed; continuing without simplification', { featureID, error: err?.message || err });
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const finalTriCount = Array.isArray(result?._triVerts) ? (result._triVerts.length / 3) : 0;
|
|
1511
|
+
const finalVertCount = Array.isArray(result?._vertProperties) ? (result._vertProperties.length / 3) : 0;
|
|
1512
|
+
if (!result || finalTriCount === 0 || finalVertCount === 0) {
|
|
1513
|
+
console.error('[Solid.fillet] Fillet result is empty or missing geometry.', {
|
|
1514
|
+
featureID,
|
|
1515
|
+
finalTriCount,
|
|
1516
|
+
finalVertCount,
|
|
1517
|
+
edgeCount: unique.length,
|
|
1518
|
+
direction: dir,
|
|
1519
|
+
inflate,
|
|
1520
|
+
});
|
|
1521
|
+
} else {
|
|
1522
|
+
consoleLogReplacement('[Solid.fillet] Completed', { featureID, triangles: finalTriCount, vertices: finalVertCount });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
return result;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
function consoleLogReplacement(args){
|
|
1531
|
+
if (debugMode) console.log(...args);
|
|
1532
|
+
}
|