@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,447 @@
1
+ import { GeometryError } from "../geometry/errors.js";
2
+ import { GenerationError, } from "../model/generation.js";
3
+ import { captureGenerationDependencies, captureGenerationValue, compareGenerationValues, generationDataEntries, } from "./immutable-generation-data.js";
4
+ import { createModelDependencies } from "./model-dependencies.js";
5
+ import { assertSynchronousScopeResult } from "./native/ownership.js";
6
+ function reusableEntry(node) {
7
+ if (node.state.kind === "ready")
8
+ return node.state.entry;
9
+ if (node.state.kind === "pending")
10
+ return node.previous;
11
+ return undefined;
12
+ }
13
+ const parameterKeys = Symbol("parameter-keys");
14
+ const emptyInputs = Object.freeze([]);
15
+ const isArray = (value) => Array.isArray(value);
16
+ const canonicalId = (id) => typeof id === "string" && id.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id);
17
+ function invalid(message) {
18
+ throw new GeometryError("INVALID_ARGUMENT", message, { operation: "generate" });
19
+ }
20
+ const sameInputs = (left, right) => left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
21
+ function evaluateComputed(callback) {
22
+ const result = callback();
23
+ assertSynchronousScopeResult(result, () => new GeometryError("INVALID_ARGUMENT", "Computed callbacks must be synchronous.", {
24
+ operation: "computed",
25
+ }));
26
+ return captureGenerationValue(result);
27
+ }
28
+ /** Reuse immutable captures only when the host supplies the same input value. */
29
+ function captureParameters(input, previousInput, previousSnapshot) {
30
+ return Object.freeze(Object.fromEntries(Array.from(generationDataEntries(input), ([key, value]) => {
31
+ if (Object.is(value, previousInput[key]) && Object.hasOwn(previousSnapshot, key))
32
+ return [key, previousSnapshot[key]];
33
+ return [key, captureGenerationValue(value)];
34
+ })));
35
+ }
36
+ /**
37
+ * Owns one revision's lazy computations and dynamic read sets. A failed read stays failed for the
38
+ * attempt; only the surrounding generation revision can promote successful entries to the cache.
39
+ */
40
+ class ComputationRevision {
41
+ options;
42
+ parameterSnapshot;
43
+ committed;
44
+ readerIds;
45
+ validatedInputs;
46
+ assertActive;
47
+ assertNoFailedAssertion;
48
+ nodes = new Map();
49
+ keySignature;
50
+ stack = [];
51
+ constructor(options, parameterSnapshot, committed, readerIds, validatedInputs, assertActive, assertNoFailedAssertion) {
52
+ this.options = options;
53
+ this.parameterSnapshot = parameterSnapshot;
54
+ this.committed = committed;
55
+ this.readerIds = readerIds;
56
+ this.validatedInputs = validatedInputs;
57
+ this.assertActive = assertActive;
58
+ this.assertNoFailedAssertion = assertNoFailedAssertion;
59
+ // Structural reads share one unambiguous signature, even when parameter names contain NUL.
60
+ this.keySignature = JSON.stringify(Object.keys(parameterSnapshot));
61
+ }
62
+ get evaluating() {
63
+ return this.stack.length > 0;
64
+ }
65
+ currentEvaluation() {
66
+ const current = this.stack.at(-1);
67
+ if (current === undefined)
68
+ return undefined;
69
+ const state = this.nodes.get(current)?.state;
70
+ return state?.kind === "evaluating" ? state : undefined;
71
+ }
72
+ dispose() {
73
+ // Retained numerical callbacks may keep an expired parameter guard alive. That guard must
74
+ // not retain unrelated callbacks or chain together every previous computation cache.
75
+ this.nodes.clear();
76
+ this.committed = new Map();
77
+ }
78
+ register(id, callback, dependencies = emptyInputs) {
79
+ if (!canonicalId(id) || this.nodes.has(id))
80
+ invalid("Computed IDs must be unique canonical lowercase identifiers of at most 64 characters.");
81
+ if (this.nodes.size >= 100_000)
82
+ throw new GeometryError("COMPLEXITY_LIMIT", "Too many declared computations.", {
83
+ operation: "computed",
84
+ });
85
+ if (typeof callback !== "function" || !Array.isArray(dependencies))
86
+ invalid("Use computed(id, callback, dependencies?) with an optional dependency array.");
87
+ const inputs = dependencies === emptyInputs
88
+ ? emptyInputs
89
+ : captureGenerationDependencies(dependencies, (value) => this.readerIds.has(value), this.validatedInputs);
90
+ const previous = this.committed.get(id);
91
+ this.nodes.set(id, {
92
+ callback,
93
+ inputs,
94
+ previous,
95
+ state: { kind: "pending" },
96
+ });
97
+ }
98
+ /** A handled dependency failure must still prevent the caller's fallback from becoming reusable. */
99
+ read = (id) => {
100
+ this.assertActive();
101
+ const parent = this.currentEvaluation();
102
+ try {
103
+ const node = this.nodes.get(id);
104
+ if (node === undefined)
105
+ invalid(`Computed '${id}' must be declared before it is read.`);
106
+ const cycle = this.stack.indexOf(id);
107
+ if (cycle >= 0)
108
+ invalid(`Computed dependency cycle: ${[...this.stack.slice(cycle), id].join(" -> ")}.`);
109
+ if (node.state.kind === "failed")
110
+ throw node.state.error;
111
+ const entry = node.state.kind === "ready" ? node.state.entry : this.evaluateNode(id, node);
112
+ if (parent !== undefined) {
113
+ parent.dependencies.set(id, entry.token);
114
+ if (!entry.cacheable)
115
+ parent.cacheable = false;
116
+ }
117
+ return entry.value;
118
+ }
119
+ catch (error) {
120
+ if (parent !== undefined)
121
+ parent.cacheable = false;
122
+ throw error;
123
+ }
124
+ };
125
+ /** Validate old reads, evaluate or reuse the output, and publish exactly one local outcome. */
126
+ evaluateNode(id, node) {
127
+ const evaluation = {
128
+ kind: "evaluating",
129
+ parameters: new Map(),
130
+ dependencies: new Map(),
131
+ cacheable: true,
132
+ };
133
+ node.state = evaluation;
134
+ this.stack.push(id);
135
+ try {
136
+ const previous = node.previous;
137
+ const reusable = previous !== undefined && this.canReuse(node, previous);
138
+ let entry;
139
+ if (reusable && this.options.verifyCache !== true) {
140
+ entry = evaluation.cacheable ? previous : Object.freeze({ ...previous, cacheable: false });
141
+ }
142
+ else {
143
+ entry = this.evaluateCallback(id, node, evaluation, reusable);
144
+ }
145
+ node.state = { kind: "ready", entry };
146
+ return entry;
147
+ }
148
+ catch (error) {
149
+ // Neither partial dependencies nor an earlier value may escape a failed evaluation.
150
+ node.state = { kind: "failed", error };
151
+ throw error;
152
+ }
153
+ finally {
154
+ this.stack.pop();
155
+ }
156
+ }
157
+ canReuse(node, previous) {
158
+ if (this.options.cache === false || !sameInputs(node.inputs, previous.inputs))
159
+ return false;
160
+ for (const [key, value] of previous.parameters) {
161
+ const current = key === parameterKeys ? this.keySignature : Reflect.get(this.parameterSnapshot, key);
162
+ if (!Object.is(current, value))
163
+ return false;
164
+ }
165
+ for (const [dependency, token] of previous.dependencies) {
166
+ if (!this.nodes.has(dependency))
167
+ return false;
168
+ try {
169
+ this.read(dependency);
170
+ }
171
+ catch {
172
+ // Let the callback decide whether a failed dependency propagates or uses a fallback.
173
+ return false;
174
+ }
175
+ const state = this.nodes.get(dependency).state;
176
+ if (state.kind !== "ready" || state.entry.token !== token)
177
+ return false;
178
+ }
179
+ return true;
180
+ }
181
+ evaluateCallback(id, node, evaluation, reusable) {
182
+ // Validation may read old branches. Only callback reads describe the successful output,
183
+ // whether this is a cache miss or a development verification.
184
+ evaluation.parameters.clear();
185
+ evaluation.dependencies.clear();
186
+ evaluation.cacheable = true;
187
+ // Declared readers depend on their output, not just their stable callable identity.
188
+ for (const input of node.inputs) {
189
+ const dependency = typeof input === "function" ? this.readerIds.get(input) : undefined;
190
+ if (dependency !== undefined) {
191
+ try {
192
+ this.read(dependency);
193
+ }
194
+ catch {
195
+ // Failed reads already prevent reuse; the callback owns propagation or fallback.
196
+ }
197
+ }
198
+ }
199
+ const result = evaluateComputed(node.callback);
200
+ this.assertNoFailedAssertion();
201
+ const previous = node.previous;
202
+ let value = result;
203
+ if (reusable && previous !== undefined) {
204
+ const comparison = compareGenerationValues(previous.value, result);
205
+ if (comparison === "different")
206
+ invalid(`Cache verification for '${id}' found a changed result without a tracked dependency. Add changing outer values to the dependency array.`);
207
+ if (comparison === "unsupported")
208
+ invalid(`Cache verification for '${id}' cannot compare this result within its bounded data/recipe probe. Use cache: false for a full output equivalence check.`);
209
+ value = previous.value;
210
+ }
211
+ return Object.freeze({
212
+ inputs: node.inputs,
213
+ parameters: evaluation.parameters,
214
+ dependencies: evaluation.dependencies,
215
+ value,
216
+ cacheable: evaluation.cacheable,
217
+ // Tokens identify successful outputs, including new registrations of removed IDs. A per-node
218
+ // counter could repeat an old dependency's version and serve stale data.
219
+ token: previous !== undefined && Object.is(previous.value, value) ? previous.token : {},
220
+ });
221
+ }
222
+ }
223
+ /** Owns computed identities and per-revision reads; the session commits only successful revisions. */
224
+ export function createGenerationGraph(options, maxParts) {
225
+ let committed = new Map();
226
+ const readers = new Map();
227
+ const readerIds = new WeakMap();
228
+ const validatedInputs = new WeakSet();
229
+ let activeRead;
230
+ let previousInput = {};
231
+ let previousParameterSnapshot = {};
232
+ // Build stable readers outside registration's lexical scope so they cannot retain a revision's
233
+ // callback, captured locals, or dependency inputs after that revision closes.
234
+ const createReader = (id) => (() => {
235
+ if (activeRead === undefined)
236
+ return invalid("Computed readers can only be used during their active model session revision.");
237
+ return activeRead(id);
238
+ });
239
+ return {
240
+ begin(input, checkReactiveRead) {
241
+ let active = true;
242
+ let declaring = true;
243
+ let assertionFailure;
244
+ const diagnostics = [];
245
+ const partIds = new Set();
246
+ const declaredParts = new WeakSet();
247
+ const modelParameters = new Set();
248
+ const inputValues = input;
249
+ const parameterSnapshot = captureParameters(inputValues, previousInput, previousParameterSnapshot);
250
+ const assertActive = () => {
251
+ if (!active)
252
+ invalid("The generation context has expired.");
253
+ checkReactiveRead();
254
+ };
255
+ const assertNoFailedAssertion = () => {
256
+ if (assertionFailure !== undefined)
257
+ throw assertionFailure;
258
+ };
259
+ const computations = new ComputationRevision(options, parameterSnapshot, committed, readerIds, validatedInputs, assertActive, assertNoFailedAssertion);
260
+ const params = new Proxy(parameterSnapshot, {
261
+ get: (target, key) => {
262
+ assertActive();
263
+ const value = Reflect.get(target, key);
264
+ const current = computations.currentEvaluation();
265
+ if (current !== undefined && typeof key === "string")
266
+ current.parameters.set(key, value);
267
+ else if (typeof key === "string" && Object.hasOwn(target, key))
268
+ modelParameters.add(key);
269
+ return value;
270
+ },
271
+ set: () => invalid("Parameters are immutable."),
272
+ deleteProperty: () => invalid("Parameters are immutable."),
273
+ ownKeys: (target) => {
274
+ assertActive();
275
+ const current = computations.currentEvaluation();
276
+ if (current !== undefined)
277
+ current.parameters.set(parameterKeys, computations.keySignature);
278
+ else
279
+ for (const key of Object.keys(target))
280
+ modelParameters.add(key);
281
+ return Reflect.ownKeys(target);
282
+ },
283
+ has: (target, key) => {
284
+ assertActive();
285
+ const current = computations.currentEvaluation();
286
+ if (current !== undefined)
287
+ current.parameters.set(parameterKeys, computations.keySignature);
288
+ else if (typeof key === "string")
289
+ modelParameters.add(key);
290
+ return Reflect.has(target, key);
291
+ },
292
+ getOwnPropertyDescriptor: (target, key) => {
293
+ assertActive();
294
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
295
+ const current = computations.currentEvaluation();
296
+ if (current !== undefined) {
297
+ const dependencies = current.parameters;
298
+ dependencies.set(parameterKeys, computations.keySignature);
299
+ if (typeof key === "string")
300
+ dependencies.set(key, descriptor?.value);
301
+ }
302
+ else if (typeof key === "string")
303
+ modelParameters.add(key);
304
+ return descriptor;
305
+ },
306
+ });
307
+ activeRead = computations.read;
308
+ const computed = ((id, callback, dependencies) => {
309
+ assertActive();
310
+ if (!declaring || computations.evaluating)
311
+ invalid("Declare computations only in the outer generation callback.");
312
+ computations.register(id, callback, dependencies);
313
+ let reader = readers.get(id);
314
+ if (reader === undefined) {
315
+ reader = createReader(id);
316
+ readers.set(id, reader);
317
+ readerIds.set(reader, id);
318
+ }
319
+ return reader;
320
+ });
321
+ const issue = (severity, message, metadata) => {
322
+ assertActive();
323
+ if (typeof message !== "string" || message.length === 0)
324
+ invalid("Diagnostics require a nonempty message.");
325
+ if (metadata?.parameterId !== undefined &&
326
+ !Object.hasOwn(parameterSnapshot, metadata.parameterId))
327
+ invalid("Diagnostic parameterId must name an existing parameter.");
328
+ diagnostics.push(Object.freeze({
329
+ severity,
330
+ message,
331
+ ...(metadata?.parameterId === undefined ? {} : { parameterId: metadata.parameterId }),
332
+ }));
333
+ if (severity === "error") {
334
+ assertionFailure ??= new GenerationError(diagnostics);
335
+ throw assertionFailure;
336
+ }
337
+ };
338
+ const part = ((id, value) => {
339
+ assertActive();
340
+ if (!declaring || computations.evaluating)
341
+ invalid("Declare parts only in the outer generation callback.");
342
+ if (!canonicalId(id) || partIds.has(id))
343
+ invalid("Part IDs must be unique canonical lowercase identifiers of at most 64 characters.");
344
+ partIds.add(id);
345
+ const configuration = typeof value === "object" && value !== null && "geometry" in value
346
+ ? value
347
+ : { geometry: value };
348
+ for (const value of [
349
+ configuration.geometry,
350
+ configuration.position,
351
+ configuration.rotation,
352
+ ])
353
+ if (typeof value === "function" && !readerIds.has(value))
354
+ invalid("Part readers must belong to this model session.");
355
+ const descriptor = Object.freeze({ ...configuration, id });
356
+ declaredParts.add(descriptor);
357
+ return descriptor;
358
+ });
359
+ function assert(condition, message, metadata) {
360
+ assertActive();
361
+ if (typeof condition !== "boolean")
362
+ invalid("assert requires a boolean condition.");
363
+ if (!condition)
364
+ issue("error", message, metadata);
365
+ }
366
+ return {
367
+ context: {
368
+ params,
369
+ computed,
370
+ part,
371
+ assert,
372
+ warn: (message, metadata) => {
373
+ if (!declaring || computations.evaluating)
374
+ invalid("Warnings must be emitted in the outer generation callback.");
375
+ issue("warning", message, metadata);
376
+ },
377
+ },
378
+ assertActive,
379
+ diagnostics,
380
+ finish(output) {
381
+ assertSynchronousScopeResult(output, () => new GeometryError("INVALID_ARGUMENT", "Generation callbacks must be synchronous.", {
382
+ operation: "generate",
383
+ }));
384
+ // Catching an assertion does not turn its revision into a successful snapshot.
385
+ assertNoFailedAssertion();
386
+ declaring = false;
387
+ if (!isArray(output) || output.length === 0)
388
+ invalid("Generation must return at least one declared part.");
389
+ if (output.length > maxParts)
390
+ throw new GeometryError("COMPLEXITY_LIMIT", `Output exceeds ${maxParts} parts.`, {
391
+ operation: "generate",
392
+ });
393
+ const returnedIds = new Set();
394
+ for (const descriptor of output) {
395
+ if (!declaredParts.has(descriptor) || returnedIds.has(descriptor.id))
396
+ invalid("Return each declared output part at most once.");
397
+ returnedIds.add(descriptor.id);
398
+ }
399
+ },
400
+ dependencies(output) {
401
+ return createModelDependencies(output, modelParameters, new Map([...computations.nodes].map(([id, node]) => [id, reusableEntry(node)])), readerIds);
402
+ },
403
+ computedValues() {
404
+ return [...computations.nodes.values()].map((node) => reusableEntry(node)?.value);
405
+ },
406
+ retainedValues() {
407
+ const retained = [];
408
+ for (const node of computations.nodes.values()) {
409
+ const entry = reusableEntry(node);
410
+ if (entry !== undefined)
411
+ retained.push(entry.value, entry.inputs, ...entry.parameters.values());
412
+ }
413
+ return [...retained, parameterSnapshot, inputValues];
414
+ },
415
+ commit() {
416
+ const accepted = new Map();
417
+ for (const [id, node] of computations.nodes) {
418
+ const entry = reusableEntry(node);
419
+ if (entry?.cacheable)
420
+ accepted.set(id, entry);
421
+ }
422
+ committed = accepted;
423
+ previousInput = { ...inputValues };
424
+ previousParameterSnapshot = parameterSnapshot;
425
+ for (const id of readers.keys())
426
+ if (!computations.nodes.has(id))
427
+ readers.delete(id);
428
+ },
429
+ close() {
430
+ active = false;
431
+ activeRead = undefined;
432
+ computations.dispose();
433
+ // Failed edits and validation probes cannot retain newly registered readers.
434
+ for (const id of readers.keys())
435
+ if (!committed.has(id))
436
+ readers.delete(id);
437
+ },
438
+ };
439
+ },
440
+ clear() {
441
+ committed.clear();
442
+ readers.clear();
443
+ previousInput = {};
444
+ previousParameterSnapshot = {};
445
+ },
446
+ };
447
+ }
@@ -0,0 +1,90 @@
1
+ import { generationValueBytes } from "./immutable-generation-data.js";
2
+ function disposeValues(values) {
3
+ const errors = [];
4
+ for (const value of new Set(values)) {
5
+ try {
6
+ value.delete();
7
+ }
8
+ catch (error) {
9
+ errors.push(error);
10
+ }
11
+ }
12
+ if (errors.length > 0)
13
+ throw new AggregateError(errors, "Failed to release cached native geometry.");
14
+ }
15
+ /** Releases provisional ownership without hiding the failure that caused rollback. */
16
+ function rollback(error, values, message) {
17
+ try {
18
+ disposeValues(values);
19
+ }
20
+ catch (cleanup) {
21
+ throw new AggregateError([error, cleanup], message, { cause: cleanup });
22
+ }
23
+ throw error;
24
+ }
25
+ /** Bounded evaluated computed roots. Candidates remain provisional until ordinary run cleanup succeeds. */
26
+ export function createNativeGeometryCache() {
27
+ let committed = new Map();
28
+ const values = (entries) => new Set([...entries.values()].map((entry) => entry.native.value));
29
+ return {
30
+ get(shape) {
31
+ return committed.get(shape);
32
+ },
33
+ prepare(roots, capture, detach, budget) {
34
+ const previousValues = values(committed);
35
+ const next = new Map();
36
+ const admitted = new Set();
37
+ const staged = new Set();
38
+ let remaining = budget;
39
+ try {
40
+ for (const root of roots) {
41
+ if (remaining <= 0 || next.size >= 256)
42
+ break;
43
+ const entry = committed.get(root) ?? capture(root);
44
+ if (entry === undefined)
45
+ continue;
46
+ const native = entry.native.value;
47
+ const cost = (admitted.has(native) ? 0 : entry.bytes) +
48
+ generationValueBytes([entry.anchors, entry.profile], remaining);
49
+ if (!Number.isFinite(cost) || cost > remaining)
50
+ continue;
51
+ if (!previousValues.has(native) && !staged.has(native)) {
52
+ detach(entry);
53
+ staged.add(native);
54
+ }
55
+ admitted.add(native);
56
+ next.set(root, entry);
57
+ remaining -= cost;
58
+ }
59
+ }
60
+ catch (error) {
61
+ rollback(error, staged, "Native cache preparation failed.");
62
+ }
63
+ return {
64
+ commit() {
65
+ committed = next;
66
+ staged.clear();
67
+ // Drop references before cleanup: a deletion failure must never leave deleted objects
68
+ // available for a later cache hit. The session clears its other caches on this failure.
69
+ try {
70
+ disposeValues([...previousValues].filter((value) => !admitted.has(value)));
71
+ }
72
+ catch (error) {
73
+ committed = new Map();
74
+ rollback(error, admitted, "Native cache replacement failed.");
75
+ }
76
+ },
77
+ dispose() {
78
+ const abandoned = [...staged];
79
+ staged.clear();
80
+ disposeValues(abandoned);
81
+ },
82
+ };
83
+ },
84
+ clear() {
85
+ const previous = values(committed);
86
+ committed = new Map();
87
+ disposeValues(previous);
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,153 @@
1
+ import { GeometryError } from "../geometry/errors.js";
2
+ import { isImmutableBuffer } from "../model/immutable-buffer.js";
3
+ import { createGeometryFinalizer, rememberSnapshotPart, } from "./finalization.js";
4
+ import { createModelSummary } from "./model-summary.js";
5
+ import { placeFinalizedGeometry } from "./placement.js";
6
+ let nextSession = 0;
7
+ function invalid(message) {
8
+ throw new GeometryError("INVALID_ARGUMENT", message, { operation: "generate" });
9
+ }
10
+ const vector = (value) => {
11
+ const result = value ?? [0, 0, 0];
12
+ if (!Array.isArray(result) || result.length !== 3 || !result.every(Number.isFinite))
13
+ invalid("Part placement requires three finite coordinates.");
14
+ return Object.freeze([...result]);
15
+ };
16
+ /** Only fixed buffer fields can establish immutable mesh identity across revisions. */
17
+ function isImmutableMesh(value) {
18
+ return (Object.isFrozen(value) &&
19
+ isImmutableBuffer(Object.getOwnPropertyDescriptor(value, "positions")?.value) &&
20
+ isImmutableBuffer(Object.getOwnPropertyDescriptor(value, "indices")?.value));
21
+ }
22
+ /** Publishes copied views while retaining the authoritative local buffers privately. */
23
+ function createResource(id, geometry, reusable) {
24
+ return {
25
+ geometry,
26
+ reusable,
27
+ public: Object.freeze({
28
+ id,
29
+ revision: 1,
30
+ measurements: geometry.measurements,
31
+ get positions() {
32
+ return new Float32Array(geometry.positions);
33
+ },
34
+ get indices() {
35
+ return new Uint32Array(geometry.indices);
36
+ },
37
+ }),
38
+ };
39
+ }
40
+ /** Captures authoritative placement and appearance before consulting world-geometry reuse. */
41
+ function capturePlacement(descriptor) {
42
+ const position = vector(typeof descriptor.position === "function" ? descriptor.position() : descriptor.position);
43
+ const rotation = vector(typeof descriptor.rotation === "function" ? descriptor.rotation() : descriptor.rotation);
44
+ const color = descriptor.color;
45
+ if (color !== undefined && (typeof color !== "string" || !/^#[\da-f]{6}$/i.test(color)))
46
+ invalid("Part colors must be six-digit hex strings.");
47
+ return { position, rotation, ...(color === undefined ? {} : { color: color.toLowerCase() }) };
48
+ }
49
+ /** Snapshot accessors own their copies; export can still use the trusted private world buffers. */
50
+ function capturePart(id, geometry, placement, worldGeometry) {
51
+ const part = Object.freeze({
52
+ id,
53
+ geometry,
54
+ ...placement,
55
+ measurements: worldGeometry.measurements,
56
+ get positions() {
57
+ return new Float32Array(worldGeometry.positions);
58
+ },
59
+ get indices() {
60
+ return new Uint32Array(worldGeometry.indices);
61
+ },
62
+ });
63
+ rememberSnapshotPart(part, worldGeometry);
64
+ return part;
65
+ }
66
+ /** Isolates a snapshot's lifetime from the cache transaction that produced it. */
67
+ function createSnapshot(parts, { bounds, stats }, diagnostics, dependencies) {
68
+ let ownedParts = Object.freeze(parts);
69
+ const requireParts = () => ownedParts ?? invalid("This model snapshot has been disposed.");
70
+ return Object.freeze({
71
+ get parts() {
72
+ return requireParts();
73
+ },
74
+ get bounds() {
75
+ requireParts();
76
+ return bounds;
77
+ },
78
+ get stats() {
79
+ requireParts();
80
+ return stats;
81
+ },
82
+ diagnostics: Object.freeze([...diagnostics]),
83
+ dependencies,
84
+ dispose() {
85
+ ownedParts = undefined;
86
+ },
87
+ });
88
+ }
89
+ /**
90
+ * Owns committed local resources and placed geometry, independently of computation tracking.
91
+ * A capture stages replacements without mutating either cache. Only its caller may commit after
92
+ * all finalization and native cleanup succeed; snapshots keep their own references either way.
93
+ */
94
+ export function createSnapshotCache(options, limits) {
95
+ const sessionId = ++nextSession;
96
+ let resourceVersion = 0;
97
+ let resourceCache = new Map();
98
+ let placedCache = new Map();
99
+ return {
100
+ capture({ output, native, deferred, startedAt, builtAt, diagnostics, readDependencies, }) {
101
+ const nextResources = new Map();
102
+ const nextPlaced = new Map();
103
+ const worldParts = [];
104
+ const summary = createModelSummary(limits);
105
+ let finalizeGeometry;
106
+ for (const descriptor of output) {
107
+ const geometry = typeof descriptor.geometry === "function" ? descriptor.geometry() : descriptor.geometry;
108
+ if (typeof geometry !== "object" || geometry === null)
109
+ invalid("Parts require Solid recipes or indexed manufacturing meshes.");
110
+ let resource = options.cache === false ? undefined : resourceCache.get(geometry);
111
+ resource ??= nextResources.get(geometry);
112
+ if (resource === undefined) {
113
+ const isSolid = deferred.isSolid(geometry);
114
+ finalizeGeometry ??= createGeometryFinalizer(native(), limits, options);
115
+ const finalized = finalizeGeometry(isSolid ? deferred.resolve(geometry) : geometry);
116
+ resource = createResource(`s${sessionId}-g${++resourceVersion}`, finalized, isSolid || isImmutableMesh(geometry));
117
+ }
118
+ nextResources.set(geometry, resource);
119
+ const placement = capturePlacement(descriptor);
120
+ // World buffers belong to geometry and placement, independently of semantic part identity.
121
+ const placementKey = JSON.stringify([
122
+ resource.public.id,
123
+ placement.position,
124
+ placement.rotation,
125
+ ]);
126
+ const placed = nextPlaced.get(placementKey) ??
127
+ placedCache.get(placementKey) ??
128
+ placeFinalizedGeometry(resource.geometry, placement.position, placement.rotation, options.meshValidation);
129
+ nextPlaced.set(placementKey, placed);
130
+ summary.add(placed);
131
+ worldParts.push(capturePart(descriptor.id, resource.public, placement, placed));
132
+ }
133
+ return {
134
+ snapshot: createSnapshot(worldParts, summary.finish(startedAt, builtAt), diagnostics, readDependencies()),
135
+ retainedValues: [
136
+ ...nextResources.keys(),
137
+ ...[...nextResources.values()].map((resource) => resource.geometry),
138
+ ...nextPlaced.values(),
139
+ ],
140
+ commit() {
141
+ // Raw mesh buffers can change even behind a frozen wrapper. Deduplicate them within
142
+ // this capture, but only reuse proven immutable geometry in later revisions.
143
+ resourceCache = new Map([...nextResources].filter(([, resource]) => resource.reusable));
144
+ placedCache = nextPlaced;
145
+ },
146
+ };
147
+ },
148
+ clear() {
149
+ resourceCache.clear();
150
+ placedCache.clear();
151
+ },
152
+ };
153
+ }