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.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/dist/dom.d.ts +36 -0
- package/dist/dom.js +192 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +2 -0
- package/dist/react.d.ts +31 -0
- package/dist/react.js +114 -0
- package/dist/spring-Dbj7NCw2.js +1099 -0
- package/dist/types-JAF3s76Y.d.ts +60 -0
- package/package.json +106 -0
|
@@ -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 };
|