@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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GeometryError } from "../geometry/errors.js";
|
|
2
2
|
import { optimizePlanarPatches } from "../geometry/mesh-planar.js";
|
|
3
|
-
import {
|
|
3
|
+
import { validateAnalyzedSolidMesh } from "../geometry/mesh-solid.js";
|
|
4
|
+
import { analyzeTriangleMesh, } from "../geometry/mesh.js";
|
|
5
|
+
import { MeshViewCache } from "./mesh-view-cache.js";
|
|
4
6
|
import { copiedMeshViews, copiedUint32Array } from "./native/copied-views.js";
|
|
5
7
|
const isArray = (value) => Array.isArray(value);
|
|
6
8
|
function finalPartTotals(parts) {
|
|
@@ -26,6 +28,12 @@ function fitsLimits(totals, limits) {
|
|
|
26
28
|
}
|
|
27
29
|
/** Finalizes one complete build under the selected limits, preserving usable originals when optional cleanup fails. */
|
|
28
30
|
export function finalizeModel(execution, output, limits, options, startedAt, builtAt) {
|
|
31
|
+
if (options.meshValidation !== undefined &&
|
|
32
|
+
options.meshValidation !== "topology" &&
|
|
33
|
+
options.meshValidation !== "solid")
|
|
34
|
+
throw new GeometryError("INVALID_ARGUMENT", "Unsupported mesh validation mode.", {
|
|
35
|
+
operation: "finalize",
|
|
36
|
+
});
|
|
29
37
|
const descriptors = execution.isSolid(output) ? [{ id: "model", solid: output }] : output;
|
|
30
38
|
if (!isArray(descriptors) || descriptors.length === 0)
|
|
31
39
|
throw new GeometryError("INVALID_ARGUMENT", "Build must return a solid or at least one named part.", { operation: "finalize" });
|
|
@@ -36,6 +44,7 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
|
|
|
36
44
|
const ids = new Set();
|
|
37
45
|
const backingBuffers = new Set();
|
|
38
46
|
const meshCopies = new Map();
|
|
47
|
+
const directMeasurements = new MeshViewCache();
|
|
39
48
|
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
40
49
|
let vertices = 0, triangles = 0, bytes = 0;
|
|
41
50
|
let parts = descriptors.map((descriptor) => {
|
|
@@ -76,10 +85,12 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
|
|
|
76
85
|
throw new GeometryError("COMPLEXITY_LIMIT", "Aggregate output exceeds the execution limits.", { operation: "finalize" });
|
|
77
86
|
// Direct meshes are copied before analysis so later caller mutation cannot invalidate facts.
|
|
78
87
|
const mesh = descriptor.mesh === undefined ? source : copiedMeshViews(source, meshCopies);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
let measurements = nativeAnalysis?.measurements ?? directMeasurements.get(mesh);
|
|
89
|
+
if (measurements === undefined) {
|
|
90
|
+
measurements = analyzeTriangleMesh(mesh, { requireClosed: true, includeTopology: true });
|
|
82
91
|
execution.validateMesh(mesh);
|
|
92
|
+
directMeasurements.set(mesh, measurements);
|
|
93
|
+
}
|
|
83
94
|
if (!(measurements.signedVolume > 0))
|
|
84
95
|
throw new GeometryError("INVALID_TOPOLOGY", "Manufacturing parts must enclose positive oriented volume.", { operation: "finalize" });
|
|
85
96
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
@@ -95,20 +106,9 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
|
|
|
95
106
|
});
|
|
96
107
|
const originalParts = parts;
|
|
97
108
|
if (options.meshTopologyMode === "optimized" && triangles <= 200_000) {
|
|
98
|
-
const candidates = new
|
|
109
|
+
const candidates = new MeshViewCache();
|
|
99
110
|
parts = parts.map((part) => {
|
|
100
|
-
|
|
101
|
-
if (indexBuffers === undefined) {
|
|
102
|
-
indexBuffers = new Map();
|
|
103
|
-
candidates.set(part.positions.buffer, indexBuffers);
|
|
104
|
-
}
|
|
105
|
-
let views = indexBuffers.get(part.indices.buffer);
|
|
106
|
-
if (views === undefined) {
|
|
107
|
-
views = new Map();
|
|
108
|
-
indexBuffers.set(part.indices.buffer, views);
|
|
109
|
-
}
|
|
110
|
-
const key = `${part.positions.byteOffset}:${part.positions.length}:${part.indices.byteOffset}:${part.indices.length}`;
|
|
111
|
-
const cached = views.get(key);
|
|
111
|
+
const cached = candidates.get(part);
|
|
112
112
|
if (cached !== undefined)
|
|
113
113
|
return Object.freeze({ ...part, ...cached });
|
|
114
114
|
let selected = {
|
|
@@ -134,7 +134,7 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
|
|
|
134
134
|
catch {
|
|
135
135
|
// Optional optimization preserves the usable original on unsupported geometry.
|
|
136
136
|
}
|
|
137
|
-
|
|
137
|
+
candidates.set(part, selected);
|
|
138
138
|
return Object.freeze({ ...part, ...selected });
|
|
139
139
|
});
|
|
140
140
|
}
|
|
@@ -148,6 +148,15 @@ export function finalizeModel(execution, output, limits, options, startedAt, bui
|
|
|
148
148
|
throw new GeometryError("COMPLEXITY_LIMIT", "Selected output exceeds the execution limits.", {
|
|
149
149
|
operation: "finalize",
|
|
150
150
|
});
|
|
151
|
+
if (options.meshValidation === "solid") {
|
|
152
|
+
const checked = new MeshViewCache();
|
|
153
|
+
for (const part of parts) {
|
|
154
|
+
if (checked.get(part) !== undefined)
|
|
155
|
+
continue;
|
|
156
|
+
validateAnalyzedSolidMesh(part, part.measurements);
|
|
157
|
+
checked.set(part, true);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
151
160
|
const finalizedAt = performance.now();
|
|
152
161
|
return Object.freeze({
|
|
153
162
|
parts: Object.freeze(parts),
|
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
import { ownScopedManifoldValue } from "./native/context.js";
|
|
2
2
|
import { copiedMeshViews } from "./native/copied-views.js";
|
|
3
|
+
import { measureSolidDistance } from "./native/distance.js";
|
|
4
|
+
import { assertNativeSolidStatus } from "./native/status.js";
|
|
3
5
|
/** Internal host-bound inspection operations. */
|
|
4
6
|
export function createInspectionOperations(environment) {
|
|
5
|
-
const { scope, throwGeometryError, nativeSolid,
|
|
7
|
+
const { scope, throwGeometryError, nativeSolid, resolveHandle, ownSolid, ownSection, validateFiniteNumber, requirePositiveNumber, validateCoordinates, analyzedMeshFor, solidInspectionFor, sectionInspectionFor, } = environment;
|
|
6
8
|
const operations = {
|
|
7
9
|
toTriangleMesh: (value) => copiedMeshViews(analyzedMeshFor(value).mesh, new Map()),
|
|
8
|
-
inspectSection:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
parts: parts.length,
|
|
16
|
-
vertices: native.numVert(),
|
|
17
|
-
};
|
|
18
|
-
},
|
|
19
|
-
inspectSolid: (handle) => {
|
|
20
|
-
const native = nativeSolid(handle), bounds = native.boundingBox();
|
|
21
|
-
const parts = native.decompose().map((part) => ownScopedManifoldValue(scope, part));
|
|
22
|
-
return { ...bounds, parts: parts.length, volume: native.volume() };
|
|
10
|
+
inspectSection: sectionInspectionFor,
|
|
11
|
+
inspectSolid: solidInspectionFor,
|
|
12
|
+
isEmpty: (handle) => {
|
|
13
|
+
const native = resolveHandle(handle);
|
|
14
|
+
if (native.kind === "solid")
|
|
15
|
+
assertNativeSolidStatus(native.value, "isEmpty");
|
|
16
|
+
return native.value.isEmpty();
|
|
23
17
|
},
|
|
18
|
+
distance: (handle, other) => measureSolidDistance(nativeSolid(handle), nativeSolid(other)),
|
|
24
19
|
rayCast: (handle, origin, endpoint) => {
|
|
25
20
|
validateCoordinates(origin, 3, "rayCast");
|
|
26
21
|
validateCoordinates(endpoint, 3, "rayCast");
|
|
@@ -36,12 +31,21 @@ export function createInspectionOperations(environment) {
|
|
|
36
31
|
normal: Object.freeze([...hit.normal]),
|
|
37
32
|
})));
|
|
38
33
|
},
|
|
39
|
-
components: (handle) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
components: ((handle) => {
|
|
35
|
+
const native = resolveHandle(handle);
|
|
36
|
+
if (native.kind === "section")
|
|
37
|
+
return Object.freeze(native.value.isEmpty() ? [] : native.value.decompose().map(ownSection));
|
|
38
|
+
assertNativeSolidStatus(native.value, "components");
|
|
39
|
+
return Object.freeze(native.value.decompose().map(ownSolid));
|
|
40
|
+
}),
|
|
41
|
+
simplify: ((handle, tolerance) => {
|
|
42
|
+
const native = resolveHandle(handle);
|
|
43
|
+
const distance = requirePositiveNumber(tolerance, "simplify");
|
|
44
|
+
if (native.kind === "section")
|
|
45
|
+
return ownSection(native.value.simplify(distance));
|
|
46
|
+
const original = ownScopedManifoldValue(scope, native.value.asOriginal());
|
|
47
|
+
return ownSolid(original.simplify(distance));
|
|
48
|
+
}),
|
|
45
49
|
setTolerance: (handle, tolerance) => {
|
|
46
50
|
if (validateFiniteNumber(tolerance, "setTolerance") < 0)
|
|
47
51
|
throwGeometryError("INVALID_ARGUMENT", "setTolerance", "Tolerance must be nonnegative millimetres.");
|
|
@@ -143,7 +143,7 @@ export function createLoftOperation(environment, solidFromMesh) {
|
|
|
143
143
|
const geometry = prepareLoftGeometry(input, selected, selected.limits, authoritative, recipeSampled);
|
|
144
144
|
if (geometry.stations.every((station) => station.profile.components.length === 1 &&
|
|
145
145
|
station.profile.components[0].holes.length === 0))
|
|
146
|
-
return solidFromMesh(simpleLoftMesh(geometry, selected.limits
|
|
146
|
+
return solidFromMesh(simpleLoftMesh(geometry, selected.limits));
|
|
147
147
|
const mesh = buildProfileLoftMesh(environment.module, geometry.stations, selected.limits, false, 0, (station, contour, slot) => geometry.world[station][contour][slot]);
|
|
148
148
|
if (measureTriangleMesh(mesh).signedVolume < 0)
|
|
149
149
|
for (let index = 0; index < mesh.indices.length; index += 3) {
|
|
@@ -2,15 +2,12 @@ import * as kernel from "./native/index.js";
|
|
|
2
2
|
/** Internal host-bound mechanical operations. */
|
|
3
3
|
export function createMechanicalOperations(environment) {
|
|
4
4
|
const { resolution } = environment;
|
|
5
|
-
const { scope, assertActive, wrapSolid,
|
|
5
|
+
const { scope, assertActive, wrapSolid, withResolutionDefaults } = environment;
|
|
6
6
|
const nutOptions = (value) => ({
|
|
7
7
|
...withResolutionDefaults(value),
|
|
8
8
|
bodyResolution: { ...resolution, ...value.bodyResolution },
|
|
9
9
|
});
|
|
10
|
-
|
|
11
|
-
const vertices = (preflight.estimatedRows + 2) * preflight.circularSegments;
|
|
12
|
-
assertMeshAllocation(vertices, preflight.estimatedTriangles, (vertices + preflight.estimatedTriangles) * 12, operation);
|
|
13
|
-
};
|
|
10
|
+
// Explicit preflight preserves the stable error operation names exposed by these constructors.
|
|
14
11
|
const operations = {
|
|
15
12
|
threadRidge: (value) => {
|
|
16
13
|
assertActive();
|
|
@@ -19,19 +16,19 @@ export function createMechanicalOperations(environment) {
|
|
|
19
16
|
threadedRod: (value) => {
|
|
20
17
|
assertActive();
|
|
21
18
|
const resolved = withResolutionDefaults(value);
|
|
22
|
-
|
|
19
|
+
kernel.preflightThreadedRod(resolved);
|
|
23
20
|
return wrapSolid(kernel.threadedRod(scope, resolved));
|
|
24
21
|
},
|
|
25
22
|
internalThreadCutter: (value) => {
|
|
26
23
|
assertActive();
|
|
27
24
|
const resolved = withResolutionDefaults(value);
|
|
28
|
-
|
|
25
|
+
kernel.preflightInternalThreadCutter(resolved);
|
|
29
26
|
return wrapSolid(kernel.internalThreadCutter(scope, resolved));
|
|
30
27
|
},
|
|
31
28
|
threadedNut: (value) => {
|
|
32
29
|
assertActive();
|
|
33
30
|
const resolved = nutOptions(value);
|
|
34
|
-
|
|
31
|
+
kernel.preflightThreadedNut(resolved);
|
|
35
32
|
return wrapSolid(kernel.threadedNut(scope, resolved));
|
|
36
33
|
},
|
|
37
34
|
preflightThreadedRod: (value) => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @internal Operation-local facts for identical privately owned mesh buffer ranges. */
|
|
2
|
+
export class MeshViewCache {
|
|
3
|
+
positions = new Map();
|
|
4
|
+
key(mesh) {
|
|
5
|
+
return `${mesh.positions.byteOffset}:${mesh.positions.length}:${mesh.indices.byteOffset}:${mesh.indices.length}`;
|
|
6
|
+
}
|
|
7
|
+
get(mesh) {
|
|
8
|
+
return this.positions.get(mesh.positions.buffer)?.get(mesh.indices.buffer)?.get(this.key(mesh));
|
|
9
|
+
}
|
|
10
|
+
set(mesh, value) {
|
|
11
|
+
let indices = this.positions.get(mesh.positions.buffer);
|
|
12
|
+
if (indices === undefined) {
|
|
13
|
+
indices = new Map();
|
|
14
|
+
this.positions.set(mesh.positions.buffer, indices);
|
|
15
|
+
}
|
|
16
|
+
let ranges = indices.get(mesh.indices.buffer);
|
|
17
|
+
if (ranges === undefined) {
|
|
18
|
+
ranges = new Map();
|
|
19
|
+
indices.set(mesh.indices.buffer, ranges);
|
|
20
|
+
}
|
|
21
|
+
ranges.set(this.key(mesh), value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { validateTriangleMesh } from "../../geometry/mesh.js";
|
|
2
|
+
import { assertNativeSolidStatus } from "./status.js";
|
|
2
3
|
/**
|
|
3
4
|
* Constructs a live Manifold solid from a copied indexed triangle mesh.
|
|
4
5
|
*
|
|
@@ -12,9 +13,6 @@ export const triangleMeshToManifold = (module, mesh, scope) => {
|
|
|
12
13
|
triVerts: mesh.indices,
|
|
13
14
|
});
|
|
14
15
|
const manifold = scope.own(new module.Manifold(manifoldMesh));
|
|
15
|
-
|
|
16
|
-
if (status !== "NoError") {
|
|
17
|
-
throw new Error(`Manifold rejected generated mesh: ${status}.`);
|
|
18
|
-
}
|
|
16
|
+
assertNativeSolidStatus(manifold, "solidFromMesh");
|
|
19
17
|
return manifold;
|
|
20
18
|
};
|
|
@@ -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 }),
|
|
@@ -1,50 +1,91 @@
|
|
|
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.
|
|
48
89
|
*
|
|
49
90
|
* @example Sample a closed wavy mesh
|
|
50
91
|
* ```ts
|
|
@@ -55,11 +96,13 @@ export function preflightHeightfield(options, limits = {}) {
|
|
|
55
96
|
* ```
|
|
56
97
|
*/
|
|
57
98
|
export function heightfieldMesh(options, limits = {}) {
|
|
58
|
-
const
|
|
99
|
+
const plan = planHeightfield(options, limits);
|
|
100
|
+
const { facts, x: nx, y: ny } = plan;
|
|
59
101
|
const operation = "heightfieldMesh";
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
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;
|
|
63
106
|
const baseZ = Math.fround(options.baseZ ?? 0);
|
|
64
107
|
if (!Number.isFinite(baseZ))
|
|
65
108
|
throw new GeometryError("NON_FINITE_VALUE", "Heightfield base must fit Float32 storage.", {
|
|
@@ -68,13 +111,16 @@ export function heightfieldMesh(options, limits = {}) {
|
|
|
68
111
|
const positions = new Float32Array(facts.vertexCount * 3);
|
|
69
112
|
const indices = new Uint32Array(facts.triangleCount * 3);
|
|
70
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
|
+
};
|
|
71
120
|
for (let y = 0; y <= ny; y += 1) {
|
|
72
121
|
for (let x = 0; x <= nx; x += 1) {
|
|
73
|
-
const point = [
|
|
74
|
-
const height =
|
|
75
|
-
if (!Number.isFinite(height) || !(height > baseZ)) {
|
|
76
|
-
throw new GeometryError("INVALID_ARGUMENT", "Every finite Float32 height must be strictly above the Float32 base.", { operation, details: { x, y } });
|
|
77
|
-
}
|
|
122
|
+
const point = [xs[x], ys[y]];
|
|
123
|
+
const height = sampleHeight(point);
|
|
78
124
|
writeMeshVertex(positions, grid(x, y) * 3, [point[0], point[1], height], operation);
|
|
79
125
|
}
|
|
80
126
|
}
|
|
@@ -87,8 +133,21 @@ export function heightfieldMesh(options, limits = {}) {
|
|
|
87
133
|
for (let y = 0; y < ny; y += 1) {
|
|
88
134
|
for (let x = 0; x < nx; x += 1) {
|
|
89
135
|
const a = grid(x, y), b = grid(x + 1, y), c = grid(x + 1, y + 1), d = grid(x, y + 1);
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
|
92
151
|
}
|
|
93
152
|
}
|
|
94
153
|
const boundary = [];
|
|
@@ -102,7 +161,7 @@ export function heightfieldMesh(options, limits = {}) {
|
|
|
102
161
|
boundary.push(grid(0, y));
|
|
103
162
|
const baseStart = facts.sampleCount;
|
|
104
163
|
const baseCenter = facts.vertexCount - 1;
|
|
105
|
-
writeMeshVertex(positions, baseCenter * 3, [
|
|
164
|
+
writeMeshVertex(positions, baseCenter * 3, [(xs[0] + xs[nx]) / 2, (ys[0] + ys[ny]) / 2, baseZ], operation);
|
|
106
165
|
for (let offset = 0; offset < boundary.length; offset += 1) {
|
|
107
166
|
const top = boundary[offset];
|
|
108
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 &&
|