okgeometry-api 1.11.1 → 1.15.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/Brep.ts CHANGED
@@ -26,6 +26,30 @@ export interface BrepParametricBooleanOptions {
26
26
  tolerance?: number;
27
27
  }
28
28
 
29
+ /** Options for mesh → BREP conversion ({@link Brep.fromMesh}). */
30
+ export interface MeshToBrepConversionOptions {
31
+ /**
32
+ * Max angle (degrees) between triangle normals for facets to group into one
33
+ * planar face. Default ≈0.057° — far below any real dihedral in tessellated
34
+ * curved regions, far above float noise on genuinely planar faces.
35
+ */
36
+ normalAngleToleranceDeg?: number;
37
+ /**
38
+ * Face-count guard: conversion fails when planar-face recognition would
39
+ * produce more faces than this (densely tessellated curved meshes do not
40
+ * belong in a planar-face BREP). Default 4096; 0 keeps the default.
41
+ */
42
+ maxFaces?: number;
43
+ }
44
+
45
+ /** Both halves of a solid ÷ sheet split ({@link Brep.splitBySheet}). */
46
+ export interface BrepSplitSolidResult {
47
+ /** The half BEHIND the sheet normal, capped by the exact trimmed sheet (null when empty). */
48
+ negative: Brep | null;
49
+ /** The half the sheet normal points into (null when empty). */
50
+ positive: Brep | null;
51
+ }
52
+
29
53
  /** Validation report for a BREP body. */
30
54
  export interface BrepValidationReport {
31
55
  isClosed: boolean;
@@ -236,6 +260,11 @@ export class Brep {
236
260
  /**
237
261
  * Exact backing surface of a face: a NurbsSurface for curved faces, a Plane
238
262
  * for planar faces.
263
+ *
264
+ * NOTE: this is the UNTRIMMED underlying surface — for the face of a
265
+ * boolean/split result it spans the whole original input surface. Use
266
+ * {@link extractFace} for the actual trimmed face, or
267
+ * {@link faceSurfaceCropped} for the surface cropped to the face's extent.
239
268
  */
240
269
  faceSurface(faceIndex: number): NurbsSurface | Plane {
241
270
  ensureInit();
@@ -252,6 +281,88 @@ export class Brep {
252
281
  return NurbsSurface.fromData(buf.slice(1));
253
282
  }
254
283
 
284
+ /**
285
+ * Extract one face as a standalone single-face TRIMMED sheet Brep: trim
286
+ * loops, pcurves, edges and vertices are preserved exactly — this is the
287
+ * actual cropped face, not the untrimmed backing surface. The result
288
+ * tessellates, transforms and participates in booleans like any sheet Brep.
289
+ */
290
+ extractFace(faceIndex: number): Brep {
291
+ ensureInit();
292
+ const json = wasm.brep_extract_face(this.json, faceIndex);
293
+ if (!json) throw new Error(`Brep.extractFace(${faceIndex}) failed`);
294
+ return new Brep(json);
295
+ }
296
+
297
+ /**
298
+ * ALL faces as standalone single-face trimmed sheet Breps, in ONE WASM
299
+ * crossing (the body is parsed once kernel-side — much faster than calling
300
+ * {@link extractFace} per face). Faces that fail to extract (corrupt
301
+ * topology) are skipped.
302
+ */
303
+ faces(): Brep[] {
304
+ ensureInit();
305
+ const arrayJson = wasm.brep_extract_faces(this.json);
306
+ if (!arrayJson) throw new Error("Brep.faces failed");
307
+ const parsed = JSON.parse(arrayJson) as unknown[];
308
+ const out: Brep[] = [];
309
+ for (const item of parsed) {
310
+ if (item != null) out.push(new Brep(JSON.stringify(item)));
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /**
316
+ * The face's underlying surface CROPPED to its trim extent, ALWAYS as a
317
+ * NurbsSurface: curved faces get an exact sub-patch of the backing surface
318
+ * over the trim's UV bounding box; planar faces become exact degree-1
319
+ * patches spanning the face (never a raw infinite Plane). Non-rectangular
320
+ * trim regions stay rectangular-cropped here — use {@link extractFace} when
321
+ * the exact trim boundary matters.
322
+ */
323
+ faceSurfaceCropped(faceIndex: number): NurbsSurface {
324
+ ensureInit();
325
+ const buf = wasm.brep_face_surface_cropped(this.json, faceIndex);
326
+ if (buf.length < 4) throw new Error(`Brep.faceSurfaceCropped(${faceIndex}) failed`);
327
+ return NurbsSurface.fromData(buf);
328
+ }
329
+
330
+ /**
331
+ * Batch variant of {@link faceSurfaceCropped} for all faces in ONE WASM
332
+ * crossing. The result is aligned with face indices; a face whose trim
333
+ * region is degenerate yields null.
334
+ */
335
+ faceSurfacesCropped(): (NurbsSurface | null)[] {
336
+ ensureInit();
337
+ const buf = wasm.brep_face_surfaces_cropped(this.json);
338
+ if (buf.length < 1) throw new Error("Brep.faceSurfacesCropped failed");
339
+ const count = Number(buf[0]);
340
+ const out: (NurbsSurface | null)[] = [];
341
+ let offset = 1;
342
+ for (let i = 0; i < count && offset < buf.length; i++) {
343
+ const len = Number(buf[offset]);
344
+ offset += 1;
345
+ if (len < 4) {
346
+ out.push(null);
347
+ continue;
348
+ }
349
+ out.push(NurbsSurface.fromData(buf.subarray(offset, offset + len)));
350
+ offset += len;
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * UV bounding box of a face's trim loops in the surface's parameter space
357
+ * (knot domain for NURBS faces, frame coordinates for planar faces).
358
+ */
359
+ faceTrimBounds(faceIndex: number): { u0: number; u1: number; v0: number; v1: number } {
360
+ ensureInit();
361
+ const buf = wasm.brep_face_trim_bounds(this.json, faceIndex);
362
+ if (buf.length < 4) throw new Error(`Brep.faceTrimBounds(${faceIndex}) failed`);
363
+ return { u0: buf[0], u1: buf[1], v0: buf[2], v1: buf[3] };
364
+ }
365
+
255
366
  /**
256
367
  * Exact iso-parametric curve of a curved face's backing surface.
257
368
  * Returns null for planar faces.
@@ -265,6 +376,115 @@ export class Brep {
265
376
  return NurbsCurve.fromData(buf);
266
377
  }
267
378
 
379
+ /**
380
+ * Tessellate with per-face triangle ranges for face-level picking.
381
+ * `tolerance = 0` selects the deterministic auto display tolerance (derived
382
+ * from the BREP's own extent), so triangle indices stay valid across calls
383
+ * that also use the auto tolerance.
384
+ */
385
+ tessellateWithFaces(tolerance = 0): {
386
+ mesh: Mesh;
387
+ /** Flat (faceIndex, firstTriangle, triangleCount) triples. */
388
+ faceRanges: Uint32Array;
389
+ } {
390
+ ensureInit();
391
+ const buf = wasm.brep_tessellate_with_face_ranges(this.json, tolerance);
392
+ if (buf.length < 3) throw new Error("Brep.tessellateWithFaces failed");
393
+ const vc = Number(buf[0]);
394
+ const ic = Number(buf[1]);
395
+ const rc = Number(buf[2]);
396
+ const meshBuf = new Float64Array(1 + vc * 3 + ic);
397
+ meshBuf[0] = vc;
398
+ meshBuf.set(buf.subarray(3, 3 + vc * 3 + ic), 1);
399
+ const ranges = new Uint32Array(rc * 3);
400
+ const base = 3 + vc * 3 + ic;
401
+ for (let i = 0; i < rc * 3; i++) ranges[i] = Number(buf[base + i]);
402
+ return { mesh: Mesh.fromBuffer(meshBuf), faceRanges: ranges };
403
+ }
404
+
405
+ /**
406
+ * Closest face to a 3D point, with the exact surface (u, v) in the KNOT
407
+ * domain. Returns null when the BREP is empty.
408
+ */
409
+ closestFace(point: Point): {
410
+ faceIndex: number;
411
+ u: number;
412
+ v: number;
413
+ distance: number;
414
+ isNurbs: boolean;
415
+ } | null {
416
+ ensureInit();
417
+ const buf = wasm.brep_closest_face(this.json, point.x, point.y, point.z);
418
+ if (buf.length < 5) return null;
419
+ return {
420
+ faceIndex: Number(buf[0]),
421
+ u: buf[1],
422
+ v: buf[2],
423
+ distance: buf[3],
424
+ isNurbs: buf[4] > 0.5,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Exact (u, v) of a point on a SPECIFIC face (closest-point Newton for
430
+ * NURBS, frame projection for planes).
431
+ */
432
+ faceClosestUv(
433
+ faceIndex: number,
434
+ point: Point,
435
+ ): { u: number; v: number; distance: number; isNurbs: boolean } | null {
436
+ ensureInit();
437
+ const buf = wasm.brep_face_closest_uv(this.json, faceIndex, point.x, point.y, point.z);
438
+ if (buf.length < 4) return null;
439
+ return { u: buf[0], v: buf[1], distance: buf[2], isNurbs: buf[3] > 0.5 };
440
+ }
441
+
442
+ /**
443
+ * Exact isocurve(s) of a face at KNOT-domain parameter `t`, CLIPPED to the
444
+ * face's trimmed region (unlike {@link faceIsoCurve}, which spans the full
445
+ * backing surface). Planar faces yield straight (degree-1) pieces across
446
+ * the region; curved faces yield exact rational sub-curves. Multiple pieces
447
+ * come back when the region is non-convex or holed along the iso line.
448
+ * @param direction - "u" runs along u at constant v = t; "v" the converse
449
+ */
450
+ faceIsoCurvesTrimmed(faceIndex: number, t: number, direction: "u" | "v"): NurbsCurve[] {
451
+ ensureInit();
452
+ return Brep.parseCurveList(
453
+ wasm.brep_face_iso_curves_trimmed(this.json, faceIndex, t, direction === "u"),
454
+ );
455
+ }
456
+
457
+ /**
458
+ * Like {@link faceIsoCurvesTrimmed}, but `t` is a NORMALIZED [0, 1]
459
+ * parameter mapped across the face's trim extent in the fixed direction —
460
+ * no knot-domain knowledge needed. t = 0.5 is the middle of the face.
461
+ */
462
+ faceIsoCurvesTrimmedNormalized(
463
+ faceIndex: number,
464
+ t: number,
465
+ direction: "u" | "v",
466
+ ): NurbsCurve[] {
467
+ ensureInit();
468
+ return Brep.parseCurveList(
469
+ wasm.brep_face_iso_curves_trimmed_normalized(this.json, faceIndex, t, direction === "u"),
470
+ );
471
+ }
472
+
473
+ /** Parse [count, (bufLen, curveData…)·count] into NurbsCurves. */
474
+ private static parseCurveList(buf: Float64Array): NurbsCurve[] {
475
+ if (buf.length < 1) return [];
476
+ const out: NurbsCurve[] = [];
477
+ let offset = 1;
478
+ const count = Number(buf[0]);
479
+ for (let k = 0; k < count && offset < buf.length; k++) {
480
+ const len = Number(buf[offset]);
481
+ offset += 1;
482
+ out.push(NurbsCurve.fromData(buf.subarray(offset, offset + len)));
483
+ offset += len;
484
+ }
485
+ return out;
486
+ }
487
+
268
488
  // ── Transforms ──
269
489
 
270
490
  /** Apply a row-major 4x4 matrix to all geometry (exact). */
@@ -384,6 +604,155 @@ export class Brep {
384
604
  return lhs.intersectMesh(rhs);
385
605
  }
386
606
 
607
+ // ── Exact intersections (parametric) ──
608
+
609
+ /**
610
+ * TRIM-EXACT intersection curves with another Brep or a NurbsSurface,
611
+ * returned as exact fitted NURBS curves.
612
+ *
613
+ * Unlike {@link intersectCurves} (tessellation-based, returns polylines at
614
+ * the tessellation density), this runs the parametric boolean's SSI →
615
+ * clip → convergence stages on the EXACT surfaces and clips each
616
+ * intersection curve to BOTH faces' trimmed regions — no classification or
617
+ * assembly, just the fitted intersection edges.
618
+ *
619
+ * Operands need NOT be closed: sheet Breps are valid on either side, and a
620
+ * NurbsSurface operand is converted via {@link Brep.fromSurface}. A closed
621
+ * intersection loop may come back as several contiguous pieces (split at
622
+ * surface seams and face-boundary crossings); the pieces chain end-to-end
623
+ * exactly. Coincident (overlapping) face pairs contribute no curves.
624
+ *
625
+ * `options.tolerance` is the absolute geometric tolerance of the fitted
626
+ * intersection curves (default 1e-6).
627
+ */
628
+ intersectionCurves(other: Brep | NurbsSurface, options?: { tolerance?: number }): NurbsCurve[] {
629
+ ensureInit();
630
+ const rhs = other instanceof Brep ? other : Brep.fromSurface(other);
631
+ const tolerance = options?.tolerance ?? 1e-6;
632
+ const buf = wasm.brep_intersection_curves_op(this.json, rhs.json, tolerance);
633
+ // An EMPTY buffer signals a kernel error (invalid BREP / pipeline
634
+ // failure); "no intersections" is the non-empty `[0]` packing.
635
+ if (buf.length < 1) {
636
+ throw new Error(
637
+ "Brep.intersectionCurves failed — invalid BREP operands or kernel error",
638
+ );
639
+ }
640
+ return Brep.parseCurveList(buf);
641
+ }
642
+
643
+ /**
644
+ * All points where a NURBS curve pierces this BREP's faces.
645
+ *
646
+ * Per face, the curve is intersected with the face's EXACT surface
647
+ * (analytic + bisection for planes, full Newton for NURBS faces) and a hit
648
+ * is kept only when it lies INSIDE the face's trimmed region. Crossings on
649
+ * an edge shared by two faces are deduplicated (smallest face index kept).
650
+ *
651
+ * Hits are sorted by `curveParam` (normalized [0, 1] —
652
+ * `curve.pointAt(curveParam)` reproduces `point` to convergence accuracy);
653
+ * (u, v) are in the pierced face's OWN parameter space (knot domain for
654
+ * NURBS faces, plane-frame coordinates for planar faces). Curve segments
655
+ * lying ON a face (coincident runs) yield no hits.
656
+ */
657
+ intersectCurve(
658
+ curve: NurbsCurve,
659
+ ): { point: Point; curveParam: number; faceIndex: number; u: number; v: number }[] {
660
+ ensureInit();
661
+ const buf = wasm.brep_curve_intersection_op(this.json, curve.data);
662
+ if (buf.length < 1) return [];
663
+ const count = Number(buf[0]);
664
+ const hits: { point: Point; curveParam: number; faceIndex: number; u: number; v: number }[] =
665
+ [];
666
+ for (let i = 0; i < count; i++) {
667
+ const off = 1 + i * 7;
668
+ hits.push({
669
+ point: new Point(buf[off], buf[off + 1], buf[off + 2]),
670
+ curveParam: buf[off + 3],
671
+ faceIndex: Number(buf[off + 4]),
672
+ u: buf[off + 5],
673
+ v: buf[off + 6],
674
+ });
675
+ }
676
+ return hits;
677
+ }
678
+
679
+ /**
680
+ * Boolean-intersection clip of an exact NURBS curve against this CLOSED
681
+ * solid: the curve is split EXACTLY (knot insertion) at every boundary
682
+ * crossing and each piece is classified by containment, so every returned
683
+ * piece lies exactly on the input curve.
684
+ *
685
+ * Closed curves merge the run spanning the parameter seam (a closed curve
686
+ * that never crosses comes back as ONE closed piece on the containing
687
+ * side). Throws when this BREP is not a closed solid — sheet operands have
688
+ * no interior (use {@link intersectCurve} for the crossing points instead).
689
+ */
690
+ clipCurve(curve: NurbsCurve): { inside: NurbsCurve[]; outside: NurbsCurve[] } {
691
+ ensureInit();
692
+ if (!this.isClosed()) {
693
+ throw new Error(
694
+ "Brep.clipCurve requires a CLOSED solid BREP (sheet operands have no interior)",
695
+ );
696
+ }
697
+ const buf = wasm.brep_clip_curve_op(curve.data, this.json);
698
+ if (buf.length < 2) throw new Error("Brep.clipCurve failed");
699
+ // Packing: two concatenated multi-curve lists — inside then outside.
700
+ const insideCount = Number(buf[0]);
701
+ let offset = 1;
702
+ const readGroup = (count: number): NurbsCurve[] => {
703
+ const out: NurbsCurve[] = [];
704
+ for (let k = 0; k < count && offset < buf.length; k++) {
705
+ const len = Number(buf[offset]);
706
+ offset += 1;
707
+ out.push(NurbsCurve.fromData(buf.subarray(offset, offset + len)));
708
+ offset += len;
709
+ }
710
+ return out;
711
+ };
712
+ const inside = readGroup(insideCount);
713
+ const outsideCount = Number(buf[offset]);
714
+ offset += 1;
715
+ const outside = readGroup(outsideCount);
716
+ return { inside, outside };
717
+ }
718
+
719
+ /**
720
+ * Intersection curves with a triangle mesh.
721
+ *
722
+ * TESSELLATION-BASED (documented approximation): the BREP is tessellated at
723
+ * `tolerance` and intersected with the mesh via BVH-pruned tri-tri crossing
724
+ * + chaining, so the polylines approximate the true intersection to the
725
+ * chordal tessellation tolerance on the BREP side — exact where both sides
726
+ * are planar.
727
+ *
728
+ * @param mesh - Triangle mesh operand
729
+ * @param tolerance - BREP tessellation tolerance; `0` (default) selects the
730
+ * deterministic auto display tolerance from the BREP's own extent
731
+ */
732
+ intersectMesh(mesh: Mesh, tolerance = 0): Polyline[] {
733
+ ensureInit();
734
+ const buf = wasm.brep_mesh_intersection_curves_op(
735
+ this.json,
736
+ mesh.vertexCount,
737
+ mesh.rawBuffer,
738
+ tolerance,
739
+ );
740
+ if (buf.length < 1) return [];
741
+ const out: Polyline[] = [];
742
+ let idx = 1;
743
+ const count = Number(buf[0]);
744
+ for (let i = 0; i < count; i++) {
745
+ const n = Number(buf[idx++]);
746
+ const pts: Point[] = [];
747
+ for (let k = 0; k < n; k++) {
748
+ pts.push(new Point(buf[idx], buf[idx + 1], buf[idx + 2]));
749
+ idx += 3;
750
+ }
751
+ if (pts.length >= 2) out.push(new Polyline(pts));
752
+ }
753
+ return out;
754
+ }
755
+
387
756
  // ── Parametric booleans (BREP → BREP) ──
388
757
 
389
758
  private runParametricBoolean(
@@ -430,6 +799,95 @@ export class Brep {
430
799
  return this.runParametricBoolean(other, "intersect", options);
431
800
  }
432
801
 
802
+ // ── Mesh → BREP and solid ÷ sheet split ──
803
+
804
+ /**
805
+ * Convert a closed manifold triangle mesh into a planar-face BREP: coplanar
806
+ * connected facet groups become single trimmed planar faces (with hole
807
+ * loops), shared boundaries weld into exact line edges. A mesh box becomes
808
+ * the same 6-face / 12-edge / 8-vertex topology as {@link Brep.box}.
809
+ *
810
+ * The mesh's winding is authoritative (inside-out input is re-oriented);
811
+ * open or non-manifold meshes are rejected. Intended for planar-faceted
812
+ * solids (boxes, extrusions, mesh-boolean results) — densely tessellated
813
+ * curved meshes fail the `maxFaces` guard instead of producing a
814
+ * pathological BREP.
815
+ */
816
+ static fromMesh(mesh: Mesh, options?: MeshToBrepConversionOptions): Brep {
817
+ ensureInit();
818
+ const json = wasm.brep_from_mesh_buffer(
819
+ mesh.vertexCount,
820
+ mesh.rawBuffer,
821
+ options?.normalAngleToleranceDeg ?? 0,
822
+ options?.maxFaces ?? 0,
823
+ );
824
+ if (!json) throw new Error("Brep.fromMesh failed");
825
+ if (json.startsWith('{"error"')) {
826
+ const parsed = JSON.parse(json) as { error: string };
827
+ throw new Error(`Brep.fromMesh failed: ${parsed.error}`);
828
+ }
829
+ return new Brep(json);
830
+ }
831
+
832
+ /**
833
+ * PARAMETRIC split of this CLOSED solid by a sheet (an open Brep — e.g.
834
+ * {@link Brep.fromSurface} — or a NurbsSurface directly): BOTH halves are
835
+ * returned as closed BREPs, each keeping the solid's exact faces on its
836
+ * side plus the sheet region inside the solid as an exact trimmed-surface
837
+ * cap (opposite orientations on the two halves).
838
+ *
839
+ * Sides follow the sheet's orientation — `negative` is the half behind the
840
+ * sheet normal — matching `Mesh.splitSolidBySurface`. When the sheet does
841
+ * not fully cut through, the whole solid comes back on one side and the
842
+ * other is null.
843
+ */
844
+ splitBySheet(
845
+ sheet: Brep | NurbsSurface,
846
+ options?: BrepParametricBooleanOptions,
847
+ ): BrepSplitSolidResult {
848
+ ensureInit();
849
+ const cutter = sheet instanceof Brep ? sheet : Brep.fromSurface(sheet);
850
+ const tolerance = options?.tolerance ?? 1e-6;
851
+ const json = wasm.brep_split_solid_by_sheet_op(this.json, cutter.json, tolerance);
852
+ return Brep.parseSplitResult(json, "Brep.splitBySheet");
853
+ }
854
+
855
+ /**
856
+ * One-shot production path for "mesh − NURBS surface → BREP": converts the
857
+ * closed triangle mesh to a planar-face BREP, builds the sheet, and runs the
858
+ * parametric split — all in a single WASM crossing. Equivalent to
859
+ * `Brep.fromMesh(mesh, options).splitBySheet(surface, options)` but without
860
+ * intermediate JSON round-trips.
861
+ */
862
+ static splitMeshBySurface(
863
+ mesh: Mesh,
864
+ surface: NurbsSurface,
865
+ options?: BrepParametricBooleanOptions & MeshToBrepConversionOptions,
866
+ ): BrepSplitSolidResult {
867
+ ensureInit();
868
+ const json = wasm.mesh_split_by_surface_to_brep(
869
+ mesh.vertexCount,
870
+ mesh.rawBuffer,
871
+ surface.data,
872
+ options?.tolerance ?? 1e-6,
873
+ options?.normalAngleToleranceDeg ?? 0,
874
+ options?.maxFaces ?? 0,
875
+ );
876
+ return Brep.parseSplitResult(json, "Brep.splitMeshBySurface");
877
+ }
878
+
879
+ private static parseSplitResult(json: string, label: string): BrepSplitSolidResult {
880
+ if (!json) throw new Error(`${label} failed`);
881
+ if (json.startsWith('{"error"')) {
882
+ const parsed = JSON.parse(json) as { error: string };
883
+ throw new Error(`${label} failed: ${parsed.error}`);
884
+ }
885
+ const parsed = JSON.parse(json) as { negative: unknown; positive: unknown };
886
+ const side = (v: unknown): Brep | null =>
887
+ v == null ? null : new Brep(JSON.stringify(v));
888
+ return { negative: side(parsed.negative), positive: side(parsed.positive) };
889
+ }
890
+
433
891
  /** JSON representation (persistence). */
434
892
  toJson(): string {
435
893
  return this.json;