@es3d/es3d 1.0.0 → 1.0.2

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