@es3d/es3d 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +86 -3
  3. package/docs/REFERENCE.md +581 -0
  4. package/docs/SPEC.md +417 -0
  5. package/package.json +50 -4
  6. package/scripts/es3d-cli.mjs +377 -0
  7. package/src/cache/geometry-store.js +392 -0
  8. package/src/cache/mesh-bundle.js +123 -0
  9. package/src/cache/sha256.js +73 -0
  10. package/src/format/ast.js +238 -0
  11. package/src/format/colors.js +93 -0
  12. package/src/format/compact-parser.js +171 -0
  13. package/src/format/compact-tokenizer.js +165 -0
  14. package/src/format/errors.js +247 -0
  15. package/src/format/json.js +55 -0
  16. package/src/format/limits.js +14 -0
  17. package/src/format/parse.js +92 -0
  18. package/src/format/schema.js +617 -0
  19. package/src/format/serializer.js +333 -0
  20. package/src/format/validator.js +330 -0
  21. package/src/format/value-error.js +12 -0
  22. package/src/format/value-format.js +314 -0
  23. package/src/format/values.js +459 -0
  24. package/src/format/xml-parser.js +136 -0
  25. package/src/format/xml-tokenizer.js +237 -0
  26. package/src/geometry/contour.js +86 -0
  27. package/src/geometry/extrude.js +175 -0
  28. package/src/geometry/gear.js +85 -0
  29. package/src/geometry/geometry-cache.js +159 -0
  30. package/src/geometry/lod-error.js +178 -0
  31. package/src/geometry/mesh.js +458 -0
  32. package/src/geometry/primitives.js +299 -0
  33. package/src/geometry/registry.js +645 -0
  34. package/src/geometry/revolve.js +136 -0
  35. package/src/geometry/sweep.js +441 -0
  36. package/src/index.js +47 -0
  37. package/src/io/compression.js +67 -0
  38. package/src/io/convert.js +236 -0
  39. package/src/io/download.js +13 -0
  40. package/src/io/external-scene.js +127 -0
  41. package/src/io/file-loader.js +28 -0
  42. package/src/io/gltf.js +630 -0
  43. package/src/io/mesh-import.js +104 -0
  44. package/src/io/obj.js +352 -0
  45. package/src/io/step-brep.js +439 -0
  46. package/src/io/step-exact.js +1149 -0
  47. package/src/io/step.js +1418 -0
  48. package/src/io/stl.js +193 -0
  49. package/src/io/svg-import.js +787 -0
  50. package/src/material/materials.js +154 -0
  51. package/src/math/mat4.js +475 -0
  52. package/src/math/random.js +30 -0
  53. package/src/math/trig.js +65 -0
  54. package/src/math/vec3.js +81 -0
  55. package/src/optimize/optimizer.js +579 -0
  56. package/src/path/path-flatten.js +322 -0
  57. package/src/path/path-parser.js +332 -0
  58. package/src/path/shapes.js +129 -0
  59. package/src/path/triangulate.js +517 -0
  60. package/src/pipeline.js +74 -0
  61. package/src/render/camera.js +168 -0
  62. package/src/render/frame-builder.js +362 -0
  63. package/src/render/geometry-pool.js +149 -0
  64. package/src/render/lighting.js +149 -0
  65. package/src/render/orbit-controls.js +237 -0
  66. package/src/render/renderer.js +249 -0
  67. package/src/render/scroll-timeline.js +104 -0
  68. package/src/render/shaders/glsl.js +285 -0
  69. package/src/render/shaders/wgsl.js +252 -0
  70. package/src/render/stage.js +352 -0
  71. package/src/render/texture-cache.js +40 -0
  72. package/src/render/textures.js +223 -0
  73. package/src/render/webgl2/webgl2-backend.js +532 -0
  74. package/src/render/webgpu/webgpu-backend.js +480 -0
  75. package/src/scene/animation.js +171 -0
  76. package/src/scene/camera-constraints.js +117 -0
  77. package/src/scene/flatten.js +754 -0
  78. package/src/scene/picking.js +161 -0
  79. package/src/scene/scene-graph.js +320 -0
  80. package/src/transform/transform.js +211 -0
  81. package/src/wasm/kernels-binary.js +3 -0
  82. package/src/wasm/kernels.js +385 -0
@@ -0,0 +1,252 @@
1
+ // WGSL shaders for the WebGPU backend. They mirror shaders/glsl.js.
2
+
3
+ import {
4
+ MAT_FLAG_MAP, MAT_FLAG_NORMAL_MAP, MAT_FLAG_UNLIT, MAT_FLAG_DOUBLE_SIDED, MAT_FLAG_MR_MAP,
5
+ MAT_FLAG_OCCLUSION, MAT_FLAG_EMISSIVE_MAP, MAT_FLAG_ALPHA_MASK,
6
+ } from '../lighting.js';
7
+
8
+ const STRUCTS = /* wgsl */ `
9
+ struct Frame {
10
+ viewProj: mat4x4f,
11
+ view: mat4x4f,
12
+ cameraPos: vec4f,
13
+ sky: vec4f,
14
+ ground: vec4f,
15
+ params: vec4f,
16
+ lightPos: array<vec4f, 16>,
17
+ lightDir: array<vec4f, 16>,
18
+ lightColor: array<vec4f, 16>,
19
+ lightExtra: array<vec4f, 16>,
20
+ };
21
+ struct Material {
22
+ baseColor: vec4f,
23
+ emissive: vec4f,
24
+ params: vec4f,
25
+ uvScale: vec4f,
26
+ overlay: vec4f,
27
+ };
28
+ @group(0) @binding(0) var<uniform> frame: Frame;
29
+ @group(1) @binding(0) var<uniform> material: Material;
30
+ @group(1) @binding(1) var samp: sampler;
31
+ @group(1) @binding(2) var baseTex: texture_2d<f32>;
32
+ @group(1) @binding(3) var normalTex: texture_2d<f32>;
33
+ @group(1) @binding(4) var mrTex: texture_2d<f32>;
34
+ @group(1) @binding(5) var occlusionTex: texture_2d<f32>;
35
+ @group(1) @binding(6) var emissiveTex: texture_2d<f32>;
36
+
37
+ struct VSIn {
38
+ @location(0) position: vec3f,
39
+ @location(1) normal: vec3f,
40
+ @location(2) uv: vec2f,
41
+ @location(3) m0: vec4f,
42
+ @location(4) m1: vec4f,
43
+ @location(5) m2: vec4f,
44
+ @location(6) m3: vec4f,
45
+ };
46
+
47
+ const PI = 3.14159265359;
48
+
49
+ fn linearToSrgb(c0: vec3f) -> vec3f {
50
+ let c = max(c0, vec3f(0.0));
51
+ return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
52
+ }
53
+ fn aces(x: vec3f) -> vec3f {
54
+ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
55
+ }
56
+ fn worldNormal(inp: VSIn) -> vec3f {
57
+ let c0 = inp.m0.xyz;
58
+ let c1 = inp.m1.xyz;
59
+ let c2 = inp.m2.xyz;
60
+ let cof = mat3x3f(cross(c1, c2), cross(c2, c0), cross(c0, c1));
61
+ let det = dot(c0, cross(c1, c2));
62
+ return cof * inp.normal * select(1.0, -1.0, det < 0.0);
63
+ }
64
+ `;
65
+
66
+ /** Vertex stage of the mesh modules; texture coordinates only for textured materials. */
67
+ const meshVertex = (textured) => /* wgsl */ `
68
+ struct VSOut {
69
+ @builtin(position) pos: vec4f,
70
+ @location(0) world: vec3f,
71
+ @location(1) normal: vec3f,
72
+ ${textured ? '@location(2) uv: vec2f,' : ''}
73
+ };
74
+
75
+ @vertex
76
+ fn vs_main(inp: VSIn) -> VSOut {
77
+ let M = mat4x4f(inp.m0, inp.m1, inp.m2, inp.m3);
78
+ let wp = M * vec4f(inp.position, 1.0);
79
+ var out: VSOut;
80
+ out.pos = frame.viewProj * wp;
81
+ out.world = wp.xyz;
82
+ out.normal = worldNormal(inp);
83
+ ${textured ? 'out.uv = inp.uv;' : ''}
84
+ return out;
85
+ }
86
+ `;
87
+
88
+ const BRDF = /* wgsl */ `
89
+ fn D_GGX(NdotH: f32, a: f32) -> f32 {
90
+ let a2 = a * a;
91
+ let d = NdotH * NdotH * (a2 - 1.0) + 1.0;
92
+ return a2 / (PI * d * d);
93
+ }
94
+ fn V_Smith(NdotV: f32, NdotL: f32, a: f32) -> f32 {
95
+ let a2 = a * a;
96
+ let gv = NdotL * sqrt(NdotV * NdotV * (1.0 - a2) + a2);
97
+ let gl = NdotV * sqrt(NdotL * NdotL * (1.0 - a2) + a2);
98
+ return 0.5 / max(gv + gl, 1e-5);
99
+ }
100
+ fn F_Schlick(f0: vec3f, VdotH: f32) -> vec3f {
101
+ return f0 + (vec3f(1.0) - f0) * pow(1.0 - VdotH, 5.0);
102
+ }
103
+ fn envBRDF(f0: vec3f, roughness: f32, NdotV: f32) -> vec3f {
104
+ let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);
105
+ let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);
106
+ let r = roughness * c0 + c1;
107
+ let a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y;
108
+ let AB = vec2f(-1.04, 1.04) * a004 + r.zw;
109
+ return f0 * AB.x + AB.y;
110
+ }
111
+ // one light's contribution, zero when it is behind the surface
112
+ fn shade(N: vec3f, V: vec3f, L: vec3f, radiance: vec3f, NdotV: f32, f0: vec3f, diffuse: vec3f, a: f32) -> vec3f {
113
+ let NdotL = dot(N, L);
114
+ if (NdotL <= 0.0) {
115
+ return vec3f(0.0);
116
+ }
117
+ let H = normalize(V + L);
118
+ let NdotH = max(dot(N, H), 0.0);
119
+ let VdotH = max(dot(V, H), 0.0);
120
+ let F = F_Schlick(f0, VdotH);
121
+ let spec = D_GGX(NdotH, a) * V_Smith(NdotV, NdotL, a) * F;
122
+ let kd = (vec3f(1.0) - F) * diffuse / PI;
123
+ return (kd + spec) * radiance * NdotL;
124
+ }
125
+ `;
126
+
127
+ /**
128
+ * Shader module for one material feature set (MAT_FLAG_* bits) and one light
129
+ * setup (lightTypes() of the frame), as meshFragmentShader() in
130
+ * glsl.js: everything the material does not use is compiled out, and the
131
+ * light loop is unrolled. Textures are sampled in uniform control flow (the
132
+ * double-sided normal flip is a select, not a branch); after an alpha-mask
133
+ * discard, lookups use explicit gradients taken before it.
134
+ */
135
+ export function meshShader(flags, lights) {
136
+ const has = (f) => (flags & f) !== 0;
137
+ const map = has(MAT_FLAG_MAP), normalMap = has(MAT_FLAG_NORMAL_MAP), mrMap = has(MAT_FLAG_MR_MAP);
138
+ const occlusion = has(MAT_FLAG_OCCLUSION), emissiveMap = has(MAT_FLAG_EMISSIVE_MAP);
139
+ const textured = map || normalMap || mrMap || occlusion || emissiveMap;
140
+ const grad = has(MAT_FLAG_ALPHA_MASK);
141
+ const sample = (tex) => (grad ? `textureSampleGrad(${tex}, samp, uv, ddx, ddy)` : `textureSample(${tex}, samp, uv)`);
142
+ const L = [STRUCTS, meshVertex(textured)];
143
+ if (!has(MAT_FLAG_UNLIT)) L.push(BRDF);
144
+ L.push('@fragment', 'fn fs_main(inp: VSOut, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4f {');
145
+ if (textured) L.push(' let uv = inp.uv * material.uvScale.xy;');
146
+ if ((textured && grad) || normalMap) L.push(' let ddx = dpdx(uv);', ' let ddy = dpdy(uv);');
147
+ if (normalMap) {
148
+ // WGSL derivatives use framebuffer y (down); negate dpdy to match GLSL's
149
+ // window-space dFdy (up) so the cotangent frame has the same handedness
150
+ L.push(' let dp1 = dpdx(inp.world);', ' let dp2 = -dpdy(inp.world);', ' let duv1 = ddx;', ' let duv2 = -ddy;');
151
+ }
152
+ L.push(' var base = material.baseColor;');
153
+ if (map) L.push(` base = base * ${sample('baseTex')};`);
154
+ if (grad) L.push(' if (base.a < material.params.w) {', ' discard;', ' }');
155
+ L.push(' var emitted = material.emissive.rgb;');
156
+ if (emissiveMap) L.push(` emitted = emitted * ${sample('emissiveTex')}.rgb;`);
157
+ if (has(MAT_FLAG_UNLIT)) {
158
+ L.push(' return vec4f(linearToSrgb(base.rgb + emitted), base.a);', '}');
159
+ return L.join('\n');
160
+ }
161
+ L.push(' var N = normalize(inp.normal);');
162
+ if (has(MAT_FLAG_DOUBLE_SIDED)) L.push(' N = select(-N, N, frontFacing);');
163
+ if (normalMap) {
164
+ L.push(` let dp2perp = cross(dp2, N);
165
+ let dp1perp = cross(N, dp1);
166
+ let T = dp2perp * duv1.x + dp1perp * duv2.x;
167
+ let B = dp2perp * duv1.y + dp1perp * duv2.y;
168
+ let invmax = inverseSqrt(max(max(dot(T, T), dot(B, B)), 1e-20));
169
+ let n0 = ${sample('normalTex')}.xyz * 2.0 - 1.0;
170
+ let n = vec3f(n0.xy * material.emissive.w, n0.z);
171
+ N = normalize(mat3x3f(T * invmax, B * invmax, N) * n);`);
172
+ }
173
+ L.push(` let V = normalize(frame.cameraPos.xyz - inp.world);
174
+ let NdotV = max(dot(N, V), 1e-4);
175
+ var rough = clamp(material.params.y, 0.045, 1.0);
176
+ var metal = clamp(material.params.x, 0.0, 1.0);`);
177
+ if (mrMap) {
178
+ L.push(` let mr = ${sample('mrTex')};
179
+ rough = clamp(rough * mr.g, 0.045, 1.0);
180
+ metal = clamp(metal * mr.b, 0.0, 1.0);`);
181
+ }
182
+ L.push(` let a = rough * rough;
183
+ let f0 = mix(vec3f(0.04), base.rgb, metal);
184
+ let diffuse = base.rgb * (1.0 - metal);
185
+ var color = vec3f(0.0);
186
+ var ambient = vec3f(0.0);`);
187
+ if (lights) lights.split(',').forEach((type, i) => L.push(lightTerm(Number(type), i)));
188
+ L.push(` let envI = frame.sky.w;
189
+ var hemi = mix(frame.ground.rgb, frame.sky.rgb, N.y * 0.5 + 0.5) * envI + ambient;
190
+ let R = reflect(-V, N);
191
+ var envSpec = mix(frame.ground.rgb, frame.sky.rgb, smoothstep(-0.25 - rough, 0.25 + rough, R.y)) * envI;
192
+ envSpec = mix(envSpec, hemi, rough * rough);`);
193
+ if (occlusion) {
194
+ L.push(` let ao = mix(1.0, ${sample('occlusionTex')}.r, material.uvScale.z);
195
+ hemi = hemi * ao;
196
+ envSpec = envSpec * ao;`);
197
+ }
198
+ L.push(` color = color + diffuse * hemi + envSpec * envBRDF(f0, rough, NdotV);
199
+ color = color + emitted;
200
+ color = color * frame.ground.w;
201
+ return vec4f(linearToSrgb(aces(color)), base.a);
202
+ }`);
203
+ return L.join('\n');
204
+ }
205
+
206
+ /** WGSL for light i of a known type (lighting.js: 0 directional, 1 point, 2 spot, 3 ambient, 4 hemisphere). */
207
+ function lightTerm(type, i) {
208
+ if (type === 3) return ` ambient = ambient + frame.lightColor[${i}].rgb;`;
209
+ if (type === 4) return ` ambient = ambient + mix(frame.lightExtra[${i}].yzw, frame.lightColor[${i}].rgb, N.y * 0.5 + 0.5);`;
210
+ if (type === 0) return ` color = color + shade(N, V, -frame.lightDir[${i}].xyz, frame.lightColor[${i}].rgb * PI, NdotV, f0, diffuse, a);`;
211
+ return ` {
212
+ let Lv = frame.lightPos[${i}].xyz - inp.world;
213
+ let d = length(Lv);
214
+ let L = Lv / max(d, 1e-5);
215
+ var att = 1.0 / max(d * d, 1e-4);
216
+ let range = frame.lightDir[${i}].w;
217
+ if (range > 0.0) {
218
+ let x = d / range;
219
+ let w = clamp(1.0 - x * x * x * x, 0.0, 1.0);
220
+ att = att * w * w;
221
+ }${type === 2 ? `\n att = att * smoothstep(frame.lightColor[${i}].w, frame.lightExtra[${i}].x, dot(-L, frame.lightDir[${i}].xyz));` : ''}
222
+ color = color + shade(N, V, L, frame.lightColor[${i}].rgb * PI * att, NdotV, f0, diffuse, a);
223
+ }`;
224
+ }
225
+
226
+ export const LINE_WGSL = /* wgsl */ `
227
+ ${STRUCTS}
228
+ @vertex
229
+ fn vs_line(inp: VSIn) -> @builtin(position) vec4f {
230
+ let M = mat4x4f(inp.m0, inp.m1, inp.m2, inp.m3);
231
+ let wp = M * vec4f(inp.position, 1.0);
232
+ return frame.viewProj * vec4f(mix(wp.xyz, frame.cameraPos.xyz, frame.params.z), 1.0);
233
+ }
234
+
235
+ @vertex
236
+ fn vs_normals(inp: VSIn) -> @builtin(position) vec4f {
237
+ let M = mat4x4f(inp.m0, inp.m1, inp.m2, inp.m3);
238
+ var wp = M * vec4f(inp.position, 1.0);
239
+ let n = normalize(worldNormal(inp));
240
+ wp = vec4f(wp.xyz + n * inp.uv.x * frame.params.y, 1.0);
241
+ return frame.viewProj * vec4f(mix(wp.xyz, frame.cameraPos.xyz, frame.params.z), 1.0);
242
+ }
243
+
244
+ @fragment
245
+ fn fs_line() -> @location(0) vec4f {
246
+ let flags = u32(material.params.z + 0.5);
247
+ if ((flags & 16u) != 0u) {
248
+ return material.overlay;
249
+ }
250
+ return vec4f(linearToSrgb(material.baseColor.rgb + material.emissive.rgb), material.baseColor.a);
251
+ }
252
+ `;
@@ -0,0 +1,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 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
+ }
@@ -0,0 +1,40 @@
1
+ // Backend-independent texture bookkeeping: each distinct texture spec (by key
2
+ // and usage) is generated or downloaded once; backends turn ready pixel data
3
+ // into GPU textures. Failures are reported, never silently ignored.
4
+
5
+ import { texturePixels } from './textures.js';
6
+
7
+ export class TextureCache {
8
+ constructor() {
9
+ this.entries = new Map(); // `${kind}:${key}` -> {status, pixels, error, gpu, spec, kind}
10
+ this.onReady = null; // called when an async texture finishes (request a redraw)
11
+ this.onError = null;
12
+ }
13
+
14
+ /** Get (and start loading) a texture; returns the entry (status 'pending'|'ready'|'failed'). */
15
+ get(spec, kind) {
16
+ const id = `${kind}:${spec.key}`;
17
+ let e = this.entries.get(id);
18
+ if (e) return e;
19
+ e = { id, spec, kind, status: 'pending', pixels: null, error: null, gpu: null };
20
+ this.entries.set(id, e);
21
+ texturePixels(spec, kind).then(
22
+ (px) => {
23
+ e.pixels = px;
24
+ e.status = 'ready';
25
+ if (this.onReady) this.onReady(e);
26
+ },
27
+ (err) => {
28
+ e.status = 'failed';
29
+ e.error = err;
30
+ if (this.onError) this.onError(e, err);
31
+ },
32
+ );
33
+ return e;
34
+ }
35
+
36
+ clear(destroy) {
37
+ for (const e of this.entries.values()) if (e.gpu && destroy) destroy(e.gpu);
38
+ this.entries.clear();
39
+ }
40
+ }