@metamesh/modeling 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +157 -22
- package/dist/api/engine.d.ts +25 -4
- package/dist/api/geometry.d.ts +391 -82
- package/dist/api/index.d.ts +732 -108
- package/dist/api/text.d.ts +40 -10
- package/dist/engine/construction-operations.js +52 -10
- package/dist/engine/execution.js +55 -2
- package/dist/engine/export-3mf.js +17 -1
- package/dist/engine/finalization.js +27 -18
- package/dist/engine/index.js +12 -1
- package/dist/engine/inspection-operations.js +26 -22
- package/dist/engine/loft-operations.js +1 -1
- package/dist/engine/mechanical-operations.js +5 -8
- package/dist/engine/mesh-view-cache.js +23 -0
- package/dist/engine/native/adapter.js +2 -4
- package/dist/engine/native/distance.js +42 -0
- package/dist/engine/native/status.js +16 -0
- package/dist/engine/operation-environment.js +2 -1
- package/dist/engine/section-operations.js +3 -3
- package/dist/geometry/curves/sampling.js +2 -1
- package/dist/geometry/heightfield.js +109 -42
- package/dist/geometry/index.js +2 -0
- package/dist/geometry/loft-simple-mesh.js +2 -3
- package/dist/geometry/loft-triangulation.js +12 -9
- package/dist/geometry/loft.js +18 -7
- package/dist/geometry/math/vector3.js +35 -5
- package/dist/geometry/mechanical/standards.js +2 -3
- package/dist/geometry/mechanical/thread-profiles.js +5 -0
- package/dist/geometry/mechanical/threads.js +2 -3
- package/dist/geometry/mesh-centroid.js +36 -0
- package/dist/geometry/mesh-edge-table.js +126 -0
- package/dist/geometry/mesh-solid-intersections.js +123 -0
- package/dist/geometry/mesh-solid.js +284 -0
- package/dist/geometry/mesh.js +8 -25
- package/dist/geometry/paths/path.js +3 -1
- package/dist/geometry/paths/resampling.js +2 -1
- package/dist/geometry/profiles/matched.js +8 -0
- package/dist/geometry/profiles/profiles.js +24 -3
- package/dist/geometry/profiles/rectangle.js +16 -2
- package/dist/geometry/profiles/shapes.js +8 -1
- package/dist/geometry/revolve/revolve-mesh.js +6 -0
- package/dist/geometry/sweeps/sweep-closed.js +4 -4
- package/dist/geometry/sweeps/sweep-mesh.js +13 -7
- package/dist/geometry/sweeps/sweep-open.js +4 -4
- package/dist/geometry/sweeps/validation.js +1 -2
- package/dist/model/operations.js +13 -4
- package/dist/text/index.js +11 -1
- package/dist/text/vector-text-layout.js +46 -3
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const MAXIMUM_UINT32 = 0xffff_ffff;
|
|
2
|
+
const MAXIMUM_SLOT_CAPACITY = 0x1_0000_0000;
|
|
3
|
+
const FORWARD = 1;
|
|
4
|
+
const BACKWARD = 2;
|
|
5
|
+
const PAIRED = 3;
|
|
6
|
+
const INVALID = 4;
|
|
7
|
+
function doubledCapacity(capacity) {
|
|
8
|
+
const doubled = capacity * 2;
|
|
9
|
+
if (doubled > MAXIMUM_SLOT_CAPACITY)
|
|
10
|
+
throw new RangeError("Mesh edge table exceeds Uint32 slot capacity.");
|
|
11
|
+
return doubled;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* @internal Indexed edge closure and first-face records; coordinates are never welded.
|
|
15
|
+
* Dense record IDs remain stable when the hash slots grow. Inputs are validated Uint32
|
|
16
|
+
* vertex indices with distinct endpoints, and triangle IDs fit Uint32 storage.
|
|
17
|
+
*/
|
|
18
|
+
export class IndexedMeshEdgeTable {
|
|
19
|
+
slotCapacity;
|
|
20
|
+
mask;
|
|
21
|
+
slots;
|
|
22
|
+
recordCapacity;
|
|
23
|
+
recordCount = 0;
|
|
24
|
+
lower;
|
|
25
|
+
upper;
|
|
26
|
+
states;
|
|
27
|
+
firstTriangle;
|
|
28
|
+
constructor(indexCount, includeTopology) {
|
|
29
|
+
if (!Number.isSafeInteger(indexCount) || indexCount < 0 || indexCount > MAXIMUM_UINT32)
|
|
30
|
+
throw new RangeError("Mesh edge table requires an index count within Uint32 capacity.");
|
|
31
|
+
let capacity = 16;
|
|
32
|
+
// Multiplication avoids the signed-int32 wrap of bitwise capacity doubling.
|
|
33
|
+
while (capacity < indexCount)
|
|
34
|
+
capacity = doubledCapacity(capacity);
|
|
35
|
+
this.slotCapacity = capacity;
|
|
36
|
+
this.mask = capacity - 1;
|
|
37
|
+
this.slots = new Uint32Array(capacity);
|
|
38
|
+
this.recordCapacity = capacity / 2;
|
|
39
|
+
this.lower = new Uint32Array(this.recordCapacity);
|
|
40
|
+
this.upper = new Uint32Array(this.recordCapacity);
|
|
41
|
+
this.states = new Uint8Array(this.recordCapacity);
|
|
42
|
+
this.firstTriangle = includeTopology ? new Uint32Array(this.recordCapacity) : null;
|
|
43
|
+
}
|
|
44
|
+
/** Returns -1 for a new edge, otherwise its first triangle (or zero without topology). */
|
|
45
|
+
touch(a, b, triangle) {
|
|
46
|
+
const lower = Math.min(a, b);
|
|
47
|
+
const upper = Math.max(a, b);
|
|
48
|
+
const state = a < b ? FORWARD : BACKWARD;
|
|
49
|
+
let slot = (this.hash(lower, upper) & this.mask) >>> 0;
|
|
50
|
+
while (this.slots[slot] !== 0) {
|
|
51
|
+
const record = this.slots[slot] - 1;
|
|
52
|
+
// Hashes wrap to int32; only the complete two Uint32 keys establish identity.
|
|
53
|
+
if (this.lower[record] === lower && this.upper[record] === upper) {
|
|
54
|
+
const previous = this.states[record];
|
|
55
|
+
this.states[record] =
|
|
56
|
+
(previous === FORWARD && state === BACKWARD) ||
|
|
57
|
+
(previous === BACKWARD && state === FORWARD)
|
|
58
|
+
? PAIRED
|
|
59
|
+
: INVALID;
|
|
60
|
+
return this.firstTriangle?.[record] ?? 0;
|
|
61
|
+
}
|
|
62
|
+
slot = ((slot + 1) & this.mask) >>> 0;
|
|
63
|
+
}
|
|
64
|
+
// Repeat touches cannot increase occupancy; check growth only for an absent key.
|
|
65
|
+
if (this.recordCount + 1 > this.slotCapacity * 0.7) {
|
|
66
|
+
this.growSlots();
|
|
67
|
+
return this.touch(a, b, triangle);
|
|
68
|
+
}
|
|
69
|
+
if (this.recordCount >= MAXIMUM_UINT32)
|
|
70
|
+
throw new RangeError("Mesh edge table exceeds Uint32 record capacity.");
|
|
71
|
+
if (this.recordCount === this.recordCapacity)
|
|
72
|
+
this.growRecords();
|
|
73
|
+
const record = this.recordCount;
|
|
74
|
+
this.slots[slot] = record + 1;
|
|
75
|
+
this.lower[record] = lower;
|
|
76
|
+
this.upper[record] = upper;
|
|
77
|
+
this.states[record] = state;
|
|
78
|
+
if (this.firstTriangle !== null)
|
|
79
|
+
this.firstTriangle[record] = triangle;
|
|
80
|
+
this.recordCount += 1;
|
|
81
|
+
return -1;
|
|
82
|
+
}
|
|
83
|
+
/** Checks exactly two oppositely wound uses after every triangle has been validated. */
|
|
84
|
+
isClosed() {
|
|
85
|
+
for (let record = 0; record < this.recordCount; record += 1)
|
|
86
|
+
if (this.states[record] !== PAIRED)
|
|
87
|
+
return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
hash(lower, upper) {
|
|
91
|
+
const mixed = Math.imul(lower, 0x9e3779b1) ^ Math.imul(upper, 0x85ebca6b);
|
|
92
|
+
return (mixed ^ (mixed >>> 16)) >>> 0;
|
|
93
|
+
}
|
|
94
|
+
growSlots() {
|
|
95
|
+
const capacity = doubledCapacity(this.slotCapacity);
|
|
96
|
+
const mask = capacity - 1;
|
|
97
|
+
const slots = new Uint32Array(capacity);
|
|
98
|
+
// Paired and invalid records must remain: forgetting either could admit four-face edges.
|
|
99
|
+
for (let record = 0; record < this.recordCount; record += 1) {
|
|
100
|
+
let slot = (this.hash(this.lower[record], this.upper[record]) & mask) >>> 0;
|
|
101
|
+
while (slots[slot] !== 0)
|
|
102
|
+
slot = ((slot + 1) & mask) >>> 0;
|
|
103
|
+
slots[slot] = record + 1;
|
|
104
|
+
}
|
|
105
|
+
this.slotCapacity = capacity;
|
|
106
|
+
this.mask = mask;
|
|
107
|
+
this.slots = slots;
|
|
108
|
+
}
|
|
109
|
+
growRecords() {
|
|
110
|
+
const capacity = doubledCapacity(this.recordCapacity);
|
|
111
|
+
const lower = new Uint32Array(capacity);
|
|
112
|
+
const upper = new Uint32Array(capacity);
|
|
113
|
+
const states = new Uint8Array(capacity);
|
|
114
|
+
const firstTriangle = this.firstTriangle === null ? null : new Uint32Array(capacity);
|
|
115
|
+
lower.set(this.lower);
|
|
116
|
+
upper.set(this.upper);
|
|
117
|
+
states.set(this.states);
|
|
118
|
+
if (firstTriangle !== null)
|
|
119
|
+
firstTriangle.set(this.firstTriangle);
|
|
120
|
+
this.recordCapacity = capacity;
|
|
121
|
+
this.lower = lower;
|
|
122
|
+
this.upper = upper;
|
|
123
|
+
this.states = states;
|
|
124
|
+
this.firstTriangle = firstTriangle;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const subtract = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
2
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
3
|
+
const cross = (a, b) => [
|
|
4
|
+
a[1] * b[2] - a[2] * b[1],
|
|
5
|
+
a[2] * b[0] - a[0] * b[2],
|
|
6
|
+
a[0] * b[1] - a[1] * b[0],
|
|
7
|
+
];
|
|
8
|
+
const distance = (a, b) => Math.hypot(...subtract(a, b));
|
|
9
|
+
const normal = (triangle) => {
|
|
10
|
+
const value = cross(subtract(triangle[1], triangle[0]), subtract(triangle[2], triangle[0]));
|
|
11
|
+
const length = Math.hypot(...value);
|
|
12
|
+
return [value[0] / length, value[1] / length, value[2] / length];
|
|
13
|
+
};
|
|
14
|
+
/** @internal Reuses the same double-precision normal for repeated contact tests. */
|
|
15
|
+
export function prepareSolidTriangle(points) {
|
|
16
|
+
return { points, normal: normal(points) };
|
|
17
|
+
}
|
|
18
|
+
const project = (point, axis) => axis === 0 ? [point[1], point[2]] : axis === 1 ? [point[0], point[2]] : [point[0], point[1]];
|
|
19
|
+
const turn = (a, b, p) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
|
|
20
|
+
const dominantAxis = (value) => {
|
|
21
|
+
let axis = 0;
|
|
22
|
+
for (let index = 1; index < 3; index++)
|
|
23
|
+
if (Math.abs(value[index]) > Math.abs(value[axis]))
|
|
24
|
+
axis = index;
|
|
25
|
+
return axis;
|
|
26
|
+
};
|
|
27
|
+
function inside(point, triangle, direction, epsilon) {
|
|
28
|
+
const axis = dominantAxis(direction), p = project(point, axis), points = triangle.map((vertex) => project(vertex, axis));
|
|
29
|
+
const sign = Math.sign(turn(points[0], points[1], points[2]));
|
|
30
|
+
return points.every((a, index) => {
|
|
31
|
+
const b = points[(index + 1) % 3];
|
|
32
|
+
return sign * turn(a, b, p) >= -epsilon * Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function onSharedBoundary(point, shared, epsilon) {
|
|
36
|
+
if (shared.some((vertex) => distance(point, vertex) <= epsilon))
|
|
37
|
+
return true;
|
|
38
|
+
if (shared.length !== 2)
|
|
39
|
+
return false;
|
|
40
|
+
const a = shared[0], b = shared[1], edge = subtract(b, a), delta = subtract(point, a);
|
|
41
|
+
const length = Math.hypot(...edge), along = dot(delta, edge) / length;
|
|
42
|
+
return (along >= -epsilon &&
|
|
43
|
+
along <= length + epsilon &&
|
|
44
|
+
Math.hypot(...cross(delta, edge)) / length <= epsilon);
|
|
45
|
+
}
|
|
46
|
+
function coplanarIntersection(first, second, direction, shared, epsilon) {
|
|
47
|
+
const axis = dominantAxis(direction), clip = second.map((point) => project(point, axis));
|
|
48
|
+
const sign = Math.sign(turn(clip[0], clip[1], clip[2]));
|
|
49
|
+
let polygon = [...first];
|
|
50
|
+
for (let edge = 0; edge < 3 && polygon.length > 0; edge++) {
|
|
51
|
+
const a = clip[edge], b = clip[(edge + 1) % 3];
|
|
52
|
+
const tolerance = epsilon * Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
53
|
+
const signed = (point) => sign * turn(a, b, project(point, axis));
|
|
54
|
+
const next = [];
|
|
55
|
+
for (let index = 0; index < polygon.length; index++) {
|
|
56
|
+
const start = polygon[index], end = polygon[(index + 1) % polygon.length];
|
|
57
|
+
const d0 = signed(start), d1 = signed(end);
|
|
58
|
+
const in0 = d0 >= -tolerance, in1 = d1 >= -tolerance;
|
|
59
|
+
if (in0)
|
|
60
|
+
next.push(start);
|
|
61
|
+
if (in0 !== in1) {
|
|
62
|
+
const fraction = Math.max(0, Math.min(1, d0 / (d0 - d1)));
|
|
63
|
+
next.push([
|
|
64
|
+
start[0] + (end[0] - start[0]) * fraction,
|
|
65
|
+
start[1] + (end[1] - start[1]) * fraction,
|
|
66
|
+
start[2] + (end[2] - start[2]) * fraction,
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
polygon = next;
|
|
71
|
+
}
|
|
72
|
+
if (polygon.length === 0)
|
|
73
|
+
return false;
|
|
74
|
+
const origin = project(polygon[0], axis);
|
|
75
|
+
let area = 0;
|
|
76
|
+
for (let index = 1; index + 1 < polygon.length; index++)
|
|
77
|
+
area += turn(origin, project(polygon[index], axis), project(polygon[index + 1], axis));
|
|
78
|
+
const scale = Math.max(...first.map((point, index) => distance(point, first[(index + 1) % 3])));
|
|
79
|
+
if (Math.abs(area) > epsilon * scale)
|
|
80
|
+
return true;
|
|
81
|
+
return polygon.some((point) => !onSharedBoundary(point, shared, epsilon));
|
|
82
|
+
}
|
|
83
|
+
/** @internal Detects contact beyond a permitted indexed vertex or edge, without welding. */
|
|
84
|
+
export function trianglesIntersectBeyondSharedBoundary(firstGeometry, secondGeometry, shared, epsilon) {
|
|
85
|
+
const first = firstGeometry.points, second = secondGeometry.points, n0 = firstGeometry.normal, n1 = secondGeometry.normal;
|
|
86
|
+
const distances0 = first.map((point) => dot(n1, subtract(point, second[0])));
|
|
87
|
+
const distances1 = second.map((point) => dot(n0, subtract(point, first[0])));
|
|
88
|
+
const separated = (values) => values.every((value) => value > epsilon) || values.every((value) => value < -epsilon);
|
|
89
|
+
if (separated(distances0) || separated(distances1))
|
|
90
|
+
return false;
|
|
91
|
+
if (distances0.every((value) => Math.abs(value) <= epsilon) &&
|
|
92
|
+
distances1.every((value) => Math.abs(value) <= epsilon))
|
|
93
|
+
return coplanarIntersection(first, second, n0, shared, epsilon);
|
|
94
|
+
const points = [];
|
|
95
|
+
const collect = (source, target, values, n) => {
|
|
96
|
+
for (let index = 0; index < 3; index++) {
|
|
97
|
+
const a = source[index], b = source[(index + 1) % 3];
|
|
98
|
+
const d0 = values[index], d1 = values[(index + 1) % 3];
|
|
99
|
+
if (Math.abs(d0) <= epsilon && inside(a, target, n, epsilon))
|
|
100
|
+
points.push(a);
|
|
101
|
+
if ((d0 < 0 && d1 > 0) || (d0 > 0 && d1 < 0)) {
|
|
102
|
+
const fraction = d0 / (d0 - d1);
|
|
103
|
+
const point = [
|
|
104
|
+
a[0] + (b[0] - a[0]) * fraction,
|
|
105
|
+
a[1] + (b[1] - a[1]) * fraction,
|
|
106
|
+
a[2] + (b[2] - a[2]) * fraction,
|
|
107
|
+
];
|
|
108
|
+
if (inside(point, target, n, epsilon))
|
|
109
|
+
points.push(point);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
collect(first, second, distances0, n1);
|
|
114
|
+
collect(second, first, distances1, n0);
|
|
115
|
+
return points.some((point) => !onSharedBoundary(point, shared, epsilon));
|
|
116
|
+
}
|
|
117
|
+
/** @internal Signed solid angle at a point separated from a closed triangle shell. */
|
|
118
|
+
export function triangleSolidAngle(point, triangle) {
|
|
119
|
+
const a = subtract(triangle[0], point), b = subtract(triangle[1], point), c = subtract(triangle[2], point);
|
|
120
|
+
const la = Math.hypot(...a), lb = Math.hypot(...b), lc = Math.hypot(...c);
|
|
121
|
+
return (2 *
|
|
122
|
+
Math.atan2(dot(a, cross(b, c)), la * lb * lc + dot(a, b) * lc + dot(b, c) * la + dot(c, a) * lb));
|
|
123
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { IndexedMeshEdgeTable } from "./mesh-edge-table.js";
|
|
3
|
+
import { prepareSolidTriangle, trianglesIntersectBeyondSharedBoundary, triangleSolidAngle, } from "./mesh-solid-intersections.js";
|
|
4
|
+
import { analyzeTriangleMesh } from "./mesh.js";
|
|
5
|
+
import { resolveGeometryTolerance } from "./tolerance.js";
|
|
6
|
+
const MAXIMUM_TRIANGLES = 200_000;
|
|
7
|
+
const MAXIMUM_TESTS = 2_000_000;
|
|
8
|
+
/**
|
|
9
|
+
* Checks closed indexed topology, triangle intersections, and alternating nested shell orientation.
|
|
10
|
+
* Outward exterior shells and inward cavity shells are accepted. Separate shells must not touch,
|
|
11
|
+
* intersect, or overlap as material. Indexed neighbours may share their own vertex or edge.
|
|
12
|
+
*
|
|
13
|
+
* Uses bounded floating-point tests, not exact predicates or repair. Contacts within the selected
|
|
14
|
+
* tolerance reject conservatively. At most 200,000 triangles and 2,000,000 spatial/containment tests
|
|
15
|
+
* are supported; exhausting a ceiling throws COMPLEXITY_LIMIT rather than claiming validity.
|
|
16
|
+
* Input buffers are freshly inspected and never modified; units are millimetres.
|
|
17
|
+
*
|
|
18
|
+
* @param mesh - Closed triangle mesh to inspect.
|
|
19
|
+
* @param options - Optional lower work ceiling and numeric comparison policy.
|
|
20
|
+
* @returns The original mesh after every requested check succeeds.
|
|
21
|
+
*/
|
|
22
|
+
export function validateSolidMesh(mesh, options = {}) {
|
|
23
|
+
analyzeSolidMesh(mesh, options);
|
|
24
|
+
return mesh;
|
|
25
|
+
}
|
|
26
|
+
function validationWork(mesh, options) {
|
|
27
|
+
if (typeof mesh !== "object" ||
|
|
28
|
+
mesh === null ||
|
|
29
|
+
!(mesh.positions instanceof Float32Array) ||
|
|
30
|
+
!(mesh.indices instanceof Uint32Array) ||
|
|
31
|
+
mesh.positions.length % 3 !== 0 ||
|
|
32
|
+
mesh.indices.length % 3 !== 0)
|
|
33
|
+
throw new GeometryError("INVALID_MESH", "Solid validation requires complete Float32 XYZ vertices and Uint32 triangle indices.", { operation: "validateSolidMesh" });
|
|
34
|
+
if (typeof options !== "object" || options === null || Array.isArray(options))
|
|
35
|
+
throw new GeometryError("INVALID_ARGUMENT", "Solid validation options must be an object.", {
|
|
36
|
+
operation: "validateSolidMesh",
|
|
37
|
+
});
|
|
38
|
+
const count = mesh.indices.length / 3;
|
|
39
|
+
const maximumTests = options.maximumTests ?? MAXIMUM_TESTS;
|
|
40
|
+
if (!Number.isSafeInteger(maximumTests) || maximumTests < 1 || maximumTests > MAXIMUM_TESTS)
|
|
41
|
+
throw new GeometryError("INVALID_ARGUMENT", "Solid validation maximumTests must be an integer from 1 through 2,000,000.", { operation: "validateSolidMesh" });
|
|
42
|
+
if (count > MAXIMUM_TRIANGLES)
|
|
43
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Solid validation exceeds its triangle ceiling.", {
|
|
44
|
+
operation: "validateSolidMesh",
|
|
45
|
+
details: { required: count, allowed: MAXIMUM_TRIANGLES },
|
|
46
|
+
});
|
|
47
|
+
if (mesh.positions.length / 3 > mesh.indices.length)
|
|
48
|
+
throw new GeometryError("INVALID_MESH", "Solid validation requires every position-buffer vertex to be referenced.", { operation: "validateSolidMesh" });
|
|
49
|
+
return { count, maximumTests };
|
|
50
|
+
}
|
|
51
|
+
/** @internal Fresh measurements precede one adjacency traversal for closure, fans, and shells. */
|
|
52
|
+
export function analyzeSolidMesh(mesh, options = {}) {
|
|
53
|
+
const work = validationWork(mesh, options);
|
|
54
|
+
const measurements = analyzeTriangleMesh(mesh, {
|
|
55
|
+
...(options.tolerance === undefined ? {} : { tolerance: options.tolerance }),
|
|
56
|
+
});
|
|
57
|
+
checkSolidGeometry(mesh, measurements, options, work);
|
|
58
|
+
return measurements;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* @internal Checks the engine's privately owned snapshot using fresh closed-topology measurements.
|
|
62
|
+
* Callers must own the exact unchanged buffer ranges and use the same tolerance as their analysis.
|
|
63
|
+
* Never accepts public cached facts; public validation always analyzes mutable input afresh.
|
|
64
|
+
*/
|
|
65
|
+
export function validateAnalyzedSolidMesh(mesh, measurements, options = {}) {
|
|
66
|
+
checkSolidGeometry(mesh, measurements, options, validationWork(mesh, options));
|
|
67
|
+
}
|
|
68
|
+
function checkSolidGeometry(mesh, facts, options, { count, maximumTests }) {
|
|
69
|
+
const epsilon = resolveGeometryTolerance(Math.hypot(...facts.bounds.max.map((value, axis) => value - facts.bounds.min[axis])), options.tolerance).linear;
|
|
70
|
+
let tests = 0;
|
|
71
|
+
const spend = () => {
|
|
72
|
+
if (++tests > maximumTests)
|
|
73
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Solid validation exhausted its work ceiling.", {
|
|
74
|
+
operation: "validateSolidMesh",
|
|
75
|
+
details: { required: tests, allowed: maximumTests },
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const vertex = (index) => [
|
|
79
|
+
mesh.positions[index * 3],
|
|
80
|
+
mesh.positions[index * 3 + 1],
|
|
81
|
+
mesh.positions[index * 3 + 2],
|
|
82
|
+
];
|
|
83
|
+
const triangle = (index) => [
|
|
84
|
+
vertex(mesh.indices[index * 3]),
|
|
85
|
+
vertex(mesh.indices[index * 3 + 1]),
|
|
86
|
+
vertex(mesh.indices[index * 3 + 2]),
|
|
87
|
+
];
|
|
88
|
+
// Cache only exact double-precision normals. Two reusable contact records avoid retaining
|
|
89
|
+
// per-triangle point objects or allocating new coordinates for every spatial comparison.
|
|
90
|
+
const normals = new Float64Array(mesh.indices.length);
|
|
91
|
+
const contactTriangle = () => ({
|
|
92
|
+
points: [
|
|
93
|
+
[0, 0, 0],
|
|
94
|
+
[0, 0, 0],
|
|
95
|
+
[0, 0, 0],
|
|
96
|
+
],
|
|
97
|
+
normal: [0, 0, 0],
|
|
98
|
+
});
|
|
99
|
+
const left = contactTriangle(), right = contactTriangle();
|
|
100
|
+
const readContactTriangle = (index, target) => {
|
|
101
|
+
for (let corner = 0; corner < 3; corner++) {
|
|
102
|
+
const offset = mesh.indices[index * 3 + corner] * 3;
|
|
103
|
+
const point = target.points[corner];
|
|
104
|
+
point[0] = mesh.positions[offset];
|
|
105
|
+
point[1] = mesh.positions[offset + 1];
|
|
106
|
+
point[2] = mesh.positions[offset + 2];
|
|
107
|
+
target.normal[corner] = normals[index * 3 + corner];
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const parents = Uint32Array.from({ length: count }, (_, index) => index);
|
|
111
|
+
const cornerParents = Uint32Array.from({ length: mesh.indices.length }, (_, index) => index);
|
|
112
|
+
const find = (links, value) => {
|
|
113
|
+
let current = value;
|
|
114
|
+
while (links[current] !== current) {
|
|
115
|
+
links[current] = links[links[current]];
|
|
116
|
+
current = links[current];
|
|
117
|
+
}
|
|
118
|
+
return current;
|
|
119
|
+
};
|
|
120
|
+
const root = (value) => find(parents, value);
|
|
121
|
+
const cornerOf = (triangle, vertex) => {
|
|
122
|
+
for (let corner = triangle * 3; corner < triangle * 3 + 3; corner++)
|
|
123
|
+
if (mesh.indices[corner] === vertex)
|
|
124
|
+
return corner;
|
|
125
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Mesh edge incidence is inconsistent.", {
|
|
126
|
+
operation: "validateSolidMesh",
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
const edges = new IndexedMeshEdgeTable(mesh.indices.length, true);
|
|
130
|
+
const bounds = new Float64Array(count * 6);
|
|
131
|
+
for (let index = 0; index < count; index++) {
|
|
132
|
+
const points = triangle(index);
|
|
133
|
+
const direction = prepareSolidTriangle(points).normal;
|
|
134
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
135
|
+
normals[index * 3 + axis] = direction[axis];
|
|
136
|
+
bounds[index * 6 + axis] = Math.min(points[0][axis], points[1][axis], points[2][axis]);
|
|
137
|
+
bounds[index * 6 + axis + 3] = Math.max(points[0][axis], points[1][axis], points[2][axis]);
|
|
138
|
+
}
|
|
139
|
+
for (let edge = 0; edge < 3; edge++) {
|
|
140
|
+
const a = mesh.indices[index * 3 + edge], b = mesh.indices[index * 3 + ((edge + 1) % 3)];
|
|
141
|
+
const previous = edges.touch(a, b, index);
|
|
142
|
+
if (previous >= 0) {
|
|
143
|
+
parents[root(index)] = root(previous);
|
|
144
|
+
for (const vertex of [a, b])
|
|
145
|
+
cornerParents[find(cornerParents, cornerOf(index, vertex))] = find(cornerParents, cornerOf(previous, vertex));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!edges.isClosed())
|
|
150
|
+
throw new GeometryError("INVALID_MESH", "Closed meshes require exactly two oppositely wound faces at every edge.", { operation: "validateSolidMesh" });
|
|
151
|
+
const firstCorners = new Uint32Array(mesh.positions.length / 3).fill(0xffff_ffff);
|
|
152
|
+
for (let corner = 0; corner < mesh.indices.length; corner++) {
|
|
153
|
+
const vertex = mesh.indices[corner], link = find(cornerParents, corner);
|
|
154
|
+
if (firstCorners[vertex] === 0xffff_ffff)
|
|
155
|
+
firstCorners[vertex] = link;
|
|
156
|
+
else if (firstCorners[vertex] !== link)
|
|
157
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Solid vertices require one connected closed incident triangle fan.", { operation: "validateSolidMesh", details: { vertex } });
|
|
158
|
+
}
|
|
159
|
+
if (firstCorners.includes(0xffff_ffff))
|
|
160
|
+
throw new GeometryError("INVALID_MESH", "Solid validation requires every position-buffer vertex to be referenced.", { operation: "validateSolidMesh" });
|
|
161
|
+
const order = Array.from({ length: count }, (_, index) => index);
|
|
162
|
+
const build = (start, end) => {
|
|
163
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
164
|
+
for (let index = start; index < end; index++)
|
|
165
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
166
|
+
min[axis] = Math.min(min[axis], bounds[order[index] * 6 + axis]);
|
|
167
|
+
max[axis] = Math.max(max[axis], bounds[order[index] * 6 + axis + 3]);
|
|
168
|
+
}
|
|
169
|
+
if (end - start <= 8)
|
|
170
|
+
return { start, end, min, max };
|
|
171
|
+
let axis = 0;
|
|
172
|
+
for (let index = 1; index < 3; index++)
|
|
173
|
+
if (max[index] - min[index] > max[axis] - min[axis])
|
|
174
|
+
axis = index;
|
|
175
|
+
const partition = order
|
|
176
|
+
.slice(start, end)
|
|
177
|
+
.sort((a, b) => bounds[a * 6 + axis] +
|
|
178
|
+
bounds[a * 6 + axis + 3] -
|
|
179
|
+
(bounds[b * 6 + axis] + bounds[b * 6 + axis + 3]));
|
|
180
|
+
for (let index = 0; index < partition.length; index++)
|
|
181
|
+
order[start + index] = partition[index];
|
|
182
|
+
const middle = start + Math.floor((end - start) / 2);
|
|
183
|
+
return { start, end, min, max, children: [build(start, middle), build(middle, end)] };
|
|
184
|
+
};
|
|
185
|
+
const overlaps = (a, b) => a.min.every((value, axis) => value <= b.max[axis] + epsilon && a.max[axis] + epsilon >= b.min[axis]);
|
|
186
|
+
const pair = (a, b) => {
|
|
187
|
+
spend();
|
|
188
|
+
for (let axis = 0; axis < 3; axis++)
|
|
189
|
+
if (bounds[a * 6 + axis] > bounds[b * 6 + axis + 3] + epsilon ||
|
|
190
|
+
bounds[b * 6 + axis] > bounds[a * 6 + axis + 3] + epsilon)
|
|
191
|
+
return;
|
|
192
|
+
const shared = [];
|
|
193
|
+
if (root(a) === root(b))
|
|
194
|
+
for (let index = 0; index < 3; index++) {
|
|
195
|
+
const id = mesh.indices[a * 3 + index];
|
|
196
|
+
if (id === mesh.indices[b * 3] ||
|
|
197
|
+
id === mesh.indices[b * 3 + 1] ||
|
|
198
|
+
id === mesh.indices[b * 3 + 2])
|
|
199
|
+
shared.push(vertex(id));
|
|
200
|
+
}
|
|
201
|
+
readContactTriangle(a, left);
|
|
202
|
+
readContactTriangle(b, right);
|
|
203
|
+
if (trianglesIntersectBeyondSharedBoundary(left, right, shared, epsilon))
|
|
204
|
+
throw new GeometryError("SELF_INTERSECTION", "Solid triangles intersect or touch beyond their indexed boundary.", { operation: "validateSolidMesh", details: { triangleA: a, triangleB: b } });
|
|
205
|
+
};
|
|
206
|
+
const visit = (a, b) => {
|
|
207
|
+
spend();
|
|
208
|
+
if (!overlaps(a, b))
|
|
209
|
+
return;
|
|
210
|
+
if (a === b && a.children !== undefined) {
|
|
211
|
+
visit(a.children[0], a.children[0]);
|
|
212
|
+
visit(a.children[0], a.children[1]);
|
|
213
|
+
visit(a.children[1], a.children[1]);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (a.children !== undefined) {
|
|
217
|
+
visit(a.children[0], b);
|
|
218
|
+
visit(a.children[1], b);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (b.children !== undefined) {
|
|
222
|
+
visit(a, b.children[0]);
|
|
223
|
+
visit(a, b.children[1]);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (let first = a.start; first < a.end; first++)
|
|
227
|
+
for (let second = b.start; second < b.end; second++)
|
|
228
|
+
if (a !== b || first < second)
|
|
229
|
+
pair(order[first], order[second]);
|
|
230
|
+
};
|
|
231
|
+
const tree = build(0, count);
|
|
232
|
+
visit(tree, tree);
|
|
233
|
+
const shells = new Map();
|
|
234
|
+
for (let index = 0; index < count; index++) {
|
|
235
|
+
const id = root(index), shell = shells.get(id);
|
|
236
|
+
if (shell === undefined)
|
|
237
|
+
shells.set(id, [index]);
|
|
238
|
+
else
|
|
239
|
+
shell.push(index);
|
|
240
|
+
}
|
|
241
|
+
const entries = [...shells.values()].map((triangles) => {
|
|
242
|
+
const origin = triangle(triangles[0])[0];
|
|
243
|
+
let volume = 0;
|
|
244
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
245
|
+
for (const index of triangles) {
|
|
246
|
+
const [a, b, c] = triangle(index).map((point) => point.map((value, axis) => value - origin[axis]));
|
|
247
|
+
volume +=
|
|
248
|
+
(a[0] * (b[1] * c[2] - b[2] * c[1]) +
|
|
249
|
+
a[1] * (b[2] * c[0] - b[0] * c[2]) +
|
|
250
|
+
a[2] * (b[0] * c[1] - b[1] * c[0])) /
|
|
251
|
+
6;
|
|
252
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
253
|
+
min[axis] = Math.min(min[axis], bounds[index * 6 + axis]);
|
|
254
|
+
max[axis] = Math.max(max[axis], bounds[index * 6 + axis + 3]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { triangles, origin, volume, min, max };
|
|
258
|
+
});
|
|
259
|
+
for (const shell of entries) {
|
|
260
|
+
let depth = 0;
|
|
261
|
+
for (const other of entries) {
|
|
262
|
+
spend();
|
|
263
|
+
if (shell === other ||
|
|
264
|
+
shell.origin.some((value, axis) => value < other.min[axis] || value > other.max[axis]))
|
|
265
|
+
continue;
|
|
266
|
+
let angle = 0;
|
|
267
|
+
for (const index of other.triangles) {
|
|
268
|
+
spend();
|
|
269
|
+
angle += triangleSolidAngle(shell.origin, triangle(index));
|
|
270
|
+
}
|
|
271
|
+
if (Math.abs(angle) > 2 * Math.PI)
|
|
272
|
+
depth++;
|
|
273
|
+
}
|
|
274
|
+
if ((depth % 2 === 0 ? shell.volume : -shell.volume) <= epsilon ** 3)
|
|
275
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Solid shells require outward exteriors and inward nested cavity boundaries.", {
|
|
276
|
+
operation: "validateSolidMesh",
|
|
277
|
+
details: {
|
|
278
|
+
triangle: shell.triangles[0],
|
|
279
|
+
nestingDepth: depth,
|
|
280
|
+
signedVolume: shell.volume,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
package/dist/geometry/mesh.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { IndexedMeshEdgeTable } from "./mesh-edge-table.js";
|
|
2
3
|
import { writeMeshVertex } from "./numeric.js";
|
|
3
4
|
import { resolveGeometryTolerance } from "./tolerance.js";
|
|
4
5
|
import { transformDeterminant3, validateTransform3 } from "./transform3.js";
|
|
@@ -33,7 +34,7 @@ function inspectTriangleMesh(mesh, options = {}, mode = "validate", topology = f
|
|
|
33
34
|
const extent = vertexCount === 0 ? 0 : Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
|
|
34
35
|
const minimumDoubleArea = resolveGeometryTolerance(extent, options.tolerance).area;
|
|
35
36
|
const edges = options.requireClosed || topology
|
|
36
|
-
? new
|
|
37
|
+
? new IndexedMeshEdgeTable(mesh.indices.length, topology)
|
|
37
38
|
: null;
|
|
38
39
|
const triangleCount = mesh.indices.length / 3;
|
|
39
40
|
const parents = topology ? new Int32Array(triangleCount) : null;
|
|
@@ -64,23 +65,11 @@ function inspectTriangleMesh(mesh, options = {}, mode = "validate", topology = f
|
|
|
64
65
|
};
|
|
65
66
|
let edgeCount = 0;
|
|
66
67
|
const addEdge = (a, b, triangle) => {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
if (neighbors === undefined) {
|
|
70
|
-
neighbors = new Map();
|
|
71
|
-
edges.set(lower, neighbors);
|
|
72
|
-
}
|
|
73
|
-
let edge = neighbors.get(upper);
|
|
74
|
-
if (edge === undefined) {
|
|
75
|
-
edge = { count: 0, balance: 0, ...(topology ? { firstTriangle: triangle } : {}) };
|
|
76
|
-
neighbors.set(upper, edge);
|
|
68
|
+
const firstTriangle = edges.touch(a, b, triangle);
|
|
69
|
+
if (firstTriangle === -1)
|
|
77
70
|
edgeCount += 1;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
connect(edge.firstTriangle, triangle);
|
|
81
|
-
}
|
|
82
|
-
edge.count += 1;
|
|
83
|
-
edge.balance += a < b ? 1 : -1;
|
|
71
|
+
else if (topology)
|
|
72
|
+
connect(firstTriangle, triangle);
|
|
84
73
|
};
|
|
85
74
|
// Keep the two hot-loop accumulators independent and local: no mutable numeric objects escape.
|
|
86
75
|
let surfaceArea = 0, areaCorrection = 0, signedVolume = 0, volumeCorrection = 0;
|
|
@@ -129,14 +118,8 @@ function inspectTriangleMesh(mesh, options = {}, mode = "validate", topology = f
|
|
|
129
118
|
addEdge(c, a, offset / 3);
|
|
130
119
|
}
|
|
131
120
|
}
|
|
132
|
-
if (options.requireClosed && edges !== null)
|
|
133
|
-
|
|
134
|
-
for (const edge of neighbors.values()) {
|
|
135
|
-
if (edge.count !== 2 || edge.balance !== 0) {
|
|
136
|
-
throw new GeometryError("INVALID_MESH", "Closed meshes require exactly two oppositely wound faces at every edge.");
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
121
|
+
if (options.requireClosed && edges !== null && !edges.isClosed())
|
|
122
|
+
throw new GeometryError("INVALID_MESH", "Closed meshes require exactly two oppositely wound faces at every edge.");
|
|
140
123
|
if (referenced !== null) {
|
|
141
124
|
for (let vertex = 0; vertex < vertexCount; vertex += 1) {
|
|
142
125
|
if (referenced[vertex] === 0) {
|
|
@@ -26,5 +26,7 @@ export const cumulativePathLengths = (path, limits = {}) => {
|
|
|
26
26
|
}
|
|
27
27
|
return cumulative;
|
|
28
28
|
};
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Returns total sampled 3D path length, or zero for an empty path.
|
|
31
|
+
*/
|
|
30
32
|
export const pathLength = (path, limits = {}) => cumulativePathLengths(path, limits).at(-1) ?? 0;
|
|
@@ -99,7 +99,8 @@ function resamplePolyline(path, dimensions, options, createWriter) {
|
|
|
99
99
|
}
|
|
100
100
|
return writer.finish();
|
|
101
101
|
}
|
|
102
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Resamples a spatial polyline at equal arc lengths with directed outgoing tangents.
|
|
103
104
|
* @remarks Open paths retain both endpoints; closed paths include the closing edge and omit the
|
|
104
105
|
* repeated seam. Repeated adjacent inputs and explicit closing duplicates are removed. Physical
|
|
105
106
|
* maxSegmentLength bounds arc-length intervals; exact counts override that policy. Output edges
|
|
@@ -68,6 +68,14 @@ const contours = (component) => [
|
|
|
68
68
|
* Cubics share a checked uniform parameter grid, including grids selected by exact paired requests.
|
|
69
69
|
* Input order and winding are preserved. Collapsed spans retain repeated logical slots, which
|
|
70
70
|
* the engine maps to shared physical vertices while omitting degenerate faces.
|
|
71
|
+
*
|
|
72
|
+
* @example Match named rectangle landmarks
|
|
73
|
+
* ```ts
|
|
74
|
+
* matchProfiles([
|
|
75
|
+
* rectangleProfile({ size: [20, 12] }),
|
|
76
|
+
* rectangleProfile({ size: [30, 20], treatment: { kind: "round", radius: 3 } }),
|
|
77
|
+
* ]);
|
|
78
|
+
* ```
|
|
71
79
|
*/
|
|
72
80
|
export function matchProfiles(profiles, options = {}) {
|
|
73
81
|
if (!isArray(profiles) || profiles.length === 0 || profiles.length > 1000)
|