@metamesh/modeling 0.10.0 → 0.12.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +70 -17
  3. package/dist/api/engine.d.ts +105 -8
  4. package/dist/api/index.d.ts +755 -771
  5. package/dist/engine/construction-operations.js +3 -1
  6. package/dist/engine/deferred-context.js +182 -0
  7. package/dist/engine/execution.js +38 -4
  8. package/dist/engine/finalization.js +65 -133
  9. package/dist/engine/geometry-recipes.js +12 -0
  10. package/dist/engine/immutable-generation-data.js +216 -0
  11. package/dist/engine/index.js +57 -35
  12. package/dist/engine/kernel/manifold.wasm +0 -0
  13. package/dist/engine/model-dependencies.js +46 -0
  14. package/dist/engine/model-summary.js +43 -0
  15. package/dist/engine/native/ownership.js +11 -5
  16. package/dist/engine/placement.js +24 -0
  17. package/dist/engine/recipe-realization.js +119 -0
  18. package/dist/engine/session-graph.js +447 -0
  19. package/dist/engine/session-native.js +90 -0
  20. package/dist/engine/session-snapshot.js +153 -0
  21. package/dist/engine/session.js +184 -0
  22. package/dist/engine/shape-anchors.js +5 -0
  23. package/dist/geometry/mesh-centroid.js +7 -6
  24. package/dist/geometry/mesh-planar.js +11 -10
  25. package/dist/geometry/mesh-quality.js +15 -14
  26. package/dist/geometry/mesh-signature.js +8 -7
  27. package/dist/geometry/mesh-solid.js +10 -4
  28. package/dist/geometry/mesh.js +36 -31
  29. package/dist/index.js +2 -0
  30. package/dist/model/execution-binding.js +5 -5
  31. package/dist/model/generation.js +27 -0
  32. package/dist/model/immutable-buffer.js +111 -0
  33. package/dist/model/modeling-context.js +1 -0
  34. package/dist/model/operations.js +521 -160
  35. package/dist/model/shape-handles.js +44 -0
  36. 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 facts = analyzeTriangleMesh(mesh, { requireClosed: true });
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 < 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];
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(mesh, options);
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(mesh.positions, firstUse.start);
320
- const endPoint = pointAt(mesh.positions, firstUse.end);
321
- const firstPoint = pointAt(mesh.positions, oppositeFirst);
322
- const secondPoint = pointAt(mesh.positions, oppositeSecond);
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(mesh.positions, replacementFirst[1]), firstPoint), subtract(pointAt(mesh.positions, replacementFirst[2]), firstPoint)), normal);
339
- const replacementSecondArea = dot(cross(subtract(pointAt(mesh.positions, replacementSecond[1]), firstPoint), subtract(pointAt(mesh.positions, replacementSecond[2]), firstPoint)), normal);
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(mesh.positions, firstTriangle, secondTriangle), pairQuality(mesh.positions, replacementFirst, replacementSecond), before.tolerance.angular);
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(mesh.indices.length);
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: mesh.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(mesh, maximumTriangleCount);
240
- const { bounds, extent } = meshBoundsAndExtent(mesh.positions);
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 = mesh.positions.length / 3;
245
- const triangleCount = mesh.indices.length / 3;
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 < mesh.indices.length; offset += 3) {
257
+ for (let offset = 0; offset < source.indices.length; offset += 3) {
257
258
  const vertices = [
258
- mesh.indices[offset],
259
- mesh.indices[offset + 1],
260
- mesh.indices[offset + 2],
259
+ source.indices[offset],
260
+ source.indices[offset + 1],
261
+ source.indices[offset + 2],
261
262
  ];
262
- const face = faceAnalysis(mesh.positions, vertices, offset / 3);
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(mesh.positions, vertices[0]);
273
- const second = pointAt(mesh.positions, vertices[1]);
274
- const third = pointAt(mesh.positions, vertices[2]);
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], mesh.positions, planarAngularTolerance, planarLinearTolerance)) {
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], mesh.positions, planarAngularTolerance, planarLinearTolerance)) {
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 (!(mesh.positions instanceof Float32Array) || !(mesh.indices instanceof Uint32Array)) {
82
- validateTriangleMesh(mesh, { requireClosed: true });
82
+ if (!(source.positions instanceof Float32Array) || !(source.indices instanceof Uint32Array)) {
83
+ validateTriangleMesh(source, { requireClosed: true });
83
84
  }
84
- const triangleCount = mesh.indices.length / 3;
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(mesh, { requireClosed: true, includeTopology: true });
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(mesh.positions, vertex, positionTolerance));
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 < mesh.indices.length; offset += 3) {
108
- triangleKeys.push(canonicalOrientedTriangleKey(coordinateKeys[mesh.indices[offset]], coordinateKeys[mesh.indices[offset + 1]], coordinateKeys[mesh.indices[offset + 2]]));
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 work = validationWork(mesh, options);
54
- const measurements = analyzeTriangleMesh(mesh, {
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(mesh, measurements, options, work);
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
- checkSolidGeometry(mesh, measurements, options, validationWork(mesh, options));
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;
@@ -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 (!(mesh.positions instanceof Float32Array) || !(mesh.indices instanceof Uint32Array)) {
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 (mesh.positions.length % 3 !== 0) {
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 (mesh.indices.length % 3 !== 0) {
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 = mesh.positions.length / 3;
18
- if ((vertexCount === 0 || mesh.indices.length === 0) &&
19
- !(options.allowEmpty && vertexCount === 0 && mesh.indices.length === 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 < mesh.positions.length; offset += 3) {
26
+ for (let offset = 0; offset < source.positions.length; offset += 3) {
25
27
  for (let axis = 0; axis < 3; axis += 1) {
26
- const coordinate = mesh.positions[offset + axis];
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(mesh.indices.length, topology)
39
+ ? new IndexedMeshEdgeTable(source.indices.length, topology)
38
40
  : null;
39
- const triangleCount = mesh.indices.length / 3;
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 = mesh.positions[0] ?? 0, originY = mesh.positions[1] ?? 0, originZ = mesh.positions[2] ?? 0;
77
- for (let offset = 0; offset < mesh.indices.length; offset += 3) {
78
- const a = mesh.indices[offset], b = mesh.indices[offset + 1], c = mesh.indices[offset + 2];
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 = mesh.positions[a * 3], ay = mesh.positions[a * 3 + 1], az = mesh.positions[a * 3 + 2];
86
- const abx = mesh.positions[b * 3] - ax, aby = mesh.positions[b * 3 + 1] - ay, abz = mesh.positions[b * 3 + 2] - az;
87
- const acx = mesh.positions[c * 3] - ax, acy = mesh.positions[c * 3 + 1] - ay, acz = mesh.positions[c * 3 + 2] - az;
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
- if (meshes.length === 0) {
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
- meshes.forEach((mesh) => validateTriangleMesh(mesh));
213
- const positionLength = meshes.reduce((sum, mesh) => sum + mesh.positions.length, 0);
214
- const indexLength = meshes.reduce((sum, mesh) => sum + mesh.indices.length, 0);
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 meshes) {
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
- validateTriangleMesh(mesh);
242
- const indices = new Uint32Array(mesh.indices);
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(mesh.positions) };
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
- validateTriangleMesh(mesh);
265
+ const source = { positions: mesh.positions, indices: mesh.indices };
266
+ validateTriangleMesh(source);
262
267
  validateTransform3(transform);
263
- const positions = new Float32Array(mesh.positions.length);
264
- for (let offset = 0; offset < mesh.positions.length; offset += 3) {
265
- const x = mesh.positions[offset];
266
- const y = mesh.positions[offset + 1];
267
- const z = mesh.positions[offset + 2];
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(mesh.indices);
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 +1,3 @@
1
1
  export * from "./model/operations.js";
2
+ export * from "./model/generation.js";
3
+ export { immutableBuffer, immutableMesh } from "./model/immutable-buffer.js";
@@ -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/2");
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: 2, context };
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 only the current live build; imported functions carry no native state themselves. */
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 !== 2) {
25
- throw new GeometryError("INVALID_ARGUMENT", "Modeling functions can only run inside build().", {
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
+ }