@es3d/es3d 0.0.0-stage → 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 (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +85 -2
  3. package/docs/REFERENCE.md +581 -0
  4. package/docs/SPEC.md +417 -0
  5. package/package.json +50 -4
  6. package/scripts/es3d-cli.mjs +377 -0
  7. package/src/cache/geometry-store.js +392 -0
  8. package/src/cache/mesh-bundle.js +123 -0
  9. package/src/cache/sha256.js +73 -0
  10. package/src/format/ast.js +238 -0
  11. package/src/format/colors.js +93 -0
  12. package/src/format/compact-parser.js +171 -0
  13. package/src/format/compact-tokenizer.js +165 -0
  14. package/src/format/errors.js +247 -0
  15. package/src/format/json.js +55 -0
  16. package/src/format/limits.js +14 -0
  17. package/src/format/parse.js +92 -0
  18. package/src/format/schema.js +617 -0
  19. package/src/format/serializer.js +333 -0
  20. package/src/format/validator.js +330 -0
  21. package/src/format/value-error.js +12 -0
  22. package/src/format/value-format.js +314 -0
  23. package/src/format/values.js +459 -0
  24. package/src/format/xml-parser.js +136 -0
  25. package/src/format/xml-tokenizer.js +237 -0
  26. package/src/geometry/contour.js +86 -0
  27. package/src/geometry/extrude.js +175 -0
  28. package/src/geometry/gear.js +85 -0
  29. package/src/geometry/geometry-cache.js +159 -0
  30. package/src/geometry/lod-error.js +178 -0
  31. package/src/geometry/mesh.js +458 -0
  32. package/src/geometry/primitives.js +299 -0
  33. package/src/geometry/registry.js +645 -0
  34. package/src/geometry/revolve.js +136 -0
  35. package/src/geometry/sweep.js +441 -0
  36. package/src/index.js +47 -0
  37. package/src/io/compression.js +67 -0
  38. package/src/io/convert.js +236 -0
  39. package/src/io/download.js +13 -0
  40. package/src/io/external-scene.js +127 -0
  41. package/src/io/file-loader.js +28 -0
  42. package/src/io/gltf.js +630 -0
  43. package/src/io/mesh-import.js +104 -0
  44. package/src/io/obj.js +352 -0
  45. package/src/io/step-brep.js +439 -0
  46. package/src/io/step-exact.js +1149 -0
  47. package/src/io/step.js +1418 -0
  48. package/src/io/stl.js +193 -0
  49. package/src/io/svg-import.js +787 -0
  50. package/src/material/materials.js +154 -0
  51. package/src/math/mat4.js +475 -0
  52. package/src/math/random.js +30 -0
  53. package/src/math/trig.js +65 -0
  54. package/src/math/vec3.js +81 -0
  55. package/src/optimize/optimizer.js +579 -0
  56. package/src/path/path-flatten.js +322 -0
  57. package/src/path/path-parser.js +332 -0
  58. package/src/path/shapes.js +129 -0
  59. package/src/path/triangulate.js +517 -0
  60. package/src/pipeline.js +74 -0
  61. package/src/render/camera.js +168 -0
  62. package/src/render/frame-builder.js +362 -0
  63. package/src/render/geometry-pool.js +149 -0
  64. package/src/render/lighting.js +149 -0
  65. package/src/render/orbit-controls.js +237 -0
  66. package/src/render/renderer.js +258 -0
  67. package/src/render/scroll-timeline.js +104 -0
  68. package/src/render/shaders/glsl.js +285 -0
  69. package/src/render/shaders/wgsl.js +252 -0
  70. package/src/render/stage.js +364 -0
  71. package/src/render/texture-cache.js +40 -0
  72. package/src/render/textures.js +223 -0
  73. package/src/render/webgl2/webgl2-backend.js +586 -0
  74. package/src/render/webgpu/webgpu-backend.js +482 -0
  75. package/src/scene/animation.js +171 -0
  76. package/src/scene/camera-constraints.js +117 -0
  77. package/src/scene/flatten.js +754 -0
  78. package/src/scene/picking.js +161 -0
  79. package/src/scene/scene-graph.js +320 -0
  80. package/src/transform/transform.js +211 -0
  81. package/src/wasm/kernels-binary.js +3 -0
  82. package/src/wasm/kernels.js +385 -0
@@ -0,0 +1,579 @@
1
+ // The ES3D optimizer: semantic-preserving rewrites that make documents smaller.
2
+ //
3
+ // stripDefaults remove attributes equal to their schema default
4
+ // dedupeMaterials merge <material>s with identical parameters
5
+ // extractRepeated identical subtrees -> one definition in <defs> + <use>
6
+ // detectPatterns sibling copies whose transforms form a progression -> <repeat>
7
+ // collapseGroups drop empty groups and attribute-less single-child groups
8
+ // weldMeshes merge duplicate vertices of <mesh> elements
9
+ // removeUnused drop definitions and materials nothing references
10
+ // precision quantize numbers when serializing (lossy, opt-in)
11
+ //
12
+ // plus expandProcedural(), the inverse operation used by the benchmark: it
13
+ // bakes <repeat>/<scatter> into explicit <use>/<group> elements.
14
+
15
+ import { Attr, Comment, Document, Element, cloneElement, h } from '../format/ast.js';
16
+ import { parse } from '../format/parse.js';
17
+ import { serialize } from '../format/serializer.js';
18
+ import { attrSpec, elementSpec, valueOf } from '../format/schema.js';
19
+ import { materialKey, paramsFromElement } from '../material/materials.js';
20
+ import { formatNumber, formatNumberList, formatPathData } from '../format/value-format.js';
21
+ import { matrixToEuler, opsToMatrix } from '../transform/transform.js';
22
+ import * as mat4 from '../math/mat4.js';
23
+ import { mulberry32 } from '../math/random.js';
24
+ import { es3dCos, es3dSin } from '../math/trig.js';
25
+
26
+ const PLACEMENT = ['transform', 'position', 'rotation', 'scale'];
27
+ const SHORT = (n) => formatNumber(n, 6);
28
+
29
+ function deepEqual(a, b, eps = 1e-12) {
30
+ if (a === b) return true;
31
+ if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) <= eps;
32
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
33
+ if (a.length !== undefined && b.length !== undefined) {
34
+ if (a.length !== b.length) return false;
35
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i], eps)) return false;
36
+ return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /** Clone a validated tree keeping a link to the source element (for typed values). */
43
+ function cloneWithOrigin(el) {
44
+ const c = new Element(el.name);
45
+ c.orig = el;
46
+ c.values = el.values;
47
+ c.attrIndex = null;
48
+ for (const a of el.attrs) c.attrs.push(new Attr(a.name, a.value));
49
+ c.text = el.text;
50
+ for (const k of el.children) {
51
+ if (k.type === 'element') c.appendChild(cloneWithOrigin(k));
52
+ else c.appendChild(new Comment(k.text));
53
+ }
54
+ c.selfClosing = c.children.length === 0;
55
+ return c;
56
+ }
57
+
58
+ function canonicalAttr(el, a) {
59
+ const spec = elementSpec(el.name);
60
+ if (!spec) return a.name;
61
+ if (spec.attrs[a.name]) return a.name;
62
+ for (const [k, s] of Object.entries(spec.attrs)) if (s.alias === a.name) return k;
63
+ return a.name;
64
+ }
65
+
66
+ function elementsOf(el) {
67
+ return el.children.filter((c) => c.type === 'element');
68
+ }
69
+
70
+ function walkAll(el, fn, parent = null) {
71
+ fn(el, parent);
72
+ for (const c of elementsOf(el)) walkAll(c, fn, el);
73
+ }
74
+
75
+ /** Structural signature of a subtree, optionally ignoring placement attributes and ids. */
76
+ function signature(el, ignorePlacement) {
77
+ const parts = [el.name];
78
+ const attrs = [];
79
+ for (const a of el.attrs) {
80
+ const k = canonicalAttr(el, a);
81
+ if (k === 'id' || k === 'name') continue;
82
+ if (ignorePlacement && PLACEMENT.includes(k)) continue;
83
+ attrs.push(`${k}=${a.value.trim()}`);
84
+ }
85
+ attrs.sort();
86
+ parts.push(attrs.join('&'));
87
+ if (el.text) parts.push('"' + el.text.trim());
88
+ const kids = elementsOf(el).map((c) => signature(c, false));
89
+ return `${parts.join('|')}(${kids.join(',')})`;
90
+ }
91
+
92
+ /** Local placement matrix of an element from its raw attributes (re-parsed when needed). */
93
+ function placementMatrix(el) {
94
+ const get = (k) => {
95
+ const a = el.attrs.find((x) => canonicalAttr(el, x) === k);
96
+ return a ? a.value : undefined;
97
+ };
98
+ const parseVec = (s, n, def) => {
99
+ if (s === undefined) return def;
100
+ const v = s.split(/[\s,]+/).filter(Boolean).map(Number);
101
+ if (v.length === 1 && n === 3) return [v[0], v[0], v[0]];
102
+ return v;
103
+ };
104
+ const m = mat4.compose(mat4.createF64(), parseVec(get('position'), 3, [0, 0, 0]), parseVec(get('rotation'), 3, [0, 0, 0]), parseVec(get('scale'), 3, [1, 1, 1]));
105
+ const t = get('transform');
106
+ if (t !== undefined) {
107
+ const doc = parse(`<es3d version="1"><group transform="${t.replace(/"/g, '&quot;')}"/></es3d>`);
108
+ const g = doc.root.children.find((c) => c.type === 'element');
109
+ if (g && g.values && g.values.transform) mat4.multiply(m, g.values.transform.matrix, m);
110
+ }
111
+ return m;
112
+ }
113
+
114
+ function setPlacement(el, m) {
115
+ el.attrs = el.attrs.filter((a) => !PLACEMENT.includes(canonicalAttr(el, a)));
116
+ const e = matrixToEuler(m);
117
+ if (!e) {
118
+ el.attrs.push(new Attr('transform', `matrix(${Array.from(m, SHORT).join(',')})`));
119
+ return;
120
+ }
121
+ if (e.translation.some((v) => Math.abs(v) > 1e-9)) el.attrs.push(new Attr('position', e.translation.map(SHORT).join(',')));
122
+ if (e.rotation.some((v) => Math.abs(v) > 1e-9)) el.attrs.push(new Attr('rotation', e.rotation.map(SHORT).join(',')));
123
+ if (e.scale.some((v) => Math.abs(v - 1) > 1e-9)) {
124
+ const [a, b, c] = e.scale;
125
+ el.attrs.push(new Attr('scale', Math.abs(a - b) < 1e-9 && Math.abs(b - c) < 1e-9 ? SHORT(a) : e.scale.map(SHORT).join(',')));
126
+ }
127
+ }
128
+
129
+ function transformString(m) {
130
+ const e = matrixToEuler(m);
131
+ if (!e) return `matrix(${Array.from(m, SHORT).join(',')})`;
132
+ const parts = [];
133
+ if (e.translation.some((v) => Math.abs(v) > 1e-9)) parts.push(`translate(${e.translation.map(SHORT).join(',')})`);
134
+ if (e.rotation.some((v) => Math.abs(v) > 1e-9)) parts.push(`rotate(${e.rotation.map(SHORT).join(',')})`);
135
+ if (e.scale.some((v) => Math.abs(v - 1) > 1e-9)) {
136
+ const [a, b, c] = e.scale;
137
+ parts.push(Math.abs(a - b) < 1e-9 && Math.abs(b - c) < 1e-9 ? `scale(${SHORT(a)})` : `scale(${e.scale.map(SHORT).join(',')})`);
138
+ }
139
+ return parts.join(' ') || 'translate(0,0,0)';
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // passes
144
+
145
+ function stripDefaults(root, report) {
146
+ let n = 0;
147
+ walkAll(root, (el) => {
148
+ const spec = elementSpec(el.name);
149
+ if (!spec || !el.values) return;
150
+ el.attrs = el.attrs.filter((a) => {
151
+ const k = canonicalAttr(el, a);
152
+ const s = spec.attrs[k];
153
+ if (!s || s.default === undefined || s.required) return true;
154
+ const v = el.values[k];
155
+ if (v === undefined) return true;
156
+ if (deepEqual(v, s.default)) {
157
+ n++;
158
+ return false;
159
+ }
160
+ return true;
161
+ });
162
+ });
163
+ if (n) report.push({ pass: 'stripDefaults', detail: `removed ${n} attribute${n === 1 ? '' : 's'} equal to the default` });
164
+ }
165
+
166
+ function rewriteRefs(root, mapping, kinds) {
167
+ walkAll(root, (el) => {
168
+ for (const a of el.attrs) {
169
+ const k = canonicalAttr(el, a);
170
+ const s = attrSpec(el.name, k);
171
+ if (!s) continue;
172
+ if (s.type === 'ref' && kinds.includes(s.ref)) {
173
+ const id = a.value.trim().replace(/^#/, '');
174
+ if (mapping.has(id)) a.value = mapping.get(id);
175
+ } else if (s.type === 'idlist' && kinds.includes(s.ref)) {
176
+ a.value = a.value
177
+ .split(/[\s,]+/)
178
+ .filter(Boolean)
179
+ .map((id) => mapping.get(id.replace(/^#/, '')) || id)
180
+ .join(',');
181
+ }
182
+ }
183
+ });
184
+ }
185
+
186
+ function removeElement(el) {
187
+ const p = el.parent;
188
+ const i = p.children.indexOf(el);
189
+ if (i >= 0) p.children.splice(i, 1);
190
+ // drop a comment directly describing the removed element? keep comments: they may describe other things
191
+ }
192
+
193
+ function dedupeMaterials(root, report) {
194
+ const byKey = new Map();
195
+ const mapping = new Map();
196
+ const remove = [];
197
+ walkAll(root, (el) => {
198
+ if (el.name !== 'material' || !el.values || el.values.id === undefined) return;
199
+ const key = materialKey(paramsFromElement(el.orig || el));
200
+ const first = byKey.get(key);
201
+ if (first) {
202
+ mapping.set(el.values.id, first);
203
+ remove.push(el);
204
+ } else byKey.set(key, el.values.id);
205
+ });
206
+ if (!mapping.size) return;
207
+ rewriteRefs(root, mapping, ['material']);
208
+ for (const el of remove) removeElement(el);
209
+ report.push({ pass: 'dedupeMaterials', detail: `merged ${mapping.size} duplicate material${mapping.size === 1 ? '' : 's'}` });
210
+ }
211
+
212
+ function collectReferences(root) {
213
+ const used = new Set();
214
+ walkAll(root, (el) => {
215
+ for (const a of el.attrs) {
216
+ const s = attrSpec(el.name, canonicalAttr(el, a));
217
+ if (!s) continue;
218
+ if (s.type === 'ref') used.add(a.value.trim().replace(/^#/, ''));
219
+ else if (s.type === 'idlist') for (const id of a.value.split(/[\s,]+/)) if (id) used.add(id.replace(/^#/, ''));
220
+ }
221
+ });
222
+ return used;
223
+ }
224
+
225
+ function removeUnused(root, report) {
226
+ let removed = 0;
227
+ for (;;) {
228
+ const used = collectReferences(root);
229
+ const victims = [];
230
+ walkAll(root, (el, parent) => {
231
+ if (!parent) return;
232
+ const id = el.attrs.find((a) => canonicalAttr(el, a) === 'id');
233
+ const inDefs = parent.name === 'defs';
234
+ if ((inDefs || el.name === 'material') && (!id || !used.has(id.value.trim()))) victims.push(el);
235
+ });
236
+ if (!victims.length) break;
237
+ for (const v of victims) removeElement(v);
238
+ removed += victims.length;
239
+ }
240
+ // empty <defs>
241
+ for (const d of elementsOf(root).filter((e) => e.name === 'defs' && !elementsOf(e).length)) removeElement(d);
242
+ if (removed) report.push({ pass: 'removeUnused', detail: `removed ${removed} unreferenced definition${removed === 1 ? '' : 's'}` });
243
+ }
244
+
245
+ function collapseGroups(root, report) {
246
+ let n = 0;
247
+ const visit = (el) => {
248
+ for (const c of elementsOf(el)) visit(c);
249
+ if (el.name !== 'group' || !el.parent) return;
250
+ const kids = elementsOf(el);
251
+ const attrs = el.attrs.filter((a) => canonicalAttr(el, a) !== 'name');
252
+ if (!kids.length && !attrs.length) {
253
+ removeElement(el);
254
+ n++;
255
+ } else if (kids.length === 1 && !attrs.length && !el.children.some((c) => c.type === 'comment')) {
256
+ const p = el.parent;
257
+ const i = p.children.indexOf(el);
258
+ kids[0].parent = p;
259
+ p.children.splice(i, 1, kids[0]);
260
+ n++;
261
+ }
262
+ };
263
+ visit(root);
264
+ if (n) report.push({ pass: 'collapseGroups', detail: `collapsed ${n} redundant group${n === 1 ? '' : 's'}` });
265
+ }
266
+
267
+ function sceneContainers(root) {
268
+ const out = [root];
269
+ walkAll(root, (el) => {
270
+ if (el !== root && ['scene', 'group', 'repeat', 'scatter', 'lod'].includes(el.name)) out.push(el);
271
+ });
272
+ return out.filter((c) => {
273
+ for (let p = c; p; p = p.parent) if (p.name === 'defs') return false;
274
+ return true;
275
+ });
276
+ }
277
+
278
+ function ensureDefs(root) {
279
+ let defs = elementsOf(root).find((e) => e.name === 'defs');
280
+ if (!defs) {
281
+ defs = h('defs');
282
+ defs.parent = root;
283
+ // place after title/desc/materials at the top
284
+ let i = 0;
285
+ while (i < root.children.length && root.children[i].type === 'element' && ['title', 'desc'].includes(root.children[i].name)) i++;
286
+ root.children.splice(i, 0, defs);
287
+ root.selfClosing = false;
288
+ }
289
+ return defs;
290
+ }
291
+
292
+ function usedIds(root) {
293
+ const ids = new Set();
294
+ walkAll(root, (el) => {
295
+ const a = el.attrs.find((x) => canonicalAttr(el, x) === 'id');
296
+ if (a) ids.add(a.value.trim());
297
+ });
298
+ return ids;
299
+ }
300
+
301
+ function freshId(ids, base) {
302
+ let i = 1;
303
+ while (ids.has(`${base}${i}`)) i++;
304
+ const id = `${base}${i}`;
305
+ ids.add(id);
306
+ return id;
307
+ }
308
+
309
+ function extractRepeated(root, report) {
310
+ const groups = new Map();
311
+ for (const container of sceneContainers(root)) {
312
+ for (const el of elementsOf(container)) {
313
+ if (!['group', 'box', 'sphere', 'cylinder', 'cone', 'capsule', 'torus', 'extrude', 'revolve', 'sweep', 'gear', 'mesh', 'path', 'polygon'].includes(el.name)) continue;
314
+ if (el.attrs.some((a) => canonicalAttr(el, a) === 'id')) continue;
315
+ const sig = signature(el, true);
316
+ let g = groups.get(sig);
317
+ if (!g) groups.set(sig, (g = []));
318
+ g.push(el);
319
+ }
320
+ }
321
+ const ids = usedIds(root);
322
+ let extracted = 0;
323
+ let uses = 0;
324
+ for (const [, list] of groups) {
325
+ if (list.length < 2) continue;
326
+ const proto = list[0];
327
+ const body = cloneWithOrigin(proto.orig || proto);
328
+ body.attrs = body.attrs.filter((a) => !PLACEMENT.includes(canonicalAttr(body, a)) && canonicalAttr(body, a) !== 'name');
329
+ const bodySize = serialize(new Document({ root: body, syntax: 'xml' }), { pretty: false }).length;
330
+ const useOverhead = 18;
331
+ if ((list.length - 1) * bodySize <= list.length * useOverhead) continue;
332
+ const id = freshId(ids, proto.name === 'group' ? 'part' : proto.name);
333
+ body.attrs.unshift(new Attr('id', id));
334
+ const defs = ensureDefs(root);
335
+ defs.appendChild(body);
336
+ for (const el of list) {
337
+ const use = new Element('use');
338
+ use.attrs.push(new Attr('ref', id));
339
+ for (const a of el.attrs) {
340
+ const k = canonicalAttr(el, a);
341
+ if (PLACEMENT.includes(k) || k === 'name') use.attrs.push(new Attr(k, a.value));
342
+ }
343
+ const p = el.parent;
344
+ const i = p.children.indexOf(el);
345
+ use.parent = p;
346
+ p.children.splice(i, 1, use);
347
+ uses++;
348
+ }
349
+ extracted++;
350
+ }
351
+ if (extracted) report.push({ pass: 'extractRepeated', detail: `moved ${extracted} repeated subtree${extracted === 1 ? '' : 's'} into <defs> (${uses} <use> elements)` });
352
+ }
353
+
354
+ function detectPatterns(root, report) {
355
+ let found = 0;
356
+ let replaced = 0;
357
+ for (const container of sceneContainers(root)) {
358
+ const kids = container.children;
359
+ let i = 0;
360
+ while (i < kids.length) {
361
+ const a = kids[i];
362
+ if (a.type !== 'element' || a.name === 'animate' || a.attrs.some((x) => canonicalAttr(a, x) === 'id')) {
363
+ i++;
364
+ continue;
365
+ }
366
+ const sig = signature(a, true);
367
+ let j = i + 1;
368
+ while (j < kids.length && kids[j].type === 'element' && !kids[j].attrs.some((x) => canonicalAttr(kids[j], x) === 'id') && signature(kids[j], true) === sig) j++;
369
+ const run = kids.slice(i, j);
370
+ if (run.length >= 3) {
371
+ const M = run.map(placementMatrix);
372
+ const inv0 = mat4.invert(mat4.createF64(), M[0]);
373
+ const step = inv0 ? mat4.multiply(mat4.createF64(), M[1], inv0) : null;
374
+ let ok = !!step;
375
+ const tmp = mat4.createF64();
376
+ for (let k = 2; ok && k < M.length; k++) {
377
+ mat4.multiply(tmp, step, M[k - 1]);
378
+ if (!mat4.equals(tmp, M[k], 1e-6)) ok = false;
379
+ }
380
+ if (ok && !mat4.isIdentity(step, 1e-12)) {
381
+ const rep = new Element('repeat');
382
+ rep.attrs.push(new Attr('count', String(run.length)), new Attr('step', transformString(step)));
383
+ const first = run[0];
384
+ rep.appendChild(first);
385
+ rep.parent = container;
386
+ kids.splice(i, run.length, rep);
387
+ found++;
388
+ replaced += run.length;
389
+ i++;
390
+ continue;
391
+ }
392
+ }
393
+ i = j;
394
+ }
395
+ }
396
+ if (found) report.push({ pass: 'detectPatterns', detail: `replaced ${replaced} elements by ${found} <repeat> pattern${found === 1 ? '' : 's'}` });
397
+ }
398
+
399
+ function weldMeshes(root, report) {
400
+ let removedVerts = 0;
401
+ walkAll(root, (el) => {
402
+ if (el.name !== 'mesh' || !el.values || !el.values.positions) return;
403
+ const v = el.values;
404
+ if (v.normals || v.uvs) return; // per-vertex attributes would have to be compared too
405
+ const p = v.positions;
406
+ const n = p.length / 3;
407
+ const idx = v.indices || Uint32Array.from({ length: n }, (_, k) => k);
408
+ const map = new Map();
409
+ const remap = new Uint32Array(n);
410
+ const out = [];
411
+ for (let k = 0; k < n; k++) {
412
+ const key = `${p[k * 3]},${p[k * 3 + 1]},${p[k * 3 + 2]}`;
413
+ let r = map.get(key);
414
+ if (r === undefined) {
415
+ r = out.length / 3;
416
+ map.set(key, r);
417
+ out.push(p[k * 3], p[k * 3 + 1], p[k * 3 + 2]);
418
+ }
419
+ remap[k] = r;
420
+ }
421
+ if (out.length === p.length && v.indices) return;
422
+ removedVerts += n - out.length / 3;
423
+ const set = (name, value) => {
424
+ const a = el.attrs.find((x) => canonicalAttr(el, x) === name);
425
+ if (a) a.value = value;
426
+ else el.attrs.push(new Attr(name, value));
427
+ };
428
+ set('positions', formatNumberList(out, { sep: ' ' }));
429
+ set('indices', Array.from(idx, (k) => remap[k]).join(' '));
430
+ });
431
+ if (removedVerts) report.push({ pass: 'weldMeshes', detail: `welded ${removedVerts} duplicate mesh vertices` });
432
+ }
433
+
434
+ function minifyPaths(root, report) {
435
+ let n = 0;
436
+ walkAll(root, (el) => {
437
+ for (const a of el.attrs) {
438
+ const k = canonicalAttr(el, a);
439
+ const s = attrSpec(el.name, k);
440
+ if (!s || s.type !== 'path2d' || !el.values || !el.values[k]) continue;
441
+ const m = formatPathData(el.values[k].segments, { minify: true });
442
+ if (m.length < a.value.length) {
443
+ a.value = m;
444
+ n++;
445
+ }
446
+ }
447
+ });
448
+ if (n) report.push({ pass: 'minifyPaths', detail: `rewrote ${n} path${n === 1 ? '' : 's'} with shorter commands` });
449
+ }
450
+
451
+ export const DEFAULT_OPTIONS = {
452
+ stripDefaults: true,
453
+ dedupeMaterials: true,
454
+ extractRepeated: true,
455
+ detectPatterns: true,
456
+ collapseGroups: true,
457
+ weldMeshes: true,
458
+ minifyPaths: true,
459
+ removeUnused: true,
460
+ };
461
+
462
+ /**
463
+ * Optimize a validated document.
464
+ * @returns {{doc: Document, report: {pass:string, detail:string}[], text: string}}
465
+ */
466
+ export function optimize(doc, options = {}) {
467
+ if (!doc.ok) throw new Error('Cannot optimize a document with errors.');
468
+ const opts = { ...DEFAULT_OPTIONS, ...options };
469
+ const report = [];
470
+ const root = cloneWithOrigin(doc.root);
471
+ if (opts.stripDefaults) stripDefaults(root, report);
472
+ if (opts.minifyPaths) minifyPaths(root, report);
473
+ if (opts.weldMeshes) weldMeshes(root, report);
474
+ if (opts.dedupeMaterials) dedupeMaterials(root, report);
475
+ if (opts.detectPatterns) detectPatterns(root, report);
476
+ if (opts.extractRepeated) extractRepeated(root, report);
477
+ if (opts.collapseGroups) collapseGroups(root, report);
478
+ if (opts.removeUnused) removeUnused(root, report);
479
+ const out = new Document({ root, syntax: doc.syntax, leading: doc.leading, trailing: doc.trailing });
480
+ const text = serialize(out, { syntax: doc.syntax === 'compact' ? 'compact' : 'xml', pretty: true });
481
+ const reparsed = parse(text);
482
+ if (!reparsed.ok) {
483
+ throw new Error(`Optimizer produced an invalid document: ${reparsed.errors[0].message}`);
484
+ }
485
+ return { doc: reparsed, report, text };
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // expansion (the inverse of procedural compression)
490
+
491
+ /**
492
+ * Replace every <repeat> and <scatter> outside <defs> by explicit copies.
493
+ * The result renders identically but stores one element per instance.
494
+ */
495
+ export function expandProcedural(doc) {
496
+ const root = cloneWithOrigin(doc.root);
497
+ const expandNode = (el) => {
498
+ for (const c of elementsOf(el)) expandNode(c);
499
+ if (el.name !== 'repeat' && el.name !== 'scatter') return;
500
+ for (let p = el.parent; p; p = p.parent) if (p.name === 'defs') return;
501
+ const src = el.orig || el;
502
+ const group = new Element('group');
503
+ for (const a of el.attrs) {
504
+ const k = canonicalAttr(el, a);
505
+ if (PLACEMENT.includes(k) || k === 'material' || k === 'color' || k === 'visible') group.attrs.push(new Attr(k, a.value));
506
+ }
507
+ const kids = elementsOf(el).filter((c) => c.name !== 'animate');
508
+ const anims = elementsOf(el).filter((c) => c.name === 'animate');
509
+ const emit = (m, material) => {
510
+ for (const k of kids) {
511
+ const copy = cloneElement(k);
512
+ // wrap when the copy has its own placement or style, so both are kept
513
+ const hasPlacement = k.attrs.some((a) => PLACEMENT.includes(canonicalAttr(k, a)));
514
+ const hasStyle = k.attrs.some((a) => ['material', 'color'].includes(canonicalAttr(k, a)));
515
+ let node = copy;
516
+ if (hasPlacement || (material && hasStyle)) {
517
+ node = new Element('group');
518
+ node.appendChild(copy);
519
+ }
520
+ setPlacement(node, m);
521
+ if (material) node.attrs.push(new Attr('material', material));
522
+ group.appendChild(node);
523
+ }
524
+ };
525
+ if (el.name === 'repeat') {
526
+ const count = valueOf(src, 'count');
527
+ const step = src.values.step ? src.values.step.matrix : opsToMatrix([]);
528
+ const acc = mat4.createF64();
529
+ for (let i = 0; i < count; i++) {
530
+ emit(acc, null);
531
+ mat4.multiply(acc, acc, step);
532
+ }
533
+ } else {
534
+ const v = src.values;
535
+ const grid = v.grid || null;
536
+ const count = grid ? grid[0] * grid[1] : v.count || 0;
537
+ const spacing = valueOf(src, 'spacing');
538
+ const jitter = valueOf(src, 'jitter');
539
+ const rand = mulberry32(valueOf(src, 'seed'));
540
+ const lerp = (r, t) => r[0] + (r[1] - r[0]) * t;
541
+ const u = new Float64Array(9);
542
+ for (let i = 0; i < count; i++) {
543
+ for (let k = 0; k < 9; k++) u[k] = rand();
544
+ const pos = [0, 0, 0];
545
+ if (grid) {
546
+ const ix = i % grid[0], iz = Math.floor(i / grid[0]);
547
+ pos[0] = (ix - (grid[0] - 1) / 2) * spacing[0] + (u[0] - 0.5) * jitter * spacing[0];
548
+ pos[2] = (iz - (grid[1] - 1) / 2) * spacing[1] + (u[1] - 0.5) * jitter * spacing[1];
549
+ } else if (v.radius) {
550
+ const r = v.radius * Math.sqrt(u[0]);
551
+ const a = u[1] * Math.PI * 2;
552
+ pos[0] = r * es3dCos(a);
553
+ pos[2] = r * es3dSin(a);
554
+ } else {
555
+ pos[0] = (u[0] - 0.5) * v.area[0];
556
+ pos[2] = (u[1] - 0.5) * v.area[1];
557
+ }
558
+ const rot = [0, 0, 0];
559
+ if (v.randomRotation) for (let k = 0; k < 3; k++) rot[k] = lerp(v.randomRotation.ranges[k], u[2 + k]);
560
+ let scl = [1, 1, 1];
561
+ if (v.randomScale) {
562
+ const r = v.randomScale.ranges;
563
+ scl = v.randomScale.uniform ? [lerp(r[0], u[5]), lerp(r[0], u[5]), lerp(r[0], u[5])] : [lerp(r[0], u[5]), lerp(r[1], u[6]), lerp(r[2], u[7])];
564
+ }
565
+ let material = null;
566
+ if (v.randomMaterial) material = v.randomMaterial[Math.min(v.randomMaterial.length - 1, Math.floor(u[8] * v.randomMaterial.length))];
567
+ emit(mat4.compose(mat4.createF64(), pos, rot, scl), material);
568
+ }
569
+ }
570
+ for (const a of anims) group.appendChild(a);
571
+ const p = el.parent;
572
+ const i = p.children.indexOf(el);
573
+ group.parent = p;
574
+ p.children.splice(i, 1, group);
575
+ };
576
+ expandNode(root);
577
+ const out = new Document({ root, syntax: 'xml' });
578
+ return serialize(out, { pretty: true });
579
+ }