@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,12 +1,12 @@
1
- /** Error raised by attribute value parsers; offsets are relative to the attribute value. */
2
- export class ValueError extends Error {
3
- constructor(message, offset = 0, length = 0, extra = {}) {
4
- super(message);
5
- this.name = 'ValueError';
6
- this.offset = offset;
7
- this.length = length;
8
- this.hint = extra.hint || null;
9
- this.suggestion = extra.suggestion || null;
10
- this.code = extra.code || null;
11
- }
12
- }
1
+ /** Error raised by attribute value parsers; offsets are relative to the attribute value. */
2
+ export class ValueError extends Error {
3
+ constructor(message, offset = 0, length = 0, extra = {}) {
4
+ super(message);
5
+ this.name = 'ValueError';
6
+ this.offset = offset;
7
+ this.length = length;
8
+ this.hint = extra.hint || null;
9
+ this.suggestion = extra.suggestion || null;
10
+ this.code = extra.code || null;
11
+ }
12
+ }
@@ -1,314 +1,314 @@
1
- // Formatting of typed values back to text: canonical numbers, quantization,
2
- // number lists, transform lists and a path data minifier (shortest of
3
- // absolute/relative commands, implicit repetition, minimal separators).
4
-
5
- import { colorToShortHex } from './colors.js';
6
-
7
- /**
8
- * Shortest decimal representation of x.
9
- * @param {number} x
10
- * @param {number|null} precision decimals to keep (quantization), null = exact
11
- * @param {boolean} compact drop the leading zero ("0.5" -> ".5")
12
- */
13
- export function formatNumber(x, precision = null, compact = false) {
14
- if (precision !== null && precision !== undefined) {
15
- const f = 10 ** precision;
16
- x = Math.round(x * f) / f;
17
- }
18
- if (x === 0 || Object.is(x, -0)) return '0';
19
- let s = String(x);
20
- if (s.indexOf('e') >= 0) {
21
- s = s.replace('e+', 'e');
22
- // prefer plain notation when it is not longer
23
- const plain = toPlain(x);
24
- if (plain && plain.length <= s.length) s = plain;
25
- }
26
- if (compact) {
27
- if (s.startsWith('0.')) s = s.slice(1);
28
- else if (s.startsWith('-0.')) s = '-' + s.slice(2);
29
- }
30
- return s;
31
- }
32
-
33
- /**
34
- * Shortest decimal that converts back to the same float32 value (mesh arrays
35
- * are float32 data: printing their double expansion would add noise digits).
36
- */
37
- export function formatFloat32(x, precision = null, compact = false) {
38
- if (precision !== null && precision !== undefined) return formatNumber(x, precision, compact);
39
- if (x === 0 || !Number.isFinite(x)) return formatNumber(x, null, compact);
40
- // toPrecision(9) always round-trips; binary search for the fewest digits
41
- let lo = 1, hi = 9;
42
- while (lo < hi) {
43
- const mid = (lo + hi) >> 1;
44
- if (Math.fround(Number(x.toPrecision(mid))) === x) hi = mid;
45
- else lo = mid + 1;
46
- }
47
- return formatNumber(Number(x.toPrecision(lo)), null, compact);
48
- }
49
-
50
- function toPlain(x) {
51
- const abs = Math.abs(x);
52
- if (abs >= 1e21 || abs < 1e-12) return null;
53
- let s = abs.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
54
- // guard against binary noise introduced by toFixed
55
- if (Number(s) !== abs) return null;
56
- return (x < 0 ? '-' : '') + s;
57
- }
58
-
59
- /** Join numbers with the minimal separators allowed by the SVG path grammar. */
60
- export function joinNumbersMinimal(strs, prevLast = null) {
61
- let out = '';
62
- let prev = prevLast;
63
- for (const s of strs) {
64
- if (prev === null) out += s;
65
- else if (s[0] === '-' || (s[0] === '.' && (prev.includes('.') || prev.includes('e')))) out += s;
66
- else out += ' ' + s;
67
- prev = s;
68
- }
69
- return out;
70
- }
71
-
72
- /**
73
- * Format a flat numeric list.
74
- * group > 1 writes "x,y,z x,y,z" (points); group === 1 with sep writes "a,b,c" or "a b c".
75
- */
76
- export function formatNumberList(list, { group = 1, sep = ',', precision = null, compact = false } = {}) {
77
- const parts = new Array(list.length);
78
- const fmt = list instanceof Float32Array ? formatFloat32 : formatNumber;
79
- for (let i = 0; i < list.length; i++) parts[i] = fmt(list[i], precision, compact);
80
- if (group <= 1) return parts.join(sep);
81
- const groups = [];
82
- for (let i = 0; i < parts.length; i += group) groups.push(parts.slice(i, i + group).join(','));
83
- return groups.join(' ');
84
- }
85
-
86
- export function formatColor(c) {
87
- return colorToShortHex(c);
88
- }
89
-
90
- /** Transform ops -> "translate(1,2,3) rotate(0,45,0)" */
91
- export function formatTransformOps(ops, precision = null, compact = false) {
92
- return ops.map((op) => `${op.fn}(${op.args.map((a) => formatNumber(a, precision, compact)).join(',')})`).join(' ');
93
- }
94
-
95
- const EPS = 1e-9;
96
-
97
- /**
98
- * Serialize absolute path segments (from parsePathData) as compact path data.
99
- * @param {object[]} segments
100
- * @param {{precision?: number|null, minify?: boolean, dim?: 2|3}} opts
101
- */
102
- export function formatPathData(segments, { precision = null, minify = true, dim = 2 } = {}) {
103
- const fmt = (v) => formatNumber(v, precision, minify);
104
- const q = (v) => (precision === null || precision === undefined ? v : Math.round(v * 10 ** precision) / 10 ** precision);
105
- if (!minify) return formatPathPretty(segments, fmt, dim);
106
-
107
- let out = '';
108
- let lastCmd = '';
109
- let lastNum = null;
110
- // reconstructed (decoder side) current point and subpath start
111
- let cx = 0, cy = 0, cz = 0;
112
- let sx = 0, sy = 0, sz = 0;
113
- let prevC2 = null; // reflected control point candidates
114
- let prevQ1 = null;
115
- let prevType = '';
116
-
117
- const emit = (cmd, nums) => {
118
- const strs = nums.map(fmt);
119
- // a repeated command (other than moveto/closepath) can omit its letter, and
120
- // so can a lineto directly after a moveto of the same relativity
121
- const omit =
122
- (cmd === lastCmd && cmd !== 'M' && cmd !== 'm') ||
123
- (lastCmd === 'M' && cmd === 'L') ||
124
- (lastCmd === 'm' && cmd === 'l');
125
- out += omit ? joinNumbersMinimal(strs, lastNum) : cmd + joinNumbersMinimal(strs, null);
126
- if (strs.length) lastNum = strs[strs.length - 1];
127
- lastCmd = cmd;
128
- };
129
- const pick = (absCmd, absNums, relCmd, relNums) => {
130
- const a = absCmd + absNums.map(fmt).join(' ');
131
- const r = relCmd + relNums.map(fmt).join(' ');
132
- return r.length < a.length ? [relCmd, relNums] : [absCmd, absNums];
133
- };
134
-
135
- for (const s of segments) {
136
- switch (s.c) {
137
- case 'M': {
138
- if (s.implicit) break;
139
- const x = q(s.x), y = q(s.y), z = q(s.z || 0);
140
- const abs = dim === 3 ? [x, y, z] : [x, y];
141
- const rel = dim === 3 ? [q(x - cx), q(y - cy), q(z - cz)] : [q(x - cx), q(y - cy)];
142
- const [cmd, nums] = out === '' ? ['M', abs] : pick('M', abs, 'm', rel);
143
- emit(cmd, nums);
144
- if (cmd === 'm') {
145
- cx = cx + nums[0];
146
- cy = cy + nums[1];
147
- if (dim === 3) cz = cz + nums[2];
148
- } else {
149
- cx = x;
150
- cy = y;
151
- cz = z;
152
- }
153
- sx = cx; sy = cy; sz = cz;
154
- prevType = 'M';
155
- break;
156
- }
157
- case 'L': {
158
- const x = q(s.x), y = q(s.y), z = q(s.z || 0);
159
- let cmd, nums;
160
- if (dim === 2 && Math.abs(y - cy) < EPS && Math.abs(x - cx) >= EPS) {
161
- [cmd, nums] = pick('H', [x], 'h', [q(x - cx)]);
162
- } else if (dim === 2 && Math.abs(x - cx) < EPS && Math.abs(y - cy) >= EPS) {
163
- [cmd, nums] = pick('V', [y], 'v', [q(y - cy)]);
164
- } else {
165
- const abs = dim === 3 ? [x, y, z] : [x, y];
166
- const rel = dim === 3 ? [q(x - cx), q(y - cy), q(z - cz)] : [q(x - cx), q(y - cy)];
167
- [cmd, nums] = pick('L', abs, 'l', rel);
168
- }
169
- emit(cmd, nums);
170
- const up = cmd.toUpperCase();
171
- const relative = cmd !== up;
172
- if (up === 'H') cx = relative ? cx + nums[0] : nums[0];
173
- else if (up === 'V') cy = relative ? cy + nums[0] : nums[0];
174
- else if (relative) {
175
- cx += nums[0];
176
- cy += nums[1];
177
- if (dim === 3) cz += nums[2];
178
- } else {
179
- cx = nums[0];
180
- cy = nums[1];
181
- if (dim === 3) cz = nums[2];
182
- }
183
- prevType = 'L';
184
- break;
185
- }
186
- case 'C': {
187
- const p1 = [q(s.x1), q(s.y1), q(s.z1 || 0)];
188
- const p2 = [q(s.x2), q(s.y2), q(s.z2 || 0)];
189
- const p = [q(s.x), q(s.y), q(s.z || 0)];
190
- const d = dim;
191
- const take = (v) => v.slice(0, d);
192
- const rel = (v) => [q(v[0] - cx), q(v[1] - cy), q(v[2] - cz)].slice(0, d);
193
- let reflect = false;
194
- if (prevC2 && (prevType === 'C')) {
195
- const rx = 2 * cx - prevC2[0], ry = 2 * cy - prevC2[1], rz = 2 * cz - prevC2[2];
196
- reflect = Math.abs(rx - p1[0]) < 1e-7 && Math.abs(ry - p1[1]) < 1e-7 && (d === 2 || Math.abs(rz - p1[2]) < 1e-7);
197
- } else if (prevType !== 'C') {
198
- reflect = Math.abs(cx - p1[0]) < 1e-7 && Math.abs(cy - p1[1]) < 1e-7 && (d === 2 || Math.abs(cz - p1[2]) < 1e-7);
199
- }
200
- let cmd, nums;
201
- if (reflect) [cmd, nums] = pick('S', [...take(p2), ...take(p)], 's', [...rel(p2), ...rel(p)]);
202
- else [cmd, nums] = pick('C', [...take(p1), ...take(p2), ...take(p)], 'c', [...rel(p1), ...rel(p2), ...rel(p)]);
203
- emit(cmd, nums);
204
- const relative = cmd === 'c' || cmd === 's';
205
- const k = nums.length;
206
- // reconstruct as the decoder will
207
- const base = relative ? [cx, cy, cz] : [0, 0, 0];
208
- const endP = [base[0] + nums[k - d], base[1] + nums[k - d + 1], d === 3 ? base[2] + nums[k - 1] : 0];
209
- const c2 = [base[0] + nums[k - 2 * d], base[1] + nums[k - 2 * d + 1], d === 3 ? base[2] + nums[k - d - 1] : 0];
210
- prevC2 = c2;
211
- cx = endP[0];
212
- cy = endP[1];
213
- cz = endP[2];
214
- prevType = 'C';
215
- break;
216
- }
217
- case 'Q': {
218
- const p1 = [q(s.x1), q(s.y1), q(s.z1 || 0)];
219
- const p = [q(s.x), q(s.y), q(s.z || 0)];
220
- const d = dim;
221
- const take = (v) => v.slice(0, d);
222
- const rel = (v) => [q(v[0] - cx), q(v[1] - cy), q(v[2] - cz)].slice(0, d);
223
- let reflect = false;
224
- if (prevQ1 && prevType === 'Q') {
225
- const rx = 2 * cx - prevQ1[0], ry = 2 * cy - prevQ1[1], rz = 2 * cz - prevQ1[2];
226
- reflect = Math.abs(rx - p1[0]) < 1e-7 && Math.abs(ry - p1[1]) < 1e-7 && (d === 2 || Math.abs(rz - p1[2]) < 1e-7);
227
- }
228
- let cmd, nums;
229
- if (reflect) [cmd, nums] = pick('T', take(p), 't', rel(p));
230
- else [cmd, nums] = pick('Q', [...take(p1), ...take(p)], 'q', [...rel(p1), ...rel(p)]);
231
- emit(cmd, nums);
232
- const relative = cmd === 'q' || cmd === 't';
233
- const base = relative ? [cx, cy, cz] : [0, 0, 0];
234
- const k = nums.length;
235
- const endP = [base[0] + nums[k - d], base[1] + nums[k - d + 1], d === 3 ? base[2] + nums[k - 1] : 0];
236
- prevQ1 = reflect ? [2 * cx - prevQ1[0], 2 * cy - prevQ1[1], 2 * cz - prevQ1[2]] : [base[0] + nums[0], base[1] + nums[1], d === 3 ? base[2] + nums[2] : 0];
237
- cx = endP[0];
238
- cy = endP[1];
239
- cz = endP[2];
240
- prevType = 'Q';
241
- break;
242
- }
243
- case 'A': {
244
- const x = q(s.x), y = q(s.y);
245
- const absNums = [s.rx, s.ry, s.rot, s.large, s.sweep, x, y];
246
- const relNums = [s.rx, s.ry, s.rot, s.large, s.sweep, q(x - cx), q(y - cy)];
247
- const a = 'A' + absNums.map(fmt).join(' ');
248
- const r = 'a' + relNums.map(fmt).join(' ');
249
- const useRel = r.length < a.length;
250
- const cmd = useRel ? 'a' : 'A';
251
- const nums = useRel ? relNums : absNums;
252
- // arc flags can be packed: radii, rotation, then flags without separators
253
- const strs = [fmt(nums[0]), fmt(nums[1]), fmt(nums[2])];
254
- const head = joinNumbersMinimal(strs, cmd === lastCmd ? lastNum : null);
255
- const tail = joinNumbersMinimal([fmt(nums[5]), fmt(nums[6])], null);
256
- const body = `${head} ${nums[3]}${nums[4]}${tail}`;
257
- out += cmd === lastCmd ? body : cmd + body;
258
- lastCmd = cmd;
259
- lastNum = fmt(nums[6]);
260
- if (useRel) {
261
- cx += nums[5];
262
- cy += nums[6];
263
- } else {
264
- cx = x;
265
- cy = y;
266
- }
267
- prevType = 'A';
268
- break;
269
- }
270
- case 'Z':
271
- out += 'Z';
272
- lastCmd = 'Z';
273
- lastNum = null;
274
- cx = sx;
275
- cy = sy;
276
- cz = sz;
277
- prevType = 'Z';
278
- break;
279
- default:
280
- break;
281
- }
282
- }
283
- return out;
284
- }
285
-
286
- function formatPathPretty(segments, fmt, dim) {
287
- const parts = [];
288
- const pt = (x, y, z) => (dim === 3 ? `${fmt(x)},${fmt(y)},${fmt(z || 0)}` : `${fmt(x)},${fmt(y)}`);
289
- for (const s of segments) {
290
- switch (s.c) {
291
- case 'M':
292
- if (!s.implicit) parts.push('M' + pt(s.x, s.y, s.z));
293
- break;
294
- case 'L':
295
- parts.push('L' + pt(s.x, s.y, s.z));
296
- break;
297
- case 'C':
298
- parts.push(`C${pt(s.x1, s.y1, s.z1)} ${pt(s.x2, s.y2, s.z2)} ${pt(s.x, s.y, s.z)}`);
299
- break;
300
- case 'Q':
301
- parts.push(`Q${pt(s.x1, s.y1, s.z1)} ${pt(s.x, s.y, s.z)}`);
302
- break;
303
- case 'A':
304
- parts.push(`A${fmt(s.rx)},${fmt(s.ry)} ${fmt(s.rot)} ${s.large},${s.sweep} ${pt(s.x, s.y)}`);
305
- break;
306
- case 'Z':
307
- parts.push('Z');
308
- break;
309
- default:
310
- break;
311
- }
312
- }
313
- return parts.join(' ');
314
- }
1
+ // Formatting of typed values back to text: canonical numbers, quantization,
2
+ // number lists, transform lists and a path data minifier (shortest of
3
+ // absolute/relative commands, implicit repetition, minimal separators).
4
+
5
+ import { colorToShortHex } from './colors.js';
6
+
7
+ /**
8
+ * Shortest decimal representation of x.
9
+ * @param {number} x
10
+ * @param {number|null} precision decimals to keep (quantization), null = exact
11
+ * @param {boolean} compact drop the leading zero ("0.5" -> ".5")
12
+ */
13
+ export function formatNumber(x, precision = null, compact = false) {
14
+ if (precision !== null && precision !== undefined) {
15
+ const f = 10 ** precision;
16
+ x = Math.round(x * f) / f;
17
+ }
18
+ if (x === 0 || Object.is(x, -0)) return '0';
19
+ let s = String(x);
20
+ if (s.indexOf('e') >= 0) {
21
+ s = s.replace('e+', 'e');
22
+ // prefer plain notation when it is not longer
23
+ const plain = toPlain(x);
24
+ if (plain && plain.length <= s.length) s = plain;
25
+ }
26
+ if (compact) {
27
+ if (s.startsWith('0.')) s = s.slice(1);
28
+ else if (s.startsWith('-0.')) s = '-' + s.slice(2);
29
+ }
30
+ return s;
31
+ }
32
+
33
+ /**
34
+ * Shortest decimal that converts back to the same float32 value (mesh arrays
35
+ * are float32 data: printing their double expansion would add noise digits).
36
+ */
37
+ export function formatFloat32(x, precision = null, compact = false) {
38
+ if (precision !== null && precision !== undefined) return formatNumber(x, precision, compact);
39
+ if (x === 0 || !Number.isFinite(x)) return formatNumber(x, null, compact);
40
+ // toPrecision(9) always round-trips; binary search for the fewest digits
41
+ let lo = 1, hi = 9;
42
+ while (lo < hi) {
43
+ const mid = (lo + hi) >> 1;
44
+ if (Math.fround(Number(x.toPrecision(mid))) === x) hi = mid;
45
+ else lo = mid + 1;
46
+ }
47
+ return formatNumber(Number(x.toPrecision(lo)), null, compact);
48
+ }
49
+
50
+ function toPlain(x) {
51
+ const abs = Math.abs(x);
52
+ if (abs >= 1e21 || abs < 1e-12) return null;
53
+ let s = abs.toFixed(20).replace(/0+$/, '').replace(/\.$/, '');
54
+ // guard against binary noise introduced by toFixed
55
+ if (Number(s) !== abs) return null;
56
+ return (x < 0 ? '-' : '') + s;
57
+ }
58
+
59
+ /** Join numbers with the minimal separators allowed by the SVG path grammar. */
60
+ export function joinNumbersMinimal(strs, prevLast = null) {
61
+ let out = '';
62
+ let prev = prevLast;
63
+ for (const s of strs) {
64
+ if (prev === null) out += s;
65
+ else if (s[0] === '-' || (s[0] === '.' && (prev.includes('.') || prev.includes('e')))) out += s;
66
+ else out += ' ' + s;
67
+ prev = s;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Format a flat numeric list.
74
+ * group > 1 writes "x,y,z x,y,z" (points); group === 1 with sep writes "a,b,c" or "a b c".
75
+ */
76
+ export function formatNumberList(list, { group = 1, sep = ',', precision = null, compact = false } = {}) {
77
+ const parts = new Array(list.length);
78
+ const fmt = list instanceof Float32Array ? formatFloat32 : formatNumber;
79
+ for (let i = 0; i < list.length; i++) parts[i] = fmt(list[i], precision, compact);
80
+ if (group <= 1) return parts.join(sep);
81
+ const groups = [];
82
+ for (let i = 0; i < parts.length; i += group) groups.push(parts.slice(i, i + group).join(','));
83
+ return groups.join(' ');
84
+ }
85
+
86
+ export function formatColor(c) {
87
+ return colorToShortHex(c);
88
+ }
89
+
90
+ /** Transform ops -> "translate(1,2,3) rotate(0,45,0)" */
91
+ export function formatTransformOps(ops, precision = null, compact = false) {
92
+ return ops.map((op) => `${op.fn}(${op.args.map((a) => formatNumber(a, precision, compact)).join(',')})`).join(' ');
93
+ }
94
+
95
+ const EPS = 1e-9;
96
+
97
+ /**
98
+ * Serialize absolute path segments (from parsePathData) as compact path data.
99
+ * @param {object[]} segments
100
+ * @param {{precision?: number|null, minify?: boolean, dim?: 2|3}} opts
101
+ */
102
+ export function formatPathData(segments, { precision = null, minify = true, dim = 2 } = {}) {
103
+ const fmt = (v) => formatNumber(v, precision, minify);
104
+ const q = (v) => (precision === null || precision === undefined ? v : Math.round(v * 10 ** precision) / 10 ** precision);
105
+ if (!minify) return formatPathPretty(segments, fmt, dim);
106
+
107
+ let out = '';
108
+ let lastCmd = '';
109
+ let lastNum = null;
110
+ // reconstructed (decoder side) current point and subpath start
111
+ let cx = 0, cy = 0, cz = 0;
112
+ let sx = 0, sy = 0, sz = 0;
113
+ let prevC2 = null; // reflected control point candidates
114
+ let prevQ1 = null;
115
+ let prevType = '';
116
+
117
+ const emit = (cmd, nums) => {
118
+ const strs = nums.map(fmt);
119
+ // a repeated command (other than moveto/closepath) can omit its letter, and
120
+ // so can a lineto directly after a moveto of the same relativity
121
+ const omit =
122
+ (cmd === lastCmd && cmd !== 'M' && cmd !== 'm') ||
123
+ (lastCmd === 'M' && cmd === 'L') ||
124
+ (lastCmd === 'm' && cmd === 'l');
125
+ out += omit ? joinNumbersMinimal(strs, lastNum) : cmd + joinNumbersMinimal(strs, null);
126
+ if (strs.length) lastNum = strs[strs.length - 1];
127
+ lastCmd = cmd;
128
+ };
129
+ const pick = (absCmd, absNums, relCmd, relNums) => {
130
+ const a = absCmd + absNums.map(fmt).join(' ');
131
+ const r = relCmd + relNums.map(fmt).join(' ');
132
+ return r.length < a.length ? [relCmd, relNums] : [absCmd, absNums];
133
+ };
134
+
135
+ for (const s of segments) {
136
+ switch (s.c) {
137
+ case 'M': {
138
+ if (s.implicit) break;
139
+ const x = q(s.x), y = q(s.y), z = q(s.z || 0);
140
+ const abs = dim === 3 ? [x, y, z] : [x, y];
141
+ const rel = dim === 3 ? [q(x - cx), q(y - cy), q(z - cz)] : [q(x - cx), q(y - cy)];
142
+ const [cmd, nums] = out === '' ? ['M', abs] : pick('M', abs, 'm', rel);
143
+ emit(cmd, nums);
144
+ if (cmd === 'm') {
145
+ cx = cx + nums[0];
146
+ cy = cy + nums[1];
147
+ if (dim === 3) cz = cz + nums[2];
148
+ } else {
149
+ cx = x;
150
+ cy = y;
151
+ cz = z;
152
+ }
153
+ sx = cx; sy = cy; sz = cz;
154
+ prevType = 'M';
155
+ break;
156
+ }
157
+ case 'L': {
158
+ const x = q(s.x), y = q(s.y), z = q(s.z || 0);
159
+ let cmd, nums;
160
+ if (dim === 2 && Math.abs(y - cy) < EPS && Math.abs(x - cx) >= EPS) {
161
+ [cmd, nums] = pick('H', [x], 'h', [q(x - cx)]);
162
+ } else if (dim === 2 && Math.abs(x - cx) < EPS && Math.abs(y - cy) >= EPS) {
163
+ [cmd, nums] = pick('V', [y], 'v', [q(y - cy)]);
164
+ } else {
165
+ const abs = dim === 3 ? [x, y, z] : [x, y];
166
+ const rel = dim === 3 ? [q(x - cx), q(y - cy), q(z - cz)] : [q(x - cx), q(y - cy)];
167
+ [cmd, nums] = pick('L', abs, 'l', rel);
168
+ }
169
+ emit(cmd, nums);
170
+ const up = cmd.toUpperCase();
171
+ const relative = cmd !== up;
172
+ if (up === 'H') cx = relative ? cx + nums[0] : nums[0];
173
+ else if (up === 'V') cy = relative ? cy + nums[0] : nums[0];
174
+ else if (relative) {
175
+ cx += nums[0];
176
+ cy += nums[1];
177
+ if (dim === 3) cz += nums[2];
178
+ } else {
179
+ cx = nums[0];
180
+ cy = nums[1];
181
+ if (dim === 3) cz = nums[2];
182
+ }
183
+ prevType = 'L';
184
+ break;
185
+ }
186
+ case 'C': {
187
+ const p1 = [q(s.x1), q(s.y1), q(s.z1 || 0)];
188
+ const p2 = [q(s.x2), q(s.y2), q(s.z2 || 0)];
189
+ const p = [q(s.x), q(s.y), q(s.z || 0)];
190
+ const d = dim;
191
+ const take = (v) => v.slice(0, d);
192
+ const rel = (v) => [q(v[0] - cx), q(v[1] - cy), q(v[2] - cz)].slice(0, d);
193
+ let reflect = false;
194
+ if (prevC2 && (prevType === 'C')) {
195
+ const rx = 2 * cx - prevC2[0], ry = 2 * cy - prevC2[1], rz = 2 * cz - prevC2[2];
196
+ reflect = Math.abs(rx - p1[0]) < 1e-7 && Math.abs(ry - p1[1]) < 1e-7 && (d === 2 || Math.abs(rz - p1[2]) < 1e-7);
197
+ } else if (prevType !== 'C') {
198
+ reflect = Math.abs(cx - p1[0]) < 1e-7 && Math.abs(cy - p1[1]) < 1e-7 && (d === 2 || Math.abs(cz - p1[2]) < 1e-7);
199
+ }
200
+ let cmd, nums;
201
+ if (reflect) [cmd, nums] = pick('S', [...take(p2), ...take(p)], 's', [...rel(p2), ...rel(p)]);
202
+ else [cmd, nums] = pick('C', [...take(p1), ...take(p2), ...take(p)], 'c', [...rel(p1), ...rel(p2), ...rel(p)]);
203
+ emit(cmd, nums);
204
+ const relative = cmd === 'c' || cmd === 's';
205
+ const k = nums.length;
206
+ // reconstruct as the decoder will
207
+ const base = relative ? [cx, cy, cz] : [0, 0, 0];
208
+ const endP = [base[0] + nums[k - d], base[1] + nums[k - d + 1], d === 3 ? base[2] + nums[k - 1] : 0];
209
+ const c2 = [base[0] + nums[k - 2 * d], base[1] + nums[k - 2 * d + 1], d === 3 ? base[2] + nums[k - d - 1] : 0];
210
+ prevC2 = c2;
211
+ cx = endP[0];
212
+ cy = endP[1];
213
+ cz = endP[2];
214
+ prevType = 'C';
215
+ break;
216
+ }
217
+ case 'Q': {
218
+ const p1 = [q(s.x1), q(s.y1), q(s.z1 || 0)];
219
+ const p = [q(s.x), q(s.y), q(s.z || 0)];
220
+ const d = dim;
221
+ const take = (v) => v.slice(0, d);
222
+ const rel = (v) => [q(v[0] - cx), q(v[1] - cy), q(v[2] - cz)].slice(0, d);
223
+ let reflect = false;
224
+ if (prevQ1 && prevType === 'Q') {
225
+ const rx = 2 * cx - prevQ1[0], ry = 2 * cy - prevQ1[1], rz = 2 * cz - prevQ1[2];
226
+ reflect = Math.abs(rx - p1[0]) < 1e-7 && Math.abs(ry - p1[1]) < 1e-7 && (d === 2 || Math.abs(rz - p1[2]) < 1e-7);
227
+ }
228
+ let cmd, nums;
229
+ if (reflect) [cmd, nums] = pick('T', take(p), 't', rel(p));
230
+ else [cmd, nums] = pick('Q', [...take(p1), ...take(p)], 'q', [...rel(p1), ...rel(p)]);
231
+ emit(cmd, nums);
232
+ const relative = cmd === 'q' || cmd === 't';
233
+ const base = relative ? [cx, cy, cz] : [0, 0, 0];
234
+ const k = nums.length;
235
+ const endP = [base[0] + nums[k - d], base[1] + nums[k - d + 1], d === 3 ? base[2] + nums[k - 1] : 0];
236
+ prevQ1 = reflect ? [2 * cx - prevQ1[0], 2 * cy - prevQ1[1], 2 * cz - prevQ1[2]] : [base[0] + nums[0], base[1] + nums[1], d === 3 ? base[2] + nums[2] : 0];
237
+ cx = endP[0];
238
+ cy = endP[1];
239
+ cz = endP[2];
240
+ prevType = 'Q';
241
+ break;
242
+ }
243
+ case 'A': {
244
+ const x = q(s.x), y = q(s.y);
245
+ const absNums = [s.rx, s.ry, s.rot, s.large, s.sweep, x, y];
246
+ const relNums = [s.rx, s.ry, s.rot, s.large, s.sweep, q(x - cx), q(y - cy)];
247
+ const a = 'A' + absNums.map(fmt).join(' ');
248
+ const r = 'a' + relNums.map(fmt).join(' ');
249
+ const useRel = r.length < a.length;
250
+ const cmd = useRel ? 'a' : 'A';
251
+ const nums = useRel ? relNums : absNums;
252
+ // arc flags can be packed: radii, rotation, then flags without separators
253
+ const strs = [fmt(nums[0]), fmt(nums[1]), fmt(nums[2])];
254
+ const head = joinNumbersMinimal(strs, cmd === lastCmd ? lastNum : null);
255
+ const tail = joinNumbersMinimal([fmt(nums[5]), fmt(nums[6])], null);
256
+ const body = `${head} ${nums[3]}${nums[4]}${tail}`;
257
+ out += cmd === lastCmd ? body : cmd + body;
258
+ lastCmd = cmd;
259
+ lastNum = fmt(nums[6]);
260
+ if (useRel) {
261
+ cx += nums[5];
262
+ cy += nums[6];
263
+ } else {
264
+ cx = x;
265
+ cy = y;
266
+ }
267
+ prevType = 'A';
268
+ break;
269
+ }
270
+ case 'Z':
271
+ out += 'Z';
272
+ lastCmd = 'Z';
273
+ lastNum = null;
274
+ cx = sx;
275
+ cy = sy;
276
+ cz = sz;
277
+ prevType = 'Z';
278
+ break;
279
+ default:
280
+ break;
281
+ }
282
+ }
283
+ return out;
284
+ }
285
+
286
+ function formatPathPretty(segments, fmt, dim) {
287
+ const parts = [];
288
+ const pt = (x, y, z) => (dim === 3 ? `${fmt(x)},${fmt(y)},${fmt(z || 0)}` : `${fmt(x)},${fmt(y)}`);
289
+ for (const s of segments) {
290
+ switch (s.c) {
291
+ case 'M':
292
+ if (!s.implicit) parts.push('M' + pt(s.x, s.y, s.z));
293
+ break;
294
+ case 'L':
295
+ parts.push('L' + pt(s.x, s.y, s.z));
296
+ break;
297
+ case 'C':
298
+ parts.push(`C${pt(s.x1, s.y1, s.z1)} ${pt(s.x2, s.y2, s.z2)} ${pt(s.x, s.y, s.z)}`);
299
+ break;
300
+ case 'Q':
301
+ parts.push(`Q${pt(s.x1, s.y1, s.z1)} ${pt(s.x, s.y, s.z)}`);
302
+ break;
303
+ case 'A':
304
+ parts.push(`A${fmt(s.rx)},${fmt(s.ry)} ${fmt(s.rot)} ${s.large},${s.sweep} ${pt(s.x, s.y)}`);
305
+ break;
306
+ case 'Z':
307
+ parts.push('Z');
308
+ break;
309
+ default:
310
+ break;
311
+ }
312
+ }
313
+ return parts.join(' ');
314
+ }