okgeometry-api 1.1.22 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Mesh.ts CHANGED
@@ -17,24 +17,24 @@ import {
17
17
  disposeMeshBooleanWorkerPools,
18
18
  runMeshBooleanInWorkerPool,
19
19
  } from "./mesh-boolean.pool.js";
20
- import { MeshBooleanExecutionError } from "./mesh-boolean.protocol.js";
21
- import type {
22
- MeshBooleanAsyncOptions,
23
- MeshBooleanLimits,
24
- MeshBooleanOptions,
25
- } from "./mesh-boolean.protocol.js";
26
- import * as wasm from "./wasm-bindings.js";
27
- import { mesh_topology_metrics, mesh_topology_metrics_raw } from "./wasm-bindings.js";
20
+ import { MeshBooleanExecutionError } from "./mesh-boolean.protocol.js";
21
+ import type {
22
+ MeshBooleanAsyncOptions,
23
+ MeshBooleanLimits,
24
+ MeshBooleanOptions,
25
+ } from "./mesh-boolean.protocol.js";
26
+ import * as wasm from "./wasm-bindings.js";
27
+ import { mesh_topology_metrics, mesh_topology_metrics_raw } from "./wasm-bindings.js";
28
28
 
29
29
  export { MeshBooleanExecutionError };
30
- export type {
31
- MeshBooleanAsyncOptions,
32
- MeshBooleanLimits,
33
- MeshBooleanOptions,
34
- MeshBooleanErrorCode,
35
- MeshBooleanErrorPayload,
36
- MeshBooleanProgressEvent,
37
- } from "./mesh-boolean.protocol.js";
30
+ export type {
31
+ MeshBooleanAsyncOptions,
32
+ MeshBooleanLimits,
33
+ MeshBooleanOptions,
34
+ MeshBooleanErrorCode,
35
+ MeshBooleanErrorPayload,
36
+ MeshBooleanProgressEvent,
37
+ } from "./mesh-boolean.protocol.js";
38
38
 
39
39
  export interface PlanarRectangle {
40
40
  corners: [Point, Point, Point, Point];
@@ -113,86 +113,117 @@ export interface MeshBooleanDebugOptions {
113
113
  probes?: MeshBooleanDebugProbeId[];
114
114
  }
115
115
 
116
- export interface MeshBooleanDebugReport {
117
- operation: MeshBooleanOperation;
118
- inputA: MeshDebugSummary;
119
- inputB: MeshDebugSummary;
120
- result: Mesh;
121
- resultSummary: MeshDebugSummary;
122
- probes: MeshBooleanDebugProbe[];
123
- }
124
-
125
- export interface MeshBooleanDebugProbeSummary {
126
- label: string;
127
- origin: { x: number; y: number; z: number };
128
- direction: { x: number; y: number; z: number };
129
- inputAFirst: {
130
- point: { x: number; y: number; z: number };
131
- normal: { x: number; y: number; z: number };
132
- faceIndex: number;
133
- distance: number;
134
- } | null;
135
- inputBFirst: {
136
- point: { x: number; y: number; z: number };
137
- normal: { x: number; y: number; z: number };
138
- faceIndex: number;
139
- distance: number;
140
- } | null;
141
- resultFirst: {
142
- point: { x: number; y: number; z: number };
143
- normal: { x: number; y: number; z: number };
144
- faceIndex: number;
145
- distance: number;
146
- } | null;
147
- inputAHits: number;
148
- inputBHits: number;
149
- resultHits: number;
150
- }
151
-
152
- export interface MeshBooleanReproOperand {
153
- trustedBooleanInput: boolean;
154
- summary: MeshDebugSummary;
155
- buffer: number[];
156
- }
157
-
158
- export interface MeshBooleanReproResult {
159
- summary: MeshDebugSummary;
160
- buffer: number[];
161
- probeSummary?: MeshBooleanDebugProbeSummary[];
116
+ export interface MeshBooleanDebugReport {
117
+ operation: MeshBooleanOperation;
118
+ inputA: MeshDebugSummary;
119
+ inputB: MeshDebugSummary;
120
+ result: Mesh;
121
+ resultSummary: MeshDebugSummary;
122
+ probes: MeshBooleanDebugProbe[];
123
+ }
124
+
125
+ export interface MeshBooleanDebugProbeSummary {
126
+ label: string;
127
+ origin: { x: number; y: number; z: number };
128
+ direction: { x: number; y: number; z: number };
129
+ inputAFirst: {
130
+ point: { x: number; y: number; z: number };
131
+ normal: { x: number; y: number; z: number };
132
+ faceIndex: number;
133
+ distance: number;
134
+ } | null;
135
+ inputBFirst: {
136
+ point: { x: number; y: number; z: number };
137
+ normal: { x: number; y: number; z: number };
138
+ faceIndex: number;
139
+ distance: number;
140
+ } | null;
141
+ resultFirst: {
142
+ point: { x: number; y: number; z: number };
143
+ normal: { x: number; y: number; z: number };
144
+ faceIndex: number;
145
+ distance: number;
146
+ } | null;
147
+ inputAHits: number;
148
+ inputBHits: number;
149
+ resultHits: number;
150
+ }
151
+
152
+ export interface MeshBooleanReproOperand {
153
+ trustedBooleanInput: boolean;
154
+ summary: MeshDebugSummary;
155
+ buffer: number[];
156
+ }
157
+
158
+ export interface MeshBooleanReproResult {
159
+ summary: MeshDebugSummary;
160
+ buffer: number[];
161
+ probeSummary?: MeshBooleanDebugProbeSummary[];
162
+ }
163
+
164
+ export interface MeshBooleanReproError {
165
+ name: string;
166
+ message: string;
167
+ stack?: string;
168
+ }
169
+
170
+ export interface MeshBooleanReproPayload {
171
+ version: 1;
172
+ operation: MeshBooleanOperation;
173
+ options: MeshBooleanOptions | null;
174
+ inputA: MeshBooleanReproOperand;
175
+ inputB: MeshBooleanReproOperand;
176
+ result?: MeshBooleanReproResult;
177
+ error?: MeshBooleanReproError;
178
+ }
179
+
180
+ export interface MeshBooleanReproOptions {
181
+ includeResult?: boolean;
182
+ debugOptions?: MeshBooleanDebugOptions;
162
183
  }
163
184
 
164
- export interface MeshBooleanReproError {
165
- name: string;
166
- message: string;
167
- stack?: string;
185
+ export interface MeshSplitResult {
186
+ outside: Mesh[];
187
+ inside: Mesh[];
188
+ all: Mesh[];
189
+ classification?: "volume" | "surface";
190
+ negative?: Mesh[];
191
+ positive?: Mesh[];
168
192
  }
169
193
 
170
- export interface MeshBooleanReproPayload {
171
- version: 1;
172
- operation: MeshBooleanOperation;
173
- options: MeshBooleanOptions | null;
174
- inputA: MeshBooleanReproOperand;
175
- inputB: MeshBooleanReproOperand;
176
- result?: MeshBooleanReproResult;
177
- error?: MeshBooleanReproError;
194
+ export interface MeshPlaneSplitResult extends MeshSplitResult {
195
+ negative: Mesh[];
196
+ positive: Mesh[];
178
197
  }
179
198
 
180
- export interface MeshBooleanReproOptions {
181
- includeResult?: boolean;
182
- debugOptions?: MeshBooleanDebugOptions;
199
+ export interface MeshPlaneSplitOptions extends MeshBooleanOptions {
200
+ planePaddingScale?: number;
183
201
  }
184
202
 
185
- function shouldFallbackFromWorkerFailure(error: unknown): boolean {
186
- if (!error || typeof error !== "object") return false;
187
- const code = "code" in error ? error.code : undefined;
188
- return code === "worker_unavailable" || code === "worker_crashed" || code === "worker_protocol";
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;
189
214
  }
190
-
191
- /**
192
- * Buffer-backed triangle mesh with GPU-ready accessors.
193
- * All geometry lives in a Float64Array from WASM.
194
- *
195
- * Buffer format: [vertexCount, x1,y1,z1, ..., i1,i2,i3, ...]
215
+
216
+ function shouldFallbackFromWorkerFailure(error: unknown): boolean {
217
+ if (!error || typeof error !== "object") return false;
218
+ const code = "code" in error ? error.code : undefined;
219
+ return code === "worker_unavailable" || code === "worker_crashed" || code === "worker_protocol";
220
+ }
221
+
222
+ /**
223
+ * Buffer-backed triangle mesh with GPU-ready accessors.
224
+ * All geometry lives in a Float64Array from WASM.
225
+ *
226
+ * Buffer format: [vertexCount, x1,y1,z1, ..., i1,i2,i3, ...]
196
227
  */
197
228
  export class Mesh {
198
229
  private _buffer: Float64Array;
@@ -549,11 +580,11 @@ export class Mesh {
549
580
  };
550
581
  }
551
582
 
552
- private static summarizeDebugProbe(probe: MeshBooleanDebugProbe): MeshBooleanDebugProbeSummary {
553
- return {
554
- label: probe.label,
555
- origin: {
556
- x: Mesh.roundDebugScalar(probe.origin.x),
583
+ private static summarizeDebugProbe(probe: MeshBooleanDebugProbe): MeshBooleanDebugProbeSummary {
584
+ return {
585
+ label: probe.label,
586
+ origin: {
587
+ x: Mesh.roundDebugScalar(probe.origin.x),
557
588
  y: Mesh.roundDebugScalar(probe.origin.y),
558
589
  z: Mesh.roundDebugScalar(probe.origin.z),
559
590
  },
@@ -567,84 +598,278 @@ export class Mesh {
567
598
  resultFirst: Mesh.summarizeDebugHit(probe.resultHits[0]),
568
599
  inputAHits: probe.inputAHits.length,
569
600
  inputBHits: probe.inputBHits.length,
570
- resultHits: probe.resultHits.length,
571
- };
601
+ resultHits: probe.resultHits.length,
602
+ };
603
+ }
604
+
605
+ private static serializeBooleanOptions(options?: MeshBooleanOptions): MeshBooleanOptions | null {
606
+ return options == null ? null : { ...options };
607
+ }
608
+
609
+ private static serializeBooleanError(error: unknown): MeshBooleanReproError {
610
+ if (error instanceof Error) {
611
+ return {
612
+ name: error.name,
613
+ message: error.message,
614
+ stack: error.stack,
615
+ };
616
+ }
617
+ return {
618
+ name: typeof error,
619
+ message: typeof error === "string" ? error : JSON.stringify(error),
620
+ };
621
+ }
622
+
623
+ private static exportBooleanReproOperand(mesh: Mesh): MeshBooleanReproOperand {
624
+ return {
625
+ trustedBooleanInput: mesh._trustedBooleanInput,
626
+ summary: mesh.debugSummary(),
627
+ buffer: Array.from(mesh._buffer),
628
+ };
629
+ }
630
+
631
+ private static createBooleanReproPayload(
632
+ inputA: Mesh,
633
+ inputB: Mesh,
634
+ operation: MeshBooleanOperation,
635
+ options?: MeshBooleanOptions,
636
+ result?: Mesh,
637
+ debugOptions?: MeshBooleanDebugOptions,
638
+ error?: unknown,
639
+ ): MeshBooleanReproPayload {
640
+ const payload: MeshBooleanReproPayload = {
641
+ version: 1,
642
+ operation,
643
+ options: Mesh.serializeBooleanOptions(options),
644
+ inputA: Mesh.exportBooleanReproOperand(inputA),
645
+ inputB: Mesh.exportBooleanReproOperand(inputB),
646
+ };
647
+
648
+ if (result) {
649
+ payload.result = {
650
+ summary: result.debugSummary(),
651
+ buffer: Array.from(result._buffer),
652
+ };
653
+ if (debugOptions) {
654
+ try {
655
+ const report = Mesh.createBooleanDebugReport(inputA, inputB, result, operation, debugOptions);
656
+ payload.result.probeSummary = report.probes.map((probe) => Mesh.summarizeDebugProbe(probe));
657
+ } catch {
658
+ // Keep the repro payload usable even when extra debug probes fail.
659
+ }
660
+ }
661
+ }
662
+
663
+ if (error !== undefined) {
664
+ payload.error = Mesh.serializeBooleanError(error);
665
+ }
666
+
667
+ return payload;
668
+ }
669
+
670
+ private static cloneMesh(mesh: Mesh): Mesh {
671
+ return Mesh.fromBuffer(new Float64Array(mesh._buffer), {
672
+ trustedBooleanInput: mesh._trustedBooleanInput,
673
+ });
674
+ }
675
+
676
+ private static emptyMesh(): Mesh {
677
+ return Mesh.fromTrustedBuffer(new Float64Array(0));
572
678
  }
573
679
 
574
- private static serializeBooleanOptions(options?: MeshBooleanOptions): MeshBooleanOptions | null {
575
- return options == null ? null : { ...options };
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;
576
696
  }
577
697
 
578
- private static serializeBooleanError(error: unknown): MeshBooleanReproError {
579
- if (error instanceof Error) {
580
- return {
581
- name: error.name,
582
- message: error.message,
583
- stack: error.stack,
584
- };
585
- }
586
- return {
587
- name: typeof error,
588
- message: typeof error === "string" ? error : JSON.stringify(error),
589
- };
698
+ private static createSurfaceSplitResult(negative: Mesh[], positive: Mesh[]): MeshSplitResult {
699
+ return Mesh.createSplitResult(negative, positive, "surface");
590
700
  }
591
701
 
592
- private static exportBooleanReproOperand(mesh: Mesh): MeshBooleanReproOperand {
702
+ private static createPlaneSplitResult(
703
+ negative: Mesh[],
704
+ positive: Mesh[],
705
+ classification: "volume" | "surface" = "surface",
706
+ ): MeshPlaneSplitResult {
593
707
  return {
594
- trustedBooleanInput: mesh._trustedBooleanInput,
595
- summary: mesh.debugSummary(),
596
- buffer: Array.from(mesh._buffer),
708
+ ...Mesh.createSplitResult(negative, positive, classification),
709
+ negative,
710
+ positive,
597
711
  };
598
712
  }
599
713
 
600
- private static createBooleanReproPayload(
601
- inputA: Mesh,
602
- inputB: Mesh,
603
- operation: MeshBooleanOperation,
604
- options?: MeshBooleanOptions,
605
- result?: Mesh,
606
- debugOptions?: MeshBooleanDebugOptions,
607
- error?: unknown,
608
- ): MeshBooleanReproPayload {
609
- const payload: MeshBooleanReproPayload = {
610
- version: 1,
611
- operation,
612
- options: Mesh.serializeBooleanOptions(options),
613
- inputA: Mesh.exportBooleanReproOperand(inputA),
614
- inputB: Mesh.exportBooleanReproOperand(inputB),
615
- };
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;
616
722
 
617
- if (result) {
618
- payload.result = {
619
- summary: result.debugSummary(),
620
- buffer: Array.from(result._buffer),
621
- };
622
- if (debugOptions) {
623
- try {
624
- const report = Mesh.createBooleanDebugReport(inputA, inputB, result, operation, debugOptions);
625
- payload.result.probeSummary = report.probes.map((probe) => Mesh.summarizeDebugProbe(probe));
626
- } catch {
627
- // Keep the repro payload usable even when extra debug probes fail.
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.");
628
729
  }
730
+ meshes.push(Mesh.fromTrustedBuffer(buffer.slice(offset, offset + rawLen)));
731
+ offset += rawLen;
629
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.");
630
740
  }
741
+ return Mesh.createSplitResult(outside, inside);
742
+ }
631
743
 
632
- if (error !== undefined) {
633
- payload.error = Mesh.serializeBooleanError(error);
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();
634
750
  }
751
+ const vAxis = normal.cross(uAxis).normalize();
752
+ return { uAxis, vAxis, normal };
753
+ }
635
754
 
636
- return payload;
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 });
637
796
  }
638
797
 
639
- private static cloneMesh(mesh: Mesh): Mesh {
640
- return Mesh.fromBuffer(new Float64Array(mesh._buffer), {
641
- trustedBooleanInput: mesh._trustedBooleanInput,
642
- });
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);
643
872
  }
644
-
645
- private static emptyMesh(): Mesh {
646
- return Mesh.fromTrustedBuffer(new Float64Array(0));
647
- }
648
873
 
649
874
  // ── GPU-ready buffers ──────────────────────────────────────────
650
875
 
@@ -1198,91 +1423,91 @@ export class Mesh {
1198
1423
  * Extrude a planar curve along a direction.
1199
1424
  * Open curves return an uncapped polysurface; closed curves are capped solids.
1200
1425
  */
1201
- static extrudePlanarCurve(points: Point[], normal: Vec3, height: number, closed: boolean): Mesh {
1202
- ensureInit();
1203
- const buf = wasm.mesh_extrude_planar_curve(
1204
- pointsToCoords(points),
1205
- normal.x, normal.y, normal.z,
1426
+ static extrudePlanarCurve(points: Point[], normal: Vec3, height: number, closed: boolean): Mesh {
1427
+ ensureInit();
1428
+ const buf = wasm.mesh_extrude_planar_curve(
1429
+ pointsToCoords(points),
1430
+ normal.x, normal.y, normal.z,
1206
1431
  height,
1207
1432
  closed,
1208
- );
1209
- return Mesh.fromTrustedBuffer(buf);
1210
- }
1211
-
1212
- private static normalizeClosedCurvePoints(points: Point[], eps = 1e-10): Point[] {
1213
- const normalized: Point[] = [];
1214
- for (const point of points) {
1215
- if (normalized.length > 0 && normalized[normalized.length - 1].equals(point, eps)) continue;
1216
- normalized.push(point);
1217
- }
1218
- if (normalized.length > 1 && normalized[0].equals(normalized[normalized.length - 1], eps)) {
1219
- normalized.pop();
1220
- }
1221
- return normalized;
1222
- }
1223
-
1224
- private static getClosedCurveLoopPoints(curve: SweepableCurve, segments: number): Point[] {
1225
- const closureEps = 1e-6;
1226
- const sampleCount = Number.isFinite(segments) ? Math.max(3, Math.floor(segments)) : 32;
1227
-
1228
- if (curve instanceof Circle) {
1229
- return Mesh.normalizeClosedCurvePoints(curve.sample(sampleCount), closureEps);
1230
- }
1231
- if (curve instanceof Polygon || curve instanceof Polyline) {
1232
- if (!curve.isClosed(closureEps)) {
1233
- throw new Error("Mesh.extrudeCurveAsSolid() requires a closed polyline or polygon.");
1234
- }
1235
- return Mesh.normalizeClosedCurvePoints(curve.points, closureEps);
1236
- }
1237
- if (curve instanceof PolyCurve) {
1238
- if (!curve.isClosed(closureEps)) {
1239
- throw new Error("Mesh.extrudeCurveAsSolid() requires a closed polycurve.");
1240
- }
1241
- return Mesh.normalizeClosedCurvePoints(curve.sample(Math.max(2, sampleCount)), closureEps);
1242
- }
1243
- if (curve instanceof NurbsCurve) {
1244
- if (!curve.isClosed(closureEps)) {
1245
- throw new Error("Mesh.extrudeCurveAsSolid() requires a closed NURBS curve.");
1246
- }
1247
- return Mesh.normalizeClosedCurvePoints(curve.sample(sampleCount), closureEps);
1248
- }
1249
-
1250
- throw new Error(
1251
- "Mesh.extrudeCurveAsSolid() requires a closed curve "
1252
- + "(Circle, closed Polyline/Polygon, closed PolyCurve, or closed NurbsCurve).",
1253
- );
1254
- }
1255
-
1256
- /**
1257
- * Extrude a closed planar curve into a trusted closed solid suitable for booleans.
1258
- * Curved inputs are sampled first, so `segments` controls tessellation density
1259
- * for circles, polycurves, and NURBS curves.
1260
- */
1261
- static extrudeCurveAsSolid(curve: SweepableCurve, direction: Vec3, segments = 32): Mesh {
1262
- ensureInit();
1263
-
1264
- const height = direction.length();
1265
- if (!Number.isFinite(height) || height < 1e-10) {
1266
- throw new Error("Mesh.extrudeCurveAsSolid() requires a non-zero extrusion direction.");
1267
- }
1268
-
1269
- const points = Mesh.getClosedCurveLoopPoints(curve, segments);
1270
- if (points.length < 3) {
1271
- throw new Error("Mesh.extrudeCurveAsSolid() requires at least 3 unique boundary points.");
1272
- }
1273
-
1274
- const normal = direction.scale(1 / height);
1275
- const mesh = Mesh.extrudePlanarCurve(points, normal, height, true);
1276
- if (mesh.vertexCount === 0) {
1277
- throw new Error("Mesh.extrudeCurveAsSolid() failed. Ensure the curve is planar and closed.");
1278
- }
1279
- return mesh;
1280
- }
1281
-
1282
- /**
1283
- * Shift a closed cutter profile slightly opposite to travel direction and
1284
- * compensate height so the distal end remains at the user-intended depth.
1285
- */
1433
+ );
1434
+ return Mesh.fromTrustedBuffer(buf);
1435
+ }
1436
+
1437
+ private static normalizeClosedCurvePoints(points: Point[], eps = 1e-10): Point[] {
1438
+ const normalized: Point[] = [];
1439
+ for (const point of points) {
1440
+ if (normalized.length > 0 && normalized[normalized.length - 1].equals(point, eps)) continue;
1441
+ normalized.push(point);
1442
+ }
1443
+ if (normalized.length > 1 && normalized[0].equals(normalized[normalized.length - 1], eps)) {
1444
+ normalized.pop();
1445
+ }
1446
+ return normalized;
1447
+ }
1448
+
1449
+ private static getClosedCurveLoopPoints(curve: SweepableCurve, segments: number): Point[] {
1450
+ const closureEps = 1e-6;
1451
+ const sampleCount = Number.isFinite(segments) ? Math.max(3, Math.floor(segments)) : 32;
1452
+
1453
+ if (curve instanceof Circle) {
1454
+ return Mesh.normalizeClosedCurvePoints(curve.sample(sampleCount), closureEps);
1455
+ }
1456
+ if (curve instanceof Polygon || curve instanceof Polyline) {
1457
+ if (!curve.isClosed(closureEps)) {
1458
+ throw new Error("Mesh.extrudeCurveAsSolid() requires a closed polyline or polygon.");
1459
+ }
1460
+ return Mesh.normalizeClosedCurvePoints(curve.points, closureEps);
1461
+ }
1462
+ if (curve instanceof PolyCurve) {
1463
+ if (!curve.isClosed(closureEps)) {
1464
+ throw new Error("Mesh.extrudeCurveAsSolid() requires a closed polycurve.");
1465
+ }
1466
+ return Mesh.normalizeClosedCurvePoints(curve.sample(Math.max(2, sampleCount)), closureEps);
1467
+ }
1468
+ if (curve instanceof NurbsCurve) {
1469
+ if (!curve.isClosed(closureEps)) {
1470
+ throw new Error("Mesh.extrudeCurveAsSolid() requires a closed NURBS curve.");
1471
+ }
1472
+ return Mesh.normalizeClosedCurvePoints(curve.sample(sampleCount), closureEps);
1473
+ }
1474
+
1475
+ throw new Error(
1476
+ "Mesh.extrudeCurveAsSolid() requires a closed curve "
1477
+ + "(Circle, closed Polyline/Polygon, closed PolyCurve, or closed NurbsCurve).",
1478
+ );
1479
+ }
1480
+
1481
+ /**
1482
+ * Extrude a closed planar curve into a trusted closed solid suitable for booleans.
1483
+ * Curved inputs are sampled first, so `segments` controls tessellation density
1484
+ * for circles, polycurves, and NURBS curves.
1485
+ */
1486
+ static extrudeCurveAsSolid(curve: SweepableCurve, direction: Vec3, segments = 32): Mesh {
1487
+ ensureInit();
1488
+
1489
+ const height = direction.length();
1490
+ if (!Number.isFinite(height) || height < 1e-10) {
1491
+ throw new Error("Mesh.extrudeCurveAsSolid() requires a non-zero extrusion direction.");
1492
+ }
1493
+
1494
+ const points = Mesh.getClosedCurveLoopPoints(curve, segments);
1495
+ if (points.length < 3) {
1496
+ throw new Error("Mesh.extrudeCurveAsSolid() requires at least 3 unique boundary points.");
1497
+ }
1498
+
1499
+ const normal = direction.scale(1 / height);
1500
+ const mesh = Mesh.extrudePlanarCurve(points, normal, height, true);
1501
+ if (mesh.vertexCount === 0) {
1502
+ throw new Error("Mesh.extrudeCurveAsSolid() failed. Ensure the curve is planar and closed.");
1503
+ }
1504
+ return mesh;
1505
+ }
1506
+
1507
+ /**
1508
+ * Shift a closed cutter profile slightly opposite to travel direction and
1509
+ * compensate height so the distal end remains at the user-intended depth.
1510
+ */
1286
1511
  static prepareBooleanCutterCurve(
1287
1512
  points: Point[],
1288
1513
  closed: boolean,
@@ -1591,10 +1816,10 @@ export class Mesh {
1591
1816
  return Mesh.fromTrustedBuffer(result);
1592
1817
  }
1593
1818
 
1594
- static encodeBooleanOperationToken(
1595
- operation: MeshBooleanOperation,
1596
- a: Mesh,
1597
- b: Mesh,
1819
+ static encodeBooleanOperationToken(
1820
+ operation: MeshBooleanOperation,
1821
+ a: Mesh,
1822
+ b: Mesh,
1598
1823
  options?: MeshBooleanOptions,
1599
1824
  ): string {
1600
1825
  const tokens: string[] = [operation];
@@ -1603,9 +1828,24 @@ export class Mesh {
1603
1828
  }
1604
1829
  if (options?.debugForceFaceID) {
1605
1830
  tokens.push("forceFaceID");
1606
- }
1607
- return tokens.join("@");
1608
- }
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
+ }
1609
1849
 
1610
1850
  // ── Booleans ───────────────────────────────────────────────────
1611
1851
 
@@ -1638,22 +1878,22 @@ export class Mesh {
1638
1878
  * @param options - Optional safety overrides
1639
1879
  * @returns New mesh with other's volume removed from this
1640
1880
  */
1641
- subtract(other: Mesh, options?: MeshBooleanOptions): Mesh {
1642
- ensureInit();
1643
- const operationToken = Mesh.encodeBooleanOperationToken("subtraction", this, other, options);
1644
- return this.runBoolean(
1645
- other,
1646
- "subtraction",
1647
- () => wasm.mesh_boolean_operation(
1648
- this._vertexCount,
1649
- this._buffer,
1650
- other._vertexCount,
1651
- other._buffer,
1652
- operationToken,
1653
- ),
1654
- options,
1655
- );
1656
- }
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
+ }
1657
1897
 
1658
1898
  /**
1659
1899
  * Compute boolean intersection with another mesh.
@@ -1661,11 +1901,11 @@ export class Mesh {
1661
1901
  * @param options - Optional safety overrides
1662
1902
  * @returns New mesh containing only the overlapping volume
1663
1903
  */
1664
- intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
1665
- ensureInit();
1666
- const operationToken = Mesh.encodeBooleanOperationToken("intersection", this, other, options);
1667
- return this.runBoolean(
1668
- other,
1904
+ intersect(other: Mesh, options?: MeshBooleanOptions): Mesh {
1905
+ ensureInit();
1906
+ const operationToken = Mesh.encodeBooleanOperationToken("intersection", this, other, options);
1907
+ return this.runBoolean(
1908
+ other,
1669
1909
  "intersection",
1670
1910
  () => wasm.mesh_boolean_operation(
1671
1911
  this._vertexCount,
@@ -1674,13 +1914,162 @@ export class Mesh {
1674
1914
  other._buffer,
1675
1915
  operationToken,
1676
1916
  ),
1677
- options,
1678
- );
1679
- }
1680
-
1681
- debugBoolean(
1682
- other: Mesh,
1683
- operation: MeshBooleanOperation,
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,
1684
2073
  options?: MeshBooleanOptions,
1685
2074
  debugOptions?: MeshBooleanDebugOptions,
1686
2075
  ): MeshBooleanDebugReport {
@@ -1709,184 +2098,184 @@ export class Mesh {
1709
2098
  return this.debugBoolean(other, "subtraction", options, debugOptions);
1710
2099
  }
1711
2100
 
1712
- debugIntersect(
1713
- other: Mesh,
1714
- options?: MeshBooleanOptions,
1715
- debugOptions?: MeshBooleanDebugOptions,
1716
- ): MeshBooleanDebugReport {
1717
- return this.debugBoolean(other, "intersection", options, debugOptions);
1718
- }
1719
-
1720
- exportBooleanRepro(
1721
- other: Mesh,
1722
- operation: MeshBooleanOperation,
1723
- options?: MeshBooleanOptions,
1724
- reproOptions?: MeshBooleanReproOptions,
1725
- ): MeshBooleanReproPayload {
1726
- if (!reproOptions?.includeResult) {
1727
- return Mesh.createBooleanReproPayload(this, other, operation, options);
1728
- }
1729
-
1730
- try {
1731
- const result = operation === "union"
1732
- ? this.union(other, options)
1733
- : operation === "subtraction"
1734
- ? this.subtract(other, options)
1735
- : this.intersect(other, options);
1736
- return Mesh.createBooleanReproPayload(
1737
- this,
1738
- other,
1739
- operation,
1740
- options,
1741
- result,
1742
- reproOptions.debugOptions,
1743
- );
1744
- } catch (error) {
1745
- return Mesh.createBooleanReproPayload(
1746
- this,
1747
- other,
1748
- operation,
1749
- options,
1750
- undefined,
1751
- undefined,
1752
- error,
1753
- );
1754
- }
1755
- }
1756
-
1757
- exportSubtractRepro(
1758
- other: Mesh,
1759
- options?: MeshBooleanOptions,
1760
- reproOptions?: MeshBooleanReproOptions,
1761
- ): MeshBooleanReproPayload {
1762
- return this.exportBooleanRepro(other, "subtraction", options, reproOptions);
1763
- }
1764
-
1765
- exportBooleanReproJSON(
1766
- other: Mesh,
1767
- operation: MeshBooleanOperation,
1768
- options?: MeshBooleanOptions,
1769
- reproOptions?: MeshBooleanReproOptions,
1770
- ): string {
1771
- return JSON.stringify(this.exportBooleanRepro(other, operation, options, reproOptions), null, 2);
1772
- }
1773
-
1774
- exportSubtractReproJSON(
1775
- other: Mesh,
1776
- options?: MeshBooleanOptions,
1777
- reproOptions?: MeshBooleanReproOptions,
1778
- ): string {
1779
- return this.exportBooleanReproJSON(other, "subtraction", options, reproOptions);
1780
- }
1781
-
1782
- exportUnionRepro(
1783
- other: Mesh,
1784
- options?: MeshBooleanOptions,
1785
- reproOptions?: MeshBooleanReproOptions,
1786
- ): MeshBooleanReproPayload {
1787
- return this.exportBooleanRepro(other, "union", options, reproOptions);
1788
- }
1789
-
1790
- exportIntersectRepro(
1791
- other: Mesh,
1792
- options?: MeshBooleanOptions,
1793
- reproOptions?: MeshBooleanReproOptions,
1794
- ): MeshBooleanReproPayload {
1795
- return this.exportBooleanRepro(other, "intersection", options, reproOptions);
1796
- }
1797
-
1798
- static replayBooleanRepro(payload: MeshBooleanReproPayload): Mesh {
1799
- const inputA = Mesh.fromBuffer(new Float64Array(payload.inputA.buffer), {
1800
- trustedBooleanInput: payload.inputA.trustedBooleanInput,
1801
- });
1802
- const inputB = Mesh.fromBuffer(new Float64Array(payload.inputB.buffer), {
1803
- trustedBooleanInput: payload.inputB.trustedBooleanInput,
1804
- });
1805
-
1806
- return payload.operation === "union"
1807
- ? inputA.union(inputB, payload.options ?? undefined)
1808
- : payload.operation === "subtraction"
1809
- ? inputA.subtract(inputB, payload.options ?? undefined)
1810
- : inputA.intersect(inputB, payload.options ?? undefined);
1811
- }
1812
-
1813
- /**
1814
- * Compute boolean union in a dedicated Web Worker (non-blocking).
1815
- * Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
1816
- */
1817
- async unionAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
1818
- try {
1819
- const result = await runMeshBooleanInWorkerPool(
1820
- "union",
1821
- this._buffer,
1822
- other._buffer,
1823
- this._trustedBooleanInput,
1824
- other._trustedBooleanInput,
1825
- options,
1826
- );
1827
- return Mesh.fromTrustedBuffer(result);
1828
- } catch (error) {
1829
- if (!shouldFallbackFromWorkerFailure(error)) throw error;
1830
- console.warn("Mesh.unionAsync worker failed; falling back to main-thread boolean.", error);
1831
- return this.union(other, {
1832
- allowUnsafe: options?.allowUnsafe ?? true,
1833
- limits: options?.limits,
1834
- debugForceFaceID: options?.debugForceFaceID,
1835
- });
1836
- }
1837
- }
2101
+ debugIntersect(
2102
+ other: Mesh,
2103
+ options?: MeshBooleanOptions,
2104
+ debugOptions?: MeshBooleanDebugOptions,
2105
+ ): MeshBooleanDebugReport {
2106
+ return this.debugBoolean(other, "intersection", options, debugOptions);
2107
+ }
1838
2108
 
1839
- /**
1840
- * Compute boolean subtraction in a dedicated Web Worker (non-blocking).
1841
- * Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
1842
- */
1843
- async subtractAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
1844
- try {
1845
- const result = await runMeshBooleanInWorkerPool(
1846
- "subtraction",
1847
- this._buffer,
1848
- other._buffer,
1849
- this._trustedBooleanInput,
1850
- other._trustedBooleanInput,
1851
- options,
1852
- );
1853
- return Mesh.fromTrustedBuffer(result);
1854
- } catch (error) {
1855
- if (!shouldFallbackFromWorkerFailure(error)) throw error;
1856
- console.warn("Mesh.subtractAsync worker failed; falling back to main-thread boolean.", error);
1857
- return this.subtract(other, {
1858
- allowUnsafe: options?.allowUnsafe ?? true,
1859
- limits: options?.limits,
1860
- debugForceFaceID: options?.debugForceFaceID,
1861
- });
1862
- }
1863
- }
2109
+ exportBooleanRepro(
2110
+ other: Mesh,
2111
+ operation: MeshBooleanOperation,
2112
+ options?: MeshBooleanOptions,
2113
+ reproOptions?: MeshBooleanReproOptions,
2114
+ ): MeshBooleanReproPayload {
2115
+ if (!reproOptions?.includeResult) {
2116
+ return Mesh.createBooleanReproPayload(this, other, operation, options);
2117
+ }
1864
2118
 
1865
- /**
1866
- * Compute boolean intersection in a dedicated Web Worker (non-blocking).
1867
- * Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
1868
- */
1869
- async intersectAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
1870
- try {
1871
- const result = await runMeshBooleanInWorkerPool(
1872
- "intersection",
1873
- this._buffer,
1874
- other._buffer,
1875
- this._trustedBooleanInput,
1876
- other._trustedBooleanInput,
1877
- options,
1878
- );
1879
- return Mesh.fromTrustedBuffer(result);
1880
- } catch (error) {
1881
- if (!shouldFallbackFromWorkerFailure(error)) throw error;
1882
- console.warn("Mesh.intersectAsync worker failed; falling back to main-thread boolean.", error);
1883
- return this.intersect(other, {
1884
- allowUnsafe: options?.allowUnsafe ?? true,
1885
- limits: options?.limits,
1886
- debugForceFaceID: options?.debugForceFaceID,
1887
- });
1888
- }
1889
- }
2119
+ try {
2120
+ const result = operation === "union"
2121
+ ? this.union(other, options)
2122
+ : operation === "subtraction"
2123
+ ? this.subtract(other, options)
2124
+ : this.intersect(other, options);
2125
+ return Mesh.createBooleanReproPayload(
2126
+ this,
2127
+ other,
2128
+ operation,
2129
+ options,
2130
+ result,
2131
+ reproOptions.debugOptions,
2132
+ );
2133
+ } catch (error) {
2134
+ return Mesh.createBooleanReproPayload(
2135
+ this,
2136
+ other,
2137
+ operation,
2138
+ options,
2139
+ undefined,
2140
+ undefined,
2141
+ error,
2142
+ );
2143
+ }
2144
+ }
2145
+
2146
+ exportSubtractRepro(
2147
+ other: Mesh,
2148
+ options?: MeshBooleanOptions,
2149
+ reproOptions?: MeshBooleanReproOptions,
2150
+ ): MeshBooleanReproPayload {
2151
+ return this.exportBooleanRepro(other, "subtraction", options, reproOptions);
2152
+ }
2153
+
2154
+ exportBooleanReproJSON(
2155
+ other: Mesh,
2156
+ operation: MeshBooleanOperation,
2157
+ options?: MeshBooleanOptions,
2158
+ reproOptions?: MeshBooleanReproOptions,
2159
+ ): string {
2160
+ return JSON.stringify(this.exportBooleanRepro(other, operation, options, reproOptions), null, 2);
2161
+ }
2162
+
2163
+ exportSubtractReproJSON(
2164
+ other: Mesh,
2165
+ options?: MeshBooleanOptions,
2166
+ reproOptions?: MeshBooleanReproOptions,
2167
+ ): string {
2168
+ return this.exportBooleanReproJSON(other, "subtraction", options, reproOptions);
2169
+ }
2170
+
2171
+ exportUnionRepro(
2172
+ other: Mesh,
2173
+ options?: MeshBooleanOptions,
2174
+ reproOptions?: MeshBooleanReproOptions,
2175
+ ): MeshBooleanReproPayload {
2176
+ return this.exportBooleanRepro(other, "union", options, reproOptions);
2177
+ }
2178
+
2179
+ exportIntersectRepro(
2180
+ other: Mesh,
2181
+ options?: MeshBooleanOptions,
2182
+ reproOptions?: MeshBooleanReproOptions,
2183
+ ): MeshBooleanReproPayload {
2184
+ return this.exportBooleanRepro(other, "intersection", options, reproOptions);
2185
+ }
2186
+
2187
+ static replayBooleanRepro(payload: MeshBooleanReproPayload): Mesh {
2188
+ const inputA = Mesh.fromBuffer(new Float64Array(payload.inputA.buffer), {
2189
+ trustedBooleanInput: payload.inputA.trustedBooleanInput,
2190
+ });
2191
+ const inputB = Mesh.fromBuffer(new Float64Array(payload.inputB.buffer), {
2192
+ trustedBooleanInput: payload.inputB.trustedBooleanInput,
2193
+ });
2194
+
2195
+ return payload.operation === "union"
2196
+ ? inputA.union(inputB, payload.options ?? undefined)
2197
+ : payload.operation === "subtraction"
2198
+ ? inputA.subtract(inputB, payload.options ?? undefined)
2199
+ : inputA.intersect(inputB, payload.options ?? undefined);
2200
+ }
2201
+
2202
+ /**
2203
+ * Compute boolean union in a dedicated Web Worker (non-blocking).
2204
+ * Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
2205
+ */
2206
+ async unionAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
2207
+ try {
2208
+ const result = await runMeshBooleanInWorkerPool(
2209
+ "union",
2210
+ this._buffer,
2211
+ other._buffer,
2212
+ this._trustedBooleanInput,
2213
+ other._trustedBooleanInput,
2214
+ options,
2215
+ );
2216
+ return Mesh.fromTrustedBuffer(result);
2217
+ } catch (error) {
2218
+ if (!shouldFallbackFromWorkerFailure(error)) throw error;
2219
+ console.warn("Mesh.unionAsync worker failed; falling back to main-thread boolean.", error);
2220
+ return this.union(other, {
2221
+ allowUnsafe: options?.allowUnsafe ?? true,
2222
+ limits: options?.limits,
2223
+ debugForceFaceID: options?.debugForceFaceID,
2224
+ });
2225
+ }
2226
+ }
2227
+
2228
+ /**
2229
+ * Compute boolean subtraction in a dedicated Web Worker (non-blocking).
2230
+ * Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
2231
+ */
2232
+ async subtractAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
2233
+ try {
2234
+ const result = await runMeshBooleanInWorkerPool(
2235
+ "subtraction",
2236
+ this._buffer,
2237
+ other._buffer,
2238
+ this._trustedBooleanInput,
2239
+ other._trustedBooleanInput,
2240
+ options,
2241
+ );
2242
+ return Mesh.fromTrustedBuffer(result);
2243
+ } catch (error) {
2244
+ if (!shouldFallbackFromWorkerFailure(error)) throw error;
2245
+ console.warn("Mesh.subtractAsync worker failed; falling back to main-thread boolean.", error);
2246
+ return this.subtract(other, {
2247
+ allowUnsafe: options?.allowUnsafe ?? true,
2248
+ limits: options?.limits,
2249
+ debugForceFaceID: options?.debugForceFaceID,
2250
+ });
2251
+ }
2252
+ }
2253
+
2254
+ /**
2255
+ * Compute boolean intersection in a dedicated Web Worker (non-blocking).
2256
+ * Defaults to allowUnsafe=true so high-poly jobs can run off the UI thread.
2257
+ */
2258
+ async intersectAsync(other: Mesh, options?: MeshBooleanAsyncOptions): Promise<Mesh> {
2259
+ try {
2260
+ const result = await runMeshBooleanInWorkerPool(
2261
+ "intersection",
2262
+ this._buffer,
2263
+ other._buffer,
2264
+ this._trustedBooleanInput,
2265
+ other._trustedBooleanInput,
2266
+ options,
2267
+ );
2268
+ return Mesh.fromTrustedBuffer(result);
2269
+ } catch (error) {
2270
+ if (!shouldFallbackFromWorkerFailure(error)) throw error;
2271
+ console.warn("Mesh.intersectAsync worker failed; falling back to main-thread boolean.", error);
2272
+ return this.intersect(other, {
2273
+ allowUnsafe: options?.allowUnsafe ?? true,
2274
+ limits: options?.limits,
2275
+ debugForceFaceID: options?.debugForceFaceID,
2276
+ });
2277
+ }
2278
+ }
1890
2279
 
1891
2280
  // ── Intersection queries ───────────────────────────────────────
1892
2281
 
@@ -2176,28 +2565,28 @@ export class Mesh {
2176
2565
  * Check if this triangulated mesh represents a closed volume.
2177
2566
  * Returns true when no welded topological boundary edges are found.
2178
2567
  */
2179
- isClosedVolume(): boolean {
2180
- if (this._isClosedVolumeCache !== null) {
2181
- return this._isClosedVolumeCache;
2182
- }
2183
- if (this._topologyMetricsCache) {
2568
+ isClosedVolume(): boolean {
2569
+ if (this._isClosedVolumeCache !== null) {
2570
+ return this._isClosedVolumeCache;
2571
+ }
2572
+ if (this._topologyMetricsCache) {
2184
2573
  const closedFromMetrics = this._topologyMetricsCache.boundaryEdges === 0;
2185
- this._isClosedVolumeCache = closedFromMetrics;
2186
- return closedFromMetrics;
2187
- }
2188
-
2189
- if (this._trustedBooleanInput) {
2190
- const metrics = this.topologyMetrics();
2191
- const closedFromMetrics = metrics.boundaryEdges === 0;
2192
- this._isClosedVolumeCache = closedFromMetrics;
2193
- return closedFromMetrics;
2194
- }
2195
-
2196
- ensureInit();
2197
- if (this.faceCount >= Mesh.TOPOLOGY_METRICS_CACHE_FACE_THRESHOLD) {
2198
- const metrics = this.topologyMetrics();
2199
- const closedFromMetrics = metrics.boundaryEdges === 0;
2200
- this._isClosedVolumeCache = closedFromMetrics;
2574
+ this._isClosedVolumeCache = closedFromMetrics;
2575
+ return closedFromMetrics;
2576
+ }
2577
+
2578
+ if (this._trustedBooleanInput) {
2579
+ const metrics = this.topologyMetrics();
2580
+ const closedFromMetrics = metrics.boundaryEdges === 0;
2581
+ this._isClosedVolumeCache = closedFromMetrics;
2582
+ return closedFromMetrics;
2583
+ }
2584
+
2585
+ ensureInit();
2586
+ if (this.faceCount >= Mesh.TOPOLOGY_METRICS_CACHE_FACE_THRESHOLD) {
2587
+ const metrics = this.topologyMetrics();
2588
+ const closedFromMetrics = metrics.boundaryEdges === 0;
2589
+ this._isClosedVolumeCache = closedFromMetrics;
2201
2590
  return closedFromMetrics;
2202
2591
  }
2203
2592
 
@@ -2206,30 +2595,30 @@ export class Mesh {
2206
2595
  return closed;
2207
2596
  }
2208
2597
 
2209
- /**
2210
- * Return topology metrics for this triangulated mesh.
2211
- *
2212
- * For trusted boolean buffers we preserve raw index topology, which is closer
2213
- * to MeshGL semantics where same-position seam vertices can stay distinct.
2214
- * For ordinary triangle soups we continue to use welded-edge metrics.
2215
- *
2216
- * `boundaryEdges`: edges referenced by exactly one triangle.
2217
- * `nonManifoldEdges`: edges referenced by more than two triangles.
2218
- */
2219
- topologyMetrics(): { boundaryEdges: number; nonManifoldEdges: number } {
2598
+ /**
2599
+ * Return topology metrics for this triangulated mesh.
2600
+ *
2601
+ * For trusted boolean buffers we preserve raw index topology, which is closer
2602
+ * to MeshGL semantics where same-position seam vertices can stay distinct.
2603
+ * For ordinary triangle soups we continue to use welded-edge metrics.
2604
+ *
2605
+ * `boundaryEdges`: edges referenced by exactly one triangle.
2606
+ * `nonManifoldEdges`: edges referenced by more than two triangles.
2607
+ */
2608
+ topologyMetrics(): { boundaryEdges: number; nonManifoldEdges: number } {
2220
2609
  if (this._topologyMetricsCache) {
2221
2610
  return {
2222
2611
  boundaryEdges: this._topologyMetricsCache.boundaryEdges,
2223
2612
  nonManifoldEdges: this._topologyMetricsCache.nonManifoldEdges,
2224
2613
  };
2225
2614
  }
2226
-
2227
- ensureInit();
2228
- const metrics = this._trustedBooleanInput
2229
- ? mesh_topology_metrics_raw(this._vertexCount, this._buffer)
2230
- : mesh_topology_metrics(this._vertexCount, this._buffer);
2231
- const boundaryEdges = Math.floor(metrics[0] ?? 0);
2232
- const nonManifoldEdges = Math.floor(metrics[1] ?? 0);
2615
+
2616
+ ensureInit();
2617
+ const metrics = this._trustedBooleanInput
2618
+ ? mesh_topology_metrics_raw(this._vertexCount, this._buffer)
2619
+ : mesh_topology_metrics(this._vertexCount, this._buffer);
2620
+ const boundaryEdges = Math.floor(metrics[0] ?? 0);
2621
+ const nonManifoldEdges = Math.floor(metrics[1] ?? 0);
2233
2622
  this._topologyMetricsCache = { boundaryEdges, nonManifoldEdges };
2234
2623
  if (this._isClosedVolumeCache === null) {
2235
2624
  this._isClosedVolumeCache = boundaryEdges === 0;