@es3d/es3d 1.0.0 → 1.0.1

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 (79) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +86 -86
  3. package/docs/SPEC.md +417 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +377 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +92 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf.js +630 -630
  42. package/src/io/mesh-import.js +104 -104
  43. package/src/io/obj.js +352 -352
  44. package/src/io/step-brep.js +439 -439
  45. package/src/io/step-exact.js +1149 -1149
  46. package/src/io/step.js +1418 -1418
  47. package/src/io/stl.js +193 -193
  48. package/src/io/svg-import.js +787 -787
  49. package/src/material/materials.js +154 -154
  50. package/src/math/mat4.js +475 -475
  51. package/src/math/random.js +30 -30
  52. package/src/math/trig.js +65 -65
  53. package/src/math/vec3.js +81 -81
  54. package/src/optimize/optimizer.js +579 -579
  55. package/src/path/path-flatten.js +322 -322
  56. package/src/path/path-parser.js +332 -332
  57. package/src/path/shapes.js +129 -129
  58. package/src/path/triangulate.js +517 -517
  59. package/src/pipeline.js +74 -74
  60. package/src/render/camera.js +168 -168
  61. package/src/render/frame-builder.js +362 -362
  62. package/src/render/geometry-pool.js +149 -149
  63. package/src/render/lighting.js +149 -149
  64. package/src/render/orbit-controls.js +237 -237
  65. package/src/render/renderer.js +258 -249
  66. package/src/render/scroll-timeline.js +104 -104
  67. package/src/render/shaders/glsl.js +285 -285
  68. package/src/render/shaders/wgsl.js +252 -252
  69. package/src/render/stage.js +364 -352
  70. package/src/render/texture-cache.js +40 -40
  71. package/src/render/textures.js +223 -223
  72. package/src/render/webgl2/webgl2-backend.js +586 -532
  73. package/src/render/webgpu/webgpu-backend.js +482 -480
  74. package/src/scene/flatten.js +754 -754
  75. package/src/scene/picking.js +161 -161
  76. package/src/scene/scene-graph.js +320 -320
  77. package/src/transform/transform.js +211 -211
  78. package/src/wasm/kernels-binary.js +3 -3
  79. package/src/wasm/kernels.js +385 -385
@@ -1,392 +1,392 @@
1
- // Persistent geometry cache: the meshes a document's shapes produced, kept
2
- // between page loads so that a repeat visit reads them instead of
3
- // tessellating again.
4
- //
5
- // - One record per document (one read on load), in ES3D's own binary layout
6
- // (mesh-bundle.js), named by content: one SHA-256 over every cached shape's
7
- // identity (its parameters and the full text of its path or point data),
8
- // the generator revision, the quality setting and what the engine's Math
9
- // functions return, which the generators use. Change any of them and the
10
- // name changes; nothing is ever invalidated by date. Web Crypto computes
11
- // the digest where the page has it, sha256.js where it does not.
12
- // - Stored in IndexedDB, which pages served over plain http (a local
13
- // network) have too, unlike Cache Storage and crypto.subtle. A store is
14
- // only a get/put interface, so other places work as well (memoryStore,
15
- // or a file store in Node).
16
- // - Bounded: least recently used records go first once the store holds more
17
- // than `maxBytes` or `maxRecords`; records of another generator revision go
18
- // before anything else.
19
- // - Advisory: a missing, unreadable, slow or invalid record means the meshes
20
- // are generated as before. Nothing it stores changes what is drawn: the
21
- // restored meshes are bit-identical to the ones generation would produce.
22
- //
23
- // Authored <mesh> data is not cached: hashing it costs about what computing
24
- // its normals does.
25
-
26
- import { sha256 } from './sha256.js';
27
- import { encodeBundle, decodeBundle } from './mesh-bundle.js';
28
-
29
- /**
30
- * Bump whenever any generator's output changes, even by one bit
31
- * (test/geometry-cache.test.mjs fails until the recorded digest of every
32
- * example's meshes matches this revision).
33
- */
34
- export const GEOMETRY_REVISION = 1;
35
-
36
- const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
37
-
38
- let fingerprint = null;
39
-
40
- /**
41
- * What this engine's Math functions return for a fixed set of arguments
42
- * (bytes). IEEE 754 does not pin them down, and the generators use them, so
43
- * meshes made by one engine are only reused by an engine that agrees.
44
- */
45
- export function mathFingerprint() {
46
- if (fingerprint) return fingerprint;
47
- const out = new Float64Array(9 * 24);
48
- let k = 0;
49
- for (let i = 0; i < 24; i++) {
50
- const x = (i - 11.5) * 0.8284271247461902;
51
- out[k++] = Math.sin(x);
52
- out[k++] = Math.cos(x);
53
- out[k++] = Math.tan(x);
54
- out[k++] = Math.atan2(x, 1.7 - i * 0.11);
55
- out[k++] = Math.acos(Math.max(-1, Math.min(1, x / 10)));
56
- out[k++] = Math.hypot(x, i * 0.3);
57
- out[k++] = Math.hypot(x, i * 0.3, 1 / (i + 1));
58
- out[k++] = Math.cbrt(x);
59
- out[k++] = Math.sqrt(i + 0.1);
60
- }
61
- fingerprint = new Uint8Array(out.buffer);
62
- return fingerprint;
63
- }
64
-
65
- const subtle = typeof crypto !== 'undefined' && crypto.subtle && typeof crypto.subtle.digest === 'function' ? crypto.subtle : null;
66
- const utf8 = new TextEncoder();
67
-
68
- async function digest(bytes, native) {
69
- if (native && subtle) {
70
- try {
71
- const d = new Uint8Array(await subtle.digest('SHA-256', bytes));
72
- let hex = '';
73
- for (const b of d) hex += (b < 16 ? '0' : '') + b.toString(16);
74
- return hex;
75
- } catch {
76
- // fall through to the script version
77
- }
78
- }
79
- return sha256(bytes);
80
- }
81
-
82
- /**
83
- * The cacheable entries of a cache, ordered by identity (so the order in
84
- * which a long-lived cache first saw them does not matter), and the name of
85
- * their record.
86
- * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
87
- * @param {{native?: boolean}} [opts] native: false always uses sha256.js
88
- */
89
- export async function geometryKey(cache, opts = {}) {
90
- const entries = [];
91
- for (const e of cache.entries.values()) if (e.identity) entries.push(e);
92
- if (!entries.length) return { key: null, entries };
93
- entries.sort((a, b) => (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
94
- // identities are length-prefixed (registry.js), so their concatenation is unambiguous
95
- const text = utf8.encode(`es3d-geometry:${GEOMETRY_REVISION}:${cache.quality}:${entries.length}:${entries.map((e) => e.identity).join('')}`);
96
- const math = mathFingerprint();
97
- const bytes = new Uint8Array(text.length + math.length);
98
- bytes.set(text);
99
- bytes.set(math, text.length);
100
- return { key: await digest(bytes, opts.native !== false), entries };
101
- }
102
-
103
- /** Levels worth storing: generated, not failed, and not a stand-in. */
104
- function storedLevels(entry) {
105
- const levels = [];
106
- for (let l = 0; l < entry.lods; l++) {
107
- const mesh = entry.meshes[l];
108
- if (mesh && !(entry.failedLods & (1 << l))) levels.push({ lod: l, mesh, reports: entry.reports[l] });
109
- }
110
- return levels;
111
- }
112
-
113
- const withTimeout = (promise, ms) =>
114
- ms > 0 && ms < Infinity ? Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(undefined), ms))]) : promise;
115
-
116
- /**
117
- * Fill a cache with the meshes stored for its current shapes. Call after
118
- * compile() and before the first frame; levels that exist already are kept.
119
- * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
120
- * @param {object|Promise<object>} store from openGeometryStore() (null: nothing to do)
121
- * @param {{timeout?: number}} [opts] give up on a store slower than this (ms, default 250)
122
- * @returns {Promise<{hit: boolean, key: string|null, levels: number, bytes: number, ms: number, reason?: string}>}
123
- */
124
- export async function restoreGeometry(cache, store, opts = {}) {
125
- const t0 = now();
126
- const done = (r) => ({ hit: false, key: null, levels: 0, bytes: 0, ...r, ms: now() - t0 });
127
- const s = await withTimeout(Promise.resolve(store), opts.timeout ?? 250);
128
- if (!s) return done({ reason: s === undefined ? 'store timed out' : 'no store' });
129
- const { key, entries } = await geometryKey(cache);
130
- if (!key) return done({ reason: 'nothing cacheable' });
131
- let buffer;
132
- try {
133
- buffer = await withTimeout(s.get(key), opts.timeout ?? 250);
134
- } catch {
135
- buffer = null;
136
- }
137
- if (buffer === undefined) return done({ key, reason: 'store timed out' });
138
- if (!buffer) {
139
- cache.persisted = null;
140
- return done({ key, reason: 'miss' });
141
- }
142
- const bundle = decodeBundle(buffer);
143
- if (!bundle || bundle.key !== key || bundle.entries.length !== entries.length) {
144
- // unreadable or not what it claims to be: drop it, generate instead
145
- Promise.resolve(s.delete(key)).catch(() => {});
146
- return done({ key, reason: 'invalid record' });
147
- }
148
- // the key covers the identities in this order, so the lists line up
149
- let levels = 0, held = 0;
150
- for (let i = 0; i < entries.length; i++) {
151
- const stored = bundle.entries[i];
152
- held += stored.length;
153
- for (const { lod, mesh, reports } of stored) if (cache.adopt(entries[i], lod, mesh, reports)) levels++;
154
- }
155
- // what the store holds: saving is worth it once the cache holds more
156
- cache.persisted = `${key}:${held}`;
157
- return { hit: true, key, levels, bytes: buffer.byteLength, ms: now() - t0 };
158
- }
159
-
160
- function countLevels(entries) {
161
- let n = 0;
162
- for (const e of entries) n += storedLevels(e).length;
163
- return n;
164
- }
165
-
166
- /**
167
- * Store the meshes a cache holds for its current shapes, if it holds any the
168
- * store does not have yet. Call when the page is idle after drawing.
169
- * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
170
- * @param {object|Promise<object>} store
171
- * @returns {Promise<{saved: boolean, key: string|null, bytes: number, reason?: string}>}
172
- */
173
- export async function saveGeometry(cache, store) {
174
- const s = await store;
175
- if (!s) return { saved: false, key: null, bytes: 0, reason: 'no store' };
176
- const { key, entries } = await geometryKey(cache);
177
- if (!key) return { saved: false, key: null, bytes: 0, reason: 'nothing cacheable' };
178
- const signature = `${key}:${countLevels(entries)}`;
179
- if (cache.persisted === signature) return { saved: false, key, bytes: 0, reason: 'up to date' };
180
- const records = entries.map(storedLevels);
181
- if (!records.some((levels) => levels.length)) return { saved: false, key, bytes: 0, reason: 'nothing generated' };
182
- const buffer = encodeBundle(key, records);
183
- if (s.maxBytes && buffer.byteLength > s.maxBytes / 4) return { saved: false, key, bytes: buffer.byteLength, reason: 'too large' };
184
- try {
185
- await s.put(key, buffer);
186
- } catch (e) {
187
- return { saved: false, key, bytes: buffer.byteLength, reason: `store failed: ${e && e.message ? e.message : e}` };
188
- }
189
- cache.persisted = signature;
190
- return { saved: true, key, bytes: buffer.byteLength };
191
- }
192
-
193
- /**
194
- * For pages: keep a geometry cache and a store in step. Call restore() after
195
- * compile() and changed() after drawing; meshes generated since the last save
196
- * are saved once nothing new has been generated for `delay` ms and the page
197
- * is idle.
198
- * @param {object|Promise<object>|null} store null: both calls do nothing
199
- * @param {{delay?: number}} [opts]
200
- */
201
- export function persistGeometry(store, opts = {}) {
202
- const delay = opts.delay ?? 1500;
203
- const idle = typeof requestIdleCallback === 'function' ? (fn) => requestIdleCallback(fn, { timeout: 5000 }) : (fn) => setTimeout(fn, 0);
204
- let timer = 0;
205
- let pending = null;
206
- let seen = -1;
207
- return {
208
- store,
209
- restore(cache, restoreOpts) {
210
- return store ? restoreGeometry(cache, store, restoreOpts) : Promise.resolve(null);
211
- },
212
- changed(cache) {
213
- if (!store || (cache === pending && cache.stats.generated === seen)) return;
214
- pending = cache;
215
- seen = cache.stats.generated;
216
- clearTimeout(timer);
217
- timer = setTimeout(() => idle(() => {
218
- const c = pending;
219
- pending = null;
220
- if (c) saveGeometry(c, store).catch(() => {});
221
- }), delay);
222
- },
223
- };
224
- }
225
-
226
- // --- stores ---------------------------------------------------------------------
227
-
228
- const DEFAULTS = { maxBytes: 64 * 1024 * 1024, maxRecords: 256 };
229
-
230
- /**
231
- * A store in memory (tests, Node, one page's lifetime). Keeps copies, as a
232
- * real store would.
233
- */
234
- export function memoryStore(opts = {}) {
235
- const { maxBytes, maxRecords } = { ...DEFAULTS, ...opts };
236
- const records = new Map();
237
- let clock = 0;
238
- const prune = () => {
239
- let total = 0;
240
- for (const r of records.values()) total += r.data.byteLength;
241
- const order = [...records].sort((a, b) => a[1].used - b[1].used);
242
- while (order.length && (total > maxBytes || records.size > maxRecords)) {
243
- const [k, r] = order.shift();
244
- records.delete(k);
245
- total -= r.data.byteLength;
246
- }
247
- };
248
- return {
249
- kind: 'memory',
250
- maxBytes,
251
- async get(key) {
252
- const r = records.get(key);
253
- if (!r) return null;
254
- r.used = ++clock;
255
- return r.data.slice(0);
256
- },
257
- async put(key, data) {
258
- records.set(key, { data: data.slice(0), used: ++clock });
259
- prune();
260
- },
261
- async delete(key) {
262
- records.delete(key);
263
- },
264
- async clear() {
265
- records.clear();
266
- },
267
- get size() {
268
- return records.size;
269
- },
270
- };
271
- }
272
-
273
- const request = (r) =>
274
- new Promise((resolve, reject) => {
275
- r.onsuccess = () => resolve(r.result);
276
- r.onerror = () => reject(r.error);
277
- });
278
-
279
- const transaction = (t) =>
280
- new Promise((resolve, reject) => {
281
- t.oncomplete = () => resolve();
282
- t.onerror = () => reject(t.error);
283
- t.onabort = () => reject(t.error || new Error('transaction aborted'));
284
- });
285
-
286
- /**
287
- * A store in IndexedDB. Two object stores: `data` (record name -> bundle)
288
- * and `meta` (record name -> {key, bytes, used, revision}, indexed by `used`),
289
- * so pruning reads the small records only.
290
- * @param {{name?: string, maxBytes?: number, maxRecords?: number, timeout?: number}} [opts]
291
- * @returns {Promise<object|null>} null where IndexedDB is missing or refuses
292
- */
293
- export async function indexedDBStore(opts = {}) {
294
- const { name, maxBytes, maxRecords, timeout } = { name: 'es3d-geometry', timeout: 2000, ...DEFAULTS, ...opts };
295
- const idb = typeof indexedDB !== 'undefined' ? indexedDB : null;
296
- if (!idb) return null;
297
- let db;
298
- try {
299
- db = await withTimeout(
300
- new Promise((resolve, reject) => {
301
- const r = idb.open(name, 1);
302
- r.onupgradeneeded = () => {
303
- const d = r.result;
304
- if (!d.objectStoreNames.contains('data')) d.createObjectStore('data');
305
- if (!d.objectStoreNames.contains('meta')) d.createObjectStore('meta', { keyPath: 'key' }).createIndex('used', 'used');
306
- };
307
- r.onsuccess = () => resolve(r.result);
308
- r.onerror = () => reject(r.error);
309
- r.onblocked = () => resolve(null);
310
- }),
311
- timeout,
312
- );
313
- } catch {
314
- return null;
315
- }
316
- if (!db) return null;
317
- // let another tab upgrade or delete the database
318
- db.onversionchange = () => db.close();
319
- const touch = (key) => {
320
- try {
321
- const t = db.transaction('meta', 'readwrite');
322
- const meta = t.objectStore('meta');
323
- const r = meta.get(key);
324
- r.onsuccess = () => {
325
- if (r.result) meta.put({ ...r.result, used: Date.now() });
326
- };
327
- } catch {
328
- // closed or busy: the order of eviction is a hint only
329
- }
330
- };
331
- return {
332
- kind: 'indexeddb',
333
- maxBytes,
334
- async get(key) {
335
- const t = db.transaction('data', 'readonly');
336
- const data = await request(t.objectStore('data').get(key));
337
- if (!(data instanceof ArrayBuffer)) return null;
338
- (typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout)(() => touch(key));
339
- return data;
340
- },
341
- async put(key, data) {
342
- const t = db.transaction(['data', 'meta'], 'readwrite');
343
- const store = t.objectStore('data');
344
- const meta = t.objectStore('meta');
345
- store.put(data, key);
346
- meta.put({ key, bytes: data.byteLength, used: Date.now(), revision: GEOMETRY_REVISION });
347
- // prune in the same transaction: other revisions first, then the least recently used
348
- const all = await request(meta.getAll());
349
- all.sort((a, b) => (a.revision === GEOMETRY_REVISION) - (b.revision === GEOMETRY_REVISION) || a.used - b.used);
350
- let total = all.reduce((s, r) => s + r.bytes, 0);
351
- let count = all.length;
352
- for (const r of all) {
353
- if (r.key === key) continue;
354
- if (r.revision === GEOMETRY_REVISION && total <= maxBytes && count <= maxRecords) break;
355
- store.delete(r.key);
356
- meta.delete(r.key);
357
- total -= r.bytes;
358
- count--;
359
- }
360
- await transaction(t);
361
- },
362
- async delete(key) {
363
- const t = db.transaction(['data', 'meta'], 'readwrite');
364
- t.objectStore('data').delete(key);
365
- t.objectStore('meta').delete(key);
366
- await transaction(t);
367
- },
368
- async clear() {
369
- const t = db.transaction(['data', 'meta'], 'readwrite');
370
- t.objectStore('data').clear();
371
- t.objectStore('meta').clear();
372
- await transaction(t);
373
- },
374
- close() {
375
- db.close();
376
- },
377
- };
378
- }
379
-
380
- /**
381
- * The best store this environment offers (IndexedDB), or null. Open it early,
382
- * in parallel with fetching the document; restoreGeometry() takes the promise.
383
- * `persist: true` also asks the browser not to evict the origin's storage
384
- * under pressure (navigator.storage.persist(); browsers may ask the user).
385
- */
386
- export async function openGeometryStore(opts = {}) {
387
- const store = await indexedDBStore(opts).catch(() => null);
388
- if (store && opts.persist && typeof navigator !== 'undefined' && navigator.storage && navigator.storage.persist) {
389
- navigator.storage.persist().catch(() => {});
390
- }
391
- return store;
392
- }
1
+ // Persistent geometry cache: the meshes a document's shapes produced, kept
2
+ // between page loads so that a repeat visit reads them instead of
3
+ // tessellating again.
4
+ //
5
+ // - One record per document (one read on load), in ES3D's own binary layout
6
+ // (mesh-bundle.js), named by content: one SHA-256 over every cached shape's
7
+ // identity (its parameters and the full text of its path or point data),
8
+ // the generator revision, the quality setting and what the engine's Math
9
+ // functions return, which the generators use. Change any of them and the
10
+ // name changes; nothing is ever invalidated by date. Web Crypto computes
11
+ // the digest where the page has it, sha256.js where it does not.
12
+ // - Stored in IndexedDB, which pages served over plain http (a local
13
+ // network) have too, unlike Cache Storage and crypto.subtle. A store is
14
+ // only a get/put interface, so other places work as well (memoryStore,
15
+ // or a file store in Node).
16
+ // - Bounded: least recently used records go first once the store holds more
17
+ // than `maxBytes` or `maxRecords`; records of another generator revision go
18
+ // before anything else.
19
+ // - Advisory: a missing, unreadable, slow or invalid record means the meshes
20
+ // are generated as before. Nothing it stores changes what is drawn: the
21
+ // restored meshes are bit-identical to the ones generation would produce.
22
+ //
23
+ // Authored <mesh> data is not cached: hashing it costs about what computing
24
+ // its normals does.
25
+
26
+ import { sha256 } from './sha256.js';
27
+ import { encodeBundle, decodeBundle } from './mesh-bundle.js';
28
+
29
+ /**
30
+ * Bump whenever any generator's output changes, even by one bit
31
+ * (test/geometry-cache.test.mjs fails until the recorded digest of every
32
+ * example's meshes matches this revision).
33
+ */
34
+ export const GEOMETRY_REVISION = 1;
35
+
36
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
37
+
38
+ let fingerprint = null;
39
+
40
+ /**
41
+ * What this engine's Math functions return for a fixed set of arguments
42
+ * (bytes). IEEE 754 does not pin them down, and the generators use them, so
43
+ * meshes made by one engine are only reused by an engine that agrees.
44
+ */
45
+ export function mathFingerprint() {
46
+ if (fingerprint) return fingerprint;
47
+ const out = new Float64Array(9 * 24);
48
+ let k = 0;
49
+ for (let i = 0; i < 24; i++) {
50
+ const x = (i - 11.5) * 0.8284271247461902;
51
+ out[k++] = Math.sin(x);
52
+ out[k++] = Math.cos(x);
53
+ out[k++] = Math.tan(x);
54
+ out[k++] = Math.atan2(x, 1.7 - i * 0.11);
55
+ out[k++] = Math.acos(Math.max(-1, Math.min(1, x / 10)));
56
+ out[k++] = Math.hypot(x, i * 0.3);
57
+ out[k++] = Math.hypot(x, i * 0.3, 1 / (i + 1));
58
+ out[k++] = Math.cbrt(x);
59
+ out[k++] = Math.sqrt(i + 0.1);
60
+ }
61
+ fingerprint = new Uint8Array(out.buffer);
62
+ return fingerprint;
63
+ }
64
+
65
+ const subtle = typeof crypto !== 'undefined' && crypto.subtle && typeof crypto.subtle.digest === 'function' ? crypto.subtle : null;
66
+ const utf8 = new TextEncoder();
67
+
68
+ async function digest(bytes, native) {
69
+ if (native && subtle) {
70
+ try {
71
+ const d = new Uint8Array(await subtle.digest('SHA-256', bytes));
72
+ let hex = '';
73
+ for (const b of d) hex += (b < 16 ? '0' : '') + b.toString(16);
74
+ return hex;
75
+ } catch {
76
+ // fall through to the script version
77
+ }
78
+ }
79
+ return sha256(bytes);
80
+ }
81
+
82
+ /**
83
+ * The cacheable entries of a cache, ordered by identity (so the order in
84
+ * which a long-lived cache first saw them does not matter), and the name of
85
+ * their record.
86
+ * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
87
+ * @param {{native?: boolean}} [opts] native: false always uses sha256.js
88
+ */
89
+ export async function geometryKey(cache, opts = {}) {
90
+ const entries = [];
91
+ for (const e of cache.entries.values()) if (e.identity) entries.push(e);
92
+ if (!entries.length) return { key: null, entries };
93
+ entries.sort((a, b) => (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
94
+ // identities are length-prefixed (registry.js), so their concatenation is unambiguous
95
+ const text = utf8.encode(`es3d-geometry:${GEOMETRY_REVISION}:${cache.quality}:${entries.length}:${entries.map((e) => e.identity).join('')}`);
96
+ const math = mathFingerprint();
97
+ const bytes = new Uint8Array(text.length + math.length);
98
+ bytes.set(text);
99
+ bytes.set(math, text.length);
100
+ return { key: await digest(bytes, opts.native !== false), entries };
101
+ }
102
+
103
+ /** Levels worth storing: generated, not failed, and not a stand-in. */
104
+ function storedLevels(entry) {
105
+ const levels = [];
106
+ for (let l = 0; l < entry.lods; l++) {
107
+ const mesh = entry.meshes[l];
108
+ if (mesh && !(entry.failedLods & (1 << l))) levels.push({ lod: l, mesh, reports: entry.reports[l] });
109
+ }
110
+ return levels;
111
+ }
112
+
113
+ const withTimeout = (promise, ms) =>
114
+ ms > 0 && ms < Infinity ? Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(undefined), ms))]) : promise;
115
+
116
+ /**
117
+ * Fill a cache with the meshes stored for its current shapes. Call after
118
+ * compile() and before the first frame; levels that exist already are kept.
119
+ * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
120
+ * @param {object|Promise<object>} store from openGeometryStore() (null: nothing to do)
121
+ * @param {{timeout?: number}} [opts] give up on a store slower than this (ms, default 250)
122
+ * @returns {Promise<{hit: boolean, key: string|null, levels: number, bytes: number, ms: number, reason?: string}>}
123
+ */
124
+ export async function restoreGeometry(cache, store, opts = {}) {
125
+ const t0 = now();
126
+ const done = (r) => ({ hit: false, key: null, levels: 0, bytes: 0, ...r, ms: now() - t0 });
127
+ const s = await withTimeout(Promise.resolve(store), opts.timeout ?? 250);
128
+ if (!s) return done({ reason: s === undefined ? 'store timed out' : 'no store' });
129
+ const { key, entries } = await geometryKey(cache);
130
+ if (!key) return done({ reason: 'nothing cacheable' });
131
+ let buffer;
132
+ try {
133
+ buffer = await withTimeout(s.get(key), opts.timeout ?? 250);
134
+ } catch {
135
+ buffer = null;
136
+ }
137
+ if (buffer === undefined) return done({ key, reason: 'store timed out' });
138
+ if (!buffer) {
139
+ cache.persisted = null;
140
+ return done({ key, reason: 'miss' });
141
+ }
142
+ const bundle = decodeBundle(buffer);
143
+ if (!bundle || bundle.key !== key || bundle.entries.length !== entries.length) {
144
+ // unreadable or not what it claims to be: drop it, generate instead
145
+ Promise.resolve(s.delete(key)).catch(() => {});
146
+ return done({ key, reason: 'invalid record' });
147
+ }
148
+ // the key covers the identities in this order, so the lists line up
149
+ let levels = 0, held = 0;
150
+ for (let i = 0; i < entries.length; i++) {
151
+ const stored = bundle.entries[i];
152
+ held += stored.length;
153
+ for (const { lod, mesh, reports } of stored) if (cache.adopt(entries[i], lod, mesh, reports)) levels++;
154
+ }
155
+ // what the store holds: saving is worth it once the cache holds more
156
+ cache.persisted = `${key}:${held}`;
157
+ return { hit: true, key, levels, bytes: buffer.byteLength, ms: now() - t0 };
158
+ }
159
+
160
+ function countLevels(entries) {
161
+ let n = 0;
162
+ for (const e of entries) n += storedLevels(e).length;
163
+ return n;
164
+ }
165
+
166
+ /**
167
+ * Store the meshes a cache holds for its current shapes, if it holds any the
168
+ * store does not have yet. Call when the page is idle after drawing.
169
+ * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
170
+ * @param {object|Promise<object>} store
171
+ * @returns {Promise<{saved: boolean, key: string|null, bytes: number, reason?: string}>}
172
+ */
173
+ export async function saveGeometry(cache, store) {
174
+ const s = await store;
175
+ if (!s) return { saved: false, key: null, bytes: 0, reason: 'no store' };
176
+ const { key, entries } = await geometryKey(cache);
177
+ if (!key) return { saved: false, key: null, bytes: 0, reason: 'nothing cacheable' };
178
+ const signature = `${key}:${countLevels(entries)}`;
179
+ if (cache.persisted === signature) return { saved: false, key, bytes: 0, reason: 'up to date' };
180
+ const records = entries.map(storedLevels);
181
+ if (!records.some((levels) => levels.length)) return { saved: false, key, bytes: 0, reason: 'nothing generated' };
182
+ const buffer = encodeBundle(key, records);
183
+ if (s.maxBytes && buffer.byteLength > s.maxBytes / 4) return { saved: false, key, bytes: buffer.byteLength, reason: 'too large' };
184
+ try {
185
+ await s.put(key, buffer);
186
+ } catch (e) {
187
+ return { saved: false, key, bytes: buffer.byteLength, reason: `store failed: ${e && e.message ? e.message : e}` };
188
+ }
189
+ cache.persisted = signature;
190
+ return { saved: true, key, bytes: buffer.byteLength };
191
+ }
192
+
193
+ /**
194
+ * For pages: keep a geometry cache and a store in step. Call restore() after
195
+ * compile() and changed() after drawing; meshes generated since the last save
196
+ * are saved once nothing new has been generated for `delay` ms and the page
197
+ * is idle.
198
+ * @param {object|Promise<object>|null} store null: both calls do nothing
199
+ * @param {{delay?: number}} [opts]
200
+ */
201
+ export function persistGeometry(store, opts = {}) {
202
+ const delay = opts.delay ?? 1500;
203
+ const idle = typeof requestIdleCallback === 'function' ? (fn) => requestIdleCallback(fn, { timeout: 5000 }) : (fn) => setTimeout(fn, 0);
204
+ let timer = 0;
205
+ let pending = null;
206
+ let seen = -1;
207
+ return {
208
+ store,
209
+ restore(cache, restoreOpts) {
210
+ return store ? restoreGeometry(cache, store, restoreOpts) : Promise.resolve(null);
211
+ },
212
+ changed(cache) {
213
+ if (!store || (cache === pending && cache.stats.generated === seen)) return;
214
+ pending = cache;
215
+ seen = cache.stats.generated;
216
+ clearTimeout(timer);
217
+ timer = setTimeout(() => idle(() => {
218
+ const c = pending;
219
+ pending = null;
220
+ if (c) saveGeometry(c, store).catch(() => {});
221
+ }), delay);
222
+ },
223
+ };
224
+ }
225
+
226
+ // --- stores ---------------------------------------------------------------------
227
+
228
+ const DEFAULTS = { maxBytes: 64 * 1024 * 1024, maxRecords: 256 };
229
+
230
+ /**
231
+ * A store in memory (tests, Node, one page's lifetime). Keeps copies, as a
232
+ * real store would.
233
+ */
234
+ export function memoryStore(opts = {}) {
235
+ const { maxBytes, maxRecords } = { ...DEFAULTS, ...opts };
236
+ const records = new Map();
237
+ let clock = 0;
238
+ const prune = () => {
239
+ let total = 0;
240
+ for (const r of records.values()) total += r.data.byteLength;
241
+ const order = [...records].sort((a, b) => a[1].used - b[1].used);
242
+ while (order.length && (total > maxBytes || records.size > maxRecords)) {
243
+ const [k, r] = order.shift();
244
+ records.delete(k);
245
+ total -= r.data.byteLength;
246
+ }
247
+ };
248
+ return {
249
+ kind: 'memory',
250
+ maxBytes,
251
+ async get(key) {
252
+ const r = records.get(key);
253
+ if (!r) return null;
254
+ r.used = ++clock;
255
+ return r.data.slice(0);
256
+ },
257
+ async put(key, data) {
258
+ records.set(key, { data: data.slice(0), used: ++clock });
259
+ prune();
260
+ },
261
+ async delete(key) {
262
+ records.delete(key);
263
+ },
264
+ async clear() {
265
+ records.clear();
266
+ },
267
+ get size() {
268
+ return records.size;
269
+ },
270
+ };
271
+ }
272
+
273
+ const request = (r) =>
274
+ new Promise((resolve, reject) => {
275
+ r.onsuccess = () => resolve(r.result);
276
+ r.onerror = () => reject(r.error);
277
+ });
278
+
279
+ const transaction = (t) =>
280
+ new Promise((resolve, reject) => {
281
+ t.oncomplete = () => resolve();
282
+ t.onerror = () => reject(t.error);
283
+ t.onabort = () => reject(t.error || new Error('transaction aborted'));
284
+ });
285
+
286
+ /**
287
+ * A store in IndexedDB. Two object stores: `data` (record name -> bundle)
288
+ * and `meta` (record name -> {key, bytes, used, revision}, indexed by `used`),
289
+ * so pruning reads the small records only.
290
+ * @param {{name?: string, maxBytes?: number, maxRecords?: number, timeout?: number}} [opts]
291
+ * @returns {Promise<object|null>} null where IndexedDB is missing or refuses
292
+ */
293
+ export async function indexedDBStore(opts = {}) {
294
+ const { name, maxBytes, maxRecords, timeout } = { name: 'es3d-geometry', timeout: 2000, ...DEFAULTS, ...opts };
295
+ const idb = typeof indexedDB !== 'undefined' ? indexedDB : null;
296
+ if (!idb) return null;
297
+ let db;
298
+ try {
299
+ db = await withTimeout(
300
+ new Promise((resolve, reject) => {
301
+ const r = idb.open(name, 1);
302
+ r.onupgradeneeded = () => {
303
+ const d = r.result;
304
+ if (!d.objectStoreNames.contains('data')) d.createObjectStore('data');
305
+ if (!d.objectStoreNames.contains('meta')) d.createObjectStore('meta', { keyPath: 'key' }).createIndex('used', 'used');
306
+ };
307
+ r.onsuccess = () => resolve(r.result);
308
+ r.onerror = () => reject(r.error);
309
+ r.onblocked = () => resolve(null);
310
+ }),
311
+ timeout,
312
+ );
313
+ } catch {
314
+ return null;
315
+ }
316
+ if (!db) return null;
317
+ // let another tab upgrade or delete the database
318
+ db.onversionchange = () => db.close();
319
+ const touch = (key) => {
320
+ try {
321
+ const t = db.transaction('meta', 'readwrite');
322
+ const meta = t.objectStore('meta');
323
+ const r = meta.get(key);
324
+ r.onsuccess = () => {
325
+ if (r.result) meta.put({ ...r.result, used: Date.now() });
326
+ };
327
+ } catch {
328
+ // closed or busy: the order of eviction is a hint only
329
+ }
330
+ };
331
+ return {
332
+ kind: 'indexeddb',
333
+ maxBytes,
334
+ async get(key) {
335
+ const t = db.transaction('data', 'readonly');
336
+ const data = await request(t.objectStore('data').get(key));
337
+ if (!(data instanceof ArrayBuffer)) return null;
338
+ (typeof requestIdleCallback === 'function' ? requestIdleCallback : setTimeout)(() => touch(key));
339
+ return data;
340
+ },
341
+ async put(key, data) {
342
+ const t = db.transaction(['data', 'meta'], 'readwrite');
343
+ const store = t.objectStore('data');
344
+ const meta = t.objectStore('meta');
345
+ store.put(data, key);
346
+ meta.put({ key, bytes: data.byteLength, used: Date.now(), revision: GEOMETRY_REVISION });
347
+ // prune in the same transaction: other revisions first, then the least recently used
348
+ const all = await request(meta.getAll());
349
+ all.sort((a, b) => (a.revision === GEOMETRY_REVISION) - (b.revision === GEOMETRY_REVISION) || a.used - b.used);
350
+ let total = all.reduce((s, r) => s + r.bytes, 0);
351
+ let count = all.length;
352
+ for (const r of all) {
353
+ if (r.key === key) continue;
354
+ if (r.revision === GEOMETRY_REVISION && total <= maxBytes && count <= maxRecords) break;
355
+ store.delete(r.key);
356
+ meta.delete(r.key);
357
+ total -= r.bytes;
358
+ count--;
359
+ }
360
+ await transaction(t);
361
+ },
362
+ async delete(key) {
363
+ const t = db.transaction(['data', 'meta'], 'readwrite');
364
+ t.objectStore('data').delete(key);
365
+ t.objectStore('meta').delete(key);
366
+ await transaction(t);
367
+ },
368
+ async clear() {
369
+ const t = db.transaction(['data', 'meta'], 'readwrite');
370
+ t.objectStore('data').clear();
371
+ t.objectStore('meta').clear();
372
+ await transaction(t);
373
+ },
374
+ close() {
375
+ db.close();
376
+ },
377
+ };
378
+ }
379
+
380
+ /**
381
+ * The best store this environment offers (IndexedDB), or null. Open it early,
382
+ * in parallel with fetching the document; restoreGeometry() takes the promise.
383
+ * `persist: true` also asks the browser not to evict the origin's storage
384
+ * under pressure (navigator.storage.persist(); browsers may ask the user).
385
+ */
386
+ export async function openGeometryStore(opts = {}) {
387
+ const store = await indexedDBStore(opts).catch(() => null);
388
+ if (store && opts.persist && typeof navigator !== 'undefined' && navigator.storage && navigator.storage.persist) {
389
+ navigator.storage.persist().catch(() => {});
390
+ }
391
+ return store;
392
+ }