@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/stl.js CHANGED
@@ -1,193 +1,193 @@
1
- // STL (stereolithography) import and export, binary and ASCII.
2
- //
3
- // STL stores unconnected triangles with one face normal each: no units, no
4
- // colors, no shared vertices. Files from CAD and 3D-printing tools are Z-up
5
- // and usually in millimetres. The importer welds identical corners and
6
- // computes vertex normals with a crease angle, so flat faces stay flat and
7
- // curved surfaces look round; the exporter writes world-space triangles of
8
- // every instance, Z-up by default, as slicers expect.
9
-
10
- import { computeNormals, weldMap } from '../geometry/mesh.js';
11
- import { buildExternalScene, exportableInstances, InstanceTable } from './external-scene.js';
12
-
13
- /** A binary STL is exactly 84 + 50 · n bytes long; that test is stronger than the header text. */
14
- export function isBinarySTL(bytes) {
15
- if (bytes.length < 84) return false;
16
- const n = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(80, true);
17
- return 84 + n * 50 === bytes.length;
18
- }
19
-
20
- /** ASCII STL: "solid <name>" followed by the first facet (or an empty solid). */
21
- export function isAsciiSTL(bytes) {
22
- const head = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 1024)));
23
- return /^\s*solid\b[^\n]*\n\s*(facet|endsolid)\b/.test(head);
24
- }
25
-
26
- /**
27
- * Parse an STL file.
28
- * @param {Uint8Array} bytes
29
- * @returns {{positions: Float32Array, triangles: number, name: string, format: 'binary'|'ascii'}}
30
- * positions: 9 floats per triangle, in file order
31
- */
32
- export function parseSTL(bytes) {
33
- if (isBinarySTL(bytes)) {
34
- const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
35
- const n = dv.getUint32(80, true);
36
- const positions = new Float32Array(n * 9);
37
- for (let t = 0; t < n; t++) {
38
- const o = 84 + t * 50 + 12; // skip the face normal
39
- for (let k = 0; k < 9; k++) positions[t * 9 + k] = dv.getFloat32(o + k * 4, true);
40
- }
41
- const header = new TextDecoder().decode(bytes.subarray(0, 80)).replace(/\0[\s\S]*$/, '').trim();
42
- return { positions, triangles: n, name: header.replace(/^solid\s*/i, ''), format: 'binary' };
43
- }
44
- const text = new TextDecoder().decode(bytes);
45
- if (!/^\s*solid\b/.test(text)) throw new Error('Not an STL file: binary STL size does not match its triangle count, and ASCII STL must start with "solid".');
46
- const out = [];
47
- const re = /\bvertex\s+(\S+)\s+(\S+)\s+(\S+)/g;
48
- let m;
49
- while ((m = re.exec(text))) {
50
- const x = Number(m[1]), y = Number(m[2]), z = Number(m[3]);
51
- if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
52
- const line = text.slice(0, m.index).split('\n').length;
53
- throw new Error(`STL line ${line}: vertex coordinates must be numbers ("${m[0]}").`);
54
- }
55
- out.push(x, y, z);
56
- }
57
- if (out.length % 9 !== 0) throw new Error(`STL: found ${out.length / 3} vertices, which is not a multiple of 3 (every facet needs exactly 3).`);
58
- const name = (/^\s*solid[ \t]*([^\r\n]*)/.exec(text) || [])[1] || '';
59
- return { positions: Float32Array.from(out), triangles: out.length / 9, name: name.trim(), format: 'ascii' };
60
- }
61
-
62
- /** Weld identical corners of a triangle soup: unique positions plus an index list. */
63
- export function weldTriangles(soup) {
64
- const map = weldMap(soup);
65
- const n = soup.length / 3;
66
- const remap = new Int32Array(n).fill(-1);
67
- const indices = new Uint32Array(n);
68
- let unique = 0;
69
- for (let i = 0; i < n; i++) {
70
- const r = map[i];
71
- if (remap[r] < 0) remap[r] = unique++;
72
- indices[i] = remap[r];
73
- }
74
- const positions = new Float32Array(unique * 3);
75
- for (let i = 0; i < n; i++) {
76
- const v = indices[i] * 3;
77
- positions[v] = soup[i * 3];
78
- positions[v + 1] = soup[i * 3 + 1];
79
- positions[v + 2] = soup[i * 3 + 2];
80
- }
81
- // drop triangles that collapsed to a line or a point
82
- let w = 0;
83
- for (let t = 0; t < indices.length; t += 3) {
84
- const a = indices[t], b = indices[t + 1], c = indices[t + 2];
85
- if (a === b || b === c || a === c) continue;
86
- indices[w++] = a;
87
- indices[w++] = b;
88
- indices[w++] = c;
89
- }
90
- return { positions, indices: indices.slice(0, w), degenerate: (indices.length - w) / 3 };
91
- }
92
-
93
- /** Z-up (STL) to Y-up (ES3D): a -90° rotation about X, column-major. */
94
- export const Z_UP_TO_Y_UP = [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1];
95
-
96
- /**
97
- * Convert an STL file into a RenderScene (one mesh, one instance).
98
- * @param {Uint8Array} bytes
99
- * @param {{up?: 'z'|'y', smooth?: number}} [opts] up: the file's up axis; smooth: crease angle in degrees
100
- */
101
- export function stlToScene(bytes, { up = 'z', smooth = 30 } = {}) {
102
- const parsed = parseSTL(bytes);
103
- if (!parsed.triangles) throw new Error('The STL file contains no triangles.');
104
- const { positions, indices, degenerate } = weldTriangles(parsed.positions);
105
- const mesh = computeNormals(positions, indices, smooth);
106
- const instances = new InstanceTable(1);
107
- instances.push(0, 0, up === 'z' ? Z_UP_TO_Y_UP : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
108
- const materials = [{ name: 'stl', color: [0.72, 0.74, 0.78, 1], roughness: 0.45, metallic: 0 }];
109
- const built = buildExternalScene({ geometries: [{ mesh }], materials, instances });
110
- return { ...built, info: { name: parsed.name, format: parsed.format, triangles: parsed.triangles, vertices: positions.length / 3, degenerate, smooth } };
111
- }
112
-
113
- /**
114
- * Export the static scene as STL: every instance's triangles in world space.
115
- * @param {import('../scene/flatten.js').RenderScene} scene
116
- * @param {(entry: object, lod: number) => import('../geometry/mesh.js').Mesh} meshOf
117
- * @param {{lod?: number, up?: 'z'|'y', binary?: boolean, name?: string, maxTriangles?: number}} [opts]
118
- * @returns {{data: Uint8Array|string, triangles: number}}
119
- */
120
- export function exportSTL(scene, meshOf, { lod = 0, up = 'z', binary = true, name = 'es3d', maxTriangles = 2e7 } = {}) {
121
- const list = exportableInstances(scene);
122
- let total = 0;
123
- for (const i of list) {
124
- const m = meshOf(scene.geometries[scene.geom[i]], lod);
125
- if (m.kind === 'triangles') total += m.primitiveCount;
126
- }
127
- if (total > maxTriangles) throw new Error(`The scene has ${total.toLocaleString('en-US')} triangles; STL output is limited to ${maxTriangles.toLocaleString('en-US')}. Export a coarser level of detail.`);
128
- const tri = new Float64Array(9);
129
- const M = new Float64Array(16);
130
- const zUp = up === 'z';
131
- let dv = null, o = 84;
132
- const lines = [];
133
- if (binary) {
134
- const out = new Uint8Array(84 + total * 50);
135
- const head = new TextEncoder().encode(`ES3D export: ${name}`.slice(0, 79));
136
- out.set(head);
137
- dv = new DataView(out.buffer);
138
- dv.setUint32(80, total, true);
139
- } else lines.push(`solid ${name}`);
140
- const f = (v) => {
141
- if (Math.abs(v) < 1e-30) return '0';
142
- const s = Math.fround(v).toPrecision(7);
143
- return s.includes('.') ? s.replace(/0+(?=e|$)/, '').replace(/\.(?=e|$)/, '') : s;
144
- };
145
- for (const i of list) {
146
- const mesh = meshOf(scene.geometries[scene.geom[i]], lod);
147
- if (mesh.kind !== 'triangles') continue;
148
- for (let k = 0; k < 16; k++) M[k] = scene.matrices[i * 16 + k];
149
- const det = M[0] * (M[5] * M[10] - M[6] * M[9]) - M[4] * (M[1] * M[10] - M[2] * M[9]) + M[8] * (M[1] * M[6] - M[2] * M[5]);
150
- const flip = det < 0;
151
- const p = mesh.positions, idx = mesh.indices;
152
- for (let t = 0; t < idx.length; t += 3) {
153
- for (let c = 0; c < 3; c++) {
154
- const v = idx[t + (flip && c ? 3 - c : c)] * 3;
155
- const x = p[v], y = p[v + 1], z = p[v + 2];
156
- const wx = M[0] * x + M[4] * y + M[8] * z + M[12];
157
- const wy = M[1] * x + M[5] * y + M[9] * z + M[13];
158
- const wz = M[2] * x + M[6] * y + M[10] * z + M[14];
159
- // Y-up to Z-up: (x, y, z) -> (x, -z, y)
160
- tri[c * 3] = wx;
161
- tri[c * 3 + 1] = zUp ? -wz : wy;
162
- tri[c * 3 + 2] = zUp ? wy : wz;
163
- }
164
- const e1x = tri[3] - tri[0], e1y = tri[4] - tri[1], e1z = tri[5] - tri[2];
165
- const e2x = tri[6] - tri[0], e2y = tri[7] - tri[1], e2z = tri[8] - tri[2];
166
- let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
167
- const l = Math.hypot(nx, ny, nz) || 1;
168
- nx /= l;
169
- ny /= l;
170
- nz /= l;
171
- if (binary) {
172
- dv.setFloat32(o, nx, true);
173
- dv.setFloat32(o + 4, ny, true);
174
- dv.setFloat32(o + 8, nz, true);
175
- for (let k = 0; k < 9; k++) dv.setFloat32(o + 12 + k * 4, tri[k], true);
176
- o += 50;
177
- } else {
178
- lines.push(
179
- ` facet normal ${f(nx)} ${f(ny)} ${f(nz)}`,
180
- ' outer loop',
181
- ` vertex ${f(tri[0])} ${f(tri[1])} ${f(tri[2])}`,
182
- ` vertex ${f(tri[3])} ${f(tri[4])} ${f(tri[5])}`,
183
- ` vertex ${f(tri[6])} ${f(tri[7])} ${f(tri[8])}`,
184
- ' endloop',
185
- ' endfacet',
186
- );
187
- }
188
- }
189
- }
190
- if (binary) return { data: new Uint8Array(dv.buffer), triangles: total };
191
- lines.push(`endsolid ${name}`);
192
- return { data: lines.join('\n') + '\n', triangles: total };
193
- }
1
+ // STL (stereolithography) import and export, binary and ASCII.
2
+ //
3
+ // STL stores unconnected triangles with one face normal each: no units, no
4
+ // colors, no shared vertices. Files from CAD and 3D-printing tools are Z-up
5
+ // and usually in millimetres. The importer welds identical corners and
6
+ // computes vertex normals with a crease angle, so flat faces stay flat and
7
+ // curved surfaces look round; the exporter writes world-space triangles of
8
+ // every instance, Z-up by default, as slicers expect.
9
+
10
+ import { computeNormals, weldMap } from '../geometry/mesh.js';
11
+ import { buildExternalScene, exportableInstances, InstanceTable } from './external-scene.js';
12
+
13
+ /** A binary STL is exactly 84 + 50 · n bytes long; that test is stronger than the header text. */
14
+ export function isBinarySTL(bytes) {
15
+ if (bytes.length < 84) return false;
16
+ const n = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(80, true);
17
+ return 84 + n * 50 === bytes.length;
18
+ }
19
+
20
+ /** ASCII STL: "solid <name>" followed by the first facet (or an empty solid). */
21
+ export function isAsciiSTL(bytes) {
22
+ const head = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 1024)));
23
+ return /^\s*solid\b[^\n]*\n\s*(facet|endsolid)\b/.test(head);
24
+ }
25
+
26
+ /**
27
+ * Parse an STL file.
28
+ * @param {Uint8Array} bytes
29
+ * @returns {{positions: Float32Array, triangles: number, name: string, format: 'binary'|'ascii'}}
30
+ * positions: 9 floats per triangle, in file order
31
+ */
32
+ export function parseSTL(bytes) {
33
+ if (isBinarySTL(bytes)) {
34
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
35
+ const n = dv.getUint32(80, true);
36
+ const positions = new Float32Array(n * 9);
37
+ for (let t = 0; t < n; t++) {
38
+ const o = 84 + t * 50 + 12; // skip the face normal
39
+ for (let k = 0; k < 9; k++) positions[t * 9 + k] = dv.getFloat32(o + k * 4, true);
40
+ }
41
+ const header = new TextDecoder().decode(bytes.subarray(0, 80)).replace(/\0[\s\S]*$/, '').trim();
42
+ return { positions, triangles: n, name: header.replace(/^solid\s*/i, ''), format: 'binary' };
43
+ }
44
+ const text = new TextDecoder().decode(bytes);
45
+ if (!/^\s*solid\b/.test(text)) throw new Error('Not an STL file: binary STL size does not match its triangle count, and ASCII STL must start with "solid".');
46
+ const out = [];
47
+ const re = /\bvertex\s+(\S+)\s+(\S+)\s+(\S+)/g;
48
+ let m;
49
+ while ((m = re.exec(text))) {
50
+ const x = Number(m[1]), y = Number(m[2]), z = Number(m[3]);
51
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
52
+ const line = text.slice(0, m.index).split('\n').length;
53
+ throw new Error(`STL line ${line}: vertex coordinates must be numbers ("${m[0]}").`);
54
+ }
55
+ out.push(x, y, z);
56
+ }
57
+ if (out.length % 9 !== 0) throw new Error(`STL: found ${out.length / 3} vertices, which is not a multiple of 3 (every facet needs exactly 3).`);
58
+ const name = (/^\s*solid[ \t]*([^\r\n]*)/.exec(text) || [])[1] || '';
59
+ return { positions: Float32Array.from(out), triangles: out.length / 9, name: name.trim(), format: 'ascii' };
60
+ }
61
+
62
+ /** Weld identical corners of a triangle soup: unique positions plus an index list. */
63
+ export function weldTriangles(soup) {
64
+ const map = weldMap(soup);
65
+ const n = soup.length / 3;
66
+ const remap = new Int32Array(n).fill(-1);
67
+ const indices = new Uint32Array(n);
68
+ let unique = 0;
69
+ for (let i = 0; i < n; i++) {
70
+ const r = map[i];
71
+ if (remap[r] < 0) remap[r] = unique++;
72
+ indices[i] = remap[r];
73
+ }
74
+ const positions = new Float32Array(unique * 3);
75
+ for (let i = 0; i < n; i++) {
76
+ const v = indices[i] * 3;
77
+ positions[v] = soup[i * 3];
78
+ positions[v + 1] = soup[i * 3 + 1];
79
+ positions[v + 2] = soup[i * 3 + 2];
80
+ }
81
+ // drop triangles that collapsed to a line or a point
82
+ let w = 0;
83
+ for (let t = 0; t < indices.length; t += 3) {
84
+ const a = indices[t], b = indices[t + 1], c = indices[t + 2];
85
+ if (a === b || b === c || a === c) continue;
86
+ indices[w++] = a;
87
+ indices[w++] = b;
88
+ indices[w++] = c;
89
+ }
90
+ return { positions, indices: indices.slice(0, w), degenerate: (indices.length - w) / 3 };
91
+ }
92
+
93
+ /** Z-up (STL) to Y-up (ES3D): a -90° rotation about X, column-major. */
94
+ export const Z_UP_TO_Y_UP = [1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1];
95
+
96
+ /**
97
+ * Convert an STL file into a RenderScene (one mesh, one instance).
98
+ * @param {Uint8Array} bytes
99
+ * @param {{up?: 'z'|'y', smooth?: number}} [opts] up: the file's up axis; smooth: crease angle in degrees
100
+ */
101
+ export function stlToScene(bytes, { up = 'z', smooth = 30 } = {}) {
102
+ const parsed = parseSTL(bytes);
103
+ if (!parsed.triangles) throw new Error('The STL file contains no triangles.');
104
+ const { positions, indices, degenerate } = weldTriangles(parsed.positions);
105
+ const mesh = computeNormals(positions, indices, smooth);
106
+ const instances = new InstanceTable(1);
107
+ instances.push(0, 0, up === 'z' ? Z_UP_TO_Y_UP : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
108
+ const materials = [{ name: 'stl', color: [0.72, 0.74, 0.78, 1], roughness: 0.45, metallic: 0 }];
109
+ const built = buildExternalScene({ geometries: [{ mesh }], materials, instances });
110
+ return { ...built, info: { name: parsed.name, format: parsed.format, triangles: parsed.triangles, vertices: positions.length / 3, degenerate, smooth } };
111
+ }
112
+
113
+ /**
114
+ * Export the static scene as STL: every instance's triangles in world space.
115
+ * @param {import('../scene/flatten.js').RenderScene} scene
116
+ * @param {(entry: object, lod: number) => import('../geometry/mesh.js').Mesh} meshOf
117
+ * @param {{lod?: number, up?: 'z'|'y', binary?: boolean, name?: string, maxTriangles?: number}} [opts]
118
+ * @returns {{data: Uint8Array|string, triangles: number}}
119
+ */
120
+ export function exportSTL(scene, meshOf, { lod = 0, up = 'z', binary = true, name = 'es3d', maxTriangles = 2e7 } = {}) {
121
+ const list = exportableInstances(scene);
122
+ let total = 0;
123
+ for (const i of list) {
124
+ const m = meshOf(scene.geometries[scene.geom[i]], lod);
125
+ if (m.kind === 'triangles') total += m.primitiveCount;
126
+ }
127
+ if (total > maxTriangles) throw new Error(`The scene has ${total.toLocaleString('en-US')} triangles; STL output is limited to ${maxTriangles.toLocaleString('en-US')}. Export a coarser level of detail.`);
128
+ const tri = new Float64Array(9);
129
+ const M = new Float64Array(16);
130
+ const zUp = up === 'z';
131
+ let dv = null, o = 84;
132
+ const lines = [];
133
+ if (binary) {
134
+ const out = new Uint8Array(84 + total * 50);
135
+ const head = new TextEncoder().encode(`ES3D export: ${name}`.slice(0, 79));
136
+ out.set(head);
137
+ dv = new DataView(out.buffer);
138
+ dv.setUint32(80, total, true);
139
+ } else lines.push(`solid ${name}`);
140
+ const f = (v) => {
141
+ if (Math.abs(v) < 1e-30) return '0';
142
+ const s = Math.fround(v).toPrecision(7);
143
+ return s.includes('.') ? s.replace(/0+(?=e|$)/, '').replace(/\.(?=e|$)/, '') : s;
144
+ };
145
+ for (const i of list) {
146
+ const mesh = meshOf(scene.geometries[scene.geom[i]], lod);
147
+ if (mesh.kind !== 'triangles') continue;
148
+ for (let k = 0; k < 16; k++) M[k] = scene.matrices[i * 16 + k];
149
+ const det = M[0] * (M[5] * M[10] - M[6] * M[9]) - M[4] * (M[1] * M[10] - M[2] * M[9]) + M[8] * (M[1] * M[6] - M[2] * M[5]);
150
+ const flip = det < 0;
151
+ const p = mesh.positions, idx = mesh.indices;
152
+ for (let t = 0; t < idx.length; t += 3) {
153
+ for (let c = 0; c < 3; c++) {
154
+ const v = idx[t + (flip && c ? 3 - c : c)] * 3;
155
+ const x = p[v], y = p[v + 1], z = p[v + 2];
156
+ const wx = M[0] * x + M[4] * y + M[8] * z + M[12];
157
+ const wy = M[1] * x + M[5] * y + M[9] * z + M[13];
158
+ const wz = M[2] * x + M[6] * y + M[10] * z + M[14];
159
+ // Y-up to Z-up: (x, y, z) -> (x, -z, y)
160
+ tri[c * 3] = wx;
161
+ tri[c * 3 + 1] = zUp ? -wz : wy;
162
+ tri[c * 3 + 2] = zUp ? wy : wz;
163
+ }
164
+ const e1x = tri[3] - tri[0], e1y = tri[4] - tri[1], e1z = tri[5] - tri[2];
165
+ const e2x = tri[6] - tri[0], e2y = tri[7] - tri[1], e2z = tri[8] - tri[2];
166
+ let nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
167
+ const l = Math.hypot(nx, ny, nz) || 1;
168
+ nx /= l;
169
+ ny /= l;
170
+ nz /= l;
171
+ if (binary) {
172
+ dv.setFloat32(o, nx, true);
173
+ dv.setFloat32(o + 4, ny, true);
174
+ dv.setFloat32(o + 8, nz, true);
175
+ for (let k = 0; k < 9; k++) dv.setFloat32(o + 12 + k * 4, tri[k], true);
176
+ o += 50;
177
+ } else {
178
+ lines.push(
179
+ ` facet normal ${f(nx)} ${f(ny)} ${f(nz)}`,
180
+ ' outer loop',
181
+ ` vertex ${f(tri[0])} ${f(tri[1])} ${f(tri[2])}`,
182
+ ` vertex ${f(tri[3])} ${f(tri[4])} ${f(tri[5])}`,
183
+ ` vertex ${f(tri[6])} ${f(tri[7])} ${f(tri[8])}`,
184
+ ' endloop',
185
+ ' endfacet',
186
+ );
187
+ }
188
+ }
189
+ }
190
+ if (binary) return { data: new Uint8Array(dv.buffer), triangles: total };
191
+ lines.push(`endsolid ${name}`);
192
+ return { data: lines.join('\n') + '\n', triangles: total };
193
+ }