okgeometry-api 1.20.0 → 1.22.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
@@ -230,6 +230,92 @@ function shouldFallbackFromWorkerFailure(error: unknown): boolean {
230
230
  return code === "worker_unavailable" || code === "worker_crashed" || code === "worker_protocol";
231
231
  }
232
232
 
233
+ /**
234
+ * Full mesh health report. Counts are on proximity-welded topology, so
235
+ * coincident-but-distinct seam vertices (geometrically watertight) are not
236
+ * mis-reported as open. `isWatertight` = closed 2-manifold with no degenerate
237
+ * or duplicate faces.
238
+ */
239
+ export interface MeshDiagnostics {
240
+ vertexCount: number;
241
+ triangleCount: number;
242
+ weldedVertexCount: number;
243
+ boundaryEdges: number;
244
+ nonManifoldEdges: number;
245
+ boundaryLoops: number;
246
+ degenerateTriangles: number;
247
+ duplicateTriangles: number;
248
+ unreferencedVertices: number;
249
+ connectedComponents: number;
250
+ isClosed: boolean;
251
+ isManifold: boolean;
252
+ signedVolume: number;
253
+ isWatertight: boolean;
254
+ }
255
+
256
+ /** Which geometry-preserving repair steps to run. */
257
+ export interface MeshRepairOptions {
258
+ weld?: boolean;
259
+ removeDegenerate?: boolean;
260
+ removeDuplicate?: boolean;
261
+ fillHoles?: boolean;
262
+ fixWinding?: boolean;
263
+ removeUnreferenced?: boolean;
264
+ /** `<= 0` = auto from bbox diagonal. */
265
+ weldTolerance?: number;
266
+ /** Skip holes whose boundary loop exceeds this many edges (`0` = fill all). */
267
+ maxHoleEdges?: number;
268
+ }
269
+
270
+ /** Result of {@link Mesh.repair}. */
271
+ export interface MeshRepairResult {
272
+ mesh: Mesh;
273
+ before: MeshDiagnostics;
274
+ after: MeshDiagnostics;
275
+ weldedVertices: number;
276
+ removedTriangles: number;
277
+ filledHoles: number;
278
+ }
279
+
280
+ /** Result of {@link Mesh.makeWatertight}. */
281
+ export interface MakeWatertightResult {
282
+ mesh: Mesh;
283
+ before: MeshDiagnostics;
284
+ after: MeshDiagnostics;
285
+ /** `'repair'` = closed by geometry-preserving repair; `'remesh'` = voxel remesh fallback. */
286
+ method: "repair" | "remesh";
287
+ /** Effective voxel resolution when `method === 'remesh'` (0 otherwise). */
288
+ effectiveResolution: number;
289
+ }
290
+
291
+ /** Parse the fixed 13-value diagnostic block at `offset` in a WASM result. */
292
+ function parseMeshDiagnostics(r: ArrayLike<number>, offset: number): MeshDiagnostics {
293
+ const boundaryEdges = Math.round(r[offset + 3] ?? 0);
294
+ const nonManifoldEdges = Math.round(r[offset + 4] ?? 0);
295
+ const degenerateTriangles = Math.round(r[offset + 6] ?? 0);
296
+ const duplicateTriangles = Math.round(r[offset + 7] ?? 0);
297
+ const isClosed = (r[offset + 10] ?? 0) > 0.5;
298
+ return {
299
+ vertexCount: Math.round(r[offset] ?? 0),
300
+ triangleCount: Math.round(r[offset + 1] ?? 0),
301
+ weldedVertexCount: Math.round(r[offset + 2] ?? 0),
302
+ boundaryEdges,
303
+ nonManifoldEdges,
304
+ boundaryLoops: Math.round(r[offset + 5] ?? 0),
305
+ degenerateTriangles,
306
+ duplicateTriangles,
307
+ unreferencedVertices: Math.round(r[offset + 8] ?? 0),
308
+ connectedComponents: Math.round(r[offset + 9] ?? 0),
309
+ isClosed,
310
+ isManifold: (r[offset + 11] ?? 0) > 0.5,
311
+ signedVolume: r[offset + 12] ?? 0,
312
+ isWatertight:
313
+ isClosed && nonManifoldEdges === 0 && degenerateTriangles === 0 && duplicateTriangles === 0,
314
+ };
315
+ }
316
+
317
+ const MESH_DIAG_LEN = 13;
318
+
233
319
  /**
234
320
  * Buffer-backed triangle mesh with GPU-ready accessors.
235
321
  * All geometry lives in a Float64Array from WASM.
@@ -3702,6 +3788,151 @@ export class Mesh {
3702
3788
  };
3703
3789
  }
3704
3790
 
3791
+ /**
3792
+ * Full mesh health report (open/closed, boundary & non-manifold edge counts,
3793
+ * boundary loops, degenerate / duplicate faces, orphan vertices, signed
3794
+ * volume, watertight verdict). Computed on proximity-welded topology.
3795
+ */
3796
+ diagnostics(): MeshDiagnostics {
3797
+ ensureInit();
3798
+ const r = wasm.mesh_repair_diagnostics(this._vertexCount, this._buffer);
3799
+ return parseMeshDiagnostics(r, 0);
3800
+ }
3801
+
3802
+ /**
3803
+ * Flat 3D endpoints of every open boundary edge, six values per segment
3804
+ * (`[x0,y0,z0, x1,y1,z1, ...]`). Empty when the mesh is closed. Drives the
3805
+ * red "open edge" highlight.
3806
+ */
3807
+ boundaryEdgeSegments(): Float64Array {
3808
+ ensureInit();
3809
+ return wasm.mesh_boundary_edge_segments(this._vertexCount, this._buffer);
3810
+ }
3811
+
3812
+ /**
3813
+ * Chamfer feature edges of this closed solid. Each entry in `edges` is one
3814
+ * picked segment `{ a, b }` on a feature edge; the kernel expands it to the
3815
+ * full chain separating the same two smooth face groups (a straight box
3816
+ * edge, or a whole tessellated rim loop from a single pick). `distance` is
3817
+ * the setback along each adjacent face, Rhino-style. Convex edges cut a
3818
+ * flat; concave edges fill the notch. The result is watertight.
3819
+ *
3820
+ * Throws for open meshes, smooth (non-feature) picks, or when the distance
3821
+ * exceeds what the adjacent faces allow.
3822
+ */
3823
+ chamferEdges(
3824
+ edges: Array<{ a: Point | Vec3; b: Point | Vec3 }>,
3825
+ distance: number,
3826
+ ): Mesh {
3827
+ ensureInit();
3828
+ if (edges.length === 0) {
3829
+ throw new Error("Mesh.chamferEdges() requires at least one edge");
3830
+ }
3831
+ const edgeBuf = new Float64Array(edges.length * 6);
3832
+ edges.forEach((edge, i) => {
3833
+ edgeBuf[i * 6] = edge.a.x;
3834
+ edgeBuf[i * 6 + 1] = edge.a.y;
3835
+ edgeBuf[i * 6 + 2] = edge.a.z;
3836
+ edgeBuf[i * 6 + 3] = edge.b.x;
3837
+ edgeBuf[i * 6 + 4] = edge.b.y;
3838
+ edgeBuf[i * 6 + 5] = edge.b.z;
3839
+ });
3840
+ const r = wasm.mesh_chamfer_edges(this._vertexCount, this._buffer, edgeBuf, distance);
3841
+ if (!r.length) {
3842
+ throw new Error(
3843
+ `Mesh.chamferEdges() failed: ${wasm.geometry_last_error() || "unknown error"}`,
3844
+ );
3845
+ }
3846
+ return Mesh.fromTrustedBuffer(r as Float64Array);
3847
+ }
3848
+
3849
+ /**
3850
+ * Geometry-preserving repair: weld coincident vertices, drop degenerate /
3851
+ * duplicate triangles, remove orphan vertices, make winding consistent +
3852
+ * outward, and fill holes by triangulating their boundary loops. Returns the
3853
+ * repaired mesh plus before/after diagnostics.
3854
+ */
3855
+ repair(options: MeshRepairOptions = {}): MeshRepairResult {
3856
+ ensureInit();
3857
+ const bit = (on: boolean | undefined, dflt: boolean, mask: number) =>
3858
+ (on ?? dflt) ? mask : 0;
3859
+ const flags =
3860
+ bit(options.weld, true, 1) |
3861
+ bit(options.removeDegenerate, true, 2) |
3862
+ bit(options.removeDuplicate, true, 4) |
3863
+ bit(options.fillHoles, true, 8) |
3864
+ bit(options.fixWinding, true, 16) |
3865
+ bit(options.removeUnreferenced, true, 32);
3866
+ const r = wasm.mesh_repair(
3867
+ this._vertexCount,
3868
+ this._buffer,
3869
+ flags,
3870
+ options.weldTolerance ?? 0,
3871
+ options.maxHoleEdges ?? 0,
3872
+ );
3873
+ const before = parseMeshDiagnostics(r, 0);
3874
+ const after = parseMeshDiagnostics(r, MESH_DIAG_LEN);
3875
+ const base = MESH_DIAG_LEN * 2;
3876
+ const weldedVertices = Math.round(r[base] ?? 0);
3877
+ const removedTriangles = Math.round(r[base + 1] ?? 0);
3878
+ const filledHoles = Math.round(r[base + 2] ?? 0);
3879
+ const meshBuffer = (r as Float64Array).slice(base + 3);
3880
+ return {
3881
+ mesh: Mesh.fromTrustedBuffer(meshBuffer),
3882
+ before,
3883
+ after,
3884
+ weldedVertices,
3885
+ removedTriangles,
3886
+ filledHoles,
3887
+ };
3888
+ }
3889
+
3890
+ /**
3891
+ * Rebuild as a guaranteed-watertight solid via generalized-winding-number
3892
+ * voxel remeshing. `resolution` = voxels along the longest axis (`0` = 64
3893
+ * default, auto-capped by a work budget); `iso` = winding threshold
3894
+ * (`<=0`/`>=1` → 0.5). Always closes arbitrary open / non-manifold /
3895
+ * self-intersecting soup, at the cost of fine detail.
3896
+ */
3897
+ voxelRemesh(resolution = 0, iso = 0.5): Mesh {
3898
+ ensureInit();
3899
+ const r = wasm.mesh_voxel_remesh(
3900
+ this._vertexCount,
3901
+ this._buffer,
3902
+ Math.max(0, Math.floor(resolution)),
3903
+ iso,
3904
+ );
3905
+ return Mesh.fromTrustedBuffer(r as Float64Array);
3906
+ }
3907
+
3908
+ /**
3909
+ * Layered "make watertight": first geometry-preserving {@link repair}; if
3910
+ * that closes the mesh it is returned (`method: 'repair'`). Otherwise fall
3911
+ * back to {@link voxelRemesh} (`method: 'remesh'`). Always returns a solid.
3912
+ */
3913
+ makeWatertight(options: { resolution?: number; iso?: number } = {}): MakeWatertightResult {
3914
+ ensureInit();
3915
+ const r = wasm.mesh_make_watertight(
3916
+ this._vertexCount,
3917
+ this._buffer,
3918
+ Math.max(0, Math.floor(options.resolution ?? 0)),
3919
+ options.iso ?? 0.5,
3920
+ );
3921
+ const before = parseMeshDiagnostics(r, 0);
3922
+ const after = parseMeshDiagnostics(r, MESH_DIAG_LEN);
3923
+ const base = MESH_DIAG_LEN * 2;
3924
+ const method = (r[base] ?? 0) < 0.5 ? "repair" : "remesh";
3925
+ const effectiveResolution = Math.round(r[base + 1] ?? 0);
3926
+ const meshBuffer = (r as Float64Array).slice(base + 2);
3927
+ return {
3928
+ mesh: Mesh.fromTrustedBuffer(meshBuffer),
3929
+ before,
3930
+ after,
3931
+ method,
3932
+ effectiveResolution,
3933
+ };
3934
+ }
3935
+
3705
3936
  /**
3706
3937
  * Get a logical planar face by its planar-face index.
3707
3938
  */
package/src/NurbsCurve.ts CHANGED
@@ -394,32 +394,9 @@ export class NurbsCurve {
394
394
  );
395
395
  }
396
396
 
397
- /**
398
- * Offset this curve by a distance.
399
- * Note: NURBS offset is approximated by sampling and offsetting as polyline.
400
- * @param distance - Offset distance
401
- * @param normal - Reference normal for determining offset direction
402
- * @param samples - Number of sample points (default 64)
403
- * @param arcSamples - Number of samples per exact arc segment in the returned polyline(s)
404
- * @param options - Offset options such as join style
405
- * @returns Offset polyline approximation
406
- */
407
- offset(
408
- distance: number,
409
- normal?: Vec3,
410
- samples = 64,
411
- arcSamples = 32,
412
- options: CurveOffsetOptions = {},
413
- ): Polyline {
414
- const results = this.offsetAll(distance, normal, samples, arcSamples, options);
415
- if (results.length === 1) return results[0];
416
- if (results.length === 0) {
417
- throw new Error("NurbsCurve.offset() collapsed and produced no valid result polylines");
418
- }
419
- throw new Error(
420
- `NurbsCurve.offset() produced ${results.length} result polylines. Use NurbsCurve.offsetAll() instead.`,
421
- );
422
- }
397
+ // NOTE: `offset()` (exact NURBS offset, kernel-refit to tolerance) lives
398
+ // further down next to `data`. The former sampled-polyline `offset()` was
399
+ // replaced by it; the sampled multi-result path remains as `offsetAll()`.
423
400
 
424
401
  /**
425
402
  * Offset this curve by a distance and return every sampled result polyline.
@@ -566,6 +543,38 @@ export class NurbsCurve {
566
543
  return NurbsCurve.fromData(buf);
567
544
  }
568
545
 
546
+ /**
547
+ * Offset this planar curve by `distance` within the plane with the given
548
+ * `normal`. Positive distance offsets toward `normal × tangent` (the same
549
+ * convention as Polyline/PolyCurve offsets). The result is refit to
550
+ * `tolerance` (default 1e-6) against the exact offset locus; rational
551
+ * curves (exact arcs) offset exactly. Closed curves stay closed.
552
+ *
553
+ * Throws for non-planar or kinked curves, or when the offset cannot reach
554
+ * tolerance (for example offsetting inward past the curvature radius).
555
+ */
556
+ offset(
557
+ distance: number,
558
+ normal: Vec3 = Vec3.Z,
559
+ options: { tolerance?: number } = {},
560
+ ): NurbsCurve {
561
+ ensureInit();
562
+ const buf = wasm.nurbs_curve_offset(
563
+ this.data,
564
+ distance,
565
+ normal.x,
566
+ normal.y,
567
+ normal.z,
568
+ options.tolerance ?? 1e-6,
569
+ );
570
+ if (buf.length < 2) {
571
+ throw new Error(
572
+ `NurbsCurve.offset() failed: ${wasm.geometry_last_error() || "unknown error"}`,
573
+ );
574
+ }
575
+ return NurbsCurve.fromData(buf);
576
+ }
577
+
569
578
  /**
570
579
  * Get internal WASM data buffer.
571
580
  * For advanced use cases requiring direct WASM interop.
@@ -312,6 +312,26 @@ export class NurbsSurface implements Surface {
312
312
  return this._data;
313
313
  }
314
314
 
315
+ /**
316
+ * Offset this surface by `distance` along its oriented normal field,
317
+ * refit to `tolerance` (default 1e-4) against the exact offset. Rational
318
+ * surfaces (cylinders, spheres, revolves) offset exactly; closed seams
319
+ * stay closed and degenerate poles stay degenerate.
320
+ *
321
+ * Throws for creased surfaces or when the offset cannot reach tolerance
322
+ * (for example offsetting inward past the curvature radius).
323
+ */
324
+ offset(distance: number, options: { tolerance?: number } = {}): NurbsSurface {
325
+ ensureInit();
326
+ const buf = wasm.nurbs_surface_offset(this.data, distance, options.tolerance ?? 1e-4);
327
+ if (buf.length < 4) {
328
+ throw new Error(
329
+ `NurbsSurface.offset() failed: ${wasm.geometry_last_error() || "unknown error"}`,
330
+ );
331
+ }
332
+ return NurbsSurface.fromData(buf);
333
+ }
334
+
315
335
  // ── Analytic queries ──
316
336
 
317
337
  /**
package/src/Polyline.ts CHANGED
@@ -240,6 +240,51 @@ export class Polyline {
240
240
  return PolyCurve.fromSegmentData(buf);
241
241
  }
242
242
 
243
+ /**
244
+ * Chamfer corners of this polyline with straight cuts of the given
245
+ * setback distance (clamped per corner to half the shorter adjacent
246
+ * segment). Closed polylines (duplicated end point) chamfer every corner
247
+ * including the seam and stay closed; open polylines never touch their
248
+ * endpoints. `options.corners` selects vertex indices to chamfer
249
+ * (indices into the unique point list, seam duplicate excluded).
250
+ */
251
+ chamfer(distance: number, options: { corners?: number[] } = {}): Polyline {
252
+ ensureInit();
253
+ const pts = this.points;
254
+ if (pts.length < 3) {
255
+ throw new Error("Polyline.chamfer() requires at least 3 points");
256
+ }
257
+ const first = pts[0];
258
+ const last = pts[pts.length - 1];
259
+ const closed =
260
+ pts.length >= 4 &&
261
+ Math.abs(first.x - last.x) < 1e-10 &&
262
+ Math.abs(first.y - last.y) < 1e-10 &&
263
+ Math.abs(first.z - last.z) < 1e-10;
264
+ const unique = closed ? pts.slice(0, -1) : pts;
265
+ const coords = new Float64Array(unique.length * 3);
266
+ unique.forEach((p, i) => {
267
+ coords[i * 3] = p.x;
268
+ coords[i * 3 + 1] = p.y;
269
+ coords[i * 3 + 2] = p.z;
270
+ });
271
+ const cornerBuf = new Float64Array(options.corners ?? []);
272
+ const buf = wasm.chamfer_polyline_corners(coords, closed, distance, cornerBuf);
273
+ if (buf.length < 6) {
274
+ throw new Error(
275
+ `Polyline.chamfer() failed: ${wasm.geometry_last_error() || "unknown error"}`,
276
+ );
277
+ }
278
+ const out: Point[] = [];
279
+ for (let i = 0; i + 2 < buf.length; i += 3) {
280
+ out.push(new Point(buf[i], buf[i + 1], buf[i + 2]));
281
+ }
282
+ if (closed && out.length > 0) {
283
+ out.push(out[0]);
284
+ }
285
+ return new Polyline(out);
286
+ }
287
+
243
288
  /**
244
289
  * Convert this polyline to a PolyCurve (line segments only).
245
290
  * @returns PolyCurve with Line segments connecting consecutive points
package/src/index.ts CHANGED
@@ -90,6 +90,10 @@ export type {
90
90
  MeshBooleanErrorCode,
91
91
  MeshBooleanErrorPayload,
92
92
  MeshBooleanProgressEvent,
93
+ MeshDiagnostics,
94
+ MeshRepairOptions,
95
+ MeshRepairResult,
96
+ MakeWatertightResult,
93
97
  } from "./Mesh.js";
94
98
  export { intersect } from "./Geometry.js";
95
99