@es3d/es3d 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/browser/es3d-stage.min.js +243 -10218
- package/browser/es3d.min.js +534 -0
- package/docs/SPEC.md +1 -1
- package/docs/STAGE.md +95 -0
- package/package.json +6 -3
- package/src/browser.js +16 -0
- package/src/cache/geometry-store.js +56 -18
- package/src/geometry/geometry-cache.js +93 -8
- package/src/io/compression.js +4 -1
- package/src/io/convert.js +14 -1
- package/src/pipeline.js +2 -1
- package/src/render/compile-worker.js +143 -0
- package/src/render/frame-builder.js +151 -79
- package/src/render/renderer.js +28 -2
- package/src/render/stage.js +305 -50
- package/src/render/worker-boot.js +14 -0
- package/src/scene/fragments.js +527 -0
- package/src/scene/picking.js +31 -2
- package/src/scene/prepared.js +398 -0
- package/src/scene/scene-graph.js +1 -1
- package/src/scene/scene-set.js +251 -0
package/docs/SPEC.md
CHANGED
|
@@ -195,7 +195,7 @@ The reference reader keeps up to four levels. At quality 1 the segment count is
|
|
|
195
195
|
|
|
196
196
|
A `segments` attribute (or both torus segment counts) requests one fixed tessellation, which is part of the shape. A `segments="6"` cylinder is a hexagonal prism at every distance. Exact shapes (box, plane, polygon, hairline, authored mesh) have one level and a bound of 0.
|
|
197
197
|
|
|
198
|
-
Each level stores its bound. A frame may draw a level only while `bound × scale × pixelsPerUnit` is at most 1. `scale` is the longest column of the instance matrix. `pixelsPerUnit` is the projected size of
|
|
198
|
+
Each level stores its bound. A frame may draw a level only while `bound × scale × pixelsPerUnit` is at most 1. `scale` is the longest column of the instance matrix. `pixelsPerUnit` is the largest projected size, in CSS pixels, of a unit length at the centre of the instance's bounding sphere. For a perspective view it is F·d/z², where F = viewportHeight / (2·tan(verticalFov/2)), d is the distance from the eye to the centre and z is its depth along the view axis; d²/z² is limited to its value at the corners of the view, and that limit is used when z ≤ 0. For an orthographic view it is viewportHeight divided by the view's height. The reference reader picks the coarsest level that satisfies that test. A renderer `lodBias` above 1 (default 1) divides the one-pixel budget; it is not a document attribute. While a level is still being generated, a renderer may draw a coarser one in its place for a few frames (progressive refinement), as long as it keeps drawing until every level passes the test. For a unit sphere on the view axis the switches fall near 59, 15 and 7 pixels of the sphere's own radius.
|
|
199
199
|
|
|
200
200
|
Closed shapes must produce closed, outward-facing surfaces. Open or flat shapes (planes, polygons, paths, open revolves, `caps="false"`) are rendered double-sided.
|
|
201
201
|
|
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.
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/src/io/compression.js
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
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();
|