@es3d/es3d 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +86 -86
  3. package/docs/SPEC.md +417 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +377 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +92 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf.js +630 -630
  42. package/src/io/mesh-import.js +104 -104
  43. package/src/io/obj.js +352 -352
  44. package/src/io/step-brep.js +439 -439
  45. package/src/io/step-exact.js +1149 -1149
  46. package/src/io/step.js +1418 -1418
  47. package/src/io/stl.js +193 -193
  48. package/src/io/svg-import.js +787 -787
  49. package/src/material/materials.js +154 -154
  50. package/src/math/mat4.js +475 -475
  51. package/src/math/random.js +30 -30
  52. package/src/math/trig.js +65 -65
  53. package/src/math/vec3.js +81 -81
  54. package/src/optimize/optimizer.js +579 -579
  55. package/src/path/path-flatten.js +322 -322
  56. package/src/path/path-parser.js +332 -332
  57. package/src/path/shapes.js +129 -129
  58. package/src/path/triangulate.js +517 -517
  59. package/src/pipeline.js +74 -74
  60. package/src/render/camera.js +168 -168
  61. package/src/render/frame-builder.js +362 -362
  62. package/src/render/geometry-pool.js +149 -149
  63. package/src/render/lighting.js +149 -149
  64. package/src/render/orbit-controls.js +237 -237
  65. package/src/render/renderer.js +258 -249
  66. package/src/render/scroll-timeline.js +104 -104
  67. package/src/render/shaders/glsl.js +285 -285
  68. package/src/render/shaders/wgsl.js +252 -252
  69. package/src/render/stage.js +364 -352
  70. package/src/render/texture-cache.js +40 -40
  71. package/src/render/textures.js +223 -223
  72. package/src/render/webgl2/webgl2-backend.js +586 -532
  73. package/src/render/webgpu/webgpu-backend.js +482 -480
  74. package/src/scene/flatten.js +754 -754
  75. package/src/scene/picking.js +161 -161
  76. package/src/scene/scene-graph.js +320 -320
  77. package/src/transform/transform.js +211 -211
  78. package/src/wasm/kernels-binary.js +3 -3
  79. package/src/wasm/kernels.js +385 -385
@@ -1,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 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 { 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
+ }