@es3d/es3d 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +86 -86
  3. package/docs/SPEC.md +417 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +377 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +92 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf.js +630 -630
  42. package/src/io/mesh-import.js +104 -104
  43. package/src/io/obj.js +352 -352
  44. package/src/io/step-brep.js +439 -439
  45. package/src/io/step-exact.js +1149 -1149
  46. package/src/io/step.js +1418 -1418
  47. package/src/io/stl.js +193 -193
  48. package/src/io/svg-import.js +787 -787
  49. package/src/material/materials.js +154 -154
  50. package/src/math/mat4.js +475 -475
  51. package/src/math/random.js +30 -30
  52. package/src/math/trig.js +65 -65
  53. package/src/math/vec3.js +81 -81
  54. package/src/optimize/optimizer.js +579 -579
  55. package/src/path/path-flatten.js +322 -322
  56. package/src/path/path-parser.js +332 -332
  57. package/src/path/shapes.js +129 -129
  58. package/src/path/triangulate.js +517 -517
  59. package/src/pipeline.js +74 -74
  60. package/src/render/camera.js +168 -168
  61. package/src/render/frame-builder.js +362 -362
  62. package/src/render/geometry-pool.js +149 -149
  63. package/src/render/lighting.js +149 -149
  64. package/src/render/orbit-controls.js +237 -237
  65. package/src/render/renderer.js +258 -249
  66. package/src/render/scroll-timeline.js +104 -104
  67. package/src/render/shaders/glsl.js +285 -285
  68. package/src/render/shaders/wgsl.js +252 -252
  69. package/src/render/stage.js +364 -352
  70. package/src/render/texture-cache.js +40 -40
  71. package/src/render/textures.js +223 -223
  72. package/src/render/webgl2/webgl2-backend.js +586 -532
  73. package/src/render/webgpu/webgpu-backend.js +482 -480
  74. package/src/scene/flatten.js +754 -754
  75. package/src/scene/picking.js +161 -161
  76. package/src/scene/scene-graph.js +320 -320
  77. package/src/transform/transform.js +211 -211
  78. package/src/wasm/kernels-binary.js +3 -3
  79. package/src/wasm/kernels.js +385 -385
@@ -1,377 +1,377 @@
1
- #!/usr/bin/env node
2
- // es3d: command-line companion of the reference implementation.
3
- //
4
- // npm run es3d -- validate scene.es3d
5
- // npm run es3d -- fmt scene.es3d -o pretty.es3d
6
- // npm run es3d -- stats scene.es3d
7
- // npm run es3d -- glb scene.es3d --instancing -o scene.glb
8
- // npm run es3d -- import drawing.svg -o drawing.es3d
9
- //
10
- // Run without arguments for the full command list. Input "-" reads stdin;
11
- // without -o, text goes to stdout (binary output requires -o).
12
-
13
- import fs from 'node:fs';
14
- import path from 'node:path';
15
- import zlib from 'node:zlib';
16
- import { parse, detectSyntax } from '../src/format/parse.js';
17
- import { serialize } from '../src/format/serializer.js';
18
- import { compile } from '../src/pipeline.js';
19
- import { exportOBJ } from '../src/io/obj.js';
20
- import { exportGLB } from '../src/io/gltf.js';
21
- import { exportSTL } from '../src/io/stl.js';
22
- import { exportSTEP } from '../src/io/step.js';
23
- import { detectKind, importFiles } from '../src/io/convert.js';
24
- import { exportableInstances } from '../src/io/external-scene.js';
25
- import { optimize, expandProcedural } from '../src/optimize/optimizer.js';
26
-
27
- const HELP = `es3d <command> [options] <input>
28
-
29
- Commands
30
- validate parse, validate and build the scene; print every diagnostic (exit 1 on errors)
31
- fmt pretty-print, keeping the syntax (markup or compact)
32
- minify minified markup (short attribute aliases, no comments)
33
- compact convert to the compact syntax (--min for one line)
34
- markup convert to the markup syntax (--min for minified)
35
- json convert to the JsonML form (--pretty for indentation)
36
- optimize run the optimizer and print its report to stderr
37
- expand replace <repeat>/<scatter> with explicit elements
38
- obj export Wavefront OBJ (+ .mtl next to -o) (--lod <0-3>)
39
- glb export glTF binary (--lod <0-3>, --instancing, --quantize 16)
40
- stl export STL for 3D printing, Z-up (--lod <0-3>, --ascii)
41
- step export tessellated STEP (AP242), Z-up (--lod <0-3>)
42
- import convert SVG, OBJ (+MTL), STL, STEP or glTF (.gltf + .bin, .glb) to ES3D:
43
- SVG shapes are extruded (--size, --depth, --bevel, --flat, --no-strokes, --plate),
44
- STEP solids become ES3D shapes (boxes, extrusions, revolves, paths; free-form faces a mesh),
45
- meshes become <mesh> elements (--precision, --smooth, --up y for Y-up STL or STEP)
46
- stats sizes, element / instance / triangle counts and pipeline timings
47
-
48
- Options
49
- -o, --out <file> output file (default: stdout for text)
50
- --precision <n> round numbers to n decimals when writing text
51
- --lod <n> level of detail for mesh exports (default 0 = finest)
52
- --instancing GLB: use EXT_mesh_gpu_instancing
53
- --quantize GLB: KHR_mesh_quantization (int16 positions, int8 normals)
54
- --min / --pretty force minified / pretty output
55
- --json validate/stats: machine readable output
56
- --ascii STL: write ASCII instead of binary
57
- --size <n> import SVG: length of the drawing's longer side (default 10)
58
- --depth <percent> import SVG: extrusion depth in % of the size (default 4)
59
- --bevel <percent> import SVG: rounded edge radius in % of the size (default 0)
60
- --flat import SVG: flat shapes instead of extrusions
61
- --no-strokes import SVG: ignore strokes
62
- --plate import SVG: add a backing plate
63
- --smooth <deg> import meshes: crease angle for computed normals (default 30)
64
- --up <z|y> import STL and STEP: the file's up axis (default z)
65
- -h, --help this text`;
66
-
67
- function parseArgs(argv) {
68
- const o = { cmd: null, input: null, out: null, precision: null, lod: 0, instancing: false, quantize: 0, min: false, pretty: false, json: false, ascii: false, svg: {}, mesh: {}, stl: {} };
69
- for (let i = 0; i < argv.length; i++) {
70
- const a = argv[i];
71
- const val = () => {
72
- if (i + 1 >= argv.length) fail(`${a} needs a value`);
73
- return argv[++i];
74
- };
75
- if (a === '-h' || a === '--help') o.help = true;
76
- else if (a === '-o' || a === '--out') o.out = val();
77
- else if (a === '--precision') o.precision = Number(val());
78
- else if (a === '--lod') o.lod = Number(val());
79
- else if (a === '--instancing') o.instancing = true;
80
- else if (a === '--quantize') o.quantize = true;
81
- else if (a === '--min') o.min = true;
82
- else if (a === '--pretty') o.pretty = true;
83
- else if (a === '--json') o.json = true;
84
- else if (a === '--ascii') o.ascii = true;
85
- else if (a === '--size') o.svg.size = Number(val());
86
- else if (a === '--depth') o.svg.depth = Number(val()) / 100;
87
- else if (a === '--bevel') o.svg.bevel = Number(val()) / 100;
88
- else if (a === '--flat') o.svg.depth = 0;
89
- else if (a === '--no-strokes') o.svg.strokes = false;
90
- else if (a === '--plate') o.svg.base = true;
91
- else if (a === '--smooth') o.mesh.smooth = Number(val());
92
- else if (a === '--up') o.stl.up = val() === 'y' ? 'y' : 'z';
93
- else if (a.startsWith('-') && a !== '-') fail(`unknown option ${a}`);
94
- else if (!o.cmd) o.cmd = a;
95
- else if (!o.input) o.input = a;
96
- else fail(`unexpected argument ${a}`);
97
- }
98
- return o;
99
- }
100
-
101
- function fail(msg, code = 2) {
102
- process.stderr.write(`es3d: ${msg}\n`);
103
- process.exit(code);
104
- }
105
-
106
- function readInput(file) {
107
- let bytes;
108
- try {
109
- bytes = file === '-' ? fs.readFileSync(0) : fs.readFileSync(file);
110
- } catch (e) {
111
- fail(`cannot read ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}`);
112
- }
113
- if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = zlib.gunzipSync(bytes);
114
- return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
115
- }
116
-
117
- /** ES3D input as text (markup, compact or JSON). */
118
- function es3dInput(bytes) {
119
- return new TextDecoder('utf-8').decode(bytes);
120
- }
121
-
122
- function write(opts, data) {
123
- if (opts.out) {
124
- fs.writeFileSync(opts.out, data);
125
- const n = typeof data === 'string' ? Buffer.byteLength(data) : data.length;
126
- process.stderr.write(`wrote ${opts.out} (${n.toLocaleString('en-US')} bytes)\n`);
127
- } else if (typeof data === 'string') {
128
- process.stdout.write(data.endsWith('\n') ? data : data + '\n');
129
- } else {
130
- fail('binary output needs -o <file>');
131
- }
132
- }
133
-
134
- function report(doc) {
135
- const out = [];
136
- for (const d of doc.diagnostics) out.push(d.format(doc.lineIndex));
137
- return out.join('\n\n');
138
- }
139
-
140
- /** Parse and validate, or exit with the diagnostics. */
141
- function parsed(input) {
142
- const doc = parse(input);
143
- if (!doc.ok) {
144
- process.stderr.write(report(doc) + '\n');
145
- process.exit(1);
146
- }
147
- for (const w of doc.warnings || []) process.stderr.write(w.format(doc.lineIndex) + '\n\n');
148
- return doc;
149
- }
150
-
151
- function compiled(input) {
152
- const r = compile(input);
153
- const errors = r.diagnostics.filter((d) => d.severity === 'error');
154
- if (errors.length) {
155
- process.stderr.write(r.diagnostics.map((d) => d.format(r.doc.lineIndex)).join('\n\n') + '\n');
156
- process.exit(1);
157
- }
158
- return r;
159
- }
160
-
161
- /** Material library of an OBJ file: its mtllib, else <name>.mtl next to it. */
162
- function mtlFor(objPath, text) {
163
- if (objPath === '-') return '';
164
- const dir = path.dirname(objPath);
165
- const m = /^mtllib\s+(.+)$/m.exec(text);
166
- const candidates = [m ? path.join(dir, m[1].trim()) : null, objPath.replace(/\.obj$/i, '') + '.mtl'].filter(Boolean);
167
- for (const c of candidates) if (fs.existsSync(c)) return fs.readFileSync(c, 'utf8');
168
- return '';
169
- }
170
-
171
- /** External buffers of a .gltf file, read from next to it. */
172
- function gltfCompanions(gltfPath, bytes) {
173
- if (gltfPath === '-') return [];
174
- let json;
175
- try {
176
- json = JSON.parse(new TextDecoder().decode(bytes));
177
- } catch {
178
- return [];
179
- }
180
- const out = [];
181
- for (const b of json.buffers || []) {
182
- if (!b.uri || b.uri.startsWith('data:')) continue;
183
- const name = decodeURIComponent(b.uri);
184
- const file = path.join(path.dirname(gltfPath), name);
185
- if (fs.existsSync(file)) out.push({ name, bytes: new Uint8Array(fs.readFileSync(file)) });
186
- }
187
- return out;
188
- }
189
-
190
- const sizes = (data) => {
191
- const b = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
192
- return { raw: b.length, gzip: zlib.gzipSync(b, { level: 9 }).length, brotli: zlib.brotliCompressSync(b).length };
193
- };
194
-
195
- function stats(input, opts) {
196
- const t0 = performance.now();
197
- const r = compiled(input);
198
- const t1 = performance.now();
199
- const meshOf = (e, l) => r.cache.mesh(e, l);
200
- const lods = [0, 1, 2, 3].map((lod) => {
201
- let tris = 0;
202
- for (const i of exportableInstances(r.scene)) {
203
- const e = r.scene.geometries[r.scene.geom[i]];
204
- const m = meshOf(e, Math.min(lod, e.lods - 1));
205
- if (m.kind === 'triangles') tris += m.primitiveCount;
206
- }
207
- return tris;
208
- });
209
- const t2 = performance.now();
210
- const elements = new Map();
211
- for (const el of r.doc.byUid.values()) elements.set(el.name, (elements.get(el.name) || 0) + 1);
212
- const out = {
213
- syntax: r.doc.syntax,
214
- size: sizes(input),
215
- elements: Object.fromEntries([...elements].sort((a, b) => b[1] - a[1])),
216
- instances: r.scene.count,
217
- uniqueGeometries: r.scene.geometries.length,
218
- materials: r.scene.materials.length,
219
- lights: r.scene.lights.length,
220
- cameras: r.scene.cameras.length,
221
- trianglesPerLod: lods,
222
- bounds: r.scene.bounds,
223
- // single cold run (includes JIT warm-up); see npm run bench for steady-state figures
224
- timingsMs: { parse: r.timings.parse, build: r.timings.build, flatten: r.timings.flatten, total: t1 - t0, geometryAllLods: t2 - t1 },
225
- diagnostics: r.diagnostics.length,
226
- };
227
- if (opts.json) return JSON.stringify(out, null, 2);
228
- const kb = (n) => (n < 1e4 ? `${n} B` : n < 1e7 ? `${(n / 1e3).toFixed(1)} kB` : `${(n / 1e6).toFixed(2)} MB`);
229
- const f = (n) => n.toLocaleString('en-US');
230
- const b = out.bounds;
231
- return [
232
- `syntax ${out.syntax}`,
233
- `size ${kb(out.size.raw)} (gzip ${kb(out.size.gzip)}, brotli ${kb(out.size.brotli)})`,
234
- `elements ${Object.entries(out.elements).map(([k, v]) => `${k} ${v}`).join(', ')}`,
235
- `instances ${f(out.instances)} from ${f(out.uniqueGeometries)} unique geometries, ${out.materials} materials, ${out.lights} lights, ${out.cameras} cameras`,
236
- `triangles ${lods.map((t, i) => `LOD${i} ${f(t)}`).join(' ')}`,
237
- `bounds [${b.min.map((v) => +v.toFixed(3)).join(', ')}] .. [${b.max.map((v) => +v.toFixed(3)).join(', ')}]`,
238
- `timings (cold) parse ${out.timingsMs.parse.toFixed(2)} ms, build ${out.timingsMs.build.toFixed(2)} ms, flatten ${out.timingsMs.flatten.toFixed(2)} ms, meshes (all LODs) ${out.timingsMs.geometryAllLods.toFixed(1)} ms`,
239
- `diagnostics ${out.diagnostics}`,
240
- ].join('\n');
241
- }
242
-
243
- const COMMANDS = ['validate', 'fmt', 'minify', 'compact', 'markup', 'xml', 'json', 'optimize', 'expand', 'obj', 'glb', 'stl', 'step', 'import', 'stats'];
244
-
245
- async function main() {
246
- // output piped into head & co.: stop quietly when the reader goes away
247
- process.stdout.on('error', (e) => {
248
- if (e.code === 'EPIPE') process.exit(0);
249
- throw e;
250
- });
251
- const opts = parseArgs(process.argv.slice(2));
252
- if (opts.help || !opts.cmd) {
253
- console.log(HELP);
254
- process.exit(opts.cmd || opts.help ? 0 : 2);
255
- }
256
- if (!COMMANDS.includes(opts.cmd)) fail(`unknown command '${opts.cmd}' (try --help)`);
257
- if (!opts.input) fail(`${opts.cmd}: missing input file (use - for stdin)`);
258
- const bytes = readInput(opts.input);
259
- const textOpts = (syntax, pretty) => ({ syntax, pretty, precision: Number.isFinite(opts.precision) ? opts.precision : null });
260
- switch (opts.cmd) {
261
- case 'validate': {
262
- const r = compile(es3dInput(bytes));
263
- const errors = r.diagnostics.filter((d) => d.severity === 'error').length;
264
- const warnings = r.diagnostics.length - errors;
265
- if (opts.json) console.log(JSON.stringify({ ok: errors === 0, diagnostics: r.diagnostics.map((d) => d.toJSON()) }, null, 2));
266
- else {
267
- if (r.diagnostics.length) process.stderr.write(r.diagnostics.map((d) => d.format(r.doc.lineIndex)).join('\n\n') + '\n\n');
268
- console.log(`${opts.input}: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}${errors ? '' : `, ${r.scene.count.toLocaleString('en-US')} instances`}`);
269
- }
270
- process.exit(errors ? 1 : 0);
271
- break;
272
- }
273
- case 'fmt': {
274
- const input = es3dInput(bytes);
275
- const doc = parsed(input);
276
- const syntax = typeof input === 'string' && detectSyntax(input) === 'compact' ? 'compact' : 'xml';
277
- write(opts, serialize(doc, textOpts(syntax, !opts.min)));
278
- break;
279
- }
280
- case 'minify':
281
- write(opts, serialize(parsed(es3dInput(bytes)), textOpts('xml', false)));
282
- break;
283
- case 'compact':
284
- write(opts, serialize(parsed(es3dInput(bytes)), textOpts('compact', !opts.min)));
285
- break;
286
- case 'markup':
287
- case 'xml':
288
- write(opts, serialize(parsed(es3dInput(bytes)), textOpts('xml', !opts.min)));
289
- break;
290
- case 'json':
291
- write(opts, serialize(parsed(es3dInput(bytes)), textOpts('json', opts.pretty)));
292
- break;
293
- case 'optimize': {
294
- const out = optimize(parsed(es3dInput(bytes)));
295
- for (const p of out.report) process.stderr.write(`${p.pass}: ${p.detail}\n`);
296
- write(opts, out.text);
297
- break;
298
- }
299
- case 'expand':
300
- write(opts, expandProcedural(parsed(es3dInput(bytes))));
301
- break;
302
- case 'obj': {
303
- const r = compiled(es3dInput(bytes));
304
- const name = opts.out ? path.basename(opts.out).replace(/\.obj$/i, '') : 'scene';
305
- const o = exportOBJ(r.scene, (e, l) => r.cache.mesh(e, l), { lod: opts.lod, name });
306
- write(opts, o.obj);
307
- if (opts.out) {
308
- const mtl = opts.out.replace(/\.obj$/i, '') + '.mtl';
309
- fs.writeFileSync(mtl, o.mtl);
310
- process.stderr.write(`wrote ${mtl}\n${o.vertices.toLocaleString('en-US')} vertices, ${o.triangles.toLocaleString('en-US')} triangles\n`);
311
- }
312
- break;
313
- }
314
- case 'glb': {
315
- const r = compiled(es3dInput(bytes));
316
- write(opts, exportGLB(r.scene, (e, l) => r.cache.mesh(e, l), { lod: opts.lod, instancing: opts.instancing, quantize: opts.quantize > 0 }));
317
- break;
318
- }
319
- case 'stl': {
320
- const r = compiled(es3dInput(bytes));
321
- const name = opts.out ? path.basename(opts.out).replace(/\.stl$/i, '') : 'es3d';
322
- let out;
323
- try {
324
- out = exportSTL(r.scene, (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1)), { lod: opts.lod, binary: !opts.ascii, name });
325
- } catch (e) {
326
- fail(e.message, 1);
327
- }
328
- if (!opts.ascii && !opts.out) fail('binary STL needs -o <file> (or use --ascii)');
329
- write(opts, out.data);
330
- process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
331
- break;
332
- }
333
- case 'step': {
334
- const r = compiled(es3dInput(bytes));
335
- const name = opts.out ? path.basename(opts.out).replace(/\.stp$/i, '').replace(/\.step$/i, '') : 'es3d';
336
- let out;
337
- try {
338
- out = exportSTEP(r.scene, (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1)), { lod: opts.lod, name });
339
- } catch (e) {
340
- fail(e.message, 1);
341
- }
342
- write(opts, out.data);
343
- process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
344
- break;
345
- }
346
- case 'import': {
347
- const name = opts.input === '-' ? 'stdin' : path.basename(opts.input);
348
- const files = [{ name, bytes }];
349
- const kind = detectKind(name, bytes);
350
- if (kind === 'obj') {
351
- const text = new TextDecoder().decode(bytes);
352
- const mtl = mtlFor(opts.input, text);
353
- if (mtl) files.push({ name: ((/^mtllib\s+(.+?)\s*$/m.exec(text) || [])[1] || name.replace(/\.obj$/i, '.mtl')).split(/[\\/]/).pop(), bytes: new TextEncoder().encode(mtl) });
354
- }
355
- if (kind === 'gltf') files.push(...gltfCompanions(opts.input, bytes));
356
- const mesh = { ...opts.mesh };
357
- if (Number.isFinite(opts.precision)) mesh.precision = opts.precision;
358
- let r;
359
- try {
360
- r = await importFiles(files, { svg: opts.svg, mesh, stl: opts.stl });
361
- } catch (e) {
362
- fail(e.message, 1);
363
- }
364
- for (const n of r.notes) process.stderr.write(`${n}\n`);
365
- for (const w of r.warnings) process.stderr.write(`warning: ${w}\n`);
366
- write(opts, r.text);
367
- break;
368
- }
369
- case 'stats':
370
- console.log(stats(es3dInput(bytes), opts));
371
- break;
372
- default:
373
- fail(`unknown command '${opts.cmd}' (try --help)`);
374
- }
375
- }
376
-
377
- main().catch((e) => fail(e.stack || e.message, 1));
1
+ #!/usr/bin/env node
2
+ // es3d: command-line companion of the reference implementation.
3
+ //
4
+ // npm run es3d -- validate scene.es3d
5
+ // npm run es3d -- fmt scene.es3d -o pretty.es3d
6
+ // npm run es3d -- stats scene.es3d
7
+ // npm run es3d -- glb scene.es3d --instancing -o scene.glb
8
+ // npm run es3d -- import drawing.svg -o drawing.es3d
9
+ //
10
+ // Run without arguments for the full command list. Input "-" reads stdin;
11
+ // without -o, text goes to stdout (binary output requires -o).
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import zlib from 'node:zlib';
16
+ import { parse, detectSyntax } from '../src/format/parse.js';
17
+ import { serialize } from '../src/format/serializer.js';
18
+ import { compile } from '../src/pipeline.js';
19
+ import { exportOBJ } from '../src/io/obj.js';
20
+ import { exportGLB } from '../src/io/gltf.js';
21
+ import { exportSTL } from '../src/io/stl.js';
22
+ import { exportSTEP } from '../src/io/step.js';
23
+ import { detectKind, importFiles } from '../src/io/convert.js';
24
+ import { exportableInstances } from '../src/io/external-scene.js';
25
+ import { optimize, expandProcedural } from '../src/optimize/optimizer.js';
26
+
27
+ const HELP = `es3d <command> [options] <input>
28
+
29
+ Commands
30
+ validate parse, validate and build the scene; print every diagnostic (exit 1 on errors)
31
+ fmt pretty-print, keeping the syntax (markup or compact)
32
+ minify minified markup (short attribute aliases, no comments)
33
+ compact convert to the compact syntax (--min for one line)
34
+ markup convert to the markup syntax (--min for minified)
35
+ json convert to the JsonML form (--pretty for indentation)
36
+ optimize run the optimizer and print its report to stderr
37
+ expand replace <repeat>/<scatter> with explicit elements
38
+ obj export Wavefront OBJ (+ .mtl next to -o) (--lod <0-3>)
39
+ glb export glTF binary (--lod <0-3>, --instancing, --quantize 16)
40
+ stl export STL for 3D printing, Z-up (--lod <0-3>, --ascii)
41
+ step export tessellated STEP (AP242), Z-up (--lod <0-3>)
42
+ import convert SVG, OBJ (+MTL), STL, STEP or glTF (.gltf + .bin, .glb) to ES3D:
43
+ SVG shapes are extruded (--size, --depth, --bevel, --flat, --no-strokes, --plate),
44
+ STEP solids become ES3D shapes (boxes, extrusions, revolves, paths; free-form faces a mesh),
45
+ meshes become <mesh> elements (--precision, --smooth, --up y for Y-up STL or STEP)
46
+ stats sizes, element / instance / triangle counts and pipeline timings
47
+
48
+ Options
49
+ -o, --out <file> output file (default: stdout for text)
50
+ --precision <n> round numbers to n decimals when writing text
51
+ --lod <n> level of detail for mesh exports (default 0 = finest)
52
+ --instancing GLB: use EXT_mesh_gpu_instancing
53
+ --quantize GLB: KHR_mesh_quantization (int16 positions, int8 normals)
54
+ --min / --pretty force minified / pretty output
55
+ --json validate/stats: machine readable output
56
+ --ascii STL: write ASCII instead of binary
57
+ --size <n> import SVG: length of the drawing's longer side (default 10)
58
+ --depth <percent> import SVG: extrusion depth in % of the size (default 4)
59
+ --bevel <percent> import SVG: rounded edge radius in % of the size (default 0)
60
+ --flat import SVG: flat shapes instead of extrusions
61
+ --no-strokes import SVG: ignore strokes
62
+ --plate import SVG: add a backing plate
63
+ --smooth <deg> import meshes: crease angle for computed normals (default 30)
64
+ --up <z|y> import STL and STEP: the file's up axis (default z)
65
+ -h, --help this text`;
66
+
67
+ function parseArgs(argv) {
68
+ const o = { cmd: null, input: null, out: null, precision: null, lod: 0, instancing: false, quantize: 0, min: false, pretty: false, json: false, ascii: false, svg: {}, mesh: {}, stl: {} };
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const a = argv[i];
71
+ const val = () => {
72
+ if (i + 1 >= argv.length) fail(`${a} needs a value`);
73
+ return argv[++i];
74
+ };
75
+ if (a === '-h' || a === '--help') o.help = true;
76
+ else if (a === '-o' || a === '--out') o.out = val();
77
+ else if (a === '--precision') o.precision = Number(val());
78
+ else if (a === '--lod') o.lod = Number(val());
79
+ else if (a === '--instancing') o.instancing = true;
80
+ else if (a === '--quantize') o.quantize = true;
81
+ else if (a === '--min') o.min = true;
82
+ else if (a === '--pretty') o.pretty = true;
83
+ else if (a === '--json') o.json = true;
84
+ else if (a === '--ascii') o.ascii = true;
85
+ else if (a === '--size') o.svg.size = Number(val());
86
+ else if (a === '--depth') o.svg.depth = Number(val()) / 100;
87
+ else if (a === '--bevel') o.svg.bevel = Number(val()) / 100;
88
+ else if (a === '--flat') o.svg.depth = 0;
89
+ else if (a === '--no-strokes') o.svg.strokes = false;
90
+ else if (a === '--plate') o.svg.base = true;
91
+ else if (a === '--smooth') o.mesh.smooth = Number(val());
92
+ else if (a === '--up') o.stl.up = val() === 'y' ? 'y' : 'z';
93
+ else if (a.startsWith('-') && a !== '-') fail(`unknown option ${a}`);
94
+ else if (!o.cmd) o.cmd = a;
95
+ else if (!o.input) o.input = a;
96
+ else fail(`unexpected argument ${a}`);
97
+ }
98
+ return o;
99
+ }
100
+
101
+ function fail(msg, code = 2) {
102
+ process.stderr.write(`es3d: ${msg}\n`);
103
+ process.exit(code);
104
+ }
105
+
106
+ function readInput(file) {
107
+ let bytes;
108
+ try {
109
+ bytes = file === '-' ? fs.readFileSync(0) : fs.readFileSync(file);
110
+ } catch (e) {
111
+ fail(`cannot read ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}`);
112
+ }
113
+ if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = zlib.gunzipSync(bytes);
114
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
115
+ }
116
+
117
+ /** ES3D input as text (markup, compact or JSON). */
118
+ function es3dInput(bytes) {
119
+ return new TextDecoder('utf-8').decode(bytes);
120
+ }
121
+
122
+ function write(opts, data) {
123
+ if (opts.out) {
124
+ fs.writeFileSync(opts.out, data);
125
+ const n = typeof data === 'string' ? Buffer.byteLength(data) : data.length;
126
+ process.stderr.write(`wrote ${opts.out} (${n.toLocaleString('en-US')} bytes)\n`);
127
+ } else if (typeof data === 'string') {
128
+ process.stdout.write(data.endsWith('\n') ? data : data + '\n');
129
+ } else {
130
+ fail('binary output needs -o <file>');
131
+ }
132
+ }
133
+
134
+ function report(doc) {
135
+ const out = [];
136
+ for (const d of doc.diagnostics) out.push(d.format(doc.lineIndex));
137
+ return out.join('\n\n');
138
+ }
139
+
140
+ /** Parse and validate, or exit with the diagnostics. */
141
+ function parsed(input) {
142
+ const doc = parse(input);
143
+ if (!doc.ok) {
144
+ process.stderr.write(report(doc) + '\n');
145
+ process.exit(1);
146
+ }
147
+ for (const w of doc.warnings || []) process.stderr.write(w.format(doc.lineIndex) + '\n\n');
148
+ return doc;
149
+ }
150
+
151
+ function compiled(input) {
152
+ const r = compile(input);
153
+ const errors = r.diagnostics.filter((d) => d.severity === 'error');
154
+ if (errors.length) {
155
+ process.stderr.write(r.diagnostics.map((d) => d.format(r.doc.lineIndex)).join('\n\n') + '\n');
156
+ process.exit(1);
157
+ }
158
+ return r;
159
+ }
160
+
161
+ /** Material library of an OBJ file: its mtllib, else <name>.mtl next to it. */
162
+ function mtlFor(objPath, text) {
163
+ if (objPath === '-') return '';
164
+ const dir = path.dirname(objPath);
165
+ const m = /^mtllib\s+(.+)$/m.exec(text);
166
+ const candidates = [m ? path.join(dir, m[1].trim()) : null, objPath.replace(/\.obj$/i, '') + '.mtl'].filter(Boolean);
167
+ for (const c of candidates) if (fs.existsSync(c)) return fs.readFileSync(c, 'utf8');
168
+ return '';
169
+ }
170
+
171
+ /** External buffers of a .gltf file, read from next to it. */
172
+ function gltfCompanions(gltfPath, bytes) {
173
+ if (gltfPath === '-') return [];
174
+ let json;
175
+ try {
176
+ json = JSON.parse(new TextDecoder().decode(bytes));
177
+ } catch {
178
+ return [];
179
+ }
180
+ const out = [];
181
+ for (const b of json.buffers || []) {
182
+ if (!b.uri || b.uri.startsWith('data:')) continue;
183
+ const name = decodeURIComponent(b.uri);
184
+ const file = path.join(path.dirname(gltfPath), name);
185
+ if (fs.existsSync(file)) out.push({ name, bytes: new Uint8Array(fs.readFileSync(file)) });
186
+ }
187
+ return out;
188
+ }
189
+
190
+ const sizes = (data) => {
191
+ const b = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
192
+ return { raw: b.length, gzip: zlib.gzipSync(b, { level: 9 }).length, brotli: zlib.brotliCompressSync(b).length };
193
+ };
194
+
195
+ function stats(input, opts) {
196
+ const t0 = performance.now();
197
+ const r = compiled(input);
198
+ const t1 = performance.now();
199
+ const meshOf = (e, l) => r.cache.mesh(e, l);
200
+ const lods = [0, 1, 2, 3].map((lod) => {
201
+ let tris = 0;
202
+ for (const i of exportableInstances(r.scene)) {
203
+ const e = r.scene.geometries[r.scene.geom[i]];
204
+ const m = meshOf(e, Math.min(lod, e.lods - 1));
205
+ if (m.kind === 'triangles') tris += m.primitiveCount;
206
+ }
207
+ return tris;
208
+ });
209
+ const t2 = performance.now();
210
+ const elements = new Map();
211
+ for (const el of r.doc.byUid.values()) elements.set(el.name, (elements.get(el.name) || 0) + 1);
212
+ const out = {
213
+ syntax: r.doc.syntax,
214
+ size: sizes(input),
215
+ elements: Object.fromEntries([...elements].sort((a, b) => b[1] - a[1])),
216
+ instances: r.scene.count,
217
+ uniqueGeometries: r.scene.geometries.length,
218
+ materials: r.scene.materials.length,
219
+ lights: r.scene.lights.length,
220
+ cameras: r.scene.cameras.length,
221
+ trianglesPerLod: lods,
222
+ bounds: r.scene.bounds,
223
+ // single cold run (includes JIT warm-up); see npm run bench for steady-state figures
224
+ timingsMs: { parse: r.timings.parse, build: r.timings.build, flatten: r.timings.flatten, total: t1 - t0, geometryAllLods: t2 - t1 },
225
+ diagnostics: r.diagnostics.length,
226
+ };
227
+ if (opts.json) return JSON.stringify(out, null, 2);
228
+ const kb = (n) => (n < 1e4 ? `${n} B` : n < 1e7 ? `${(n / 1e3).toFixed(1)} kB` : `${(n / 1e6).toFixed(2)} MB`);
229
+ const f = (n) => n.toLocaleString('en-US');
230
+ const b = out.bounds;
231
+ return [
232
+ `syntax ${out.syntax}`,
233
+ `size ${kb(out.size.raw)} (gzip ${kb(out.size.gzip)}, brotli ${kb(out.size.brotli)})`,
234
+ `elements ${Object.entries(out.elements).map(([k, v]) => `${k} ${v}`).join(', ')}`,
235
+ `instances ${f(out.instances)} from ${f(out.uniqueGeometries)} unique geometries, ${out.materials} materials, ${out.lights} lights, ${out.cameras} cameras`,
236
+ `triangles ${lods.map((t, i) => `LOD${i} ${f(t)}`).join(' ')}`,
237
+ `bounds [${b.min.map((v) => +v.toFixed(3)).join(', ')}] .. [${b.max.map((v) => +v.toFixed(3)).join(', ')}]`,
238
+ `timings (cold) parse ${out.timingsMs.parse.toFixed(2)} ms, build ${out.timingsMs.build.toFixed(2)} ms, flatten ${out.timingsMs.flatten.toFixed(2)} ms, meshes (all LODs) ${out.timingsMs.geometryAllLods.toFixed(1)} ms`,
239
+ `diagnostics ${out.diagnostics}`,
240
+ ].join('\n');
241
+ }
242
+
243
+ const COMMANDS = ['validate', 'fmt', 'minify', 'compact', 'markup', 'xml', 'json', 'optimize', 'expand', 'obj', 'glb', 'stl', 'step', 'import', 'stats'];
244
+
245
+ async function main() {
246
+ // output piped into head & co.: stop quietly when the reader goes away
247
+ process.stdout.on('error', (e) => {
248
+ if (e.code === 'EPIPE') process.exit(0);
249
+ throw e;
250
+ });
251
+ const opts = parseArgs(process.argv.slice(2));
252
+ if (opts.help || !opts.cmd) {
253
+ console.log(HELP);
254
+ process.exit(opts.cmd || opts.help ? 0 : 2);
255
+ }
256
+ if (!COMMANDS.includes(opts.cmd)) fail(`unknown command '${opts.cmd}' (try --help)`);
257
+ if (!opts.input) fail(`${opts.cmd}: missing input file (use - for stdin)`);
258
+ const bytes = readInput(opts.input);
259
+ const textOpts = (syntax, pretty) => ({ syntax, pretty, precision: Number.isFinite(opts.precision) ? opts.precision : null });
260
+ switch (opts.cmd) {
261
+ case 'validate': {
262
+ const r = compile(es3dInput(bytes));
263
+ const errors = r.diagnostics.filter((d) => d.severity === 'error').length;
264
+ const warnings = r.diagnostics.length - errors;
265
+ if (opts.json) console.log(JSON.stringify({ ok: errors === 0, diagnostics: r.diagnostics.map((d) => d.toJSON()) }, null, 2));
266
+ else {
267
+ if (r.diagnostics.length) process.stderr.write(r.diagnostics.map((d) => d.format(r.doc.lineIndex)).join('\n\n') + '\n\n');
268
+ console.log(`${opts.input}: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}${errors ? '' : `, ${r.scene.count.toLocaleString('en-US')} instances`}`);
269
+ }
270
+ process.exit(errors ? 1 : 0);
271
+ break;
272
+ }
273
+ case 'fmt': {
274
+ const input = es3dInput(bytes);
275
+ const doc = parsed(input);
276
+ const syntax = typeof input === 'string' && detectSyntax(input) === 'compact' ? 'compact' : 'xml';
277
+ write(opts, serialize(doc, textOpts(syntax, !opts.min)));
278
+ break;
279
+ }
280
+ case 'minify':
281
+ write(opts, serialize(parsed(es3dInput(bytes)), textOpts('xml', false)));
282
+ break;
283
+ case 'compact':
284
+ write(opts, serialize(parsed(es3dInput(bytes)), textOpts('compact', !opts.min)));
285
+ break;
286
+ case 'markup':
287
+ case 'xml':
288
+ write(opts, serialize(parsed(es3dInput(bytes)), textOpts('xml', !opts.min)));
289
+ break;
290
+ case 'json':
291
+ write(opts, serialize(parsed(es3dInput(bytes)), textOpts('json', opts.pretty)));
292
+ break;
293
+ case 'optimize': {
294
+ const out = optimize(parsed(es3dInput(bytes)));
295
+ for (const p of out.report) process.stderr.write(`${p.pass}: ${p.detail}\n`);
296
+ write(opts, out.text);
297
+ break;
298
+ }
299
+ case 'expand':
300
+ write(opts, expandProcedural(parsed(es3dInput(bytes))));
301
+ break;
302
+ case 'obj': {
303
+ const r = compiled(es3dInput(bytes));
304
+ const name = opts.out ? path.basename(opts.out).replace(/\.obj$/i, '') : 'scene';
305
+ const o = exportOBJ(r.scene, (e, l) => r.cache.mesh(e, l), { lod: opts.lod, name });
306
+ write(opts, o.obj);
307
+ if (opts.out) {
308
+ const mtl = opts.out.replace(/\.obj$/i, '') + '.mtl';
309
+ fs.writeFileSync(mtl, o.mtl);
310
+ process.stderr.write(`wrote ${mtl}\n${o.vertices.toLocaleString('en-US')} vertices, ${o.triangles.toLocaleString('en-US')} triangles\n`);
311
+ }
312
+ break;
313
+ }
314
+ case 'glb': {
315
+ const r = compiled(es3dInput(bytes));
316
+ write(opts, exportGLB(r.scene, (e, l) => r.cache.mesh(e, l), { lod: opts.lod, instancing: opts.instancing, quantize: opts.quantize > 0 }));
317
+ break;
318
+ }
319
+ case 'stl': {
320
+ const r = compiled(es3dInput(bytes));
321
+ const name = opts.out ? path.basename(opts.out).replace(/\.stl$/i, '') : 'es3d';
322
+ let out;
323
+ try {
324
+ out = exportSTL(r.scene, (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1)), { lod: opts.lod, binary: !opts.ascii, name });
325
+ } catch (e) {
326
+ fail(e.message, 1);
327
+ }
328
+ if (!opts.ascii && !opts.out) fail('binary STL needs -o <file> (or use --ascii)');
329
+ write(opts, out.data);
330
+ process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
331
+ break;
332
+ }
333
+ case 'step': {
334
+ const r = compiled(es3dInput(bytes));
335
+ const name = opts.out ? path.basename(opts.out).replace(/\.stp$/i, '').replace(/\.step$/i, '') : 'es3d';
336
+ let out;
337
+ try {
338
+ out = exportSTEP(r.scene, (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1)), { lod: opts.lod, name });
339
+ } catch (e) {
340
+ fail(e.message, 1);
341
+ }
342
+ write(opts, out.data);
343
+ process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
344
+ break;
345
+ }
346
+ case 'import': {
347
+ const name = opts.input === '-' ? 'stdin' : path.basename(opts.input);
348
+ const files = [{ name, bytes }];
349
+ const kind = detectKind(name, bytes);
350
+ if (kind === 'obj') {
351
+ const text = new TextDecoder().decode(bytes);
352
+ const mtl = mtlFor(opts.input, text);
353
+ if (mtl) files.push({ name: ((/^mtllib\s+(.+?)\s*$/m.exec(text) || [])[1] || name.replace(/\.obj$/i, '.mtl')).split(/[\\/]/).pop(), bytes: new TextEncoder().encode(mtl) });
354
+ }
355
+ if (kind === 'gltf') files.push(...gltfCompanions(opts.input, bytes));
356
+ const mesh = { ...opts.mesh };
357
+ if (Number.isFinite(opts.precision)) mesh.precision = opts.precision;
358
+ let r;
359
+ try {
360
+ r = await importFiles(files, { svg: opts.svg, mesh, stl: opts.stl });
361
+ } catch (e) {
362
+ fail(e.message, 1);
363
+ }
364
+ for (const n of r.notes) process.stderr.write(`${n}\n`);
365
+ for (const w of r.warnings) process.stderr.write(`warning: ${w}\n`);
366
+ write(opts, r.text);
367
+ break;
368
+ }
369
+ case 'stats':
370
+ console.log(stats(es3dInput(bytes), opts));
371
+ break;
372
+ default:
373
+ fail(`unknown command '${opts.cmd}' (try --help)`);
374
+ }
375
+ }
376
+
377
+ main().catch((e) => fail(e.stack || e.message, 1));