@es3d/es3d 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +86 -3
  3. package/docs/REFERENCE.md +581 -0
  4. package/docs/SPEC.md +417 -0
  5. package/package.json +50 -4
  6. package/scripts/es3d-cli.mjs +377 -0
  7. package/src/cache/geometry-store.js +392 -0
  8. package/src/cache/mesh-bundle.js +123 -0
  9. package/src/cache/sha256.js +73 -0
  10. package/src/format/ast.js +238 -0
  11. package/src/format/colors.js +93 -0
  12. package/src/format/compact-parser.js +171 -0
  13. package/src/format/compact-tokenizer.js +165 -0
  14. package/src/format/errors.js +247 -0
  15. package/src/format/json.js +55 -0
  16. package/src/format/limits.js +14 -0
  17. package/src/format/parse.js +92 -0
  18. package/src/format/schema.js +617 -0
  19. package/src/format/serializer.js +333 -0
  20. package/src/format/validator.js +330 -0
  21. package/src/format/value-error.js +12 -0
  22. package/src/format/value-format.js +314 -0
  23. package/src/format/values.js +459 -0
  24. package/src/format/xml-parser.js +136 -0
  25. package/src/format/xml-tokenizer.js +237 -0
  26. package/src/geometry/contour.js +86 -0
  27. package/src/geometry/extrude.js +175 -0
  28. package/src/geometry/gear.js +85 -0
  29. package/src/geometry/geometry-cache.js +159 -0
  30. package/src/geometry/lod-error.js +178 -0
  31. package/src/geometry/mesh.js +458 -0
  32. package/src/geometry/primitives.js +299 -0
  33. package/src/geometry/registry.js +645 -0
  34. package/src/geometry/revolve.js +136 -0
  35. package/src/geometry/sweep.js +441 -0
  36. package/src/index.js +47 -0
  37. package/src/io/compression.js +67 -0
  38. package/src/io/convert.js +236 -0
  39. package/src/io/download.js +13 -0
  40. package/src/io/external-scene.js +127 -0
  41. package/src/io/file-loader.js +28 -0
  42. package/src/io/gltf.js +630 -0
  43. package/src/io/mesh-import.js +104 -0
  44. package/src/io/obj.js +352 -0
  45. package/src/io/step-brep.js +439 -0
  46. package/src/io/step-exact.js +1149 -0
  47. package/src/io/step.js +1418 -0
  48. package/src/io/stl.js +193 -0
  49. package/src/io/svg-import.js +787 -0
  50. package/src/material/materials.js +154 -0
  51. package/src/math/mat4.js +475 -0
  52. package/src/math/random.js +30 -0
  53. package/src/math/trig.js +65 -0
  54. package/src/math/vec3.js +81 -0
  55. package/src/optimize/optimizer.js +579 -0
  56. package/src/path/path-flatten.js +322 -0
  57. package/src/path/path-parser.js +332 -0
  58. package/src/path/shapes.js +129 -0
  59. package/src/path/triangulate.js +517 -0
  60. package/src/pipeline.js +74 -0
  61. package/src/render/camera.js +168 -0
  62. package/src/render/frame-builder.js +362 -0
  63. package/src/render/geometry-pool.js +149 -0
  64. package/src/render/lighting.js +149 -0
  65. package/src/render/orbit-controls.js +237 -0
  66. package/src/render/renderer.js +249 -0
  67. package/src/render/scroll-timeline.js +104 -0
  68. package/src/render/shaders/glsl.js +285 -0
  69. package/src/render/shaders/wgsl.js +252 -0
  70. package/src/render/stage.js +352 -0
  71. package/src/render/texture-cache.js +40 -0
  72. package/src/render/textures.js +223 -0
  73. package/src/render/webgl2/webgl2-backend.js +532 -0
  74. package/src/render/webgpu/webgpu-backend.js +480 -0
  75. package/src/scene/animation.js +171 -0
  76. package/src/scene/camera-constraints.js +117 -0
  77. package/src/scene/flatten.js +754 -0
  78. package/src/scene/picking.js +161 -0
  79. package/src/scene/scene-graph.js +320 -0
  80. package/src/transform/transform.js +211 -0
  81. package/src/wasm/kernels-binary.js +3 -0
  82. package/src/wasm/kernels.js +385 -0
@@ -0,0 +1,104 @@
1
+ // Conversion of tessellated scenes (OBJ / STL / glTF imports) into ES3D documents.
2
+ //
3
+ // Every distinct mesh becomes one <mesh> definition in <defs>, every instance
4
+ // a <use> with its transform, every material a <material>. This is the
5
+ // "escape hatch" path: the result is valid ES3D but not procedural, so it is
6
+ // about as large as the source mesh format (see the report).
7
+
8
+ import { h, Document } from '../format/ast.js';
9
+ import { serialize } from '../format/serializer.js';
10
+ import { formatNumber, formatFloat32 } from '../format/value-format.js';
11
+ import { matrixToTransformString } from '../transform/transform.js';
12
+ import { linearToSrgb, colorToHex } from '../format/colors.js';
13
+ import { weldMap } from '../geometry/mesh.js';
14
+ import { materialNames } from './external-scene.js';
15
+
16
+ /** Decimals that keep coordinates to `relative` of the scene size (e.g. 1e-5). */
17
+ export function decimalsFor(bounds, relative) {
18
+ const size = Math.max(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]);
19
+ if (!(size > 0) || !Number.isFinite(size)) return 5;
20
+ return Math.max(0, Math.min(9, Math.ceil(-Math.log10(size * relative))));
21
+ }
22
+
23
+ /** Merge vertices with equal positions (when neither normals nor UVs are written, they are redundant). */
24
+ function welded(mesh) {
25
+ const map = weldMap(mesh.positions);
26
+ const n = mesh.vertexCount;
27
+ const remap = new Int32Array(n).fill(-1);
28
+ let unique = 0;
29
+ for (let i = 0; i < n; i++) if (remap[map[i]] < 0) remap[map[i]] = unique++;
30
+ if (unique === n) return { positions: mesh.positions, indices: mesh.indices };
31
+ const positions = new Float32Array(unique * 3);
32
+ for (let i = 0; i < n; i++) {
33
+ const r = remap[map[i]] * 3;
34
+ positions[r] = mesh.positions[i * 3];
35
+ positions[r + 1] = mesh.positions[i * 3 + 1];
36
+ positions[r + 2] = mesh.positions[i * 3 + 2];
37
+ }
38
+ const indices = new Uint32Array(mesh.indices.length);
39
+ for (let i = 0; i < indices.length; i++) indices[i] = remap[map[mesh.indices[i]]];
40
+ return { positions, indices };
41
+ }
42
+
43
+ /**
44
+ * @param {import('../scene/flatten.js').RenderScene} scene
45
+ * @param {(entry: object, lod: number) => import('../geometry/mesh.js').Mesh} meshOf
46
+ * @param {string} [title]
47
+ * @param {{digits?: number|null|'auto', normals?: boolean, uvs?: boolean|'auto', smooth?: number, relative?: number}} [opts]
48
+ * digits: decimals for positions ('auto' = relative precision of the scene size, null = exact float32);
49
+ * normals: false omits them, and the reader recomputes them with the crease angle `smooth`;
50
+ * uvs: 'auto' writes texture coordinates only when they are not all zero
51
+ * @returns {string} ES3D markup
52
+ */
53
+ export function sceneToMeshDocument(scene, meshOf, title = 'Imported mesh', { digits = 'auto', normals = true, uvs = 'auto', smooth = 30, relative = 1e-5 } = {}) {
54
+ const decimals = digits === 'auto' ? decimalsFor(scene.bounds, relative) : digits;
55
+ const fmt = decimals === null ? (v) => formatFloat32(v) : (v) => formatNumber(v, decimals);
56
+ const names = materialNames(scene.materials);
57
+ const defs = h('defs');
58
+ scene.materials.forEach((m, i) => {
59
+ const attrs = { id: names[i], color: colorToHex([linearToSrgb(m.baseColor[0]), linearToSrgb(m.baseColor[1]), linearToSrgb(m.baseColor[2]), 1]) };
60
+ if (m.metallic) attrs.metallic = formatNumber(m.metallic, 3);
61
+ if (m.roughness !== 0.5) attrs.roughness = formatNumber(m.roughness, 3);
62
+ if (m.opacity < 1) attrs.opacity = formatNumber(m.opacity, 3);
63
+ if (m.alphaMode === 'mask') {
64
+ attrs.alphaMode = 'mask';
65
+ if (m.alphaCutoff !== 0.5) attrs.alphaCutoff = formatNumber(m.alphaCutoff, 3);
66
+ } else if (m.alphaMode === 'blend') attrs.alphaMode = 'blend';
67
+ const peak = Math.max(...m.emissive);
68
+ if (peak > 0) {
69
+ // emissive is stored linear and pre-multiplied by its intensity
70
+ const k = peak > 1 ? 1 / peak : 1;
71
+ attrs.emissive = colorToHex([linearToSrgb(m.emissive[0] * k), linearToSrgb(m.emissive[1] * k), linearToSrgb(m.emissive[2] * k), 1]);
72
+ if (peak > 1) attrs.emissiveIntensity = formatNumber(peak, 3);
73
+ }
74
+ if (m.doubleSided) attrs.doubleSided = 'true';
75
+ defs.appendChild(h('material', attrs));
76
+ });
77
+ const meshIds = new Map();
78
+ scene.geometries.forEach((entry, gi) => {
79
+ const mesh = meshOf(entry, 0);
80
+ if (mesh.kind !== 'triangles' || !mesh.vertexCount) return;
81
+ const id = `mesh${gi}`;
82
+ meshIds.set(gi, id);
83
+ const writeUvs = uvs === true || (uvs === 'auto' && mesh.uvs.some((v) => v !== 0));
84
+ const data = !normals && !writeUvs ? welded(mesh) : mesh;
85
+ const attrs = { id, positions: Array.from(data.positions, fmt).join(' ') };
86
+ if (normals) attrs.normals = Array.from(mesh.normals, (v) => formatNumber(v, 2)).join(' ');
87
+ if (writeUvs) attrs.uvs = Array.from(mesh.uvs, (v) => formatNumber(v, 4)).join(' ');
88
+ attrs.indices = Array.from(data.indices).join(' ');
89
+ if (!normals && smooth !== 30) attrs.smooth = formatNumber(smooth, 2);
90
+ defs.appendChild(h('mesh', attrs));
91
+ });
92
+ const root = h('es3d', { version: '1' }, h('title', {}, String(title)), defs);
93
+ const m = new Float64Array(16);
94
+ for (let i = 0; i < scene.count; i++) {
95
+ const id = meshIds.get(scene.geom[i]);
96
+ if (!id) continue;
97
+ for (let k = 0; k < 16; k++) m[k] = scene.matrices[i * 16 + k];
98
+ const attrs = { ref: id, material: names[scene.mat[i]] };
99
+ const t = matrixToTransformString(m, (v) => formatNumber(v, 6));
100
+ if (t) attrs.transform = t;
101
+ root.appendChild(h('use', attrs));
102
+ }
103
+ return serialize(new Document({ root, syntax: 'xml' }), { pretty: true });
104
+ }
package/src/io/obj.js ADDED
@@ -0,0 +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
+ }