@metamesh/modeling 0.10.0 → 0.11.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 +40 -0
- package/README.md +56 -17
- package/dist/api/engine.d.ts +105 -8
- package/dist/api/index.d.ts +754 -771
- package/dist/engine/construction-operations.js +3 -1
- package/dist/engine/deferred-context.js +182 -0
- package/dist/engine/execution.js +38 -4
- package/dist/engine/finalization.js +65 -133
- package/dist/engine/geometry-recipes.js +12 -0
- package/dist/engine/immutable-generation-data.js +186 -0
- package/dist/engine/index.js +57 -35
- package/dist/engine/kernel/manifold.wasm +0 -0
- package/dist/engine/model-dependencies.js +46 -0
- package/dist/engine/model-summary.js +43 -0
- package/dist/engine/native/ownership.js +11 -5
- package/dist/engine/placement.js +24 -0
- package/dist/engine/recipe-realization.js +119 -0
- package/dist/engine/session-graph.js +442 -0
- package/dist/engine/session-native.js +90 -0
- package/dist/engine/session-snapshot.js +153 -0
- package/dist/engine/session.js +184 -0
- package/dist/engine/shape-anchors.js +5 -0
- package/dist/geometry/mesh-centroid.js +7 -6
- package/dist/geometry/mesh-planar.js +11 -10
- package/dist/geometry/mesh-quality.js +15 -14
- package/dist/geometry/mesh-signature.js +8 -7
- package/dist/geometry/mesh-solid.js +10 -4
- package/dist/geometry/mesh.js +36 -31
- package/dist/index.js +2 -0
- package/dist/model/execution-binding.js +5 -5
- package/dist/model/generation.js +27 -0
- package/dist/model/immutable-buffer.js +111 -0
- package/dist/model/modeling-context.js +1 -0
- package/dist/model/operations.js +521 -160
- package/dist/model/shape-handles.js +44 -0
- package/package.json +4 -4
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { GeometryError } from "../geometry/errors.js";
|
|
2
|
+
import { resolveModelingLimits } from "../geometry/model-limits.js";
|
|
3
|
+
import { bindModelContext } from "../model/execution-binding.js";
|
|
4
|
+
import { createDeferredContext } from "./deferred-context.js";
|
|
5
|
+
import { captureGenerationValue, generationValueBytes } from "./immutable-generation-data.js";
|
|
6
|
+
import { createGenerationGraph } from "./session-graph.js";
|
|
7
|
+
import { createNativeGeometryCache } from "./session-native.js";
|
|
8
|
+
import { createSnapshotCache } from "./session-snapshot.js";
|
|
9
|
+
function invalid(message) {
|
|
10
|
+
throw new GeometryError("INVALID_ARGUMENT", message, { operation: "generate" });
|
|
11
|
+
}
|
|
12
|
+
/** Coordinates native transactions and bounded atomic cache commits; snapshots own their lifetime. */
|
|
13
|
+
export function createSession(program, suppliedOptions, engineLimits, transaction) {
|
|
14
|
+
const options = Object.freeze({
|
|
15
|
+
...suppliedOptions,
|
|
16
|
+
...(suppliedOptions.resolution === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: { resolution: captureGenerationValue(suppliedOptions.resolution) }),
|
|
19
|
+
...(suppliedOptions.limits === undefined
|
|
20
|
+
? {}
|
|
21
|
+
: { limits: captureGenerationValue(suppliedOptions.limits) }),
|
|
22
|
+
});
|
|
23
|
+
if (program?.kind !== "metamesh-model" || typeof program.generate !== "function")
|
|
24
|
+
invalid("Expected a generate() model program.");
|
|
25
|
+
const limits = resolveModelingLimits(options.limits, engineLimits);
|
|
26
|
+
const owner = {};
|
|
27
|
+
// Capabilities may be class instances with private state or nonenumerable methods. The host
|
|
28
|
+
// owns their fixed lifetime; copying their enumerable fields would change their semantics.
|
|
29
|
+
const services = (options.services ?? Object.freeze({}));
|
|
30
|
+
const caches = {
|
|
31
|
+
graph: createGenerationGraph(options, limits.maxParts),
|
|
32
|
+
snapshots: createSnapshotCache(options, limits),
|
|
33
|
+
geometry: createNativeGeometryCache(),
|
|
34
|
+
clear() {
|
|
35
|
+
this.graph.clear();
|
|
36
|
+
this.snapshots.clear();
|
|
37
|
+
this.geometry.clear();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const context = { owner, program, services, options, limits, caches };
|
|
41
|
+
let disposed = false;
|
|
42
|
+
let running = false;
|
|
43
|
+
function execute(input, mode) {
|
|
44
|
+
if (disposed)
|
|
45
|
+
invalid("This model session has been disposed.");
|
|
46
|
+
if (running)
|
|
47
|
+
invalid("Model generation cannot nest.");
|
|
48
|
+
if (typeof input !== "object" || input === null || Array.isArray(input))
|
|
49
|
+
invalid("Parameters must be a plain object.");
|
|
50
|
+
running = true;
|
|
51
|
+
const revision = new SessionRevision(context);
|
|
52
|
+
try {
|
|
53
|
+
const completed = transaction(options, (native) => revision.execute(input, native, mode));
|
|
54
|
+
if (completed.kind === "validation")
|
|
55
|
+
return completed.diagnostics;
|
|
56
|
+
// Nothing becomes reusable until geometry validation AND native cleanup have succeeded.
|
|
57
|
+
revision.commit();
|
|
58
|
+
return completed.snapshot;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
try {
|
|
62
|
+
revision.dispose();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
running = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
run: (params) => execute(params, "run"),
|
|
71
|
+
validate: (params) => execute(params, "validate"),
|
|
72
|
+
clearCache() {
|
|
73
|
+
if (running)
|
|
74
|
+
invalid("Cannot clear a running session.");
|
|
75
|
+
caches.clear();
|
|
76
|
+
},
|
|
77
|
+
dispose() {
|
|
78
|
+
if (running)
|
|
79
|
+
invalid("Cannot dispose a running session.");
|
|
80
|
+
disposed = true;
|
|
81
|
+
caches.clear();
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Owns one attempted revision, including provisional retained native allocations. Declaration and capture
|
|
87
|
+
* happen inside the native transaction; commit is allowed only after that transaction cleans up.
|
|
88
|
+
* Disposal invalidates readers and releases any native allocations that were not committed.
|
|
89
|
+
*/
|
|
90
|
+
class SessionRevision {
|
|
91
|
+
session;
|
|
92
|
+
revision;
|
|
93
|
+
cacheCandidate;
|
|
94
|
+
nativeDepth = 0;
|
|
95
|
+
constructor(session) {
|
|
96
|
+
this.session = session;
|
|
97
|
+
}
|
|
98
|
+
execute(input, native, mode) {
|
|
99
|
+
const { owner, options, limits, caches } = this.session;
|
|
100
|
+
const revision = caches.graph.begin(input, () => this.assertDeclarationAllowed());
|
|
101
|
+
this.revision = revision;
|
|
102
|
+
const deferred = createDeferredContext(owner, () => native().context, limits, options.resolution ?? {}, (callback) => this.evaluateNative(callback), revision.assertActive, (shape) => {
|
|
103
|
+
const retained = caches.geometry.get(shape);
|
|
104
|
+
if (retained !== undefined)
|
|
105
|
+
return native().restoreNative(retained);
|
|
106
|
+
return undefined;
|
|
107
|
+
});
|
|
108
|
+
return bindModelContext(deferred.context, () => this.generate(revision, deferred, native, mode));
|
|
109
|
+
}
|
|
110
|
+
generate(revision, deferred, native, mode) {
|
|
111
|
+
const { program, services, caches } = this.session;
|
|
112
|
+
const startedAt = performance.now();
|
|
113
|
+
const output = program.generate({ ...revision.context, services });
|
|
114
|
+
revision.finish(output);
|
|
115
|
+
if (mode === "validate")
|
|
116
|
+
return { kind: "validation", diagnostics: Object.freeze([...revision.diagnostics]) };
|
|
117
|
+
const captured = caches.snapshots.capture({
|
|
118
|
+
output,
|
|
119
|
+
native,
|
|
120
|
+
deferred,
|
|
121
|
+
startedAt,
|
|
122
|
+
builtAt: performance.now(),
|
|
123
|
+
diagnostics: revision.diagnostics,
|
|
124
|
+
readDependencies: () => revision.dependencies(output),
|
|
125
|
+
});
|
|
126
|
+
this.prepareNativeCache(revision, captured, deferred, native);
|
|
127
|
+
return { kind: "snapshot", snapshot: captured.snapshot };
|
|
128
|
+
}
|
|
129
|
+
/** Retention is optional: an over-budget revision still produces a valid uncached snapshot. */
|
|
130
|
+
prepareNativeCache(revision, captured, deferred, native) {
|
|
131
|
+
const { options, limits, caches } = this.session;
|
|
132
|
+
if (options.cache === false)
|
|
133
|
+
return;
|
|
134
|
+
const retainedBytes = generationValueBytes([...revision.retainedValues(), ...captured.retainedValues], limits.maxBytes);
|
|
135
|
+
if (retainedBytes > limits.maxBytes)
|
|
136
|
+
return;
|
|
137
|
+
const nativeCandidate = caches.geometry.prepare(deferred.retentionCandidates(revision.computedValues()), (shape) => {
|
|
138
|
+
const realized = deferred.realizedShape(shape);
|
|
139
|
+
if (realized !== undefined)
|
|
140
|
+
return native().captureNative(realized);
|
|
141
|
+
return undefined;
|
|
142
|
+
}, (entry) => native().detachNative(entry), limits.maxBytes - retainedBytes);
|
|
143
|
+
this.cacheCandidate = { graph: revision, snapshot: captured, native: nativeCandidate };
|
|
144
|
+
}
|
|
145
|
+
/** Native replacement can fail during eviction; no other cache may survive that failure. */
|
|
146
|
+
commit() {
|
|
147
|
+
try {
|
|
148
|
+
const candidate = this.cacheCandidate;
|
|
149
|
+
if (candidate === undefined) {
|
|
150
|
+
this.session.caches.clear();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
candidate.native.commit();
|
|
154
|
+
candidate.graph.commit();
|
|
155
|
+
candidate.snapshot.commit();
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
this.session.caches.clear();
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
dispose() {
|
|
163
|
+
this.revision?.close();
|
|
164
|
+
this.revision = undefined;
|
|
165
|
+
// A cached recipe callback can retain the revision guard. Drop staged snapshot references
|
|
166
|
+
// even after commit, when the native candidate no longer owns provisional allocations.
|
|
167
|
+
const candidate = this.cacheCandidate;
|
|
168
|
+
this.cacheCandidate = undefined;
|
|
169
|
+
candidate?.native.dispose();
|
|
170
|
+
}
|
|
171
|
+
assertDeclarationAllowed() {
|
|
172
|
+
if (this.nativeDepth > 0)
|
|
173
|
+
invalid("Reactive reads and Modeling calls are not allowed in late numerical callbacks. Capture immutable inputs while declaring the recipe.");
|
|
174
|
+
}
|
|
175
|
+
evaluateNative(callback) {
|
|
176
|
+
this.nativeDepth++;
|
|
177
|
+
try {
|
|
178
|
+
return callback();
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
this.nativeDepth--;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -54,6 +54,11 @@ export function createShapeAnchorOwner(assertShape, aliasShape) {
|
|
|
54
54
|
};
|
|
55
55
|
return {
|
|
56
56
|
remember,
|
|
57
|
+
/** Private immutable metadata retained with a session-owned native shape. */
|
|
58
|
+
read(shape) {
|
|
59
|
+
assertShape(shape);
|
|
60
|
+
return anchors.get(shape);
|
|
61
|
+
},
|
|
57
62
|
anchor(shape, name) {
|
|
58
63
|
assertShape(shape);
|
|
59
64
|
anchorName(name, "anchor");
|
|
@@ -10,16 +10,17 @@ import { analyzeTriangleMesh } from "./mesh.js";
|
|
|
10
10
|
* @returns A fresh immutable XYZ volume centroid.
|
|
11
11
|
*/
|
|
12
12
|
export function computeTriangleMeshCentroid(mesh) {
|
|
13
|
-
const
|
|
13
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
14
|
+
const facts = analyzeTriangleMesh(source, { requireClosed: true });
|
|
14
15
|
if (!(facts.signedVolume > 0))
|
|
15
16
|
throw new GeometryError("INVALID_TOPOLOGY", "A volume centroid requires positive oriented volume.", { operation: "computeTriangleMeshCentroid" });
|
|
16
17
|
const origin = facts.bounds.min;
|
|
17
18
|
const totals = [0, 0, 0], corrections = [0, 0, 0];
|
|
18
|
-
for (let offset = 0; offset <
|
|
19
|
-
const a =
|
|
20
|
-
const ax =
|
|
21
|
-
const bx =
|
|
22
|
-
const cx =
|
|
19
|
+
for (let offset = 0; offset < source.indices.length; offset += 3) {
|
|
20
|
+
const a = source.indices[offset] * 3, b = source.indices[offset + 1] * 3, c = source.indices[offset + 2] * 3;
|
|
21
|
+
const ax = source.positions[a] - origin[0], ay = source.positions[a + 1] - origin[1], az = source.positions[a + 2] - origin[2];
|
|
22
|
+
const bx = source.positions[b] - origin[0], by = source.positions[b + 1] - origin[1], bz = source.positions[b + 2] - origin[2];
|
|
23
|
+
const cx = source.positions[c] - origin[0], cy = source.positions[c + 1] - origin[1], cz = source.positions[c + 2] - origin[2];
|
|
23
24
|
const volume = (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6;
|
|
24
25
|
const coordinates = [(ax + bx + cx) / 4, (ay + by + cy) / 4, (az + bz + cz) / 4];
|
|
25
26
|
for (let axis = 0; axis < 3; axis++) {
|
|
@@ -175,8 +175,9 @@ const fallbackReasonForCandidate = (before, after, boundaryEdgeKeys) => {
|
|
|
175
175
|
* @returns Optimized connectivity and before/after diagnostics, or an unchanged safe fallback.
|
|
176
176
|
*/
|
|
177
177
|
export function optimizePlanarPatches(mesh, options = {}) {
|
|
178
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
178
179
|
const resolved = resolveOptions(options);
|
|
179
|
-
const before = buildTriangleMeshQualityAnalysis(
|
|
180
|
+
const before = buildTriangleMeshQualityAnalysis(source, options);
|
|
180
181
|
if (!before.report.isClosedOrientedManifold) {
|
|
181
182
|
return unchangedResult(mesh, before.report, 0, false, "input-not-closed-oriented-manifold");
|
|
182
183
|
}
|
|
@@ -316,10 +317,10 @@ export function optimizePlanarPatches(mesh, options = {}) {
|
|
|
316
317
|
const patchId = before.patchByFace[firstUse.face];
|
|
317
318
|
const patch = before.patches[patchId];
|
|
318
319
|
const normal = patch.report.normal;
|
|
319
|
-
const startPoint = pointAt(
|
|
320
|
-
const endPoint = pointAt(
|
|
321
|
-
const firstPoint = pointAt(
|
|
322
|
-
const secondPoint = pointAt(
|
|
320
|
+
const startPoint = pointAt(source.positions, firstUse.start);
|
|
321
|
+
const endPoint = pointAt(source.positions, firstUse.end);
|
|
322
|
+
const firstPoint = pointAt(source.positions, oppositeFirst);
|
|
323
|
+
const secondPoint = pointAt(source.positions, oppositeSecond);
|
|
323
324
|
const sideOfReplacementFirst = dot(cross(subtract(secondPoint, firstPoint), subtract(startPoint, firstPoint)), normal);
|
|
324
325
|
const sideOfReplacementSecond = dot(cross(subtract(secondPoint, firstPoint), subtract(endPoint, firstPoint)), normal);
|
|
325
326
|
const sideOfCurrentFirst = dot(cross(subtract(endPoint, startPoint), subtract(firstPoint, startPoint)), normal);
|
|
@@ -335,12 +336,12 @@ export function optimizePlanarPatches(mesh, options = {}) {
|
|
|
335
336
|
}
|
|
336
337
|
const replacementFirst = [oppositeFirst, firstUse.start, oppositeSecond];
|
|
337
338
|
const replacementSecond = [oppositeFirst, oppositeSecond, firstUse.end];
|
|
338
|
-
const replacementFirstArea = dot(cross(subtract(pointAt(
|
|
339
|
-
const replacementSecondArea = dot(cross(subtract(pointAt(
|
|
339
|
+
const replacementFirstArea = dot(cross(subtract(pointAt(source.positions, replacementFirst[1]), firstPoint), subtract(pointAt(source.positions, replacementFirst[2]), firstPoint)), normal);
|
|
340
|
+
const replacementSecondArea = dot(cross(subtract(pointAt(source.positions, replacementSecond[1]), firstPoint), subtract(pointAt(source.positions, replacementSecond[2]), firstPoint)), normal);
|
|
340
341
|
if (replacementFirstArea <= minimumDoubleArea || replacementSecondArea <= minimumDoubleArea) {
|
|
341
342
|
continue;
|
|
342
343
|
}
|
|
343
|
-
const qualityComparison = comparePairQuality(pairQuality(
|
|
344
|
+
const qualityComparison = comparePairQuality(pairQuality(source.positions, firstTriangle, secondTriangle), pairQuality(source.positions, replacementFirst, replacementSecond), before.tolerance.angular);
|
|
344
345
|
if (qualityComparison < 0)
|
|
345
346
|
continue;
|
|
346
347
|
if (qualityComparison === 0) {
|
|
@@ -385,11 +386,11 @@ export function optimizePlanarPatches(mesh, options = {}) {
|
|
|
385
386
|
if (flipCount === 0) {
|
|
386
387
|
return unchangedResult(mesh, before.report, skippedPatchCount, budgetExhausted);
|
|
387
388
|
}
|
|
388
|
-
const indices = new Uint32Array(
|
|
389
|
+
const indices = new Uint32Array(source.indices.length);
|
|
389
390
|
for (let faceIndex = 0; faceIndex < triangles.length; faceIndex += 1) {
|
|
390
391
|
indices.set(triangles[faceIndex], faceIndex * 3);
|
|
391
392
|
}
|
|
392
|
-
const candidate = { indices, positions:
|
|
393
|
+
const candidate = { indices, positions: source.positions };
|
|
393
394
|
const after = buildTriangleMeshQualityAnalysis(candidate, options);
|
|
394
395
|
const fallbackReason = fallbackReasonForCandidate(before, after, boundaryEdgeKeys);
|
|
395
396
|
if (fallbackReason !== undefined) {
|
|
@@ -235,14 +235,15 @@ const buildPatchAnalysis = (faces, edges, faceIndices, id) => {
|
|
|
235
235
|
};
|
|
236
236
|
/** @internal */
|
|
237
237
|
export function buildTriangleMeshQualityAnalysis(mesh, options = {}) {
|
|
238
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
238
239
|
const maximumTriangleCount = positiveInteger(options.maximumTriangleCount ?? DEFAULT_MAXIMUM_TRIANGLE_COUNT, "Maximum analyzed triangle count");
|
|
239
|
-
structurallyValidateMesh(
|
|
240
|
-
const { bounds, extent } = meshBoundsAndExtent(
|
|
240
|
+
structurallyValidateMesh(source, maximumTriangleCount);
|
|
241
|
+
const { bounds, extent } = meshBoundsAndExtent(source.positions);
|
|
241
242
|
const tolerance = resolveGeometryTolerance(extent, options.tolerance);
|
|
242
243
|
const planarLinearTolerance = Math.max(tolerance.linear, Math.max(extent, 1) * FLOAT32_EPSILON * 8);
|
|
243
244
|
const planarAngularTolerance = Math.max(tolerance.angular, MINIMUM_PLANAR_ANGULAR_TOLERANCE);
|
|
244
|
-
const vertexCount =
|
|
245
|
-
const triangleCount =
|
|
245
|
+
const vertexCount = source.positions.length / 3;
|
|
246
|
+
const triangleCount = source.indices.length / 3;
|
|
246
247
|
const neighbourSets = Array.from({ length: vertexCount }, () => new Set());
|
|
247
248
|
const vertexTriangleIncidence = new Uint32Array(vertexCount);
|
|
248
249
|
const mutableEdges = new Map();
|
|
@@ -253,13 +254,13 @@ export function buildTriangleMeshQualityAnalysis(mesh, options = {}) {
|
|
|
253
254
|
let nearZeroAreaTriangleCount = 0;
|
|
254
255
|
let minimumTriangleAngleRadians = Number.POSITIVE_INFINITY;
|
|
255
256
|
let maximumTriangleAspectRatio = 0;
|
|
256
|
-
for (let offset = 0; offset <
|
|
257
|
+
for (let offset = 0; offset < source.indices.length; offset += 3) {
|
|
257
258
|
const vertices = [
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
source.indices[offset],
|
|
260
|
+
source.indices[offset + 1],
|
|
261
|
+
source.indices[offset + 2],
|
|
261
262
|
];
|
|
262
|
-
const face = faceAnalysis(
|
|
263
|
+
const face = faceAnalysis(source.positions, vertices, offset / 3);
|
|
263
264
|
faces.push(face);
|
|
264
265
|
surfaceArea += face.area;
|
|
265
266
|
minimumTriangleAngleRadians = Math.min(minimumTriangleAngleRadians, face.minimumAngleRadians);
|
|
@@ -269,9 +270,9 @@ export function buildTriangleMeshQualityAnalysis(mesh, options = {}) {
|
|
|
269
270
|
face.normal === undefined) {
|
|
270
271
|
nearZeroAreaTriangleCount += 1;
|
|
271
272
|
}
|
|
272
|
-
const first = pointAt(
|
|
273
|
-
const second = pointAt(
|
|
274
|
-
const third = pointAt(
|
|
273
|
+
const first = pointAt(source.positions, vertices[0]);
|
|
274
|
+
const second = pointAt(source.positions, vertices[1]);
|
|
275
|
+
const third = pointAt(source.positions, vertices[2]);
|
|
275
276
|
signedVolume +=
|
|
276
277
|
(first[0] * (second[1] * third[2] - second[2] * third[1]) +
|
|
277
278
|
first[1] * (second[2] * third[0] - second[0] * third[2]) +
|
|
@@ -305,7 +306,7 @@ export function buildTriangleMeshQualityAnalysis(mesh, options = {}) {
|
|
|
305
306
|
continue;
|
|
306
307
|
const first = edge.uses[0].face;
|
|
307
308
|
const second = edge.uses[1].face;
|
|
308
|
-
if (facesAreCoplanar(faces[first], faces[second],
|
|
309
|
+
if (facesAreCoplanar(faces[first], faces[second], source.positions, planarAngularTolerance, planarLinearTolerance)) {
|
|
309
310
|
adjacency[first].push(second);
|
|
310
311
|
adjacency[second].push(first);
|
|
311
312
|
}
|
|
@@ -325,7 +326,7 @@ export function buildTriangleMeshQualityAnalysis(mesh, options = {}) {
|
|
|
325
326
|
for (const candidate of adjacency[faceIndex]) {
|
|
326
327
|
if (patchByFace[candidate] !== -1)
|
|
327
328
|
continue;
|
|
328
|
-
if (!facesAreCoplanar(faces[seed], faces[candidate],
|
|
329
|
+
if (!facesAreCoplanar(faces[seed], faces[candidate], source.positions, planarAngularTolerance, planarLinearTolerance)) {
|
|
329
330
|
continue;
|
|
330
331
|
}
|
|
331
332
|
patchByFace[candidate] = id;
|
|
@@ -71,6 +71,7 @@ const hashCanonicalKeys = (header, groups) => {
|
|
|
71
71
|
* @throws {@link GeometryError} When validation, topology, tolerance, or complexity checks fail.
|
|
72
72
|
*/
|
|
73
73
|
export function createTriangleMeshSignature(mesh, options = {}) {
|
|
74
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
74
75
|
const maximumTriangleCount = options.maximumTriangleCount ?? DEFAULT_MAXIMUM_TRIANGLE_COUNT;
|
|
75
76
|
if (!Number.isSafeInteger(maximumTriangleCount) || maximumTriangleCount <= 0) {
|
|
76
77
|
throw new GeometryError("INVALID_ARGUMENT", "maximumTriangleCount must be a positive safe integer.", {
|
|
@@ -78,10 +79,10 @@ export function createTriangleMeshSignature(mesh, options = {}) {
|
|
|
78
79
|
details: { maximumTriangleCount },
|
|
79
80
|
});
|
|
80
81
|
}
|
|
81
|
-
if (!(
|
|
82
|
-
validateTriangleMesh(
|
|
82
|
+
if (!(source.positions instanceof Float32Array) || !(source.indices instanceof Uint32Array)) {
|
|
83
|
+
validateTriangleMesh(source, { requireClosed: true });
|
|
83
84
|
}
|
|
84
|
-
const triangleCount =
|
|
85
|
+
const triangleCount = source.indices.length / 3;
|
|
85
86
|
if (triangleCount > maximumTriangleCount) {
|
|
86
87
|
throw new GeometryError("COMPLEXITY_LIMIT", `Mesh signature analysis is limited to ${maximumTriangleCount} triangles; received ${triangleCount}.`, {
|
|
87
88
|
operation: "createTriangleMeshSignature",
|
|
@@ -90,7 +91,7 @@ export function createTriangleMeshSignature(mesh, options = {}) {
|
|
|
90
91
|
}
|
|
91
92
|
const positionTolerance = validatePositiveTolerance(options.positionTolerance ?? DEFAULT_POSITION_TOLERANCE, "positionTolerance");
|
|
92
93
|
const volumeTolerance = validatePositiveTolerance(options.volumeTolerance ?? DEFAULT_VOLUME_TOLERANCE, "volumeTolerance");
|
|
93
|
-
const measured = analyzeTriangleMesh(
|
|
94
|
+
const measured = analyzeTriangleMesh(source, { requireClosed: true, includeTopology: true });
|
|
94
95
|
const connectedComponentCount = measured.connectedComponentCount;
|
|
95
96
|
const edgeCount = measured.edgeCount;
|
|
96
97
|
const eulerCharacteristic = measured.vertexCount - edgeCount + measured.triangleCount;
|
|
@@ -101,11 +102,11 @@ export function createTriangleMeshSignature(mesh, options = {}) {
|
|
|
101
102
|
details: { connectedComponentCount, edgeCount, eulerCharacteristic },
|
|
102
103
|
});
|
|
103
104
|
}
|
|
104
|
-
const coordinateKeys = Array.from({ length: measured.vertexCount }, (_, vertex) => coordinateKey(
|
|
105
|
+
const coordinateKeys = Array.from({ length: measured.vertexCount }, (_, vertex) => coordinateKey(source.positions, vertex, positionTolerance));
|
|
105
106
|
const vertexKeys = [...coordinateKeys].sort();
|
|
106
107
|
const triangleKeys = [];
|
|
107
|
-
for (let offset = 0; offset <
|
|
108
|
-
triangleKeys.push(canonicalOrientedTriangleKey(coordinateKeys[
|
|
108
|
+
for (let offset = 0; offset < source.indices.length; offset += 3) {
|
|
109
|
+
triangleKeys.push(canonicalOrientedTriangleKey(coordinateKeys[source.indices[offset]], coordinateKeys[source.indices[offset + 1]], coordinateKeys[source.indices[offset + 2]]));
|
|
109
110
|
}
|
|
110
111
|
triangleKeys.sort();
|
|
111
112
|
const quantizeBounds = ({ min, max }) => Object.freeze({
|
|
@@ -50,11 +50,14 @@ function validationWork(mesh, options) {
|
|
|
50
50
|
}
|
|
51
51
|
/** @internal Fresh measurements precede one adjacency traversal for closure, fans, and shells. */
|
|
52
52
|
export function analyzeSolidMesh(mesh, options = {}) {
|
|
53
|
-
const
|
|
54
|
-
|
|
53
|
+
const source = typeof mesh === "object" && mesh !== null
|
|
54
|
+
? { positions: mesh.positions, indices: mesh.indices }
|
|
55
|
+
: mesh;
|
|
56
|
+
const work = validationWork(source, options);
|
|
57
|
+
const measurements = analyzeTriangleMesh(source, {
|
|
55
58
|
...(options.tolerance === undefined ? {} : { tolerance: options.tolerance }),
|
|
56
59
|
});
|
|
57
|
-
checkSolidGeometry(
|
|
60
|
+
checkSolidGeometry(source, measurements, options, work);
|
|
58
61
|
return measurements;
|
|
59
62
|
}
|
|
60
63
|
/**
|
|
@@ -63,7 +66,10 @@ export function analyzeSolidMesh(mesh, options = {}) {
|
|
|
63
66
|
* Never accepts public cached facts; public validation always analyzes mutable input afresh.
|
|
64
67
|
*/
|
|
65
68
|
export function validateAnalyzedSolidMesh(mesh, measurements, options = {}) {
|
|
66
|
-
|
|
69
|
+
const source = typeof mesh === "object" && mesh !== null
|
|
70
|
+
? { positions: mesh.positions, indices: mesh.indices }
|
|
71
|
+
: mesh;
|
|
72
|
+
checkSolidGeometry(source, measurements, options, validationWork(source, options));
|
|
67
73
|
}
|
|
68
74
|
function checkSolidGeometry(mesh, facts, options, { count, maximumTests }) {
|
|
69
75
|
const epsilon = resolveGeometryTolerance(Math.hypot(...facts.bounds.max.map((value, axis) => value - facts.bounds.min[axis])), options.tolerance).linear;
|
package/dist/geometry/mesh.js
CHANGED
|
@@ -4,26 +4,28 @@ import { writeMeshVertex } from "./numeric.js";
|
|
|
4
4
|
import { resolveGeometryTolerance } from "./tolerance.js";
|
|
5
5
|
import { transformDeterminant3, validateTransform3 } from "./transform3.js";
|
|
6
6
|
function inspectTriangleMesh(mesh, options = {}, mode = "validate", topology = false) {
|
|
7
|
+
// Snapshot meshes can expose owned-copy accessors. Read each buffer once before traversal.
|
|
8
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
7
9
|
const measurements = mode === "measure";
|
|
8
|
-
if (!(
|
|
10
|
+
if (!(source.positions instanceof Float32Array) || !(source.indices instanceof Uint32Array)) {
|
|
9
11
|
throw new GeometryError("INVALID_MESH", "Triangle meshes require Float32Array positions and Uint32Array indices.");
|
|
10
12
|
}
|
|
11
|
-
if (
|
|
13
|
+
if (source.positions.length % 3 !== 0) {
|
|
12
14
|
throw new GeometryError("INVALID_MESH", "Mesh positions must contain complete XYZ vertices.");
|
|
13
15
|
}
|
|
14
|
-
if (
|
|
16
|
+
if (source.indices.length % 3 !== 0) {
|
|
15
17
|
throw new GeometryError("INVALID_MESH", "Mesh indices must contain complete triangles.");
|
|
16
18
|
}
|
|
17
|
-
const vertexCount =
|
|
18
|
-
if ((vertexCount === 0 ||
|
|
19
|
-
!(options.allowEmpty && vertexCount === 0 &&
|
|
19
|
+
const vertexCount = source.positions.length / 3;
|
|
20
|
+
if ((vertexCount === 0 || source.indices.length === 0) &&
|
|
21
|
+
!(options.allowEmpty && vertexCount === 0 && source.indices.length === 0)) {
|
|
20
22
|
throw new GeometryError("INVALID_MESH", "A triangle mesh must contain vertices and faces.");
|
|
21
23
|
}
|
|
22
24
|
const min = [Infinity, Infinity, Infinity];
|
|
23
25
|
const max = [-Infinity, -Infinity, -Infinity];
|
|
24
|
-
for (let offset = 0; offset <
|
|
26
|
+
for (let offset = 0; offset < source.positions.length; offset += 3) {
|
|
25
27
|
for (let axis = 0; axis < 3; axis += 1) {
|
|
26
|
-
const coordinate =
|
|
28
|
+
const coordinate = source.positions[offset + axis];
|
|
27
29
|
if (!Number.isFinite(coordinate)) {
|
|
28
30
|
throw new GeometryError("NON_FINITE_VALUE", `Mesh position ${offset + axis} must be finite.`);
|
|
29
31
|
}
|
|
@@ -34,9 +36,9 @@ function inspectTriangleMesh(mesh, options = {}, mode = "validate", topology = f
|
|
|
34
36
|
const extent = vertexCount === 0 ? 0 : Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
|
|
35
37
|
const minimumDoubleArea = resolveGeometryTolerance(extent, options.tolerance).area;
|
|
36
38
|
const edges = options.requireClosed || topology
|
|
37
|
-
? new IndexedMeshEdgeTable(
|
|
39
|
+
? new IndexedMeshEdgeTable(source.indices.length, topology)
|
|
38
40
|
: null;
|
|
39
|
-
const triangleCount =
|
|
41
|
+
const triangleCount = source.indices.length / 3;
|
|
40
42
|
const parents = topology ? new Int32Array(triangleCount) : null;
|
|
41
43
|
const ranks = topology ? new Uint8Array(triangleCount) : null;
|
|
42
44
|
const referenced = topology ? new Uint8Array(vertexCount) : null;
|
|
@@ -73,18 +75,18 @@ function inspectTriangleMesh(mesh, options = {}, mode = "validate", topology = f
|
|
|
73
75
|
};
|
|
74
76
|
// Keep the two hot-loop accumulators independent and local: no mutable numeric objects escape.
|
|
75
77
|
let surfaceArea = 0, areaCorrection = 0, signedVolume = 0, volumeCorrection = 0;
|
|
76
|
-
const originX =
|
|
77
|
-
for (let offset = 0; offset <
|
|
78
|
-
const a =
|
|
78
|
+
const originX = source.positions[0] ?? 0, originY = source.positions[1] ?? 0, originZ = source.positions[2] ?? 0;
|
|
79
|
+
for (let offset = 0; offset < source.indices.length; offset += 3) {
|
|
80
|
+
const a = source.indices[offset], b = source.indices[offset + 1], c = source.indices[offset + 2];
|
|
79
81
|
if (a >= vertexCount || b >= vertexCount || c >= vertexCount) {
|
|
80
82
|
throw new GeometryError("INVALID_MESH", `Triangle ${offset / 3} references a missing vertex.`);
|
|
81
83
|
}
|
|
82
84
|
if (a === b || b === c || c === a) {
|
|
83
85
|
throw new GeometryError("DEGENERATE_GEOMETRY", `Triangle ${offset / 3} repeats a vertex.`);
|
|
84
86
|
}
|
|
85
|
-
const ax =
|
|
86
|
-
const abx =
|
|
87
|
-
const acx =
|
|
87
|
+
const ax = source.positions[a * 3], ay = source.positions[a * 3 + 1], az = source.positions[a * 3 + 2];
|
|
88
|
+
const abx = source.positions[b * 3] - ax, aby = source.positions[b * 3 + 1] - ay, abz = source.positions[b * 3 + 2] - az;
|
|
89
|
+
const acx = source.positions[c * 3] - ax, acy = source.positions[c * 3 + 1] - ay, acz = source.positions[c * 3 + 2] - az;
|
|
88
90
|
const crossX = aby * acz - abz * acy, crossY = abz * acx - abx * acz, crossZ = abx * acy - aby * acx;
|
|
89
91
|
const doubleArea = Math.hypot(crossX, crossY, crossZ);
|
|
90
92
|
if (doubleArea <= minimumDoubleArea) {
|
|
@@ -206,12 +208,13 @@ export function measureTriangleMesh(mesh, options = {}) {
|
|
|
206
208
|
* @throws {@link GeometryError} When no mesh is supplied or an input is invalid.
|
|
207
209
|
*/
|
|
208
210
|
export function joinTriangleMeshes(meshes) {
|
|
209
|
-
|
|
211
|
+
const sources = meshes.map(({ positions, indices }) => ({ positions, indices }));
|
|
212
|
+
if (sources.length === 0) {
|
|
210
213
|
throw new GeometryError("INVALID_ARGUMENT", "Joining meshes requires at least one mesh.");
|
|
211
214
|
}
|
|
212
|
-
|
|
213
|
-
const positionLength =
|
|
214
|
-
const indexLength =
|
|
215
|
+
sources.forEach((mesh) => validateTriangleMesh(mesh));
|
|
216
|
+
const positionLength = sources.reduce((sum, mesh) => sum + mesh.positions.length, 0);
|
|
217
|
+
const indexLength = sources.reduce((sum, mesh) => sum + mesh.indices.length, 0);
|
|
215
218
|
if (positionLength / 3 > 0xffff_ffff) {
|
|
216
219
|
throw new GeometryError("INVALID_MESH", "Joined mesh exceeds the Uint32 vertex index range.");
|
|
217
220
|
}
|
|
@@ -220,7 +223,7 @@ export function joinTriangleMeshes(meshes) {
|
|
|
220
223
|
let positionOffset = 0;
|
|
221
224
|
let indexOffset = 0;
|
|
222
225
|
let vertexOffset = 0;
|
|
223
|
-
for (const mesh of
|
|
226
|
+
for (const mesh of sources) {
|
|
224
227
|
positions.set(mesh.positions, positionOffset);
|
|
225
228
|
for (let index = 0; index < mesh.indices.length; index += 1) {
|
|
226
229
|
indices[indexOffset + index] = mesh.indices[index] + vertexOffset;
|
|
@@ -238,14 +241,15 @@ export function joinTriangleMeshes(meshes) {
|
|
|
238
241
|
* @returns Fresh position and index buffers; the input remains unchanged.
|
|
239
242
|
*/
|
|
240
243
|
export function reverseTriangleMesh(mesh) {
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
245
|
+
validateTriangleMesh(source);
|
|
246
|
+
const indices = new Uint32Array(source.indices);
|
|
243
247
|
for (let offset = 0; offset < indices.length; offset += 3) {
|
|
244
248
|
const second = indices[offset + 1];
|
|
245
249
|
indices[offset + 1] = indices[offset + 2];
|
|
246
250
|
indices[offset + 2] = second;
|
|
247
251
|
}
|
|
248
|
-
return { indices, positions: new Float32Array(
|
|
252
|
+
return { indices, positions: new Float32Array(source.positions) };
|
|
249
253
|
}
|
|
250
254
|
/**
|
|
251
255
|
* Applies an affine transform to a mesh and preserves outward face orientation.
|
|
@@ -258,20 +262,21 @@ export function reverseTriangleMesh(mesh) {
|
|
|
258
262
|
* @returns A mesh with fresh transformed positions and copied or reversed indices.
|
|
259
263
|
*/
|
|
260
264
|
export function transformTriangleMesh(mesh, transform) {
|
|
261
|
-
|
|
265
|
+
const source = { positions: mesh.positions, indices: mesh.indices };
|
|
266
|
+
validateTriangleMesh(source);
|
|
262
267
|
validateTransform3(transform);
|
|
263
|
-
const positions = new Float32Array(
|
|
264
|
-
for (let offset = 0; offset <
|
|
265
|
-
const x =
|
|
266
|
-
const y =
|
|
267
|
-
const z =
|
|
268
|
+
const positions = new Float32Array(source.positions.length);
|
|
269
|
+
for (let offset = 0; offset < source.positions.length; offset += 3) {
|
|
270
|
+
const x = source.positions[offset];
|
|
271
|
+
const y = source.positions[offset + 1];
|
|
272
|
+
const z = source.positions[offset + 2];
|
|
268
273
|
writeMeshVertex(positions, offset, [
|
|
269
274
|
transform[0] * x + transform[1] * y + transform[2] * z + transform[3],
|
|
270
275
|
transform[4] * x + transform[5] * y + transform[6] * z + transform[7],
|
|
271
276
|
transform[8] * x + transform[9] * y + transform[10] * z + transform[11],
|
|
272
277
|
], "transformTriangleMesh");
|
|
273
278
|
}
|
|
274
|
-
const indices = new Uint32Array(
|
|
279
|
+
const indices = new Uint32Array(source.indices);
|
|
275
280
|
if (transformDeterminant3(transform) < 0) {
|
|
276
281
|
for (let offset = 0; offset < indices.length; offset += 3) {
|
|
277
282
|
const second = indices[offset + 1];
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { GeometryError } from "../geometry/errors.js";
|
|
2
2
|
// One protocol key lets locally bundled operation wrappers resolve the host's execution
|
|
3
3
|
// without depending on package instances, class identity, or initializing a native runtime.
|
|
4
|
-
const executionKey = Symbol.for("@metamesh/modeling/execution/
|
|
4
|
+
const executionKey = Symbol.for("@metamesh/modeling/execution/4");
|
|
5
5
|
const bindings = globalThis;
|
|
6
6
|
/** Internal host binding. Executable callbacks are synchronous and may not nest executions. */
|
|
7
7
|
export function bindModelContext(context, build) {
|
|
@@ -10,7 +10,7 @@ export function bindModelContext(context, build) {
|
|
|
10
10
|
operation: "build",
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
|
-
bindings[executionKey] = { version:
|
|
13
|
+
bindings[executionKey] = { version: 4, context };
|
|
14
14
|
try {
|
|
15
15
|
return build();
|
|
16
16
|
}
|
|
@@ -18,11 +18,11 @@ export function bindModelContext(context, build) {
|
|
|
18
18
|
delete bindings[executionKey];
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
/** Resolves
|
|
21
|
+
/** Resolves the private realm protocol; imported functions carry no native state themselves. */
|
|
22
22
|
export function currentModelContext(operation) {
|
|
23
23
|
const binding = bindings[executionKey];
|
|
24
|
-
if (binding?.version !==
|
|
25
|
-
throw new GeometryError("INVALID_ARGUMENT", "Modeling functions can only run inside
|
|
24
|
+
if (binding?.version !== 4) {
|
|
25
|
+
throw new GeometryError("INVALID_ARGUMENT", "Modeling functions can only run inside generate().", {
|
|
26
26
|
operation,
|
|
27
27
|
});
|
|
28
28
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A user-correctable assertion failure. Successful computation caches never retain failures. */
|
|
2
|
+
export class GenerationError extends Error {
|
|
3
|
+
/** Issues reached in normal JavaScript order, ending with the first failed assertion. */
|
|
4
|
+
diagnostics;
|
|
5
|
+
/** Captures immutable diagnostics; unexpected author errors retain their original type. */
|
|
6
|
+
constructor(diagnostics) {
|
|
7
|
+
super(diagnostics.at(-1)?.message ?? "Generation failed.");
|
|
8
|
+
this.name = "GenerationError";
|
|
9
|
+
this.diagnostics = Object.freeze(diagnostics.map((issue) => Object.freeze({ ...issue })));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Defines a typed model without executing it. Use `computed` only for work worth reusing.
|
|
14
|
+
*
|
|
15
|
+
* @example A simple model
|
|
16
|
+
* ```ts
|
|
17
|
+
* const model = generate<{ height: number }>(({ params, part, assert }) => {
|
|
18
|
+
* assert(params.height > 0, "Height must be positive.");
|
|
19
|
+
* return [part("body", box({ size: [10, 10, params.height] }))];
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function generate(callback) {
|
|
24
|
+
if (typeof callback !== "function")
|
|
25
|
+
throw new TypeError("generate requires a callback.");
|
|
26
|
+
return Object.freeze({ kind: "metamesh-model", generate: callback });
|
|
27
|
+
}
|