okgeometry-api 1.2.1 → 1.5.0

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 CHANGED
@@ -5,11 +5,11 @@ import { Plane } from "./Plane.js";
5
5
  import { Polyline } from "./Polyline.js";
6
6
  import { Line } from "./Line.js";
7
7
  import { Circle } from "./Circle.js";
8
- import { Arc } from "./Arc.js";
9
- import { Polygon } from "./Polygon.js";
10
- import { MeshSurface } from "./MeshSurface.js";
11
- import { NurbsCurve } from "./NurbsCurve.js";
12
- import { PolyCurve } from "./PolyCurve.js";
8
+ import { Arc } from "./Arc.js";
9
+ import { Polygon } from "./Polygon.js";
10
+ import { MeshSurface } from "./MeshSurface.js";
11
+ import { NurbsCurve } from "./NurbsCurve.js";
12
+ import { PolyCurve } from "./PolyCurve.js";
13
13
  import type { SweepableCurve, RotationAxis } from "./types.js";
14
14
  import { CurveTypeCode, SegmentTypeCode } from "./types.js";
15
15
  import { pointsToCoords, parsePolylineBuffer as parsePolylineBuf } from "./BufferCodec.js";
@@ -48,24 +48,24 @@ export interface PlanarCircle {
48
48
  radius: number;
49
49
  }
50
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
- export interface MeshPlanarFace {
61
- index: number;
62
- seedTriangleIndex: number;
63
- triangleIndices: number[];
64
- triangleCount: number;
65
- centroid: Point;
66
- normal: Vec3;
67
- area: number;
68
- }
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
+ export interface MeshPlanarFace {
61
+ index: number;
62
+ seedTriangleIndex: number;
63
+ triangleIndices: number[];
64
+ triangleCount: number;
65
+ centroid: Point;
66
+ normal: Vec3;
67
+ area: number;
68
+ }
69
69
 
70
70
  interface RawMeshBounds {
71
71
  minX: number;
@@ -188,41 +188,41 @@ export interface MeshBooleanReproPayload {
188
188
  error?: MeshBooleanReproError;
189
189
  }
190
190
 
191
- export interface MeshBooleanReproOptions {
192
- includeResult?: boolean;
193
- debugOptions?: MeshBooleanDebugOptions;
194
- }
195
-
196
- export interface MeshSplitResult {
197
- outside: Mesh[];
198
- inside: Mesh[];
199
- all: Mesh[];
200
- classification?: "volume" | "surface";
201
- negative?: Mesh[];
202
- positive?: Mesh[];
203
- }
204
-
205
- export interface MeshPlaneSplitResult extends MeshSplitResult {
206
- negative: Mesh[];
207
- positive: Mesh[];
208
- }
209
-
210
- export interface MeshPlaneSplitOptions extends MeshBooleanOptions {
211
- planePaddingScale?: number;
212
- }
213
-
214
- export interface MeshCurveSplitOptions extends MeshBooleanOptions {
215
- /**
216
- * Explicit cutter extrusion direction. If omitted, a through-cut is generated
217
- * automatically along the detected curve normal across the host mesh.
218
- */
219
- direction?: Vec3;
220
- /**
221
- * Tessellation density for curved closed profiles such as circles, polycurves,
222
- * and NURBS curves.
223
- */
224
- curveSegments?: number;
225
- }
191
+ export interface MeshBooleanReproOptions {
192
+ includeResult?: boolean;
193
+ debugOptions?: MeshBooleanDebugOptions;
194
+ }
195
+
196
+ export interface MeshSplitResult {
197
+ outside: Mesh[];
198
+ inside: Mesh[];
199
+ all: Mesh[];
200
+ classification?: "volume" | "surface";
201
+ negative?: Mesh[];
202
+ positive?: Mesh[];
203
+ }
204
+
205
+ export interface MeshPlaneSplitResult extends MeshSplitResult {
206
+ negative: Mesh[];
207
+ positive: Mesh[];
208
+ }
209
+
210
+ export interface MeshPlaneSplitOptions extends MeshBooleanOptions {
211
+ planePaddingScale?: number;
212
+ }
213
+
214
+ export interface MeshCurveSplitOptions extends MeshBooleanOptions {
215
+ /**
216
+ * Explicit cutter extrusion direction. If omitted, a through-cut is generated
217
+ * automatically along the detected curve normal across the host mesh.
218
+ */
219
+ direction?: Vec3;
220
+ /**
221
+ * Tessellation density for curved closed profiles such as circles, polycurves,
222
+ * and NURBS curves.
223
+ */
224
+ curveSegments?: number;
225
+ }
226
226
 
227
227
  function shouldFallbackFromWorkerFailure(error: unknown): boolean {
228
228
  if (!error || typeof error !== "object") return false;
@@ -258,17 +258,17 @@ export class Mesh {
258
258
 
259
259
  // Lazy caches
260
260
  private _positionBuffer: Float32Array | null = null;
261
- private _indexBuffer: Uint32Array | null = null;
262
- private _vertices: Point[] | null = null;
263
- private _faces: number[][] | null = null;
264
- private _planarFaces: MeshPlanarFace[] | null = null;
265
- private _planarFacesByTriangleIndex: Array<MeshPlanarFace | null> | null = null;
266
- private _surfaces: MeshSurface[] | null = null;
267
- private _surfacesByTriangleIndex: Array<MeshSurface | null> | null = null;
268
- private _edgeVertexPairs: Array<[number, number]> | null = null;
269
- private _topologyMetricsCache: { boundaryEdges: number; nonManifoldEdges: number } | null = null;
270
- private _isClosedVolumeCache: boolean | null = null;
271
- private _rawBoundsCache: RawMeshBounds | null | undefined = undefined;
261
+ private _indexBuffer: Uint32Array | null = null;
262
+ private _vertices: Point[] | null = null;
263
+ private _faces: number[][] | null = null;
264
+ private _planarFaces: MeshPlanarFace[] | null = null;
265
+ private _planarFacesByTriangleIndex: Array<MeshPlanarFace | null> | null = null;
266
+ private _surfaces: MeshSurface[] | null = null;
267
+ private _surfacesByTriangleIndex: Array<MeshSurface | null> | null = null;
268
+ private _edgeVertexPairs: Array<[number, number]> | null = null;
269
+ private _topologyMetricsCache: { boundaryEdges: number; nonManifoldEdges: number } | null = null;
270
+ private _isClosedVolumeCache: boolean | null = null;
271
+ private _rawBoundsCache: RawMeshBounds | null | undefined = undefined;
272
272
 
273
273
  private constructor(buffer: Float64Array, trustedBooleanInput = false) {
274
274
  this._buffer = buffer;
@@ -345,68 +345,68 @@ export class Mesh {
345
345
  && a.maxZ + eps >= b.minZ;
346
346
  }
347
347
 
348
- private static boundsDiag(bounds: RawMeshBounds | null): number {
349
- if (!bounds) return 1;
350
- const dx = bounds.maxX - bounds.minX;
351
- const dy = bounds.maxY - bounds.minY;
352
- const dz = bounds.maxZ - bounds.minZ;
353
- const diag = Math.hypot(dx, dy, dz);
354
- return Number.isFinite(diag) && diag > 0 ? diag : 1;
355
- }
356
-
357
- private static computeSignedVolumeFromBuffer(buffer: Float64Array): number {
358
- const vertexCount = Math.max(0, Math.floor(buffer[0] ?? 0));
359
- if (vertexCount <= 0) return 0;
360
-
361
- const indexStart = 1 + vertexCount * 3;
362
- let signedVolume = 0;
363
- for (let i = indexStart; i + 2 < buffer.length; i += 3) {
364
- const ia = Math.floor(buffer[i] ?? -1);
365
- const ib = Math.floor(buffer[i + 1] ?? -1);
366
- const ic = Math.floor(buffer[i + 2] ?? -1);
367
- if (ia < 0 || ib < 0 || ic < 0 || ia >= vertexCount || ib >= vertexCount || ic >= vertexCount) continue;
368
-
369
- const ax = buffer[1 + ia * 3];
370
- const ay = buffer[1 + ia * 3 + 1];
371
- const az = buffer[1 + ia * 3 + 2];
372
- const bx = buffer[1 + ib * 3];
373
- const by = buffer[1 + ib * 3 + 1];
374
- const bz = buffer[1 + ib * 3 + 2];
375
- const cx = buffer[1 + ic * 3];
376
- const cy = buffer[1 + ic * 3 + 1];
377
- const cz = buffer[1 + ic * 3 + 2];
378
-
379
- signedVolume += ax * (by * cz - bz * cy)
380
- - ay * (bx * cz - bz * cx)
381
- + az * (bx * cy - by * cx);
382
- }
383
-
384
- return signedVolume / 6;
385
- }
386
-
387
- private static reverseTriangleWinding(buffer: Float64Array): Float64Array {
388
- const flipped = new Float64Array(buffer);
389
- const vertexCount = Math.max(0, Math.floor(flipped[0] ?? 0));
390
- const indexStart = 1 + vertexCount * 3;
391
- for (let i = indexStart; i + 2 < flipped.length; i += 3) {
392
- const tmp = flipped[i + 1];
393
- flipped[i + 1] = flipped[i + 2];
394
- flipped[i + 2] = tmp;
395
- }
396
- return flipped;
397
- }
398
-
399
- private static normalizeClosedVolumeOrientation(mesh: Mesh): Mesh {
400
- if (mesh.faceCount === 0 || mesh._vertexCount <= 0) return mesh;
401
-
402
- const topology = mesh.topologyMetrics();
403
- if (topology.boundaryEdges !== 0 || topology.nonManifoldEdges !== 0) return mesh;
404
-
405
- const signedVolume = Mesh.computeSignedVolumeFromBuffer(mesh._buffer);
406
- if (!Number.isFinite(signedVolume) || signedVolume >= 0) return mesh;
407
-
408
- return new Mesh(Mesh.reverseTriangleWinding(mesh._buffer), mesh._trustedBooleanInput);
409
- }
348
+ private static boundsDiag(bounds: RawMeshBounds | null): number {
349
+ if (!bounds) return 1;
350
+ const dx = bounds.maxX - bounds.minX;
351
+ const dy = bounds.maxY - bounds.minY;
352
+ const dz = bounds.maxZ - bounds.minZ;
353
+ const diag = Math.hypot(dx, dy, dz);
354
+ return Number.isFinite(diag) && diag > 0 ? diag : 1;
355
+ }
356
+
357
+ private static computeSignedVolumeFromBuffer(buffer: Float64Array): number {
358
+ const vertexCount = Math.max(0, Math.floor(buffer[0] ?? 0));
359
+ if (vertexCount <= 0) return 0;
360
+
361
+ const indexStart = 1 + vertexCount * 3;
362
+ let signedVolume = 0;
363
+ for (let i = indexStart; i + 2 < buffer.length; i += 3) {
364
+ const ia = Math.floor(buffer[i] ?? -1);
365
+ const ib = Math.floor(buffer[i + 1] ?? -1);
366
+ const ic = Math.floor(buffer[i + 2] ?? -1);
367
+ if (ia < 0 || ib < 0 || ic < 0 || ia >= vertexCount || ib >= vertexCount || ic >= vertexCount) continue;
368
+
369
+ const ax = buffer[1 + ia * 3];
370
+ const ay = buffer[1 + ia * 3 + 1];
371
+ const az = buffer[1 + ia * 3 + 2];
372
+ const bx = buffer[1 + ib * 3];
373
+ const by = buffer[1 + ib * 3 + 1];
374
+ const bz = buffer[1 + ib * 3 + 2];
375
+ const cx = buffer[1 + ic * 3];
376
+ const cy = buffer[1 + ic * 3 + 1];
377
+ const cz = buffer[1 + ic * 3 + 2];
378
+
379
+ signedVolume += ax * (by * cz - bz * cy)
380
+ - ay * (bx * cz - bz * cx)
381
+ + az * (bx * cy - by * cx);
382
+ }
383
+
384
+ return signedVolume / 6;
385
+ }
386
+
387
+ private static reverseTriangleWinding(buffer: Float64Array): Float64Array {
388
+ const flipped = new Float64Array(buffer);
389
+ const vertexCount = Math.max(0, Math.floor(flipped[0] ?? 0));
390
+ const indexStart = 1 + vertexCount * 3;
391
+ for (let i = indexStart; i + 2 < flipped.length; i += 3) {
392
+ const tmp = flipped[i + 1];
393
+ flipped[i + 1] = flipped[i + 2];
394
+ flipped[i + 2] = tmp;
395
+ }
396
+ return flipped;
397
+ }
398
+
399
+ private static normalizeClosedVolumeOrientation(mesh: Mesh): Mesh {
400
+ if (mesh.faceCount === 0 || mesh._vertexCount <= 0) return mesh;
401
+
402
+ const topology = mesh.topologyMetrics();
403
+ if (topology.boundaryEdges !== 0 || topology.nonManifoldEdges !== 0) return mesh;
404
+
405
+ const signedVolume = Mesh.computeSignedVolumeFromBuffer(mesh._buffer);
406
+ if (!Number.isFinite(signedVolume) || signedVolume >= 0) return mesh;
407
+
408
+ return new Mesh(Mesh.reverseTriangleWinding(mesh._buffer), mesh._trustedBooleanInput);
409
+ }
410
410
 
411
411
  private static booleanContactTolerance(a: RawMeshBounds | null, b: RawMeshBounds | null): number {
412
412
  const scale = Math.max(Mesh.boundsDiag(a), Mesh.boundsDiag(b));
@@ -742,203 +742,203 @@ export class Mesh {
742
742
  });
743
743
  }
744
744
 
745
- private static emptyMesh(): Mesh {
746
- return Mesh.fromTrustedBuffer(new Float64Array(0));
747
- }
748
-
749
- private static createSplitResult(
750
- outside: Mesh[],
751
- inside: Mesh[],
752
- classification: "volume" | "surface" = "volume",
753
- ): MeshSplitResult {
754
- const result: MeshSplitResult = {
755
- outside,
756
- inside,
757
- all: outside.concat(inside),
758
- classification,
759
- };
760
- if (classification === "surface") {
761
- result.negative = outside;
762
- result.positive = inside;
763
- }
764
- return result;
765
- }
766
-
767
- private static createSurfaceSplitResult(negative: Mesh[], positive: Mesh[]): MeshSplitResult {
768
- return Mesh.createSplitResult(negative, positive, "surface");
769
- }
770
-
771
- private static createPlaneSplitResult(
772
- negative: Mesh[],
773
- positive: Mesh[],
774
- classification: "volume" | "surface" = "surface",
775
- ): MeshPlaneSplitResult {
776
- return {
777
- ...Mesh.createSplitResult(negative, positive, classification),
778
- negative,
779
- positive,
780
- };
781
- }
782
-
783
- private static parseSplitResultBuffer(buffer: Float64Array): MeshSplitResult {
784
- if (buffer.length < 2) {
785
- throw new Error("Mesh split failed and returned a corrupt split buffer.");
786
- }
787
-
788
- const outsideCount = Math.max(0, Math.floor(buffer[0] ?? 0));
789
- const insideCount = Math.max(0, Math.floor(buffer[1] ?? 0));
790
- let offset = 2;
791
-
792
- const decodeGroup = (count: number): Mesh[] => {
793
- const meshes: Mesh[] = [];
794
- for (let i = 0; i < count; i++) {
795
- const rawLen = Math.floor(buffer[offset++] ?? -1);
796
- if (rawLen < 0 || offset + rawLen > buffer.length) {
797
- throw new Error("Mesh split failed and returned an invalid component buffer.");
798
- }
799
- meshes.push(Mesh.fromTrustedBuffer(buffer.slice(offset, offset + rawLen)));
800
- offset += rawLen;
801
- }
802
- return meshes;
803
- };
804
-
805
- const outside = decodeGroup(outsideCount);
806
- const inside = decodeGroup(insideCount);
807
- if (offset !== buffer.length) {
808
- throw new Error("Mesh split failed and returned trailing buffer data.");
809
- }
810
- return Mesh.createSplitResult(outside, inside);
811
- }
812
-
813
- private static orthonormalPlaneFrame(plane: Plane): { uAxis: Vec3; vAxis: Vec3; normal: Vec3 } {
814
- const normal = plane.normal.normalize();
815
- let uAxis = plane.getXAxis().normalize();
816
- if (uAxis.length() < 1e-9) {
817
- uAxis = Math.abs(normal.x) > 0.9 ? Vec3.Y : Vec3.X;
818
- uAxis = uAxis.sub(normal.scale(uAxis.dot(normal))).normalize();
819
- }
820
- const vAxis = normal.cross(uAxis).normalize();
821
- return { uAxis, vAxis, normal };
822
- }
823
-
824
- private static createOrientedBox(
825
- center: Point,
826
- uAxis: Vec3,
827
- vAxis: Vec3,
828
- wAxis: Vec3,
829
- halfU: number,
830
- halfV: number,
831
- halfW: number,
832
- ): Mesh {
833
- const axes = [uAxis.normalize(), vAxis.normalize(), wAxis.normalize()] as const;
834
- const [u, v, w] = axes;
835
- const corners = [
836
- center.add(u.scale(-halfU)).add(v.scale(-halfV)).add(w.scale(-halfW)),
837
- center.add(u.scale(halfU)).add(v.scale(-halfV)).add(w.scale(-halfW)),
838
- center.add(u.scale(halfU)).add(v.scale(halfV)).add(w.scale(-halfW)),
839
- center.add(u.scale(-halfU)).add(v.scale(halfV)).add(w.scale(-halfW)),
840
- center.add(u.scale(-halfU)).add(v.scale(-halfV)).add(w.scale(halfW)),
841
- center.add(u.scale(halfU)).add(v.scale(-halfV)).add(w.scale(halfW)),
842
- center.add(u.scale(halfU)).add(v.scale(halfV)).add(w.scale(halfW)),
843
- center.add(u.scale(-halfU)).add(v.scale(halfV)).add(w.scale(halfW)),
844
- ];
845
- const indices = [
846
- 0, 2, 1, 0, 3, 2,
847
- 4, 5, 6, 4, 6, 7,
848
- 0, 1, 5, 0, 5, 4,
849
- 3, 7, 6, 3, 6, 2,
850
- 0, 4, 7, 0, 7, 3,
851
- 1, 2, 6, 1, 6, 5,
852
- ];
853
- const raw = new Float64Array(1 + corners.length * 3 + indices.length);
854
- raw[0] = corners.length;
855
- let offset = 1;
856
- for (const corner of corners) {
857
- raw[offset++] = corner.x;
858
- raw[offset++] = corner.y;
859
- raw[offset++] = corner.z;
860
- }
861
- for (const index of indices) {
862
- raw[offset++] = index;
863
- }
864
- return Mesh.fromBuffer(raw, { trustedBooleanInput: true });
865
- }
866
-
867
- private static classifyPlaneDistances(mesh: Mesh, plane: Plane): { min: number; max: number } {
868
- if (mesh.vertexCount <= 0) return { min: 0, max: 0 };
869
- let min = Number.POSITIVE_INFINITY;
870
- let max = Number.NEGATIVE_INFINITY;
871
- for (const vertex of mesh.vertices) {
872
- const distance = plane.distanceTo(vertex);
873
- if (distance < min) min = distance;
874
- if (distance > max) max = distance;
875
- }
876
- return { min, max };
877
- }
878
-
879
- private static createPlaneHalfSpaceCutter(
880
- host: Mesh,
881
- plane: Plane,
882
- options?: MeshPlaneSplitOptions,
883
- ): Mesh {
884
- const bounds = Mesh.toDebugBounds(host.getBounds());
885
- const scale = Number.isFinite(options?.planePaddingScale)
886
- ? Math.max(1.1, options?.planePaddingScale ?? 2.5)
887
- : 2.5;
888
- const radius = Math.max(1e-6, bounds.diagonal * 0.5);
889
- const halfU = radius * scale;
890
- const halfV = radius * scale;
891
- const halfW = radius * scale;
892
- const { uAxis, vAxis, normal } = Mesh.orthonormalPlaneFrame(plane);
893
- const projectedCenter = plane.projectPoint(bounds.center);
894
- const boxCenter = projectedCenter.add(normal.scale(halfW));
895
- return Mesh.createOrientedBox(boxCenter, uAxis, vAxis, normal, halfU, halfV, halfW);
896
- }
897
-
898
- private static detectClosedCurveNormal(curve: SweepableCurve, points: Point[]): Vec3 {
899
- if (curve instanceof Circle || curve instanceof Arc) {
900
- return curve.normal.normalize();
901
- }
902
- const normal = Mesh.computePlanarCurveNormal(points, true).normalize();
903
- if (!Number.isFinite(normal.x) || !Number.isFinite(normal.y) || !Number.isFinite(normal.z) || normal.length() < 1e-9) {
904
- throw new Error("Mesh.split(curve) requires a planar closed curve with a valid normal.");
905
- }
906
- return normal;
907
- }
908
-
909
- private static createCurveSplitCutter(
910
- host: Mesh,
911
- curve: SweepableCurve,
912
- options?: MeshCurveSplitOptions,
913
- ): Mesh {
914
- const segments = Number.isFinite(options?.curveSegments)
915
- ? Math.max(3, Math.floor(options?.curveSegments ?? 32))
916
- : 32;
917
- const points = Mesh.getClosedCurveLoopPoints(curve, segments);
918
- const normal = Mesh.detectClosedCurveNormal(curve, points);
919
-
920
- let extrusionDirection = options?.direction;
921
- let basePoints = points;
922
- if (extrusionDirection) {
923
- if (!Number.isFinite(extrusionDirection.length()) || extrusionDirection.length() < 1e-9) {
924
- throw new Error("Mesh.split(curve) requires a non-zero direction when direction is provided.");
925
- }
926
- } else {
927
- const bounds = Mesh.toDebugBounds(host.getBounds());
928
- const margin = Math.max(1e-6, bounds.diagonal * 0.25);
929
- const distances = host.vertices.map((vertex) => normal.dot(vertex.sub(points[0])));
930
- const minDistance = Math.min(...distances);
931
- const maxDistance = Math.max(...distances);
932
- const halfDepth = Math.max(Math.abs(minDistance), Math.abs(maxDistance)) + margin;
933
- extrusionDirection = normal.scale(halfDepth * 2);
934
- basePoints = points.map((point) => point.add(normal.scale(-halfDepth)));
935
- }
936
-
937
- const height = extrusionDirection.length();
938
- const unit = extrusionDirection.normalize();
939
- const prepared = Mesh.prepareBooleanCutterCurve(basePoints, true, unit, height);
940
- return Mesh.extrudePlanarCurve(prepared.points, unit, prepared.height, true);
941
- }
745
+ private static emptyMesh(): Mesh {
746
+ return Mesh.fromTrustedBuffer(new Float64Array(0));
747
+ }
748
+
749
+ private static createSplitResult(
750
+ outside: Mesh[],
751
+ inside: Mesh[],
752
+ classification: "volume" | "surface" = "volume",
753
+ ): MeshSplitResult {
754
+ const result: MeshSplitResult = {
755
+ outside,
756
+ inside,
757
+ all: outside.concat(inside),
758
+ classification,
759
+ };
760
+ if (classification === "surface") {
761
+ result.negative = outside;
762
+ result.positive = inside;
763
+ }
764
+ return result;
765
+ }
766
+
767
+ private static createSurfaceSplitResult(negative: Mesh[], positive: Mesh[]): MeshSplitResult {
768
+ return Mesh.createSplitResult(negative, positive, "surface");
769
+ }
770
+
771
+ private static createPlaneSplitResult(
772
+ negative: Mesh[],
773
+ positive: Mesh[],
774
+ classification: "volume" | "surface" = "surface",
775
+ ): MeshPlaneSplitResult {
776
+ return {
777
+ ...Mesh.createSplitResult(negative, positive, classification),
778
+ negative,
779
+ positive,
780
+ };
781
+ }
782
+
783
+ private static parseSplitResultBuffer(buffer: Float64Array): MeshSplitResult {
784
+ if (buffer.length < 2) {
785
+ throw new Error("Mesh split failed and returned a corrupt split buffer.");
786
+ }
787
+
788
+ const outsideCount = Math.max(0, Math.floor(buffer[0] ?? 0));
789
+ const insideCount = Math.max(0, Math.floor(buffer[1] ?? 0));
790
+ let offset = 2;
791
+
792
+ const decodeGroup = (count: number): Mesh[] => {
793
+ const meshes: Mesh[] = [];
794
+ for (let i = 0; i < count; i++) {
795
+ const rawLen = Math.floor(buffer[offset++] ?? -1);
796
+ if (rawLen < 0 || offset + rawLen > buffer.length) {
797
+ throw new Error("Mesh split failed and returned an invalid component buffer.");
798
+ }
799
+ meshes.push(Mesh.fromTrustedBuffer(buffer.slice(offset, offset + rawLen)));
800
+ offset += rawLen;
801
+ }
802
+ return meshes;
803
+ };
804
+
805
+ const outside = decodeGroup(outsideCount);
806
+ const inside = decodeGroup(insideCount);
807
+ if (offset !== buffer.length) {
808
+ throw new Error("Mesh split failed and returned trailing buffer data.");
809
+ }
810
+ return Mesh.createSplitResult(outside, inside);
811
+ }
812
+
813
+ private static orthonormalPlaneFrame(plane: Plane): { uAxis: Vec3; vAxis: Vec3; normal: Vec3 } {
814
+ const normal = plane.normal.normalize();
815
+ let uAxis = plane.getXAxis().normalize();
816
+ if (uAxis.length() < 1e-9) {
817
+ uAxis = Math.abs(normal.x) > 0.9 ? Vec3.Y : Vec3.X;
818
+ uAxis = uAxis.sub(normal.scale(uAxis.dot(normal))).normalize();
819
+ }
820
+ const vAxis = normal.cross(uAxis).normalize();
821
+ return { uAxis, vAxis, normal };
822
+ }
823
+
824
+ private static createOrientedBox(
825
+ center: Point,
826
+ uAxis: Vec3,
827
+ vAxis: Vec3,
828
+ wAxis: Vec3,
829
+ halfU: number,
830
+ halfV: number,
831
+ halfW: number,
832
+ ): Mesh {
833
+ const axes = [uAxis.normalize(), vAxis.normalize(), wAxis.normalize()] as const;
834
+ const [u, v, w] = axes;
835
+ const corners = [
836
+ center.add(u.scale(-halfU)).add(v.scale(-halfV)).add(w.scale(-halfW)),
837
+ center.add(u.scale(halfU)).add(v.scale(-halfV)).add(w.scale(-halfW)),
838
+ center.add(u.scale(halfU)).add(v.scale(halfV)).add(w.scale(-halfW)),
839
+ center.add(u.scale(-halfU)).add(v.scale(halfV)).add(w.scale(-halfW)),
840
+ center.add(u.scale(-halfU)).add(v.scale(-halfV)).add(w.scale(halfW)),
841
+ center.add(u.scale(halfU)).add(v.scale(-halfV)).add(w.scale(halfW)),
842
+ center.add(u.scale(halfU)).add(v.scale(halfV)).add(w.scale(halfW)),
843
+ center.add(u.scale(-halfU)).add(v.scale(halfV)).add(w.scale(halfW)),
844
+ ];
845
+ const indices = [
846
+ 0, 2, 1, 0, 3, 2,
847
+ 4, 5, 6, 4, 6, 7,
848
+ 0, 1, 5, 0, 5, 4,
849
+ 3, 7, 6, 3, 6, 2,
850
+ 0, 4, 7, 0, 7, 3,
851
+ 1, 2, 6, 1, 6, 5,
852
+ ];
853
+ const raw = new Float64Array(1 + corners.length * 3 + indices.length);
854
+ raw[0] = corners.length;
855
+ let offset = 1;
856
+ for (const corner of corners) {
857
+ raw[offset++] = corner.x;
858
+ raw[offset++] = corner.y;
859
+ raw[offset++] = corner.z;
860
+ }
861
+ for (const index of indices) {
862
+ raw[offset++] = index;
863
+ }
864
+ return Mesh.fromBuffer(raw, { trustedBooleanInput: true });
865
+ }
866
+
867
+ private static classifyPlaneDistances(mesh: Mesh, plane: Plane): { min: number; max: number } {
868
+ if (mesh.vertexCount <= 0) return { min: 0, max: 0 };
869
+ let min = Number.POSITIVE_INFINITY;
870
+ let max = Number.NEGATIVE_INFINITY;
871
+ for (const vertex of mesh.vertices) {
872
+ const distance = plane.distanceTo(vertex);
873
+ if (distance < min) min = distance;
874
+ if (distance > max) max = distance;
875
+ }
876
+ return { min, max };
877
+ }
878
+
879
+ private static createPlaneHalfSpaceCutter(
880
+ host: Mesh,
881
+ plane: Plane,
882
+ options?: MeshPlaneSplitOptions,
883
+ ): Mesh {
884
+ const bounds = Mesh.toDebugBounds(host.getBounds());
885
+ const scale = Number.isFinite(options?.planePaddingScale)
886
+ ? Math.max(1.1, options?.planePaddingScale ?? 2.5)
887
+ : 2.5;
888
+ const radius = Math.max(1e-6, bounds.diagonal * 0.5);
889
+ const halfU = radius * scale;
890
+ const halfV = radius * scale;
891
+ const halfW = radius * scale;
892
+ const { uAxis, vAxis, normal } = Mesh.orthonormalPlaneFrame(plane);
893
+ const projectedCenter = plane.projectPoint(bounds.center);
894
+ const boxCenter = projectedCenter.add(normal.scale(halfW));
895
+ return Mesh.createOrientedBox(boxCenter, uAxis, vAxis, normal, halfU, halfV, halfW);
896
+ }
897
+
898
+ private static detectClosedCurveNormal(curve: SweepableCurve, points: Point[]): Vec3 {
899
+ if (curve instanceof Circle || curve instanceof Arc) {
900
+ return curve.normal.normalize();
901
+ }
902
+ const normal = Mesh.computePlanarCurveNormal(points, true).normalize();
903
+ if (!Number.isFinite(normal.x) || !Number.isFinite(normal.y) || !Number.isFinite(normal.z) || normal.length() < 1e-9) {
904
+ throw new Error("Mesh.split(curve) requires a planar closed curve with a valid normal.");
905
+ }
906
+ return normal;
907
+ }
908
+
909
+ private static createCurveSplitCutter(
910
+ host: Mesh,
911
+ curve: SweepableCurve,
912
+ options?: MeshCurveSplitOptions,
913
+ ): Mesh {
914
+ const segments = Number.isFinite(options?.curveSegments)
915
+ ? Math.max(3, Math.floor(options?.curveSegments ?? 32))
916
+ : 32;
917
+ const points = Mesh.getClosedCurveLoopPoints(curve, segments);
918
+ const normal = Mesh.detectClosedCurveNormal(curve, points);
919
+
920
+ let extrusionDirection = options?.direction;
921
+ let basePoints = points;
922
+ if (extrusionDirection) {
923
+ if (!Number.isFinite(extrusionDirection.length()) || extrusionDirection.length() < 1e-9) {
924
+ throw new Error("Mesh.split(curve) requires a non-zero direction when direction is provided.");
925
+ }
926
+ } else {
927
+ const bounds = Mesh.toDebugBounds(host.getBounds());
928
+ const margin = Math.max(1e-6, bounds.diagonal * 0.25);
929
+ const distances = host.vertices.map((vertex) => normal.dot(vertex.sub(points[0])));
930
+ const minDistance = Math.min(...distances);
931
+ const maxDistance = Math.max(...distances);
932
+ const halfDepth = Math.max(Math.abs(minDistance), Math.abs(maxDistance)) + margin;
933
+ extrusionDirection = normal.scale(halfDepth * 2);
934
+ basePoints = points.map((point) => point.add(normal.scale(-halfDepth)));
935
+ }
936
+
937
+ const height = extrusionDirection.length();
938
+ const unit = extrusionDirection.normalize();
939
+ const prepared = Mesh.prepareBooleanCutterCurve(basePoints, true, unit, height);
940
+ return Mesh.extrudePlanarCurve(prepared.points, unit, prepared.height, true);
941
+ }
942
942
 
943
943
  // ── GPU-ready buffers ──────────────────────────────────────────
944
944
 
@@ -978,12 +978,12 @@ export class Mesh {
978
978
  return this._vertexCount;
979
979
  }
980
980
 
981
- /** Number of triangular faces in this mesh. Use `planarFaceCount` for logical planar faces. */
982
- get faceCount(): number {
983
- const start = 1 + this._vertexCount * 3;
984
- if (this._buffer.length <= start) return 0;
985
- return Math.floor((this._buffer.length - start) / 3);
986
- }
981
+ /** Number of triangular faces in this mesh. Use `planarFaceCount` for logical planar faces. */
982
+ get faceCount(): number {
983
+ const start = 1 + this._vertexCount * 3;
984
+ if (this._buffer.length <= start) return 0;
985
+ return Math.floor((this._buffer.length - start) / 3);
986
+ }
987
987
 
988
988
  // ── High-level accessors (lazy) ────────────────────────────────
989
989
 
@@ -1003,64 +1003,64 @@ export class Mesh {
1003
1003
  return this._vertices;
1004
1004
  }
1005
1005
 
1006
- /**
1007
- * Get all faces as arrays of vertex indices.
1008
- * Each face is [i0, i1, i2] for the three triangle vertices.
1009
- * For coplanar edge-connected logical faces, use `planarFaces`.
1010
- * Lazy-computed and cached.
1011
- */
1012
- get faces(): number[][] {
1013
- if (!this._faces) {
1014
- const idx = this.indexBuffer;
1006
+ /**
1007
+ * Get all faces as arrays of vertex indices.
1008
+ * Each face is [i0, i1, i2] for the three triangle vertices.
1009
+ * For coplanar edge-connected logical faces, use `planarFaces`.
1010
+ * Lazy-computed and cached.
1011
+ */
1012
+ get faces(): number[][] {
1013
+ if (!this._faces) {
1014
+ const idx = this.indexBuffer;
1015
1015
  const f: number[][] = [];
1016
1016
  for (let i = 0; i < idx.length; i += 3) {
1017
1017
  f.push([idx[i], idx[i + 1], idx[i + 2]]);
1018
1018
  }
1019
1019
  this._faces = f;
1020
- }
1021
- return this._faces;
1022
- }
1023
-
1024
- /**
1025
- * Alias for triangle faces, mirroring `faces`.
1026
- * Use `planarFaces` when you want logical mesh faces such as the 6 faces of a box.
1027
- */
1028
- get triangles(): number[][] {
1029
- return this.faces;
1030
- }
1031
-
1032
- /**
1033
- * Number of coplanar edge-connected planar faces in this mesh.
1034
- * Examples: a box has 6, an L-shaped extrusion has 8.
1035
- */
1036
- get planarFaceCount(): number {
1037
- return this.planarFaces.length;
1038
- }
1039
-
1040
- /**
1041
- * Logical mesh faces built by grouping coplanar edge-connected triangles.
1042
- * Cached after the first query to keep repeated face access cheap.
1043
- */
1044
- get planarFaces(): MeshPlanarFace[] {
1045
- this.ensurePlanarFaceCache();
1046
- return this._planarFaces ?? [];
1047
- }
1048
-
1049
- /**
1050
- * Number of logical planar surfaces in this mesh.
1051
- * Mirrors `planarFaceCount`, but returns wrapper objects with surface queries.
1052
- */
1053
- get surfaceCount(): number {
1054
- return this.surfaces.length;
1055
- }
1056
-
1057
- /**
1058
- * Logical planar mesh surfaces with `evaluate(u, v)` and `normal(u, v)` methods.
1059
- */
1060
- get surfaces(): MeshSurface[] {
1061
- this.ensureSurfaceCache();
1062
- return this._surfaces ?? [];
1063
- }
1020
+ }
1021
+ return this._faces;
1022
+ }
1023
+
1024
+ /**
1025
+ * Alias for triangle faces, mirroring `faces`.
1026
+ * Use `planarFaces` when you want logical mesh faces such as the 6 faces of a box.
1027
+ */
1028
+ get triangles(): number[][] {
1029
+ return this.faces;
1030
+ }
1031
+
1032
+ /**
1033
+ * Number of coplanar edge-connected planar faces in this mesh.
1034
+ * Examples: a box has 6, an L-shaped extrusion has 8.
1035
+ */
1036
+ get planarFaceCount(): number {
1037
+ return this.planarFaces.length;
1038
+ }
1039
+
1040
+ /**
1041
+ * Logical mesh faces built by grouping coplanar edge-connected triangles.
1042
+ * Cached after the first query to keep repeated face access cheap.
1043
+ */
1044
+ get planarFaces(): MeshPlanarFace[] {
1045
+ this.ensurePlanarFaceCache();
1046
+ return this._planarFaces ?? [];
1047
+ }
1048
+
1049
+ /**
1050
+ * Number of logical planar surfaces in this mesh.
1051
+ * Mirrors `planarFaceCount`, but returns wrapper objects with surface queries.
1052
+ */
1053
+ get surfaceCount(): number {
1054
+ return this.surfaces.length;
1055
+ }
1056
+
1057
+ /**
1058
+ * Logical planar mesh surfaces with `evaluate(u, v)` and `normal(u, v)` methods.
1059
+ */
1060
+ get surfaces(): MeshSurface[] {
1061
+ this.ensureSurfaceCache();
1062
+ return this._surfaces ?? [];
1063
+ }
1064
1064
 
1065
1065
  /** Raw WASM buffer (for advanced use / re-passing to WASM) */
1066
1066
  get rawBuffer(): Float64Array {
@@ -1074,146 +1074,146 @@ export class Mesh {
1074
1074
  * @param buffer - Float64Array in mesh buffer format
1075
1075
  * @returns New Mesh instance
1076
1076
  */
1077
- static fromBuffer(
1078
- buffer: Float64Array,
1079
- options?: { trustedBooleanInput?: boolean },
1080
- ): Mesh {
1081
- return new Mesh(buffer, options?.trustedBooleanInput ?? false);
1082
- }
1083
-
1084
- private static fromTrustedBuffer(buffer: Float64Array): Mesh {
1085
- return Mesh.normalizeClosedVolumeOrientation(new Mesh(buffer, true));
1086
- }
1087
-
1088
- get trustedBooleanInput(): boolean {
1089
- return this._trustedBooleanInput;
1090
- }
1091
-
1092
- private getTriangleVertexIndices(faceIndex: number): [number, number, number] | null {
1093
- if (!Number.isFinite(faceIndex) || faceIndex < 0 || faceIndex >= this.faceCount) return null;
1094
- const indexBuffer = this.indexBuffer;
1095
- const offset = Math.floor(faceIndex) * 3;
1096
- if (offset + 2 >= indexBuffer.length) return null;
1097
- return [indexBuffer[offset], indexBuffer[offset + 1], indexBuffer[offset + 2]];
1098
- }
1099
-
1100
- private computeTriangleAreaByIndex(faceIndex: number): number {
1101
- const tri = this.getTriangleVertexIndices(faceIndex);
1102
- if (!tri) return 0;
1103
-
1104
- const [i0, i1, i2] = tri;
1105
- const base = 1;
1106
- const off0 = base + i0 * 3;
1107
- const off1 = base + i1 * 3;
1108
- const off2 = base + i2 * 3;
1109
-
1110
- const ax = this._buffer[off0] ?? 0;
1111
- const ay = this._buffer[off0 + 1] ?? 0;
1112
- const az = this._buffer[off0 + 2] ?? 0;
1113
- const bx = this._buffer[off1] ?? 0;
1114
- const by = this._buffer[off1 + 1] ?? 0;
1115
- const bz = this._buffer[off1 + 2] ?? 0;
1116
- const cx = this._buffer[off2] ?? 0;
1117
- const cy = this._buffer[off2 + 1] ?? 0;
1118
- const cz = this._buffer[off2 + 2] ?? 0;
1119
-
1120
- const abx = bx - ax;
1121
- const aby = by - ay;
1122
- const abz = bz - az;
1123
- const acx = cx - ax;
1124
- const acy = cy - ay;
1125
- const acz = cz - az;
1126
-
1127
- const crossX = aby * acz - abz * acy;
1128
- const crossY = abz * acx - abx * acz;
1129
- const crossZ = abx * acy - aby * acx;
1130
-
1131
- return 0.5 * Math.sqrt(crossX * crossX + crossY * crossY + crossZ * crossZ);
1132
- }
1133
-
1134
- private ensurePlanarFaceCache(): void {
1135
- if (this._planarFaces && this._planarFacesByTriangleIndex) return;
1136
-
1137
- ensureInit();
1138
- const raw = wasm.mesh_build_coplanar_connected_face_groups(this._vertexCount, this._buffer);
1139
- const declaredGroupCount = Math.max(0, Math.floor(raw[0] ?? 0));
1140
- const planarFaces: MeshPlanarFace[] = [];
1141
- const planarFacesByTriangleIndex: Array<MeshPlanarFace | null> = new Array(this.faceCount).fill(null);
1142
-
1143
- let offset = 1;
1144
- for (let groupIndex = 0; groupIndex < declaredGroupCount && offset < raw.length; groupIndex++) {
1145
- const declaredTriangleCount = Math.max(0, Math.floor(raw[offset] ?? 0));
1146
- offset += 1;
1147
-
1148
- const triangleIndices: number[] = [];
1149
- let area = 0;
1150
- for (let i = 0; i < declaredTriangleCount && offset < raw.length; i++, offset++) {
1151
- const triangleIndex = Math.floor(raw[offset] ?? -1);
1152
- if (triangleIndex < 0 || triangleIndex >= this.faceCount) continue;
1153
- triangleIndices.push(triangleIndex);
1154
- area += this.computeTriangleAreaByIndex(triangleIndex);
1155
- }
1156
-
1157
- if (offset + 5 >= raw.length) break;
1158
-
1159
- const centroid = new Point(
1160
- raw[offset] ?? 0,
1161
- raw[offset + 1] ?? 0,
1162
- raw[offset + 2] ?? 0,
1163
- );
1164
- const normal = new Vec3(
1165
- raw[offset + 3] ?? 0,
1166
- raw[offset + 4] ?? 0,
1167
- raw[offset + 5] ?? 0,
1168
- ).normalize();
1169
- offset += 6;
1170
-
1171
- if (triangleIndices.length === 0) continue;
1172
-
1173
- const planarFace: MeshPlanarFace = {
1174
- index: planarFaces.length,
1175
- seedTriangleIndex: triangleIndices[0],
1176
- triangleIndices,
1177
- triangleCount: triangleIndices.length,
1178
- centroid,
1179
- normal,
1180
- area,
1181
- };
1182
-
1183
- planarFaces.push(planarFace);
1184
- for (const triangleIndex of triangleIndices) {
1185
- planarFacesByTriangleIndex[triangleIndex] = planarFace;
1186
- }
1187
- }
1188
-
1189
- this._planarFaces = planarFaces;
1190
- this._planarFacesByTriangleIndex = planarFacesByTriangleIndex;
1191
- this._surfaces = null;
1192
- this._surfacesByTriangleIndex = null;
1193
- }
1194
-
1195
- private ensureSurfaceCache(): void {
1196
- if (this._surfaces && this._surfacesByTriangleIndex) return;
1197
-
1198
- const surfaces = this.planarFaces.map(face => new MeshSurface(this, face));
1199
- const surfacesByTriangleIndex: Array<MeshSurface | null> = new Array(this.faceCount).fill(null);
1200
-
1201
- for (const surface of surfaces) {
1202
- for (const triangleIndex of surface.triangleIndices) {
1203
- if (triangleIndex >= 0 && triangleIndex < surfacesByTriangleIndex.length) {
1204
- surfacesByTriangleIndex[triangleIndex] = surface;
1205
- }
1206
- }
1207
- }
1208
-
1209
- this._surfaces = surfaces;
1210
- this._surfacesByTriangleIndex = surfacesByTriangleIndex;
1211
- }
1212
-
1213
- debugSummary(): MeshDebugSummary {
1214
- const bounds = Mesh.toDebugBounds(this.getBounds());
1215
- const topology = this.topologyMetrics();
1216
- return {
1077
+ static fromBuffer(
1078
+ buffer: Float64Array,
1079
+ options?: { trustedBooleanInput?: boolean },
1080
+ ): Mesh {
1081
+ return new Mesh(buffer, options?.trustedBooleanInput ?? false);
1082
+ }
1083
+
1084
+ private static fromTrustedBuffer(buffer: Float64Array): Mesh {
1085
+ return Mesh.normalizeClosedVolumeOrientation(new Mesh(buffer, true));
1086
+ }
1087
+
1088
+ get trustedBooleanInput(): boolean {
1089
+ return this._trustedBooleanInput;
1090
+ }
1091
+
1092
+ private getTriangleVertexIndices(faceIndex: number): [number, number, number] | null {
1093
+ if (!Number.isFinite(faceIndex) || faceIndex < 0 || faceIndex >= this.faceCount) return null;
1094
+ const indexBuffer = this.indexBuffer;
1095
+ const offset = Math.floor(faceIndex) * 3;
1096
+ if (offset + 2 >= indexBuffer.length) return null;
1097
+ return [indexBuffer[offset], indexBuffer[offset + 1], indexBuffer[offset + 2]];
1098
+ }
1099
+
1100
+ private computeTriangleAreaByIndex(faceIndex: number): number {
1101
+ const tri = this.getTriangleVertexIndices(faceIndex);
1102
+ if (!tri) return 0;
1103
+
1104
+ const [i0, i1, i2] = tri;
1105
+ const base = 1;
1106
+ const off0 = base + i0 * 3;
1107
+ const off1 = base + i1 * 3;
1108
+ const off2 = base + i2 * 3;
1109
+
1110
+ const ax = this._buffer[off0] ?? 0;
1111
+ const ay = this._buffer[off0 + 1] ?? 0;
1112
+ const az = this._buffer[off0 + 2] ?? 0;
1113
+ const bx = this._buffer[off1] ?? 0;
1114
+ const by = this._buffer[off1 + 1] ?? 0;
1115
+ const bz = this._buffer[off1 + 2] ?? 0;
1116
+ const cx = this._buffer[off2] ?? 0;
1117
+ const cy = this._buffer[off2 + 1] ?? 0;
1118
+ const cz = this._buffer[off2 + 2] ?? 0;
1119
+
1120
+ const abx = bx - ax;
1121
+ const aby = by - ay;
1122
+ const abz = bz - az;
1123
+ const acx = cx - ax;
1124
+ const acy = cy - ay;
1125
+ const acz = cz - az;
1126
+
1127
+ const crossX = aby * acz - abz * acy;
1128
+ const crossY = abz * acx - abx * acz;
1129
+ const crossZ = abx * acy - aby * acx;
1130
+
1131
+ return 0.5 * Math.sqrt(crossX * crossX + crossY * crossY + crossZ * crossZ);
1132
+ }
1133
+
1134
+ private ensurePlanarFaceCache(): void {
1135
+ if (this._planarFaces && this._planarFacesByTriangleIndex) return;
1136
+
1137
+ ensureInit();
1138
+ const raw = wasm.mesh_build_coplanar_connected_face_groups(this._vertexCount, this._buffer);
1139
+ const declaredGroupCount = Math.max(0, Math.floor(raw[0] ?? 0));
1140
+ const planarFaces: MeshPlanarFace[] = [];
1141
+ const planarFacesByTriangleIndex: Array<MeshPlanarFace | null> = new Array(this.faceCount).fill(null);
1142
+
1143
+ let offset = 1;
1144
+ for (let groupIndex = 0; groupIndex < declaredGroupCount && offset < raw.length; groupIndex++) {
1145
+ const declaredTriangleCount = Math.max(0, Math.floor(raw[offset] ?? 0));
1146
+ offset += 1;
1147
+
1148
+ const triangleIndices: number[] = [];
1149
+ let area = 0;
1150
+ for (let i = 0; i < declaredTriangleCount && offset < raw.length; i++, offset++) {
1151
+ const triangleIndex = Math.floor(raw[offset] ?? -1);
1152
+ if (triangleIndex < 0 || triangleIndex >= this.faceCount) continue;
1153
+ triangleIndices.push(triangleIndex);
1154
+ area += this.computeTriangleAreaByIndex(triangleIndex);
1155
+ }
1156
+
1157
+ if (offset + 5 >= raw.length) break;
1158
+
1159
+ const centroid = new Point(
1160
+ raw[offset] ?? 0,
1161
+ raw[offset + 1] ?? 0,
1162
+ raw[offset + 2] ?? 0,
1163
+ );
1164
+ const normal = new Vec3(
1165
+ raw[offset + 3] ?? 0,
1166
+ raw[offset + 4] ?? 0,
1167
+ raw[offset + 5] ?? 0,
1168
+ ).normalize();
1169
+ offset += 6;
1170
+
1171
+ if (triangleIndices.length === 0) continue;
1172
+
1173
+ const planarFace: MeshPlanarFace = {
1174
+ index: planarFaces.length,
1175
+ seedTriangleIndex: triangleIndices[0],
1176
+ triangleIndices,
1177
+ triangleCount: triangleIndices.length,
1178
+ centroid,
1179
+ normal,
1180
+ area,
1181
+ };
1182
+
1183
+ planarFaces.push(planarFace);
1184
+ for (const triangleIndex of triangleIndices) {
1185
+ planarFacesByTriangleIndex[triangleIndex] = planarFace;
1186
+ }
1187
+ }
1188
+
1189
+ this._planarFaces = planarFaces;
1190
+ this._planarFacesByTriangleIndex = planarFacesByTriangleIndex;
1191
+ this._surfaces = null;
1192
+ this._surfacesByTriangleIndex = null;
1193
+ }
1194
+
1195
+ private ensureSurfaceCache(): void {
1196
+ if (this._surfaces && this._surfacesByTriangleIndex) return;
1197
+
1198
+ const surfaces = this.planarFaces.map(face => new MeshSurface(this, face));
1199
+ const surfacesByTriangleIndex: Array<MeshSurface | null> = new Array(this.faceCount).fill(null);
1200
+
1201
+ for (const surface of surfaces) {
1202
+ for (const triangleIndex of surface.triangleIndices) {
1203
+ if (triangleIndex >= 0 && triangleIndex < surfacesByTriangleIndex.length) {
1204
+ surfacesByTriangleIndex[triangleIndex] = surface;
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ this._surfaces = surfaces;
1210
+ this._surfacesByTriangleIndex = surfacesByTriangleIndex;
1211
+ }
1212
+
1213
+ debugSummary(): MeshDebugSummary {
1214
+ const bounds = Mesh.toDebugBounds(this.getBounds());
1215
+ const topology = this.topologyMetrics();
1216
+ return {
1217
1217
  vertexCount: this.vertexCount,
1218
1218
  faceCount: this.faceCount,
1219
1219
  trustedBooleanInput: this._trustedBooleanInput,
@@ -1629,6 +1629,32 @@ export class Mesh {
1629
1629
  return Mesh.fromTrustedBuffer(wasm.loft_polylines(new Float64Array(parts), segments, caps));
1630
1630
  }
1631
1631
 
1632
+ /**
1633
+ * Loft through multiple raw profiles with kernel-managed compatibility:
1634
+ * profiles are resampled to a common point count (uniform arc length), and
1635
+ * closed profiles get seam alignment + winding direction matching so the
1636
+ * loft does not twist.
1637
+ *
1638
+ * @param profiles - Array of point arrays defining cross-sections. Closed
1639
+ * profiles must NOT repeat the first point at the end.
1640
+ * @param closed - Whether profiles are closed loops (tube) or open rows (sheet)
1641
+ * @param caps - Cap first/last profiles (closed profiles only) to produce a
1642
+ * watertight solid ready for booleans (default false)
1643
+ * @returns New Mesh representing the lofted surface
1644
+ */
1645
+ static loftProfiles(profiles: Point[][], closed: boolean, caps = false): Mesh {
1646
+ ensureInit();
1647
+ // Format: [count1, x,y,z,..., count2, x,y,z,...]
1648
+ const parts: number[] = [];
1649
+ for (const profile of profiles) {
1650
+ parts.push(profile.length);
1651
+ for (const p of profile) {
1652
+ parts.push(p.x, p.y, p.z);
1653
+ }
1654
+ }
1655
+ return Mesh.fromTrustedBuffer(wasm.loft_profiles(new Float64Array(parts), closed, caps));
1656
+ }
1657
+
1632
1658
  /**
1633
1659
  * Sweep a profile polyline along a path polyline.
1634
1660
  * @param profilePoints - Profile cross-section points
@@ -1991,11 +2017,11 @@ export class Mesh {
1991
2017
  return Mesh.fromTrustedBuffer(wasm.mesh_scale(this._vertexCount, this._buffer, sx, sy, sz));
1992
2018
  }
1993
2019
 
1994
- private runBoolean(
1995
- other: Mesh,
1996
- operation: "union" | "subtraction" | "intersection",
1997
- invoke: () => Float64Array,
1998
- options?: MeshBooleanOptions,
2020
+ private runBoolean(
2021
+ other: Mesh,
2022
+ operation: "union" | "subtraction" | "intersection",
2023
+ invoke: () => Float64Array,
2024
+ options?: MeshBooleanOptions,
1999
2025
  ): Mesh {
2000
2026
  const faceCountA = this.faceCount;
2001
2027
  const faceCountB = other.faceCount;
@@ -2044,57 +2070,57 @@ export class Mesh {
2044
2070
  const vertexCount = result[0];
2045
2071
  if (!Number.isFinite(vertexCount) || vertexCount < 0) {
2046
2072
  throw new Error(`Boolean ${operation} failed and returned a corrupt mesh buffer.`);
2047
- }
2048
- return Mesh.fromTrustedBuffer(result);
2049
- }
2050
-
2051
- private static mergeMeshComponents(meshes: Mesh[]): Mesh {
2052
- if (meshes.length === 0) return Mesh.emptyMesh();
2053
- if (meshes.length === 1) return Mesh.cloneMesh(meshes[0]);
2054
- return Mesh.mergeMeshes(meshes);
2055
- }
2056
-
2057
- private static createUnsupportedOpenMeshBooleanError(
2058
- operation: MeshBooleanOperation,
2059
- aClosed: boolean,
2060
- bClosed: boolean,
2061
- ): Error {
2062
- const topology = `A=${aClosed ? "closed" : "open"}, B=${bClosed ? "closed" : "open"}`;
2063
- if (operation === "union") {
2064
- return new Error(
2065
- `Boolean union requires both inputs to be closed volumes (${topology}). `
2066
- + "Close or cap the open mesh before calling union().",
2067
- );
2068
- }
2069
-
2070
- return new Error(
2071
- `Boolean ${operation} with open meshes is only supported when A is open and B is closed `
2072
- + `(surface trim mode). Got ${topology}. Use Mesh.split(...) and choose outside/inside explicitly `
2073
- + "for other open-mesh combinations.",
2074
- );
2075
- }
2076
-
2077
- private resolveOpenMeshBooleanFallback(
2078
- other: Mesh,
2079
- operation: MeshBooleanOperation,
2080
- options?: MeshBooleanOptions,
2081
- ): Mesh | null {
2082
- const aClosed = this.isClosedVolume();
2083
- const bClosed = other.isClosedVolume();
2084
- if (aClosed && bClosed) return null;
2085
-
2086
- if (!aClosed && bClosed && (operation === "subtraction" || operation === "intersection")) {
2087
- const split = this.splitWithMesh(other, options);
2088
- return Mesh.mergeMeshComponents(operation === "subtraction" ? split.outside : split.inside);
2089
- }
2090
-
2091
- throw Mesh.createUnsupportedOpenMeshBooleanError(operation, aClosed, bClosed);
2092
- }
2093
-
2094
- static encodeBooleanOperationToken(
2095
- operation: MeshBooleanOperation,
2096
- a: Mesh,
2097
- b: Mesh,
2073
+ }
2074
+ return Mesh.fromTrustedBuffer(result);
2075
+ }
2076
+
2077
+ private static mergeMeshComponents(meshes: Mesh[]): Mesh {
2078
+ if (meshes.length === 0) return Mesh.emptyMesh();
2079
+ if (meshes.length === 1) return Mesh.cloneMesh(meshes[0]);
2080
+ return Mesh.mergeMeshes(meshes);
2081
+ }
2082
+
2083
+ private static createUnsupportedOpenMeshBooleanError(
2084
+ operation: MeshBooleanOperation,
2085
+ aClosed: boolean,
2086
+ bClosed: boolean,
2087
+ ): Error {
2088
+ const topology = `A=${aClosed ? "closed" : "open"}, B=${bClosed ? "closed" : "open"}`;
2089
+ if (operation === "union") {
2090
+ return new Error(
2091
+ `Boolean union requires both inputs to be closed volumes (${topology}). `
2092
+ + "Close or cap the open mesh before calling union().",
2093
+ );
2094
+ }
2095
+
2096
+ return new Error(
2097
+ `Boolean ${operation} with open meshes is only supported when A is open and B is closed `
2098
+ + `(surface trim mode). Got ${topology}. Use Mesh.split(...) and choose outside/inside explicitly `
2099
+ + "for other open-mesh combinations.",
2100
+ );
2101
+ }
2102
+
2103
+ private resolveOpenMeshBooleanFallback(
2104
+ other: Mesh,
2105
+ operation: MeshBooleanOperation,
2106
+ options?: MeshBooleanOptions,
2107
+ ): Mesh | null {
2108
+ const aClosed = this.isClosedVolume();
2109
+ const bClosed = other.isClosedVolume();
2110
+ if (aClosed && bClosed) return null;
2111
+
2112
+ if (!aClosed && bClosed && (operation === "subtraction" || operation === "intersection")) {
2113
+ const split = this.splitWithMesh(other, options);
2114
+ return Mesh.mergeMeshComponents(operation === "subtraction" ? split.outside : split.inside);
2115
+ }
2116
+
2117
+ throw Mesh.createUnsupportedOpenMeshBooleanError(operation, aClosed, bClosed);
2118
+ }
2119
+
2120
+ static encodeBooleanOperationToken(
2121
+ operation: MeshBooleanOperation,
2122
+ a: Mesh,
2123
+ b: Mesh,
2098
2124
  options?: MeshBooleanOptions,
2099
2125
  ): string {
2100
2126
  const tokens: string[] = [operation];
@@ -2103,264 +2129,386 @@ export class Mesh {
2103
2129
  }
2104
2130
  if (options?.debugForceFaceID) {
2105
2131
  tokens.push("forceFaceID");
2106
- }
2107
- return tokens.join("@");
2108
- }
2109
-
2110
- static encodeBooleanSplitToken(
2111
- a: Mesh,
2112
- b: Mesh,
2113
- options?: MeshBooleanOptions,
2114
- ): string {
2115
- const tokens: string[] = ["split"];
2116
- if (a._trustedBooleanInput && b._trustedBooleanInput) {
2117
- tokens.push("trustedInput");
2118
- }
2119
- if (options?.debugForceFaceID) {
2120
- tokens.push("forceFaceID");
2121
- }
2122
- return tokens.join("@");
2123
- }
2132
+ }
2133
+ return tokens.join("@");
2134
+ }
2135
+
2136
+ static encodeBooleanSplitToken(
2137
+ a: Mesh,
2138
+ b: Mesh,
2139
+ options?: MeshBooleanOptions,
2140
+ ): string {
2141
+ const tokens: string[] = ["split"];
2142
+ if (a._trustedBooleanInput && b._trustedBooleanInput) {
2143
+ tokens.push("trustedInput");
2144
+ }
2145
+ if (options?.debugForceFaceID) {
2146
+ tokens.push("forceFaceID");
2147
+ }
2148
+ return tokens.join("@");
2149
+ }
2124
2150
 
2125
2151
  // ── Booleans ───────────────────────────────────────────────────
2126
2152
 
2127
- /**
2128
- * Compute boolean union with another mesh.
2129
- * Requires both inputs to be closed volumes.
2130
- * @param other - Mesh to union with
2131
- * @param options - Optional safety overrides
2132
- * @returns New mesh containing volume of both inputs
2133
- */
2134
- union(other: Mesh, options?: MeshBooleanOptions): Mesh {
2135
- ensureInit();
2136
- const lhs = Mesh.normalizeClosedVolumeOrientation(this);
2137
- const rhs = Mesh.normalizeClosedVolumeOrientation(other);
2138
- lhs.resolveOpenMeshBooleanFallback(rhs, "union", options);
2139
- const operationToken = Mesh.encodeBooleanOperationToken("union", lhs, rhs, options);
2140
- return lhs.runBoolean(
2141
- rhs,
2142
- "union",
2143
- () => wasm.mesh_boolean_operation(
2144
- lhs._vertexCount,
2145
- lhs._buffer,
2146
- rhs._vertexCount,
2147
- rhs._buffer,
2148
- operationToken,
2149
- ),
2150
- options,
2151
- );
2152
- }
2153
-
2154
- /**
2155
- * Compute boolean subtraction with another mesh.
2156
- * If this mesh is open and `other` is a closed cutter, this trims the open
2157
- * host via `split()` and returns the outside surface pieces merged together.
2158
- * @param other - Mesh to subtract
2159
- * @param options - Optional safety overrides
2160
- * @returns New mesh with other's volume removed from this
2161
- */
2162
- subtract(other: Mesh, options?: MeshBooleanOptions): Mesh {
2163
- ensureInit();
2164
- const lhs = Mesh.normalizeClosedVolumeOrientation(this);
2165
- const rhs = Mesh.normalizeClosedVolumeOrientation(other);
2166
- const fallback = lhs.resolveOpenMeshBooleanFallback(rhs, "subtraction", options);
2167
- if (fallback) return fallback;
2168
- const operationToken = Mesh.encodeBooleanOperationToken("subtraction", lhs, rhs, options);
2169
- return lhs.runBoolean(
2170
- rhs,
2171
- "subtraction",
2172
- () => wasm.mesh_boolean_operation(
2173
- lhs._vertexCount,
2174
- lhs._buffer,
2175
- rhs._vertexCount,
2176
- rhs._buffer,
2177
- operationToken,
2178
- ),
2179
- options,
2180
- );
2181
- }
2182
-
2183
- /**
2184
- * Compute boolean intersection with another mesh.
2185
- * If this mesh is open and `other` is a closed cutter, this trims the open
2186
- * host via `split()` and returns the inside surface pieces merged together.
2187
- * @param other - Mesh to intersect with
2188
- * @param options - Optional safety overrides
2189
- * @returns New mesh containing only the overlapping volume
2190
- */
2191
- intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
2192
- ensureInit();
2193
- const lhs = Mesh.normalizeClosedVolumeOrientation(this);
2194
- const rhs = Mesh.normalizeClosedVolumeOrientation(other);
2195
- const fallback = lhs.resolveOpenMeshBooleanFallback(rhs, "intersection", options);
2196
- if (fallback) return fallback;
2197
- const operationToken = Mesh.encodeBooleanOperationToken("intersection", lhs, rhs, options);
2198
- return lhs.runBoolean(
2199
- rhs,
2200
- "intersection",
2201
- () => wasm.mesh_boolean_operation(
2202
- lhs._vertexCount,
2203
- lhs._buffer,
2204
- rhs._vertexCount,
2205
- rhs._buffer,
2206
- operationToken,
2207
- ),
2208
- options,
2209
- );
2210
- }
2211
-
2212
- private splitWithMesh(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult {
2213
- ensureInit();
2214
-
2215
- const faceCountA = this.faceCount;
2216
- const faceCountB = other.faceCount;
2217
- const hostClosed = this.isClosedVolume();
2218
- const cutterClosed = other.isClosedVolume();
2219
- const useVolumetricSplit = hostClosed && cutterClosed;
2220
- if (faceCountA === 0) return Mesh.createSplitResult([], [], useVolumetricSplit ? "volume" : "surface");
2221
- if (faceCountB === 0) {
2222
- return Mesh.createSplitResult(
2223
- [Mesh.cloneMesh(this)],
2224
- [],
2225
- useVolumetricSplit || cutterClosed ? "volume" : "surface",
2226
- );
2227
- }
2228
-
2229
- const boundsA = Mesh.computeRawBounds(this);
2230
- const boundsB = Mesh.computeRawBounds(other);
2231
- const contactTol = Mesh.booleanContactTolerance(boundsA, boundsB);
2232
- if (useVolumetricSplit && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
2233
- return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
2234
- }
2235
- if (!useVolumetricSplit && cutterClosed && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
2236
- return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
2237
- }
2238
-
2239
- if (!options?.allowUnsafe) {
2240
- const limits = Mesh.resolveBooleanLimits(options?.limits);
2241
- const maxInputFaces = Math.max(faceCountA, faceCountB);
2242
- const combinedInputFaces = faceCountA + faceCountB;
2243
- const faceProduct = faceCountA * faceCountB;
2244
- if (
2245
- maxInputFaces > limits.maxInputFacesPerMesh
2246
- || combinedInputFaces > limits.maxCombinedInputFaces
2247
- || faceProduct > limits.maxFaceProduct
2248
- ) {
2249
- throw new Error(
2250
- `Mesh split blocked by safety limits `
2251
- + `(A faces=${faceCountA}, B faces=${faceCountB}, faceProduct=${faceProduct}). `
2252
- + "Simplify inputs or pass allowUnsafe: true to force execution.",
2253
- );
2254
- }
2255
- }
2256
-
2257
- const splitToken = Mesh.encodeBooleanSplitToken(this, other, options);
2258
- const result = useVolumetricSplit
2259
- ? wasm.mesh_boolean_split(
2260
- this._vertexCount,
2261
- this._buffer,
2262
- other._vertexCount,
2263
- other._buffer,
2264
- splitToken,
2265
- )
2266
- : wasm.mesh_surface_split(
2267
- this._vertexCount,
2268
- this._buffer,
2269
- other._vertexCount,
2270
- other._buffer,
2271
- splitToken,
2272
- );
2273
- if (result.length === 0) {
2274
- throw new Error("Mesh split failed and returned an invalid result buffer.");
2275
- }
2276
- const parsed = Mesh.parseSplitResultBuffer(result);
2277
- return cutterClosed
2278
- ? Mesh.createSplitResult(parsed.outside, parsed.inside, "volume")
2279
- : Mesh.createSurfaceSplitResult(parsed.outside, parsed.inside);
2280
- }
2281
-
2282
- private splitWithPlane(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult {
2283
- ensureInit();
2284
- const hostClosed = this.isClosedVolume();
2285
-
2286
- if (this.faceCount === 0) return Mesh.createPlaneSplitResult([], [], hostClosed ? "volume" : "surface");
2287
-
2288
- const distances = Mesh.classifyPlaneDistances(this, plane);
2289
- const eps = Mesh.boundsDiag(Mesh.computeRawBounds(this)) * 1e-9 + 1e-9;
2290
- if (distances.max <= eps) {
2291
- return Mesh.createPlaneSplitResult([Mesh.cloneMesh(this)], [], hostClosed ? "volume" : "surface");
2292
- }
2293
- if (distances.min >= -eps) {
2294
- return Mesh.createPlaneSplitResult([], [Mesh.cloneMesh(this)], hostClosed ? "volume" : "surface");
2295
- }
2296
-
2297
- if (!hostClosed) {
2298
- const result = wasm.mesh_surface_split_plane(
2299
- this._vertexCount,
2300
- this._buffer,
2301
- plane.normal.x,
2302
- plane.normal.y,
2303
- plane.normal.z,
2304
- plane.d,
2305
- Mesh.encodeBooleanSplitToken(this, this, options),
2306
- );
2307
- if (result.length === 0) {
2308
- throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2309
- }
2310
- const split = Mesh.parseSplitResultBuffer(result);
2311
- return Mesh.createPlaneSplitResult(split.outside, split.inside, "surface");
2312
- }
2313
-
2314
- const result = wasm.mesh_solid_split_plane(
2315
- this._vertexCount,
2316
- this._buffer,
2317
- plane.normal.x,
2318
- plane.normal.y,
2319
- plane.normal.z,
2320
- plane.d,
2321
- Mesh.encodeBooleanSplitToken(this, this, options),
2322
- );
2323
- if (result.length === 0) {
2324
- throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2325
- }
2326
- const split = Mesh.parseSplitResultBuffer(result);
2327
- return Mesh.createPlaneSplitResult(split.outside, split.inside, "volume");
2328
- }
2329
-
2330
- private splitWithCurve(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult {
2331
- ensureInit();
2332
- if (this.faceCount === 0) return Mesh.createSplitResult([], []);
2333
- const cutter = Mesh.createCurveSplitCutter(this, curve, options);
2334
- return this.splitWithMesh(cutter, options);
2335
- }
2336
-
2337
- /**
2338
- * Split this mesh by another mesh, a plane, or a closed planar curve.
2339
- * Mesh split: `outside = this - cutter`, `inside = this ∩ cutter`.
2340
- * Plane split: `negative` is opposite the plane normal, `positive` follows the plane normal.
2341
- * Curve split: the curve is converted into a cutter solid, either from the
2342
- * explicit direction you pass or via an automatic through-cut along the
2343
- * detected curve normal across the host mesh.
2344
- */
2345
- split(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult;
2346
- split(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult;
2347
- split(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult;
2348
- split(
2349
- target: Mesh | Plane | SweepableCurve,
2350
- options?: MeshBooleanOptions | MeshPlaneSplitOptions | MeshCurveSplitOptions,
2351
- ): MeshSplitResult | MeshPlaneSplitResult {
2352
- if (target instanceof Mesh) {
2353
- return this.splitWithMesh(target, options as MeshBooleanOptions | undefined);
2354
- }
2355
- if (target instanceof Plane) {
2356
- return this.splitWithPlane(target, options as MeshPlaneSplitOptions | undefined);
2357
- }
2358
- return this.splitWithCurve(target, options as MeshCurveSplitOptions | undefined);
2359
- }
2360
-
2361
- debugBoolean(
2362
- other: Mesh,
2363
- operation: MeshBooleanOperation,
2153
+ /**
2154
+ * Compute boolean union with another mesh.
2155
+ * Requires both inputs to be closed volumes.
2156
+ * @param other - Mesh to union with
2157
+ * @param options - Optional safety overrides
2158
+ * @returns New mesh containing volume of both inputs
2159
+ */
2160
+ union(other: Mesh, options?: MeshBooleanOptions): Mesh {
2161
+ ensureInit();
2162
+ const lhs = Mesh.normalizeClosedVolumeOrientation(this);
2163
+ const rhs = Mesh.normalizeClosedVolumeOrientation(other);
2164
+ lhs.resolveOpenMeshBooleanFallback(rhs, "union", options);
2165
+ const operationToken = Mesh.encodeBooleanOperationToken("union", lhs, rhs, options);
2166
+ return lhs.runBoolean(
2167
+ rhs,
2168
+ "union",
2169
+ () => wasm.mesh_boolean_operation(
2170
+ lhs._vertexCount,
2171
+ lhs._buffer,
2172
+ rhs._vertexCount,
2173
+ rhs._buffer,
2174
+ operationToken,
2175
+ ),
2176
+ options,
2177
+ );
2178
+ }
2179
+
2180
+ /**
2181
+ * Compute boolean subtraction with another mesh.
2182
+ * If this mesh is open and `other` is a closed cutter, this trims the open
2183
+ * host via `split()` and returns the outside surface pieces merged together.
2184
+ * @param other - Mesh to subtract
2185
+ * @param options - Optional safety overrides
2186
+ * @returns New mesh with other's volume removed from this
2187
+ */
2188
+ subtract(other: Mesh, options?: MeshBooleanOptions): Mesh {
2189
+ ensureInit();
2190
+ const lhs = Mesh.normalizeClosedVolumeOrientation(this);
2191
+ const rhs = Mesh.normalizeClosedVolumeOrientation(other);
2192
+ const fallback = lhs.resolveOpenMeshBooleanFallback(rhs, "subtraction", options);
2193
+ if (fallback) return fallback;
2194
+ const operationToken = Mesh.encodeBooleanOperationToken("subtraction", lhs, rhs, options);
2195
+ return lhs.runBoolean(
2196
+ rhs,
2197
+ "subtraction",
2198
+ () => wasm.mesh_boolean_operation(
2199
+ lhs._vertexCount,
2200
+ lhs._buffer,
2201
+ rhs._vertexCount,
2202
+ rhs._buffer,
2203
+ operationToken,
2204
+ ),
2205
+ options,
2206
+ );
2207
+ }
2208
+
2209
+ /**
2210
+ * Compute boolean intersection with another mesh.
2211
+ * If this mesh is open and `other` is a closed cutter, this trims the open
2212
+ * host via `split()` and returns the inside surface pieces merged together.
2213
+ * @param other - Mesh to intersect with
2214
+ * @param options - Optional safety overrides
2215
+ * @returns New mesh containing only the overlapping volume
2216
+ */
2217
+ intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
2218
+ ensureInit();
2219
+ const lhs = Mesh.normalizeClosedVolumeOrientation(this);
2220
+ const rhs = Mesh.normalizeClosedVolumeOrientation(other);
2221
+ const fallback = lhs.resolveOpenMeshBooleanFallback(rhs, "intersection", options);
2222
+ if (fallback) return fallback;
2223
+ const operationToken = Mesh.encodeBooleanOperationToken("intersection", lhs, rhs, options);
2224
+ return lhs.runBoolean(
2225
+ rhs,
2226
+ "intersection",
2227
+ () => wasm.mesh_boolean_operation(
2228
+ lhs._vertexCount,
2229
+ lhs._buffer,
2230
+ rhs._vertexCount,
2231
+ rhs._buffer,
2232
+ operationToken,
2233
+ ),
2234
+ options,
2235
+ );
2236
+ }
2237
+
2238
+ /**
2239
+ * Union many meshes into a single mesh in ONE WASM call.
2240
+ *
2241
+ * Substantially faster than chaining pairwise `union()` calls: every input
2242
+ * crosses the JS/WASM boundary once, intermediate results never round-trip
2243
+ * through buffers, mutually disjoint inputs are concatenated instead of
2244
+ * run through CSG, and overlapping inputs are merged smallest-first.
2245
+ *
2246
+ * Requires all inputs to be closed volumes.
2247
+ * @param meshes - Meshes to union (order does not affect the result)
2248
+ * @param options - Optional safety overrides
2249
+ * @returns New mesh containing the combined volume of all inputs
2250
+ */
2251
+ static unionAll(meshes: Mesh[], options?: MeshBooleanOptions): Mesh {
2252
+ ensureInit();
2253
+ const inputs = meshes
2254
+ .filter((mesh) => mesh.faceCount > 0)
2255
+ .map((mesh) => Mesh.normalizeClosedVolumeOrientation(mesh));
2256
+ if (inputs.length === 0) return Mesh.emptyMesh();
2257
+ if (inputs.length === 1) return Mesh.cloneMesh(inputs[0]);
2258
+
2259
+ const openCount = inputs.reduce((count, mesh) => count + (mesh.isClosedVolume() ? 0 : 1), 0);
2260
+ if (openCount > 0) {
2261
+ throw new Error(
2262
+ `Boolean unionAll requires all inputs to be closed volumes (${openCount} of ${inputs.length} inputs are open). `
2263
+ + "Close or cap the open meshes before calling unionAll().",
2264
+ );
2265
+ }
2266
+
2267
+ Mesh.enforceBatchBooleanLimits("unionAll", inputs, options);
2268
+
2269
+ const flags = inputs.every((mesh) => mesh._trustedBooleanInput) ? "trustedInput" : "";
2270
+ const result = wasm.mesh_boolean_union_all(Mesh.packMeshes(inputs), flags);
2271
+ return Mesh.decodeBatchBooleanResult("unionAll", result);
2272
+ }
2273
+
2274
+ /**
2275
+ * Subtract many cutter meshes from this mesh in ONE WASM call.
2276
+ *
2277
+ * The kernel culls cutters that miss this mesh's bounds, unions mutually
2278
+ * overlapping cutters, combines disjoint cutters into one multi-shell
2279
+ * operand, and performs a single CSG subtraction — instead of N pairwise
2280
+ * subtractions that re-process the shrinking host every time.
2281
+ *
2282
+ * The fast batched path requires this mesh and all cutters to be closed
2283
+ * volumes; open-mesh combinations fall back to sequential pairwise
2284
+ * `subtract()` (which carries the split-based open-host trim semantics).
2285
+ * @param others - Cutter meshes to subtract
2286
+ * @param options - Optional safety overrides
2287
+ * @returns New mesh with all cutter volumes removed from this
2288
+ */
2289
+ subtractAll(others: Mesh[], options?: MeshBooleanOptions): Mesh {
2290
+ ensureInit();
2291
+ if (this.faceCount === 0) return Mesh.emptyMesh();
2292
+ const host = Mesh.normalizeClosedVolumeOrientation(this);
2293
+ const cutters = others
2294
+ .filter((mesh) => mesh.faceCount > 0)
2295
+ .map((mesh) => Mesh.normalizeClosedVolumeOrientation(mesh));
2296
+ if (cutters.length === 0) return Mesh.cloneMesh(host);
2297
+ if (cutters.length === 1) return host.subtract(cutters[0], options);
2298
+
2299
+ if (!host.isClosedVolume() || cutters.some((mesh) => !mesh.isClosedVolume())) {
2300
+ return cutters.reduce((acc: Mesh, cutter) => acc.subtract(cutter, options), host);
2301
+ }
2302
+
2303
+ // Cheap bounds cull before crossing into WASM at all.
2304
+ const hostBounds = Mesh.computeRawBounds(host);
2305
+ const relevant = cutters.filter((cutter) => {
2306
+ const cutterBounds = Mesh.computeRawBounds(cutter);
2307
+ const contactTol = Mesh.booleanContactTolerance(hostBounds, cutterBounds);
2308
+ return Mesh.boundsOverlap(hostBounds, cutterBounds, contactTol);
2309
+ });
2310
+ if (relevant.length === 0) return Mesh.cloneMesh(host);
2311
+
2312
+ Mesh.enforceBatchBooleanLimits("subtractAll", [host, ...relevant], options);
2313
+
2314
+ const flags = host._trustedBooleanInput && relevant.every((mesh) => mesh._trustedBooleanInput)
2315
+ ? "trustedInput"
2316
+ : "";
2317
+ const result = wasm.mesh_boolean_difference_all(Mesh.packMeshes([host, ...relevant]), flags);
2318
+ return Mesh.decodeBatchBooleanResult("subtractAll", result);
2319
+ }
2320
+
2321
+ private static enforceBatchBooleanLimits(
2322
+ operation: string,
2323
+ inputs: Mesh[],
2324
+ options?: MeshBooleanOptions,
2325
+ ): void {
2326
+ if (options?.allowUnsafe) return;
2327
+ const limits = Mesh.resolveBooleanLimits(options?.limits);
2328
+ let combinedInputFaces = 0;
2329
+ for (const mesh of inputs) {
2330
+ if (mesh.faceCount > limits.maxInputFacesPerMesh) {
2331
+ throw new Error(
2332
+ `Boolean ${operation} blocked by safety limits `
2333
+ + `(input faces=${mesh.faceCount} > maxInputFacesPerMesh=${limits.maxInputFacesPerMesh}). `
2334
+ + "Simplify inputs, run in a Worker, or pass allowUnsafe: true to force execution.",
2335
+ );
2336
+ }
2337
+ combinedInputFaces += mesh.faceCount;
2338
+ }
2339
+ if (combinedInputFaces > limits.maxCombinedInputFaces) {
2340
+ throw new Error(
2341
+ `Boolean ${operation} blocked by safety limits `
2342
+ + `(combined faces=${combinedInputFaces} > maxCombinedInputFaces=${limits.maxCombinedInputFaces}). `
2343
+ + "Simplify inputs, run in a Worker, or pass allowUnsafe: true to force execution.",
2344
+ );
2345
+ }
2346
+ }
2347
+
2348
+ private static decodeBatchBooleanResult(operation: string, result: Float64Array): Mesh {
2349
+ if (result.length === 0) {
2350
+ throw new Error(`Boolean ${operation} failed and returned an invalid mesh buffer.`);
2351
+ }
2352
+ const vertexCount = result[0];
2353
+ if (!Number.isFinite(vertexCount) || vertexCount < 0) {
2354
+ throw new Error(`Boolean ${operation} failed and returned a corrupt mesh buffer.`);
2355
+ }
2356
+ if (vertexCount === 0) return Mesh.emptyMesh();
2357
+ return Mesh.fromTrustedBuffer(result);
2358
+ }
2359
+
2360
+ private splitWithMesh(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult {
2361
+ ensureInit();
2362
+
2363
+ const faceCountA = this.faceCount;
2364
+ const faceCountB = other.faceCount;
2365
+ const hostClosed = this.isClosedVolume();
2366
+ const cutterClosed = other.isClosedVolume();
2367
+ const useVolumetricSplit = hostClosed && cutterClosed;
2368
+ if (faceCountA === 0) return Mesh.createSplitResult([], [], useVolumetricSplit ? "volume" : "surface");
2369
+ if (faceCountB === 0) {
2370
+ return Mesh.createSplitResult(
2371
+ [Mesh.cloneMesh(this)],
2372
+ [],
2373
+ useVolumetricSplit || cutterClosed ? "volume" : "surface",
2374
+ );
2375
+ }
2376
+
2377
+ const boundsA = Mesh.computeRawBounds(this);
2378
+ const boundsB = Mesh.computeRawBounds(other);
2379
+ const contactTol = Mesh.booleanContactTolerance(boundsA, boundsB);
2380
+ if (useVolumetricSplit && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
2381
+ return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
2382
+ }
2383
+ if (!useVolumetricSplit && cutterClosed && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
2384
+ return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
2385
+ }
2386
+
2387
+ if (!options?.allowUnsafe) {
2388
+ const limits = Mesh.resolveBooleanLimits(options?.limits);
2389
+ const maxInputFaces = Math.max(faceCountA, faceCountB);
2390
+ const combinedInputFaces = faceCountA + faceCountB;
2391
+ const faceProduct = faceCountA * faceCountB;
2392
+ if (
2393
+ maxInputFaces > limits.maxInputFacesPerMesh
2394
+ || combinedInputFaces > limits.maxCombinedInputFaces
2395
+ || faceProduct > limits.maxFaceProduct
2396
+ ) {
2397
+ throw new Error(
2398
+ `Mesh split blocked by safety limits `
2399
+ + `(A faces=${faceCountA}, B faces=${faceCountB}, faceProduct=${faceProduct}). `
2400
+ + "Simplify inputs or pass allowUnsafe: true to force execution.",
2401
+ );
2402
+ }
2403
+ }
2404
+
2405
+ const splitToken = Mesh.encodeBooleanSplitToken(this, other, options);
2406
+ const result = useVolumetricSplit
2407
+ ? wasm.mesh_boolean_split(
2408
+ this._vertexCount,
2409
+ this._buffer,
2410
+ other._vertexCount,
2411
+ other._buffer,
2412
+ splitToken,
2413
+ )
2414
+ : wasm.mesh_surface_split(
2415
+ this._vertexCount,
2416
+ this._buffer,
2417
+ other._vertexCount,
2418
+ other._buffer,
2419
+ splitToken,
2420
+ );
2421
+ if (result.length === 0) {
2422
+ throw new Error("Mesh split failed and returned an invalid result buffer.");
2423
+ }
2424
+ const parsed = Mesh.parseSplitResultBuffer(result);
2425
+ return cutterClosed
2426
+ ? Mesh.createSplitResult(parsed.outside, parsed.inside, "volume")
2427
+ : Mesh.createSurfaceSplitResult(parsed.outside, parsed.inside);
2428
+ }
2429
+
2430
+ private splitWithPlane(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult {
2431
+ ensureInit();
2432
+ const hostClosed = this.isClosedVolume();
2433
+
2434
+ if (this.faceCount === 0) return Mesh.createPlaneSplitResult([], [], hostClosed ? "volume" : "surface");
2435
+
2436
+ const distances = Mesh.classifyPlaneDistances(this, plane);
2437
+ const eps = Mesh.boundsDiag(Mesh.computeRawBounds(this)) * 1e-9 + 1e-9;
2438
+ if (distances.max <= eps) {
2439
+ return Mesh.createPlaneSplitResult([Mesh.cloneMesh(this)], [], hostClosed ? "volume" : "surface");
2440
+ }
2441
+ if (distances.min >= -eps) {
2442
+ return Mesh.createPlaneSplitResult([], [Mesh.cloneMesh(this)], hostClosed ? "volume" : "surface");
2443
+ }
2444
+
2445
+ if (!hostClosed) {
2446
+ const result = wasm.mesh_surface_split_plane(
2447
+ this._vertexCount,
2448
+ this._buffer,
2449
+ plane.normal.x,
2450
+ plane.normal.y,
2451
+ plane.normal.z,
2452
+ plane.d,
2453
+ Mesh.encodeBooleanSplitToken(this, this, options),
2454
+ );
2455
+ if (result.length === 0) {
2456
+ throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2457
+ }
2458
+ const split = Mesh.parseSplitResultBuffer(result);
2459
+ return Mesh.createPlaneSplitResult(split.outside, split.inside, "surface");
2460
+ }
2461
+
2462
+ const result = wasm.mesh_solid_split_plane(
2463
+ this._vertexCount,
2464
+ this._buffer,
2465
+ plane.normal.x,
2466
+ plane.normal.y,
2467
+ plane.normal.z,
2468
+ plane.d,
2469
+ Mesh.encodeBooleanSplitToken(this, this, options),
2470
+ );
2471
+ if (result.length === 0) {
2472
+ throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2473
+ }
2474
+ const split = Mesh.parseSplitResultBuffer(result);
2475
+ return Mesh.createPlaneSplitResult(split.outside, split.inside, "volume");
2476
+ }
2477
+
2478
+ private splitWithCurve(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult {
2479
+ ensureInit();
2480
+ if (this.faceCount === 0) return Mesh.createSplitResult([], []);
2481
+ const cutter = Mesh.createCurveSplitCutter(this, curve, options);
2482
+ return this.splitWithMesh(cutter, options);
2483
+ }
2484
+
2485
+ /**
2486
+ * Split this mesh by another mesh, a plane, or a closed planar curve.
2487
+ * Mesh split: `outside = this - cutter`, `inside = this ∩ cutter`.
2488
+ * Plane split: `negative` is opposite the plane normal, `positive` follows the plane normal.
2489
+ * Curve split: the curve is converted into a cutter solid, either from the
2490
+ * explicit direction you pass or via an automatic through-cut along the
2491
+ * detected curve normal across the host mesh.
2492
+ */
2493
+ split(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult;
2494
+ split(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult;
2495
+ split(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult;
2496
+ split(
2497
+ target: Mesh | Plane | SweepableCurve,
2498
+ options?: MeshBooleanOptions | MeshPlaneSplitOptions | MeshCurveSplitOptions,
2499
+ ): MeshSplitResult | MeshPlaneSplitResult {
2500
+ if (target instanceof Mesh) {
2501
+ return this.splitWithMesh(target, options as MeshBooleanOptions | undefined);
2502
+ }
2503
+ if (target instanceof Plane) {
2504
+ return this.splitWithPlane(target, options as MeshPlaneSplitOptions | undefined);
2505
+ }
2506
+ return this.splitWithCurve(target, options as MeshCurveSplitOptions | undefined);
2507
+ }
2508
+
2509
+ debugBoolean(
2510
+ other: Mesh,
2511
+ operation: MeshBooleanOperation,
2364
2512
  options?: MeshBooleanOptions,
2365
2513
  debugOptions?: MeshBooleanDebugOptions,
2366
2514
  ): MeshBooleanDebugReport {
@@ -2696,6 +2844,71 @@ export class Mesh {
2696
2844
  return out;
2697
2845
  }
2698
2846
 
2847
+ /**
2848
+ * Find the edge-connected smooth face group containing a triangle: flood
2849
+ * fill crossing only edges whose dihedral angle is below `maxAngleDeg`.
2850
+ * On a tessellated curved surface (cylinder wall, lofted patch) this
2851
+ * returns the whole smooth region (`isPlanar: false`); on a flat face it
2852
+ * matches the coplanar group (`isPlanar: true`).
2853
+ */
2854
+ getSmoothFaceGroup(
2855
+ faceIndex: number,
2856
+ maxAngleDeg = 30,
2857
+ ): { faceIndices: number[]; isPlanar: boolean } {
2858
+ ensureInit();
2859
+ if (!Number.isFinite(faceIndex) || faceIndex < 0) {
2860
+ return { faceIndices: [], isPlanar: true };
2861
+ }
2862
+ const r = wasm.mesh_get_smooth_face_group(
2863
+ this._vertexCount,
2864
+ this._buffer,
2865
+ Math.floor(faceIndex),
2866
+ maxAngleDeg,
2867
+ );
2868
+ const count = Math.max(0, Math.floor(r[0] ?? 0));
2869
+ const isPlanar = (r[1] ?? 1) !== 0;
2870
+ const faceIndices: number[] = [];
2871
+ for (let i = 0; i < count; i++) {
2872
+ faceIndices.push(Math.floor(r[2 + i] ?? 0));
2873
+ }
2874
+ return { faceIndices, isPlanar };
2875
+ }
2876
+
2877
+ /**
2878
+ * Display-ready render buffers computed in one kernel pass: creased
2879
+ * (crease-split) vertex normals plus feature edge segments. Replaces
2880
+ * per-mesh THREE.toCreasedNormals + EdgesGeometry in app layers.
2881
+ *
2882
+ * `positions`/`normals` are non-indexed, three floats per vertex, in the
2883
+ * mesh's triangle order (raycast face indices stay valid). `featureEdges`
2884
+ * holds two endpoints (six floats) per feature edge segment: boundary
2885
+ * edges, non-manifold edges, and edges whose dihedral angle exceeds
2886
+ * `creaseAngleDeg`.
2887
+ */
2888
+ buildRenderBuffers(creaseAngleDeg = 30): {
2889
+ positions: Float32Array;
2890
+ normals: Float32Array;
2891
+ featureEdges: Float32Array;
2892
+ } {
2893
+ ensureInit();
2894
+ const r: Float32Array = wasm.mesh_build_render_buffers(
2895
+ this._vertexCount,
2896
+ this._buffer,
2897
+ creaseAngleDeg,
2898
+ );
2899
+ const vertexCount = Math.max(0, Math.floor(r[0] ?? 0));
2900
+ const posStart = 1;
2901
+ const norStart = posStart + vertexCount * 3;
2902
+ const edgeCountIndex = norStart + vertexCount * 3;
2903
+ const edgeCount = Math.max(0, Math.floor(r[edgeCountIndex] ?? 0));
2904
+ const edgeStart = edgeCountIndex + 1;
2905
+ return {
2906
+ positions: r.subarray(posStart, posStart + vertexCount * 3),
2907
+ normals: r.subarray(norStart, norStart + vertexCount * 3),
2908
+ featureEdges: r.subarray(edgeStart, edgeStart + edgeCount * 6),
2909
+ };
2910
+ }
2911
+
2699
2912
  /**
2700
2913
  * Coplanar connected face region and its projection bounds on a plane basis.
2701
2914
  * Returns null for invalid inputs or empty regions.
@@ -2742,9 +2955,9 @@ export class Mesh {
2742
2955
  /**
2743
2956
  * Centroid of the coplanar edge-connected face group containing faceIndex.
2744
2957
  */
2745
- getCoplanarFaceGroupCentroid(faceIndex: number): Point | null {
2746
- ensureInit();
2747
- if (!Number.isFinite(faceIndex) || faceIndex < 0) return null;
2958
+ getCoplanarFaceGroupCentroid(faceIndex: number): Point | null {
2959
+ ensureInit();
2960
+ if (!Number.isFinite(faceIndex) || faceIndex < 0) return null;
2748
2961
  const r = wasm.mesh_get_coplanar_face_group_centroid(
2749
2962
  this._vertexCount,
2750
2963
  this._buffer,
@@ -2914,125 +3127,125 @@ export class Mesh {
2914
3127
  if (this._isClosedVolumeCache === null) {
2915
3128
  this._isClosedVolumeCache = boundaryEdges === 0;
2916
3129
  }
2917
- return {
2918
- boundaryEdges,
2919
- nonManifoldEdges,
2920
- };
2921
- }
2922
-
2923
- /**
2924
- * Get a logical planar face by its planar-face index.
2925
- */
2926
- getPlanarFace(index: number): MeshPlanarFace | null {
2927
- if (!Number.isFinite(index) || index < 0) return null;
2928
- const planarFaces = this.planarFaces;
2929
- return planarFaces[Math.floor(index)] ?? null;
2930
- }
2931
-
2932
- /**
2933
- * Get a logical planar surface wrapper by its planar-face index.
2934
- */
2935
- getSurface(index: number): MeshSurface | null {
2936
- if (!Number.isFinite(index) || index < 0) return null;
2937
- const surfaces = this.surfaces;
2938
- return surfaces[Math.floor(index)] ?? null;
2939
- }
2940
-
2941
- /**
2942
- * Get the logical planar face that contains a triangle face index.
2943
- */
2944
- getPlanarFaceByTriangleIndex(triangleIndex: number): MeshPlanarFace | null {
2945
- if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
2946
- this.ensurePlanarFaceCache();
2947
- return this._planarFacesByTriangleIndex?.[Math.floor(triangleIndex)] ?? null;
2948
- }
2949
-
2950
- /**
2951
- * Get the logical planar surface wrapper that contains a triangle face index.
2952
- */
2953
- getSurfaceByTriangleIndex(triangleIndex: number): MeshSurface | null {
2954
- if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
2955
- this.ensureSurfaceCache();
2956
- return this._surfacesByTriangleIndex?.[Math.floor(triangleIndex)] ?? null;
2957
- }
2958
-
2959
- /**
2960
- * Find the best matching logical planar face by normal and optional centroid proximity.
2961
- */
2962
- findPlanarFaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshPlanarFace | null {
2963
- const planarFaces = this.planarFaces;
2964
- if (planarFaces.length === 0) return null;
2965
-
2966
- const normalizedTarget = targetNormal.normalize();
2967
- if (normalizedTarget.length() < 1e-12) return null;
2968
-
2969
- const alignmentEps = 1e-9;
2970
- const distanceEps = 1e-9;
2971
-
2972
- let best: MeshPlanarFace | null = null;
2973
- let bestAlignment = -Infinity;
2974
- let bestDistance = Number.POSITIVE_INFINITY;
2975
- let bestArea = -Infinity;
2976
-
2977
- for (const planarFace of planarFaces) {
2978
- const alignment = planarFace.normal.dot(normalizedTarget);
2979
- const distance = nearPoint ? planarFace.centroid.distanceTo(nearPoint) : 0;
2980
-
2981
- const betterAlignment = alignment > bestAlignment + alignmentEps;
2982
- const similarAlignment = Math.abs(alignment - bestAlignment) <= alignmentEps;
2983
- const betterDistance = nearPoint !== undefined && distance < bestDistance - distanceEps;
2984
- const similarDistance = Math.abs(distance - bestDistance) <= distanceEps;
2985
- const betterArea = planarFace.area > bestArea + distanceEps;
2986
-
2987
- if (
2988
- best === null
2989
- || betterAlignment
2990
- || (similarAlignment && betterDistance)
2991
- || (similarAlignment && (!nearPoint || similarDistance) && betterArea)
2992
- ) {
2993
- best = planarFace;
2994
- bestAlignment = alignment;
2995
- bestDistance = distance;
2996
- bestArea = planarFace.area;
2997
- }
2998
- }
2999
-
3000
- return best;
3001
- }
3002
-
3003
- /**
3004
- * Find the best matching logical planar surface wrapper by normal
3005
- * similarity and optional point proximity.
3006
- */
3007
- findSurfaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshSurface | null {
3008
- const face = this.findPlanarFaceByNormal(targetNormal, nearPoint);
3009
- if (!face) return null;
3010
- return this.getSurface(face.index);
3011
- }
3012
-
3013
- /**
3014
- * Odd/even point containment test against a closed mesh.
3015
- * Uses majority vote across multiple ray directions for robustness.
3130
+ return {
3131
+ boundaryEdges,
3132
+ nonManifoldEdges,
3133
+ };
3134
+ }
3135
+
3136
+ /**
3137
+ * Get a logical planar face by its planar-face index.
3138
+ */
3139
+ getPlanarFace(index: number): MeshPlanarFace | null {
3140
+ if (!Number.isFinite(index) || index < 0) return null;
3141
+ const planarFaces = this.planarFaces;
3142
+ return planarFaces[Math.floor(index)] ?? null;
3143
+ }
3144
+
3145
+ /**
3146
+ * Get a logical planar surface wrapper by its planar-face index.
3147
+ */
3148
+ getSurface(index: number): MeshSurface | null {
3149
+ if (!Number.isFinite(index) || index < 0) return null;
3150
+ const surfaces = this.surfaces;
3151
+ return surfaces[Math.floor(index)] ?? null;
3152
+ }
3153
+
3154
+ /**
3155
+ * Get the logical planar face that contains a triangle face index.
3156
+ */
3157
+ getPlanarFaceByTriangleIndex(triangleIndex: number): MeshPlanarFace | null {
3158
+ if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
3159
+ this.ensurePlanarFaceCache();
3160
+ return this._planarFacesByTriangleIndex?.[Math.floor(triangleIndex)] ?? null;
3161
+ }
3162
+
3163
+ /**
3164
+ * Get the logical planar surface wrapper that contains a triangle face index.
3165
+ */
3166
+ getSurfaceByTriangleIndex(triangleIndex: number): MeshSurface | null {
3167
+ if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
3168
+ this.ensureSurfaceCache();
3169
+ return this._surfacesByTriangleIndex?.[Math.floor(triangleIndex)] ?? null;
3170
+ }
3171
+
3172
+ /**
3173
+ * Find the best matching logical planar face by normal and optional centroid proximity.
3174
+ */
3175
+ findPlanarFaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshPlanarFace | null {
3176
+ const planarFaces = this.planarFaces;
3177
+ if (planarFaces.length === 0) return null;
3178
+
3179
+ const normalizedTarget = targetNormal.normalize();
3180
+ if (normalizedTarget.length() < 1e-12) return null;
3181
+
3182
+ const alignmentEps = 1e-9;
3183
+ const distanceEps = 1e-9;
3184
+
3185
+ let best: MeshPlanarFace | null = null;
3186
+ let bestAlignment = -Infinity;
3187
+ let bestDistance = Number.POSITIVE_INFINITY;
3188
+ let bestArea = -Infinity;
3189
+
3190
+ for (const planarFace of planarFaces) {
3191
+ const alignment = planarFace.normal.dot(normalizedTarget);
3192
+ const distance = nearPoint ? planarFace.centroid.distanceTo(nearPoint) : 0;
3193
+
3194
+ const betterAlignment = alignment > bestAlignment + alignmentEps;
3195
+ const similarAlignment = Math.abs(alignment - bestAlignment) <= alignmentEps;
3196
+ const betterDistance = nearPoint !== undefined && distance < bestDistance - distanceEps;
3197
+ const similarDistance = Math.abs(distance - bestDistance) <= distanceEps;
3198
+ const betterArea = planarFace.area > bestArea + distanceEps;
3199
+
3200
+ if (
3201
+ best === null
3202
+ || betterAlignment
3203
+ || (similarAlignment && betterDistance)
3204
+ || (similarAlignment && (!nearPoint || similarDistance) && betterArea)
3205
+ ) {
3206
+ best = planarFace;
3207
+ bestAlignment = alignment;
3208
+ bestDistance = distance;
3209
+ bestArea = planarFace.area;
3210
+ }
3211
+ }
3212
+
3213
+ return best;
3214
+ }
3215
+
3216
+ /**
3217
+ * Find the best matching logical planar surface wrapper by normal
3218
+ * similarity and optional point proximity.
3219
+ */
3220
+ findSurfaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshSurface | null {
3221
+ const face = this.findPlanarFaceByNormal(targetNormal, nearPoint);
3222
+ if (!face) return null;
3223
+ return this.getSurface(face.index);
3224
+ }
3225
+
3226
+ /**
3227
+ * Odd/even point containment test against a closed mesh.
3228
+ * Uses majority vote across multiple ray directions for robustness.
3016
3229
  */
3017
3230
  containsPoint(point: Point): boolean {
3018
3231
  ensureInit();
3019
3232
  return wasm.mesh_contains_point(this._vertexCount, this._buffer, point.x, point.y, point.z);
3020
3233
  }
3021
3234
 
3022
- /**
3023
- * Find the coplanar + edge-connected face group containing a triangle.
3024
- */
3025
- findFaceByTriangleIndex(triangleIndex: number): MeshPlanarFace | null {
3026
- return this.getPlanarFaceByTriangleIndex(triangleIndex);
3027
- }
3028
-
3029
- /**
3030
- * Find the best matching coplanar + edge-connected face group by normal
3031
- * similarity and optional point proximity.
3032
- */
3033
- findFaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshPlanarFace | null {
3034
- return this.findPlanarFaceByNormal(targetNormal, nearPoint);
3035
- }
3235
+ /**
3236
+ * Find the coplanar + edge-connected face group containing a triangle.
3237
+ */
3238
+ findFaceByTriangleIndex(triangleIndex: number): MeshPlanarFace | null {
3239
+ return this.getPlanarFaceByTriangleIndex(triangleIndex);
3240
+ }
3241
+
3242
+ /**
3243
+ * Find the best matching coplanar + edge-connected face group by normal
3244
+ * similarity and optional point proximity.
3245
+ */
3246
+ findFaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshPlanarFace | null {
3247
+ return this.findPlanarFaceByNormal(targetNormal, nearPoint);
3248
+ }
3036
3249
 
3037
3250
  // ── Export ──────────────────────────────────────────────────────
3038
3251