okgeometry-api 1.11.1 → 1.14.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/dist/Brep.d.ts +150 -0
- package/dist/Brep.d.ts.map +1 -1
- package/dist/Brep.js +241 -0
- package/dist/Brep.js.map +1 -1
- package/dist/Mesh.d.ts +31 -2
- package/dist/Mesh.d.ts.map +1 -1
- package/dist/Mesh.js +113 -4
- package/dist/Mesh.js.map +1 -1
- package/dist/NurbsSurface.d.ts.map +1 -1
- package/dist/NurbsSurface.js +23 -15
- package/dist/NurbsSurface.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/wasm-base64.d.ts +1 -1
- package/dist/wasm-base64.d.ts.map +1 -1
- package/dist/wasm-base64.js +1 -1
- package/dist/wasm-base64.js.map +1 -1
- package/dist/wasm-bindings.d.ts +151 -0
- package/dist/wasm-bindings.d.ts.map +1 -1
- package/dist/wasm-bindings.js +288 -0
- package/dist/wasm-bindings.js.map +1 -1
- package/package.json +52 -50
- package/src/Brep.ts +309 -0
- package/src/Mesh.ts +127 -2
- package/src/NurbsSurface.ts +24 -15
- package/src/index.ts +2 -0
- package/src/wasm-base64.ts +1 -1
- package/src/wasm-bindings.d.ts +122 -0
- package/src/wasm-bindings.js +297 -0
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). */
|
|
@@ -430,6 +650,95 @@ export class Brep {
|
|
|
430
650
|
return this.runParametricBoolean(other, "intersect", options);
|
|
431
651
|
}
|
|
432
652
|
|
|
653
|
+
// ── Mesh → BREP and solid ÷ sheet split ──
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Convert a closed manifold triangle mesh into a planar-face BREP: coplanar
|
|
657
|
+
* connected facet groups become single trimmed planar faces (with hole
|
|
658
|
+
* loops), shared boundaries weld into exact line edges. A mesh box becomes
|
|
659
|
+
* the same 6-face / 12-edge / 8-vertex topology as {@link Brep.box}.
|
|
660
|
+
*
|
|
661
|
+
* The mesh's winding is authoritative (inside-out input is re-oriented);
|
|
662
|
+
* open or non-manifold meshes are rejected. Intended for planar-faceted
|
|
663
|
+
* solids (boxes, extrusions, mesh-boolean results) — densely tessellated
|
|
664
|
+
* curved meshes fail the `maxFaces` guard instead of producing a
|
|
665
|
+
* pathological BREP.
|
|
666
|
+
*/
|
|
667
|
+
static fromMesh(mesh: Mesh, options?: MeshToBrepConversionOptions): Brep {
|
|
668
|
+
ensureInit();
|
|
669
|
+
const json = wasm.brep_from_mesh_buffer(
|
|
670
|
+
mesh.vertexCount,
|
|
671
|
+
mesh.rawBuffer,
|
|
672
|
+
options?.normalAngleToleranceDeg ?? 0,
|
|
673
|
+
options?.maxFaces ?? 0,
|
|
674
|
+
);
|
|
675
|
+
if (!json) throw new Error("Brep.fromMesh failed");
|
|
676
|
+
if (json.startsWith('{"error"')) {
|
|
677
|
+
const parsed = JSON.parse(json) as { error: string };
|
|
678
|
+
throw new Error(`Brep.fromMesh failed: ${parsed.error}`);
|
|
679
|
+
}
|
|
680
|
+
return new Brep(json);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* PARAMETRIC split of this CLOSED solid by a sheet (an open Brep — e.g.
|
|
685
|
+
* {@link Brep.fromSurface} — or a NurbsSurface directly): BOTH halves are
|
|
686
|
+
* returned as closed BREPs, each keeping the solid's exact faces on its
|
|
687
|
+
* side plus the sheet region inside the solid as an exact trimmed-surface
|
|
688
|
+
* cap (opposite orientations on the two halves).
|
|
689
|
+
*
|
|
690
|
+
* Sides follow the sheet's orientation — `negative` is the half behind the
|
|
691
|
+
* sheet normal — matching `Mesh.splitSolidBySurface`. When the sheet does
|
|
692
|
+
* not fully cut through, the whole solid comes back on one side and the
|
|
693
|
+
* other is null.
|
|
694
|
+
*/
|
|
695
|
+
splitBySheet(
|
|
696
|
+
sheet: Brep | NurbsSurface,
|
|
697
|
+
options?: BrepParametricBooleanOptions,
|
|
698
|
+
): BrepSplitSolidResult {
|
|
699
|
+
ensureInit();
|
|
700
|
+
const cutter = sheet instanceof Brep ? sheet : Brep.fromSurface(sheet);
|
|
701
|
+
const tolerance = options?.tolerance ?? 1e-6;
|
|
702
|
+
const json = wasm.brep_split_solid_by_sheet_op(this.json, cutter.json, tolerance);
|
|
703
|
+
return Brep.parseSplitResult(json, "Brep.splitBySheet");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* One-shot production path for "mesh − NURBS surface → BREP": converts the
|
|
708
|
+
* closed triangle mesh to a planar-face BREP, builds the sheet, and runs the
|
|
709
|
+
* parametric split — all in a single WASM crossing. Equivalent to
|
|
710
|
+
* `Brep.fromMesh(mesh, options).splitBySheet(surface, options)` but without
|
|
711
|
+
* intermediate JSON round-trips.
|
|
712
|
+
*/
|
|
713
|
+
static splitMeshBySurface(
|
|
714
|
+
mesh: Mesh,
|
|
715
|
+
surface: NurbsSurface,
|
|
716
|
+
options?: BrepParametricBooleanOptions & MeshToBrepConversionOptions,
|
|
717
|
+
): BrepSplitSolidResult {
|
|
718
|
+
ensureInit();
|
|
719
|
+
const json = wasm.mesh_split_by_surface_to_brep(
|
|
720
|
+
mesh.vertexCount,
|
|
721
|
+
mesh.rawBuffer,
|
|
722
|
+
surface.data,
|
|
723
|
+
options?.tolerance ?? 1e-6,
|
|
724
|
+
options?.normalAngleToleranceDeg ?? 0,
|
|
725
|
+
options?.maxFaces ?? 0,
|
|
726
|
+
);
|
|
727
|
+
return Brep.parseSplitResult(json, "Brep.splitMeshBySurface");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private static parseSplitResult(json: string, label: string): BrepSplitSolidResult {
|
|
731
|
+
if (!json) throw new Error(`${label} failed`);
|
|
732
|
+
if (json.startsWith('{"error"')) {
|
|
733
|
+
const parsed = JSON.parse(json) as { error: string };
|
|
734
|
+
throw new Error(`${label} failed: ${parsed.error}`);
|
|
735
|
+
}
|
|
736
|
+
const parsed = JSON.parse(json) as { negative: unknown; positive: unknown };
|
|
737
|
+
const side = (v: unknown): Brep | null =>
|
|
738
|
+
v == null ? null : new Brep(JSON.stringify(v));
|
|
739
|
+
return { negative: side(parsed.negative), positive: side(parsed.positive) };
|
|
740
|
+
}
|
|
741
|
+
|
|
433
742
|
/** JSON representation (persistence). */
|
|
434
743
|
toJson(): string {
|
|
435
744
|
return this.json;
|
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
|
-
*
|
|
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
|
|
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,
|
|
@@ -3116,6 +3210,37 @@ export class Mesh {
|
|
|
3116
3210
|
return new Point(r[0], r[1], r[2]);
|
|
3117
3211
|
}
|
|
3118
3212
|
|
|
3213
|
+
/**
|
|
3214
|
+
* Boundary loops of the coplanar edge-connected face group containing
|
|
3215
|
+
* faceIndex. Each loop is a closed polygon (winding preserved, collinear
|
|
3216
|
+
* subdivision points collapsed); the closing edge back to the first point
|
|
3217
|
+
* is implicit.
|
|
3218
|
+
*/
|
|
3219
|
+
getFaceBoundaryLoops(faceIndex: number): Point[][] {
|
|
3220
|
+
ensureInit();
|
|
3221
|
+
if (!Number.isFinite(faceIndex) || faceIndex < 0) return [];
|
|
3222
|
+
const r = wasm.mesh_get_face_boundary_loops(
|
|
3223
|
+
this._vertexCount,
|
|
3224
|
+
this._buffer,
|
|
3225
|
+
Math.floor(faceIndex),
|
|
3226
|
+
);
|
|
3227
|
+
if (!r || r.length < 1) return [];
|
|
3228
|
+
const loopCount = Math.floor(r[0]);
|
|
3229
|
+
const loops: Point[][] = [];
|
|
3230
|
+
let offset = 1;
|
|
3231
|
+
for (let l = 0; l < loopCount && offset < r.length; l++) {
|
|
3232
|
+
const pointCount = Math.floor(r[offset]);
|
|
3233
|
+
offset += 1;
|
|
3234
|
+
const loop: Point[] = [];
|
|
3235
|
+
for (let i = 0; i < pointCount && offset + 2 < r.length; i++) {
|
|
3236
|
+
loop.push(new Point(r[offset], r[offset + 1], r[offset + 2]));
|
|
3237
|
+
offset += 3;
|
|
3238
|
+
}
|
|
3239
|
+
if (loop.length >= 3) loops.push(loop);
|
|
3240
|
+
}
|
|
3241
|
+
return loops;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3119
3244
|
/**
|
|
3120
3245
|
* Unique edge count for this triangulated mesh.
|
|
3121
3246
|
*/
|
package/src/NurbsSurface.ts
CHANGED
|
@@ -176,11 +176,19 @@ export class NurbsSurface implements Surface {
|
|
|
176
176
|
// Step 0: Convert all curves to NurbsCurve
|
|
177
177
|
const nurbsCurves = curves.map(c => NurbsSurface.toNurbs(c));
|
|
178
178
|
|
|
179
|
-
// Step 1: Make curves compatible (unified knot vectors,
|
|
179
|
+
// Step 1: Make curves compatible (same degree, unified knot vectors,
|
|
180
|
+
// same CP count). Mixed degrees are fine — the kernel degree-elevates
|
|
181
|
+
// lower-degree curves to the max degree exactly (a 2-point line lofts
|
|
182
|
+
// with a cubic).
|
|
180
183
|
let loftCurves = nurbsCurves;
|
|
181
184
|
if (nurbsCurves.length >= 2) {
|
|
182
|
-
const
|
|
183
|
-
const
|
|
185
|
+
const ref = nurbsCurves[0];
|
|
186
|
+
const matchesRef = (c: NurbsCurve) =>
|
|
187
|
+
c.degree === ref.degree &&
|
|
188
|
+
c.controlPoints.length === ref.controlPoints.length &&
|
|
189
|
+
c.knots.length === ref.knots.length &&
|
|
190
|
+
c.knots.every((k, i) => Math.abs(k - (ref.knots[i] as number)) < 1e-12);
|
|
191
|
+
const allSame = nurbsCurves.every(matchesRef);
|
|
184
192
|
if (!allSame) {
|
|
185
193
|
const compatParts: number[] = [nurbsCurves.length];
|
|
186
194
|
for (const c of nurbsCurves) {
|
|
@@ -190,19 +198,20 @@ export class NurbsSurface implements Surface {
|
|
|
190
198
|
for (const k of c.knots) { compatParts.push(k); }
|
|
191
199
|
}
|
|
192
200
|
const compatBuf = wasm.make_nurbs_curves_compatible(new Float64Array(compatParts));
|
|
193
|
-
if (compatBuf.length
|
|
194
|
-
|
|
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;
|
|
201
|
+
if (compatBuf.length === 0) {
|
|
202
|
+
throw new Error("NurbsSurface loft: profile curves could not be made compatible");
|
|
205
203
|
}
|
|
204
|
+
const count = Number(compatBuf[0] ?? 0);
|
|
205
|
+
const decoded: NurbsCurve[] = [];
|
|
206
|
+
let off = 1;
|
|
207
|
+
for (let i = 0; i < count; i++) {
|
|
208
|
+
const deg = Number(compatBuf[off] ?? 0);
|
|
209
|
+
const n = Number(compatBuf[off + 1] ?? 0);
|
|
210
|
+
const curveLen = 2 + n * 3 + n + (n + deg + 1);
|
|
211
|
+
decoded.push(NurbsCurve.fromData(compatBuf.slice(off, off + curveLen)));
|
|
212
|
+
off += curveLen;
|
|
213
|
+
}
|
|
214
|
+
loftCurves = decoded;
|
|
206
215
|
}
|
|
207
216
|
}
|
|
208
217
|
|