@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.
- package/LICENSE +201 -201
- package/README.md +86 -86
- package/docs/SPEC.md +417 -417
- package/package.json +1 -1
- package/scripts/es3d-cli.mjs +377 -377
- package/src/cache/geometry-store.js +392 -392
- package/src/cache/mesh-bundle.js +123 -123
- package/src/cache/sha256.js +73 -73
- package/src/format/ast.js +238 -238
- package/src/format/colors.js +93 -93
- package/src/format/compact-parser.js +171 -171
- package/src/format/compact-tokenizer.js +165 -165
- package/src/format/errors.js +247 -247
- package/src/format/json.js +55 -55
- package/src/format/limits.js +14 -14
- package/src/format/parse.js +92 -92
- package/src/format/schema.js +617 -617
- package/src/format/serializer.js +333 -333
- package/src/format/validator.js +330 -330
- package/src/format/value-error.js +12 -12
- package/src/format/value-format.js +314 -314
- package/src/format/values.js +459 -459
- package/src/format/xml-parser.js +136 -136
- package/src/format/xml-tokenizer.js +237 -237
- package/src/geometry/contour.js +86 -86
- package/src/geometry/extrude.js +175 -175
- package/src/geometry/gear.js +85 -85
- package/src/geometry/geometry-cache.js +159 -159
- package/src/geometry/lod-error.js +178 -178
- package/src/geometry/mesh.js +458 -458
- package/src/geometry/primitives.js +299 -299
- package/src/geometry/registry.js +645 -645
- package/src/geometry/revolve.js +136 -136
- package/src/geometry/sweep.js +441 -441
- package/src/index.js +47 -47
- package/src/io/compression.js +67 -67
- package/src/io/convert.js +236 -236
- package/src/io/download.js +13 -13
- package/src/io/external-scene.js +127 -127
- package/src/io/file-loader.js +28 -28
- package/src/io/gltf.js +630 -630
- package/src/io/mesh-import.js +104 -104
- package/src/io/obj.js +352 -352
- package/src/io/step-brep.js +439 -439
- package/src/io/step-exact.js +1149 -1149
- package/src/io/step.js +1418 -1418
- package/src/io/stl.js +193 -193
- package/src/io/svg-import.js +787 -787
- package/src/material/materials.js +154 -154
- package/src/math/mat4.js +475 -475
- package/src/math/random.js +30 -30
- package/src/math/trig.js +65 -65
- package/src/math/vec3.js +81 -81
- package/src/optimize/optimizer.js +579 -579
- package/src/path/path-flatten.js +322 -322
- package/src/path/path-parser.js +332 -332
- package/src/path/shapes.js +129 -129
- package/src/path/triangulate.js +517 -517
- package/src/pipeline.js +74 -74
- package/src/render/camera.js +168 -168
- package/src/render/frame-builder.js +362 -362
- package/src/render/geometry-pool.js +149 -149
- package/src/render/lighting.js +149 -149
- package/src/render/orbit-controls.js +237 -237
- package/src/render/renderer.js +258 -249
- package/src/render/scroll-timeline.js +104 -104
- package/src/render/shaders/glsl.js +285 -285
- package/src/render/shaders/wgsl.js +252 -252
- package/src/render/stage.js +364 -352
- package/src/render/texture-cache.js +40 -40
- package/src/render/textures.js +223 -223
- package/src/render/webgl2/webgl2-backend.js +586 -532
- package/src/render/webgpu/webgpu-backend.js +482 -480
- package/src/scene/flatten.js +754 -754
- package/src/scene/picking.js +161 -161
- package/src/scene/scene-graph.js +320 -320
- package/src/transform/transform.js +211 -211
- package/src/wasm/kernels-binary.js +3 -3
- package/src/wasm/kernels.js +385 -385
package/src/render/stage.js
CHANGED
|
@@ -1,352 +1,364 @@
|
|
|
1
|
-
// A stage: an ES3D document on a canvas, ready for a web page.
|
|
2
|
-
//
|
|
3
|
-
// const stage = await createStage(canvas, { scroll: document.querySelector('#tour') });
|
|
4
|
-
// stage.show(source);
|
|
5
|
-
//
|
|
6
|
-
// It compiles the document, draws it with the renderer, and handles what a
|
|
7
|
-
// page needs around that:
|
|
8
|
-
//
|
|
9
|
-
// - Draws only when something visible changed (camera, animation, size, a
|
|
10
|
-
// texture arriving), and not at all while the canvas is scrolled away.
|
|
11
|
-
// - Applies the document's <controls>: orbit limits, collision with the scene,
|
|
12
|
-
// no dragging (mode="none") or a camera driven by page scroll (mode="scroll").
|
|
13
|
-
// - Drives timeline="scroll" animations from the scroll position of a page
|
|
14
|
-
// section (see ScrollTimeline), and builds the meshes the scroll path will
|
|
15
|
-
// need while the browser is idle, so scrolling does not stutter later.
|
|
16
|
-
// - Keeps geometry between documents: show() reuses the previous document's
|
|
17
|
-
// geometry cache, so swapping a colour re-tessellates and re-uploads nothing.
|
|
18
|
-
// - Keeps geometry between visits: the meshes a document's shapes produced
|
|
19
|
-
// are stored in the browser (IndexedDB, src/cache/geometry-store.js) once
|
|
20
|
-
// the page is idle, and read back instead of tessellated on the next visit.
|
|
21
|
-
//
|
|
22
|
-
// Reduced motion (prefers-reduced-motion) stops clock animations and the
|
|
23
|
-
// automatic orbit; scroll-driven cameras jump between stops.
|
|
24
|
-
|
|
25
|
-
import { compile } from '../pipeline.js';
|
|
26
|
-
import { updateDynamic } from '../scene/flatten.js';
|
|
27
|
-
import { pick } from '../scene/picking.js';
|
|
28
|
-
import { createRenderer, PROGRESSIVE_BUDGET_MS } from './renderer.js';
|
|
29
|
-
import { openGeometryStore, persistGeometry } from '../cache/geometry-store.js';
|
|
30
|
-
import { Camera } from './camera.js';
|
|
31
|
-
import { OrbitControls } from './orbit-controls.js';
|
|
32
|
-
import { ScrollTimeline } from './scroll-timeline.js';
|
|
33
|
-
|
|
34
|
-
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
35
|
-
const idle = (fn) => (typeof requestIdleCallback === 'function' ? requestIdleCallback(fn, { timeout: 500 }) : setTimeout(fn, 16));
|
|
36
|
-
|
|
37
|
-
/** Names (name or id) on an element and its ancestors, nearest first. */
|
|
38
|
-
export function ancestorNames(doc, uid) {
|
|
39
|
-
const names = [];
|
|
40
|
-
let el = doc && doc.byUid.get(uid);
|
|
41
|
-
while (el) {
|
|
42
|
-
const n = el.values && (el.values.name || el.values.id);
|
|
43
|
-
if (n) names.push(n);
|
|
44
|
-
el = el.parent;
|
|
45
|
-
}
|
|
46
|
-
return names;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Views sampled along a scroll path to warm up its meshes. */
|
|
50
|
-
const PREFETCH_SAMPLES = 24;
|
|
51
|
-
|
|
52
|
-
/** How long a new document waits for its stored meshes before drawing without them (ms). */
|
|
53
|
-
const RESTORE_WAIT_MS = 50;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @param {HTMLCanvasElement} canvas sized by its parent element
|
|
57
|
-
* @param {object} [opts]
|
|
58
|
-
* @param {boolean} [opts.playing=true] play clock animations
|
|
59
|
-
* @param {boolean} [opts.autoOrbit=true] turn slowly around the target until someone drags
|
|
60
|
-
* @param {number} [opts.orbitSpeed=0.22] radians per second
|
|
61
|
-
* @param {boolean} [opts.interactive=true] false: no dragging, whatever the document says
|
|
62
|
-
* @param {HTMLElement} [opts.scroll] section whose scroll position drives timeline="scroll"
|
|
63
|
-
* animations (default: the whole page)
|
|
64
|
-
* @param {string} [opts.backend] 'auto' | 'webgpu' | 'webgl2'
|
|
65
|
-
* @param {(dt:number, stage:object) => void} [opts.onFrame] called every animation frame
|
|
66
|
-
* @param {(hit: object|null) => void} [opts.onPick] click on the scene; hit.names lists name/id up the tree
|
|
67
|
-
* @param {() => void} [opts.onInteract] the visitor moved the camera
|
|
68
|
-
* @param {(message: string, result: object) => void} [opts.onError] the document did not compile
|
|
69
|
-
* @param {object|Promise<object>|false} [opts.geometryStore] where generated meshes are kept
|
|
70
|
-
* between visits (default: IndexedDB, openGeometryStore()); false keeps nothing
|
|
71
|
-
*/
|
|
72
|
-
export async function createStage(canvas, opts = {}) {
|
|
73
|
-
// opened while the renderer starts, so the first show() rarely waits for it
|
|
74
|
-
const persist = persistGeometry(opts.geometryStore === false ? null : opts.geometryStore || openGeometryStore());
|
|
75
|
-
const renderer = await createRenderer(canvas, { backend: opts.backend });
|
|
76
|
-
renderer.options.tessellationBudget = PROGRESSIVE_BUDGET_MS;
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
target.style.display = 'block';
|
|
80
|
-
const camera = new Camera();
|
|
81
|
-
const controls = new OrbitControls(target, camera);
|
|
82
|
-
const motion = !reducedMotion();
|
|
83
|
-
const state = {
|
|
84
|
-
result: null,
|
|
85
|
-
time: 0,
|
|
86
|
-
progress: 0,
|
|
87
|
-
last: 0,
|
|
88
|
-
disposed: false,
|
|
89
|
-
playing: opts.playing !== false && motion,
|
|
90
|
-
auto: opts.autoOrbit !== false && motion,
|
|
91
|
-
orbitSpeed: opts.orbitSpeed ?? 0.22,
|
|
92
|
-
onScreen: true,
|
|
93
|
-
// A frame is drawn only when something visible changed: the camera, the
|
|
94
|
-
// animation time or scroll progress, the size, the scene or a texture.
|
|
95
|
-
dirty: true,
|
|
96
|
-
sceneTime: NaN, // time and progress the animated instances were last evaluated at
|
|
97
|
-
sceneProgress: NaN,
|
|
98
|
-
width: 0,
|
|
99
|
-
height: 0,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
see(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
see(
|
|
129
|
-
see(
|
|
130
|
-
see(
|
|
131
|
-
see(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
state.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
state.
|
|
280
|
-
state.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
state.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (
|
|
324
|
-
if (state.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
state.
|
|
339
|
-
|
|
340
|
-
state.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
state.dirty =
|
|
345
|
-
renderer.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1
|
+
// A stage: an ES3D document on a canvas, ready for a web page.
|
|
2
|
+
//
|
|
3
|
+
// const stage = await createStage(canvas, { scroll: document.querySelector('#tour') });
|
|
4
|
+
// stage.show(source);
|
|
5
|
+
//
|
|
6
|
+
// It compiles the document, draws it with the renderer, and handles what a
|
|
7
|
+
// page needs around that:
|
|
8
|
+
//
|
|
9
|
+
// - Draws only when something visible changed (camera, animation, size, a
|
|
10
|
+
// texture arriving), and not at all while the canvas is scrolled away.
|
|
11
|
+
// - Applies the document's <controls>: orbit limits, collision with the scene,
|
|
12
|
+
// no dragging (mode="none") or a camera driven by page scroll (mode="scroll").
|
|
13
|
+
// - Drives timeline="scroll" animations from the scroll position of a page
|
|
14
|
+
// section (see ScrollTimeline), and builds the meshes the scroll path will
|
|
15
|
+
// need while the browser is idle, so scrolling does not stutter later.
|
|
16
|
+
// - Keeps geometry between documents: show() reuses the previous document's
|
|
17
|
+
// geometry cache, so swapping a colour re-tessellates and re-uploads nothing.
|
|
18
|
+
// - Keeps geometry between visits: the meshes a document's shapes produced
|
|
19
|
+
// are stored in the browser (IndexedDB, src/cache/geometry-store.js) once
|
|
20
|
+
// the page is idle, and read back instead of tessellated on the next visit.
|
|
21
|
+
//
|
|
22
|
+
// Reduced motion (prefers-reduced-motion) stops clock animations and the
|
|
23
|
+
// automatic orbit; scroll-driven cameras jump between stops.
|
|
24
|
+
|
|
25
|
+
import { compile } from '../pipeline.js';
|
|
26
|
+
import { updateDynamic } from '../scene/flatten.js';
|
|
27
|
+
import { pick } from '../scene/picking.js';
|
|
28
|
+
import { createRenderer, PROGRESSIVE_BUDGET_MS } from './renderer.js';
|
|
29
|
+
import { openGeometryStore, persistGeometry } from '../cache/geometry-store.js';
|
|
30
|
+
import { Camera } from './camera.js';
|
|
31
|
+
import { OrbitControls } from './orbit-controls.js';
|
|
32
|
+
import { ScrollTimeline } from './scroll-timeline.js';
|
|
33
|
+
|
|
34
|
+
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
35
|
+
const idle = (fn) => (typeof requestIdleCallback === 'function' ? requestIdleCallback(fn, { timeout: 500 }) : setTimeout(fn, 16));
|
|
36
|
+
|
|
37
|
+
/** Names (name or id) on an element and its ancestors, nearest first. */
|
|
38
|
+
export function ancestorNames(doc, uid) {
|
|
39
|
+
const names = [];
|
|
40
|
+
let el = doc && doc.byUid.get(uid);
|
|
41
|
+
while (el) {
|
|
42
|
+
const n = el.values && (el.values.name || el.values.id);
|
|
43
|
+
if (n) names.push(n);
|
|
44
|
+
el = el.parent;
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Views sampled along a scroll path to warm up its meshes. */
|
|
50
|
+
const PREFETCH_SAMPLES = 24;
|
|
51
|
+
|
|
52
|
+
/** How long a new document waits for its stored meshes before drawing without them (ms). */
|
|
53
|
+
const RESTORE_WAIT_MS = 50;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {HTMLCanvasElement} canvas sized by its parent element
|
|
57
|
+
* @param {object} [opts]
|
|
58
|
+
* @param {boolean} [opts.playing=true] play clock animations
|
|
59
|
+
* @param {boolean} [opts.autoOrbit=true] turn slowly around the target until someone drags
|
|
60
|
+
* @param {number} [opts.orbitSpeed=0.22] radians per second
|
|
61
|
+
* @param {boolean} [opts.interactive=true] false: no dragging, whatever the document says
|
|
62
|
+
* @param {HTMLElement} [opts.scroll] section whose scroll position drives timeline="scroll"
|
|
63
|
+
* animations (default: the whole page)
|
|
64
|
+
* @param {string} [opts.backend] 'auto' | 'webgpu' | 'webgl2'
|
|
65
|
+
* @param {(dt:number, stage:object) => void} [opts.onFrame] called every animation frame
|
|
66
|
+
* @param {(hit: object|null) => void} [opts.onPick] click on the scene; hit.names lists name/id up the tree
|
|
67
|
+
* @param {() => void} [opts.onInteract] the visitor moved the camera
|
|
68
|
+
* @param {(message: string, result: object) => void} [opts.onError] the document did not compile
|
|
69
|
+
* @param {object|Promise<object>|false} [opts.geometryStore] where generated meshes are kept
|
|
70
|
+
* between visits (default: IndexedDB, openGeometryStore()); false keeps nothing
|
|
71
|
+
*/
|
|
72
|
+
export async function createStage(canvas, opts = {}) {
|
|
73
|
+
// opened while the renderer starts, so the first show() rarely waits for it
|
|
74
|
+
const persist = persistGeometry(opts.geometryStore === false ? null : opts.geometryStore || openGeometryStore());
|
|
75
|
+
const renderer = await createRenderer(canvas, { backend: opts.backend });
|
|
76
|
+
renderer.options.tessellationBudget = PROGRESSIVE_BUDGET_MS;
|
|
77
|
+
const target = renderer.canvas || canvas;
|
|
78
|
+
const parent = target.parentElement;
|
|
79
|
+
target.style.display = 'block';
|
|
80
|
+
const camera = new Camera();
|
|
81
|
+
const controls = new OrbitControls(target, camera);
|
|
82
|
+
const motion = !reducedMotion();
|
|
83
|
+
const state = {
|
|
84
|
+
result: null,
|
|
85
|
+
time: 0,
|
|
86
|
+
progress: 0,
|
|
87
|
+
last: 0,
|
|
88
|
+
disposed: false,
|
|
89
|
+
playing: opts.playing !== false && motion,
|
|
90
|
+
auto: opts.autoOrbit !== false && motion,
|
|
91
|
+
orbitSpeed: opts.orbitSpeed ?? 0.22,
|
|
92
|
+
onScreen: true,
|
|
93
|
+
// A frame is drawn only when something visible changed: the camera, the
|
|
94
|
+
// animation time or scroll progress, the size, the scene or a texture.
|
|
95
|
+
dirty: true,
|
|
96
|
+
sceneTime: NaN, // time and progress the animated instances were last evaluated at
|
|
97
|
+
sceneProgress: NaN,
|
|
98
|
+
width: 0,
|
|
99
|
+
height: 0,
|
|
100
|
+
dpr: renderer.pixelRatio(typeof devicePixelRatio === 'number' ? devicePixelRatio : 1),
|
|
101
|
+
gpuEpoch: renderer.backend.resourceEpoch || 0,
|
|
102
|
+
timeline: null,
|
|
103
|
+
generation: 0, // bumps on show(), to cancel stale idle work
|
|
104
|
+
holdUntil: 0, // no drawing before this time: a new document's stored meshes are on their way
|
|
105
|
+
};
|
|
106
|
+
const invalidate = () => {
|
|
107
|
+
state.dirty = true;
|
|
108
|
+
};
|
|
109
|
+
renderer.textures.onReady = invalidate;
|
|
110
|
+
|
|
111
|
+
// Camera state at the last drawn frame; onFrame callbacks move the camera directly.
|
|
112
|
+
const seen = new Float64Array(15).fill(NaN);
|
|
113
|
+
let moved = false;
|
|
114
|
+
const see = (i, v) => {
|
|
115
|
+
if (seen[i] !== v) {
|
|
116
|
+
seen[i] = v;
|
|
117
|
+
moved = true;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const cameraMoved = () => {
|
|
121
|
+
const c = camera;
|
|
122
|
+
moved = false;
|
|
123
|
+
for (let k = 0; k < 3; k++) {
|
|
124
|
+
see(k, c.position[k]);
|
|
125
|
+
see(3 + k, c.target[k]);
|
|
126
|
+
see(6 + k, c.up[k]);
|
|
127
|
+
}
|
|
128
|
+
see(9, c.fov);
|
|
129
|
+
see(10, c.orthoHeight);
|
|
130
|
+
see(11, c.type === 'orthographic' ? 1 : 0);
|
|
131
|
+
see(12, c.near ?? -1);
|
|
132
|
+
see(13, c.refAspect);
|
|
133
|
+
see(14, c.fit === 'cover' ? 1 : c.fit === 'width' ? 2 : c.fit === 'height' ? 3 : 0);
|
|
134
|
+
return moved;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const meshOf = (entry, lod) => state.result.cache.mesh(entry, lod);
|
|
138
|
+
const lodVisible = (scene) => {
|
|
139
|
+
const lods = renderer.frames.lodStates(scene, camera.position, (p) => Math.hypot(p[0] - camera.position[0], p[1] - camera.position[1], p[2] - camera.position[2]));
|
|
140
|
+
return (i) => {
|
|
141
|
+
const ln = scene.lodNode[i];
|
|
142
|
+
return ln < 0 || (lods.visible[ln] && lods.active[ln] === scene.lodChild[i]);
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
const world = () => (state.result ? { scene: state.result.scene, meshOf, isVisible: lodVisible(state.result.scene) } : null);
|
|
146
|
+
|
|
147
|
+
controls.onChange = () => {
|
|
148
|
+
state.auto = false;
|
|
149
|
+
state.dirty = true;
|
|
150
|
+
if (opts.onInteract) opts.onInteract();
|
|
151
|
+
};
|
|
152
|
+
controls.onClick = (x, y) => {
|
|
153
|
+
if (!state.result || !opts.onPick) return;
|
|
154
|
+
const w = target.clientWidth;
|
|
155
|
+
const h = target.clientHeight;
|
|
156
|
+
if (!w || !h) return;
|
|
157
|
+
const scene = state.result.scene;
|
|
158
|
+
camera.update(w / h, renderer.backend.zeroToOne, scene.bounds);
|
|
159
|
+
const ray = camera.ray(x, y, w, h, renderer.backend.zeroToOne);
|
|
160
|
+
const hit = pick(scene, meshOf, ray.origin, ray.dir, lodVisible(scene));
|
|
161
|
+
opts.onPick(hit ? { ...hit, names: ancestorNames(state.result.doc, hit.uid) } : null);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
165
|
+
state.onScreen = entry.isIntersecting;
|
|
166
|
+
if (state.onScreen) state.dirty = true;
|
|
167
|
+
}, { threshold: 0.02 });
|
|
168
|
+
io.observe(parent || target);
|
|
169
|
+
// Size comes from the observer, so frames never read layout.
|
|
170
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
171
|
+
const box = entry.contentBoxSize && entry.contentBoxSize[0];
|
|
172
|
+
const w = Math.round(box ? box.inlineSize : entry.contentRect.width);
|
|
173
|
+
const h = Math.round(box ? box.blockSize : entry.contentRect.height);
|
|
174
|
+
if (w === state.width && h === state.height) return;
|
|
175
|
+
state.width = w;
|
|
176
|
+
state.height = h;
|
|
177
|
+
target.style.width = `${w}px`;
|
|
178
|
+
target.style.height = `${h}px`;
|
|
179
|
+
state.dirty = true;
|
|
180
|
+
});
|
|
181
|
+
ro.observe(parent || target);
|
|
182
|
+
|
|
183
|
+
const mode = () => (state.result && state.result.scene.controls ? state.result.scene.controls.mode : 'orbit');
|
|
184
|
+
// in scroll and none modes the document's first camera is the view
|
|
185
|
+
const followsDocument = () => mode() !== 'orbit' && state.result && state.result.scene.cameras.length > 0;
|
|
186
|
+
const view = () => ({ width: state.width, height: state.height, dpr: typeof devicePixelRatio === 'number' ? devicePixelRatio : 1 });
|
|
187
|
+
|
|
188
|
+
/** Build, while the browser is idle, the meshes for views sampled along the scroll path. */
|
|
189
|
+
const prefetchScrollPath = () => {
|
|
190
|
+
const gen = state.generation;
|
|
191
|
+
let k = 0;
|
|
192
|
+
const step = () => {
|
|
193
|
+
const res = state.result;
|
|
194
|
+
if (state.disposed || gen !== state.generation || !res || k > PREFETCH_SAMPLES) return;
|
|
195
|
+
if (!state.width || !state.height) return void setTimeout(() => idle(step), 200);
|
|
196
|
+
const p = k / PREFETCH_SAMPLES;
|
|
197
|
+
k++;
|
|
198
|
+
updateDynamic(res.scene, res.graph, state.time, p);
|
|
199
|
+
const cam = followsDocument() ? new Camera().fromSpec(res.scene.cameras[0]) : camera;
|
|
200
|
+
renderer.prepare(cam, view());
|
|
201
|
+
// put the scene back where the page is
|
|
202
|
+
updateDynamic(res.scene, res.graph, state.sceneTime, state.sceneProgress);
|
|
203
|
+
persist.changed(res.cache);
|
|
204
|
+
idle(step);
|
|
205
|
+
};
|
|
206
|
+
idle(step);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const configureScroll = () => {
|
|
210
|
+
const scene = state.result.scene;
|
|
211
|
+
if (!scene.animation.scroll) {
|
|
212
|
+
if (state.timeline) state.timeline.dispose();
|
|
213
|
+
state.timeline = null;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!state.timeline) {
|
|
217
|
+
const section = opts.scroll || document.scrollingElement || document.documentElement;
|
|
218
|
+
state.timeline = new ScrollTimeline(section);
|
|
219
|
+
}
|
|
220
|
+
state.timeline.update(0);
|
|
221
|
+
state.progress = state.timeline.value;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const api = {
|
|
225
|
+
camera,
|
|
226
|
+
renderer,
|
|
227
|
+
controls,
|
|
228
|
+
kind: renderer.kind,
|
|
229
|
+
get time() {
|
|
230
|
+
return state.time;
|
|
231
|
+
},
|
|
232
|
+
set time(v) {
|
|
233
|
+
state.time = v;
|
|
234
|
+
},
|
|
235
|
+
/** Scroll timeline position 0..1; set it to drive scroll animations without a page section. */
|
|
236
|
+
get progress() {
|
|
237
|
+
return state.progress;
|
|
238
|
+
},
|
|
239
|
+
set progress(v) {
|
|
240
|
+
if (state.timeline) {
|
|
241
|
+
state.timeline.dispose();
|
|
242
|
+
state.timeline = null;
|
|
243
|
+
}
|
|
244
|
+
state.progress = Math.min(1, Math.max(0, v));
|
|
245
|
+
},
|
|
246
|
+
get playing() {
|
|
247
|
+
return state.playing;
|
|
248
|
+
},
|
|
249
|
+
set playing(v) {
|
|
250
|
+
state.playing = !!v;
|
|
251
|
+
},
|
|
252
|
+
/** Draw the next frame even if nothing the stage tracks has changed. */
|
|
253
|
+
invalidate,
|
|
254
|
+
get result() {
|
|
255
|
+
return state.result;
|
|
256
|
+
},
|
|
257
|
+
/**
|
|
258
|
+
* Compile and show a document. Returns the compile result; on errors the
|
|
259
|
+
* previous document stays on screen.
|
|
260
|
+
* @param {string} source
|
|
261
|
+
* @param {{keepCamera?: boolean, time?: number}} [showOpts]
|
|
262
|
+
*/
|
|
263
|
+
show(source, showOpts = {}) {
|
|
264
|
+
if (showOpts.time !== undefined) state.time = showOpts.time;
|
|
265
|
+
// Reusing the cache keeps every mesh whose definition did not change
|
|
266
|
+
// (a new wrap or paint only changes materials), so nothing is re-tessellated
|
|
267
|
+
// and the renderer finds those meshes already on the GPU.
|
|
268
|
+
const result = compile(source, { time: state.time, progress: state.progress, cache: state.result ? state.result.cache : undefined });
|
|
269
|
+
if (!result.ok) {
|
|
270
|
+
const msg = result.diagnostics.map((d) => d.format()).join('\n\n');
|
|
271
|
+
if (opts.onError) opts.onError(msg, result);
|
|
272
|
+
else console.error(msg);
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
state.result = result;
|
|
276
|
+
state.generation++;
|
|
277
|
+
renderer.setScene(result.scene, meshOf);
|
|
278
|
+
configureScroll();
|
|
279
|
+
if (state.progress !== 0) updateDynamic(result.scene, result.graph, state.time, state.progress);
|
|
280
|
+
state.sceneTime = state.time;
|
|
281
|
+
state.sceneProgress = state.progress;
|
|
282
|
+
state.dirty = true;
|
|
283
|
+
const spec = result.scene.controls;
|
|
284
|
+
controls.configure(spec, { world });
|
|
285
|
+
if (opts.interactive === false) controls.enabled = false;
|
|
286
|
+
if (spec && spec.mode !== 'orbit') state.auto = false;
|
|
287
|
+
if (!showOpts.keepCamera || followsDocument()) {
|
|
288
|
+
if (result.scene.cameras.length) camera.fromSpec(result.scene.cameras[0]);
|
|
289
|
+
else camera.frame(result.scene.bounds);
|
|
290
|
+
}
|
|
291
|
+
// draw once the stored meshes are in, or without them after a short wait;
|
|
292
|
+
// the scroll path is prepared after that, so it only builds what was not stored
|
|
293
|
+
const gen = state.generation;
|
|
294
|
+
state.holdUntil = performance.now() + RESTORE_WAIT_MS;
|
|
295
|
+
persist.restore(result.cache).finally(() => {
|
|
296
|
+
if (gen !== state.generation) return;
|
|
297
|
+
state.holdUntil = 0;
|
|
298
|
+
state.dirty = true;
|
|
299
|
+
if (result.scene.animation.scroll) prefetchScrollPath();
|
|
300
|
+
});
|
|
301
|
+
return result;
|
|
302
|
+
},
|
|
303
|
+
dispose() {
|
|
304
|
+
state.disposed = true;
|
|
305
|
+
io.disconnect();
|
|
306
|
+
ro.disconnect();
|
|
307
|
+
if (state.timeline) state.timeline.dispose();
|
|
308
|
+
controls.dispose();
|
|
309
|
+
renderer.dispose();
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const frame = (now) => {
|
|
314
|
+
if (state.disposed) return;
|
|
315
|
+
requestAnimationFrame(frame);
|
|
316
|
+
const dt = state.last ? Math.min(0.05, (now - state.last) / 1000) : 0;
|
|
317
|
+
state.last = now;
|
|
318
|
+
if (!state.result || !state.onScreen) return;
|
|
319
|
+
if (!state.width || !state.height) return;
|
|
320
|
+
const res = state.result;
|
|
321
|
+
const scene = res.scene;
|
|
322
|
+
// a scene without clock animations looks the same at every time
|
|
323
|
+
if (state.playing && scene.animation.time) state.time += dt;
|
|
324
|
+
if (state.timeline && state.timeline.update(dt)) state.progress = state.timeline.value;
|
|
325
|
+
if (opts.onFrame) opts.onFrame(dt, api);
|
|
326
|
+
if (state.auto) {
|
|
327
|
+
const prev = controls.snapshot();
|
|
328
|
+
const dx = camera.position[0] - camera.target[0];
|
|
329
|
+
const dz = camera.position[2] - camera.target[2];
|
|
330
|
+
const a = state.orbitSpeed * dt;
|
|
331
|
+
const co = Math.cos(a);
|
|
332
|
+
const si = Math.sin(a);
|
|
333
|
+
camera.position[0] = camera.target[0] + dx * co - dz * si;
|
|
334
|
+
camera.position[2] = camera.target[2] + dx * si + dz * co;
|
|
335
|
+
controls.constrain(prev);
|
|
336
|
+
}
|
|
337
|
+
if (scene.dynamic.length && (state.time !== state.sceneTime || state.progress !== state.sceneProgress)) {
|
|
338
|
+
updateDynamic(scene, res.graph, state.time, state.progress);
|
|
339
|
+
state.sceneTime = state.time;
|
|
340
|
+
state.sceneProgress = state.progress;
|
|
341
|
+
if (followsDocument()) camera.fromSpec(scene.cameras[0]);
|
|
342
|
+
state.dirty = true;
|
|
343
|
+
}
|
|
344
|
+
if (cameraMoved()) state.dirty = true;
|
|
345
|
+
const dpr = renderer.pixelRatio(typeof devicePixelRatio === 'number' ? devicePixelRatio : 1);
|
|
346
|
+
if (dpr !== state.dpr) {
|
|
347
|
+
state.dpr = dpr;
|
|
348
|
+
state.dirty = true;
|
|
349
|
+
}
|
|
350
|
+
const epoch = renderer.backend.resourceEpoch || 0;
|
|
351
|
+
if (epoch !== state.gpuEpoch) {
|
|
352
|
+
state.gpuEpoch = epoch;
|
|
353
|
+
state.dirty = true;
|
|
354
|
+
}
|
|
355
|
+
if (!state.dirty || (state.holdUntil && now < state.holdUntil)) return;
|
|
356
|
+
state.dirty = false;
|
|
357
|
+
renderer.render(camera, { ...view(), time: state.time });
|
|
358
|
+
// levels drawn coarser than wanted: refine them on the next frame
|
|
359
|
+
if (renderer.stats.refining) state.dirty = true;
|
|
360
|
+
persist.changed(res.cache);
|
|
361
|
+
};
|
|
362
|
+
requestAnimationFrame(frame);
|
|
363
|
+
return api;
|
|
364
|
+
}
|