@pooder/kit 5.2.0 → 5.3.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.
Files changed (86) hide show
  1. package/.test-dist/src/CanvasService.js +249 -249
  2. package/.test-dist/src/ViewportSystem.js +75 -75
  3. package/.test-dist/src/background.js +203 -203
  4. package/.test-dist/src/bridgeSelection.js +20 -20
  5. package/.test-dist/src/constraints.js +237 -237
  6. package/.test-dist/src/dieline.js +818 -818
  7. package/.test-dist/src/edgeScale.js +12 -12
  8. package/.test-dist/src/extensions/background.js +203 -0
  9. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  10. package/.test-dist/src/extensions/constraints.js +237 -0
  11. package/.test-dist/src/extensions/dieline.js +828 -0
  12. package/.test-dist/src/extensions/edgeScale.js +12 -0
  13. package/.test-dist/src/extensions/feature.js +825 -0
  14. package/.test-dist/src/extensions/featureComplete.js +32 -0
  15. package/.test-dist/src/extensions/film.js +167 -0
  16. package/.test-dist/src/extensions/geometry.js +545 -0
  17. package/.test-dist/src/extensions/image.js +1529 -0
  18. package/.test-dist/src/extensions/index.js +30 -0
  19. package/.test-dist/src/extensions/maskOps.js +279 -0
  20. package/.test-dist/src/extensions/mirror.js +104 -0
  21. package/.test-dist/src/extensions/ruler.js +345 -0
  22. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  23. package/.test-dist/src/extensions/sceneLayoutModel.js +196 -0
  24. package/.test-dist/src/extensions/sceneVisibility.js +62 -0
  25. package/.test-dist/src/extensions/size.js +331 -0
  26. package/.test-dist/src/extensions/tracer.js +538 -0
  27. package/.test-dist/src/extensions/white-ink.js +1190 -0
  28. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  29. package/.test-dist/src/feature.js +826 -826
  30. package/.test-dist/src/featureComplete.js +32 -32
  31. package/.test-dist/src/film.js +167 -167
  32. package/.test-dist/src/geometry.js +506 -506
  33. package/.test-dist/src/image.js +1250 -1250
  34. package/.test-dist/src/index.js +2 -19
  35. package/.test-dist/src/maskOps.js +270 -270
  36. package/.test-dist/src/mirror.js +104 -104
  37. package/.test-dist/src/renderSpec.js +2 -2
  38. package/.test-dist/src/ruler.js +343 -343
  39. package/.test-dist/src/sceneLayout.js +99 -99
  40. package/.test-dist/src/sceneLayoutModel.js +196 -196
  41. package/.test-dist/src/sceneView.js +40 -40
  42. package/.test-dist/src/sceneVisibility.js +42 -42
  43. package/.test-dist/src/services/CanvasService.js +249 -0
  44. package/.test-dist/src/services/ViewportSystem.js +76 -0
  45. package/.test-dist/src/services/index.js +24 -0
  46. package/.test-dist/src/services/renderSpec.js +2 -0
  47. package/.test-dist/src/size.js +332 -332
  48. package/.test-dist/src/tracer.js +544 -544
  49. package/.test-dist/src/white-ink.js +829 -829
  50. package/.test-dist/src/wrappedOffsets.js +33 -33
  51. package/CHANGELOG.md +6 -0
  52. package/dist/index.d.mts +5 -0
  53. package/dist/index.d.ts +5 -0
  54. package/dist/index.js +411 -375
  55. package/dist/index.mjs +411 -375
  56. package/package.json +1 -1
  57. package/src/coordinate.ts +106 -106
  58. package/src/extensions/background.ts +230 -230
  59. package/src/extensions/bridgeSelection.ts +17 -17
  60. package/src/extensions/constraints.ts +322 -322
  61. package/src/extensions/dieline.ts +20 -17
  62. package/src/extensions/edgeScale.ts +19 -19
  63. package/src/extensions/feature.ts +1021 -1021
  64. package/src/extensions/featureComplete.ts +46 -46
  65. package/src/extensions/film.ts +194 -194
  66. package/src/extensions/geometry.ts +719 -719
  67. package/src/extensions/image.ts +1924 -1924
  68. package/src/extensions/index.ts +11 -11
  69. package/src/extensions/maskOps.ts +365 -299
  70. package/src/extensions/mirror.ts +128 -128
  71. package/src/extensions/ruler.ts +451 -451
  72. package/src/extensions/sceneLayout.ts +140 -140
  73. package/src/extensions/sceneLayoutModel.ts +342 -342
  74. package/src/extensions/sceneVisibility.ts +71 -71
  75. package/src/extensions/size.ts +389 -389
  76. package/src/extensions/tracer.ts +302 -370
  77. package/src/extensions/white-ink.ts +1489 -1366
  78. package/src/extensions/wrappedOffsets.ts +33 -33
  79. package/src/index.ts +2 -2
  80. package/src/services/CanvasService.ts +300 -300
  81. package/src/services/ViewportSystem.ts +95 -95
  82. package/src/services/index.ts +3 -3
  83. package/src/services/renderSpec.ts +18 -18
  84. package/src/units.ts +27 -27
  85. package/tests/run.ts +118 -118
  86. package/tsconfig.test.json +15 -15
@@ -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
 
@@ -309,16 +367,15 @@ export class ImageTracer {
309
367
  };
310
368
  }
311
369
 
370
+ // Keep expanded coordinates in the unpadded space without clamping to
371
+ // original image bounds. If the shape touches an edge, clamping would
372
+ // drop one-sided expand distance (e.g. bottom/right expansion).
312
373
  const expandedUnpaddedContours = expandedContours
313
374
  .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
- ),
375
+ contour.map((p) => ({
376
+ x: p.x - padding,
377
+ y: p.y - padding,
378
+ })),
322
379
  )
323
380
  .filter((contour) => contour.length > 2);
324
381
  if (!expandedUnpaddedContours.length) {
@@ -339,7 +396,7 @@ export class ImageTracer {
339
396
  this.flattenContours(expandedUnpaddedContours),
340
397
  );
341
398
 
342
- // 9. Post-processing (Scale)
399
+ // Post-processing (Scale)
343
400
  let finalContours = expandedUnpaddedContours;
344
401
  if (options.scaleToWidth && options.scaleToHeight) {
345
402
  finalContours = this.scaleContours(
@@ -356,43 +413,39 @@ export class ImageTracer {
356
413
  options.scaleToHeight,
357
414
  baseBounds,
358
415
  );
359
- baseBounds = this.boundsFromPoints(this.flattenContours(baseScaledContours));
416
+ baseBounds = this.boundsFromPoints(
417
+ this.flattenContours(baseScaledContours),
418
+ );
360
419
  }
361
420
 
362
- // 10. Simplify and Generate SVG
363
- const useSmoothing = options.smoothing !== false; // Default true
421
+ // Simplify and Generate SVG
364
422
  debugLog("traceWithBounds:contours", {
365
423
  baseContourCount: baseContoursRaw.length,
366
424
  baseSelectedCount: baseContours.length,
367
425
  expandedContourCount: expandedContoursRaw.length,
368
426
  expandedSelectedCount: expandedContours.length,
369
- connectRadiusMax,
370
- appliedConnectRadius: rConnect,
371
427
  baseBounds,
372
428
  expandedBounds: globalBounds,
373
429
  expandedDeltaX: globalBounds.width - baseBounds.width,
374
430
  expandedDeltaY: globalBounds.height - baseBounds.height,
431
+ expandedMayOverflowImageBounds: expand > 0,
375
432
  useSmoothing,
376
433
  componentMode,
377
434
  });
378
435
 
379
436
  if (useSmoothing) {
380
437
  return {
381
- pathData: this.contoursToSVGPaper(
382
- finalContours,
383
- options.simplifyTolerance ?? 2.5,
384
- ),
438
+ pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
385
439
  baseBounds,
386
440
  bounds: globalBounds,
387
441
  };
388
442
  } else {
389
443
  const simplifiedContours = finalContours
390
- .map((points) =>
391
- this.douglasPeucker(points, options.simplifyTolerance ?? 2.0),
392
- )
444
+ .map((points) => this.douglasPeucker(points, simplifyTolerance))
393
445
  .filter((points) => points.length > 2);
394
446
  const pathData =
395
- this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
447
+ this.contoursToSVG(simplifiedContours) ||
448
+ this.contoursToSVG(finalContours);
396
449
  return {
397
450
  pathData,
398
451
  baseBounds,
@@ -438,7 +491,7 @@ export class ImageTracer {
438
491
  const yj = polygon[j].y;
439
492
  const intersects =
440
493
  yi > y !== yj > y &&
441
- x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi;
494
+ x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
442
495
  if (intersects) inside = !inside;
443
496
  }
444
497
  return inside;
@@ -463,55 +516,120 @@ export class ImageTracer {
463
516
  return selected;
464
517
  }
465
518
 
466
- private static countSelectedContours(
519
+ private static summarizeAllContours(
467
520
  mask: Uint8Array,
468
521
  width: number,
469
522
  height: number,
470
523
  minComponentArea: number,
471
- ): number {
472
- const contours = this.traceAllContours(mask, width, height);
473
- return this.selectContours(contours, "all", minComponentArea).length;
524
+ ): { rawCount: number; selectedCount: number } {
525
+ const raw = this.traceAllContours(mask, width, height);
526
+ const selected = this.selectContours(raw, "all", minComponentArea);
527
+ return {
528
+ rawCount: raw.length,
529
+ selectedCount: selected.length,
530
+ };
474
531
  }
475
532
 
476
- private static findMinimalMergeRadiusByContourCount(
477
- mask: Uint8Array,
533
+ private static findForceConnectResult(
534
+ sourceMask: Uint8Array,
478
535
  width: number,
479
536
  height: number,
480
- maxRadius: number,
481
537
  minComponentArea: number,
482
- ): number {
483
- if (maxRadius <= 0) return 0;
484
- if (this.countSelectedContours(mask, width, height, minComponentArea) <= 1) {
485
- return 0;
538
+ startDilateRadius: number,
539
+ maxDilateRadius: number,
540
+ erodeRatio: number,
541
+ ): ForceConnectResult {
542
+ const initial = this.summarizeAllContours(
543
+ sourceMask,
544
+ width,
545
+ height,
546
+ minComponentArea,
547
+ );
548
+ if (initial.selectedCount <= 1) {
549
+ return {
550
+ mask: sourceMask,
551
+ appliedDilateRadius: 0,
552
+ appliedErodeRadius: 0,
553
+ reachedSingleComponent: true,
554
+ rawContourCount: initial.rawCount,
555
+ selectedContourCount: initial.selectedCount,
556
+ };
486
557
  }
487
558
 
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;
559
+ const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
560
+ const normalizedMax = Math.max(
561
+ normalizedStart,
562
+ Math.floor(maxDilateRadius),
563
+ );
564
+ const normalizedErodeRatio = Math.max(0, erodeRatio);
565
+ const evaluate = (dilateRadius: number) => {
566
+ const erodeRadius = Math.max(
567
+ 1,
568
+ Math.floor(dilateRadius * normalizedErodeRatio),
569
+ );
570
+ let mask = sourceMask;
571
+ mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
572
+ mask = fillHoles(mask, width, height);
573
+ mask = circularMorphology(mask, width, height, erodeRadius, "erode");
574
+ mask = fillHoles(mask, width, height);
575
+ const summary = this.summarizeAllContours(
576
+ mask,
577
+ width,
578
+ height,
579
+ minComponentArea,
580
+ );
581
+ return {
582
+ dilateRadius,
583
+ erodeRadius,
584
+ mask,
585
+ rawCount: summary.rawCount,
586
+ selectedCount: summary.selectedCount,
587
+ };
588
+ };
589
+
590
+ let low = normalizedStart - 1;
591
+ let high = normalizedStart;
592
+ let highResult = evaluate(high);
593
+ while (high < normalizedMax && highResult.selectedCount > 1) {
594
+ low = high;
595
+ high = Math.min(
596
+ normalizedMax,
597
+ Math.max(high + 1, Math.floor(high * 1.6)),
598
+ );
599
+ highResult = evaluate(high);
496
600
  }
497
- if (high > maxRadius) high = maxRadius;
498
601
 
499
- const highMask = circularMorphology(mask, width, height, high, "closing");
500
- if (this.countSelectedContours(highMask, width, height, minComponentArea) > 1) {
501
- return high;
602
+ if (highResult.selectedCount > 1) {
603
+ return {
604
+ mask: highResult.mask,
605
+ appliedDilateRadius: highResult.dilateRadius,
606
+ appliedErodeRadius: highResult.erodeRadius,
607
+ reachedSingleComponent: false,
608
+ rawContourCount: highResult.rawCount,
609
+ selectedContourCount: highResult.selectedCount,
610
+ };
502
611
  }
503
612
 
613
+ let best = highResult;
504
614
  while (low + 1 < high) {
505
615
  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) {
616
+ const midResult = evaluate(mid);
617
+ if (midResult.selectedCount <= 1) {
618
+ best = midResult;
508
619
  high = mid;
509
620
  } else {
510
621
  low = mid;
511
622
  }
512
623
  }
513
624
 
514
- return high;
625
+ return {
626
+ mask: best.mask,
627
+ appliedDilateRadius: best.dilateRadius,
628
+ appliedErodeRadius: best.erodeRadius,
629
+ reachedSingleComponent: true,
630
+ rawContourCount: best.rawCount,
631
+ selectedContourCount: best.selectedCount,
632
+ };
515
633
  }
516
634
 
517
635
  private static selectContours(
@@ -566,192 +684,6 @@ export class ImageTracer {
566
684
  };
567
685
  }
568
686
 
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
687
  /**
756
688
  * Traces all contours in the mask with optimized start-point detection
757
689
  */
@@ -1001,21 +933,21 @@ export class ImageTracer {
1001
933
 
1002
934
  private static pointsToSVGPaper(points: Point[], tolerance: number): string {
1003
935
  if (points.length < 3) return this.pointsToSVG(points);
1004
-
936
+
1005
937
  this.ensurePaper();
1006
-
938
+
1007
939
  // Create Path
1008
940
  const path = new paper.Path({
1009
- segments: points.map(p => [p.x, p.y]),
1010
- closed: true
941
+ segments: points.map((p) => [p.x, p.y]),
942
+ closed: true,
1011
943
  });
1012
-
944
+
1013
945
  // Simplify
1014
946
  path.simplify(tolerance);
1015
-
947
+
1016
948
  const data = path.pathData;
1017
949
  path.remove();
1018
-
950
+
1019
951
  return data;
1020
952
  }
1021
953