@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
@@ -1,377 +1,390 @@
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 -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, formatDiagnostics } 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 { exportSTL } from '../src/io/stl.js';
21
+ import { exportSTEP } from '../src/io/step.js';
22
+ import { detectKind, importFiles } from '../src/io/convert.js';
23
+ import { exportableInstances } from '../src/io/external-scene.js';
24
+ import { optimize, expandProcedural } from '../src/optimize/optimizer.js';
25
+
26
+ const HELP = `es3d <command> [options] <input>
27
+
28
+ Commands
29
+ validate parse, validate and build the scene; print diagnostics (exit 1 on errors).
30
+ Repeated warnings are grouped; --json lists every one
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 packed glTF binary (instances, LODs, lights, cameras, animation)
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 obj, stl and step (default 0 = finest)
52
+ --instancing glb always writes EXT_mesh_gpu_instancing
53
+ --quantize glb always writes KHR_mesh_quantization
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
+ return formatDiagnostics(doc);
136
+ }
137
+
138
+ /** Parse and validate, or exit with the diagnostics. */
139
+ function parsed(input) {
140
+ const doc = parse(input);
141
+ if (!doc.ok) {
142
+ process.stderr.write(report(doc) + '\n');
143
+ process.exit(1);
144
+ }
145
+ const warnings = formatDiagnostics(doc.warnings || [], doc.lineIndex);
146
+ if (warnings) process.stderr.write(warnings + '\n\n');
147
+ return doc;
148
+ }
149
+
150
+ function compiled(input) {
151
+ const r = compile(input);
152
+ const errors = r.diagnostics.filter((d) => d.severity === 'error');
153
+ if (errors.length) {
154
+ process.stderr.write(formatDiagnostics(r) + '\n');
155
+ process.exit(1);
156
+ }
157
+ return r;
158
+ }
159
+
160
+ /** Material library of an OBJ file: its mtllib, else <name>.mtl next to it. */
161
+ function mtlFor(objPath, text) {
162
+ if (objPath === '-') return '';
163
+ const dir = path.dirname(objPath);
164
+ const m = /^mtllib\s+(.+)$/m.exec(text);
165
+ const candidates = [m ? path.join(dir, m[1].trim()) : null, objPath.replace(/\.obj$/i, '') + '.mtl'].filter(Boolean);
166
+ for (const c of candidates) if (fs.existsSync(c)) return fs.readFileSync(c, 'utf8');
167
+ return '';
168
+ }
169
+
170
+ /** External buffers of a .gltf file, read from next to it. */
171
+ function gltfCompanions(gltfPath, bytes) {
172
+ if (gltfPath === '-') return [];
173
+ let json;
174
+ try {
175
+ json = JSON.parse(new TextDecoder().decode(bytes));
176
+ } catch {
177
+ return [];
178
+ }
179
+ const out = [];
180
+ for (const b of json.buffers || []) {
181
+ if (!b.uri || b.uri.startsWith('data:')) continue;
182
+ const name = decodeURIComponent(b.uri);
183
+ const file = path.join(path.dirname(gltfPath), name);
184
+ if (fs.existsSync(file)) out.push({ name, bytes: new Uint8Array(fs.readFileSync(file)) });
185
+ }
186
+ return out;
187
+ }
188
+
189
+ const sizes = (data) => {
190
+ const b = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data.buffer, data.byteOffset, data.byteLength);
191
+ return { raw: b.length, gzip: zlib.gzipSync(b, { level: 9 }).length, brotli: zlib.brotliCompressSync(b).length };
192
+ };
193
+
194
+ function stats(input, opts) {
195
+ const t0 = performance.now();
196
+ const r = compiled(input);
197
+ const t1 = performance.now();
198
+ const meshOf = (e, l) => r.cache.mesh(e, l);
199
+ const lods = [0, 1, 2, 3].map((lod) => {
200
+ let tris = 0;
201
+ for (const i of exportableInstances(r.scene)) {
202
+ const e = r.scene.geometries[r.scene.geom[i]];
203
+ const m = meshOf(e, Math.min(lod, e.lods - 1));
204
+ if (m.kind === 'triangles') tris += m.primitiveCount;
205
+ }
206
+ return tris;
207
+ });
208
+ const t2 = performance.now();
209
+ const elements = new Map();
210
+ for (const el of r.doc.byUid.values()) elements.set(el.name, (elements.get(el.name) || 0) + 1);
211
+ const out = {
212
+ syntax: r.doc.syntax,
213
+ size: sizes(input),
214
+ elements: Object.fromEntries([...elements].sort((a, b) => b[1] - a[1])),
215
+ instances: r.scene.count,
216
+ uniqueGeometries: r.scene.geometries.length,
217
+ materials: r.scene.materials.length,
218
+ lights: r.scene.lights.length,
219
+ cameras: r.scene.cameras.length,
220
+ trianglesPerLod: lods,
221
+ bounds: r.scene.bounds,
222
+ // single cold run (includes JIT warm-up); see npm run bench for steady-state figures
223
+ timingsMs: { parse: r.timings.parse, build: r.timings.build, flatten: r.timings.flatten, total: t1 - t0, geometryAllLods: t2 - t1 },
224
+ diagnostics: r.diagnostics.length,
225
+ };
226
+ if (opts.json) return JSON.stringify(out, null, 2);
227
+ const kb = (n) => (n < 1e4 ? `${n} B` : n < 1e7 ? `${(n / 1e3).toFixed(1)} kB` : `${(n / 1e6).toFixed(2)} MB`);
228
+ const f = (n) => n.toLocaleString('en-US');
229
+ const b = out.bounds;
230
+ return [
231
+ `syntax ${out.syntax}`,
232
+ `size ${kb(out.size.raw)} (gzip ${kb(out.size.gzip)}, brotli ${kb(out.size.brotli)})`,
233
+ `elements ${Object.entries(out.elements).map(([k, v]) => `${k} ${v}`).join(', ')}`,
234
+ `instances ${f(out.instances)} from ${f(out.uniqueGeometries)} unique geometries, ${out.materials} materials, ${out.lights} lights, ${out.cameras} cameras`,
235
+ `triangles ${lods.map((t, i) => `LOD${i} ${f(t)}`).join(' ')}`,
236
+ `bounds [${b.min.map((v) => +v.toFixed(3)).join(', ')}] .. [${b.max.map((v) => +v.toFixed(3)).join(', ')}]`,
237
+ `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`,
238
+ `diagnostics ${out.diagnostics}`,
239
+ ].join('\n');
240
+ }
241
+
242
+ const COMMANDS = ['validate', 'fmt', 'minify', 'compact', 'markup', 'xml', 'json', 'optimize', 'expand', 'obj', 'glb', 'stl', 'step', 'import', 'stats'];
243
+
244
+ async function main() {
245
+ // output piped into head & co.: stop quietly when the reader goes away
246
+ process.stdout.on('error', (e) => {
247
+ if (e.code === 'EPIPE') process.exit(0);
248
+ throw e;
249
+ });
250
+ const opts = parseArgs(process.argv.slice(2));
251
+ if (opts.help || !opts.cmd) {
252
+ console.log(HELP);
253
+ process.exit(opts.cmd || opts.help ? 0 : 2);
254
+ }
255
+ if (!COMMANDS.includes(opts.cmd)) fail(`unknown command '${opts.cmd}' (try --help)`);
256
+ if (!opts.input) fail(`${opts.cmd}: missing input file (use - for stdin)`);
257
+ const bytes = readInput(opts.input);
258
+ const textOpts = (syntax, pretty) => ({ syntax, pretty, precision: Number.isFinite(opts.precision) ? opts.precision : null });
259
+ switch (opts.cmd) {
260
+ case 'validate': {
261
+ const r = compile(es3dInput(bytes));
262
+ const errors = r.diagnostics.filter((d) => d.severity === 'error').length;
263
+ const warnings = r.diagnostics.length - errors;
264
+ if (opts.json) console.log(JSON.stringify({ ok: errors === 0, diagnostics: r.diagnostics.map((d) => d.toJSON()) }, null, 2));
265
+ else {
266
+ const text = formatDiagnostics(r);
267
+ if (text) process.stderr.write(text + '\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
+ let exportPackedGLB;
316
+ try {
317
+ ({ exportPackedGLB } = await import('../src/io/gltf-pack.js'));
318
+ } catch (e) {
319
+ fail(`glb could not load the packer (${e.message})`, 1);
320
+ }
321
+ const baseDir = opts.input === '-' ? '' : path.dirname(path.resolve(opts.input));
322
+ const sourceName = opts.input === '-' ? 'stdin' : `${path.basename(path.dirname(opts.input))}/${path.basename(opts.input)}`;
323
+ let file;
324
+ try {
325
+ file = await exportPackedGLB(es3dInput(bytes), { baseDir, sourceName });
326
+ } catch (e) {
327
+ fail(e.message, 1);
328
+ }
329
+ write(opts, file);
330
+ break;
331
+ }
332
+ case 'stl': {
333
+ const r = compiled(es3dInput(bytes));
334
+ const name = opts.out ? path.basename(opts.out).replace(/\.stl$/i, '') : 'es3d';
335
+ let out;
336
+ try {
337
+ out = exportSTL(r.scene, (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1)), { lod: opts.lod, binary: !opts.ascii, name });
338
+ } catch (e) {
339
+ fail(e.message, 1);
340
+ }
341
+ if (!opts.ascii && !opts.out) fail('binary STL needs -o <file> (or use --ascii)');
342
+ write(opts, out.data);
343
+ process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
344
+ break;
345
+ }
346
+ case 'step': {
347
+ const r = compiled(es3dInput(bytes));
348
+ const name = opts.out ? path.basename(opts.out).replace(/\.stp$/i, '').replace(/\.step$/i, '') : 'es3d';
349
+ let out;
350
+ try {
351
+ out = exportSTEP(r.scene, (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1)), { lod: opts.lod, name });
352
+ } catch (e) {
353
+ fail(e.message, 1);
354
+ }
355
+ write(opts, out.data);
356
+ process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
357
+ break;
358
+ }
359
+ case 'import': {
360
+ const name = opts.input === '-' ? 'stdin' : path.basename(opts.input);
361
+ const files = [{ name, bytes }];
362
+ const kind = detectKind(name, bytes);
363
+ if (kind === 'obj') {
364
+ const text = new TextDecoder().decode(bytes);
365
+ const mtl = mtlFor(opts.input, text);
366
+ if (mtl) files.push({ name: ((/^mtllib\s+(.+?)\s*$/m.exec(text) || [])[1] || name.replace(/\.obj$/i, '.mtl')).split(/[\\/]/).pop(), bytes: new TextEncoder().encode(mtl) });
367
+ }
368
+ if (kind === 'gltf') files.push(...gltfCompanions(opts.input, bytes));
369
+ const mesh = { ...opts.mesh };
370
+ if (Number.isFinite(opts.precision)) mesh.precision = opts.precision;
371
+ let r;
372
+ try {
373
+ r = await importFiles(files, { svg: opts.svg, mesh, stl: opts.stl });
374
+ } catch (e) {
375
+ fail(e.message, 1);
376
+ }
377
+ for (const n of r.notes) process.stderr.write(`${n}\n`);
378
+ for (const w of r.warnings) process.stderr.write(`warning: ${w}\n`);
379
+ write(opts, r.text);
380
+ break;
381
+ }
382
+ case 'stats':
383
+ console.log(stats(es3dInput(bytes), opts));
384
+ break;
385
+ default:
386
+ fail(`unknown command '${opts.cmd}' (try --help)`);
387
+ }
388
+ }
389
+
390
+ main().catch((e) => fail(e.stack || e.message, 1));