okgeometry-api 1.2.0 → 1.2.1

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 +82 -9
  6. package/dist/Mesh.d.ts.map +1 -1
  7. package/dist/Mesh.js +329 -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 +24 -2
  14. package/dist/NurbsCurve.d.ts.map +1 -1
  15. package/dist/NurbsCurve.js +34 -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 +65 -2
  48. package/dist/wasm-bindings.d.ts.map +1 -1
  49. package/dist/wasm-bindings.js +100 -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 +538 -184
  54. package/src/MeshSurface.ts +72 -0
  55. package/src/NurbsCurve.ts +80 -26
  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 +43 -2
  65. package/src/wasm-bindings.js +105 -2
package/src/Mesh.ts CHANGED
@@ -5,10 +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 { NurbsCurve } from "./NurbsCurve.js";
11
- 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";
12
13
  import type { SweepableCurve, RotationAxis } from "./types.js";
13
14
  import { CurveTypeCode, SegmentTypeCode } from "./types.js";
14
15
  import { pointsToCoords, parsePolylineBuffer as parsePolylineBuf } from "./BufferCodec.js";
@@ -47,14 +48,24 @@ export interface PlanarCircle {
47
48
  radius: number;
48
49
  }
49
50
 
50
- export interface PlanarArc {
51
- points: Point[];
52
- center: Point;
53
- radius: number;
54
- startAngle: number;
55
- endAngle: number;
56
- sweepAngle: number;
57
- }
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
+ }
58
69
 
59
70
  interface RawMeshBounds {
60
71
  minX: number;
@@ -247,13 +258,17 @@ export class Mesh {
247
258
 
248
259
  // Lazy caches
249
260
  private _positionBuffer: Float32Array | null = null;
250
- private _indexBuffer: Uint32Array | null = null;
251
- private _vertices: Point[] | null = null;
252
- private _faces: number[][] | null = null;
253
- private _edgeVertexPairs: Array<[number, number]> | null = null;
254
- private _topologyMetricsCache: { boundaryEdges: number; nonManifoldEdges: number } | null = null;
255
- private _isClosedVolumeCache: boolean | null = null;
256
- 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;
257
272
 
258
273
  private constructor(buffer: Float64Array, trustedBooleanInput = false) {
259
274
  this._buffer = buffer;
@@ -330,14 +345,68 @@ export class Mesh {
330
345
  && a.maxZ + eps >= b.minZ;
331
346
  }
332
347
 
333
- private static boundsDiag(bounds: RawMeshBounds | null): number {
334
- if (!bounds) return 1;
335
- const dx = bounds.maxX - bounds.minX;
336
- const dy = bounds.maxY - bounds.minY;
337
- const dz = bounds.maxZ - bounds.minZ;
338
- const diag = Math.hypot(dx, dy, dz);
339
- return Number.isFinite(diag) && diag > 0 ? diag : 1;
340
- }
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
+ }
341
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));
@@ -909,12 +978,12 @@ export class Mesh {
909
978
  return this._vertexCount;
910
979
  }
911
980
 
912
- /** Number of triangular faces in this mesh */
913
- get faceCount(): number {
914
- const start = 1 + this._vertexCount * 3;
915
- if (this._buffer.length <= start) return 0;
916
- return Math.floor((this._buffer.length - start) / 3);
917
- }
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
+ }
918
987
 
919
988
  // ── High-level accessors (lazy) ────────────────────────────────
920
989
 
@@ -934,22 +1003,64 @@ export class Mesh {
934
1003
  return this._vertices;
935
1004
  }
936
1005
 
937
- /**
938
- * Get all faces as arrays of vertex indices.
939
- * Each face is [i0, i1, i2] for the three triangle vertices.
940
- * Lazy-computed and cached.
941
- */
942
- get faces(): number[][] {
943
- if (!this._faces) {
944
- 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;
945
1015
  const f: number[][] = [];
946
1016
  for (let i = 0; i < idx.length; i += 3) {
947
1017
  f.push([idx[i], idx[i + 1], idx[i + 2]]);
948
1018
  }
949
1019
  this._faces = f;
950
- }
951
- return this._faces;
952
- }
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
+ }
953
1064
 
954
1065
  /** Raw WASM buffer (for advanced use / re-passing to WASM) */
955
1066
  get rawBuffer(): Float64Array {
@@ -963,25 +1074,146 @@ export class Mesh {
963
1074
  * @param buffer - Float64Array in mesh buffer format
964
1075
  * @returns New Mesh instance
965
1076
  */
966
- static fromBuffer(
967
- buffer: Float64Array,
968
- options?: { trustedBooleanInput?: boolean },
969
- ): Mesh {
970
- return new Mesh(buffer, options?.trustedBooleanInput ?? false);
971
- }
972
-
973
- private static fromTrustedBuffer(buffer: Float64Array): Mesh {
974
- return new Mesh(buffer, true);
975
- }
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
+ }
976
1087
 
977
- get trustedBooleanInput(): boolean {
978
- return this._trustedBooleanInput;
979
- }
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
+ }
980
1212
 
981
- debugSummary(): MeshDebugSummary {
982
- const bounds = Mesh.toDebugBounds(this.getBounds());
983
- const topology = this.topologyMetrics();
984
- return {
1213
+ debugSummary(): MeshDebugSummary {
1214
+ const bounds = Mesh.toDebugBounds(this.getBounds());
1215
+ const topology = this.topologyMetrics();
1216
+ return {
985
1217
  vertexCount: this.vertexCount,
986
1218
  faceCount: this.faceCount,
987
1219
  trustedBooleanInput: this._trustedBooleanInput,
@@ -1759,11 +1991,11 @@ export class Mesh {
1759
1991
  return Mesh.fromTrustedBuffer(wasm.mesh_scale(this._vertexCount, this._buffer, sx, sy, sz));
1760
1992
  }
1761
1993
 
1762
- private runBoolean(
1763
- other: Mesh,
1764
- operation: "union" | "subtraction" | "intersection",
1765
- invoke: () => Float64Array,
1766
- options?: MeshBooleanOptions,
1994
+ private runBoolean(
1995
+ other: Mesh,
1996
+ operation: "union" | "subtraction" | "intersection",
1997
+ invoke: () => Float64Array,
1998
+ options?: MeshBooleanOptions,
1767
1999
  ): Mesh {
1768
2000
  const faceCountA = this.faceCount;
1769
2001
  const faceCountB = other.faceCount;
@@ -1812,10 +2044,53 @@ export class Mesh {
1812
2044
  const vertexCount = result[0];
1813
2045
  if (!Number.isFinite(vertexCount) || vertexCount < 0) {
1814
2046
  throw new Error(`Boolean ${operation} failed and returned a corrupt mesh buffer.`);
1815
- }
1816
- return Mesh.fromTrustedBuffer(result);
1817
- }
1818
-
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
+
1819
2094
  static encodeBooleanOperationToken(
1820
2095
  operation: MeshBooleanOperation,
1821
2096
  a: Mesh,
@@ -1849,71 +2124,87 @@ export class Mesh {
1849
2124
 
1850
2125
  // ── Booleans ───────────────────────────────────────────────────
1851
2126
 
1852
- /**
1853
- * Compute boolean union with another mesh.
1854
- * @param other - Mesh to union with
1855
- * @param options - Optional safety overrides
1856
- * @returns New mesh containing volume of both inputs
1857
- */
1858
- union(other: Mesh, options?: MeshBooleanOptions): Mesh {
1859
- ensureInit();
1860
- const operationToken = Mesh.encodeBooleanOperationToken("union", this, other, options);
1861
- return this.runBoolean(
1862
- other,
1863
- "union",
1864
- () => wasm.mesh_boolean_operation(
1865
- this._vertexCount,
1866
- this._buffer,
1867
- other._vertexCount,
1868
- other._buffer,
1869
- operationToken,
1870
- ),
1871
- options,
1872
- );
1873
- }
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
+ }
1874
2153
 
1875
- /**
1876
- * Compute boolean subtraction with another mesh.
1877
- * @param other - Mesh to subtract
1878
- * @param options - Optional safety overrides
1879
- * @returns New mesh with other's volume removed from this
1880
- */
1881
- subtract(other: Mesh, options?: MeshBooleanOptions): Mesh {
1882
- ensureInit();
1883
- const operationToken = Mesh.encodeBooleanOperationToken("subtraction", this, other, options);
1884
- return this.runBoolean(
1885
- other,
1886
- "subtraction",
1887
- () => wasm.mesh_boolean_operation(
1888
- this._vertexCount,
1889
- this._buffer,
1890
- other._vertexCount,
1891
- other._buffer,
1892
- operationToken,
1893
- ),
1894
- options,
1895
- );
1896
- }
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
+ }
1897
2182
 
1898
- /**
1899
- * Compute boolean intersection with another mesh.
1900
- * @param other - Mesh to intersect with
1901
- * @param options - Optional safety overrides
1902
- * @returns New mesh containing only the overlapping volume
1903
- */
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
+ */
1904
2191
  intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
1905
2192
  ensureInit();
1906
- const operationToken = Mesh.encodeBooleanOperationToken("intersection", this, other, options);
1907
- return this.runBoolean(
1908
- other,
1909
- "intersection",
1910
- () => wasm.mesh_boolean_operation(
1911
- this._vertexCount,
1912
- this._buffer,
1913
- other._vertexCount,
1914
- other._buffer,
1915
- operationToken,
1916
- ),
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
+ ),
1917
2208
  options,
1918
2209
  );
1919
2210
  }
@@ -2451,9 +2742,9 @@ export class Mesh {
2451
2742
  /**
2452
2743
  * Centroid of the coplanar edge-connected face group containing faceIndex.
2453
2744
  */
2454
- getCoplanarFaceGroupCentroid(faceIndex: number): Point | null {
2455
- ensureInit();
2456
- if (!Number.isFinite(faceIndex) || faceIndex < 0) return null;
2745
+ getCoplanarFaceGroupCentroid(faceIndex: number): Point | null {
2746
+ ensureInit();
2747
+ if (!Number.isFinite(faceIndex) || faceIndex < 0) return null;
2457
2748
  const r = wasm.mesh_get_coplanar_face_group_centroid(
2458
2749
  this._vertexCount,
2459
2750
  this._buffer,
@@ -2623,62 +2914,125 @@ export class Mesh {
2623
2914
  if (this._isClosedVolumeCache === null) {
2624
2915
  this._isClosedVolumeCache = boundaryEdges === 0;
2625
2916
  }
2626
- return {
2627
- boundaryEdges,
2628
- nonManifoldEdges,
2629
- };
2630
- }
2631
-
2632
- /**
2633
- * Odd/even point containment test against a closed mesh.
2634
- * Uses majority vote across multiple ray directions for robustness.
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.
2635
3016
  */
2636
3017
  containsPoint(point: Point): boolean {
2637
3018
  ensureInit();
2638
3019
  return wasm.mesh_contains_point(this._vertexCount, this._buffer, point.x, point.y, point.z);
2639
3020
  }
2640
3021
 
2641
- /**
2642
- * Find the coplanar + edge-connected face group containing a triangle.
2643
- */
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
- };
2657
- }
2658
-
2659
- /**
2660
- * Find the best matching coplanar + edge-connected face group by normal
2661
- * similarity and optional point proximity.
2662
- */
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
- };
2681
- }
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
+ }
2682
3036
 
2683
3037
  // ── Export ──────────────────────────────────────────────────────
2684
3038