@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
package/src/io/gltf.js CHANGED
@@ -1,630 +1,630 @@
1
- // glTF 2.0 binary (GLB) export and import.
2
- //
3
- // Export modes:
4
- // nodes one node per instance referencing a shared mesh (glTF's native
5
- // reuse; hierarchy is flattened because glTF nodes cannot be shared)
6
- // instanced EXT_mesh_gpu_instancing: one node per (mesh, material) with
7
- // TRANSLATION / ROTATION / SCALE instance accessors
8
- // The importer understands both, plus node hierarchies, TRS and matrices.
9
-
10
- import * as mat4 from '../math/mat4.js';
11
- import { Mesh } from '../geometry/mesh.js';
12
- import { linearToSrgb } from '../format/colors.js';
13
- import { exportableInstances, materialNames, buildExternalScene, texturedGeometries, InstanceTable } from './external-scene.js';
14
-
15
- const FLOAT = 5126;
16
- const INT8 = 5120;
17
- const INT16 = 5122;
18
- const UINT16 = 5123;
19
- const UINT32 = 5125;
20
- const ARRAY_BUFFER = 34962;
21
- const ELEMENT_ARRAY_BUFFER = 34963;
22
-
23
- const r7 = (v) => (Math.abs(v) < 1e-12 ? 0 : Number(v.toPrecision(7)));
24
-
25
- class BinWriter {
26
- constructor() {
27
- this.chunks = [];
28
- this.length = 0;
29
- this.views = [];
30
- this.accessors = [];
31
- }
32
- add(typed, target, byteStride = 0) {
33
- const pad = (4 - (this.length % 4)) % 4;
34
- if (pad) {
35
- this.chunks.push(new Uint8Array(pad));
36
- this.length += pad;
37
- }
38
- const bytes = new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength);
39
- const view = { buffer: 0, byteOffset: this.length, byteLength: bytes.byteLength };
40
- if (byteStride) view.byteStride = byteStride;
41
- if (target) view.target = target;
42
- this.chunks.push(bytes.slice());
43
- this.length += bytes.byteLength;
44
- this.views.push(view);
45
- return this.views.length - 1;
46
- }
47
- accessor(typed, componentType, type, count, target, minmax = null, { byteStride = 0, normalized = false } = {}) {
48
- const bufferView = this.add(typed, target, byteStride);
49
- const a = { bufferView, componentType, count, type };
50
- if (normalized) a.normalized = true;
51
- if (minmax) {
52
- a.min = minmax.min;
53
- a.max = minmax.max;
54
- }
55
- this.accessors.push(a);
56
- return this.accessors.length - 1;
57
- }
58
- bytes() {
59
- const out = new Uint8Array(Math.ceil(this.length / 4) * 4);
60
- let o = 0;
61
- for (const c of this.chunks) {
62
- out.set(c, o);
63
- o += c.byteLength;
64
- }
65
- return out;
66
- }
67
- }
68
-
69
- function minMax3(p) {
70
- const min = [Infinity, Infinity, Infinity];
71
- const max = [-Infinity, -Infinity, -Infinity];
72
- for (let i = 0; i < p.length; i += 3) {
73
- for (let k = 0; k < 3; k++) {
74
- if (p[i + k] < min[k]) min[k] = p[i + k];
75
- if (p[i + k] > max[k]) max[k] = p[i + k];
76
- }
77
- }
78
- if (!p.length) return { min: [0, 0, 0], max: [0, 0, 0] };
79
- return { min: min.map((v) => Math.fround(v)), max: max.map((v) => Math.fround(v)) };
80
- }
81
-
82
- /**
83
- * KHR_mesh_quantization: positions as normalized int16 in the mesh's bounding
84
- * box (the box's centre and half extent go into the node transform), normals
85
- * as normalized int8. Both padded to 4-byte strides as glTF requires.
86
- * @returns {{positions: Int16Array, normals: Int8Array, center: number[], half: number[], min: number[], max: number[]}}
87
- */
88
- function quantizeMesh(mesh) {
89
- const p = mesh.positions, n = mesh.normals, count = mesh.vertexCount;
90
- const { min, max } = minMax3(p);
91
- const center = [0, 1, 2].map((k) => (min[k] + max[k]) / 2);
92
- const half = [0, 1, 2].map((k) => Math.max((max[k] - min[k]) / 2, 1e-12));
93
- const positions = new Int16Array(count * 4);
94
- const normals = new Int8Array(count * 4);
95
- const qmin = [32767, 32767, 32767], qmax = [-32767, -32767, -32767];
96
- for (let i = 0; i < count; i++) {
97
- // normals live in the quantized (non-uniformly scaled) space as well: the
98
- // node's normal matrix undoes S(half), so store normalize(half * n)
99
- const nx = n[i * 3] * half[0], ny = n[i * 3 + 1] * half[1], nz = n[i * 3 + 2] * half[2];
100
- const nl = Math.hypot(nx, ny, nz) || 1;
101
- const nn = [nx / nl, ny / nl, nz / nl];
102
- for (let k = 0; k < 3; k++) {
103
- const q = Math.max(-32767, Math.min(32767, Math.round(((p[i * 3 + k] - center[k]) / half[k]) * 32767)));
104
- positions[i * 4 + k] = q;
105
- if (q < qmin[k]) qmin[k] = q;
106
- if (q > qmax[k]) qmax[k] = q;
107
- normals[i * 4 + k] = Math.max(-127, Math.min(127, Math.round(nn[k] * 127)));
108
- }
109
- }
110
- return { positions, normals, center, half, min: qmin, max: qmax };
111
- }
112
-
113
- /**
114
- * @param {import('../scene/flatten.js').RenderScene} scene
115
- * @param {(entry:object, lod:number)=>Mesh} meshOf
116
- * @param {{lod?: number, instancing?: boolean, uvs?: 'auto'|boolean, quantize?: boolean}} [opts]
117
- * uvs: 'auto' writes TEXCOORD_0 only for geometry drawn with a textured material;
118
- * quantize: KHR_mesh_quantization (int16 positions, int8 normals; lossy)
119
- * @returns {Uint8Array} GLB bytes
120
- */
121
- export function exportGLB(scene, meshOf, { lod = 0, instancing = false, uvs = 'auto', quantize = false } = {}) {
122
- const bin = new BinWriter();
123
- const names = materialNames(scene.materials);
124
- const json = {
125
- asset: { version: '2.0', generator: 'ES3D reference implementation' },
126
- scene: 0,
127
- scenes: [{ nodes: [] }],
128
- nodes: [],
129
- meshes: [],
130
- materials: scene.materials.map((m, i) => {
131
- const mat = {
132
- name: names[i],
133
- pbrMetallicRoughness: {
134
- baseColorFactor: [r7(m.baseColor[0]), r7(m.baseColor[1]), r7(m.baseColor[2]), r7(m.opacity)],
135
- metallicFactor: r7(m.metallic),
136
- roughnessFactor: r7(m.roughness),
137
- },
138
- };
139
- if (m.emissive.some((v) => v > 0)) mat.emissiveFactor = m.emissive.map((v) => r7(Math.min(1, v)));
140
- if (m.alphaMode === 'mask') {
141
- mat.alphaMode = 'MASK';
142
- mat.alphaCutoff = m.alphaCutoff ?? 0.5;
143
- } else if (m.alphaMode === 'blend' || (m.alphaMode !== 'opaque' && m.opacity < 1)) mat.alphaMode = 'BLEND';
144
- if (m.doubleSided) mat.doubleSided = true;
145
- return mat;
146
- }),
147
- accessors: bin.accessors,
148
- bufferViews: bin.views,
149
- buffers: [{ byteLength: 0 }],
150
- };
151
- // geometry data, once per (geometry entry, lod)
152
- const geomData = new Map();
153
- const meshFor = new Map(); // `${geom}|${mat}` -> glTF mesh index
154
- const textured = uvs === 'auto' ? texturedGeometries(scene) : null;
155
- const geomAccessors = (gi) => {
156
- let g = geomData.get(gi);
157
- if (g) return g;
158
- const entry = scene.geometries[gi];
159
- const mesh = meshOf(entry, Math.min(lod, entry.lods - 1));
160
- let attributes;
161
- let dequant = null;
162
- if (quantize && mesh.kind === 'triangles' && mesh.vertexCount) {
163
- const q = quantizeMesh(mesh);
164
- attributes = {
165
- POSITION: bin.accessor(q.positions, INT16, 'VEC3', mesh.vertexCount, ARRAY_BUFFER, { min: q.min, max: q.max }, { byteStride: 8, normalized: true }),
166
- NORMAL: bin.accessor(q.normals, INT8, 'VEC3', mesh.vertexCount, ARRAY_BUFFER, null, { byteStride: 4, normalized: true }),
167
- };
168
- // vertex = centre + half * normalized: folded into every node using this mesh
169
- dequant = mat4.createF64();
170
- dequant[0] = q.half[0];
171
- dequant[5] = q.half[1];
172
- dequant[10] = q.half[2];
173
- dequant[12] = q.center[0];
174
- dequant[13] = q.center[1];
175
- dequant[14] = q.center[2];
176
- } else {
177
- attributes = { POSITION: bin.accessor(mesh.positions, FLOAT, 'VEC3', mesh.vertexCount, ARRAY_BUFFER, minMax3(mesh.positions)) };
178
- if (mesh.kind === 'triangles') attributes.NORMAL = bin.accessor(mesh.normals, FLOAT, 'VEC3', mesh.vertexCount, ARRAY_BUFFER);
179
- }
180
- if (mesh.kind === 'triangles' && (uvs === true || (textured && textured.has(gi)))) {
181
- attributes.TEXCOORD_0 = bin.accessor(mesh.uvs, FLOAT, 'VEC2', mesh.vertexCount, ARRAY_BUFFER);
182
- }
183
- const small = mesh.vertexCount < 65536;
184
- const idx = small ? Uint16Array.from(mesh.indices) : mesh.indices;
185
- const indices = bin.accessor(idx, small ? UINT16 : UINT32, 'SCALAR', idx.length, ELEMENT_ARRAY_BUFFER);
186
- g = { attributes, indices, mode: mesh.kind === 'lines' ? 1 : 4, empty: mesh.vertexCount === 0, dequant };
187
- geomData.set(gi, g);
188
- return g;
189
- };
190
- const gltfMesh = (gi, mi) => {
191
- const key = `${gi}|${mi}`;
192
- let k = meshFor.get(key);
193
- if (k !== undefined) return k;
194
- const g = geomAccessors(gi);
195
- k = json.meshes.length;
196
- json.meshes.push({ primitives: [{ attributes: g.attributes, indices: g.indices, material: mi, mode: g.mode }] });
197
- meshFor.set(key, k);
198
- return k;
199
- };
200
- const list = exportableInstances(scene);
201
- const m = new Float64Array(16);
202
- // world matrix of instance i, including the mesh's dequantization (KHR_mesh_quantization)
203
- const instanceMatrix = (i) => {
204
- for (let k = 0; k < 16; k++) m[k] = scene.matrices[i * 16 + k];
205
- const dq = geomAccessors(scene.geom[i]).dequant;
206
- if (dq) mat4.multiply(m, m, dq);
207
- return m;
208
- };
209
- const nodeFor = (i, meshIndex) => {
210
- instanceMatrix(i);
211
- const node = { mesh: meshIndex };
212
- const d = mat4.decompose(m);
213
- if (d) {
214
- if (d.translation.some((v) => Math.abs(v) > 1e-9)) node.translation = d.translation.map(r7);
215
- if (Math.abs(d.rotation[3] - 1) > 1e-9) node.rotation = d.rotation.map(r7);
216
- if (d.scale.some((v) => Math.abs(v - 1) > 1e-9)) node.scale = d.scale.map(r7);
217
- } else {
218
- node.matrix = Array.from(m, r7);
219
- }
220
- json.nodes.push(node);
221
- json.scenes[0].nodes.push(json.nodes.length - 1);
222
- };
223
- if (quantize) {
224
- json.extensionsUsed = ['KHR_mesh_quantization'];
225
- json.extensionsRequired = ['KHR_mesh_quantization'];
226
- }
227
- if (!instancing) {
228
- for (const i of list) {
229
- if (geomAccessors(scene.geom[i]).empty) continue;
230
- nodeFor(i, gltfMesh(scene.geom[i], scene.mat[i]));
231
- }
232
- } else {
233
- json.extensionsUsed = [...(json.extensionsUsed || []), 'EXT_mesh_gpu_instancing'];
234
- const groups = new Map();
235
- for (const i of list) {
236
- const key = `${scene.geom[i]}|${scene.mat[i]}`;
237
- let g = groups.get(key);
238
- if (!g) groups.set(key, (g = []));
239
- g.push(i);
240
- }
241
- for (const [key, members] of groups) {
242
- const [gi, mi] = key.split('|').map(Number);
243
- if (geomAccessors(gi).empty) continue;
244
- const meshIndex = gltfMesh(gi, mi);
245
- // instancing requires shear-free transforms
246
- const trs = [];
247
- let ok = members.length > 1;
248
- for (const i of members) {
249
- const d = mat4.decompose(instanceMatrix(i));
250
- if (!d) {
251
- ok = false;
252
- break;
253
- }
254
- trs.push(d);
255
- }
256
- if (!ok) {
257
- for (const i of members) nodeFor(i, meshIndex);
258
- continue;
259
- }
260
- const T = new Float32Array(members.length * 3);
261
- const R = new Float32Array(members.length * 4);
262
- const S = new Float32Array(members.length * 3);
263
- trs.forEach((d, k) => {
264
- T.set(d.translation, k * 3);
265
- R.set(d.rotation, k * 4);
266
- S.set(d.scale, k * 3);
267
- });
268
- const attributes = {
269
- TRANSLATION: bin.accessor(T, FLOAT, 'VEC3', members.length),
270
- ROTATION: bin.accessor(R, FLOAT, 'VEC4', members.length),
271
- SCALE: bin.accessor(S, FLOAT, 'VEC3', members.length),
272
- };
273
- json.nodes.push({ mesh: meshIndex, extensions: { EXT_mesh_gpu_instancing: { attributes } } });
274
- json.scenes[0].nodes.push(json.nodes.length - 1);
275
- }
276
- }
277
- const binBytes = bin.bytes();
278
- json.buffers[0].byteLength = binBytes.byteLength;
279
- if (!json.meshes.length) {
280
- delete json.meshes;
281
- }
282
- const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
283
- const jsonPadded = new Uint8Array(Math.ceil(jsonBytes.length / 4) * 4).fill(0x20);
284
- jsonPadded.set(jsonBytes);
285
- const total = 12 + 8 + jsonPadded.length + (binBytes.length ? 8 + binBytes.length : 0);
286
- const out = new Uint8Array(total);
287
- const dv = new DataView(out.buffer);
288
- dv.setUint32(0, 0x46546c67, true); // 'glTF'
289
- dv.setUint32(4, 2, true);
290
- dv.setUint32(8, total, true);
291
- dv.setUint32(12, jsonPadded.length, true);
292
- dv.setUint32(16, 0x4e4f534a, true); // 'JSON'
293
- out.set(jsonPadded, 20);
294
- if (binBytes.length) {
295
- const o = 20 + jsonPadded.length;
296
- dv.setUint32(o, binBytes.length, true);
297
- dv.setUint32(o + 4, 0x004e4942, true); // 'BIN\0'
298
- out.set(binBytes, o + 8);
299
- }
300
- return out;
301
- }
302
-
303
- // ---------------------------------------------------------------------------
304
- // import
305
-
306
- const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
307
- const TYPED = { 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array };
308
- const GETTERS = {
309
- 5120: (v, o) => v.getInt8(o),
310
- 5121: (v, o) => v.getUint8(o),
311
- 5122: (v, o) => v.getInt16(o, true),
312
- 5123: (v, o) => v.getUint16(o, true),
313
- 5125: (v, o) => v.getUint32(o, true),
314
- 5126: (v, o) => v.getFloat32(o, true),
315
- };
316
- const NORMALIZE = { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 };
317
-
318
- export function isGLB(bytes) {
319
- return bytes.length >= 12 && bytes[0] === 0x67 && bytes[1] === 0x6c && bytes[2] === 0x54 && bytes[3] === 0x46;
320
- }
321
-
322
- /**
323
- * Typed views of accessor data over a list of buffers.
324
- * Vertex attributes (asFloat) come back as Float32Array, normalized integers mapped to [-1,1] / [0,1].
325
- */
326
- function makeAccessor(json, buffers) {
327
- const cache = new Map();
328
- return (i, asFloat = false) => {
329
- const key = asFloat ? -1 - i : i;
330
- if (cache.has(key)) return cache.get(key);
331
- const a = json.accessors[i];
332
- if (!a) throw new Error(`glTF accessor ${i} does not exist.`);
333
- if (a.sparse) throw new Error(`glTF accessor ${i} is sparse; sparse accessors are not supported.`);
334
- const Ctor = TYPED[a.componentType];
335
- if (!Ctor) throw new Error(`Unsupported accessor component type ${a.componentType}.`);
336
- const comps = COMPONENTS[a.type];
337
- const n = comps * a.count;
338
- let arr;
339
- if (a.bufferView === undefined) {
340
- arr = new Ctor(n); // no data: all zeros (glTF 2.0, 3.6.2.1)
341
- } else {
342
- const view = json.bufferViews[a.bufferView];
343
- const buf = buffers[view.buffer];
344
- if (!buf) throw new Error(`glTF buffer ${view.buffer} is missing.`);
345
- const byteOffset = buf.byteOffset + (view.byteOffset || 0) + (a.byteOffset || 0);
346
- const stride = view.byteStride || 0;
347
- const elemBytes = comps * Ctor.BYTES_PER_ELEMENT;
348
- const end = byteOffset + (a.count ? (stride || elemBytes) * (a.count - 1) + elemBytes : 0);
349
- if (end > buf.byteOffset + buf.byteLength) throw new Error(`glTF accessor ${i} reads past the end of buffer ${view.buffer}.`);
350
- if (stride && stride !== elemBytes) {
351
- arr = new Ctor(n);
352
- const src = new DataView(buf.buffer);
353
- const get = GETTERS[a.componentType];
354
- for (let k = 0; k < a.count; k++) {
355
- for (let c = 0; c < comps; c++) arr[k * comps + c] = get(src, byteOffset + k * stride + c * Ctor.BYTES_PER_ELEMENT);
356
- }
357
- } else if (byteOffset % Ctor.BYTES_PER_ELEMENT === 0) {
358
- arr = new Ctor(buf.buffer, byteOffset, n);
359
- } else {
360
- arr = new Ctor(buf.buffer.slice(byteOffset, byteOffset + n * Ctor.BYTES_PER_ELEMENT));
361
- }
362
- }
363
- if (asFloat && !(arr instanceof Float32Array)) {
364
- const f = new Float32Array(arr.length);
365
- if (a.normalized) {
366
- const d = NORMALIZE[a.componentType];
367
- for (let k = 0; k < arr.length; k++) f[k] = Math.max(arr[k] / d, -1);
368
- } else f.set(arr);
369
- arr = f;
370
- }
371
- cache.set(key, arr);
372
- return arr;
373
- };
374
- }
375
-
376
- /**
377
- * Parse a GLB file.
378
- * @returns {{json: object, accessor: (i:number, asFloat?: boolean)=>ArrayLike<number>}}
379
- */
380
- export function parseGLB(bytes) {
381
- if (!isGLB(bytes)) throw new Error('Not a GLB file (missing glTF magic).');
382
- const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
383
- const version = dv.getUint32(4, true);
384
- if (version !== 2) throw new Error(`Unsupported glTF version ${version}.`);
385
- let o = 12;
386
- let json = null;
387
- let bin = null;
388
- while (o + 8 <= bytes.byteLength) {
389
- const len = dv.getUint32(o, true);
390
- const type = dv.getUint32(o + 4, true);
391
- const data = bytes.subarray(o + 8, o + 8 + len);
392
- if (type === 0x4e4f534a) json = JSON.parse(new TextDecoder().decode(data));
393
- else if (type === 0x004e4942) bin = data;
394
- o += 8 + len;
395
- }
396
- if (!json) throw new Error('GLB has no JSON chunk.');
397
- // buffer 0 without a uri is the BIN chunk; any other buffer must be a data: URI
398
- const buffers = (json.buffers || []).map((b, i) => (i === 0 && b.uri === undefined ? bin : loadBuffer(b, i, null)));
399
- return { json, accessor: makeAccessor(json, buffers) };
400
- }
401
-
402
- function decodeBase64(s) {
403
- const bin = atob(s);
404
- const out = new Uint8Array(bin.length);
405
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
406
- return out;
407
- }
408
-
409
- /**
410
- * Bytes of a glTF buffer: an embedded data: URI, or a file from `resources`
411
- * (looked up by its URI, then by its file name, ignoring case).
412
- */
413
- function loadBuffer(buffer, index, resources) {
414
- const uri = buffer.uri;
415
- if (uri === undefined) throw new Error(`glTF buffer ${index} has no data (no uri and no GLB binary chunk).`);
416
- if (uri.startsWith('data:')) {
417
- const comma = uri.indexOf(',');
418
- if (comma < 0 || !/;base64$/i.test(uri.slice(0, comma))) throw new Error(`glTF buffer ${index}: only base64 data: URIs are supported.`);
419
- return decodeBase64(uri.slice(comma + 1));
420
- }
421
- const name = decodeURIComponent(uri);
422
- const files = resources || {};
423
- const base = name.split('/').pop();
424
- const key = [name, base].find((k) => files[k]) || Object.keys(files).find((k) => k.toLowerCase() === base.toLowerCase());
425
- if (!key) throw new Error(`glTF buffer "${name}" is missing: open it together with the .gltf file.`);
426
- const bytes = files[key];
427
- if (bytes.byteLength < (buffer.byteLength || 0)) throw new Error(`glTF buffer "${name}" is ${bytes.byteLength} bytes long but should be ${buffer.byteLength}.`);
428
- return bytes;
429
- }
430
-
431
- /**
432
- * Parse a glTF JSON file (.gltf).
433
- * @param {string|Uint8Array|object} input the JSON text, its UTF-8 bytes or the parsed object
434
- * @param {Record<string, Uint8Array>} [resources] external buffers by file name (scene.bin …)
435
- */
436
- export function parseGLTF(input, resources = {}) {
437
- const json = typeof input === 'string' ? JSON.parse(input) : input instanceof Uint8Array ? JSON.parse(new TextDecoder().decode(input)) : input;
438
- const version = json && json.asset && String(json.asset.version);
439
- if (!version) throw new Error('Not a glTF file: the JSON has no "asset" with a "version".');
440
- if (!version.startsWith('2')) throw new Error(`Unsupported glTF version ${version} (only 2.0 is supported).`);
441
- const buffers = (json.buffers || []).map((b, i) => loadBuffer(b, i, resources));
442
- return { json, accessor: makeAccessor(json, buffers) };
443
- }
444
-
445
- /** out = T * R * S (glTF node TRS; R a unit quaternion). */
446
- function composeTRS(out, tx, ty, tz, qx, qy, qz, qw, sx, sy, sz) {
447
- mat4.fromQuat(out, qx, qy, qz, qw);
448
- out[0] *= sx;
449
- out[1] *= sx;
450
- out[2] *= sx;
451
- out[4] *= sy;
452
- out[5] *= sy;
453
- out[6] *= sy;
454
- out[8] *= sz;
455
- out[9] *= sz;
456
- out[10] *= sz;
457
- out[12] = tx;
458
- out[13] = ty;
459
- out[14] = tz;
460
- return out;
461
- }
462
-
463
- /** Local matrix of a node (matrix or TRS), written into out. */
464
- function nodeLocal(node, out) {
465
- if (node.matrix) {
466
- for (let k = 0; k < 16; k++) out[k] = node.matrix[k];
467
- return out;
468
- }
469
- const t = node.translation, r = node.rotation, s = node.scale;
470
- return composeTRS(out, t ? t[0] : 0, t ? t[1] : 0, t ? t[2] : 0, r ? r[0] : 0, r ? r[1] : 0, r ? r[2] : 0, r ? r[3] : 1, s ? s[0] : 1, s ? s[1] : 1, s ? s[2] : 1);
471
- }
472
-
473
- /** Convert a GLB file into a RenderScene (mesh reuse becomes GPU instancing). */
474
- export function glbToScene(bytes) {
475
- return sceneFromGltf(parseGLB(bytes));
476
- }
477
-
478
- /** Convert a .gltf JSON file (with its external buffers, if any) into a RenderScene. */
479
- export function gltfToScene(input, resources = {}) {
480
- return sceneFromGltf(parseGLTF(input, resources));
481
- }
482
-
483
- /** Triangle (or line) index list of a primitive in any glTF topology; null for points. */
484
- function primitiveIndices(src, mode) {
485
- const n = src.length;
486
- switch (mode) {
487
- case 4: // TRIANGLES
488
- case 1: // LINES
489
- return src instanceof Uint32Array ? src : Uint32Array.from(src);
490
- case 5: { // TRIANGLE_STRIP
491
- const out = new Uint32Array(Math.max(0, n - 2) * 3);
492
- for (let i = 0; i + 2 < n; i++) {
493
- const even = (i & 1) === 0;
494
- out[i * 3] = src[even ? i : i + 1];
495
- out[i * 3 + 1] = src[even ? i + 1 : i];
496
- out[i * 3 + 2] = src[i + 2];
497
- }
498
- return out;
499
- }
500
- case 6: { // TRIANGLE_FAN
501
- const out = new Uint32Array(Math.max(0, n - 2) * 3);
502
- for (let i = 0; i + 2 < n; i++) {
503
- out[i * 3] = src[0];
504
- out[i * 3 + 1] = src[i + 1];
505
- out[i * 3 + 2] = src[i + 2];
506
- }
507
- return out;
508
- }
509
- case 3: // LINE_STRIP
510
- case 2: { // LINE_LOOP
511
- const segs = mode === 2 ? n : n - 1;
512
- const out = new Uint32Array(Math.max(0, segs) * 2);
513
- for (let i = 0; i < segs; i++) {
514
- out[i * 2] = src[i];
515
- out[i * 2 + 1] = src[(i + 1) % n];
516
- }
517
- return out;
518
- }
519
- default: // POINTS
520
- return null;
521
- }
522
- }
523
-
524
- function sceneFromGltf({ json, accessor }) {
525
- const skipped = { points: 0, textures: 0 };
526
- const materials = (json.materials || []).map((m, i) => {
527
- const pbr = m.pbrMetallicRoughness || {};
528
- const c = pbr.baseColorFactor || [1, 1, 1, 1];
529
- const e = m.emissiveFactor || [0, 0, 0];
530
- return {
531
- name: m.name || `material${i}`,
532
- color: [linearToSrgb(c[0]), linearToSrgb(c[1]), linearToSrgb(c[2]), 1],
533
- opacity: c[3],
534
- metallic: pbr.metallicFactor ?? 1,
535
- roughness: pbr.roughnessFactor ?? 1,
536
- alphaMode: (m.alphaMode || 'OPAQUE').toLowerCase() === 'mask' ? 'mask' : (m.alphaMode || 'OPAQUE').toLowerCase() === 'blend' ? 'blend' : 'opaque',
537
- alphaCutoff: m.alphaCutoff ?? 0.5,
538
- emissive: [linearToSrgb(e[0]), linearToSrgb(e[1]), linearToSrgb(e[2]), 1],
539
- doubleSided: !!m.doubleSided,
540
- };
541
- });
542
- const defaultMat = materials.length;
543
- materials.push({ name: 'default' });
544
- const geometries = [];
545
- const meshParts = new Map(); // glTF mesh -> [{geom, mat}] per primitive
546
- // primitives that share their accessors (one mesh drawn with several
547
- // materials) share one geometry, so the data is uploaded to the GPU once
548
- const byAccessors = new Map();
549
- const geometryOf = (mi, pi) => {
550
- const prim = json.meshes[mi].primitives[pi];
551
- const at = prim.attributes;
552
- const key = [at.POSITION, at.NORMAL, at.TEXCOORD_0, prim.indices, prim.mode].join('|');
553
- const known = byAccessors.get(key);
554
- if (known !== undefined) return known;
555
- const positions = accessor(prim.attributes.POSITION, true);
556
- let normals = prim.attributes.NORMAL !== undefined ? accessor(prim.attributes.NORMAL, true) : null;
557
- if (normals && json.accessors[prim.attributes.NORMAL].normalized) {
558
- // quantized normals: restore unit length
559
- normals = Float32Array.from(normals);
560
- for (let k = 0; k < normals.length; k += 3) {
561
- const l = Math.hypot(normals[k], normals[k + 1], normals[k + 2]) || 1;
562
- normals[k] /= l;
563
- normals[k + 1] /= l;
564
- normals[k + 2] /= l;
565
- }
566
- }
567
- const uvs = prim.attributes.TEXCOORD_0 !== undefined ? accessor(prim.attributes.TEXCOORD_0, true) : null;
568
- const mode = prim.mode === undefined ? 4 : prim.mode;
569
- const src = prim.indices !== undefined ? accessor(prim.indices) : Uint32Array.from({ length: positions.length / 3 }, (_, k) => k);
570
- const indices = primitiveIndices(src, mode);
571
- if (!indices) {
572
- skipped.points++;
573
- byAccessors.set(key, -1);
574
- return -1;
575
- }
576
- const mesh = new Mesh({ positions, normals, uvs, indices, kind: mode >= 1 && mode <= 3 ? 'lines' : 'triangles' });
577
- geometries.push({ mesh });
578
- byAccessors.set(key, geometries.length - 1);
579
- return geometries.length - 1;
580
- };
581
- const partsOf = (mi) => {
582
- let parts = meshParts.get(mi);
583
- if (!parts) {
584
- parts = json.meshes[mi].primitives
585
- .map((prim, pi) => ({ geom: prim.attributes && prim.attributes.POSITION !== undefined ? geometryOf(mi, pi) : -1, mat: prim.material !== undefined ? prim.material : defaultMat }))
586
- .filter((p) => p.geom >= 0);
587
- meshParts.set(mi, parts);
588
- }
589
- return parts;
590
- };
591
- // scratch matrices: one world matrix per hierarchy level, no per-instance allocations
592
- const instances = new InstanceTable(Math.max(64, json.nodes ? json.nodes.length : 0));
593
- const worlds = [];
594
- const local = mat4.createF64();
595
- const inst = mat4.createF64();
596
- const visit = (ni, parent, depth) => {
597
- const node = json.nodes[ni];
598
- const world = mat4.multiply(worlds[depth] || (worlds[depth] = mat4.createF64()), parent, nodeLocal(node, local));
599
- if (node.mesh !== undefined) {
600
- const parts = partsOf(node.mesh);
601
- const ext = node.extensions && node.extensions.EXT_mesh_gpu_instancing;
602
- if (ext) {
603
- const A = ext.attributes;
604
- const T = A.TRANSLATION !== undefined ? accessor(A.TRANSLATION, true) : null;
605
- const R = A.ROTATION !== undefined ? accessor(A.ROTATION, true) : null;
606
- const S = A.SCALE !== undefined ? accessor(A.SCALE, true) : null;
607
- const count = (T || R || S).length / (T ? 3 : R ? 4 : 3);
608
- for (let k = 0; k < count; k++) {
609
- composeTRS(
610
- local,
611
- T ? T[k * 3] : 0, T ? T[k * 3 + 1] : 0, T ? T[k * 3 + 2] : 0,
612
- R ? R[k * 4] : 0, R ? R[k * 4 + 1] : 0, R ? R[k * 4 + 2] : 0, R ? R[k * 4 + 3] : 1,
613
- S ? S[k * 3] : 1, S ? S[k * 3 + 1] : 1, S ? S[k * 3 + 2] : 1,
614
- );
615
- mat4.multiply(inst, world, local);
616
- for (const p of parts) instances.push(p.geom, p.mat, inst);
617
- }
618
- } else {
619
- for (const p of parts) instances.push(p.geom, p.mat, world);
620
- }
621
- }
622
- if (node.children) for (const c of node.children) visit(c, world, depth + 1);
623
- };
624
- const nodes = json.nodes || [];
625
- const sceneDef = json.scenes && json.scenes.length ? json.scenes[json.scene || 0] : { nodes: nodes.map((_, i) => i).filter((i) => !nodes.some((p) => p.children && p.children.includes(i))) };
626
- const root = mat4.createF64();
627
- for (const n of sceneDef.nodes || []) visit(n, root, 0);
628
- skipped.textures = (json.materials || []).filter((m) => (m.pbrMetallicRoughness && m.pbrMetallicRoughness.baseColorTexture) || m.normalTexture || m.emissiveTexture).length;
629
- return { ...buildExternalScene({ geometries, materials, instances }), json, skipped };
630
- }
1
+ // glTF 2.0 binary (GLB) export and import.
2
+ //
3
+ // Export modes:
4
+ // nodes one node per instance referencing a shared mesh (glTF's native
5
+ // reuse; hierarchy is flattened because glTF nodes cannot be shared)
6
+ // instanced EXT_mesh_gpu_instancing: one node per (mesh, material) with
7
+ // TRANSLATION / ROTATION / SCALE instance accessors
8
+ // The importer understands both, plus node hierarchies, TRS and matrices.
9
+
10
+ import * as mat4 from '../math/mat4.js';
11
+ import { Mesh } from '../geometry/mesh.js';
12
+ import { linearToSrgb } from '../format/colors.js';
13
+ import { exportableInstances, materialNames, buildExternalScene, texturedGeometries, InstanceTable } from './external-scene.js';
14
+
15
+ const FLOAT = 5126;
16
+ const INT8 = 5120;
17
+ const INT16 = 5122;
18
+ const UINT16 = 5123;
19
+ const UINT32 = 5125;
20
+ const ARRAY_BUFFER = 34962;
21
+ const ELEMENT_ARRAY_BUFFER = 34963;
22
+
23
+ const r7 = (v) => (Math.abs(v) < 1e-12 ? 0 : Number(v.toPrecision(7)));
24
+
25
+ class BinWriter {
26
+ constructor() {
27
+ this.chunks = [];
28
+ this.length = 0;
29
+ this.views = [];
30
+ this.accessors = [];
31
+ }
32
+ add(typed, target, byteStride = 0) {
33
+ const pad = (4 - (this.length % 4)) % 4;
34
+ if (pad) {
35
+ this.chunks.push(new Uint8Array(pad));
36
+ this.length += pad;
37
+ }
38
+ const bytes = new Uint8Array(typed.buffer, typed.byteOffset, typed.byteLength);
39
+ const view = { buffer: 0, byteOffset: this.length, byteLength: bytes.byteLength };
40
+ if (byteStride) view.byteStride = byteStride;
41
+ if (target) view.target = target;
42
+ this.chunks.push(bytes.slice());
43
+ this.length += bytes.byteLength;
44
+ this.views.push(view);
45
+ return this.views.length - 1;
46
+ }
47
+ accessor(typed, componentType, type, count, target, minmax = null, { byteStride = 0, normalized = false } = {}) {
48
+ const bufferView = this.add(typed, target, byteStride);
49
+ const a = { bufferView, componentType, count, type };
50
+ if (normalized) a.normalized = true;
51
+ if (minmax) {
52
+ a.min = minmax.min;
53
+ a.max = minmax.max;
54
+ }
55
+ this.accessors.push(a);
56
+ return this.accessors.length - 1;
57
+ }
58
+ bytes() {
59
+ const out = new Uint8Array(Math.ceil(this.length / 4) * 4);
60
+ let o = 0;
61
+ for (const c of this.chunks) {
62
+ out.set(c, o);
63
+ o += c.byteLength;
64
+ }
65
+ return out;
66
+ }
67
+ }
68
+
69
+ function minMax3(p) {
70
+ const min = [Infinity, Infinity, Infinity];
71
+ const max = [-Infinity, -Infinity, -Infinity];
72
+ for (let i = 0; i < p.length; i += 3) {
73
+ for (let k = 0; k < 3; k++) {
74
+ if (p[i + k] < min[k]) min[k] = p[i + k];
75
+ if (p[i + k] > max[k]) max[k] = p[i + k];
76
+ }
77
+ }
78
+ if (!p.length) return { min: [0, 0, 0], max: [0, 0, 0] };
79
+ return { min: min.map((v) => Math.fround(v)), max: max.map((v) => Math.fround(v)) };
80
+ }
81
+
82
+ /**
83
+ * KHR_mesh_quantization: positions as normalized int16 in the mesh's bounding
84
+ * box (the box's centre and half extent go into the node transform), normals
85
+ * as normalized int8. Both padded to 4-byte strides as glTF requires.
86
+ * @returns {{positions: Int16Array, normals: Int8Array, center: number[], half: number[], min: number[], max: number[]}}
87
+ */
88
+ function quantizeMesh(mesh) {
89
+ const p = mesh.positions, n = mesh.normals, count = mesh.vertexCount;
90
+ const { min, max } = minMax3(p);
91
+ const center = [0, 1, 2].map((k) => (min[k] + max[k]) / 2);
92
+ const half = [0, 1, 2].map((k) => Math.max((max[k] - min[k]) / 2, 1e-12));
93
+ const positions = new Int16Array(count * 4);
94
+ const normals = new Int8Array(count * 4);
95
+ const qmin = [32767, 32767, 32767], qmax = [-32767, -32767, -32767];
96
+ for (let i = 0; i < count; i++) {
97
+ // normals live in the quantized (non-uniformly scaled) space as well: the
98
+ // node's normal matrix undoes S(half), so store normalize(half * n)
99
+ const nx = n[i * 3] * half[0], ny = n[i * 3 + 1] * half[1], nz = n[i * 3 + 2] * half[2];
100
+ const nl = Math.hypot(nx, ny, nz) || 1;
101
+ const nn = [nx / nl, ny / nl, nz / nl];
102
+ for (let k = 0; k < 3; k++) {
103
+ const q = Math.max(-32767, Math.min(32767, Math.round(((p[i * 3 + k] - center[k]) / half[k]) * 32767)));
104
+ positions[i * 4 + k] = q;
105
+ if (q < qmin[k]) qmin[k] = q;
106
+ if (q > qmax[k]) qmax[k] = q;
107
+ normals[i * 4 + k] = Math.max(-127, Math.min(127, Math.round(nn[k] * 127)));
108
+ }
109
+ }
110
+ return { positions, normals, center, half, min: qmin, max: qmax };
111
+ }
112
+
113
+ /**
114
+ * @param {import('../scene/flatten.js').RenderScene} scene
115
+ * @param {(entry:object, lod:number)=>Mesh} meshOf
116
+ * @param {{lod?: number, instancing?: boolean, uvs?: 'auto'|boolean, quantize?: boolean}} [opts]
117
+ * uvs: 'auto' writes TEXCOORD_0 only for geometry drawn with a textured material;
118
+ * quantize: KHR_mesh_quantization (int16 positions, int8 normals; lossy)
119
+ * @returns {Uint8Array} GLB bytes
120
+ */
121
+ export function exportGLB(scene, meshOf, { lod = 0, instancing = false, uvs = 'auto', quantize = false } = {}) {
122
+ const bin = new BinWriter();
123
+ const names = materialNames(scene.materials);
124
+ const json = {
125
+ asset: { version: '2.0', generator: 'ES3D reference implementation' },
126
+ scene: 0,
127
+ scenes: [{ nodes: [] }],
128
+ nodes: [],
129
+ meshes: [],
130
+ materials: scene.materials.map((m, i) => {
131
+ const mat = {
132
+ name: names[i],
133
+ pbrMetallicRoughness: {
134
+ baseColorFactor: [r7(m.baseColor[0]), r7(m.baseColor[1]), r7(m.baseColor[2]), r7(m.opacity)],
135
+ metallicFactor: r7(m.metallic),
136
+ roughnessFactor: r7(m.roughness),
137
+ },
138
+ };
139
+ if (m.emissive.some((v) => v > 0)) mat.emissiveFactor = m.emissive.map((v) => r7(Math.min(1, v)));
140
+ if (m.alphaMode === 'mask') {
141
+ mat.alphaMode = 'MASK';
142
+ mat.alphaCutoff = m.alphaCutoff ?? 0.5;
143
+ } else if (m.alphaMode === 'blend' || (m.alphaMode !== 'opaque' && m.opacity < 1)) mat.alphaMode = 'BLEND';
144
+ if (m.doubleSided) mat.doubleSided = true;
145
+ return mat;
146
+ }),
147
+ accessors: bin.accessors,
148
+ bufferViews: bin.views,
149
+ buffers: [{ byteLength: 0 }],
150
+ };
151
+ // geometry data, once per (geometry entry, lod)
152
+ const geomData = new Map();
153
+ const meshFor = new Map(); // `${geom}|${mat}` -> glTF mesh index
154
+ const textured = uvs === 'auto' ? texturedGeometries(scene) : null;
155
+ const geomAccessors = (gi) => {
156
+ let g = geomData.get(gi);
157
+ if (g) return g;
158
+ const entry = scene.geometries[gi];
159
+ const mesh = meshOf(entry, Math.min(lod, entry.lods - 1));
160
+ let attributes;
161
+ let dequant = null;
162
+ if (quantize && mesh.kind === 'triangles' && mesh.vertexCount) {
163
+ const q = quantizeMesh(mesh);
164
+ attributes = {
165
+ POSITION: bin.accessor(q.positions, INT16, 'VEC3', mesh.vertexCount, ARRAY_BUFFER, { min: q.min, max: q.max }, { byteStride: 8, normalized: true }),
166
+ NORMAL: bin.accessor(q.normals, INT8, 'VEC3', mesh.vertexCount, ARRAY_BUFFER, null, { byteStride: 4, normalized: true }),
167
+ };
168
+ // vertex = centre + half * normalized: folded into every node using this mesh
169
+ dequant = mat4.createF64();
170
+ dequant[0] = q.half[0];
171
+ dequant[5] = q.half[1];
172
+ dequant[10] = q.half[2];
173
+ dequant[12] = q.center[0];
174
+ dequant[13] = q.center[1];
175
+ dequant[14] = q.center[2];
176
+ } else {
177
+ attributes = { POSITION: bin.accessor(mesh.positions, FLOAT, 'VEC3', mesh.vertexCount, ARRAY_BUFFER, minMax3(mesh.positions)) };
178
+ if (mesh.kind === 'triangles') attributes.NORMAL = bin.accessor(mesh.normals, FLOAT, 'VEC3', mesh.vertexCount, ARRAY_BUFFER);
179
+ }
180
+ if (mesh.kind === 'triangles' && (uvs === true || (textured && textured.has(gi)))) {
181
+ attributes.TEXCOORD_0 = bin.accessor(mesh.uvs, FLOAT, 'VEC2', mesh.vertexCount, ARRAY_BUFFER);
182
+ }
183
+ const small = mesh.vertexCount < 65536;
184
+ const idx = small ? Uint16Array.from(mesh.indices) : mesh.indices;
185
+ const indices = bin.accessor(idx, small ? UINT16 : UINT32, 'SCALAR', idx.length, ELEMENT_ARRAY_BUFFER);
186
+ g = { attributes, indices, mode: mesh.kind === 'lines' ? 1 : 4, empty: mesh.vertexCount === 0, dequant };
187
+ geomData.set(gi, g);
188
+ return g;
189
+ };
190
+ const gltfMesh = (gi, mi) => {
191
+ const key = `${gi}|${mi}`;
192
+ let k = meshFor.get(key);
193
+ if (k !== undefined) return k;
194
+ const g = geomAccessors(gi);
195
+ k = json.meshes.length;
196
+ json.meshes.push({ primitives: [{ attributes: g.attributes, indices: g.indices, material: mi, mode: g.mode }] });
197
+ meshFor.set(key, k);
198
+ return k;
199
+ };
200
+ const list = exportableInstances(scene);
201
+ const m = new Float64Array(16);
202
+ // world matrix of instance i, including the mesh's dequantization (KHR_mesh_quantization)
203
+ const instanceMatrix = (i) => {
204
+ for (let k = 0; k < 16; k++) m[k] = scene.matrices[i * 16 + k];
205
+ const dq = geomAccessors(scene.geom[i]).dequant;
206
+ if (dq) mat4.multiply(m, m, dq);
207
+ return m;
208
+ };
209
+ const nodeFor = (i, meshIndex) => {
210
+ instanceMatrix(i);
211
+ const node = { mesh: meshIndex };
212
+ const d = mat4.decompose(m);
213
+ if (d) {
214
+ if (d.translation.some((v) => Math.abs(v) > 1e-9)) node.translation = d.translation.map(r7);
215
+ if (Math.abs(d.rotation[3] - 1) > 1e-9) node.rotation = d.rotation.map(r7);
216
+ if (d.scale.some((v) => Math.abs(v - 1) > 1e-9)) node.scale = d.scale.map(r7);
217
+ } else {
218
+ node.matrix = Array.from(m, r7);
219
+ }
220
+ json.nodes.push(node);
221
+ json.scenes[0].nodes.push(json.nodes.length - 1);
222
+ };
223
+ if (quantize) {
224
+ json.extensionsUsed = ['KHR_mesh_quantization'];
225
+ json.extensionsRequired = ['KHR_mesh_quantization'];
226
+ }
227
+ if (!instancing) {
228
+ for (const i of list) {
229
+ if (geomAccessors(scene.geom[i]).empty) continue;
230
+ nodeFor(i, gltfMesh(scene.geom[i], scene.mat[i]));
231
+ }
232
+ } else {
233
+ json.extensionsUsed = [...(json.extensionsUsed || []), 'EXT_mesh_gpu_instancing'];
234
+ const groups = new Map();
235
+ for (const i of list) {
236
+ const key = `${scene.geom[i]}|${scene.mat[i]}`;
237
+ let g = groups.get(key);
238
+ if (!g) groups.set(key, (g = []));
239
+ g.push(i);
240
+ }
241
+ for (const [key, members] of groups) {
242
+ const [gi, mi] = key.split('|').map(Number);
243
+ if (geomAccessors(gi).empty) continue;
244
+ const meshIndex = gltfMesh(gi, mi);
245
+ // instancing requires shear-free transforms
246
+ const trs = [];
247
+ let ok = members.length > 1;
248
+ for (const i of members) {
249
+ const d = mat4.decompose(instanceMatrix(i));
250
+ if (!d) {
251
+ ok = false;
252
+ break;
253
+ }
254
+ trs.push(d);
255
+ }
256
+ if (!ok) {
257
+ for (const i of members) nodeFor(i, meshIndex);
258
+ continue;
259
+ }
260
+ const T = new Float32Array(members.length * 3);
261
+ const R = new Float32Array(members.length * 4);
262
+ const S = new Float32Array(members.length * 3);
263
+ trs.forEach((d, k) => {
264
+ T.set(d.translation, k * 3);
265
+ R.set(d.rotation, k * 4);
266
+ S.set(d.scale, k * 3);
267
+ });
268
+ const attributes = {
269
+ TRANSLATION: bin.accessor(T, FLOAT, 'VEC3', members.length),
270
+ ROTATION: bin.accessor(R, FLOAT, 'VEC4', members.length),
271
+ SCALE: bin.accessor(S, FLOAT, 'VEC3', members.length),
272
+ };
273
+ json.nodes.push({ mesh: meshIndex, extensions: { EXT_mesh_gpu_instancing: { attributes } } });
274
+ json.scenes[0].nodes.push(json.nodes.length - 1);
275
+ }
276
+ }
277
+ const binBytes = bin.bytes();
278
+ json.buffers[0].byteLength = binBytes.byteLength;
279
+ if (!json.meshes.length) {
280
+ delete json.meshes;
281
+ }
282
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
283
+ const jsonPadded = new Uint8Array(Math.ceil(jsonBytes.length / 4) * 4).fill(0x20);
284
+ jsonPadded.set(jsonBytes);
285
+ const total = 12 + 8 + jsonPadded.length + (binBytes.length ? 8 + binBytes.length : 0);
286
+ const out = new Uint8Array(total);
287
+ const dv = new DataView(out.buffer);
288
+ dv.setUint32(0, 0x46546c67, true); // 'glTF'
289
+ dv.setUint32(4, 2, true);
290
+ dv.setUint32(8, total, true);
291
+ dv.setUint32(12, jsonPadded.length, true);
292
+ dv.setUint32(16, 0x4e4f534a, true); // 'JSON'
293
+ out.set(jsonPadded, 20);
294
+ if (binBytes.length) {
295
+ const o = 20 + jsonPadded.length;
296
+ dv.setUint32(o, binBytes.length, true);
297
+ dv.setUint32(o + 4, 0x004e4942, true); // 'BIN\0'
298
+ out.set(binBytes, o + 8);
299
+ }
300
+ return out;
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // import
305
+
306
+ const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 };
307
+ const TYPED = { 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array };
308
+ const GETTERS = {
309
+ 5120: (v, o) => v.getInt8(o),
310
+ 5121: (v, o) => v.getUint8(o),
311
+ 5122: (v, o) => v.getInt16(o, true),
312
+ 5123: (v, o) => v.getUint16(o, true),
313
+ 5125: (v, o) => v.getUint32(o, true),
314
+ 5126: (v, o) => v.getFloat32(o, true),
315
+ };
316
+ const NORMALIZE = { 5120: 127, 5121: 255, 5122: 32767, 5123: 65535 };
317
+
318
+ export function isGLB(bytes) {
319
+ return bytes.length >= 12 && bytes[0] === 0x67 && bytes[1] === 0x6c && bytes[2] === 0x54 && bytes[3] === 0x46;
320
+ }
321
+
322
+ /**
323
+ * Typed views of accessor data over a list of buffers.
324
+ * Vertex attributes (asFloat) come back as Float32Array, normalized integers mapped to [-1,1] / [0,1].
325
+ */
326
+ function makeAccessor(json, buffers) {
327
+ const cache = new Map();
328
+ return (i, asFloat = false) => {
329
+ const key = asFloat ? -1 - i : i;
330
+ if (cache.has(key)) return cache.get(key);
331
+ const a = json.accessors[i];
332
+ if (!a) throw new Error(`glTF accessor ${i} does not exist.`);
333
+ if (a.sparse) throw new Error(`glTF accessor ${i} is sparse; sparse accessors are not supported.`);
334
+ const Ctor = TYPED[a.componentType];
335
+ if (!Ctor) throw new Error(`Unsupported accessor component type ${a.componentType}.`);
336
+ const comps = COMPONENTS[a.type];
337
+ const n = comps * a.count;
338
+ let arr;
339
+ if (a.bufferView === undefined) {
340
+ arr = new Ctor(n); // no data: all zeros (glTF 2.0, 3.6.2.1)
341
+ } else {
342
+ const view = json.bufferViews[a.bufferView];
343
+ const buf = buffers[view.buffer];
344
+ if (!buf) throw new Error(`glTF buffer ${view.buffer} is missing.`);
345
+ const byteOffset = buf.byteOffset + (view.byteOffset || 0) + (a.byteOffset || 0);
346
+ const stride = view.byteStride || 0;
347
+ const elemBytes = comps * Ctor.BYTES_PER_ELEMENT;
348
+ const end = byteOffset + (a.count ? (stride || elemBytes) * (a.count - 1) + elemBytes : 0);
349
+ if (end > buf.byteOffset + buf.byteLength) throw new Error(`glTF accessor ${i} reads past the end of buffer ${view.buffer}.`);
350
+ if (stride && stride !== elemBytes) {
351
+ arr = new Ctor(n);
352
+ const src = new DataView(buf.buffer);
353
+ const get = GETTERS[a.componentType];
354
+ for (let k = 0; k < a.count; k++) {
355
+ for (let c = 0; c < comps; c++) arr[k * comps + c] = get(src, byteOffset + k * stride + c * Ctor.BYTES_PER_ELEMENT);
356
+ }
357
+ } else if (byteOffset % Ctor.BYTES_PER_ELEMENT === 0) {
358
+ arr = new Ctor(buf.buffer, byteOffset, n);
359
+ } else {
360
+ arr = new Ctor(buf.buffer.slice(byteOffset, byteOffset + n * Ctor.BYTES_PER_ELEMENT));
361
+ }
362
+ }
363
+ if (asFloat && !(arr instanceof Float32Array)) {
364
+ const f = new Float32Array(arr.length);
365
+ if (a.normalized) {
366
+ const d = NORMALIZE[a.componentType];
367
+ for (let k = 0; k < arr.length; k++) f[k] = Math.max(arr[k] / d, -1);
368
+ } else f.set(arr);
369
+ arr = f;
370
+ }
371
+ cache.set(key, arr);
372
+ return arr;
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Parse a GLB file.
378
+ * @returns {{json: object, accessor: (i:number, asFloat?: boolean)=>ArrayLike<number>}}
379
+ */
380
+ export function parseGLB(bytes) {
381
+ if (!isGLB(bytes)) throw new Error('Not a GLB file (missing glTF magic).');
382
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
383
+ const version = dv.getUint32(4, true);
384
+ if (version !== 2) throw new Error(`Unsupported glTF version ${version}.`);
385
+ let o = 12;
386
+ let json = null;
387
+ let bin = null;
388
+ while (o + 8 <= bytes.byteLength) {
389
+ const len = dv.getUint32(o, true);
390
+ const type = dv.getUint32(o + 4, true);
391
+ const data = bytes.subarray(o + 8, o + 8 + len);
392
+ if (type === 0x4e4f534a) json = JSON.parse(new TextDecoder().decode(data));
393
+ else if (type === 0x004e4942) bin = data;
394
+ o += 8 + len;
395
+ }
396
+ if (!json) throw new Error('GLB has no JSON chunk.');
397
+ // buffer 0 without a uri is the BIN chunk; any other buffer must be a data: URI
398
+ const buffers = (json.buffers || []).map((b, i) => (i === 0 && b.uri === undefined ? bin : loadBuffer(b, i, null)));
399
+ return { json, accessor: makeAccessor(json, buffers) };
400
+ }
401
+
402
+ function decodeBase64(s) {
403
+ const bin = atob(s);
404
+ const out = new Uint8Array(bin.length);
405
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
406
+ return out;
407
+ }
408
+
409
+ /**
410
+ * Bytes of a glTF buffer: an embedded data: URI, or a file from `resources`
411
+ * (looked up by its URI, then by its file name, ignoring case).
412
+ */
413
+ function loadBuffer(buffer, index, resources) {
414
+ const uri = buffer.uri;
415
+ if (uri === undefined) throw new Error(`glTF buffer ${index} has no data (no uri and no GLB binary chunk).`);
416
+ if (uri.startsWith('data:')) {
417
+ const comma = uri.indexOf(',');
418
+ if (comma < 0 || !/;base64$/i.test(uri.slice(0, comma))) throw new Error(`glTF buffer ${index}: only base64 data: URIs are supported.`);
419
+ return decodeBase64(uri.slice(comma + 1));
420
+ }
421
+ const name = decodeURIComponent(uri);
422
+ const files = resources || {};
423
+ const base = name.split('/').pop();
424
+ const key = [name, base].find((k) => files[k]) || Object.keys(files).find((k) => k.toLowerCase() === base.toLowerCase());
425
+ if (!key) throw new Error(`glTF buffer "${name}" is missing: open it together with the .gltf file.`);
426
+ const bytes = files[key];
427
+ if (bytes.byteLength < (buffer.byteLength || 0)) throw new Error(`glTF buffer "${name}" is ${bytes.byteLength} bytes long but should be ${buffer.byteLength}.`);
428
+ return bytes;
429
+ }
430
+
431
+ /**
432
+ * Parse a glTF JSON file (.gltf).
433
+ * @param {string|Uint8Array|object} input the JSON text, its UTF-8 bytes or the parsed object
434
+ * @param {Record<string, Uint8Array>} [resources] external buffers by file name (scene.bin …)
435
+ */
436
+ export function parseGLTF(input, resources = {}) {
437
+ const json = typeof input === 'string' ? JSON.parse(input) : input instanceof Uint8Array ? JSON.parse(new TextDecoder().decode(input)) : input;
438
+ const version = json && json.asset && String(json.asset.version);
439
+ if (!version) throw new Error('Not a glTF file: the JSON has no "asset" with a "version".');
440
+ if (!version.startsWith('2')) throw new Error(`Unsupported glTF version ${version} (only 2.0 is supported).`);
441
+ const buffers = (json.buffers || []).map((b, i) => loadBuffer(b, i, resources));
442
+ return { json, accessor: makeAccessor(json, buffers) };
443
+ }
444
+
445
+ /** out = T * R * S (glTF node TRS; R a unit quaternion). */
446
+ function composeTRS(out, tx, ty, tz, qx, qy, qz, qw, sx, sy, sz) {
447
+ mat4.fromQuat(out, qx, qy, qz, qw);
448
+ out[0] *= sx;
449
+ out[1] *= sx;
450
+ out[2] *= sx;
451
+ out[4] *= sy;
452
+ out[5] *= sy;
453
+ out[6] *= sy;
454
+ out[8] *= sz;
455
+ out[9] *= sz;
456
+ out[10] *= sz;
457
+ out[12] = tx;
458
+ out[13] = ty;
459
+ out[14] = tz;
460
+ return out;
461
+ }
462
+
463
+ /** Local matrix of a node (matrix or TRS), written into out. */
464
+ function nodeLocal(node, out) {
465
+ if (node.matrix) {
466
+ for (let k = 0; k < 16; k++) out[k] = node.matrix[k];
467
+ return out;
468
+ }
469
+ const t = node.translation, r = node.rotation, s = node.scale;
470
+ return composeTRS(out, t ? t[0] : 0, t ? t[1] : 0, t ? t[2] : 0, r ? r[0] : 0, r ? r[1] : 0, r ? r[2] : 0, r ? r[3] : 1, s ? s[0] : 1, s ? s[1] : 1, s ? s[2] : 1);
471
+ }
472
+
473
+ /** Convert a GLB file into a RenderScene (mesh reuse becomes GPU instancing). */
474
+ export function glbToScene(bytes) {
475
+ return sceneFromGltf(parseGLB(bytes));
476
+ }
477
+
478
+ /** Convert a .gltf JSON file (with its external buffers, if any) into a RenderScene. */
479
+ export function gltfToScene(input, resources = {}) {
480
+ return sceneFromGltf(parseGLTF(input, resources));
481
+ }
482
+
483
+ /** Triangle (or line) index list of a primitive in any glTF topology; null for points. */
484
+ function primitiveIndices(src, mode) {
485
+ const n = src.length;
486
+ switch (mode) {
487
+ case 4: // TRIANGLES
488
+ case 1: // LINES
489
+ return src instanceof Uint32Array ? src : Uint32Array.from(src);
490
+ case 5: { // TRIANGLE_STRIP
491
+ const out = new Uint32Array(Math.max(0, n - 2) * 3);
492
+ for (let i = 0; i + 2 < n; i++) {
493
+ const even = (i & 1) === 0;
494
+ out[i * 3] = src[even ? i : i + 1];
495
+ out[i * 3 + 1] = src[even ? i + 1 : i];
496
+ out[i * 3 + 2] = src[i + 2];
497
+ }
498
+ return out;
499
+ }
500
+ case 6: { // TRIANGLE_FAN
501
+ const out = new Uint32Array(Math.max(0, n - 2) * 3);
502
+ for (let i = 0; i + 2 < n; i++) {
503
+ out[i * 3] = src[0];
504
+ out[i * 3 + 1] = src[i + 1];
505
+ out[i * 3 + 2] = src[i + 2];
506
+ }
507
+ return out;
508
+ }
509
+ case 3: // LINE_STRIP
510
+ case 2: { // LINE_LOOP
511
+ const segs = mode === 2 ? n : n - 1;
512
+ const out = new Uint32Array(Math.max(0, segs) * 2);
513
+ for (let i = 0; i < segs; i++) {
514
+ out[i * 2] = src[i];
515
+ out[i * 2 + 1] = src[(i + 1) % n];
516
+ }
517
+ return out;
518
+ }
519
+ default: // POINTS
520
+ return null;
521
+ }
522
+ }
523
+
524
+ function sceneFromGltf({ json, accessor }) {
525
+ const skipped = { points: 0, textures: 0 };
526
+ const materials = (json.materials || []).map((m, i) => {
527
+ const pbr = m.pbrMetallicRoughness || {};
528
+ const c = pbr.baseColorFactor || [1, 1, 1, 1];
529
+ const e = m.emissiveFactor || [0, 0, 0];
530
+ return {
531
+ name: m.name || `material${i}`,
532
+ color: [linearToSrgb(c[0]), linearToSrgb(c[1]), linearToSrgb(c[2]), 1],
533
+ opacity: c[3],
534
+ metallic: pbr.metallicFactor ?? 1,
535
+ roughness: pbr.roughnessFactor ?? 1,
536
+ alphaMode: (m.alphaMode || 'OPAQUE').toLowerCase() === 'mask' ? 'mask' : (m.alphaMode || 'OPAQUE').toLowerCase() === 'blend' ? 'blend' : 'opaque',
537
+ alphaCutoff: m.alphaCutoff ?? 0.5,
538
+ emissive: [linearToSrgb(e[0]), linearToSrgb(e[1]), linearToSrgb(e[2]), 1],
539
+ doubleSided: !!m.doubleSided,
540
+ };
541
+ });
542
+ const defaultMat = materials.length;
543
+ materials.push({ name: 'default' });
544
+ const geometries = [];
545
+ const meshParts = new Map(); // glTF mesh -> [{geom, mat}] per primitive
546
+ // primitives that share their accessors (one mesh drawn with several
547
+ // materials) share one geometry, so the data is uploaded to the GPU once
548
+ const byAccessors = new Map();
549
+ const geometryOf = (mi, pi) => {
550
+ const prim = json.meshes[mi].primitives[pi];
551
+ const at = prim.attributes;
552
+ const key = [at.POSITION, at.NORMAL, at.TEXCOORD_0, prim.indices, prim.mode].join('|');
553
+ const known = byAccessors.get(key);
554
+ if (known !== undefined) return known;
555
+ const positions = accessor(prim.attributes.POSITION, true);
556
+ let normals = prim.attributes.NORMAL !== undefined ? accessor(prim.attributes.NORMAL, true) : null;
557
+ if (normals && json.accessors[prim.attributes.NORMAL].normalized) {
558
+ // quantized normals: restore unit length
559
+ normals = Float32Array.from(normals);
560
+ for (let k = 0; k < normals.length; k += 3) {
561
+ const l = Math.hypot(normals[k], normals[k + 1], normals[k + 2]) || 1;
562
+ normals[k] /= l;
563
+ normals[k + 1] /= l;
564
+ normals[k + 2] /= l;
565
+ }
566
+ }
567
+ const uvs = prim.attributes.TEXCOORD_0 !== undefined ? accessor(prim.attributes.TEXCOORD_0, true) : null;
568
+ const mode = prim.mode === undefined ? 4 : prim.mode;
569
+ const src = prim.indices !== undefined ? accessor(prim.indices) : Uint32Array.from({ length: positions.length / 3 }, (_, k) => k);
570
+ const indices = primitiveIndices(src, mode);
571
+ if (!indices) {
572
+ skipped.points++;
573
+ byAccessors.set(key, -1);
574
+ return -1;
575
+ }
576
+ const mesh = new Mesh({ positions, normals, uvs, indices, kind: mode >= 1 && mode <= 3 ? 'lines' : 'triangles' });
577
+ geometries.push({ mesh });
578
+ byAccessors.set(key, geometries.length - 1);
579
+ return geometries.length - 1;
580
+ };
581
+ const partsOf = (mi) => {
582
+ let parts = meshParts.get(mi);
583
+ if (!parts) {
584
+ parts = json.meshes[mi].primitives
585
+ .map((prim, pi) => ({ geom: prim.attributes && prim.attributes.POSITION !== undefined ? geometryOf(mi, pi) : -1, mat: prim.material !== undefined ? prim.material : defaultMat }))
586
+ .filter((p) => p.geom >= 0);
587
+ meshParts.set(mi, parts);
588
+ }
589
+ return parts;
590
+ };
591
+ // scratch matrices: one world matrix per hierarchy level, no per-instance allocations
592
+ const instances = new InstanceTable(Math.max(64, json.nodes ? json.nodes.length : 0));
593
+ const worlds = [];
594
+ const local = mat4.createF64();
595
+ const inst = mat4.createF64();
596
+ const visit = (ni, parent, depth) => {
597
+ const node = json.nodes[ni];
598
+ const world = mat4.multiply(worlds[depth] || (worlds[depth] = mat4.createF64()), parent, nodeLocal(node, local));
599
+ if (node.mesh !== undefined) {
600
+ const parts = partsOf(node.mesh);
601
+ const ext = node.extensions && node.extensions.EXT_mesh_gpu_instancing;
602
+ if (ext) {
603
+ const A = ext.attributes;
604
+ const T = A.TRANSLATION !== undefined ? accessor(A.TRANSLATION, true) : null;
605
+ const R = A.ROTATION !== undefined ? accessor(A.ROTATION, true) : null;
606
+ const S = A.SCALE !== undefined ? accessor(A.SCALE, true) : null;
607
+ const count = (T || R || S).length / (T ? 3 : R ? 4 : 3);
608
+ for (let k = 0; k < count; k++) {
609
+ composeTRS(
610
+ local,
611
+ T ? T[k * 3] : 0, T ? T[k * 3 + 1] : 0, T ? T[k * 3 + 2] : 0,
612
+ R ? R[k * 4] : 0, R ? R[k * 4 + 1] : 0, R ? R[k * 4 + 2] : 0, R ? R[k * 4 + 3] : 1,
613
+ S ? S[k * 3] : 1, S ? S[k * 3 + 1] : 1, S ? S[k * 3 + 2] : 1,
614
+ );
615
+ mat4.multiply(inst, world, local);
616
+ for (const p of parts) instances.push(p.geom, p.mat, inst);
617
+ }
618
+ } else {
619
+ for (const p of parts) instances.push(p.geom, p.mat, world);
620
+ }
621
+ }
622
+ if (node.children) for (const c of node.children) visit(c, world, depth + 1);
623
+ };
624
+ const nodes = json.nodes || [];
625
+ const sceneDef = json.scenes && json.scenes.length ? json.scenes[json.scene || 0] : { nodes: nodes.map((_, i) => i).filter((i) => !nodes.some((p) => p.children && p.children.includes(i))) };
626
+ const root = mat4.createF64();
627
+ for (const n of sceneDef.nodes || []) visit(n, root, 0);
628
+ skipped.textures = (json.materials || []).filter((m) => (m.pbrMetallicRoughness && m.pbrMetallicRoughness.baseColorTexture) || m.normalTexture || m.emissiveTexture).length;
629
+ return { ...buildExternalScene({ geometries, materials, instances }), json, skipped };
630
+ }