@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
package/src/io/obj.js CHANGED
@@ -1,352 +1,352 @@
1
- // Wavefront OBJ export (fully flattened triangles, the format has no
2
- // instancing) and a reasonably fast OBJ parser used by the benchmark and the
3
- // importer. The exporter is deliberately generous to OBJ: trimmed numbers,
4
- // de-duplicated normals and texture coordinates, one usemtl per material run.
5
-
6
- import { Mesh, computeNormals } from '../geometry/mesh.js';
7
- import { linearToSrgb } from '../format/colors.js';
8
- import { exportableInstances, materialNames, fixed, buildExternalScene, InstanceTable } from './external-scene.js';
9
-
10
- /**
11
- * @param {import('../scene/flatten.js').RenderScene} scene
12
- * @param {(entry:object, lod:number)=>Mesh} meshOf
13
- * @param {{lod?: number, digits?: number, name?: string, uvs?: 'auto'|boolean}} [opts]
14
- * uvs: 'auto' writes texture coordinates only for textured materials
15
- * @returns {{obj: string, mtl: string, vertices: number, triangles: number}}
16
- */
17
- export function exportOBJ(scene, meshOf, { lod = 0, digits = 5, name = 'scene', uvs = 'auto' } = {}) {
18
- const names = materialNames(scene.materials);
19
- const out = [`# ES3D export: ${name}`, `mtllib ${name}.mtl`];
20
- const vnIndex = new Map();
21
- const vtIndex = new Map();
22
- const vnLines = [];
23
- const vtLines = [];
24
- const vLines = [];
25
- const faces = [];
26
- let vCount = 0;
27
- let triangles = 0;
28
- let currentMat = -1;
29
- const m = new Float64Array(16);
30
- for (const i of exportableInstances(scene)) {
31
- const entry = scene.geometries[scene.geom[i]];
32
- const mesh = meshOf(entry, Math.min(lod, entry.lods - 1));
33
- if (!mesh.vertexCount) continue;
34
- for (let k = 0; k < 16; k++) m[k] = scene.matrices[i * 16 + k];
35
- const mi = scene.mat[i];
36
- if (mi !== currentMat) {
37
- faces.push(`usemtl ${names[mi]}`);
38
- currentMat = mi;
39
- }
40
- // normal matrix = cofactor (sign-corrected)
41
- const c00 = m[5] * m[10] - m[6] * m[9], c01 = m[6] * m[8] - m[4] * m[10], c02 = m[4] * m[9] - m[5] * m[8];
42
- const c10 = m[9] * m[2] - m[10] * m[1], c11 = m[10] * m[0] - m[8] * m[2], c12 = m[8] * m[1] - m[9] * m[0];
43
- const c20 = m[1] * m[6] - m[2] * m[5], c21 = m[2] * m[4] - m[0] * m[6], c22 = m[0] * m[5] - m[1] * m[4];
44
- const det = m[0] * c00 + m[1] * c01 + m[2] * c02;
45
- const sg = det < 0 ? -1 : 1;
46
- const p = mesh.positions, nr = mesh.normals, uv = mesh.uvs;
47
- const n = mesh.vertexCount;
48
- const material = scene.materials[mi];
49
- const withUV = uvs === true || (uvs === 'auto' && !!(material.map || material.normalMap));
50
- const vt = new Int32Array(n);
51
- const vn = new Int32Array(n);
52
- for (let k = 0; k < n; k++) {
53
- const x = p[k * 3], y = p[k * 3 + 1], z = p[k * 3 + 2];
54
- vLines.push(`v ${fixed(m[0] * x + m[4] * y + m[8] * z + m[12], digits)} ${fixed(m[1] * x + m[5] * y + m[9] * z + m[13], digits)} ${fixed(m[2] * x + m[6] * y + m[10] * z + m[14], digits)}`);
55
- if (mesh.kind === 'triangles') {
56
- const a = nr[k * 3], b = nr[k * 3 + 1], c = nr[k * 3 + 2];
57
- let nx = (c00 * a + c10 * b + c20 * c) * sg, ny = (c01 * a + c11 * b + c21 * c) * sg, nz = (c02 * a + c12 * b + c22 * c) * sg;
58
- const l = Math.hypot(nx, ny, nz) || 1;
59
- const key = `${fixed(nx / l, 4)} ${fixed(ny / l, 4)} ${fixed(nz / l, 4)}`;
60
- let ni = vnIndex.get(key);
61
- if (ni === undefined) {
62
- ni = vnLines.length + 1;
63
- vnIndex.set(key, ni);
64
- vnLines.push('vn ' + key);
65
- }
66
- vn[k] = ni;
67
- if (!withUV) continue;
68
- const tkey = `${fixed(uv[k * 2], 4)} ${fixed(uv[k * 2 + 1], 4)}`;
69
- let ti = vtIndex.get(tkey);
70
- if (ti === undefined) {
71
- ti = vtLines.length + 1;
72
- vtIndex.set(tkey, ti);
73
- vtLines.push('vt ' + tkey);
74
- }
75
- vt[k] = ti;
76
- }
77
- }
78
- const idx = mesh.indices;
79
- if (mesh.kind === 'lines') {
80
- for (let k = 0; k < idx.length; k += 2) faces.push(`l ${idx[k] + 1 + vCount} ${idx[k + 1] + 1 + vCount}`);
81
- } else {
82
- for (let k = 0; k < idx.length; k += 3) {
83
- let a = idx[k], b = idx[k + 1], c = idx[k + 2];
84
- if (det < 0) [b, c] = [c, b];
85
- if (withUV) faces.push(`f ${a + 1 + vCount}/${vt[a]}/${vn[a]} ${b + 1 + vCount}/${vt[b]}/${vn[b]} ${c + 1 + vCount}/${vt[c]}/${vn[c]}`);
86
- else faces.push(`f ${a + 1 + vCount}//${vn[a]} ${b + 1 + vCount}//${vn[b]} ${c + 1 + vCount}//${vn[c]}`);
87
- }
88
- triangles += idx.length / 3;
89
- }
90
- vCount += n;
91
- }
92
- const parts = [out, vLines, vtLines, vnLines, faces].filter((a) => a.length).map((a) => a.join('\n'));
93
- const mtl = [];
94
- scene.materials.forEach((mat, i) => {
95
- const kd = mat.baseColor.slice(0, 3).map((c) => fixed(linearToSrgb(c), 4)).join(' ');
96
- const ke = mat.emissive.map((c) => fixed(Math.min(1, linearToSrgb(c)), 4)).join(' ');
97
- mtl.push(`newmtl ${names[i]}`, `Kd ${kd}`, `Ke ${ke}`, `d ${fixed(mat.opacity, 4)}`, `Pr ${fixed(mat.roughness, 4)}`, `Pm ${fixed(mat.metallic, 4)}`, '');
98
- });
99
- return { obj: parts.join('\n') + '\n', mtl: mtl.join('\n'), vertices: vCount, triangles };
100
- }
101
-
102
- // ---------------------------------------------------------------------------
103
- // parsing
104
-
105
- const POW10 = Array.from({ length: 23 }, (_, i) => 10 ** i); // exact doubles
106
-
107
- function parseMtl(text) {
108
- const mats = new Map();
109
- let cur = null;
110
- for (const raw of text.split('\n')) {
111
- const line = raw.trim();
112
- if (!line || line[0] === '#') continue;
113
- const parts = line.split(/\s+/);
114
- const k = parts[0];
115
- if (k === 'newmtl') {
116
- cur = { name: parts.slice(1).join(' '), color: [0.8, 0.8, 0.8, 1], metallic: 0, roughness: 0.5, opacity: 1, emissive: [0, 0, 0, 1] };
117
- mats.set(cur.name, cur);
118
- } else if (cur) {
119
- const nums = parts.slice(1).map(Number);
120
- if (k === 'Kd') cur.color = [nums[0], nums[1], nums[2], 1];
121
- else if (k === 'Ke') cur.emissive = [nums[0], nums[1], nums[2], 1];
122
- else if (k === 'd') cur.opacity = nums[0];
123
- else if (k === 'Tr') cur.opacity = 1 - nums[0];
124
- else if (k === 'Pr') cur.roughness = nums[0];
125
- else if (k === 'Pm') cur.metallic = nums[0];
126
- else if (k === 'Ns') cur.roughness = Math.sqrt(2 / (nums[0] + 2));
127
- }
128
- }
129
- return mats;
130
- }
131
-
132
- /**
133
- * Parse OBJ text (and optional MTL text) into meshes grouped by material.
134
- * Faces are scanned character by character and corners are de-duplicated
135
- * through per-position linked lists, so large files parse without per-corner
136
- * string allocations.
137
- * @returns {{groups: {material: string, mesh: Mesh}[], materials: Map, stats: object}}
138
- */
139
- export function parseOBJ(text, mtlText = '') {
140
- const pos = [];
141
- const nrm = [];
142
- const tex = [];
143
- const groups = new Map();
144
- let group = null;
145
- const groupList = [];
146
- const useGroup = (name) => {
147
- group = groups.get(name);
148
- if (!group) {
149
- group = { material: name, id: groupList.length, p: [], n: [], t: [], idx: [], lines: [], hasNormals: true, hasUV: true };
150
- groups.set(name, group);
151
- groupList.push(group);
152
- }
153
- };
154
- useGroup('default');
155
- // corner de-duplication: for every position index a linked list of the
156
- // (group, texcoord, normal) combinations already emitted
157
- let head = new Int32Array(1024).fill(-1);
158
- let cap = 1024;
159
- let nextE = new Int32Array(cap), gE = new Int32Array(cap), tE = new Int32Array(cap), nE = new Int32Array(cap), outE = new Int32Array(cap);
160
- let entries = 0;
161
- const growEntries = () => {
162
- cap *= 2;
163
- const grow = (a) => {
164
- const b = new Int32Array(cap);
165
- b.set(a);
166
- return b;
167
- };
168
- nextE = grow(nextE);
169
- gE = grow(gE);
170
- tE = grow(tE);
171
- nE = grow(nE);
172
- outE = grow(outE);
173
- };
174
- const corner = (v, t, n) => {
175
- if (v < 0) v = pos.length / 3 + v + 1;
176
- if (t < 0) t = tex.length / 2 + t + 1;
177
- if (n < 0) n = nrm.length / 3 + n + 1;
178
- if (v < 1 || v > pos.length / 3) throw new Error(`OBJ face references vertex ${v}, but only ${pos.length / 3} are defined.`);
179
- if (v > head.length) {
180
- let len = head.length;
181
- while (len < v) len *= 2;
182
- const h = new Int32Array(len).fill(-1);
183
- h.set(head);
184
- head = h;
185
- }
186
- for (let e = head[v - 1]; e >= 0; e = nextE[e]) {
187
- if (gE[e] === group.id && tE[e] === t && nE[e] === n) return outE[e];
188
- }
189
- const idx = group.p.length / 3;
190
- group.p.push(pos[(v - 1) * 3], pos[(v - 1) * 3 + 1], pos[(v - 1) * 3 + 2]);
191
- if (n) group.n.push(nrm[(n - 1) * 3], nrm[(n - 1) * 3 + 1], nrm[(n - 1) * 3 + 2]);
192
- else {
193
- group.hasNormals = false;
194
- group.n.push(0, 0, 0);
195
- }
196
- if (t) group.t.push(tex[(t - 1) * 2], tex[(t - 1) * 2 + 1]);
197
- else {
198
- group.hasUV = false;
199
- group.t.push(0, 0);
200
- }
201
- if (entries === cap) growEntries();
202
- gE[entries] = group.id;
203
- tE[entries] = t;
204
- nE[entries] = n;
205
- outE[entries] = idx;
206
- nextE[entries] = head[v - 1];
207
- head[v - 1] = entries++;
208
- return idx;
209
- };
210
- let i = 0;
211
- const L = text.length;
212
- // plain decimals (the common case) are converted with one exact division,
213
- // which gives the same correctly rounded double as Number(); anything else
214
- // (exponents, very long mantissas) goes through Number()
215
- const decimal = (start, end) => {
216
- let k = start;
217
- let neg = false;
218
- const c0 = text.charCodeAt(k);
219
- if (c0 === 45 || c0 === 43) {
220
- neg = c0 === 45;
221
- k++;
222
- }
223
- let mant = 0, scale = 0, dot = false, digits = 0;
224
- for (; k < end; k++) {
225
- const c = text.charCodeAt(k);
226
- if (c >= 48 && c <= 57) {
227
- if (mant > 9e14) return +text.slice(start, end);
228
- mant = mant * 10 + (c - 48);
229
- digits++;
230
- if (dot) scale++;
231
- } else if (c === 46 && !dot) dot = true;
232
- else return +text.slice(start, end);
233
- }
234
- if (!digits || scale > 22) return +text.slice(start, end);
235
- const v = scale ? mant / POW10[scale] : mant;
236
- return neg ? -v : v;
237
- };
238
- const readFloats = (start, end, out, count) => {
239
- let k = start;
240
- for (let c = 0; c < count; c++) {
241
- while (k < end && (text.charCodeAt(k) === 32 || text.charCodeAt(k) === 9)) k++;
242
- let e = k;
243
- while (e < end && text.charCodeAt(e) !== 32 && text.charCodeAt(e) !== 9) e++;
244
- out.push(k < e ? decimal(k, e) : 0);
245
- k = e;
246
- }
247
- };
248
- // signed integer scanner for face records; `at` is the cursor
249
- let at = 0;
250
- const int = (end) => {
251
- let sign = 1;
252
- if (text.charCodeAt(at) === 45) {
253
- sign = -1;
254
- at++;
255
- }
256
- let v = 0;
257
- let digits = 0;
258
- for (let c = text.charCodeAt(at); at < end && c >= 48 && c <= 57; c = text.charCodeAt(++at)) {
259
- v = v * 10 + (c - 48);
260
- digits++;
261
- }
262
- return digits ? sign * v : 0;
263
- };
264
- // the corners of one face / line record, as output indices
265
- const refs = [];
266
- const readRefs = (start, end) => {
267
- refs.length = 0;
268
- at = start;
269
- for (;;) {
270
- while (at < end && (text.charCodeAt(at) === 32 || text.charCodeAt(at) === 9)) at++;
271
- if (at >= end) break;
272
- const v = int(end);
273
- let t = 0, n = 0;
274
- if (text.charCodeAt(at) === 47) {
275
- at++;
276
- t = int(end);
277
- if (text.charCodeAt(at) === 47) {
278
- at++;
279
- n = int(end);
280
- }
281
- }
282
- const c = text.charCodeAt(at);
283
- if (at < end && c !== 32 && c !== 9) throw new Error(`Invalid OBJ vertex reference near "${text.slice(start, Math.min(end, start + 40))}".`);
284
- refs.push(corner(v, t, n));
285
- }
286
- };
287
- while (i < L) {
288
- let e = text.indexOf('\n', i);
289
- if (e < 0) e = L;
290
- let s = i;
291
- while (s < e && (text.charCodeAt(s) === 32 || text.charCodeAt(s) === 9)) s++;
292
- const c0 = text.charCodeAt(s);
293
- const c1 = text.charCodeAt(s + 1);
294
- let end = e;
295
- if (text.charCodeAt(end - 1) === 13) end--;
296
- if (c0 === 118) {
297
- // v, vn, vt
298
- if (c1 === 32 || c1 === 9) readFloats(s + 2, end, pos, 3);
299
- else if (c1 === 110) readFloats(s + 3, end, nrm, 3);
300
- else if (c1 === 116) readFloats(s + 3, end, tex, 2);
301
- } else if (c0 === 102 && (c1 === 32 || c1 === 9)) {
302
- readRefs(s + 2, end);
303
- for (let k = 2; k < refs.length; k++) group.idx.push(refs[0], refs[k - 1], refs[k]);
304
- } else if (c0 === 108 && (c1 === 32 || c1 === 9)) {
305
- readRefs(s + 2, end);
306
- for (let k = 0; k + 1 < refs.length; k++) group.lines.push(refs[k], refs[k + 1]);
307
- } else if (text.startsWith('usemtl', s)) {
308
- useGroup(text.slice(s + 6, end).trim() || 'default');
309
- }
310
- i = e + 1;
311
- }
312
- const result = [];
313
- let triangles = 0;
314
- for (const g of groups.values()) {
315
- if (g.idx.length) {
316
- let mesh;
317
- if (g.hasNormals) {
318
- mesh = new Mesh({ positions: Float32Array.from(g.p), normals: Float32Array.from(g.n), uvs: Float32Array.from(g.t), indices: Uint32Array.from(g.idx) });
319
- } else {
320
- mesh = computeNormals(Float32Array.from(g.p), Uint32Array.from(g.idx), 30, g.hasUV ? Float32Array.from(g.t) : null);
321
- }
322
- result.push({ material: g.material, mesh });
323
- triangles += g.idx.length / 3;
324
- }
325
- if (g.lines.length) {
326
- result.push({ material: g.material, mesh: new Mesh({ positions: Float32Array.from(g.p), indices: Uint32Array.from(g.lines), kind: 'lines' }) });
327
- }
328
- }
329
- return { groups: result, materials: mtlText ? parseMtl(mtlText) : new Map(), stats: { vertices: pos.length / 3, triangles } };
330
- }
331
-
332
- /** Parse OBJ into a RenderScene (one identity instance per material group). */
333
- export function objToScene(text, mtlText = '') {
334
- const parsed = parseOBJ(text, mtlText);
335
- const matIndex = new Map();
336
- const materials = [];
337
- const geometries = [];
338
- const instances = new InstanceTable();
339
- const I = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
340
- for (const g of parsed.groups) {
341
- let mi = matIndex.get(g.material);
342
- if (mi === undefined) {
343
- mi = materials.length;
344
- matIndex.set(g.material, mi);
345
- const m = parsed.materials.get(g.material);
346
- materials.push(m ? { ...m } : { name: g.material });
347
- }
348
- geometries.push({ mesh: g.mesh });
349
- instances.push(geometries.length - 1, mi, I);
350
- }
351
- return { ...buildExternalScene({ geometries, materials, instances }), parsed };
352
- }
1
+ // Wavefront OBJ export (fully flattened triangles, the format has no
2
+ // instancing) and a reasonably fast OBJ parser used by the benchmark and the
3
+ // importer. The exporter is deliberately generous to OBJ: trimmed numbers,
4
+ // de-duplicated normals and texture coordinates, one usemtl per material run.
5
+
6
+ import { Mesh, computeNormals } from '../geometry/mesh.js';
7
+ import { linearToSrgb } from '../format/colors.js';
8
+ import { exportableInstances, materialNames, fixed, buildExternalScene, InstanceTable } from './external-scene.js';
9
+
10
+ /**
11
+ * @param {import('../scene/flatten.js').RenderScene} scene
12
+ * @param {(entry:object, lod:number)=>Mesh} meshOf
13
+ * @param {{lod?: number, digits?: number, name?: string, uvs?: 'auto'|boolean}} [opts]
14
+ * uvs: 'auto' writes texture coordinates only for textured materials
15
+ * @returns {{obj: string, mtl: string, vertices: number, triangles: number}}
16
+ */
17
+ export function exportOBJ(scene, meshOf, { lod = 0, digits = 5, name = 'scene', uvs = 'auto' } = {}) {
18
+ const names = materialNames(scene.materials);
19
+ const out = [`# ES3D export: ${name}`, `mtllib ${name}.mtl`];
20
+ const vnIndex = new Map();
21
+ const vtIndex = new Map();
22
+ const vnLines = [];
23
+ const vtLines = [];
24
+ const vLines = [];
25
+ const faces = [];
26
+ let vCount = 0;
27
+ let triangles = 0;
28
+ let currentMat = -1;
29
+ const m = new Float64Array(16);
30
+ for (const i of exportableInstances(scene)) {
31
+ const entry = scene.geometries[scene.geom[i]];
32
+ const mesh = meshOf(entry, Math.min(lod, entry.lods - 1));
33
+ if (!mesh.vertexCount) continue;
34
+ for (let k = 0; k < 16; k++) m[k] = scene.matrices[i * 16 + k];
35
+ const mi = scene.mat[i];
36
+ if (mi !== currentMat) {
37
+ faces.push(`usemtl ${names[mi]}`);
38
+ currentMat = mi;
39
+ }
40
+ // normal matrix = cofactor (sign-corrected)
41
+ const c00 = m[5] * m[10] - m[6] * m[9], c01 = m[6] * m[8] - m[4] * m[10], c02 = m[4] * m[9] - m[5] * m[8];
42
+ const c10 = m[9] * m[2] - m[10] * m[1], c11 = m[10] * m[0] - m[8] * m[2], c12 = m[8] * m[1] - m[9] * m[0];
43
+ const c20 = m[1] * m[6] - m[2] * m[5], c21 = m[2] * m[4] - m[0] * m[6], c22 = m[0] * m[5] - m[1] * m[4];
44
+ const det = m[0] * c00 + m[1] * c01 + m[2] * c02;
45
+ const sg = det < 0 ? -1 : 1;
46
+ const p = mesh.positions, nr = mesh.normals, uv = mesh.uvs;
47
+ const n = mesh.vertexCount;
48
+ const material = scene.materials[mi];
49
+ const withUV = uvs === true || (uvs === 'auto' && !!(material.map || material.normalMap));
50
+ const vt = new Int32Array(n);
51
+ const vn = new Int32Array(n);
52
+ for (let k = 0; k < n; k++) {
53
+ const x = p[k * 3], y = p[k * 3 + 1], z = p[k * 3 + 2];
54
+ vLines.push(`v ${fixed(m[0] * x + m[4] * y + m[8] * z + m[12], digits)} ${fixed(m[1] * x + m[5] * y + m[9] * z + m[13], digits)} ${fixed(m[2] * x + m[6] * y + m[10] * z + m[14], digits)}`);
55
+ if (mesh.kind === 'triangles') {
56
+ const a = nr[k * 3], b = nr[k * 3 + 1], c = nr[k * 3 + 2];
57
+ let nx = (c00 * a + c10 * b + c20 * c) * sg, ny = (c01 * a + c11 * b + c21 * c) * sg, nz = (c02 * a + c12 * b + c22 * c) * sg;
58
+ const l = Math.hypot(nx, ny, nz) || 1;
59
+ const key = `${fixed(nx / l, 4)} ${fixed(ny / l, 4)} ${fixed(nz / l, 4)}`;
60
+ let ni = vnIndex.get(key);
61
+ if (ni === undefined) {
62
+ ni = vnLines.length + 1;
63
+ vnIndex.set(key, ni);
64
+ vnLines.push('vn ' + key);
65
+ }
66
+ vn[k] = ni;
67
+ if (!withUV) continue;
68
+ const tkey = `${fixed(uv[k * 2], 4)} ${fixed(uv[k * 2 + 1], 4)}`;
69
+ let ti = vtIndex.get(tkey);
70
+ if (ti === undefined) {
71
+ ti = vtLines.length + 1;
72
+ vtIndex.set(tkey, ti);
73
+ vtLines.push('vt ' + tkey);
74
+ }
75
+ vt[k] = ti;
76
+ }
77
+ }
78
+ const idx = mesh.indices;
79
+ if (mesh.kind === 'lines') {
80
+ for (let k = 0; k < idx.length; k += 2) faces.push(`l ${idx[k] + 1 + vCount} ${idx[k + 1] + 1 + vCount}`);
81
+ } else {
82
+ for (let k = 0; k < idx.length; k += 3) {
83
+ let a = idx[k], b = idx[k + 1], c = idx[k + 2];
84
+ if (det < 0) [b, c] = [c, b];
85
+ if (withUV) faces.push(`f ${a + 1 + vCount}/${vt[a]}/${vn[a]} ${b + 1 + vCount}/${vt[b]}/${vn[b]} ${c + 1 + vCount}/${vt[c]}/${vn[c]}`);
86
+ else faces.push(`f ${a + 1 + vCount}//${vn[a]} ${b + 1 + vCount}//${vn[b]} ${c + 1 + vCount}//${vn[c]}`);
87
+ }
88
+ triangles += idx.length / 3;
89
+ }
90
+ vCount += n;
91
+ }
92
+ const parts = [out, vLines, vtLines, vnLines, faces].filter((a) => a.length).map((a) => a.join('\n'));
93
+ const mtl = [];
94
+ scene.materials.forEach((mat, i) => {
95
+ const kd = mat.baseColor.slice(0, 3).map((c) => fixed(linearToSrgb(c), 4)).join(' ');
96
+ const ke = mat.emissive.map((c) => fixed(Math.min(1, linearToSrgb(c)), 4)).join(' ');
97
+ mtl.push(`newmtl ${names[i]}`, `Kd ${kd}`, `Ke ${ke}`, `d ${fixed(mat.opacity, 4)}`, `Pr ${fixed(mat.roughness, 4)}`, `Pm ${fixed(mat.metallic, 4)}`, '');
98
+ });
99
+ return { obj: parts.join('\n') + '\n', mtl: mtl.join('\n'), vertices: vCount, triangles };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // parsing
104
+
105
+ const POW10 = Array.from({ length: 23 }, (_, i) => 10 ** i); // exact doubles
106
+
107
+ function parseMtl(text) {
108
+ const mats = new Map();
109
+ let cur = null;
110
+ for (const raw of text.split('\n')) {
111
+ const line = raw.trim();
112
+ if (!line || line[0] === '#') continue;
113
+ const parts = line.split(/\s+/);
114
+ const k = parts[0];
115
+ if (k === 'newmtl') {
116
+ cur = { name: parts.slice(1).join(' '), color: [0.8, 0.8, 0.8, 1], metallic: 0, roughness: 0.5, opacity: 1, emissive: [0, 0, 0, 1] };
117
+ mats.set(cur.name, cur);
118
+ } else if (cur) {
119
+ const nums = parts.slice(1).map(Number);
120
+ if (k === 'Kd') cur.color = [nums[0], nums[1], nums[2], 1];
121
+ else if (k === 'Ke') cur.emissive = [nums[0], nums[1], nums[2], 1];
122
+ else if (k === 'd') cur.opacity = nums[0];
123
+ else if (k === 'Tr') cur.opacity = 1 - nums[0];
124
+ else if (k === 'Pr') cur.roughness = nums[0];
125
+ else if (k === 'Pm') cur.metallic = nums[0];
126
+ else if (k === 'Ns') cur.roughness = Math.sqrt(2 / (nums[0] + 2));
127
+ }
128
+ }
129
+ return mats;
130
+ }
131
+
132
+ /**
133
+ * Parse OBJ text (and optional MTL text) into meshes grouped by material.
134
+ * Faces are scanned character by character and corners are de-duplicated
135
+ * through per-position linked lists, so large files parse without per-corner
136
+ * string allocations.
137
+ * @returns {{groups: {material: string, mesh: Mesh}[], materials: Map, stats: object}}
138
+ */
139
+ export function parseOBJ(text, mtlText = '') {
140
+ const pos = [];
141
+ const nrm = [];
142
+ const tex = [];
143
+ const groups = new Map();
144
+ let group = null;
145
+ const groupList = [];
146
+ const useGroup = (name) => {
147
+ group = groups.get(name);
148
+ if (!group) {
149
+ group = { material: name, id: groupList.length, p: [], n: [], t: [], idx: [], lines: [], hasNormals: true, hasUV: true };
150
+ groups.set(name, group);
151
+ groupList.push(group);
152
+ }
153
+ };
154
+ useGroup('default');
155
+ // corner de-duplication: for every position index a linked list of the
156
+ // (group, texcoord, normal) combinations already emitted
157
+ let head = new Int32Array(1024).fill(-1);
158
+ let cap = 1024;
159
+ let nextE = new Int32Array(cap), gE = new Int32Array(cap), tE = new Int32Array(cap), nE = new Int32Array(cap), outE = new Int32Array(cap);
160
+ let entries = 0;
161
+ const growEntries = () => {
162
+ cap *= 2;
163
+ const grow = (a) => {
164
+ const b = new Int32Array(cap);
165
+ b.set(a);
166
+ return b;
167
+ };
168
+ nextE = grow(nextE);
169
+ gE = grow(gE);
170
+ tE = grow(tE);
171
+ nE = grow(nE);
172
+ outE = grow(outE);
173
+ };
174
+ const corner = (v, t, n) => {
175
+ if (v < 0) v = pos.length / 3 + v + 1;
176
+ if (t < 0) t = tex.length / 2 + t + 1;
177
+ if (n < 0) n = nrm.length / 3 + n + 1;
178
+ if (v < 1 || v > pos.length / 3) throw new Error(`OBJ face references vertex ${v}, but only ${pos.length / 3} are defined.`);
179
+ if (v > head.length) {
180
+ let len = head.length;
181
+ while (len < v) len *= 2;
182
+ const h = new Int32Array(len).fill(-1);
183
+ h.set(head);
184
+ head = h;
185
+ }
186
+ for (let e = head[v - 1]; e >= 0; e = nextE[e]) {
187
+ if (gE[e] === group.id && tE[e] === t && nE[e] === n) return outE[e];
188
+ }
189
+ const idx = group.p.length / 3;
190
+ group.p.push(pos[(v - 1) * 3], pos[(v - 1) * 3 + 1], pos[(v - 1) * 3 + 2]);
191
+ if (n) group.n.push(nrm[(n - 1) * 3], nrm[(n - 1) * 3 + 1], nrm[(n - 1) * 3 + 2]);
192
+ else {
193
+ group.hasNormals = false;
194
+ group.n.push(0, 0, 0);
195
+ }
196
+ if (t) group.t.push(tex[(t - 1) * 2], tex[(t - 1) * 2 + 1]);
197
+ else {
198
+ group.hasUV = false;
199
+ group.t.push(0, 0);
200
+ }
201
+ if (entries === cap) growEntries();
202
+ gE[entries] = group.id;
203
+ tE[entries] = t;
204
+ nE[entries] = n;
205
+ outE[entries] = idx;
206
+ nextE[entries] = head[v - 1];
207
+ head[v - 1] = entries++;
208
+ return idx;
209
+ };
210
+ let i = 0;
211
+ const L = text.length;
212
+ // plain decimals (the common case) are converted with one exact division,
213
+ // which gives the same correctly rounded double as Number(); anything else
214
+ // (exponents, very long mantissas) goes through Number()
215
+ const decimal = (start, end) => {
216
+ let k = start;
217
+ let neg = false;
218
+ const c0 = text.charCodeAt(k);
219
+ if (c0 === 45 || c0 === 43) {
220
+ neg = c0 === 45;
221
+ k++;
222
+ }
223
+ let mant = 0, scale = 0, dot = false, digits = 0;
224
+ for (; k < end; k++) {
225
+ const c = text.charCodeAt(k);
226
+ if (c >= 48 && c <= 57) {
227
+ if (mant > 9e14) return +text.slice(start, end);
228
+ mant = mant * 10 + (c - 48);
229
+ digits++;
230
+ if (dot) scale++;
231
+ } else if (c === 46 && !dot) dot = true;
232
+ else return +text.slice(start, end);
233
+ }
234
+ if (!digits || scale > 22) return +text.slice(start, end);
235
+ const v = scale ? mant / POW10[scale] : mant;
236
+ return neg ? -v : v;
237
+ };
238
+ const readFloats = (start, end, out, count) => {
239
+ let k = start;
240
+ for (let c = 0; c < count; c++) {
241
+ while (k < end && (text.charCodeAt(k) === 32 || text.charCodeAt(k) === 9)) k++;
242
+ let e = k;
243
+ while (e < end && text.charCodeAt(e) !== 32 && text.charCodeAt(e) !== 9) e++;
244
+ out.push(k < e ? decimal(k, e) : 0);
245
+ k = e;
246
+ }
247
+ };
248
+ // signed integer scanner for face records; `at` is the cursor
249
+ let at = 0;
250
+ const int = (end) => {
251
+ let sign = 1;
252
+ if (text.charCodeAt(at) === 45) {
253
+ sign = -1;
254
+ at++;
255
+ }
256
+ let v = 0;
257
+ let digits = 0;
258
+ for (let c = text.charCodeAt(at); at < end && c >= 48 && c <= 57; c = text.charCodeAt(++at)) {
259
+ v = v * 10 + (c - 48);
260
+ digits++;
261
+ }
262
+ return digits ? sign * v : 0;
263
+ };
264
+ // the corners of one face / line record, as output indices
265
+ const refs = [];
266
+ const readRefs = (start, end) => {
267
+ refs.length = 0;
268
+ at = start;
269
+ for (;;) {
270
+ while (at < end && (text.charCodeAt(at) === 32 || text.charCodeAt(at) === 9)) at++;
271
+ if (at >= end) break;
272
+ const v = int(end);
273
+ let t = 0, n = 0;
274
+ if (text.charCodeAt(at) === 47) {
275
+ at++;
276
+ t = int(end);
277
+ if (text.charCodeAt(at) === 47) {
278
+ at++;
279
+ n = int(end);
280
+ }
281
+ }
282
+ const c = text.charCodeAt(at);
283
+ if (at < end && c !== 32 && c !== 9) throw new Error(`Invalid OBJ vertex reference near "${text.slice(start, Math.min(end, start + 40))}".`);
284
+ refs.push(corner(v, t, n));
285
+ }
286
+ };
287
+ while (i < L) {
288
+ let e = text.indexOf('\n', i);
289
+ if (e < 0) e = L;
290
+ let s = i;
291
+ while (s < e && (text.charCodeAt(s) === 32 || text.charCodeAt(s) === 9)) s++;
292
+ const c0 = text.charCodeAt(s);
293
+ const c1 = text.charCodeAt(s + 1);
294
+ let end = e;
295
+ if (text.charCodeAt(end - 1) === 13) end--;
296
+ if (c0 === 118) {
297
+ // v, vn, vt
298
+ if (c1 === 32 || c1 === 9) readFloats(s + 2, end, pos, 3);
299
+ else if (c1 === 110) readFloats(s + 3, end, nrm, 3);
300
+ else if (c1 === 116) readFloats(s + 3, end, tex, 2);
301
+ } else if (c0 === 102 && (c1 === 32 || c1 === 9)) {
302
+ readRefs(s + 2, end);
303
+ for (let k = 2; k < refs.length; k++) group.idx.push(refs[0], refs[k - 1], refs[k]);
304
+ } else if (c0 === 108 && (c1 === 32 || c1 === 9)) {
305
+ readRefs(s + 2, end);
306
+ for (let k = 0; k + 1 < refs.length; k++) group.lines.push(refs[k], refs[k + 1]);
307
+ } else if (text.startsWith('usemtl', s)) {
308
+ useGroup(text.slice(s + 6, end).trim() || 'default');
309
+ }
310
+ i = e + 1;
311
+ }
312
+ const result = [];
313
+ let triangles = 0;
314
+ for (const g of groups.values()) {
315
+ if (g.idx.length) {
316
+ let mesh;
317
+ if (g.hasNormals) {
318
+ mesh = new Mesh({ positions: Float32Array.from(g.p), normals: Float32Array.from(g.n), uvs: Float32Array.from(g.t), indices: Uint32Array.from(g.idx) });
319
+ } else {
320
+ mesh = computeNormals(Float32Array.from(g.p), Uint32Array.from(g.idx), 30, g.hasUV ? Float32Array.from(g.t) : null);
321
+ }
322
+ result.push({ material: g.material, mesh });
323
+ triangles += g.idx.length / 3;
324
+ }
325
+ if (g.lines.length) {
326
+ result.push({ material: g.material, mesh: new Mesh({ positions: Float32Array.from(g.p), indices: Uint32Array.from(g.lines), kind: 'lines' }) });
327
+ }
328
+ }
329
+ return { groups: result, materials: mtlText ? parseMtl(mtlText) : new Map(), stats: { vertices: pos.length / 3, triangles } };
330
+ }
331
+
332
+ /** Parse OBJ into a RenderScene (one identity instance per material group). */
333
+ export function objToScene(text, mtlText = '') {
334
+ const parsed = parseOBJ(text, mtlText);
335
+ const matIndex = new Map();
336
+ const materials = [];
337
+ const geometries = [];
338
+ const instances = new InstanceTable();
339
+ const I = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
340
+ for (const g of parsed.groups) {
341
+ let mi = matIndex.get(g.material);
342
+ if (mi === undefined) {
343
+ mi = materials.length;
344
+ matIndex.set(g.material, mi);
345
+ const m = parsed.materials.get(g.material);
346
+ materials.push(m ? { ...m } : { name: g.material });
347
+ }
348
+ geometries.push({ mesh: g.mesh });
349
+ instances.push(geometries.length - 1, mi, I);
350
+ }
351
+ return { ...buildExternalScene({ geometries, materials, instances }), parsed };
352
+ }