@metamesh/modeling 0.6.0 → 0.8.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +157 -22
  3. package/dist/api/engine.d.ts +25 -4
  4. package/dist/api/geometry.d.ts +391 -82
  5. package/dist/api/index.d.ts +732 -108
  6. package/dist/api/text.d.ts +40 -10
  7. package/dist/engine/construction-operations.js +52 -10
  8. package/dist/engine/execution.js +55 -2
  9. package/dist/engine/export-3mf.js +17 -1
  10. package/dist/engine/finalization.js +27 -18
  11. package/dist/engine/index.js +12 -1
  12. package/dist/engine/inspection-operations.js +26 -22
  13. package/dist/engine/loft-operations.js +1 -1
  14. package/dist/engine/mechanical-operations.js +5 -8
  15. package/dist/engine/mesh-view-cache.js +23 -0
  16. package/dist/engine/native/adapter.js +2 -4
  17. package/dist/engine/native/distance.js +42 -0
  18. package/dist/engine/native/status.js +16 -0
  19. package/dist/engine/operation-environment.js +2 -1
  20. package/dist/engine/section-operations.js +3 -3
  21. package/dist/geometry/curves/sampling.js +2 -1
  22. package/dist/geometry/heightfield.js +109 -42
  23. package/dist/geometry/index.js +2 -0
  24. package/dist/geometry/loft-simple-mesh.js +2 -3
  25. package/dist/geometry/loft-triangulation.js +12 -9
  26. package/dist/geometry/loft.js +18 -7
  27. package/dist/geometry/math/vector3.js +35 -5
  28. package/dist/geometry/mechanical/standards.js +2 -3
  29. package/dist/geometry/mechanical/thread-profiles.js +5 -0
  30. package/dist/geometry/mechanical/threads.js +2 -3
  31. package/dist/geometry/mesh-centroid.js +36 -0
  32. package/dist/geometry/mesh-edge-table.js +126 -0
  33. package/dist/geometry/mesh-solid-intersections.js +123 -0
  34. package/dist/geometry/mesh-solid.js +284 -0
  35. package/dist/geometry/mesh.js +8 -25
  36. package/dist/geometry/paths/path.js +3 -1
  37. package/dist/geometry/paths/resampling.js +2 -1
  38. package/dist/geometry/profiles/matched.js +8 -0
  39. package/dist/geometry/profiles/profiles.js +24 -3
  40. package/dist/geometry/profiles/rectangle.js +16 -2
  41. package/dist/geometry/profiles/shapes.js +8 -1
  42. package/dist/geometry/revolve/revolve-mesh.js +6 -0
  43. package/dist/geometry/sweeps/sweep-closed.js +4 -4
  44. package/dist/geometry/sweeps/sweep-mesh.js +13 -7
  45. package/dist/geometry/sweeps/sweep-open.js +4 -4
  46. package/dist/geometry/sweeps/validation.js +1 -2
  47. package/dist/model/operations.js +13 -4
  48. package/dist/text/index.js +11 -1
  49. package/dist/text/vector-text-layout.js +46 -3
  50. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ import { GeometryError } from "../../geometry/errors.js";
2
+ import { assertNativeSolidStatus } from "./status.js";
3
+ /** Internal minimum separation query; never exposes the kernel's capped search result. */
4
+ export function measureSolidDistance(left, right) {
5
+ assertNativeSolidStatus(left, "distance");
6
+ assertNativeSolidStatus(right, "distance");
7
+ if (left.isEmpty() || right.isEmpty())
8
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Distance requires nonempty solids.", {
9
+ operation: "distance",
10
+ });
11
+ const a = left.boundingBox(), b = right.boundingBox();
12
+ const axes = [0, 1, 2];
13
+ const lower = Math.hypot(...axes.map((i) => Math.max(0, a.min[i] - b.max[i], b.min[i] - a.max[i])));
14
+ const upper = Math.hypot(...axes.map((i) => Math.max(Math.abs(a.min[i] - b.max[i]), Math.abs(a.max[i] - b.min[i]))));
15
+ const diagonal = Math.max(Math.hypot(...axes.map((i) => a.max[i] - a.min[i])), Math.hypot(...axes.map((i) => b.max[i] - b.min[i])));
16
+ const scale = Math.max(upper, ...[...a.min, ...a.max, ...b.min, ...b.max].map(Math.abs));
17
+ const padding = Math.max(Math.sqrt(Number.MIN_VALUE) * 2, scale * Number.EPSILON * 8);
18
+ const maximum = upper + padding;
19
+ if (!Number.isFinite(maximum * maximum) || maximum <= 0)
20
+ throw new GeometryError("NON_FINITE_VALUE", "Distance exceeds the kernel's numeric range.", {
21
+ operation: "distance",
22
+ });
23
+ // A modest extent-based seed avoids many tiny searches for very unequal shapes.
24
+ // A full bounding-box diagonal would make the kernel visit unnecessary triangle pairs.
25
+ let radius = Math.min(maximum, Math.max(lower + padding, diagonal / 32, padding));
26
+ for (;;) {
27
+ const value = left.minGap(right, radius);
28
+ // The kernel takes sqrt(radius²) when capped; that can round below radius.
29
+ const capSlack = Math.max(Math.sqrt(Number.MIN_VALUE), radius * Number.EPSILON * 4);
30
+ if (!Number.isFinite(value) || value < 0 || value > radius + capSlack)
31
+ throw new GeometryError("NON_FINITE_VALUE", "The kernel returned an invalid distance.", {
32
+ operation: "distance",
33
+ });
34
+ if (value < radius - capSlack)
35
+ return value;
36
+ if (radius === maximum)
37
+ throw new GeometryError("INVALID_MESH", "The kernel could not resolve the minimum distance.", {
38
+ operation: "distance",
39
+ });
40
+ radius = Math.min(maximum, radius * 2);
41
+ }
42
+ }
@@ -0,0 +1,16 @@
1
+ import { GeometryError } from "../../geometry/errors.js";
2
+ /** Checks an already required native evaluation without exposing upstream status types publicly. */
3
+ export function assertNativeSolidStatus(native, operation) {
4
+ const status = native.status();
5
+ if (status === "NoError")
6
+ return;
7
+ const code = status === "NonFiniteVertex"
8
+ ? "NON_FINITE_VALUE"
9
+ : status === "ResultTooLarge"
10
+ ? "COMPLEXITY_LIMIT"
11
+ : "INVALID_TOPOLOGY";
12
+ throw new GeometryError(code, `The geometry kernel rejected the solid: ${status}.`, {
13
+ operation,
14
+ details: { nativeStatus: status },
15
+ });
16
+ }
@@ -3,7 +3,8 @@ import { resolveCircularPrimitiveSegments } from "../geometry/primitive-resoluti
3
3
  /** Applies explicit resolution inheritance once for this bound context. */
4
4
  export function createOperationEnvironment(owner, resolution) {
5
5
  const withResolutionDefaults = (value) => {
6
- const limits = resolveModelingLimits(value.limits, owner.limits);
6
+ const overrides = value.limits;
7
+ const limits = overrides === undefined ? owner.limits : resolveModelingLimits(overrides, owner.limits);
7
8
  return {
8
9
  ...value,
9
10
  resolution: {
@@ -5,7 +5,7 @@ import { sampleTextContours } from "../geometry/text-contours.js";
5
5
  import * as kernel from "./native/index.js";
6
6
  /** Internal host-bound section operations. */
7
7
  export function createSectionOperations(environment, transforms) {
8
- const { module, limits, scope, assertActive, throwGeometryError, nativeSolid, nativeSection, wrapSection, ownSection, validateFiniteNumber, requirePositiveNumber, validateCoordinates, assertOperandCount, withResolutionDefaults, resolveCircularSegments, } = environment;
8
+ const { module, limits, scope, assertActive, throwGeometryError, nativeSolid, nativeSection, wrapSection, ownSection, validateFiniteNumber, validateCoordinates, assertOperandCount, withResolutionDefaults, resolveCircularSegments, } = environment;
9
9
  const { translate } = transforms.operations;
10
10
  const fromContours = (contours, value = {}) => {
11
11
  assertActive();
@@ -51,8 +51,8 @@ export function createSectionOperations(environment, transforms) {
51
51
  const join = value.join ?? "round";
52
52
  if (!["round", "miter", "square"].includes(join))
53
53
  throwGeometryError("INVALID_ARGUMENT", "offset", "Unsupported offset join.");
54
- if (value.miterLimit !== undefined)
55
- requirePositiveNumber(value.miterLimit, "offset");
54
+ if (value.miterLimit !== undefined && validateFiniteNumber(value.miterLimit, "offset") < 2)
55
+ throwGeometryError("INVALID_ARGUMENT", "offset", "miterLimit must be at least two.");
56
56
  return ownSection(native.offset(distance, join === "round" ? "Round" : join === "miter" ? "Miter" : "Square", value.miterLimit, resolveCircularSegments(Math.max(Math.abs(distance), 0.001), {
57
57
  ...(value.circularSegments === undefined ? {} : { segments: value.circularSegments }),
58
58
  ...(value.resolution === undefined ? {} : { resolution: value.resolution }),
@@ -224,7 +224,8 @@ function sampleCurve(curve, options, createWriter) {
224
224
  return curveFailure("INVALID_ARGUMENT", "Unsupported curve kind.");
225
225
  }
226
226
  }
227
- /** Samples a spatial curve, preserving endpoints and directed unit tangents.
227
+ /**
228
+ * Samples a spatial curve, preserving endpoints and directed unit tangents.
228
229
  * @remarks Exact counts use uniform curve parameters, not equal physical distances. Adaptive cubics
229
230
  * bound the full control hull. Full-circle arcs repeat their endpoint; closed polylines do not.
230
231
  * @example
@@ -1,57 +1,108 @@
1
1
  import { GeometryError } from "./errors.js";
2
2
  import { validateTriangleMesh } from "./mesh.js";
3
- import { defaultModelingLimits } from "./model-limits.js";
3
+ import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "./model-limits.js";
4
4
  import { writeMeshVertex } from "./numeric.js";
5
- const isArray = (value) => Array.isArray(value);
6
- /** Checks regular-grid dimensions and bounds allocation before evaluating any height samples. */
7
- export function preflightHeightfield(options, limits = {}) {
5
+ function planHeightfield(options, limits) {
8
6
  const operation = "preflightHeightfield";
9
7
  if (typeof options !== "object" ||
10
8
  options === null ||
11
- !isArray(options.size) ||
12
- options.size.length !== 2 ||
13
- !options.size.every((value) => Number.isFinite(value) && value > 0) ||
14
- !isArray(options.segments) ||
15
- options.segments.length !== 2 ||
16
- !options.segments.every((value) => Number.isSafeInteger(value) && value > 0) ||
17
9
  typeof options.height !== "function" ||
18
10
  !Number.isFinite(options.baseZ ?? 0) ||
19
- (options.origin !== undefined &&
20
- (!isArray(options.origin) ||
21
- options.origin.length !== 2 ||
22
- !options.origin.every(Number.isFinite)))) {
23
- throw new GeometryError("INVALID_ARGUMENT", "Heightfields need a finite positive footprint, integer intervals, finite base, and a height callback.", { operation });
11
+ (options.triangulation !== undefined &&
12
+ !["fixed", "min-edge", "center-error"].includes(options.triangulation))) {
13
+ throw new GeometryError("INVALID_ARGUMENT", "Heightfields need a finite base, height callback, and supported triangulation policy.", { operation });
14
+ }
15
+ let x, y;
16
+ let xAt, yAt;
17
+ if (options.coordinates !== undefined) {
18
+ const coordinates = options.coordinates;
19
+ if (typeof coordinates !== "object" ||
20
+ coordinates === null ||
21
+ !Array.isArray(coordinates.x) ||
22
+ !Array.isArray(coordinates.y) ||
23
+ coordinates.x.length < 2 ||
24
+ coordinates.y.length < 2 ||
25
+ options.size !== undefined ||
26
+ options.segments !== undefined ||
27
+ options.origin !== undefined) {
28
+ throw new GeometryError("INVALID_ARGUMENT", "Explicit heightfield coordinates need two or more samples per axis and cannot include size, segments, or origin.", { operation });
29
+ }
30
+ x = coordinates.x.length - 1;
31
+ y = coordinates.y.length - 1;
32
+ xAt = (index) => coordinates.x[index];
33
+ yAt = (index) => coordinates.y[index];
34
+ }
35
+ else {
36
+ if (!Array.isArray(options.size) ||
37
+ options.size.length !== 2 ||
38
+ !options.size.every((value) => Number.isFinite(value) && value > 0) ||
39
+ !Array.isArray(options.segments) ||
40
+ options.segments.length !== 2 ||
41
+ !options.segments.every((value) => Number.isSafeInteger(value) && value > 0) ||
42
+ (options.origin !== undefined &&
43
+ (!Array.isArray(options.origin) ||
44
+ options.origin.length !== 2 ||
45
+ !options.origin.every(Number.isFinite)))) {
46
+ throw new GeometryError("INVALID_ARGUMENT", "Uniform heightfields need a finite positive footprint, integer intervals, and finite origin.", { operation });
47
+ }
48
+ [x, y] = options.segments;
49
+ const [width, depth] = options.size;
50
+ const [originX, originY] = options.origin ?? [-width / 2, -depth / 2];
51
+ xAt = (index) => originX + (width * index) / x;
52
+ yAt = (index) => originY + (depth * index) / y;
24
53
  }
25
- const [x, y] = options.segments;
26
54
  const sampleCount = (x + 1) * (y + 1);
27
55
  const perimeter = 2 * (x + y);
28
56
  const vertexCount = sampleCount + perimeter + 1;
29
57
  const triangleCount = 2 * x * y + 3 * perimeter;
30
58
  const byteCount = 12 * (vertexCount + triangleCount);
31
- const policy = { ...defaultModelingLimits, ...limits };
32
- if (![policy.maxVertices, policy.maxTriangles, policy.maxBytes].every((value) => Number.isSafeInteger(value) && value > 0))
33
- throw new GeometryError("INVALID_ARGUMENT", "Heightfield ceilings must be positive safe integers.", { operation });
34
- if (!Number.isSafeInteger(byteCount) ||
35
- vertexCount > 0xffff_ffff ||
36
- triangleCount * 3 > 0xffff_ffff ||
37
- vertexCount > policy.maxVertices ||
38
- triangleCount > policy.maxTriangles ||
39
- byteCount > policy.maxBytes) {
40
- throw new GeometryError("COMPLEXITY_LIMIT", "Heightfield grid exceeds the configured output limits or numeric index ceiling.", { operation });
59
+ assertMeshAllocationWithinLimits(vertexCount, triangleCount, byteCount, operation, resolveModelingLimits(limits));
60
+ for (const [axis, count, at] of [
61
+ ["x", x, xAt],
62
+ ["y", y, yAt],
63
+ ]) {
64
+ let previous = -Infinity;
65
+ for (let index = 0; index <= count; index += 1) {
66
+ const coordinate = Math.fround(at(index));
67
+ if (!Number.isFinite(coordinate) || !(coordinate > previous))
68
+ throw new GeometryError("INVALID_ARGUMENT", "Heightfield axes must be finite, strictly increasing, and distinct in Float32 storage.", { operation, details: { axis, index } });
69
+ previous = coordinate;
70
+ }
41
71
  }
42
- return Object.freeze({ sampleCount, vertexCount, triangleCount, byteCount });
72
+ return {
73
+ x,
74
+ y,
75
+ xAt,
76
+ yAt,
77
+ facts: Object.freeze({ sampleCount, vertexCount, triangleCount, byteCount }),
78
+ };
79
+ }
80
+ /** Checks grid dimensions, coordinate precision, and allocation bounds before evaluating heights. */
81
+ export function preflightHeightfield(options, limits = {}) {
82
+ return planHeightfield(options, limits).facts;
43
83
  }
44
84
  /**
45
- * Builds a closed indexed solid from a regularly sampled top, matched boundary walls, and flat base.
46
- * Heights are evaluated once per grid point after allocation preflight. Physical XY sizes and all
47
- * output coordinates must fit Float32 storage. Shared wall vertices prevent cracks at the base.
85
+ * Builds a closed indexed mesh from a rectilinear top, matched boundary walls, and flat base.
86
+ * Heights run once per grid point after preflight; center-error adds one evaluation per cell.
87
+ * Top winding is +Z, the base is -Z, and sides face outward. Coordinates and heights must fit
88
+ * Float32 storage. Explicit axes are captured before sampling; shared wall vertices prevent cracks.
89
+ *
90
+ * @example Sample a closed wavy mesh
91
+ * ```ts
92
+ * heightfieldMesh({
93
+ * size: [30, 20], segments: [24, 16],
94
+ * height: ([x, y]) => 3 + Math.cos(x / 5) * Math.cos(y / 5),
95
+ * });
96
+ * ```
48
97
  */
49
98
  export function heightfieldMesh(options, limits = {}) {
50
- const facts = preflightHeightfield(options, limits);
99
+ const plan = planHeightfield(options, limits);
100
+ const { facts, x: nx, y: ny } = plan;
51
101
  const operation = "heightfieldMesh";
52
- const [nx, ny] = options.segments;
53
- const [width, depth] = options.size;
54
- const [originX, originY] = options.origin ?? [-width / 2, -depth / 2];
102
+ const xs = Array.from({ length: nx + 1 }, (_, index) => plan.xAt(index));
103
+ const ys = Array.from({ length: ny + 1 }, (_, index) => plan.yAt(index));
104
+ const triangulation = options.triangulation ?? "fixed";
105
+ const heightAt = options.height;
55
106
  const baseZ = Math.fround(options.baseZ ?? 0);
56
107
  if (!Number.isFinite(baseZ))
57
108
  throw new GeometryError("NON_FINITE_VALUE", "Heightfield base must fit Float32 storage.", {
@@ -60,13 +111,16 @@ export function heightfieldMesh(options, limits = {}) {
60
111
  const positions = new Float32Array(facts.vertexCount * 3);
61
112
  const indices = new Uint32Array(facts.triangleCount * 3);
62
113
  const grid = (x, y) => y * (nx + 1) + x;
114
+ const sampleHeight = (point) => {
115
+ const height = Math.fround(heightAt(point));
116
+ if (!Number.isFinite(height) || !(height > baseZ))
117
+ throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x: point[0], y: point[1] } });
118
+ return height;
119
+ };
63
120
  for (let y = 0; y <= ny; y += 1) {
64
121
  for (let x = 0; x <= nx; x += 1) {
65
- const point = [originX + (width * x) / nx, originY + (depth * y) / ny];
66
- const height = Math.fround(options.height(point));
67
- if (!Number.isFinite(height) || !(height > baseZ)) {
68
- throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x, y } });
69
- }
122
+ const point = [xs[x], ys[y]];
123
+ const height = sampleHeight(point);
70
124
  writeMeshVertex(positions, grid(x, y) * 3, [point[0], point[1], height], operation);
71
125
  }
72
126
  }
@@ -79,8 +133,21 @@ export function heightfieldMesh(options, limits = {}) {
79
133
  for (let y = 0; y < ny; y += 1) {
80
134
  for (let x = 0; x < nx; x += 1) {
81
135
  const a = grid(x, y), b = grid(x + 1, y), c = grid(x + 1, y + 1), d = grid(x, y + 1);
82
- triangle(a, b, c);
83
- triangle(a, c, d);
136
+ const za = positions[a * 3 + 2], zb = positions[b * 3 + 2], zc = positions[c * 3 + 2], zd = positions[d * 3 + 2];
137
+ // Rectangular XY cells have equal XY diagonal lengths, so only Z differs.
138
+ let alternate = triangulation === "min-edge" && Math.abs(zb - zd) < Math.abs(za - zc);
139
+ if (triangulation === "center-error") {
140
+ const centre = sampleHeight([(xs[x] + xs[x + 1]) / 2, (ys[y] + ys[y + 1]) / 2]);
141
+ alternate = Math.abs((zb + zd) / 2 - centre) < Math.abs((za + zc) / 2 - centre);
142
+ }
143
+ if (alternate) {
144
+ triangle(a, b, d);
145
+ triangle(b, c, d);
146
+ }
147
+ else {
148
+ triangle(a, b, c);
149
+ triangle(a, c, d);
150
+ }
84
151
  }
85
152
  }
86
153
  const boundary = [];
@@ -94,7 +161,7 @@ export function heightfieldMesh(options, limits = {}) {
94
161
  boundary.push(grid(0, y));
95
162
  const baseStart = facts.sampleCount;
96
163
  const baseCenter = facts.vertexCount - 1;
97
- writeMeshVertex(positions, baseCenter * 3, [originX + width / 2, originY + depth / 2, baseZ], operation);
164
+ writeMeshVertex(positions, baseCenter * 3, [(xs[0] + xs[nx]) / 2, (ys[0] + ys[ny]) / 2, baseZ], operation);
98
165
  for (let offset = 0; offset < boundary.length; offset += 1) {
99
166
  const top = boundary[offset];
100
167
  writeMeshVertex(positions, (baseStart + offset) * 3, [positions[top * 3], positions[top * 3 + 1], baseZ], operation);
@@ -1,4 +1,6 @@
1
1
  /** Pure geometry foundations without host initialization. @packageDocumentation */
2
+ export { validateSolidMesh } from "./mesh-solid.js";
3
+ export { computeTriangleMeshCentroid } from "./mesh-centroid.js";
2
4
  export { matchProfiles, } from "./profiles/matched.js";
3
5
  export { heightfieldMesh, preflightHeightfield, } from "./heightfield.js";
4
6
  export { preflightThreadedRodSolid as preflightThreadedRod, preflightInternalThreadCutterSolid as preflightInternalThreadCutter, preflightThreadedNutSolid as preflightThreadedNut, } from "./mechanical/thread-solids.js";
@@ -1,15 +1,14 @@
1
1
  import { GeometryError } from "./errors.js";
2
2
  import { triangulateLoop2 } from "./loft-triangulation.js";
3
3
  import { measureTriangleMesh, validateTriangleMesh } from "./mesh.js";
4
- import { assertMeshAllocationWithinLimits, } from "./model-limits.js";
4
+ import { assertMeshAllocationWithinLimits } from "./model-limits.js";
5
5
  import { writeMeshVertex } from "./numeric.js";
6
6
  /** @internal Pure closed simple-contour strips, retaining repeated correspondence slots as shared physical vertices. */
7
- export function simpleLoftMesh(geometry, limits, allocationGuard) {
7
+ export function simpleLoftMesh(geometry, limits) {
8
8
  const count = geometry.world[0][0].length, rowCount = geometry.world.length;
9
9
  const maximumVertices = count * rowCount, maximumTriangles = 2 * count * (rowCount - 1) + 2 * (count - 2);
10
10
  const bytes = 12 * (maximumVertices + maximumTriangles);
11
11
  assertMeshAllocationWithinLimits(maximumVertices, maximumTriangles, bytes, "loftMesh", limits);
12
- allocationGuard?.(maximumVertices, maximumTriangles, bytes, "loftMesh");
13
12
  const positions = new Float32Array(maximumVertices * 3), indices = new Uint32Array(maximumTriangles * 3);
14
13
  let vertexCount = 0, cursor = 0;
15
14
  const rows = geometry.world.map((contours, row) => {
@@ -11,6 +11,16 @@ const pointInTriangle = (point, a, b, c, epsilon) => {
11
11
  /** Ear-clips one normalized simple CCW loop into stable local vertex indices. */
12
12
  export function triangulateLoop2(loop) {
13
13
  const normalized = normalizeLoop2(loop, true);
14
+ const assertComparisonLimit = (requiredCount) => {
15
+ if (requiredCount > maximumComparisons)
16
+ throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
17
+ operation: "triangulateLoop2",
18
+ details: { requiredCount, allowedCount: maximumComparisons },
19
+ });
20
+ };
21
+ // Every accepted ear scans all remaining vertices plus its outer-loop comparison.
22
+ // Reject impossible workloads before clipping; unsuccessful ears still spend the live budget.
23
+ assertComparisonLimit(((normalized.length - 3) * (normalized.length + 6)) / 2);
14
24
  // Subdividing a straight edge introduces roundoff-sized turns. Treat them consistently
15
25
  // in both ear convexity and containment so they cannot consume a false ear or block the cap.
16
26
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -25,24 +35,17 @@ export function triangulateLoop2(loop) {
25
35
  const remaining = normalized.map((_, index) => index);
26
36
  const triangles = [];
27
37
  let comparisons = 0;
28
- const consume = () => {
29
- if (++comparisons > maximumComparisons)
30
- throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
31
- operation: "triangulateLoop2",
32
- details: { requiredCount: comparisons, allowedCount: maximumComparisons },
33
- });
34
- };
35
38
  while (remaining.length > 3) {
36
39
  let clipped = false;
37
40
  for (let offset = 0; offset < remaining.length; offset += 1) {
38
- consume();
41
+ assertComparisonLimit(++comparisons);
39
42
  const previous = remaining[(offset - 1 + remaining.length) % remaining.length];
40
43
  const current = remaining[offset];
41
44
  const next = remaining[(offset + 1) % remaining.length];
42
45
  if (cross(normalized[previous], normalized[current], normalized[next]) <= epsilon)
43
46
  continue;
44
47
  if (remaining.some((candidate) => {
45
- consume();
48
+ assertComparisonLimit(++comparisons);
46
49
  return (candidate !== previous &&
47
50
  candidate !== current &&
48
51
  candidate !== next &&
@@ -3,7 +3,7 @@ import { resolveExtrusionControls, resolveExtrusionDivisions, } from "./extrusio
3
3
  import { pointLoftProfile, prepareLoftGeometry } from "./loft-geometry.js";
4
4
  import {} from "./loft-options.js";
5
5
  import { simpleLoftMesh } from "./loft-simple-mesh.js";
6
- import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "./model-limits.js";
6
+ import { assertMeshAllocationWithinLimits, resolveModelingLimits } from "./model-limits.js";
7
7
  import { assertSamplingCount } from "./sampling-limits.js";
8
8
  export { triangulateLoop2 } from "./loft-triangulation.js";
9
9
  /**
@@ -14,12 +14,15 @@ export { triangulateLoop2 } from "./loft-triangulation.js";
14
14
  * Both ends use ear-clipped caps, supporting concave profiles without center fans.
15
15
  * Intermediate slices interpolate placed world vertices, never frame axes.
16
16
  * Transformed coordinates must fit finite Float32 mesh storage.
17
+ *
18
+ * @example Loft with explicit point correspondence
19
+ * ```ts
20
+ * const bottom = rectangleLoop({ size: [20, 12] });
21
+ * const top = rectangleLoop({ size: [30, 20], treatment: { kind: "round", radius: 3 } });
22
+ * loftMesh([{ profile: bottom, z: 0 }, { profile: top, z: 30 }], { correspondence: "reindex" });
23
+ * ```
17
24
  */
18
25
  export function loftMesh(stations, options) {
19
- return buildLoftMesh(stations, options);
20
- }
21
- /** @internal Shared pure loft with host allocation preflight. */
22
- export function buildLoftMesh(stations, options, allocationGuard) {
23
26
  if (options?.correspondence === undefined)
24
27
  throw new GeometryError("INVALID_ARGUMENT", "Point-loop lofts require an explicit correspondence method.", { operation: "loftMesh" });
25
28
  const limits = resolveModelingLimits(options.limits);
@@ -38,9 +41,17 @@ export function buildLoftMesh(stations, options, allocationGuard) {
38
41
  ...station,
39
42
  profile: pointLoftProfile(station.profile),
40
43
  }));
41
- return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits, allocationGuard);
44
+ return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits);
42
45
  }
43
- /** Linearly extrudes one simple profile along +Z with robust ear-clipped caps. */
46
+ /**
47
+ * Linearly extrudes one simple profile along +Z with robust ear-clipped caps.
48
+ *
49
+ * @example Extrude point data without an engine
50
+ * ```ts
51
+ * const outline = rectangleLoop({ size: [30, 20], treatment: { kind: "round", radius: 3 } });
52
+ * extrudeMesh(outline, { height: 8 });
53
+ * ```
54
+ */
44
55
  export function extrudeMesh(profile, options) {
45
56
  const { height } = options;
46
57
  const { center, scales, twistDegrees } = resolveExtrusionControls(height, options, "extrudeMesh");
@@ -1,16 +1,46 @@
1
- /** Adds two finite XYZ vectors component-wise. @example `add3([1, 2, 3], [4, 5, 6])` */
1
+ /**
2
+ * Adds two finite XYZ vectors component-wise.
3
+ * @example
4
+ * ```ts
5
+ * add3([1, 2, 3], [4, 5, 6]); // [5, 7, 9]
6
+ * ```
7
+ */
2
8
  export const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
3
- /** Subtracts `b` from `a`. @example `subtract3([4, 5, 6], [1, 2, 3])` */
9
+ /**
10
+ * Subtracts `b` from `a`.
11
+ * @example
12
+ * ```ts
13
+ * subtract3([4, 5, 6], [1, 2, 3]); // [3, 3, 3]
14
+ * ```
15
+ */
4
16
  export const subtract3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
5
- /** Multiplies a vector by a dimensionless scalar. @example `scale3([1, 2, 3], 2)` */
17
+ /**
18
+ * Multiplies a vector by a dimensionless scalar.
19
+ * @example
20
+ * ```ts
21
+ * scale3([1, 2, 3], 2); // [2, 4, 6]
22
+ * ```
23
+ */
6
24
  export const scale3 = (vector, scale) => [
7
25
  vector[0] * scale,
8
26
  vector[1] * scale,
9
27
  vector[2] * scale,
10
28
  ];
11
- /** Returns the scalar dot product. @example `dot3([1, 0, 0], [0, 1, 0])` */
29
+ /**
30
+ * Returns the scalar dot product.
31
+ * @example
32
+ * ```ts
33
+ * dot3([1, 0, 0], [0, 1, 0]); // 0
34
+ * ```
35
+ */
12
36
  export const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
13
- /** Returns the right-handed cross product. @example `cross3([1, 0, 0], [0, 1, 0])` */
37
+ /**
38
+ * Returns the right-handed cross product.
39
+ * @example
40
+ * ```ts
41
+ * cross3([1, 0, 0], [0, 1, 0]); // [0, 0, 1]
42
+ * ```
43
+ */
14
44
  export const cross3 = (a, b) => [
15
45
  a[1] * b[2] - a[2] * b[1],
16
46
  a[2] * b[0] - a[0] * b[2],
@@ -44,11 +44,10 @@ export const METRIC_FASTENER_STANDARDS = Object.freeze({
44
44
  });
45
45
  /** Returns the immutable standards record for one supported metric size. */
46
46
  export function getMetricFastenerStandard(size) {
47
- const value = METRIC_FASTENER_STANDARDS[size];
48
- if (value === undefined) {
47
+ if (!Object.hasOwn(METRIC_FASTENER_STANDARDS, size)) {
49
48
  throw new GeometryError("INVALID_ARGUMENT", `Unsupported metric fastener size: ${size}.`);
50
49
  }
51
- return value;
50
+ return METRIC_FASTENER_STANDARDS[size];
52
51
  }
53
52
  /** Returns one recommended metric clearance-hole diameter in millimetres. */
54
53
  export function getMetricClearanceHole(size, fit = "normal") {
@@ -234,6 +234,11 @@ const standardProfile = (kind, pitch, depth, root, crest, leftRun, rightRun) =>
234
234
  * The open path spans one pitch. Radial zero is the major envelope; negative
235
235
  * values point toward the shaft. Custom paths must be finite, axially
236
236
  * non-decreasing, periodic at their endpoints, and reach radial zero.
237
+ *
238
+ * @example Printable triangular thread profile
239
+ * ```ts
240
+ * createThreadProfile({ kind: "triangular", pitch: 1.75 });
241
+ * ```
237
242
  */
238
243
  export function createThreadProfile(spec) {
239
244
  const operation = "createThreadProfile";
@@ -36,11 +36,10 @@ const validateFitPolicy = (policy) => {
36
36
  export function resolvePrintableThreadFitPolicy(fit = "normal") {
37
37
  if (typeof fit !== "string")
38
38
  return validateFitPolicy(fit);
39
- const policy = PRINTABLE_THREAD_FIT_POLICIES[fit];
40
- if (policy === undefined) {
39
+ if (!Object.hasOwn(PRINTABLE_THREAD_FIT_POLICIES, fit)) {
41
40
  throw new GeometryError("INVALID_ARGUMENT", `Unsupported printable thread fit: ${fit}.`);
42
41
  }
43
- return policy;
42
+ return PRINTABLE_THREAD_FIT_POLICIES[fit];
44
43
  }
45
44
  /**
46
45
  * Applies role-specific radial compensation while preserving thread depth.
@@ -0,0 +1,36 @@
1
+ import { GeometryError } from "./errors.js";
2
+ import { analyzeTriangleMesh } from "./mesh.js";
3
+ /**
4
+ * Computes the uniform-density volume centroid of an oriented closed solid, including cavity shells.
5
+ * Coordinates are millimetres. Indexed closure and positive aggregate volume are required; callers
6
+ * may use validateSolidMesh first when geometric intersections and shell orientation are untrusted.
7
+ * Fresh mutable input is inspected on every call, and buffers are never changed.
8
+ *
9
+ * @param mesh - Consistently oriented closed mesh with positive enclosed volume.
10
+ * @returns A fresh immutable XYZ volume centroid.
11
+ */
12
+ export function computeTriangleMeshCentroid(mesh) {
13
+ const facts = analyzeTriangleMesh(mesh, { requireClosed: true });
14
+ if (!(facts.signedVolume > 0))
15
+ throw new GeometryError("INVALID_TOPOLOGY", "A volume centroid requires positive oriented volume.", { operation: "computeTriangleMeshCentroid" });
16
+ const origin = facts.bounds.min;
17
+ const totals = [0, 0, 0], corrections = [0, 0, 0];
18
+ for (let offset = 0; offset < mesh.indices.length; offset += 3) {
19
+ const a = mesh.indices[offset] * 3, b = mesh.indices[offset + 1] * 3, c = mesh.indices[offset + 2] * 3;
20
+ const ax = mesh.positions[a] - origin[0], ay = mesh.positions[a + 1] - origin[1], az = mesh.positions[a + 2] - origin[2];
21
+ const bx = mesh.positions[b] - origin[0], by = mesh.positions[b + 1] - origin[1], bz = mesh.positions[b + 2] - origin[2];
22
+ const cx = mesh.positions[c] - origin[0], cy = mesh.positions[c + 1] - origin[1], cz = mesh.positions[c + 2] - origin[2];
23
+ const volume = (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6;
24
+ const coordinates = [(ax + bx + cx) / 4, (ay + by + cy) / 4, (az + bz + cz) / 4];
25
+ for (let axis = 0; axis < 3; axis++) {
26
+ const value = volume * coordinates[axis], next = totals[axis] + value;
27
+ corrections[axis] +=
28
+ Math.abs(totals[axis]) >= Math.abs(value)
29
+ ? totals[axis] - next + value
30
+ : value - next + totals[axis];
31
+ totals[axis] = next;
32
+ }
33
+ }
34
+ const coordinate = (axis) => origin[axis] + (totals[axis] + corrections[axis]) / facts.signedVolume;
35
+ return Object.freeze([coordinate(0), coordinate(1), coordinate(2)]);
36
+ }