@metamesh/modeling 0.0.0-stage → 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.
- package/CHANGELOG.md +105 -0
- package/LICENSE +200 -0
- package/README.md +314 -2
- package/THIRD_PARTY_NOTICES.md +153 -0
- package/dist/api/engine.d.ts +54 -0
- package/dist/api/geometry.d.ts +2912 -0
- package/dist/api/index.d.ts +1648 -0
- package/dist/api/text.d.ts +77 -0
- package/dist/engine/construction-operations.js +189 -0
- package/dist/engine/execution.js +227 -0
- package/dist/engine/export-3mf.js +100 -0
- package/dist/engine/extrusion-finishing.js +82 -0
- package/dist/engine/finalization.js +214 -0
- package/dist/engine/index.js +72 -0
- package/dist/engine/inspection-operations.js +52 -0
- package/dist/engine/kernel/LICENSE +201 -0
- package/dist/engine/kernel/manifold.js +2 -0
- package/dist/engine/kernel/manifold.wasm +0 -0
- package/dist/engine/loft-operations.js +156 -0
- package/dist/engine/mechanical-operations.js +51 -0
- package/dist/engine/modeling-context.js +38 -0
- package/dist/engine/native/adapter.js +20 -0
- package/dist/engine/native/context.js +209 -0
- package/dist/engine/native/copied-views.js +46 -0
- package/dist/engine/native/index.js +13 -0
- package/dist/engine/native/operations.js +115 -0
- package/dist/engine/native/ownership.js +98 -0
- package/dist/engine/native/regions.js +138 -0
- package/dist/engine/native/scoped-primitives.js +127 -0
- package/dist/engine/native/scoped-threads.js +34 -0
- package/dist/engine/native/threads.js +52 -0
- package/dist/engine/native/transforms.js +111 -0
- package/dist/engine/operation-environment.js +20 -0
- package/dist/engine/pattern-operations.js +36 -0
- package/dist/engine/pattern-placements.js +176 -0
- package/dist/engine/primitive-anchors.js +70 -0
- package/dist/engine/primitive-correspondence.js +74 -0
- package/dist/engine/primitive-operations.js +151 -0
- package/dist/engine/primitive-profile-types.js +1 -0
- package/dist/engine/primitive-profiles.js +250 -0
- package/dist/engine/profile-mesh.js +279 -0
- package/dist/engine/section-operations.js +93 -0
- package/dist/engine/shape-anchors.js +88 -0
- package/dist/engine/transform-operations.js +225 -0
- package/dist/engine/types.js +1 -0
- package/dist/geometry/anchors.js +226 -0
- package/dist/geometry/curves/evaluation.js +162 -0
- package/dist/geometry/curves/sampling.js +265 -0
- package/dist/geometry/curves/types.js +1 -0
- package/dist/geometry/cylinder.js +117 -0
- package/dist/geometry/edge-finishing.js +160 -0
- package/dist/geometry/errors.js +76 -0
- package/dist/geometry/extrusion.js +150 -0
- package/dist/geometry/heightfield.js +107 -0
- package/dist/geometry/index.js +42 -0
- package/dist/geometry/loft-correspondence.js +204 -0
- package/dist/geometry/loft-geometry.js +184 -0
- package/dist/geometry/loft-options.js +56 -0
- package/dist/geometry/loft-simple-mesh.js +92 -0
- package/dist/geometry/loft-triangulation.js +64 -0
- package/dist/geometry/loft.js +80 -0
- package/dist/geometry/math/vector3.js +41 -0
- package/dist/geometry/mechanical/gear-motion.js +94 -0
- package/dist/geometry/mechanical/gears.js +484 -0
- package/dist/geometry/mechanical/index.js +13 -0
- package/dist/geometry/mechanical/standards.js +67 -0
- package/dist/geometry/mechanical/thread-helices.js +44 -0
- package/dist/geometry/mechanical/thread-mesh-plan.js +358 -0
- package/dist/geometry/mechanical/thread-mesh.js +142 -0
- package/dist/geometry/mechanical/thread-profiles.js +368 -0
- package/dist/geometry/mechanical/thread-solids.js +230 -0
- package/dist/geometry/mechanical/thread-surface.js +433 -0
- package/dist/geometry/mechanical/threads.js +138 -0
- package/dist/geometry/mesh-planar.js +408 -0
- package/dist/geometry/mesh-quality.js +396 -0
- package/dist/geometry/mesh-signature.js +137 -0
- package/dist/geometry/mesh.js +300 -0
- package/dist/geometry/model-limits.js +50 -0
- package/dist/geometry/numeric.js +11 -0
- package/dist/geometry/paths/frames.js +118 -0
- package/dist/geometry/paths/helix.js +49 -0
- package/dist/geometry/paths/path.js +30 -0
- package/dist/geometry/paths/planar.js +135 -0
- package/dist/geometry/paths/resampling.js +124 -0
- package/dist/geometry/polygon-clipping.js +52 -0
- package/dist/geometry/primitive-options.js +1 -0
- package/dist/geometry/primitive-resolution.js +17 -0
- package/dist/geometry/primitive-resolvers.js +235 -0
- package/dist/geometry/profiles/matched.js +184 -0
- package/dist/geometry/profiles/open-profile.js +137 -0
- package/dist/geometry/profiles/profiles.js +70 -0
- package/dist/geometry/profiles/rectangle.js +235 -0
- package/dist/geometry/profiles/shapes.js +34 -0
- package/dist/geometry/profiles/span-sampling.js +28 -0
- package/dist/geometry/profiles/types.js +1 -0
- package/dist/geometry/profiles/validation.js +20 -0
- package/dist/geometry/regions.js +371 -0
- package/dist/geometry/resolution.js +236 -0
- package/dist/geometry/revolve/revolve-mesh.js +123 -0
- package/dist/geometry/sampling-limits.js +29 -0
- package/dist/geometry/stroke-path.js +181 -0
- package/dist/geometry/svg/errors.js +23 -0
- package/dist/geometry/svg/fill.js +172 -0
- package/dist/geometry/svg/index.js +1 -0
- package/dist/geometry/svg/matrix.js +130 -0
- package/dist/geometry/svg/parser.js +536 -0
- package/dist/geometry/svg/path.js +349 -0
- package/dist/geometry/svg/types.js +1 -0
- package/dist/geometry/svg-import.js +662 -0
- package/dist/geometry/sweeps/options.js +28 -0
- package/dist/geometry/sweeps/orientation.js +106 -0
- package/dist/geometry/sweeps/stations.js +161 -0
- package/dist/geometry/sweeps/sweep-closed.js +96 -0
- package/dist/geometry/sweeps/sweep-mesh.js +20 -0
- package/dist/geometry/sweeps/sweep-open.js +76 -0
- package/dist/geometry/sweeps/validation.js +116 -0
- package/dist/geometry/text-contours.js +32 -0
- package/dist/geometry/text-layout.js +1 -0
- package/dist/geometry/tolerance.js +53 -0
- package/dist/geometry/transform-geometry.js +41 -0
- package/dist/geometry/transform3.js +340 -0
- package/dist/geometry/types.js +1 -0
- package/dist/index.js +1 -0
- package/dist/model/execution-binding.js +30 -0
- package/dist/model/operations.js +152 -0
- package/dist/model/options.js +1 -0
- package/dist/model/types.js +1 -0
- package/dist/text/index.js +48 -0
- package/dist/text/vector-text-layout.js +279 -0
- package/package.json +100 -3
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/** Thread input resolution, end/bevel boundaries, and allocation preflight. */
|
|
2
|
+
import { GeometryError } from "../errors.js";
|
|
3
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "../model-limits.js";
|
|
4
|
+
import { resolveCircularSegments, resolveGeometryResolution, } from "../resolution.js";
|
|
5
|
+
import { resolveGeometryTolerance } from "../tolerance.js";
|
|
6
|
+
import { assertFiniteThreadNumber, createThreadRidgeSection, freezeThreadProfilePoints, MAX_THREAD_PROFILE_POINTS, } from "./thread-profiles.js";
|
|
7
|
+
const validateMeshingProfile = (profile, linearTolerance, operation) => {
|
|
8
|
+
const pitch = assertFiniteThreadNumber(profile.pitch, "profile.pitch", operation);
|
|
9
|
+
const depth = assertFiniteThreadNumber(profile.depth, "profile.depth", operation);
|
|
10
|
+
const crestFlatWidth = assertFiniteThreadNumber(profile.crestFlatWidth, "profile.crestFlatWidth", operation);
|
|
11
|
+
const rootFlatWidth = assertFiniteThreadNumber(profile.rootFlatWidth, "profile.rootFlatWidth", operation);
|
|
12
|
+
if (!(pitch > linearTolerance && depth > linearTolerance)) {
|
|
13
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread profile pitch and depth must be positive.", {
|
|
14
|
+
operation,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (crestFlatWidth < 0 || rootFlatWidth < 0) {
|
|
18
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread profile flat widths cannot be negative.", {
|
|
19
|
+
operation,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (profile.points.length < 4 || profile.points.length > MAX_THREAD_PROFILE_POINTS) {
|
|
23
|
+
throw new GeometryError(profile.points.length > MAX_THREAD_PROFILE_POINTS ? "COMPLEXITY_LIMIT" : "INVALID_TOPOLOGY", `Thread profiles require 4 through ${MAX_THREAD_PROFILE_POINTS} points.`, { operation, details: { pointCount: profile.points.length } });
|
|
24
|
+
}
|
|
25
|
+
let maximum = Number.NEGATIVE_INFINITY;
|
|
26
|
+
let minimum = Number.POSITIVE_INFINITY;
|
|
27
|
+
for (let index = 0; index < profile.points.length; index += 1) {
|
|
28
|
+
const point = profile.points[index];
|
|
29
|
+
assertFiniteThreadNumber(point[0], `profile.points[${index}].x`, operation);
|
|
30
|
+
assertFiniteThreadNumber(point[1], `profile.points[${index}].y`, operation);
|
|
31
|
+
if (index > 0 && point[0] + linearTolerance < profile.points[index - 1][0]) {
|
|
32
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Thread profile axial coordinates must not decrease.", { operation });
|
|
33
|
+
}
|
|
34
|
+
maximum = Math.max(maximum, point[1]);
|
|
35
|
+
minimum = Math.min(minimum, point[1]);
|
|
36
|
+
}
|
|
37
|
+
if (Math.abs(profile.points[0][0] + pitch / 2) > linearTolerance ||
|
|
38
|
+
Math.abs(profile.points.at(-1)[0] - pitch / 2) > linearTolerance ||
|
|
39
|
+
Math.abs(profile.points[0][1] + depth) > linearTolerance ||
|
|
40
|
+
Math.abs(profile.points.at(-1)[1] + depth) > linearTolerance ||
|
|
41
|
+
Math.abs(maximum) > linearTolerance ||
|
|
42
|
+
Math.abs(minimum + depth) > linearTolerance) {
|
|
43
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Thread profiles must span one pitch from the minor envelope and reach radial zero.", { operation });
|
|
44
|
+
}
|
|
45
|
+
return createThreadRidgeSection(profile.points, minimum, linearTolerance, operation);
|
|
46
|
+
};
|
|
47
|
+
const reindexThreadSectionForMajorEnvelope = (section, pitch, linearTolerance, operation) => {
|
|
48
|
+
const maximum = Math.max(...section.map((point) => point[1]));
|
|
49
|
+
const extremeIndices = section
|
|
50
|
+
.map((point, index) => (Math.abs(point[1] - maximum) <= linearTolerance ? index : -1))
|
|
51
|
+
.filter((index) => index >= 0);
|
|
52
|
+
if (extremeIndices.length === 0) {
|
|
53
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Thread profile has no major-envelope plateau.", {
|
|
54
|
+
operation,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const firstExtreme = extremeIndices[0];
|
|
58
|
+
const lastExtreme = extremeIndices.at(-1);
|
|
59
|
+
const cutPoint = (section[firstExtreme][0] + section[lastExtreme][0]) / 2;
|
|
60
|
+
const reindexed = freezeThreadProfilePoints([
|
|
61
|
+
...section
|
|
62
|
+
.filter(([axial]) => axial >= cutPoint)
|
|
63
|
+
.map(([axial, radial]) => [axial - cutPoint - pitch / 2, radial]),
|
|
64
|
+
...section
|
|
65
|
+
.filter(([axial]) => axial < cutPoint)
|
|
66
|
+
.map(([axial, radial]) => [axial - cutPoint + pitch / 2, radial]),
|
|
67
|
+
]);
|
|
68
|
+
if (reindexed.length < 2 ||
|
|
69
|
+
reindexed.some((point, index) => index > 0 && point[0] < reindexed[index - 1][0]) ||
|
|
70
|
+
Math.abs(reindexed[0][1] - maximum) > linearTolerance ||
|
|
71
|
+
Math.abs(reindexed.at(-1)[1] - maximum) > linearTolerance) {
|
|
72
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Thread profile cannot be reindexed around its major envelope.", { operation });
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({ phaseOffset: cutPoint + pitch / 2, section: reindexed });
|
|
75
|
+
};
|
|
76
|
+
/** @internal Resolves shared ridge and solid sampling before construction. */
|
|
77
|
+
export const resolveHelicalThreadMeshOptions = (options, operation, circularSamplingRadius) => {
|
|
78
|
+
const diameter = assertFiniteThreadNumber(options.majorDiameter, "majorDiameter", operation);
|
|
79
|
+
const length = assertFiniteThreadNumber(options.length, "length", operation);
|
|
80
|
+
assertFiniteThreadNumber(options.profile.pitch, "profile.pitch", operation);
|
|
81
|
+
assertFiniteThreadNumber(options.profile.depth, "profile.depth", operation);
|
|
82
|
+
const starts = assertFiniteThreadNumber(options.starts ?? 1, "starts", operation);
|
|
83
|
+
const endLength = assertFiniteThreadNumber(options.ends?.endLength ?? 0, "endLength", operation);
|
|
84
|
+
if (!Number.isInteger(starts) || starts < 1 || starts > 32 || endLength < 0)
|
|
85
|
+
throw new GeometryError("INVALID_ARGUMENT", "starts or endLength are invalid.", { operation });
|
|
86
|
+
const tolerance = resolveGeometryTolerance(Math.max(diameter, length), options.tolerance);
|
|
87
|
+
const section = validateMeshingProfile(options.profile, tolerance.linear, operation);
|
|
88
|
+
const bottom = options.ends?.bottom ?? "blunt";
|
|
89
|
+
const top = options.ends?.top ?? "blunt";
|
|
90
|
+
if ((bottom !== "blunt" && bottom !== "standard" && bottom !== "tapered") ||
|
|
91
|
+
(top !== "blunt" && top !== "standard" && top !== "tapered"))
|
|
92
|
+
throw new GeometryError("INVALID_ARGUMENT", "Unsupported end style.", { operation });
|
|
93
|
+
const leadInShape = options.ends?.leadInShape ?? "cut";
|
|
94
|
+
const leadInEnvelope = options.ends?.leadInEnvelope ?? "minor";
|
|
95
|
+
const leadInLength = assertFiniteThreadNumber(options.ends?.leadInLength ?? options.profile.depth, "leadInLength", operation);
|
|
96
|
+
if ((leadInShape !== "cut" &&
|
|
97
|
+
leadInShape !== "linear" &&
|
|
98
|
+
leadInShape !== "smooth" &&
|
|
99
|
+
leadInShape !== "sqrt") ||
|
|
100
|
+
(leadInEnvelope !== "major" && leadInEnvelope !== "minor") ||
|
|
101
|
+
leadInLength < 0) {
|
|
102
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread lead-in options are invalid.", {
|
|
103
|
+
operation,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const bottomBoundary = bottom === "standard" ? 0 : endLength;
|
|
107
|
+
const topBoundary = top === "standard" ? length : length - endLength;
|
|
108
|
+
const usable = topBoundary - bottomBoundary;
|
|
109
|
+
if (!(diameter - 2 * options.profile.depth > tolerance.linear)) {
|
|
110
|
+
throw new GeometryError("INVALID_ARGUMENT", "A positive minor diameter is required.", {
|
|
111
|
+
operation,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (usable + tolerance.linear < 2 * options.profile.pitch) {
|
|
115
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread length must leave at least two usable pitches between its end treatments.", {
|
|
116
|
+
operation,
|
|
117
|
+
details: {
|
|
118
|
+
reason: "insufficient-usable-length",
|
|
119
|
+
required: 2 * options.profile.pitch,
|
|
120
|
+
usable,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const lead = options.profile.pitch * starts;
|
|
125
|
+
const surfaceRadius = circularSamplingRadius?.(diameter, length) ?? diameter / 2;
|
|
126
|
+
if (!Number.isFinite(surfaceRadius) || !(surfaceRadius > tolerance.linear)) {
|
|
127
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread circular sampling radius must be finite and greater than tolerance.", { operation });
|
|
128
|
+
}
|
|
129
|
+
const effectiveRadius = Math.hypot(Math.PI * surfaceRadius * 2, lead) / (Math.PI * 2);
|
|
130
|
+
const resolution = resolveGeometryResolution(options.resolution, options.limits);
|
|
131
|
+
const base = resolveCircularSegments(effectiveRadius, options.resolution, options.limits);
|
|
132
|
+
if (resolution.circularSegments !== undefined && base % starts !== 0) {
|
|
133
|
+
throw new GeometryError("INVALID_ARGUMENT", "Exact circularSegments must be divisible by the requested thread starts.", { operation, details: { circularSegments: base, starts } });
|
|
134
|
+
}
|
|
135
|
+
const segments = Math.ceil(base / starts) * starts;
|
|
136
|
+
const maximumSegments = resolution.maximumSegments;
|
|
137
|
+
if (segments > maximumSegments) {
|
|
138
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Start-aligned thread tessellation exceeds the configured segment safety limit.", { operation, details: { maximumSegments, segments } });
|
|
139
|
+
}
|
|
140
|
+
const handedness = options.handedness ?? "right";
|
|
141
|
+
if (handedness !== "left" && handedness !== "right")
|
|
142
|
+
throw new GeometryError("INVALID_ARGUMENT", "Unsupported handedness.", { operation });
|
|
143
|
+
const usesMajorEnvelopeLeadIn = leadInEnvelope === "major" &&
|
|
144
|
+
leadInShape !== "cut" &&
|
|
145
|
+
leadInLength > 0 &&
|
|
146
|
+
(bottom === "blunt" || top === "blunt");
|
|
147
|
+
const surface = usesMajorEnvelopeLeadIn
|
|
148
|
+
? reindexThreadSectionForMajorEnvelope(section, options.profile.pitch, tolerance.linear, operation)
|
|
149
|
+
: Object.freeze({
|
|
150
|
+
phaseOffset: 0,
|
|
151
|
+
// The active tooth plus its two envelope transitions is the complete nonredundant
|
|
152
|
+
// periodic surface. Missing space between copies is the flat envelope.
|
|
153
|
+
section,
|
|
154
|
+
});
|
|
155
|
+
const surfaceSection = surface.section;
|
|
156
|
+
const facetAxialStep = lead / segments;
|
|
157
|
+
const rawLeadInAxialLength = leadInShape === "cut" || !(leadInLength > 0) ? 0 : (lead * leadInLength) / (Math.PI * diameter);
|
|
158
|
+
const leadInAxialLength = rawLeadInAxialLength === 0
|
|
159
|
+
? 0
|
|
160
|
+
: Math.ceil(rawLeadInAxialLength / facetAxialStep - 1e-12) * facetAxialStep;
|
|
161
|
+
// BOSL2 keeps profile centers on one global pitch lattice. End reservations and role-specific
|
|
162
|
+
// bevel expansion must not phase-shift the complete helix; independently built male/female parts
|
|
163
|
+
// can reserve different blunt bands. Reindexing changes only representation, so carry its cyclic
|
|
164
|
+
// shift while leaving that shared global datum intact.
|
|
165
|
+
const surfacePhaseBase = surface.phaseOffset;
|
|
166
|
+
const quantizeUp = (value) => surfacePhaseBase +
|
|
167
|
+
Math.ceil((value - surfacePhaseBase) / facetAxialStep - 1e-12) * facetAxialStep;
|
|
168
|
+
const quantizeDown = (value) => surfacePhaseBase +
|
|
169
|
+
Math.floor((value - surfacePhaseBase) / facetAxialStep + 1e-12) * facetAxialStep;
|
|
170
|
+
const rawBottomLeadTipCenter = bottomBoundary - surfaceSection[0][0];
|
|
171
|
+
const bottomLeadTipCenter = quantizeUp(rawBottomLeadTipCenter);
|
|
172
|
+
const bottomLeadFullCenter = bottomLeadTipCenter + leadInAxialLength;
|
|
173
|
+
const rawTopLeadFullCenter = topBoundary - surfaceSection.at(-1)[0] - leadInAxialLength;
|
|
174
|
+
const topLeadFullCenter = quantizeDown(rawTopLeadFullCenter);
|
|
175
|
+
const topLeadTipCenter = topLeadFullCenter + leadInAxialLength;
|
|
176
|
+
if (leadInAxialLength > 0 &&
|
|
177
|
+
((bottom === "blunt" &&
|
|
178
|
+
top === "blunt" &&
|
|
179
|
+
bottomLeadFullCenter >= topLeadFullCenter - tolerance.linear) ||
|
|
180
|
+
(bottom === "blunt" && bottomLeadFullCenter >= topBoundary - tolerance.linear) ||
|
|
181
|
+
(top === "blunt" && topLeadFullCenter <= bottomBoundary + tolerance.linear))) {
|
|
182
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread lead-ins overlap inside the usable length.", {
|
|
183
|
+
operation,
|
|
184
|
+
details: { reason: "overlapping-lead-ins" },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
bottom,
|
|
189
|
+
bottomBoundary,
|
|
190
|
+
bottomLeadFullCenter,
|
|
191
|
+
bottomLeadTipCenter,
|
|
192
|
+
diameter,
|
|
193
|
+
direction: handedness === "right" ? 1 : -1,
|
|
194
|
+
endLength,
|
|
195
|
+
handedness,
|
|
196
|
+
lead,
|
|
197
|
+
leadInEnvelope,
|
|
198
|
+
leadInAxialLength,
|
|
199
|
+
leadInLength,
|
|
200
|
+
leadInShape,
|
|
201
|
+
length,
|
|
202
|
+
phase: (assertFiniteThreadNumber(options.phaseDegrees ?? 0, "phaseDegrees", operation) * Math.PI) /
|
|
203
|
+
180,
|
|
204
|
+
section,
|
|
205
|
+
segments,
|
|
206
|
+
starts,
|
|
207
|
+
surfacePhaseBase,
|
|
208
|
+
surfaceSection,
|
|
209
|
+
tolerance,
|
|
210
|
+
top,
|
|
211
|
+
topBoundary,
|
|
212
|
+
topLeadFullCenter,
|
|
213
|
+
topLeadTipCenter,
|
|
214
|
+
usable,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
/** @internal Resolves the active radial bevel boundary at one axial station. */
|
|
218
|
+
export const threadSurfaceBevelBoundaryAt = (axial, profile, resolved, options) => {
|
|
219
|
+
const bottomBevelSize = Math.min(options.bevelBottomSize ?? options.bevelSize ?? profile.depth, resolved.length / 2);
|
|
220
|
+
const topBevelSize = Math.min(options.bevelTopSize ?? options.bevelSize ?? profile.depth, resolved.length / 2);
|
|
221
|
+
const bevelOffset = options.bevelOffset ?? 0;
|
|
222
|
+
const bottomFace = bevelOffset;
|
|
223
|
+
const topFace = resolved.length - bevelOffset;
|
|
224
|
+
// Include the zero-depth stations here so callers can solve the exact point where a linear
|
|
225
|
+
// thread flank first meets the bevel. Applying the boundary at zero depth remains a no-op.
|
|
226
|
+
const bottomApplies = options.bevelBottom === true &&
|
|
227
|
+
bottomBevelSize > 0 &&
|
|
228
|
+
axial <= bottomFace + bottomBevelSize + resolved.tolerance.linear;
|
|
229
|
+
const topApplies = options.bevelTop === true &&
|
|
230
|
+
topBevelSize > 0 &&
|
|
231
|
+
axial >= topFace - topBevelSize - resolved.tolerance.linear;
|
|
232
|
+
if (!bottomApplies && !topApplies)
|
|
233
|
+
return undefined;
|
|
234
|
+
const bottomDepth = bottomApplies
|
|
235
|
+
? Math.max(0, Math.min(bottomBevelSize, bottomFace + bottomBevelSize - axial))
|
|
236
|
+
: Number.NEGATIVE_INFINITY;
|
|
237
|
+
const topDepth = topApplies
|
|
238
|
+
? Math.max(0, Math.min(topBevelSize, axial - (topFace - topBevelSize)))
|
|
239
|
+
: Number.NEGATIVE_INFINITY;
|
|
240
|
+
const bottomWins = bottomDepth >= topDepth;
|
|
241
|
+
const depth = bottomWins ? bottomDepth : topDepth;
|
|
242
|
+
const endStyle = bottomWins ? resolved.bottom : resolved.top;
|
|
243
|
+
const majorRadius = resolved.diameter / 2;
|
|
244
|
+
if ((options.bevelMode ?? "inset") === "outset") {
|
|
245
|
+
const recoveryRadius = endStyle === "blunt" ? majorRadius : majorRadius - profile.depth;
|
|
246
|
+
return { depth, endStyle, kind: "lower", radius: recoveryRadius + depth };
|
|
247
|
+
}
|
|
248
|
+
const recoveryRadius = endStyle === "blunt" ? majorRadius - profile.depth : majorRadius;
|
|
249
|
+
return { depth, endStyle, kind: "upper", radius: recoveryRadius - depth };
|
|
250
|
+
};
|
|
251
|
+
/** @internal Clips a radius against its resolved end bevel boundary. */
|
|
252
|
+
export const applyThreadSurfaceBevel = (radial, axial, profile, resolved, options) => {
|
|
253
|
+
const boundary = threadSurfaceBevelBoundaryAt(axial, profile, resolved, options);
|
|
254
|
+
if (boundary === undefined || !(boundary.depth > 0))
|
|
255
|
+
return radial;
|
|
256
|
+
return boundary.kind === "lower"
|
|
257
|
+
? Math.max(radial, boundary.radius)
|
|
258
|
+
: Math.min(radial, boundary.radius);
|
|
259
|
+
};
|
|
260
|
+
/** @internal Rejects a collapsed thread surface before emitting vertices. */
|
|
261
|
+
export const requirePositiveThreadSurfaceRadius = (radius, resolved, operation) => {
|
|
262
|
+
if (!(radius > resolved.tolerance.linear)) {
|
|
263
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", "Thread surface radius must stay positive.", {
|
|
264
|
+
operation,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const resolveMaximumThreadSurfaceRadius = (options, diameter, length, operation) => {
|
|
269
|
+
const bevelSize = assertFiniteThreadNumber(options.bevelSize ?? options.profile.depth, "bevelSize", operation);
|
|
270
|
+
const bottomBevelSize = assertFiniteThreadNumber(options.bevelBottomSize ?? bevelSize, "bevelBottomSize", operation);
|
|
271
|
+
const topBevelSize = assertFiniteThreadNumber(options.bevelTopSize ?? bevelSize, "bevelTopSize", operation);
|
|
272
|
+
const bevelOffset = assertFiniteThreadNumber(options.bevelOffset ?? 0, "bevelOffset", operation);
|
|
273
|
+
if (bevelSize < 0 ||
|
|
274
|
+
bottomBevelSize < 0 ||
|
|
275
|
+
topBevelSize < 0 ||
|
|
276
|
+
bevelOffset < 0 ||
|
|
277
|
+
bevelOffset * 2 > length ||
|
|
278
|
+
(options.bevelMode !== undefined &&
|
|
279
|
+
options.bevelMode !== "inset" &&
|
|
280
|
+
options.bevelMode !== "outset")) {
|
|
281
|
+
throw new GeometryError("INVALID_ARGUMENT", "Thread bevel options are invalid.", {
|
|
282
|
+
operation,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const majorRadius = diameter / 2;
|
|
286
|
+
if (options.bevelMode !== "outset")
|
|
287
|
+
return majorRadius;
|
|
288
|
+
const outsetRadius = (style, size) => (style === "blunt" ? majorRadius : majorRadius - options.profile.depth) +
|
|
289
|
+
Math.min(size, length / 2);
|
|
290
|
+
return Math.max(majorRadius, options.bevelBottom === true
|
|
291
|
+
? outsetRadius(options.ends?.bottom ?? "blunt", bottomBevelSize)
|
|
292
|
+
: majorRadius, options.bevelTop === true
|
|
293
|
+
? outsetRadius(options.ends?.top ?? "blunt", topBevelSize)
|
|
294
|
+
: majorRadius);
|
|
295
|
+
};
|
|
296
|
+
/** @internal Resolves the solid's sampling and conservative allocation plan. */
|
|
297
|
+
export const resolveHelicalThreadSolidMeshPreflight = (options, operation) => {
|
|
298
|
+
const requestedEndLength = options.ends?.endLength ?? 0;
|
|
299
|
+
const sharedBevelSize = options.bevelSize ?? options.profile.depth;
|
|
300
|
+
const bottomBevelSize = options.bevelBottomSize ?? sharedBevelSize;
|
|
301
|
+
const topBevelSize = options.bevelTopSize ?? sharedBevelSize;
|
|
302
|
+
const bluntBevelReservation = Math.max(options.bevelBottom === true && (options.ends?.bottom ?? "blunt") === "blunt"
|
|
303
|
+
? bottomBevelSize
|
|
304
|
+
: 0, options.bevelTop === true && (options.ends?.top ?? "blunt") === "blunt" ? topBevelSize : 0);
|
|
305
|
+
const effectiveBluntBevelReservation = Math.min(bluntBevelReservation, options.length / 2);
|
|
306
|
+
const resolvedOptions = effectiveBluntBevelReservation > 0
|
|
307
|
+
? {
|
|
308
|
+
...options,
|
|
309
|
+
ends: {
|
|
310
|
+
...options.ends,
|
|
311
|
+
endLength: Math.max(requestedEndLength, effectiveBluntBevelReservation),
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
: options;
|
|
315
|
+
const resolved = resolveHelicalThreadMeshOptions(resolvedOptions, operation, (diameter, length) => resolveMaximumThreadSurfaceRadius(resolvedOptions, diameter, length, operation));
|
|
316
|
+
if ((options.bevelMode ?? "inset") === "inset") {
|
|
317
|
+
const majorRadius = resolved.diameter / 2;
|
|
318
|
+
if (options.bevelBottom === true) {
|
|
319
|
+
requirePositiveThreadSurfaceRadius(applyThreadSurfaceBevel(majorRadius, 0, options.profile, resolved, resolvedOptions), resolved, operation);
|
|
320
|
+
}
|
|
321
|
+
if (options.bevelTop === true) {
|
|
322
|
+
requirePositiveThreadSurfaceRadius(applyThreadSurfaceBevel(majorRadius, resolved.length, options.profile, resolved, resolvedOptions), resolved, operation);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const resolution = resolveGeometryResolution(options.resolution, options.limits);
|
|
326
|
+
// Profile breakpoints already bound the axial error of each helical facet. Sampling an
|
|
327
|
+
// additional row for every angular phase step squares mesh density as circular quality rises
|
|
328
|
+
// and produces thousands of near-collinear zipper triangles. Only a caller's physical edge
|
|
329
|
+
// limit warrants extra axial samples between those semantic stations.
|
|
330
|
+
const maximumAxialSegmentLength = resolution.maxSegmentLength ?? Number.POSITIVE_INFINITY;
|
|
331
|
+
const axialIntervals = Math.ceil(resolved.length / maximumAxialSegmentLength);
|
|
332
|
+
const profilePeriods = Math.ceil(resolved.usable / options.profile.pitch) +
|
|
333
|
+
(resolved.leadInShape !== "cut" && (resolved.bottom === "blunt" || resolved.top === "blunt")
|
|
334
|
+
? 4
|
|
335
|
+
: 2);
|
|
336
|
+
const estimatedRows = axialIntervals + 1 + resolved.surfaceSection.length * profilePeriods + 10;
|
|
337
|
+
const estimatedTriangles = 2 * resolved.segments * estimatedRows;
|
|
338
|
+
const vertices = (estimatedRows + 2) * resolved.segments;
|
|
339
|
+
assertMeshAllocationWithinLimits(vertices, estimatedTriangles, (vertices + estimatedTriangles) * 12, operation, resolveModelingLimits(options.limits));
|
|
340
|
+
return Object.freeze({
|
|
341
|
+
maximumAxialSegmentLength,
|
|
342
|
+
preflight: Object.freeze({
|
|
343
|
+
axialIntervals,
|
|
344
|
+
circularSegments: resolved.segments,
|
|
345
|
+
estimatedRows,
|
|
346
|
+
estimatedTriangles,
|
|
347
|
+
}),
|
|
348
|
+
resolved,
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
/**
|
|
352
|
+
* Validates a direct thread solid and returns its conservative allocation estimate without
|
|
353
|
+
* constructing vertex or index buffers.
|
|
354
|
+
*/
|
|
355
|
+
export function preflightHelicalThreadSolidMesh(options) {
|
|
356
|
+
return resolveHelicalThreadSolidMeshPreflight(options, "preflightHelicalThreadSolidMesh")
|
|
357
|
+
.preflight;
|
|
358
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Closed ridge and complete-solid mesh construction from resolved thread plans. */
|
|
2
|
+
import { triangulateLoop2 } from "../loft.js";
|
|
3
|
+
import { joinTriangleMeshes, measureTriangleMesh, reverseTriangleMesh, validateTriangleMesh, } from "../mesh.js";
|
|
4
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits } from "../model-limits.js";
|
|
5
|
+
import { resolveHelicalThreadMeshOptions, resolveHelicalThreadSolidMeshPreflight, } from "./thread-mesh-plan.js";
|
|
6
|
+
import { buildThreadSurfaceColumn, stitchThreadSurfaceColumns } from "./thread-surface.js";
|
|
7
|
+
const MINIMUM_TAPERED_RIDGE_HEIGHT = 0.0005;
|
|
8
|
+
const buildThreadRidgeComponent = (options) => {
|
|
9
|
+
const { profile } = options;
|
|
10
|
+
const { section } = options;
|
|
11
|
+
const lead = profile.pitch * options.starts;
|
|
12
|
+
const z0 = options.bottomBoundary - section[0][0];
|
|
13
|
+
const z1 = options.topBoundary - section.at(-1)[0];
|
|
14
|
+
const axialStep = lead / options.segments;
|
|
15
|
+
const exactSpans = (z1 - z0) / axialStep;
|
|
16
|
+
const roundedSpans = Math.round(exactSpans);
|
|
17
|
+
// Decimal metric dimensions can land a few ulps above an integral column
|
|
18
|
+
// count. Do not add a microscopic terminal span that collapses in Float32.
|
|
19
|
+
const spans = Math.max(1, Math.abs(exactSpans - roundedSpans) <= 1e-6 ? roundedSpans : Math.ceil(exactSpans));
|
|
20
|
+
const size = section.length;
|
|
21
|
+
const positions = [];
|
|
22
|
+
const initial = options.phase + (options.direction * 2 * Math.PI * options.start) / options.starts;
|
|
23
|
+
for (let column = 0; column <= spans; column += 1) {
|
|
24
|
+
// Keep complete columns on the same angular grid as a cylinder with the
|
|
25
|
+
// requested circular segment count. Only a fractional terminal column is
|
|
26
|
+
// allowed to leave that grid so arbitrary lengths still end exactly.
|
|
27
|
+
const centerZ = column === spans ? z1 : z0 + column * axialStep;
|
|
28
|
+
const angle = initial + (options.direction * 2 * Math.PI * (centerZ - z0)) / lead;
|
|
29
|
+
const bottomFactor = options.bottom === "tapered" ? (centerZ - z0) / profile.depth : 1;
|
|
30
|
+
const topFactor = options.top === "tapered" ? (z1 - centerZ) / profile.depth : 1;
|
|
31
|
+
// Keep the terminal cross-section non-degenerate in the standalone ridge mesh with a 0.5 µm
|
|
32
|
+
// radial remnant. Complete thread solids use their integrated end surface instead.
|
|
33
|
+
const minimumFactor = Math.min(1, MINIMUM_TAPERED_RIDGE_HEIGHT / profile.depth);
|
|
34
|
+
const factor = Math.max(minimumFactor, Math.min(bottomFactor, topFactor, 1));
|
|
35
|
+
for (const [axial, radial] of section) {
|
|
36
|
+
const radius = options.majorDiameter / 2 - profile.depth + (radial + profile.depth) * factor;
|
|
37
|
+
positions.push(radius * Math.cos(angle), radius * Math.sin(angle), centerZ + axial);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const indices = [];
|
|
41
|
+
for (let column = 0; column < spans; column += 1)
|
|
42
|
+
for (let point = 0; point < size; point += 1) {
|
|
43
|
+
const next = (point + 1) % size;
|
|
44
|
+
const a = column * size + point, b = column * size + next;
|
|
45
|
+
const c = (column + 1) * size + next, d = (column + 1) * size + point;
|
|
46
|
+
indices.push(a, b, c, a, c, d);
|
|
47
|
+
}
|
|
48
|
+
const capTriangles = triangulateLoop2([...section].reverse());
|
|
49
|
+
const cap = (column, top) => {
|
|
50
|
+
for (let offset = 0; offset < capTriangles.length; offset += 3) {
|
|
51
|
+
const a = column * size + (size - 1 - capTriangles[offset]);
|
|
52
|
+
const b = column * size + (size - 1 - capTriangles[offset + 1]);
|
|
53
|
+
const c = column * size + (size - 1 - capTriangles[offset + 2]);
|
|
54
|
+
indices.push(...(top ? [a, c, b] : [a, b, c]));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
cap(0, false);
|
|
58
|
+
cap(spans, true);
|
|
59
|
+
let mesh = {
|
|
60
|
+
positions: new Float32Array(positions),
|
|
61
|
+
indices: new Uint32Array(indices),
|
|
62
|
+
};
|
|
63
|
+
validateTriangleMesh(mesh, { requireClosed: true });
|
|
64
|
+
if (measureTriangleMesh(mesh, { requireClosed: true }).signedVolume < 0)
|
|
65
|
+
mesh = reverseTriangleMesh(mesh);
|
|
66
|
+
return mesh;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Builds one closed threaded cylinder without a ridge/core Boolean seam.
|
|
70
|
+
*
|
|
71
|
+
* Each circular facet includes its exact phase-shifted profile breakpoints, including duplicate-Z
|
|
72
|
+
* samples for vertical flanks and blunt transitions. Adjacent variable grids are stitched as a
|
|
73
|
+
* monotone strip, with optional intermediate rows bounded by physical `maxSegmentLength`. A
|
|
74
|
+
* conservative pre-allocation estimate rejects excessive meshes before constructing position
|
|
75
|
+
* arrays.
|
|
76
|
+
*/
|
|
77
|
+
export function buildHelicalThreadSolidMesh(options) {
|
|
78
|
+
const operation = "buildHelicalThreadSolidMesh";
|
|
79
|
+
const { maximumAxialSegmentLength, resolved } = resolveHelicalThreadSolidMeshPreflight(options, operation);
|
|
80
|
+
const positions = [];
|
|
81
|
+
const columns = Array.from({ length: resolved.segments }, (_, column) => buildThreadSurfaceColumn(options.profile, resolved, options, resolved.phase + (column * 2 * Math.PI) / resolved.segments, maximumAxialSegmentLength, positions));
|
|
82
|
+
const indices = [];
|
|
83
|
+
for (let column = 0; column < columns.length; column += 1) {
|
|
84
|
+
stitchThreadSurfaceColumns(columns[column], columns[(column + 1) % columns.length], resolved.direction, column === columns.length - 1
|
|
85
|
+
? resolved.direction * resolved.starts * resolved.surfaceSection.length
|
|
86
|
+
: 0, resolved.tolerance.linear, positions, resolved.leadInEnvelope === "major", indices);
|
|
87
|
+
}
|
|
88
|
+
const bottomCenter = positions.length / 3;
|
|
89
|
+
positions.push(0, 0, 0);
|
|
90
|
+
const topCenter = positions.length / 3;
|
|
91
|
+
positions.push(0, 0, resolved.length);
|
|
92
|
+
for (let column = 0; column < columns.length; column += 1) {
|
|
93
|
+
const next = (column + 1) % columns.length;
|
|
94
|
+
const bottom = columns[column].indices[0];
|
|
95
|
+
const nextBottom = columns[next].indices[0];
|
|
96
|
+
const top = columns[column].indices.at(-1);
|
|
97
|
+
const nextTop = columns[next].indices.at(-1);
|
|
98
|
+
indices.push(bottomCenter, nextBottom, bottom, topCenter, top, nextTop);
|
|
99
|
+
}
|
|
100
|
+
assertMeshAllocationWithinLimits(positions.length / 3, indices.length / 3, (positions.length + indices.length) * 4, operation, resolveModelingLimits(options.limits));
|
|
101
|
+
let mesh = {
|
|
102
|
+
positions: new Float32Array(positions),
|
|
103
|
+
indices: new Uint32Array(indices),
|
|
104
|
+
};
|
|
105
|
+
validateTriangleMesh(mesh, { requireClosed: true });
|
|
106
|
+
if (measureTriangleMesh(mesh, { requireClosed: true }).signedVolume < 0) {
|
|
107
|
+
mesh = reverseTriangleMesh(mesh);
|
|
108
|
+
}
|
|
109
|
+
return mesh;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Builds closed thread-ridge components with every profile locked to the
|
|
113
|
+
* shaft's radial/axial frame. The mesh is intended for union with its
|
|
114
|
+
* minor-envelope cylinder and never owns WASM objects.
|
|
115
|
+
*/
|
|
116
|
+
export function buildHelicalThreadRidgeMesh(options) {
|
|
117
|
+
const operation = "buildHelicalThreadRidgeMesh";
|
|
118
|
+
const resolved = resolveHelicalThreadMeshOptions(options, operation);
|
|
119
|
+
const z0 = resolved.bottomBoundary - resolved.section[0][0];
|
|
120
|
+
const z1 = resolved.topBoundary - resolved.section.at(-1)[0];
|
|
121
|
+
const exactSpans = (z1 - z0) / ((options.profile.pitch * resolved.starts) / resolved.segments);
|
|
122
|
+
const roundedSpans = Math.round(exactSpans);
|
|
123
|
+
const spans = Math.max(1, Math.abs(exactSpans - roundedSpans) <= 1e-6 ? roundedSpans : Math.ceil(exactSpans));
|
|
124
|
+
const vertices = resolved.starts * (spans + 1) * resolved.section.length;
|
|
125
|
+
const triangles = resolved.starts * (spans * resolved.section.length * 2 + (resolved.section.length - 2) * 2);
|
|
126
|
+
assertMeshAllocationWithinLimits(vertices, triangles, (vertices + triangles) * 12, operation, resolveModelingLimits(options.limits));
|
|
127
|
+
return joinTriangleMeshes(Array.from({ length: resolved.starts }, (_, start) => buildThreadRidgeComponent({
|
|
128
|
+
length: resolved.length,
|
|
129
|
+
majorDiameter: resolved.diameter,
|
|
130
|
+
profile: options.profile,
|
|
131
|
+
start,
|
|
132
|
+
starts: resolved.starts,
|
|
133
|
+
phase: resolved.phase,
|
|
134
|
+
direction: resolved.direction,
|
|
135
|
+
segments: resolved.segments,
|
|
136
|
+
bottomBoundary: resolved.bottomBoundary,
|
|
137
|
+
topBoundary: resolved.topBoundary,
|
|
138
|
+
bottom: resolved.bottom,
|
|
139
|
+
top: resolved.top,
|
|
140
|
+
section: resolved.section,
|
|
141
|
+
})));
|
|
142
|
+
}
|