@es3d/es3d 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SPEC.md CHANGED
@@ -403,11 +403,20 @@ The title names the phase (`Parse`, `Reference`, `Geometry`, `Render`, `File`) a
403
403
 
404
404
  Unknown names get a suggestion when a known name is within a small edit distance or shares a prefix (`raduis` → `radius`, `spotlight` → `spot`, `sphear` → `sphere`).
405
405
 
406
+ The reference reader's human-readable report (`formatDiagnostics`, and the text from `validate`) shows a repeated warning once. Warnings group when they share a code, the element name written in the message (its first `<name>`), and the same message. The report keeps the first location and then says how many times that warning occurred:
407
+
408
+ ```
409
+ Reported 906 times. Showing the first.
410
+ ```
411
+
412
+ Every warning remains its own entry in the diagnostic list. `validate --json` prints that list. Errors are listed one by one.
413
+
406
414
  ## 16. Extensibility and versioning
407
415
 
408
416
  - Elements whose names start with `x-` (and their subtrees) are vendor extensions: readers that do not understand them skip them with `W_EXTENSION`. Unknown elements without the prefix are errors, so typos are never mistaken for extensions.
409
417
  - Unknown attributes are always errors. (A future version may reserve an `x-` attribute prefix as well.)
410
418
  - `version="1.x"` documents must remain readable by version-1 readers; incompatible changes require version 2, which version-1 readers reject with `E_VERSION`.
419
+ - A processor that rewrites a document keeps `x-` elements, and keeps any element whose id is named by a token in an `x-` attribute. The reference optimizer treats a whitespace-separated token (an optional leading `#` is ignored) as a reference when that id exists, and updates the token when it merges a duplicate material.
411
420
 
412
421
  ## 17. File names and media types
413
422
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@es3d/es3d",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "ES3D (Extensible Scalable 3D): a declarative, SVG-inspired 3D scene format with a WebGPU/WebGL2 reference renderer, editor and converter.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "main": "src/index.js",
30
30
  "exports": {
31
- ".": "./src/index.js"
31
+ ".": "./src/index.js",
32
+ "./stage": "./browser/es3d-stage.min.js"
32
33
  },
33
34
  "bin": {
34
35
  "es3d": "scripts/es3d-cli.mjs"
@@ -40,6 +41,7 @@
40
41
  "!src/bench/",
41
42
  "!src/main.js",
42
43
  "scripts/es3d-cli.mjs",
44
+ "browser/es3d-stage.min.js",
43
45
  "docs/SPEC.md",
44
46
  "docs/REFERENCE.md"
45
47
  ],
@@ -4,7 +4,7 @@
4
4
  // npm run es3d -- validate scene.es3d
5
5
  // npm run es3d -- fmt scene.es3d -o pretty.es3d
6
6
  // npm run es3d -- stats scene.es3d
7
- // npm run es3d -- glb scene.es3d --instancing -o scene.glb
7
+ // npm run es3d -- glb scene.es3d -o scene.glb
8
8
  // npm run es3d -- import drawing.svg -o drawing.es3d
9
9
  //
10
10
  // Run without arguments for the full command list. Input "-" reads stdin;
@@ -13,21 +13,21 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import zlib from 'node:zlib';
16
- import { parse, detectSyntax } from '../src/format/parse.js';
16
+ import { parse, detectSyntax, formatDiagnostics } from '../src/format/parse.js';
17
17
  import { serialize } from '../src/format/serializer.js';
18
18
  import { compile } from '../src/pipeline.js';
19
19
  import { exportOBJ } from '../src/io/obj.js';
20
- import { exportGLB } from '../src/io/gltf.js';
21
20
  import { exportSTL } from '../src/io/stl.js';
22
21
  import { exportSTEP } from '../src/io/step.js';
23
- import { detectKind, importFiles } from '../src/io/convert.js';
22
+ import { detectKind, importFiles, exportFormat } from '../src/io/convert.js';
24
23
  import { exportableInstances } from '../src/io/external-scene.js';
25
24
  import { optimize, expandProcedural } from '../src/optimize/optimizer.js';
26
25
 
27
26
  const HELP = `es3d <command> [options] <input>
28
27
 
29
28
  Commands
30
- validate parse, validate and build the scene; print every diagnostic (exit 1 on errors)
29
+ validate parse, validate and build the scene; print diagnostics (exit 1 on errors).
30
+ Repeated warnings are grouped; --json lists every one
31
31
  fmt pretty-print, keeping the syntax (markup or compact)
32
32
  minify minified markup (short attribute aliases, no comments)
33
33
  compact convert to the compact syntax (--min for one line)
@@ -36,21 +36,27 @@ Commands
36
36
  optimize run the optimizer and print its report to stderr
37
37
  expand replace <repeat>/<scatter> with explicit elements
38
38
  obj export Wavefront OBJ (+ .mtl next to -o) (--lod <0-3>)
39
- glb export glTF binary (--lod <0-3>, --instancing, --quantize 16)
39
+ glb packed glTF binary (instances, LODs, lights, cameras, animation)
40
40
  stl export STL for 3D printing, Z-up (--lod <0-3>, --ascii)
41
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:
42
+ x3d export X3D Classic (.x3dv) (--lod <0-3>, --flatten)
43
+ usd export USD ASCII (.usda) (--lod <0-3>, --flatten)
44
+ scad export an OpenSCAD program (--lod <0-3>, --flatten)
45
+ js export a three.js module (--lod <0-3>, --flatten)
46
+ import convert SVG, OBJ (+MTL), STL, STEP, glTF, X3D, USDA or OpenSCAD to ES3D:
43
47
  SVG shapes are extruded (--size, --depth, --bevel, --flat, --no-strokes, --plate),
44
48
  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)
49
+ meshes become <mesh> elements (--precision, --smooth, --up y for Y-up STL or STEP),
50
+ OpenSCAD programs become shapes (difference and intersection are kept and noted, not evaluated)
46
51
  stats sizes, element / instance / triangle counts and pipeline timings
47
52
 
48
53
  Options
49
54
  -o, --out <file> output file (default: stdout for text)
50
55
  --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)
56
+ --lod <n> level of detail for mesh export (default 0 = finest)
57
+ --flatten x3d, usd, scad, js: write triangles instead of primitives
58
+ --instancing glb always writes EXT_mesh_gpu_instancing
59
+ --quantize glb always writes KHR_mesh_quantization
54
60
  --min / --pretty force minified / pretty output
55
61
  --json validate/stats: machine readable output
56
62
  --ascii STL: write ASCII instead of binary
@@ -65,7 +71,7 @@ Options
65
71
  -h, --help this text`;
66
72
 
67
73
  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: {} };
74
+ const o = { cmd: null, input: null, out: null, precision: null, lod: 0, instancing: false, quantize: 0, min: false, pretty: false, json: false, ascii: false, flatten: false, svg: {}, mesh: {}, stl: {} };
69
75
  for (let i = 0; i < argv.length; i++) {
70
76
  const a = argv[i];
71
77
  const val = () => {
@@ -82,6 +88,7 @@ function parseArgs(argv) {
82
88
  else if (a === '--pretty') o.pretty = true;
83
89
  else if (a === '--json') o.json = true;
84
90
  else if (a === '--ascii') o.ascii = true;
91
+ else if (a === '--flatten') o.flatten = true;
85
92
  else if (a === '--size') o.svg.size = Number(val());
86
93
  else if (a === '--depth') o.svg.depth = Number(val()) / 100;
87
94
  else if (a === '--bevel') o.svg.bevel = Number(val()) / 100;
@@ -132,9 +139,7 @@ function write(opts, data) {
132
139
  }
133
140
 
134
141
  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');
142
+ return formatDiagnostics(doc);
138
143
  }
139
144
 
140
145
  /** Parse and validate, or exit with the diagnostics. */
@@ -144,7 +149,8 @@ function parsed(input) {
144
149
  process.stderr.write(report(doc) + '\n');
145
150
  process.exit(1);
146
151
  }
147
- for (const w of doc.warnings || []) process.stderr.write(w.format(doc.lineIndex) + '\n\n');
152
+ const warnings = formatDiagnostics(doc.warnings || [], doc.lineIndex);
153
+ if (warnings) process.stderr.write(warnings + '\n\n');
148
154
  return doc;
149
155
  }
150
156
 
@@ -152,7 +158,7 @@ function compiled(input) {
152
158
  const r = compile(input);
153
159
  const errors = r.diagnostics.filter((d) => d.severity === 'error');
154
160
  if (errors.length) {
155
- process.stderr.write(r.diagnostics.map((d) => d.format(r.doc.lineIndex)).join('\n\n') + '\n');
161
+ process.stderr.write(formatDiagnostics(r) + '\n');
156
162
  process.exit(1);
157
163
  }
158
164
  return r;
@@ -264,7 +270,8 @@ async function main() {
264
270
  const warnings = r.diagnostics.length - errors;
265
271
  if (opts.json) console.log(JSON.stringify({ ok: errors === 0, diagnostics: r.diagnostics.map((d) => d.toJSON()) }, null, 2));
266
272
  else {
267
- if (r.diagnostics.length) process.stderr.write(r.diagnostics.map((d) => d.format(r.doc.lineIndex)).join('\n\n') + '\n\n');
273
+ const text = formatDiagnostics(r);
274
+ if (text) process.stderr.write(text + '\n\n');
268
275
  console.log(`${opts.input}: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}${errors ? '' : `, ${r.scene.count.toLocaleString('en-US')} instances`}`);
269
276
  }
270
277
  process.exit(errors ? 1 : 0);
@@ -312,8 +319,21 @@ async function main() {
312
319
  break;
313
320
  }
314
321
  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 }));
322
+ let exportPackedGLB;
323
+ try {
324
+ ({ exportPackedGLB } = await import('../src/io/gltf-pack.js'));
325
+ } catch (e) {
326
+ fail(`glb could not load the packer (${e.message})`, 1);
327
+ }
328
+ const baseDir = opts.input === '-' ? '' : path.dirname(path.resolve(opts.input));
329
+ const sourceName = opts.input === '-' ? 'stdin' : `${path.basename(path.dirname(opts.input))}/${path.basename(opts.input)}`;
330
+ let file;
331
+ try {
332
+ file = await exportPackedGLB(es3dInput(bytes), { baseDir, sourceName });
333
+ } catch (e) {
334
+ fail(e.message, 1);
335
+ }
336
+ write(opts, file);
317
337
  break;
318
338
  }
319
339
  case 'stl': {
@@ -343,6 +363,22 @@ async function main() {
343
363
  process.stderr.write(`${out.triangles.toLocaleString('en-US')} triangles\n`);
344
364
  break;
345
365
  }
366
+ case 'x3d':
367
+ case 'usd':
368
+ case 'scad':
369
+ case 'js': {
370
+ const format = { x3d: 'x3dv', usd: 'usda', scad: 'scad', js: 'js' }[opts.cmd];
371
+ const base = opts.out ? path.basename(opts.out).replace(/\.[^.]+$/, '') : 'scene';
372
+ let out;
373
+ try {
374
+ out = exportFormat(es3dInput(bytes), format, { baseName: base, lod: opts.lod, structure: opts.flatten ? 'flatten' : 'keep' });
375
+ } catch (e) {
376
+ fail(e.message, 1);
377
+ }
378
+ write(opts, out.files[0].data);
379
+ for (const note of out.notes || []) process.stderr.write(`${note}\n`);
380
+ break;
381
+ }
346
382
  case 'import': {
347
383
  const name = opts.input === '-' ? 'stdin' : path.basename(opts.input);
348
384
  const files = [{ name, bytes }];
@@ -86,7 +86,47 @@ export function parseStrict(input, opts = {}) {
86
86
  return doc;
87
87
  }
88
88
 
89
- /** Human readable report of all diagnostics. */
90
- export function formatDiagnostics(doc) {
91
- return doc.diagnostics.map((d) => d.format(doc.lineIndex)).join('\n\n');
89
+ /** Element name written in a message, if it has one (`<x-anchors>`). */
90
+ function mentionedElement(message) {
91
+ const m = /<([^<>\s]+)>/.exec(message || '');
92
+ return m ? m[1] : '';
93
+ }
94
+
95
+ /**
96
+ * Human-readable report of diagnostics.
97
+ * Repeated warnings that share a code, the element name in the message, and
98
+ * the same message are shown once, at the first location, with a count.
99
+ * Errors are listed one by one. The diagnostic objects are not changed.
100
+ * @param {object|Array} source a document, a compile result, or a diagnostic list
101
+ * @param {import('./errors.js').LineIndex} [lineIndex]
102
+ */
103
+ export function formatDiagnostics(source, lineIndex) {
104
+ if (!source) return '';
105
+ const list = Array.isArray(source) ? source : source.diagnostics || [];
106
+ const index = lineIndex || source.lineIndex || (source.doc && source.doc.lineIndex) || null;
107
+ const seen = new Map();
108
+ const items = [];
109
+ for (const d of list) {
110
+ if (!d || d.severity !== 'warning') {
111
+ items.push(d);
112
+ continue;
113
+ }
114
+ const key = `${d.code}\0${mentionedElement(d.message)}\0${d.message}`;
115
+ const prev = seen.get(key);
116
+ if (prev) {
117
+ prev.count++;
118
+ continue;
119
+ }
120
+ const slot = { d, count: 1 };
121
+ seen.set(key, slot);
122
+ items.push(slot);
123
+ }
124
+ return items
125
+ .map((item) => {
126
+ if (!item || !item.d) return item.format(index);
127
+ const text = item.d.format(index);
128
+ if (item.count < 2) return text;
129
+ return `${text}\n\nReported ${item.count} times. Showing the first.`;
130
+ })
131
+ .join('\n\n');
92
132
  }
package/src/index.js CHANGED
@@ -1,16 +1,16 @@
1
1
  // Public API of the ES3D reference implementation.
2
2
  //
3
- // import { compile, createRenderer, Camera } from '@es3d/es3d';
3
+ // import { compile, createRenderer, Camera, formatDiagnostics } from '@es3d/es3d';
4
4
  //
5
5
  // const result = compile(text); // parse + validate + build + flatten
6
- // if (!result.ok) console.error(result.diagnostics.map((d) => d.format()).join('\n\n'));
6
+ // if (!result.ok) console.error(formatDiagnostics(result));
7
7
  // const renderer = await createRenderer(canvas); // WebGPU, else WebGL2
8
8
  // renderer.setScene(result.scene, (entry, lod) => result.cache.mesh(entry, lod));
9
9
  // const camera = new Camera().frame(result.scene.bounds);
10
10
  // renderer.render(camera, { width: canvas.clientWidth, height: canvas.clientHeight });
11
11
  //
12
- // const svg = await importFiles([{ name: 'logo.svg', bytes }]); // SVG, OBJ, STL, STEP, glTF → ES3D
13
- // const { files } = exportFormat(svg.text, 'glb'); // ES3D → glTF, OBJ, STL, STEP
12
+ // const svg = await importFiles([{ name: 'logo.svg', bytes }]); // SVG, OBJ, STL, STEP, glTF, X3D, USDA, OpenSCAD → ES3D
13
+ // const { files } = exportFormat(svg.text, 'glb'); // ES3D → glTF, OBJ, STL, STEP, X3D, USD, OpenSCAD, three.js
14
14
  //
15
15
  // The format, scene, geometry and I/O modules run in Node as well; only the
16
16
  // renderer and controls need a browser.
@@ -25,7 +25,9 @@ export { ELEMENTS, elementSpec, attrSpec } from './format/schema.js';
25
25
  // scene pipeline
26
26
  export { compile, generateGeometry, GeometryCache } from './pipeline.js';
27
27
  export { openGeometryStore, indexedDBStore, memoryStore, restoreGeometry, saveGeometry, persistGeometry, geometryKey, GEOMETRY_REVISION } from './cache/geometry-store.js';
28
- export { RenderScene, updateDynamic, FLAG_MIRRORED, FLAG_NO_COLLIDE } from './scene/flatten.js';
28
+ export { RenderScene, updateDynamic, applyLocalMatrix, applyLocalMatrices, FLAG_MIRRORED, FLAG_NO_COLLIDE } from './scene/flatten.js';
29
+ export { mulberry32 } from './math/random.js';
30
+ export { es3dSin, es3dCos } from './math/trig.js';
29
31
  export { pick, raycast } from './scene/picking.js';
30
32
  export { applyLimits, sweepCamera, constrainCamera } from './scene/camera-constraints.js';
31
33
 
@@ -43,5 +45,10 @@ export { exportOBJ, parseOBJ, objToScene } from './io/obj.js';
43
45
  export { exportGLB, parseGLB, glbToScene, isGLB, parseGLTF, gltfToScene } from './io/gltf.js';
44
46
  export { exportSTL, parseSTL, stlToScene } from './io/stl.js';
45
47
  export { exportSTEP, parseSTEP, stepToScene, isSTEP } from './io/step.js';
48
+ export { exportX3D, importX3D } from './io/x3d.js';
49
+ export { exportUSD, importUSD } from './io/usd.js';
50
+ export { exportOpenSCAD } from './io/openscad.js';
51
+ export { importOpenSCAD } from './io/openscad-import.js';
52
+ export { exportThree } from './io/threejs.js';
46
53
  export { sceneToMeshDocument } from './io/mesh-import.js';
47
54
  export { optimize, expandProcedural } from './optimize/optimizer.js';
package/src/io/convert.js CHANGED
@@ -16,6 +16,12 @@ import { svgToES3D } from './svg-import.js';
16
16
  import { sceneToMeshDocument } from './mesh-import.js';
17
17
  import { compile } from '../pipeline.js';
18
18
  import { exportableInstances } from './external-scene.js';
19
+ import { buildExportModel } from './export-model.js';
20
+ import { exportX3D, importX3D } from './x3d.js';
21
+ import { exportUSD, importUSD } from './usd.js';
22
+ import { exportOpenSCAD } from './openscad.js';
23
+ import { importOpenSCAD } from './openscad-import.js';
24
+ import { exportThree } from './threejs.js';
19
25
 
20
26
  /** OBJ and STL repeat every copy's triangles; beyond this the file gets too large for a browser tab. */
21
27
  const OBJ_TRIANGLE_LIMIT = 4e6;
@@ -28,6 +34,12 @@ export const INPUT_KINDS = {
28
34
  gltf: 'glTF (JSON)',
29
35
  glb: 'glTF binary (GLB)',
30
36
  step: 'STEP',
37
+ x3dv: 'X3D Classic',
38
+ x3d: 'X3D XML',
39
+ usda: 'USD ASCII',
40
+ usdc: 'USD crate',
41
+ usdz: 'USDZ',
42
+ scad: 'OpenSCAD',
31
43
  };
32
44
 
33
45
  export const OUTPUT_FORMATS = {
@@ -38,6 +50,10 @@ export const OUTPUT_FORMATS = {
38
50
  obj: { label: 'Wavefront OBJ + MTL (.obj)', ext: '.obj', mime: 'model/obj', text: true },
39
51
  stl: { label: 'STL for 3D printing (.stl)', ext: '.stl', mime: 'model/stl' },
40
52
  step: { label: 'STEP, tessellated (.step)', ext: '.step', mime: 'model/step', text: true },
53
+ x3dv: { label: 'X3D Classic (.x3dv)', ext: '.x3dv', mime: 'model/x3d+vrml', text: true },
54
+ usda: { label: 'USD ASCII (.usda)', ext: '.usda', mime: 'model/vnd.usda', text: true },
55
+ scad: { label: 'OpenSCAD (.scad)', ext: '.scad', mime: 'text/x-openscad', text: true },
56
+ js: { label: 'three.js module (.js)', ext: '.js', mime: 'text/javascript', text: true },
41
57
  };
42
58
 
43
59
  /** Files that only accompany a main file (OBJ materials, glTF buffers and textures). */
@@ -61,6 +77,11 @@ export function detectKind(name, bytes) {
61
77
  if (ext === 'stl' || isAsciiSTL(bytes)) return 'stl';
62
78
  if (ext === 'gltf' || /^\s*\{[\s\S]*"asset"\s*:/.test(head)) return 'gltf';
63
79
  if (ext === 'obj' || /^\s*(#[^\n]*\n\s*)*(mtllib|o|g|v|vt|vn|f|usemtl|s)\s/.test(head)) return 'obj';
80
+ if (ext === 'x3dv' || /^#X3D\b/.test(head)) return 'x3dv';
81
+ if (ext === 'x3d' || /<X3D[\s>]/i.test(head)) return 'x3d';
82
+ if (ext === 'usda' || /^#usda\b/.test(head)) return 'usda';
83
+ if (ext === 'usdc' || ext === 'usdz') return ext;
84
+ if (ext === 'scad') return 'scad';
64
85
  if (ext === 'step' || ext === 'stp' || ext === 'p21') return 'step';
65
86
  return null;
66
87
  }
@@ -93,7 +114,7 @@ export async function importFiles(files, opts = {}) {
93
114
  const main = list.find((f) => !COMPANION.test(f.name)) || list[0];
94
115
  const companions = list.filter((f) => f !== main);
95
116
  const kind = detectKind(main.name, main.bytes);
96
- if (!kind) throw new Error(`${main.name}: unknown file type. The converter reads SVG, OBJ, STL, STEP (.step, .stp) and glTF (.gltf, .glb) and ES3D (.es3d) files.`);
117
+ if (!kind) throw new Error(`${main.name}: unknown file type. The converter reads SVG, OBJ, STL, STEP (.step, .stp), glTF (.gltf, .glb), X3D (.x3d, .x3dv), USDA, OpenSCAD (.scad) and ES3D (.es3d) files.`);
97
118
  const title = main.name.replace(/\.[^.]+$/, '') || 'untitled';
98
119
  const svgOpts = { ...IMPORT_DEFAULTS.svg, ...(opts.svg || {}) };
99
120
  const meshOpts = { ...IMPORT_DEFAULTS.mesh, ...(opts.mesh || {}) };
@@ -165,6 +186,23 @@ export async function importFiles(files, opts = {}) {
165
186
  text = meshDocument(s, hasNormals);
166
187
  break;
167
188
  }
189
+ case 'x3dv':
190
+ case 'x3d':
191
+ case 'usda': {
192
+ const imported = kind === 'usda' ? importUSD(decode(main.bytes)) : importX3D(decode(main.bytes));
193
+ text = imported.text;
194
+ notes.push(...imported.notes);
195
+ break;
196
+ }
197
+ case 'usdc':
198
+ case 'usdz':
199
+ throw new Error('USDC and USDZ are not read here. This tool reads and writes USDA text; convert a crate with usdcat if you have the USD tools.');
200
+ case 'scad': {
201
+ const imported = importOpenSCAD(decode(main.bytes), { files: companions });
202
+ text = imported.text;
203
+ notes.push(...imported.notes);
204
+ break;
205
+ }
168
206
  }
169
207
  return { kind, name: main.name, title, text, warnings, notes };
170
208
  }
@@ -186,11 +224,13 @@ function describeErrors(diagnostics, lineIndex) {
186
224
  * Write an ES3D document in another format.
187
225
  * @param {string|Uint8Array} source ES3D text
188
226
  * @param {keyof OUTPUT_FORMATS} format
189
- * @param {{baseName?: string, lod?: number, compiled?: object, instancing?: boolean}} [opts]
190
- * compiled: an existing compile() result for the same source (saves compiling twice)
191
- * @returns {{files: {name: string, data: Uint8Array|string, mime: string}[], triangles?: number}}
227
+ * @param {{baseName?: string, lod?: number, compiled?: object, instancing?: boolean, structure?: 'keep'|'flatten'}} [opts]
228
+ * compiled: an existing compile() result for the same source (saves compiling twice).
229
+ * structure applies to X3D, USD, OpenSCAD and three.js: keep (the default) writes
230
+ * primitives and the target's own reuse; flatten writes the same triangles as glTF and OBJ.
231
+ * @returns {{files: {name: string, data: Uint8Array|string, mime: string}[], triangles?: number, notes?: string[]}}
192
232
  */
193
- export function exportFormat(source, format, { baseName = 'scene', lod = 0, compiled = null, instancing = true } = {}) {
233
+ export function exportFormat(source, format, { baseName = 'scene', lod = 0, compiled = null, instancing = true, structure = 'keep' } = {}) {
194
234
  const spec = OUTPUT_FORMATS[format];
195
235
  if (!spec) throw new Error(`Unknown output format ${format}.`);
196
236
  const name = baseName + spec.ext;
@@ -206,6 +246,14 @@ export function exportFormat(source, format, { baseName = 'scene', lod = 0, comp
206
246
  const r = compiled || compile(source);
207
247
  if (!r.ok) throw new Error(describeErrors(r.diagnostics, r.doc && r.doc.lineIndex));
208
248
  const meshOf = (e, l) => r.cache.mesh(e, Math.min(l, e.lods - 1));
249
+ if (format === 'x3dv' || format === 'usda' || format === 'scad' || format === 'js') {
250
+ const model = buildExportModel(r, meshOf, { lod, structure: structure === 'flatten' ? 'flatten' : 'keep' });
251
+ if (model.meshTriangles > OBJ_TRIANGLE_LIMIT) {
252
+ throw new Error(`At this level of detail the scene has ${model.meshTriangles.toLocaleString('en-US')} triangles, too many for this file (limit ${OBJ_TRIANGLE_LIMIT.toLocaleString('en-US')}). Choose a coarser detail.`);
253
+ }
254
+ const written = format === 'x3dv' ? exportX3D(model) : format === 'usda' ? exportUSD(model) : format === 'scad' ? exportOpenSCAD(model) : exportThree(model);
255
+ return { ...one(written.text), notes: written.notes };
256
+ }
209
257
  if (format === 'glb') return one(exportGLB(r.scene, meshOf, { lod, instancing }));
210
258
  if (format === 'obj') {
211
259
  let triangles = 0;