@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,396 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { resolveGeometryTolerance, } from "./tolerance.js";
|
|
3
|
+
const DEFAULT_MAXIMUM_TRIANGLE_COUNT = 200_000;
|
|
4
|
+
const FLOAT32_EPSILON = 2 ** -23;
|
|
5
|
+
const MINIMUM_PLANAR_ANGULAR_TOLERANCE = 1e-5;
|
|
6
|
+
const edgeKey = (first, second) => first < second ? `${first}:${second}` : `${second}:${first}`;
|
|
7
|
+
const pointAt = (positions, vertex) => [
|
|
8
|
+
positions[vertex * 3],
|
|
9
|
+
positions[vertex * 3 + 1],
|
|
10
|
+
positions[vertex * 3 + 2],
|
|
11
|
+
];
|
|
12
|
+
const subtract = (left, right) => [
|
|
13
|
+
left[0] - right[0],
|
|
14
|
+
left[1] - right[1],
|
|
15
|
+
left[2] - right[2],
|
|
16
|
+
];
|
|
17
|
+
const dot = (left, right) => left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
|
18
|
+
const cross = (left, right) => [
|
|
19
|
+
left[1] * right[2] - left[2] * right[1],
|
|
20
|
+
left[2] * right[0] - left[0] * right[2],
|
|
21
|
+
left[0] * right[1] - left[1] * right[0],
|
|
22
|
+
];
|
|
23
|
+
const length = (vector) => Math.hypot(vector[0], vector[1], vector[2]);
|
|
24
|
+
const maximumUint32 = (values) => {
|
|
25
|
+
let maximum = 0;
|
|
26
|
+
for (const value of values)
|
|
27
|
+
maximum = Math.max(maximum, value);
|
|
28
|
+
return maximum;
|
|
29
|
+
};
|
|
30
|
+
const maximumIterable = (values) => {
|
|
31
|
+
let maximum = 0;
|
|
32
|
+
for (const value of values)
|
|
33
|
+
maximum = Math.max(maximum, value);
|
|
34
|
+
return maximum;
|
|
35
|
+
};
|
|
36
|
+
const positiveInteger = (value, label) => {
|
|
37
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
38
|
+
throw new GeometryError("INVALID_ARGUMENT", `${label} must be a positive safe integer.`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
const structurallyValidateMesh = (mesh, maximumTriangleCount) => {
|
|
43
|
+
if (!(mesh.positions instanceof Float32Array) || !(mesh.indices instanceof Uint32Array)) {
|
|
44
|
+
throw new GeometryError("INVALID_MESH", "Triangle meshes require Float32Array positions and Uint32Array indices.");
|
|
45
|
+
}
|
|
46
|
+
if (mesh.positions.length === 0 || mesh.positions.length % 3 !== 0) {
|
|
47
|
+
throw new GeometryError("INVALID_MESH", "Mesh positions must contain complete XYZ vertices.");
|
|
48
|
+
}
|
|
49
|
+
if (mesh.indices.length === 0 || mesh.indices.length % 3 !== 0) {
|
|
50
|
+
throw new GeometryError("INVALID_MESH", "Mesh indices must contain complete triangles.");
|
|
51
|
+
}
|
|
52
|
+
const triangleCount = mesh.indices.length / 3;
|
|
53
|
+
if (triangleCount > maximumTriangleCount) {
|
|
54
|
+
throw new GeometryError("COMPLEXITY_LIMIT", `Mesh quality analysis is limited to ${maximumTriangleCount} triangles.`);
|
|
55
|
+
}
|
|
56
|
+
for (let index = 0; index < mesh.positions.length; index += 1) {
|
|
57
|
+
if (!Number.isFinite(mesh.positions[index])) {
|
|
58
|
+
throw new GeometryError("NON_FINITE_VALUE", `Mesh position ${index} must be finite.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const vertexCount = mesh.positions.length / 3;
|
|
62
|
+
for (let index = 0; index < mesh.indices.length; index += 1) {
|
|
63
|
+
if (mesh.indices[index] >= vertexCount) {
|
|
64
|
+
throw new GeometryError("INVALID_MESH", `Triangle ${Math.floor(index / 3)} references a missing vertex.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const meshBoundsAndExtent = (positions) => {
|
|
69
|
+
const minimum = [Infinity, Infinity, Infinity];
|
|
70
|
+
const maximum = [-Infinity, -Infinity, -Infinity];
|
|
71
|
+
for (let offset = 0; offset < positions.length; offset += 3) {
|
|
72
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
73
|
+
minimum[axis] = Math.min(minimum[axis], positions[offset + axis]);
|
|
74
|
+
maximum[axis] = Math.max(maximum[axis], positions[offset + axis]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
bounds: { min: minimum, max: maximum },
|
|
79
|
+
extent: Math.max(maximum[0] - minimum[0], maximum[1] - minimum[1], maximum[2] - minimum[2]),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const triangleAngles = (first, second, third) => {
|
|
83
|
+
const firstSecond = subtract(second, first);
|
|
84
|
+
const firstThird = subtract(third, first);
|
|
85
|
+
const secondThird = subtract(third, second);
|
|
86
|
+
const lengthFirstSecond = length(firstSecond);
|
|
87
|
+
const lengthFirstThird = length(firstThird);
|
|
88
|
+
const lengthSecondThird = length(secondThird);
|
|
89
|
+
if (lengthFirstSecond === 0 || lengthFirstThird === 0 || lengthSecondThird === 0) {
|
|
90
|
+
return [0, 0, 0];
|
|
91
|
+
}
|
|
92
|
+
const thirdFirst = subtract(first, third);
|
|
93
|
+
const secondFirst = [-firstSecond[0], -firstSecond[1], -firstSecond[2]];
|
|
94
|
+
const thirdSecond = [-secondThird[0], -secondThird[1], -secondThird[2]];
|
|
95
|
+
return [
|
|
96
|
+
Math.atan2(length(cross(firstSecond, firstThird)), dot(firstSecond, firstThird)),
|
|
97
|
+
Math.atan2(length(cross(secondThird, secondFirst)), dot(secondThird, secondFirst)),
|
|
98
|
+
Math.atan2(length(cross(thirdFirst, thirdSecond)), dot(thirdFirst, thirdSecond)),
|
|
99
|
+
];
|
|
100
|
+
};
|
|
101
|
+
const faceAnalysis = (positions, vertices, index) => {
|
|
102
|
+
const first = pointAt(positions, vertices[0]);
|
|
103
|
+
const second = pointAt(positions, vertices[1]);
|
|
104
|
+
const third = pointAt(positions, vertices[2]);
|
|
105
|
+
const firstSecond = subtract(second, first);
|
|
106
|
+
const firstThird = subtract(third, first);
|
|
107
|
+
const secondThird = subtract(third, second);
|
|
108
|
+
const product = cross(firstSecond, firstThird);
|
|
109
|
+
const doubleArea = length(product);
|
|
110
|
+
const area = doubleArea / 2;
|
|
111
|
+
const longestEdge = Math.max(length(firstSecond), length(firstThird), length(secondThird));
|
|
112
|
+
const normal = doubleArea === 0
|
|
113
|
+
? undefined
|
|
114
|
+
: [product[0] / doubleArea, product[1] / doubleArea, product[2] / doubleArea];
|
|
115
|
+
const angles = triangleAngles(first, second, third);
|
|
116
|
+
return {
|
|
117
|
+
area,
|
|
118
|
+
aspectRatio: area === 0 ? Number.POSITIVE_INFINITY : (Math.sqrt(3) * longestEdge ** 2) / (4 * area),
|
|
119
|
+
doubleArea,
|
|
120
|
+
index,
|
|
121
|
+
minimumAngleRadians: Math.min(...angles),
|
|
122
|
+
normal,
|
|
123
|
+
planeOffset: normal === undefined ? undefined : dot(normal, first),
|
|
124
|
+
vertices,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const facesAreCoplanar = (reference, candidate, positions, angularTolerance, linearTolerance) => {
|
|
128
|
+
if (reference.normal === undefined ||
|
|
129
|
+
reference.planeOffset === undefined ||
|
|
130
|
+
candidate.normal === undefined ||
|
|
131
|
+
dot(reference.normal, candidate.normal) < Math.cos(angularTolerance)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return candidate.vertices.every((vertex) => Math.abs(dot(reference.normal, pointAt(positions, vertex)) - reference.planeOffset) <=
|
|
135
|
+
linearTolerance);
|
|
136
|
+
};
|
|
137
|
+
const countBoundaryLoops = (boundaryEdges) => {
|
|
138
|
+
if (boundaryEdges.length === 0)
|
|
139
|
+
return 0;
|
|
140
|
+
const neighbours = new Map();
|
|
141
|
+
for (const edge of boundaryEdges) {
|
|
142
|
+
const first = neighbours.get(edge.first) ?? new Set();
|
|
143
|
+
const second = neighbours.get(edge.second) ?? new Set();
|
|
144
|
+
first.add(edge.second);
|
|
145
|
+
second.add(edge.first);
|
|
146
|
+
neighbours.set(edge.first, first);
|
|
147
|
+
neighbours.set(edge.second, second);
|
|
148
|
+
}
|
|
149
|
+
const visited = new Set();
|
|
150
|
+
let components = 0;
|
|
151
|
+
for (const start of neighbours.keys()) {
|
|
152
|
+
if (visited.has(start))
|
|
153
|
+
continue;
|
|
154
|
+
components += 1;
|
|
155
|
+
const pending = [start];
|
|
156
|
+
visited.add(start);
|
|
157
|
+
while (pending.length > 0) {
|
|
158
|
+
const vertex = pending.pop();
|
|
159
|
+
for (const neighbour of neighbours.get(vertex) ?? []) {
|
|
160
|
+
if (visited.has(neighbour))
|
|
161
|
+
continue;
|
|
162
|
+
visited.add(neighbour);
|
|
163
|
+
pending.push(neighbour);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return components;
|
|
168
|
+
};
|
|
169
|
+
const buildPatchAnalysis = (faces, edges, faceIndices, id) => {
|
|
170
|
+
const faceSet = new Set(faceIndices);
|
|
171
|
+
const patchEdgeKeys = new Set();
|
|
172
|
+
const vertices = new Set();
|
|
173
|
+
const neighbours = new Map();
|
|
174
|
+
const incidence = new Map();
|
|
175
|
+
const boundaryEdges = [];
|
|
176
|
+
let internalEdgeCount = 0;
|
|
177
|
+
let surfaceArea = 0;
|
|
178
|
+
let minimumAngleRadians = Number.POSITIVE_INFINITY;
|
|
179
|
+
let maximumAspectRatio = 0;
|
|
180
|
+
for (const faceIndex of faceIndices) {
|
|
181
|
+
const face = faces[faceIndex];
|
|
182
|
+
surfaceArea += face.area;
|
|
183
|
+
minimumAngleRadians = Math.min(minimumAngleRadians, face.minimumAngleRadians);
|
|
184
|
+
maximumAspectRatio = Math.max(maximumAspectRatio, face.aspectRatio);
|
|
185
|
+
for (const vertex of face.vertices) {
|
|
186
|
+
vertices.add(vertex);
|
|
187
|
+
incidence.set(vertex, (incidence.get(vertex) ?? 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
for (let localEdge = 0; localEdge < 3; localEdge += 1) {
|
|
190
|
+
patchEdgeKeys.add(edgeKey(face.vertices[localEdge], face.vertices[(localEdge + 1) % 3]));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const key of patchEdgeKeys) {
|
|
194
|
+
const edge = edges.get(key);
|
|
195
|
+
const patchUseCount = edge.uses.reduce((count, use) => count + (faceSet.has(use.face) ? 1 : 0), 0);
|
|
196
|
+
if (patchUseCount === 1)
|
|
197
|
+
boundaryEdges.push(edge);
|
|
198
|
+
else if (patchUseCount === 2)
|
|
199
|
+
internalEdgeCount += 1;
|
|
200
|
+
if (patchUseCount > 0) {
|
|
201
|
+
const first = neighbours.get(edge.first) ?? new Set();
|
|
202
|
+
const second = neighbours.get(edge.second) ?? new Set();
|
|
203
|
+
first.add(edge.second);
|
|
204
|
+
second.add(edge.first);
|
|
205
|
+
neighbours.set(edge.first, first);
|
|
206
|
+
neighbours.set(edge.second, second);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const maximumVertexTriangleIncidence = maximumIterable(incidence.values());
|
|
210
|
+
const firstFace = faces[faceIndices[0]];
|
|
211
|
+
return {
|
|
212
|
+
boundaryEdgeKeys: new Set(boundaryEdges.map(({ key }) => key)),
|
|
213
|
+
faceIndices,
|
|
214
|
+
report: {
|
|
215
|
+
boundaryEdgeCount: boundaryEdges.length,
|
|
216
|
+
boundaryLoopCount: countBoundaryLoops(boundaryEdges),
|
|
217
|
+
fanRatio: maximumVertexTriangleIncidence / faceIndices.length,
|
|
218
|
+
firstTriangleIndex: faceIndices[0],
|
|
219
|
+
id,
|
|
220
|
+
internalEdgeCount,
|
|
221
|
+
maximumTriangleAspectRatio: maximumAspectRatio,
|
|
222
|
+
maximumVertexTriangleIncidence,
|
|
223
|
+
maximumVertexValence: maximumIterable((function* () {
|
|
224
|
+
for (const neighbourSet of neighbours.values())
|
|
225
|
+
yield neighbourSet.size;
|
|
226
|
+
})()),
|
|
227
|
+
minimumTriangleAngleDegrees: (minimumAngleRadians * 180) / Math.PI,
|
|
228
|
+
normal: firstFace.normal,
|
|
229
|
+
planeOffset: firstFace.planeOffset,
|
|
230
|
+
surfaceArea,
|
|
231
|
+
triangleCount: faceIndices.length,
|
|
232
|
+
vertexCount: vertices.size,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
/** @internal */
|
|
237
|
+
export function buildTriangleMeshQualityAnalysis(mesh, options = {}) {
|
|
238
|
+
const maximumTriangleCount = positiveInteger(options.maximumTriangleCount ?? DEFAULT_MAXIMUM_TRIANGLE_COUNT, "Maximum analyzed triangle count");
|
|
239
|
+
structurallyValidateMesh(mesh, maximumTriangleCount);
|
|
240
|
+
const { bounds, extent } = meshBoundsAndExtent(mesh.positions);
|
|
241
|
+
const tolerance = resolveGeometryTolerance(extent, options.tolerance);
|
|
242
|
+
const planarLinearTolerance = Math.max(tolerance.linear, Math.max(extent, 1) * FLOAT32_EPSILON * 8);
|
|
243
|
+
const planarAngularTolerance = Math.max(tolerance.angular, MINIMUM_PLANAR_ANGULAR_TOLERANCE);
|
|
244
|
+
const vertexCount = mesh.positions.length / 3;
|
|
245
|
+
const triangleCount = mesh.indices.length / 3;
|
|
246
|
+
const neighbourSets = Array.from({ length: vertexCount }, () => new Set());
|
|
247
|
+
const vertexTriangleIncidence = new Uint32Array(vertexCount);
|
|
248
|
+
const mutableEdges = new Map();
|
|
249
|
+
const faces = [];
|
|
250
|
+
const duplicateFaces = new Map();
|
|
251
|
+
let surfaceArea = 0;
|
|
252
|
+
let signedVolume = 0;
|
|
253
|
+
let nearZeroAreaTriangleCount = 0;
|
|
254
|
+
let minimumTriangleAngleRadians = Number.POSITIVE_INFINITY;
|
|
255
|
+
let maximumTriangleAspectRatio = 0;
|
|
256
|
+
for (let offset = 0; offset < mesh.indices.length; offset += 3) {
|
|
257
|
+
const vertices = [
|
|
258
|
+
mesh.indices[offset],
|
|
259
|
+
mesh.indices[offset + 1],
|
|
260
|
+
mesh.indices[offset + 2],
|
|
261
|
+
];
|
|
262
|
+
const face = faceAnalysis(mesh.positions, vertices, offset / 3);
|
|
263
|
+
faces.push(face);
|
|
264
|
+
surfaceArea += face.area;
|
|
265
|
+
minimumTriangleAngleRadians = Math.min(minimumTriangleAngleRadians, face.minimumAngleRadians);
|
|
266
|
+
maximumTriangleAspectRatio = Math.max(maximumTriangleAspectRatio, face.aspectRatio);
|
|
267
|
+
if (new Set(vertices).size !== 3 ||
|
|
268
|
+
face.doubleArea <= tolerance.area ||
|
|
269
|
+
face.normal === undefined) {
|
|
270
|
+
nearZeroAreaTriangleCount += 1;
|
|
271
|
+
}
|
|
272
|
+
const first = pointAt(mesh.positions, vertices[0]);
|
|
273
|
+
const second = pointAt(mesh.positions, vertices[1]);
|
|
274
|
+
const third = pointAt(mesh.positions, vertices[2]);
|
|
275
|
+
signedVolume +=
|
|
276
|
+
(first[0] * (second[1] * third[2] - second[2] * third[1]) +
|
|
277
|
+
first[1] * (second[2] * third[0] - second[0] * third[2]) +
|
|
278
|
+
first[2] * (second[0] * third[1] - second[1] * third[0])) /
|
|
279
|
+
6;
|
|
280
|
+
const duplicateKey = [...vertices].sort((left, right) => left - right).join(":");
|
|
281
|
+
duplicateFaces.set(duplicateKey, (duplicateFaces.get(duplicateKey) ?? 0) + 1);
|
|
282
|
+
for (const vertex of vertices)
|
|
283
|
+
vertexTriangleIncidence[vertex] += 1;
|
|
284
|
+
for (let localEdge = 0; localEdge < 3; localEdge += 1) {
|
|
285
|
+
const start = vertices[localEdge];
|
|
286
|
+
const end = vertices[(localEdge + 1) % 3];
|
|
287
|
+
neighbourSets[start].add(end);
|
|
288
|
+
neighbourSets[end].add(start);
|
|
289
|
+
const key = edgeKey(start, end);
|
|
290
|
+
const edge = mutableEdges.get(key) ??
|
|
291
|
+
{
|
|
292
|
+
first: Math.min(start, end),
|
|
293
|
+
key,
|
|
294
|
+
second: Math.max(start, end),
|
|
295
|
+
uses: [],
|
|
296
|
+
};
|
|
297
|
+
edge.uses.push({ end, face: offset / 3, start });
|
|
298
|
+
mutableEdges.set(key, edge);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const edges = new Map(mutableEdges);
|
|
302
|
+
const adjacency = Array.from({ length: triangleCount }, () => []);
|
|
303
|
+
for (const edge of edges.values()) {
|
|
304
|
+
if (edge.uses.length !== 2)
|
|
305
|
+
continue;
|
|
306
|
+
const first = edge.uses[0].face;
|
|
307
|
+
const second = edge.uses[1].face;
|
|
308
|
+
if (facesAreCoplanar(faces[first], faces[second], mesh.positions, planarAngularTolerance, planarLinearTolerance)) {
|
|
309
|
+
adjacency[first].push(second);
|
|
310
|
+
adjacency[second].push(first);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const patchByFace = new Int32Array(triangleCount).fill(-1);
|
|
314
|
+
const patches = [];
|
|
315
|
+
for (let seed = 0; seed < triangleCount; seed += 1) {
|
|
316
|
+
if (patchByFace[seed] !== -1 || faces[seed].normal === undefined)
|
|
317
|
+
continue;
|
|
318
|
+
const id = patches.length;
|
|
319
|
+
const pending = [seed];
|
|
320
|
+
const faceIndices = [];
|
|
321
|
+
patchByFace[seed] = id;
|
|
322
|
+
while (pending.length > 0) {
|
|
323
|
+
const faceIndex = pending.pop();
|
|
324
|
+
faceIndices.push(faceIndex);
|
|
325
|
+
for (const candidate of adjacency[faceIndex]) {
|
|
326
|
+
if (patchByFace[candidate] !== -1)
|
|
327
|
+
continue;
|
|
328
|
+
if (!facesAreCoplanar(faces[seed], faces[candidate], mesh.positions, planarAngularTolerance, planarLinearTolerance)) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
patchByFace[candidate] = id;
|
|
332
|
+
pending.push(candidate);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
faceIndices.sort((left, right) => left - right);
|
|
336
|
+
patches.push(buildPatchAnalysis(faces, edges, faceIndices, id));
|
|
337
|
+
}
|
|
338
|
+
const duplicateTriangleCount = [...duplicateFaces.values()].reduce((count, occurrences) => count + Math.max(0, occurrences - 1), 0);
|
|
339
|
+
const openEdgeCount = [...edges.values()].filter(({ uses }) => uses.length === 1).length;
|
|
340
|
+
const nonManifoldEdgeCount = [...edges.values()].filter(({ uses }) => uses.length > 2).length;
|
|
341
|
+
const inconsistentWindingEdgeCount = [...edges.values()].filter(({ uses }) => {
|
|
342
|
+
if (uses.length !== 2)
|
|
343
|
+
return false;
|
|
344
|
+
return (uses[0].start < uses[0].end ? 1 : -1) === (uses[1].start < uses[1].end ? 1 : -1);
|
|
345
|
+
}).length;
|
|
346
|
+
const vertexValence = Uint32Array.from(neighbourSets, ({ size }) => size);
|
|
347
|
+
const report = {
|
|
348
|
+
duplicateTriangleCount,
|
|
349
|
+
inconsistentWindingEdgeCount,
|
|
350
|
+
isClosedOrientedManifold: nearZeroAreaTriangleCount === 0 &&
|
|
351
|
+
duplicateTriangleCount === 0 &&
|
|
352
|
+
openEdgeCount === 0 &&
|
|
353
|
+
nonManifoldEdgeCount === 0 &&
|
|
354
|
+
inconsistentWindingEdgeCount === 0,
|
|
355
|
+
maximumTriangleAspectRatio,
|
|
356
|
+
maximumVertexTriangleIncidence: maximumUint32(vertexTriangleIncidence),
|
|
357
|
+
maximumVertexValence: maximumUint32(vertexValence),
|
|
358
|
+
minimumTriangleAngleDegrees: (minimumTriangleAngleRadians * 180) / Math.PI,
|
|
359
|
+
nearZeroAreaTriangleCount,
|
|
360
|
+
nonManifoldEdgeCount,
|
|
361
|
+
openEdgeCount,
|
|
362
|
+
planarPatches: patches.map(({ report }) => report),
|
|
363
|
+
signedVolume,
|
|
364
|
+
surfaceArea,
|
|
365
|
+
triangleCount,
|
|
366
|
+
vertexCount,
|
|
367
|
+
};
|
|
368
|
+
return {
|
|
369
|
+
bounds,
|
|
370
|
+
edges,
|
|
371
|
+
extent,
|
|
372
|
+
faces,
|
|
373
|
+
patches,
|
|
374
|
+
patchByFace,
|
|
375
|
+
planarAngularTolerance,
|
|
376
|
+
planarLinearTolerance,
|
|
377
|
+
report,
|
|
378
|
+
tolerance,
|
|
379
|
+
vertexTriangleIncidence,
|
|
380
|
+
vertexValence,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Measures triangle shape, edge topology, vertex concentration, and connected planar patches.
|
|
385
|
+
*
|
|
386
|
+
* Positions are never welded: valence and manifold status describe the supplied indexed topology.
|
|
387
|
+
* Degenerate triangles are reported rather than rejected, while malformed buffers, non-finite
|
|
388
|
+
* positions, missing vertex references, and the configured complexity limit throw.
|
|
389
|
+
*
|
|
390
|
+
* @param mesh - Indexed triangle mesh in arbitrary consistent model units.
|
|
391
|
+
* @param options - Scale-aware tolerance and analysis budget.
|
|
392
|
+
* @returns A deterministic report without mutating or copying the mesh.
|
|
393
|
+
*/
|
|
394
|
+
export function analyzeTriangleMeshQuality(mesh, options = {}) {
|
|
395
|
+
return buildTriangleMeshQualityAnalysis(mesh, options).report;
|
|
396
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { analyzeTriangleMesh, validateTriangleMesh, } from "./mesh.js";
|
|
3
|
+
const DEFAULT_POSITION_TOLERANCE = 1e-5;
|
|
4
|
+
const DEFAULT_VOLUME_TOLERANCE = 1e-4;
|
|
5
|
+
const DEFAULT_MAXIMUM_TRIANGLE_COUNT = 200_000;
|
|
6
|
+
const validatePositiveTolerance = (value, name) => {
|
|
7
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
8
|
+
throw new GeometryError("INVALID_TOLERANCE", `${name} must be finite and greater than zero.`, {
|
|
9
|
+
operation: "createTriangleMeshSignature",
|
|
10
|
+
details: { [name]: value },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
const quantizedInteger = (value, tolerance, name) => {
|
|
16
|
+
const scaled = value / tolerance;
|
|
17
|
+
const quantized = scaled < 0 ? -Math.round(-scaled) : Math.round(scaled);
|
|
18
|
+
if (!Number.isSafeInteger(quantized)) {
|
|
19
|
+
throw new GeometryError("INVALID_TOLERANCE", `${name} cannot be represented safely at the requested signature tolerance.`, {
|
|
20
|
+
operation: "createTriangleMeshSignature",
|
|
21
|
+
details: { tolerance, value },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return Object.is(quantized, -0) ? 0 : quantized;
|
|
25
|
+
};
|
|
26
|
+
const quantizedScalar = (value, tolerance, name) => {
|
|
27
|
+
const rounded = quantizedInteger(value, tolerance, name) * tolerance;
|
|
28
|
+
if (rounded === 0)
|
|
29
|
+
return 0;
|
|
30
|
+
return Number(rounded.toPrecision(15));
|
|
31
|
+
};
|
|
32
|
+
const coordinateKey = (positions, vertex, tolerance) => {
|
|
33
|
+
const offset = vertex * 3;
|
|
34
|
+
return `${quantizedInteger(positions[offset], tolerance, "Mesh coordinate")},${quantizedInteger(positions[offset + 1], tolerance, "Mesh coordinate")},${quantizedInteger(positions[offset + 2], tolerance, "Mesh coordinate")}`;
|
|
35
|
+
};
|
|
36
|
+
const canonicalOrientedTriangleKey = (a, b, c) => {
|
|
37
|
+
const rotations = [`${a}|${b}|${c}`, `${b}|${c}|${a}`, `${c}|${a}|${b}`];
|
|
38
|
+
rotations.sort();
|
|
39
|
+
return rotations[0];
|
|
40
|
+
};
|
|
41
|
+
const hashCanonicalKeys = (header, groups) => {
|
|
42
|
+
let first = 0x811c9dc5;
|
|
43
|
+
let second = 0x9e3779b9;
|
|
44
|
+
const update = (value) => {
|
|
45
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
46
|
+
const code = value.charCodeAt(index);
|
|
47
|
+
first = Math.imul(first ^ code, 0x01000193) >>> 0;
|
|
48
|
+
second = Math.imul(second ^ code, 0x01000193) >>> 0;
|
|
49
|
+
}
|
|
50
|
+
first = Math.imul(first ^ 10, 0x01000193) >>> 0;
|
|
51
|
+
second = Math.imul(second ^ 10, 0x01000193) >>> 0;
|
|
52
|
+
};
|
|
53
|
+
update(header);
|
|
54
|
+
groups.forEach((keys, groupIndex) => {
|
|
55
|
+
update(`[${groupIndex}]`);
|
|
56
|
+
for (const key of keys)
|
|
57
|
+
update(key);
|
|
58
|
+
});
|
|
59
|
+
return `fnv1a32x2:${first.toString(16).padStart(8, "0")}${second.toString(16).padStart(8, "0")}`;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Creates an order-independent regression signature for a manufacturing mesh.
|
|
63
|
+
*
|
|
64
|
+
* The input must be non-empty, closed, consistently wound, and have no unreferenced vertices.
|
|
65
|
+
* Vertex and triangle order do not affect the fingerprint; winding, quantized geometry, and
|
|
66
|
+
* connectivity metrics do. The fingerprint is a regression aid, not a cryptographic digest.
|
|
67
|
+
*
|
|
68
|
+
* @param mesh - Closed oriented mesh to measure without mutation.
|
|
69
|
+
* @param options - Absolute quantization and complexity limits.
|
|
70
|
+
* @returns Compact counts, bounds, volume, topology, tolerances, and canonical fingerprint.
|
|
71
|
+
* @throws {@link GeometryError} When validation, topology, tolerance, or complexity checks fail.
|
|
72
|
+
*/
|
|
73
|
+
export function createTriangleMeshSignature(mesh, options = {}) {
|
|
74
|
+
const maximumTriangleCount = options.maximumTriangleCount ?? DEFAULT_MAXIMUM_TRIANGLE_COUNT;
|
|
75
|
+
if (!Number.isSafeInteger(maximumTriangleCount) || maximumTriangleCount <= 0) {
|
|
76
|
+
throw new GeometryError("INVALID_ARGUMENT", "maximumTriangleCount must be a positive safe integer.", {
|
|
77
|
+
operation: "createTriangleMeshSignature",
|
|
78
|
+
details: { maximumTriangleCount },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (!(mesh.positions instanceof Float32Array) || !(mesh.indices instanceof Uint32Array)) {
|
|
82
|
+
validateTriangleMesh(mesh, { requireClosed: true });
|
|
83
|
+
}
|
|
84
|
+
const triangleCount = mesh.indices.length / 3;
|
|
85
|
+
if (triangleCount > maximumTriangleCount) {
|
|
86
|
+
throw new GeometryError("COMPLEXITY_LIMIT", `Mesh signature analysis is limited to ${maximumTriangleCount} triangles; received ${triangleCount}.`, {
|
|
87
|
+
operation: "createTriangleMeshSignature",
|
|
88
|
+
details: { maximumTriangleCount, triangleCount },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const positionTolerance = validatePositiveTolerance(options.positionTolerance ?? DEFAULT_POSITION_TOLERANCE, "positionTolerance");
|
|
92
|
+
const volumeTolerance = validatePositiveTolerance(options.volumeTolerance ?? DEFAULT_VOLUME_TOLERANCE, "volumeTolerance");
|
|
93
|
+
const measured = analyzeTriangleMesh(mesh, { requireClosed: true, includeTopology: true });
|
|
94
|
+
const connectedComponentCount = measured.connectedComponentCount;
|
|
95
|
+
const edgeCount = measured.edgeCount;
|
|
96
|
+
const eulerCharacteristic = measured.vertexCount - edgeCount + measured.triangleCount;
|
|
97
|
+
const twiceGenus = connectedComponentCount * 2 - eulerCharacteristic;
|
|
98
|
+
if (twiceGenus < 0 || twiceGenus % 2 !== 0) {
|
|
99
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Closed mesh topology does not describe orientable vertex-manifold components.", {
|
|
100
|
+
operation: "createTriangleMeshSignature",
|
|
101
|
+
details: { connectedComponentCount, edgeCount, eulerCharacteristic },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const coordinateKeys = Array.from({ length: measured.vertexCount }, (_, vertex) => coordinateKey(mesh.positions, vertex, positionTolerance));
|
|
105
|
+
const vertexKeys = [...coordinateKeys].sort();
|
|
106
|
+
const triangleKeys = [];
|
|
107
|
+
for (let offset = 0; offset < mesh.indices.length; offset += 3) {
|
|
108
|
+
triangleKeys.push(canonicalOrientedTriangleKey(coordinateKeys[mesh.indices[offset]], coordinateKeys[mesh.indices[offset + 1]], coordinateKeys[mesh.indices[offset + 2]]));
|
|
109
|
+
}
|
|
110
|
+
triangleKeys.sort();
|
|
111
|
+
const quantizeBounds = ({ min, max }) => Object.freeze({
|
|
112
|
+
min: Object.freeze([
|
|
113
|
+
quantizedScalar(min[0], positionTolerance, "Mesh bound"),
|
|
114
|
+
quantizedScalar(min[1], positionTolerance, "Mesh bound"),
|
|
115
|
+
quantizedScalar(min[2], positionTolerance, "Mesh bound"),
|
|
116
|
+
]),
|
|
117
|
+
max: Object.freeze([
|
|
118
|
+
quantizedScalar(max[0], positionTolerance, "Mesh bound"),
|
|
119
|
+
quantizedScalar(max[1], positionTolerance, "Mesh bound"),
|
|
120
|
+
quantizedScalar(max[2], positionTolerance, "Mesh bound"),
|
|
121
|
+
]),
|
|
122
|
+
});
|
|
123
|
+
const tolerances = Object.freeze({
|
|
124
|
+
position: positionTolerance,
|
|
125
|
+
signedVolume: volumeTolerance,
|
|
126
|
+
});
|
|
127
|
+
return Object.freeze({
|
|
128
|
+
bounds: quantizeBounds(measured.bounds),
|
|
129
|
+
connectedComponentCount,
|
|
130
|
+
fingerprint: hashCanonicalKeys(`${measured.vertexCount}|${measured.triangleCount}|${positionTolerance}`, [vertexKeys, triangleKeys]),
|
|
131
|
+
genus: twiceGenus / 2,
|
|
132
|
+
signedVolume: quantizedScalar(measured.signedVolume, volumeTolerance, "Signed volume"),
|
|
133
|
+
tolerances,
|
|
134
|
+
triangleCount: measured.triangleCount,
|
|
135
|
+
vertexCount: measured.vertexCount,
|
|
136
|
+
});
|
|
137
|
+
}
|