@pooder/kit 4.3.0 → 5.0.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/.test-dist/src/CanvasService.js +249 -0
- package/.test-dist/src/ViewportSystem.js +75 -0
- package/.test-dist/src/background.js +203 -0
- package/.test-dist/src/bridgeSelection.js +20 -0
- package/.test-dist/src/constraints.js +237 -0
- package/.test-dist/src/coordinate.js +74 -0
- package/.test-dist/src/dieline.js +723 -0
- package/.test-dist/src/edgeScale.js +12 -0
- package/.test-dist/src/feature.js +752 -0
- package/.test-dist/src/featureComplete.js +32 -0
- package/.test-dist/src/film.js +167 -0
- package/.test-dist/src/geometry.js +506 -0
- package/.test-dist/src/image.js +1234 -0
- package/.test-dist/src/index.js +35 -0
- package/.test-dist/src/maskOps.js +270 -0
- package/.test-dist/src/mirror.js +104 -0
- package/.test-dist/src/renderSpec.js +2 -0
- package/.test-dist/src/ruler.js +343 -0
- package/.test-dist/src/sceneLayout.js +99 -0
- package/.test-dist/src/sceneLayoutModel.js +196 -0
- package/.test-dist/src/sceneView.js +40 -0
- package/.test-dist/src/sceneVisibility.js +42 -0
- package/.test-dist/src/size.js +332 -0
- package/.test-dist/src/tracer.js +544 -0
- package/.test-dist/src/units.js +30 -0
- package/.test-dist/src/white-ink.js +829 -0
- package/.test-dist/src/wrappedOffsets.js +33 -0
- package/.test-dist/tests/run.js +94 -0
- package/CHANGELOG.md +17 -0
- package/dist/index.d.mts +339 -36
- package/dist/index.d.ts +339 -36
- package/dist/index.js +3587 -854
- package/dist/index.mjs +3580 -856
- package/package.json +2 -2
- package/src/CanvasService.ts +300 -96
- package/src/ViewportSystem.ts +92 -92
- package/src/background.ts +230 -230
- package/src/bridgeSelection.ts +17 -0
- package/src/coordinate.ts +106 -106
- package/src/dieline.ts +897 -955
- package/src/edgeScale.ts +19 -0
- package/src/feature.ts +83 -30
- package/src/film.ts +194 -194
- package/src/geometry.ts +234 -80
- package/src/image.ts +1582 -512
- package/src/index.ts +14 -10
- package/src/maskOps.ts +326 -0
- package/src/mirror.ts +128 -128
- package/src/renderSpec.ts +18 -0
- package/src/ruler.ts +449 -508
- package/src/sceneLayout.ts +121 -0
- package/src/sceneLayoutModel.ts +335 -0
- package/src/sceneVisibility.ts +49 -0
- package/src/size.ts +379 -0
- package/src/tracer.ts +719 -570
- package/src/units.ts +27 -27
- package/src/white-ink.ts +1018 -373
- package/src/wrappedOffsets.ts +33 -0
- package/tests/run.ts +118 -0
- package/tsconfig.test.json +15 -15
package/src/tracer.ts
CHANGED
|
@@ -1,570 +1,719 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image Tracer Utility
|
|
3
|
-
* Converts raster images (URL/Base64) to SVG Path Data using Marching Squares algorithm.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import paper from "paper";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
let
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
let
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
mask
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Image Tracer Utility
|
|
3
|
+
* Converts raster images (URL/Base64) to SVG Path Data using Marching Squares algorithm.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import paper from "paper";
|
|
7
|
+
import {
|
|
8
|
+
circularMorphology,
|
|
9
|
+
createMask,
|
|
10
|
+
fillHoles,
|
|
11
|
+
findMinimalConnectRadius,
|
|
12
|
+
polygonSignedArea,
|
|
13
|
+
type MaskMode,
|
|
14
|
+
} from "./maskOps";
|
|
15
|
+
|
|
16
|
+
interface Point {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Bounds {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ImageTracer {
|
|
29
|
+
/**
|
|
30
|
+
* Main entry point: Traces an image URL to an SVG path string.
|
|
31
|
+
* @param imageUrl The URL or Base64 string of the image.
|
|
32
|
+
* @param options Configuration options.
|
|
33
|
+
*/
|
|
34
|
+
public static async trace(
|
|
35
|
+
imageUrl: string,
|
|
36
|
+
options: {
|
|
37
|
+
threshold?: number; // 0-255, default 10
|
|
38
|
+
simplifyTolerance?: number; // default 2.5
|
|
39
|
+
scale?: number; // Scale factor for the processing canvas, default 1.0
|
|
40
|
+
scaleToWidth?: number;
|
|
41
|
+
scaleToHeight?: number;
|
|
42
|
+
morphologyRadius?: number; // Default 10.
|
|
43
|
+
connectRadiusMax?: number;
|
|
44
|
+
maskMode?: MaskMode;
|
|
45
|
+
whiteThreshold?: number;
|
|
46
|
+
alphaOpaqueCutoff?: number;
|
|
47
|
+
expand?: number; // Expansion radius in pixels. Default 0.
|
|
48
|
+
noChannels?: boolean;
|
|
49
|
+
smoothing?: boolean; // Use Paper.js smoothing (curve fitting). Default true.
|
|
50
|
+
debug?: boolean;
|
|
51
|
+
} = {},
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
const { pathData } = await this.traceWithBounds(imageUrl, options);
|
|
54
|
+
return pathData;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public static async traceWithBounds(
|
|
58
|
+
imageUrl: string,
|
|
59
|
+
options: {
|
|
60
|
+
threshold?: number;
|
|
61
|
+
simplifyTolerance?: number;
|
|
62
|
+
scale?: number;
|
|
63
|
+
scaleToWidth?: number;
|
|
64
|
+
scaleToHeight?: number;
|
|
65
|
+
morphologyRadius?: number;
|
|
66
|
+
connectRadiusMax?: number;
|
|
67
|
+
maskMode?: MaskMode;
|
|
68
|
+
whiteThreshold?: number;
|
|
69
|
+
alphaOpaqueCutoff?: number;
|
|
70
|
+
expand?: number;
|
|
71
|
+
noChannels?: boolean;
|
|
72
|
+
smoothing?: boolean;
|
|
73
|
+
debug?: boolean;
|
|
74
|
+
} = {},
|
|
75
|
+
): Promise<{ pathData: string; baseBounds: Bounds; bounds: Bounds }> {
|
|
76
|
+
const img = await this.loadImage(imageUrl);
|
|
77
|
+
const width = img.width;
|
|
78
|
+
const height = img.height;
|
|
79
|
+
const debug = options.debug === true;
|
|
80
|
+
const debugLog = (message: string, payload?: Record<string, unknown>) => {
|
|
81
|
+
if (!debug) return;
|
|
82
|
+
if (payload) {
|
|
83
|
+
console.info(`[ImageTracer] ${message}`, payload);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
console.info(`[ImageTracer] ${message}`);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// 1. Draw to canvas and get pixel data
|
|
90
|
+
const canvas = document.createElement("canvas");
|
|
91
|
+
canvas.width = width;
|
|
92
|
+
canvas.height = height;
|
|
93
|
+
const ctx = canvas.getContext("2d");
|
|
94
|
+
if (!ctx) throw new Error("Could not get 2D context");
|
|
95
|
+
|
|
96
|
+
ctx.drawImage(img, 0, 0);
|
|
97
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
98
|
+
|
|
99
|
+
// 2. Morphology processing
|
|
100
|
+
const threshold = options.threshold ?? 10;
|
|
101
|
+
// Adaptive radius: 3% of the image's largest dimension, at least 5px
|
|
102
|
+
const adaptiveRadius = Math.max(
|
|
103
|
+
5,
|
|
104
|
+
Math.floor(Math.max(width, height) * 0.02),
|
|
105
|
+
);
|
|
106
|
+
const radius = options.morphologyRadius ?? adaptiveRadius;
|
|
107
|
+
const expand = options.expand ?? 0;
|
|
108
|
+
const noChannels = options.noChannels !== false;
|
|
109
|
+
debugLog("traceWithBounds:start", {
|
|
110
|
+
width,
|
|
111
|
+
height,
|
|
112
|
+
threshold,
|
|
113
|
+
radius,
|
|
114
|
+
expand,
|
|
115
|
+
noChannels,
|
|
116
|
+
simplifyTolerance: options.simplifyTolerance ?? 2.5,
|
|
117
|
+
smoothing: options.smoothing !== false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Add padding to the processing canvas to avoid edge clipping during dilation
|
|
121
|
+
// Padding should be at least the radius + expansion size
|
|
122
|
+
const padding = radius + expand + 2;
|
|
123
|
+
const paddedWidth = width + padding * 2;
|
|
124
|
+
const paddedHeight = height + padding * 2;
|
|
125
|
+
|
|
126
|
+
let mask = createMask(imageData, {
|
|
127
|
+
threshold,
|
|
128
|
+
padding,
|
|
129
|
+
paddedWidth,
|
|
130
|
+
paddedHeight,
|
|
131
|
+
maskMode: options.maskMode,
|
|
132
|
+
whiteThreshold: options.whiteThreshold,
|
|
133
|
+
alphaOpaqueCutoff: options.alphaOpaqueCutoff,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const connectRadiusMax =
|
|
137
|
+
options.connectRadiusMax ?? Math.max(10, Math.floor(Math.max(width, height) * 0.12));
|
|
138
|
+
|
|
139
|
+
const rConnect = findMinimalConnectRadius(
|
|
140
|
+
mask,
|
|
141
|
+
paddedWidth,
|
|
142
|
+
paddedHeight,
|
|
143
|
+
connectRadiusMax,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (rConnect > 0) {
|
|
147
|
+
mask = circularMorphology(mask, paddedWidth, paddedHeight, rConnect, "closing");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (radius > 0) {
|
|
151
|
+
mask = circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (noChannels) {
|
|
155
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (radius > 0) {
|
|
159
|
+
const smoothRadius = Math.max(2, Math.floor(radius * 0.3));
|
|
160
|
+
mask = circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const baseMask = mask;
|
|
164
|
+
const baseContour = this.pickPrimaryContour(
|
|
165
|
+
this.traceAllContours(baseMask, paddedWidth, paddedHeight),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!baseContour) {
|
|
169
|
+
// Fallback: Return a rectangular outline matching dimensions
|
|
170
|
+
const w = options.scaleToWidth ?? width;
|
|
171
|
+
const h = options.scaleToHeight ?? height;
|
|
172
|
+
debugLog("fallback:no-base-contour", { width: w, height: h });
|
|
173
|
+
return {
|
|
174
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
175
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
176
|
+
bounds: { x: 0, y: 0, width: w, height: h },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const baseUnpadded = baseContour.map(p => ({
|
|
181
|
+
x: p.x - padding,
|
|
182
|
+
y: p.y - padding,
|
|
183
|
+
}));
|
|
184
|
+
let baseBounds = this.boundsFromPoints(baseUnpadded);
|
|
185
|
+
|
|
186
|
+
let maskExpanded = baseMask;
|
|
187
|
+
if (expand > 0) {
|
|
188
|
+
maskExpanded = circularMorphology(baseMask, paddedWidth, paddedHeight, expand, "dilate");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const expandedContour = this.pickPrimaryContour(
|
|
192
|
+
this.traceAllContours(maskExpanded, paddedWidth, paddedHeight),
|
|
193
|
+
);
|
|
194
|
+
if (!expandedContour) {
|
|
195
|
+
debugLog("fallback:no-expanded-contour", {
|
|
196
|
+
baseBounds,
|
|
197
|
+
width,
|
|
198
|
+
height,
|
|
199
|
+
expand,
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
203
|
+
baseBounds,
|
|
204
|
+
bounds: baseBounds,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const expandedUnpadded = expandedContour.map(p => ({
|
|
209
|
+
x: p.x - padding,
|
|
210
|
+
y: p.y - padding,
|
|
211
|
+
}));
|
|
212
|
+
let globalBounds = this.boundsFromPoints(expandedUnpadded);
|
|
213
|
+
|
|
214
|
+
// 9. Post-processing (Scale)
|
|
215
|
+
let finalPoints = expandedUnpadded;
|
|
216
|
+
if (options.scaleToWidth && options.scaleToHeight) {
|
|
217
|
+
finalPoints = this.scalePoints(
|
|
218
|
+
expandedUnpadded,
|
|
219
|
+
options.scaleToWidth,
|
|
220
|
+
options.scaleToHeight,
|
|
221
|
+
globalBounds,
|
|
222
|
+
);
|
|
223
|
+
globalBounds = this.boundsFromPoints(finalPoints);
|
|
224
|
+
|
|
225
|
+
const baseScaled = this.scalePoints(
|
|
226
|
+
baseUnpadded,
|
|
227
|
+
options.scaleToWidth,
|
|
228
|
+
options.scaleToHeight,
|
|
229
|
+
baseBounds,
|
|
230
|
+
);
|
|
231
|
+
baseBounds = this.boundsFromPoints(baseScaled);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 10. Simplify and Generate SVG
|
|
235
|
+
const useSmoothing = options.smoothing !== false; // Default true
|
|
236
|
+
debugLog("traceWithBounds:contours", {
|
|
237
|
+
baseBounds,
|
|
238
|
+
expandedBounds: globalBounds,
|
|
239
|
+
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
240
|
+
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
241
|
+
useSmoothing,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (useSmoothing) {
|
|
245
|
+
return {
|
|
246
|
+
pathData: this.pointsToSVGPaper(finalPoints, options.simplifyTolerance ?? 2.5),
|
|
247
|
+
baseBounds,
|
|
248
|
+
bounds: globalBounds,
|
|
249
|
+
};
|
|
250
|
+
} else {
|
|
251
|
+
const simplifiedPoints = this.douglasPeucker(
|
|
252
|
+
finalPoints,
|
|
253
|
+
options.simplifyTolerance ?? 2.0,
|
|
254
|
+
);
|
|
255
|
+
return {
|
|
256
|
+
pathData: this.pointsToSVG(simplifiedPoints),
|
|
257
|
+
baseBounds,
|
|
258
|
+
bounds: globalBounds,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private static pickPrimaryContour(contours: Point[][]): Point[] | null {
|
|
264
|
+
if (contours.length === 0) return null;
|
|
265
|
+
return contours.reduce((best, cur) => {
|
|
266
|
+
if (!best) return cur;
|
|
267
|
+
const bestArea = Math.abs(polygonSignedArea(best));
|
|
268
|
+
const curArea = Math.abs(polygonSignedArea(cur));
|
|
269
|
+
if (curArea !== bestArea) return curArea > bestArea ? cur : best;
|
|
270
|
+
return cur.length > best.length ? cur : best;
|
|
271
|
+
}, contours[0]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private static boundsFromPoints(points: Point[]): Bounds {
|
|
275
|
+
let minX = Infinity;
|
|
276
|
+
let minY = Infinity;
|
|
277
|
+
let maxX = -Infinity;
|
|
278
|
+
let maxY = -Infinity;
|
|
279
|
+
|
|
280
|
+
for (const p of points) {
|
|
281
|
+
if (p.x < minX) minX = p.x;
|
|
282
|
+
if (p.y < minY) minY = p.y;
|
|
283
|
+
if (p.x > maxX) maxX = p.x;
|
|
284
|
+
if (p.y > maxY) maxY = p.y;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
|
288
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
x: minX,
|
|
293
|
+
y: minY,
|
|
294
|
+
width: maxX - minX,
|
|
295
|
+
height: maxY - minY,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private static createMask(
|
|
300
|
+
imageData: ImageData,
|
|
301
|
+
threshold: number,
|
|
302
|
+
padding: number,
|
|
303
|
+
paddedWidth: number,
|
|
304
|
+
paddedHeight: number,
|
|
305
|
+
): Uint8Array {
|
|
306
|
+
const { width, height, data } = imageData;
|
|
307
|
+
const mask = new Uint8Array(paddedWidth * paddedHeight);
|
|
308
|
+
|
|
309
|
+
// 1. Detect if the image has transparency (any pixel with alpha < 255)
|
|
310
|
+
let hasTransparency = false;
|
|
311
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
312
|
+
if (data[i] < 255) {
|
|
313
|
+
hasTransparency = true;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 2. Binarize based on alpha or luminance
|
|
319
|
+
for (let y = 0; y < height; y++) {
|
|
320
|
+
for (let x = 0; x < width; x++) {
|
|
321
|
+
const srcIdx = (y * width + x) * 4;
|
|
322
|
+
const r = data[srcIdx];
|
|
323
|
+
const g = data[srcIdx + 1];
|
|
324
|
+
const b = data[srcIdx + 2];
|
|
325
|
+
const a = data[srcIdx + 3];
|
|
326
|
+
|
|
327
|
+
const destIdx = (y + padding) * paddedWidth + (x + padding);
|
|
328
|
+
|
|
329
|
+
if (hasTransparency) {
|
|
330
|
+
if (a > threshold) {
|
|
331
|
+
mask[destIdx] = 1;
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
if (!(r > 240 && g > 240 && b > 240)) {
|
|
335
|
+
mask[destIdx] = 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return mask;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Fast circular morphology using a distance-transform inspired separable approach.
|
|
345
|
+
* O(N * R) complexity, where R is the radius.
|
|
346
|
+
*/
|
|
347
|
+
private static circularMorphology(
|
|
348
|
+
mask: Uint8Array,
|
|
349
|
+
width: number,
|
|
350
|
+
height: number,
|
|
351
|
+
radius: number,
|
|
352
|
+
op: "dilate" | "erode" | "closing" | "opening",
|
|
353
|
+
): Uint8Array {
|
|
354
|
+
const dilate = (m: Uint8Array, r: number) => {
|
|
355
|
+
const horizontalDist = new Int32Array(width * height);
|
|
356
|
+
// Horizontal pass: dist to nearest solid pixel in row
|
|
357
|
+
for (let y = 0; y < height; y++) {
|
|
358
|
+
let lastSolid = -r * 2;
|
|
359
|
+
for (let x = 0; x < width; x++) {
|
|
360
|
+
if (m[y * width + x]) lastSolid = x;
|
|
361
|
+
horizontalDist[y * width + x] = x - lastSolid;
|
|
362
|
+
}
|
|
363
|
+
lastSolid = width + r * 2;
|
|
364
|
+
for (let x = width - 1; x >= 0; x--) {
|
|
365
|
+
if (m[y * width + x]) lastSolid = x;
|
|
366
|
+
horizontalDist[y * width + x] = Math.min(
|
|
367
|
+
horizontalDist[y * width + x],
|
|
368
|
+
lastSolid - x,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = new Uint8Array(width * height);
|
|
374
|
+
const r2 = r * r;
|
|
375
|
+
// Vertical pass: check Euclidean distance using precomputed horizontal distances
|
|
376
|
+
for (let x = 0; x < width; x++) {
|
|
377
|
+
for (let y = 0; y < height; y++) {
|
|
378
|
+
let found = false;
|
|
379
|
+
const minY = Math.max(0, y - r);
|
|
380
|
+
const maxY = Math.min(height - 1, y + r);
|
|
381
|
+
for (let dy = minY; dy <= maxY; dy++) {
|
|
382
|
+
const dY = dy - y;
|
|
383
|
+
const hDist = horizontalDist[dy * width + x];
|
|
384
|
+
if (hDist * hDist + dY * dY <= r2) {
|
|
385
|
+
found = true;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (found) result[y * width + x] = 1;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return result;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const erode = (m: Uint8Array, r: number) => {
|
|
396
|
+
// Erosion is dilation of the inverted mask
|
|
397
|
+
const inverted = new Uint8Array(m.length);
|
|
398
|
+
for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
|
|
399
|
+
const dilatedInverted = dilate(inverted, r);
|
|
400
|
+
const result = new Uint8Array(m.length);
|
|
401
|
+
for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
|
|
402
|
+
return result;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
switch (op) {
|
|
406
|
+
case "dilate":
|
|
407
|
+
return dilate(mask, radius);
|
|
408
|
+
case "erode":
|
|
409
|
+
return erode(mask, radius);
|
|
410
|
+
case "closing":
|
|
411
|
+
return erode(dilate(mask, radius), radius);
|
|
412
|
+
case "opening":
|
|
413
|
+
return dilate(erode(mask, radius), radius);
|
|
414
|
+
default:
|
|
415
|
+
return mask;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Fills internal holes in the binary mask using flood fill from edges.
|
|
421
|
+
*/
|
|
422
|
+
private static fillHoles(
|
|
423
|
+
mask: Uint8Array,
|
|
424
|
+
width: number,
|
|
425
|
+
height: number,
|
|
426
|
+
): Uint8Array {
|
|
427
|
+
const background = new Uint8Array(width * height);
|
|
428
|
+
const queue: [number, number][] = [];
|
|
429
|
+
|
|
430
|
+
// Add all edge pixels that are 0 to the queue
|
|
431
|
+
for (let x = 0; x < width; x++) {
|
|
432
|
+
if (mask[x] === 0) {
|
|
433
|
+
background[x] = 1;
|
|
434
|
+
queue.push([x, 0]);
|
|
435
|
+
}
|
|
436
|
+
const lastRow = (height - 1) * width + x;
|
|
437
|
+
if (mask[lastRow] === 0) {
|
|
438
|
+
background[lastRow] = 1;
|
|
439
|
+
queue.push([x, height - 1]);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (let y = 1; y < height - 1; y++) {
|
|
443
|
+
if (mask[y * width] === 0) {
|
|
444
|
+
background[y * width] = 1;
|
|
445
|
+
queue.push([0, y]);
|
|
446
|
+
}
|
|
447
|
+
if (mask[y * width + width - 1] === 0) {
|
|
448
|
+
background[y * width + width - 1] = 1;
|
|
449
|
+
queue.push([width - 1, y]);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Flood fill from the edges to find all background pixels
|
|
454
|
+
const dirs = [
|
|
455
|
+
[0, 1],
|
|
456
|
+
[0, -1],
|
|
457
|
+
[1, 0],
|
|
458
|
+
[-1, 0],
|
|
459
|
+
];
|
|
460
|
+
let head = 0;
|
|
461
|
+
while (head < queue.length) {
|
|
462
|
+
const [cx, cy] = queue[head++];
|
|
463
|
+
for (const [dx, dy] of dirs) {
|
|
464
|
+
const nx = cx + dx;
|
|
465
|
+
const ny = cy + dy;
|
|
466
|
+
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
|
467
|
+
const nidx = ny * width + nx;
|
|
468
|
+
if (mask[nidx] === 0 && background[nidx] === 0) {
|
|
469
|
+
background[nidx] = 1;
|
|
470
|
+
queue.push([nx, ny]);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Any pixel that is NOT reachable from the background is part of the "filled" mask
|
|
477
|
+
const filledMask = new Uint8Array(width * height);
|
|
478
|
+
for (let i = 0; i < width * height; i++) {
|
|
479
|
+
filledMask[i] = background[i] === 0 ? 1 : 0;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return filledMask;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Traces all contours in the mask with optimized start-point detection
|
|
487
|
+
*/
|
|
488
|
+
private static traceAllContours(
|
|
489
|
+
mask: Uint8Array,
|
|
490
|
+
width: number,
|
|
491
|
+
height: number,
|
|
492
|
+
): Point[][] {
|
|
493
|
+
const visited = new Uint8Array(width * height);
|
|
494
|
+
const allContours: Point[][] = [];
|
|
495
|
+
|
|
496
|
+
for (let y = 0; y < height; y++) {
|
|
497
|
+
for (let x = 0; x < width; x++) {
|
|
498
|
+
const idx = y * width + x;
|
|
499
|
+
if (mask[idx] && !visited[idx]) {
|
|
500
|
+
// Only start a new trace if it's a potential outer boundary (left edge)
|
|
501
|
+
const isLeftEdge = x === 0 || mask[idx - 1] === 0;
|
|
502
|
+
if (isLeftEdge) {
|
|
503
|
+
const contour = this.marchingSquares(
|
|
504
|
+
mask,
|
|
505
|
+
visited,
|
|
506
|
+
x,
|
|
507
|
+
y,
|
|
508
|
+
width,
|
|
509
|
+
height,
|
|
510
|
+
);
|
|
511
|
+
if (contour.length > 2) {
|
|
512
|
+
allContours.push(contour);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return allContours;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private static loadImage(url: string): Promise<HTMLImageElement> {
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
const img = new Image();
|
|
524
|
+
img.crossOrigin = "Anonymous";
|
|
525
|
+
img.onload = () => resolve(img);
|
|
526
|
+
img.onerror = (e) => reject(e);
|
|
527
|
+
img.src = url;
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Moore-Neighbor Tracing Algorithm
|
|
533
|
+
* More robust for irregular shapes than simple Marching Squares walker.
|
|
534
|
+
*/
|
|
535
|
+
private static marchingSquares(
|
|
536
|
+
mask: Uint8Array,
|
|
537
|
+
visited: Uint8Array,
|
|
538
|
+
startX: number,
|
|
539
|
+
startY: number,
|
|
540
|
+
width: number,
|
|
541
|
+
height: number,
|
|
542
|
+
): Point[] {
|
|
543
|
+
const isSolid = (x: number, y: number): boolean => {
|
|
544
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return false;
|
|
545
|
+
return mask[y * width + x] === 1;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const points: Point[] = [];
|
|
549
|
+
|
|
550
|
+
// Moore-Neighbor Tracing
|
|
551
|
+
// We enter from the Left (since we scan Left->Right), so "backtrack" is Left.
|
|
552
|
+
// B = (startX - 1, startY)
|
|
553
|
+
// P = (startX, startY)
|
|
554
|
+
|
|
555
|
+
let cx = startX;
|
|
556
|
+
let cy = startY;
|
|
557
|
+
|
|
558
|
+
// Start backtrack direction: Left (since we found it scanning from left)
|
|
559
|
+
// Directions: 0=Up, 1=UpRight, 2=Right, 3=DownRight, 4=Down, 5=DownLeft, 6=Left, 7=UpLeft
|
|
560
|
+
// Offsets for 8 neighbors starting from Up (0,-1) clockwise
|
|
561
|
+
const neighbors = [
|
|
562
|
+
{ x: 0, y: -1 },
|
|
563
|
+
{ x: 1, y: -1 },
|
|
564
|
+
{ x: 1, y: 0 },
|
|
565
|
+
{ x: 1, y: 1 },
|
|
566
|
+
{ x: 0, y: 1 },
|
|
567
|
+
{ x: -1, y: 1 },
|
|
568
|
+
{ x: -1, y: 0 },
|
|
569
|
+
{ x: -1, y: -1 },
|
|
570
|
+
];
|
|
571
|
+
|
|
572
|
+
// Backtrack is Left -> Index 6.
|
|
573
|
+
let backtrack = 6;
|
|
574
|
+
|
|
575
|
+
const maxSteps = width * height * 3;
|
|
576
|
+
let steps = 0;
|
|
577
|
+
|
|
578
|
+
do {
|
|
579
|
+
points.push({ x: cx, y: cy });
|
|
580
|
+
visited[cy * width + cx] = 1; // Mark as visited to avoid re-starting here
|
|
581
|
+
|
|
582
|
+
// Search for next solid neighbor in clockwise order, starting from backtrack
|
|
583
|
+
let found = false;
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < 8; i++) {
|
|
586
|
+
const idx = (backtrack + 1 + i) % 8;
|
|
587
|
+
const nx = cx + neighbors[idx].x;
|
|
588
|
+
const ny = cy + neighbors[idx].y;
|
|
589
|
+
|
|
590
|
+
if (isSolid(nx, ny)) {
|
|
591
|
+
cx = nx;
|
|
592
|
+
cy = ny;
|
|
593
|
+
backtrack = (idx + 4 + 1) % 8;
|
|
594
|
+
found = true;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!found) break;
|
|
600
|
+
|
|
601
|
+
steps++;
|
|
602
|
+
} while ((cx !== startX || cy !== startY) && steps < maxSteps);
|
|
603
|
+
|
|
604
|
+
return points;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Douglas-Peucker Line Simplification
|
|
609
|
+
*/
|
|
610
|
+
private static douglasPeucker(points: Point[], tolerance: number): Point[] {
|
|
611
|
+
if (points.length <= 2) return points;
|
|
612
|
+
|
|
613
|
+
const sqTolerance = tolerance * tolerance;
|
|
614
|
+
let maxSqDist = 0;
|
|
615
|
+
let index = 0;
|
|
616
|
+
|
|
617
|
+
const first = points[0];
|
|
618
|
+
const last = points[points.length - 1];
|
|
619
|
+
|
|
620
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
621
|
+
const sqDist = this.getSqSegDist(points[i], first, last);
|
|
622
|
+
if (sqDist > maxSqDist) {
|
|
623
|
+
index = i;
|
|
624
|
+
maxSqDist = sqDist;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (maxSqDist > sqTolerance) {
|
|
629
|
+
// Check if closed loop?
|
|
630
|
+
// If closed loop, we shouldn't simplify start/end connection too much?
|
|
631
|
+
// Douglas-Peucker works on segments.
|
|
632
|
+
const left = this.douglasPeucker(points.slice(0, index + 1), tolerance);
|
|
633
|
+
const right = this.douglasPeucker(points.slice(index), tolerance);
|
|
634
|
+
return left.slice(0, left.length - 1).concat(right);
|
|
635
|
+
} else {
|
|
636
|
+
return [first, last];
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private static getSqSegDist(p: Point, p1: Point, p2: Point): number {
|
|
641
|
+
let x = p1.x;
|
|
642
|
+
let y = p1.y;
|
|
643
|
+
let dx = p2.x - x;
|
|
644
|
+
let dy = p2.y - y;
|
|
645
|
+
|
|
646
|
+
if (dx !== 0 || dy !== 0) {
|
|
647
|
+
const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
|
|
648
|
+
if (t > 1) {
|
|
649
|
+
x = p2.x;
|
|
650
|
+
y = p2.y;
|
|
651
|
+
} else if (t > 0) {
|
|
652
|
+
x += dx * t;
|
|
653
|
+
y += dy * t;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
dx = p.x - x;
|
|
658
|
+
dy = p.y - y;
|
|
659
|
+
|
|
660
|
+
return dx * dx + dy * dy;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private static scalePoints(
|
|
664
|
+
points: Point[],
|
|
665
|
+
targetWidth: number,
|
|
666
|
+
targetHeight: number,
|
|
667
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
668
|
+
): Point[] {
|
|
669
|
+
if (points.length === 0) return points;
|
|
670
|
+
|
|
671
|
+
if (bounds.width === 0 || bounds.height === 0) return points;
|
|
672
|
+
|
|
673
|
+
const scaleX = targetWidth / bounds.width;
|
|
674
|
+
const scaleY = targetHeight / bounds.height;
|
|
675
|
+
|
|
676
|
+
return points.map((p) => ({
|
|
677
|
+
x: (p.x - bounds.x) * scaleX,
|
|
678
|
+
y: (p.y - bounds.y) * scaleY,
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private static pointsToSVG(points: Point[]): string {
|
|
683
|
+
if (points.length === 0) return "";
|
|
684
|
+
const head = points[0];
|
|
685
|
+
const tail = points.slice(1);
|
|
686
|
+
|
|
687
|
+
return (
|
|
688
|
+
`M ${head.x} ${head.y} ` +
|
|
689
|
+
tail.map((p) => `L ${p.x} ${p.y}`).join(" ") +
|
|
690
|
+
" Z"
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private static ensurePaper() {
|
|
695
|
+
if (!paper.project) {
|
|
696
|
+
paper.setup(new paper.Size(100, 100));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private static pointsToSVGPaper(points: Point[], tolerance: number): string {
|
|
701
|
+
if (points.length < 3) return this.pointsToSVG(points);
|
|
702
|
+
|
|
703
|
+
this.ensurePaper();
|
|
704
|
+
|
|
705
|
+
// Create Path
|
|
706
|
+
const path = new paper.Path({
|
|
707
|
+
segments: points.map(p => [p.x, p.y]),
|
|
708
|
+
closed: true
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Simplify
|
|
712
|
+
path.simplify(tolerance);
|
|
713
|
+
|
|
714
|
+
const data = path.pathData;
|
|
715
|
+
path.remove();
|
|
716
|
+
|
|
717
|
+
return data;
|
|
718
|
+
}
|
|
719
|
+
}
|