@metamesh/modeling 0.0.0-bootstrap.0 → 0.6.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 (129) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +313 -6
  3. package/THIRD_PARTY_NOTICES.md +153 -0
  4. package/dist/api/engine.d.ts +54 -0
  5. package/dist/api/geometry.d.ts +2912 -0
  6. package/dist/api/index.d.ts +1648 -0
  7. package/dist/api/text.d.ts +77 -0
  8. package/dist/engine/construction-operations.js +189 -0
  9. package/dist/engine/execution.js +227 -0
  10. package/dist/engine/export-3mf.js +100 -0
  11. package/dist/engine/extrusion-finishing.js +82 -0
  12. package/dist/engine/finalization.js +214 -0
  13. package/dist/engine/index.js +72 -0
  14. package/dist/engine/inspection-operations.js +52 -0
  15. package/dist/engine/kernel/LICENSE +201 -0
  16. package/dist/engine/kernel/manifold.js +2 -0
  17. package/dist/engine/kernel/manifold.wasm +0 -0
  18. package/dist/engine/loft-operations.js +156 -0
  19. package/dist/engine/mechanical-operations.js +51 -0
  20. package/dist/engine/modeling-context.js +38 -0
  21. package/dist/engine/native/adapter.js +20 -0
  22. package/dist/engine/native/context.js +209 -0
  23. package/dist/engine/native/copied-views.js +46 -0
  24. package/dist/engine/native/index.js +13 -0
  25. package/dist/engine/native/operations.js +115 -0
  26. package/dist/engine/native/ownership.js +98 -0
  27. package/dist/engine/native/regions.js +138 -0
  28. package/dist/engine/native/scoped-primitives.js +127 -0
  29. package/dist/engine/native/scoped-threads.js +34 -0
  30. package/dist/engine/native/threads.js +52 -0
  31. package/dist/engine/native/transforms.js +111 -0
  32. package/dist/engine/operation-environment.js +20 -0
  33. package/dist/engine/pattern-operations.js +36 -0
  34. package/dist/engine/pattern-placements.js +176 -0
  35. package/dist/engine/primitive-anchors.js +70 -0
  36. package/dist/engine/primitive-correspondence.js +74 -0
  37. package/dist/engine/primitive-operations.js +151 -0
  38. package/dist/engine/primitive-profile-types.js +1 -0
  39. package/dist/engine/primitive-profiles.js +250 -0
  40. package/dist/engine/profile-mesh.js +279 -0
  41. package/dist/engine/section-operations.js +93 -0
  42. package/dist/engine/shape-anchors.js +88 -0
  43. package/dist/engine/transform-operations.js +225 -0
  44. package/dist/engine/types.js +1 -0
  45. package/dist/geometry/anchors.js +226 -0
  46. package/dist/geometry/curves/evaluation.js +162 -0
  47. package/dist/geometry/curves/sampling.js +265 -0
  48. package/dist/geometry/curves/types.js +1 -0
  49. package/dist/geometry/cylinder.js +117 -0
  50. package/dist/geometry/edge-finishing.js +160 -0
  51. package/dist/geometry/errors.js +76 -0
  52. package/dist/geometry/extrusion.js +150 -0
  53. package/dist/geometry/heightfield.js +107 -0
  54. package/dist/geometry/index.js +42 -0
  55. package/dist/geometry/loft-correspondence.js +204 -0
  56. package/dist/geometry/loft-geometry.js +184 -0
  57. package/dist/geometry/loft-options.js +56 -0
  58. package/dist/geometry/loft-simple-mesh.js +92 -0
  59. package/dist/geometry/loft-triangulation.js +64 -0
  60. package/dist/geometry/loft.js +80 -0
  61. package/dist/geometry/math/vector3.js +41 -0
  62. package/dist/geometry/mechanical/gear-motion.js +94 -0
  63. package/dist/geometry/mechanical/gears.js +484 -0
  64. package/dist/geometry/mechanical/index.js +13 -0
  65. package/dist/geometry/mechanical/standards.js +67 -0
  66. package/dist/geometry/mechanical/thread-helices.js +44 -0
  67. package/dist/geometry/mechanical/thread-mesh-plan.js +358 -0
  68. package/dist/geometry/mechanical/thread-mesh.js +142 -0
  69. package/dist/geometry/mechanical/thread-profiles.js +368 -0
  70. package/dist/geometry/mechanical/thread-solids.js +230 -0
  71. package/dist/geometry/mechanical/thread-surface.js +433 -0
  72. package/dist/geometry/mechanical/threads.js +138 -0
  73. package/dist/geometry/mesh-planar.js +408 -0
  74. package/dist/geometry/mesh-quality.js +396 -0
  75. package/dist/geometry/mesh-signature.js +137 -0
  76. package/dist/geometry/mesh.js +300 -0
  77. package/dist/geometry/model-limits.js +50 -0
  78. package/dist/geometry/numeric.js +11 -0
  79. package/dist/geometry/paths/frames.js +118 -0
  80. package/dist/geometry/paths/helix.js +49 -0
  81. package/dist/geometry/paths/path.js +30 -0
  82. package/dist/geometry/paths/planar.js +135 -0
  83. package/dist/geometry/paths/resampling.js +124 -0
  84. package/dist/geometry/polygon-clipping.js +52 -0
  85. package/dist/geometry/primitive-options.js +1 -0
  86. package/dist/geometry/primitive-resolution.js +17 -0
  87. package/dist/geometry/primitive-resolvers.js +235 -0
  88. package/dist/geometry/profiles/matched.js +184 -0
  89. package/dist/geometry/profiles/open-profile.js +137 -0
  90. package/dist/geometry/profiles/profiles.js +70 -0
  91. package/dist/geometry/profiles/rectangle.js +235 -0
  92. package/dist/geometry/profiles/shapes.js +34 -0
  93. package/dist/geometry/profiles/span-sampling.js +28 -0
  94. package/dist/geometry/profiles/types.js +1 -0
  95. package/dist/geometry/profiles/validation.js +20 -0
  96. package/dist/geometry/regions.js +371 -0
  97. package/dist/geometry/resolution.js +236 -0
  98. package/dist/geometry/revolve/revolve-mesh.js +123 -0
  99. package/dist/geometry/sampling-limits.js +29 -0
  100. package/dist/geometry/stroke-path.js +181 -0
  101. package/dist/geometry/svg/errors.js +23 -0
  102. package/dist/geometry/svg/fill.js +172 -0
  103. package/dist/geometry/svg/index.js +1 -0
  104. package/dist/geometry/svg/matrix.js +130 -0
  105. package/dist/geometry/svg/parser.js +536 -0
  106. package/dist/geometry/svg/path.js +349 -0
  107. package/dist/geometry/svg/types.js +1 -0
  108. package/dist/geometry/svg-import.js +662 -0
  109. package/dist/geometry/sweeps/options.js +28 -0
  110. package/dist/geometry/sweeps/orientation.js +106 -0
  111. package/dist/geometry/sweeps/stations.js +161 -0
  112. package/dist/geometry/sweeps/sweep-closed.js +96 -0
  113. package/dist/geometry/sweeps/sweep-mesh.js +20 -0
  114. package/dist/geometry/sweeps/sweep-open.js +76 -0
  115. package/dist/geometry/sweeps/validation.js +116 -0
  116. package/dist/geometry/text-contours.js +32 -0
  117. package/dist/geometry/text-layout.js +1 -0
  118. package/dist/geometry/tolerance.js +53 -0
  119. package/dist/geometry/transform-geometry.js +41 -0
  120. package/dist/geometry/transform3.js +340 -0
  121. package/dist/geometry/types.js +1 -0
  122. package/dist/index.js +1 -0
  123. package/dist/model/execution-binding.js +30 -0
  124. package/dist/model/operations.js +152 -0
  125. package/dist/model/options.js +1 -0
  126. package/dist/model/types.js +1 -0
  127. package/dist/text/index.js +48 -0
  128. package/dist/text/vector-text-layout.js +279 -0
  129. package/package.json +89 -8
@@ -0,0 +1,368 @@
1
+ /** Periodic thread profiles and axial/radial fit compensation. */
2
+ import { GeometryError } from "../errors.js";
3
+ import { triangulateLoop2 } from "../loft.js";
4
+ import { resolveGeometryTolerance } from "../tolerance.js";
5
+ const BUTTRESS_DEFAULT_DEPTH_RATIO = 0.66271;
6
+ const BUTTRESS_CREST_FLAT_RATIO = 0.16316;
7
+ const BUTTRESS_PRESSURE_ANGLE_RADIANS = (7 * Math.PI) / 180;
8
+ const BUTTRESS_CLEARANCE_ANGLE_RADIANS = Math.PI / 4;
9
+ /** @internal Maximum periodic profile size accepted before meshing. */
10
+ export const MAX_THREAD_PROFILE_POINTS = 1_024;
11
+ /**
12
+ * Applies axial fit allowance to a validated periodic profile.
13
+ *
14
+ * Male crests/ridges narrow by the requested backlash; female cutter ridges
15
+ * widen by it. Periodic root endpoints remain fixed so pitch and lead do not
16
+ * change. The returned profile is a new deeply immutable value.
17
+ */
18
+ export function compensateThreadProfileAxially(profile, axialBacklash, role) {
19
+ const operation = "compensateThreadProfileAxially";
20
+ const backlash = assertFiniteThreadNumber(axialBacklash, "axialBacklash", operation);
21
+ if (backlash < 0 || backlash >= profile.pitch / 2) {
22
+ throw new GeometryError("INVALID_ARGUMENT", "Axial backlash must be non-negative and below half the pitch.", { operation });
23
+ }
24
+ if (role !== "female" && role !== "male") {
25
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported thread role.", { operation });
26
+ }
27
+ if (backlash === 0)
28
+ return profile;
29
+ return shiftThreadProfileFlanks(profile, backlash / 4, role, operation);
30
+ }
31
+ const shiftThreadProfileFlanks = (profile, flankShift, role, operation) => {
32
+ const direction = role === "male" ? -1 : 1;
33
+ const crestPoints = profile.points.filter((point) => Math.abs(point[1]) <= Number.EPSILON);
34
+ const crestCenter = crestPoints.length === 0
35
+ ? 0
36
+ : (Math.min(...crestPoints.map((point) => point[0])) +
37
+ Math.max(...crestPoints.map((point) => point[0]))) /
38
+ 2;
39
+ const points = profile.points.map(([axial, radial], index) => {
40
+ if (index === 0 || index === profile.points.length - 1 || axial === crestCenter)
41
+ return [axial, radial];
42
+ const shifted = axial + direction * Math.sign(axial - crestCenter) * flankShift;
43
+ return [Math.abs(shifted - crestCenter) <= 1e-12 ? crestCenter : shifted, radial];
44
+ });
45
+ if (points.some((point, index) => index > 0 && point[0] < points[index - 1][0])) {
46
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Axial compensation collapses or reverses profile features.", { operation });
47
+ }
48
+ const frozen = freezeThreadProfilePoints(points);
49
+ return Object.freeze({
50
+ ...profile,
51
+ crestFlatWidth: flatWidthAtRadius(frozen, 0, 1e-12),
52
+ points: frozen,
53
+ rootFlatWidth: flatWidthAtRadius(frozen, -profile.depth, 1e-12),
54
+ });
55
+ };
56
+ /** @internal Rejects a nonfinite numeric thread input before arithmetic. */
57
+ export const assertFiniteThreadNumber = (value, name, operation) => {
58
+ if (!Number.isFinite(value))
59
+ throw new GeometryError("NON_FINITE_VALUE", `${name} must be finite.`, { operation });
60
+ return value;
61
+ };
62
+ /** @internal Copies a periodic profile into an immutable point snapshot. */
63
+ export const freezeThreadProfilePoints = (points) => Object.freeze(points
64
+ .filter((point, index) => index === 0 ||
65
+ Math.hypot(point[0] - points[index - 1][0], point[1] - points[index - 1][1]) > 1e-12)
66
+ .map(([x, y]) => Object.freeze([x, y])));
67
+ /** @internal Scales only the radial dimensions of a validated profile. */
68
+ export function scaleThreadProfileRadially(profile, scale) {
69
+ const radialScale = assertFiniteThreadNumber(scale, "scale", "scaleThreadProfileRadially");
70
+ if (!(radialScale > 0)) {
71
+ throw new GeometryError("INVALID_ARGUMENT", "Thread profile radial scale must be positive.", {
72
+ operation: "scaleThreadProfileRadially",
73
+ });
74
+ }
75
+ if (radialScale === 1)
76
+ return profile;
77
+ const depth = profile.depth * radialScale;
78
+ const points = freezeThreadProfilePoints(profile.points.map(([axial, radial]) => [axial, radial * radialScale]));
79
+ return Object.freeze({
80
+ ...profile,
81
+ crestFlatWidth: flatWidthAtRadius(points, 0, 1e-12),
82
+ depth,
83
+ points,
84
+ rootFlatWidth: flatWidthAtRadius(points, -depth, 1e-12),
85
+ });
86
+ }
87
+ /**
88
+ * Applies printable axial fit plus the conservative phase allowance required by circular facets.
89
+ *
90
+ * One triangulated facet spans `pitch * starts / circularSegments` axially. A directed zipper
91
+ * triangle can expose either end of that complete span when independently faceted parts rotate.
92
+ * This helper distributes the full conservative flank clearance between the male crest and female
93
+ * root according to their available flat widths, and rejects a resolution that cannot represent
94
+ * it exactly.
95
+ */
96
+ export function compensateThreadProfileForFaceting(profile, axialBacklash, role, starts, circularSegments) {
97
+ const operation = "compensateThreadProfileForFaceting";
98
+ const startCount = assertFiniteThreadNumber(starts, "starts", operation);
99
+ const segments = assertFiniteThreadNumber(circularSegments, "circularSegments", operation);
100
+ if (!Number.isInteger(startCount) ||
101
+ startCount < 1 ||
102
+ startCount > 32 ||
103
+ !Number.isInteger(segments) ||
104
+ segments < 3 ||
105
+ segments % startCount !== 0) {
106
+ throw new GeometryError("INVALID_ARGUMENT", "Thread starts and circular segments must be positive aligned integers.", { operation });
107
+ }
108
+ const requestedBacklash = assertFiniteThreadNumber(axialBacklash, "axialBacklash", operation);
109
+ if (requestedBacklash < 0 || requestedBacklash >= profile.pitch / 2) {
110
+ throw new GeometryError("INVALID_ARGUMENT", "Axial backlash must be non-negative and below half the pitch.", { operation });
111
+ }
112
+ if (role !== "female" && role !== "male") {
113
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported thread role.", { operation });
114
+ }
115
+ const baseShift = requestedBacklash / 4;
116
+ const { femaleCapacity, maleCapacity } = axialCompensationCapacities(profile);
117
+ if (baseShift > maleCapacity + 1e-12 || baseShift > femaleCapacity + 1e-12) {
118
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Requested axial backlash consumes a thread crest or root flat.", { operation });
119
+ }
120
+ const requiredExtra = (profile.pitch * startCount) / segments;
121
+ const maleAvailable = Math.max(0, maleCapacity - baseShift);
122
+ const femaleAvailable = Math.max(0, femaleCapacity - baseShift);
123
+ const minimumMaleExtra = Math.max(0, requiredExtra - femaleAvailable);
124
+ const maximumMaleExtra = Math.min(requiredExtra, maleAvailable);
125
+ if (minimumMaleExtra > maximumMaleExtra + 1e-12) {
126
+ throw new GeometryError("COMPLEXITY_LIMIT", "Circular thread resolution is too coarse for the requested starts and axial fit.", {
127
+ operation,
128
+ details: { requiredExtra, segments, starts: startCount },
129
+ });
130
+ }
131
+ const maleExtra = Math.max(minimumMaleExtra, Math.min(maximumMaleExtra, requiredExtra / 2));
132
+ const roleExtra = role === "male" ? maleExtra : requiredExtra - maleExtra;
133
+ try {
134
+ return shiftThreadProfileFlanks(profile, baseShift + roleExtra, role, operation);
135
+ }
136
+ catch (error) {
137
+ if (error instanceof GeometryError && error.code === "DEGENERATE_GEOMETRY") {
138
+ throw new GeometryError("COMPLEXITY_LIMIT", "Circular thread resolution cannot preserve this profile's asymmetric flats.", {
139
+ operation,
140
+ details: { requiredExtra, segments, starts: startCount },
141
+ });
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+ const axialCompensationCapacities = (profile) => {
147
+ const radialTolerance = 1e-12;
148
+ const crestPoints = profile.points.filter((point) => Math.abs(point[1]) <= radialTolerance);
149
+ if (crestPoints.length === 0) {
150
+ throw new GeometryError("INVALID_TOPOLOGY", "Thread profile does not reach its major envelope.", {
151
+ operation: "compensateThreadProfileForFaceting",
152
+ });
153
+ }
154
+ const crestMinimum = Math.min(...crestPoints.map(([axial]) => axial));
155
+ const crestMaximum = Math.max(...crestPoints.map(([axial]) => axial));
156
+ const crestCenter = (crestMinimum + crestMaximum) / 2;
157
+ const rootRadius = -profile.depth;
158
+ const firstRaised = profile.points.findIndex((point) => point[1] > rootRadius + radialTolerance);
159
+ let lastRaised = -1;
160
+ for (let index = profile.points.length - 1; index >= 0; index -= 1) {
161
+ if (profile.points[index][1] > rootRadius + radialTolerance) {
162
+ lastRaised = index;
163
+ break;
164
+ }
165
+ }
166
+ if (firstRaised <= 0 || lastRaised < firstRaised || lastRaised >= profile.points.length - 1) {
167
+ throw new GeometryError("INVALID_TOPOLOGY", "Thread profile has no bounded ridge.", {
168
+ operation: "compensateThreadProfileForFaceting",
169
+ });
170
+ }
171
+ const leftRootRun = profile.points[firstRaised - 1][0] - profile.points[0][0];
172
+ const rightRootRun = profile.points.at(-1)[0] - profile.points[lastRaised + 1][0];
173
+ return Object.freeze({
174
+ // Shifting is symmetric about the crest, but a custom profile need not have symmetric
175
+ // periodic root flats. The smaller side is the only clearance both flanks can realize.
176
+ femaleCapacity: Math.max(0, Math.min(leftRootRun, rightRootRun)),
177
+ maleCapacity: Math.max(0, Math.min(crestCenter - crestMinimum, crestMaximum - crestCenter)),
178
+ });
179
+ };
180
+ const flatWidthAtRadius = (points, radial, tolerance) => points.reduce((width, point, index) => {
181
+ if (index === 0)
182
+ return width;
183
+ const previous = points[index - 1];
184
+ return Math.abs(previous[1] - radial) <= tolerance && Math.abs(point[1] - radial) <= tolerance
185
+ ? width + point[0] - previous[0]
186
+ : width;
187
+ }, 0);
188
+ /** @internal Extracts the connected tooth section between its root transitions. */
189
+ export const createThreadRidgeSection = (points, minimum, linearTolerance, operation) => {
190
+ const firstRaised = points.findIndex((point) => point[1] > minimum + linearTolerance);
191
+ let lastRaised = -1;
192
+ for (let index = points.length - 1; index >= 0; index -= 1) {
193
+ if (points[index][1] > minimum + linearTolerance) {
194
+ lastRaised = index;
195
+ break;
196
+ }
197
+ }
198
+ if (firstRaised <= 0 || lastRaised < firstRaised || lastRaised >= points.length - 1) {
199
+ throw new GeometryError("INVALID_TOPOLOGY", "Thread profiles must leave the minor envelope and return to it within one pitch.", { operation });
200
+ }
201
+ for (let index = firstRaised; index <= lastRaised; index += 1) {
202
+ if (points[index][1] <= minimum + linearTolerance) {
203
+ throw new GeometryError("INVALID_TOPOLOGY", "A thread profile must describe one connected ridge above the minor envelope.", { operation });
204
+ }
205
+ }
206
+ // Period endpoints and adjacent root flats belong to the root cylinder. Sweeping them and then
207
+ // closing the ridge section retraces its minor-envelope edge, creating overlapping cap faces and
208
+ // zero-area triangles. Keep exactly the connected tooth plus its two root transition points.
209
+ const section = points.slice(firstRaised - 1, lastRaised + 2);
210
+ triangulateLoop2([...section].reverse());
211
+ return section;
212
+ };
213
+ const standardProfile = (kind, pitch, depth, root, crest, leftRun, rightRun) => {
214
+ const half = pitch / 2;
215
+ return Object.freeze({
216
+ kind,
217
+ pitch,
218
+ depth,
219
+ rootFlatWidth: root,
220
+ crestFlatWidth: crest,
221
+ points: freezeThreadProfilePoints([
222
+ [-half, -depth],
223
+ [-half + root / 2, -depth],
224
+ [-half + root / 2 + leftRun, 0],
225
+ [half - root / 2 - rightRun, 0],
226
+ [half - root / 2, -depth],
227
+ [half, -depth],
228
+ ]),
229
+ });
230
+ };
231
+ /**
232
+ * Creates a periodic thread profile for the shaft-locked helical mesher.
233
+ *
234
+ * The open path spans one pitch. Radial zero is the major envelope; negative
235
+ * values point toward the shaft. Custom paths must be finite, axially
236
+ * non-decreasing, periodic at their endpoints, and reach radial zero.
237
+ */
238
+ export function createThreadProfile(spec) {
239
+ const operation = "createThreadProfile";
240
+ const pitch = assertFiniteThreadNumber(spec.pitch, "pitch", operation);
241
+ const tolerance = resolveGeometryTolerance(Math.abs(pitch), spec.tolerance);
242
+ if (!(pitch > tolerance.linear))
243
+ throw new GeometryError("INVALID_ARGUMENT", "pitch must exceed tolerance.", { operation });
244
+ if (spec.kind === "custom") {
245
+ if (spec.points.length < 4)
246
+ throw new GeometryError("DEGENERATE_GEOMETRY", "A custom profile needs at least four points.", { operation });
247
+ if (spec.points.length > MAX_THREAD_PROFILE_POINTS)
248
+ throw new GeometryError("COMPLEXITY_LIMIT", `A custom profile accepts at most ${MAX_THREAD_PROFILE_POINTS} points.`, { operation, details: { pointCount: spec.points.length } });
249
+ const parsed = spec.points.map(([x, y], index) => [
250
+ assertFiniteThreadNumber(x, `points[${index}].x`, operation),
251
+ assertFiniteThreadNumber(y, `points[${index}].y`, operation),
252
+ ]);
253
+ const points = freezeThreadProfilePoints(parsed);
254
+ if (points.length < 4)
255
+ throw new GeometryError("DEGENERATE_GEOMETRY", "A custom profile needs at least four distinct consecutive points.", { operation });
256
+ if (Math.abs(points[0][0] + pitch / 2) > tolerance.linear ||
257
+ Math.abs(points.at(-1)[0] - pitch / 2) > tolerance.linear) {
258
+ throw new GeometryError("INVALID_ARGUMENT", "Custom profiles must span [-pitch/2, pitch/2].", { operation });
259
+ }
260
+ if (points.some((point, index) => index > 0 && point[0] < points[index - 1][0])) {
261
+ throw new GeometryError("INVALID_ARGUMENT", "Custom profile axial coordinates must not decrease.", { operation });
262
+ }
263
+ let equalAxialRun = 1;
264
+ for (let index = 1; index < points.length; index += 1) {
265
+ equalAxialRun =
266
+ Math.abs(points[index][0] - points[index - 1][0]) <= tolerance.linear
267
+ ? equalAxialRun + 1
268
+ : 1;
269
+ if (equalAxialRun > 2) {
270
+ throw new GeometryError("INVALID_TOPOLOGY", "Custom profiles may use one segment, but not a retracing chain, at one axial coordinate.", { operation });
271
+ }
272
+ }
273
+ if (Math.abs(points[0][1] - points.at(-1)[1]) > tolerance.linear) {
274
+ throw new GeometryError("INVALID_TOPOLOGY", "Custom profile endpoint radii must match.", {
275
+ operation,
276
+ });
277
+ }
278
+ const maximum = Math.max(...points.map((point) => point[1]));
279
+ const minimum = Math.min(...points.map((point) => point[1]));
280
+ if (Math.abs(maximum) > tolerance.linear || !(minimum < -tolerance.linear)) {
281
+ throw new GeometryError("INVALID_ARGUMENT", "Custom profiles must reach radial zero and have positive depth.", { operation });
282
+ }
283
+ if (Math.abs(points[0][1] - minimum) > tolerance.linear ||
284
+ Math.abs(points.at(-1)[1] - minimum) > tolerance.linear) {
285
+ throw new GeometryError("INVALID_TOPOLOGY", "Custom profile endpoints must lie on the minor envelope so periodic ridges join the shaft.", { operation });
286
+ }
287
+ // Canonicalize accepted envelope values so downstream fit/scaling helpers do not need to
288
+ // rediscover the caller's tolerance. This also keeps reported flat widths exact and truthful.
289
+ const normalizedPoints = freezeThreadProfilePoints(points.map(([axial, radial]) => [
290
+ axial,
291
+ Math.abs(radial) <= tolerance.linear
292
+ ? 0
293
+ : Math.abs(radial - minimum) <= tolerance.linear
294
+ ? minimum
295
+ : radial,
296
+ ]));
297
+ const firstCrest = normalizedPoints.findIndex((point) => point[1] === 0);
298
+ let lastCrest = firstCrest;
299
+ for (let index = normalizedPoints.length - 1; index >= firstCrest; index -= 1) {
300
+ if (normalizedPoints[index][1] === 0) {
301
+ lastCrest = index;
302
+ break;
303
+ }
304
+ }
305
+ if (normalizedPoints.slice(firstCrest, lastCrest + 1).some((point) => point[1] !== 0)) {
306
+ throw new GeometryError("INVALID_TOPOLOGY", "A custom thread profile must have one contiguous crest plateau.", { operation });
307
+ }
308
+ createThreadRidgeSection(normalizedPoints, minimum, tolerance.linear, operation);
309
+ return Object.freeze({
310
+ kind: spec.kind,
311
+ pitch,
312
+ depth: -minimum,
313
+ crestFlatWidth: flatWidthAtRadius(normalizedPoints, 0, tolerance.linear),
314
+ rootFlatWidth: flatWidthAtRadius(normalizedPoints, minimum, tolerance.linear),
315
+ points: normalizedPoints,
316
+ });
317
+ }
318
+ if (spec.kind === "triangular") {
319
+ const crestRatio = assertFiniteThreadNumber(spec.crestFlatRatio ?? 1 / 8, "crestFlatRatio", operation);
320
+ const rootRatio = assertFiniteThreadNumber(spec.rootFlatRatio ?? 1 / 4, "rootFlatRatio", operation);
321
+ if (crestRatio < 0 || rootRatio < 0 || crestRatio + rootRatio >= 1) {
322
+ throw new GeometryError("INVALID_ARGUMENT", "Flat ratios must be non-negative and sum below one.", { operation });
323
+ }
324
+ const crest = pitch * crestRatio;
325
+ const root = pitch * rootRatio;
326
+ const run = (pitch - crest - root) / 2;
327
+ const depth = run * Math.tan(Math.PI / 3);
328
+ if (!(depth > tolerance.linear))
329
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Flats leave no triangular thread depth.", {
330
+ operation,
331
+ });
332
+ return standardProfile(spec.kind, pitch, depth, root, crest, run, run);
333
+ }
334
+ const depth = assertFiniteThreadNumber(spec.depth ?? (spec.kind === "buttress" ? pitch * BUTTRESS_DEFAULT_DEPTH_RATIO : pitch * 0.5), "depth", operation);
335
+ if (!(depth > tolerance.linear))
336
+ throw new GeometryError("INVALID_ARGUMENT", "depth must exceed tolerance.", { operation });
337
+ if (spec.kind === "square")
338
+ return standardProfile(spec.kind, pitch, depth, pitch / 2, pitch / 2, 0, 0);
339
+ if (spec.kind === "buttress") {
340
+ const side = spec.pressureFlankSide ?? "positive";
341
+ if (side !== "negative" && side !== "positive") {
342
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported buttress pressure-flank side.", {
343
+ operation,
344
+ });
345
+ }
346
+ const pressureRun = depth * Math.tan(BUTTRESS_PRESSURE_ANGLE_RADIANS);
347
+ const clearanceRun = depth * Math.tan(BUTTRESS_CLEARANCE_ANGLE_RADIANS);
348
+ const crest = pitch * BUTTRESS_CREST_FLAT_RATIO;
349
+ const root = pitch - pressureRun - clearanceRun - crest;
350
+ if (!(root > tolerance.linear))
351
+ throw new GeometryError("DEGENERATE_GEOMETRY", "depth leaves no root flat for the 7/45-degree buttress profile.", { operation });
352
+ return standardProfile(spec.kind, pitch, depth, root, crest, side === "positive" ? clearanceRun : pressureRun, side === "positive" ? pressureRun : clearanceRun);
353
+ }
354
+ const includedAngle = spec.kind === "acme"
355
+ ? 29
356
+ : assertFiniteThreadNumber(spec.threadAngle ?? 30, "threadAngle", operation);
357
+ if (includedAngle < 0 || includedAngle >= 180) {
358
+ throw new GeometryError("INVALID_ARGUMENT", "threadAngle must be at least 0 degrees and below 180 degrees.", { operation });
359
+ }
360
+ const halfIncludedAngle = includedAngle / 2;
361
+ const flankRun = depth * (halfIncludedAngle === 45 ? 1 : Math.tan((halfIncludedAngle * Math.PI) / 180));
362
+ const remaining = pitch - flankRun * 2;
363
+ if (!(remaining > tolerance.linear * 2))
364
+ throw new GeometryError("DEGENERATE_GEOMETRY", "depth leaves no crest/root flats.", {
365
+ operation,
366
+ });
367
+ return standardProfile(spec.kind, pitch, depth, remaining / 2, remaining / 2, flankRun, flankRun);
368
+ }
@@ -0,0 +1,230 @@
1
+ import { GeometryError } from "../errors.js";
2
+ import { resolveCircularSegments } from "../resolution.js";
3
+ import { resolveGeometryTolerance } from "../tolerance.js";
4
+ import { compensateThreadDiameters, preflightHelicalThreadSolidMesh, resolveThreadCircularSegments, } from "./index.js";
5
+ import { compensateThreadProfileForFaceting, scaleThreadProfileRadially, } from "./thread-profiles.js";
6
+ const THREAD_FACETING_MARGIN = 0.001;
7
+ const resolveCompensatedThreadBevelSize = (options, role, compensated, style) => {
8
+ if (options.bevelSize !== undefined)
9
+ return options.bevelSize;
10
+ if (style !== "blunt")
11
+ return options.profile.depth;
12
+ const envelopeDiameter = role === "female" ? compensated.majorDiameter : compensated.minorDiameter;
13
+ return envelopeDiameter / 12;
14
+ };
15
+ export const resolveCompensatedThreadSolidMesh = (options, role) => {
16
+ if (options.bevelSize !== undefined &&
17
+ (!Number.isFinite(options.bevelSize) || options.bevelSize < 0)) {
18
+ throw new GeometryError("INVALID_ARGUMENT", "Thread bevel size must be finite and non-negative.", {
19
+ operation: "threadedSolid",
20
+ });
21
+ }
22
+ const compensated = compensateThreadDiameters({
23
+ nominalMajorDiameter: options.nominalMajorDiameter,
24
+ threadDepth: options.profile.depth,
25
+ role,
26
+ ...(options.fit === undefined ? {} : { fit: options.fit }),
27
+ });
28
+ const compensatedFemale = role === "female"
29
+ ? compensated
30
+ : compensateThreadDiameters({
31
+ nominalMajorDiameter: options.nominalMajorDiameter,
32
+ threadDepth: options.profile.depth,
33
+ role: "female",
34
+ ...(options.fit === undefined ? {} : { fit: options.fit }),
35
+ });
36
+ const requestedEnds = options.ends ?? {};
37
+ const bottomStyle = requestedEnds.bottom ?? "blunt";
38
+ const topStyle = requestedEnds.top ?? "blunt";
39
+ const bottomBevelSize = resolveCompensatedThreadBevelSize(options, role, compensated, bottomStyle);
40
+ const topBevelSize = resolveCompensatedThreadBevelSize(options, role, compensated, topStyle);
41
+ const femaleBottomBevelSize = resolveCompensatedThreadBevelSize(options, "female", compensatedFemale, bottomStyle);
42
+ const femaleTopBevelSize = resolveCompensatedThreadBevelSize(options, "female", compensatedFemale, topStyle);
43
+ const starts = options.starts ?? 1;
44
+ const hypotheticalFemaleLength = options.length + options.profile.pitch * 2;
45
+ const femaleBevelOutset = (style, size) => style === "blunt"
46
+ ? Math.min(size, hypotheticalFemaleLength / 2)
47
+ : Math.max(0, Math.min(size, hypotheticalFemaleLength / 2) - options.profile.depth);
48
+ const maximumRadialOutset = Math.max(options.bevelBottom === true ? femaleBevelOutset(bottomStyle, femaleBottomBevelSize) : 0, options.bevelTop === true ? femaleBevelOutset(topStyle, femaleTopBevelSize) : 0);
49
+ const segments = resolveThreadCircularSegments({
50
+ nominalMajorDiameter: options.nominalMajorDiameter,
51
+ profile: options.profile,
52
+ starts,
53
+ maximumRadialOutset,
54
+ ...(options.limits === undefined ? {} : { limits: options.limits }),
55
+ ...(options.fit === undefined ? {} : { fit: options.fit }),
56
+ ...(options.resolution === undefined ? {} : { resolution: options.resolution }),
57
+ });
58
+ const resolution = { ...options.resolution, circularSegments: segments };
59
+ const faceting = role === "female" ? 1 / Math.cos(Math.PI / segments) : 1;
60
+ // A 1 µm diameter margin avoids Boolean contact caused by Float32 mesh roundoff without
61
+ // materially changing the requested printable allowance.
62
+ const majorDiameter = compensated.majorDiameter * faceting + (role === "female" ? THREAD_FACETING_MARGIN : 0);
63
+ // The shared helper distributes one complete zipper-facet phase span across the two roles, so
64
+ // independently triangulated mating surfaces remain nested at arbitrary screw rotations.
65
+ const compensatedProfile = compensateThreadProfileForFaceting(options.profile, compensated.axialBacklash, role, starts, segments);
66
+ const meshingProfile = scaleThreadProfileRadially(compensatedProfile, faceting);
67
+ // Internal tools extend beyond both requested faces so subtraction exposes
68
+ // the complete helical cavity rather than a ridge-center end cap.
69
+ const axialExtension = role === "female" ? options.profile.pitch : 0;
70
+ const workingLength = options.length + axialExtension * 2;
71
+ // End treatment coordinates belong to the requested solid, while the female cutter is meshed in
72
+ // an extended working interval. Include that extension in each reserved role-specific end so the
73
+ // translated cutter still starts and stops its thread at the caller's requested coordinates.
74
+ const ends = {
75
+ ...requestedEnds,
76
+ endLength: Math.max(requestedEnds.endLength ?? 0, options.bevelBottom === true && bottomStyle === "blunt" ? bottomBevelSize : 0, options.bevelTop === true && topStyle === "blunt" ? topBevelSize : 0) + axialExtension,
77
+ leadInEnvelope: role === "female" ? "major" : "minor",
78
+ };
79
+ return Object.freeze({
80
+ axialExtension,
81
+ circularSegments: segments,
82
+ meshOptions: Object.freeze({
83
+ ...options,
84
+ profile: meshingProfile,
85
+ majorDiameter,
86
+ length: workingLength,
87
+ resolution,
88
+ bevelBottomSize: bottomBevelSize,
89
+ bevelMode: role === "female" ? "outset" : "inset",
90
+ bevelOffset: axialExtension,
91
+ bevelTopSize: topBevelSize,
92
+ ...(options.bevelBottom === undefined ? {} : { bevelBottom: options.bevelBottom }),
93
+ ...(options.bevelTop === undefined ? {} : { bevelTop: options.bevelTop }),
94
+ ends,
95
+ }),
96
+ });
97
+ };
98
+ const preflightCompensatedThreadSolid = (options, role) => preflightHelicalThreadSolidMesh(resolveCompensatedThreadSolidMesh(options, role).meshOptions);
99
+ /** Preflights the exact compensated direct mesh used by the threaded rod constructor. */
100
+ export function preflightThreadedRodSolid(options) {
101
+ return preflightCompensatedThreadSolid(options, "male");
102
+ }
103
+ /** Preflights the exact extended mesh used by the internal thread cutter constructor. */
104
+ export function preflightInternalThreadCutterSolid(options) {
105
+ return preflightCompensatedThreadSolid(options, "female");
106
+ }
107
+ const throwInvalidNutScalar = (label, parameter, requirement, operation) => {
108
+ throw new GeometryError("INVALID_ARGUMENT", `Threaded nut ${label} must be ${requirement}.`, {
109
+ operation,
110
+ details: { parameter },
111
+ });
112
+ };
113
+ export const resolveThreadedNutSolid = (options, operation) => {
114
+ // Keep these public boundary checks ahead of resolution and all Manifold body construction.
115
+ if (!Number.isFinite(options.width) || !(options.width > 0)) {
116
+ throwInvalidNutScalar("width", "width", "finite and greater than zero", operation);
117
+ }
118
+ if (!Number.isFinite(options.thickness) || !(options.thickness > 0)) {
119
+ throwInvalidNutScalar("thickness", "thickness", "finite and greater than zero", operation);
120
+ }
121
+ if (options.chamferSize !== undefined &&
122
+ (!Number.isFinite(options.chamferSize) || options.chamferSize < 0)) {
123
+ throwInvalidNutScalar("chamfer size", "chamferSize", "finite and non-negative", operation);
124
+ }
125
+ if (options.shape !== "hex" && options.shape !== "round" && options.shape !== "square") {
126
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported threaded nut shape.", {
127
+ operation,
128
+ details: { parameter: "shape" },
129
+ });
130
+ }
131
+ const bevel = options.bevel ?? "automatic";
132
+ if (bevel !== "automatic" &&
133
+ bevel !== "both" &&
134
+ bevel !== "inside" &&
135
+ bevel !== "none" &&
136
+ bevel !== "outside") {
137
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported threaded nut bevel selection.", {
138
+ operation,
139
+ details: { parameter: "bevel" },
140
+ });
141
+ }
142
+ const segments = options.shape === "hex"
143
+ ? 6
144
+ : options.shape === "square"
145
+ ? 4
146
+ : resolveCircularSegments(options.width / 2, options.bodyResolution ?? options.resolution, options.limits);
147
+ const sideCosine = Math.cos(Math.PI / segments);
148
+ const outerRadius = options.shape === "round" ? options.width / 2 : options.width / (2 * sideCosine);
149
+ const inside = bevel === "both" || bevel === "inside" || bevel === "automatic";
150
+ const outside = bevel === "both" || bevel === "outside" || bevel === "automatic";
151
+ const chamferSize = options.chamferSize ?? Math.min(options.profile.depth, options.thickness / 4);
152
+ if (!Number.isFinite(chamferSize) || chamferSize < 0) {
153
+ throwInvalidNutScalar("chamfer size", "chamferSize", "finite and non-negative", operation);
154
+ }
155
+ if ((inside || outside) && chamferSize > 0 && chamferSize * 2 >= options.thickness) {
156
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Threaded nut chamfer size must be smaller than half the body thickness.", {
157
+ operation,
158
+ details: {
159
+ chamferSize,
160
+ parameter: "chamferSize",
161
+ reason: "insufficient-axial-space",
162
+ thickness: options.thickness,
163
+ },
164
+ });
165
+ }
166
+ const bevelBottom = outside && !options.flushBuildPlateBottom && chamferSize > 0;
167
+ const bevelTop = outside && chamferSize > 0;
168
+ const cutter = resolveCompensatedThreadSolidMesh({
169
+ ...options,
170
+ length: options.thickness,
171
+ bevelBottom: inside,
172
+ bevelTop: inside,
173
+ bevelSize: chamferSize,
174
+ }, "female");
175
+ const plainBodyFaceInradius = outerRadius * sideCosine;
176
+ const chamferedBodyFaceInradius = (outerRadius - chamferSize) * sideCosine;
177
+ const bottomBodyFaceInradius = bevelBottom ? chamferedBodyFaceInradius : plainBodyFaceInradius;
178
+ const topBodyFaceInradius = bevelTop ? chamferedBodyFaceInradius : plainBodyFaceInradius;
179
+ const minimumBodyFaceInradius = Math.min(bottomBodyFaceInradius, topBodyFaceInradius);
180
+ const cutterMajorRadius = cutter.meshOptions.majorDiameter / 2;
181
+ const cutterFaceRadiusFor = (style) => inside
182
+ ? cutterMajorRadius +
183
+ (style === "blunt" ? chamferSize : Math.max(0, chamferSize - options.profile.depth))
184
+ : cutterMajorRadius;
185
+ const bottomCutterFaceRadius = cutterFaceRadiusFor(options.ends?.bottom ?? "blunt");
186
+ const topCutterFaceRadius = cutterFaceRadiusFor(options.ends?.top ?? "blunt");
187
+ const cutterFaceRadius = Math.max(bottomCutterFaceRadius, topCutterFaceRadius);
188
+ const minimumFaceWall = Math.min(bottomBodyFaceInradius - bottomCutterFaceRadius, topBodyFaceInradius - topCutterFaceRadius);
189
+ const requiredFaceWall = Math.max(THREAD_FACETING_MARGIN, resolveGeometryTolerance(Math.max(options.width, options.thickness, cutter.meshOptions.majorDiameter), options.tolerance).linear);
190
+ if (!(minimumFaceWall > requiredFaceWall)) {
191
+ const chamferIsActive = (inside || outside) && chamferSize > 0;
192
+ throw new GeometryError("DEGENERATE_GEOMETRY", chamferIsActive
193
+ ? "Threaded nut chamfer must leave a positive end-face annulus around the compensated thread."
194
+ : "Threaded nut width must leave a positive end-face annulus around the compensated thread.", {
195
+ operation,
196
+ details: {
197
+ chamferSize,
198
+ cutterFaceRadius,
199
+ minimumBodyFaceInradius,
200
+ minimumFaceWall,
201
+ parameter: chamferIsActive ? "chamferSize" : "width",
202
+ reason: "insufficient-face-annulus",
203
+ requiredFaceWall,
204
+ },
205
+ });
206
+ }
207
+ const thread = preflightHelicalThreadSolidMesh(cutter.meshOptions);
208
+ return Object.freeze({
209
+ bevelBottom,
210
+ bevelTop,
211
+ cutter,
212
+ outerRadius,
213
+ preflight: Object.freeze({
214
+ bodySegments: segments,
215
+ chamferSize,
216
+ cutterFaceRadius,
217
+ minimumBodyFaceInradius,
218
+ minimumFaceWall,
219
+ thread,
220
+ threadCircularSegments: cutter.circularSegments,
221
+ }),
222
+ });
223
+ };
224
+ /**
225
+ * Resolves a nut's exact body/cutter chamfer and preflights its extended thread mesh without
226
+ * allocating Manifold WASM objects.
227
+ */
228
+ export function preflightThreadedNutSolid(options) {
229
+ return resolveThreadedNutSolid(options, "preflightThreadedNutSolid").preflight;
230
+ }