morphicons 0.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.
@@ -0,0 +1,1099 @@
1
+ //#region src/core/interpolate.ts
2
+ /** Preallocated output buffers for a plan (zero allocation per frame). */
3
+ function allocOutputs(plan) {
4
+ return plan.items.map(() => new Float64Array(2 * plan.n));
5
+ }
6
+ function interpPolar(plan, t, out) {
7
+ for (let k = 0; k < plan.items.length; k++) {
8
+ const it = plan.items[k];
9
+ const o = out[k];
10
+ const n = plan.n;
11
+ const cx = it.ca[0] + (it.cb[0] - it.ca[0]) * t;
12
+ const cy = it.ca[1] + (it.cb[1] - it.ca[1]) * t;
13
+ const s = Math.exp(it.lnSigma * t);
14
+ const ang = it.theta * t;
15
+ const cos = Math.cos(ang) * s;
16
+ const sin = Math.sin(ang) * s;
17
+ for (let i = 0; i < n; i++) {
18
+ const px = it.aC[2 * i] + (it.bT[2 * i] - it.aC[2 * i]) * t;
19
+ const py = it.aC[2 * i + 1] + (it.bT[2 * i + 1] - it.aC[2 * i + 1]) * t;
20
+ o[2 * i] = cx + px * cos - py * sin;
21
+ o[2 * i + 1] = cy + px * sin + py * cos;
22
+ }
23
+ }
24
+ }
25
+ /** Raw coordinate lerp (same correspondence, no decomposition). */
26
+ function interpLinear(plan, t, out) {
27
+ for (let k = 0; k < plan.items.length; k++) {
28
+ const it = plan.items[k];
29
+ const o = out[k];
30
+ const n = plan.n;
31
+ for (let i = 0; i < n; i++) {
32
+ o[2 * i] = it.a[2 * i] + (it.bO[2 * i] - it.a[2 * i]) * t;
33
+ o[2 * i + 1] = it.a[2 * i + 1] + (it.bO[2 * i + 1] - it.a[2 * i + 1]) * t;
34
+ }
35
+ }
36
+ }
37
+ //#endregion
38
+ //#region src/core/parse.ts
39
+ const COMMANDS = "MmLlHhVvCcSsQqTtAaZz";
40
+ function parsePath(d) {
41
+ const subs = [];
42
+ const n = d.length;
43
+ let i = 0;
44
+ let cx = 0;
45
+ let cy = 0;
46
+ let sx = 0;
47
+ let sy = 0;
48
+ let cur = null;
49
+ let cmd = "";
50
+ let px = 0;
51
+ let py = 0;
52
+ let prev = "";
53
+ let started = false;
54
+ const err = (msg) => {
55
+ throw new Error(`morphicons: ${msg} at d[${i}]`);
56
+ };
57
+ const isDigit = (c) => c >= 48 && c <= 57;
58
+ const skip = () => {
59
+ while (i < n) {
60
+ const c = d.charCodeAt(i);
61
+ if (c === 32 || c === 9 || c === 10 || c === 13 || c === 12 || c === 44) i++;
62
+ else break;
63
+ }
64
+ };
65
+ const num = () => {
66
+ skip();
67
+ const start = i;
68
+ if (i < n && (d[i] === "+" || d[i] === "-")) i++;
69
+ let dig = false;
70
+ while (i < n && isDigit(d.charCodeAt(i))) {
71
+ i++;
72
+ dig = true;
73
+ }
74
+ if (i < n && d[i] === ".") {
75
+ i++;
76
+ while (i < n && isDigit(d.charCodeAt(i))) {
77
+ i++;
78
+ dig = true;
79
+ }
80
+ }
81
+ if (!dig) err("expected number");
82
+ if (i < n && (d[i] === "e" || d[i] === "E")) {
83
+ const save = i;
84
+ i++;
85
+ if (i < n && (d[i] === "+" || d[i] === "-")) i++;
86
+ let ed = false;
87
+ while (i < n && isDigit(d.charCodeAt(i))) {
88
+ i++;
89
+ ed = true;
90
+ }
91
+ if (!ed) i = save;
92
+ }
93
+ return Number(d.slice(start, i));
94
+ };
95
+ const flag = () => {
96
+ skip();
97
+ const c = d[i];
98
+ if (c === "0" || c === "1") {
99
+ i++;
100
+ return c === "1" ? 1 : 0;
101
+ }
102
+ return err("expected arc flag (0|1)");
103
+ };
104
+ const open = () => {
105
+ if (!started) err("path must start with M/m");
106
+ if (!cur) {
107
+ cur = {
108
+ x0: cx,
109
+ y0: cy,
110
+ segs: [],
111
+ closed: false
112
+ };
113
+ subs.push(cur);
114
+ }
115
+ return cur;
116
+ };
117
+ let rel = false;
118
+ const nx = () => num() + (rel ? cx : 0);
119
+ const ny = () => num() + (rel ? cy : 0);
120
+ while (true) {
121
+ skip();
122
+ if (i >= n) break;
123
+ const ch = d[i];
124
+ if (COMMANDS.includes(ch)) {
125
+ cmd = ch;
126
+ i++;
127
+ } else if (cmd === "") err("path must start with M/m");
128
+ else if (cmd === "M") cmd = "L";
129
+ else if (cmd === "m") cmd = "l";
130
+ else if (cmd === "Z" || cmd === "z") err("stray data after Z");
131
+ rel = cmd >= "a";
132
+ switch (rel ? cmd.toUpperCase() : cmd) {
133
+ case "M": {
134
+ started = true;
135
+ const x = nx();
136
+ const y = ny();
137
+ cx = x;
138
+ cy = y;
139
+ sx = x;
140
+ sy = y;
141
+ cur = {
142
+ x0: x,
143
+ y0: y,
144
+ segs: [],
145
+ closed: false
146
+ };
147
+ subs.push(cur);
148
+ prev = "";
149
+ break;
150
+ }
151
+ case "L": {
152
+ const x = nx();
153
+ const y = ny();
154
+ open().segs.push([
155
+ "L",
156
+ x,
157
+ y
158
+ ]);
159
+ cx = x;
160
+ cy = y;
161
+ prev = "";
162
+ break;
163
+ }
164
+ case "H": {
165
+ const x = nx();
166
+ open().segs.push([
167
+ "L",
168
+ x,
169
+ cy
170
+ ]);
171
+ cx = x;
172
+ prev = "";
173
+ break;
174
+ }
175
+ case "V": {
176
+ const y = ny();
177
+ open().segs.push([
178
+ "L",
179
+ cx,
180
+ y
181
+ ]);
182
+ cy = y;
183
+ prev = "";
184
+ break;
185
+ }
186
+ case "C":
187
+ case "S": {
188
+ let x1;
189
+ let y1;
190
+ if (cmd === "C" || cmd === "c") {
191
+ x1 = nx();
192
+ y1 = ny();
193
+ } else {
194
+ x1 = prev === "C" ? 2 * cx - px : cx;
195
+ y1 = prev === "C" ? 2 * cy - py : cy;
196
+ }
197
+ const x2 = nx();
198
+ const y2 = ny();
199
+ const x = nx();
200
+ const y = ny();
201
+ open().segs.push([
202
+ "C",
203
+ x1,
204
+ y1,
205
+ x2,
206
+ y2,
207
+ x,
208
+ y
209
+ ]);
210
+ px = x2;
211
+ py = y2;
212
+ cx = x;
213
+ cy = y;
214
+ prev = "C";
215
+ break;
216
+ }
217
+ case "Q":
218
+ case "T": {
219
+ let x1;
220
+ let y1;
221
+ if (cmd === "Q" || cmd === "q") {
222
+ x1 = nx();
223
+ y1 = ny();
224
+ } else {
225
+ x1 = prev === "Q" ? 2 * cx - px : cx;
226
+ y1 = prev === "Q" ? 2 * cy - py : cy;
227
+ }
228
+ const x = nx();
229
+ const y = ny();
230
+ open().segs.push([
231
+ "Q",
232
+ x1,
233
+ y1,
234
+ x,
235
+ y
236
+ ]);
237
+ px = x1;
238
+ py = y1;
239
+ cx = x;
240
+ cy = y;
241
+ prev = "Q";
242
+ break;
243
+ }
244
+ case "A": {
245
+ const rx = num();
246
+ const ry = num();
247
+ const rot = num();
248
+ const large = flag();
249
+ const sweep = flag();
250
+ const x = nx();
251
+ const y = ny();
252
+ open().segs.push([
253
+ "A",
254
+ rx,
255
+ ry,
256
+ rot,
257
+ large,
258
+ sweep,
259
+ x,
260
+ y
261
+ ]);
262
+ cx = x;
263
+ cy = y;
264
+ prev = "";
265
+ break;
266
+ }
267
+ case "Z":
268
+ if (cur) {
269
+ cur.closed = true;
270
+ cur = null;
271
+ }
272
+ cx = sx;
273
+ cy = sy;
274
+ prev = "";
275
+ break;
276
+ default: err(`unsupported command "${cmd}"`);
277
+ }
278
+ }
279
+ return subs.filter((s) => s.segs.length > 0);
280
+ }
281
+ //#endregion
282
+ //#region src/core/normalize.ts
283
+ /** Control-point offset for a quarter circle: (4/3)·tan(π/8) ≈ 0.5523. */
284
+ const KAPPA = 4 / 3 * Math.tan(Math.PI / 8);
285
+ const TAU = 2 * Math.PI;
286
+ function builder(x0, y0) {
287
+ const pts = [x0, y0];
288
+ let cx = x0;
289
+ let cy = y0;
290
+ const cubic = (x1, y1, x2, y2, x, y) => {
291
+ pts.push(x1, y1, x2, y2, x, y);
292
+ cx = x;
293
+ cy = y;
294
+ };
295
+ const line = (x, y) => {
296
+ if (Math.abs(x - cx) < 1e-12 && Math.abs(y - cy) < 1e-12) return;
297
+ cubic(cx + (x - cx) / 3, cy + (y - cy) / 3, cx + 2 * (x - cx) / 3, cy + 2 * (y - cy) / 3, x, y);
298
+ };
299
+ const quad = (x1, y1, x, y) => {
300
+ cubic(cx + 2 / 3 * (x1 - cx), cy + 2 / 3 * (y1 - cy), x + 2 / 3 * (x1 - x), y + 2 / 3 * (y1 - y), x, y);
301
+ };
302
+ const arc = (rx0, ry0, rotDeg, large, sweep, x, y) => {
303
+ const x1 = cx;
304
+ const y1 = cy;
305
+ if (Math.abs(x - x1) < 1e-12 && Math.abs(y - y1) < 1e-12) return;
306
+ let rx = Math.abs(rx0);
307
+ let ry = Math.abs(ry0);
308
+ if (rx < 1e-12 || ry < 1e-12) {
309
+ line(x, y);
310
+ return;
311
+ }
312
+ const phi = rotDeg * Math.PI / 180;
313
+ const cosP = Math.cos(phi);
314
+ const sinP = Math.sin(phi);
315
+ const hx = (x1 - x) / 2;
316
+ const hy = (y1 - y) / 2;
317
+ const x1p = cosP * hx + sinP * hy;
318
+ const y1p = -sinP * hx + cosP * hy;
319
+ const lam = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
320
+ if (lam > 1) {
321
+ const s = Math.sqrt(lam);
322
+ rx *= s;
323
+ ry *= s;
324
+ }
325
+ const rx2 = rx * rx;
326
+ const ry2 = ry * ry;
327
+ const xp2 = x1p * x1p;
328
+ const yp2 = y1p * y1p;
329
+ let rad = (rx2 * ry2 - rx2 * yp2 - ry2 * xp2) / (rx2 * yp2 + ry2 * xp2);
330
+ if (rad < 0) rad = 0;
331
+ const co = (large === sweep ? -1 : 1) * Math.sqrt(rad);
332
+ const cxp = co * rx * y1p / ry;
333
+ const cyp = -co * ry * x1p / rx;
334
+ const ccx = cosP * cxp - sinP * cyp + (x1 + x) / 2;
335
+ const ccy = sinP * cxp + cosP * cyp + (y1 + y) / 2;
336
+ const th1 = Math.atan2((y1p - cyp) / ry, (x1p - cxp) / rx);
337
+ let dth = Math.atan2((-y1p - cyp) / ry, (-x1p - cxp) / rx) - th1;
338
+ if (sweep === 0 && dth > 0) dth -= TAU;
339
+ else if (sweep === 1 && dth < 0) dth += TAU;
340
+ const slices = Math.max(1, Math.ceil(Math.abs(dth) / (Math.PI / 2) - 1e-9));
341
+ const delta = dth / slices;
342
+ const alpha = 4 / 3 * Math.tan(delta / 4);
343
+ const ex = (t) => ccx + rx * Math.cos(t) * cosP - ry * Math.sin(t) * sinP;
344
+ const ey = (t) => ccy + rx * Math.cos(t) * sinP + ry * Math.sin(t) * cosP;
345
+ const dx = (t) => -rx * Math.sin(t) * cosP - ry * Math.cos(t) * sinP;
346
+ const dy = (t) => -rx * Math.sin(t) * sinP + ry * Math.cos(t) * cosP;
347
+ let t0 = th1;
348
+ let p0x = x1;
349
+ let p0y = y1;
350
+ for (let s = 1; s <= slices; s++) {
351
+ const t1 = th1 + delta * s;
352
+ const p1x = s === slices ? x : ex(t1);
353
+ const p1y = s === slices ? y : ey(t1);
354
+ cubic(p0x + alpha * dx(t0), p0y + alpha * dy(t0), p1x - alpha * dx(t1), p1y - alpha * dy(t1), p1x, p1y);
355
+ t0 = t1;
356
+ p0x = p1x;
357
+ p0y = p1y;
358
+ }
359
+ };
360
+ const finish = (closed) => {
361
+ if (closed) line(pts[0], pts[1]);
362
+ if (pts.length < 8) return null;
363
+ return {
364
+ pts: Float64Array.from(pts),
365
+ closed
366
+ };
367
+ };
368
+ return [
369
+ cubic,
370
+ line,
371
+ quad,
372
+ arc,
373
+ finish
374
+ ];
375
+ }
376
+ function lowerSubpath(raw) {
377
+ const [cubic, line, quad, arc, finish] = builder(raw.x0, raw.y0);
378
+ for (const s of raw.segs) switch (s[0]) {
379
+ case "L":
380
+ line(s[1], s[2]);
381
+ break;
382
+ case "C":
383
+ cubic(s[1], s[2], s[3], s[4], s[5], s[6]);
384
+ break;
385
+ case "Q":
386
+ quad(s[1], s[2], s[3], s[4]);
387
+ break;
388
+ case "A": arc(s[1], s[2], s[3], s[4], s[5], s[6], s[7]);
389
+ }
390
+ return finish(raw.closed);
391
+ }
392
+ function attrNum(attrs, key, fallback = 0) {
393
+ const v = attrs[key];
394
+ if (v === void 0) return fallback;
395
+ const x = typeof v === "number" ? v : Number(v);
396
+ return Number.isFinite(x) ? x : fallback;
397
+ }
398
+ function parsePoints(v) {
399
+ const s = String(v ?? "").trim();
400
+ if (!s) return [];
401
+ const nums = s.split(/[\s,]+/).map(Number);
402
+ if (nums.some((x) => !Number.isFinite(x))) throw new Error(`morphicons: invalid points: "${s}"`);
403
+ return nums;
404
+ }
405
+ function polyPath(nums, closed) {
406
+ if (nums.length < 4) return null;
407
+ const [, line, , , finish] = builder(nums[0], nums[1]);
408
+ for (let i = 2; i + 1 < nums.length; i += 2) line(nums[i], nums[i + 1]);
409
+ return finish(closed);
410
+ }
411
+ function ellipsePath(cx, cy, rx, ry) {
412
+ if (rx < 1e-12 || ry < 1e-12) return null;
413
+ const kx = KAPPA * rx;
414
+ const ky = KAPPA * ry;
415
+ const e = cx + rx;
416
+ const w = cx - rx;
417
+ const s = cy + ry;
418
+ const n = cy - ry;
419
+ const [cubic, , , , finish] = builder(e, cy);
420
+ cubic(e, cy + ky, cx + kx, s, cx, s);
421
+ cubic(cx - kx, s, w, cy + ky, w, cy);
422
+ cubic(w, cy - ky, cx - kx, n, cx, n);
423
+ cubic(cx + kx, n, e, cy - ky, e, cy);
424
+ return finish(true);
425
+ }
426
+ function rectPath(attrs) {
427
+ const x = attrNum(attrs, "x");
428
+ const y = attrNum(attrs, "y");
429
+ const w = attrNum(attrs, "width");
430
+ const h = attrNum(attrs, "height");
431
+ if (w < 1e-12 || h < 1e-12) return null;
432
+ let rx = attrNum(attrs, "rx", NaN);
433
+ let ry = attrNum(attrs, "ry", NaN);
434
+ if (Number.isNaN(rx)) rx = Number.isNaN(ry) ? 0 : ry;
435
+ if (Number.isNaN(ry)) ry = rx;
436
+ rx = Math.min(Math.max(rx, 0), w / 2);
437
+ ry = Math.min(Math.max(ry, 0), h / 2);
438
+ if (rx < 1e-12 || ry < 1e-12) return polyPath([
439
+ x,
440
+ y,
441
+ x + w,
442
+ y,
443
+ x + w,
444
+ y + h,
445
+ x,
446
+ y + h
447
+ ], true);
448
+ const xa = x + rx;
449
+ const xb = x + w - rx;
450
+ const xr = x + w;
451
+ const ya = y + ry;
452
+ const yb = y + h - ry;
453
+ const yd = y + h;
454
+ const kx = KAPPA * rx;
455
+ const ky = KAPPA * ry;
456
+ const [cubic, line, , , finish] = builder(xa, y);
457
+ line(xb, y);
458
+ cubic(xb + kx, y, xr, ya - ky, xr, ya);
459
+ line(xr, yb);
460
+ cubic(xr, yb + ky, xb + kx, yd, xb, yd);
461
+ line(xa, yd);
462
+ cubic(xa - kx, yd, x, yb + ky, x, yb);
463
+ line(x, ya);
464
+ cubic(x, ya - ky, xa - kx, y, xa, y);
465
+ return finish(true);
466
+ }
467
+ /** Icon (IconNode or `d` string) → list of cubic subpaths. */
468
+ function iconToCubics(input) {
469
+ const out = [];
470
+ const push = (p) => {
471
+ if (p) out.push(p);
472
+ };
473
+ if (typeof input === "string") {
474
+ for (const s of parsePath(input)) push(lowerSubpath(s));
475
+ return out;
476
+ }
477
+ for (const [tag, attrs] of input) switch (tag) {
478
+ case "path":
479
+ for (const s of parsePath(String(attrs.d ?? ""))) push(lowerSubpath(s));
480
+ break;
481
+ case "line": {
482
+ const [, line, , , finish] = builder(attrNum(attrs, "x1"), attrNum(attrs, "y1"));
483
+ line(attrNum(attrs, "x2"), attrNum(attrs, "y2"));
484
+ push(finish(false));
485
+ break;
486
+ }
487
+ case "circle": {
488
+ const r = attrNum(attrs, "r");
489
+ push(ellipsePath(attrNum(attrs, "cx"), attrNum(attrs, "cy"), r, r));
490
+ break;
491
+ }
492
+ case "ellipse":
493
+ push(ellipsePath(attrNum(attrs, "cx"), attrNum(attrs, "cy"), attrNum(attrs, "rx"), attrNum(attrs, "ry")));
494
+ break;
495
+ case "rect":
496
+ push(rectPath(attrs));
497
+ break;
498
+ case "polyline":
499
+ push(polyPath(parsePoints(attrs.points), false));
500
+ break;
501
+ case "polygon":
502
+ push(polyPath(parsePoints(attrs.points), true));
503
+ break;
504
+ default: throw new Error(`morphicons: unsupported tag <${tag}>`);
505
+ }
506
+ return out;
507
+ }
508
+ //#endregion
509
+ //#region src/core/plan.ts
510
+ /** Weight of |ΔL| in the subpath pairing cost. */
511
+ const LEN_WEIGHT = .35;
512
+ /** λ of the minimal-rotation tie-break: score = res + λ·|θ|/π.
513
+ * It exists because shapes symmetric under inversion (lines) tie in
514
+ * residual for both traversal orientations yet produce different rotations. */
515
+ const LAMBDA = .05;
516
+ /** Global residual below which the whole icon counts as congruent and the
517
+ * plan shares (θ, σ) across all items (hybrid variant of Procrustes). */
518
+ const GLOBAL_EPS = .005;
519
+ /** Bounds for exhaustive matching; above them it falls back to greedy with
520
+ * repair. 8! = 40 320 permutations / 1e5 assignments — both sub-ms. */
521
+ const PERM_MAX = 8;
522
+ const SURJ_MAX = 1e5;
523
+ function centroid(p) {
524
+ const n = p.length / 2;
525
+ let cx = 0;
526
+ let cy = 0;
527
+ for (let i = 0; i < n; i++) {
528
+ cx += p[2 * i];
529
+ cy += p[2 * i + 1];
530
+ }
531
+ return [cx / n, cy / n];
532
+ }
533
+ function polyLen(p) {
534
+ const n = p.length / 2;
535
+ let L = 0;
536
+ for (let i = 1; i < n; i++) L += Math.hypot(p[2 * i] - p[2 * i - 2], p[2 * i + 1] - p[2 * i - 1]);
537
+ return L;
538
+ }
539
+ function reversePts(p) {
540
+ const n = p.length / 2;
541
+ const out = new Float64Array(2 * n);
542
+ for (let i = 0; i < n; i++) {
543
+ out[2 * i] = p[2 * (n - 1 - i)];
544
+ out[2 * i + 1] = p[2 * (n - 1 - i) + 1];
545
+ }
546
+ return out;
547
+ }
548
+ /** Circular re-indexing of a loop: out[i] = p[(i+off) mod n]. Same point
549
+ * set, different cut point — the circular degree of freedom of closed paths. */
550
+ function rotatePts(p, off) {
551
+ const n = p.length / 2;
552
+ const out = new Float64Array(2 * n);
553
+ for (let i = 0; i < n; i++) {
554
+ const j = (i + off) % n;
555
+ out[2 * i] = p[2 * j];
556
+ out[2 * i + 1] = p[2 * j + 1];
557
+ }
558
+ return out;
559
+ }
560
+ /** Optimal similarity (θ, σ) minimizing Σ|σ·R(θ)·(a−c_A) − (b−c_B)|².
561
+ * θ* = atan2(S_xy − S_yx, S_xx + S_yy); σ* by zero derivative.
562
+ * res = RMS residual normalized by b's energy (0 → same shape). */
563
+ function procrustes(a, b, ca, cb) {
564
+ const n = a.length / 2;
565
+ let sxx = 0;
566
+ let sxy = 0;
567
+ let syx = 0;
568
+ let syy = 0;
569
+ let na = 0;
570
+ let nb = 0;
571
+ for (let i = 0; i < n; i++) {
572
+ const ax = a[2 * i] - ca[0];
573
+ const ay = a[2 * i + 1] - ca[1];
574
+ const bx = b[2 * i] - cb[0];
575
+ const by = b[2 * i + 1] - cb[1];
576
+ sxx += ax * bx;
577
+ syy += ay * by;
578
+ sxy += ax * by;
579
+ syx += ay * bx;
580
+ na += ax * ax + ay * ay;
581
+ nb += bx * bx + by * by;
582
+ }
583
+ const theta = Math.atan2(sxy - syx, sxx + syy);
584
+ const num = Math.cos(theta) * (sxx + syy) + Math.sin(theta) * (sxy - syx);
585
+ let sigma = na > 1e-12 ? num / na : 1;
586
+ if (!(sigma > 1e-6)) sigma = 1e-6;
587
+ const res2 = Math.max(0, sigma * sigma * na - 2 * sigma * num + nb);
588
+ const res = nb > 1e-12 ? Math.sqrt(res2 / nb) : 0;
589
+ return {
590
+ theta,
591
+ sigma,
592
+ res
593
+ };
594
+ }
595
+ /** Best index-to-index correspondence between a and b: tries both traversal
596
+ * directions and, if there is a closed loop, its N circular offsets,
597
+ * scoring with score = res + λ·|θ|/π. The freedom is applied to ONE cloud
598
+ * — the closed one (b if both are); varying both at once would be
599
+ * redundant. */
600
+ function alignPair(aPts, bPts, aClosed = false, bClosed = false) {
601
+ const ca = centroid(aPts);
602
+ const cb = centroid(bPts);
603
+ const varyA = aClosed && !bClosed;
604
+ const base = varyA ? aPts : bPts;
605
+ const offs = aClosed || bClosed ? base.length / 2 : 1;
606
+ let bestScore = Number.POSITIVE_INFINITY;
607
+ let best = base;
608
+ let sim = {
609
+ theta: 0,
610
+ sigma: 1,
611
+ res: 0
612
+ };
613
+ for (let dir = 0; dir < 2; dir++) {
614
+ const walk = dir ? reversePts(base) : base;
615
+ for (let off = 0; off < offs; off++) {
616
+ const cand = off ? rotatePts(walk, off) : walk;
617
+ const s = varyA ? procrustes(cand, bPts, ca, cb) : procrustes(aPts, cand, ca, cb);
618
+ const score = s.res + LAMBDA * Math.abs(s.theta) / Math.PI;
619
+ if (score < bestScore) {
620
+ bestScore = score;
621
+ best = cand;
622
+ sim = s;
623
+ }
624
+ }
625
+ }
626
+ return varyA ? {
627
+ ca,
628
+ cb,
629
+ a: best,
630
+ b: bPts,
631
+ ...sim
632
+ } : {
633
+ ca,
634
+ cb,
635
+ a: aPts,
636
+ b: best,
637
+ ...sim
638
+ };
639
+ }
640
+ function costMatrix(A, B) {
641
+ const cbs = B.map(centroid);
642
+ const lbs = B.map(polyLen);
643
+ return A.map((a) => {
644
+ const ca = centroid(a);
645
+ const la = polyLen(a);
646
+ return cbs.map((cb, j) => Math.hypot(ca[0] - cb[0], ca[1] - cb[1]) + LEN_WEIGHT * Math.abs(la - lbs[j]));
647
+ });
648
+ }
649
+ function bestPermutation(C) {
650
+ const n = C.length;
651
+ if (n > PERM_MAX) {
652
+ const pairs = [];
653
+ for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) pairs.push([
654
+ C[i][j],
655
+ i,
656
+ j
657
+ ]);
658
+ pairs.sort((x, y) => x[0] - y[0]);
659
+ const out = new Array(n).fill(-1);
660
+ const used = new Array(n).fill(false);
661
+ for (const [, i, j] of pairs) if (out[i] < 0 && !used[j]) {
662
+ out[i] = j;
663
+ used[j] = true;
664
+ }
665
+ return out;
666
+ }
667
+ const idx = Array.from({ length: n }, (_, i) => i);
668
+ let best = idx.slice();
669
+ let bc = Number.POSITIVE_INFINITY;
670
+ const perm = (arr, k, acc) => {
671
+ if (acc >= bc) return;
672
+ if (k === n) {
673
+ bc = acc;
674
+ best = arr.slice();
675
+ return;
676
+ }
677
+ for (let i = k; i < n; i++) {
678
+ [arr[k], arr[i]] = [arr[i], arr[k]];
679
+ perm(arr, k + 1, acc + C[k][arr[k]]);
680
+ [arr[k], arr[i]] = [arr[i], arr[k]];
681
+ }
682
+ };
683
+ perm(idx, 0, 0);
684
+ return best;
685
+ }
686
+ function bestSurjection(C) {
687
+ const B = C.length;
688
+ const S = C[0].length;
689
+ if (S ** B > SURJ_MAX) {
690
+ const f = C.map((row) => {
691
+ let m = 0;
692
+ for (let j = 1; j < row.length; j++) if (row[j] < row[m]) m = j;
693
+ return m;
694
+ });
695
+ const mult = new Array(S).fill(0);
696
+ for (const s of f) mult[s]++;
697
+ for (let s = 0; s < S; s++) {
698
+ if (mult[s] > 0) continue;
699
+ let bi = -1;
700
+ let bc = Number.POSITIVE_INFINITY;
701
+ for (let i = 0; i < B; i++) {
702
+ if (mult[f[i]] < 2) continue;
703
+ const extra = C[i][s] - C[i][f[i]];
704
+ if (extra < bc) {
705
+ bc = extra;
706
+ bi = i;
707
+ }
708
+ }
709
+ mult[f[bi]]--;
710
+ f[bi] = s;
711
+ mult[s]++;
712
+ }
713
+ return f;
714
+ }
715
+ let best = null;
716
+ let bc = Number.POSITIVE_INFINITY;
717
+ const f = new Array(B);
718
+ const mult = new Array(S).fill(0);
719
+ const rec = (i, acc, covered) => {
720
+ if (acc >= bc || S - covered > B - i) return;
721
+ if (i === B) {
722
+ bc = acc;
723
+ best = f.slice();
724
+ return;
725
+ }
726
+ for (let s = 0; s < S; s++) {
727
+ f[i] = s;
728
+ mult[s]++;
729
+ rec(i + 1, acc + C[i][s], covered + (mult[s] === 1 ? 1 : 0));
730
+ mult[s]--;
731
+ }
732
+ };
733
+ rec(0, 0, 0);
734
+ if (!best) throw new Error("morphicons: no valid surjection (B < S)");
735
+ return best;
736
+ }
737
+ function applyGlobal(items, n) {
738
+ const T = items.length * n;
739
+ const ga = new Float64Array(2 * T);
740
+ const gb = new Float64Array(2 * T);
741
+ items.forEach((it, k) => {
742
+ ga.set(it.a, 2 * n * k);
743
+ gb.set(it.bO, 2 * n * k);
744
+ });
745
+ const g = procrustes(ga, gb, centroid(ga), centroid(gb));
746
+ if (g.res >= GLOBAL_EPS) return;
747
+ const cos = Math.cos(-g.theta);
748
+ const sin = Math.sin(-g.theta);
749
+ const rc = Math.cos(g.theta);
750
+ const rs = Math.sin(g.theta);
751
+ for (const it of items) {
752
+ let e2 = 0;
753
+ let nb = 0;
754
+ for (let i = 0; i < n; i++) {
755
+ const bx = it.bO[2 * i] - it.cb[0];
756
+ const by = it.bO[2 * i + 1] - it.cb[1];
757
+ it.bT[2 * i] = (bx * cos - by * sin) / g.sigma;
758
+ it.bT[2 * i + 1] = (bx * sin + by * cos) / g.sigma;
759
+ const ex = g.sigma * (rc * it.aC[2 * i] - rs * it.aC[2 * i + 1]) - bx;
760
+ const ey = g.sigma * (rs * it.aC[2 * i] + rc * it.aC[2 * i + 1]) - by;
761
+ e2 += ex * ex + ey * ey;
762
+ nb += bx * bx + by * by;
763
+ }
764
+ it.theta = g.theta;
765
+ it.lnSigma = Math.log(g.sigma);
766
+ it.res = nb > 1e-12 ? Math.sqrt(e2 / nb) : 0;
767
+ }
768
+ }
769
+ /** Builds the morph plan between two lists of sampled subpaths. The plan is
770
+ * cacheable and serializable; it accepts any list — including intermediate
771
+ * shapes (interruptions). */
772
+ function buildPlan(srcSubs, dstSubs) {
773
+ const p = srcSubs.length;
774
+ const q = dstSubs.length;
775
+ if (p === 0 || q === 0) throw new Error("morphicons: icon has no subpaths");
776
+ const A = srcSubs.map((s) => s.pts);
777
+ const B = dstSubs.map((s) => s.pts);
778
+ const pairs = [];
779
+ if (p === q) {
780
+ const perm = bestPermutation(costMatrix(A, B));
781
+ for (let i = 0; i < p; i++) pairs.push([i, perm[i]]);
782
+ } else if (p < q) {
783
+ const f = bestSurjection(costMatrix(B, A));
784
+ for (let j = 0; j < q; j++) pairs.push([f[j], j]);
785
+ } else {
786
+ const f = bestSurjection(costMatrix(A, B));
787
+ for (let i = 0; i < p; i++) pairs.push([i, f[i]]);
788
+ }
789
+ const n = A[0].length / 2;
790
+ const items = pairs.map(([si, di]) => {
791
+ const al = alignPair(A[si], B[di], srcSubs[si].closed, dstSubs[di].closed);
792
+ const a = al.a;
793
+ const aC = new Float64Array(2 * n);
794
+ const bT = new Float64Array(2 * n);
795
+ const bO = new Float64Array(2 * n);
796
+ const cos = Math.cos(-al.theta);
797
+ const sin = Math.sin(-al.theta);
798
+ for (let i = 0; i < n; i++) {
799
+ aC[2 * i] = a[2 * i] - al.ca[0];
800
+ aC[2 * i + 1] = a[2 * i + 1] - al.ca[1];
801
+ const bx = al.b[2 * i] - al.cb[0];
802
+ const by = al.b[2 * i + 1] - al.cb[1];
803
+ bT[2 * i] = (bx * cos - by * sin) / al.sigma;
804
+ bT[2 * i + 1] = (bx * sin + by * cos) / al.sigma;
805
+ bO[2 * i] = al.b[2 * i];
806
+ bO[2 * i + 1] = al.b[2 * i + 1];
807
+ }
808
+ return {
809
+ a,
810
+ aC,
811
+ bT,
812
+ bO,
813
+ ca: al.ca,
814
+ cb: al.cb,
815
+ theta: al.theta,
816
+ lnSigma: Math.log(al.sigma),
817
+ res: al.res,
818
+ closed: srcSubs[si].closed && dstSubs[di].closed
819
+ };
820
+ });
821
+ if (items.length > 1) applyGlobal(items, n);
822
+ return {
823
+ items,
824
+ n
825
+ };
826
+ }
827
+ //#endregion
828
+ //#region src/core/resample.ts
829
+ /** Default angular threshold for a segment joint to count as a corner. */
830
+ const CORNER_THRESHOLD = Math.PI / 8;
831
+ const GX = [
832
+ .18343464249564978,
833
+ .525532409916329,
834
+ .7966664774136267,
835
+ .9602898564975363
836
+ ];
837
+ const GW = [
838
+ .362683783378362,
839
+ .31370664587788727,
840
+ .22238103445337448,
841
+ .10122853629037626
842
+ ];
843
+ function speed(p, k, t) {
844
+ const i = 6 * k;
845
+ const u = 1 - t;
846
+ const c0 = 3 * u * u;
847
+ const c1 = 6 * u * t;
848
+ const c2 = 3 * t * t;
849
+ const dx = c0 * (p[i + 2] - p[i]) + c1 * (p[i + 4] - p[i + 2]) + c2 * (p[i + 6] - p[i + 4]);
850
+ const dy = c0 * (p[i + 3] - p[i + 1]) + c1 * (p[i + 5] - p[i + 3]) + c2 * (p[i + 7] - p[i + 5]);
851
+ return Math.hypot(dx, dy);
852
+ }
853
+ function segLen(p, k, t1 = 1) {
854
+ const half = t1 / 2;
855
+ let s = 0;
856
+ for (let j = 0; j < 4; j++) s += GW[j] * (speed(p, k, half + half * GX[j]) + speed(p, k, half - half * GX[j]));
857
+ return s * half;
858
+ }
859
+ function point(p, k, t, out, o) {
860
+ const i = 6 * k;
861
+ const u = 1 - t;
862
+ const b0 = u * u * u;
863
+ const b1 = 3 * u * u * t;
864
+ const b2 = 3 * u * t * t;
865
+ const b3 = t * t * t;
866
+ out[o] = b0 * p[i] + b1 * p[i + 2] + b2 * p[i + 4] + b3 * p[i + 6];
867
+ out[o + 1] = b0 * p[i + 1] + b1 * p[i + 3] + b2 * p[i + 5] + b3 * p[i + 7];
868
+ }
869
+ function tangent(p, k, atEnd) {
870
+ const i = 6 * k;
871
+ const b = atEnd ? i + 6 : i;
872
+ const s = atEnd ? -1 : 1;
873
+ for (const j of atEnd ? [
874
+ 4,
875
+ 2,
876
+ 0
877
+ ] : [
878
+ 2,
879
+ 4,
880
+ 6
881
+ ]) {
882
+ const dx = s * (p[i + j] - p[b]);
883
+ const dy = s * (p[i + j + 1] - p[b + 1]);
884
+ if (dx * dx + dy * dy > 1e-18) return [dx, dy];
885
+ }
886
+ return null;
887
+ }
888
+ /** Segment boundaries (index of the segment starting at the corner) whose
889
+ * tangent discontinuity exceeds the threshold. For closed paths this
890
+ * includes the closing joint (boundary = first active segment). */
891
+ function detectCorners(path, threshold = CORNER_THRESHOLD) {
892
+ const p = path.pts;
893
+ const m = (p.length / 2 - 1) / 3;
894
+ const active = [];
895
+ for (let k = 0; k < m; k++) if (segLen(p, k) > 1e-9) active.push(k);
896
+ if (active.length === 0) return [];
897
+ const corners = /* @__PURE__ */ new Set();
898
+ const test = (a, b) => {
899
+ const u = tangent(p, a, true);
900
+ const v = tangent(p, b, false);
901
+ if (!u || !v) return;
902
+ if (Math.abs(Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1])) > threshold) corners.add(b);
903
+ };
904
+ for (let j = 0; j + 1 < active.length; j++) test(active[j], active[j + 1]);
905
+ if (path.closed && active.length > 1) test(active[active.length - 1], active[0]);
906
+ return [...corners].sort((a, b) => a - b);
907
+ }
908
+ function invert(p, k, s, ls) {
909
+ if (s <= 0) return 0;
910
+ if (s >= ls) return 1;
911
+ let lo = 0;
912
+ let hi = 1;
913
+ let t = s / ls;
914
+ for (let it = 0; it < 12; it++) {
915
+ const f = segLen(p, k, t) - s;
916
+ if (Math.abs(f) < 1e-10 * ls + 1e-14) break;
917
+ if (f > 0) hi = t;
918
+ else lo = t;
919
+ const sp = speed(p, k, t);
920
+ let nt = sp > 1e-12 ? t - f / sp : (lo + hi) / 2;
921
+ if (!(nt > lo && nt < hi)) nt = (lo + hi) / 2;
922
+ t = nt;
923
+ }
924
+ return t;
925
+ }
926
+ /** Samples a cubic subpath at N points equidistant by arc length, anchoring
927
+ * corners and endpoints as exact samples. Returns Float64Array(2N). Closed
928
+ * paths distribute N intervals around the loop (without duplicating the
929
+ * first point); the circular start-point freedom is resolved by the plan's
930
+ * circular correspondence. */
931
+ function resamplePath(path, N = 64, cornerThreshold = CORNER_THRESHOLD) {
932
+ const p = path.pts;
933
+ const m = (p.length / 2 - 1) / 3;
934
+ const out = new Float64Array(2 * N);
935
+ const fill = () => {
936
+ for (let i = 0; i < N; i++) {
937
+ out[2 * i] = p[0];
938
+ out[2 * i + 1] = p[1];
939
+ }
940
+ return out;
941
+ };
942
+ if (m < 1) return fill();
943
+ const lens = new Array(m);
944
+ let L = 0;
945
+ for (let k = 0; k < m; k++) {
946
+ lens[k] = segLen(p, k);
947
+ L += lens[k];
948
+ }
949
+ if (L < 1e-12) return fill();
950
+ const cs = detectCorners(path, cornerThreshold);
951
+ const anchors = path.closed ? cs.length > 0 ? cs : [0] : [.../* @__PURE__ */ new Set([
952
+ 0,
953
+ ...cs,
954
+ m
955
+ ])].sort((a, b) => a - b);
956
+ const runs = [];
957
+ if (path.closed) for (let j = 0; j < anchors.length; j++) {
958
+ const a = anchors[j];
959
+ const b = j + 1 < anchors.length ? anchors[j + 1] : anchors[0] + m;
960
+ runs.push([a, b]);
961
+ }
962
+ else for (let j = 0; j + 1 < anchors.length; j++) runs.push([anchors[j], anchors[j + 1]]);
963
+ const rl = runs.map(([a, b]) => {
964
+ let s = 0;
965
+ for (let k = a; k < b; k++) s += lens[k % m];
966
+ return s;
967
+ });
968
+ const intervals = path.closed ? N : N - 1;
969
+ if (runs.length > intervals) throw new Error(`morphicons: N=${N} too small (${runs.length} runs)`);
970
+ const total = rl.reduce((a, b) => a + b, 0) || 1;
971
+ const ideal = rl.map((l) => intervals * l / total);
972
+ const counts = ideal.map((q) => Math.max(1, Math.floor(q)));
973
+ let R = intervals - counts.reduce((a, b) => a + b, 0);
974
+ if (R > 0) {
975
+ const order = ideal.map((q, idx) => [Math.round((q - Math.floor(q)) * 1e9), idx]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
976
+ for (let j = 0; j < R; j++) counts[order[j % counts.length][1]]++;
977
+ }
978
+ while (R < 0) {
979
+ let bi = 0;
980
+ for (let idx = 1; idx < counts.length; idx++) if (counts[idx] > counts[bi]) bi = idx;
981
+ if (counts[bi] <= 1) break;
982
+ counts[bi]--;
983
+ R++;
984
+ }
985
+ let w = 0;
986
+ for (let r = 0; r < runs.length; r++) {
987
+ const [k0, k1] = runs[r];
988
+ const cnt = counts[r];
989
+ const Lr = rl[r];
990
+ const vi = 6 * (k0 % m);
991
+ out[2 * w] = p[vi];
992
+ out[2 * w + 1] = p[vi + 1];
993
+ w++;
994
+ let seg = k0;
995
+ let acc = 0;
996
+ for (let j = 1; j < cnt; j++) {
997
+ const target = Lr * j / cnt;
998
+ while (seg < k1 - 1 && acc + lens[seg % m] < target) {
999
+ acc += lens[seg % m];
1000
+ seg++;
1001
+ }
1002
+ const k = seg % m;
1003
+ const ls = lens[k];
1004
+ point(p, k, ls > 1e-12 ? invert(p, k, target - acc, ls) : 0, out, 2 * w);
1005
+ w++;
1006
+ }
1007
+ }
1008
+ if (!path.closed) {
1009
+ const vi = 6 * m;
1010
+ out[2 * w] = p[vi];
1011
+ out[2 * w + 1] = p[vi + 1];
1012
+ }
1013
+ return out;
1014
+ }
1015
+ /** Full input pipeline: icon → cubics → sampled subpaths with their
1016
+ * topology (the plan needs to know which subpaths are closed loops). */
1017
+ function resampleIcon(input, N = 64) {
1018
+ return iconToCubics(input).map((path) => ({
1019
+ pts: resamplePath(path, N),
1020
+ closed: path.closed
1021
+ }));
1022
+ }
1023
+ //#endregion
1024
+ //#region src/core/serialize.ts
1025
+ function fmt(v) {
1026
+ return String(Math.round(v * 100) / 100);
1027
+ }
1028
+ /** Sampled subpaths → polyline `d` attribute. `closed[k]` appends Z to
1029
+ * subpath k (closed loops in flight); without flags everything is open. */
1030
+ function serialize(subs, closed) {
1031
+ let d = "";
1032
+ for (let k = 0; k < subs.length; k++) {
1033
+ const o = subs[k];
1034
+ const n = o.length / 2;
1035
+ d += `M${fmt(o[0])} ${fmt(o[1])}`;
1036
+ for (let i = 1; i < n; i++) d += `L${fmt(o[2 * i])} ${fmt(o[2 * i + 1])}`;
1037
+ if (closed?.[k]) d += "Z";
1038
+ }
1039
+ return d;
1040
+ }
1041
+ /** Cubic subpaths → canonical `d` at full precision (round-trip). */
1042
+ function cubicsToPathD(paths) {
1043
+ let d = "";
1044
+ for (const { pts, closed } of paths) {
1045
+ d += `M${pts[0]} ${pts[1]}`;
1046
+ for (let i = 2; i < pts.length; i += 6) d += `C${pts[i]} ${pts[i + 1]} ${pts[i + 2]} ${pts[i + 3]} ${pts[i + 4]} ${pts[i + 5]}`;
1047
+ if (closed) d += "Z";
1048
+ }
1049
+ return d;
1050
+ }
1051
+ //#endregion
1052
+ //#region src/core/spring.ts
1053
+ var Spring = class {
1054
+ x = 1;
1055
+ v = 0;
1056
+ k = 250;
1057
+ c = 24;
1058
+ config(k, c) {
1059
+ this.k = k;
1060
+ this.c = c;
1061
+ }
1062
+ /** Starts (or restarts mid-flight) preserving velocity. */
1063
+ start() {
1064
+ this.x = 0;
1065
+ if (this.v > 14) this.v = 14;
1066
+ if (this.v < -14) this.v = -14;
1067
+ }
1068
+ /** Advances dt seconds. Returns true on settle (|1−x| < 0.001 ∧ |v| < 0.02). */
1069
+ step(dt) {
1070
+ const steps = Math.max(1, Math.min(16, Math.ceil(dt / (1 / 240))));
1071
+ const s = dt / steps;
1072
+ for (let i = 0; i < steps; i++) {
1073
+ const a = this.k * (1 - this.x) - this.c * this.v;
1074
+ this.v += a * s;
1075
+ this.x += this.v * s;
1076
+ }
1077
+ return Math.abs(1 - this.x) < .001 && Math.abs(this.v) < .02;
1078
+ }
1079
+ };
1080
+ /** Spring presets (ζ = c/(2√k)) with the API's public names. */
1081
+ const SPRING_PRESETS = {
1082
+ /** ζ = 1.00 — critically damped, no overshoot. */
1083
+ smooth: {
1084
+ k: 170,
1085
+ c: 26
1086
+ },
1087
+ /** ζ = 0.73 — fast, subtle overshoot. */
1088
+ snappy: {
1089
+ k: 420,
1090
+ c: 30
1091
+ },
1092
+ /** ζ = 0.40 — playful. */
1093
+ bouncy: {
1094
+ k: 300,
1095
+ c: 14
1096
+ }
1097
+ };
1098
+ //#endregion
1099
+ export { resampleIcon as a, iconToCubics as c, interpPolar as d, serialize as i, allocOutputs as l, Spring as n, resamplePath as o, cubicsToPathD as r, buildPlan as s, SPRING_PRESETS as t, interpLinear as u };