okgeometry-api 1.16.0 → 1.17.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.
@@ -0,0 +1,863 @@
1
+ import { Mesh } from "./Mesh.js";
2
+ import { Brep } from "./Brep.js";
3
+ import { NurbsSurface } from "./NurbsSurface.js";
4
+ import { NurbsCurve } from "./NurbsCurve.js";
5
+ import { Polyline } from "./Polyline.js";
6
+ import { Polygon } from "./Polygon.js";
7
+ import { PolyCurve } from "./PolyCurve.js";
8
+ import { Arc } from "./Arc.js";
9
+ import { Circle } from "./Circle.js";
10
+ import { Line } from "./Line.js";
11
+ import { Point } from "./Point.js";
12
+ import type { SweepableCurve } from "./types.js";
13
+
14
+ /**
15
+ * Universal geometry boolean dispatcher, the single source of truth for
16
+ * "any geometry x any geometry" boolean semantics. Both okdraw and oknodes
17
+ * consume this module instead of re-implementing the dispatch matrix.
18
+ *
19
+ * Operands are classified by EFFECTIVE DIMENSION (what the geometry actually
20
+ * is, not what class it arrived as):
21
+ * - brep solid — closed Brep
22
+ * - brep sheet — open Brep, or a NurbsSurface (via Brep.fromSurface)
23
+ * - mesh solid — closed-volume triangle mesh
24
+ * - mesh sheet — open triangle mesh
25
+ * - curve — NurbsCurve, Polyline, Polygon (a Polyline subclass, always
26
+ * closed), PolyCurve, Arc, Circle, Line (all coerce to an exact
27
+ * NurbsCurve; piecewise-linear inputs keep their native polyline where
28
+ * polyline ops are cheaper or exact)
29
+ *
30
+ * Closedness caveat: curve closedness is decided by each operand's own
31
+ * isClosed() test, which for NurbsCurve / Polyline / PolyCurve is an
32
+ * ENDPOINT-PROXIMITY gate — start and end must coincide within 1e-10
33
+ * (Arc closes on a full 2π sweep; Circle and Polygon are always closed).
34
+ * A curve whose endpoints coincide within that tolerance therefore gets the
35
+ * closed-curve semantics (region booleans); a curve with a genuine endpoint
36
+ * gap gets the open-curve semantics (no region booleans,
37
+ * point-intersections). The region-boolean kernel re-verifies closure with a
38
+ * scale-relative tolerance and rejects open curves instead of silently
39
+ * chord-closing them.
40
+ *
41
+ * Result kinds follow the dimension of the result set:
42
+ * - solid/sheet x solid/sheet — 'brep' (parametric path) or 'mesh'
43
+ * (tessellated path, the documented downgrade when the parametric
44
+ * pipeline is unavailable or fails)
45
+ * - curve x solid — 'curves' (clip pieces)
46
+ * - curve x sheet — 'points' (crossings) for intersect, 'curves' for subtract
47
+ * - curve x curve — 'curves' (region boundaries) when both are closed +
48
+ * coplanar (Rhino CurveBoolean); 'points' for intersect of open curves
49
+ *
50
+ * Unsupported combinations throw typed, actionable errors.
51
+ */
52
+
53
+ export type BooleanOpKind = "union" | "subtract" | "intersect";
54
+
55
+ export type BooleanOperand =
56
+ | Mesh
57
+ | Brep
58
+ | NurbsSurface
59
+ | NurbsCurve
60
+ | Polyline
61
+ | Polygon
62
+ | PolyCurve
63
+ | Arc
64
+ | Circle
65
+ | Line;
66
+
67
+ export interface GeometryBooleanOptions {
68
+ /**
69
+ * PARAMETRIC tolerance: the SSI edge / classification tolerance of the
70
+ * exact BREP paths (default 1e-6). It is NEVER used as a tessellation
71
+ * tolerance — feeding an SSI-grade tolerance to toMesh would produce
72
+ * pathologically dense fallback meshes.
73
+ */
74
+ tolerance?: number;
75
+ /**
76
+ * TESSELLATION tolerance: used whenever a BREP/surface must be converted
77
+ * to a mesh (toMesh conversions, tessellated fallbacks, side-probe
78
+ * meshes). When omitted the default is SCALE-RELATIVE: the combined
79
+ * operands' bounds diagonal × 2e-3 (the render pipeline's recipe), floored
80
+ * at 1e-6 — an absolute default breaks mm-scale models (degenerate probe
81
+ * tessellations) and wastes time on 100 m models. An explicit value always
82
+ * wins.
83
+ */
84
+ meshTolerance?: number;
85
+ }
86
+
87
+ export interface GeometryBooleanResult {
88
+ kind: "brep" | "mesh" | "curves" | "points";
89
+ /** Single BREP result (set when the result is exactly one BREP). */
90
+ brep?: Brep;
91
+ /** All BREP pieces ('brep' results always set this, booleans have one entry). */
92
+ breps?: Brep[];
93
+ /** Merged mesh result (all pieces in one multi-component mesh). */
94
+ mesh?: Mesh;
95
+ /** Individual mesh pieces ('mesh' results always set this). */
96
+ meshes?: Mesh[];
97
+ /** Curve pieces. NurbsCurve pieces are exact (they lie exactly on the input curve); Polyline pieces come from polyline-native or tessellated paths. */
98
+ curves?: (NurbsCurve | Polyline)[];
99
+ /** Intersection points. */
100
+ points?: Point[];
101
+ }
102
+
103
+ /** Fallback tessellation tolerance when no operand yields usable bounds. */
104
+ const DEFAULT_TESS_TOLERANCE = 0.005;
105
+ /** Scale-relative default: bounds diagonal × this (render pipeline recipe). */
106
+ const MESH_TOLERANCE_DIAG_FACTOR = 2e-3;
107
+ /** Absolute floor for the scale-relative default. */
108
+ const MESH_TOLERANCE_FLOOR = 1e-6;
109
+ /** Coarse probe tolerance for BREP bounds estimation (minimal tessellation). */
110
+ const BOUNDS_PROBE_TOLERANCE = 1e6;
111
+
112
+ // ── operand classification ──────────────────────────────────────────
113
+
114
+ interface SurfaceLikeOperand {
115
+ kind: "brep" | "mesh";
116
+ closed: boolean;
117
+ brep?: Brep;
118
+ mesh?: Mesh;
119
+ }
120
+
121
+ interface CurveOperand {
122
+ kind: "curve";
123
+ raw: BooleanOperand;
124
+ closed: boolean;
125
+ /** Native polyline when the input was already piecewise linear (exact). */
126
+ polyline: Polyline | null;
127
+ /** Exact NURBS coercion (lazy, cached). */
128
+ nurbs: () => NurbsCurve;
129
+ }
130
+
131
+ type Operand = SurfaceLikeOperand | CurveOperand;
132
+
133
+ function cached<T>(build: () => T): () => T {
134
+ let value: T | null = null;
135
+ return () => {
136
+ if (value === null) value = build();
137
+ return value;
138
+ };
139
+ }
140
+
141
+ const TAU = Math.PI * 2;
142
+
143
+ function classifyOperand(operand: BooleanOperand, label: string): Operand {
144
+ if (operand instanceof Brep) {
145
+ return { kind: "brep", closed: operand.isClosed(), brep: operand };
146
+ }
147
+ if (operand instanceof NurbsSurface) {
148
+ // A surface is an (untrimmed) sheet; the sheet BREP carries the exact
149
+ // surface so the parametric paths stay exact.
150
+ return { kind: "brep", closed: false, brep: Brep.fromSurface(operand) };
151
+ }
152
+ if (operand instanceof Mesh) {
153
+ return { kind: "mesh", closed: operand.isClosedVolume(), mesh: operand };
154
+ }
155
+ if (operand instanceof NurbsCurve) {
156
+ return {
157
+ kind: "curve",
158
+ raw: operand,
159
+ closed: operand.isClosed(),
160
+ polyline: null,
161
+ nurbs: () => operand,
162
+ };
163
+ }
164
+ if (operand instanceof Polyline) {
165
+ // Polygon extends Polyline with automatic closure, so it lands here and
166
+ // classifies as a CLOSED polyline (region-boolean capable).
167
+ return {
168
+ kind: "curve",
169
+ raw: operand,
170
+ closed: operand.isClosed(),
171
+ polyline: operand,
172
+ nurbs: cached(() => NurbsCurve.fromPoints(1, operand.points)),
173
+ };
174
+ }
175
+ if (operand instanceof Line) {
176
+ const polyline = new Polyline([operand.start, operand.end]);
177
+ return {
178
+ kind: "curve",
179
+ raw: operand,
180
+ closed: false,
181
+ polyline,
182
+ nurbs: cached(() => NurbsCurve.fromLine(operand)),
183
+ };
184
+ }
185
+ if (operand instanceof Arc) {
186
+ const closed = Math.abs(operand.endAngle - operand.startAngle) >= TAU - 1e-9;
187
+ return {
188
+ kind: "curve",
189
+ raw: operand,
190
+ closed,
191
+ polyline: null,
192
+ nurbs: cached(() => NurbsCurve.fromArc(operand)),
193
+ };
194
+ }
195
+ if (operand instanceof Circle) {
196
+ return {
197
+ kind: "curve",
198
+ raw: operand,
199
+ closed: true,
200
+ polyline: null,
201
+ nurbs: cached(() =>
202
+ NurbsCurve.fromArc(new Arc(operand.center, operand.radius, 0, TAU, operand.normal)),
203
+ ),
204
+ };
205
+ }
206
+ if (operand instanceof PolyCurve) {
207
+ return {
208
+ kind: "curve",
209
+ raw: operand,
210
+ closed: operand.isClosed(),
211
+ polyline: null,
212
+ nurbs: cached(() => operand.toNurbs()),
213
+ };
214
+ }
215
+ throw new Error(
216
+ `geometryBoolean operand ${label} has an unsupported type, expected `
217
+ + "Mesh, Brep, NurbsSurface, NurbsCurve, Polyline, Polygon, PolyCurve, "
218
+ + "Arc, Circle, or Line",
219
+ );
220
+ }
221
+
222
+ function operandMesh(operand: SurfaceLikeOperand, tolerance: number): Mesh {
223
+ if (operand.mesh) return operand.mesh;
224
+ return operand.brep!.toMesh(tolerance);
225
+ }
226
+
227
+ // ── scale-relative tessellation tolerance ───────────────────────────
228
+
229
+ interface BoundsAcc {
230
+ minX: number;
231
+ minY: number;
232
+ minZ: number;
233
+ maxX: number;
234
+ maxY: number;
235
+ maxZ: number;
236
+ }
237
+
238
+ /**
239
+ * Accumulate an operand's world bounds for the scale-relative default
240
+ * tessellation tolerance. Mesh operands use their exact vertex bounds; BREP
241
+ * operands use a MINIMAL probe tessellation (huge chordal tolerance → one
242
+ * segment per knot span — cheap, and adequate for a scale estimate); curve
243
+ * operands use their polyline points or NURBS control points (a convex-hull
244
+ * bound). Operands that fail to yield bounds are skipped.
245
+ */
246
+ function accumulateOperandBounds(operand: Operand, acc: BoundsAcc): void {
247
+ const add = (x: number, y: number, z: number): void => {
248
+ acc.minX = Math.min(acc.minX, x);
249
+ acc.minY = Math.min(acc.minY, y);
250
+ acc.minZ = Math.min(acc.minZ, z);
251
+ acc.maxX = Math.max(acc.maxX, x);
252
+ acc.maxY = Math.max(acc.maxY, y);
253
+ acc.maxZ = Math.max(acc.maxZ, z);
254
+ };
255
+ try {
256
+ if (operand.kind === "curve") {
257
+ const points = operand.polyline
258
+ ? operand.polyline.points
259
+ : operand.nurbs().controlPoints;
260
+ for (const p of points) add(p.x, p.y, p.z);
261
+ return;
262
+ }
263
+ const mesh = operand.mesh ?? operand.brep!.tessellate(BOUNDS_PROBE_TOLERANCE);
264
+ const bounds = mesh.getBounds();
265
+ add(bounds.min.x, bounds.min.y, bounds.min.z);
266
+ add(bounds.max.x, bounds.max.y, bounds.max.z);
267
+ } catch {
268
+ // Bounds are a scale ESTIMATE — a failing operand is skipped, never fatal.
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Resolve the tessellation tolerance: an explicit `meshTolerance` wins;
274
+ * otherwise combined-operand bounds diagonal × 2e-3 (floored at 1e-6), so
275
+ * mm-scale models get mm-scale probe tessellations and 100 m models don't
276
+ * over-tessellate. Falls back to the legacy 0.005 when no operand yields
277
+ * bounds.
278
+ */
279
+ function resolveMeshTolerance(
280
+ options: GeometryBooleanOptions | undefined,
281
+ ...operands: Operand[]
282
+ ): number {
283
+ if (options?.meshTolerance !== undefined) return options.meshTolerance;
284
+ const acc: BoundsAcc = {
285
+ minX: Infinity,
286
+ minY: Infinity,
287
+ minZ: Infinity,
288
+ maxX: -Infinity,
289
+ maxY: -Infinity,
290
+ maxZ: -Infinity,
291
+ };
292
+ for (const operand of operands) accumulateOperandBounds(operand, acc);
293
+ if (!Number.isFinite(acc.minX) || !Number.isFinite(acc.maxX)) {
294
+ return DEFAULT_TESS_TOLERANCE;
295
+ }
296
+ const diag = Math.hypot(acc.maxX - acc.minX, acc.maxY - acc.minY, acc.maxZ - acc.minZ);
297
+ if (!(diag > 0)) return DEFAULT_TESS_TOLERANCE;
298
+ return Math.max(diag * MESH_TOLERANCE_DIAG_FACTOR, MESH_TOLERANCE_FLOOR);
299
+ }
300
+
301
+ /** A parametric-path operand plus whether it was COERCED from a mesh. */
302
+ interface ParametricOperand {
303
+ brep: Brep;
304
+ coercedFromMesh: boolean;
305
+ }
306
+
307
+ /**
308
+ * Try to view a surface-like operand as a BREP for the parametric paths.
309
+ * Mesh operands convert via Brep.fromMesh with a 512-face guard (planar
310
+ * faceted solids only, densely tessellated curved meshes fall back to the
311
+ * mesh path instead of producing a pathological per-triangle BREP).
312
+ *
313
+ * Facet-planarity heuristic: fromMesh can "succeed" on a curved closed mesh
314
+ * under the face guard by emitting nearly one BREP face per triangle. A BREP
315
+ * face count CLOSE TO the mesh triangle count means barely any coplanar
316
+ * clustering happened — reject it (mesh path) instead of running the exact
317
+ * pipeline on a per-facet pseudo-BREP. The threshold is tris × 0.55: a
318
+ * quad-faceted planar solid legitimately has faces ≈ tris / 2 (every planar
319
+ * face triangulated as one quad), so a tris / 3 cutoff wrongly rejected it,
320
+ * while a per-facet pseudo-BREP (faces ≈ tris) still lands far above 0.55.
321
+ */
322
+ function tryOperandBrep(operand: SurfaceLikeOperand): ParametricOperand | null {
323
+ if (operand.brep) return { brep: operand.brep, coercedFromMesh: false };
324
+ if (!operand.closed) return null; // Brep.fromMesh rejects open meshes.
325
+ try {
326
+ const mesh = operand.mesh!;
327
+ const brep = Brep.fromMesh(mesh, { maxFaces: 512 });
328
+ if (brep.faceCount > Math.max(32, mesh.faceCount * 0.55)) {
329
+ return null;
330
+ }
331
+ return { brep, coercedFromMesh: true };
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * The parametric BREP pipeline runs only when at least one operand arrived
339
+ * as exact geometry (Brep or NurbsSurface); mesh x mesh pairs always use the
340
+ * mesh kernel directly.
341
+ */
342
+ function shouldTryParametric(a: SurfaceLikeOperand, b: SurfaceLikeOperand): boolean {
343
+ return a.kind === "brep" || b.kind === "brep";
344
+ }
345
+
346
+ /**
347
+ * Sample a curve operand into a polyline with KNOWN uniform parameters:
348
+ * point i sits at curve parameter i / segmentCount (closed) or
349
+ * i / (sampleCount - 1) (open), so mesh-side hits map back to curve
350
+ * parameters via (segmentIndex + segmentT) / segmentCount.
351
+ *
352
+ * Polyline-NATIVE operands never reach this sampler — every dispatch row
353
+ * handles `operand.polyline` on a native path first; a polyline that did
354
+ * arrive here would simply sample its exact degree-1 NURBS coercion.
355
+ */
356
+ function sampleCurveOperand(
357
+ operand: CurveOperand,
358
+ samples = 128,
359
+ ): { points: Point[]; closed: boolean; segmentCount: number } {
360
+ const curve = operand.nurbs();
361
+ const points: Point[] = [];
362
+ if (operand.closed) {
363
+ // Closed sampling omits the duplicate seam point and flags closedness
364
+ // explicitly; segment i runs i/n to (i+1)/n with the wrap segment last.
365
+ for (let i = 0; i < samples; i++) points.push(curve.pointAt(i / samples));
366
+ return { points, closed: true, segmentCount: samples };
367
+ }
368
+ for (let i = 0; i < samples; i++) points.push(curve.pointAt(i / (samples - 1)));
369
+ return { points, closed: false, segmentCount: samples - 1 };
370
+ }
371
+
372
+ /**
373
+ * Which side of an oriented sheet mesh a crossing-free curve piece lies on.
374
+ *
375
+ * Classifies probe POINTS — interior parameter samples of the piece — by
376
+ * their signed side against the sheet, each through a tiny 2-point probe
377
+ * polyline (a crossing-free micro-span classifies by its midpoint's signed
378
+ * side, i.e. a point probe). Points are used instead of clipping whole
379
+ * probe CHORDS between distant samples: a chord can shortcut across a
380
+ * curved sheet (crossing it even though the curve piece does not) and
381
+ * misclassify, and a degenerate micro-piece chord probe can throw and abort
382
+ * the whole operation.
383
+ *
384
+ * Robustness contract: a sample whose probe fails (degenerate span) is
385
+ * skipped; a piece with no classifiable samples, or an exact tie, defaults
386
+ * to "front" — the documented ON convention (a sheet's material is the
387
+ * CLOSED front half-space, so on-sheet pieces belong to the front and are
388
+ * dropped by subtract). This function never throws.
389
+ */
390
+ function pieceSideOfSheet(piece: NurbsCurve, sheetMesh: Mesh): "front" | "back" {
391
+ let front = 0;
392
+ let back = 0;
393
+ for (let i = 1; i <= 5; i++) {
394
+ const t = i / 6;
395
+ const p = piece.pointAt(t);
396
+ // Tiny tangent-direction partner keeps the probe span on the sample's
397
+ // side of the sheet (the piece is crossing-free).
398
+ const q = piece.pointAt(Math.min(1, t + 1e-3));
399
+ try {
400
+ const groups = sheetMesh.clipPolylineBySheet([p, q], false);
401
+ const total = (list: Polyline[]) => list.reduce((sum, pl) => sum + pl.length(), 0);
402
+ const inside = total(groups.inside);
403
+ const outside = total(groups.outside);
404
+ if (inside > outside) front++;
405
+ else if (outside > inside) back++;
406
+ } catch {
407
+ // Degenerate probe: skip this sample, never abort the operation.
408
+ }
409
+ }
410
+ return back > front ? "back" : "front";
411
+ }
412
+
413
+ // ── result assembly ─────────────────────────────────────────────────
414
+
415
+ function brepResult(pieces: (Brep | null | undefined)[]): GeometryBooleanResult {
416
+ const breps = pieces.filter((piece): piece is Brep => piece != null);
417
+ return {
418
+ kind: "brep",
419
+ breps,
420
+ brep: breps.length === 1 ? breps[0] : undefined,
421
+ };
422
+ }
423
+
424
+ function meshResult(pieces: Mesh[]): GeometryBooleanResult {
425
+ if (pieces.length === 0) return { kind: "mesh", meshes: [] };
426
+ const mesh = pieces.length === 1 ? pieces[0] : Mesh.mergeMeshes(pieces);
427
+ return { kind: "mesh", mesh, meshes: pieces };
428
+ }
429
+
430
+ function curvesResult(curves: (NurbsCurve | Polyline)[]): GeometryBooleanResult {
431
+ return { kind: "curves", curves };
432
+ }
433
+
434
+ function pointsResult(points: Point[]): GeometryBooleanResult {
435
+ return { kind: "points", points };
436
+ }
437
+
438
+ // ── curve x curve region booleans ───────────────────────────────────
439
+
440
+ /**
441
+ * 2D region boolean between two CLOSED coplanar curve operands (Rhino
442
+ * CurveBoolean). Both operands are coerced to exact NURBS; the kernel fits a
443
+ * common plane, verifies coplanarity, and returns the result region boundary
444
+ * loops (polyline fidelity — outer CCW, holes CW). Throws with a clear message
445
+ * when the curves are not coplanar.
446
+ */
447
+ function regionBoolean(
448
+ a: CurveOperand,
449
+ b: CurveOperand,
450
+ op: BooleanOpKind,
451
+ ): (NurbsCurve | Polyline)[] {
452
+ const na = a.nurbs();
453
+ const nb = b.nurbs();
454
+ switch (op) {
455
+ case "union":
456
+ return na.regionUnion(nb);
457
+ case "subtract":
458
+ return na.regionSubtract(nb);
459
+ case "intersect":
460
+ return na.regionIntersect(nb);
461
+ }
462
+ }
463
+
464
+ // ── surface-like x surface-like ─────────────────────────────────────
465
+
466
+ function meshBoolean(
467
+ a: SurfaceLikeOperand,
468
+ b: SurfaceLikeOperand,
469
+ op: BooleanOpKind,
470
+ tolerance: number,
471
+ ): GeometryBooleanResult {
472
+ const meshA = operandMesh(a, tolerance);
473
+ const meshB = operandMesh(b, tolerance);
474
+
475
+ if (a.closed && !b.closed) {
476
+ // solid x sheet: cut the solid along the oriented sheet and keep the
477
+ // CAPPED half, matching the parametric solid/sheet convention
478
+ // (subtract keeps the half behind the sheet normal, intersect the front).
479
+ const split = meshA.splitSolidBySurface(meshB);
480
+ return meshResult(op === "subtract" ? split.outside : split.inside);
481
+ }
482
+ if (!a.closed && (b.closed || op !== "union")) {
483
+ // sheet x solid trims by containment; sheet x sheet subtract/intersect
484
+ // classify by the other sheet's signed side (back/front).
485
+ const split = meshA.split(meshB);
486
+ return meshResult(op === "subtract" ? split.outside : split.inside);
487
+ }
488
+ if (op === "union") {
489
+ // closed x closed volumetric union, or open x open sheet union
490
+ // (mutual trim + join) — Mesh.union dispatches on closedness.
491
+ return meshResult([meshA.union(meshB)]);
492
+ }
493
+ return meshResult([op === "subtract" ? meshA.subtract(meshB) : meshA.intersect(meshB)]);
494
+ }
495
+
496
+ function surfaceBoolean(
497
+ a: SurfaceLikeOperand,
498
+ b: SurfaceLikeOperand,
499
+ op: BooleanOpKind,
500
+ options?: GeometryBooleanOptions,
501
+ ): GeometryBooleanResult {
502
+ // Union of a solid and a sheet is not defined in ANY representation, and
503
+ // this is a semantic error, never a fallback trigger.
504
+ if (op === "union" && a.closed !== b.closed) {
505
+ throw new Error(
506
+ "Union of a solid and a sheet is not defined, trim the sheet with "
507
+ + "subtract or intersect, or close the sheet into a solid first",
508
+ );
509
+ }
510
+
511
+ if (shouldTryParametric(a, b)) {
512
+ const brepA = tryOperandBrep(a);
513
+ const brepB = tryOperandBrep(b);
514
+ if (brepA && brepB) {
515
+ try {
516
+ const parametricOptions =
517
+ options?.tolerance !== undefined ? { tolerance: options.tolerance } : undefined;
518
+ const result =
519
+ op === "union"
520
+ ? brepA.brep.unionBrep(brepB.brep, parametricOptions)
521
+ : op === "subtract"
522
+ ? brepA.brep.subtractBrep(brepB.brep, parametricOptions)
523
+ : brepA.brep.intersectBrep(brepB.brep, parametricOptions);
524
+ // Validation gate for COERCED mesh operands: fromMesh conversion can
525
+ // hand the exact pipeline a borderline pseudo-BREP; when both inputs
526
+ // are closed, the boolean of two solids must come back closed —
527
+ // otherwise reject the parametric result and use the mesh path.
528
+ if (
529
+ (brepA.coercedFromMesh || brepB.coercedFromMesh)
530
+ && a.closed && b.closed
531
+ && result.faceCount > 0
532
+ && !result.isClosed()
533
+ ) {
534
+ throw new Error("parametric result of coerced mesh operand is not closed");
535
+ }
536
+ return brepResult([result]);
537
+ } catch {
538
+ // Documented downgrade: the parametric pipeline failed, fall back to
539
+ // the tessellated mesh path below.
540
+ }
541
+ }
542
+ }
543
+
544
+ return meshBoolean(a, b, op, resolveMeshTolerance(options, a, b));
545
+ }
546
+
547
+ // ── curve x surface-like ────────────────────────────────────────────
548
+
549
+ function curveSolidPieces(
550
+ curve: CurveOperand,
551
+ solid: SurfaceLikeOperand,
552
+ ): { inside: (NurbsCurve | Polyline)[]; outside: (NurbsCurve | Polyline)[] } {
553
+ if (solid.kind === "brep") {
554
+ // Exact: knot-insertion split + containment classification.
555
+ return curve.nurbs().clipByBrep(solid.brep!);
556
+ }
557
+ if (curve.polyline) {
558
+ return solid.mesh!.clipPolyline(curve.polyline);
559
+ }
560
+ // Exact curve against a mesh solid: tessellated clip (documented
561
+ // approximation, pieces come back as Polylines). Pass the solid as a Brep
562
+ // for the exact path instead.
563
+ const sampled = sampleCurveOperand(curve);
564
+ return solid.mesh!.clipPolyline(sampled.points, sampled.closed);
565
+ }
566
+
567
+ function curveSheetCrossings(curve: CurveOperand, sheet: SurfaceLikeOperand): Point[] {
568
+ if (sheet.kind === "brep") {
569
+ return sheet.brep!.intersectCurve(curve.nurbs()).map((hit) => hit.point);
570
+ }
571
+ const sampled = curve.polyline
572
+ ? { points: curve.polyline.points, closed: curve.closed }
573
+ : sampleCurveOperand(curve);
574
+ return sheet
575
+ .mesh!.intersectPolyline(sampled.points, sampled.closed)
576
+ .map((hit) => hit.point);
577
+ }
578
+
579
+ function curveMinusSheet(
580
+ curve: CurveOperand,
581
+ sheet: SurfaceLikeOperand,
582
+ tolerance: number,
583
+ ): (NurbsCurve | Polyline)[] {
584
+ if (sheet.kind === "brep") {
585
+ // Exact split parameters from the trim-checked curve x BREP hits, then
586
+ // exact knot-insertion splitting; side classification uses the
587
+ // tessellated sheet (no exact open-sheet clip exists, clipCurve
588
+ // requires a closed solid).
589
+ const hits = sheet.brep!.intersectCurve(curve.nurbs());
590
+ const pieces = curve
591
+ .nurbs()
592
+ .splitAtParams(hits.map((hit) => hit.curveParam), curve.closed);
593
+ const sheetMesh = sheet.brep!.toMesh(tolerance);
594
+ return pieces.filter((piece) => pieceSideOfSheet(piece, sheetMesh) === "back");
595
+ }
596
+ if (curve.polyline) {
597
+ return sheet.mesh!.clipPolylineBySheet(curve.polyline).outside;
598
+ }
599
+ // Exact curve against a mesh sheet: exact split at sampled-hit parameters
600
+ // (hit parameters are exact in parameter space up to chord deviation),
601
+ // then signed-side classification per piece.
602
+ const sampled = sampleCurveOperand(curve);
603
+ const hits = sheet.mesh!.intersectPolyline(sampled.points, sampled.closed);
604
+ const params = hits.map((hit) => (hit.segmentIndex + hit.segmentT) / sampled.segmentCount);
605
+ const pieces = curve.nurbs().splitAtParams(params, curve.closed);
606
+ return pieces.filter((piece) => pieceSideOfSheet(piece, sheet.mesh!) === "back");
607
+ }
608
+
609
+ function curveSurfaceBoolean(
610
+ curve: CurveOperand,
611
+ other: SurfaceLikeOperand,
612
+ op: BooleanOpKind,
613
+ curveIsFirst: boolean,
614
+ options?: GeometryBooleanOptions,
615
+ ): GeometryBooleanResult {
616
+ if (op === "union") {
617
+ throw new Error(
618
+ "Union with a curve operand is not defined, curves have no area or volume",
619
+ );
620
+ }
621
+ if (op === "subtract" && !curveIsFirst) {
622
+ throw new Error(
623
+ "Subtracting a curve from a solid or sheet is not defined, curves have "
624
+ + "no volume or area to remove",
625
+ );
626
+ }
627
+
628
+ if (other.closed) {
629
+ // curve x solid: intersect keeps the inside pieces, subtract the outside.
630
+ const pieces = curveSolidPieces(curve, other);
631
+ return curvesResult(op === "intersect" ? pieces.inside : pieces.outside);
632
+ }
633
+
634
+ // curve x sheet: the intersection set is the crossing POINTS; subtract
635
+ // keeps the pieces behind the sheet normal. The tessellation tolerance is
636
+ // resolved LAZILY here — the exact paths above never pay the bounds probe.
637
+ if (op === "intersect") {
638
+ return pointsResult(curveSheetCrossings(curve, other));
639
+ }
640
+ return curvesResult(curveMinusSheet(curve, other, resolveMeshTolerance(options, curve, other)));
641
+ }
642
+
643
+ // ── public API: boolean ─────────────────────────────────────────────
644
+
645
+ /**
646
+ * Boolean of any two geometries, dispatched by effective dimension.
647
+ *
648
+ * Dispatch matrix (A x B):
649
+ * - brep x brep (any closedness) — parametric unionBrep/subtractBrep/
650
+ * intersectBrep (sheet-capable) returning kind 'brep'; parametric failures
651
+ * fall back to the tessellated mesh path (kind 'mesh').
652
+ * - brep x mesh — the closed mesh side is converted with Brep.fromMesh
653
+ * (planar-faceted) for the parametric path; otherwise tessellated mesh ops.
654
+ * - mesh x mesh — solid x solid runs CSG; a sheet operand uses the
655
+ * split-based trim semantics (subtract keeps outside/back, intersect keeps
656
+ * inside/front; solid x sheet returns the CAPPED clicked-side halves);
657
+ * sheet union sheet runs the kernel mutual trim + join. A sheet's material
658
+ * is its FRONT half-space, so the sheet union keeps each sheet's pieces
659
+ * BEHIND the other (front pieces are interior to the union material);
660
+ * uncut regions are kept, coincident overlaps keep A's copy.
661
+ * - union of a solid and a sheet (any representation) throws.
662
+ * - curve x solid — intersect keeps the inside clip pieces, subtract the
663
+ * outside pieces (kind 'curves'); union throws.
664
+ * - curve x sheet — intersect returns the crossing points (kind 'points');
665
+ * subtract keeps the pieces behind the sheet normal (kind 'curves').
666
+ * - curve x curve — when BOTH curves are closed and coplanar, union /
667
+ * subtract / intersect are 2D REGION booleans (Rhino CurveBoolean): the
668
+ * result is the region boundary CURVES (kind 'curves', outer CCW / holes
669
+ * CW, polyline fidelity). For open curves, intersect returns the crossing
670
+ * POINTS (kind 'points') and union / subtract throw (open curves bound no
671
+ * region). Note: region-intersect returns the overlapping region's
672
+ * BOUNDARY, distinct from the point-set intersection of the open case.
673
+ */
674
+ export function geometryBoolean(
675
+ a: BooleanOperand,
676
+ b: BooleanOperand,
677
+ op: BooleanOpKind,
678
+ options?: GeometryBooleanOptions,
679
+ ): GeometryBooleanResult {
680
+ const oa = classifyOperand(a, "A");
681
+ const ob = classifyOperand(b, "B");
682
+
683
+ if (oa.kind === "curve" && ob.kind === "curve") {
684
+ // Two CLOSED coplanar curves bound 2D regions, so union / subtract /
685
+ // intersect are the REGION booleans (Rhino CurveBoolean). The region
686
+ // op returns the boundary CURVES (outer CCW, holes CW). Coplanarity is
687
+ // verified kernel-side; a non-coplanar pair throws with a clear message.
688
+ if (oa.closed && ob.closed) {
689
+ return curvesResult(regionBoolean(oa, ob, op));
690
+ }
691
+ // Open curves cannot bound a region. Intersect still returns the crossing
692
+ // POINTS (the meaningful lower-dimensional result); union / subtract of an
693
+ // open curve is undefined.
694
+ if (op !== "intersect") {
695
+ throw new Error(
696
+ `Boolean ${op} of two curves requires BOTH curves to be closed and `
697
+ + "coplanar (open curves bound no region)",
698
+ );
699
+ }
700
+ const hits = oa.nurbs().intersectCurveParams(ob.nurbs());
701
+ return pointsResult(hits.map((hit) => hit.point));
702
+ }
703
+
704
+ if (oa.kind === "curve") {
705
+ return curveSurfaceBoolean(oa, ob as SurfaceLikeOperand, op, true, options);
706
+ }
707
+ if (ob.kind === "curve") {
708
+ return curveSurfaceBoolean(ob, oa, op, false, options);
709
+ }
710
+
711
+ return surfaceBoolean(oa, ob, op, options);
712
+ }
713
+
714
+ // ── public API: split ───────────────────────────────────────────────
715
+
716
+ function splitCurveByOperand(
717
+ curve: CurveOperand,
718
+ blade: Operand,
719
+ ): GeometryBooleanResult {
720
+ if (blade.kind === "curve") {
721
+ // Two CLOSED coplanar curves → REGION split (Rhino "region split"): the
722
+ // arrangement of the two loops partitions the plane into all its regions,
723
+ // returned as closed boundary curves (outer CCW, holes CW).
724
+ if (curve.closed && blade.closed) {
725
+ return curvesResult(curve.nurbs().regionSplit(blade.nurbs()));
726
+ }
727
+ // Otherwise split THIS curve exactly at every crossing parameter (the
728
+ // blade is untouched), the pre-region behavior.
729
+ const hits = curve.nurbs().intersectCurveParams(blade.nurbs());
730
+ return curvesResult(
731
+ curve.nurbs().splitAtParams(hits.map((hit) => hit.paramA), curve.closed),
732
+ );
733
+ }
734
+ if (blade.kind === "brep") {
735
+ if (blade.closed) {
736
+ // Exact containment clip, all pieces (inside then outside).
737
+ const clipped = curve.nurbs().clipByBrep(blade.brep!);
738
+ return curvesResult([...clipped.inside, ...clipped.outside]);
739
+ }
740
+ const hits = blade.brep!.intersectCurve(curve.nurbs());
741
+ return curvesResult(
742
+ curve.nurbs().splitAtParams(hits.map((hit) => hit.curveParam), curve.closed),
743
+ );
744
+ }
745
+ // Mesh blade (open or closed): polyline-native input splits natively;
746
+ // exact curves split EXACTLY at the sampled-hit parameters so the pieces
747
+ // stay on the curve.
748
+ if (curve.polyline) {
749
+ return curvesResult(blade.mesh!.splitPolyline(curve.polyline));
750
+ }
751
+ const sampled = sampleCurveOperand(curve);
752
+ const hits = blade.mesh!.intersectPolyline(sampled.points, sampled.closed);
753
+ const params = hits.map((hit) => (hit.segmentIndex + hit.segmentT) / sampled.segmentCount);
754
+ return curvesResult(curve.nurbs().splitAtParams(params, curve.closed));
755
+ }
756
+
757
+ function splitSurfaceByOperand(
758
+ a: SurfaceLikeOperand,
759
+ blade: Operand,
760
+ options?: GeometryBooleanOptions,
761
+ ): GeometryBooleanResult {
762
+ if (blade.kind === "curve") {
763
+ if (!blade.closed) {
764
+ throw new Error(
765
+ "geometrySplit: an OPEN curve cannot cut a solid or sheet — only a "
766
+ + "CLOSED planar curve acts as a through-cut blade; close the curve "
767
+ + "(or use a Polygon), or split with a sheet or solid blade instead",
768
+ );
769
+ }
770
+ // Closed planar blade curves become through-cut cutter solids
771
+ // (Mesh.split's curve mode). BREP hosts tessellate first (documented
772
+ // downgrade).
773
+ const hostMesh = operandMesh(a, resolveMeshTolerance(options, a, blade));
774
+ const split = hostMesh.split(blade.raw as SweepableCurve);
775
+ return meshResult([...split.outside, ...split.inside]);
776
+ }
777
+
778
+ const parametricOptions =
779
+ options?.tolerance !== undefined ? { tolerance: options.tolerance } : undefined;
780
+
781
+ // Parametric splits where an exact pipeline exists (only when at least one
782
+ // side arrived as exact geometry, mesh x mesh always splits in the mesh
783
+ // kernel).
784
+ if (shouldTryParametric(a, blade)) {
785
+ const brepA = tryOperandBrep(a);
786
+ const brepB = tryOperandBrep(blade);
787
+ if (brepA && brepB) {
788
+ try {
789
+ if (a.closed && !blade.closed) {
790
+ const halves = brepA.brep.splitBySheet(brepB.brep, parametricOptions);
791
+ // Validation gate for coerced-mesh hosts: a genuine cut of a closed
792
+ // solid must yield closed halves, else use the mesh path.
793
+ if (
794
+ brepA.coercedFromMesh
795
+ && [halves.negative, halves.positive].some(
796
+ (h) => h != null && h.faceCount > 0 && !h.isClosed(),
797
+ )
798
+ ) {
799
+ throw new Error("parametric split of coerced mesh host is not closed");
800
+ }
801
+ return brepResult([halves.negative, halves.positive]);
802
+ }
803
+ if (!a.closed && blade.closed) {
804
+ const sides = brepA.brep.splitSheetBySolid(brepB.brep, parametricOptions);
805
+ return brepResult([sides.outside, sides.inside]);
806
+ }
807
+ if (!a.closed && !blade.closed) {
808
+ const sides = brepA.brep.splitSheetBySheet(brepB.brep, parametricOptions);
809
+ return brepResult([sides.outside, sides.inside]);
810
+ }
811
+ // solid / solid has no parametric split pipeline, use the mesh path.
812
+ } catch {
813
+ // Documented downgrade: fall through to the tessellated mesh path.
814
+ }
815
+ }
816
+ }
817
+
818
+ // Tessellated fallback: the tolerance is resolved LAZILY here so the
819
+ // parametric success paths above never pay the bounds probe.
820
+ const tolerance = resolveMeshTolerance(options, a, blade);
821
+ const hostMesh = operandMesh(a, tolerance);
822
+ const bladeMesh = operandMesh(blade, tolerance);
823
+ if (a.closed && !blade.closed) {
824
+ // Capped halves on both sides of the oriented sheet.
825
+ const split = hostMesh.splitSolidBySurface(bladeMesh);
826
+ return meshResult([...split.outside, ...split.inside]);
827
+ }
828
+ const split = hostMesh.split(bladeMesh);
829
+ return meshResult([...split.outside, ...split.inside]);
830
+ }
831
+
832
+ /**
833
+ * Split geometry A by blade B, returning ALL pieces of A (the blade is
834
+ * untouched). Dispatch mirrors {@link geometryBoolean}:
835
+ * - brep solid / sheet blades use the parametric splitBySheet /
836
+ * splitSheetBySolid / splitSheetBySheet pipelines (kind 'brep', both
837
+ * sides); failures fall back to the tessellated mesh path.
838
+ * - mesh hosts use Mesh.split / Mesh.splitSolidBySurface (kind 'mesh',
839
+ * outside pieces then inside pieces).
840
+ * - curve hosts split exactly: clipByBrep (closed BREP blade), exact
841
+ * parameters from trim-checked BREP hits (sheet BREP blade),
842
+ * splitPolyline or exact splitAtParams (mesh blade). For a closed curve
843
+ * host split by a closed coplanar curve blade → 2D REGION split (all region
844
+ * boundaries, Rhino "region split"); an open host splits at
845
+ * intersectCurveParams hits (curve blade). Kind 'curves'.
846
+ * - a closed planar curve blade cuts mesh and BREP hosts through
847
+ * Mesh.split's curve mode (through-cut cutter); an OPEN curve blade
848
+ * against a solid/sheet host throws a typed error (an open curve cannot
849
+ * bound a through-cut).
850
+ */
851
+ export function geometrySplit(
852
+ a: BooleanOperand,
853
+ b: BooleanOperand,
854
+ options?: GeometryBooleanOptions,
855
+ ): GeometryBooleanResult {
856
+ const oa = classifyOperand(a, "A");
857
+ const ob = classifyOperand(b, "B");
858
+
859
+ if (oa.kind === "curve") {
860
+ return splitCurveByOperand(oa, ob);
861
+ }
862
+ return splitSurfaceByOperand(oa, ob, options);
863
+ }