@metamesh/modeling 0.0.0-stage → 0.7.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 (131) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/LICENSE +200 -0
  3. package/README.md +376 -2
  4. package/THIRD_PARTY_NOTICES.md +153 -0
  5. package/dist/api/engine.d.ts +71 -0
  6. package/dist/api/geometry.d.ts +3136 -0
  7. package/dist/api/index.d.ts +2087 -0
  8. package/dist/api/text.d.ts +93 -0
  9. package/dist/engine/construction-operations.js +189 -0
  10. package/dist/engine/execution.js +227 -0
  11. package/dist/engine/export-3mf.js +100 -0
  12. package/dist/engine/extrusion-finishing.js +82 -0
  13. package/dist/engine/finalization.js +214 -0
  14. package/dist/engine/index.js +83 -0
  15. package/dist/engine/inspection-operations.js +52 -0
  16. package/dist/engine/kernel/LICENSE +201 -0
  17. package/dist/engine/kernel/manifold.js +2 -0
  18. package/dist/engine/kernel/manifold.wasm +0 -0
  19. package/dist/engine/loft-operations.js +156 -0
  20. package/dist/engine/mechanical-operations.js +51 -0
  21. package/dist/engine/modeling-context.js +38 -0
  22. package/dist/engine/native/adapter.js +20 -0
  23. package/dist/engine/native/context.js +209 -0
  24. package/dist/engine/native/copied-views.js +46 -0
  25. package/dist/engine/native/index.js +13 -0
  26. package/dist/engine/native/operations.js +115 -0
  27. package/dist/engine/native/ownership.js +98 -0
  28. package/dist/engine/native/regions.js +138 -0
  29. package/dist/engine/native/scoped-primitives.js +127 -0
  30. package/dist/engine/native/scoped-threads.js +34 -0
  31. package/dist/engine/native/threads.js +52 -0
  32. package/dist/engine/native/transforms.js +111 -0
  33. package/dist/engine/operation-environment.js +20 -0
  34. package/dist/engine/pattern-operations.js +36 -0
  35. package/dist/engine/pattern-placements.js +176 -0
  36. package/dist/engine/primitive-anchors.js +70 -0
  37. package/dist/engine/primitive-correspondence.js +74 -0
  38. package/dist/engine/primitive-operations.js +151 -0
  39. package/dist/engine/primitive-profile-types.js +1 -0
  40. package/dist/engine/primitive-profiles.js +250 -0
  41. package/dist/engine/profile-mesh.js +279 -0
  42. package/dist/engine/section-operations.js +93 -0
  43. package/dist/engine/shape-anchors.js +88 -0
  44. package/dist/engine/transform-operations.js +225 -0
  45. package/dist/engine/types.js +1 -0
  46. package/dist/geometry/anchors.js +226 -0
  47. package/dist/geometry/curves/evaluation.js +162 -0
  48. package/dist/geometry/curves/sampling.js +266 -0
  49. package/dist/geometry/curves/types.js +1 -0
  50. package/dist/geometry/cylinder.js +117 -0
  51. package/dist/geometry/edge-finishing.js +160 -0
  52. package/dist/geometry/errors.js +76 -0
  53. package/dist/geometry/extrusion.js +150 -0
  54. package/dist/geometry/heightfield.js +115 -0
  55. package/dist/geometry/index.js +42 -0
  56. package/dist/geometry/loft-correspondence.js +204 -0
  57. package/dist/geometry/loft-geometry.js +184 -0
  58. package/dist/geometry/loft-options.js +56 -0
  59. package/dist/geometry/loft-simple-mesh.js +92 -0
  60. package/dist/geometry/loft-triangulation.js +64 -0
  61. package/dist/geometry/loft.js +95 -0
  62. package/dist/geometry/math/vector3.js +71 -0
  63. package/dist/geometry/mechanical/gear-motion.js +94 -0
  64. package/dist/geometry/mechanical/gears.js +484 -0
  65. package/dist/geometry/mechanical/index.js +13 -0
  66. package/dist/geometry/mechanical/standards.js +67 -0
  67. package/dist/geometry/mechanical/thread-helices.js +44 -0
  68. package/dist/geometry/mechanical/thread-mesh-plan.js +358 -0
  69. package/dist/geometry/mechanical/thread-mesh.js +142 -0
  70. package/dist/geometry/mechanical/thread-profiles.js +373 -0
  71. package/dist/geometry/mechanical/thread-solids.js +230 -0
  72. package/dist/geometry/mechanical/thread-surface.js +433 -0
  73. package/dist/geometry/mechanical/threads.js +138 -0
  74. package/dist/geometry/mesh-edge-table.js +126 -0
  75. package/dist/geometry/mesh-planar.js +408 -0
  76. package/dist/geometry/mesh-quality.js +396 -0
  77. package/dist/geometry/mesh-signature.js +137 -0
  78. package/dist/geometry/mesh.js +283 -0
  79. package/dist/geometry/model-limits.js +50 -0
  80. package/dist/geometry/numeric.js +11 -0
  81. package/dist/geometry/paths/frames.js +118 -0
  82. package/dist/geometry/paths/helix.js +49 -0
  83. package/dist/geometry/paths/path.js +32 -0
  84. package/dist/geometry/paths/planar.js +135 -0
  85. package/dist/geometry/paths/resampling.js +125 -0
  86. package/dist/geometry/polygon-clipping.js +52 -0
  87. package/dist/geometry/primitive-options.js +1 -0
  88. package/dist/geometry/primitive-resolution.js +17 -0
  89. package/dist/geometry/primitive-resolvers.js +235 -0
  90. package/dist/geometry/profiles/matched.js +192 -0
  91. package/dist/geometry/profiles/open-profile.js +137 -0
  92. package/dist/geometry/profiles/profiles.js +91 -0
  93. package/dist/geometry/profiles/rectangle.js +249 -0
  94. package/dist/geometry/profiles/shapes.js +41 -0
  95. package/dist/geometry/profiles/span-sampling.js +28 -0
  96. package/dist/geometry/profiles/types.js +1 -0
  97. package/dist/geometry/profiles/validation.js +20 -0
  98. package/dist/geometry/regions.js +371 -0
  99. package/dist/geometry/resolution.js +236 -0
  100. package/dist/geometry/revolve/revolve-mesh.js +129 -0
  101. package/dist/geometry/sampling-limits.js +29 -0
  102. package/dist/geometry/stroke-path.js +181 -0
  103. package/dist/geometry/svg/errors.js +23 -0
  104. package/dist/geometry/svg/fill.js +172 -0
  105. package/dist/geometry/svg/index.js +1 -0
  106. package/dist/geometry/svg/matrix.js +130 -0
  107. package/dist/geometry/svg/parser.js +536 -0
  108. package/dist/geometry/svg/path.js +349 -0
  109. package/dist/geometry/svg/types.js +1 -0
  110. package/dist/geometry/svg-import.js +662 -0
  111. package/dist/geometry/sweeps/options.js +28 -0
  112. package/dist/geometry/sweeps/orientation.js +106 -0
  113. package/dist/geometry/sweeps/stations.js +161 -0
  114. package/dist/geometry/sweeps/sweep-closed.js +96 -0
  115. package/dist/geometry/sweeps/sweep-mesh.js +30 -0
  116. package/dist/geometry/sweeps/sweep-open.js +76 -0
  117. package/dist/geometry/sweeps/validation.js +116 -0
  118. package/dist/geometry/text-contours.js +32 -0
  119. package/dist/geometry/text-layout.js +1 -0
  120. package/dist/geometry/tolerance.js +53 -0
  121. package/dist/geometry/transform-geometry.js +41 -0
  122. package/dist/geometry/transform3.js +340 -0
  123. package/dist/geometry/types.js +1 -0
  124. package/dist/index.js +1 -0
  125. package/dist/model/execution-binding.js +30 -0
  126. package/dist/model/operations.js +154 -0
  127. package/dist/model/options.js +1 -0
  128. package/dist/model/types.js +1 -0
  129. package/dist/text/index.js +58 -0
  130. package/dist/text/vector-text-layout.js +279 -0
  131. package/package.json +100 -3
@@ -0,0 +1,484 @@
1
+ import { GeometryError } from "../errors.js";
2
+ import { signedAreaLoop2 } from "../regions.js";
3
+ import { resolveCircularSegments, resolveGeometryResolution, } from "../resolution.js";
4
+ import { assertSamplingCount, resolveGeometrySamplingLimits, } from "../sampling-limits.js";
5
+ const DEFAULT_PRESSURE_ANGLE_DEGREES = 20;
6
+ const DEFAULT_CLEARANCE_FACTOR = 0.25;
7
+ const MAXIMUM_TOOTH_COUNT = 1_000;
8
+ const finite = (value, label, operation = "resolveCylindricalGear") => {
9
+ if (!Number.isFinite(value)) {
10
+ throw new GeometryError("NON_FINITE_VALUE", `${label} must be finite.`, {
11
+ operation,
12
+ });
13
+ }
14
+ return value;
15
+ };
16
+ const degreesToRadians = (degrees) => (degrees * Math.PI) / 180;
17
+ const involuteAngle = (baseRadius, radius) => {
18
+ if (radius <= baseRadius)
19
+ return 0;
20
+ const pressure = Math.acos(baseRadius / radius);
21
+ return Math.tan(pressure) - pressure;
22
+ };
23
+ const polarPoint = (radius, angle) => Object.freeze([radius * Math.cos(angle), radius * Math.sin(angle)]);
24
+ const freezePoints = (points) => {
25
+ for (const point of points)
26
+ Object.freeze(point);
27
+ return Object.freeze(points);
28
+ };
29
+ /**
30
+ * Returns the smallest unshifted full-depth external tooth count that avoids rack-cutter undercut.
31
+ *
32
+ * The normal-plane limit is `2 / sin²(alpha_n)`. Helical gears use the standard virtual tooth
33
+ * count `z_v = z / cos³(beta)`, so increasing helix angle can reduce the physical tooth count.
34
+ */
35
+ export function minimumCylindricalGearToothCountWithoutUndercut(spec = {}) {
36
+ const pressureAngleDegrees = finite(spec.pressureAngleDegrees ?? DEFAULT_PRESSURE_ANGLE_DEGREES, "Pressure angle", "minimumCylindricalGearToothCountWithoutUndercut");
37
+ const helixAngleDegrees = finite(spec.helixAngleDegrees ?? 0, "Helix angle", "minimumCylindricalGearToothCountWithoutUndercut");
38
+ if (!(pressureAngleDegrees > 0 && pressureAngleDegrees < 45)) {
39
+ throw new GeometryError("INVALID_ARGUMENT", "Pressure angle must be greater than 0 and less than 45 degrees.", {
40
+ operation: "minimumCylindricalGearToothCountWithoutUndercut",
41
+ details: { pressureAngleDegrees },
42
+ });
43
+ }
44
+ if (!(Math.abs(helixAngleDegrees) < 45)) {
45
+ throw new GeometryError("INVALID_ARGUMENT", "The absolute helix angle must be less than 45 degrees.", {
46
+ operation: "minimumCylindricalGearToothCountWithoutUndercut",
47
+ details: { helixAngleDegrees },
48
+ });
49
+ }
50
+ const pressureAngleRadians = degreesToRadians(pressureAngleDegrees);
51
+ const helixAngleRadians = degreesToRadians(helixAngleDegrees);
52
+ const exact = (2 * Math.cos(helixAngleRadians) ** 3) / Math.sin(pressureAngleRadians) ** 2;
53
+ // Exact boundary cases such as alpha=30° should not round up because of floating-point noise.
54
+ return Math.ceil(exact - Math.max(1, Math.abs(exact)) * 1e-12);
55
+ }
56
+ const resolveSimpleInvoluteProfileFacts = (dimensions, operation) => {
57
+ const startRadius = Math.max(dimensions.rootRadius, dimensions.baseRadius);
58
+ const pitchInvolute = involuteAngle(dimensions.baseRadius, dimensions.pitchRadius);
59
+ const halfPitchThicknessAngle = dimensions.toothThicknessAtPitch / (dimensions.pitchRadius * 2);
60
+ const halfAngleAt = (radius) => halfPitchThicknessAngle +
61
+ pitchInvolute -
62
+ involuteAngle(dimensions.baseRadius, Math.max(radius, dimensions.baseRadius));
63
+ const halfAngleAtStart = halfAngleAt(startRadius);
64
+ const halfAngleAtTip = halfAngleAt(dimensions.outsideRadius);
65
+ if (!(startRadius <= dimensions.outsideRadius) ||
66
+ !(halfAngleAtTip > 0) ||
67
+ !(halfAngleAtStart < dimensions.angularPitch / 2)) {
68
+ throw new GeometryError("DEGENERATE_GEOMETRY", "The selected tooth proportions cannot form a simple involute outline.", {
69
+ operation,
70
+ details: { halfAngleAtStart, halfAngleAtTip, startRadius },
71
+ });
72
+ }
73
+ return Object.freeze({ halfAngleAt, halfAngleAtStart, halfAngleAtTip, startRadius });
74
+ };
75
+ const internalCylindricalGearSpaceDimensions = (dimensions) => {
76
+ const circularPitch = dimensions.angularPitch * dimensions.pitchRadius;
77
+ return Object.freeze({
78
+ ...dimensions,
79
+ outsideRadius: dimensions.pitchRadius + dimensions.dedendum,
80
+ rootRadius: dimensions.pitchRadius - dimensions.addendum,
81
+ toothThicknessAtPitch: circularPitch - dimensions.toothThicknessAtPitch,
82
+ });
83
+ };
84
+ /**
85
+ * Validates and resolves a cylindrical involute gear into transverse dimensions.
86
+ *
87
+ * The gear axis is +Z and the returned radii describe its XY pitch plane.
88
+ * These are nominal printable-model dimensions, not a certified gear standard.
89
+ */
90
+ const resolveCylindricalGearDimensions = (spec, profile) => {
91
+ const toothCount = finite(spec.toothCount, "Tooth count");
92
+ const module = finite(spec.module, "Module");
93
+ const pressureAngleDegrees = finite(spec.pressureAngleDegrees ?? DEFAULT_PRESSURE_ANGLE_DEGREES, "Pressure angle");
94
+ const helixAngleDegrees = finite(spec.helixAngleDegrees ?? 0, "Helix angle");
95
+ const backlash = finite(spec.backlash ?? 0, "Backlash");
96
+ const clearanceFactor = finite(spec.clearanceFactor ?? DEFAULT_CLEARANCE_FACTOR, "Clearance factor");
97
+ if (!Number.isInteger(toothCount) || toothCount < 6 || toothCount > MAXIMUM_TOOTH_COUNT) {
98
+ throw new GeometryError("INVALID_ARGUMENT", `Tooth count must be an integer from 6 through ${MAXIMUM_TOOTH_COUNT}.`, { operation: "resolveCylindricalGear", details: { toothCount } });
99
+ }
100
+ if (!(module > 0)) {
101
+ throw new GeometryError("INVALID_ARGUMENT", "Module must be greater than zero.", {
102
+ operation: "resolveCylindricalGear",
103
+ details: { module },
104
+ });
105
+ }
106
+ if (!(pressureAngleDegrees > 0 && pressureAngleDegrees < 45)) {
107
+ throw new GeometryError("INVALID_ARGUMENT", "Pressure angle must be greater than 0 and less than 45 degrees.", { operation: "resolveCylindricalGear", details: { pressureAngleDegrees } });
108
+ }
109
+ if (!(Math.abs(helixAngleDegrees) < 45)) {
110
+ throw new GeometryError("INVALID_ARGUMENT", "The absolute helix angle must be less than 45 degrees.", { operation: "resolveCylindricalGear", details: { helixAngleDegrees } });
111
+ }
112
+ if (backlash < 0 || clearanceFactor < 0) {
113
+ throw new GeometryError("INVALID_ARGUMENT", "Backlash and clearance factor must be non-negative.", { operation: "resolveCylindricalGear" });
114
+ }
115
+ const pressureAngleRadians = degreesToRadians(pressureAngleDegrees);
116
+ const helixAngleRadians = degreesToRadians(helixAngleDegrees);
117
+ const minimumToothCountWithoutUndercut = minimumCylindricalGearToothCountWithoutUndercut({
118
+ helixAngleDegrees,
119
+ pressureAngleDegrees,
120
+ });
121
+ const virtualToothCount = toothCount / Math.cos(helixAngleRadians) ** 3;
122
+ if (profile === "external" && toothCount < minimumToothCountWithoutUndercut) {
123
+ throw new GeometryError("INVALID_ARGUMENT", `An unshifted full-depth external involute at ${pressureAngleDegrees}° pressure angle and ${Math.abs(helixAngleDegrees)}° helix angle requires at least ${minimumToothCountWithoutUndercut} teeth to avoid undercut; profile shift is not supported.`, {
124
+ operation: "resolveCylindricalGear",
125
+ details: {
126
+ helixAngleDegrees,
127
+ minimumToothCountWithoutUndercut,
128
+ pressureAngleDegrees,
129
+ toothCount,
130
+ virtualToothCount,
131
+ },
132
+ });
133
+ }
134
+ const transverseModule = module / Math.cos(helixAngleRadians);
135
+ const transversePressureAngleRadians = Math.atan(Math.tan(pressureAngleRadians) / Math.cos(helixAngleRadians));
136
+ const pitchRadius = (transverseModule * toothCount) / 2;
137
+ const addendum = module;
138
+ const dedendum = module * (1 + clearanceFactor);
139
+ const outsideRadius = pitchRadius + addendum;
140
+ const rootRadius = pitchRadius - dedendum;
141
+ const circularPitch = Math.PI * transverseModule;
142
+ const toothThicknessAtPitch = circularPitch / 2 - backlash;
143
+ if (!(rootRadius > 0)) {
144
+ throw new GeometryError("DEGENERATE_GEOMETRY", "The dedendum consumes the gear root radius.", {
145
+ operation: "resolveCylindricalGear",
146
+ details: { rootRadius },
147
+ });
148
+ }
149
+ if (!(toothThicknessAtPitch > 0) || !(toothThicknessAtPitch < circularPitch)) {
150
+ throw new GeometryError("DEGENERATE_GEOMETRY", "Backlash leaves an invalid tooth thickness at the pitch circle.", { operation: "resolveCylindricalGear", details: { toothThicknessAtPitch } });
151
+ }
152
+ const dimensions = Object.freeze({
153
+ addendum,
154
+ angularPitch: (Math.PI * 2) / toothCount,
155
+ backlash,
156
+ baseRadius: pitchRadius * Math.cos(transversePressureAngleRadians),
157
+ dedendum,
158
+ helixAngleRadians,
159
+ minimumToothCountWithoutUndercut,
160
+ module,
161
+ outsideRadius,
162
+ pitchRadius,
163
+ pressureAngleRadians,
164
+ rootRadius,
165
+ toothCount,
166
+ toothThicknessAtPitch,
167
+ transverseModule,
168
+ transversePressureAngleRadians,
169
+ virtualToothCount,
170
+ });
171
+ resolveSimpleInvoluteProfileFacts(profile === "external" ? dimensions : internalCylindricalGearSpaceDimensions(dimensions), "resolveCylindricalGear");
172
+ return dimensions;
173
+ };
174
+ /**
175
+ * Resolves a validated unshifted full-depth external cylindrical gear.
176
+ *
177
+ * The gear axis is +Z and the returned radii describe its XY pitch plane. Inputs below the
178
+ * pressure- and helix-dependent no-undercut tooth count are rejected because profile shift is not
179
+ * currently supported.
180
+ */
181
+ export function resolveCylindricalGear(spec) {
182
+ return resolveCylindricalGearDimensions(spec, "external");
183
+ }
184
+ /**
185
+ * Resolves ratio, center distance, and helix relationship for a gear pair.
186
+ *
187
+ * Both gears must use the same normal module, pressure angle, and helix-angle
188
+ * magnitude. An external pair uses opposite helix hands; an internal pair uses
189
+ * the same hand. The function validates those conjugate pitch invariants.
190
+ */
191
+ export function resolveCylindricalGearPair(spec) {
192
+ if (spec.mesh !== "external" && spec.mesh !== "internal") {
193
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported cylindrical gear mesh kind.", {
194
+ operation: "resolveCylindricalGearPair",
195
+ });
196
+ }
197
+ const driver = resolveCylindricalGearDimensions(spec.driver, "external");
198
+ // Internal tooth spaces are not subject to the external rack-cutter undercut limit. Their
199
+ // separate tip/interference constraints are checked by the pair and coupled-motion validators.
200
+ const driven = resolveCylindricalGearDimensions(spec.driven, spec.mesh);
201
+ const epsilon = 1e-10;
202
+ const driverHelixDegrees = spec.driver.helixAngleDegrees ?? 0;
203
+ const drivenHelixDegrees = spec.driven.helixAngleDegrees ?? 0;
204
+ const requiredDrivenHelixAngleDegrees = spec.mesh === "external" ? -driverHelixDegrees : driverHelixDegrees;
205
+ if (Math.abs(driver.module - driven.module) > epsilon ||
206
+ Math.abs(driver.pressureAngleRadians - driven.pressureAngleRadians) > epsilon ||
207
+ Math.abs(drivenHelixDegrees - requiredDrivenHelixAngleDegrees) > epsilon) {
208
+ throw new GeometryError("INVALID_ARGUMENT", "Mating gears must share normal module and pressure angle with conjugate helix hands.", { operation: "resolveCylindricalGearPair" });
209
+ }
210
+ if (spec.mesh === "internal" && driven.pitchRadius <= driver.outsideRadius) {
211
+ throw new GeometryError("DEGENERATE_GEOMETRY", "An internal driven gear must leave radial room for the driver tooth tips.", { operation: "resolveCylindricalGearPair" });
212
+ }
213
+ return Object.freeze({
214
+ centerDistance: spec.mesh === "external"
215
+ ? driver.pitchRadius + driven.pitchRadius
216
+ : driven.pitchRadius - driver.pitchRadius,
217
+ driver,
218
+ driven,
219
+ drivenDirection: spec.mesh === "external" ? -1 : 1,
220
+ ratio: driven.toothCount / driver.toothCount,
221
+ requiredDrivenHelixAngleDegrees,
222
+ });
223
+ }
224
+ const exactSampleCount = (value, label, minimum, maximum, operation) => {
225
+ const resolved = finite(value, label, operation);
226
+ if (!Number.isSafeInteger(resolved) || resolved < minimum) {
227
+ throw new GeometryError("INVALID_ARGUMENT", `${label} must be a safe integer of at least ${minimum}.`, { operation });
228
+ }
229
+ assertSamplingCount(resolved, operation, maximum);
230
+ return resolved;
231
+ };
232
+ const involutePoint = (baseRadius, radius) => {
233
+ const angle = involuteAngle(baseRadius, radius);
234
+ return [radius * Math.cos(angle), radius * Math.sin(angle)];
235
+ };
236
+ const involuteDerivative = (baseRadius, radius) => {
237
+ const angle = involuteAngle(baseRadius, radius);
238
+ const tangentParameter = Math.sqrt(Math.max(0, (radius / baseRadius) ** 2 - 1));
239
+ return [
240
+ Math.cos(angle) - tangentParameter * Math.sin(angle),
241
+ Math.sin(angle) + tangentParameter * Math.cos(angle),
242
+ ];
243
+ };
244
+ /** Maximum normal distance between one involute interval and its straight chord. */
245
+ const involuteChordDeviation = (baseRadius, startRadius, endRadius) => {
246
+ const start = involutePoint(baseRadius, startRadius);
247
+ const end = involutePoint(baseRadius, endRadius);
248
+ const chordX = end[0] - start[0];
249
+ const chordY = end[1] - start[1];
250
+ const chordLength = Math.hypot(chordX, chordY);
251
+ if (chordLength === 0)
252
+ return 0;
253
+ const derivativeCross = (radius) => {
254
+ const derivative = involuteDerivative(baseRadius, radius);
255
+ return chordX * derivative[1] - chordY * derivative[0];
256
+ };
257
+ let lower = startRadius;
258
+ let upper = endRadius;
259
+ let lowerDerivative = derivativeCross(lower);
260
+ const upperDerivative = derivativeCross(upper);
261
+ if (lowerDerivative * upperDerivative <= 0) {
262
+ for (let iteration = 0; iteration < 48; iteration += 1) {
263
+ const middle = (lower + upper) / 2;
264
+ const middleDerivative = derivativeCross(middle);
265
+ if (lowerDerivative * middleDerivative <= 0) {
266
+ upper = middle;
267
+ }
268
+ else {
269
+ lower = middle;
270
+ lowerDerivative = middleDerivative;
271
+ }
272
+ }
273
+ }
274
+ else {
275
+ // Degenerate near-linear intervals can lose the derivative sign change to roundoff. A dense
276
+ // deterministic fallback still measures the physical curve rather than assuming zero error.
277
+ let maximum = 0;
278
+ for (let sample = 1; sample < 32; sample += 1) {
279
+ const point = involutePoint(baseRadius, startRadius + ((endRadius - startRadius) * sample) / 32);
280
+ maximum = Math.max(maximum, Math.abs(chordX * (point[1] - start[1]) - chordY * (point[0] - start[0])) / chordLength);
281
+ }
282
+ return maximum;
283
+ }
284
+ const point = involutePoint(baseRadius, (lower + upper) / 2);
285
+ return Math.abs(chordX * (point[1] - start[1]) - chordY * (point[0] - start[0])) / chordLength;
286
+ };
287
+ const resolveAdaptiveFlankSamples = (baseRadius, startRadius, endRadius, resolution, maximum, operation) => {
288
+ const satisfiesResolution = (samples) => {
289
+ for (let segment = 0; segment < samples; segment += 1) {
290
+ const segmentStart = startRadius + ((endRadius - startRadius) * segment) / samples;
291
+ const segmentEnd = startRadius + ((endRadius - startRadius) * (segment + 1)) / samples;
292
+ const chordStart = involutePoint(baseRadius, segmentStart);
293
+ const chordEnd = involutePoint(baseRadius, segmentEnd);
294
+ if (involuteChordDeviation(baseRadius, segmentStart, segmentEnd) > resolution.maxChordError ||
295
+ (resolution.maxSegmentLength !== undefined &&
296
+ Math.hypot(chordEnd[0] - chordStart[0], chordEnd[1] - chordStart[1]) >
297
+ resolution.maxSegmentLength)) {
298
+ return false;
299
+ }
300
+ }
301
+ return true;
302
+ };
303
+ // Preserve the smallest ordinary uniform count. For tighter requested tolerances, doubling
304
+ // and a bounded search avoid scanning every integer up to a host-raised profile ceiling.
305
+ for (let samples = 2; samples <= Math.min(32, maximum); samples += 1) {
306
+ if (satisfiesResolution(samples))
307
+ return samples;
308
+ }
309
+ let lower = 32;
310
+ while (lower < maximum) {
311
+ let upper = Math.min(maximum, lower * 2);
312
+ if (satisfiesResolution(upper)) {
313
+ while (upper - lower > 1) {
314
+ const middle = Math.floor((lower + upper) / 2);
315
+ if (satisfiesResolution(middle))
316
+ upper = middle;
317
+ else
318
+ lower = middle;
319
+ }
320
+ return upper;
321
+ }
322
+ lower = upper;
323
+ }
324
+ throw new GeometryError("COMPLEXITY_LIMIT", `The requested physical gear-flank tolerance requires more than ${maximum} samples per flank; the selected profile ceiling permits ${maximum}.`, {
325
+ operation,
326
+ details: {
327
+ baseRadius,
328
+ requiredCount: maximum + 1,
329
+ allowedCount: maximum,
330
+ endRadius,
331
+ maxChordError: resolution.maxChordError,
332
+ ...(resolution.maxSegmentLength === undefined
333
+ ? {}
334
+ : { maxSegmentLength: resolution.maxSegmentLength }),
335
+ startRadius,
336
+ },
337
+ });
338
+ };
339
+ const resolveAdaptiveArcSamples = (radius, angleRadians, resolution, limits) => {
340
+ const fullCircleSegments = resolveCircularSegments(radius, resolution, limits);
341
+ const samples = Math.max(1, Math.ceil((Math.abs(angleRadians) * fullCircleSegments) / (Math.PI * 2)));
342
+ return samples;
343
+ };
344
+ const sampleResolvedCylindricalGearOutline = (dimensions, options, operation) => {
345
+ const { halfAngleAt, halfAngleAtStart, halfAngleAtTip, startRadius } = resolveSimpleInvoluteProfileFacts(dimensions, operation);
346
+ const limits = resolveGeometrySamplingLimits(options.limits);
347
+ const resolution = resolveGeometryResolution(options.resolution, limits);
348
+ const rootArcAngle = dimensions.angularPitch / 2 - halfAngleAtStart;
349
+ const rootArcSamples = options.rootArcSamples === undefined
350
+ ? resolveAdaptiveArcSamples(dimensions.rootRadius, rootArcAngle, resolution, limits)
351
+ : exactSampleCount(options.rootArcSamples, "Root arc samples", 1, limits.maxCircularSegments, operation);
352
+ const tipArcSamples = options.tipArcSamples === undefined
353
+ ? resolveAdaptiveArcSamples(dimensions.outsideRadius, halfAngleAtTip * 2, resolution, limits)
354
+ : exactSampleCount(options.tipArcSamples, "Tip arc samples", 1, limits.maxCircularSegments, operation);
355
+ const radialTransitions = startRadius > dimensions.rootRadius ? 2 : 0;
356
+ const fixedPerTooth = rootArcSamples * 2 + tipArcSamples + radialTransitions;
357
+ assertSamplingCount(dimensions.toothCount * (4 + fixedPerTooth), operation, limits.maxProfilePoints);
358
+ const maximumFlankSamples = Math.floor((Math.floor(limits.maxProfilePoints / dimensions.toothCount) - fixedPerTooth) / 2);
359
+ const flankSamples = options.flankSamples === undefined
360
+ ? resolveAdaptiveFlankSamples(dimensions.baseRadius, startRadius, dimensions.outsideRadius, resolution, maximumFlankSamples, operation)
361
+ : exactSampleCount(options.flankSamples, "Flank samples", 2, maximumFlankSamples, operation);
362
+ const estimatedPoints = dimensions.toothCount * (flankSamples * 2 + fixedPerTooth);
363
+ assertSamplingCount(estimatedPoints, operation, limits.maxProfilePoints);
364
+ const points = [polarPoint(dimensions.rootRadius, -dimensions.angularPitch / 2)];
365
+ for (let tooth = 0; tooth < dimensions.toothCount; tooth += 1) {
366
+ const centerAngle = tooth * dimensions.angularPitch;
367
+ const valleyBefore = centerAngle - dimensions.angularPitch / 2;
368
+ const leftRootAngle = centerAngle - halfAngleAtStart;
369
+ for (let sample = 1; sample <= rootArcSamples; sample += 1) {
370
+ const amount = sample / rootArcSamples;
371
+ points.push(polarPoint(dimensions.rootRadius, valleyBefore + (leftRootAngle - valleyBefore) * amount));
372
+ }
373
+ if (startRadius > dimensions.rootRadius) {
374
+ points.push(polarPoint(startRadius, leftRootAngle));
375
+ }
376
+ for (let sample = 1; sample <= flankSamples; sample += 1) {
377
+ const amount = sample / flankSamples;
378
+ const radius = startRadius + (dimensions.outsideRadius - startRadius) * amount;
379
+ points.push(polarPoint(radius, centerAngle - halfAngleAt(radius)));
380
+ }
381
+ for (let sample = 1; sample <= tipArcSamples; sample += 1) {
382
+ const amount = sample / tipArcSamples;
383
+ points.push(polarPoint(dimensions.outsideRadius, centerAngle - halfAngleAtTip + halfAngleAtTip * 2 * amount));
384
+ }
385
+ for (let sample = flankSamples - 1; sample >= 0; sample -= 1) {
386
+ const amount = sample / flankSamples;
387
+ const radius = startRadius + (dimensions.outsideRadius - startRadius) * amount;
388
+ points.push(polarPoint(radius, centerAngle + halfAngleAt(radius)));
389
+ }
390
+ const rightRootAngle = centerAngle + halfAngleAtStart;
391
+ if (startRadius > dimensions.rootRadius) {
392
+ points.push(polarPoint(dimensions.rootRadius, rightRootAngle));
393
+ }
394
+ const valleyAfter = centerAngle + dimensions.angularPitch / 2;
395
+ for (let sample = 1; sample <= rootArcSamples; sample += 1) {
396
+ const amount = sample / rootArcSamples;
397
+ points.push(polarPoint(dimensions.rootRadius, rightRootAngle + (valleyAfter - rightRootAngle) * amount));
398
+ }
399
+ }
400
+ points.pop();
401
+ if (!(signedAreaLoop2(points) > 0)) {
402
+ throw new GeometryError("INVALID_TOPOLOGY", "The sampled gear outline must be counter-clockwise.", {
403
+ operation,
404
+ });
405
+ }
406
+ return freezePoints(points);
407
+ };
408
+ /**
409
+ * Samples a complete external involute gear boundary in the XY plane.
410
+ *
411
+ * The returned loop is counter-clockwise, closed implicitly without a repeated
412
+ * seam point, and contains no holes. Its center is the origin and its axis is
413
+ * +Z. Bore, hub, keyway, twist, and face-width choices remain generator-owned.
414
+ */
415
+ export function sampleCylindricalGearOutline(spec, options = {}) {
416
+ return sampleResolvedCylindricalGearOutline(resolveCylindricalGear(spec), options, "sampleCylindricalGearOutline");
417
+ }
418
+ /**
419
+ * Samples the center opening and tooth spaces used to subtract an internal ring gear.
420
+ *
421
+ * Internal tooth tips lie one addendum below the pitch radius and their roots lie one
422
+ * dedendum above it. At the pitch circle, positive backlash widens each sampled space
423
+ * tangentially. The returned CCW loop encloses the center and is intended to be reversed
424
+ * when used as a region hole.
425
+ */
426
+ export function sampleInternalCylindricalGearSpaceOutline(spec, options = {}) {
427
+ return sampleResolvedCylindricalGearOutline(internalCylindricalGearSpaceDimensions(resolveCylindricalGearDimensions(spec, "internal")), options, "sampleInternalCylindricalGearSpaceOutline");
428
+ }
429
+ /** Resolves a rack segment that is conjugate with the same normal cylindrical pitch system. */
430
+ export function resolveRackPitch(spec) {
431
+ const reference = resolveCylindricalGear({
432
+ ...(spec.backlash === undefined ? {} : { backlash: spec.backlash }),
433
+ ...(spec.clearanceFactor === undefined ? {} : { clearanceFactor: spec.clearanceFactor }),
434
+ ...(spec.helixAngleDegrees === undefined ? {} : { helixAngleDegrees: spec.helixAngleDegrees }),
435
+ module: spec.module,
436
+ ...(spec.pressureAngleDegrees === undefined
437
+ ? {}
438
+ : { pressureAngleDegrees: spec.pressureAngleDegrees }),
439
+ // Rack pitch facts do not depend on this cylindrical reference count. Use the maximum so the
440
+ // reference remains undercut-free for every supported normal pressure and helix angle.
441
+ toothCount: MAXIMUM_TOOTH_COUNT,
442
+ });
443
+ const toothCount = finite(spec.toothCount, "Rack tooth count");
444
+ if (!Number.isInteger(toothCount) || toothCount < 1 || toothCount > MAXIMUM_TOOTH_COUNT) {
445
+ throw new GeometryError("INVALID_ARGUMENT", `Rack tooth count must be an integer from 1 through ${MAXIMUM_TOOTH_COUNT}.`, { operation: "resolveRackPitch", details: { toothCount } });
446
+ }
447
+ const pitch = Math.PI * reference.transverseModule;
448
+ return Object.freeze({
449
+ addendum: reference.addendum,
450
+ dedendum: reference.dedendum,
451
+ helixAngleRadians: reference.helixAngleRadians,
452
+ length: pitch * toothCount,
453
+ module: reference.module,
454
+ pitch,
455
+ pressureAngleRadians: reference.transversePressureAngleRadians,
456
+ toothCount,
457
+ toothThicknessAtPitch: reference.toothThicknessAtPitch,
458
+ });
459
+ }
460
+ /**
461
+ * Samples the open top boundary of a finite rack in its XY pitch plane.
462
+ *
463
+ * X follows rack travel, Y is tooth height, and the pitch line is `Y = 0`.
464
+ * The path begins and ends on the root line, omits duplicate adjacent points,
465
+ * and leaves base thickness, mounting features, extrusion, and helical sweep to
466
+ * the owning generator.
467
+ */
468
+ export function sampleRackToothPath(spec, limits = {}) {
469
+ const dimensions = resolveRackPitch(spec);
470
+ assertSamplingCount(dimensions.toothCount * 4 + 2, "sampleRackToothPath", resolveGeometrySamplingLimits(limits).maxPathPoints);
471
+ const slope = Math.tan(dimensions.pressureAngleRadians);
472
+ const rootHalfWidth = dimensions.toothThicknessAtPitch / 2 + dimensions.dedendum * slope;
473
+ const tipHalfWidth = dimensions.toothThicknessAtPitch / 2 - dimensions.addendum * slope;
474
+ if (!(tipHalfWidth > 0) || !(rootHalfWidth < dimensions.pitch / 2)) {
475
+ throw new GeometryError("DEGENERATE_GEOMETRY", "The selected rack proportions cannot form separated trapezoidal teeth.", { operation: "sampleRackToothPath" });
476
+ }
477
+ const points = [Object.freeze([0, -dimensions.dedendum])];
478
+ for (let tooth = 0; tooth < dimensions.toothCount; tooth += 1) {
479
+ const center = (tooth + 0.5) * dimensions.pitch;
480
+ points.push(Object.freeze([center - rootHalfWidth, -dimensions.dedendum]), Object.freeze([center - tipHalfWidth, dimensions.addendum]), Object.freeze([center + tipHalfWidth, dimensions.addendum]), Object.freeze([center + rootHalfWidth, -dimensions.dedendum]));
481
+ }
482
+ points.push(Object.freeze([dimensions.length, -dimensions.dedendum]));
483
+ return freezePoints(points);
484
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Runtime-neutral, opt-in mechanical geometry helpers and standards data.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ export { minimumCylindricalGearToothCountWithoutUndercut, resolveCylindricalGear, resolveCylindricalGearPair, resolveRackPitch, sampleCylindricalGearOutline, sampleInternalCylindricalGearSpaceOutline, sampleRackToothPath, } from "./gears.js";
7
+ export { measureInternalCylindricalGearPairMotion } from "./gear-motion.js";
8
+ export { buildHelicalThreadRidgeMesh, buildHelicalThreadSolidMesh } from "./thread-mesh.js";
9
+ export { compensateThreadProfileAxially, compensateThreadProfileForFaceting, createThreadProfile, } from "./thread-profiles.js";
10
+ export { preflightHelicalThreadSolidMesh } from "./thread-mesh-plan.js";
11
+ export { sampleConventionalThreadHelices as sampleThreadHelices } from "./thread-helices.js";
12
+ export { compensateThreadDiameters, PRINTABLE_THREAD_FIT_POLICIES, resolvePrintableThreadFitPolicy, resolveThreadCircularSegments, } from "./threads.js";
13
+ export { getMetricClearanceHole, getMetricFastenerStandard, METRIC_FASTENER_STANDARDS, } from "./standards.js";
@@ -0,0 +1,67 @@
1
+ import { GeometryError } from "../errors.js";
2
+ const standard = (nominalDiameter, coarsePitch, clearanceHoles, hexNut, socketCapHead) => Object.freeze({
3
+ coarsePitch,
4
+ clearanceHoles: Object.freeze({
5
+ close: clearanceHoles[0],
6
+ loose: clearanceHoles[2],
7
+ normal: clearanceHoles[1],
8
+ }),
9
+ hexNut: Object.freeze({ acrossFlats: hexNut[0], thickness: hexNut[1] }),
10
+ nominalDiameter,
11
+ socketCapHead: Object.freeze({
12
+ diameter: socketCapHead[0],
13
+ height: socketCapHead[1],
14
+ hexKey: socketCapHead[2],
15
+ }),
16
+ });
17
+ /**
18
+ * Curated metric modeling dimensions for clearance holes, regular hex nuts,
19
+ * and socket-head cap screws.
20
+ *
21
+ * Values are nominal reference dimensions intended for parametric envelopes.
22
+ * Manufacturing tolerances, coatings, and printer compensation remain caller
23
+ * responsibilities.
24
+ */
25
+ export const METRIC_FASTENER_STANDARDS = Object.freeze({
26
+ /** Nominal M2 coarse-thread modeling dimensions. */
27
+ M2: standard(2, 0.4, [2.2, 2.4, 2.6], [4, 1.6], [3.8, 2, 1.5]),
28
+ /** Nominal M2.5 coarse-thread modeling dimensions. */
29
+ "M2.5": standard(2.5, 0.45, [2.7, 2.9, 3.1], [5, 2], [4.5, 2.5, 2]),
30
+ /** Nominal M3 coarse-thread modeling dimensions. */
31
+ M3: standard(3, 0.5, [3.2, 3.4, 3.6], [5.5, 2.4], [5.5, 3, 2.5]),
32
+ /** Nominal M4 coarse-thread modeling dimensions. */
33
+ M4: standard(4, 0.7, [4.3, 4.5, 4.8], [7, 3.2], [7, 4, 3]),
34
+ /** Nominal M5 coarse-thread modeling dimensions. */
35
+ M5: standard(5, 0.8, [5.3, 5.5, 5.8], [8, 4], [8.5, 5, 4]),
36
+ /** Nominal M6 coarse-thread modeling dimensions. */
37
+ M6: standard(6, 1, [6.4, 6.6, 7], [10, 5], [10, 6, 5]),
38
+ /** Nominal M8 coarse-thread modeling dimensions. */
39
+ M8: standard(8, 1.25, [8.4, 9, 10], [13, 6.5], [13, 8, 6]),
40
+ /** Nominal M10 coarse-thread modeling dimensions. */
41
+ M10: standard(10, 1.5, [10.5, 11, 12], [17, 8], [16, 10, 8]),
42
+ /** Nominal M12 coarse-thread modeling dimensions. */
43
+ M12: standard(12, 1.75, [13, 13.5, 14.5], [19, 10], [18, 12, 10]),
44
+ });
45
+ /** Returns the immutable standards record for one supported metric size. */
46
+ export function getMetricFastenerStandard(size) {
47
+ const value = METRIC_FASTENER_STANDARDS[size];
48
+ if (value === undefined) {
49
+ throw new GeometryError("INVALID_ARGUMENT", `Unsupported metric fastener size: ${size}.`);
50
+ }
51
+ return value;
52
+ }
53
+ /** Returns one recommended metric clearance-hole diameter in millimetres. */
54
+ export function getMetricClearanceHole(size, fit = "normal") {
55
+ const holes = getMetricFastenerStandard(size).clearanceHoles;
56
+ const diameter = fit === "close"
57
+ ? holes.close
58
+ : fit === "normal"
59
+ ? holes.normal
60
+ : fit === "loose"
61
+ ? holes.loose
62
+ : undefined;
63
+ if (diameter === undefined) {
64
+ throw new GeometryError("INVALID_ARGUMENT", `Unsupported clearance-hole fit: ${fit}.`);
65
+ }
66
+ return diameter;
67
+ }
@@ -0,0 +1,44 @@
1
+ /** Conventional thread helix sampling, independent of surface construction. */
2
+ import { GeometryError } from "../errors.js";
3
+ import { assertSamplingCount, resolveGeometrySamplingLimits, } from "../sampling-limits.js";
4
+ import { assertFiniteThreadNumber } from "./thread-profiles.js";
5
+ /**
6
+ * Samples evenly phased thread helices with finite `lead = pitch * starts`.
7
+ * All starts together respect maxPathPoints (default 100000); excessive counts fail before allocation.
8
+ */
9
+ export function sampleConventionalThreadHelices(options) {
10
+ const operation = "sampleThreadHelices";
11
+ const radius = assertFiniteThreadNumber(options.radius, "radius", operation);
12
+ const length = assertFiniteThreadNumber(options.length, "length", operation);
13
+ const pitch = assertFiniteThreadNumber(options.pitch, "pitch", operation);
14
+ const starts = assertFiniteThreadNumber(options.starts ?? 1, "starts", operation);
15
+ const density = assertFiniteThreadNumber(options.segmentsPerTurn ?? 32, "segmentsPerTurn", operation);
16
+ if (!(radius > 0 && length > 0 && pitch > 0) ||
17
+ !Number.isInteger(starts) ||
18
+ starts < 1 ||
19
+ starts > 32 ||
20
+ !Number.isInteger(density) ||
21
+ density < 3) {
22
+ throw new GeometryError("INVALID_ARGUMENT", "Helix dimensions, starts, or density are invalid.", { operation });
23
+ }
24
+ const handedness = options.handedness ?? "right";
25
+ if (handedness !== "left" && handedness !== "right")
26
+ throw new GeometryError("INVALID_ARGUMENT", "Unsupported handedness.", { operation });
27
+ const lead = assertFiniteThreadNumber(pitch * starts, "lead", operation);
28
+ const turns = length / lead;
29
+ const count = Math.max(1, Math.ceil(turns * density));
30
+ assertSamplingCount(starts * (count + 1), operation, resolveGeometrySamplingLimits(options.limits).maxPathPoints);
31
+ const direction = handedness === "right" ? 1 : -1;
32
+ const phase = assertFiniteThreadNumber((assertFiniteThreadNumber(options.phaseDegrees ?? 0, "phaseDegrees", operation) * Math.PI) /
33
+ 180, "phase", operation);
34
+ const paths = Object.freeze(Array.from({ length: starts }, (_, start) => {
35
+ const initial = phase + (direction * 2 * Math.PI * start) / starts;
36
+ return Object.freeze(Array.from({ length: count + 1 }, (_, index) => {
37
+ const t = index / count;
38
+ const z = length * t;
39
+ const angle = initial + direction * 2 * Math.PI * turns * t;
40
+ return Object.freeze([radius * Math.cos(angle), radius * Math.sin(angle), z]);
41
+ }));
42
+ }));
43
+ return Object.freeze({ lead, paths, pitch, turns });
44
+ }