@pooder/kit 5.3.1 → 6.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.
Files changed (65) hide show
  1. package/.test-dist/src/extensions/background.js +475 -131
  2. package/.test-dist/src/extensions/dieline.js +283 -180
  3. package/.test-dist/src/extensions/dielineShape.js +66 -0
  4. package/.test-dist/src/extensions/feature.js +388 -303
  5. package/.test-dist/src/extensions/film.js +133 -74
  6. package/.test-dist/src/extensions/geometry.js +120 -56
  7. package/.test-dist/src/extensions/image.js +296 -212
  8. package/.test-dist/src/extensions/index.js +1 -3
  9. package/.test-dist/src/extensions/maskOps.js +75 -20
  10. package/.test-dist/src/extensions/ruler.js +312 -215
  11. package/.test-dist/src/extensions/sceneLayoutModel.js +9 -3
  12. package/.test-dist/src/extensions/sceneVisibility.js +3 -10
  13. package/.test-dist/src/extensions/tracer.js +229 -58
  14. package/.test-dist/src/extensions/white-ink.js +139 -129
  15. package/.test-dist/src/services/CanvasService.js +888 -126
  16. package/.test-dist/src/services/index.js +1 -0
  17. package/.test-dist/src/services/visibility.js +54 -0
  18. package/.test-dist/tests/run.js +58 -4
  19. package/CHANGELOG.md +12 -0
  20. package/dist/index.d.mts +377 -82
  21. package/dist/index.d.ts +377 -82
  22. package/dist/index.js +3920 -2178
  23. package/dist/index.mjs +3992 -2247
  24. package/package.json +1 -1
  25. package/src/extensions/background.ts +631 -145
  26. package/src/extensions/dieline.ts +280 -187
  27. package/src/extensions/dielineShape.ts +109 -0
  28. package/src/extensions/feature.ts +485 -366
  29. package/src/extensions/film.ts +152 -76
  30. package/src/extensions/geometry.ts +203 -104
  31. package/src/extensions/image.ts +319 -238
  32. package/src/extensions/index.ts +0 -1
  33. package/src/extensions/ruler.ts +481 -268
  34. package/src/extensions/sceneLayoutModel.ts +18 -6
  35. package/src/extensions/white-ink.ts +157 -171
  36. package/src/services/CanvasService.ts +1126 -140
  37. package/src/services/index.ts +1 -0
  38. package/src/services/renderSpec.ts +69 -4
  39. package/src/services/visibility.ts +78 -0
  40. package/tests/run.ts +139 -4
  41. package/.test-dist/src/CanvasService.js +0 -249
  42. package/.test-dist/src/ViewportSystem.js +0 -75
  43. package/.test-dist/src/background.js +0 -203
  44. package/.test-dist/src/bridgeSelection.js +0 -20
  45. package/.test-dist/src/constraints.js +0 -237
  46. package/.test-dist/src/dieline.js +0 -818
  47. package/.test-dist/src/edgeScale.js +0 -12
  48. package/.test-dist/src/feature.js +0 -826
  49. package/.test-dist/src/featureComplete.js +0 -32
  50. package/.test-dist/src/film.js +0 -167
  51. package/.test-dist/src/geometry.js +0 -506
  52. package/.test-dist/src/image.js +0 -1250
  53. package/.test-dist/src/maskOps.js +0 -270
  54. package/.test-dist/src/mirror.js +0 -104
  55. package/.test-dist/src/renderSpec.js +0 -2
  56. package/.test-dist/src/ruler.js +0 -343
  57. package/.test-dist/src/sceneLayout.js +0 -99
  58. package/.test-dist/src/sceneLayoutModel.js +0 -196
  59. package/.test-dist/src/sceneView.js +0 -40
  60. package/.test-dist/src/sceneVisibility.js +0 -42
  61. package/.test-dist/src/size.js +0 -332
  62. package/.test-dist/src/tracer.js +0 -544
  63. package/.test-dist/src/white-ink.js +0 -829
  64. package/.test-dist/src/wrappedOffsets.js +0 -33
  65. package/src/extensions/sceneVisibility.ts +0 -71
@@ -12,6 +12,7 @@ exports.computeSceneLayout = computeSceneLayout;
12
12
  exports.buildSceneGeometry = buildSceneGeometry;
13
13
  const coordinate_1 = require("../coordinate");
14
14
  const units_1 = require("../units");
15
+ const dielineShape_1 = require("./dielineShape");
15
16
  const DEFAULT_SIZE_STATE = {
16
17
  unit: "mm",
17
18
  actualWidthMm: 500,
@@ -180,10 +181,13 @@ function computeSceneLayout(canvasService, size) {
180
181
  function buildSceneGeometry(configService, layout) {
181
182
  const radiusMm = (0, units_1.parseLengthToMm)(configService.get("dieline.radius", 0), "mm");
182
183
  const offset = (layout.cutRect.width - layout.trimRect.width) / 2;
184
+ const sourceWidth = Number(configService.get("dieline.customSourceWidthPx", 0));
185
+ const sourceHeight = Number(configService.get("dieline.customSourceHeightPx", 0));
186
+ const shapeStyle = (0, dielineShape_1.normalizeShapeStyle)(configService.get("dieline.shapeStyle", dielineShape_1.DEFAULT_DIELINE_SHAPE_STYLE));
183
187
  return {
184
- shape: configService.get("dieline.shape", "rect"),
185
- unit: "mm",
186
- displayUnit: normalizeUnit(configService.get("size.unit", "mm")),
188
+ shape: (0, dielineShape_1.normalizeDielineShape)(configService.get("dieline.shape", dielineShape_1.DEFAULT_DIELINE_SHAPE)),
189
+ shapeStyle,
190
+ unit: "px",
187
191
  x: layout.trimRect.centerX,
188
192
  y: layout.trimRect.centerY,
189
193
  width: layout.trimRect.width,
@@ -192,5 +196,7 @@ function buildSceneGeometry(configService, layout) {
192
196
  offset,
193
197
  scale: layout.scale,
194
198
  pathData: configService.get("dieline.pathData"),
199
+ customSourceWidthPx: Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : undefined,
200
+ customSourceHeightPx: Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : undefined,
195
201
  };
196
202
  }
@@ -45,17 +45,10 @@ class SceneVisibilityService {
45
45
  const dielineLayer = this.canvasService.getLayer("dieline-overlay");
46
46
  if (dielineLayer) {
47
47
  const visible = !HIDDEN_DIELINE_TOOLS.has(this.activeToolId || "");
48
- if (dielineLayer.visible !== visible) {
49
- dielineLayer.set({ visible });
50
- }
51
- }
52
- const rulerLayer = this.canvasService.getLayer("ruler-overlay");
53
- if (rulerLayer) {
54
- const visible = !HIDDEN_RULER_TOOLS.has(this.activeToolId || "");
55
- if (rulerLayer.visible !== visible) {
56
- rulerLayer.set({ visible });
57
- }
48
+ this.canvasService.setLayerVisibility("dieline-overlay", visible);
58
49
  }
50
+ const rulerVisible = !HIDDEN_RULER_TOOLS.has(this.activeToolId || "");
51
+ this.canvasService.setLayerVisibility("ruler-overlay", rulerVisible);
59
52
  this.canvasService.requestRenderAll();
60
53
  }
61
54
  }
@@ -24,6 +24,15 @@ class ImageTracer {
24
24
  const img = await this.loadImage(imageUrl);
25
25
  const width = img.width;
26
26
  const height = img.height;
27
+ if (width <= 0 || height <= 0) {
28
+ const w = options.scaleToWidth ?? 0;
29
+ const h = options.scaleToHeight ?? 0;
30
+ return {
31
+ pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
32
+ baseBounds: { x: 0, y: 0, width: w, height: h },
33
+ bounds: { x: 0, y: 0, width: w, height: h },
34
+ };
35
+ }
27
36
  const debug = options.debug === true;
28
37
  const debugLog = (message, payload) => {
29
38
  if (!debug)
@@ -34,7 +43,7 @@ class ImageTracer {
34
43
  }
35
44
  console.info(`[ImageTracer] ${message}`);
36
45
  };
37
- // 1. Draw to canvas and get pixel data
46
+ // Draw to canvas and get pixel data
38
47
  const canvas = document.createElement("canvas");
39
48
  canvas.width = width;
40
49
  canvas.height = height;
@@ -43,64 +52,122 @@ class ImageTracer {
43
52
  throw new Error("Could not get 2D context");
44
53
  ctx.drawImage(img, 0, 0);
45
54
  const imageData = ctx.getImageData(0, 0, width, height);
46
- // 2. Morphology processing
55
+ // Strategy: fixed internal morphology + single-component target.
47
56
  const threshold = options.threshold ?? 10;
48
- const componentMode = options.componentMode ?? "largest";
49
- const minComponentArea = Math.max(0, options.minComponentArea ?? 0);
50
- // Adaptive radius: 3% of the image's largest dimension, at least 6px
51
- const adaptiveRadius = Math.max(6, Math.floor(Math.max(width, height) * 0.03));
52
- const radius = options.morphologyRadius ?? adaptiveRadius;
53
- const expand = options.expand ?? 0;
54
- const noChannels = options.noChannels !== false;
55
- const alphaOpaqueCutoff = options.alphaOpaqueCutoff ?? 250;
56
- const resolvedMaskMode = (options.maskMode ?? "auto") === "auto"
57
- ? (0, maskOps_1.inferMaskMode)(imageData, alphaOpaqueCutoff)
58
- : options.maskMode;
59
- const alphaAnalysis = (0, maskOps_1.analyzeAlpha)(imageData, alphaOpaqueCutoff);
57
+ const expand = Math.max(0, Math.floor(options.expand ?? 0));
58
+ const simplifyTolerance = options.simplifyTolerance ?? 2.5;
59
+ const useSmoothing = options.smoothing !== false;
60
+ const componentMode = "all";
61
+ const minComponentArea = 0;
62
+ const maxDim = Math.max(width, height);
63
+ const maskMode = "auto";
64
+ const whiteThreshold = 240;
65
+ const alphaOpaqueCutoff = 250;
66
+ const preprocessDilateRadius = Math.max(2, Math.floor(Math.max(maxDim * 0.012, expand * 0.35)));
67
+ const preprocessErodeRadius = Math.max(1, Math.floor(preprocessDilateRadius * 0.65));
68
+ const smoothDilateRadius = Math.max(1, Math.floor(preprocessDilateRadius * 0.25));
69
+ const smoothErodeRadius = Math.max(1, Math.floor(smoothDilateRadius * 0.8));
70
+ const connectStartDilateRadius = Math.max(1, Math.floor(Math.max(maxDim * 0.006, expand * 0.2)));
71
+ const connectMaxDilateRadius = Math.max(connectStartDilateRadius, Math.floor(Math.max(maxDim * 0.2, expand * 2.5)));
72
+ const connectErodeRatio = 0.65;
60
73
  debugLog("traceWithBounds:start", {
61
74
  width,
62
75
  height,
63
76
  threshold,
64
- radius,
65
77
  expand,
66
- noChannels,
67
- maskMode: options.maskMode ?? "auto",
68
- resolvedMaskMode,
69
- alphaOpaqueCutoff,
70
- alpha: {
71
- minAlpha: alphaAnalysis.minAlpha,
72
- belowOpaqueRatio: Number(alphaAnalysis.belowOpaqueRatio.toFixed(4)),
73
- veryTransparentRatio: Number(alphaAnalysis.veryTransparentRatio.toFixed(4)),
78
+ simplifyTolerance,
79
+ smoothing: useSmoothing,
80
+ strategy: {
81
+ maskMode,
82
+ whiteThreshold,
83
+ alphaOpaqueCutoff,
84
+ fillHoles: true,
85
+ preprocessDilateRadius,
86
+ preprocessErodeRadius,
87
+ smoothDilateRadius,
88
+ smoothErodeRadius,
89
+ connectEnabled: true,
90
+ connectStartDilateRadius,
91
+ connectMaxDilateRadius,
92
+ connectErodeRatio,
74
93
  },
75
- componentMode,
76
- minComponentArea,
77
- forceConnected: options.forceConnected === true,
78
- simplifyTolerance: options.simplifyTolerance ?? 2.5,
79
- smoothing: options.smoothing !== false,
80
94
  });
81
- // Add padding to the processing canvas to avoid edge clipping during dilation
82
- // Padding should be at least the radius + expansion size
83
- const padding = radius + expand + 2;
95
+ // Padding must cover morphology and expansion margins.
96
+ const padding = Math.max(preprocessDilateRadius, smoothDilateRadius, connectMaxDilateRadius, expand) + 2;
84
97
  const paddedWidth = width + padding * 2;
85
98
  const paddedHeight = height + padding * 2;
99
+ const summarizeMaskContours = (m) => {
100
+ const summary = this.summarizeAllContours(m, paddedWidth, paddedHeight, minComponentArea);
101
+ return {
102
+ rawContourCount: summary.rawCount,
103
+ selectedContourCount: summary.selectedCount,
104
+ };
105
+ };
86
106
  let mask = (0, maskOps_1.createMask)(imageData, {
87
107
  threshold,
88
108
  padding,
89
109
  paddedWidth,
90
110
  paddedHeight,
91
- maskMode: options.maskMode,
92
- whiteThreshold: options.whiteThreshold,
111
+ maskMode,
112
+ whiteThreshold,
93
113
  alphaOpaqueCutoff,
94
114
  });
95
- if (radius > 0) {
96
- mask = (0, maskOps_1.circularMorphology)(mask, paddedWidth, paddedHeight, radius, "closing");
115
+ if (debug) {
116
+ debugLog("traceWithBounds:mask:after-create", summarizeMaskContours(mask));
117
+ }
118
+ mask = (0, maskOps_1.circularMorphology)(mask, paddedWidth, paddedHeight, preprocessDilateRadius, "dilate");
119
+ mask = (0, maskOps_1.fillHoles)(mask, paddedWidth, paddedHeight);
120
+ mask = (0, maskOps_1.circularMorphology)(mask, paddedWidth, paddedHeight, preprocessErodeRadius, "erode");
121
+ mask = (0, maskOps_1.fillHoles)(mask, paddedWidth, paddedHeight);
122
+ if (debug) {
123
+ debugLog("traceWithBounds:mask:after-preprocess", {
124
+ dilateRadius: preprocessDilateRadius,
125
+ erodeRadius: preprocessErodeRadius,
126
+ ...summarizeMaskContours(mask),
127
+ });
128
+ }
129
+ mask = (0, maskOps_1.circularMorphology)(mask, paddedWidth, paddedHeight, smoothDilateRadius, "dilate");
130
+ mask = (0, maskOps_1.fillHoles)(mask, paddedWidth, paddedHeight);
131
+ mask = (0, maskOps_1.circularMorphology)(mask, paddedWidth, paddedHeight, smoothErodeRadius, "erode");
132
+ mask = (0, maskOps_1.fillHoles)(mask, paddedWidth, paddedHeight);
133
+ if (debug) {
134
+ debugLog("traceWithBounds:mask:after-smooth", {
135
+ dilateRadius: smoothDilateRadius,
136
+ erodeRadius: smoothErodeRadius,
137
+ ...summarizeMaskContours(mask),
138
+ });
97
139
  }
98
- if (noChannels) {
99
- mask = (0, maskOps_1.fillHoles)(mask, paddedWidth, paddedHeight);
140
+ const beforeConnectSummary = summarizeMaskContours(mask);
141
+ if (beforeConnectSummary.selectedContourCount <= 1) {
142
+ debugLog("traceWithBounds:mask:connect-skipped", {
143
+ reason: "already-single-component",
144
+ before: beforeConnectSummary,
145
+ });
146
+ }
147
+ else {
148
+ const connectResult = this.findForceConnectResult(mask, paddedWidth, paddedHeight, minComponentArea, connectStartDilateRadius, connectMaxDilateRadius, connectErodeRatio);
149
+ if (debug) {
150
+ debugLog("traceWithBounds:mask:after-connect", {
151
+ before: beforeConnectSummary,
152
+ appliedDilateRadius: connectResult.appliedDilateRadius,
153
+ appliedErodeRadius: connectResult.appliedErodeRadius,
154
+ reachedSingleComponent: connectResult.reachedSingleComponent,
155
+ after: {
156
+ rawContourCount: connectResult.rawContourCount,
157
+ selectedContourCount: connectResult.selectedContourCount,
158
+ },
159
+ });
160
+ }
161
+ mask = connectResult.mask;
100
162
  }
101
- if (radius > 0) {
102
- const smoothRadius = Math.max(1, Math.floor(radius * 0.2));
103
- mask = (0, maskOps_1.circularMorphology)(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
163
+ if (debug) {
164
+ const afterConnectSummary = summarizeMaskContours(mask);
165
+ if (afterConnectSummary.selectedContourCount > 1) {
166
+ debugLog("traceWithBounds:mask:connect-warning", {
167
+ reason: "still-multi-component-after-connect-search",
168
+ summary: afterConnectSummary,
169
+ });
170
+ }
104
171
  }
105
172
  const baseMask = mask;
106
173
  const baseContoursRaw = this.traceAllContours(baseMask, paddedWidth, paddedHeight);
@@ -117,10 +184,10 @@ class ImageTracer {
117
184
  };
118
185
  }
119
186
  const baseUnpaddedContours = baseContours
120
- .map((contour) => this.clampPointsToImageBounds(contour.map((p) => ({
187
+ .map((contour) => contour.map((p) => ({
121
188
  x: p.x - padding,
122
189
  y: p.y - padding,
123
- })), width, height))
190
+ })))
124
191
  .filter((contour) => contour.length > 2);
125
192
  if (!baseUnpaddedContours.length) {
126
193
  const w = options.scaleToWidth ?? width;
@@ -175,7 +242,7 @@ class ImageTracer {
175
242
  };
176
243
  }
177
244
  let globalBounds = this.boundsFromPoints(this.flattenContours(expandedUnpaddedContours));
178
- // 9. Post-processing (Scale)
245
+ // Post-processing (Scale)
179
246
  let finalContours = expandedUnpaddedContours;
180
247
  if (options.scaleToWidth && options.scaleToHeight) {
181
248
  finalContours = this.scaleContours(expandedUnpaddedContours, options.scaleToWidth, options.scaleToHeight, globalBounds);
@@ -183,8 +250,35 @@ class ImageTracer {
183
250
  const baseScaledContours = this.scaleContours(baseUnpaddedContours, options.scaleToWidth, options.scaleToHeight, baseBounds);
184
251
  baseBounds = this.boundsFromPoints(this.flattenContours(baseScaledContours));
185
252
  }
186
- // 10. Simplify and Generate SVG
187
- const useSmoothing = options.smoothing !== false; // Default true
253
+ if (expand > 0) {
254
+ const expectedExpandedBounds = {
255
+ x: baseBounds.x - expand,
256
+ y: baseBounds.y - expand,
257
+ width: baseBounds.width + expand * 2,
258
+ height: baseBounds.height + expand * 2,
259
+ };
260
+ if (expectedExpandedBounds.width > 0 &&
261
+ expectedExpandedBounds.height > 0 &&
262
+ globalBounds.width > 0 &&
263
+ globalBounds.height > 0) {
264
+ const shouldNormalizeExpandBounds = Math.abs(globalBounds.x - expectedExpandedBounds.x) > 1 ||
265
+ Math.abs(globalBounds.y - expectedExpandedBounds.y) > 1 ||
266
+ Math.abs(globalBounds.width - expectedExpandedBounds.width) > 1 ||
267
+ Math.abs(globalBounds.height - expectedExpandedBounds.height) > 1;
268
+ if (shouldNormalizeExpandBounds) {
269
+ const beforeNormalize = globalBounds;
270
+ finalContours = this.translateContours(this.scaleContours(finalContours, expectedExpandedBounds.width, expectedExpandedBounds.height, globalBounds), expectedExpandedBounds.x, expectedExpandedBounds.y);
271
+ globalBounds = this.boundsFromPoints(this.flattenContours(finalContours));
272
+ debugLog("traceWithBounds:expand-normalized", {
273
+ expand,
274
+ expectedExpandedBounds,
275
+ beforeNormalize,
276
+ afterNormalize: globalBounds,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ // Simplify and Generate SVG
188
282
  debugLog("traceWithBounds:contours", {
189
283
  baseContourCount: baseContoursRaw.length,
190
284
  baseSelectedCount: baseContours.length,
@@ -200,16 +294,17 @@ class ImageTracer {
200
294
  });
201
295
  if (useSmoothing) {
202
296
  return {
203
- pathData: this.contoursToSVGPaper(finalContours, options.simplifyTolerance ?? 2.5),
297
+ pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
204
298
  baseBounds,
205
299
  bounds: globalBounds,
206
300
  };
207
301
  }
208
302
  else {
209
303
  const simplifiedContours = finalContours
210
- .map((points) => this.douglasPeucker(points, options.simplifyTolerance ?? 2.0))
304
+ .map((points) => this.douglasPeucker(points, simplifyTolerance))
211
305
  .filter((points) => points.length > 2);
212
- const pathData = this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
306
+ const pathData = this.contoursToSVG(simplifiedContours) ||
307
+ this.contoursToSVG(finalContours);
213
308
  return {
214
309
  pathData,
215
310
  baseBounds,
@@ -251,7 +346,7 @@ class ImageTracer {
251
346
  const xj = polygon[j].x;
252
347
  const yj = polygon[j].y;
253
348
  const intersects = yi > y !== yj > y &&
254
- x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi;
349
+ x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
255
350
  if (intersects)
256
351
  inside = !inside;
257
352
  }
@@ -271,6 +366,84 @@ class ImageTracer {
271
366
  }
272
367
  return selected;
273
368
  }
369
+ static summarizeAllContours(mask, width, height, minComponentArea) {
370
+ const raw = this.traceAllContours(mask, width, height);
371
+ const selected = this.selectContours(raw, "all", minComponentArea);
372
+ return {
373
+ rawCount: raw.length,
374
+ selectedCount: selected.length,
375
+ };
376
+ }
377
+ static findForceConnectResult(sourceMask, width, height, minComponentArea, startDilateRadius, maxDilateRadius, erodeRatio) {
378
+ const initial = this.summarizeAllContours(sourceMask, width, height, minComponentArea);
379
+ if (initial.selectedCount <= 1) {
380
+ return {
381
+ mask: sourceMask,
382
+ appliedDilateRadius: 0,
383
+ appliedErodeRadius: 0,
384
+ reachedSingleComponent: true,
385
+ rawContourCount: initial.rawCount,
386
+ selectedContourCount: initial.selectedCount,
387
+ };
388
+ }
389
+ const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
390
+ const normalizedMax = Math.max(normalizedStart, Math.floor(maxDilateRadius));
391
+ const normalizedErodeRatio = Math.max(0, erodeRatio);
392
+ const evaluate = (dilateRadius) => {
393
+ const erodeRadius = Math.max(1, Math.floor(dilateRadius * normalizedErodeRatio));
394
+ let mask = sourceMask;
395
+ mask = (0, maskOps_1.circularMorphology)(mask, width, height, dilateRadius, "dilate");
396
+ mask = (0, maskOps_1.fillHoles)(mask, width, height);
397
+ mask = (0, maskOps_1.circularMorphology)(mask, width, height, erodeRadius, "erode");
398
+ mask = (0, maskOps_1.fillHoles)(mask, width, height);
399
+ const summary = this.summarizeAllContours(mask, width, height, minComponentArea);
400
+ return {
401
+ dilateRadius,
402
+ erodeRadius,
403
+ mask,
404
+ rawCount: summary.rawCount,
405
+ selectedCount: summary.selectedCount,
406
+ };
407
+ };
408
+ let low = normalizedStart - 1;
409
+ let high = normalizedStart;
410
+ let highResult = evaluate(high);
411
+ while (high < normalizedMax && highResult.selectedCount > 1) {
412
+ low = high;
413
+ high = Math.min(normalizedMax, Math.max(high + 1, Math.floor(high * 1.6)));
414
+ highResult = evaluate(high);
415
+ }
416
+ if (highResult.selectedCount > 1) {
417
+ return {
418
+ mask: highResult.mask,
419
+ appliedDilateRadius: highResult.dilateRadius,
420
+ appliedErodeRadius: highResult.erodeRadius,
421
+ reachedSingleComponent: false,
422
+ rawContourCount: highResult.rawCount,
423
+ selectedContourCount: highResult.selectedCount,
424
+ };
425
+ }
426
+ let best = highResult;
427
+ while (low + 1 < high) {
428
+ const mid = Math.floor((low + high) / 2);
429
+ const midResult = evaluate(mid);
430
+ if (midResult.selectedCount <= 1) {
431
+ best = midResult;
432
+ high = mid;
433
+ }
434
+ else {
435
+ low = mid;
436
+ }
437
+ }
438
+ return {
439
+ mask: best.mask,
440
+ appliedDilateRadius: best.dilateRadius,
441
+ appliedErodeRadius: best.erodeRadius,
442
+ reachedSingleComponent: true,
443
+ rawContourCount: best.rawCount,
444
+ selectedContourCount: best.selectedCount,
445
+ };
446
+ }
274
447
  static selectContours(contours, mode, minComponentArea) {
275
448
  if (!contours.length)
276
449
  return [];
@@ -468,13 +641,11 @@ class ImageTracer {
468
641
  static scaleContours(contours, targetWidth, targetHeight, bounds) {
469
642
  return contours.map((points) => this.scalePoints(points, targetWidth, targetHeight, bounds));
470
643
  }
471
- static clampPointsToImageBounds(points, width, height) {
472
- const maxX = Math.max(0, width);
473
- const maxY = Math.max(0, height);
474
- return points.map((p) => ({
475
- x: Math.max(0, Math.min(maxX, p.x)),
476
- y: Math.max(0, Math.min(maxY, p.y)),
477
- }));
644
+ static translateContours(contours, offsetX, offsetY) {
645
+ return contours.map((points) => points.map((p) => ({
646
+ x: p.x + offsetX,
647
+ y: p.y + offsetY,
648
+ })));
478
649
  }
479
650
  static pointsToSVG(points) {
480
651
  if (points.length === 0)
@@ -503,8 +674,8 @@ class ImageTracer {
503
674
  this.ensurePaper();
504
675
  // Create Path
505
676
  const path = new paper_1.default.Path({
506
- segments: points.map(p => [p.x, p.y]),
507
- closed: true
677
+ segments: points.map((p) => [p.x, p.y]),
678
+ closed: true,
508
679
  });
509
680
  // Simplify
510
681
  path.simplify(tolerance);