@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/step.js CHANGED
@@ -1,1418 +1,1418 @@
1
- // ISO 10303-21 (STEP) import and export.
2
- //
3
- // A boundary representation (what CAD tools export) is converted to ES3D
4
- // shapes by step-exact.js, from the exact geometry step-brep.js reads; this
5
- // module provides the parser and the helpers they share.
6
- //
7
- // stepToScene() is the triangle path: tessellated AP242 geometry
8
- // (TRIANGULATED_SURFACE_SET, COMPLEX_TRIANGULATED_SURFACE_SET,
9
- // TRIANGULATED_FACE and the complex face), and boundary solids made of
10
- // POLY_LOOP, or of EDGE_LOOP lines and circles on planes and cylinders.
11
- // The converter uses it for files without a B-rep.
12
- //
13
- // Writers such as Shapr3D emit FACE_BOUND for every loop and never
14
- // FACE_OUTER_BOUND. When no loop is marked outer, the largest is the outer
15
- // boundary and the rest are holes.
16
- //
17
- // Coordinates stay in the file's length unit. The unit is reported and not
18
- // applied. Z-up files are turned Y-up on import, matching STL.
19
- //
20
- // Export writes one AP242 triangulated surface of every instance in world
21
- // space. The file declares millimetres and does not rescale the numbers.
22
- // Z is up by default, again matching STL.
23
-
24
- import { computeNormals } from '../geometry/mesh.js';
25
- import { triangulate } from '../path/triangulate.js';
26
- import { buildExternalScene, exportableInstances, InstanceTable } from './external-scene.js';
27
- import { Z_UP_TO_Y_UP } from './stl.js';
28
-
29
- const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
30
- const TESS_TYPES = new Set([
31
- 'TRIANGULATED_SURFACE_SET',
32
- 'COMPLEX_TRIANGULATED_SURFACE_SET',
33
- 'TRIANGULATED_FACE',
34
- 'COMPLEX_TRIANGULATED_FACE',
35
- ]);
36
- const SHELL_TYPES = new Set(['CLOSED_SHELL', 'OPEN_SHELL', 'CONNECTED_FACE_SET']);
37
- const FACE_TYPES = new Set(['FACE', 'FACE_SURFACE', 'ADVANCED_FACE']);
38
-
39
- const CURVED_MSG = 'This STEP file is a curved solid (NURBS or an analytic surface other than a cylinder). ES3D imports tessellated STEP, planar faces, circles and cylinders. Re-export the model from the CAD tool as a tessellated STEP or as STL.';
40
-
41
- const decodeHead = (bytes) => new TextDecoder('utf-8').decode(bytes.subarray(0, Math.min(bytes.length, 1024))).replace(/^\uFEFF/, '');
42
-
43
- function toText(input) {
44
- if (typeof input === 'string') return input.replace(/^\uFEFF/, '');
45
- return new TextDecoder('utf-8').decode(input).replace(/^\uFEFF/, '');
46
- }
47
-
48
- /** True when the bytes (or a leading slice of text) are an ISO-10303-21 file. */
49
- export function isSTEP(input) {
50
- const text = typeof input === 'string' ? input : decodeHead(input);
51
- return /^\uFEFF?\s*(?:\/\*[\s\S]*?\*\/\s*)*ISO-10303-21\b/i.test(text);
52
- }
53
-
54
- /**
55
- * Parse a STEP file into its DATA entities.
56
- * @param {string|Uint8Array} input
57
- * @returns {{name: string, schema: string, unit: string|null, entities: Map<number, {id: number, parts: {name: string, args: any[]}[]}>}}
58
- */
59
- export function parseSTEP(input) {
60
- const text = toText(input);
61
- if (!isSTEP(text.slice(0, 1024))) throw new Error('Not a STEP file: expected ISO-10303-21.');
62
- const dataAt = findData(text);
63
- const header = text.slice(0, dataAt);
64
- const entities = parseData(text, dataAt);
65
- return {
66
- name: headerString(header, 'FILE_NAME'),
67
- schema: headerString(header, 'FILE_SCHEMA'),
68
- unit: lengthUnitName(entities),
69
- entities,
70
- };
71
- }
72
-
73
- /**
74
- * Convert a STEP file into a RenderScene. One mesh per shell, or one mesh
75
- * for faces that belong to no shell. Tessellation is used when the file has it.
76
- * @param {string|Uint8Array} input
77
- * @param {{up?: 'z'|'y', smooth?: number}} [opts]
78
- */
79
- export function stepToScene(input, { up = 'z', smooth = 30 } = {}) {
80
- const doc = parseSTEP(input);
81
- const { meshes, curved } = extractMeshes(doc.entities, smooth);
82
- if (!meshes.length) {
83
- if (curved) throw new Error(CURVED_MSG);
84
- throw new Error('The STEP file contains no triangles or supported faces.');
85
- }
86
- const matrix = up === 'y' ? IDENTITY : Z_UP_TO_Y_UP;
87
- const geometries = [];
88
- const materials = [];
89
- const instances = new InstanceTable(meshes.length);
90
- let triangles = 0;
91
- let vertices = 0;
92
- const base = productName(doc.entities) || doc.name.replace(/\.[^.]+$/, '') || 'step';
93
- meshes.forEach((m, i) => {
94
- geometries.push({ mesh: m.mesh, key: `step:${i}:${m.name}` });
95
- const name = meshes.length === 1 ? (m.name || base) : (m.name || `${base} ${i + 1}`);
96
- materials.push({ name, color: m.color || [0.72, 0.74, 0.78, 1], roughness: 0.45, metallic: 0 });
97
- instances.push(i, i, matrix);
98
- triangles += m.mesh.primitiveCount;
99
- vertices += m.mesh.vertexCount;
100
- });
101
- const built = buildExternalScene({ geometries, materials, instances });
102
- return { ...built, info: { name: base, schema: doc.schema, unit: doc.unit, triangles, vertices, meshes: meshes.length, curved } };
103
- }
104
-
105
- /**
106
- * Export the static scene as one tessellated STEP file.
107
- * @param {import('../scene/flatten.js').RenderScene} scene
108
- * @param {(entry: object, lod: number) => import('../geometry/mesh.js').Mesh} meshOf
109
- * @param {{lod?: number, up?: 'z'|'y', name?: string, maxTriangles?: number}} [opts]
110
- * @returns {{data: string, triangles: number}}
111
- */
112
- export function exportSTEP(scene, meshOf, { lod = 0, up = 'z', name = 'es3d', maxTriangles = 2e7 } = {}) {
113
- const list = exportableInstances(scene);
114
- let total = 0;
115
- for (const i of list) {
116
- const m = meshOf(scene.geometries[scene.geom[i]], lod);
117
- if (m.kind === 'triangles') total += m.primitiveCount;
118
- }
119
- if (!total) throw new Error('The scene has no triangles to write as STEP.');
120
- if (total > maxTriangles) throw new Error(`The scene has ${total.toLocaleString('en-US')} triangles; STEP output is limited to ${maxTriangles.toLocaleString('en-US')}. Export a coarser level of detail, or glTF, which stores repeated shapes once.`);
121
- const zUp = up === 'z';
122
- const keyTo = new Map();
123
- const points = [];
124
- const tris = [];
125
- const M = new Float64Array(16);
126
- const add = (x, y, z) => {
127
- const k = `${Math.fround(x)},${Math.fround(y)},${Math.fround(z)}`;
128
- let v = keyTo.get(k);
129
- if (v == null) {
130
- v = points.length;
131
- points.push([Math.fround(x), Math.fround(y), Math.fround(z)]);
132
- keyTo.set(k, v);
133
- }
134
- return v;
135
- };
136
- for (const i of list) {
137
- const mesh = meshOf(scene.geometries[scene.geom[i]], lod);
138
- if (mesh.kind !== 'triangles') continue;
139
- for (let k = 0; k < 16; k++) M[k] = scene.matrices[i * 16 + k];
140
- 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]);
141
- const flip = det < 0;
142
- const p = mesh.positions;
143
- const idx = mesh.indices;
144
- for (let t = 0; t < idx.length; t += 3) {
145
- const tri = [];
146
- for (let c = 0; c < 3; c++) {
147
- const v = idx[t + (flip && c ? 3 - c : c)] * 3;
148
- const x = p[v], y = p[v + 1], z = p[v + 2];
149
- const wx = M[0] * x + M[4] * y + M[8] * z + M[12];
150
- const wy = M[1] * x + M[5] * y + M[9] * z + M[13];
151
- const wz = M[2] * x + M[6] * y + M[10] * z + M[14];
152
- tri.push(add(wx, zUp ? -wz : wy, zUp ? wy : wz));
153
- }
154
- tris.push(tri[0], tri[1], tri[2]);
155
- }
156
- }
157
- return { data: writeStep(name, points, tris), triangles: tris.length / 3 };
158
- }
159
-
160
- // --- Part 21 parser ----------------------------------------------------------
161
-
162
- class Scanner {
163
- constructor(text, at) {
164
- this.s = text;
165
- this.n = text.length;
166
- this.i = at;
167
- }
168
-
169
- get c() {
170
- return this.i < this.n ? this.s.charCodeAt(this.i) : -1;
171
- }
172
-
173
- skip() {
174
- const s = this.s;
175
- const n = this.n;
176
- let i = this.i;
177
- while (i < n) {
178
- const c = s.charCodeAt(i);
179
- if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; }
180
- if (c === 47 && s.charCodeAt(i + 1) === 42) {
181
- i += 2;
182
- while (i < n && !(s.charCodeAt(i) === 42 && s.charCodeAt(i + 1) === 47)) i++;
183
- if (i >= n) { this.i = i; this.error('unterminated comment'); }
184
- i += 2;
185
- continue;
186
- }
187
- break;
188
- }
189
- this.i = i;
190
- }
191
-
192
- error(msg) {
193
- let line = 1;
194
- for (let k = 0; k < this.i && k < this.n; k++) if (this.s.charCodeAt(k) === 10) line++;
195
- throw new Error(`STEP: ${msg} (line ${line}).`);
196
- }
197
-
198
- peekKeyword() {
199
- const s = this.s;
200
- let i = this.i;
201
- while (i < this.n) {
202
- const c = s.charCodeAt(i);
203
- const ok = (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c === 95 || c === 45;
204
- if (!ok) break;
205
- i++;
206
- }
207
- return s.slice(this.i, i).toUpperCase();
208
- }
209
- }
210
-
211
- function isDigit(c) {
212
- return c >= 48 && c <= 57;
213
- }
214
-
215
- function isIdentStart(c) {
216
- return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
217
- }
218
-
219
- function isIdentPart(c) {
220
- return isIdentStart(c) || isDigit(c) || c === 95;
221
- }
222
-
223
- function findData(text) {
224
- const n = text.length;
225
- let i = 0;
226
- while (i < n) {
227
- const c = text.charCodeAt(i);
228
- if (c === 39) {
229
- i++;
230
- while (i < n) {
231
- if (text.charCodeAt(i) === 39) {
232
- if (text.charCodeAt(i + 1) === 39) { i += 2; continue; }
233
- i++;
234
- break;
235
- }
236
- i++;
237
- }
238
- continue;
239
- }
240
- if (c === 47 && text.charCodeAt(i + 1) === 42) {
241
- i += 2;
242
- while (i < n && !(text.charCodeAt(i) === 42 && text.charCodeAt(i + 1) === 47)) i++;
243
- i += 2;
244
- continue;
245
- }
246
- if ((c === 68 || c === 100) && /^data/i.test(text.slice(i, i + 4))) {
247
- const prev = i > 0 ? text.charCodeAt(i - 1) : 0;
248
- if (!isIdentPart(prev)) {
249
- let j = i + 4;
250
- while (j < n && (text.charCodeAt(j) === 32 || text.charCodeAt(j) === 9 || text.charCodeAt(j) === 10 || text.charCodeAt(j) === 13)) j++;
251
- if (text.charCodeAt(j) === 59) return j + 1;
252
- }
253
- }
254
- i++;
255
- }
256
- throw new Error('STEP: the file has no DATA section.');
257
- }
258
-
259
- function headerString(header, keyword) {
260
- const m = new RegExp(`\\b${keyword}\\s*\\(`, 'i').exec(header);
261
- if (!m) return '';
262
- let i = m.index + m[0].length;
263
- while (i < header.length && /\s/.test(header[i])) i++;
264
- if (header[i] === '(') {
265
- i++;
266
- while (i < header.length && /\s/.test(header[i])) i++;
267
- }
268
- if (header[i] !== "'") return '';
269
- i++;
270
- let out = '';
271
- while (i < header.length) {
272
- if (header[i] === "'") {
273
- if (header[i + 1] === "'") { out += "'"; i += 2; continue; }
274
- break;
275
- }
276
- out += header[i++];
277
- }
278
- return out;
279
- }
280
-
281
- function parseData(text, at) {
282
- const sc = new Scanner(text, at);
283
- const entities = new Map();
284
- for (;;) {
285
- sc.skip();
286
- if (sc.i >= sc.n) break;
287
- if (isIdentStart(sc.c)) {
288
- const w = sc.peekKeyword();
289
- if (w === 'ENDSEC' || w === 'END-ISO-10303-21') break;
290
- sc.error('expected #id or ENDSEC');
291
- }
292
- if (sc.c === 38) sc.error('SCOPE sections are not supported');
293
- if (sc.c !== 35) sc.error('expected an entity id');
294
- const id = parseRef(sc);
295
- sc.skip();
296
- if (sc.c !== 61) sc.error('expected =');
297
- sc.i++;
298
- sc.skip();
299
- const parts = [];
300
- if (sc.c === 40 && isIdentStart(sc.s.charCodeAt(skipAhead(sc)))) {
301
- sc.i++;
302
- sc.skip();
303
- while (sc.c !== 41) {
304
- const name = parseIdent(sc);
305
- parts.push({ name, args: parseList(sc) });
306
- sc.skip();
307
- }
308
- sc.i++;
309
- } else {
310
- const name = parseIdent(sc);
311
- parts.push({ name, args: parseList(sc) });
312
- }
313
- sc.skip();
314
- if (sc.c !== 59) sc.error('expected ;');
315
- sc.i++;
316
- if (entities.has(id)) sc.error(`entity #${id} is defined twice`);
317
- entities.set(id, { id, parts });
318
- }
319
- return entities;
320
- }
321
-
322
- /** Index of the next non-space character, without moving the scanner. Comments are not expected here. */
323
- function skipAhead(sc) {
324
- let i = sc.i + 1;
325
- const s = sc.s;
326
- while (i < sc.n) {
327
- const c = s.charCodeAt(i);
328
- if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; }
329
- return i;
330
- }
331
- return i;
332
- }
333
-
334
- function parseRef(sc) {
335
- if (sc.c !== 35) sc.error('expected #');
336
- sc.i++;
337
- if (!isDigit(sc.c)) sc.error('expected an entity id');
338
- const start = sc.i;
339
- while (isDigit(sc.c)) sc.i++;
340
- return Number(sc.s.slice(start, sc.i));
341
- }
342
-
343
- function parseIdent(sc) {
344
- if (!isIdentStart(sc.c)) sc.error('expected a type name');
345
- const start = sc.i;
346
- sc.i++;
347
- while (isIdentPart(sc.c)) sc.i++;
348
- return sc.s.slice(start, sc.i).toUpperCase();
349
- }
350
-
351
- function parseList(sc) {
352
- sc.skip();
353
- if (sc.c !== 40) sc.error('expected (');
354
- sc.i++;
355
- const args = [];
356
- sc.skip();
357
- if (sc.c === 41) { sc.i++; return args; }
358
- for (;;) {
359
- args.push(parseValue(sc));
360
- sc.skip();
361
- if (sc.c === 44) { sc.i++; continue; }
362
- if (sc.c === 41) { sc.i++; break; }
363
- sc.error('expected , or )');
364
- }
365
- return args;
366
- }
367
-
368
- function parseValue(sc) {
369
- sc.skip();
370
- const c = sc.c;
371
- if (c === 36) { sc.i++; return null; }
372
- if (c === 42) { sc.i++; return '*'; }
373
- if (c === 35) return { ref: parseRef(sc) };
374
- if (c === 40) return parseList(sc);
375
- if (c === 39) return parseString(sc);
376
- if (c === 46) return isDigit(sc.s.charCodeAt(sc.i + 1)) ? parseNumber(sc) : parseEnum(sc);
377
- if (c === 43 || c === 45 || isDigit(c)) return parseNumber(sc);
378
- if (isIdentStart(c)) {
379
- const name = parseIdent(sc);
380
- sc.skip();
381
- if (sc.c === 40) return { typed: name, args: parseList(sc) };
382
- return name;
383
- }
384
- sc.error('unexpected character');
385
- return null;
386
- }
387
-
388
- function parseEnum(sc) {
389
- if (sc.c !== 46) sc.error('expected an enumeration');
390
- sc.i++;
391
- const start = sc.i;
392
- while (sc.i < sc.n && sc.c !== 46) sc.i++;
393
- if (sc.c !== 46) sc.error('unterminated enumeration');
394
- const name = sc.s.slice(start, sc.i).toUpperCase();
395
- sc.i++;
396
- if (name === 'T') return true;
397
- if (name === 'F') return false;
398
- return { enum: name };
399
- }
400
-
401
- function parseString(sc) {
402
- sc.i++;
403
- let out = '';
404
- while (sc.i < sc.n) {
405
- if (sc.c === 39) {
406
- if (sc.s.charCodeAt(sc.i + 1) === 39) { out += "'"; sc.i += 2; continue; }
407
- sc.i++;
408
- return out;
409
- }
410
- out += sc.s[sc.i++];
411
- }
412
- sc.error('unterminated string');
413
- return out;
414
- }
415
-
416
- function parseNumber(sc) {
417
- const start = sc.i;
418
- if (sc.c === 43 || sc.c === 45) sc.i++;
419
- let saw = false;
420
- while (isDigit(sc.c)) { saw = true; sc.i++; }
421
- if (sc.c === 46) {
422
- sc.i++;
423
- while (isDigit(sc.c)) { saw = true; sc.i++; }
424
- }
425
- if (sc.c === 69 || sc.c === 101) {
426
- const e = sc.i;
427
- sc.i++;
428
- if (sc.c === 43 || sc.c === 45) sc.i++;
429
- let exp = false;
430
- while (isDigit(sc.c)) { exp = true; sc.i++; }
431
- if (!exp) sc.i = e;
432
- }
433
- if (!saw) sc.error('expected a number');
434
- return Number(sc.s.slice(start, sc.i));
435
- }
436
-
437
- function lengthUnitName(entities) {
438
- for (const e of entities.values()) {
439
- const si = e.parts.find((p) => p.name === 'SI_UNIT');
440
- const length = e.parts.some((p) => p.name === 'LENGTH_UNIT');
441
- const metre = si && si.args.some((a) => a && a.enum === 'METRE');
442
- if (si && (length || metre)) {
443
- const prefix = si.args.find((a) => a && a.enum && a.enum !== 'METRE');
444
- return siLength(prefix && prefix.enum);
445
- }
446
- if (length) {
447
- const conv = e.parts.find((p) => p.name === 'CONVERSION_BASED_UNIT');
448
- const name = conv && conv.args.find((a) => typeof a === 'string');
449
- if (name) return name.toLowerCase();
450
- }
451
- }
452
- return null;
453
- }
454
-
455
- function siLength(prefix) {
456
- const map = {
457
- MICRO: 'micrometres', MILLI: 'millimetres', CENTI: 'centimetres', DECI: 'decimetres',
458
- DECA: 'decametres', HECTO: 'hectometres', KILO: 'kilometres',
459
- };
460
- if (!prefix) return 'metres';
461
- return map[prefix] || `${prefix.toLowerCase()}metres`;
462
- }
463
-
464
- export function productName(entities) {
465
- for (const e of entities.values()) {
466
- const p = e.parts.find((x) => x.name === 'PRODUCT');
467
- if (!p) continue;
468
- const strings = p.args.filter((a) => typeof a === 'string' && a);
469
- return strings[1] || strings[0] || '';
470
- }
471
- return '';
472
- }
473
-
474
- // --- geometry ----------------------------------------------------------------
475
-
476
- export function has(e, name) {
477
- return e.parts.some((p) => p.name === name);
478
- }
479
-
480
- export function part(e, name) {
481
- return e.parts.find((p) => p.name === name) || null;
482
- }
483
-
484
- export function coordsOf(entities, v, depth = 0) {
485
- if (v == null || depth > 8) return null;
486
- if (Array.isArray(v) && v.length >= 2 && typeof v[0] === 'number' && typeof v[1] === 'number') {
487
- return [v[0], v[1], v.length > 2 && typeof v[2] === 'number' ? v[2] : 0];
488
- }
489
- if (!v.ref) return null;
490
- const e = entities.get(v.ref);
491
- if (!e) return null;
492
- const cp = part(e, 'CARTESIAN_POINT');
493
- if (cp) {
494
- const list = cp.args.find((a) => Array.isArray(a) && typeof a[0] === 'number');
495
- if (!list) return null;
496
- return [list[0], list[1], list.length > 2 ? list[2] : 0];
497
- }
498
- const vp = part(e, 'VERTEX_POINT');
499
- if (vp) {
500
- const ref = [...vp.args].reverse().find((a) => a && a.ref);
501
- return coordsOf(entities, ref, depth + 1);
502
- }
503
- return null;
504
- }
505
-
506
- function pointListOf(entities, v) {
507
- if (Array.isArray(v) && v.length && Array.isArray(v[0]) && typeof v[0][0] === 'number') {
508
- return v.map((p) => [p[0], p[1], p.length > 2 ? p[2] : 0]);
509
- }
510
- if (!v || !v.ref) return null;
511
- const e = entities.get(v.ref);
512
- if (!e) return null;
513
- const p = part(e, 'CARTESIAN_POINT_LIST_3D') || part(e, 'CARTESIAN_POINT_LIST_2D');
514
- if (!p) return null;
515
- const lists = p.args.find((a) => Array.isArray(a) && a.length && Array.isArray(a[0]));
516
- if (!lists) return null;
517
- return lists.map((q) => [q[0], q[1], q.length > 2 ? q[2] : 0]);
518
- }
519
-
520
- function isRowList(a) {
521
- return Array.isArray(a) && a.length > 0 && Array.isArray(a[0]) && a[0].length >= 3 && typeof a[0][0] === 'number';
522
- }
523
-
524
- function takeRows(a) {
525
- if (a == null) return [];
526
- if (Array.isArray(a) && a.length === 0) return [];
527
- if (isRowList(a)) return a;
528
- return null;
529
- }
530
-
531
- function isIndexInto(rows, n) {
532
- if (!isRowList(rows) || !n) return false;
533
- let max = 0;
534
- for (const row of rows) {
535
- for (const v of row) {
536
- if (!Number.isInteger(v) || v < 1 || v > n) return false;
537
- if (v > max) max = v;
538
- }
539
- }
540
- if (rows.length === n && max <= 1) return false;
541
- return true;
542
- }
543
-
544
- function flatIndex(args, n) {
545
- let best = null;
546
- for (const a of args) {
547
- if (!Array.isArray(a) || !a.length || Array.isArray(a[0])) continue;
548
- if (!a.every((v) => Number.isInteger(v) && v >= 1 && v <= n)) continue;
549
- if (!best || a.length > best.length) best = a;
550
- }
551
- return best;
552
- }
553
-
554
- function findPoints(entities, args) {
555
- for (const a of args) {
556
- if (a && a.ref) {
557
- const pts = pointListOf(entities, a);
558
- if (pts && pts.length) return pts;
559
- }
560
- }
561
- const rows = args.filter(isRowList);
562
- for (const candidate of rows) {
563
- const others = rows.filter((r) => r !== candidate);
564
- if (others.some((r) => isIndexInto(r, candidate.length)) && !others.some((o) => isIndexInto(candidate, o.length))) {
565
- return candidate.map((p) => [p[0], p[1], p[2] || 0]);
566
- }
567
- }
568
- return null;
569
- }
570
-
571
- class Soup {
572
- constructor() {
573
- this.pos = [];
574
- this.idx = [];
575
- this.map = new Map();
576
- }
577
-
578
- add(p, key) {
579
- const k = key != null ? key : `${p[0]},${p[1]},${p[2]}`;
580
- let v = this.map.get(k);
581
- if (v == null) {
582
- v = this.pos.length / 3;
583
- this.pos.push(p[0], p[1], p[2]);
584
- this.map.set(k, v);
585
- }
586
- return v;
587
- }
588
-
589
- tri(a, b, c) {
590
- if (a === b || b === c || a === c) return;
591
- const p = this.pos;
592
- const ax = p[a * 3], ay = p[a * 3 + 1], az = p[a * 3 + 2];
593
- const bx = p[b * 3] - ax, by = p[b * 3 + 1] - ay, bz = p[b * 3 + 2] - az;
594
- const cx = p[c * 3] - ax, cy = p[c * 3 + 1] - ay, cz = p[c * 3 + 2] - az;
595
- const nx = by * cz - bz * cy, ny = bz * cx - bx * cz, nz = bx * cy - by * cx;
596
- if (nx * nx + ny * ny + nz * nz < 1e-24) return;
597
- this.idx.push(a, b, c);
598
- }
599
-
600
- finish(smooth) {
601
- if (this.idx.length < 3) return null;
602
- return computeNormals(Float32Array.from(this.pos), Uint32Array.from(this.idx), smooth);
603
- }
604
- }
605
-
606
- function indexOrigin(groups) {
607
- for (const g of groups) {
608
- for (const row of g) {
609
- for (const v of row) if (v === 0) return 0;
610
- }
611
- }
612
- return 1;
613
- }
614
-
615
- function addIndexed(soup, points, triangles, strips, fans, pn, keyPrefix) {
616
- const groups = [triangles, strips, fans].filter((g) => g && g.length);
617
- const base = indexOrigin(groups);
618
- const map = (i) => {
619
- let v = i;
620
- if (pn && base === 1) {
621
- if (v < 1 || v > pn.length) return -1;
622
- v = pn[v - 1];
623
- if (!v) return -1;
624
- }
625
- const idx = base === 0 ? v : v - 1;
626
- if (idx < 0 || idx >= points.length) return -1;
627
- return soup.add(points[idx], `${keyPrefix}:${idx}`);
628
- };
629
- const put = (a, b, c) => {
630
- const ia = map(a), ib = map(b), ic = map(c);
631
- if (ia < 0 || ib < 0 || ic < 0) return;
632
- soup.tri(ia, ib, ic);
633
- };
634
- for (const t of triangles) if (t.length >= 3) put(t[0], t[1], t[2]);
635
- for (const strip of strips) {
636
- for (let i = 0; i + 2 < strip.length; i++) {
637
- const a = strip[i], b = strip[i + 1], c = strip[i + 2];
638
- if (i % 2 === 0) put(a, b, c);
639
- else put(b, a, c);
640
- }
641
- }
642
- for (const fan of fans) {
643
- for (let i = 1; i + 1 < fan.length; i++) put(fan[0], fan[i], fan[i + 1]);
644
- }
645
- }
646
-
647
- function addTess(soup, entities, e) {
648
- const args = e.parts.flatMap((p) => p.args);
649
- const complex = e.parts.some((p) => p.name.startsWith('COMPLEX_TRIANGULATED'));
650
- const points = findPoints(entities, args);
651
- if (!points) return;
652
- let triangles = [];
653
- let strips = [];
654
- let fans = [];
655
- let found = false;
656
- if (complex && args.length >= 7 && takeRows(args[5]) && takeRows(args[6])) {
657
- strips = takeRows(args[5]);
658
- fans = takeRows(args[6]);
659
- found = true;
660
- } else if (!complex && args.length >= 6 && takeRows(args[5])) {
661
- triangles = takeRows(args[5]);
662
- found = true;
663
- }
664
- if (!found) {
665
- const indexLists = args.filter((a) => isRowList(a) && isIndexInto(a, points.length));
666
- if (complex) {
667
- strips = indexLists[0] || [];
668
- fans = indexLists[1] || [];
669
- } else triangles = indexLists[0] || [];
670
- }
671
- const pn = flatIndex(args, points.length);
672
- addIndexed(soup, points, triangles, strips, fans, pn, e.id);
673
- }
674
-
675
- export function colourMap(entities) {
676
- const map = new Map();
677
- for (const e of entities.values()) {
678
- if (!has(e, 'STYLED_ITEM')) continue;
679
- const args = e.parts.flatMap((p) => p.args);
680
- let target = null;
681
- for (const a of args) if (a && a.ref) target = a.ref;
682
- if (target == null) continue;
683
- const colour = findColour(entities, args, 0, new Set());
684
- if (colour && !map.has(target)) map.set(target, colour);
685
- }
686
- return map;
687
- }
688
-
689
- function findColour(entities, node, depth, seen) {
690
- if (node == null || depth > 24) return null;
691
- if (node.ref) {
692
- if (seen.has(node.ref)) return null;
693
- seen.add(node.ref);
694
- return findColour(entities, entities.get(node.ref), depth + 1, seen);
695
- }
696
- if (Array.isArray(node)) {
697
- for (const c of node) {
698
- const hit = findColour(entities, c, depth, seen);
699
- if (hit) return hit;
700
- }
701
- return null;
702
- }
703
- if (node.parts) {
704
- const rgb = part(node, 'COLOUR_RGB');
705
- if (rgb) {
706
- const nums = rgb.args.filter((n) => typeof n === 'number');
707
- if (nums.length >= 3) return normalizeRgb(nums);
708
- }
709
- for (const p of node.parts) {
710
- const hit = findColour(entities, p.args, depth, seen);
711
- if (hit) return hit;
712
- }
713
- }
714
- return null;
715
- }
716
-
717
- function normalizeRgb(nums) {
718
- let r = nums[0], g = nums[1], b = nums[2];
719
- if (r > 1 || g > 1 || b > 1) { r /= 255; g /= 255; b /= 255; }
720
- return [r, g, b, 1];
721
- }
722
-
723
- export function labelOf(e) {
724
- const s = e.parts.flatMap((p) => p.args).find((a) => typeof a === 'string' && a.trim());
725
- return s ? s.trim() : '';
726
- }
727
-
728
- export function isFaceEntity(e) {
729
- if (!e || has(e, 'ORIENTED_FACE')) return false;
730
- return e.parts.some((p) => FACE_TYPES.has(p.name));
731
- }
732
-
733
- export function asFace(entities, ref) {
734
- let e = ref && ref.ref ? entities.get(ref.ref) : null;
735
- let flip = false;
736
- if (e && has(e, 'ORIENTED_FACE')) {
737
- const args = part(e, 'ORIENTED_FACE').args;
738
- if (args.filter((a) => typeof a === 'boolean').pop() === false) flip = true;
739
- const next = args.filter((a) => a && a.ref).pop();
740
- e = next ? entities.get(next.ref) : null;
741
- }
742
- return { face: e, flip, id: ref && ref.ref };
743
- }
744
-
745
- function faceCurved(entities, face) {
746
- for (const a of face.parts.flatMap((p) => p.args)) {
747
- if (!a || !a.ref) continue;
748
- const g = entities.get(a.ref);
749
- if (!g) continue;
750
- const names = g.parts.map((p) => p.name);
751
- if (names.some((n) => n === 'FACE_BOUND' || n === 'FACE_OUTER_BOUND' || n === 'POLY_LOOP' || n === 'EDGE_LOOP' || n === 'PLANE' || n === 'CYLINDRICAL_SURFACE' || n === 'AXIS2_PLACEMENT_3D')) continue;
752
- if (names.some((n) => n.includes('SURFACE') || n.includes('CURVE') || n === 'PCURVE')) return true;
753
- }
754
- return false;
755
- }
756
-
757
- export function directionOf(entities, ref) {
758
- const e = ref && ref.ref ? entities.get(ref.ref) : null;
759
- const d = e && part(e, 'DIRECTION');
760
- if (!d) return null;
761
- const list = d.args.find((a) => Array.isArray(a) && typeof a[0] === 'number');
762
- if (!list) return null;
763
- const v = [list[0], list[1], list.length > 2 ? list[2] : 0];
764
- const len = Math.hypot(v[0], v[1], v[2]);
765
- if (len < 1e-15) return null;
766
- return [v[0] / len, v[1] / len, v[2] / len];
767
- }
768
-
769
- export function placementFrame(entities, ref) {
770
- const e = ref && ref.ref ? entities.get(ref.ref) : null;
771
- const pl = e && part(e, 'AXIS2_PLACEMENT_3D');
772
- if (!pl) return null;
773
- const refs = pl.args.filter((a) => a && a.ref);
774
- const origin = coordsOf(entities, refs[0]);
775
- if (!origin) return null;
776
- const z = directionOf(entities, refs[1]) || [0, 0, 1];
777
- const refDir = directionOf(entities, refs[2]) || [1, 0, 0];
778
- let x0 = refDir[0] - z[0] * (refDir[0] * z[0] + refDir[1] * z[1] + refDir[2] * z[2]);
779
- let x1 = refDir[1] - z[1] * (refDir[0] * z[0] + refDir[1] * z[1] + refDir[2] * z[2]);
780
- let x2 = refDir[2] - z[2] * (refDir[0] * z[0] + refDir[1] * z[1] + refDir[2] * z[2]);
781
- if (Math.hypot(x0, x1, x2) < 1e-12) {
782
- const t = Math.abs(z[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
783
- const d = t[0] * z[0] + t[1] * z[1] + t[2] * z[2];
784
- x0 = t[0] - z[0] * d;
785
- x1 = t[1] - z[1] * d;
786
- x2 = t[2] - z[2] * d;
787
- }
788
- const xl = Math.hypot(x0, x1, x2);
789
- x0 /= xl; x1 /= xl; x2 /= xl;
790
- return { origin, x: [x0, x1, x2], y: [z[1] * x2 - z[2] * x1, z[2] * x0 - z[0] * x2, z[0] * x1 - z[1] * x0], z };
791
- }
792
-
793
- function pointAngle(frame, p) {
794
- const dx = p[0] - frame.origin[0], dy = p[1] - frame.origin[1], dz = p[2] - frame.origin[2];
795
- const x = dx * frame.x[0] + dy * frame.x[1] + dz * frame.x[2];
796
- const y = dx * frame.y[0] + dy * frame.y[1] + dz * frame.y[2];
797
- return Math.atan2(y, x);
798
- }
799
-
800
- function pointHeight(frame, p) {
801
- return (p[0] - frame.origin[0]) * frame.z[0] + (p[1] - frame.origin[1]) * frame.z[1] + (p[2] - frame.origin[2]) * frame.z[2];
802
- }
803
-
804
- function circleAt(frame, radius, t) {
805
- const c = Math.cos(t), s = Math.sin(t);
806
- return [
807
- frame.origin[0] + radius * (c * frame.x[0] + s * frame.y[0]),
808
- frame.origin[1] + radius * (c * frame.x[1] + s * frame.y[1]),
809
- frame.origin[2] + radius * (c * frame.x[2] + s * frame.y[2]),
810
- ];
811
- }
812
-
813
- const ARC_STEP = Math.PI / 36;
814
- const ARC_MAX = 128;
815
-
816
- function sampleCircle(entities, geom, start, end, increase) {
817
- const args = part(geom, 'CIRCLE').args;
818
- const radius = args.find((a) => typeof a === 'number');
819
- const place = args.find((a) => a && a.ref);
820
- if (!(radius > 0) || !place) return null;
821
- const frame = placementFrame(entities, place);
822
- if (!frame) return null;
823
- let a0 = pointAngle(frame, start);
824
- let a1 = pointAngle(frame, end);
825
- const dx = start[0] - end[0], dy = start[1] - end[1], dz = start[2] - end[2];
826
- if (dx * dx + dy * dy + dz * dz < 1e-16) a1 = a0 + (increase ? Math.PI * 2 : -Math.PI * 2);
827
- else if (increase) {
828
- while (a1 < a0 - 1e-7) a1 += Math.PI * 2;
829
- } else {
830
- while (a1 > a0 + 1e-7) a1 -= Math.PI * 2;
831
- }
832
- const sweep = a1 - a0;
833
- const n = Math.min(ARC_MAX, Math.max(1, Math.ceil(Math.abs(sweep) / ARC_STEP)));
834
- const pts = [start];
835
- for (let i = 1; i < n; i++) pts.push(circleAt(frame, radius, a0 + sweep * (i / n)));
836
- return pts;
837
- }
838
-
839
- function orientedEdge(entities, item) {
840
- let e = item && item.ref ? entities.get(item.ref) : null;
841
- if (!e) return null;
842
- let orientFlip = false;
843
- if (has(e, 'ORIENTED_EDGE')) {
844
- const args = part(e, 'ORIENTED_EDGE').args;
845
- if (args.filter((a) => typeof a === 'boolean').pop() === false) orientFlip = true;
846
- const ref = args.filter((a) => a && a.ref).pop();
847
- e = ref ? entities.get(ref.ref) : null;
848
- if (!e) return null;
849
- }
850
- const edge = part(e, 'EDGE_CURVE') || part(e, 'EDGE');
851
- if (!edge) return { curved: true };
852
- const refs = edge.args.filter((a) => a && a.ref);
853
- const sameSense = edge.args.filter((a) => typeof a === 'boolean').pop();
854
- const geom = refs[2] ? entities.get(refs[2].ref) : null;
855
- const startRef = orientFlip ? refs[1] : refs[0];
856
- const endRef = orientFlip ? refs[0] : refs[1];
857
- const start = coordsOf(entities, startRef);
858
- if (!start) return { curved: true };
859
- const startId = startRef && startRef.ref;
860
- if (!geom || has(geom, 'LINE')) return { curved: false, pts: [start], ids: [startId] };
861
- if (has(geom, 'CIRCLE')) {
862
- const end = coordsOf(entities, endRef);
863
- if (!end) return { curved: true };
864
- let increase = sameSense !== false;
865
- if (orientFlip) increase = !increase;
866
- const pts = sampleCircle(entities, geom, start, end, increase);
867
- if (!pts) return { curved: true };
868
- return { curved: false, pts, ids: pts.map((_, i) => (i === 0 ? startId : null)) };
869
- }
870
- return { curved: true };
871
- }
872
-
873
- function readLoop(entities, loop) {
874
- if (!loop) return null;
875
- if (has(loop, 'POLY_LOOP')) {
876
- const list = part(loop, 'POLY_LOOP').args.find((a) => Array.isArray(a));
877
- if (!list) return { pts: [], ids: [], curved: false };
878
- const pts = [];
879
- const ids = [];
880
- for (const item of list) {
881
- const p = coordsOf(entities, item);
882
- if (!p) return { pts: [], ids: [], curved: false };
883
- pts.push(p);
884
- ids.push(item && item.ref);
885
- }
886
- return { pts, ids, curved: false };
887
- }
888
- if (has(loop, 'EDGE_LOOP')) {
889
- const list = part(loop, 'EDGE_LOOP').args.find((a) => Array.isArray(a));
890
- if (!list) return { pts: [], ids: [], curved: false };
891
- const pts = [];
892
- const ids = [];
893
- for (const item of list) {
894
- const edge = orientedEdge(entities, item);
895
- if (!edge || edge.curved) return { pts: [], ids: [], curved: true };
896
- for (let i = 0; i < edge.pts.length; i++) {
897
- pts.push(edge.pts[i]);
898
- ids.push(edge.ids[i]);
899
- }
900
- }
901
- return { pts, ids, curved: false };
902
- }
903
- return { pts: [], ids: [], curved: true };
904
- }
905
-
906
- function cleanRing(pts, ids) {
907
- const p = [];
908
- const d = [];
909
- for (let i = 0; i < pts.length; i++) {
910
- const prev = p[p.length - 1];
911
- if (prev && prev[0] === pts[i][0] && prev[1] === pts[i][1] && prev[2] === pts[i][2]) continue;
912
- p.push(pts[i]);
913
- d.push(ids[i]);
914
- }
915
- if (p.length > 1) {
916
- const a = p[0], b = p[p.length - 1];
917
- if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) { p.pop(); d.pop(); }
918
- }
919
- return { pts: p, ids: d };
920
- }
921
-
922
- function newell(pts) {
923
- let x = 0, y = 0, z = 0;
924
- for (let i = 0; i < pts.length; i++) {
925
- const a = pts[i], b = pts[(i + 1) % pts.length];
926
- x += (a[1] - b[1]) * (a[2] + b[2]);
927
- y += (a[2] - b[2]) * (a[0] + b[0]);
928
- z += (a[0] - b[0]) * (a[1] + b[1]);
929
- }
930
- return [x, y, z];
931
- }
932
-
933
- function basis(n) {
934
- const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
935
- const t = ax <= ay && ax <= az ? [1, 0, 0] : ay <= az ? [0, 1, 0] : [0, 0, 1];
936
- let ux = n[1] * t[2] - n[2] * t[1];
937
- let uy = n[2] * t[0] - n[0] * t[2];
938
- let uz = n[0] * t[1] - n[1] * t[0];
939
- const ul = Math.hypot(ux, uy, uz) || 1;
940
- ux /= ul; uy /= ul; uz /= ul;
941
- const nl = Math.hypot(n[0], n[1], n[2]) || 1;
942
- const nx = n[0] / nl, ny = n[1] / nl, nz = n[2] / nl;
943
- return { u: [ux, uy, uz], v: [ny * uz - nz * uy, nz * ux - nx * uz, nx * uy - ny * ux] };
944
- }
945
-
946
- function project(pts, origin, u, v) {
947
- const flat = new Float64Array(pts.length * 2);
948
- for (let i = 0; i < pts.length; i++) {
949
- const d0 = pts[i][0] - origin[0], d1 = pts[i][1] - origin[1], d2 = pts[i][2] - origin[2];
950
- flat[i * 2] = d0 * u[0] + d1 * u[1] + d2 * u[2];
951
- flat[i * 2 + 1] = d0 * v[0] + d1 * v[1] + d2 * v[2];
952
- }
953
- return flat;
954
- }
955
-
956
- function isConvex(flat) {
957
- const n = flat.length / 2;
958
- let sign = 0;
959
- for (let i = 0; i < n; i++) {
960
- const p = (i + n - 1) % n, q = (i + 1) % n;
961
- const c = (flat[i * 2] - flat[p * 2]) * (flat[q * 2 + 1] - flat[i * 2 + 1]) - (flat[i * 2 + 1] - flat[p * 2 + 1]) * (flat[q * 2] - flat[i * 2]);
962
- if (Math.abs(c) < 1e-12) continue;
963
- const s = c > 0 ? 1 : -1;
964
- if (!sign) sign = s;
965
- else if (s !== sign) return false;
966
- }
967
- return true;
968
- }
969
-
970
- function ringArea(flat, n) {
971
- let s = 0;
972
- for (let i = 0, j = n - 1; i < n; j = i++) s += (flat[j * 2] - flat[i * 2]) * (flat[i * 2 + 1] + flat[j * 2 + 1]);
973
- return s;
974
- }
975
-
976
- function polygonIndices(outerPts, holePts) {
977
- const nrm = newell(outerPts);
978
- if (Math.hypot(nrm[0], nrm[1], nrm[2]) < 1e-18) return [];
979
- const { u, v } = basis(nrm);
980
- if (!holePts.length) {
981
- const flat = project(outerPts, outerPts[0], u, v);
982
- if (isConvex(flat)) {
983
- const idx = [];
984
- for (let i = 1; i + 1 < outerPts.length; i++) idx.push(0, i, i + 1);
985
- return idx;
986
- }
987
- }
988
- const all = outerPts.concat(...holePts);
989
- const flat = project(all, outerPts[0], u, v);
990
- const holes = [];
991
- let acc = outerPts.length;
992
- for (const h of holePts) {
993
- if (h.length >= 3) holes.push(acc);
994
- acc += h.length;
995
- }
996
- let ix = [];
997
- try { ix = triangulate(flat, holes.length ? holes : null) || []; } catch { ix = []; }
998
- if (!ix.length) {
999
- const fan = [];
1000
- for (let i = 1; i + 1 < outerPts.length; i++) fan.push(0, i, i + 1);
1001
- return fan;
1002
- }
1003
- if (ringArea(flat, outerPts.length) < 0) {
1004
- for (let i = 0; i < ix.length; i += 3) {
1005
- const t = ix[i + 1];
1006
- ix[i + 1] = ix[i + 2];
1007
- ix[i + 2] = t;
1008
- }
1009
- }
1010
- return ix;
1011
- }
1012
-
1013
- function surfaceEntity(entities, face) {
1014
- const args = (part(face, 'ADVANCED_FACE') || part(face, 'FACE_SURFACE') || part(face, 'FACE') || face.parts[face.parts.length - 1]).args;
1015
- for (let i = args.length - 1; i >= 0; i--) {
1016
- const a = args[i];
1017
- if (!a || !a.ref) continue;
1018
- const e = entities.get(a.ref);
1019
- if (e && e.parts.some((p) => p.name.includes('SURFACE') || p.name === 'PLANE')) return e;
1020
- }
1021
- return null;
1022
- }
1023
-
1024
- function unwrapCylinder(pts, frame) {
1025
- const uv = new Array(pts.length);
1026
- let prev = null;
1027
- let min = Infinity;
1028
- let max = -Infinity;
1029
- for (let i = 0; i < pts.length; i++) {
1030
- let th = pointAngle(frame, pts[i]);
1031
- if (prev != null) {
1032
- while (th - prev > Math.PI) th -= Math.PI * 2;
1033
- while (prev - th > Math.PI) th += Math.PI * 2;
1034
- }
1035
- uv[i] = [th, pointHeight(frame, pts[i])];
1036
- if (th < min) min = th;
1037
- if (th > max) max = th;
1038
- prev = th;
1039
- }
1040
- if (!(max > min + 1e-8)) return null;
1041
- return uv;
1042
- }
1043
-
1044
- function cylinderIndices(pts, frame) {
1045
- const uv = unwrapCylinder(pts, frame);
1046
- if (!uv) return null;
1047
- const n = pts.length;
1048
- let iMin = 0;
1049
- let iMax = 0;
1050
- for (let i = 1; i < n; i++) {
1051
- if (uv[i][0] < uv[iMin][0] - 1e-9) iMin = i;
1052
- if (uv[i][0] > uv[iMax][0] + 1e-9) iMax = i;
1053
- }
1054
- if (iMin === iMax) return null;
1055
- const walk = (step) => {
1056
- const chain = [];
1057
- for (let i = iMin, guard = 0; guard <= n; guard++) {
1058
- chain.push(i);
1059
- if (i === iMax) return chain;
1060
- i = (i + step + n) % n;
1061
- }
1062
- return null;
1063
- };
1064
- const rising = (chain) => {
1065
- for (let i = 1; i < chain.length; i++) if (uv[chain[i]][0] < uv[chain[i - 1]][0] - 1e-5) return false;
1066
- return true;
1067
- };
1068
- const a = walk(1);
1069
- const b = walk(-1);
1070
- if (!a || !b || a.length < 2 || b.length < 2 || !rising(a) || !rising(b)) return null;
1071
- let ring = 0;
1072
- for (let i = 0; i < n; i++) {
1073
- const p = uv[i], q = uv[(i + 1) % n];
1074
- ring += p[0] * q[1] - q[0] * p[1];
1075
- }
1076
- const sign = ring < 0 ? -1 : 1;
1077
- const ix = [];
1078
- let ia = 0;
1079
- let ib = 0;
1080
- while (ia < a.length - 1 || ib < b.length - 1) {
1081
- let p, q, r;
1082
- if (ia >= a.length - 1) { p = a[ia]; q = b[ib]; r = b[ib + 1]; ib++; }
1083
- else if (ib >= b.length - 1) { p = a[ia]; q = b[ib]; r = a[ia + 1]; ia++; }
1084
- else if (uv[a[ia + 1]][0] <= uv[b[ib + 1]][0] + 1e-12) { p = a[ia]; q = b[ib]; r = a[ia + 1]; ia++; }
1085
- else { p = a[ia]; q = b[ib]; r = b[ib + 1]; ib++; }
1086
- if (p === q || q === r || p === r) continue;
1087
- const pa = uv[p], pb = uv[q], pc = uv[r];
1088
- const area = (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0]);
1089
- if (Math.abs(area) < 1e-14) continue;
1090
- if ((area < 0 ? -1 : 1) === sign) ix.push(p, q, r);
1091
- else ix.push(p, r, q);
1092
- }
1093
- if (ix.length < 3) return null;
1094
- const nrm = newell(pts);
1095
- for (let i = 0; i < ix.length; i += 3) {
1096
- const a0 = pts[ix[i]], b0 = pts[ix[i + 1]], c0 = pts[ix[i + 2]];
1097
- const ux = b0[0] - a0[0], uy = b0[1] - a0[1], uz = b0[2] - a0[2];
1098
- const vx = c0[0] - a0[0], vy = c0[1] - a0[1], vz = c0[2] - a0[2];
1099
- const dot = (uy * vz - uz * vy) * nrm[0] + (uz * vx - ux * vz) * nrm[1] + (ux * vy - uy * vx) * nrm[2];
1100
- if (Math.abs(dot) < 1e-12) continue;
1101
- if (dot < 0) {
1102
- for (let k = 0; k < ix.length; k += 3) {
1103
- const t = ix[k + 1];
1104
- ix[k + 1] = ix[k + 2];
1105
- ix[k + 2] = t;
1106
- }
1107
- }
1108
- break;
1109
- }
1110
- return ix;
1111
- }
1112
-
1113
- function emitFace(soup, pts, ids, ix) {
1114
- let n = 0;
1115
- for (let i = 0; i < ix.length; i += 3) {
1116
- const va = ix[i], vb = ix[i + 1], vc = ix[i + 2];
1117
- if (va == null || vb == null || vc == null) continue;
1118
- soup.tri(
1119
- soup.add(pts[va], ids[va] != null ? `p${ids[va]}` : null),
1120
- soup.add(pts[vb], ids[vb] != null ? `p${ids[vb]}` : null),
1121
- soup.add(pts[vc], ids[vc] != null ? `p${ids[vc]}` : null),
1122
- );
1123
- n++;
1124
- }
1125
- return n;
1126
- }
1127
-
1128
- function faceRings(entities, face, extraFlip) {
1129
- if (!face) return null;
1130
- const args = (part(face, 'ADVANCED_FACE') || part(face, 'FACE_SURFACE') || part(face, 'FACE') || face.parts[face.parts.length - 1]).args;
1131
- // FACE_BOUND orientation already follows the face. same_sense only says
1132
- // whether that face normal agrees with the surface, so it must not reverse
1133
- // the loop a second time.
1134
- const flip = extraFlip;
1135
- let outer = null;
1136
- const holes = [];
1137
- for (const a of args) {
1138
- const refs = Array.isArray(a) ? a.filter((x) => x && x.ref) : (a && a.ref ? [a] : []);
1139
- for (const ref of refs) {
1140
- const bound = entities.get(ref.ref);
1141
- if (!bound || (!has(bound, 'FACE_BOUND') && !has(bound, 'FACE_OUTER_BOUND'))) continue;
1142
- const ba = (part(bound, 'FACE_OUTER_BOUND') || part(bound, 'FACE_BOUND')).args;
1143
- const loopRef = ba.find((x) => x && x.ref);
1144
- const loop = readLoop(entities, loopRef ? entities.get(loopRef.ref) : null);
1145
- if (!loop) continue;
1146
- if (loop.curved) return { curved: true };
1147
- const reversed = ba.find((x) => typeof x === 'boolean') === false;
1148
- let ring = cleanRing(loop.pts, loop.ids);
1149
- if (reversed !== flip && ring.pts.length) ring = { pts: ring.pts.slice().reverse(), ids: ring.ids.slice().reverse() };
1150
- else if (reversed && flip) ring = cleanRing(loop.pts, loop.ids);
1151
- if (has(bound, 'FACE_OUTER_BOUND')) {
1152
- if (!outer) outer = ring;
1153
- else holes.push(ring);
1154
- } else holes.push(ring);
1155
- }
1156
- }
1157
- if (!outer) {
1158
- let best = -1;
1159
- let bestAt = -1;
1160
- for (let i = 0; i < holes.length; i++) {
1161
- const n = newell(holes[i].pts);
1162
- const area = Math.hypot(n[0], n[1], n[2]);
1163
- if (area > best) { best = area; bestAt = i; }
1164
- }
1165
- if (bestAt >= 0) outer = holes.splice(bestAt, 1)[0];
1166
- }
1167
- return { curved: false, outer, holes };
1168
- }
1169
-
1170
- function addFace(soup, entities, face, extraFlip) {
1171
- if (!face) return 'skip';
1172
- if (faceCurved(entities, face)) return 'curved';
1173
- const surf = surfaceEntity(entities, face);
1174
- const cylindrical = !!(surf && has(surf, 'CYLINDRICAL_SURFACE'));
1175
- const rings = faceRings(entities, face, extraFlip);
1176
- if (!rings) return 'skip';
1177
- if (rings.curved) return 'curved';
1178
- const { outer, holes } = rings;
1179
- if (cylindrical) {
1180
- const holePts = holes.filter((h) => h.pts.length >= 3);
1181
- if (!outer || outer.pts.length < 3 || holePts.length) return 'curved';
1182
- const place = part(surf, 'CYLINDRICAL_SURFACE').args.find((a) => a && a.ref);
1183
- const frame = place && placementFrame(entities, place);
1184
- const ix = frame && cylinderIndices(outer.pts, frame);
1185
- if (!ix || !emitFace(soup, outer.pts, outer.ids, ix)) return 'curved';
1186
- return 'ok';
1187
- }
1188
- if (!outer || outer.pts.length < 3) return 'skip';
1189
- const holePts = holes.filter((h) => h.pts.length >= 3);
1190
- const ix = polygonIndices(outer.pts, holePts.map((h) => h.pts));
1191
- const all = outer.pts.concat(...holePts.map((h) => h.pts));
1192
- const ids = outer.ids.concat(...holePts.map((h) => h.ids));
1193
- emitFace(soup, all, ids, ix);
1194
- return 'ok';
1195
- }
1196
-
1197
- export function hasTessellation(entities) {
1198
- for (const e of entities.values()) {
1199
- if (e.parts.some((p) => TESS_TYPES.has(p.name))) return true;
1200
- }
1201
- return false;
1202
- }
1203
-
1204
- /**
1205
- * Analytic shells for solid recognition. Empty when the file is tessellated
1206
- * (that geometry is already triangles) or has no boundary faces.
1207
- * Each face is { kind:'plane'|'cylinder'|'other', normal, outer, holes, radius?, frame? }.
1208
- * Normals and loops follow the face orientation used by the tessellator.
1209
- * @param {Map<number, object>} entities
1210
- */
1211
- function paint(ids, colours) {
1212
- for (const id of ids) if (colours.has(id)) return colours.get(id);
1213
- return null;
1214
- }
1215
-
1216
- export function ownerIndex(entities) {
1217
- const map = new Map();
1218
- const add = (id, owner) => {
1219
- let list = map.get(id);
1220
- if (!list) map.set(id, list = []);
1221
- list.push(owner);
1222
- };
1223
- for (const e of entities.values()) {
1224
- for (const a of e.parts.flatMap((p) => p.args)) {
1225
- if (a && a.ref) add(a.ref, e.id);
1226
- else if (Array.isArray(a)) for (const x of a) if (x && x.ref) add(x.ref, e.id);
1227
- }
1228
- }
1229
- return map;
1230
- }
1231
-
1232
- export function inheritedColour(colours, owners, ids) {
1233
- const direct = paint(ids, colours);
1234
- if (direct) return direct;
1235
- let frontier = ids;
1236
- const seen = new Set(ids);
1237
- for (let depth = 0; depth < 4 && frontier.length; depth++) {
1238
- const next = [];
1239
- for (const id of frontier) {
1240
- const ups = owners.get(id);
1241
- if (!ups) continue;
1242
- for (const owner of ups) {
1243
- if (seen.has(owner)) continue;
1244
- seen.add(owner);
1245
- if (colours.has(owner)) return colours.get(owner);
1246
- next.push(owner);
1247
- }
1248
- }
1249
- frontier = next;
1250
- }
1251
- return null;
1252
- }
1253
-
1254
- export function shellEntries(entities, shell) {
1255
- const out = [];
1256
- const seen = new Set();
1257
- for (const a of shell.parts.flatMap((p) => p.args)) {
1258
- if (!Array.isArray(a)) continue;
1259
- for (const item of a) {
1260
- if (!item || !item.ref) continue;
1261
- const unwrapped = asFace(entities, item);
1262
- if (!unwrapped.face || !isFaceEntity(unwrapped.face) || seen.has(unwrapped.face.id)) continue;
1263
- seen.add(unwrapped.face.id);
1264
- out.push(unwrapped);
1265
- }
1266
- }
1267
- return out;
1268
- }
1269
-
1270
- function facesToRecord(entities, entries, colours, owners, smooth, name, extraIds) {
1271
- const soup = new Soup();
1272
- let curved = 0;
1273
- const ids = [];
1274
- for (const entry of entries) {
1275
- ids.push(entry.face.id);
1276
- const status = addFace(soup, entities, entry.face, entry.flip);
1277
- if (status === 'curved') curved++;
1278
- }
1279
- const color = inheritedColour(colours, owners, ids.concat(extraIds));
1280
- const mesh = soup.finish(smooth);
1281
- if (!mesh) return { mesh: null, curved, color };
1282
- return { mesh, curved, color, name };
1283
- }
1284
-
1285
- function extractMeshes(entities, smooth) {
1286
- const colours = colourMap(entities);
1287
- const owners = ownerIndex(entities);
1288
- const tess = collectTess(entities, colours, smooth);
1289
- if (tess.length) return { meshes: tess, curved: 0 };
1290
- return collectFaceted(entities, colours, owners, smooth);
1291
- }
1292
-
1293
- function collectTess(entities, colours, smooth) {
1294
- const grouped = new Set();
1295
- const meshes = [];
1296
- for (const e of entities.values()) {
1297
- if (!has(e, 'TESSELLATED_SHELL')) continue;
1298
- const items = [];
1299
- for (const a of e.parts.flatMap((p) => p.args)) {
1300
- if (!Array.isArray(a)) continue;
1301
- for (const ref of a) {
1302
- const child = ref && ref.ref ? entities.get(ref.ref) : null;
1303
- if (!child || !child.parts.some((p) => TESS_TYPES.has(p.name))) continue;
1304
- grouped.add(child.id);
1305
- items.push(child);
1306
- }
1307
- }
1308
- const soup = new Soup();
1309
- for (const item of items) addTess(soup, entities, item);
1310
- const mesh = soup.finish(smooth);
1311
- if (mesh) meshes.push({ mesh, name: labelOf(e), color: colours.get(e.id) || items.map((it) => colours.get(it.id)).find(Boolean) || null });
1312
- }
1313
- for (const e of entities.values()) {
1314
- if (grouped.has(e.id) || !e.parts.some((p) => TESS_TYPES.has(p.name))) continue;
1315
- const soup = new Soup();
1316
- addTess(soup, entities, e);
1317
- const mesh = soup.finish(smooth);
1318
- if (mesh) meshes.push({ mesh, name: labelOf(e), color: colours.get(e.id) || null });
1319
- }
1320
- return meshes;
1321
- }
1322
-
1323
- function collectFaceted(entities, colours, owners, smooth) {
1324
- const used = new Set();
1325
- const meshes = [];
1326
- let curved = 0;
1327
- for (const e of entities.values()) {
1328
- if (!e.parts.some((p) => SHELL_TYPES.has(p.name))) continue;
1329
- const entries = shellEntries(entities, e);
1330
- for (const entry of entries) {
1331
- used.add(entry.id);
1332
- used.add(entry.face.id);
1333
- }
1334
- const rec = facesToRecord(entities, entries, colours, owners, smooth, labelOf(e), [e.id]);
1335
- curved += rec.curved;
1336
- if (rec.mesh) meshes.push(rec);
1337
- }
1338
- const loose = [];
1339
- for (const e of entities.values()) {
1340
- if (!isFaceEntity(e) || used.has(e.id)) continue;
1341
- loose.push({ face: e, flip: false, id: e.id });
1342
- }
1343
- if (loose.length) {
1344
- const rec = facesToRecord(entities, loose, colours, owners, smooth, '', []);
1345
- curved += rec.curved;
1346
- if (rec.mesh) meshes.push(rec);
1347
- }
1348
- return { meshes, curved };
1349
- }
1350
-
1351
- // --- writer ------------------------------------------------------------------
1352
-
1353
- function stepString(s) {
1354
- return `'${String(s).replace(/\\/g, '/').replace(/'/g, "''").replace(/[\r\n]+/g, ' ')}'`;
1355
- }
1356
-
1357
- function stepReal(v) {
1358
- if (!Number.isFinite(v) || Object.is(v, -0) || Math.abs(v) < 1e-15) return '0.';
1359
- const a = Math.abs(v);
1360
- let s = a >= 1e7 || a < 1e-4 ? v.toExponential(9).replace('e', 'E') : String(Math.round(v * 1e9) / 1e9);
1361
- s = s.replace(/(\.\d*?)0+E/, '$1E').replace(/\.E/, 'E');
1362
- if (!s.includes('.') && !s.includes('E')) s += '.';
1363
- return s;
1364
- }
1365
-
1366
- function wrapList(parts) {
1367
- const lines = [];
1368
- let line = '';
1369
- for (let i = 0; i < parts.length; i++) {
1370
- const piece = (line ? ',' : '') + parts[i];
1371
- if (line && line.length + piece.length > 100) {
1372
- lines.push(`${line},`);
1373
- line = parts[i];
1374
- } else line += piece;
1375
- }
1376
- if (line) lines.push(line);
1377
- return lines.join('\n');
1378
- }
1379
-
1380
- function writeStep(name, points, tris) {
1381
- const safe = String(name || 'es3d').replace(/[^\w.-]+/g, '_').slice(0, 64) || 'es3d';
1382
- const when = new Date().toISOString().replace(/\.\d{3}Z$/, '');
1383
- const pts = wrapList(points.map((p) => `(${stepReal(p[0])},${stepReal(p[1])},${stepReal(p[2])})`));
1384
- const faces = [];
1385
- for (let i = 0; i < tris.length; i += 3) faces.push(`(${tris[i] + 1},${tris[i + 1] + 1},${tris[i + 2] + 1})`);
1386
- const triText = wrapList(faces);
1387
- return `ISO-10303-21;
1388
- HEADER;
1389
- FILE_DESCRIPTION(('ES3D tessellated shape'),'2;1');
1390
- FILE_NAME(${stepString(`${safe}.step`)},${stepString(when)},('ES3D'),('ES3D'),'ES3D 0.1','ES3D','');
1391
- FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));
1392
- ENDSEC;
1393
- DATA;
1394
- #1=APPLICATION_CONTEXT('managed model based 3d engineering');
1395
- #2=APPLICATION_PROTOCOL_DEFINITION('international standard','ap242_managed_model_based_3d_engineering',2014,#1);
1396
- #3=PRODUCT_CONTEXT('',#1,'mechanical');
1397
- #4=PRODUCT(${stepString(safe)},${stepString(safe)},'',(#3));
1398
- #5=PRODUCT_DEFINITION_FORMATION('','',#4);
1399
- #6=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design');
1400
- #7=PRODUCT_DEFINITION('design','',#5,#6);
1401
- #8=PRODUCT_DEFINITION_SHAPE('','',#7);
1402
- #9=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));
1403
- #10=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));
1404
- #11=(NAMED_UNIT(*)SOLID_ANGLE_UNIT()SI_UNIT($,.STERADIAN.));
1405
- #12=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-7),#9,'distance_accuracy_value','confusion accuracy');
1406
- #13=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#12))GLOBAL_UNIT_ASSIGNED_CONTEXT((#9,#10,#11))REPRESENTATION_CONTEXT('','3D'));
1407
- #14=CARTESIAN_POINT_LIST_3D('',(
1408
- ${pts}
1409
- ));
1410
- #15=TRIANGULATED_SURFACE_SET('',#14,0,$,$,(
1411
- ${triText}
1412
- ));
1413
- #16=TESSELLATED_SHAPE_REPRESENTATION('',(#15),#13);
1414
- #17=SHAPE_DEFINITION_REPRESENTATION(#8,#16);
1415
- ENDSEC;
1416
- END-ISO-10303-21;
1417
- `;
1418
- }
1
+ // ISO 10303-21 (STEP) import and export.
2
+ //
3
+ // A boundary representation (what CAD tools export) is converted to ES3D
4
+ // shapes by step-exact.js, from the exact geometry step-brep.js reads; this
5
+ // module provides the parser and the helpers they share.
6
+ //
7
+ // stepToScene() is the triangle path: tessellated AP242 geometry
8
+ // (TRIANGULATED_SURFACE_SET, COMPLEX_TRIANGULATED_SURFACE_SET,
9
+ // TRIANGULATED_FACE and the complex face), and boundary solids made of
10
+ // POLY_LOOP, or of EDGE_LOOP lines and circles on planes and cylinders.
11
+ // The converter uses it for files without a B-rep.
12
+ //
13
+ // Writers such as Shapr3D emit FACE_BOUND for every loop and never
14
+ // FACE_OUTER_BOUND. When no loop is marked outer, the largest is the outer
15
+ // boundary and the rest are holes.
16
+ //
17
+ // Coordinates stay in the file's length unit. The unit is reported and not
18
+ // applied. Z-up files are turned Y-up on import, matching STL.
19
+ //
20
+ // Export writes one AP242 triangulated surface of every instance in world
21
+ // space. The file declares millimetres and does not rescale the numbers.
22
+ // Z is up by default, again matching STL.
23
+
24
+ import { computeNormals } from '../geometry/mesh.js';
25
+ import { triangulate } from '../path/triangulate.js';
26
+ import { buildExternalScene, exportableInstances, InstanceTable } from './external-scene.js';
27
+ import { Z_UP_TO_Y_UP } from './stl.js';
28
+
29
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
30
+ const TESS_TYPES = new Set([
31
+ 'TRIANGULATED_SURFACE_SET',
32
+ 'COMPLEX_TRIANGULATED_SURFACE_SET',
33
+ 'TRIANGULATED_FACE',
34
+ 'COMPLEX_TRIANGULATED_FACE',
35
+ ]);
36
+ const SHELL_TYPES = new Set(['CLOSED_SHELL', 'OPEN_SHELL', 'CONNECTED_FACE_SET']);
37
+ const FACE_TYPES = new Set(['FACE', 'FACE_SURFACE', 'ADVANCED_FACE']);
38
+
39
+ const CURVED_MSG = 'This STEP file is a curved solid (NURBS or an analytic surface other than a cylinder). ES3D imports tessellated STEP, planar faces, circles and cylinders. Re-export the model from the CAD tool as a tessellated STEP or as STL.';
40
+
41
+ const decodeHead = (bytes) => new TextDecoder('utf-8').decode(bytes.subarray(0, Math.min(bytes.length, 1024))).replace(/^\uFEFF/, '');
42
+
43
+ function toText(input) {
44
+ if (typeof input === 'string') return input.replace(/^\uFEFF/, '');
45
+ return new TextDecoder('utf-8').decode(input).replace(/^\uFEFF/, '');
46
+ }
47
+
48
+ /** True when the bytes (or a leading slice of text) are an ISO-10303-21 file. */
49
+ export function isSTEP(input) {
50
+ const text = typeof input === 'string' ? input : decodeHead(input);
51
+ return /^\uFEFF?\s*(?:\/\*[\s\S]*?\*\/\s*)*ISO-10303-21\b/i.test(text);
52
+ }
53
+
54
+ /**
55
+ * Parse a STEP file into its DATA entities.
56
+ * @param {string|Uint8Array} input
57
+ * @returns {{name: string, schema: string, unit: string|null, entities: Map<number, {id: number, parts: {name: string, args: any[]}[]}>}}
58
+ */
59
+ export function parseSTEP(input) {
60
+ const text = toText(input);
61
+ if (!isSTEP(text.slice(0, 1024))) throw new Error('Not a STEP file: expected ISO-10303-21.');
62
+ const dataAt = findData(text);
63
+ const header = text.slice(0, dataAt);
64
+ const entities = parseData(text, dataAt);
65
+ return {
66
+ name: headerString(header, 'FILE_NAME'),
67
+ schema: headerString(header, 'FILE_SCHEMA'),
68
+ unit: lengthUnitName(entities),
69
+ entities,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Convert a STEP file into a RenderScene. One mesh per shell, or one mesh
75
+ * for faces that belong to no shell. Tessellation is used when the file has it.
76
+ * @param {string|Uint8Array} input
77
+ * @param {{up?: 'z'|'y', smooth?: number}} [opts]
78
+ */
79
+ export function stepToScene(input, { up = 'z', smooth = 30 } = {}) {
80
+ const doc = parseSTEP(input);
81
+ const { meshes, curved } = extractMeshes(doc.entities, smooth);
82
+ if (!meshes.length) {
83
+ if (curved) throw new Error(CURVED_MSG);
84
+ throw new Error('The STEP file contains no triangles or supported faces.');
85
+ }
86
+ const matrix = up === 'y' ? IDENTITY : Z_UP_TO_Y_UP;
87
+ const geometries = [];
88
+ const materials = [];
89
+ const instances = new InstanceTable(meshes.length);
90
+ let triangles = 0;
91
+ let vertices = 0;
92
+ const base = productName(doc.entities) || doc.name.replace(/\.[^.]+$/, '') || 'step';
93
+ meshes.forEach((m, i) => {
94
+ geometries.push({ mesh: m.mesh, key: `step:${i}:${m.name}` });
95
+ const name = meshes.length === 1 ? (m.name || base) : (m.name || `${base} ${i + 1}`);
96
+ materials.push({ name, color: m.color || [0.72, 0.74, 0.78, 1], roughness: 0.45, metallic: 0 });
97
+ instances.push(i, i, matrix);
98
+ triangles += m.mesh.primitiveCount;
99
+ vertices += m.mesh.vertexCount;
100
+ });
101
+ const built = buildExternalScene({ geometries, materials, instances });
102
+ return { ...built, info: { name: base, schema: doc.schema, unit: doc.unit, triangles, vertices, meshes: meshes.length, curved } };
103
+ }
104
+
105
+ /**
106
+ * Export the static scene as one tessellated STEP file.
107
+ * @param {import('../scene/flatten.js').RenderScene} scene
108
+ * @param {(entry: object, lod: number) => import('../geometry/mesh.js').Mesh} meshOf
109
+ * @param {{lod?: number, up?: 'z'|'y', name?: string, maxTriangles?: number}} [opts]
110
+ * @returns {{data: string, triangles: number}}
111
+ */
112
+ export function exportSTEP(scene, meshOf, { lod = 0, up = 'z', name = 'es3d', maxTriangles = 2e7 } = {}) {
113
+ const list = exportableInstances(scene);
114
+ let total = 0;
115
+ for (const i of list) {
116
+ const m = meshOf(scene.geometries[scene.geom[i]], lod);
117
+ if (m.kind === 'triangles') total += m.primitiveCount;
118
+ }
119
+ if (!total) throw new Error('The scene has no triangles to write as STEP.');
120
+ if (total > maxTriangles) throw new Error(`The scene has ${total.toLocaleString('en-US')} triangles; STEP output is limited to ${maxTriangles.toLocaleString('en-US')}. Export a coarser level of detail, or glTF, which stores repeated shapes once.`);
121
+ const zUp = up === 'z';
122
+ const keyTo = new Map();
123
+ const points = [];
124
+ const tris = [];
125
+ const M = new Float64Array(16);
126
+ const add = (x, y, z) => {
127
+ const k = `${Math.fround(x)},${Math.fround(y)},${Math.fround(z)}`;
128
+ let v = keyTo.get(k);
129
+ if (v == null) {
130
+ v = points.length;
131
+ points.push([Math.fround(x), Math.fround(y), Math.fround(z)]);
132
+ keyTo.set(k, v);
133
+ }
134
+ return v;
135
+ };
136
+ for (const i of list) {
137
+ const mesh = meshOf(scene.geometries[scene.geom[i]], lod);
138
+ if (mesh.kind !== 'triangles') continue;
139
+ for (let k = 0; k < 16; k++) M[k] = scene.matrices[i * 16 + k];
140
+ 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]);
141
+ const flip = det < 0;
142
+ const p = mesh.positions;
143
+ const idx = mesh.indices;
144
+ for (let t = 0; t < idx.length; t += 3) {
145
+ const tri = [];
146
+ for (let c = 0; c < 3; c++) {
147
+ const v = idx[t + (flip && c ? 3 - c : c)] * 3;
148
+ const x = p[v], y = p[v + 1], z = p[v + 2];
149
+ const wx = M[0] * x + M[4] * y + M[8] * z + M[12];
150
+ const wy = M[1] * x + M[5] * y + M[9] * z + M[13];
151
+ const wz = M[2] * x + M[6] * y + M[10] * z + M[14];
152
+ tri.push(add(wx, zUp ? -wz : wy, zUp ? wy : wz));
153
+ }
154
+ tris.push(tri[0], tri[1], tri[2]);
155
+ }
156
+ }
157
+ return { data: writeStep(name, points, tris), triangles: tris.length / 3 };
158
+ }
159
+
160
+ // --- Part 21 parser ----------------------------------------------------------
161
+
162
+ class Scanner {
163
+ constructor(text, at) {
164
+ this.s = text;
165
+ this.n = text.length;
166
+ this.i = at;
167
+ }
168
+
169
+ get c() {
170
+ return this.i < this.n ? this.s.charCodeAt(this.i) : -1;
171
+ }
172
+
173
+ skip() {
174
+ const s = this.s;
175
+ const n = this.n;
176
+ let i = this.i;
177
+ while (i < n) {
178
+ const c = s.charCodeAt(i);
179
+ if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; }
180
+ if (c === 47 && s.charCodeAt(i + 1) === 42) {
181
+ i += 2;
182
+ while (i < n && !(s.charCodeAt(i) === 42 && s.charCodeAt(i + 1) === 47)) i++;
183
+ if (i >= n) { this.i = i; this.error('unterminated comment'); }
184
+ i += 2;
185
+ continue;
186
+ }
187
+ break;
188
+ }
189
+ this.i = i;
190
+ }
191
+
192
+ error(msg) {
193
+ let line = 1;
194
+ for (let k = 0; k < this.i && k < this.n; k++) if (this.s.charCodeAt(k) === 10) line++;
195
+ throw new Error(`STEP: ${msg} (line ${line}).`);
196
+ }
197
+
198
+ peekKeyword() {
199
+ const s = this.s;
200
+ let i = this.i;
201
+ while (i < this.n) {
202
+ const c = s.charCodeAt(i);
203
+ const ok = (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c === 95 || c === 45;
204
+ if (!ok) break;
205
+ i++;
206
+ }
207
+ return s.slice(this.i, i).toUpperCase();
208
+ }
209
+ }
210
+
211
+ function isDigit(c) {
212
+ return c >= 48 && c <= 57;
213
+ }
214
+
215
+ function isIdentStart(c) {
216
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
217
+ }
218
+
219
+ function isIdentPart(c) {
220
+ return isIdentStart(c) || isDigit(c) || c === 95;
221
+ }
222
+
223
+ function findData(text) {
224
+ const n = text.length;
225
+ let i = 0;
226
+ while (i < n) {
227
+ const c = text.charCodeAt(i);
228
+ if (c === 39) {
229
+ i++;
230
+ while (i < n) {
231
+ if (text.charCodeAt(i) === 39) {
232
+ if (text.charCodeAt(i + 1) === 39) { i += 2; continue; }
233
+ i++;
234
+ break;
235
+ }
236
+ i++;
237
+ }
238
+ continue;
239
+ }
240
+ if (c === 47 && text.charCodeAt(i + 1) === 42) {
241
+ i += 2;
242
+ while (i < n && !(text.charCodeAt(i) === 42 && text.charCodeAt(i + 1) === 47)) i++;
243
+ i += 2;
244
+ continue;
245
+ }
246
+ if ((c === 68 || c === 100) && /^data/i.test(text.slice(i, i + 4))) {
247
+ const prev = i > 0 ? text.charCodeAt(i - 1) : 0;
248
+ if (!isIdentPart(prev)) {
249
+ let j = i + 4;
250
+ while (j < n && (text.charCodeAt(j) === 32 || text.charCodeAt(j) === 9 || text.charCodeAt(j) === 10 || text.charCodeAt(j) === 13)) j++;
251
+ if (text.charCodeAt(j) === 59) return j + 1;
252
+ }
253
+ }
254
+ i++;
255
+ }
256
+ throw new Error('STEP: the file has no DATA section.');
257
+ }
258
+
259
+ function headerString(header, keyword) {
260
+ const m = new RegExp(`\\b${keyword}\\s*\\(`, 'i').exec(header);
261
+ if (!m) return '';
262
+ let i = m.index + m[0].length;
263
+ while (i < header.length && /\s/.test(header[i])) i++;
264
+ if (header[i] === '(') {
265
+ i++;
266
+ while (i < header.length && /\s/.test(header[i])) i++;
267
+ }
268
+ if (header[i] !== "'") return '';
269
+ i++;
270
+ let out = '';
271
+ while (i < header.length) {
272
+ if (header[i] === "'") {
273
+ if (header[i + 1] === "'") { out += "'"; i += 2; continue; }
274
+ break;
275
+ }
276
+ out += header[i++];
277
+ }
278
+ return out;
279
+ }
280
+
281
+ function parseData(text, at) {
282
+ const sc = new Scanner(text, at);
283
+ const entities = new Map();
284
+ for (;;) {
285
+ sc.skip();
286
+ if (sc.i >= sc.n) break;
287
+ if (isIdentStart(sc.c)) {
288
+ const w = sc.peekKeyword();
289
+ if (w === 'ENDSEC' || w === 'END-ISO-10303-21') break;
290
+ sc.error('expected #id or ENDSEC');
291
+ }
292
+ if (sc.c === 38) sc.error('SCOPE sections are not supported');
293
+ if (sc.c !== 35) sc.error('expected an entity id');
294
+ const id = parseRef(sc);
295
+ sc.skip();
296
+ if (sc.c !== 61) sc.error('expected =');
297
+ sc.i++;
298
+ sc.skip();
299
+ const parts = [];
300
+ if (sc.c === 40 && isIdentStart(sc.s.charCodeAt(skipAhead(sc)))) {
301
+ sc.i++;
302
+ sc.skip();
303
+ while (sc.c !== 41) {
304
+ const name = parseIdent(sc);
305
+ parts.push({ name, args: parseList(sc) });
306
+ sc.skip();
307
+ }
308
+ sc.i++;
309
+ } else {
310
+ const name = parseIdent(sc);
311
+ parts.push({ name, args: parseList(sc) });
312
+ }
313
+ sc.skip();
314
+ if (sc.c !== 59) sc.error('expected ;');
315
+ sc.i++;
316
+ if (entities.has(id)) sc.error(`entity #${id} is defined twice`);
317
+ entities.set(id, { id, parts });
318
+ }
319
+ return entities;
320
+ }
321
+
322
+ /** Index of the next non-space character, without moving the scanner. Comments are not expected here. */
323
+ function skipAhead(sc) {
324
+ let i = sc.i + 1;
325
+ const s = sc.s;
326
+ while (i < sc.n) {
327
+ const c = s.charCodeAt(i);
328
+ if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; }
329
+ return i;
330
+ }
331
+ return i;
332
+ }
333
+
334
+ function parseRef(sc) {
335
+ if (sc.c !== 35) sc.error('expected #');
336
+ sc.i++;
337
+ if (!isDigit(sc.c)) sc.error('expected an entity id');
338
+ const start = sc.i;
339
+ while (isDigit(sc.c)) sc.i++;
340
+ return Number(sc.s.slice(start, sc.i));
341
+ }
342
+
343
+ function parseIdent(sc) {
344
+ if (!isIdentStart(sc.c)) sc.error('expected a type name');
345
+ const start = sc.i;
346
+ sc.i++;
347
+ while (isIdentPart(sc.c)) sc.i++;
348
+ return sc.s.slice(start, sc.i).toUpperCase();
349
+ }
350
+
351
+ function parseList(sc) {
352
+ sc.skip();
353
+ if (sc.c !== 40) sc.error('expected (');
354
+ sc.i++;
355
+ const args = [];
356
+ sc.skip();
357
+ if (sc.c === 41) { sc.i++; return args; }
358
+ for (;;) {
359
+ args.push(parseValue(sc));
360
+ sc.skip();
361
+ if (sc.c === 44) { sc.i++; continue; }
362
+ if (sc.c === 41) { sc.i++; break; }
363
+ sc.error('expected , or )');
364
+ }
365
+ return args;
366
+ }
367
+
368
+ function parseValue(sc) {
369
+ sc.skip();
370
+ const c = sc.c;
371
+ if (c === 36) { sc.i++; return null; }
372
+ if (c === 42) { sc.i++; return '*'; }
373
+ if (c === 35) return { ref: parseRef(sc) };
374
+ if (c === 40) return parseList(sc);
375
+ if (c === 39) return parseString(sc);
376
+ if (c === 46) return isDigit(sc.s.charCodeAt(sc.i + 1)) ? parseNumber(sc) : parseEnum(sc);
377
+ if (c === 43 || c === 45 || isDigit(c)) return parseNumber(sc);
378
+ if (isIdentStart(c)) {
379
+ const name = parseIdent(sc);
380
+ sc.skip();
381
+ if (sc.c === 40) return { typed: name, args: parseList(sc) };
382
+ return name;
383
+ }
384
+ sc.error('unexpected character');
385
+ return null;
386
+ }
387
+
388
+ function parseEnum(sc) {
389
+ if (sc.c !== 46) sc.error('expected an enumeration');
390
+ sc.i++;
391
+ const start = sc.i;
392
+ while (sc.i < sc.n && sc.c !== 46) sc.i++;
393
+ if (sc.c !== 46) sc.error('unterminated enumeration');
394
+ const name = sc.s.slice(start, sc.i).toUpperCase();
395
+ sc.i++;
396
+ if (name === 'T') return true;
397
+ if (name === 'F') return false;
398
+ return { enum: name };
399
+ }
400
+
401
+ function parseString(sc) {
402
+ sc.i++;
403
+ let out = '';
404
+ while (sc.i < sc.n) {
405
+ if (sc.c === 39) {
406
+ if (sc.s.charCodeAt(sc.i + 1) === 39) { out += "'"; sc.i += 2; continue; }
407
+ sc.i++;
408
+ return out;
409
+ }
410
+ out += sc.s[sc.i++];
411
+ }
412
+ sc.error('unterminated string');
413
+ return out;
414
+ }
415
+
416
+ function parseNumber(sc) {
417
+ const start = sc.i;
418
+ if (sc.c === 43 || sc.c === 45) sc.i++;
419
+ let saw = false;
420
+ while (isDigit(sc.c)) { saw = true; sc.i++; }
421
+ if (sc.c === 46) {
422
+ sc.i++;
423
+ while (isDigit(sc.c)) { saw = true; sc.i++; }
424
+ }
425
+ if (sc.c === 69 || sc.c === 101) {
426
+ const e = sc.i;
427
+ sc.i++;
428
+ if (sc.c === 43 || sc.c === 45) sc.i++;
429
+ let exp = false;
430
+ while (isDigit(sc.c)) { exp = true; sc.i++; }
431
+ if (!exp) sc.i = e;
432
+ }
433
+ if (!saw) sc.error('expected a number');
434
+ return Number(sc.s.slice(start, sc.i));
435
+ }
436
+
437
+ function lengthUnitName(entities) {
438
+ for (const e of entities.values()) {
439
+ const si = e.parts.find((p) => p.name === 'SI_UNIT');
440
+ const length = e.parts.some((p) => p.name === 'LENGTH_UNIT');
441
+ const metre = si && si.args.some((a) => a && a.enum === 'METRE');
442
+ if (si && (length || metre)) {
443
+ const prefix = si.args.find((a) => a && a.enum && a.enum !== 'METRE');
444
+ return siLength(prefix && prefix.enum);
445
+ }
446
+ if (length) {
447
+ const conv = e.parts.find((p) => p.name === 'CONVERSION_BASED_UNIT');
448
+ const name = conv && conv.args.find((a) => typeof a === 'string');
449
+ if (name) return name.toLowerCase();
450
+ }
451
+ }
452
+ return null;
453
+ }
454
+
455
+ function siLength(prefix) {
456
+ const map = {
457
+ MICRO: 'micrometres', MILLI: 'millimetres', CENTI: 'centimetres', DECI: 'decimetres',
458
+ DECA: 'decametres', HECTO: 'hectometres', KILO: 'kilometres',
459
+ };
460
+ if (!prefix) return 'metres';
461
+ return map[prefix] || `${prefix.toLowerCase()}metres`;
462
+ }
463
+
464
+ export function productName(entities) {
465
+ for (const e of entities.values()) {
466
+ const p = e.parts.find((x) => x.name === 'PRODUCT');
467
+ if (!p) continue;
468
+ const strings = p.args.filter((a) => typeof a === 'string' && a);
469
+ return strings[1] || strings[0] || '';
470
+ }
471
+ return '';
472
+ }
473
+
474
+ // --- geometry ----------------------------------------------------------------
475
+
476
+ export function has(e, name) {
477
+ return e.parts.some((p) => p.name === name);
478
+ }
479
+
480
+ export function part(e, name) {
481
+ return e.parts.find((p) => p.name === name) || null;
482
+ }
483
+
484
+ export function coordsOf(entities, v, depth = 0) {
485
+ if (v == null || depth > 8) return null;
486
+ if (Array.isArray(v) && v.length >= 2 && typeof v[0] === 'number' && typeof v[1] === 'number') {
487
+ return [v[0], v[1], v.length > 2 && typeof v[2] === 'number' ? v[2] : 0];
488
+ }
489
+ if (!v.ref) return null;
490
+ const e = entities.get(v.ref);
491
+ if (!e) return null;
492
+ const cp = part(e, 'CARTESIAN_POINT');
493
+ if (cp) {
494
+ const list = cp.args.find((a) => Array.isArray(a) && typeof a[0] === 'number');
495
+ if (!list) return null;
496
+ return [list[0], list[1], list.length > 2 ? list[2] : 0];
497
+ }
498
+ const vp = part(e, 'VERTEX_POINT');
499
+ if (vp) {
500
+ const ref = [...vp.args].reverse().find((a) => a && a.ref);
501
+ return coordsOf(entities, ref, depth + 1);
502
+ }
503
+ return null;
504
+ }
505
+
506
+ function pointListOf(entities, v) {
507
+ if (Array.isArray(v) && v.length && Array.isArray(v[0]) && typeof v[0][0] === 'number') {
508
+ return v.map((p) => [p[0], p[1], p.length > 2 ? p[2] : 0]);
509
+ }
510
+ if (!v || !v.ref) return null;
511
+ const e = entities.get(v.ref);
512
+ if (!e) return null;
513
+ const p = part(e, 'CARTESIAN_POINT_LIST_3D') || part(e, 'CARTESIAN_POINT_LIST_2D');
514
+ if (!p) return null;
515
+ const lists = p.args.find((a) => Array.isArray(a) && a.length && Array.isArray(a[0]));
516
+ if (!lists) return null;
517
+ return lists.map((q) => [q[0], q[1], q.length > 2 ? q[2] : 0]);
518
+ }
519
+
520
+ function isRowList(a) {
521
+ return Array.isArray(a) && a.length > 0 && Array.isArray(a[0]) && a[0].length >= 3 && typeof a[0][0] === 'number';
522
+ }
523
+
524
+ function takeRows(a) {
525
+ if (a == null) return [];
526
+ if (Array.isArray(a) && a.length === 0) return [];
527
+ if (isRowList(a)) return a;
528
+ return null;
529
+ }
530
+
531
+ function isIndexInto(rows, n) {
532
+ if (!isRowList(rows) || !n) return false;
533
+ let max = 0;
534
+ for (const row of rows) {
535
+ for (const v of row) {
536
+ if (!Number.isInteger(v) || v < 1 || v > n) return false;
537
+ if (v > max) max = v;
538
+ }
539
+ }
540
+ if (rows.length === n && max <= 1) return false;
541
+ return true;
542
+ }
543
+
544
+ function flatIndex(args, n) {
545
+ let best = null;
546
+ for (const a of args) {
547
+ if (!Array.isArray(a) || !a.length || Array.isArray(a[0])) continue;
548
+ if (!a.every((v) => Number.isInteger(v) && v >= 1 && v <= n)) continue;
549
+ if (!best || a.length > best.length) best = a;
550
+ }
551
+ return best;
552
+ }
553
+
554
+ function findPoints(entities, args) {
555
+ for (const a of args) {
556
+ if (a && a.ref) {
557
+ const pts = pointListOf(entities, a);
558
+ if (pts && pts.length) return pts;
559
+ }
560
+ }
561
+ const rows = args.filter(isRowList);
562
+ for (const candidate of rows) {
563
+ const others = rows.filter((r) => r !== candidate);
564
+ if (others.some((r) => isIndexInto(r, candidate.length)) && !others.some((o) => isIndexInto(candidate, o.length))) {
565
+ return candidate.map((p) => [p[0], p[1], p[2] || 0]);
566
+ }
567
+ }
568
+ return null;
569
+ }
570
+
571
+ class Soup {
572
+ constructor() {
573
+ this.pos = [];
574
+ this.idx = [];
575
+ this.map = new Map();
576
+ }
577
+
578
+ add(p, key) {
579
+ const k = key != null ? key : `${p[0]},${p[1]},${p[2]}`;
580
+ let v = this.map.get(k);
581
+ if (v == null) {
582
+ v = this.pos.length / 3;
583
+ this.pos.push(p[0], p[1], p[2]);
584
+ this.map.set(k, v);
585
+ }
586
+ return v;
587
+ }
588
+
589
+ tri(a, b, c) {
590
+ if (a === b || b === c || a === c) return;
591
+ const p = this.pos;
592
+ const ax = p[a * 3], ay = p[a * 3 + 1], az = p[a * 3 + 2];
593
+ const bx = p[b * 3] - ax, by = p[b * 3 + 1] - ay, bz = p[b * 3 + 2] - az;
594
+ const cx = p[c * 3] - ax, cy = p[c * 3 + 1] - ay, cz = p[c * 3 + 2] - az;
595
+ const nx = by * cz - bz * cy, ny = bz * cx - bx * cz, nz = bx * cy - by * cx;
596
+ if (nx * nx + ny * ny + nz * nz < 1e-24) return;
597
+ this.idx.push(a, b, c);
598
+ }
599
+
600
+ finish(smooth) {
601
+ if (this.idx.length < 3) return null;
602
+ return computeNormals(Float32Array.from(this.pos), Uint32Array.from(this.idx), smooth);
603
+ }
604
+ }
605
+
606
+ function indexOrigin(groups) {
607
+ for (const g of groups) {
608
+ for (const row of g) {
609
+ for (const v of row) if (v === 0) return 0;
610
+ }
611
+ }
612
+ return 1;
613
+ }
614
+
615
+ function addIndexed(soup, points, triangles, strips, fans, pn, keyPrefix) {
616
+ const groups = [triangles, strips, fans].filter((g) => g && g.length);
617
+ const base = indexOrigin(groups);
618
+ const map = (i) => {
619
+ let v = i;
620
+ if (pn && base === 1) {
621
+ if (v < 1 || v > pn.length) return -1;
622
+ v = pn[v - 1];
623
+ if (!v) return -1;
624
+ }
625
+ const idx = base === 0 ? v : v - 1;
626
+ if (idx < 0 || idx >= points.length) return -1;
627
+ return soup.add(points[idx], `${keyPrefix}:${idx}`);
628
+ };
629
+ const put = (a, b, c) => {
630
+ const ia = map(a), ib = map(b), ic = map(c);
631
+ if (ia < 0 || ib < 0 || ic < 0) return;
632
+ soup.tri(ia, ib, ic);
633
+ };
634
+ for (const t of triangles) if (t.length >= 3) put(t[0], t[1], t[2]);
635
+ for (const strip of strips) {
636
+ for (let i = 0; i + 2 < strip.length; i++) {
637
+ const a = strip[i], b = strip[i + 1], c = strip[i + 2];
638
+ if (i % 2 === 0) put(a, b, c);
639
+ else put(b, a, c);
640
+ }
641
+ }
642
+ for (const fan of fans) {
643
+ for (let i = 1; i + 1 < fan.length; i++) put(fan[0], fan[i], fan[i + 1]);
644
+ }
645
+ }
646
+
647
+ function addTess(soup, entities, e) {
648
+ const args = e.parts.flatMap((p) => p.args);
649
+ const complex = e.parts.some((p) => p.name.startsWith('COMPLEX_TRIANGULATED'));
650
+ const points = findPoints(entities, args);
651
+ if (!points) return;
652
+ let triangles = [];
653
+ let strips = [];
654
+ let fans = [];
655
+ let found = false;
656
+ if (complex && args.length >= 7 && takeRows(args[5]) && takeRows(args[6])) {
657
+ strips = takeRows(args[5]);
658
+ fans = takeRows(args[6]);
659
+ found = true;
660
+ } else if (!complex && args.length >= 6 && takeRows(args[5])) {
661
+ triangles = takeRows(args[5]);
662
+ found = true;
663
+ }
664
+ if (!found) {
665
+ const indexLists = args.filter((a) => isRowList(a) && isIndexInto(a, points.length));
666
+ if (complex) {
667
+ strips = indexLists[0] || [];
668
+ fans = indexLists[1] || [];
669
+ } else triangles = indexLists[0] || [];
670
+ }
671
+ const pn = flatIndex(args, points.length);
672
+ addIndexed(soup, points, triangles, strips, fans, pn, e.id);
673
+ }
674
+
675
+ export function colourMap(entities) {
676
+ const map = new Map();
677
+ for (const e of entities.values()) {
678
+ if (!has(e, 'STYLED_ITEM')) continue;
679
+ const args = e.parts.flatMap((p) => p.args);
680
+ let target = null;
681
+ for (const a of args) if (a && a.ref) target = a.ref;
682
+ if (target == null) continue;
683
+ const colour = findColour(entities, args, 0, new Set());
684
+ if (colour && !map.has(target)) map.set(target, colour);
685
+ }
686
+ return map;
687
+ }
688
+
689
+ function findColour(entities, node, depth, seen) {
690
+ if (node == null || depth > 24) return null;
691
+ if (node.ref) {
692
+ if (seen.has(node.ref)) return null;
693
+ seen.add(node.ref);
694
+ return findColour(entities, entities.get(node.ref), depth + 1, seen);
695
+ }
696
+ if (Array.isArray(node)) {
697
+ for (const c of node) {
698
+ const hit = findColour(entities, c, depth, seen);
699
+ if (hit) return hit;
700
+ }
701
+ return null;
702
+ }
703
+ if (node.parts) {
704
+ const rgb = part(node, 'COLOUR_RGB');
705
+ if (rgb) {
706
+ const nums = rgb.args.filter((n) => typeof n === 'number');
707
+ if (nums.length >= 3) return normalizeRgb(nums);
708
+ }
709
+ for (const p of node.parts) {
710
+ const hit = findColour(entities, p.args, depth, seen);
711
+ if (hit) return hit;
712
+ }
713
+ }
714
+ return null;
715
+ }
716
+
717
+ function normalizeRgb(nums) {
718
+ let r = nums[0], g = nums[1], b = nums[2];
719
+ if (r > 1 || g > 1 || b > 1) { r /= 255; g /= 255; b /= 255; }
720
+ return [r, g, b, 1];
721
+ }
722
+
723
+ export function labelOf(e) {
724
+ const s = e.parts.flatMap((p) => p.args).find((a) => typeof a === 'string' && a.trim());
725
+ return s ? s.trim() : '';
726
+ }
727
+
728
+ export function isFaceEntity(e) {
729
+ if (!e || has(e, 'ORIENTED_FACE')) return false;
730
+ return e.parts.some((p) => FACE_TYPES.has(p.name));
731
+ }
732
+
733
+ export function asFace(entities, ref) {
734
+ let e = ref && ref.ref ? entities.get(ref.ref) : null;
735
+ let flip = false;
736
+ if (e && has(e, 'ORIENTED_FACE')) {
737
+ const args = part(e, 'ORIENTED_FACE').args;
738
+ if (args.filter((a) => typeof a === 'boolean').pop() === false) flip = true;
739
+ const next = args.filter((a) => a && a.ref).pop();
740
+ e = next ? entities.get(next.ref) : null;
741
+ }
742
+ return { face: e, flip, id: ref && ref.ref };
743
+ }
744
+
745
+ function faceCurved(entities, face) {
746
+ for (const a of face.parts.flatMap((p) => p.args)) {
747
+ if (!a || !a.ref) continue;
748
+ const g = entities.get(a.ref);
749
+ if (!g) continue;
750
+ const names = g.parts.map((p) => p.name);
751
+ if (names.some((n) => n === 'FACE_BOUND' || n === 'FACE_OUTER_BOUND' || n === 'POLY_LOOP' || n === 'EDGE_LOOP' || n === 'PLANE' || n === 'CYLINDRICAL_SURFACE' || n === 'AXIS2_PLACEMENT_3D')) continue;
752
+ if (names.some((n) => n.includes('SURFACE') || n.includes('CURVE') || n === 'PCURVE')) return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ export function directionOf(entities, ref) {
758
+ const e = ref && ref.ref ? entities.get(ref.ref) : null;
759
+ const d = e && part(e, 'DIRECTION');
760
+ if (!d) return null;
761
+ const list = d.args.find((a) => Array.isArray(a) && typeof a[0] === 'number');
762
+ if (!list) return null;
763
+ const v = [list[0], list[1], list.length > 2 ? list[2] : 0];
764
+ const len = Math.hypot(v[0], v[1], v[2]);
765
+ if (len < 1e-15) return null;
766
+ return [v[0] / len, v[1] / len, v[2] / len];
767
+ }
768
+
769
+ export function placementFrame(entities, ref) {
770
+ const e = ref && ref.ref ? entities.get(ref.ref) : null;
771
+ const pl = e && part(e, 'AXIS2_PLACEMENT_3D');
772
+ if (!pl) return null;
773
+ const refs = pl.args.filter((a) => a && a.ref);
774
+ const origin = coordsOf(entities, refs[0]);
775
+ if (!origin) return null;
776
+ const z = directionOf(entities, refs[1]) || [0, 0, 1];
777
+ const refDir = directionOf(entities, refs[2]) || [1, 0, 0];
778
+ let x0 = refDir[0] - z[0] * (refDir[0] * z[0] + refDir[1] * z[1] + refDir[2] * z[2]);
779
+ let x1 = refDir[1] - z[1] * (refDir[0] * z[0] + refDir[1] * z[1] + refDir[2] * z[2]);
780
+ let x2 = refDir[2] - z[2] * (refDir[0] * z[0] + refDir[1] * z[1] + refDir[2] * z[2]);
781
+ if (Math.hypot(x0, x1, x2) < 1e-12) {
782
+ const t = Math.abs(z[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
783
+ const d = t[0] * z[0] + t[1] * z[1] + t[2] * z[2];
784
+ x0 = t[0] - z[0] * d;
785
+ x1 = t[1] - z[1] * d;
786
+ x2 = t[2] - z[2] * d;
787
+ }
788
+ const xl = Math.hypot(x0, x1, x2);
789
+ x0 /= xl; x1 /= xl; x2 /= xl;
790
+ return { origin, x: [x0, x1, x2], y: [z[1] * x2 - z[2] * x1, z[2] * x0 - z[0] * x2, z[0] * x1 - z[1] * x0], z };
791
+ }
792
+
793
+ function pointAngle(frame, p) {
794
+ const dx = p[0] - frame.origin[0], dy = p[1] - frame.origin[1], dz = p[2] - frame.origin[2];
795
+ const x = dx * frame.x[0] + dy * frame.x[1] + dz * frame.x[2];
796
+ const y = dx * frame.y[0] + dy * frame.y[1] + dz * frame.y[2];
797
+ return Math.atan2(y, x);
798
+ }
799
+
800
+ function pointHeight(frame, p) {
801
+ return (p[0] - frame.origin[0]) * frame.z[0] + (p[1] - frame.origin[1]) * frame.z[1] + (p[2] - frame.origin[2]) * frame.z[2];
802
+ }
803
+
804
+ function circleAt(frame, radius, t) {
805
+ const c = Math.cos(t), s = Math.sin(t);
806
+ return [
807
+ frame.origin[0] + radius * (c * frame.x[0] + s * frame.y[0]),
808
+ frame.origin[1] + radius * (c * frame.x[1] + s * frame.y[1]),
809
+ frame.origin[2] + radius * (c * frame.x[2] + s * frame.y[2]),
810
+ ];
811
+ }
812
+
813
+ const ARC_STEP = Math.PI / 36;
814
+ const ARC_MAX = 128;
815
+
816
+ function sampleCircle(entities, geom, start, end, increase) {
817
+ const args = part(geom, 'CIRCLE').args;
818
+ const radius = args.find((a) => typeof a === 'number');
819
+ const place = args.find((a) => a && a.ref);
820
+ if (!(radius > 0) || !place) return null;
821
+ const frame = placementFrame(entities, place);
822
+ if (!frame) return null;
823
+ let a0 = pointAngle(frame, start);
824
+ let a1 = pointAngle(frame, end);
825
+ const dx = start[0] - end[0], dy = start[1] - end[1], dz = start[2] - end[2];
826
+ if (dx * dx + dy * dy + dz * dz < 1e-16) a1 = a0 + (increase ? Math.PI * 2 : -Math.PI * 2);
827
+ else if (increase) {
828
+ while (a1 < a0 - 1e-7) a1 += Math.PI * 2;
829
+ } else {
830
+ while (a1 > a0 + 1e-7) a1 -= Math.PI * 2;
831
+ }
832
+ const sweep = a1 - a0;
833
+ const n = Math.min(ARC_MAX, Math.max(1, Math.ceil(Math.abs(sweep) / ARC_STEP)));
834
+ const pts = [start];
835
+ for (let i = 1; i < n; i++) pts.push(circleAt(frame, radius, a0 + sweep * (i / n)));
836
+ return pts;
837
+ }
838
+
839
+ function orientedEdge(entities, item) {
840
+ let e = item && item.ref ? entities.get(item.ref) : null;
841
+ if (!e) return null;
842
+ let orientFlip = false;
843
+ if (has(e, 'ORIENTED_EDGE')) {
844
+ const args = part(e, 'ORIENTED_EDGE').args;
845
+ if (args.filter((a) => typeof a === 'boolean').pop() === false) orientFlip = true;
846
+ const ref = args.filter((a) => a && a.ref).pop();
847
+ e = ref ? entities.get(ref.ref) : null;
848
+ if (!e) return null;
849
+ }
850
+ const edge = part(e, 'EDGE_CURVE') || part(e, 'EDGE');
851
+ if (!edge) return { curved: true };
852
+ const refs = edge.args.filter((a) => a && a.ref);
853
+ const sameSense = edge.args.filter((a) => typeof a === 'boolean').pop();
854
+ const geom = refs[2] ? entities.get(refs[2].ref) : null;
855
+ const startRef = orientFlip ? refs[1] : refs[0];
856
+ const endRef = orientFlip ? refs[0] : refs[1];
857
+ const start = coordsOf(entities, startRef);
858
+ if (!start) return { curved: true };
859
+ const startId = startRef && startRef.ref;
860
+ if (!geom || has(geom, 'LINE')) return { curved: false, pts: [start], ids: [startId] };
861
+ if (has(geom, 'CIRCLE')) {
862
+ const end = coordsOf(entities, endRef);
863
+ if (!end) return { curved: true };
864
+ let increase = sameSense !== false;
865
+ if (orientFlip) increase = !increase;
866
+ const pts = sampleCircle(entities, geom, start, end, increase);
867
+ if (!pts) return { curved: true };
868
+ return { curved: false, pts, ids: pts.map((_, i) => (i === 0 ? startId : null)) };
869
+ }
870
+ return { curved: true };
871
+ }
872
+
873
+ function readLoop(entities, loop) {
874
+ if (!loop) return null;
875
+ if (has(loop, 'POLY_LOOP')) {
876
+ const list = part(loop, 'POLY_LOOP').args.find((a) => Array.isArray(a));
877
+ if (!list) return { pts: [], ids: [], curved: false };
878
+ const pts = [];
879
+ const ids = [];
880
+ for (const item of list) {
881
+ const p = coordsOf(entities, item);
882
+ if (!p) return { pts: [], ids: [], curved: false };
883
+ pts.push(p);
884
+ ids.push(item && item.ref);
885
+ }
886
+ return { pts, ids, curved: false };
887
+ }
888
+ if (has(loop, 'EDGE_LOOP')) {
889
+ const list = part(loop, 'EDGE_LOOP').args.find((a) => Array.isArray(a));
890
+ if (!list) return { pts: [], ids: [], curved: false };
891
+ const pts = [];
892
+ const ids = [];
893
+ for (const item of list) {
894
+ const edge = orientedEdge(entities, item);
895
+ if (!edge || edge.curved) return { pts: [], ids: [], curved: true };
896
+ for (let i = 0; i < edge.pts.length; i++) {
897
+ pts.push(edge.pts[i]);
898
+ ids.push(edge.ids[i]);
899
+ }
900
+ }
901
+ return { pts, ids, curved: false };
902
+ }
903
+ return { pts: [], ids: [], curved: true };
904
+ }
905
+
906
+ function cleanRing(pts, ids) {
907
+ const p = [];
908
+ const d = [];
909
+ for (let i = 0; i < pts.length; i++) {
910
+ const prev = p[p.length - 1];
911
+ if (prev && prev[0] === pts[i][0] && prev[1] === pts[i][1] && prev[2] === pts[i][2]) continue;
912
+ p.push(pts[i]);
913
+ d.push(ids[i]);
914
+ }
915
+ if (p.length > 1) {
916
+ const a = p[0], b = p[p.length - 1];
917
+ if (a[0] === b[0] && a[1] === b[1] && a[2] === b[2]) { p.pop(); d.pop(); }
918
+ }
919
+ return { pts: p, ids: d };
920
+ }
921
+
922
+ function newell(pts) {
923
+ let x = 0, y = 0, z = 0;
924
+ for (let i = 0; i < pts.length; i++) {
925
+ const a = pts[i], b = pts[(i + 1) % pts.length];
926
+ x += (a[1] - b[1]) * (a[2] + b[2]);
927
+ y += (a[2] - b[2]) * (a[0] + b[0]);
928
+ z += (a[0] - b[0]) * (a[1] + b[1]);
929
+ }
930
+ return [x, y, z];
931
+ }
932
+
933
+ function basis(n) {
934
+ const ax = Math.abs(n[0]), ay = Math.abs(n[1]), az = Math.abs(n[2]);
935
+ const t = ax <= ay && ax <= az ? [1, 0, 0] : ay <= az ? [0, 1, 0] : [0, 0, 1];
936
+ let ux = n[1] * t[2] - n[2] * t[1];
937
+ let uy = n[2] * t[0] - n[0] * t[2];
938
+ let uz = n[0] * t[1] - n[1] * t[0];
939
+ const ul = Math.hypot(ux, uy, uz) || 1;
940
+ ux /= ul; uy /= ul; uz /= ul;
941
+ const nl = Math.hypot(n[0], n[1], n[2]) || 1;
942
+ const nx = n[0] / nl, ny = n[1] / nl, nz = n[2] / nl;
943
+ return { u: [ux, uy, uz], v: [ny * uz - nz * uy, nz * ux - nx * uz, nx * uy - ny * ux] };
944
+ }
945
+
946
+ function project(pts, origin, u, v) {
947
+ const flat = new Float64Array(pts.length * 2);
948
+ for (let i = 0; i < pts.length; i++) {
949
+ const d0 = pts[i][0] - origin[0], d1 = pts[i][1] - origin[1], d2 = pts[i][2] - origin[2];
950
+ flat[i * 2] = d0 * u[0] + d1 * u[1] + d2 * u[2];
951
+ flat[i * 2 + 1] = d0 * v[0] + d1 * v[1] + d2 * v[2];
952
+ }
953
+ return flat;
954
+ }
955
+
956
+ function isConvex(flat) {
957
+ const n = flat.length / 2;
958
+ let sign = 0;
959
+ for (let i = 0; i < n; i++) {
960
+ const p = (i + n - 1) % n, q = (i + 1) % n;
961
+ const c = (flat[i * 2] - flat[p * 2]) * (flat[q * 2 + 1] - flat[i * 2 + 1]) - (flat[i * 2 + 1] - flat[p * 2 + 1]) * (flat[q * 2] - flat[i * 2]);
962
+ if (Math.abs(c) < 1e-12) continue;
963
+ const s = c > 0 ? 1 : -1;
964
+ if (!sign) sign = s;
965
+ else if (s !== sign) return false;
966
+ }
967
+ return true;
968
+ }
969
+
970
+ function ringArea(flat, n) {
971
+ let s = 0;
972
+ for (let i = 0, j = n - 1; i < n; j = i++) s += (flat[j * 2] - flat[i * 2]) * (flat[i * 2 + 1] + flat[j * 2 + 1]);
973
+ return s;
974
+ }
975
+
976
+ function polygonIndices(outerPts, holePts) {
977
+ const nrm = newell(outerPts);
978
+ if (Math.hypot(nrm[0], nrm[1], nrm[2]) < 1e-18) return [];
979
+ const { u, v } = basis(nrm);
980
+ if (!holePts.length) {
981
+ const flat = project(outerPts, outerPts[0], u, v);
982
+ if (isConvex(flat)) {
983
+ const idx = [];
984
+ for (let i = 1; i + 1 < outerPts.length; i++) idx.push(0, i, i + 1);
985
+ return idx;
986
+ }
987
+ }
988
+ const all = outerPts.concat(...holePts);
989
+ const flat = project(all, outerPts[0], u, v);
990
+ const holes = [];
991
+ let acc = outerPts.length;
992
+ for (const h of holePts) {
993
+ if (h.length >= 3) holes.push(acc);
994
+ acc += h.length;
995
+ }
996
+ let ix = [];
997
+ try { ix = triangulate(flat, holes.length ? holes : null) || []; } catch { ix = []; }
998
+ if (!ix.length) {
999
+ const fan = [];
1000
+ for (let i = 1; i + 1 < outerPts.length; i++) fan.push(0, i, i + 1);
1001
+ return fan;
1002
+ }
1003
+ if (ringArea(flat, outerPts.length) < 0) {
1004
+ for (let i = 0; i < ix.length; i += 3) {
1005
+ const t = ix[i + 1];
1006
+ ix[i + 1] = ix[i + 2];
1007
+ ix[i + 2] = t;
1008
+ }
1009
+ }
1010
+ return ix;
1011
+ }
1012
+
1013
+ function surfaceEntity(entities, face) {
1014
+ const args = (part(face, 'ADVANCED_FACE') || part(face, 'FACE_SURFACE') || part(face, 'FACE') || face.parts[face.parts.length - 1]).args;
1015
+ for (let i = args.length - 1; i >= 0; i--) {
1016
+ const a = args[i];
1017
+ if (!a || !a.ref) continue;
1018
+ const e = entities.get(a.ref);
1019
+ if (e && e.parts.some((p) => p.name.includes('SURFACE') || p.name === 'PLANE')) return e;
1020
+ }
1021
+ return null;
1022
+ }
1023
+
1024
+ function unwrapCylinder(pts, frame) {
1025
+ const uv = new Array(pts.length);
1026
+ let prev = null;
1027
+ let min = Infinity;
1028
+ let max = -Infinity;
1029
+ for (let i = 0; i < pts.length; i++) {
1030
+ let th = pointAngle(frame, pts[i]);
1031
+ if (prev != null) {
1032
+ while (th - prev > Math.PI) th -= Math.PI * 2;
1033
+ while (prev - th > Math.PI) th += Math.PI * 2;
1034
+ }
1035
+ uv[i] = [th, pointHeight(frame, pts[i])];
1036
+ if (th < min) min = th;
1037
+ if (th > max) max = th;
1038
+ prev = th;
1039
+ }
1040
+ if (!(max > min + 1e-8)) return null;
1041
+ return uv;
1042
+ }
1043
+
1044
+ function cylinderIndices(pts, frame) {
1045
+ const uv = unwrapCylinder(pts, frame);
1046
+ if (!uv) return null;
1047
+ const n = pts.length;
1048
+ let iMin = 0;
1049
+ let iMax = 0;
1050
+ for (let i = 1; i < n; i++) {
1051
+ if (uv[i][0] < uv[iMin][0] - 1e-9) iMin = i;
1052
+ if (uv[i][0] > uv[iMax][0] + 1e-9) iMax = i;
1053
+ }
1054
+ if (iMin === iMax) return null;
1055
+ const walk = (step) => {
1056
+ const chain = [];
1057
+ for (let i = iMin, guard = 0; guard <= n; guard++) {
1058
+ chain.push(i);
1059
+ if (i === iMax) return chain;
1060
+ i = (i + step + n) % n;
1061
+ }
1062
+ return null;
1063
+ };
1064
+ const rising = (chain) => {
1065
+ for (let i = 1; i < chain.length; i++) if (uv[chain[i]][0] < uv[chain[i - 1]][0] - 1e-5) return false;
1066
+ return true;
1067
+ };
1068
+ const a = walk(1);
1069
+ const b = walk(-1);
1070
+ if (!a || !b || a.length < 2 || b.length < 2 || !rising(a) || !rising(b)) return null;
1071
+ let ring = 0;
1072
+ for (let i = 0; i < n; i++) {
1073
+ const p = uv[i], q = uv[(i + 1) % n];
1074
+ ring += p[0] * q[1] - q[0] * p[1];
1075
+ }
1076
+ const sign = ring < 0 ? -1 : 1;
1077
+ const ix = [];
1078
+ let ia = 0;
1079
+ let ib = 0;
1080
+ while (ia < a.length - 1 || ib < b.length - 1) {
1081
+ let p, q, r;
1082
+ if (ia >= a.length - 1) { p = a[ia]; q = b[ib]; r = b[ib + 1]; ib++; }
1083
+ else if (ib >= b.length - 1) { p = a[ia]; q = b[ib]; r = a[ia + 1]; ia++; }
1084
+ else if (uv[a[ia + 1]][0] <= uv[b[ib + 1]][0] + 1e-12) { p = a[ia]; q = b[ib]; r = a[ia + 1]; ia++; }
1085
+ else { p = a[ia]; q = b[ib]; r = b[ib + 1]; ib++; }
1086
+ if (p === q || q === r || p === r) continue;
1087
+ const pa = uv[p], pb = uv[q], pc = uv[r];
1088
+ const area = (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0]);
1089
+ if (Math.abs(area) < 1e-14) continue;
1090
+ if ((area < 0 ? -1 : 1) === sign) ix.push(p, q, r);
1091
+ else ix.push(p, r, q);
1092
+ }
1093
+ if (ix.length < 3) return null;
1094
+ const nrm = newell(pts);
1095
+ for (let i = 0; i < ix.length; i += 3) {
1096
+ const a0 = pts[ix[i]], b0 = pts[ix[i + 1]], c0 = pts[ix[i + 2]];
1097
+ const ux = b0[0] - a0[0], uy = b0[1] - a0[1], uz = b0[2] - a0[2];
1098
+ const vx = c0[0] - a0[0], vy = c0[1] - a0[1], vz = c0[2] - a0[2];
1099
+ const dot = (uy * vz - uz * vy) * nrm[0] + (uz * vx - ux * vz) * nrm[1] + (ux * vy - uy * vx) * nrm[2];
1100
+ if (Math.abs(dot) < 1e-12) continue;
1101
+ if (dot < 0) {
1102
+ for (let k = 0; k < ix.length; k += 3) {
1103
+ const t = ix[k + 1];
1104
+ ix[k + 1] = ix[k + 2];
1105
+ ix[k + 2] = t;
1106
+ }
1107
+ }
1108
+ break;
1109
+ }
1110
+ return ix;
1111
+ }
1112
+
1113
+ function emitFace(soup, pts, ids, ix) {
1114
+ let n = 0;
1115
+ for (let i = 0; i < ix.length; i += 3) {
1116
+ const va = ix[i], vb = ix[i + 1], vc = ix[i + 2];
1117
+ if (va == null || vb == null || vc == null) continue;
1118
+ soup.tri(
1119
+ soup.add(pts[va], ids[va] != null ? `p${ids[va]}` : null),
1120
+ soup.add(pts[vb], ids[vb] != null ? `p${ids[vb]}` : null),
1121
+ soup.add(pts[vc], ids[vc] != null ? `p${ids[vc]}` : null),
1122
+ );
1123
+ n++;
1124
+ }
1125
+ return n;
1126
+ }
1127
+
1128
+ function faceRings(entities, face, extraFlip) {
1129
+ if (!face) return null;
1130
+ const args = (part(face, 'ADVANCED_FACE') || part(face, 'FACE_SURFACE') || part(face, 'FACE') || face.parts[face.parts.length - 1]).args;
1131
+ // FACE_BOUND orientation already follows the face. same_sense only says
1132
+ // whether that face normal agrees with the surface, so it must not reverse
1133
+ // the loop a second time.
1134
+ const flip = extraFlip;
1135
+ let outer = null;
1136
+ const holes = [];
1137
+ for (const a of args) {
1138
+ const refs = Array.isArray(a) ? a.filter((x) => x && x.ref) : (a && a.ref ? [a] : []);
1139
+ for (const ref of refs) {
1140
+ const bound = entities.get(ref.ref);
1141
+ if (!bound || (!has(bound, 'FACE_BOUND') && !has(bound, 'FACE_OUTER_BOUND'))) continue;
1142
+ const ba = (part(bound, 'FACE_OUTER_BOUND') || part(bound, 'FACE_BOUND')).args;
1143
+ const loopRef = ba.find((x) => x && x.ref);
1144
+ const loop = readLoop(entities, loopRef ? entities.get(loopRef.ref) : null);
1145
+ if (!loop) continue;
1146
+ if (loop.curved) return { curved: true };
1147
+ const reversed = ba.find((x) => typeof x === 'boolean') === false;
1148
+ let ring = cleanRing(loop.pts, loop.ids);
1149
+ if (reversed !== flip && ring.pts.length) ring = { pts: ring.pts.slice().reverse(), ids: ring.ids.slice().reverse() };
1150
+ else if (reversed && flip) ring = cleanRing(loop.pts, loop.ids);
1151
+ if (has(bound, 'FACE_OUTER_BOUND')) {
1152
+ if (!outer) outer = ring;
1153
+ else holes.push(ring);
1154
+ } else holes.push(ring);
1155
+ }
1156
+ }
1157
+ if (!outer) {
1158
+ let best = -1;
1159
+ let bestAt = -1;
1160
+ for (let i = 0; i < holes.length; i++) {
1161
+ const n = newell(holes[i].pts);
1162
+ const area = Math.hypot(n[0], n[1], n[2]);
1163
+ if (area > best) { best = area; bestAt = i; }
1164
+ }
1165
+ if (bestAt >= 0) outer = holes.splice(bestAt, 1)[0];
1166
+ }
1167
+ return { curved: false, outer, holes };
1168
+ }
1169
+
1170
+ function addFace(soup, entities, face, extraFlip) {
1171
+ if (!face) return 'skip';
1172
+ if (faceCurved(entities, face)) return 'curved';
1173
+ const surf = surfaceEntity(entities, face);
1174
+ const cylindrical = !!(surf && has(surf, 'CYLINDRICAL_SURFACE'));
1175
+ const rings = faceRings(entities, face, extraFlip);
1176
+ if (!rings) return 'skip';
1177
+ if (rings.curved) return 'curved';
1178
+ const { outer, holes } = rings;
1179
+ if (cylindrical) {
1180
+ const holePts = holes.filter((h) => h.pts.length >= 3);
1181
+ if (!outer || outer.pts.length < 3 || holePts.length) return 'curved';
1182
+ const place = part(surf, 'CYLINDRICAL_SURFACE').args.find((a) => a && a.ref);
1183
+ const frame = place && placementFrame(entities, place);
1184
+ const ix = frame && cylinderIndices(outer.pts, frame);
1185
+ if (!ix || !emitFace(soup, outer.pts, outer.ids, ix)) return 'curved';
1186
+ return 'ok';
1187
+ }
1188
+ if (!outer || outer.pts.length < 3) return 'skip';
1189
+ const holePts = holes.filter((h) => h.pts.length >= 3);
1190
+ const ix = polygonIndices(outer.pts, holePts.map((h) => h.pts));
1191
+ const all = outer.pts.concat(...holePts.map((h) => h.pts));
1192
+ const ids = outer.ids.concat(...holePts.map((h) => h.ids));
1193
+ emitFace(soup, all, ids, ix);
1194
+ return 'ok';
1195
+ }
1196
+
1197
+ export function hasTessellation(entities) {
1198
+ for (const e of entities.values()) {
1199
+ if (e.parts.some((p) => TESS_TYPES.has(p.name))) return true;
1200
+ }
1201
+ return false;
1202
+ }
1203
+
1204
+ /**
1205
+ * Analytic shells for solid recognition. Empty when the file is tessellated
1206
+ * (that geometry is already triangles) or has no boundary faces.
1207
+ * Each face is { kind:'plane'|'cylinder'|'other', normal, outer, holes, radius?, frame? }.
1208
+ * Normals and loops follow the face orientation used by the tessellator.
1209
+ * @param {Map<number, object>} entities
1210
+ */
1211
+ function paint(ids, colours) {
1212
+ for (const id of ids) if (colours.has(id)) return colours.get(id);
1213
+ return null;
1214
+ }
1215
+
1216
+ export function ownerIndex(entities) {
1217
+ const map = new Map();
1218
+ const add = (id, owner) => {
1219
+ let list = map.get(id);
1220
+ if (!list) map.set(id, list = []);
1221
+ list.push(owner);
1222
+ };
1223
+ for (const e of entities.values()) {
1224
+ for (const a of e.parts.flatMap((p) => p.args)) {
1225
+ if (a && a.ref) add(a.ref, e.id);
1226
+ else if (Array.isArray(a)) for (const x of a) if (x && x.ref) add(x.ref, e.id);
1227
+ }
1228
+ }
1229
+ return map;
1230
+ }
1231
+
1232
+ export function inheritedColour(colours, owners, ids) {
1233
+ const direct = paint(ids, colours);
1234
+ if (direct) return direct;
1235
+ let frontier = ids;
1236
+ const seen = new Set(ids);
1237
+ for (let depth = 0; depth < 4 && frontier.length; depth++) {
1238
+ const next = [];
1239
+ for (const id of frontier) {
1240
+ const ups = owners.get(id);
1241
+ if (!ups) continue;
1242
+ for (const owner of ups) {
1243
+ if (seen.has(owner)) continue;
1244
+ seen.add(owner);
1245
+ if (colours.has(owner)) return colours.get(owner);
1246
+ next.push(owner);
1247
+ }
1248
+ }
1249
+ frontier = next;
1250
+ }
1251
+ return null;
1252
+ }
1253
+
1254
+ export function shellEntries(entities, shell) {
1255
+ const out = [];
1256
+ const seen = new Set();
1257
+ for (const a of shell.parts.flatMap((p) => p.args)) {
1258
+ if (!Array.isArray(a)) continue;
1259
+ for (const item of a) {
1260
+ if (!item || !item.ref) continue;
1261
+ const unwrapped = asFace(entities, item);
1262
+ if (!unwrapped.face || !isFaceEntity(unwrapped.face) || seen.has(unwrapped.face.id)) continue;
1263
+ seen.add(unwrapped.face.id);
1264
+ out.push(unwrapped);
1265
+ }
1266
+ }
1267
+ return out;
1268
+ }
1269
+
1270
+ function facesToRecord(entities, entries, colours, owners, smooth, name, extraIds) {
1271
+ const soup = new Soup();
1272
+ let curved = 0;
1273
+ const ids = [];
1274
+ for (const entry of entries) {
1275
+ ids.push(entry.face.id);
1276
+ const status = addFace(soup, entities, entry.face, entry.flip);
1277
+ if (status === 'curved') curved++;
1278
+ }
1279
+ const color = inheritedColour(colours, owners, ids.concat(extraIds));
1280
+ const mesh = soup.finish(smooth);
1281
+ if (!mesh) return { mesh: null, curved, color };
1282
+ return { mesh, curved, color, name };
1283
+ }
1284
+
1285
+ function extractMeshes(entities, smooth) {
1286
+ const colours = colourMap(entities);
1287
+ const owners = ownerIndex(entities);
1288
+ const tess = collectTess(entities, colours, smooth);
1289
+ if (tess.length) return { meshes: tess, curved: 0 };
1290
+ return collectFaceted(entities, colours, owners, smooth);
1291
+ }
1292
+
1293
+ function collectTess(entities, colours, smooth) {
1294
+ const grouped = new Set();
1295
+ const meshes = [];
1296
+ for (const e of entities.values()) {
1297
+ if (!has(e, 'TESSELLATED_SHELL')) continue;
1298
+ const items = [];
1299
+ for (const a of e.parts.flatMap((p) => p.args)) {
1300
+ if (!Array.isArray(a)) continue;
1301
+ for (const ref of a) {
1302
+ const child = ref && ref.ref ? entities.get(ref.ref) : null;
1303
+ if (!child || !child.parts.some((p) => TESS_TYPES.has(p.name))) continue;
1304
+ grouped.add(child.id);
1305
+ items.push(child);
1306
+ }
1307
+ }
1308
+ const soup = new Soup();
1309
+ for (const item of items) addTess(soup, entities, item);
1310
+ const mesh = soup.finish(smooth);
1311
+ if (mesh) meshes.push({ mesh, name: labelOf(e), color: colours.get(e.id) || items.map((it) => colours.get(it.id)).find(Boolean) || null });
1312
+ }
1313
+ for (const e of entities.values()) {
1314
+ if (grouped.has(e.id) || !e.parts.some((p) => TESS_TYPES.has(p.name))) continue;
1315
+ const soup = new Soup();
1316
+ addTess(soup, entities, e);
1317
+ const mesh = soup.finish(smooth);
1318
+ if (mesh) meshes.push({ mesh, name: labelOf(e), color: colours.get(e.id) || null });
1319
+ }
1320
+ return meshes;
1321
+ }
1322
+
1323
+ function collectFaceted(entities, colours, owners, smooth) {
1324
+ const used = new Set();
1325
+ const meshes = [];
1326
+ let curved = 0;
1327
+ for (const e of entities.values()) {
1328
+ if (!e.parts.some((p) => SHELL_TYPES.has(p.name))) continue;
1329
+ const entries = shellEntries(entities, e);
1330
+ for (const entry of entries) {
1331
+ used.add(entry.id);
1332
+ used.add(entry.face.id);
1333
+ }
1334
+ const rec = facesToRecord(entities, entries, colours, owners, smooth, labelOf(e), [e.id]);
1335
+ curved += rec.curved;
1336
+ if (rec.mesh) meshes.push(rec);
1337
+ }
1338
+ const loose = [];
1339
+ for (const e of entities.values()) {
1340
+ if (!isFaceEntity(e) || used.has(e.id)) continue;
1341
+ loose.push({ face: e, flip: false, id: e.id });
1342
+ }
1343
+ if (loose.length) {
1344
+ const rec = facesToRecord(entities, loose, colours, owners, smooth, '', []);
1345
+ curved += rec.curved;
1346
+ if (rec.mesh) meshes.push(rec);
1347
+ }
1348
+ return { meshes, curved };
1349
+ }
1350
+
1351
+ // --- writer ------------------------------------------------------------------
1352
+
1353
+ function stepString(s) {
1354
+ return `'${String(s).replace(/\\/g, '/').replace(/'/g, "''").replace(/[\r\n]+/g, ' ')}'`;
1355
+ }
1356
+
1357
+ function stepReal(v) {
1358
+ if (!Number.isFinite(v) || Object.is(v, -0) || Math.abs(v) < 1e-15) return '0.';
1359
+ const a = Math.abs(v);
1360
+ let s = a >= 1e7 || a < 1e-4 ? v.toExponential(9).replace('e', 'E') : String(Math.round(v * 1e9) / 1e9);
1361
+ s = s.replace(/(\.\d*?)0+E/, '$1E').replace(/\.E/, 'E');
1362
+ if (!s.includes('.') && !s.includes('E')) s += '.';
1363
+ return s;
1364
+ }
1365
+
1366
+ function wrapList(parts) {
1367
+ const lines = [];
1368
+ let line = '';
1369
+ for (let i = 0; i < parts.length; i++) {
1370
+ const piece = (line ? ',' : '') + parts[i];
1371
+ if (line && line.length + piece.length > 100) {
1372
+ lines.push(`${line},`);
1373
+ line = parts[i];
1374
+ } else line += piece;
1375
+ }
1376
+ if (line) lines.push(line);
1377
+ return lines.join('\n');
1378
+ }
1379
+
1380
+ function writeStep(name, points, tris) {
1381
+ const safe = String(name || 'es3d').replace(/[^\w.-]+/g, '_').slice(0, 64) || 'es3d';
1382
+ const when = new Date().toISOString().replace(/\.\d{3}Z$/, '');
1383
+ const pts = wrapList(points.map((p) => `(${stepReal(p[0])},${stepReal(p[1])},${stepReal(p[2])})`));
1384
+ const faces = [];
1385
+ for (let i = 0; i < tris.length; i += 3) faces.push(`(${tris[i] + 1},${tris[i + 1] + 1},${tris[i + 2] + 1})`);
1386
+ const triText = wrapList(faces);
1387
+ return `ISO-10303-21;
1388
+ HEADER;
1389
+ FILE_DESCRIPTION(('ES3D tessellated shape'),'2;1');
1390
+ FILE_NAME(${stepString(`${safe}.step`)},${stepString(when)},('ES3D'),('ES3D'),'ES3D 0.1','ES3D','');
1391
+ FILE_SCHEMA(('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF { 1 0 10303 442 1 1 4 }'));
1392
+ ENDSEC;
1393
+ DATA;
1394
+ #1=APPLICATION_CONTEXT('managed model based 3d engineering');
1395
+ #2=APPLICATION_PROTOCOL_DEFINITION('international standard','ap242_managed_model_based_3d_engineering',2014,#1);
1396
+ #3=PRODUCT_CONTEXT('',#1,'mechanical');
1397
+ #4=PRODUCT(${stepString(safe)},${stepString(safe)},'',(#3));
1398
+ #5=PRODUCT_DEFINITION_FORMATION('','',#4);
1399
+ #6=PRODUCT_DEFINITION_CONTEXT('part definition',#1,'design');
1400
+ #7=PRODUCT_DEFINITION('design','',#5,#6);
1401
+ #8=PRODUCT_DEFINITION_SHAPE('','',#7);
1402
+ #9=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));
1403
+ #10=(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.));
1404
+ #11=(NAMED_UNIT(*)SOLID_ANGLE_UNIT()SI_UNIT($,.STERADIAN.));
1405
+ #12=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-7),#9,'distance_accuracy_value','confusion accuracy');
1406
+ #13=(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#12))GLOBAL_UNIT_ASSIGNED_CONTEXT((#9,#10,#11))REPRESENTATION_CONTEXT('','3D'));
1407
+ #14=CARTESIAN_POINT_LIST_3D('',(
1408
+ ${pts}
1409
+ ));
1410
+ #15=TRIANGULATED_SURFACE_SET('',#14,0,$,$,(
1411
+ ${triText}
1412
+ ));
1413
+ #16=TESSELLATED_SHAPE_REPRESENTATION('',(#15),#13);
1414
+ #17=SHAPE_DEFINITION_REPRESENTATION(#8,#16);
1415
+ ENDSEC;
1416
+ END-ISO-10303-21;
1417
+ `;
1418
+ }