okgeometry-api 0.5.0 → 0.5.2
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/dist/Mesh.d.ts +0 -32
- package/dist/Mesh.d.ts.map +1 -1
- package/dist/Mesh.js +8 -91
- package/dist/Mesh.js.map +1 -1
- package/dist/engine.d.ts +0 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +0 -37
- package/dist/engine.js.map +1 -1
- package/dist/mesh-boolean.protocol.d.ts +2 -2
- package/dist/mesh-boolean.protocol.d.ts.map +1 -1
- package/dist/wasm-base64.d.ts +1 -1
- package/dist/wasm-base64.d.ts.map +1 -1
- package/dist/wasm-base64.js +1 -1
- package/dist/wasm-base64.js.map +1 -1
- package/package.json +46 -45
- package/src/Arc.ts +117 -0
- package/src/BufferCodec.ts +181 -0
- package/src/Circle.ts +153 -0
- package/src/Geometry.ts +39 -0
- package/src/Line.ts +144 -0
- package/src/Mesh.ts +1476 -0
- package/src/NurbsCurve.ts +240 -0
- package/src/NurbsSurface.ts +267 -0
- package/src/Plane.ts +132 -0
- package/src/Point.ts +145 -0
- package/src/PolyCurve.ts +306 -0
- package/src/Polygon.ts +75 -0
- package/src/Polyline.ts +153 -0
- package/src/Ray.ts +90 -0
- package/src/Vec3.ts +159 -0
- package/src/engine.ts +92 -0
- package/src/index.ts +45 -0
- package/src/mesh-boolean.pool.ts +481 -0
- package/src/mesh-boolean.protocol.ts +122 -0
- package/src/mesh-boolean.worker.ts +160 -0
- package/src/types.ts +84 -0
- package/src/wasm-base64.ts +2 -0
- package/wasm/okgeometrycore.d.ts +19 -20
- package/wasm/okgeometrycore.js +62 -26
- package/wasm/okgeometrycore_bg.js +47 -0
- package/wasm/okgeometrycore_bg.wasm +0 -0
- package/wasm/okgeometrycore_bg.wasm.d.ts +2 -2
package/src/Mesh.ts
ADDED
|
@@ -0,0 +1,1476 @@
|
|
|
1
|
+
import { ensureInit } from "./engine.js";
|
|
2
|
+
import { Point } from "./Point.js";
|
|
3
|
+
import { Vec3 } from "./Vec3.js";
|
|
4
|
+
import { Plane } from "./Plane.js";
|
|
5
|
+
import { Polyline } from "./Polyline.js";
|
|
6
|
+
import { Line } from "./Line.js";
|
|
7
|
+
import { Circle } from "./Circle.js";
|
|
8
|
+
import { Arc } from "./Arc.js";
|
|
9
|
+
import { Polygon } from "./Polygon.js";
|
|
10
|
+
import { NurbsCurve } from "./NurbsCurve.js";
|
|
11
|
+
import { PolyCurve } from "./PolyCurve.js";
|
|
12
|
+
import type { SweepableCurve, RotationAxis } from "./types.js";
|
|
13
|
+
import { CurveTypeCode, SegmentTypeCode } from "./types.js";
|
|
14
|
+
import { pointsToCoords, parsePolylineBuffer as parsePolylineBuf } from "./BufferCodec.js";
|
|
15
|
+
import {
|
|
16
|
+
configureDefaultMeshBooleanWorkerPool,
|
|
17
|
+
disposeMeshBooleanWorkerPools,
|
|
18
|
+
runMeshBooleanInWorkerPool,
|
|
19
|
+
} from "./mesh-boolean.pool.js";
|
|
20
|
+
import { MeshBooleanExecutionError } from "./mesh-boolean.protocol.js";
|
|
21
|
+
import type {
|
|
22
|
+
MeshBooleanAsyncOptions,
|
|
23
|
+
MeshBooleanBackend,
|
|
24
|
+
MeshBooleanLimits,
|
|
25
|
+
MeshBooleanOptions,
|
|
26
|
+
} from "./mesh-boolean.protocol.js";
|
|
27
|
+
import * as wasm from "../wasm/okgeometrycore_bg.js";
|
|
28
|
+
|
|
29
|
+
export { MeshBooleanExecutionError };
|
|
30
|
+
export type {
|
|
31
|
+
MeshBooleanAsyncOptions,
|
|
32
|
+
MeshBooleanBackend,
|
|
33
|
+
MeshBooleanLimits,
|
|
34
|
+
MeshBooleanOptions,
|
|
35
|
+
MeshBooleanErrorCode,
|
|
36
|
+
MeshBooleanErrorPayload,
|
|
37
|
+
MeshBooleanProgressEvent,
|
|
38
|
+
} from "./mesh-boolean.protocol.js";
|
|
39
|
+
|
|
40
|
+
export interface PlanarRectangle {
|
|
41
|
+
corners: [Point, Point, Point, Point];
|
|
42
|
+
width: number;
|
|
43
|
+
height: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PlanarCircle {
|
|
47
|
+
points: Point[];
|
|
48
|
+
radius: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PlanarArc {
|
|
52
|
+
points: Point[];
|
|
53
|
+
center: Point;
|
|
54
|
+
radius: number;
|
|
55
|
+
startAngle: number;
|
|
56
|
+
endAngle: number;
|
|
57
|
+
sweepAngle: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface RawMeshBounds {
|
|
61
|
+
minX: number;
|
|
62
|
+
minY: number;
|
|
63
|
+
minZ: number;
|
|
64
|
+
maxX: number;
|
|
65
|
+
maxY: number;
|
|
66
|
+
maxZ: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Buffer-backed triangle mesh with GPU-ready accessors.
|
|
71
|
+
* All geometry lives in a Float64Array from WASM.
|
|
72
|
+
*
|
|
73
|
+
* Buffer format: [vertexCount, x1,y1,z1, ..., i1,i2,i3, ...]
|
|
74
|
+
*/
|
|
75
|
+
export class Mesh {
|
|
76
|
+
private _buffer: Float64Array;
|
|
77
|
+
private _vertexCount: number;
|
|
78
|
+
|
|
79
|
+
private static readonly DEFAULT_BOOLEAN_LIMITS: MeshBooleanLimits = {
|
|
80
|
+
maxInputFacesPerMesh: 120_000,
|
|
81
|
+
maxCombinedInputFaces: 180_000,
|
|
82
|
+
maxFaceProduct: 500_000_000,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Lazy caches
|
|
86
|
+
private _positionBuffer: Float32Array | null = null;
|
|
87
|
+
private _indexBuffer: Uint32Array | null = null;
|
|
88
|
+
private _vertices: Point[] | null = null;
|
|
89
|
+
private _faces: number[][] | null = null;
|
|
90
|
+
private _edgeVertexPairs: Array<[number, number]> | null = null;
|
|
91
|
+
|
|
92
|
+
private constructor(buffer: Float64Array) {
|
|
93
|
+
this._buffer = buffer;
|
|
94
|
+
this._vertexCount = buffer.length > 0 ? buffer[0] : 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Configure default size for the shared boolean worker pool.
|
|
99
|
+
*/
|
|
100
|
+
static configureBooleanWorkerPool(options: { size: number }): void {
|
|
101
|
+
configureDefaultMeshBooleanWorkerPool(options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Dispose all shared boolean worker pools.
|
|
106
|
+
*/
|
|
107
|
+
static disposeBooleanWorkerPools(): void {
|
|
108
|
+
disposeMeshBooleanWorkerPools();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private static resolveBooleanLimits(
|
|
112
|
+
overrides?: Partial<MeshBooleanLimits>,
|
|
113
|
+
): MeshBooleanLimits {
|
|
114
|
+
const defaults = Mesh.DEFAULT_BOOLEAN_LIMITS;
|
|
115
|
+
return {
|
|
116
|
+
maxInputFacesPerMesh: overrides?.maxInputFacesPerMesh ?? defaults.maxInputFacesPerMesh,
|
|
117
|
+
maxCombinedInputFaces: overrides?.maxCombinedInputFaces ?? defaults.maxCombinedInputFaces,
|
|
118
|
+
maxFaceProduct: overrides?.maxFaceProduct ?? defaults.maxFaceProduct,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private static computeRawBounds(mesh: Mesh): RawMeshBounds | null {
|
|
123
|
+
if (mesh._vertexCount <= 0) return null;
|
|
124
|
+
|
|
125
|
+
const data = mesh._buffer;
|
|
126
|
+
const limit = 1 + mesh._vertexCount * 3;
|
|
127
|
+
|
|
128
|
+
let minX = data[1];
|
|
129
|
+
let minY = data[2];
|
|
130
|
+
let minZ = data[3];
|
|
131
|
+
let maxX = minX;
|
|
132
|
+
let maxY = minY;
|
|
133
|
+
let maxZ = minZ;
|
|
134
|
+
|
|
135
|
+
for (let i = 4; i < limit; i += 3) {
|
|
136
|
+
const x = data[i];
|
|
137
|
+
const y = data[i + 1];
|
|
138
|
+
const z = data[i + 2];
|
|
139
|
+
if (x < minX) minX = x;
|
|
140
|
+
if (y < minY) minY = y;
|
|
141
|
+
if (z < minZ) minZ = z;
|
|
142
|
+
if (x > maxX) maxX = x;
|
|
143
|
+
if (y > maxY) maxY = y;
|
|
144
|
+
if (z > maxZ) maxZ = z;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { minX, minY, minZ, maxX, maxY, maxZ };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private static boundsOverlap(a: RawMeshBounds | null, b: RawMeshBounds | null, eps = 1e-9): boolean {
|
|
151
|
+
if (!a || !b) return false;
|
|
152
|
+
return a.minX <= b.maxX + eps
|
|
153
|
+
&& a.maxX + eps >= b.minX
|
|
154
|
+
&& a.minY <= b.maxY + eps
|
|
155
|
+
&& a.maxY + eps >= b.minY
|
|
156
|
+
&& a.minZ <= b.maxZ + eps
|
|
157
|
+
&& a.maxZ + eps >= b.minZ;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private static cloneMesh(mesh: Mesh): Mesh {
|
|
161
|
+
return Mesh.fromBuffer(new Float64Array(mesh._buffer));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private static emptyMesh(): Mesh {
|
|
165
|
+
return Mesh.fromBuffer(new Float64Array(0));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── GPU-ready buffers ──────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Float32 xyz positions for Three.js BufferGeometry.
|
|
172
|
+
* Lazy-computed and cached.
|
|
173
|
+
*/
|
|
174
|
+
get positionBuffer(): Float32Array {
|
|
175
|
+
if (!this._positionBuffer) {
|
|
176
|
+
const n = this._vertexCount * 3;
|
|
177
|
+
this._positionBuffer = new Float32Array(n);
|
|
178
|
+
for (let i = 0; i < n; i++) {
|
|
179
|
+
this._positionBuffer[i] = this._buffer[1 + i];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return this._positionBuffer;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Uint32 triangle indices for Three.js BufferGeometry.setIndex.
|
|
187
|
+
* Lazy-computed and cached.
|
|
188
|
+
*/
|
|
189
|
+
get indexBuffer(): Uint32Array {
|
|
190
|
+
if (!this._indexBuffer) {
|
|
191
|
+
const start = 1 + this._vertexCount * 3;
|
|
192
|
+
const len = Math.max(0, this._buffer.length - start);
|
|
193
|
+
this._indexBuffer = new Uint32Array(len);
|
|
194
|
+
for (let i = 0; i < len; i++) {
|
|
195
|
+
this._indexBuffer[i] = this._buffer[start + i];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return this._indexBuffer;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Number of vertices in this mesh */
|
|
202
|
+
get vertexCount(): number {
|
|
203
|
+
return this._vertexCount;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Number of triangular faces in this mesh */
|
|
207
|
+
get faceCount(): number {
|
|
208
|
+
return this.indexBuffer.length / 3;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── High-level accessors (lazy) ────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get all vertices as Point objects.
|
|
215
|
+
* Lazy-computed and cached.
|
|
216
|
+
*/
|
|
217
|
+
get vertices(): Point[] {
|
|
218
|
+
if (!this._vertices) {
|
|
219
|
+
const pts: Point[] = [];
|
|
220
|
+
for (let i = 0; i < this._vertexCount; i++) {
|
|
221
|
+
const off = 1 + i * 3;
|
|
222
|
+
pts.push(new Point(this._buffer[off], this._buffer[off + 1], this._buffer[off + 2]));
|
|
223
|
+
}
|
|
224
|
+
this._vertices = pts;
|
|
225
|
+
}
|
|
226
|
+
return this._vertices;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get all faces as arrays of vertex indices.
|
|
231
|
+
* Each face is [i0, i1, i2] for the three triangle vertices.
|
|
232
|
+
* Lazy-computed and cached.
|
|
233
|
+
*/
|
|
234
|
+
get faces(): number[][] {
|
|
235
|
+
if (!this._faces) {
|
|
236
|
+
const idx = this.indexBuffer;
|
|
237
|
+
const f: number[][] = [];
|
|
238
|
+
for (let i = 0; i < idx.length; i += 3) {
|
|
239
|
+
f.push([idx[i], idx[i + 1], idx[i + 2]]);
|
|
240
|
+
}
|
|
241
|
+
this._faces = f;
|
|
242
|
+
}
|
|
243
|
+
return this._faces;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Raw WASM buffer (for advanced use / re-passing to WASM) */
|
|
247
|
+
get rawBuffer(): Float64Array {
|
|
248
|
+
return this._buffer;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Static factories ───────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create a Mesh from a raw WASM buffer.
|
|
255
|
+
* @param buffer - Float64Array in mesh buffer format
|
|
256
|
+
* @returns New Mesh instance
|
|
257
|
+
*/
|
|
258
|
+
static fromBuffer(buffer: Float64Array): Mesh {
|
|
259
|
+
return new Mesh(buffer);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build an axis-aligned rectangle on a plane basis from opposite corners.
|
|
264
|
+
* The resulting corners are ordered [p0, p1, p2, p3] and form a closed loop.
|
|
265
|
+
*/
|
|
266
|
+
static buildPlanarRectangle(
|
|
267
|
+
startPoint: Point,
|
|
268
|
+
endPoint: Point,
|
|
269
|
+
uAxis: Vec3,
|
|
270
|
+
vAxis: Vec3,
|
|
271
|
+
): PlanarRectangle {
|
|
272
|
+
const delta = endPoint.sub(startPoint);
|
|
273
|
+
const uLen = delta.dot(uAxis);
|
|
274
|
+
const vLen = delta.dot(vAxis);
|
|
275
|
+
|
|
276
|
+
const p0 = startPoint;
|
|
277
|
+
const p1 = startPoint.add(uAxis.scale(uLen));
|
|
278
|
+
const p3 = startPoint.add(vAxis.scale(vLen));
|
|
279
|
+
const p2 = p1.add(vAxis.scale(vLen));
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
corners: [p0, p1, p2, p3],
|
|
283
|
+
width: Math.abs(uLen),
|
|
284
|
+
height: Math.abs(vLen),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build a planar circle from center and a point on/near its radius direction.
|
|
290
|
+
* Radius is measured in the plane orthogonal to `normal`.
|
|
291
|
+
*/
|
|
292
|
+
static buildPlanarCircle(
|
|
293
|
+
center: Point,
|
|
294
|
+
radiusPoint: Point,
|
|
295
|
+
normal: Vec3,
|
|
296
|
+
segments = 64,
|
|
297
|
+
): PlanarCircle {
|
|
298
|
+
const n = normal.normalize();
|
|
299
|
+
const radial = radiusPoint.sub(center);
|
|
300
|
+
const radialInPlane = radial.sub(n.scale(radial.dot(n)));
|
|
301
|
+
const radius = radialInPlane.length();
|
|
302
|
+
|
|
303
|
+
if (radius < 1e-12 || segments < 3) {
|
|
304
|
+
return { points: [], radius: 0 };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const circle = new Circle(center, radius, n);
|
|
308
|
+
return {
|
|
309
|
+
points: circle.sample(Math.floor(segments)),
|
|
310
|
+
radius,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Build a planar arc from center, start, and end points.
|
|
316
|
+
* Uses the shortest signed sweep between start and end around `normal`.
|
|
317
|
+
*/
|
|
318
|
+
static buildPlanarArcCenterStartEnd(
|
|
319
|
+
center: Point,
|
|
320
|
+
startPoint: Point,
|
|
321
|
+
endPoint: Point,
|
|
322
|
+
normal: Vec3,
|
|
323
|
+
segments = 64,
|
|
324
|
+
): PlanarArc {
|
|
325
|
+
const n = normal.normalize();
|
|
326
|
+
const minSegments = Math.max(2, Math.floor(segments));
|
|
327
|
+
|
|
328
|
+
const startRaw = startPoint.sub(center);
|
|
329
|
+
const endRaw = endPoint.sub(center);
|
|
330
|
+
const startVec = startRaw.sub(n.scale(startRaw.dot(n)));
|
|
331
|
+
const endVec = endRaw.sub(n.scale(endRaw.dot(n)));
|
|
332
|
+
|
|
333
|
+
const radius = startVec.length();
|
|
334
|
+
const endLen = endVec.length();
|
|
335
|
+
|
|
336
|
+
if (radius < 1e-12 || endLen < 1e-12) {
|
|
337
|
+
return {
|
|
338
|
+
points: [],
|
|
339
|
+
center,
|
|
340
|
+
radius: 0,
|
|
341
|
+
startAngle: 0,
|
|
342
|
+
endAngle: 0,
|
|
343
|
+
sweepAngle: 0,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const basis = Mesh.resolveArcBasis(center, radius, n);
|
|
348
|
+
const startAngle = Mesh.angleOnBasis(startVec, basis.uAxis, basis.vAxis);
|
|
349
|
+
const endAngleRaw = Mesh.angleOnBasis(endVec.scale(radius / endLen), basis.uAxis, basis.vAxis);
|
|
350
|
+
const sweepAngle = Mesh.normalizeSignedAngle(endAngleRaw - startAngle);
|
|
351
|
+
if (Math.abs(sweepAngle) < 1e-9) {
|
|
352
|
+
return {
|
|
353
|
+
points: [],
|
|
354
|
+
center,
|
|
355
|
+
radius: 0,
|
|
356
|
+
startAngle: 0,
|
|
357
|
+
endAngle: 0,
|
|
358
|
+
sweepAngle: 0,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const endAngle = startAngle + sweepAngle;
|
|
363
|
+
const arc = new Arc(center, radius, startAngle, endAngle, n);
|
|
364
|
+
return {
|
|
365
|
+
points: arc.sample(minSegments),
|
|
366
|
+
center,
|
|
367
|
+
radius,
|
|
368
|
+
startAngle,
|
|
369
|
+
endAngle,
|
|
370
|
+
sweepAngle,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Build a planar arc from start/end points and a radius control point.
|
|
376
|
+
* Radius is measured from start point to radiusPoint in the active plane.
|
|
377
|
+
* The best of 4 candidates (2 centers x minor/major sweeps) is selected by
|
|
378
|
+
* midpoint proximity to radiusPoint.
|
|
379
|
+
*/
|
|
380
|
+
static buildPlanarArcStartEndRadius(
|
|
381
|
+
startPoint: Point,
|
|
382
|
+
endPoint: Point,
|
|
383
|
+
radiusPoint: Point,
|
|
384
|
+
normal: Vec3,
|
|
385
|
+
segments = 64,
|
|
386
|
+
): PlanarArc | null {
|
|
387
|
+
const n = normal.normalize();
|
|
388
|
+
const minSegments = Math.max(2, Math.floor(segments));
|
|
389
|
+
const start = Mesh.projectPointToPlane(startPoint, startPoint, n);
|
|
390
|
+
const end = Mesh.projectPointToPlane(endPoint, startPoint, n);
|
|
391
|
+
const guide = Mesh.projectPointToPlane(radiusPoint, startPoint, n);
|
|
392
|
+
|
|
393
|
+
const chord = end.sub(start);
|
|
394
|
+
const chordLength = chord.length();
|
|
395
|
+
if (chordLength < 1e-9) {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const radius = guide.sub(start).length();
|
|
400
|
+
const halfChord = chordLength * 0.5;
|
|
401
|
+
if (!Number.isFinite(radius) || radius < halfChord + 1e-9) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const chordDir = chord.scale(1 / chordLength);
|
|
406
|
+
const perpRaw = n.cross(chordDir);
|
|
407
|
+
const perpLen = perpRaw.length();
|
|
408
|
+
if (perpLen < 1e-12) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
const perp = perpRaw.scale(1 / perpLen);
|
|
412
|
+
|
|
413
|
+
const hSq = radius * radius - halfChord * halfChord;
|
|
414
|
+
if (hSq < 0) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
const h = Math.sqrt(Math.max(0, hSq));
|
|
418
|
+
const mid = start.add(chord.scale(0.5));
|
|
419
|
+
const centers = [mid.add(perp.scale(h)), mid.add(perp.scale(-h))];
|
|
420
|
+
|
|
421
|
+
type ArcCandidate = {
|
|
422
|
+
center: Point;
|
|
423
|
+
radius: number;
|
|
424
|
+
startAngle: number;
|
|
425
|
+
endAngle: number;
|
|
426
|
+
sweepAngle: number;
|
|
427
|
+
points: Point[];
|
|
428
|
+
score: number;
|
|
429
|
+
};
|
|
430
|
+
const candidates: ArcCandidate[] = [];
|
|
431
|
+
|
|
432
|
+
for (const center of centers) {
|
|
433
|
+
const startVec = start.sub(center);
|
|
434
|
+
const endVec = end.sub(center);
|
|
435
|
+
if (startVec.length() < 1e-12 || endVec.length() < 1e-12) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const basis = Mesh.resolveArcBasis(center, radius, n);
|
|
440
|
+
const startAngle = Mesh.angleOnBasis(startVec, basis.uAxis, basis.vAxis);
|
|
441
|
+
const endAngleRaw = Mesh.angleOnBasis(endVec, basis.uAxis, basis.vAxis);
|
|
442
|
+
const minorSweep = Mesh.normalizeSignedAngle(endAngleRaw - startAngle);
|
|
443
|
+
if (Math.abs(minorSweep) < 1e-9) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Keep this mode on the minor arc branch for stable radius editing.
|
|
448
|
+
const sweepAngle = minorSweep;
|
|
449
|
+
const endAngle = startAngle + sweepAngle;
|
|
450
|
+
const arc = new Arc(center, radius, startAngle, endAngle, n);
|
|
451
|
+
const midPoint = arc.pointAt(0.5);
|
|
452
|
+
candidates.push({
|
|
453
|
+
center,
|
|
454
|
+
radius,
|
|
455
|
+
startAngle,
|
|
456
|
+
endAngle,
|
|
457
|
+
sweepAngle,
|
|
458
|
+
points: arc.sample(minSegments),
|
|
459
|
+
score: midPoint.distanceTo(guide),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (candidates.length === 0) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
candidates.sort((a, b) => a.score - b.score);
|
|
468
|
+
const winner = candidates[0];
|
|
469
|
+
return {
|
|
470
|
+
points: winner.points,
|
|
471
|
+
center: winner.center,
|
|
472
|
+
radius: winner.radius,
|
|
473
|
+
startAngle: winner.startAngle,
|
|
474
|
+
endAngle: winner.endAngle,
|
|
475
|
+
sweepAngle: winner.sweepAngle,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private static resolveArcBasis(
|
|
480
|
+
center: Point,
|
|
481
|
+
radius: number,
|
|
482
|
+
normal: Vec3,
|
|
483
|
+
): { uAxis: Vec3; vAxis: Vec3 } {
|
|
484
|
+
if (radius > 1e-12) {
|
|
485
|
+
const circle = new Circle(center, radius, normal);
|
|
486
|
+
const p0 = circle.pointAt(0);
|
|
487
|
+
const pQuarter = circle.pointAt(0.25);
|
|
488
|
+
const uRaw = p0.sub(center);
|
|
489
|
+
const vRaw = pQuarter.sub(center);
|
|
490
|
+
const uLen = uRaw.length();
|
|
491
|
+
const vLen = vRaw.length();
|
|
492
|
+
if (uLen > 1e-12 && vLen > 1e-12) {
|
|
493
|
+
return {
|
|
494
|
+
uAxis: uRaw.scale(1 / uLen),
|
|
495
|
+
vAxis: vRaw.scale(1 / vLen),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const n = normal.normalize();
|
|
501
|
+
const reference = Math.abs(n.z) < 0.9 ? Vec3.Z : Vec3.X;
|
|
502
|
+
const uRaw = n.cross(reference);
|
|
503
|
+
const uLen = uRaw.length();
|
|
504
|
+
if (uLen < 1e-12) {
|
|
505
|
+
return { uAxis: Vec3.X, vAxis: Vec3.Y };
|
|
506
|
+
}
|
|
507
|
+
const uAxis = uRaw.scale(1 / uLen);
|
|
508
|
+
const vRaw = n.cross(uAxis);
|
|
509
|
+
const vLen = vRaw.length();
|
|
510
|
+
if (vLen < 1e-12) {
|
|
511
|
+
return { uAxis, vAxis: Vec3.Y };
|
|
512
|
+
}
|
|
513
|
+
return { uAxis, vAxis: vRaw.scale(1 / vLen) };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private static angleOnBasis(vector: Vec3, uAxis: Vec3, vAxis: Vec3): number {
|
|
517
|
+
return Math.atan2(vector.dot(vAxis), vector.dot(uAxis));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private static normalizeSignedAngle(angle: number): number {
|
|
521
|
+
const twoPi = Math.PI * 2;
|
|
522
|
+
let wrapped = angle % twoPi;
|
|
523
|
+
if (wrapped <= -Math.PI) {
|
|
524
|
+
wrapped += twoPi;
|
|
525
|
+
} else if (wrapped > Math.PI) {
|
|
526
|
+
wrapped -= twoPi;
|
|
527
|
+
}
|
|
528
|
+
return wrapped;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private static projectPointToPlane(point: Point, planeOrigin: Point, planeNormal: Vec3): Point {
|
|
532
|
+
const offset = point.sub(planeOrigin);
|
|
533
|
+
const projected = offset.sub(planeNormal.scale(offset.dot(planeNormal)));
|
|
534
|
+
return planeOrigin.add(projected);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Create a planar patch mesh from boundary points using CDT (Constrained Delaunay Triangulation).
|
|
539
|
+
* Correctly handles both convex and concave polygons.
|
|
540
|
+
* @param pts - Ordered boundary points defining a closed polygon (minimum 3)
|
|
541
|
+
* @returns New Mesh triangulating the polygon interior
|
|
542
|
+
*/
|
|
543
|
+
static patchFromPoints(pts: Point[]): Mesh {
|
|
544
|
+
ensureInit();
|
|
545
|
+
const coords = pointsToCoords(pts);
|
|
546
|
+
const buf = wasm.mesh_patch_from_points(coords);
|
|
547
|
+
return new Mesh(buf);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Create an axis-aligned box centered at origin.
|
|
552
|
+
* @param width - Size along X axis
|
|
553
|
+
* @param height - Size along Y axis
|
|
554
|
+
* @param depth - Size along Z axis
|
|
555
|
+
* @returns New Mesh with 8 vertices and 12 triangles
|
|
556
|
+
*/
|
|
557
|
+
static createBox(width: number, height: number, depth: number): Mesh {
|
|
558
|
+
ensureInit();
|
|
559
|
+
return new Mesh(wasm.mesh_create_box(width, height, depth));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Create a UV sphere centered at origin.
|
|
564
|
+
* @param radius - Sphere radius
|
|
565
|
+
* @param segments - Number of longitudinal segments
|
|
566
|
+
* @param rings - Number of latitudinal rings
|
|
567
|
+
* @returns New Mesh representing the sphere
|
|
568
|
+
*/
|
|
569
|
+
static createSphere(radius: number, segments: number, rings: number): Mesh {
|
|
570
|
+
ensureInit();
|
|
571
|
+
return new Mesh(wasm.mesh_create_sphere(radius, segments, rings));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Create a cylinder centered at origin with axis along Y.
|
|
576
|
+
* @param radius - Cylinder radius
|
|
577
|
+
* @param height - Total height (extends height/2 above and below origin)
|
|
578
|
+
* @param segments - Number of circumferential segments
|
|
579
|
+
* @returns New Mesh with caps
|
|
580
|
+
*/
|
|
581
|
+
static createCylinder(radius: number, height: number, segments: number): Mesh {
|
|
582
|
+
ensureInit();
|
|
583
|
+
return new Mesh(wasm.mesh_create_cylinder(radius, height, segments));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Create a regular prism centered at origin.
|
|
588
|
+
* @param radius - Circumradius of the base polygon
|
|
589
|
+
* @param height - Total height
|
|
590
|
+
* @param sides - Number of sides (3 = triangular prism, 6 = hexagonal, etc.)
|
|
591
|
+
* @returns New Mesh with caps
|
|
592
|
+
*/
|
|
593
|
+
static createPrism(radius: number, height: number, sides: number): Mesh {
|
|
594
|
+
ensureInit();
|
|
595
|
+
return new Mesh(wasm.mesh_create_prism(radius, height, sides));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Create a cone centered at origin with apex at top.
|
|
600
|
+
* @param radius - Base radius
|
|
601
|
+
* @param height - Height from base to apex
|
|
602
|
+
* @param segments - Number of circumferential segments
|
|
603
|
+
* @returns New Mesh with base cap
|
|
604
|
+
*/
|
|
605
|
+
static createCone(radius: number, height: number, segments: number): Mesh {
|
|
606
|
+
ensureInit();
|
|
607
|
+
return new Mesh(wasm.mesh_create_cone(radius, height, segments));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Import a mesh from OBJ format string.
|
|
612
|
+
* @param objString - OBJ file content
|
|
613
|
+
* @returns New Mesh parsed from OBJ
|
|
614
|
+
*/
|
|
615
|
+
static fromOBJ(objString: string): Mesh {
|
|
616
|
+
ensureInit();
|
|
617
|
+
return new Mesh(wasm.mesh_import_obj(objString));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Loft through multiple circles to create a surface of revolution.
|
|
622
|
+
* @param circles - Array of circle definitions (center, normal, radius)
|
|
623
|
+
* @param segments - Number of circumferential segments
|
|
624
|
+
* @param caps - Whether to cap the ends (default false)
|
|
625
|
+
* @returns New Mesh representing the lofted surface
|
|
626
|
+
*/
|
|
627
|
+
static loftCircles(
|
|
628
|
+
circles: Array<{ center: Point; normal?: { x: number; y: number; z: number }; radius: number }>,
|
|
629
|
+
segments: number,
|
|
630
|
+
caps = false
|
|
631
|
+
): Mesh {
|
|
632
|
+
ensureInit();
|
|
633
|
+
const data = new Float64Array(circles.length * 7);
|
|
634
|
+
for (let i = 0; i < circles.length; i++) {
|
|
635
|
+
const c = circles[i];
|
|
636
|
+
const off = i * 7;
|
|
637
|
+
data[off] = c.center.x;
|
|
638
|
+
data[off + 1] = c.center.y;
|
|
639
|
+
data[off + 2] = c.center.z;
|
|
640
|
+
data[off + 3] = c.normal?.x ?? 0;
|
|
641
|
+
data[off + 4] = c.normal?.y ?? 1;
|
|
642
|
+
data[off + 5] = c.normal?.z ?? 0;
|
|
643
|
+
data[off + 6] = c.radius;
|
|
644
|
+
}
|
|
645
|
+
return new Mesh(wasm.loft_circles(data, segments, caps));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Loft through multiple polylines to create a ruled surface.
|
|
650
|
+
* @param polylines - Array of point arrays defining cross-sections
|
|
651
|
+
* @param segments - Interpolation segments between sections
|
|
652
|
+
* @param caps - Whether to cap the ends (default false)
|
|
653
|
+
* @returns New Mesh representing the lofted surface
|
|
654
|
+
*/
|
|
655
|
+
static loftPolylines(polylines: Point[][], segments: number, caps = false): Mesh {
|
|
656
|
+
ensureInit();
|
|
657
|
+
// Format: [count1, x,y,z,..., count2, x,y,z,...]
|
|
658
|
+
const parts: number[] = [];
|
|
659
|
+
for (const pl of polylines) {
|
|
660
|
+
parts.push(pl.length);
|
|
661
|
+
for (const p of pl) {
|
|
662
|
+
parts.push(p.x, p.y, p.z);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return new Mesh(wasm.loft_polylines(new Float64Array(parts), segments, caps));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Sweep a profile polyline along a path polyline.
|
|
670
|
+
* @param profilePoints - Profile cross-section points
|
|
671
|
+
* @param pathPoints - Path points to sweep along
|
|
672
|
+
* @param caps - Whether to cap the ends (default false)
|
|
673
|
+
* @returns New Mesh representing the swept surface
|
|
674
|
+
*/
|
|
675
|
+
static sweep(profilePoints: Point[], pathPoints: Point[], caps = false): Mesh {
|
|
676
|
+
ensureInit();
|
|
677
|
+
return new Mesh(wasm.sweep_polylines(pointsToCoords(profilePoints), pointsToCoords(pathPoints), caps));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Compute a stable planar normal from ordered curve points.
|
|
682
|
+
* Closed curves use Newell's method; open curves use first non-collinear triple.
|
|
683
|
+
*/
|
|
684
|
+
static computePlanarCurveNormal(points: Point[], closed: boolean): Vec3 {
|
|
685
|
+
ensureInit();
|
|
686
|
+
const r = wasm.mesh_compute_planar_curve_normal(pointsToCoords(points), closed);
|
|
687
|
+
return new Vec3(r[0] ?? 0, r[1] ?? 1, r[2] ?? 0);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Extrude a planar curve along a direction.
|
|
692
|
+
* Open curves return an uncapped polysurface; closed curves are capped solids.
|
|
693
|
+
*/
|
|
694
|
+
static extrudePlanarCurve(points: Point[], normal: Vec3, height: number, closed: boolean): Mesh {
|
|
695
|
+
ensureInit();
|
|
696
|
+
const buf = wasm.mesh_extrude_planar_curve(
|
|
697
|
+
pointsToCoords(points),
|
|
698
|
+
normal.x, normal.y, normal.z,
|
|
699
|
+
height,
|
|
700
|
+
closed,
|
|
701
|
+
);
|
|
702
|
+
return Mesh.fromBuffer(buf);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Shift a closed cutter profile slightly opposite to travel direction and
|
|
707
|
+
* compensate height so the distal end remains at the user-intended depth.
|
|
708
|
+
*/
|
|
709
|
+
static prepareBooleanCutterCurve(
|
|
710
|
+
points: Point[],
|
|
711
|
+
closed: boolean,
|
|
712
|
+
normal: Vec3,
|
|
713
|
+
height: number,
|
|
714
|
+
): { points: Point[]; height: number; epsilon: number } {
|
|
715
|
+
ensureInit();
|
|
716
|
+
const buf = wasm.mesh_prepare_boolean_cutter_curve(
|
|
717
|
+
pointsToCoords(points),
|
|
718
|
+
closed,
|
|
719
|
+
normal.x, normal.y, normal.z,
|
|
720
|
+
height,
|
|
721
|
+
);
|
|
722
|
+
const outHeight = buf[0] ?? height;
|
|
723
|
+
const epsilon = buf[1] ?? 0;
|
|
724
|
+
const count = Math.max(0, Math.floor(buf[2] ?? 0));
|
|
725
|
+
const shifted: Point[] = [];
|
|
726
|
+
let off = 3;
|
|
727
|
+
for (let i = 0; i < count; i++) {
|
|
728
|
+
shifted.push(new Point(buf[off], buf[off + 1], buf[off + 2]));
|
|
729
|
+
off += 3;
|
|
730
|
+
}
|
|
731
|
+
return { points: shifted, height: outHeight, epsilon };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Sweep any curve type along any curve type.
|
|
736
|
+
* Passes exact curve data to WASM for native evaluation (no pre-sampling).
|
|
737
|
+
* @param profile - Profile curve (cross-section)
|
|
738
|
+
* @param path - Path curve to sweep along
|
|
739
|
+
* @param segments - Number of samples along path (default 32)
|
|
740
|
+
* @param caps - Whether to cap the ends (default false)
|
|
741
|
+
* @returns New Mesh representing the swept surface
|
|
742
|
+
*/
|
|
743
|
+
static sweepCurves(profile: SweepableCurve, path: SweepableCurve, segments = 32, caps = false): Mesh {
|
|
744
|
+
ensureInit();
|
|
745
|
+
const profileData = Mesh.encodeCurve(profile);
|
|
746
|
+
const pathData = Mesh.encodeCurve(path);
|
|
747
|
+
return new Mesh(wasm.sweep_curves(profileData, pathData, segments, segments, caps));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Encode a curve into the WASM format for sweep_curves. */
|
|
751
|
+
private static encodeCurve(curve: SweepableCurve): Float64Array {
|
|
752
|
+
if (curve instanceof Line) {
|
|
753
|
+
return new Float64Array([CurveTypeCode.Line,
|
|
754
|
+
curve.start.x, curve.start.y, curve.start.z,
|
|
755
|
+
curve.end.x, curve.end.y, curve.end.z]);
|
|
756
|
+
}
|
|
757
|
+
if (curve instanceof Circle) {
|
|
758
|
+
// Include uAxis if present (11 floats), otherwise just 8
|
|
759
|
+
if (curve.uAxis) {
|
|
760
|
+
return new Float64Array([CurveTypeCode.Circle,
|
|
761
|
+
curve.center.x, curve.center.y, curve.center.z,
|
|
762
|
+
curve.normal.x, curve.normal.y, curve.normal.z,
|
|
763
|
+
curve.radius,
|
|
764
|
+
curve.uAxis.x, curve.uAxis.y, curve.uAxis.z]);
|
|
765
|
+
} else {
|
|
766
|
+
return new Float64Array([CurveTypeCode.Circle,
|
|
767
|
+
curve.center.x, curve.center.y, curve.center.z,
|
|
768
|
+
curve.normal.x, curve.normal.y, curve.normal.z,
|
|
769
|
+
curve.radius]);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (curve instanceof Arc) {
|
|
773
|
+
return new Float64Array([CurveTypeCode.Arc,
|
|
774
|
+
curve.center.x, curve.center.y, curve.center.z,
|
|
775
|
+
curve.normal.x, curve.normal.y, curve.normal.z,
|
|
776
|
+
curve.radius, curve.startAngle, curve.endAngle]);
|
|
777
|
+
}
|
|
778
|
+
if (curve instanceof Polygon || curve instanceof Polyline) {
|
|
779
|
+
const pts = curve.points;
|
|
780
|
+
const coords = pointsToCoords(pts);
|
|
781
|
+
const data = new Float64Array(2 + coords.length);
|
|
782
|
+
data[0] = CurveTypeCode.Polyline;
|
|
783
|
+
data[1] = pts.length;
|
|
784
|
+
data.set(coords, 2);
|
|
785
|
+
return data;
|
|
786
|
+
}
|
|
787
|
+
if (curve instanceof NurbsCurve) {
|
|
788
|
+
const n = curve.controlPoints.length;
|
|
789
|
+
const data = new Float64Array(3 + n * 3 + curve.weights.length + curve.knots.length);
|
|
790
|
+
data[0] = CurveTypeCode.NurbsCurve;
|
|
791
|
+
data[1] = curve.degree;
|
|
792
|
+
data[2] = n;
|
|
793
|
+
let idx = 3;
|
|
794
|
+
for (const p of curve.controlPoints) { data[idx++] = p.x; data[idx++] = p.y; data[idx++] = p.z; }
|
|
795
|
+
for (const w of curve.weights) data[idx++] = w;
|
|
796
|
+
for (const k of curve.knots) data[idx++] = k;
|
|
797
|
+
return data;
|
|
798
|
+
}
|
|
799
|
+
if (curve instanceof PolyCurve) {
|
|
800
|
+
const parts: number[] = [CurveTypeCode.PolyCurve, curve.segments.length];
|
|
801
|
+
for (const seg of curve.segments) {
|
|
802
|
+
if (seg instanceof Line) {
|
|
803
|
+
parts.push(SegmentTypeCode.Line, seg.start.x, seg.start.y, seg.start.z, seg.end.x, seg.end.y, seg.end.z);
|
|
804
|
+
} else {
|
|
805
|
+
parts.push(CurveTypeCode.Arc, seg.center.x, seg.center.y, seg.center.z,
|
|
806
|
+
seg.normal.x, seg.normal.y, seg.normal.z,
|
|
807
|
+
seg.radius, seg.startAngle, seg.endAngle);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return new Float64Array(parts);
|
|
811
|
+
}
|
|
812
|
+
// Fallback: sample to polyline
|
|
813
|
+
const pts: Point[] = [];
|
|
814
|
+
for (let i = 0; i <= 32; i++) pts.push((curve as any).pointAt(i / 32));
|
|
815
|
+
const coords = pointsToCoords(pts);
|
|
816
|
+
const data = new Float64Array(2 + coords.length);
|
|
817
|
+
data[0] = CurveTypeCode.Polyline;
|
|
818
|
+
data[1] = pts.length;
|
|
819
|
+
data.set(coords, 2);
|
|
820
|
+
return data;
|
|
821
|
+
}
|
|
822
|
+
private static mergeMeshes(meshes: Mesh[]): Mesh {
|
|
823
|
+
ensureInit();
|
|
824
|
+
const packed = Mesh.packMeshes(meshes);
|
|
825
|
+
return Mesh.fromBuffer(wasm.mesh_merge(packed));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Raycast against many meshes and return all hits sorted by distance.
|
|
830
|
+
*/
|
|
831
|
+
static raycastMany(
|
|
832
|
+
meshes: Mesh[],
|
|
833
|
+
origin: Point,
|
|
834
|
+
direction: Vec3,
|
|
835
|
+
maxDistance = Number.POSITIVE_INFINITY,
|
|
836
|
+
): Array<{ meshIndex: number; point: Point; normal: Vec3; faceIndex: number; distance: number }> {
|
|
837
|
+
ensureInit();
|
|
838
|
+
if (meshes.length === 0) return [];
|
|
839
|
+
|
|
840
|
+
const packed = Mesh.packMeshes(meshes);
|
|
841
|
+
const raycastMany = (wasm as unknown as {
|
|
842
|
+
mesh_raycast_many: (
|
|
843
|
+
meshData: Float64Array,
|
|
844
|
+
ox: number, oy: number, oz: number,
|
|
845
|
+
dx: number, dy: number, dz: number,
|
|
846
|
+
maxDistance: number,
|
|
847
|
+
) => Float64Array;
|
|
848
|
+
}).mesh_raycast_many;
|
|
849
|
+
const buf = raycastMany(
|
|
850
|
+
packed,
|
|
851
|
+
origin.x, origin.y, origin.z,
|
|
852
|
+
direction.x, direction.y, direction.z,
|
|
853
|
+
maxDistance,
|
|
854
|
+
);
|
|
855
|
+
const count = Math.max(0, Math.floor(buf[0] ?? 0));
|
|
856
|
+
const hits: Array<{ meshIndex: number; point: Point; normal: Vec3; faceIndex: number; distance: number }> = [];
|
|
857
|
+
let off = 1;
|
|
858
|
+
for (let i = 0; i < count; i++) {
|
|
859
|
+
hits.push({
|
|
860
|
+
meshIndex: Math.floor(buf[off]),
|
|
861
|
+
point: new Point(buf[off + 1], buf[off + 2], buf[off + 3]),
|
|
862
|
+
normal: new Vec3(buf[off + 4], buf[off + 5], buf[off + 6]),
|
|
863
|
+
faceIndex: Math.floor(buf[off + 7]),
|
|
864
|
+
distance: buf[off + 8],
|
|
865
|
+
});
|
|
866
|
+
off += 9;
|
|
867
|
+
}
|
|
868
|
+
return hits;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private static packMeshes(meshes: Mesh[]): Float64Array {
|
|
872
|
+
const totalLen = meshes.reduce((sum, m) => sum + 1 + m.rawBuffer.length, 1);
|
|
873
|
+
const packed = new Float64Array(totalLen);
|
|
874
|
+
packed[0] = meshes.length;
|
|
875
|
+
let off = 1;
|
|
876
|
+
for (const mesh of meshes) {
|
|
877
|
+
const raw = mesh.rawBuffer;
|
|
878
|
+
packed[off++] = raw.length;
|
|
879
|
+
packed.set(raw, off);
|
|
880
|
+
off += raw.length;
|
|
881
|
+
}
|
|
882
|
+
return packed;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Unique undirected triangle edges as vertex-index pairs.
|
|
887
|
+
*/
|
|
888
|
+
private getUniqueEdgeVertexPairs(): Array<[number, number]> {
|
|
889
|
+
if (this._edgeVertexPairs) return this._edgeVertexPairs;
|
|
890
|
+
|
|
891
|
+
ensureInit();
|
|
892
|
+
const packed = wasm.mesh_get_edge_vertex_pairs(this._vertexCount, this._buffer);
|
|
893
|
+
const edgeCount = Math.max(0, Math.floor(packed[0] ?? 0));
|
|
894
|
+
const pairs: Array<[number, number]> = [];
|
|
895
|
+
let off = 1;
|
|
896
|
+
|
|
897
|
+
for (let i = 0; i < edgeCount; i++) {
|
|
898
|
+
const a = Math.floor(packed[off++] ?? -1);
|
|
899
|
+
const b = Math.floor(packed[off++] ?? -1);
|
|
900
|
+
if (a < 0 || b < 0) break;
|
|
901
|
+
pairs.push([a, b]);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
this._edgeVertexPairs = pairs;
|
|
905
|
+
return this._edgeVertexPairs;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ── Transforms ────────────────────────────────────────────────
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Translate this mesh by an offset vector.
|
|
912
|
+
* @param offset - Translation vector
|
|
913
|
+
* @returns New translated mesh
|
|
914
|
+
*/
|
|
915
|
+
translate(offset: Vec3): Mesh {
|
|
916
|
+
ensureInit();
|
|
917
|
+
return new Mesh(wasm.mesh_translate(this._vertexCount, this._buffer, offset.x, offset.y, offset.z));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Rotate this mesh around an axis.
|
|
922
|
+
* @param axis - Rotation axis (Vec3 through origin, or Line for arbitrary axis)
|
|
923
|
+
* @param angleRadians - Rotation angle in radians
|
|
924
|
+
* @returns New rotated mesh
|
|
925
|
+
*/
|
|
926
|
+
rotate(axis: RotationAxis, angleRadians: number): Mesh {
|
|
927
|
+
ensureInit();
|
|
928
|
+
if ('start' in axis && 'end' in axis) {
|
|
929
|
+
const dir = new Vec3(axis.end.x - axis.start.x, axis.end.y - axis.start.y, axis.end.z - axis.start.z).normalize();
|
|
930
|
+
const c = axis.start;
|
|
931
|
+
return this.translate(new Vec3(-c.x, -c.y, -c.z))
|
|
932
|
+
.rotate(dir, angleRadians)
|
|
933
|
+
.translate(new Vec3(c.x, c.y, c.z));
|
|
934
|
+
}
|
|
935
|
+
return new Mesh(wasm.mesh_rotate(this._vertexCount, this._buffer, axis.x, axis.y, axis.z, angleRadians));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Scale this mesh uniformly.
|
|
940
|
+
* @param factor - Scale factor (1 = no change, 2 = double size)
|
|
941
|
+
* @returns New scaled mesh
|
|
942
|
+
*/
|
|
943
|
+
scale(factor: number): Mesh {
|
|
944
|
+
ensureInit();
|
|
945
|
+
return new Mesh(wasm.mesh_scale(this._vertexCount, this._buffer, factor, factor, factor));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Scale this mesh non-uniformly along each axis.
|
|
950
|
+
* @param sx - Scale factor along X
|
|
951
|
+
* @param sy - Scale factor along Y
|
|
952
|
+
* @param sz - Scale factor along Z
|
|
953
|
+
* @returns New scaled mesh
|
|
954
|
+
*/
|
|
955
|
+
scaleXYZ(sx: number, sy: number, sz: number): Mesh {
|
|
956
|
+
ensureInit();
|
|
957
|
+
return new Mesh(wasm.mesh_scale(this._vertexCount, this._buffer, sx, sy, sz));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private runBoolean(
|
|
961
|
+
other: Mesh,
|
|
962
|
+
operation: "union" | "subtraction" | "intersection",
|
|
963
|
+
invoke: () => Float64Array,
|
|
964
|
+
options?: MeshBooleanOptions,
|
|
965
|
+
): Mesh {
|
|
966
|
+
const faceCountA = this.faceCount;
|
|
967
|
+
const faceCountB = other.faceCount;
|
|
968
|
+
|
|
969
|
+
if (operation === "union") {
|
|
970
|
+
if (faceCountA === 0) return Mesh.cloneMesh(other);
|
|
971
|
+
if (faceCountB === 0) return Mesh.cloneMesh(this);
|
|
972
|
+
} else if (operation === "intersection") {
|
|
973
|
+
if (faceCountA === 0 || faceCountB === 0) return Mesh.emptyMesh();
|
|
974
|
+
} else {
|
|
975
|
+
if (faceCountA === 0) return Mesh.emptyMesh();
|
|
976
|
+
if (faceCountB === 0) return Mesh.cloneMesh(this);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const boundsA = Mesh.computeRawBounds(this);
|
|
980
|
+
const boundsB = Mesh.computeRawBounds(other);
|
|
981
|
+
if (!Mesh.boundsOverlap(boundsA, boundsB)) {
|
|
982
|
+
if (operation === "union") return Mesh.mergeMeshes([this, other]);
|
|
983
|
+
if (operation === "intersection") return Mesh.emptyMesh();
|
|
984
|
+
return Mesh.cloneMesh(this);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (!options?.allowUnsafe) {
|
|
988
|
+
const limits = Mesh.resolveBooleanLimits(options?.limits);
|
|
989
|
+
const maxInputFaces = Math.max(faceCountA, faceCountB);
|
|
990
|
+
const combinedInputFaces = faceCountA + faceCountB;
|
|
991
|
+
const faceProduct = faceCountA * faceCountB;
|
|
992
|
+
if (
|
|
993
|
+
maxInputFaces > limits.maxInputFacesPerMesh
|
|
994
|
+
|| combinedInputFaces > limits.maxCombinedInputFaces
|
|
995
|
+
|| faceProduct > limits.maxFaceProduct
|
|
996
|
+
) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`Boolean ${operation} blocked by safety limits `
|
|
999
|
+
+ `(A faces=${faceCountA}, B faces=${faceCountB}, faceProduct=${faceProduct}). `
|
|
1000
|
+
+ "Simplify inputs, run in a Worker, or pass allowUnsafe: true to force execution.",
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const result = invoke();
|
|
1006
|
+
if (result.length === 0) {
|
|
1007
|
+
throw new Error(`Boolean ${operation} failed and returned an invalid mesh buffer.`);
|
|
1008
|
+
}
|
|
1009
|
+
const vertexCount = result[0];
|
|
1010
|
+
if (!Number.isFinite(vertexCount) || vertexCount < 0) {
|
|
1011
|
+
throw new Error(`Boolean ${operation} failed and returned a corrupt mesh buffer.`);
|
|
1012
|
+
}
|
|
1013
|
+
return Mesh.fromBuffer(result);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
private static encodeBooleanOperationWithBackend(
|
|
1017
|
+
operation: "union" | "subtraction" | "intersection",
|
|
1018
|
+
_backend: MeshBooleanBackend | undefined,
|
|
1019
|
+
): string {
|
|
1020
|
+
return `${operation}@nextgen`;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ── Booleans ───────────────────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Compute boolean union with another mesh.
|
|
1027
|
+
* @param other - Mesh to union with
|
|
1028
|
+
* @param options - Optional safety overrides
|
|
1029
|
+
* @returns New mesh containing volume of both inputs
|
|
1030
|
+
*/
|
|
1031
|
+
union(other: Mesh, options?: MeshBooleanOptions): Mesh {
|
|
1032
|
+
ensureInit();
|
|
1033
|
+
const operationToken = Mesh.encodeBooleanOperationWithBackend("union", options?.backend);
|
|
1034
|
+
return this.runBoolean(
|
|
1035
|
+
other,
|
|
1036
|
+
"union",
|
|
1037
|
+
() => wasm.mesh_boolean_operation(
|
|
1038
|
+
this._vertexCount,
|
|
1039
|
+
this._buffer,
|
|
1040
|
+
other._vertexCount,
|
|
1041
|
+
other._buffer,
|
|
1042
|
+
operationToken,
|
|
1043
|
+
),
|
|
1044
|
+
options,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Compute boolean subtraction with another mesh.
|
|
1050
|
+
* @param other - Mesh to subtract
|
|
1051
|
+
* @param options - Optional safety overrides
|
|
1052
|
+
* @returns New mesh with other's volume removed from this
|
|
1053
|
+
*/
|
|
1054
|
+
subtract(other: Mesh, options?: MeshBooleanOptions): Mesh {
|
|
1055
|
+
ensureInit();
|
|
1056
|
+
const operationToken = Mesh.encodeBooleanOperationWithBackend("subtraction", options?.backend);
|
|
1057
|
+
return this.runBoolean(
|
|
1058
|
+
other,
|
|
1059
|
+
"subtraction",
|
|
1060
|
+
() => wasm.mesh_boolean_operation(
|
|
1061
|
+
this._vertexCount,
|
|
1062
|
+
this._buffer,
|
|
1063
|
+
other._vertexCount,
|
|
1064
|
+
other._buffer,
|
|
1065
|
+
operationToken,
|
|
1066
|
+
),
|
|
1067
|
+
options,
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Compute boolean intersection with another mesh.
|
|
1073
|
+
* @param other - Mesh to intersect with
|
|
1074
|
+
* @param options - Optional safety overrides
|
|
1075
|
+
* @returns New mesh containing only the overlapping volume
|
|
1076
|
+
*/
|
|
1077
|
+
intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
|
|
1078
|
+
ensureInit();
|
|
1079
|
+
const operationToken = Mesh.encodeBooleanOperationWithBackend("intersection", options?.backend);
|
|
1080
|
+
return this.runBoolean(
|
|
1081
|
+
other,
|
|
1082
|
+
"intersection",
|
|
1083
|
+
() => wasm.mesh_boolean_operation(
|
|
1084
|
+
this._vertexCount,
|
|
1085
|
+
this._buffer,
|
|
1086
|
+
other._vertexCount,
|
|
1087
|
+
other._buffer,
|
|
1088
|
+
operationToken,
|
|
1089
|
+
),
|
|
1090
|
+
options,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Compute boolean union in a dedicated Web Worker (non-blocking).
|
|
1096
|
+
* Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
|
|
1097
|
+
*/
|
|
1098
|
+
async unionAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
|
|
1099
|
+
const result = await runMeshBooleanInWorkerPool("union", this._buffer, other._buffer, options);
|
|
1100
|
+
return Mesh.fromBuffer(result);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Compute boolean subtraction in a dedicated Web Worker (non-blocking).
|
|
1105
|
+
* Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
|
|
1106
|
+
*/
|
|
1107
|
+
async subtractAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
|
|
1108
|
+
const result = await runMeshBooleanInWorkerPool("subtraction", this._buffer, other._buffer, options);
|
|
1109
|
+
return Mesh.fromBuffer(result);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Compute boolean intersection in a dedicated Web Worker (non-blocking).
|
|
1114
|
+
* Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
|
|
1115
|
+
*/
|
|
1116
|
+
async intersectAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
|
|
1117
|
+
const result = await runMeshBooleanInWorkerPool("intersection", this._buffer, other._buffer, options);
|
|
1118
|
+
return Mesh.fromBuffer(result);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ── Intersection queries ───────────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Compute intersection curves with a plane.
|
|
1125
|
+
* @param plane - Cutting plane
|
|
1126
|
+
* @returns Array of polylines representing intersection curves
|
|
1127
|
+
*/
|
|
1128
|
+
intersectPlane(plane: Plane): Polyline[] {
|
|
1129
|
+
ensureInit();
|
|
1130
|
+
const result = wasm.mesh_plane_intersect(
|
|
1131
|
+
this._vertexCount,
|
|
1132
|
+
this._buffer,
|
|
1133
|
+
plane.normal.x,
|
|
1134
|
+
plane.normal.y,
|
|
1135
|
+
plane.normal.z,
|
|
1136
|
+
plane.d
|
|
1137
|
+
);
|
|
1138
|
+
return parsePolylineBuf(result).map(pts => new Polyline(pts));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Compute intersection curves with another mesh.
|
|
1143
|
+
* @param other - Mesh to intersect with
|
|
1144
|
+
* @returns Array of polylines representing intersection curves
|
|
1145
|
+
*/
|
|
1146
|
+
intersectMesh(other: Mesh): Polyline[] {
|
|
1147
|
+
ensureInit();
|
|
1148
|
+
const result = wasm.mesh_mesh_intersect(
|
|
1149
|
+
this._vertexCount,
|
|
1150
|
+
this._buffer,
|
|
1151
|
+
other._vertexCount,
|
|
1152
|
+
other._buffer
|
|
1153
|
+
);
|
|
1154
|
+
return parsePolylineBuf(result).map(pts => new Polyline(pts));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Apply a 4x4 transformation matrix.
|
|
1159
|
+
* @param matrix - Row-major flat array of 16 numbers
|
|
1160
|
+
* @returns New transformed mesh
|
|
1161
|
+
*/
|
|
1162
|
+
applyMatrix(matrix: number[]): Mesh {
|
|
1163
|
+
ensureInit();
|
|
1164
|
+
return new Mesh(wasm.mesh_apply_matrix(this._vertexCount, this._buffer, new Float64Array(matrix)));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Evaluate a point on the mesh surface at parametric coordinates.
|
|
1169
|
+
* Maps u ∈ [0,1] and v ∈ [0,1] across the mesh's bounding box and
|
|
1170
|
+
* finds the corresponding surface point via ray casting.
|
|
1171
|
+
*
|
|
1172
|
+
* @param u - Parameter in first surface direction [0, 1]
|
|
1173
|
+
* @param v - Parameter in second surface direction [0, 1]
|
|
1174
|
+
* @returns Point on surface, or Point(NaN, NaN, NaN) if no intersection found
|
|
1175
|
+
*/
|
|
1176
|
+
evaluate(u: number, v: number): Point {
|
|
1177
|
+
ensureInit();
|
|
1178
|
+
const r = wasm.mesh_evaluate(this._vertexCount, this._buffer, u, v);
|
|
1179
|
+
return new Point(r[0], r[1], r[2]);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Extract boundary (perimeter) edges as polylines.
|
|
1184
|
+
* Boundary edges are edges that belong to only one face.
|
|
1185
|
+
* @returns Array of polylines representing open boundaries
|
|
1186
|
+
*/
|
|
1187
|
+
boundaryPolylines(): Polyline[] {
|
|
1188
|
+
ensureInit();
|
|
1189
|
+
const buf = wasm.mesh_boundary_polylines(this._vertexCount, this._buffer);
|
|
1190
|
+
return parsePolylineBuf(buf).map(pts => new Polyline(pts));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Axis-aligned bounds of this mesh.
|
|
1195
|
+
*/
|
|
1196
|
+
getBounds(): { min: Point; max: Point } {
|
|
1197
|
+
ensureInit();
|
|
1198
|
+
const b = wasm.mesh_get_bounds(this._vertexCount, this._buffer);
|
|
1199
|
+
return {
|
|
1200
|
+
min: new Point(b[0] ?? 0, b[1] ?? 0, b[2] ?? 0),
|
|
1201
|
+
max: new Point(b[3] ?? 0, b[4] ?? 0, b[5] ?? 0),
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Unit normal of a triangle face.
|
|
1207
|
+
*/
|
|
1208
|
+
getFaceNormal(faceIndex: number): Vec3 {
|
|
1209
|
+
ensureInit();
|
|
1210
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return Vec3.Y;
|
|
1211
|
+
const n = wasm.mesh_get_face_normal(this._vertexCount, this._buffer, Math.floor(faceIndex));
|
|
1212
|
+
return new Vec3(n[0] ?? 0, n[1] ?? 1, n[2] ?? 0);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Centroid of a triangle face.
|
|
1217
|
+
*/
|
|
1218
|
+
getFaceCentroid(faceIndex: number): Point {
|
|
1219
|
+
ensureInit();
|
|
1220
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return Point.ORIGIN;
|
|
1221
|
+
const c = wasm.mesh_get_face_centroid(this._vertexCount, this._buffer, Math.floor(faceIndex));
|
|
1222
|
+
return new Point(c[0] ?? 0, c[1] ?? 0, c[2] ?? 0);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Area of a triangle face.
|
|
1227
|
+
*/
|
|
1228
|
+
getFaceArea(faceIndex: number): number {
|
|
1229
|
+
ensureInit();
|
|
1230
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return 0;
|
|
1231
|
+
return wasm.mesh_get_face_area(this._vertexCount, this._buffer, Math.floor(faceIndex));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Find edge-connected coplanar triangles that belong to the same planar face.
|
|
1236
|
+
*/
|
|
1237
|
+
getCoplanarFaceIndices(faceIndex: number): number[] {
|
|
1238
|
+
ensureInit();
|
|
1239
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return [];
|
|
1240
|
+
const r = wasm.mesh_get_coplanar_face_indices(this._vertexCount, this._buffer, Math.floor(faceIndex));
|
|
1241
|
+
const count = Math.max(0, Math.floor(r[0] ?? 0));
|
|
1242
|
+
const out: number[] = [];
|
|
1243
|
+
for (let i = 0; i < count; i++) {
|
|
1244
|
+
out.push(Math.floor(r[1 + i] ?? 0));
|
|
1245
|
+
}
|
|
1246
|
+
return out;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Coplanar connected face region and its projection bounds on a plane basis.
|
|
1251
|
+
* Returns null for invalid inputs or empty regions.
|
|
1252
|
+
*/
|
|
1253
|
+
getCoplanarFaceRegion(
|
|
1254
|
+
faceIndex: number,
|
|
1255
|
+
origin: Point,
|
|
1256
|
+
uAxis: Vec3,
|
|
1257
|
+
vAxis: Vec3,
|
|
1258
|
+
marginScale = 0.1,
|
|
1259
|
+
minMargin = 0.2,
|
|
1260
|
+
): { faceIndices: number[]; uMin: number; uMax: number; vMin: number; vMax: number } | null {
|
|
1261
|
+
ensureInit();
|
|
1262
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return null;
|
|
1263
|
+
|
|
1264
|
+
const r = wasm.mesh_get_coplanar_face_region(
|
|
1265
|
+
this._vertexCount,
|
|
1266
|
+
this._buffer,
|
|
1267
|
+
Math.floor(faceIndex),
|
|
1268
|
+
origin.x, origin.y, origin.z,
|
|
1269
|
+
uAxis.x, uAxis.y, uAxis.z,
|
|
1270
|
+
vAxis.x, vAxis.y, vAxis.z,
|
|
1271
|
+
marginScale,
|
|
1272
|
+
minMargin,
|
|
1273
|
+
);
|
|
1274
|
+
|
|
1275
|
+
const count = Math.max(0, Math.floor(r[0] ?? 0));
|
|
1276
|
+
if (count <= 0 || r.length < 1 + count + 4) return null;
|
|
1277
|
+
|
|
1278
|
+
const faceIndices: number[] = [];
|
|
1279
|
+
for (let i = 0; i < count; i++) {
|
|
1280
|
+
faceIndices.push(Math.floor(r[1 + i] ?? 0));
|
|
1281
|
+
}
|
|
1282
|
+
const off = 1 + count;
|
|
1283
|
+
return {
|
|
1284
|
+
faceIndices,
|
|
1285
|
+
uMin: r[off],
|
|
1286
|
+
uMax: r[off + 1],
|
|
1287
|
+
vMin: r[off + 2],
|
|
1288
|
+
vMax: r[off + 3],
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Centroid of the coplanar edge-connected face group containing faceIndex.
|
|
1294
|
+
*/
|
|
1295
|
+
getCoplanarFaceGroupCentroid(faceIndex: number): Point | null {
|
|
1296
|
+
ensureInit();
|
|
1297
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return null;
|
|
1298
|
+
const r = wasm.mesh_get_coplanar_face_group_centroid(
|
|
1299
|
+
this._vertexCount,
|
|
1300
|
+
this._buffer,
|
|
1301
|
+
Math.floor(faceIndex),
|
|
1302
|
+
);
|
|
1303
|
+
if (!r || r.length < 3) return null;
|
|
1304
|
+
return new Point(r[0], r[1], r[2]);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Unique edge count for this triangulated mesh.
|
|
1309
|
+
*/
|
|
1310
|
+
getEdgeCount(): number {
|
|
1311
|
+
return this.getUniqueEdgeVertexPairs().length;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Return edge endpoints by edge index in the unique edge list.
|
|
1316
|
+
*/
|
|
1317
|
+
getEdgeVertices(edgeIndex: number): [Point, Point] {
|
|
1318
|
+
const pairs = this.getUniqueEdgeVertexPairs();
|
|
1319
|
+
if (!Number.isFinite(edgeIndex) || edgeIndex < 0 || edgeIndex >= pairs.length) {
|
|
1320
|
+
return [Point.ORIGIN, Point.ORIGIN];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const [i0, i1] = pairs[Math.floor(edgeIndex)];
|
|
1324
|
+
const pos = this.positionBuffer;
|
|
1325
|
+
const off0 = i0 * 3;
|
|
1326
|
+
const off1 = i1 * 3;
|
|
1327
|
+
|
|
1328
|
+
return [
|
|
1329
|
+
new Point(pos[off0], pos[off0 + 1], pos[off0 + 2]),
|
|
1330
|
+
new Point(pos[off1], pos[off1 + 1], pos[off1 + 2]),
|
|
1331
|
+
];
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Raycast against this mesh and return nearest hit.
|
|
1336
|
+
*/
|
|
1337
|
+
raycast(
|
|
1338
|
+
origin: Point,
|
|
1339
|
+
direction: Vec3,
|
|
1340
|
+
maxDistance = Number.POSITIVE_INFINITY,
|
|
1341
|
+
): { point: Point; normal: Vec3; faceIndex: number; distance: number } | null {
|
|
1342
|
+
ensureInit();
|
|
1343
|
+
const r = wasm.mesh_raycast(
|
|
1344
|
+
this._vertexCount,
|
|
1345
|
+
this._buffer,
|
|
1346
|
+
origin.x, origin.y, origin.z,
|
|
1347
|
+
direction.x, direction.y, direction.z,
|
|
1348
|
+
maxDistance,
|
|
1349
|
+
);
|
|
1350
|
+
if (!r || r.length < 8) return null;
|
|
1351
|
+
return {
|
|
1352
|
+
point: new Point(r[0], r[1], r[2]),
|
|
1353
|
+
normal: new Vec3(r[3], r[4], r[5]),
|
|
1354
|
+
faceIndex: Math.floor(r[6]),
|
|
1355
|
+
distance: r[7],
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Raycast against this mesh and return all hits sorted by distance.
|
|
1361
|
+
*/
|
|
1362
|
+
raycastAll(
|
|
1363
|
+
origin: Point,
|
|
1364
|
+
direction: Vec3,
|
|
1365
|
+
maxDistance = Number.POSITIVE_INFINITY,
|
|
1366
|
+
): Array<{ point: Point; normal: Vec3; faceIndex: number; distance: number }> {
|
|
1367
|
+
ensureInit();
|
|
1368
|
+
const buf = wasm.mesh_raycast_all(
|
|
1369
|
+
this._vertexCount,
|
|
1370
|
+
this._buffer,
|
|
1371
|
+
origin.x, origin.y, origin.z,
|
|
1372
|
+
direction.x, direction.y, direction.z,
|
|
1373
|
+
maxDistance,
|
|
1374
|
+
);
|
|
1375
|
+
const count = Math.max(0, Math.floor(buf[0] ?? 0));
|
|
1376
|
+
const hits: Array<{ point: Point; normal: Vec3; faceIndex: number; distance: number }> = [];
|
|
1377
|
+
let off = 1;
|
|
1378
|
+
for (let i = 0; i < count; i++) {
|
|
1379
|
+
hits.push({
|
|
1380
|
+
point: new Point(buf[off], buf[off + 1], buf[off + 2]),
|
|
1381
|
+
normal: new Vec3(buf[off + 3], buf[off + 4], buf[off + 5]),
|
|
1382
|
+
faceIndex: Math.floor(buf[off + 6]),
|
|
1383
|
+
distance: buf[off + 7],
|
|
1384
|
+
});
|
|
1385
|
+
off += 8;
|
|
1386
|
+
}
|
|
1387
|
+
return hits;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Push/pull a planar face set by moving its coplanar connected region.
|
|
1392
|
+
*/
|
|
1393
|
+
extrudeFace(faceIndex: number, distance: number): Mesh {
|
|
1394
|
+
ensureInit();
|
|
1395
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) {
|
|
1396
|
+
return Mesh.fromBuffer(new Float64Array(this._buffer));
|
|
1397
|
+
}
|
|
1398
|
+
return Mesh.fromBuffer(
|
|
1399
|
+
wasm.mesh_extrude_face(this._vertexCount, this._buffer, Math.floor(faceIndex), distance),
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Check if this triangulated mesh represents a closed volume.
|
|
1405
|
+
* Returns true when no welded topological boundary edges are found.
|
|
1406
|
+
*/
|
|
1407
|
+
isClosedVolume(): boolean {
|
|
1408
|
+
ensureInit();
|
|
1409
|
+
return wasm.mesh_is_closed_volume(this._vertexCount, this._buffer);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Odd/even point containment test against a closed mesh.
|
|
1414
|
+
* Uses majority vote across multiple ray directions for robustness.
|
|
1415
|
+
*/
|
|
1416
|
+
containsPoint(point: Point): boolean {
|
|
1417
|
+
ensureInit();
|
|
1418
|
+
return wasm.mesh_contains_point(this._vertexCount, this._buffer, point.x, point.y, point.z);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* Find the coplanar + edge-connected face group containing a triangle.
|
|
1423
|
+
*/
|
|
1424
|
+
findFaceByTriangleIndex(triangleIndex: number): { centroid: Point; normal: Vec3 } | null {
|
|
1425
|
+
if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
|
|
1426
|
+
ensureInit();
|
|
1427
|
+
const r = wasm.mesh_find_face_group_by_triangle_index(
|
|
1428
|
+
this._vertexCount,
|
|
1429
|
+
this._buffer,
|
|
1430
|
+
Math.floor(triangleIndex),
|
|
1431
|
+
);
|
|
1432
|
+
if (!r || r.length < 6) return null;
|
|
1433
|
+
return {
|
|
1434
|
+
centroid: new Point(r[0], r[1], r[2]),
|
|
1435
|
+
normal: new Vec3(r[3], r[4], r[5]),
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Find the best matching coplanar + edge-connected face group by normal
|
|
1441
|
+
* similarity and optional point proximity.
|
|
1442
|
+
*/
|
|
1443
|
+
findFaceByNormal(targetNormal: Vec3, nearPoint?: Point): { centroid: Point; normal: Vec3 } | null {
|
|
1444
|
+
ensureInit();
|
|
1445
|
+
const r = wasm.mesh_find_face_group_by_normal(
|
|
1446
|
+
this._vertexCount,
|
|
1447
|
+
this._buffer,
|
|
1448
|
+
targetNormal.x,
|
|
1449
|
+
targetNormal.y,
|
|
1450
|
+
targetNormal.z,
|
|
1451
|
+
nearPoint?.x ?? 0,
|
|
1452
|
+
nearPoint?.y ?? 0,
|
|
1453
|
+
nearPoint?.z ?? 0,
|
|
1454
|
+
nearPoint !== undefined,
|
|
1455
|
+
);
|
|
1456
|
+
if (!r || r.length < 6) return null;
|
|
1457
|
+
return {
|
|
1458
|
+
centroid: new Point(r[0], r[1], r[2]),
|
|
1459
|
+
normal: new Vec3(r[3], r[4], r[5]),
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// ── Export ──────────────────────────────────────────────────────
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Export this mesh to OBJ format.
|
|
1467
|
+
* @returns OBJ file content as string
|
|
1468
|
+
*/
|
|
1469
|
+
toOBJ(): string {
|
|
1470
|
+
ensureInit();
|
|
1471
|
+
return wasm.mesh_export_obj(this._vertexCount, this._buffer);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
|