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/Mesh.ts CHANGED
@@ -1277,7 +1277,12 @@ export class Mesh {
1277
1277
 
1278
1278
  /**
1279
1279
  * Build a planar arc from center, start, and end points.
1280
- * Uses the shortest signed sweep between start and end around `normal`.
1280
+ * Without `sweepHint` the shortest signed sweep between start and end
1281
+ * around `normal` is used. With `sweepHint` (signed radians, typically the
1282
+ * sweep from the previous interactive update or a stored arc parameter),
1283
+ * the sweep candidate closest to the hint is chosen instead — this tracks
1284
+ * the user's winding direction continuously through 180° and up to just
1285
+ * under a full circle.
1281
1286
  */
1282
1287
  static buildPlanarArcCenterStartEnd(
1283
1288
  center: Point,
@@ -1285,6 +1290,7 @@ export class Mesh {
1285
1290
  endPoint: Point,
1286
1291
  normal: Vec3,
1287
1292
  segments = 64,
1293
+ sweepHint?: number,
1288
1294
  ): PlanarArc {
1289
1295
  const n = normal.normalize();
1290
1296
  const minSegments = Math.max(2, Math.floor(segments));
@@ -1311,7 +1317,8 @@ export class Mesh {
1311
1317
  const basis = Mesh.resolveArcBasis(center, radius, n);
1312
1318
  const startAngle = Mesh.angleOnBasis(startVec, basis.uAxis, basis.vAxis);
1313
1319
  const endAngleRaw = Mesh.angleOnBasis(endVec.scale(radius / endLen), basis.uAxis, basis.vAxis);
1314
- const sweepAngle = Mesh.normalizeSignedAngle(endAngleRaw - startAngle);
1320
+ const shortestSweep = Mesh.normalizeSignedAngle(endAngleRaw - startAngle);
1321
+ const sweepAngle = Mesh.resolveSweepWithHint(shortestSweep, sweepHint);
1315
1322
  if (Math.abs(sweepAngle) < 1e-9) {
1316
1323
  return {
1317
1324
  points: [],
@@ -1440,6 +1447,93 @@ export class Mesh {
1440
1447
  };
1441
1448
  }
1442
1449
 
1450
+ /**
1451
+ * Build a planar arc through three points: `startPoint`, a point the arc
1452
+ * passes through, and `endPoint`. All points are projected to the plane
1453
+ * through `startPoint` orthogonal to `normal`; the circle is the
1454
+ * circumcircle of the projected points and the sweep direction is the one
1455
+ * that passes through `throughPoint` (any sweep up to a full circle).
1456
+ * Returns null when the points are (near) collinear or coincident.
1457
+ */
1458
+ static buildPlanarArcThreePoint(
1459
+ startPoint: Point,
1460
+ throughPoint: Point,
1461
+ endPoint: Point,
1462
+ normal: Vec3,
1463
+ segments = 64,
1464
+ ): PlanarArc | null {
1465
+ const n = normal.normalize();
1466
+ const minSegments = Math.max(2, Math.floor(segments));
1467
+ const a = Mesh.projectPointToPlane(startPoint, startPoint, n);
1468
+ const b = Mesh.projectPointToPlane(throughPoint, startPoint, n);
1469
+ const c = Mesh.projectPointToPlane(endPoint, startPoint, n);
1470
+
1471
+ const ab = b.sub(a);
1472
+ const ac = c.sub(a);
1473
+ const scale = Math.max(ab.length(), ac.length());
1474
+ const cross = ab.cross(ac);
1475
+ const crossLen = cross.length();
1476
+ if (scale < 1e-12 || crossLen < 1e-9 * scale * scale) {
1477
+ return null;
1478
+ }
1479
+
1480
+ // Circumcenter: A + ((|ab|²·ac − |ac|²·ab) × (ab × ac)) / (2·|ab × ac|²)
1481
+ const numerator = ac.scale(ab.dot(ab)).sub(ab.scale(ac.dot(ac)));
1482
+ const center = a.add(numerator.cross(cross).scale(1 / (2 * cross.dot(cross))));
1483
+ const radius = a.sub(center).length();
1484
+ if (radius < 1e-12) {
1485
+ return null;
1486
+ }
1487
+
1488
+ const basis = Mesh.resolveArcBasis(center, radius, n);
1489
+ const twoPi = Math.PI * 2;
1490
+ const startAngle = Mesh.angleOnBasis(a.sub(center), basis.uAxis, basis.vAxis);
1491
+ const throughAngle = Mesh.angleOnBasis(b.sub(center), basis.uAxis, basis.vAxis);
1492
+ const endAngleRaw = Mesh.angleOnBasis(c.sub(center), basis.uAxis, basis.vAxis);
1493
+
1494
+ const ccwSweep = ((endAngleRaw - startAngle) % twoPi + twoPi) % twoPi;
1495
+ const ccwThrough = ((throughAngle - startAngle) % twoPi + twoPi) % twoPi;
1496
+ if (ccwSweep < 1e-9 || twoPi - ccwSweep < 1e-9) {
1497
+ return null;
1498
+ }
1499
+ const sweepAngle = ccwThrough <= ccwSweep ? ccwSweep : ccwSweep - twoPi;
1500
+ const endAngle = startAngle + sweepAngle;
1501
+ const arc = new Arc(center, radius, startAngle, endAngle, n);
1502
+ return {
1503
+ points: arc.sample(minSegments),
1504
+ center,
1505
+ radius,
1506
+ startAngle,
1507
+ endAngle,
1508
+ sweepAngle,
1509
+ };
1510
+ }
1511
+
1512
+ /** Largest supported arc sweep — just under a full circle keeps arcs open. */
1513
+ private static readonly MAX_ARC_SWEEP = Math.PI * 2 - 1e-4;
1514
+
1515
+ /**
1516
+ * Pick the sweep equivalent (shortest, +2π, or −2π) closest to `hint`,
1517
+ * clamped to just under a full circle. Without a hint the shortest sweep
1518
+ * is kept.
1519
+ */
1520
+ private static resolveSweepWithHint(shortestSweep: number, hint?: number): number {
1521
+ if (hint === undefined || !Number.isFinite(hint)) {
1522
+ return shortestSweep;
1523
+ }
1524
+ const twoPi = Math.PI * 2;
1525
+ let best = shortestSweep;
1526
+ let bestDistance = Math.abs(shortestSweep - hint);
1527
+ for (const candidate of [shortestSweep + twoPi, shortestSweep - twoPi]) {
1528
+ const distance = Math.abs(candidate - hint);
1529
+ if (distance < bestDistance) {
1530
+ best = candidate;
1531
+ bestDistance = distance;
1532
+ }
1533
+ }
1534
+ return Math.max(-Mesh.MAX_ARC_SWEEP, Math.min(Mesh.MAX_ARC_SWEEP, best));
1535
+ }
1536
+
1443
1537
  private static resolveArcBasis(
1444
1538
  center: Point,
1445
1539
  radius: number,
@@ -2901,6 +2995,179 @@ export class Mesh {
2901
2995
  return { inside, outside };
2902
2996
  }
2903
2997
 
2998
+ /**
2999
+ * Compute all transversal crossing points of a polyline with this mesh.
3000
+ * Works on OPEN meshes (sheets) — unlike `clipPolyline`, the mesh is NOT
3001
+ * required to be a closed volume.
3002
+ *
3003
+ * Hits are deduplicated (shared-edge / shared-vertex crossings merge into
3004
+ * one, keeping the smallest triangle index) and sorted by
3005
+ * (segmentIndex, segmentT). Coplanar sliding overlaps yield no hits.
3006
+ * Segment `i` runs from point `i` to point `(i + 1) % n`; open polylines
3007
+ * have n − 1 segments, closed ones n (the wrap segment).
3008
+ *
3009
+ * @param polyline - Polyline (closedness inferred) or raw point list
3010
+ * @param closed - Treat the points as a closed loop (defaults to the
3011
+ * polyline's own closedness; required when passing a raw point list)
3012
+ * @returns Crossings with the 3D point, segment index/parameter, and
3013
+ * triangle index
3014
+ */
3015
+ intersectPolyline(
3016
+ polyline: Polyline | Point[],
3017
+ closed?: boolean,
3018
+ ): { point: Point; segmentIndex: number; segmentT: number; triangleIndex: number }[] {
3019
+ ensureInit();
3020
+ const { flat, isClosed } = Mesh.flattenPolylineArg(polyline, closed, "Mesh.intersectPolyline");
3021
+ const buf = wasm.mesh_polyline_intersection_points(
3022
+ this._vertexCount,
3023
+ this._buffer,
3024
+ flat,
3025
+ isClosed,
3026
+ );
3027
+ if (buf.length === 0) {
3028
+ throw new Error(
3029
+ "Mesh.intersectPolyline failed — the mesh and polyline must be non-degenerate.",
3030
+ );
3031
+ }
3032
+ const count = buf[0];
3033
+ const hits: { point: Point; segmentIndex: number; segmentT: number; triangleIndex: number }[] = [];
3034
+ for (let i = 0; i < count; i++) {
3035
+ const off = 1 + i * 6;
3036
+ hits.push({
3037
+ point: new Point(buf[off], buf[off + 1], buf[off + 2]),
3038
+ segmentIndex: buf[off + 3],
3039
+ segmentT: buf[off + 4],
3040
+ triangleIndex: buf[off + 5],
3041
+ });
3042
+ }
3043
+ return hits;
3044
+ }
3045
+
3046
+ /**
3047
+ * Split a polyline into pieces at every crossing with this mesh (open
3048
+ * sheets allowed). Hit points become the shared endpoints of adjacent
3049
+ * pieces; a closed loop is opened at the first crossing (N crossings → N
3050
+ * pieces), an open polyline yields N + 1 pieces. With no crossings the
3051
+ * original polyline comes back as one piece. Pieces are NOT classified —
3052
+ * use `clipPolyline` (closed volume, containment) or
3053
+ * `clipPolylineBySheet` (oriented sheet, signed side) for classification.
3054
+ *
3055
+ * @param polyline - Polyline (closedness inferred) or raw point list
3056
+ * @param closed - Treat the points as a closed loop (defaults to the
3057
+ * polyline's own closedness; required when passing a raw point list)
3058
+ * @returns Polyline pieces in order along the input
3059
+ */
3060
+ splitPolyline(polyline: Polyline | Point[], closed?: boolean): Polyline[] {
3061
+ ensureInit();
3062
+ const { flat, isClosed } = Mesh.flattenPolylineArg(polyline, closed, "Mesh.splitPolyline");
3063
+ const buf = wasm.mesh_split_polyline(this._vertexCount, this._buffer, flat, isClosed);
3064
+ if (buf.length === 0) {
3065
+ throw new Error(
3066
+ "Mesh.splitPolyline failed — the mesh and polyline must be non-degenerate.",
3067
+ );
3068
+ }
3069
+ return parsePolylineBuf(buf).map(pts => new Polyline(pts));
3070
+ }
3071
+
3072
+ /**
3073
+ * Clip a polyline by this mesh treated as an OPEN oriented sheet: the
3074
+ * polyline is split at every crossing and each piece is classified by the
3075
+ * SIGNED SIDE of the sheet — `inside` = in FRONT of the sheet normal,
3076
+ * `outside` = behind it (the mesh's stored winding defines the normals).
3077
+ *
3078
+ * This is side classification, NOT containment — unlike `clipPolyline`,
3079
+ * which requires a CLOSED volume: the sheet may be open, and pieces far
3080
+ * from it still classify deterministically by the nearest facet's normal.
3081
+ * Pieces lying ON the sheet default to `inside`. Adjacent same-side pieces
3082
+ * (e.g. from a touch, not a crossing) merge back into one run; closed
3083
+ * loops merge the run spanning the seam.
3084
+ *
3085
+ * @param polyline - Polyline (closedness inferred) or raw point list
3086
+ * @param closed - Treat the points as a closed loop (defaults to the
3087
+ * polyline's own closedness; required when passing a raw point list)
3088
+ */
3089
+ clipPolylineBySheet(
3090
+ polyline: Polyline | Point[],
3091
+ closed?: boolean,
3092
+ ): { inside: Polyline[]; outside: Polyline[] } {
3093
+ ensureInit();
3094
+ const { flat, isClosed } = Mesh.flattenPolylineArg(
3095
+ polyline,
3096
+ closed,
3097
+ "Mesh.clipPolylineBySheet",
3098
+ );
3099
+ const buf = wasm.mesh_clip_polyline_by_sheet(this._vertexCount, this._buffer, flat, isClosed);
3100
+ if (buf.length === 0) {
3101
+ throw new Error(
3102
+ "Mesh.clipPolylineBySheet failed — the sheet and polyline must be non-degenerate.",
3103
+ );
3104
+ }
3105
+ // Two concatenated polyline groups: inside first, then outside — same
3106
+ // packing as clipPolyline.
3107
+ let idx = 0;
3108
+ const readGroup = (): Polyline[] => {
3109
+ const group: Polyline[] = [];
3110
+ const count = buf[idx++];
3111
+ for (let i = 0; i < count; i++) {
3112
+ const n = buf[idx++];
3113
+ const points: Point[] = [];
3114
+ for (let k = 0; k < n; k++) {
3115
+ points.push(new Point(buf[idx], buf[idx + 1], buf[idx + 2]));
3116
+ idx += 3;
3117
+ }
3118
+ if (points.length >= 2) group.push(new Polyline(points));
3119
+ }
3120
+ return group;
3121
+ };
3122
+ const inside = readGroup();
3123
+ const outside = readGroup();
3124
+ return { inside, outside };
3125
+ }
3126
+
3127
+ /**
3128
+ * Flatten a Polyline-or-points argument into WASM coords + closed flag.
3129
+ *
3130
+ * Closed inputs following the duplicated-seam convention (terminal point
3131
+ * repeating the first) have the duplicate STRIPPED before crossing the
3132
+ * boundary — the kernel's closed-polyline encoding is seam-free (the wrap
3133
+ * segment is implicit), and a duplicated seam would add a zero-length wrap
3134
+ * segment / doubled seam vertex to split pieces.
3135
+ */
3136
+ private static flattenPolylineArg(
3137
+ polyline: Polyline | Point[],
3138
+ closed: boolean | undefined,
3139
+ context: string,
3140
+ ): { flat: Float64Array; isClosed: boolean } {
3141
+ let pts = Array.isArray(polyline) ? polyline : polyline.points;
3142
+ if (pts.length < 2) {
3143
+ throw new Error(`${context} needs at least 2 polyline points`);
3144
+ }
3145
+ const isClosed = closed ?? (!Array.isArray(polyline) && polyline.isClosed());
3146
+ if (isClosed && pts.length > 2) {
3147
+ // Scale-relative epsilon for the duplicate-seam test.
3148
+ let maxAbs = 0;
3149
+ for (const p of pts) {
3150
+ maxAbs = Math.max(maxAbs, Math.abs(p.x), Math.abs(p.y), Math.abs(p.z));
3151
+ }
3152
+ const eps = (1 + maxAbs) * 1e-12;
3153
+ const first = pts[0];
3154
+ const last = pts[pts.length - 1];
3155
+ const dx = first.x - last.x;
3156
+ const dy = first.y - last.y;
3157
+ const dz = first.z - last.z;
3158
+ if (Math.sqrt(dx * dx + dy * dy + dz * dz) <= eps) {
3159
+ pts = pts.slice(0, -1);
3160
+ }
3161
+ }
3162
+ const flat = new Float64Array(pts.length * 3);
3163
+ for (let i = 0; i < pts.length; i++) {
3164
+ flat[i * 3] = pts[i].x;
3165
+ flat[i * 3 + 1] = pts[i].y;
3166
+ flat[i * 3 + 2] = pts[i].z;
3167
+ }
3168
+ return { flat, isClosed };
3169
+ }
3170
+
2904
3171
  /**
2905
3172
  * Apply a 4x4 transformation matrix.
2906
3173
  * @param matrix - Row-major flat array of 16 numbers
@@ -3116,6 +3383,37 @@ export class Mesh {
3116
3383
  return new Point(r[0], r[1], r[2]);
3117
3384
  }
3118
3385
 
3386
+ /**
3387
+ * Boundary loops of the coplanar edge-connected face group containing
3388
+ * faceIndex. Each loop is a closed polygon (winding preserved, collinear
3389
+ * subdivision points collapsed); the closing edge back to the first point
3390
+ * is implicit.
3391
+ */
3392
+ getFaceBoundaryLoops(faceIndex: number): Point[][] {
3393
+ ensureInit();
3394
+ if (!Number.isFinite(faceIndex) || faceIndex < 0) return [];
3395
+ const r = wasm.mesh_get_face_boundary_loops(
3396
+ this._vertexCount,
3397
+ this._buffer,
3398
+ Math.floor(faceIndex),
3399
+ );
3400
+ if (!r || r.length < 1) return [];
3401
+ const loopCount = Math.floor(r[0]);
3402
+ const loops: Point[][] = [];
3403
+ let offset = 1;
3404
+ for (let l = 0; l < loopCount && offset < r.length; l++) {
3405
+ const pointCount = Math.floor(r[offset]);
3406
+ offset += 1;
3407
+ const loop: Point[] = [];
3408
+ for (let i = 0; i < pointCount && offset + 2 < r.length; i++) {
3409
+ loop.push(new Point(r[offset], r[offset + 1], r[offset + 2]));
3410
+ offset += 3;
3411
+ }
3412
+ if (loop.length >= 3) loops.push(loop);
3413
+ }
3414
+ return loops;
3415
+ }
3416
+
3119
3417
  /**
3120
3418
  * Unique edge count for this triangulated mesh.
3121
3419
  */
package/src/NurbsCurve.ts CHANGED
@@ -7,6 +7,8 @@ import { Polyline } from "./Polyline.js";
7
7
  import { PolyCurve } from "./PolyCurve.js";
8
8
  import type { Line } from "./Line.js";
9
9
  import type { Arc } from "./Arc.js";
10
+ import type { NurbsSurface } from "./NurbsSurface.js";
11
+ import type { Brep } from "./Brep.js";
10
12
  import type { CurveOffsetOptions, RotationAxis } from "./types.js";
11
13
  import * as wasm from "./wasm-bindings.js";
12
14
 
@@ -110,6 +112,133 @@ export class NurbsCurve {
110
112
  return pts;
111
113
  }
112
114
 
115
+ /**
116
+ * Find all intersections with another NURBS curve, with exact parameters.
117
+ *
118
+ * Parameters are normalized to [0, 1] (the same parameterization as
119
+ * `pointAt`), so `this.pointAt(paramA)` and `other.pointAt(paramB)` both
120
+ * reproduce `point` up to the convergence tolerance. Results are sorted by
121
+ * `paramA`. Coincident (overlapping) curve runs yield NO points — identical
122
+ * curves return an empty array.
123
+ *
124
+ * @param other - Other curve to intersect with
125
+ * @returns Intersections with the 3D point and the parameter on each curve
126
+ */
127
+ intersectCurveParams(other: NurbsCurve): { point: Point; paramA: number; paramB: number }[] {
128
+ ensureInit();
129
+ const buf = wasm.nurbs_curve_curve_intersect_params(this._data, other._data);
130
+ if (buf.length === 0) return [];
131
+ const count = buf[0];
132
+ const hits: { point: Point; paramA: number; paramB: number }[] = [];
133
+ for (let i = 0; i < count; i++) {
134
+ const off = 1 + i * 5;
135
+ hits.push({
136
+ point: new Point(buf[off], buf[off + 1], buf[off + 2]),
137
+ paramA: buf[off + 3],
138
+ paramB: buf[off + 4],
139
+ });
140
+ }
141
+ return hits;
142
+ }
143
+
144
+ /**
145
+ * Find all isolated intersections with a NURBS surface, with exact
146
+ * parameters.
147
+ *
148
+ * All parameters are normalized to [0, 1]: `this.pointAt(curveParam)` and
149
+ * `surface.evaluate(u, v)` both reproduce `point` up to the convergence
150
+ * tolerance. Results are sorted by `curveParam`. Curve segments lying ON
151
+ * the surface (coincident runs) yield no points — a line inside a planar
152
+ * surface returns an empty array.
153
+ *
154
+ * @param surface - Surface to intersect with
155
+ * @returns Intersections with the 3D point, curve parameter, and surface (u, v)
156
+ */
157
+ intersectSurfaceExact(
158
+ surface: NurbsSurface,
159
+ ): { point: Point; curveParam: number; u: number; v: number }[] {
160
+ ensureInit();
161
+ const buf = wasm.nurbs_curve_surface_intersect_exact(this._data, surface.data);
162
+ if (buf.length === 0) return [];
163
+ const count = buf[0];
164
+ const hits: { point: Point; curveParam: number; u: number; v: number }[] = [];
165
+ for (let i = 0; i < count; i++) {
166
+ const off = 1 + i * 6;
167
+ hits.push({
168
+ point: new Point(buf[off], buf[off + 1], buf[off + 2]),
169
+ curveParam: buf[off + 3],
170
+ u: buf[off + 4],
171
+ v: buf[off + 5],
172
+ });
173
+ }
174
+ return hits;
175
+ }
176
+
177
+ /**
178
+ * Find intersection points with a NURBS surface.
179
+ * Point-only convenience wrapper around `intersectSurfaceExact`.
180
+ * @param surface - Surface to intersect with
181
+ * @returns Array of intersection points, sorted along the curve
182
+ */
183
+ intersectSurface(surface: NurbsSurface): Point[] {
184
+ return this.intersectSurfaceExact(surface).map(hit => hit.point);
185
+ }
186
+
187
+ /**
188
+ * Boolean-intersection clip of this curve against a CLOSED solid Brep —
189
+ * delegate for {@link Brep.clipCurve}: the curve is split EXACTLY (knot
190
+ * insertion) at every boundary crossing and each piece is classified by
191
+ * containment, so every returned piece lies exactly on this curve. Throws
192
+ * when the Brep is not a closed solid.
193
+ *
194
+ * @param brep - Closed solid BREP to clip against
195
+ * @returns Exact sub-curves inside and outside the solid
196
+ */
197
+ clipByBrep(brep: Brep): { inside: NurbsCurve[]; outside: NurbsCurve[] } {
198
+ return brep.clipCurve(this);
199
+ }
200
+
201
+ /**
202
+ * Split this curve at multiple normalized parameters t in [0, 1].
203
+ *
204
+ * Splitting is exact (knot insertion), so every returned piece lies exactly
205
+ * on this curve. Parameters are sorted, deduplicated, and clamped
206
+ * internally. Closed curves are opened at the FIRST parameter (N interior
207
+ * parameters yield N pieces, the seam-spanning piece rejoined); open curves
208
+ * yield N + 1 pieces. With no valid parameters the (clamped) curve comes
209
+ * back as the single piece.
210
+ *
211
+ * @param params - Normalized split parameters in [0, 1]
212
+ * @param closed - Explicit topology override. `true` forces closed
213
+ * (seam-rejoin) semantics; `false` forces open semantics — an open curve
214
+ * whose endpoints merely coincide (a C-shape drawn back onto its start)
215
+ * keeps its N + 1 pieces. Omit to infer closedness geometrically from the
216
+ * endpoint distance (the historical behavior).
217
+ * @returns Curve pieces in parameter order
218
+ */
219
+ splitAtParams(params: number[], closed?: boolean): NurbsCurve[] {
220
+ ensureInit();
221
+ const closedHint = closed === undefined ? -1 : closed ? 1 : 0;
222
+ const buf = wasm.nurbs_curve_split_at_params(
223
+ this._data,
224
+ new Float64Array(params),
225
+ closedHint
226
+ );
227
+ if (buf.length === 0) {
228
+ throw new Error("NurbsCurve.splitAtParams failed — malformed curve data");
229
+ }
230
+ // Packing: [curveCount, (bufLen, curveData…)·curveCount]
231
+ const count = buf[0];
232
+ const pieces: NurbsCurve[] = [];
233
+ let idx = 1;
234
+ for (let i = 0; i < count; i++) {
235
+ const len = buf[idx++];
236
+ pieces.push(NurbsCurve.fromData(buf.subarray(idx, idx + len)));
237
+ idx += len;
238
+ }
239
+ return pieces;
240
+ }
241
+
113
242
  /**
114
243
  * Translate this curve by an offset vector.
115
244
  * @param offset - Translation vector
@@ -147,6 +147,47 @@ export class NurbsSurface implements Surface {
147
147
  return parsePolylineBuf(buf).map(pts => new Polyline(pts));
148
148
  }
149
149
 
150
+ /**
151
+ * EXACT intersection curves with another NURBS surface.
152
+ *
153
+ * Unlike {@link intersectSurface} (tessellation-based, returns polylines at
154
+ * a fixed sampling resolution), this runs the parametric boolean's
155
+ * surface–surface intersection pipeline — seeded tri-tri detection,
156
+ * chaining, and Newton/tangent-plane relaxation of every point onto the
157
+ * true intersection — and fits each chain to an interpolating NURBS curve,
158
+ * densified until the fitted curve stays within `tolerance` of the exact
159
+ * intersection.
160
+ *
161
+ * Closed intersection loops (e.g. cylinder × plane) come back as ONE closed
162
+ * curve. Coincident (overlapping) surfaces return an empty array — their
163
+ * overlap is a 2D region, not a curve set.
164
+ *
165
+ * @param other - Other surface to intersect with
166
+ * @param tolerance - Absolute geometric tolerance of the fitted curves
167
+ * (model units; floored relative to the combined model scale)
168
+ * @returns Exact fitted NURBS intersection curves
169
+ */
170
+ intersectSurfaceCurves(other: NurbsSurface, tolerance = 1e-6): NurbsCurve[] {
171
+ ensureInit();
172
+ const buf = wasm.nurbs_surface_surface_intersect_curves(this._data, other._data, tolerance);
173
+ return NurbsSurface.parseCurveList(buf);
174
+ }
175
+
176
+ /** Parse [count, (bufLen, curveData…)·count] into NurbsCurves. */
177
+ private static parseCurveList(buf: Float64Array): NurbsCurve[] {
178
+ if (buf.length < 1) return [];
179
+ const out: NurbsCurve[] = [];
180
+ const count = Number(buf[0]);
181
+ let offset = 1;
182
+ for (let k = 0; k < count && offset < buf.length; k++) {
183
+ const len = Number(buf[offset]);
184
+ offset += 1;
185
+ out.push(NurbsCurve.fromData(buf.subarray(offset, offset + len)));
186
+ offset += len;
187
+ }
188
+ return out;
189
+ }
190
+
150
191
  /**
151
192
  * Convert any supported curve type to a NurbsCurve.
152
193
  * @param c - Curve to convert
@@ -176,11 +217,19 @@ export class NurbsSurface implements Surface {
176
217
  // Step 0: Convert all curves to NurbsCurve
177
218
  const nurbsCurves = curves.map(c => NurbsSurface.toNurbs(c));
178
219
 
179
- // Step 1: Make curves compatible (unified knot vectors, same CP count)
220
+ // Step 1: Make curves compatible (same degree, unified knot vectors,
221
+ // same CP count). Mixed degrees are fine — the kernel degree-elevates
222
+ // lower-degree curves to the max degree exactly (a 2-point line lofts
223
+ // with a cubic).
180
224
  let loftCurves = nurbsCurves;
181
225
  if (nurbsCurves.length >= 2) {
182
- const cpCounts = nurbsCurves.map(c => c.controlPoints.length);
183
- const allSame = cpCounts.every(n => n === cpCounts[0]);
226
+ const ref = nurbsCurves[0];
227
+ const matchesRef = (c: NurbsCurve) =>
228
+ c.degree === ref.degree &&
229
+ c.controlPoints.length === ref.controlPoints.length &&
230
+ c.knots.length === ref.knots.length &&
231
+ c.knots.every((k, i) => Math.abs(k - (ref.knots[i] as number)) < 1e-12);
232
+ const allSame = nurbsCurves.every(matchesRef);
184
233
  if (!allSame) {
185
234
  const compatParts: number[] = [nurbsCurves.length];
186
235
  for (const c of nurbsCurves) {
@@ -190,19 +239,20 @@ export class NurbsSurface implements Surface {
190
239
  for (const k of c.knots) { compatParts.push(k); }
191
240
  }
192
241
  const compatBuf = wasm.make_nurbs_curves_compatible(new Float64Array(compatParts));
193
- if (compatBuf.length > 0) {
194
- const count = Number(compatBuf[0] ?? 0);
195
- const decoded: NurbsCurve[] = [];
196
- let off = 1;
197
- for (let i = 0; i < count; i++) {
198
- const deg = Number(compatBuf[off] ?? 0);
199
- const n = Number(compatBuf[off + 1] ?? 0);
200
- const curveLen = 2 + n * 3 + n + (n + deg + 1);
201
- decoded.push(NurbsCurve.fromData(compatBuf.slice(off, off + curveLen)));
202
- off += curveLen;
203
- }
204
- loftCurves = decoded;
242
+ if (compatBuf.length === 0) {
243
+ throw new Error("NurbsSurface loft: profile curves could not be made compatible");
244
+ }
245
+ const count = Number(compatBuf[0] ?? 0);
246
+ const decoded: NurbsCurve[] = [];
247
+ let off = 1;
248
+ for (let i = 0; i < count; i++) {
249
+ const deg = Number(compatBuf[off] ?? 0);
250
+ const n = Number(compatBuf[off + 1] ?? 0);
251
+ const curveLen = 2 + n * 3 + n + (n + deg + 1);
252
+ decoded.push(NurbsCurve.fromData(compatBuf.slice(off, off + curveLen)));
253
+ off += curveLen;
205
254
  }
255
+ loftCurves = decoded;
206
256
  }
207
257
  }
208
258