@metamesh/modeling 0.7.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 +24 -0
- package/README.md +76 -3
- package/dist/api/engine.d.ts +4 -0
- package/dist/api/geometry.d.ts +104 -19
- package/dist/api/index.d.ts +203 -18
- package/dist/api/text.d.ts +21 -7
- 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/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/heightfield.js +101 -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 +2 -6
- package/dist/geometry/mechanical/standards.js +2 -3
- package/dist/geometry/mechanical/threads.js +2 -3
- package/dist/geometry/mesh-centroid.js +36 -0
- package/dist/geometry/mesh-solid-intersections.js +123 -0
- package/dist/geometry/mesh-solid.js +284 -0
- package/dist/geometry/sweeps/sweep-closed.js +4 -4
- package/dist/geometry/sweeps/sweep-mesh.js +2 -6
- package/dist/geometry/sweeps/sweep-open.js +4 -4
- package/dist/geometry/sweeps/validation.js +1 -2
- package/dist/model/operations.js +10 -3
- package/dist/text/vector-text-layout.js +46 -3
- package/package.json +1 -1
package/dist/geometry/loft.js
CHANGED
|
@@ -3,7 +3,7 @@ import { resolveExtrusionControls, resolveExtrusionDivisions, } from "./extrusio
|
|
|
3
3
|
import { pointLoftProfile, prepareLoftGeometry } from "./loft-geometry.js";
|
|
4
4
|
import {} from "./loft-options.js";
|
|
5
5
|
import { simpleLoftMesh } from "./loft-simple-mesh.js";
|
|
6
|
-
import { assertMeshAllocationWithinLimits, resolveModelingLimits
|
|
6
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits } from "./model-limits.js";
|
|
7
7
|
import { assertSamplingCount } from "./sampling-limits.js";
|
|
8
8
|
export { triangulateLoop2 } from "./loft-triangulation.js";
|
|
9
9
|
/**
|
|
@@ -23,10 +23,6 @@ export { triangulateLoop2 } from "./loft-triangulation.js";
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
export function loftMesh(stations, options) {
|
|
26
|
-
return buildLoftMesh(stations, options);
|
|
27
|
-
}
|
|
28
|
-
/** @internal Shared pure loft with host allocation preflight. */
|
|
29
|
-
export function buildLoftMesh(stations, options, allocationGuard) {
|
|
30
26
|
if (options?.correspondence === undefined)
|
|
31
27
|
throw new GeometryError("INVALID_ARGUMENT", "Point-loop lofts require an explicit correspondence method.", { operation: "loftMesh" });
|
|
32
28
|
const limits = resolveModelingLimits(options.limits);
|
|
@@ -45,7 +41,7 @@ export function buildLoftMesh(stations, options, allocationGuard) {
|
|
|
45
41
|
...station,
|
|
46
42
|
profile: pointLoftProfile(station.profile),
|
|
47
43
|
}));
|
|
48
|
-
return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits
|
|
44
|
+
return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits);
|
|
49
45
|
}
|
|
50
46
|
/**
|
|
51
47
|
* Linearly extrudes one simple profile along +Z with robust ear-clipped caps.
|
|
@@ -44,11 +44,10 @@ export const METRIC_FASTENER_STANDARDS = Object.freeze({
|
|
|
44
44
|
});
|
|
45
45
|
/** Returns the immutable standards record for one supported metric size. */
|
|
46
46
|
export function getMetricFastenerStandard(size) {
|
|
47
|
-
|
|
48
|
-
if (value === undefined) {
|
|
47
|
+
if (!Object.hasOwn(METRIC_FASTENER_STANDARDS, size)) {
|
|
49
48
|
throw new GeometryError("INVALID_ARGUMENT", `Unsupported metric fastener size: ${size}.`);
|
|
50
49
|
}
|
|
51
|
-
return
|
|
50
|
+
return METRIC_FASTENER_STANDARDS[size];
|
|
52
51
|
}
|
|
53
52
|
/** Returns one recommended metric clearance-hole diameter in millimetres. */
|
|
54
53
|
export function getMetricClearanceHole(size, fit = "normal") {
|
|
@@ -36,11 +36,10 @@ const validateFitPolicy = (policy) => {
|
|
|
36
36
|
export function resolvePrintableThreadFitPolicy(fit = "normal") {
|
|
37
37
|
if (typeof fit !== "string")
|
|
38
38
|
return validateFitPolicy(fit);
|
|
39
|
-
|
|
40
|
-
if (policy === undefined) {
|
|
39
|
+
if (!Object.hasOwn(PRINTABLE_THREAD_FIT_POLICIES, fit)) {
|
|
41
40
|
throw new GeometryError("INVALID_ARGUMENT", `Unsupported printable thread fit: ${fit}.`);
|
|
42
41
|
}
|
|
43
|
-
return
|
|
42
|
+
return PRINTABLE_THREAD_FIT_POLICIES[fit];
|
|
44
43
|
}
|
|
45
44
|
/**
|
|
46
45
|
* Applies role-specific radial compensation while preserving thread depth.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { analyzeTriangleMesh } from "./mesh.js";
|
|
3
|
+
/**
|
|
4
|
+
* Computes the uniform-density volume centroid of an oriented closed solid, including cavity shells.
|
|
5
|
+
* Coordinates are millimetres. Indexed closure and positive aggregate volume are required; callers
|
|
6
|
+
* may use validateSolidMesh first when geometric intersections and shell orientation are untrusted.
|
|
7
|
+
* Fresh mutable input is inspected on every call, and buffers are never changed.
|
|
8
|
+
*
|
|
9
|
+
* @param mesh - Consistently oriented closed mesh with positive enclosed volume.
|
|
10
|
+
* @returns A fresh immutable XYZ volume centroid.
|
|
11
|
+
*/
|
|
12
|
+
export function computeTriangleMeshCentroid(mesh) {
|
|
13
|
+
const facts = analyzeTriangleMesh(mesh, { requireClosed: true });
|
|
14
|
+
if (!(facts.signedVolume > 0))
|
|
15
|
+
throw new GeometryError("INVALID_TOPOLOGY", "A volume centroid requires positive oriented volume.", { operation: "computeTriangleMeshCentroid" });
|
|
16
|
+
const origin = facts.bounds.min;
|
|
17
|
+
const totals = [0, 0, 0], corrections = [0, 0, 0];
|
|
18
|
+
for (let offset = 0; offset < mesh.indices.length; offset += 3) {
|
|
19
|
+
const a = mesh.indices[offset] * 3, b = mesh.indices[offset + 1] * 3, c = mesh.indices[offset + 2] * 3;
|
|
20
|
+
const ax = mesh.positions[a] - origin[0], ay = mesh.positions[a + 1] - origin[1], az = mesh.positions[a + 2] - origin[2];
|
|
21
|
+
const bx = mesh.positions[b] - origin[0], by = mesh.positions[b + 1] - origin[1], bz = mesh.positions[b + 2] - origin[2];
|
|
22
|
+
const cx = mesh.positions[c] - origin[0], cy = mesh.positions[c + 1] - origin[1], cz = mesh.positions[c + 2] - origin[2];
|
|
23
|
+
const volume = (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6;
|
|
24
|
+
const coordinates = [(ax + bx + cx) / 4, (ay + by + cy) / 4, (az + bz + cz) / 4];
|
|
25
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
26
|
+
const value = volume * coordinates[axis], next = totals[axis] + value;
|
|
27
|
+
corrections[axis] +=
|
|
28
|
+
Math.abs(totals[axis]) >= Math.abs(value)
|
|
29
|
+
? totals[axis] - next + value
|
|
30
|
+
: value - next + totals[axis];
|
|
31
|
+
totals[axis] = next;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const coordinate = (axis) => origin[axis] + (totals[axis] + corrections[axis]) / facts.signedVolume;
|
|
35
|
+
return Object.freeze([coordinate(0), coordinate(1), coordinate(2)]);
|
|
36
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,12 +7,12 @@ import { computeSweepFrames, validateSweepOptions } from "./orientation.js";
|
|
|
7
7
|
import { planSweepStations, refineSweepStations } from "./stations.js";
|
|
8
8
|
import { assertSweepAllocation, normalizeSweepPath, normalizeSweepProfile, writeSweepVertex, } from "./validation.js";
|
|
9
9
|
/** @internal Checks host allocation policy before frames or manufacturing buffers. */
|
|
10
|
-
export const buildClosedSweepMesh = (profile, path, options = {}
|
|
10
|
+
export const buildClosedSweepMesh = (profile, path, options = {}) => {
|
|
11
11
|
const operation = "sweep";
|
|
12
12
|
validateSweepOptions(options);
|
|
13
13
|
profile = normalizeSweepProfile(profile, operation, options.limits);
|
|
14
14
|
path = normalizeSweepPath(path, true, operation, options.limits);
|
|
15
|
-
assertSweepAllocation(path.length, profile.length, true, operation,
|
|
15
|
+
assertSweepAllocation(path.length, profile.length, true, operation, options.limits);
|
|
16
16
|
let scales = resolveSweepScales(options.scale, path.length);
|
|
17
17
|
const seamProfileOffset = options.seamProfileOffset ?? 0;
|
|
18
18
|
if (!Number.isSafeInteger(seamProfileOffset)) {
|
|
@@ -44,13 +44,13 @@ export const buildClosedSweepMesh = (profile, path, options = {}, allocationGuar
|
|
|
44
44
|
}
|
|
45
45
|
if (twistRadians !== 0) {
|
|
46
46
|
const plan = planSweepStations([profile], path, scales, options);
|
|
47
|
-
assertSweepAllocation(plan.stationCount, profile.length, true, operation,
|
|
47
|
+
assertSweepAllocation(plan.stationCount, profile.length, true, operation, options.limits);
|
|
48
48
|
}
|
|
49
49
|
let transportedFrames = computeSweepFrames(path, options);
|
|
50
50
|
let twists;
|
|
51
51
|
if (twistRadians !== 0) {
|
|
52
52
|
const plan = planSweepStations([profile], path, scales, options, transportedFrames);
|
|
53
|
-
assertSweepAllocation(plan.stationCount, profile.length, true, operation,
|
|
53
|
+
assertSweepAllocation(plan.stationCount, profile.length, true, operation, options.limits);
|
|
54
54
|
const refined = refineSweepStations(path, scales, transportedFrames, options, plan);
|
|
55
55
|
path = refined.path;
|
|
56
56
|
scales = refined.scales;
|
|
@@ -14,17 +14,13 @@ import { buildOpenSweepMesh } from "./sweep-open.js";
|
|
|
14
14
|
* ```
|
|
15
15
|
*/
|
|
16
16
|
export function sweepMesh(profile, path, options = {}) {
|
|
17
|
-
return buildSweepMesh(profile, path, options);
|
|
18
|
-
}
|
|
19
|
-
/** @internal Applies host ceilings before frames, caps, or manufacturing buffers. */
|
|
20
|
-
export function buildSweepMesh(profile, path, options = {}, allocationGuard) {
|
|
21
17
|
validateSweepOptions(options);
|
|
22
18
|
if (options.closed === true) {
|
|
23
|
-
return buildClosedSweepMesh(profile, path, options
|
|
19
|
+
return buildClosedSweepMesh(profile, path, options);
|
|
24
20
|
}
|
|
25
21
|
if ((options.seamProfileOffset ?? 0) !== 0)
|
|
26
22
|
throw new GeometryError("INVALID_ARGUMENT", "A seamProfileOffset requires a closed path.", {
|
|
27
23
|
operation: "sweep",
|
|
28
24
|
});
|
|
29
|
-
return buildOpenSweepMesh(profile, path, options
|
|
25
|
+
return buildOpenSweepMesh(profile, path, options);
|
|
30
26
|
}
|
|
@@ -6,23 +6,23 @@ import { computeSweepFrames, validateSweepOptions } from "./orientation.js";
|
|
|
6
6
|
import { planSweepStations, refineSweepStations } from "./stations.js";
|
|
7
7
|
import { assertSweepAllocation, normalizeSweepPath, normalizeSweepProfile, writeSweepVertex, } from "./validation.js";
|
|
8
8
|
/** @internal Checks host allocation policy before frames, caps or manufacturing buffers. */
|
|
9
|
-
export const buildOpenSweepMesh = (profile, path, options = {}
|
|
9
|
+
export const buildOpenSweepMesh = (profile, path, options = {}) => {
|
|
10
10
|
const operation = "sweep";
|
|
11
11
|
validateSweepOptions(options);
|
|
12
12
|
profile = normalizeSweepProfile(profile, operation, options.limits);
|
|
13
13
|
path = normalizeSweepPath(path, false, operation, options.limits);
|
|
14
|
-
assertSweepAllocation(path.length, profile.length, false, operation,
|
|
14
|
+
assertSweepAllocation(path.length, profile.length, false, operation, options.limits);
|
|
15
15
|
let scales = resolveSweepScales(options.scale, path.length);
|
|
16
16
|
const twistRadians = ((options.twistDegrees ?? 0) / 180) * Math.PI;
|
|
17
17
|
if (twistRadians !== 0) {
|
|
18
18
|
const plan = planSweepStations([profile], path, scales, options);
|
|
19
|
-
assertSweepAllocation(plan.stationCount, profile.length, false, operation,
|
|
19
|
+
assertSweepAllocation(plan.stationCount, profile.length, false, operation, options.limits);
|
|
20
20
|
}
|
|
21
21
|
let frames = computeSweepFrames(path, options);
|
|
22
22
|
let twists;
|
|
23
23
|
if (twistRadians !== 0) {
|
|
24
24
|
const plan = planSweepStations([profile], path, scales, options, frames);
|
|
25
|
-
assertSweepAllocation(plan.stationCount, profile.length, false, operation,
|
|
25
|
+
assertSweepAllocation(plan.stationCount, profile.length, false, operation, options.limits);
|
|
26
26
|
const refined = refineSweepStations(path, scales, frames, options, plan);
|
|
27
27
|
path = refined.path;
|
|
28
28
|
scales = refined.scales;
|
|
@@ -87,11 +87,10 @@ export function normalizeSweepPath(path, closed, operation, limits = {}) {
|
|
|
87
87
|
return Object.freeze(cleaned);
|
|
88
88
|
}
|
|
89
89
|
/** @internal Checks output allocation before creating frames or buffers. */
|
|
90
|
-
export function assertSweepAllocation(pathCount, profileCount, closed, operation,
|
|
90
|
+
export function assertSweepAllocation(pathCount, profileCount, closed, operation, limits = {}) {
|
|
91
91
|
const vertexCount = pathCount * profileCount;
|
|
92
92
|
const triangleCount = (closed ? pathCount : pathCount - 1) * profileCount * 2 + (closed ? 0 : (profileCount - 2) * 2);
|
|
93
93
|
assertMeshAllocationWithinLimits(vertexCount, triangleCount, (vertexCount + triangleCount) * 12, operation, resolveModelingLimits(limits));
|
|
94
|
-
allocationGuard?.(vertexCount, triangleCount, (vertexCount + triangleCount) * 12, operation);
|
|
95
94
|
}
|
|
96
95
|
/** @internal Rejects coordinates that cannot be represented in manufacturing mesh buffers. */
|
|
97
96
|
export function writeSweepVertex(positions, offset, point, operation) {
|
package/dist/model/operations.js
CHANGED
|
@@ -3,8 +3,6 @@ function operation(name) {
|
|
|
3
3
|
const invoke = (...args) => {
|
|
4
4
|
const context = currentModelContext(name);
|
|
5
5
|
const method = context[name];
|
|
6
|
-
if (typeof method !== "function")
|
|
7
|
-
throw new Error(`Unknown modeling operation "${name}".`);
|
|
8
6
|
return Reflect.apply(method, undefined, args);
|
|
9
7
|
};
|
|
10
8
|
return invoke;
|
|
@@ -23,7 +21,7 @@ export function modelingFunctions(resolution) {
|
|
|
23
21
|
}
|
|
24
22
|
/** Host-bound box operation. Valid only during a synchronous build. */
|
|
25
23
|
export const box = /* @__PURE__ */ operation("box");
|
|
26
|
-
/** Constructs a closed
|
|
24
|
+
/** Constructs a closed rectilinear heightfield during the active synchronous build. */
|
|
27
25
|
export const heightfield = /* @__PURE__ */ operation("heightfield");
|
|
28
26
|
/** Returns an immutable copy with the explicit native millimetre tolerance. */
|
|
29
27
|
export const setTolerance =
|
|
@@ -42,6 +40,11 @@ export const union = /* @__PURE__ */ operation("union");
|
|
|
42
40
|
export const subtract = /* @__PURE__ */ operation("subtract");
|
|
43
41
|
/** Host-bound intersect operation. Valid only during a synchronous build. */
|
|
44
42
|
export const intersect = /* @__PURE__ */ operation("intersect");
|
|
43
|
+
/** Returns intersection and difference with a same-build cutter, including empty results. */
|
|
44
|
+
export const split = /* @__PURE__ */ operation("split");
|
|
45
|
+
/** Splits a solid into the normal-side and opposite-side halves of a world-space plane. */
|
|
46
|
+
export const splitByPlane =
|
|
47
|
+
/* @__PURE__ */ operation("splitByPlane");
|
|
45
48
|
/** Host-bound compose operation. Valid only during a synchronous build. */
|
|
46
49
|
export const compose = /* @__PURE__ */ operation("compose");
|
|
47
50
|
/** Host-bound hull operation. Valid only during a synchronous build. */
|
|
@@ -98,6 +101,10 @@ export const inspectSection =
|
|
|
98
101
|
/** Host-bound inspectSolid operation. Valid only during a synchronous build. */
|
|
99
102
|
export const inspectSolid =
|
|
100
103
|
/* @__PURE__ */ operation("inspectSolid");
|
|
104
|
+
/** Tests a solid or filled section in the active build, including expected empty Boolean results. */
|
|
105
|
+
export const isEmpty = /* @__PURE__ */ operation("isEmpty");
|
|
106
|
+
/** Measures the minimum separation between two nonempty solids in millimetres. */
|
|
107
|
+
export const distance = /* @__PURE__ */ operation("distance");
|
|
101
108
|
/** Host-bound components operation. Valid only during a synchronous build. */
|
|
102
109
|
export const components = /* @__PURE__ */ operation("components");
|
|
103
110
|
/** Host-bound simplify operation. Valid only during a synchronous build. */
|