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/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
+