@pooder/kit 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.test-dist/src/extensions/background.js +203 -0
  2. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  3. package/.test-dist/src/extensions/constraints.js +237 -0
  4. package/.test-dist/src/extensions/dieline.js +828 -0
  5. package/.test-dist/src/extensions/edgeScale.js +12 -0
  6. package/.test-dist/src/extensions/feature.js +825 -0
  7. package/.test-dist/src/extensions/featureComplete.js +32 -0
  8. package/.test-dist/src/extensions/film.js +167 -0
  9. package/.test-dist/src/extensions/geometry.js +545 -0
  10. package/.test-dist/src/extensions/image.js +1529 -0
  11. package/.test-dist/src/extensions/index.js +30 -0
  12. package/.test-dist/src/extensions/maskOps.js +279 -0
  13. package/.test-dist/src/extensions/mirror.js +104 -0
  14. package/.test-dist/src/extensions/ruler.js +345 -0
  15. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  16. package/.test-dist/src/extensions/sceneLayoutModel.js +196 -0
  17. package/.test-dist/src/extensions/sceneVisibility.js +62 -0
  18. package/.test-dist/src/extensions/size.js +331 -0
  19. package/.test-dist/src/extensions/tracer.js +538 -0
  20. package/.test-dist/src/extensions/white-ink.js +1190 -0
  21. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  22. package/.test-dist/src/index.js +2 -19
  23. package/.test-dist/src/services/CanvasService.js +249 -0
  24. package/.test-dist/src/services/ViewportSystem.js +76 -0
  25. package/.test-dist/src/services/index.js +24 -0
  26. package/.test-dist/src/services/renderSpec.js +2 -0
  27. package/CHANGELOG.md +12 -0
  28. package/dist/index.d.mts +11 -0
  29. package/dist/index.d.ts +11 -0
  30. package/dist/index.js +519 -395
  31. package/dist/index.mjs +519 -395
  32. package/package.json +1 -1
  33. package/src/extensions/dieline.ts +66 -17
  34. package/src/extensions/geometry.ts +36 -3
  35. package/src/extensions/image.ts +2 -0
  36. package/src/extensions/maskOps.ts +84 -18
  37. package/src/extensions/sceneLayoutModel.ts +10 -0
  38. package/src/extensions/tracer.ts +360 -389
  39. package/src/extensions/white-ink.ts +125 -2
@@ -5,12 +5,9 @@
5
5
 
6
6
  import paper from "paper";
7
7
  import {
8
- analyzeAlpha,
9
8
  circularMorphology,
10
9
  createMask,
11
10
  fillHoles,
12
- findMinimalConnectRadius,
13
- inferMaskMode,
14
11
  polygonSignedArea,
15
12
  type MaskMode,
16
13
  } from "./maskOps";
@@ -29,6 +26,25 @@ interface Bounds {
29
26
 
30
27
  type ComponentMode = "largest" | "all";
31
28
 
29
+ interface ForceConnectResult {
30
+ mask: Uint8Array;
31
+ appliedDilateRadius: number;
32
+ appliedErodeRadius: number;
33
+ reachedSingleComponent: boolean;
34
+ rawContourCount: number;
35
+ selectedContourCount: number;
36
+ }
37
+
38
+ export interface ImageTraceOptions {
39
+ threshold?: number;
40
+ simplifyTolerance?: number;
41
+ expand?: number;
42
+ smoothing?: boolean;
43
+ scaleToWidth?: number;
44
+ scaleToHeight?: number;
45
+ debug?: boolean;
46
+ }
47
+
32
48
  export class ImageTracer {
33
49
  /**
34
50
  * Main entry point: Traces an image URL to an SVG path string.
@@ -37,25 +53,7 @@ export class ImageTracer {
37
53
  */
38
54
  public static async trace(
39
55
  imageUrl: string,
40
- options: {
41
- threshold?: number; // 0-255, default 10
42
- simplifyTolerance?: number; // default 2.5
43
- scale?: number; // Scale factor for the processing canvas, default 1.0
44
- scaleToWidth?: number;
45
- scaleToHeight?: number;
46
- morphologyRadius?: number; // Default 10.
47
- connectRadiusMax?: number;
48
- maskMode?: MaskMode;
49
- whiteThreshold?: number;
50
- alphaOpaqueCutoff?: number;
51
- expand?: number; // Expansion radius in pixels. Default 0.
52
- noChannels?: boolean;
53
- smoothing?: boolean; // Use Paper.js smoothing (curve fitting). Default true.
54
- componentMode?: ComponentMode;
55
- minComponentArea?: number;
56
- forceConnected?: boolean;
57
- debug?: boolean;
58
- } = {},
56
+ options: ImageTraceOptions = {},
59
57
  ): Promise<string> {
60
58
  const { pathData } = await this.traceWithBounds(imageUrl, options);
61
59
  return pathData;
@@ -63,29 +61,20 @@ export class ImageTracer {
63
61
 
64
62
  public static async traceWithBounds(
65
63
  imageUrl: string,
66
- options: {
67
- threshold?: number;
68
- simplifyTolerance?: number;
69
- scale?: number;
70
- scaleToWidth?: number;
71
- scaleToHeight?: number;
72
- morphologyRadius?: number;
73
- connectRadiusMax?: number;
74
- maskMode?: MaskMode;
75
- whiteThreshold?: number;
76
- alphaOpaqueCutoff?: number;
77
- expand?: number;
78
- noChannels?: boolean;
79
- smoothing?: boolean;
80
- componentMode?: ComponentMode;
81
- minComponentArea?: number;
82
- forceConnected?: boolean;
83
- debug?: boolean;
84
- } = {},
64
+ options: ImageTraceOptions = {},
85
65
  ): Promise<{ pathData: string; baseBounds: Bounds; bounds: Bounds }> {
86
66
  const img = await this.loadImage(imageUrl);
87
67
  const width = img.width;
88
68
  const height = img.height;
69
+ if (width <= 0 || height <= 0) {
70
+ const w = options.scaleToWidth ?? 0;
71
+ const h = options.scaleToHeight ?? 0;
72
+ return {
73
+ pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
74
+ baseBounds: { x: 0, y: 0, width: w, height: h },
75
+ bounds: { x: 0, y: 0, width: w, height: h },
76
+ };
77
+ }
89
78
  const debug = options.debug === true;
90
79
  const debugLog = (message: string, payload?: Record<string, unknown>) => {
91
80
  if (!debug) return;
@@ -96,7 +85,7 @@ export class ImageTracer {
96
85
  console.info(`[ImageTracer] ${message}`);
97
86
  };
98
87
 
99
- // 1. Draw to canvas and get pixel data
88
+ // Draw to canvas and get pixel data
100
89
  const canvas = document.createElement("canvas");
101
90
  canvas.width = width;
102
91
  canvas.height = height;
@@ -106,119 +95,188 @@ export class ImageTracer {
106
95
  ctx.drawImage(img, 0, 0);
107
96
  const imageData = ctx.getImageData(0, 0, width, height);
108
97
 
109
- // 2. Morphology processing
98
+ // Strategy: fixed internal morphology + single-component target.
110
99
  const threshold = options.threshold ?? 10;
111
- const componentMode = options.componentMode ?? "largest";
112
- const minComponentArea = Math.max(0, options.minComponentArea ?? 0);
113
- const forceConnected = options.forceConnected === true;
114
- // Adaptive radius: 3% of the image's largest dimension, at least 5px
115
- const adaptiveRadius = Math.max(
116
- 5,
117
- Math.floor(Math.max(width, height) * 0.02),
100
+ const expand = Math.max(0, Math.floor(options.expand ?? 0));
101
+ const simplifyTolerance = options.simplifyTolerance ?? 2.5;
102
+ const useSmoothing = options.smoothing !== false;
103
+ const componentMode: ComponentMode = "all";
104
+ const minComponentArea = 0;
105
+ const maxDim = Math.max(width, height);
106
+ const maskMode: MaskMode = "auto";
107
+ const whiteThreshold = 240;
108
+ const alphaOpaqueCutoff = 250;
109
+ const preprocessDilateRadius = Math.max(
110
+ 2,
111
+ Math.floor(Math.max(maxDim * 0.012, expand * 0.35)),
118
112
  );
119
- const radius = options.morphologyRadius ?? adaptiveRadius;
120
- const expand = options.expand ?? 0;
121
- const noChannels = options.noChannels !== false;
122
- const alphaOpaqueCutoff = options.alphaOpaqueCutoff ?? 250;
123
- const resolvedMaskMode =
124
- (options.maskMode ?? "auto") === "auto"
125
- ? inferMaskMode(imageData, alphaOpaqueCutoff)
126
- : (options.maskMode as MaskMode);
127
- const alphaAnalysis = analyzeAlpha(imageData, alphaOpaqueCutoff);
113
+ const preprocessErodeRadius = Math.max(
114
+ 1,
115
+ Math.floor(preprocessDilateRadius * 0.65),
116
+ );
117
+ const smoothDilateRadius = Math.max(
118
+ 1,
119
+ Math.floor(preprocessDilateRadius * 0.25),
120
+ );
121
+ const smoothErodeRadius = Math.max(1, Math.floor(smoothDilateRadius * 0.8));
122
+ const connectStartDilateRadius = Math.max(
123
+ 1,
124
+ Math.floor(Math.max(maxDim * 0.006, expand * 0.2)),
125
+ );
126
+ const connectMaxDilateRadius = Math.max(
127
+ connectStartDilateRadius,
128
+ Math.floor(Math.max(maxDim * 0.2, expand * 2.5)),
129
+ );
130
+ const connectErodeRatio = 0.65;
131
+
128
132
  debugLog("traceWithBounds:start", {
129
133
  width,
130
134
  height,
131
135
  threshold,
132
- radius,
133
136
  expand,
134
- noChannels,
135
- maskMode: options.maskMode ?? "auto",
136
- resolvedMaskMode,
137
- alphaOpaqueCutoff,
138
- alpha: {
139
- minAlpha: alphaAnalysis.minAlpha,
140
- belowOpaqueRatio: Number(alphaAnalysis.belowOpaqueRatio.toFixed(4)),
141
- veryTransparentRatio: Number(
142
- alphaAnalysis.veryTransparentRatio.toFixed(4),
143
- ),
137
+ simplifyTolerance,
138
+ smoothing: useSmoothing,
139
+ strategy: {
140
+ maskMode,
141
+ whiteThreshold,
142
+ alphaOpaqueCutoff,
143
+ fillHoles: true,
144
+ preprocessDilateRadius,
145
+ preprocessErodeRadius,
146
+ smoothDilateRadius,
147
+ smoothErodeRadius,
148
+ connectEnabled: true,
149
+ connectStartDilateRadius,
150
+ connectMaxDilateRadius,
151
+ connectErodeRatio,
144
152
  },
145
- componentMode,
146
- minComponentArea,
147
- forceConnected,
148
- simplifyTolerance: options.simplifyTolerance ?? 2.5,
149
- smoothing: options.smoothing !== false,
150
153
  });
151
154
 
152
- // Add padding to the processing canvas to avoid edge clipping during dilation
153
- // Padding should be at least the radius + expansion size
154
- const padding = radius + expand + 2;
155
+ // Padding must cover morphology and expansion margins.
156
+ const padding =
157
+ Math.max(
158
+ preprocessDilateRadius,
159
+ smoothDilateRadius,
160
+ connectMaxDilateRadius,
161
+ expand,
162
+ ) + 2;
155
163
  const paddedWidth = width + padding * 2;
156
164
  const paddedHeight = height + padding * 2;
165
+ const summarizeMaskContours = (m: Uint8Array) => {
166
+ const summary = this.summarizeAllContours(
167
+ m,
168
+ paddedWidth,
169
+ paddedHeight,
170
+ minComponentArea,
171
+ );
172
+ return {
173
+ rawContourCount: summary.rawCount,
174
+ selectedContourCount: summary.selectedCount,
175
+ };
176
+ };
157
177
 
158
178
  let mask = createMask(imageData, {
159
179
  threshold,
160
180
  padding,
161
181
  paddedWidth,
162
182
  paddedHeight,
163
- maskMode: options.maskMode,
164
- whiteThreshold: options.whiteThreshold,
183
+ maskMode,
184
+ whiteThreshold,
165
185
  alphaOpaqueCutoff,
166
186
  });
187
+ if (debug) {
188
+ debugLog(
189
+ "traceWithBounds:mask:after-create",
190
+ summarizeMaskContours(mask),
191
+ );
192
+ }
167
193
 
168
- if (radius > 0) {
169
- mask = circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
194
+ mask = circularMorphology(
195
+ mask,
196
+ paddedWidth,
197
+ paddedHeight,
198
+ preprocessDilateRadius,
199
+ "dilate",
200
+ );
201
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
202
+ mask = circularMorphology(
203
+ mask,
204
+ paddedWidth,
205
+ paddedHeight,
206
+ preprocessErodeRadius,
207
+ "erode",
208
+ );
209
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
210
+ if (debug) {
211
+ debugLog("traceWithBounds:mask:after-preprocess", {
212
+ dilateRadius: preprocessDilateRadius,
213
+ erodeRadius: preprocessErodeRadius,
214
+ ...summarizeMaskContours(mask),
215
+ });
170
216
  }
171
217
 
172
- if (noChannels) {
173
- mask = fillHoles(mask, paddedWidth, paddedHeight);
218
+ mask = circularMorphology(
219
+ mask,
220
+ paddedWidth,
221
+ paddedHeight,
222
+ smoothDilateRadius,
223
+ "dilate",
224
+ );
225
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
226
+ mask = circularMorphology(
227
+ mask,
228
+ paddedWidth,
229
+ paddedHeight,
230
+ smoothErodeRadius,
231
+ "erode",
232
+ );
233
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
234
+ if (debug) {
235
+ debugLog("traceWithBounds:mask:after-smooth", {
236
+ dilateRadius: smoothDilateRadius,
237
+ erodeRadius: smoothErodeRadius,
238
+ ...summarizeMaskContours(mask),
239
+ });
174
240
  }
175
241
 
176
- if (radius > 0) {
177
- const smoothRadius = Math.max(2, Math.floor(radius * 0.3));
178
- mask = circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
242
+ const beforeConnectSummary = summarizeMaskContours(mask);
243
+ if (beforeConnectSummary.selectedContourCount <= 1) {
244
+ debugLog("traceWithBounds:mask:connect-skipped", {
245
+ reason: "already-single-component",
246
+ before: beforeConnectSummary,
247
+ });
248
+ } else {
249
+ const connectResult = this.findForceConnectResult(
250
+ mask,
251
+ paddedWidth,
252
+ paddedHeight,
253
+ minComponentArea,
254
+ connectStartDilateRadius,
255
+ connectMaxDilateRadius,
256
+ connectErodeRatio,
257
+ );
258
+ if (debug) {
259
+ debugLog("traceWithBounds:mask:after-connect", {
260
+ before: beforeConnectSummary,
261
+ appliedDilateRadius: connectResult.appliedDilateRadius,
262
+ appliedErodeRadius: connectResult.appliedErodeRadius,
263
+ reachedSingleComponent: connectResult.reachedSingleComponent,
264
+ after: {
265
+ rawContourCount: connectResult.rawContourCount,
266
+ selectedContourCount: connectResult.selectedContourCount,
267
+ },
268
+ });
269
+ }
270
+ mask = connectResult.mask;
179
271
  }
180
272
 
181
- const autoConnectRadiusMax = Math.max(
182
- 10,
183
- Math.floor(Math.max(width, height) * 0.12),
184
- );
185
- const requestedConnectRadiusMax = options.connectRadiusMax;
186
- const connectRadiusMax =
187
- requestedConnectRadiusMax === undefined
188
- ? autoConnectRadiusMax
189
- : requestedConnectRadiusMax > 0
190
- ? requestedConnectRadiusMax
191
- : forceConnected
192
- ? autoConnectRadiusMax
193
- : 0;
194
-
195
- let rConnect = 0;
196
- if (connectRadiusMax > 0) {
197
- rConnect = forceConnected
198
- ? this.findMinimalMergeRadiusByContourCount(
199
- mask,
200
- paddedWidth,
201
- paddedHeight,
202
- connectRadiusMax,
203
- minComponentArea,
204
- )
205
- : findMinimalConnectRadius(
206
- mask,
207
- paddedWidth,
208
- paddedHeight,
209
- connectRadiusMax,
210
- );
211
- if (rConnect > 0) {
212
- mask = circularMorphology(
213
- mask,
214
- paddedWidth,
215
- paddedHeight,
216
- rConnect,
217
- "closing",
218
- );
219
- if (noChannels) {
220
- mask = fillHoles(mask, paddedWidth, paddedHeight);
221
- }
273
+ if (debug) {
274
+ const afterConnectSummary = summarizeMaskContours(mask);
275
+ if (afterConnectSummary.selectedContourCount > 1) {
276
+ debugLog("traceWithBounds:mask:connect-warning", {
277
+ reason: "still-multi-component-after-connect-search",
278
+ summary: afterConnectSummary,
279
+ });
222
280
  }
223
281
  }
224
282
 
@@ -248,14 +306,10 @@ export class ImageTracer {
248
306
 
249
307
  const baseUnpaddedContours = baseContours
250
308
  .map((contour) =>
251
- this.clampPointsToImageBounds(
252
- contour.map((p) => ({
253
- x: p.x - padding,
254
- y: p.y - padding,
255
- })),
256
- width,
257
- height,
258
- ),
309
+ contour.map((p) => ({
310
+ x: p.x - padding,
311
+ y: p.y - padding,
312
+ })),
259
313
  )
260
314
  .filter((contour) => contour.length > 2);
261
315
 
@@ -309,16 +363,15 @@ export class ImageTracer {
309
363
  };
310
364
  }
311
365
 
366
+ // Keep expanded coordinates in the unpadded space without clamping to
367
+ // original image bounds. If the shape touches an edge, clamping would
368
+ // drop one-sided expand distance (e.g. bottom/right expansion).
312
369
  const expandedUnpaddedContours = expandedContours
313
370
  .map((contour) =>
314
- this.clampPointsToImageBounds(
315
- contour.map((p) => ({
316
- x: p.x - padding,
317
- y: p.y - padding,
318
- })),
319
- width,
320
- height,
321
- ),
371
+ contour.map((p) => ({
372
+ x: p.x - padding,
373
+ y: p.y - padding,
374
+ })),
322
375
  )
323
376
  .filter((contour) => contour.length > 2);
324
377
  if (!expandedUnpaddedContours.length) {
@@ -339,7 +392,7 @@ export class ImageTracer {
339
392
  this.flattenContours(expandedUnpaddedContours),
340
393
  );
341
394
 
342
- // 9. Post-processing (Scale)
395
+ // Post-processing (Scale)
343
396
  let finalContours = expandedUnpaddedContours;
344
397
  if (options.scaleToWidth && options.scaleToHeight) {
345
398
  finalContours = this.scaleContours(
@@ -356,43 +409,82 @@ export class ImageTracer {
356
409
  options.scaleToHeight,
357
410
  baseBounds,
358
411
  );
359
- baseBounds = this.boundsFromPoints(this.flattenContours(baseScaledContours));
412
+ baseBounds = this.boundsFromPoints(
413
+ this.flattenContours(baseScaledContours),
414
+ );
415
+ }
416
+
417
+ if (expand > 0) {
418
+ const expectedExpandedBounds = {
419
+ x: baseBounds.x - expand,
420
+ y: baseBounds.y - expand,
421
+ width: baseBounds.width + expand * 2,
422
+ height: baseBounds.height + expand * 2,
423
+ };
424
+ if (
425
+ expectedExpandedBounds.width > 0 &&
426
+ expectedExpandedBounds.height > 0 &&
427
+ globalBounds.width > 0 &&
428
+ globalBounds.height > 0
429
+ ) {
430
+ const shouldNormalizeExpandBounds =
431
+ Math.abs(globalBounds.x - expectedExpandedBounds.x) > 1 ||
432
+ Math.abs(globalBounds.y - expectedExpandedBounds.y) > 1 ||
433
+ Math.abs(globalBounds.width - expectedExpandedBounds.width) > 1 ||
434
+ Math.abs(globalBounds.height - expectedExpandedBounds.height) > 1;
435
+ if (shouldNormalizeExpandBounds) {
436
+ const beforeNormalize = globalBounds;
437
+ finalContours = this.translateContours(
438
+ this.scaleContours(
439
+ finalContours,
440
+ expectedExpandedBounds.width,
441
+ expectedExpandedBounds.height,
442
+ globalBounds,
443
+ ),
444
+ expectedExpandedBounds.x,
445
+ expectedExpandedBounds.y,
446
+ );
447
+ globalBounds = this.boundsFromPoints(
448
+ this.flattenContours(finalContours),
449
+ );
450
+ debugLog("traceWithBounds:expand-normalized", {
451
+ expand,
452
+ expectedExpandedBounds,
453
+ beforeNormalize,
454
+ afterNormalize: globalBounds,
455
+ });
456
+ }
457
+ }
360
458
  }
361
459
 
362
- // 10. Simplify and Generate SVG
363
- const useSmoothing = options.smoothing !== false; // Default true
460
+ // Simplify and Generate SVG
364
461
  debugLog("traceWithBounds:contours", {
365
462
  baseContourCount: baseContoursRaw.length,
366
463
  baseSelectedCount: baseContours.length,
367
464
  expandedContourCount: expandedContoursRaw.length,
368
465
  expandedSelectedCount: expandedContours.length,
369
- connectRadiusMax,
370
- appliedConnectRadius: rConnect,
371
466
  baseBounds,
372
467
  expandedBounds: globalBounds,
373
468
  expandedDeltaX: globalBounds.width - baseBounds.width,
374
469
  expandedDeltaY: globalBounds.height - baseBounds.height,
470
+ expandedMayOverflowImageBounds: expand > 0,
375
471
  useSmoothing,
376
472
  componentMode,
377
473
  });
378
474
 
379
475
  if (useSmoothing) {
380
476
  return {
381
- pathData: this.contoursToSVGPaper(
382
- finalContours,
383
- options.simplifyTolerance ?? 2.5,
384
- ),
477
+ pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
385
478
  baseBounds,
386
479
  bounds: globalBounds,
387
480
  };
388
481
  } else {
389
482
  const simplifiedContours = finalContours
390
- .map((points) =>
391
- this.douglasPeucker(points, options.simplifyTolerance ?? 2.0),
392
- )
483
+ .map((points) => this.douglasPeucker(points, simplifyTolerance))
393
484
  .filter((points) => points.length > 2);
394
485
  const pathData =
395
- this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
486
+ this.contoursToSVG(simplifiedContours) ||
487
+ this.contoursToSVG(finalContours);
396
488
  return {
397
489
  pathData,
398
490
  baseBounds,
@@ -438,7 +530,7 @@ export class ImageTracer {
438
530
  const yj = polygon[j].y;
439
531
  const intersects =
440
532
  yi > y !== yj > y &&
441
- x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi;
533
+ x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
442
534
  if (intersects) inside = !inside;
443
535
  }
444
536
  return inside;
@@ -463,55 +555,120 @@ export class ImageTracer {
463
555
  return selected;
464
556
  }
465
557
 
466
- private static countSelectedContours(
558
+ private static summarizeAllContours(
467
559
  mask: Uint8Array,
468
560
  width: number,
469
561
  height: number,
470
562
  minComponentArea: number,
471
- ): number {
472
- const contours = this.traceAllContours(mask, width, height);
473
- return this.selectContours(contours, "all", minComponentArea).length;
563
+ ): { rawCount: number; selectedCount: number } {
564
+ const raw = this.traceAllContours(mask, width, height);
565
+ const selected = this.selectContours(raw, "all", minComponentArea);
566
+ return {
567
+ rawCount: raw.length,
568
+ selectedCount: selected.length,
569
+ };
474
570
  }
475
571
 
476
- private static findMinimalMergeRadiusByContourCount(
477
- mask: Uint8Array,
572
+ private static findForceConnectResult(
573
+ sourceMask: Uint8Array,
478
574
  width: number,
479
575
  height: number,
480
- maxRadius: number,
481
576
  minComponentArea: number,
482
- ): number {
483
- if (maxRadius <= 0) return 0;
484
- if (this.countSelectedContours(mask, width, height, minComponentArea) <= 1) {
485
- return 0;
577
+ startDilateRadius: number,
578
+ maxDilateRadius: number,
579
+ erodeRatio: number,
580
+ ): ForceConnectResult {
581
+ const initial = this.summarizeAllContours(
582
+ sourceMask,
583
+ width,
584
+ height,
585
+ minComponentArea,
586
+ );
587
+ if (initial.selectedCount <= 1) {
588
+ return {
589
+ mask: sourceMask,
590
+ appliedDilateRadius: 0,
591
+ appliedErodeRadius: 0,
592
+ reachedSingleComponent: true,
593
+ rawContourCount: initial.rawCount,
594
+ selectedContourCount: initial.selectedCount,
595
+ };
486
596
  }
487
597
 
488
- let low = 0;
489
- let high = 1;
490
- while (high <= maxRadius) {
491
- const closed = circularMorphology(mask, width, height, high, "closing");
492
- if (this.countSelectedContours(closed, width, height, minComponentArea) <= 1) {
493
- break;
494
- }
495
- high *= 2;
598
+ const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
599
+ const normalizedMax = Math.max(
600
+ normalizedStart,
601
+ Math.floor(maxDilateRadius),
602
+ );
603
+ const normalizedErodeRatio = Math.max(0, erodeRatio);
604
+ const evaluate = (dilateRadius: number) => {
605
+ const erodeRadius = Math.max(
606
+ 1,
607
+ Math.floor(dilateRadius * normalizedErodeRatio),
608
+ );
609
+ let mask = sourceMask;
610
+ mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
611
+ mask = fillHoles(mask, width, height);
612
+ mask = circularMorphology(mask, width, height, erodeRadius, "erode");
613
+ mask = fillHoles(mask, width, height);
614
+ const summary = this.summarizeAllContours(
615
+ mask,
616
+ width,
617
+ height,
618
+ minComponentArea,
619
+ );
620
+ return {
621
+ dilateRadius,
622
+ erodeRadius,
623
+ mask,
624
+ rawCount: summary.rawCount,
625
+ selectedCount: summary.selectedCount,
626
+ };
627
+ };
628
+
629
+ let low = normalizedStart - 1;
630
+ let high = normalizedStart;
631
+ let highResult = evaluate(high);
632
+ while (high < normalizedMax && highResult.selectedCount > 1) {
633
+ low = high;
634
+ high = Math.min(
635
+ normalizedMax,
636
+ Math.max(high + 1, Math.floor(high * 1.6)),
637
+ );
638
+ highResult = evaluate(high);
496
639
  }
497
- if (high > maxRadius) high = maxRadius;
498
640
 
499
- const highMask = circularMorphology(mask, width, height, high, "closing");
500
- if (this.countSelectedContours(highMask, width, height, minComponentArea) > 1) {
501
- return high;
641
+ if (highResult.selectedCount > 1) {
642
+ return {
643
+ mask: highResult.mask,
644
+ appliedDilateRadius: highResult.dilateRadius,
645
+ appliedErodeRadius: highResult.erodeRadius,
646
+ reachedSingleComponent: false,
647
+ rawContourCount: highResult.rawCount,
648
+ selectedContourCount: highResult.selectedCount,
649
+ };
502
650
  }
503
651
 
652
+ let best = highResult;
504
653
  while (low + 1 < high) {
505
654
  const mid = Math.floor((low + high) / 2);
506
- const midMask = circularMorphology(mask, width, height, mid, "closing");
507
- if (this.countSelectedContours(midMask, width, height, minComponentArea) <= 1) {
655
+ const midResult = evaluate(mid);
656
+ if (midResult.selectedCount <= 1) {
657
+ best = midResult;
508
658
  high = mid;
509
659
  } else {
510
660
  low = mid;
511
661
  }
512
662
  }
513
663
 
514
- return high;
664
+ return {
665
+ mask: best.mask,
666
+ appliedDilateRadius: best.dilateRadius,
667
+ appliedErodeRadius: best.erodeRadius,
668
+ reachedSingleComponent: true,
669
+ rawContourCount: best.rawCount,
670
+ selectedContourCount: best.selectedCount,
671
+ };
515
672
  }
516
673
 
517
674
  private static selectContours(
@@ -566,192 +723,6 @@ export class ImageTracer {
566
723
  };
567
724
  }
568
725
 
569
- private static createMask(
570
- imageData: ImageData,
571
- threshold: number,
572
- padding: number,
573
- paddedWidth: number,
574
- paddedHeight: number,
575
- ): Uint8Array {
576
- const { width, height, data } = imageData;
577
- const mask = new Uint8Array(paddedWidth * paddedHeight);
578
-
579
- // 1. Detect if the image has transparency (any pixel with alpha < 255)
580
- let hasTransparency = false;
581
- for (let i = 3; i < data.length; i += 4) {
582
- if (data[i] < 255) {
583
- hasTransparency = true;
584
- break;
585
- }
586
- }
587
-
588
- // 2. Binarize based on alpha or luminance
589
- for (let y = 0; y < height; y++) {
590
- for (let x = 0; x < width; x++) {
591
- const srcIdx = (y * width + x) * 4;
592
- const r = data[srcIdx];
593
- const g = data[srcIdx + 1];
594
- const b = data[srcIdx + 2];
595
- const a = data[srcIdx + 3];
596
-
597
- const destIdx = (y + padding) * paddedWidth + (x + padding);
598
-
599
- if (hasTransparency) {
600
- if (a > threshold) {
601
- mask[destIdx] = 1;
602
- }
603
- } else {
604
- if (!(r > 240 && g > 240 && b > 240)) {
605
- mask[destIdx] = 1;
606
- }
607
- }
608
- }
609
- }
610
- return mask;
611
- }
612
-
613
- /**
614
- * Fast circular morphology using a distance-transform inspired separable approach.
615
- * O(N * R) complexity, where R is the radius.
616
- */
617
- private static circularMorphology(
618
- mask: Uint8Array,
619
- width: number,
620
- height: number,
621
- radius: number,
622
- op: "dilate" | "erode" | "closing" | "opening",
623
- ): Uint8Array {
624
- const dilate = (m: Uint8Array, r: number) => {
625
- const horizontalDist = new Int32Array(width * height);
626
- // Horizontal pass: dist to nearest solid pixel in row
627
- for (let y = 0; y < height; y++) {
628
- let lastSolid = -r * 2;
629
- for (let x = 0; x < width; x++) {
630
- if (m[y * width + x]) lastSolid = x;
631
- horizontalDist[y * width + x] = x - lastSolid;
632
- }
633
- lastSolid = width + r * 2;
634
- for (let x = width - 1; x >= 0; x--) {
635
- if (m[y * width + x]) lastSolid = x;
636
- horizontalDist[y * width + x] = Math.min(
637
- horizontalDist[y * width + x],
638
- lastSolid - x,
639
- );
640
- }
641
- }
642
-
643
- const result = new Uint8Array(width * height);
644
- const r2 = r * r;
645
- // Vertical pass: check Euclidean distance using precomputed horizontal distances
646
- for (let x = 0; x < width; x++) {
647
- for (let y = 0; y < height; y++) {
648
- let found = false;
649
- const minY = Math.max(0, y - r);
650
- const maxY = Math.min(height - 1, y + r);
651
- for (let dy = minY; dy <= maxY; dy++) {
652
- const dY = dy - y;
653
- const hDist = horizontalDist[dy * width + x];
654
- if (hDist * hDist + dY * dY <= r2) {
655
- found = true;
656
- break;
657
- }
658
- }
659
- if (found) result[y * width + x] = 1;
660
- }
661
- }
662
- return result;
663
- };
664
-
665
- const erode = (m: Uint8Array, r: number) => {
666
- // Erosion is dilation of the inverted mask
667
- const inverted = new Uint8Array(m.length);
668
- for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
669
- const dilatedInverted = dilate(inverted, r);
670
- const result = new Uint8Array(m.length);
671
- for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
672
- return result;
673
- };
674
-
675
- switch (op) {
676
- case "dilate":
677
- return dilate(mask, radius);
678
- case "erode":
679
- return erode(mask, radius);
680
- case "closing":
681
- return erode(dilate(mask, radius), radius);
682
- case "opening":
683
- return dilate(erode(mask, radius), radius);
684
- default:
685
- return mask;
686
- }
687
- }
688
-
689
- /**
690
- * Fills internal holes in the binary mask using flood fill from edges.
691
- */
692
- private static fillHoles(
693
- mask: Uint8Array,
694
- width: number,
695
- height: number,
696
- ): Uint8Array {
697
- const background = new Uint8Array(width * height);
698
- const queue: [number, number][] = [];
699
-
700
- // Add all edge pixels that are 0 to the queue
701
- for (let x = 0; x < width; x++) {
702
- if (mask[x] === 0) {
703
- background[x] = 1;
704
- queue.push([x, 0]);
705
- }
706
- const lastRow = (height - 1) * width + x;
707
- if (mask[lastRow] === 0) {
708
- background[lastRow] = 1;
709
- queue.push([x, height - 1]);
710
- }
711
- }
712
- for (let y = 1; y < height - 1; y++) {
713
- if (mask[y * width] === 0) {
714
- background[y * width] = 1;
715
- queue.push([0, y]);
716
- }
717
- if (mask[y * width + width - 1] === 0) {
718
- background[y * width + width - 1] = 1;
719
- queue.push([width - 1, y]);
720
- }
721
- }
722
-
723
- // Flood fill from the edges to find all background pixels
724
- const dirs = [
725
- [0, 1],
726
- [0, -1],
727
- [1, 0],
728
- [-1, 0],
729
- ];
730
- let head = 0;
731
- while (head < queue.length) {
732
- const [cx, cy] = queue[head++];
733
- for (const [dx, dy] of dirs) {
734
- const nx = cx + dx;
735
- const ny = cy + dy;
736
- if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
737
- const nidx = ny * width + nx;
738
- if (mask[nidx] === 0 && background[nidx] === 0) {
739
- background[nidx] = 1;
740
- queue.push([nx, ny]);
741
- }
742
- }
743
- }
744
- }
745
-
746
- // Any pixel that is NOT reachable from the background is part of the "filled" mask
747
- const filledMask = new Uint8Array(width * height);
748
- for (let i = 0; i < width * height; i++) {
749
- filledMask[i] = background[i] === 0 ? 1 : 0;
750
- }
751
-
752
- return filledMask;
753
- }
754
-
755
726
  /**
756
727
  * Traces all contours in the mask with optimized start-point detection
757
728
  */
@@ -960,17 +931,17 @@ export class ImageTracer {
960
931
  );
961
932
  }
962
933
 
963
- private static clampPointsToImageBounds(
964
- points: Point[],
965
- width: number,
966
- height: number,
967
- ): Point[] {
968
- const maxX = Math.max(0, width);
969
- const maxY = Math.max(0, height);
970
- return points.map((p) => ({
971
- x: Math.max(0, Math.min(maxX, p.x)),
972
- y: Math.max(0, Math.min(maxY, p.y)),
973
- }));
934
+ private static translateContours(
935
+ contours: Point[][],
936
+ offsetX: number,
937
+ offsetY: number,
938
+ ): Point[][] {
939
+ return contours.map((points) =>
940
+ points.map((p) => ({
941
+ x: p.x + offsetX,
942
+ y: p.y + offsetY,
943
+ })),
944
+ );
974
945
  }
975
946
 
976
947
  private static pointsToSVG(points: Point[]): string {
@@ -1001,21 +972,21 @@ export class ImageTracer {
1001
972
 
1002
973
  private static pointsToSVGPaper(points: Point[], tolerance: number): string {
1003
974
  if (points.length < 3) return this.pointsToSVG(points);
1004
-
975
+
1005
976
  this.ensurePaper();
1006
-
977
+
1007
978
  // Create Path
1008
979
  const path = new paper.Path({
1009
- segments: points.map(p => [p.x, p.y]),
1010
- closed: true
980
+ segments: points.map((p) => [p.x, p.y]),
981
+ closed: true,
1011
982
  });
1012
-
983
+
1013
984
  // Simplify
1014
985
  path.simplify(tolerance);
1015
-
986
+
1016
987
  const data = path.pathData;
1017
988
  path.remove();
1018
-
989
+
1019
990
  return data;
1020
991
  }
1021
992