@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,42 @@
|
|
|
1
|
+
import { GeometryError } from "../../geometry/errors.js";
|
|
2
|
+
import { assertNativeSolidStatus } from "./status.js";
|
|
3
|
+
/** Internal minimum separation query; never exposes the kernel's capped search result. */
|
|
4
|
+
export function measureSolidDistance(left, right) {
|
|
5
|
+
assertNativeSolidStatus(left, "distance");
|
|
6
|
+
assertNativeSolidStatus(right, "distance");
|
|
7
|
+
if (left.isEmpty() || right.isEmpty())
|
|
8
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", "Distance requires nonempty solids.", {
|
|
9
|
+
operation: "distance",
|
|
10
|
+
});
|
|
11
|
+
const a = left.boundingBox(), b = right.boundingBox();
|
|
12
|
+
const axes = [0, 1, 2];
|
|
13
|
+
const lower = Math.hypot(...axes.map((i) => Math.max(0, a.min[i] - b.max[i], b.min[i] - a.max[i])));
|
|
14
|
+
const upper = Math.hypot(...axes.map((i) => Math.max(Math.abs(a.min[i] - b.max[i]), Math.abs(a.max[i] - b.min[i]))));
|
|
15
|
+
const diagonal = Math.max(Math.hypot(...axes.map((i) => a.max[i] - a.min[i])), Math.hypot(...axes.map((i) => b.max[i] - b.min[i])));
|
|
16
|
+
const scale = Math.max(upper, ...[...a.min, ...a.max, ...b.min, ...b.max].map(Math.abs));
|
|
17
|
+
const padding = Math.max(Math.sqrt(Number.MIN_VALUE) * 2, scale * Number.EPSILON * 8);
|
|
18
|
+
const maximum = upper + padding;
|
|
19
|
+
if (!Number.isFinite(maximum * maximum) || maximum <= 0)
|
|
20
|
+
throw new GeometryError("NON_FINITE_VALUE", "Distance exceeds the kernel's numeric range.", {
|
|
21
|
+
operation: "distance",
|
|
22
|
+
});
|
|
23
|
+
// A modest extent-based seed avoids many tiny searches for very unequal shapes.
|
|
24
|
+
// A full bounding-box diagonal would make the kernel visit unnecessary triangle pairs.
|
|
25
|
+
let radius = Math.min(maximum, Math.max(lower + padding, diagonal / 32, padding));
|
|
26
|
+
for (;;) {
|
|
27
|
+
const value = left.minGap(right, radius);
|
|
28
|
+
// The kernel takes sqrt(radius²) when capped; that can round below radius.
|
|
29
|
+
const capSlack = Math.max(Math.sqrt(Number.MIN_VALUE), radius * Number.EPSILON * 4);
|
|
30
|
+
if (!Number.isFinite(value) || value < 0 || value > radius + capSlack)
|
|
31
|
+
throw new GeometryError("NON_FINITE_VALUE", "The kernel returned an invalid distance.", {
|
|
32
|
+
operation: "distance",
|
|
33
|
+
});
|
|
34
|
+
if (value < radius - capSlack)
|
|
35
|
+
return value;
|
|
36
|
+
if (radius === maximum)
|
|
37
|
+
throw new GeometryError("INVALID_MESH", "The kernel could not resolve the minimum distance.", {
|
|
38
|
+
operation: "distance",
|
|
39
|
+
});
|
|
40
|
+
radius = Math.min(maximum, radius * 2);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { GeometryError } from "../../geometry/errors.js";
|
|
2
|
+
/** Checks an already required native evaluation without exposing upstream status types publicly. */
|
|
3
|
+
export function assertNativeSolidStatus(native, operation) {
|
|
4
|
+
const status = native.status();
|
|
5
|
+
if (status === "NoError")
|
|
6
|
+
return;
|
|
7
|
+
const code = status === "NonFiniteVertex"
|
|
8
|
+
? "NON_FINITE_VALUE"
|
|
9
|
+
: status === "ResultTooLarge"
|
|
10
|
+
? "COMPLEXITY_LIMIT"
|
|
11
|
+
: "INVALID_TOPOLOGY";
|
|
12
|
+
throw new GeometryError(code, `The geometry kernel rejected the solid: ${status}.`, {
|
|
13
|
+
operation,
|
|
14
|
+
details: { nativeStatus: status },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -3,7 +3,8 @@ import { resolveCircularPrimitiveSegments } from "../geometry/primitive-resoluti
|
|
|
3
3
|
/** Applies explicit resolution inheritance once for this bound context. */
|
|
4
4
|
export function createOperationEnvironment(owner, resolution) {
|
|
5
5
|
const withResolutionDefaults = (value) => {
|
|
6
|
-
const
|
|
6
|
+
const overrides = value.limits;
|
|
7
|
+
const limits = overrides === undefined ? owner.limits : resolveModelingLimits(overrides, owner.limits);
|
|
7
8
|
return {
|
|
8
9
|
...value,
|
|
9
10
|
resolution: {
|
|
@@ -5,7 +5,7 @@ import { sampleTextContours } from "../geometry/text-contours.js";
|
|
|
5
5
|
import * as kernel from "./native/index.js";
|
|
6
6
|
/** Internal host-bound section operations. */
|
|
7
7
|
export function createSectionOperations(environment, transforms) {
|
|
8
|
-
const { module, limits, scope, assertActive, throwGeometryError, nativeSolid, nativeSection, wrapSection, ownSection, validateFiniteNumber,
|
|
8
|
+
const { module, limits, scope, assertActive, throwGeometryError, nativeSolid, nativeSection, wrapSection, ownSection, validateFiniteNumber, validateCoordinates, assertOperandCount, withResolutionDefaults, resolveCircularSegments, } = environment;
|
|
9
9
|
const { translate } = transforms.operations;
|
|
10
10
|
const fromContours = (contours, value = {}) => {
|
|
11
11
|
assertActive();
|
|
@@ -51,8 +51,8 @@ export function createSectionOperations(environment, transforms) {
|
|
|
51
51
|
const join = value.join ?? "round";
|
|
52
52
|
if (!["round", "miter", "square"].includes(join))
|
|
53
53
|
throwGeometryError("INVALID_ARGUMENT", "offset", "Unsupported offset join.");
|
|
54
|
-
if (value.miterLimit !== undefined)
|
|
55
|
-
|
|
54
|
+
if (value.miterLimit !== undefined && validateFiniteNumber(value.miterLimit, "offset") < 2)
|
|
55
|
+
throwGeometryError("INVALID_ARGUMENT", "offset", "miterLimit must be at least two.");
|
|
56
56
|
return ownSection(native.offset(distance, join === "round" ? "Round" : join === "miter" ? "Miter" : "Square", value.miterLimit, resolveCircularSegments(Math.max(Math.abs(distance), 0.001), {
|
|
57
57
|
...(value.circularSegments === undefined ? {} : { segments: value.circularSegments }),
|
|
58
58
|
...(value.resolution === undefined ? {} : { resolution: value.resolution }),
|
|
@@ -224,7 +224,8 @@ function sampleCurve(curve, options, createWriter) {
|
|
|
224
224
|
return curveFailure("INVALID_ARGUMENT", "Unsupported curve kind.");
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Samples a spatial curve, preserving endpoints and directed unit tangents.
|
|
228
229
|
* @remarks Exact counts use uniform curve parameters, not equal physical distances. Adaptive cubics
|
|
229
230
|
* bound the full control hull. Full-circle arcs repeat their endpoint; closed polylines do not.
|
|
230
231
|
* @example
|
|
@@ -1,57 +1,108 @@
|
|
|
1
1
|
import { GeometryError } from "./errors.js";
|
|
2
2
|
import { validateTriangleMesh } from "./mesh.js";
|
|
3
|
-
import {
|
|
3
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "./model-limits.js";
|
|
4
4
|
import { writeMeshVertex } from "./numeric.js";
|
|
5
|
-
|
|
6
|
-
/** Checks regular-grid dimensions and bounds allocation before evaluating any height samples. */
|
|
7
|
-
export function preflightHeightfield(options, limits = {}) {
|
|
5
|
+
function planHeightfield(options, limits) {
|
|
8
6
|
const operation = "preflightHeightfield";
|
|
9
7
|
if (typeof options !== "object" ||
|
|
10
8
|
options === null ||
|
|
11
|
-
!isArray(options.size) ||
|
|
12
|
-
options.size.length !== 2 ||
|
|
13
|
-
!options.size.every((value) => Number.isFinite(value) && value > 0) ||
|
|
14
|
-
!isArray(options.segments) ||
|
|
15
|
-
options.segments.length !== 2 ||
|
|
16
|
-
!options.segments.every((value) => Number.isSafeInteger(value) && value > 0) ||
|
|
17
9
|
typeof options.height !== "function" ||
|
|
18
10
|
!Number.isFinite(options.baseZ ?? 0) ||
|
|
19
|
-
(options.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
(options.triangulation !== undefined &&
|
|
12
|
+
!["fixed", "min-edge", "center-error"].includes(options.triangulation))) {
|
|
13
|
+
throw new GeometryError("INVALID_ARGUMENT", "Heightfields need a finite base, height callback, and supported triangulation policy.", { operation });
|
|
14
|
+
}
|
|
15
|
+
let x, y;
|
|
16
|
+
let xAt, yAt;
|
|
17
|
+
if (options.coordinates !== undefined) {
|
|
18
|
+
const coordinates = options.coordinates;
|
|
19
|
+
if (typeof coordinates !== "object" ||
|
|
20
|
+
coordinates === null ||
|
|
21
|
+
!Array.isArray(coordinates.x) ||
|
|
22
|
+
!Array.isArray(coordinates.y) ||
|
|
23
|
+
coordinates.x.length < 2 ||
|
|
24
|
+
coordinates.y.length < 2 ||
|
|
25
|
+
options.size !== undefined ||
|
|
26
|
+
options.segments !== undefined ||
|
|
27
|
+
options.origin !== undefined) {
|
|
28
|
+
throw new GeometryError("INVALID_ARGUMENT", "Explicit heightfield coordinates need two or more samples per axis and cannot include size, segments, or origin.", { operation });
|
|
29
|
+
}
|
|
30
|
+
x = coordinates.x.length - 1;
|
|
31
|
+
y = coordinates.y.length - 1;
|
|
32
|
+
xAt = (index) => coordinates.x[index];
|
|
33
|
+
yAt = (index) => coordinates.y[index];
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (!Array.isArray(options.size) ||
|
|
37
|
+
options.size.length !== 2 ||
|
|
38
|
+
!options.size.every((value) => Number.isFinite(value) && value > 0) ||
|
|
39
|
+
!Array.isArray(options.segments) ||
|
|
40
|
+
options.segments.length !== 2 ||
|
|
41
|
+
!options.segments.every((value) => Number.isSafeInteger(value) && value > 0) ||
|
|
42
|
+
(options.origin !== undefined &&
|
|
43
|
+
(!Array.isArray(options.origin) ||
|
|
44
|
+
options.origin.length !== 2 ||
|
|
45
|
+
!options.origin.every(Number.isFinite)))) {
|
|
46
|
+
throw new GeometryError("INVALID_ARGUMENT", "Uniform heightfields need a finite positive footprint, integer intervals, and finite origin.", { operation });
|
|
47
|
+
}
|
|
48
|
+
[x, y] = options.segments;
|
|
49
|
+
const [width, depth] = options.size;
|
|
50
|
+
const [originX, originY] = options.origin ?? [-width / 2, -depth / 2];
|
|
51
|
+
xAt = (index) => originX + (width * index) / x;
|
|
52
|
+
yAt = (index) => originY + (depth * index) / y;
|
|
24
53
|
}
|
|
25
|
-
const [x, y] = options.segments;
|
|
26
54
|
const sampleCount = (x + 1) * (y + 1);
|
|
27
55
|
const perimeter = 2 * (x + y);
|
|
28
56
|
const vertexCount = sampleCount + perimeter + 1;
|
|
29
57
|
const triangleCount = 2 * x * y + 3 * perimeter;
|
|
30
58
|
const byteCount = 12 * (vertexCount + triangleCount);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
assertMeshAllocationWithinLimits(vertexCount, triangleCount, byteCount, operation, resolveModelingLimits(limits));
|
|
60
|
+
for (const [axis, count, at] of [
|
|
61
|
+
["x", x, xAt],
|
|
62
|
+
["y", y, yAt],
|
|
63
|
+
]) {
|
|
64
|
+
let previous = -Infinity;
|
|
65
|
+
for (let index = 0; index <= count; index += 1) {
|
|
66
|
+
const coordinate = Math.fround(at(index));
|
|
67
|
+
if (!Number.isFinite(coordinate) || !(coordinate > previous))
|
|
68
|
+
throw new GeometryError("INVALID_ARGUMENT", "Heightfield axes must be finite, strictly increasing, and distinct in Float32 storage.", { operation, details: { axis, index } });
|
|
69
|
+
previous = coordinate;
|
|
70
|
+
}
|
|
41
71
|
}
|
|
42
|
-
return
|
|
72
|
+
return {
|
|
73
|
+
x,
|
|
74
|
+
y,
|
|
75
|
+
xAt,
|
|
76
|
+
yAt,
|
|
77
|
+
facts: Object.freeze({ sampleCount, vertexCount, triangleCount, byteCount }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Checks grid dimensions, coordinate precision, and allocation bounds before evaluating heights. */
|
|
81
|
+
export function preflightHeightfield(options, limits = {}) {
|
|
82
|
+
return planHeightfield(options, limits).facts;
|
|
43
83
|
}
|
|
44
84
|
/**
|
|
45
|
-
* Builds a closed indexed
|
|
46
|
-
* Heights
|
|
47
|
-
*
|
|
85
|
+
* Builds a closed indexed mesh from a rectilinear top, matched boundary walls, and flat base.
|
|
86
|
+
* Heights run once per grid point after preflight; center-error adds one evaluation per cell.
|
|
87
|
+
* Top winding is +Z, the base is -Z, and sides face outward. Coordinates and heights must fit
|
|
88
|
+
* Float32 storage. Explicit axes are captured before sampling; shared wall vertices prevent cracks.
|
|
89
|
+
*
|
|
90
|
+
* @example Sample a closed wavy mesh
|
|
91
|
+
* ```ts
|
|
92
|
+
* heightfieldMesh({
|
|
93
|
+
* size: [30, 20], segments: [24, 16],
|
|
94
|
+
* height: ([x, y]) => 3 + Math.cos(x / 5) * Math.cos(y / 5),
|
|
95
|
+
* });
|
|
96
|
+
* ```
|
|
48
97
|
*/
|
|
49
98
|
export function heightfieldMesh(options, limits = {}) {
|
|
50
|
-
const
|
|
99
|
+
const plan = planHeightfield(options, limits);
|
|
100
|
+
const { facts, x: nx, y: ny } = plan;
|
|
51
101
|
const operation = "heightfieldMesh";
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
102
|
+
const xs = Array.from({ length: nx + 1 }, (_, index) => plan.xAt(index));
|
|
103
|
+
const ys = Array.from({ length: ny + 1 }, (_, index) => plan.yAt(index));
|
|
104
|
+
const triangulation = options.triangulation ?? "fixed";
|
|
105
|
+
const heightAt = options.height;
|
|
55
106
|
const baseZ = Math.fround(options.baseZ ?? 0);
|
|
56
107
|
if (!Number.isFinite(baseZ))
|
|
57
108
|
throw new GeometryError("NON_FINITE_VALUE", "Heightfield base must fit Float32 storage.", {
|
|
@@ -60,13 +111,16 @@ export function heightfieldMesh(options, limits = {}) {
|
|
|
60
111
|
const positions = new Float32Array(facts.vertexCount * 3);
|
|
61
112
|
const indices = new Uint32Array(facts.triangleCount * 3);
|
|
62
113
|
const grid = (x, y) => y * (nx + 1) + x;
|
|
114
|
+
const sampleHeight = (point) => {
|
|
115
|
+
const height = Math.fround(heightAt(point));
|
|
116
|
+
if (!Number.isFinite(height) || !(height > baseZ))
|
|
117
|
+
throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x: point[0], y: point[1] } });
|
|
118
|
+
return height;
|
|
119
|
+
};
|
|
63
120
|
for (let y = 0; y <= ny; y += 1) {
|
|
64
121
|
for (let x = 0; x <= nx; x += 1) {
|
|
65
|
-
const point = [
|
|
66
|
-
const height =
|
|
67
|
-
if (!Number.isFinite(height) || !(height > baseZ)) {
|
|
68
|
-
throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x, y } });
|
|
69
|
-
}
|
|
122
|
+
const point = [xs[x], ys[y]];
|
|
123
|
+
const height = sampleHeight(point);
|
|
70
124
|
writeMeshVertex(positions, grid(x, y) * 3, [point[0], point[1], height], operation);
|
|
71
125
|
}
|
|
72
126
|
}
|
|
@@ -79,8 +133,21 @@ export function heightfieldMesh(options, limits = {}) {
|
|
|
79
133
|
for (let y = 0; y < ny; y += 1) {
|
|
80
134
|
for (let x = 0; x < nx; x += 1) {
|
|
81
135
|
const a = grid(x, y), b = grid(x + 1, y), c = grid(x + 1, y + 1), d = grid(x, y + 1);
|
|
82
|
-
|
|
83
|
-
|
|
136
|
+
const za = positions[a * 3 + 2], zb = positions[b * 3 + 2], zc = positions[c * 3 + 2], zd = positions[d * 3 + 2];
|
|
137
|
+
// Rectangular XY cells have equal XY diagonal lengths, so only Z differs.
|
|
138
|
+
let alternate = triangulation === "min-edge" && Math.abs(zb - zd) < Math.abs(za - zc);
|
|
139
|
+
if (triangulation === "center-error") {
|
|
140
|
+
const centre = sampleHeight([(xs[x] + xs[x + 1]) / 2, (ys[y] + ys[y + 1]) / 2]);
|
|
141
|
+
alternate = Math.abs((zb + zd) / 2 - centre) < Math.abs((za + zc) / 2 - centre);
|
|
142
|
+
}
|
|
143
|
+
if (alternate) {
|
|
144
|
+
triangle(a, b, d);
|
|
145
|
+
triangle(b, c, d);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
triangle(a, b, c);
|
|
149
|
+
triangle(a, c, d);
|
|
150
|
+
}
|
|
84
151
|
}
|
|
85
152
|
}
|
|
86
153
|
const boundary = [];
|
|
@@ -94,7 +161,7 @@ export function heightfieldMesh(options, limits = {}) {
|
|
|
94
161
|
boundary.push(grid(0, y));
|
|
95
162
|
const baseStart = facts.sampleCount;
|
|
96
163
|
const baseCenter = facts.vertexCount - 1;
|
|
97
|
-
writeMeshVertex(positions, baseCenter * 3, [
|
|
164
|
+
writeMeshVertex(positions, baseCenter * 3, [(xs[0] + xs[nx]) / 2, (ys[0] + ys[ny]) / 2, baseZ], operation);
|
|
98
165
|
for (let offset = 0; offset < boundary.length; offset += 1) {
|
|
99
166
|
const top = boundary[offset];
|
|
100
167
|
writeMeshVertex(positions, (baseStart + offset) * 3, [positions[top * 3], positions[top * 3 + 1], baseZ], operation);
|
package/dist/geometry/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Pure geometry foundations without host initialization. @packageDocumentation */
|
|
2
|
+
export { validateSolidMesh } from "./mesh-solid.js";
|
|
3
|
+
export { computeTriangleMeshCentroid } from "./mesh-centroid.js";
|
|
2
4
|
export { matchProfiles, } from "./profiles/matched.js";
|
|
3
5
|
export { heightfieldMesh, preflightHeightfield, } from "./heightfield.js";
|
|
4
6
|
export { preflightThreadedRodSolid as preflightThreadedRod, preflightInternalThreadCutterSolid as preflightInternalThreadCutter, preflightThreadedNutSolid as preflightThreadedNut, } from "./mechanical/thread-solids.js";
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { GeometryError } from "./errors.js";
|
|
2
2
|
import { triangulateLoop2 } from "./loft-triangulation.js";
|
|
3
3
|
import { measureTriangleMesh, validateTriangleMesh } from "./mesh.js";
|
|
4
|
-
import { assertMeshAllocationWithinLimits
|
|
4
|
+
import { assertMeshAllocationWithinLimits } from "./model-limits.js";
|
|
5
5
|
import { writeMeshVertex } from "./numeric.js";
|
|
6
6
|
/** @internal Pure closed simple-contour strips, retaining repeated correspondence slots as shared physical vertices. */
|
|
7
|
-
export function simpleLoftMesh(geometry, limits
|
|
7
|
+
export function simpleLoftMesh(geometry, limits) {
|
|
8
8
|
const count = geometry.world[0][0].length, rowCount = geometry.world.length;
|
|
9
9
|
const maximumVertices = count * rowCount, maximumTriangles = 2 * count * (rowCount - 1) + 2 * (count - 2);
|
|
10
10
|
const bytes = 12 * (maximumVertices + maximumTriangles);
|
|
11
11
|
assertMeshAllocationWithinLimits(maximumVertices, maximumTriangles, bytes, "loftMesh", limits);
|
|
12
|
-
allocationGuard?.(maximumVertices, maximumTriangles, bytes, "loftMesh");
|
|
13
12
|
const positions = new Float32Array(maximumVertices * 3), indices = new Uint32Array(maximumTriangles * 3);
|
|
14
13
|
let vertexCount = 0, cursor = 0;
|
|
15
14
|
const rows = geometry.world.map((contours, row) => {
|
|
@@ -11,6 +11,16 @@ const pointInTriangle = (point, a, b, c, epsilon) => {
|
|
|
11
11
|
/** Ear-clips one normalized simple CCW loop into stable local vertex indices. */
|
|
12
12
|
export function triangulateLoop2(loop) {
|
|
13
13
|
const normalized = normalizeLoop2(loop, true);
|
|
14
|
+
const assertComparisonLimit = (requiredCount) => {
|
|
15
|
+
if (requiredCount > maximumComparisons)
|
|
16
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
|
|
17
|
+
operation: "triangulateLoop2",
|
|
18
|
+
details: { requiredCount, allowedCount: maximumComparisons },
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
// Every accepted ear scans all remaining vertices plus its outer-loop comparison.
|
|
22
|
+
// Reject impossible workloads before clipping; unsuccessful ears still spend the live budget.
|
|
23
|
+
assertComparisonLimit(((normalized.length - 3) * (normalized.length + 6)) / 2);
|
|
14
24
|
// Subdividing a straight edge introduces roundoff-sized turns. Treat them consistently
|
|
15
25
|
// in both ear convexity and containment so they cannot consume a false ear or block the cap.
|
|
16
26
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
@@ -25,24 +35,17 @@ export function triangulateLoop2(loop) {
|
|
|
25
35
|
const remaining = normalized.map((_, index) => index);
|
|
26
36
|
const triangles = [];
|
|
27
37
|
let comparisons = 0;
|
|
28
|
-
const consume = () => {
|
|
29
|
-
if (++comparisons > maximumComparisons)
|
|
30
|
-
throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
|
|
31
|
-
operation: "triangulateLoop2",
|
|
32
|
-
details: { requiredCount: comparisons, allowedCount: maximumComparisons },
|
|
33
|
-
});
|
|
34
|
-
};
|
|
35
38
|
while (remaining.length > 3) {
|
|
36
39
|
let clipped = false;
|
|
37
40
|
for (let offset = 0; offset < remaining.length; offset += 1) {
|
|
38
|
-
|
|
41
|
+
assertComparisonLimit(++comparisons);
|
|
39
42
|
const previous = remaining[(offset - 1 + remaining.length) % remaining.length];
|
|
40
43
|
const current = remaining[offset];
|
|
41
44
|
const next = remaining[(offset + 1) % remaining.length];
|
|
42
45
|
if (cross(normalized[previous], normalized[current], normalized[next]) <= epsilon)
|
|
43
46
|
continue;
|
|
44
47
|
if (remaining.some((candidate) => {
|
|
45
|
-
|
|
48
|
+
assertComparisonLimit(++comparisons);
|
|
46
49
|
return (candidate !== previous &&
|
|
47
50
|
candidate !== current &&
|
|
48
51
|
candidate !== next &&
|
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
|
/**
|
|
@@ -14,12 +14,15 @@ export { triangulateLoop2 } from "./loft-triangulation.js";
|
|
|
14
14
|
* Both ends use ear-clipped caps, supporting concave profiles without center fans.
|
|
15
15
|
* Intermediate slices interpolate placed world vertices, never frame axes.
|
|
16
16
|
* Transformed coordinates must fit finite Float32 mesh storage.
|
|
17
|
+
*
|
|
18
|
+
* @example Loft with explicit point correspondence
|
|
19
|
+
* ```ts
|
|
20
|
+
* const bottom = rectangleLoop({ size: [20, 12] });
|
|
21
|
+
* const top = rectangleLoop({ size: [30, 20], treatment: { kind: "round", radius: 3 } });
|
|
22
|
+
* loftMesh([{ profile: bottom, z: 0 }, { profile: top, z: 30 }], { correspondence: "reindex" });
|
|
23
|
+
* ```
|
|
17
24
|
*/
|
|
18
25
|
export function loftMesh(stations, options) {
|
|
19
|
-
return buildLoftMesh(stations, options);
|
|
20
|
-
}
|
|
21
|
-
/** @internal Shared pure loft with host allocation preflight. */
|
|
22
|
-
export function buildLoftMesh(stations, options, allocationGuard) {
|
|
23
26
|
if (options?.correspondence === undefined)
|
|
24
27
|
throw new GeometryError("INVALID_ARGUMENT", "Point-loop lofts require an explicit correspondence method.", { operation: "loftMesh" });
|
|
25
28
|
const limits = resolveModelingLimits(options.limits);
|
|
@@ -38,9 +41,17 @@ export function buildLoftMesh(stations, options, allocationGuard) {
|
|
|
38
41
|
...station,
|
|
39
42
|
profile: pointLoftProfile(station.profile),
|
|
40
43
|
}));
|
|
41
|
-
return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits
|
|
44
|
+
return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits);
|
|
42
45
|
}
|
|
43
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Linearly extrudes one simple profile along +Z with robust ear-clipped caps.
|
|
48
|
+
*
|
|
49
|
+
* @example Extrude point data without an engine
|
|
50
|
+
* ```ts
|
|
51
|
+
* const outline = rectangleLoop({ size: [30, 20], treatment: { kind: "round", radius: 3 } });
|
|
52
|
+
* extrudeMesh(outline, { height: 8 });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
44
55
|
export function extrudeMesh(profile, options) {
|
|
45
56
|
const { height } = options;
|
|
46
57
|
const { center, scales, twistDegrees } = resolveExtrusionControls(height, options, "extrudeMesh");
|
|
@@ -1,16 +1,46 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Adds two finite XYZ vectors component-wise.
|
|
3
|
+
* @example
|
|
4
|
+
* ```ts
|
|
5
|
+
* add3([1, 2, 3], [4, 5, 6]); // [5, 7, 9]
|
|
6
|
+
* ```
|
|
7
|
+
*/
|
|
2
8
|
export const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
3
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Subtracts `b` from `a`.
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* subtract3([4, 5, 6], [1, 2, 3]); // [3, 3, 3]
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
4
16
|
export const subtract3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
5
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Multiplies a vector by a dimensionless scalar.
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* scale3([1, 2, 3], 2); // [2, 4, 6]
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
6
24
|
export const scale3 = (vector, scale) => [
|
|
7
25
|
vector[0] * scale,
|
|
8
26
|
vector[1] * scale,
|
|
9
27
|
vector[2] * scale,
|
|
10
28
|
];
|
|
11
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Returns the scalar dot product.
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* dot3([1, 0, 0], [0, 1, 0]); // 0
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
12
36
|
export const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
13
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Returns the right-handed cross product.
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* cross3([1, 0, 0], [0, 1, 0]); // [0, 0, 1]
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
14
44
|
export const cross3 = (a, b) => [
|
|
15
45
|
a[1] * b[2] - a[2] * b[1],
|
|
16
46
|
a[2] * b[0] - a[0] * b[2],
|
|
@@ -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") {
|
|
@@ -234,6 +234,11 @@ const standardProfile = (kind, pitch, depth, root, crest, leftRun, rightRun) =>
|
|
|
234
234
|
* The open path spans one pitch. Radial zero is the major envelope; negative
|
|
235
235
|
* values point toward the shaft. Custom paths must be finite, axially
|
|
236
236
|
* non-decreasing, periodic at their endpoints, and reach radial zero.
|
|
237
|
+
*
|
|
238
|
+
* @example Printable triangular thread profile
|
|
239
|
+
* ```ts
|
|
240
|
+
* createThreadProfile({ kind: "triangular", pitch: 1.75 });
|
|
241
|
+
* ```
|
|
237
242
|
*/
|
|
238
243
|
export function createThreadProfile(spec) {
|
|
239
244
|
const operation = "createThreadProfile";
|
|
@@ -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
|
+
}
|