@es3d/es3d 1.1.2 → 1.2.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.
package/docs/STAGE.md ADDED
@@ -0,0 +1,95 @@
1
+ # Stage API
2
+
3
+ The stage puts an ES3D document on a canvas in a web page. It compiles the document, draws it with WebGPU or WebGL2, and handles what a page needs around that: resizing, the document's `<controls>`, scroll-driven animation, picking, and keeping meshes between visits.
4
+
5
+ ```js
6
+ import { createStage } from '@es3d/es3d/stage';
7
+
8
+ const stage = await createStage(document.querySelector('canvas'));
9
+ stage.show(source);
10
+ ```
11
+
12
+ `@es3d/es3d/stage` is one minified file, `browser/es3d-stage.min.js`, so a page loads it in one request, also from a CDN. `@es3d/es3d/browser` has the same stage plus the rest of the library (file conversion included), and `createStage` is also in `@es3d/es3d`. The canvas takes the size of its parent element.
13
+
14
+ ## Options
15
+
16
+ `createStage(canvas, options)`. Every option can be left out.
17
+
18
+ | Option | Default | What it does |
19
+ |---|---|---|
20
+ | `playing` | `true` | Play clock animations. |
21
+ | `autoOrbit` | `true` | Turn slowly around the target until someone drags. |
22
+ | `orbitSpeed` | `0.22` | Radians per second. |
23
+ | `interactive` | `true` | `false`: no dragging, whatever the document says. |
24
+ | `background` | `false` | A still camera and a running clock, for a page that moves shapes itself. |
25
+ | `clock` | `'document'` | `'always'` runs the clock in a document with no `<animate>`. |
26
+ | `scroll` | the page | The section whose scroll position drives `timeline="scroll"` animations. |
27
+ | `backend` | `'auto'` | `'webgpu'` or `'webgl2'` to choose. |
28
+ | `geometryStore` | IndexedDB | Where generated meshes are kept between visits. `false` keeps nothing. |
29
+ | `changeBudget` | `4` | Milliseconds per frame for compiling and placing fragments. |
30
+ | `workers` | `1` | `0` compiles `prepare()` on the main thread. |
31
+ | `onFrame(dt, stage)` | | Called every animation frame. |
32
+ | `onPick(hit)` | | A click on the scene. `hit.names` lists name and id up the tree; `hit.fragment` names the fragment hit. |
33
+ | `onInteract()` | | The visitor moved the camera. |
34
+ | `onError(message, result)` | | A document or fragment did not compile, or a fragment has elements only the stage's document can set. |
35
+
36
+ With `prefers-reduced-motion`, the clock and the automatic orbit stand still and a scroll-driven camera jumps from stop to stop. `stage.reducedMotion` says whether that is on.
37
+
38
+ ## Showing a document
39
+
40
+ `stage.show(source, { keepCamera, time, keepFragments })` compiles and shows a document in any of the three syntaxes, and returns the compile result. On errors the previous document stays on screen and `onError` is called. A new document reuses the geometry of the last one, so a colour change re-tessellates nothing.
41
+
42
+ `stage.result` is the compile result on screen. `stage.camera`, `stage.controls` and `stage.renderer` are the parts the stage drives. `stage.time`, `stage.progress` (the scroll timeline, 0 to 1) and `stage.playing` can be read and set. `stage.invalidate()` draws the next frame even if nothing changed. `stage.dispose()` releases everything.
43
+
44
+ ## Moving shapes
45
+
46
+ `stage.setTransform(id, matrix)` replaces the local matrix of the element with that id. The matrix is column-major, 16 numbers, the layout of `matrix()`. `stage.setTransforms([[id, matrix], ...])` does the same for many elements, walks each affected part of the scene once, and returns a boolean for each entry.
47
+
48
+ An entry is `false` when the id is missing or the element is drawn in more than one place: a shared definition, or a copy made by `<repeat>` or `<scatter>`. Give each shape you move its own id.
49
+
50
+ ## Fragments
51
+
52
+ A fragment is another complete document shown inside the stage's document: a map tile, a room, a route. Only that document is compiled when it is added, replaced or removed.
53
+
54
+ ```js
55
+ const tile = stage.add(source, { id: '16/32748/21789' }); // returns at once
56
+ const { ok, diagnostics } = await tile.ready; // placed
57
+ tile.visible = false; // kept, draws nothing
58
+ tile.replace(newSource); // only this fragment is rebuilt
59
+ tile.setTransforms([['pier', matrix]]); // ids of this fragment's document
60
+ tile.remove();
61
+ ```
62
+
63
+ | Call | What it does |
64
+ |---|---|
65
+ | `stage.add(source, { id, immediate })` | Adds a fragment and returns it. It is compiled and placed in the frame loop, within `changeBudget`; `immediate: true` does it now. Until then it draws nothing. |
66
+ | `fragment.ready` | Resolves with `{ ok, diagnostics, fragment }` once placed, or once it failed. |
67
+ | `fragment.visible` | `false` hides it without recompiling: it adds no rows to the frame. |
68
+ | `fragment.replace(source)` | New content in the same place. Bad input keeps what is on screen. Returns a promise like `ready`. |
69
+ | `fragment.setTransforms(entries)`, `setTransform(id, matrix)` | As on the stage, with the fragment's own ids. |
70
+ | `fragment.remove()` | Takes it out at once and frees what only it used. |
71
+ | `fragment.placed`, `fragment.diagnostics`, `fragment.id` | Its state. |
72
+ | `stage.fragment(id)`, `stage.fragments` | One fragment, or all of them in the order added. |
73
+
74
+ Each fragment is a complete, valid document, and any version-1 reader can open it on its own. Its shapes, groups, `<use>` with its own `<defs>`, `<repeat>`, `<scatter>`, `<lod>`, lights and `<animate>` are drawn as `show()` draws them. `<environment>`, `<camera>` and `<controls>` belong to the stage's document: in a fragment they are ignored, with a warning (code `STAGE_HOST_ONLY`). Two fragments may use the same ids. Materials with identical parameters are one material across all documents, and a shape repeated across fragments is generated and uploaded once.
75
+
76
+ The stage's document and its fragments draw the same picture as one document that holds the document's children and then each fragment's, in the order added. `show(source)` keeps the fragments; `show(source, { keepFragments: false })` removes them.
77
+
78
+ ## Compiling off the main thread
79
+
80
+ ```js
81
+ const prepared = await stage.prepare(source); // { as: 'document' } for show()
82
+ const tile = stage.add(prepared, { id });
83
+ ```
84
+
85
+ `stage.prepare(source, { as })` compiles in a worker the stage starts from its own file, and generates the meshes there too. Adding the result costs the main thread about a millisecond for a tile of a thousand buildings. A stage loaded from another origin starts its worker through a small module made in the page. Where no worker can start (a content security policy, `workers: 0`), `prepare()` compiles on the main thread, so a page needs no special case. The diagnostics are the same either way. A prepared document is added or shown once.
86
+
87
+ ## Knowing what is on screen
88
+
89
+ `stage.drawn()` resolves after the first frame that shows every change queued before the call, fully refined. It resolves with `stage.stats`.
90
+
91
+ `stage.stats` describes the last drawn frame, in main-thread milliseconds: `frameMs` (the whole frame), `changeMs` (fragment changes since the frame before), `compileMs` (the compiling part of that), `tessellateMs` (mesh generation while drawing) and `renderMs` (drawing without it). Also `instances`, `drawCalls`, `uploadBytes`, `refining` (levels still being refined) and `queued` (changes waiting).
92
+
93
+ ## Placement functions
94
+
95
+ `mulberry32`, `es3dSin` and `es3dCos` are exported too: the random generator and the sine and cosine the reader uses for `<scatter>`, so a script can place things exactly where a document would.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@es3d/es3d",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "ES3D (Extensible Scalable 3D): a declarative, SVG-inspired 3D scene format with a WebGPU/WebGL2 reference renderer, editor and converter.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -29,7 +29,8 @@
29
29
  "main": "src/index.js",
30
30
  "exports": {
31
31
  ".": "./src/index.js",
32
- "./stage": "./browser/es3d-stage.min.js"
32
+ "./stage": "./browser/es3d-stage.min.js",
33
+ "./browser": "./browser/es3d.min.js"
33
34
  },
34
35
  "bin": {
35
36
  "es3d": "scripts/es3d-cli.mjs"
@@ -42,8 +43,10 @@
42
43
  "!src/main.js",
43
44
  "scripts/es3d-cli.mjs",
44
45
  "browser/es3d-stage.min.js",
46
+ "browser/es3d.min.js",
45
47
  "docs/SPEC.md",
46
- "docs/REFERENCE.md"
48
+ "docs/REFERENCE.md",
49
+ "docs/STAGE.md"
47
50
  ],
48
51
  "engines": {
49
52
  "node": ">=22"
package/src/browser.js ADDED
@@ -0,0 +1,16 @@
1
+ // The browser entry: the whole browser-safe API in one file
2
+ // (browser/es3d.min.js, `npm run build:browser`, published as
3
+ // @es3d/es3d/browser), so a page loads ES3D in a single request: parse,
4
+ // validate and compile documents, draw them (createRenderer, createStage),
5
+ // and convert files on the visitor's own computer.
6
+ //
7
+ // import { importFiles, exportFormat } from '@es3d/es3d/browser';
8
+ //
9
+ // const doc = await importFiles(input.files); // SVG, OBJ (+.mtl), STL, STEP, glTF, X3D, USDA, OpenSCAD
10
+ // const { files } = exportFormat(doc.text, 'glb'); // nothing is uploaded
11
+ //
12
+ // It is the package entry (src/index.js) as it is: everything there runs in a
13
+ // browser. Node-only code (the glTF packer behind scripts/, zlib in
14
+ // io/compression.js) is not part of it; the build checks that.
15
+
16
+ export * from './index.js';
@@ -22,6 +22,10 @@
22
22
  //
23
23
  // Authored <mesh> data is not cached: hashing it costs about what computing
24
24
  // its normals does.
25
+ //
26
+ // A stage that shows fragments keeps one record per owner (the host document
27
+ // and each fragment, GeometryCache.owned), named by that owner's entries
28
+ // alone, so a tile's meshes are found again whatever else is on screen.
25
29
 
26
30
  import { sha256 } from './sha256.js';
27
31
  import { encodeBundle, decodeBundle } from './mesh-bundle.js';
@@ -84,11 +88,12 @@ async function digest(bytes, native) {
84
88
  * which a long-lived cache first saw them does not matter), and the name of
85
89
  * their record.
86
90
  * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
87
- * @param {{native?: boolean}} [opts] native: false always uses sha256.js
91
+ * @param {{native?: boolean, entries?: Iterable<object>}} [opts] native: false always uses
92
+ * sha256.js. entries: only these entries of the cache (one owner's), not all of them
88
93
  */
89
94
  export async function geometryKey(cache, opts = {}) {
90
95
  const entries = [];
91
- for (const e of cache.entries.values()) if (e.identity) entries.push(e);
96
+ for (const e of opts.entries || cache.entries.values()) if (e.identity) entries.push(e);
92
97
  if (!entries.length) return { key: null, entries };
93
98
  entries.sort((a, b) => (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
94
99
  // identities are length-prefixed (registry.js), so their concatenation is unambiguous
@@ -118,7 +123,8 @@ const withTimeout = (promise, ms) =>
118
123
  * compile() and before the first frame; levels that exist already are kept.
119
124
  * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
120
125
  * @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)
126
+ * @param {{timeout?: number, entries?: Iterable<object>}} [opts] timeout: give up on a store slower
127
+ * than this (ms, default 250). entries: restore only these (one owner's, see geometryKey)
122
128
  * @returns {Promise<{hit: boolean, key: string|null, levels: number, bytes: number, ms: number, reason?: string}>}
123
129
  */
124
130
  export async function restoreGeometry(cache, store, opts = {}) {
@@ -126,7 +132,7 @@ export async function restoreGeometry(cache, store, opts = {}) {
126
132
  const done = (r) => ({ hit: false, key: null, levels: 0, bytes: 0, ...r, ms: now() - t0 });
127
133
  const s = await withTimeout(Promise.resolve(store), opts.timeout ?? 250);
128
134
  if (!s) return done({ reason: s === undefined ? 'store timed out' : 'no store' });
129
- const { key, entries } = await geometryKey(cache);
135
+ const { key, entries } = await geometryKey(cache, { entries: opts.entries });
130
136
  if (!key) return done({ reason: 'nothing cacheable' });
131
137
  let buffer;
132
138
  try {
@@ -153,7 +159,7 @@ export async function restoreGeometry(cache, store, opts = {}) {
153
159
  for (const { lod, mesh, reports } of stored) if (cache.adopt(entries[i], lod, mesh, reports)) levels++;
154
160
  }
155
161
  // what the store holds: saving is worth it once the cache holds more
156
- cache.persisted = `${key}:${held}`;
162
+ markPersisted(cache, `${key}:${held}`);
157
163
  return { hit: true, key, levels, bytes: buffer.byteLength, ms: now() - t0 };
158
164
  }
159
165
 
@@ -163,20 +169,30 @@ function countLevels(entries) {
163
169
  return n;
164
170
  }
165
171
 
172
+ // cache.persisted is the record the cache last read or wrote (one document);
173
+ // persistedSet remembers every one, for a cache that several owners share
174
+ function markPersisted(cache, signature) {
175
+ cache.persisted = signature;
176
+ if (!cache.persistedSet) cache.persistedSet = new Set();
177
+ if (cache.persistedSet.size > 4096) cache.persistedSet.clear();
178
+ cache.persistedSet.add(signature);
179
+ }
180
+
166
181
  /**
167
182
  * Store the meshes a cache holds for its current shapes, if it holds any the
168
183
  * store does not have yet. Call when the page is idle after drawing.
169
184
  * @param {import('../geometry/geometry-cache.js').GeometryCache} cache
170
185
  * @param {object|Promise<object>} store
186
+ * @param {{entries?: Iterable<object>}} [opts] entries: store only these (one owner's, see geometryKey)
171
187
  * @returns {Promise<{saved: boolean, key: string|null, bytes: number, reason?: string}>}
172
188
  */
173
- export async function saveGeometry(cache, store) {
189
+ export async function saveGeometry(cache, store, opts = {}) {
174
190
  const s = await store;
175
191
  if (!s) return { saved: false, key: null, bytes: 0, reason: 'no store' };
176
- const { key, entries } = await geometryKey(cache);
192
+ const { key, entries } = await geometryKey(cache, { entries: opts.entries });
177
193
  if (!key) return { saved: false, key: null, bytes: 0, reason: 'nothing cacheable' };
178
194
  const signature = `${key}:${countLevels(entries)}`;
179
- if (cache.persisted === signature) return { saved: false, key, bytes: 0, reason: 'up to date' };
195
+ if (cache.persisted === signature || (cache.persistedSet && cache.persistedSet.has(signature))) return { saved: false, key, bytes: 0, reason: 'up to date' };
180
196
  const records = entries.map(storedLevels);
181
197
  if (!records.some((levels) => levels.length)) return { saved: false, key, bytes: 0, reason: 'nothing generated' };
182
198
  const buffer = encodeBundle(key, records);
@@ -186,7 +202,7 @@ export async function saveGeometry(cache, store) {
186
202
  } catch (e) {
187
203
  return { saved: false, key, bytes: buffer.byteLength, reason: `store failed: ${e && e.message ? e.message : e}` };
188
204
  }
189
- cache.persisted = signature;
205
+ markPersisted(cache, signature);
190
206
  return { saved: true, key, bytes: buffer.byteLength };
191
207
  }
192
208
 
@@ -195,6 +211,10 @@ export async function saveGeometry(cache, store) {
195
211
  * compile() and changed() after drawing; meshes generated since the last save
196
212
  * are saved once nothing new has been generated for `delay` ms and the page
197
213
  * is idle.
214
+ *
215
+ * changed(cache, {owner, entries}) keeps one record per owner of a shared
216
+ * cache (the stage's host document and each fragment); every owner changed
217
+ * since the last save is saved then.
198
218
  * @param {object|Promise<object>|null} store null: both calls do nothing
199
219
  * @param {{delay?: number}} [opts]
200
220
  */
@@ -204,21 +224,39 @@ export function persistGeometry(store, opts = {}) {
204
224
  let timer = 0;
205
225
  let pending = null;
206
226
  let seen = -1;
227
+ const owners = new Map(); // owner -> {cache, entries}
228
+ const flush = () => {
229
+ const c = pending;
230
+ pending = null;
231
+ const list = [...owners.values()];
232
+ owners.clear();
233
+ if (c) saveGeometry(c, store).catch(() => {});
234
+ // one owner after the other: each save hashes and encodes that owner's meshes
235
+ list.reduce((p, o) => p.then(() => saveGeometry(o.cache, store, { entries: o.entries() }).catch(() => {})), Promise.resolve());
236
+ };
207
237
  return {
208
238
  store,
209
239
  restore(cache, restoreOpts) {
210
240
  return store ? restoreGeometry(cache, store, restoreOpts) : Promise.resolve(null);
211
241
  },
212
- changed(cache) {
213
- if (!store || (cache === pending && cache.stats.generated === seen)) return;
214
- pending = cache;
215
- seen = cache.stats.generated;
242
+ /**
243
+ * @param {object} cache
244
+ * @param {{owner: *, entries: () => Iterable<object>}} [part] one owner of a shared cache
245
+ */
246
+ changed(cache, part) {
247
+ if (!store) return;
248
+ if (part) owners.set(part.owner, { cache, entries: part.entries });
249
+ else {
250
+ if (cache === pending && cache.stats.generated === seen) return;
251
+ pending = cache;
252
+ seen = cache.stats.generated;
253
+ }
216
254
  clearTimeout(timer);
217
- timer = setTimeout(() => idle(() => {
218
- const c = pending;
219
- pending = null;
220
- if (c) saveGeometry(c, store).catch(() => {});
221
- }), delay);
255
+ timer = setTimeout(() => idle(flush), delay);
256
+ },
257
+ /** Stop saving an owner that is gone. */
258
+ forget(owner) {
259
+ owners.delete(owner);
222
260
  },
223
261
  };
224
262
  }
@@ -3,8 +3,13 @@
3
3
  // Geometry is identified by its descriptor key, so identical shapes anywhere
4
4
  // in a document (and across live edits of the document) share one entry.
5
5
  // Meshes are generated on first use and per level of detail; an entry that is
6
- // never drawn is never tessellated. Entries unused by the latest scene build
7
- // are evicted.
6
+ // never drawn is never tessellated.
7
+ //
8
+ // Each build belongs to an owner. A page showing one document has one owner
9
+ // (DOCUMENT), and an entry its latest build did not use is evicted, as before.
10
+ // A stage that shows fragments next to its document builds each of them under
11
+ // an owner of its own (any object), so building one tile does not evict the
12
+ // meshes of the host or of other tiles: an entry goes once no owner holds it.
8
13
 
9
14
  import { Mesh } from './mesh.js';
10
15
 
@@ -27,6 +32,7 @@ export class GeometryEntry {
27
32
  // restored from the persistent cache reports them again
28
33
  this.reports = new Array(desc.lods).fill(null);
29
34
  this.owner = owner; // first element using this geometry (diagnostics)
35
+ this.owners = new Set(); // build owners holding this entry (GeometryCache.held)
30
36
  this.epoch = 0;
31
37
  this.useCount = 0;
32
38
  this.failed = false;
@@ -34,9 +40,17 @@ export class GeometryEntry {
34
40
  }
35
41
  }
36
42
 
43
+ /** The owner of builds that name none: the one document a page shows. */
44
+ export const DOCUMENT = 'document';
45
+
37
46
  export class GeometryCache {
38
47
  constructor() {
39
48
  this.entries = new Map();
49
+ this.held = new Map(); // owner -> Set of the entries its latest build used
50
+ this.building = null; // owner of the build in progress
51
+ this.buildSet = null;
52
+ // called with each entry a level was generated for (the stage stores them per owner)
53
+ this.onGenerate = null;
40
54
  this.quality = 1;
41
55
  this.epoch = 0;
42
56
  this.version = 0; // bumps when meshes are invalidated
@@ -45,9 +59,15 @@ export class GeometryCache {
45
59
  this.onDiagnostic = null;
46
60
  }
47
61
 
48
- beginBuild() {
62
+ /**
63
+ * Start a build. Entries acquired until endBuild() belong to `owner`.
64
+ * @param {*} [owner] DOCUMENT, or any object for a fragment's build
65
+ */
66
+ beginBuild(owner = DOCUMENT) {
49
67
  this.epoch++;
50
- for (const e of this.entries.values()) e.useCount = 0;
68
+ this.building = owner;
69
+ this.buildSet = new Set();
70
+ if (owner === DOCUMENT) for (const e of this.entries.values()) e.useCount = 0;
51
71
  }
52
72
 
53
73
  /** Get or create the entry for a descriptor. */
@@ -56,8 +76,10 @@ export class GeometryCache {
56
76
  if (!e) {
57
77
  e = new GeometryEntry(desc, owner);
58
78
  this.entries.set(desc.key, e);
59
- } else if (e.epoch !== this.epoch) {
60
- // the descriptor closure may reference a newer AST; keep it current
79
+ } else if (e.epoch !== this.epoch && !this.heldByOthers(e)) {
80
+ // the descriptor closure may reference a newer AST; keep it current.
81
+ // An entry another owner holds keeps its descriptor and owner element,
82
+ // so its diagnostics stay in that owner's document.
61
83
  e.desc = desc;
62
84
  e.identity = desc.identity || null;
63
85
  e.owner = owner;
@@ -66,14 +88,76 @@ export class GeometryCache {
66
88
  }
67
89
  e.epoch = this.epoch;
68
90
  e.useCount++;
91
+ if (this.buildSet) this.buildSet.add(e);
69
92
  return e;
70
93
  }
71
94
 
72
- /** Drop entries that the latest build did not use. Returns the evicted entries. */
95
+ heldByOthers(e) {
96
+ for (const o of e.owners) if (o !== this.building) return true;
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * End a build: its owner now holds exactly the entries it acquired. Entries
102
+ * no owner holds are dropped. Returns the evicted entries.
103
+ */
73
104
  endBuild() {
105
+ const owner = this.building === null ? DOCUMENT : this.building;
106
+ const used = this.buildSet || new Set();
107
+ const before = this.held.get(owner);
108
+ this.held.set(owner, used);
109
+ for (const e of used) e.owners.add(owner);
110
+ const dropped = [];
111
+ if (before) {
112
+ for (const e of before) {
113
+ if (used.has(e)) continue;
114
+ e.owners.delete(owner);
115
+ dropped.push(e);
116
+ }
117
+ }
118
+ this.building = null;
119
+ this.buildSet = null;
120
+ // the document's build sweeps the whole cache, as it always has; a
121
+ // fragment's build only looks at what it held, so it costs what the fragment does
122
+ if (owner === DOCUMENT) return this.evictUnheld();
123
+ const evicted = [];
124
+ for (const e of dropped) {
125
+ if (e.owners.size === 0 && this.entries.get(e.key) === e) {
126
+ this.entries.delete(e.key);
127
+ evicted.push(e);
128
+ }
129
+ }
130
+ return evicted;
131
+ }
132
+
133
+ /**
134
+ * Forget everything an owner holds (a fragment that was removed or
135
+ * replaced). Returns the evicted entries.
136
+ */
137
+ release(owner) {
138
+ const set = this.held.get(owner);
139
+ if (!set) return [];
140
+ this.held.delete(owner);
141
+ for (const e of set) e.owners.delete(owner);
142
+ const evicted = [];
143
+ for (const e of set) {
144
+ if (e.owners.size === 0 && this.entries.get(e.key) === e) {
145
+ this.entries.delete(e.key);
146
+ evicted.push(e);
147
+ }
148
+ }
149
+ return evicted;
150
+ }
151
+
152
+ /** The entries an owner's latest build used (empty when it has none). */
153
+ owned(owner = DOCUMENT) {
154
+ return this.held.get(owner) || new Set();
155
+ }
156
+
157
+ evictUnheld() {
74
158
  const evicted = [];
75
159
  for (const [k, e] of this.entries) {
76
- if (e.epoch !== this.epoch) {
160
+ if (e.owners.size === 0) {
77
161
  this.entries.delete(k);
78
162
  evicted.push(e);
79
163
  }
@@ -119,6 +203,7 @@ export class GeometryCache {
119
203
  this.stats.generateMs += t1 - t0;
120
204
  entry.meshes[l] = m;
121
205
  entry.reports[l] = reports.length ? reports : null;
206
+ if (this.onGenerate) this.onGenerate(entry);
122
207
  return m;
123
208
  }
124
209
 
@@ -2,7 +2,10 @@
2
2
  // (zlib, which also provides brotli). Used for file-size statistics and the
3
3
  // benchmark, and for loading .gz files.
4
4
 
5
- const isNode = typeof process !== 'undefined' && !!(process.versions && process.versions.node) && typeof window === 'undefined';
5
+ // The browser bundles (scripts/build-browser.mjs) define __ES3D_BROWSER__ as
6
+ // true, so the Node branch and its zlib import are left out of them.
7
+ const browserBuild = typeof __ES3D_BROWSER__ !== 'undefined' && __ES3D_BROWSER__;
8
+ const isNode = !browserBuild && typeof process !== 'undefined' && !!(process.versions && process.versions.node) && typeof window === 'undefined';
6
9
 
7
10
  let zlibPromise = null;
8
11
  function zlib() {
package/src/io/convert.js CHANGED
@@ -87,6 +87,16 @@ export function detectKind(name, bytes) {
87
87
  }
88
88
 
89
89
  /** Options of the importers, with their defaults. */
90
+ /** {name, bytes} for each file: a File or Blob is read, a {name, bytes} object passes through. */
91
+ async function readFiles(files) {
92
+ return Promise.all(Array.from(files || [], async (f) => {
93
+ // a File has a bytes() method of its own (Blob.bytes), so test for data, not the name
94
+ if (f && f.bytes && typeof f.bytes === 'object') return f;
95
+ if (f && typeof f.arrayBuffer === 'function') return { name: f.name || 'file', bytes: new Uint8Array(await f.arrayBuffer()) };
96
+ throw new TypeError('importFiles takes {name, bytes} objects or File objects.');
97
+ }));
98
+ }
99
+
90
100
  export const IMPORT_DEFAULTS = {
91
101
  svg: { size: 10, depth: 0.04, bevel: 0, strokes: true, base: false },
92
102
  mesh: { precision: 'auto', normals: 'auto', uvs: true, smooth: 30 },
@@ -95,11 +105,14 @@ export const IMPORT_DEFAULTS = {
95
105
 
96
106
  /**
97
107
  * Convert input files into an ES3D document.
98
- * @param {{name: string, bytes: Uint8Array}[]} files the main file plus companions (.mtl, .bin)
108
+ * @param {Iterable<{name: string, bytes: Uint8Array}|Blob>} files the main file plus companions
109
+ * (.mtl, .bin): {name, bytes} objects, or the File objects of an <input type="file">
110
+ * or a drop (a FileList works too), which are read here
99
111
  * @param {{svg?: object, mesh?: object, stl?: object}} [opts] see IMPORT_DEFAULTS
100
112
  * @returns {Promise<{kind: string, name: string, title: string, text: string, warnings: string[], notes: string[]}>}
101
113
  */
102
114
  export async function importFiles(files, opts = {}) {
115
+ files = await readFiles(files);
103
116
  if (!files.length) throw new Error('No file was given.');
104
117
  const list = [];
105
118
  for (const f of files) {
package/src/pipeline.js CHANGED
@@ -34,7 +34,8 @@ export function elementDiagnostic(doc, d) {
34
34
  /**
35
35
  * Parse, validate, build and flatten a document.
36
36
  * @param {string|ArrayBuffer|Uint8Array} input
37
- * @param {{cache?: GeometryCache, time?: number, progress?: number, maxInstances?: number}} [opts]
37
+ * @param {{cache?: GeometryCache, owner?: *, time?: number, progress?: number, maxInstances?: number}} [opts]
38
+ * owner: whose build this is in a shared cache (GeometryCache.beginBuild); default the document
38
39
  */
39
40
  export function compile(input, opts = {}) {
40
41
  const t0 = now();
@@ -0,0 +1,143 @@
1
+ // The stage's compiler thread.
2
+ //
3
+ // The stage is one file. A page that calls stage.prepare() starts a worker
4
+ // from that same file; there it finds itself in a worker and serves compile
5
+ // requests instead of drawing (render/stage.js calls serveCompiler). The
6
+ // worker runs preparePayload (scene/prepared.js): parsing, validation,
7
+ // building, flattening and mesh generation, and sends the result back as
8
+ // transferred buffers.
9
+ //
10
+ // A worker script must come from the page's origin. A stage loaded from a CDN
11
+ // is started through a small module made in the page (a blob URL) that
12
+ // imports the stage by its URL, which the CDN's CORS headers allow. Where
13
+ // neither works (a content security policy without worker-src blob:, an old
14
+ // browser, Node), every request is compiled on the main thread instead, and
15
+ // the page does not need to know.
16
+
17
+ import { preparePayload } from '../scene/prepared.js';
18
+ import { early, inWorker } from './worker-boot.js';
19
+
20
+ /** True inside a dedicated worker. */
21
+ export function isWorkerScope() {
22
+ return inWorker;
23
+ }
24
+
25
+ // a worker that has not answered its first request by then is given up on
26
+ const FIRST_ANSWER_MS = 10000;
27
+
28
+ // keys whose meshes this worker sent; capped, so a long session does not grow it forever
29
+ const SENT_LIMIT = 200000;
30
+
31
+ /**
32
+ * Answer compile requests posted to this worker.
33
+ * @param {object} scope the worker's global scope
34
+ */
35
+ export function serveCompiler(scope) {
36
+ const sent = new Set();
37
+ const answer = (e) => {
38
+ const { id, input, time, progress, quality } = e.data || {};
39
+ if (sent.size > SENT_LIMIT) sent.clear();
40
+ let reply;
41
+ try {
42
+ reply = preparePayload(input, { time, progress, quality, sent });
43
+ } catch (err) {
44
+ scope.postMessage({ id, error: String(err && err.message ? err.message : err) });
45
+ return;
46
+ }
47
+ try {
48
+ scope.postMessage({ id, ...reply.message }, reply.transfer);
49
+ } catch (err) {
50
+ // something in the scene could not be cloned: the main thread compiles it
51
+ scope.postMessage({ id, ok: reply.message.ok, diagnostics: reply.message.diagnostics, hostOnly: reply.message.hostOnly, timings: reply.message.timings, lineText: reply.message.lineText, lineIndex: reply.message.lineIndex, main: true });
52
+ }
53
+ };
54
+ scope.onmessage = answer;
55
+ // requests that came while the module was still loading (worker-boot.js)
56
+ for (const e of early.splice(0)) answer(e);
57
+ }
58
+
59
+ /** Start a module worker running this file, or null where none can start. */
60
+ function startWorker(url) {
61
+ if (typeof Worker !== 'function' || !url) return null;
62
+ const sameOrigin = typeof location === 'undefined' || new URL(url).origin === location.origin;
63
+ try {
64
+ if (sameOrigin) return new Worker(url, { type: 'module' });
65
+ } catch {
66
+ // fall through to the blob module
67
+ }
68
+ try {
69
+ const blob = new Blob([`import ${JSON.stringify(url)};`], { type: 'text/javascript' });
70
+ return new Worker(URL.createObjectURL(blob), { type: 'module' });
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The page's side: posts documents to the worker and resolves each request
78
+ * with its payload. A failed worker (it did not load, or it crashed) answers
79
+ * every waiting and later request with null, which means: compile here.
80
+ */
81
+ export class CompilerClient {
82
+ /** @param {string} url this module's file (the stage bundle's import.meta.url) */
83
+ constructor(url) {
84
+ this.worker = startWorker(url);
85
+ this.failed = !this.worker;
86
+ this.pending = new Map();
87
+ this.next = 1;
88
+ if (!this.worker) return;
89
+ this.answered = false;
90
+ this.watchdog = 0;
91
+ this.worker.onmessage = (e) => {
92
+ const msg = e.data;
93
+ this.answered = true;
94
+ clearTimeout(this.watchdog);
95
+ const done = this.pending.get(msg.id);
96
+ if (!done) return;
97
+ this.pending.delete(msg.id);
98
+ done(msg.error ? null : msg);
99
+ };
100
+ const fail = () => {
101
+ clearTimeout(this.watchdog);
102
+ this.failed = true;
103
+ for (const done of this.pending.values()) done(null);
104
+ this.pending.clear();
105
+ try { this.worker.terminate(); } catch {}
106
+ };
107
+ this.worker.onerror = (e) => {
108
+ if (e && e.preventDefault) e.preventDefault();
109
+ fail();
110
+ };
111
+ this.worker.onmessageerror = fail;
112
+ this.fail = fail;
113
+ }
114
+
115
+ /**
116
+ * @param {string|ArrayBuffer|Uint8Array} input copied to the worker, not transferred
117
+ * @param {{time?: number, progress?: number, quality?: number}} opts
118
+ * @returns {Promise<object|null>} the payload, or null: compile on this thread
119
+ */
120
+ compile(input, opts) {
121
+ if (this.failed) return Promise.resolve(null);
122
+ const id = this.next++;
123
+ return new Promise((resolve) => {
124
+ this.pending.set(id, resolve);
125
+ // a worker that never starts (blocked, or its module failed silently)
126
+ // must not hold the page's documents: compile them here instead
127
+ if (!this.answered && !this.watchdog) this.watchdog = setTimeout(() => this.fail(), FIRST_ANSWER_MS);
128
+ try {
129
+ this.worker.postMessage({ id, input, time: opts.time, progress: opts.progress, quality: opts.quality });
130
+ } catch {
131
+ this.pending.delete(id);
132
+ resolve(null);
133
+ }
134
+ });
135
+ }
136
+
137
+ dispose() {
138
+ if (this.worker) this.worker.terminate();
139
+ for (const done of this.pending.values()) done(null);
140
+ this.pending.clear();
141
+ this.failed = true;
142
+ }
143
+ }