okgeometry-api 1.2.0 → 1.4.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.
Files changed (65) hide show
  1. package/dist/Line.d.ts +10 -1
  2. package/dist/Line.d.ts.map +1 -1
  3. package/dist/Line.js +11 -0
  4. package/dist/Line.js.map +1 -1
  5. package/dist/Mesh.d.ts +123 -9
  6. package/dist/Mesh.d.ts.map +1 -1
  7. package/dist/Mesh.js +401 -26
  8. package/dist/Mesh.js.map +1 -1
  9. package/dist/MeshSurface.d.ts +32 -0
  10. package/dist/MeshSurface.d.ts.map +1 -0
  11. package/dist/MeshSurface.js +51 -0
  12. package/dist/MeshSurface.js.map +1 -0
  13. package/dist/NurbsCurve.d.ts +50 -2
  14. package/dist/NurbsCurve.d.ts.map +1 -1
  15. package/dist/NurbsCurve.js +76 -2
  16. package/dist/NurbsCurve.js.map +1 -1
  17. package/dist/NurbsSurface.d.ts +9 -1
  18. package/dist/NurbsSurface.d.ts.map +1 -1
  19. package/dist/NurbsSurface.js +12 -3
  20. package/dist/NurbsSurface.js.map +1 -1
  21. package/dist/PolyCurve.d.ts +21 -3
  22. package/dist/PolyCurve.d.ts.map +1 -1
  23. package/dist/PolyCurve.js +82 -38
  24. package/dist/PolyCurve.js.map +1 -1
  25. package/dist/Polygon.d.ts +13 -2
  26. package/dist/Polygon.d.ts.map +1 -1
  27. package/dist/Polygon.js +21 -3
  28. package/dist/Polygon.js.map +1 -1
  29. package/dist/Polyline.d.ts +19 -2
  30. package/dist/Polyline.d.ts.map +1 -1
  31. package/dist/Polyline.js +38 -6
  32. package/dist/Polyline.js.map +1 -1
  33. package/dist/Surface.d.ts +17 -0
  34. package/dist/Surface.d.ts.map +1 -0
  35. package/dist/Surface.js +2 -0
  36. package/dist/Surface.js.map +1 -0
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/types.d.ts +13 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/wasm-base64.d.ts +1 -1
  44. package/dist/wasm-base64.d.ts.map +1 -1
  45. package/dist/wasm-base64.js +1 -1
  46. package/dist/wasm-base64.js.map +1 -1
  47. package/dist/wasm-bindings.d.ts +132 -2
  48. package/dist/wasm-bindings.d.ts.map +1 -1
  49. package/dist/wasm-bindings.js +207 -2
  50. package/dist/wasm-bindings.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/Line.ts +38 -20
  53. package/src/Mesh.ts +909 -464
  54. package/src/MeshSurface.ts +72 -0
  55. package/src/NurbsCurve.ts +103 -3
  56. package/src/NurbsSurface.ts +28 -13
  57. package/src/PolyCurve.ts +157 -85
  58. package/src/Polygon.ts +34 -4
  59. package/src/Polyline.ts +74 -24
  60. package/src/Surface.ts +18 -0
  61. package/src/index.ts +5 -0
  62. package/src/types.ts +15 -0
  63. package/src/wasm-base64.ts +1 -1
  64. package/src/wasm-bindings.d.ts +101 -2
  65. package/src/wasm-bindings.js +218 -2
package/src/Mesh.ts CHANGED
@@ -7,6 +7,7 @@ import { Line } from "./Line.js";
7
7
  import { Circle } from "./Circle.js";
8
8
  import { Arc } from "./Arc.js";
9
9
  import { Polygon } from "./Polygon.js";
10
+ import { MeshSurface } from "./MeshSurface.js";
10
11
  import { NurbsCurve } from "./NurbsCurve.js";
11
12
  import { PolyCurve } from "./PolyCurve.js";
12
13
  import type { SweepableCurve, RotationAxis } from "./types.js";
@@ -56,6 +57,16 @@ export interface PlanarArc {
56
57
  sweepAngle: number;
57
58
  }
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
+
59
70
  interface RawMeshBounds {
60
71
  minX: number;
61
72
  minY: number;
@@ -177,41 +188,41 @@ export interface MeshBooleanReproPayload {
177
188
  error?: MeshBooleanReproError;
178
189
  }
179
190
 
180
- export interface MeshBooleanReproOptions {
181
- includeResult?: boolean;
182
- debugOptions?: MeshBooleanDebugOptions;
183
- }
184
-
185
- export interface MeshSplitResult {
186
- outside: Mesh[];
187
- inside: Mesh[];
188
- all: Mesh[];
189
- classification?: "volume" | "surface";
190
- negative?: Mesh[];
191
- positive?: Mesh[];
192
- }
193
-
194
- export interface MeshPlaneSplitResult extends MeshSplitResult {
195
- negative: Mesh[];
196
- positive: Mesh[];
197
- }
198
-
199
- export interface MeshPlaneSplitOptions extends MeshBooleanOptions {
200
- planePaddingScale?: number;
201
- }
202
-
203
- export interface MeshCurveSplitOptions extends MeshBooleanOptions {
204
- /**
205
- * Explicit cutter extrusion direction. If omitted, a through-cut is generated
206
- * automatically along the detected curve normal across the host mesh.
207
- */
208
- direction?: Vec3;
209
- /**
210
- * Tessellation density for curved closed profiles such as circles, polycurves,
211
- * and NURBS curves.
212
- */
213
- curveSegments?: number;
214
- }
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
+ }
215
226
 
216
227
  function shouldFallbackFromWorkerFailure(error: unknown): boolean {
217
228
  if (!error || typeof error !== "object") return false;
@@ -250,6 +261,10 @@ export class Mesh {
250
261
  private _indexBuffer: Uint32Array | null = null;
251
262
  private _vertices: Point[] | null = null;
252
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;
253
268
  private _edgeVertexPairs: Array<[number, number]> | null = null;
254
269
  private _topologyMetricsCache: { boundaryEdges: number; nonManifoldEdges: number } | null = null;
255
270
  private _isClosedVolumeCache: boolean | null = null;
@@ -339,6 +354,60 @@ export class Mesh {
339
354
  return Number.isFinite(diag) && diag > 0 ? diag : 1;
340
355
  }
341
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
+
342
411
  private static booleanContactTolerance(a: RawMeshBounds | null, b: RawMeshBounds | null): number {
343
412
  const scale = Math.max(Mesh.boundsDiag(a), Mesh.boundsDiag(b));
344
413
  return Math.min(1e-4, Math.max(1e-9, scale * 1e-6));
@@ -673,203 +742,203 @@ export class Mesh {
673
742
  });
674
743
  }
675
744
 
676
- private static emptyMesh(): Mesh {
677
- return Mesh.fromTrustedBuffer(new Float64Array(0));
678
- }
679
-
680
- private static createSplitResult(
681
- outside: Mesh[],
682
- inside: Mesh[],
683
- classification: "volume" | "surface" = "volume",
684
- ): MeshSplitResult {
685
- const result: MeshSplitResult = {
686
- outside,
687
- inside,
688
- all: outside.concat(inside),
689
- classification,
690
- };
691
- if (classification === "surface") {
692
- result.negative = outside;
693
- result.positive = inside;
694
- }
695
- return result;
696
- }
697
-
698
- private static createSurfaceSplitResult(negative: Mesh[], positive: Mesh[]): MeshSplitResult {
699
- return Mesh.createSplitResult(negative, positive, "surface");
700
- }
701
-
702
- private static createPlaneSplitResult(
703
- negative: Mesh[],
704
- positive: Mesh[],
705
- classification: "volume" | "surface" = "surface",
706
- ): MeshPlaneSplitResult {
707
- return {
708
- ...Mesh.createSplitResult(negative, positive, classification),
709
- negative,
710
- positive,
711
- };
712
- }
713
-
714
- private static parseSplitResultBuffer(buffer: Float64Array): MeshSplitResult {
715
- if (buffer.length < 2) {
716
- throw new Error("Mesh split failed and returned a corrupt split buffer.");
717
- }
718
-
719
- const outsideCount = Math.max(0, Math.floor(buffer[0] ?? 0));
720
- const insideCount = Math.max(0, Math.floor(buffer[1] ?? 0));
721
- let offset = 2;
722
-
723
- const decodeGroup = (count: number): Mesh[] => {
724
- const meshes: Mesh[] = [];
725
- for (let i = 0; i < count; i++) {
726
- const rawLen = Math.floor(buffer[offset++] ?? -1);
727
- if (rawLen < 0 || offset + rawLen > buffer.length) {
728
- throw new Error("Mesh split failed and returned an invalid component buffer.");
729
- }
730
- meshes.push(Mesh.fromTrustedBuffer(buffer.slice(offset, offset + rawLen)));
731
- offset += rawLen;
732
- }
733
- return meshes;
734
- };
735
-
736
- const outside = decodeGroup(outsideCount);
737
- const inside = decodeGroup(insideCount);
738
- if (offset !== buffer.length) {
739
- throw new Error("Mesh split failed and returned trailing buffer data.");
740
- }
741
- return Mesh.createSplitResult(outside, inside);
742
- }
743
-
744
- private static orthonormalPlaneFrame(plane: Plane): { uAxis: Vec3; vAxis: Vec3; normal: Vec3 } {
745
- const normal = plane.normal.normalize();
746
- let uAxis = plane.getXAxis().normalize();
747
- if (uAxis.length() < 1e-9) {
748
- uAxis = Math.abs(normal.x) > 0.9 ? Vec3.Y : Vec3.X;
749
- uAxis = uAxis.sub(normal.scale(uAxis.dot(normal))).normalize();
750
- }
751
- const vAxis = normal.cross(uAxis).normalize();
752
- return { uAxis, vAxis, normal };
753
- }
754
-
755
- private static createOrientedBox(
756
- center: Point,
757
- uAxis: Vec3,
758
- vAxis: Vec3,
759
- wAxis: Vec3,
760
- halfU: number,
761
- halfV: number,
762
- halfW: number,
763
- ): Mesh {
764
- const axes = [uAxis.normalize(), vAxis.normalize(), wAxis.normalize()] as const;
765
- const [u, v, w] = axes;
766
- const corners = [
767
- center.add(u.scale(-halfU)).add(v.scale(-halfV)).add(w.scale(-halfW)),
768
- center.add(u.scale(halfU)).add(v.scale(-halfV)).add(w.scale(-halfW)),
769
- center.add(u.scale(halfU)).add(v.scale(halfV)).add(w.scale(-halfW)),
770
- center.add(u.scale(-halfU)).add(v.scale(halfV)).add(w.scale(-halfW)),
771
- center.add(u.scale(-halfU)).add(v.scale(-halfV)).add(w.scale(halfW)),
772
- center.add(u.scale(halfU)).add(v.scale(-halfV)).add(w.scale(halfW)),
773
- center.add(u.scale(halfU)).add(v.scale(halfV)).add(w.scale(halfW)),
774
- center.add(u.scale(-halfU)).add(v.scale(halfV)).add(w.scale(halfW)),
775
- ];
776
- const indices = [
777
- 0, 2, 1, 0, 3, 2,
778
- 4, 5, 6, 4, 6, 7,
779
- 0, 1, 5, 0, 5, 4,
780
- 3, 7, 6, 3, 6, 2,
781
- 0, 4, 7, 0, 7, 3,
782
- 1, 2, 6, 1, 6, 5,
783
- ];
784
- const raw = new Float64Array(1 + corners.length * 3 + indices.length);
785
- raw[0] = corners.length;
786
- let offset = 1;
787
- for (const corner of corners) {
788
- raw[offset++] = corner.x;
789
- raw[offset++] = corner.y;
790
- raw[offset++] = corner.z;
791
- }
792
- for (const index of indices) {
793
- raw[offset++] = index;
794
- }
795
- return Mesh.fromBuffer(raw, { trustedBooleanInput: true });
796
- }
797
-
798
- private static classifyPlaneDistances(mesh: Mesh, plane: Plane): { min: number; max: number } {
799
- if (mesh.vertexCount <= 0) return { min: 0, max: 0 };
800
- let min = Number.POSITIVE_INFINITY;
801
- let max = Number.NEGATIVE_INFINITY;
802
- for (const vertex of mesh.vertices) {
803
- const distance = plane.distanceTo(vertex);
804
- if (distance < min) min = distance;
805
- if (distance > max) max = distance;
806
- }
807
- return { min, max };
808
- }
809
-
810
- private static createPlaneHalfSpaceCutter(
811
- host: Mesh,
812
- plane: Plane,
813
- options?: MeshPlaneSplitOptions,
814
- ): Mesh {
815
- const bounds = Mesh.toDebugBounds(host.getBounds());
816
- const scale = Number.isFinite(options?.planePaddingScale)
817
- ? Math.max(1.1, options?.planePaddingScale ?? 2.5)
818
- : 2.5;
819
- const radius = Math.max(1e-6, bounds.diagonal * 0.5);
820
- const halfU = radius * scale;
821
- const halfV = radius * scale;
822
- const halfW = radius * scale;
823
- const { uAxis, vAxis, normal } = Mesh.orthonormalPlaneFrame(plane);
824
- const projectedCenter = plane.projectPoint(bounds.center);
825
- const boxCenter = projectedCenter.add(normal.scale(halfW));
826
- return Mesh.createOrientedBox(boxCenter, uAxis, vAxis, normal, halfU, halfV, halfW);
827
- }
828
-
829
- private static detectClosedCurveNormal(curve: SweepableCurve, points: Point[]): Vec3 {
830
- if (curve instanceof Circle || curve instanceof Arc) {
831
- return curve.normal.normalize();
832
- }
833
- const normal = Mesh.computePlanarCurveNormal(points, true).normalize();
834
- if (!Number.isFinite(normal.x) || !Number.isFinite(normal.y) || !Number.isFinite(normal.z) || normal.length() < 1e-9) {
835
- throw new Error("Mesh.split(curve) requires a planar closed curve with a valid normal.");
836
- }
837
- return normal;
838
- }
839
-
840
- private static createCurveSplitCutter(
841
- host: Mesh,
842
- curve: SweepableCurve,
843
- options?: MeshCurveSplitOptions,
844
- ): Mesh {
845
- const segments = Number.isFinite(options?.curveSegments)
846
- ? Math.max(3, Math.floor(options?.curveSegments ?? 32))
847
- : 32;
848
- const points = Mesh.getClosedCurveLoopPoints(curve, segments);
849
- const normal = Mesh.detectClosedCurveNormal(curve, points);
850
-
851
- let extrusionDirection = options?.direction;
852
- let basePoints = points;
853
- if (extrusionDirection) {
854
- if (!Number.isFinite(extrusionDirection.length()) || extrusionDirection.length() < 1e-9) {
855
- throw new Error("Mesh.split(curve) requires a non-zero direction when direction is provided.");
856
- }
857
- } else {
858
- const bounds = Mesh.toDebugBounds(host.getBounds());
859
- const margin = Math.max(1e-6, bounds.diagonal * 0.25);
860
- const distances = host.vertices.map((vertex) => normal.dot(vertex.sub(points[0])));
861
- const minDistance = Math.min(...distances);
862
- const maxDistance = Math.max(...distances);
863
- const halfDepth = Math.max(Math.abs(minDistance), Math.abs(maxDistance)) + margin;
864
- extrusionDirection = normal.scale(halfDepth * 2);
865
- basePoints = points.map((point) => point.add(normal.scale(-halfDepth)));
866
- }
867
-
868
- const height = extrusionDirection.length();
869
- const unit = extrusionDirection.normalize();
870
- const prepared = Mesh.prepareBooleanCutterCurve(basePoints, true, unit, height);
871
- return Mesh.extrudePlanarCurve(prepared.points, unit, prepared.height, true);
872
- }
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
+ }
873
942
 
874
943
  // ── GPU-ready buffers ──────────────────────────────────────────
875
944
 
@@ -909,7 +978,7 @@ export class Mesh {
909
978
  return this._vertexCount;
910
979
  }
911
980
 
912
- /** Number of triangular faces in this mesh */
981
+ /** Number of triangular faces in this mesh. Use `planarFaceCount` for logical planar faces. */
913
982
  get faceCount(): number {
914
983
  const start = 1 + this._vertexCount * 3;
915
984
  if (this._buffer.length <= start) return 0;
@@ -937,6 +1006,7 @@ export class Mesh {
937
1006
  /**
938
1007
  * Get all faces as arrays of vertex indices.
939
1008
  * Each face is [i0, i1, i2] for the three triangle vertices.
1009
+ * For coplanar edge-connected logical faces, use `planarFaces`.
940
1010
  * Lazy-computed and cached.
941
1011
  */
942
1012
  get faces(): number[][] {
@@ -951,6 +1021,47 @@ export class Mesh {
951
1021
  return this._faces;
952
1022
  }
953
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
+
954
1065
  /** Raw WASM buffer (for advanced use / re-passing to WASM) */
955
1066
  get rawBuffer(): Float64Array {
956
1067
  return this._buffer;
@@ -971,13 +1082,134 @@ export class Mesh {
971
1082
  }
972
1083
 
973
1084
  private static fromTrustedBuffer(buffer: Float64Array): Mesh {
974
- return new Mesh(buffer, true);
1085
+ return Mesh.normalizeClosedVolumeOrientation(new Mesh(buffer, true));
975
1086
  }
976
1087
 
977
1088
  get trustedBooleanInput(): boolean {
978
1089
  return this._trustedBooleanInput;
979
1090
  }
980
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
+
981
1213
  debugSummary(): MeshDebugSummary {
982
1214
  const bounds = Mesh.toDebugBounds(this.getBounds());
983
1215
  const topology = this.topologyMetrics();
@@ -1397,6 +1629,32 @@ export class Mesh {
1397
1629
  return Mesh.fromTrustedBuffer(wasm.loft_polylines(new Float64Array(parts), segments, caps));
1398
1630
  }
1399
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
+
1400
1658
  /**
1401
1659
  * Sweep a profile polyline along a path polyline.
1402
1660
  * @param profilePoints - Profile cross-section points
@@ -1816,10 +2074,53 @@ export class Mesh {
1816
2074
  return Mesh.fromTrustedBuffer(result);
1817
2075
  }
1818
2076
 
1819
- static encodeBooleanOperationToken(
1820
- operation: MeshBooleanOperation,
1821
- a: Mesh,
1822
- b: Mesh,
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,
1823
2124
  options?: MeshBooleanOptions,
1824
2125
  ): string {
1825
2126
  const tokens: string[] = [operation];
@@ -1828,44 +2129,48 @@ export class Mesh {
1828
2129
  }
1829
2130
  if (options?.debugForceFaceID) {
1830
2131
  tokens.push("forceFaceID");
1831
- }
1832
- return tokens.join("@");
1833
- }
1834
-
1835
- static encodeBooleanSplitToken(
1836
- a: Mesh,
1837
- b: Mesh,
1838
- options?: MeshBooleanOptions,
1839
- ): string {
1840
- const tokens: string[] = ["split"];
1841
- if (a._trustedBooleanInput && b._trustedBooleanInput) {
1842
- tokens.push("trustedInput");
1843
- }
1844
- if (options?.debugForceFaceID) {
1845
- tokens.push("forceFaceID");
1846
- }
1847
- return tokens.join("@");
1848
- }
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
+ }
1849
2150
 
1850
2151
  // ── Booleans ───────────────────────────────────────────────────
1851
2152
 
1852
2153
  /**
1853
2154
  * Compute boolean union with another mesh.
2155
+ * Requires both inputs to be closed volumes.
1854
2156
  * @param other - Mesh to union with
1855
2157
  * @param options - Optional safety overrides
1856
2158
  * @returns New mesh containing volume of both inputs
1857
2159
  */
1858
2160
  union(other: Mesh, options?: MeshBooleanOptions): Mesh {
1859
2161
  ensureInit();
1860
- const operationToken = Mesh.encodeBooleanOperationToken("union", this, other, options);
1861
- return this.runBoolean(
1862
- other,
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,
1863
2168
  "union",
1864
2169
  () => wasm.mesh_boolean_operation(
1865
- this._vertexCount,
1866
- this._buffer,
1867
- other._vertexCount,
1868
- other._buffer,
2170
+ lhs._vertexCount,
2171
+ lhs._buffer,
2172
+ rhs._vertexCount,
2173
+ rhs._buffer,
1869
2174
  operationToken,
1870
2175
  ),
1871
2176
  options,
@@ -1874,21 +2179,27 @@ export class Mesh {
1874
2179
 
1875
2180
  /**
1876
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.
1877
2184
  * @param other - Mesh to subtract
1878
2185
  * @param options - Optional safety overrides
1879
2186
  * @returns New mesh with other's volume removed from this
1880
2187
  */
1881
2188
  subtract(other: Mesh, options?: MeshBooleanOptions): Mesh {
1882
2189
  ensureInit();
1883
- const operationToken = Mesh.encodeBooleanOperationToken("subtraction", this, other, options);
1884
- return this.runBoolean(
1885
- other,
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,
1886
2197
  "subtraction",
1887
2198
  () => wasm.mesh_boolean_operation(
1888
- this._vertexCount,
1889
- this._buffer,
1890
- other._vertexCount,
1891
- other._buffer,
2199
+ lhs._vertexCount,
2200
+ lhs._buffer,
2201
+ rhs._vertexCount,
2202
+ rhs._buffer,
1892
2203
  operationToken,
1893
2204
  ),
1894
2205
  options,
@@ -1897,179 +2208,185 @@ export class Mesh {
1897
2208
 
1898
2209
  /**
1899
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.
1900
2213
  * @param other - Mesh to intersect with
1901
2214
  * @param options - Optional safety overrides
1902
2215
  * @returns New mesh containing only the overlapping volume
1903
2216
  */
1904
- intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
1905
- ensureInit();
1906
- const operationToken = Mesh.encodeBooleanOperationToken("intersection", this, other, options);
1907
- return this.runBoolean(
1908
- other,
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,
1909
2226
  "intersection",
1910
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
+ private splitWithMesh(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult {
2239
+ ensureInit();
2240
+
2241
+ const faceCountA = this.faceCount;
2242
+ const faceCountB = other.faceCount;
2243
+ const hostClosed = this.isClosedVolume();
2244
+ const cutterClosed = other.isClosedVolume();
2245
+ const useVolumetricSplit = hostClosed && cutterClosed;
2246
+ if (faceCountA === 0) return Mesh.createSplitResult([], [], useVolumetricSplit ? "volume" : "surface");
2247
+ if (faceCountB === 0) {
2248
+ return Mesh.createSplitResult(
2249
+ [Mesh.cloneMesh(this)],
2250
+ [],
2251
+ useVolumetricSplit || cutterClosed ? "volume" : "surface",
2252
+ );
2253
+ }
2254
+
2255
+ const boundsA = Mesh.computeRawBounds(this);
2256
+ const boundsB = Mesh.computeRawBounds(other);
2257
+ const contactTol = Mesh.booleanContactTolerance(boundsA, boundsB);
2258
+ if (useVolumetricSplit && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
2259
+ return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
2260
+ }
2261
+ if (!useVolumetricSplit && cutterClosed && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
2262
+ return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
2263
+ }
2264
+
2265
+ if (!options?.allowUnsafe) {
2266
+ const limits = Mesh.resolveBooleanLimits(options?.limits);
2267
+ const maxInputFaces = Math.max(faceCountA, faceCountB);
2268
+ const combinedInputFaces = faceCountA + faceCountB;
2269
+ const faceProduct = faceCountA * faceCountB;
2270
+ if (
2271
+ maxInputFaces > limits.maxInputFacesPerMesh
2272
+ || combinedInputFaces > limits.maxCombinedInputFaces
2273
+ || faceProduct > limits.maxFaceProduct
2274
+ ) {
2275
+ throw new Error(
2276
+ `Mesh split blocked by safety limits `
2277
+ + `(A faces=${faceCountA}, B faces=${faceCountB}, faceProduct=${faceProduct}). `
2278
+ + "Simplify inputs or pass allowUnsafe: true to force execution.",
2279
+ );
2280
+ }
2281
+ }
2282
+
2283
+ const splitToken = Mesh.encodeBooleanSplitToken(this, other, options);
2284
+ const result = useVolumetricSplit
2285
+ ? wasm.mesh_boolean_split(
1911
2286
  this._vertexCount,
1912
2287
  this._buffer,
1913
2288
  other._vertexCount,
1914
2289
  other._buffer,
1915
- operationToken,
1916
- ),
1917
- options,
1918
- );
1919
- }
1920
-
1921
- private splitWithMesh(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult {
1922
- ensureInit();
1923
-
1924
- const faceCountA = this.faceCount;
1925
- const faceCountB = other.faceCount;
1926
- const hostClosed = this.isClosedVolume();
1927
- const cutterClosed = other.isClosedVolume();
1928
- const useVolumetricSplit = hostClosed && cutterClosed;
1929
- if (faceCountA === 0) return Mesh.createSplitResult([], [], useVolumetricSplit ? "volume" : "surface");
1930
- if (faceCountB === 0) {
1931
- return Mesh.createSplitResult(
1932
- [Mesh.cloneMesh(this)],
1933
- [],
1934
- useVolumetricSplit || cutterClosed ? "volume" : "surface",
1935
- );
1936
- }
1937
-
1938
- const boundsA = Mesh.computeRawBounds(this);
1939
- const boundsB = Mesh.computeRawBounds(other);
1940
- const contactTol = Mesh.booleanContactTolerance(boundsA, boundsB);
1941
- if (useVolumetricSplit && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
1942
- return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
1943
- }
1944
- if (!useVolumetricSplit && cutterClosed && !Mesh.boundsOverlap(boundsA, boundsB, contactTol)) {
1945
- return Mesh.createSplitResult([Mesh.cloneMesh(this)], [], "volume");
1946
- }
1947
-
1948
- if (!options?.allowUnsafe) {
1949
- const limits = Mesh.resolveBooleanLimits(options?.limits);
1950
- const maxInputFaces = Math.max(faceCountA, faceCountB);
1951
- const combinedInputFaces = faceCountA + faceCountB;
1952
- const faceProduct = faceCountA * faceCountB;
1953
- if (
1954
- maxInputFaces > limits.maxInputFacesPerMesh
1955
- || combinedInputFaces > limits.maxCombinedInputFaces
1956
- || faceProduct > limits.maxFaceProduct
1957
- ) {
1958
- throw new Error(
1959
- `Mesh split blocked by safety limits `
1960
- + `(A faces=${faceCountA}, B faces=${faceCountB}, faceProduct=${faceProduct}). `
1961
- + "Simplify inputs or pass allowUnsafe: true to force execution.",
1962
- );
1963
- }
1964
- }
1965
-
1966
- const splitToken = Mesh.encodeBooleanSplitToken(this, other, options);
1967
- const result = useVolumetricSplit
1968
- ? wasm.mesh_boolean_split(
1969
- this._vertexCount,
1970
- this._buffer,
1971
- other._vertexCount,
1972
- other._buffer,
1973
- splitToken,
1974
- )
1975
- : wasm.mesh_surface_split(
1976
- this._vertexCount,
1977
- this._buffer,
1978
- other._vertexCount,
1979
- other._buffer,
1980
- splitToken,
1981
- );
1982
- if (result.length === 0) {
1983
- throw new Error("Mesh split failed and returned an invalid result buffer.");
1984
- }
1985
- const parsed = Mesh.parseSplitResultBuffer(result);
1986
- return cutterClosed
1987
- ? Mesh.createSplitResult(parsed.outside, parsed.inside, "volume")
1988
- : Mesh.createSurfaceSplitResult(parsed.outside, parsed.inside);
1989
- }
1990
-
1991
- private splitWithPlane(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult {
1992
- ensureInit();
1993
- const hostClosed = this.isClosedVolume();
1994
-
1995
- if (this.faceCount === 0) return Mesh.createPlaneSplitResult([], [], hostClosed ? "volume" : "surface");
1996
-
1997
- const distances = Mesh.classifyPlaneDistances(this, plane);
1998
- const eps = Mesh.boundsDiag(Mesh.computeRawBounds(this)) * 1e-9 + 1e-9;
1999
- if (distances.max <= eps) {
2000
- return Mesh.createPlaneSplitResult([Mesh.cloneMesh(this)], [], hostClosed ? "volume" : "surface");
2001
- }
2002
- if (distances.min >= -eps) {
2003
- return Mesh.createPlaneSplitResult([], [Mesh.cloneMesh(this)], hostClosed ? "volume" : "surface");
2004
- }
2005
-
2006
- if (!hostClosed) {
2007
- const result = wasm.mesh_surface_split_plane(
2008
- this._vertexCount,
2009
- this._buffer,
2010
- plane.normal.x,
2011
- plane.normal.y,
2012
- plane.normal.z,
2013
- plane.d,
2014
- Mesh.encodeBooleanSplitToken(this, this, options),
2015
- );
2016
- if (result.length === 0) {
2017
- throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2018
- }
2019
- const split = Mesh.parseSplitResultBuffer(result);
2020
- return Mesh.createPlaneSplitResult(split.outside, split.inside, "surface");
2021
- }
2022
-
2023
- const result = wasm.mesh_solid_split_plane(
2024
- this._vertexCount,
2025
- this._buffer,
2026
- plane.normal.x,
2027
- plane.normal.y,
2028
- plane.normal.z,
2029
- plane.d,
2030
- Mesh.encodeBooleanSplitToken(this, this, options),
2031
- );
2032
- if (result.length === 0) {
2033
- throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2034
- }
2035
- const split = Mesh.parseSplitResultBuffer(result);
2036
- return Mesh.createPlaneSplitResult(split.outside, split.inside, "volume");
2037
- }
2038
-
2039
- private splitWithCurve(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult {
2040
- ensureInit();
2041
- if (this.faceCount === 0) return Mesh.createSplitResult([], []);
2042
- const cutter = Mesh.createCurveSplitCutter(this, curve, options);
2043
- return this.splitWithMesh(cutter, options);
2044
- }
2045
-
2046
- /**
2047
- * Split this mesh by another mesh, a plane, or a closed planar curve.
2048
- * Mesh split: `outside = this - cutter`, `inside = this ∩ cutter`.
2049
- * Plane split: `negative` is opposite the plane normal, `positive` follows the plane normal.
2050
- * Curve split: the curve is converted into a cutter solid, either from the
2051
- * explicit direction you pass or via an automatic through-cut along the
2052
- * detected curve normal across the host mesh.
2053
- */
2054
- split(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult;
2055
- split(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult;
2056
- split(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult;
2057
- split(
2058
- target: Mesh | Plane | SweepableCurve,
2059
- options?: MeshBooleanOptions | MeshPlaneSplitOptions | MeshCurveSplitOptions,
2060
- ): MeshSplitResult | MeshPlaneSplitResult {
2061
- if (target instanceof Mesh) {
2062
- return this.splitWithMesh(target, options as MeshBooleanOptions | undefined);
2063
- }
2064
- if (target instanceof Plane) {
2065
- return this.splitWithPlane(target, options as MeshPlaneSplitOptions | undefined);
2066
- }
2067
- return this.splitWithCurve(target, options as MeshCurveSplitOptions | undefined);
2068
- }
2069
-
2070
- debugBoolean(
2071
- other: Mesh,
2072
- operation: MeshBooleanOperation,
2290
+ splitToken,
2291
+ )
2292
+ : wasm.mesh_surface_split(
2293
+ this._vertexCount,
2294
+ this._buffer,
2295
+ other._vertexCount,
2296
+ other._buffer,
2297
+ splitToken,
2298
+ );
2299
+ if (result.length === 0) {
2300
+ throw new Error("Mesh split failed and returned an invalid result buffer.");
2301
+ }
2302
+ const parsed = Mesh.parseSplitResultBuffer(result);
2303
+ return cutterClosed
2304
+ ? Mesh.createSplitResult(parsed.outside, parsed.inside, "volume")
2305
+ : Mesh.createSurfaceSplitResult(parsed.outside, parsed.inside);
2306
+ }
2307
+
2308
+ private splitWithPlane(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult {
2309
+ ensureInit();
2310
+ const hostClosed = this.isClosedVolume();
2311
+
2312
+ if (this.faceCount === 0) return Mesh.createPlaneSplitResult([], [], hostClosed ? "volume" : "surface");
2313
+
2314
+ const distances = Mesh.classifyPlaneDistances(this, plane);
2315
+ const eps = Mesh.boundsDiag(Mesh.computeRawBounds(this)) * 1e-9 + 1e-9;
2316
+ if (distances.max <= eps) {
2317
+ return Mesh.createPlaneSplitResult([Mesh.cloneMesh(this)], [], hostClosed ? "volume" : "surface");
2318
+ }
2319
+ if (distances.min >= -eps) {
2320
+ return Mesh.createPlaneSplitResult([], [Mesh.cloneMesh(this)], hostClosed ? "volume" : "surface");
2321
+ }
2322
+
2323
+ if (!hostClosed) {
2324
+ const result = wasm.mesh_surface_split_plane(
2325
+ this._vertexCount,
2326
+ this._buffer,
2327
+ plane.normal.x,
2328
+ plane.normal.y,
2329
+ plane.normal.z,
2330
+ plane.d,
2331
+ Mesh.encodeBooleanSplitToken(this, this, options),
2332
+ );
2333
+ if (result.length === 0) {
2334
+ throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2335
+ }
2336
+ const split = Mesh.parseSplitResultBuffer(result);
2337
+ return Mesh.createPlaneSplitResult(split.outside, split.inside, "surface");
2338
+ }
2339
+
2340
+ const result = wasm.mesh_solid_split_plane(
2341
+ this._vertexCount,
2342
+ this._buffer,
2343
+ plane.normal.x,
2344
+ plane.normal.y,
2345
+ plane.normal.z,
2346
+ plane.d,
2347
+ Mesh.encodeBooleanSplitToken(this, this, options),
2348
+ );
2349
+ if (result.length === 0) {
2350
+ throw new Error("Mesh split by plane failed and returned an invalid result buffer.");
2351
+ }
2352
+ const split = Mesh.parseSplitResultBuffer(result);
2353
+ return Mesh.createPlaneSplitResult(split.outside, split.inside, "volume");
2354
+ }
2355
+
2356
+ private splitWithCurve(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult {
2357
+ ensureInit();
2358
+ if (this.faceCount === 0) return Mesh.createSplitResult([], []);
2359
+ const cutter = Mesh.createCurveSplitCutter(this, curve, options);
2360
+ return this.splitWithMesh(cutter, options);
2361
+ }
2362
+
2363
+ /**
2364
+ * Split this mesh by another mesh, a plane, or a closed planar curve.
2365
+ * Mesh split: `outside = this - cutter`, `inside = this ∩ cutter`.
2366
+ * Plane split: `negative` is opposite the plane normal, `positive` follows the plane normal.
2367
+ * Curve split: the curve is converted into a cutter solid, either from the
2368
+ * explicit direction you pass or via an automatic through-cut along the
2369
+ * detected curve normal across the host mesh.
2370
+ */
2371
+ split(other: Mesh, options?: MeshBooleanOptions): MeshSplitResult;
2372
+ split(plane: Plane, options?: MeshPlaneSplitOptions): MeshPlaneSplitResult;
2373
+ split(curve: SweepableCurve, options?: MeshCurveSplitOptions): MeshSplitResult;
2374
+ split(
2375
+ target: Mesh | Plane | SweepableCurve,
2376
+ options?: MeshBooleanOptions | MeshPlaneSplitOptions | MeshCurveSplitOptions,
2377
+ ): MeshSplitResult | MeshPlaneSplitResult {
2378
+ if (target instanceof Mesh) {
2379
+ return this.splitWithMesh(target, options as MeshBooleanOptions | undefined);
2380
+ }
2381
+ if (target instanceof Plane) {
2382
+ return this.splitWithPlane(target, options as MeshPlaneSplitOptions | undefined);
2383
+ }
2384
+ return this.splitWithCurve(target, options as MeshCurveSplitOptions | undefined);
2385
+ }
2386
+
2387
+ debugBoolean(
2388
+ other: Mesh,
2389
+ operation: MeshBooleanOperation,
2073
2390
  options?: MeshBooleanOptions,
2074
2391
  debugOptions?: MeshBooleanDebugOptions,
2075
2392
  ): MeshBooleanDebugReport {
@@ -2405,6 +2722,71 @@ export class Mesh {
2405
2722
  return out;
2406
2723
  }
2407
2724
 
2725
+ /**
2726
+ * Find the edge-connected smooth face group containing a triangle: flood
2727
+ * fill crossing only edges whose dihedral angle is below `maxAngleDeg`.
2728
+ * On a tessellated curved surface (cylinder wall, lofted patch) this
2729
+ * returns the whole smooth region (`isPlanar: false`); on a flat face it
2730
+ * matches the coplanar group (`isPlanar: true`).
2731
+ */
2732
+ getSmoothFaceGroup(
2733
+ faceIndex: number,
2734
+ maxAngleDeg = 30,
2735
+ ): { faceIndices: number[]; isPlanar: boolean } {
2736
+ ensureInit();
2737
+ if (!Number.isFinite(faceIndex) || faceIndex < 0) {
2738
+ return { faceIndices: [], isPlanar: true };
2739
+ }
2740
+ const r = wasm.mesh_get_smooth_face_group(
2741
+ this._vertexCount,
2742
+ this._buffer,
2743
+ Math.floor(faceIndex),
2744
+ maxAngleDeg,
2745
+ );
2746
+ const count = Math.max(0, Math.floor(r[0] ?? 0));
2747
+ const isPlanar = (r[1] ?? 1) !== 0;
2748
+ const faceIndices: number[] = [];
2749
+ for (let i = 0; i < count; i++) {
2750
+ faceIndices.push(Math.floor(r[2 + i] ?? 0));
2751
+ }
2752
+ return { faceIndices, isPlanar };
2753
+ }
2754
+
2755
+ /**
2756
+ * Display-ready render buffers computed in one kernel pass: creased
2757
+ * (crease-split) vertex normals plus feature edge segments. Replaces
2758
+ * per-mesh THREE.toCreasedNormals + EdgesGeometry in app layers.
2759
+ *
2760
+ * `positions`/`normals` are non-indexed, three floats per vertex, in the
2761
+ * mesh's triangle order (raycast face indices stay valid). `featureEdges`
2762
+ * holds two endpoints (six floats) per feature edge segment: boundary
2763
+ * edges, non-manifold edges, and edges whose dihedral angle exceeds
2764
+ * `creaseAngleDeg`.
2765
+ */
2766
+ buildRenderBuffers(creaseAngleDeg = 30): {
2767
+ positions: Float32Array;
2768
+ normals: Float32Array;
2769
+ featureEdges: Float32Array;
2770
+ } {
2771
+ ensureInit();
2772
+ const r: Float32Array = wasm.mesh_build_render_buffers(
2773
+ this._vertexCount,
2774
+ this._buffer,
2775
+ creaseAngleDeg,
2776
+ );
2777
+ const vertexCount = Math.max(0, Math.floor(r[0] ?? 0));
2778
+ const posStart = 1;
2779
+ const norStart = posStart + vertexCount * 3;
2780
+ const edgeCountIndex = norStart + vertexCount * 3;
2781
+ const edgeCount = Math.max(0, Math.floor(r[edgeCountIndex] ?? 0));
2782
+ const edgeStart = edgeCountIndex + 1;
2783
+ return {
2784
+ positions: r.subarray(posStart, posStart + vertexCount * 3),
2785
+ normals: r.subarray(norStart, norStart + vertexCount * 3),
2786
+ featureEdges: r.subarray(edgeStart, edgeStart + edgeCount * 6),
2787
+ };
2788
+ }
2789
+
2408
2790
  /**
2409
2791
  * Coplanar connected face region and its projection bounds on a plane basis.
2410
2792
  * Returns null for invalid inputs or empty regions.
@@ -2629,6 +3011,96 @@ export class Mesh {
2629
3011
  };
2630
3012
  }
2631
3013
 
3014
+ /**
3015
+ * Get a logical planar face by its planar-face index.
3016
+ */
3017
+ getPlanarFace(index: number): MeshPlanarFace | null {
3018
+ if (!Number.isFinite(index) || index < 0) return null;
3019
+ const planarFaces = this.planarFaces;
3020
+ return planarFaces[Math.floor(index)] ?? null;
3021
+ }
3022
+
3023
+ /**
3024
+ * Get a logical planar surface wrapper by its planar-face index.
3025
+ */
3026
+ getSurface(index: number): MeshSurface | null {
3027
+ if (!Number.isFinite(index) || index < 0) return null;
3028
+ const surfaces = this.surfaces;
3029
+ return surfaces[Math.floor(index)] ?? null;
3030
+ }
3031
+
3032
+ /**
3033
+ * Get the logical planar face that contains a triangle face index.
3034
+ */
3035
+ getPlanarFaceByTriangleIndex(triangleIndex: number): MeshPlanarFace | null {
3036
+ if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
3037
+ this.ensurePlanarFaceCache();
3038
+ return this._planarFacesByTriangleIndex?.[Math.floor(triangleIndex)] ?? null;
3039
+ }
3040
+
3041
+ /**
3042
+ * Get the logical planar surface wrapper that contains a triangle face index.
3043
+ */
3044
+ getSurfaceByTriangleIndex(triangleIndex: number): MeshSurface | null {
3045
+ if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
3046
+ this.ensureSurfaceCache();
3047
+ return this._surfacesByTriangleIndex?.[Math.floor(triangleIndex)] ?? null;
3048
+ }
3049
+
3050
+ /**
3051
+ * Find the best matching logical planar face by normal and optional centroid proximity.
3052
+ */
3053
+ findPlanarFaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshPlanarFace | null {
3054
+ const planarFaces = this.planarFaces;
3055
+ if (planarFaces.length === 0) return null;
3056
+
3057
+ const normalizedTarget = targetNormal.normalize();
3058
+ if (normalizedTarget.length() < 1e-12) return null;
3059
+
3060
+ const alignmentEps = 1e-9;
3061
+ const distanceEps = 1e-9;
3062
+
3063
+ let best: MeshPlanarFace | null = null;
3064
+ let bestAlignment = -Infinity;
3065
+ let bestDistance = Number.POSITIVE_INFINITY;
3066
+ let bestArea = -Infinity;
3067
+
3068
+ for (const planarFace of planarFaces) {
3069
+ const alignment = planarFace.normal.dot(normalizedTarget);
3070
+ const distance = nearPoint ? planarFace.centroid.distanceTo(nearPoint) : 0;
3071
+
3072
+ const betterAlignment = alignment > bestAlignment + alignmentEps;
3073
+ const similarAlignment = Math.abs(alignment - bestAlignment) <= alignmentEps;
3074
+ const betterDistance = nearPoint !== undefined && distance < bestDistance - distanceEps;
3075
+ const similarDistance = Math.abs(distance - bestDistance) <= distanceEps;
3076
+ const betterArea = planarFace.area > bestArea + distanceEps;
3077
+
3078
+ if (
3079
+ best === null
3080
+ || betterAlignment
3081
+ || (similarAlignment && betterDistance)
3082
+ || (similarAlignment && (!nearPoint || similarDistance) && betterArea)
3083
+ ) {
3084
+ best = planarFace;
3085
+ bestAlignment = alignment;
3086
+ bestDistance = distance;
3087
+ bestArea = planarFace.area;
3088
+ }
3089
+ }
3090
+
3091
+ return best;
3092
+ }
3093
+
3094
+ /**
3095
+ * Find the best matching logical planar surface wrapper by normal
3096
+ * similarity and optional point proximity.
3097
+ */
3098
+ findSurfaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshSurface | null {
3099
+ const face = this.findPlanarFaceByNormal(targetNormal, nearPoint);
3100
+ if (!face) return null;
3101
+ return this.getSurface(face.index);
3102
+ }
3103
+
2632
3104
  /**
2633
3105
  * Odd/even point containment test against a closed mesh.
2634
3106
  * Uses majority vote across multiple ray directions for robustness.
@@ -2641,43 +3113,16 @@ export class Mesh {
2641
3113
  /**
2642
3114
  * Find the coplanar + edge-connected face group containing a triangle.
2643
3115
  */
2644
- findFaceByTriangleIndex(triangleIndex: number): { centroid: Point; normal: Vec3 } | null {
2645
- if (!Number.isFinite(triangleIndex) || triangleIndex < 0) return null;
2646
- ensureInit();
2647
- const r = wasm.mesh_find_face_group_by_triangle_index(
2648
- this._vertexCount,
2649
- this._buffer,
2650
- Math.floor(triangleIndex),
2651
- );
2652
- if (!r || r.length < 6) return null;
2653
- return {
2654
- centroid: new Point(r[0], r[1], r[2]),
2655
- normal: new Vec3(r[3], r[4], r[5]),
2656
- };
3116
+ findFaceByTriangleIndex(triangleIndex: number): MeshPlanarFace | null {
3117
+ return this.getPlanarFaceByTriangleIndex(triangleIndex);
2657
3118
  }
2658
3119
 
2659
3120
  /**
2660
3121
  * Find the best matching coplanar + edge-connected face group by normal
2661
3122
  * similarity and optional point proximity.
2662
3123
  */
2663
- findFaceByNormal(targetNormal: Vec3, nearPoint?: Point): { centroid: Point; normal: Vec3 } | null {
2664
- ensureInit();
2665
- const r = wasm.mesh_find_face_group_by_normal(
2666
- this._vertexCount,
2667
- this._buffer,
2668
- targetNormal.x,
2669
- targetNormal.y,
2670
- targetNormal.z,
2671
- nearPoint?.x ?? 0,
2672
- nearPoint?.y ?? 0,
2673
- nearPoint?.z ?? 0,
2674
- nearPoint !== undefined,
2675
- );
2676
- if (!r || r.length < 6) return null;
2677
- return {
2678
- centroid: new Point(r[0], r[1], r[2]),
2679
- normal: new Vec3(r[3], r[4], r[5]),
2680
- };
3124
+ findFaceByNormal(targetNormal: Vec3, nearPoint?: Point): MeshPlanarFace | null {
3125
+ return this.findPlanarFaceByNormal(targetNormal, nearPoint);
2681
3126
  }
2682
3127
 
2683
3128
  // ── Export ──────────────────────────────────────────────────────