@pooder/kit 5.0.3 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/index.d.mts +239 -269
- package/dist/index.d.ts +239 -269
- package/dist/index.js +6485 -5833
- package/dist/index.mjs +6587 -5923
- package/package.json +2 -2
- package/src/{background.ts → extensions/background.ts} +1 -1
- package/src/{dieline.ts → extensions/dieline.ts} +39 -17
- package/src/{feature.ts → extensions/feature.ts} +80 -67
- package/src/{film.ts → extensions/film.ts} +1 -1
- package/src/{geometry.ts → extensions/geometry.ts} +151 -105
- package/src/{image.ts → extensions/image.ts} +190 -192
- package/src/extensions/index.ts +11 -0
- package/src/{maskOps.ts → extensions/maskOps.ts} +28 -10
- package/src/{mirror.ts → extensions/mirror.ts} +1 -1
- package/src/{ruler.ts → extensions/ruler.ts} +5 -3
- package/src/extensions/sceneLayout.ts +140 -0
- package/src/{sceneLayoutModel.ts → extensions/sceneLayoutModel.ts} +17 -10
- package/src/extensions/sceneVisibility.ts +71 -0
- package/src/{size.ts → extensions/size.ts} +23 -13
- package/src/{tracer.ts → extensions/tracer.ts} +374 -45
- package/src/{white-ink.ts → extensions/white-ink.ts} +620 -236
- package/src/index.ts +2 -14
- package/src/{ViewportSystem.ts → services/ViewportSystem.ts} +5 -2
- package/src/services/index.ts +3 -0
- package/src/sceneLayout.ts +0 -121
- package/src/sceneVisibility.ts +0 -49
- /package/src/{bridgeSelection.ts → extensions/bridgeSelection.ts} +0 -0
- /package/src/{constraints.ts → extensions/constraints.ts} +0 -0
- /package/src/{edgeScale.ts → extensions/edgeScale.ts} +0 -0
- /package/src/{featureComplete.ts → extensions/featureComplete.ts} +0 -0
- /package/src/{wrappedOffsets.ts → extensions/wrappedOffsets.ts} +0 -0
- /package/src/{CanvasService.ts → services/CanvasService.ts} +0 -0
- /package/src/{renderSpec.ts → services/renderSpec.ts} +0 -0
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import paper from "paper";
|
|
7
7
|
import {
|
|
8
|
+
analyzeAlpha,
|
|
8
9
|
circularMorphology,
|
|
9
10
|
createMask,
|
|
10
11
|
fillHoles,
|
|
11
12
|
findMinimalConnectRadius,
|
|
13
|
+
inferMaskMode,
|
|
12
14
|
polygonSignedArea,
|
|
13
15
|
type MaskMode,
|
|
14
16
|
} from "./maskOps";
|
|
@@ -25,6 +27,8 @@ interface Bounds {
|
|
|
25
27
|
height: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
type ComponentMode = "largest" | "all";
|
|
31
|
+
|
|
28
32
|
export class ImageTracer {
|
|
29
33
|
/**
|
|
30
34
|
* Main entry point: Traces an image URL to an SVG path string.
|
|
@@ -47,6 +51,9 @@ export class ImageTracer {
|
|
|
47
51
|
expand?: number; // Expansion radius in pixels. Default 0.
|
|
48
52
|
noChannels?: boolean;
|
|
49
53
|
smoothing?: boolean; // Use Paper.js smoothing (curve fitting). Default true.
|
|
54
|
+
componentMode?: ComponentMode;
|
|
55
|
+
minComponentArea?: number;
|
|
56
|
+
forceConnected?: boolean;
|
|
50
57
|
debug?: boolean;
|
|
51
58
|
} = {},
|
|
52
59
|
): Promise<string> {
|
|
@@ -70,6 +77,9 @@ export class ImageTracer {
|
|
|
70
77
|
expand?: number;
|
|
71
78
|
noChannels?: boolean;
|
|
72
79
|
smoothing?: boolean;
|
|
80
|
+
componentMode?: ComponentMode;
|
|
81
|
+
minComponentArea?: number;
|
|
82
|
+
forceConnected?: boolean;
|
|
73
83
|
debug?: boolean;
|
|
74
84
|
} = {},
|
|
75
85
|
): Promise<{ pathData: string; baseBounds: Bounds; bounds: Bounds }> {
|
|
@@ -98,6 +108,9 @@ export class ImageTracer {
|
|
|
98
108
|
|
|
99
109
|
// 2. Morphology processing
|
|
100
110
|
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;
|
|
101
114
|
// Adaptive radius: 3% of the image's largest dimension, at least 5px
|
|
102
115
|
const adaptiveRadius = Math.max(
|
|
103
116
|
5,
|
|
@@ -106,6 +119,12 @@ export class ImageTracer {
|
|
|
106
119
|
const radius = options.morphologyRadius ?? adaptiveRadius;
|
|
107
120
|
const expand = options.expand ?? 0;
|
|
108
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);
|
|
109
128
|
debugLog("traceWithBounds:start", {
|
|
110
129
|
width,
|
|
111
130
|
height,
|
|
@@ -113,6 +132,19 @@ export class ImageTracer {
|
|
|
113
132
|
radius,
|
|
114
133
|
expand,
|
|
115
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
|
+
),
|
|
144
|
+
},
|
|
145
|
+
componentMode,
|
|
146
|
+
minComponentArea,
|
|
147
|
+
forceConnected,
|
|
116
148
|
simplifyTolerance: options.simplifyTolerance ?? 2.5,
|
|
117
149
|
smoothing: options.smoothing !== false,
|
|
118
150
|
});
|
|
@@ -130,23 +162,9 @@ export class ImageTracer {
|
|
|
130
162
|
paddedHeight,
|
|
131
163
|
maskMode: options.maskMode,
|
|
132
164
|
whiteThreshold: options.whiteThreshold,
|
|
133
|
-
alphaOpaqueCutoff
|
|
165
|
+
alphaOpaqueCutoff,
|
|
134
166
|
});
|
|
135
167
|
|
|
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
168
|
if (radius > 0) {
|
|
151
169
|
mask = circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
|
|
152
170
|
}
|
|
@@ -160,12 +178,63 @@ export class ImageTracer {
|
|
|
160
178
|
mask = circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
|
|
161
179
|
}
|
|
162
180
|
|
|
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
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
163
225
|
const baseMask = mask;
|
|
164
|
-
const
|
|
165
|
-
|
|
226
|
+
const baseContoursRaw = this.traceAllContours(
|
|
227
|
+
baseMask,
|
|
228
|
+
paddedWidth,
|
|
229
|
+
paddedHeight,
|
|
230
|
+
);
|
|
231
|
+
const baseContours = this.selectContours(
|
|
232
|
+
baseContoursRaw,
|
|
233
|
+
componentMode,
|
|
234
|
+
minComponentArea,
|
|
166
235
|
);
|
|
167
236
|
|
|
168
|
-
if (!
|
|
237
|
+
if (!baseContours.length) {
|
|
169
238
|
// Fallback: Return a rectangular outline matching dimensions
|
|
170
239
|
const w = options.scaleToWidth ?? width;
|
|
171
240
|
const h = options.scaleToHeight ?? height;
|
|
@@ -177,21 +246,56 @@ export class ImageTracer {
|
|
|
177
246
|
};
|
|
178
247
|
}
|
|
179
248
|
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
249
|
+
const baseUnpaddedContours = baseContours
|
|
250
|
+
.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
|
+
),
|
|
259
|
+
)
|
|
260
|
+
.filter((contour) => contour.length > 2);
|
|
261
|
+
|
|
262
|
+
if (!baseUnpaddedContours.length) {
|
|
263
|
+
const w = options.scaleToWidth ?? width;
|
|
264
|
+
const h = options.scaleToHeight ?? height;
|
|
265
|
+
debugLog("fallback:empty-base-contours", { width: w, height: h });
|
|
266
|
+
return {
|
|
267
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
268
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
269
|
+
bounds: { x: 0, y: 0, width: w, height: h },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let baseBounds = this.boundsFromPoints(
|
|
274
|
+
this.flattenContours(baseUnpaddedContours),
|
|
275
|
+
);
|
|
185
276
|
|
|
186
277
|
let maskExpanded = baseMask;
|
|
187
278
|
if (expand > 0) {
|
|
188
|
-
maskExpanded = circularMorphology(
|
|
279
|
+
maskExpanded = circularMorphology(
|
|
280
|
+
baseMask,
|
|
281
|
+
paddedWidth,
|
|
282
|
+
paddedHeight,
|
|
283
|
+
expand,
|
|
284
|
+
"dilate",
|
|
285
|
+
);
|
|
189
286
|
}
|
|
190
287
|
|
|
191
|
-
const
|
|
192
|
-
|
|
288
|
+
const expandedContoursRaw = this.traceAllContours(
|
|
289
|
+
maskExpanded,
|
|
290
|
+
paddedWidth,
|
|
291
|
+
paddedHeight,
|
|
292
|
+
);
|
|
293
|
+
const expandedContours = this.selectContours(
|
|
294
|
+
expandedContoursRaw,
|
|
295
|
+
componentMode,
|
|
296
|
+
minComponentArea,
|
|
193
297
|
);
|
|
194
|
-
if (!
|
|
298
|
+
if (!expandedContours.length) {
|
|
195
299
|
debugLog("fallback:no-expanded-contour", {
|
|
196
300
|
baseBounds,
|
|
197
301
|
width,
|
|
@@ -205,55 +309,92 @@ export class ImageTracer {
|
|
|
205
309
|
};
|
|
206
310
|
}
|
|
207
311
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
312
|
+
const expandedUnpaddedContours = expandedContours
|
|
313
|
+
.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
|
+
),
|
|
322
|
+
)
|
|
323
|
+
.filter((contour) => contour.length > 2);
|
|
324
|
+
if (!expandedUnpaddedContours.length) {
|
|
325
|
+
debugLog("fallback:empty-expanded-contours", {
|
|
326
|
+
baseBounds,
|
|
327
|
+
width,
|
|
328
|
+
height,
|
|
329
|
+
expand,
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
333
|
+
baseBounds,
|
|
334
|
+
bounds: baseBounds,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let globalBounds = this.boundsFromPoints(
|
|
339
|
+
this.flattenContours(expandedUnpaddedContours),
|
|
340
|
+
);
|
|
213
341
|
|
|
214
342
|
// 9. Post-processing (Scale)
|
|
215
|
-
let
|
|
343
|
+
let finalContours = expandedUnpaddedContours;
|
|
216
344
|
if (options.scaleToWidth && options.scaleToHeight) {
|
|
217
|
-
|
|
218
|
-
|
|
345
|
+
finalContours = this.scaleContours(
|
|
346
|
+
expandedUnpaddedContours,
|
|
219
347
|
options.scaleToWidth,
|
|
220
348
|
options.scaleToHeight,
|
|
221
349
|
globalBounds,
|
|
222
350
|
);
|
|
223
|
-
globalBounds = this.boundsFromPoints(
|
|
351
|
+
globalBounds = this.boundsFromPoints(this.flattenContours(finalContours));
|
|
224
352
|
|
|
225
|
-
const
|
|
226
|
-
|
|
353
|
+
const baseScaledContours = this.scaleContours(
|
|
354
|
+
baseUnpaddedContours,
|
|
227
355
|
options.scaleToWidth,
|
|
228
356
|
options.scaleToHeight,
|
|
229
357
|
baseBounds,
|
|
230
358
|
);
|
|
231
|
-
baseBounds = this.boundsFromPoints(
|
|
359
|
+
baseBounds = this.boundsFromPoints(this.flattenContours(baseScaledContours));
|
|
232
360
|
}
|
|
233
361
|
|
|
234
362
|
// 10. Simplify and Generate SVG
|
|
235
363
|
const useSmoothing = options.smoothing !== false; // Default true
|
|
236
364
|
debugLog("traceWithBounds:contours", {
|
|
365
|
+
baseContourCount: baseContoursRaw.length,
|
|
366
|
+
baseSelectedCount: baseContours.length,
|
|
367
|
+
expandedContourCount: expandedContoursRaw.length,
|
|
368
|
+
expandedSelectedCount: expandedContours.length,
|
|
369
|
+
connectRadiusMax,
|
|
370
|
+
appliedConnectRadius: rConnect,
|
|
237
371
|
baseBounds,
|
|
238
372
|
expandedBounds: globalBounds,
|
|
239
373
|
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
240
374
|
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
241
375
|
useSmoothing,
|
|
376
|
+
componentMode,
|
|
242
377
|
});
|
|
243
378
|
|
|
244
379
|
if (useSmoothing) {
|
|
245
380
|
return {
|
|
246
|
-
pathData: this.
|
|
381
|
+
pathData: this.contoursToSVGPaper(
|
|
382
|
+
finalContours,
|
|
383
|
+
options.simplifyTolerance ?? 2.5,
|
|
384
|
+
),
|
|
247
385
|
baseBounds,
|
|
248
386
|
bounds: globalBounds,
|
|
249
387
|
};
|
|
250
388
|
} else {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
389
|
+
const simplifiedContours = finalContours
|
|
390
|
+
.map((points) =>
|
|
391
|
+
this.douglasPeucker(points, options.simplifyTolerance ?? 2.0),
|
|
392
|
+
)
|
|
393
|
+
.filter((points) => points.length > 2);
|
|
394
|
+
const pathData =
|
|
395
|
+
this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
|
|
255
396
|
return {
|
|
256
|
-
pathData
|
|
397
|
+
pathData,
|
|
257
398
|
baseBounds,
|
|
258
399
|
bounds: globalBounds,
|
|
259
400
|
};
|
|
@@ -271,6 +412,135 @@ export class ImageTracer {
|
|
|
271
412
|
}, contours[0]);
|
|
272
413
|
}
|
|
273
414
|
|
|
415
|
+
private static flattenContours(contours: Point[][]): Point[] {
|
|
416
|
+
return contours.flatMap((contour) => contour);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private static contourCentroid(points: Point[]): Point {
|
|
420
|
+
if (!points.length) return { x: 0, y: 0 };
|
|
421
|
+
const sum = points.reduce(
|
|
422
|
+
(acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }),
|
|
423
|
+
{ x: 0, y: 0 },
|
|
424
|
+
);
|
|
425
|
+
return {
|
|
426
|
+
x: sum.x / points.length,
|
|
427
|
+
y: sum.y / points.length,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private static pointInPolygon(point: Point, polygon: Point[]): boolean {
|
|
432
|
+
let inside = false;
|
|
433
|
+
const { x, y } = point;
|
|
434
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
435
|
+
const xi = polygon[i].x;
|
|
436
|
+
const yi = polygon[i].y;
|
|
437
|
+
const xj = polygon[j].x;
|
|
438
|
+
const yj = polygon[j].y;
|
|
439
|
+
const intersects =
|
|
440
|
+
yi > y !== yj > y &&
|
|
441
|
+
x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi;
|
|
442
|
+
if (intersects) inside = !inside;
|
|
443
|
+
}
|
|
444
|
+
return inside;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private static keepOutermostContours(contours: Point[][]): Point[][] {
|
|
448
|
+
if (contours.length <= 1) return contours;
|
|
449
|
+
|
|
450
|
+
const sorted = [...contours].sort(
|
|
451
|
+
(a, b) => Math.abs(polygonSignedArea(b)) - Math.abs(polygonSignedArea(a)),
|
|
452
|
+
);
|
|
453
|
+
const selected: Point[][] = [];
|
|
454
|
+
for (const contour of sorted) {
|
|
455
|
+
const centroid = this.contourCentroid(contour);
|
|
456
|
+
const isNested = selected.some((outer) =>
|
|
457
|
+
this.pointInPolygon(centroid, outer),
|
|
458
|
+
);
|
|
459
|
+
if (!isNested) {
|
|
460
|
+
selected.push(contour);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return selected;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private static countSelectedContours(
|
|
467
|
+
mask: Uint8Array,
|
|
468
|
+
width: number,
|
|
469
|
+
height: number,
|
|
470
|
+
minComponentArea: number,
|
|
471
|
+
): number {
|
|
472
|
+
const contours = this.traceAllContours(mask, width, height);
|
|
473
|
+
return this.selectContours(contours, "all", minComponentArea).length;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private static findMinimalMergeRadiusByContourCount(
|
|
477
|
+
mask: Uint8Array,
|
|
478
|
+
width: number,
|
|
479
|
+
height: number,
|
|
480
|
+
maxRadius: number,
|
|
481
|
+
minComponentArea: number,
|
|
482
|
+
): number {
|
|
483
|
+
if (maxRadius <= 0) return 0;
|
|
484
|
+
if (this.countSelectedContours(mask, width, height, minComponentArea) <= 1) {
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
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;
|
|
496
|
+
}
|
|
497
|
+
if (high > maxRadius) high = maxRadius;
|
|
498
|
+
|
|
499
|
+
const highMask = circularMorphology(mask, width, height, high, "closing");
|
|
500
|
+
if (this.countSelectedContours(highMask, width, height, minComponentArea) > 1) {
|
|
501
|
+
return high;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
while (low + 1 < high) {
|
|
505
|
+
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) {
|
|
508
|
+
high = mid;
|
|
509
|
+
} else {
|
|
510
|
+
low = mid;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return high;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private static selectContours(
|
|
518
|
+
contours: Point[][],
|
|
519
|
+
mode: ComponentMode,
|
|
520
|
+
minComponentArea: number,
|
|
521
|
+
): Point[][] {
|
|
522
|
+
if (!contours.length) return [];
|
|
523
|
+
if (mode === "largest") {
|
|
524
|
+
const primary = this.pickPrimaryContour(contours);
|
|
525
|
+
return primary ? [primary] : [];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const threshold = Math.max(0, minComponentArea);
|
|
529
|
+
if (threshold <= 0) {
|
|
530
|
+
return this.keepOutermostContours(contours);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const filtered = contours.filter(
|
|
534
|
+
(contour) => Math.abs(polygonSignedArea(contour)) >= threshold,
|
|
535
|
+
);
|
|
536
|
+
if (filtered.length > 0) {
|
|
537
|
+
return this.keepOutermostContours(filtered);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const primary = this.pickPrimaryContour(contours);
|
|
541
|
+
return primary ? [primary] : [];
|
|
542
|
+
}
|
|
543
|
+
|
|
274
544
|
private static boundsFromPoints(points: Point[]): Bounds {
|
|
275
545
|
let minX = Infinity;
|
|
276
546
|
let minY = Infinity;
|
|
@@ -679,6 +949,30 @@ export class ImageTracer {
|
|
|
679
949
|
}));
|
|
680
950
|
}
|
|
681
951
|
|
|
952
|
+
private static scaleContours(
|
|
953
|
+
contours: Point[][],
|
|
954
|
+
targetWidth: number,
|
|
955
|
+
targetHeight: number,
|
|
956
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
957
|
+
): Point[][] {
|
|
958
|
+
return contours.map((points) =>
|
|
959
|
+
this.scalePoints(points, targetWidth, targetHeight, bounds),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
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
|
+
}));
|
|
974
|
+
}
|
|
975
|
+
|
|
682
976
|
private static pointsToSVG(points: Point[]): string {
|
|
683
977
|
if (points.length === 0) return "";
|
|
684
978
|
const head = points[0];
|
|
@@ -691,6 +985,14 @@ export class ImageTracer {
|
|
|
691
985
|
);
|
|
692
986
|
}
|
|
693
987
|
|
|
988
|
+
private static contoursToSVG(contours: Point[][]): string {
|
|
989
|
+
return contours
|
|
990
|
+
.filter((points) => points.length > 2)
|
|
991
|
+
.map((points) => this.pointsToSVG(points))
|
|
992
|
+
.join(" ")
|
|
993
|
+
.trim();
|
|
994
|
+
}
|
|
995
|
+
|
|
694
996
|
private static ensurePaper() {
|
|
695
997
|
if (!paper.project) {
|
|
696
998
|
paper.setup(new paper.Size(100, 100));
|
|
@@ -716,4 +1018,31 @@ export class ImageTracer {
|
|
|
716
1018
|
|
|
717
1019
|
return data;
|
|
718
1020
|
}
|
|
1021
|
+
|
|
1022
|
+
private static contoursToSVGPaper(
|
|
1023
|
+
contours: Point[][],
|
|
1024
|
+
tolerance: number,
|
|
1025
|
+
): string {
|
|
1026
|
+
const normalizedContours = contours.filter((points) => points.length > 2);
|
|
1027
|
+
if (!normalizedContours.length) return "";
|
|
1028
|
+
if (normalizedContours.length === 1) {
|
|
1029
|
+
return this.pointsToSVGPaper(normalizedContours[0], tolerance);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
this.ensurePaper();
|
|
1033
|
+
const compound = new paper.CompoundPath({ insert: false });
|
|
1034
|
+
for (const points of normalizedContours) {
|
|
1035
|
+
const child = new paper.Path({
|
|
1036
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
1037
|
+
closed: true,
|
|
1038
|
+
insert: false,
|
|
1039
|
+
});
|
|
1040
|
+
child.simplify(tolerance);
|
|
1041
|
+
compound.addChild(child);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const data = compound.pathData || this.contoursToSVG(normalizedContours);
|
|
1045
|
+
compound.remove();
|
|
1046
|
+
return data;
|
|
1047
|
+
}
|
|
719
1048
|
}
|