@pooder/kit 5.4.0 → 6.0.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.
- package/.test-dist/src/coordinate.js +74 -0
- package/.test-dist/src/extensions/background.js +547 -0
- package/.test-dist/src/extensions/bridgeSelection.js +20 -0
- package/.test-dist/src/extensions/constraints.js +237 -0
- package/.test-dist/src/extensions/dieline.js +935 -0
- package/.test-dist/src/extensions/dielineShape.js +66 -0
- package/.test-dist/src/extensions/edgeScale.js +12 -0
- package/.test-dist/src/extensions/feature.js +910 -0
- package/.test-dist/src/extensions/featureComplete.js +32 -0
- package/.test-dist/src/extensions/film.js +226 -0
- package/.test-dist/src/extensions/geometry.js +609 -0
- package/.test-dist/src/extensions/image.js +1788 -0
- package/.test-dist/src/extensions/index.js +28 -0
- package/.test-dist/src/extensions/maskOps.js +334 -0
- package/.test-dist/src/extensions/mirror.js +104 -0
- package/.test-dist/src/extensions/ruler.js +442 -0
- package/.test-dist/src/extensions/sceneLayout.js +96 -0
- package/.test-dist/src/extensions/sceneLayoutModel.js +202 -0
- package/.test-dist/src/extensions/sceneVisibility.js +55 -0
- package/.test-dist/src/extensions/size.js +331 -0
- package/.test-dist/src/extensions/tracer.js +709 -0
- package/.test-dist/src/extensions/white-ink.js +1200 -0
- package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
- package/.test-dist/src/index.js +18 -0
- package/.test-dist/src/services/CanvasService.js +1032 -0
- package/.test-dist/src/services/ViewportSystem.js +76 -0
- package/.test-dist/src/services/index.js +25 -0
- package/.test-dist/src/services/renderSpec.js +2 -0
- package/.test-dist/src/services/visibility.js +57 -0
- package/.test-dist/src/units.js +30 -0
- package/.test-dist/tests/run.js +150 -0
- package/CHANGELOG.md +12 -0
- package/dist/index.d.mts +164 -62
- package/dist/index.d.ts +164 -62
- package/dist/index.js +2433 -1719
- package/dist/index.mjs +2442 -1723
- package/package.json +1 -1
- package/src/coordinate.ts +106 -106
- package/src/extensions/background.ts +716 -323
- package/src/extensions/bridgeSelection.ts +17 -17
- package/src/extensions/constraints.ts +322 -322
- package/src/extensions/dieline.ts +1173 -1149
- package/src/extensions/dielineShape.ts +109 -109
- package/src/extensions/edgeScale.ts +19 -19
- package/src/extensions/feature.ts +1140 -1137
- package/src/extensions/featureComplete.ts +46 -46
- package/src/extensions/film.ts +270 -266
- package/src/extensions/geometry.ts +851 -885
- package/src/extensions/image.ts +2240 -2054
- package/src/extensions/index.ts +10 -11
- package/src/extensions/maskOps.ts +283 -283
- package/src/extensions/mirror.ts +128 -128
- package/src/extensions/ruler.ts +664 -654
- package/src/extensions/sceneLayout.ts +140 -140
- package/src/extensions/sceneLayoutModel.ts +364 -364
- package/src/extensions/size.ts +389 -389
- package/src/extensions/tracer.ts +1019 -1019
- package/src/extensions/white-ink.ts +1508 -1575
- package/src/extensions/wrappedOffsets.ts +33 -33
- package/src/index.ts +2 -2
- package/src/services/CanvasService.ts +1317 -832
- package/src/services/ViewportSystem.ts +95 -95
- package/src/services/index.ts +4 -3
- package/src/services/renderSpec.ts +85 -53
- package/src/services/visibility.ts +83 -0
- package/src/units.ts +27 -27
- package/tests/run.ts +258 -118
- package/tsconfig.test.json +15 -15
- package/src/extensions/sceneVisibility.ts +0 -64
package/src/extensions/tracer.ts
CHANGED
|
@@ -1,1019 +1,1019 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image Tracer Utility
|
|
3
|
-
* Converts raster images (URL/Base64) to SVG Path Data using Marching Squares algorithm.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import paper from "paper";
|
|
7
|
-
import {
|
|
8
|
-
circularMorphology,
|
|
9
|
-
createMask,
|
|
10
|
-
fillHoles,
|
|
11
|
-
polygonSignedArea,
|
|
12
|
-
type MaskMode,
|
|
13
|
-
} from "./maskOps";
|
|
14
|
-
|
|
15
|
-
interface Point {
|
|
16
|
-
x: number;
|
|
17
|
-
y: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface Bounds {
|
|
21
|
-
x: number;
|
|
22
|
-
y: number;
|
|
23
|
-
width: number;
|
|
24
|
-
height: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
type ComponentMode = "largest" | "all";
|
|
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
|
-
|
|
48
|
-
export class ImageTracer {
|
|
49
|
-
/**
|
|
50
|
-
* Main entry point: Traces an image URL to an SVG path string.
|
|
51
|
-
* @param imageUrl The URL or Base64 string of the image.
|
|
52
|
-
* @param options Configuration options.
|
|
53
|
-
*/
|
|
54
|
-
public static async trace(
|
|
55
|
-
imageUrl: string,
|
|
56
|
-
options: ImageTraceOptions = {},
|
|
57
|
-
): Promise<string> {
|
|
58
|
-
const { pathData } = await this.traceWithBounds(imageUrl, options);
|
|
59
|
-
return pathData;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
public static async traceWithBounds(
|
|
63
|
-
imageUrl: string,
|
|
64
|
-
options: ImageTraceOptions = {},
|
|
65
|
-
): Promise<{ pathData: string; baseBounds: Bounds; bounds: Bounds }> {
|
|
66
|
-
const img = await this.loadImage(imageUrl);
|
|
67
|
-
const width = img.width;
|
|
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
|
-
}
|
|
78
|
-
const debug = options.debug === true;
|
|
79
|
-
const debugLog = (message: string, payload?: Record<string, unknown>) => {
|
|
80
|
-
if (!debug) return;
|
|
81
|
-
if (payload) {
|
|
82
|
-
console.info(`[ImageTracer] ${message}`, payload);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
console.info(`[ImageTracer] ${message}`);
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
// Draw to canvas and get pixel data
|
|
89
|
-
const canvas = document.createElement("canvas");
|
|
90
|
-
canvas.width = width;
|
|
91
|
-
canvas.height = height;
|
|
92
|
-
const ctx = canvas.getContext("2d");
|
|
93
|
-
if (!ctx) throw new Error("Could not get 2D context");
|
|
94
|
-
|
|
95
|
-
ctx.drawImage(img, 0, 0);
|
|
96
|
-
const imageData = ctx.getImageData(0, 0, width, height);
|
|
97
|
-
|
|
98
|
-
// Strategy: fixed internal morphology + single-component target.
|
|
99
|
-
const threshold = options.threshold ?? 10;
|
|
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)),
|
|
112
|
-
);
|
|
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
|
-
|
|
132
|
-
debugLog("traceWithBounds:start", {
|
|
133
|
-
width,
|
|
134
|
-
height,
|
|
135
|
-
threshold,
|
|
136
|
-
expand,
|
|
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,
|
|
152
|
-
},
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// Padding must cover morphology and expansion margins.
|
|
156
|
-
const padding =
|
|
157
|
-
Math.max(
|
|
158
|
-
preprocessDilateRadius,
|
|
159
|
-
smoothDilateRadius,
|
|
160
|
-
connectMaxDilateRadius,
|
|
161
|
-
expand,
|
|
162
|
-
) + 2;
|
|
163
|
-
const paddedWidth = width + padding * 2;
|
|
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
|
-
};
|
|
177
|
-
|
|
178
|
-
let mask = createMask(imageData, {
|
|
179
|
-
threshold,
|
|
180
|
-
padding,
|
|
181
|
-
paddedWidth,
|
|
182
|
-
paddedHeight,
|
|
183
|
-
maskMode,
|
|
184
|
-
whiteThreshold,
|
|
185
|
-
alphaOpaqueCutoff,
|
|
186
|
-
});
|
|
187
|
-
if (debug) {
|
|
188
|
-
debugLog(
|
|
189
|
-
"traceWithBounds:mask:after-create",
|
|
190
|
-
summarizeMaskContours(mask),
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
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
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
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
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
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;
|
|
271
|
-
}
|
|
272
|
-
|
|
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
|
-
});
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const baseMask = mask;
|
|
284
|
-
const baseContoursRaw = this.traceAllContours(
|
|
285
|
-
baseMask,
|
|
286
|
-
paddedWidth,
|
|
287
|
-
paddedHeight,
|
|
288
|
-
);
|
|
289
|
-
const baseContours = this.selectContours(
|
|
290
|
-
baseContoursRaw,
|
|
291
|
-
componentMode,
|
|
292
|
-
minComponentArea,
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
if (!baseContours.length) {
|
|
296
|
-
// Fallback: Return a rectangular outline matching dimensions
|
|
297
|
-
const w = options.scaleToWidth ?? width;
|
|
298
|
-
const h = options.scaleToHeight ?? height;
|
|
299
|
-
debugLog("fallback:no-base-contour", { width: w, height: h });
|
|
300
|
-
return {
|
|
301
|
-
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
302
|
-
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
303
|
-
bounds: { x: 0, y: 0, width: w, height: h },
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const baseUnpaddedContours = baseContours
|
|
308
|
-
.map((contour) =>
|
|
309
|
-
contour.map((p) => ({
|
|
310
|
-
x: p.x - padding,
|
|
311
|
-
y: p.y - padding,
|
|
312
|
-
})),
|
|
313
|
-
)
|
|
314
|
-
.filter((contour) => contour.length > 2);
|
|
315
|
-
|
|
316
|
-
if (!baseUnpaddedContours.length) {
|
|
317
|
-
const w = options.scaleToWidth ?? width;
|
|
318
|
-
const h = options.scaleToHeight ?? height;
|
|
319
|
-
debugLog("fallback:empty-base-contours", { width: w, height: h });
|
|
320
|
-
return {
|
|
321
|
-
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
322
|
-
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
323
|
-
bounds: { x: 0, y: 0, width: w, height: h },
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
let baseBounds = this.boundsFromPoints(
|
|
328
|
-
this.flattenContours(baseUnpaddedContours),
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
let maskExpanded = baseMask;
|
|
332
|
-
if (expand > 0) {
|
|
333
|
-
maskExpanded = circularMorphology(
|
|
334
|
-
baseMask,
|
|
335
|
-
paddedWidth,
|
|
336
|
-
paddedHeight,
|
|
337
|
-
expand,
|
|
338
|
-
"dilate",
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const expandedContoursRaw = this.traceAllContours(
|
|
343
|
-
maskExpanded,
|
|
344
|
-
paddedWidth,
|
|
345
|
-
paddedHeight,
|
|
346
|
-
);
|
|
347
|
-
const expandedContours = this.selectContours(
|
|
348
|
-
expandedContoursRaw,
|
|
349
|
-
componentMode,
|
|
350
|
-
minComponentArea,
|
|
351
|
-
);
|
|
352
|
-
if (!expandedContours.length) {
|
|
353
|
-
debugLog("fallback:no-expanded-contour", {
|
|
354
|
-
baseBounds,
|
|
355
|
-
width,
|
|
356
|
-
height,
|
|
357
|
-
expand,
|
|
358
|
-
});
|
|
359
|
-
return {
|
|
360
|
-
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
361
|
-
baseBounds,
|
|
362
|
-
bounds: baseBounds,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
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).
|
|
369
|
-
const expandedUnpaddedContours = expandedContours
|
|
370
|
-
.map((contour) =>
|
|
371
|
-
contour.map((p) => ({
|
|
372
|
-
x: p.x - padding,
|
|
373
|
-
y: p.y - padding,
|
|
374
|
-
})),
|
|
375
|
-
)
|
|
376
|
-
.filter((contour) => contour.length > 2);
|
|
377
|
-
if (!expandedUnpaddedContours.length) {
|
|
378
|
-
debugLog("fallback:empty-expanded-contours", {
|
|
379
|
-
baseBounds,
|
|
380
|
-
width,
|
|
381
|
-
height,
|
|
382
|
-
expand,
|
|
383
|
-
});
|
|
384
|
-
return {
|
|
385
|
-
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
386
|
-
baseBounds,
|
|
387
|
-
bounds: baseBounds,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
let globalBounds = this.boundsFromPoints(
|
|
392
|
-
this.flattenContours(expandedUnpaddedContours),
|
|
393
|
-
);
|
|
394
|
-
|
|
395
|
-
// Post-processing (Scale)
|
|
396
|
-
let finalContours = expandedUnpaddedContours;
|
|
397
|
-
if (options.scaleToWidth && options.scaleToHeight) {
|
|
398
|
-
finalContours = this.scaleContours(
|
|
399
|
-
expandedUnpaddedContours,
|
|
400
|
-
options.scaleToWidth,
|
|
401
|
-
options.scaleToHeight,
|
|
402
|
-
globalBounds,
|
|
403
|
-
);
|
|
404
|
-
globalBounds = this.boundsFromPoints(this.flattenContours(finalContours));
|
|
405
|
-
|
|
406
|
-
const baseScaledContours = this.scaleContours(
|
|
407
|
-
baseUnpaddedContours,
|
|
408
|
-
options.scaleToWidth,
|
|
409
|
-
options.scaleToHeight,
|
|
410
|
-
baseBounds,
|
|
411
|
-
);
|
|
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
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Simplify and Generate SVG
|
|
461
|
-
debugLog("traceWithBounds:contours", {
|
|
462
|
-
baseContourCount: baseContoursRaw.length,
|
|
463
|
-
baseSelectedCount: baseContours.length,
|
|
464
|
-
expandedContourCount: expandedContoursRaw.length,
|
|
465
|
-
expandedSelectedCount: expandedContours.length,
|
|
466
|
-
baseBounds,
|
|
467
|
-
expandedBounds: globalBounds,
|
|
468
|
-
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
469
|
-
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
470
|
-
expandedMayOverflowImageBounds: expand > 0,
|
|
471
|
-
useSmoothing,
|
|
472
|
-
componentMode,
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
if (useSmoothing) {
|
|
476
|
-
return {
|
|
477
|
-
pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
|
|
478
|
-
baseBounds,
|
|
479
|
-
bounds: globalBounds,
|
|
480
|
-
};
|
|
481
|
-
} else {
|
|
482
|
-
const simplifiedContours = finalContours
|
|
483
|
-
.map((points) => this.douglasPeucker(points, simplifyTolerance))
|
|
484
|
-
.filter((points) => points.length > 2);
|
|
485
|
-
const pathData =
|
|
486
|
-
this.contoursToSVG(simplifiedContours) ||
|
|
487
|
-
this.contoursToSVG(finalContours);
|
|
488
|
-
return {
|
|
489
|
-
pathData,
|
|
490
|
-
baseBounds,
|
|
491
|
-
bounds: globalBounds,
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private static pickPrimaryContour(contours: Point[][]): Point[] | null {
|
|
497
|
-
if (contours.length === 0) return null;
|
|
498
|
-
return contours.reduce((best, cur) => {
|
|
499
|
-
if (!best) return cur;
|
|
500
|
-
const bestArea = Math.abs(polygonSignedArea(best));
|
|
501
|
-
const curArea = Math.abs(polygonSignedArea(cur));
|
|
502
|
-
if (curArea !== bestArea) return curArea > bestArea ? cur : best;
|
|
503
|
-
return cur.length > best.length ? cur : best;
|
|
504
|
-
}, contours[0]);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
private static flattenContours(contours: Point[][]): Point[] {
|
|
508
|
-
return contours.flatMap((contour) => contour);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
private static contourCentroid(points: Point[]): Point {
|
|
512
|
-
if (!points.length) return { x: 0, y: 0 };
|
|
513
|
-
const sum = points.reduce(
|
|
514
|
-
(acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }),
|
|
515
|
-
{ x: 0, y: 0 },
|
|
516
|
-
);
|
|
517
|
-
return {
|
|
518
|
-
x: sum.x / points.length,
|
|
519
|
-
y: sum.y / points.length,
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
private static pointInPolygon(point: Point, polygon: Point[]): boolean {
|
|
524
|
-
let inside = false;
|
|
525
|
-
const { x, y } = point;
|
|
526
|
-
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
527
|
-
const xi = polygon[i].x;
|
|
528
|
-
const yi = polygon[i].y;
|
|
529
|
-
const xj = polygon[j].x;
|
|
530
|
-
const yj = polygon[j].y;
|
|
531
|
-
const intersects =
|
|
532
|
-
yi > y !== yj > y &&
|
|
533
|
-
x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
|
|
534
|
-
if (intersects) inside = !inside;
|
|
535
|
-
}
|
|
536
|
-
return inside;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
private static keepOutermostContours(contours: Point[][]): Point[][] {
|
|
540
|
-
if (contours.length <= 1) return contours;
|
|
541
|
-
|
|
542
|
-
const sorted = [...contours].sort(
|
|
543
|
-
(a, b) => Math.abs(polygonSignedArea(b)) - Math.abs(polygonSignedArea(a)),
|
|
544
|
-
);
|
|
545
|
-
const selected: Point[][] = [];
|
|
546
|
-
for (const contour of sorted) {
|
|
547
|
-
const centroid = this.contourCentroid(contour);
|
|
548
|
-
const isNested = selected.some((outer) =>
|
|
549
|
-
this.pointInPolygon(centroid, outer),
|
|
550
|
-
);
|
|
551
|
-
if (!isNested) {
|
|
552
|
-
selected.push(contour);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
return selected;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
private static summarizeAllContours(
|
|
559
|
-
mask: Uint8Array,
|
|
560
|
-
width: number,
|
|
561
|
-
height: number,
|
|
562
|
-
minComponentArea: number,
|
|
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
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
private static findForceConnectResult(
|
|
573
|
-
sourceMask: Uint8Array,
|
|
574
|
-
width: number,
|
|
575
|
-
height: number,
|
|
576
|
-
minComponentArea: number,
|
|
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
|
-
};
|
|
596
|
-
}
|
|
597
|
-
|
|
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);
|
|
639
|
-
}
|
|
640
|
-
|
|
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
|
-
};
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
let best = highResult;
|
|
653
|
-
while (low + 1 < high) {
|
|
654
|
-
const mid = Math.floor((low + high) / 2);
|
|
655
|
-
const midResult = evaluate(mid);
|
|
656
|
-
if (midResult.selectedCount <= 1) {
|
|
657
|
-
best = midResult;
|
|
658
|
-
high = mid;
|
|
659
|
-
} else {
|
|
660
|
-
low = mid;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
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
|
-
};
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
private static selectContours(
|
|
675
|
-
contours: Point[][],
|
|
676
|
-
mode: ComponentMode,
|
|
677
|
-
minComponentArea: number,
|
|
678
|
-
): Point[][] {
|
|
679
|
-
if (!contours.length) return [];
|
|
680
|
-
if (mode === "largest") {
|
|
681
|
-
const primary = this.pickPrimaryContour(contours);
|
|
682
|
-
return primary ? [primary] : [];
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
const threshold = Math.max(0, minComponentArea);
|
|
686
|
-
if (threshold <= 0) {
|
|
687
|
-
return this.keepOutermostContours(contours);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const filtered = contours.filter(
|
|
691
|
-
(contour) => Math.abs(polygonSignedArea(contour)) >= threshold,
|
|
692
|
-
);
|
|
693
|
-
if (filtered.length > 0) {
|
|
694
|
-
return this.keepOutermostContours(filtered);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const primary = this.pickPrimaryContour(contours);
|
|
698
|
-
return primary ? [primary] : [];
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
private static boundsFromPoints(points: Point[]): Bounds {
|
|
702
|
-
let minX = Infinity;
|
|
703
|
-
let minY = Infinity;
|
|
704
|
-
let maxX = -Infinity;
|
|
705
|
-
let maxY = -Infinity;
|
|
706
|
-
|
|
707
|
-
for (const p of points) {
|
|
708
|
-
if (p.x < minX) minX = p.x;
|
|
709
|
-
if (p.y < minY) minY = p.y;
|
|
710
|
-
if (p.x > maxX) maxX = p.x;
|
|
711
|
-
if (p.y > maxY) maxY = p.y;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
|
715
|
-
return { x: 0, y: 0, width: 0, height: 0 };
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
return {
|
|
719
|
-
x: minX,
|
|
720
|
-
y: minY,
|
|
721
|
-
width: maxX - minX,
|
|
722
|
-
height: maxY - minY,
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Traces all contours in the mask with optimized start-point detection
|
|
728
|
-
*/
|
|
729
|
-
private static traceAllContours(
|
|
730
|
-
mask: Uint8Array,
|
|
731
|
-
width: number,
|
|
732
|
-
height: number,
|
|
733
|
-
): Point[][] {
|
|
734
|
-
const visited = new Uint8Array(width * height);
|
|
735
|
-
const allContours: Point[][] = [];
|
|
736
|
-
|
|
737
|
-
for (let y = 0; y < height; y++) {
|
|
738
|
-
for (let x = 0; x < width; x++) {
|
|
739
|
-
const idx = y * width + x;
|
|
740
|
-
if (mask[idx] && !visited[idx]) {
|
|
741
|
-
// Only start a new trace if it's a potential outer boundary (left edge)
|
|
742
|
-
const isLeftEdge = x === 0 || mask[idx - 1] === 0;
|
|
743
|
-
if (isLeftEdge) {
|
|
744
|
-
const contour = this.marchingSquares(
|
|
745
|
-
mask,
|
|
746
|
-
visited,
|
|
747
|
-
x,
|
|
748
|
-
y,
|
|
749
|
-
width,
|
|
750
|
-
height,
|
|
751
|
-
);
|
|
752
|
-
if (contour.length > 2) {
|
|
753
|
-
allContours.push(contour);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
return allContours;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
private static loadImage(url: string): Promise<HTMLImageElement> {
|
|
763
|
-
return new Promise((resolve, reject) => {
|
|
764
|
-
const img = new Image();
|
|
765
|
-
img.crossOrigin = "Anonymous";
|
|
766
|
-
img.onload = () => resolve(img);
|
|
767
|
-
img.onerror = (e) => reject(e);
|
|
768
|
-
img.src = url;
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Moore-Neighbor Tracing Algorithm
|
|
774
|
-
* More robust for irregular shapes than simple Marching Squares walker.
|
|
775
|
-
*/
|
|
776
|
-
private static marchingSquares(
|
|
777
|
-
mask: Uint8Array,
|
|
778
|
-
visited: Uint8Array,
|
|
779
|
-
startX: number,
|
|
780
|
-
startY: number,
|
|
781
|
-
width: number,
|
|
782
|
-
height: number,
|
|
783
|
-
): Point[] {
|
|
784
|
-
const isSolid = (x: number, y: number): boolean => {
|
|
785
|
-
if (x < 0 || x >= width || y < 0 || y >= height) return false;
|
|
786
|
-
return mask[y * width + x] === 1;
|
|
787
|
-
};
|
|
788
|
-
|
|
789
|
-
const points: Point[] = [];
|
|
790
|
-
|
|
791
|
-
// Moore-Neighbor Tracing
|
|
792
|
-
// We enter from the Left (since we scan Left->Right), so "backtrack" is Left.
|
|
793
|
-
// B = (startX - 1, startY)
|
|
794
|
-
// P = (startX, startY)
|
|
795
|
-
|
|
796
|
-
let cx = startX;
|
|
797
|
-
let cy = startY;
|
|
798
|
-
|
|
799
|
-
// Start backtrack direction: Left (since we found it scanning from left)
|
|
800
|
-
// Directions: 0=Up, 1=UpRight, 2=Right, 3=DownRight, 4=Down, 5=DownLeft, 6=Left, 7=UpLeft
|
|
801
|
-
// Offsets for 8 neighbors starting from Up (0,-1) clockwise
|
|
802
|
-
const neighbors = [
|
|
803
|
-
{ x: 0, y: -1 },
|
|
804
|
-
{ x: 1, y: -1 },
|
|
805
|
-
{ x: 1, y: 0 },
|
|
806
|
-
{ x: 1, y: 1 },
|
|
807
|
-
{ x: 0, y: 1 },
|
|
808
|
-
{ x: -1, y: 1 },
|
|
809
|
-
{ x: -1, y: 0 },
|
|
810
|
-
{ x: -1, y: -1 },
|
|
811
|
-
];
|
|
812
|
-
|
|
813
|
-
// Backtrack is Left -> Index 6.
|
|
814
|
-
let backtrack = 6;
|
|
815
|
-
|
|
816
|
-
const maxSteps = width * height * 3;
|
|
817
|
-
let steps = 0;
|
|
818
|
-
|
|
819
|
-
do {
|
|
820
|
-
points.push({ x: cx, y: cy });
|
|
821
|
-
visited[cy * width + cx] = 1; // Mark as visited to avoid re-starting here
|
|
822
|
-
|
|
823
|
-
// Search for next solid neighbor in clockwise order, starting from backtrack
|
|
824
|
-
let found = false;
|
|
825
|
-
|
|
826
|
-
for (let i = 0; i < 8; i++) {
|
|
827
|
-
const idx = (backtrack + 1 + i) % 8;
|
|
828
|
-
const nx = cx + neighbors[idx].x;
|
|
829
|
-
const ny = cy + neighbors[idx].y;
|
|
830
|
-
|
|
831
|
-
if (isSolid(nx, ny)) {
|
|
832
|
-
cx = nx;
|
|
833
|
-
cy = ny;
|
|
834
|
-
backtrack = (idx + 4 + 1) % 8;
|
|
835
|
-
found = true;
|
|
836
|
-
break;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
if (!found) break;
|
|
841
|
-
|
|
842
|
-
steps++;
|
|
843
|
-
} while ((cx !== startX || cy !== startY) && steps < maxSteps);
|
|
844
|
-
|
|
845
|
-
return points;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Douglas-Peucker Line Simplification
|
|
850
|
-
*/
|
|
851
|
-
private static douglasPeucker(points: Point[], tolerance: number): Point[] {
|
|
852
|
-
if (points.length <= 2) return points;
|
|
853
|
-
|
|
854
|
-
const sqTolerance = tolerance * tolerance;
|
|
855
|
-
let maxSqDist = 0;
|
|
856
|
-
let index = 0;
|
|
857
|
-
|
|
858
|
-
const first = points[0];
|
|
859
|
-
const last = points[points.length - 1];
|
|
860
|
-
|
|
861
|
-
for (let i = 1; i < points.length - 1; i++) {
|
|
862
|
-
const sqDist = this.getSqSegDist(points[i], first, last);
|
|
863
|
-
if (sqDist > maxSqDist) {
|
|
864
|
-
index = i;
|
|
865
|
-
maxSqDist = sqDist;
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (maxSqDist > sqTolerance) {
|
|
870
|
-
// Check if closed loop?
|
|
871
|
-
// If closed loop, we shouldn't simplify start/end connection too much?
|
|
872
|
-
// Douglas-Peucker works on segments.
|
|
873
|
-
const left = this.douglasPeucker(points.slice(0, index + 1), tolerance);
|
|
874
|
-
const right = this.douglasPeucker(points.slice(index), tolerance);
|
|
875
|
-
return left.slice(0, left.length - 1).concat(right);
|
|
876
|
-
} else {
|
|
877
|
-
return [first, last];
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
private static getSqSegDist(p: Point, p1: Point, p2: Point): number {
|
|
882
|
-
let x = p1.x;
|
|
883
|
-
let y = p1.y;
|
|
884
|
-
let dx = p2.x - x;
|
|
885
|
-
let dy = p2.y - y;
|
|
886
|
-
|
|
887
|
-
if (dx !== 0 || dy !== 0) {
|
|
888
|
-
const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
|
|
889
|
-
if (t > 1) {
|
|
890
|
-
x = p2.x;
|
|
891
|
-
y = p2.y;
|
|
892
|
-
} else if (t > 0) {
|
|
893
|
-
x += dx * t;
|
|
894
|
-
y += dy * t;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
dx = p.x - x;
|
|
899
|
-
dy = p.y - y;
|
|
900
|
-
|
|
901
|
-
return dx * dx + dy * dy;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
private static scalePoints(
|
|
905
|
-
points: Point[],
|
|
906
|
-
targetWidth: number,
|
|
907
|
-
targetHeight: number,
|
|
908
|
-
bounds: { x: number; y: number; width: number; height: number },
|
|
909
|
-
): Point[] {
|
|
910
|
-
if (points.length === 0) return points;
|
|
911
|
-
|
|
912
|
-
if (bounds.width === 0 || bounds.height === 0) return points;
|
|
913
|
-
|
|
914
|
-
const scaleX = targetWidth / bounds.width;
|
|
915
|
-
const scaleY = targetHeight / bounds.height;
|
|
916
|
-
|
|
917
|
-
return points.map((p) => ({
|
|
918
|
-
x: (p.x - bounds.x) * scaleX,
|
|
919
|
-
y: (p.y - bounds.y) * scaleY,
|
|
920
|
-
}));
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
private static scaleContours(
|
|
924
|
-
contours: Point[][],
|
|
925
|
-
targetWidth: number,
|
|
926
|
-
targetHeight: number,
|
|
927
|
-
bounds: { x: number; y: number; width: number; height: number },
|
|
928
|
-
): Point[][] {
|
|
929
|
-
return contours.map((points) =>
|
|
930
|
-
this.scalePoints(points, targetWidth, targetHeight, bounds),
|
|
931
|
-
);
|
|
932
|
-
}
|
|
933
|
-
|
|
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
|
-
);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
private static pointsToSVG(points: Point[]): string {
|
|
948
|
-
if (points.length === 0) return "";
|
|
949
|
-
const head = points[0];
|
|
950
|
-
const tail = points.slice(1);
|
|
951
|
-
|
|
952
|
-
return (
|
|
953
|
-
`M ${head.x} ${head.y} ` +
|
|
954
|
-
tail.map((p) => `L ${p.x} ${p.y}`).join(" ") +
|
|
955
|
-
" Z"
|
|
956
|
-
);
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
private static contoursToSVG(contours: Point[][]): string {
|
|
960
|
-
return contours
|
|
961
|
-
.filter((points) => points.length > 2)
|
|
962
|
-
.map((points) => this.pointsToSVG(points))
|
|
963
|
-
.join(" ")
|
|
964
|
-
.trim();
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
private static ensurePaper() {
|
|
968
|
-
if (!paper.project) {
|
|
969
|
-
paper.setup(new paper.Size(100, 100));
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
private static pointsToSVGPaper(points: Point[], tolerance: number): string {
|
|
974
|
-
if (points.length < 3) return this.pointsToSVG(points);
|
|
975
|
-
|
|
976
|
-
this.ensurePaper();
|
|
977
|
-
|
|
978
|
-
// Create Path
|
|
979
|
-
const path = new paper.Path({
|
|
980
|
-
segments: points.map((p) => [p.x, p.y]),
|
|
981
|
-
closed: true,
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
// Simplify
|
|
985
|
-
path.simplify(tolerance);
|
|
986
|
-
|
|
987
|
-
const data = path.pathData;
|
|
988
|
-
path.remove();
|
|
989
|
-
|
|
990
|
-
return data;
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
private static contoursToSVGPaper(
|
|
994
|
-
contours: Point[][],
|
|
995
|
-
tolerance: number,
|
|
996
|
-
): string {
|
|
997
|
-
const normalizedContours = contours.filter((points) => points.length > 2);
|
|
998
|
-
if (!normalizedContours.length) return "";
|
|
999
|
-
if (normalizedContours.length === 1) {
|
|
1000
|
-
return this.pointsToSVGPaper(normalizedContours[0], tolerance);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
this.ensurePaper();
|
|
1004
|
-
const compound = new paper.CompoundPath({ insert: false });
|
|
1005
|
-
for (const points of normalizedContours) {
|
|
1006
|
-
const child = new paper.Path({
|
|
1007
|
-
segments: points.map((p) => [p.x, p.y]),
|
|
1008
|
-
closed: true,
|
|
1009
|
-
insert: false,
|
|
1010
|
-
});
|
|
1011
|
-
child.simplify(tolerance);
|
|
1012
|
-
compound.addChild(child);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
const data = compound.pathData || this.contoursToSVG(normalizedContours);
|
|
1016
|
-
compound.remove();
|
|
1017
|
-
return data;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Image Tracer Utility
|
|
3
|
+
* Converts raster images (URL/Base64) to SVG Path Data using Marching Squares algorithm.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import paper from "paper";
|
|
7
|
+
import {
|
|
8
|
+
circularMorphology,
|
|
9
|
+
createMask,
|
|
10
|
+
fillHoles,
|
|
11
|
+
polygonSignedArea,
|
|
12
|
+
type MaskMode,
|
|
13
|
+
} from "./maskOps";
|
|
14
|
+
|
|
15
|
+
interface Point {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Bounds {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ComponentMode = "largest" | "all";
|
|
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
|
+
|
|
48
|
+
export class ImageTracer {
|
|
49
|
+
/**
|
|
50
|
+
* Main entry point: Traces an image URL to an SVG path string.
|
|
51
|
+
* @param imageUrl The URL or Base64 string of the image.
|
|
52
|
+
* @param options Configuration options.
|
|
53
|
+
*/
|
|
54
|
+
public static async trace(
|
|
55
|
+
imageUrl: string,
|
|
56
|
+
options: ImageTraceOptions = {},
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const { pathData } = await this.traceWithBounds(imageUrl, options);
|
|
59
|
+
return pathData;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public static async traceWithBounds(
|
|
63
|
+
imageUrl: string,
|
|
64
|
+
options: ImageTraceOptions = {},
|
|
65
|
+
): Promise<{ pathData: string; baseBounds: Bounds; bounds: Bounds }> {
|
|
66
|
+
const img = await this.loadImage(imageUrl);
|
|
67
|
+
const width = img.width;
|
|
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
|
+
}
|
|
78
|
+
const debug = options.debug === true;
|
|
79
|
+
const debugLog = (message: string, payload?: Record<string, unknown>) => {
|
|
80
|
+
if (!debug) return;
|
|
81
|
+
if (payload) {
|
|
82
|
+
console.info(`[ImageTracer] ${message}`, payload);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.info(`[ImageTracer] ${message}`);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Draw to canvas and get pixel data
|
|
89
|
+
const canvas = document.createElement("canvas");
|
|
90
|
+
canvas.width = width;
|
|
91
|
+
canvas.height = height;
|
|
92
|
+
const ctx = canvas.getContext("2d");
|
|
93
|
+
if (!ctx) throw new Error("Could not get 2D context");
|
|
94
|
+
|
|
95
|
+
ctx.drawImage(img, 0, 0);
|
|
96
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
97
|
+
|
|
98
|
+
// Strategy: fixed internal morphology + single-component target.
|
|
99
|
+
const threshold = options.threshold ?? 10;
|
|
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)),
|
|
112
|
+
);
|
|
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
|
+
|
|
132
|
+
debugLog("traceWithBounds:start", {
|
|
133
|
+
width,
|
|
134
|
+
height,
|
|
135
|
+
threshold,
|
|
136
|
+
expand,
|
|
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,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Padding must cover morphology and expansion margins.
|
|
156
|
+
const padding =
|
|
157
|
+
Math.max(
|
|
158
|
+
preprocessDilateRadius,
|
|
159
|
+
smoothDilateRadius,
|
|
160
|
+
connectMaxDilateRadius,
|
|
161
|
+
expand,
|
|
162
|
+
) + 2;
|
|
163
|
+
const paddedWidth = width + padding * 2;
|
|
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
|
+
};
|
|
177
|
+
|
|
178
|
+
let mask = createMask(imageData, {
|
|
179
|
+
threshold,
|
|
180
|
+
padding,
|
|
181
|
+
paddedWidth,
|
|
182
|
+
paddedHeight,
|
|
183
|
+
maskMode,
|
|
184
|
+
whiteThreshold,
|
|
185
|
+
alphaOpaqueCutoff,
|
|
186
|
+
});
|
|
187
|
+
if (debug) {
|
|
188
|
+
debugLog(
|
|
189
|
+
"traceWithBounds:mask:after-create",
|
|
190
|
+
summarizeMaskContours(mask),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
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
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
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
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
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;
|
|
271
|
+
}
|
|
272
|
+
|
|
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
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const baseMask = mask;
|
|
284
|
+
const baseContoursRaw = this.traceAllContours(
|
|
285
|
+
baseMask,
|
|
286
|
+
paddedWidth,
|
|
287
|
+
paddedHeight,
|
|
288
|
+
);
|
|
289
|
+
const baseContours = this.selectContours(
|
|
290
|
+
baseContoursRaw,
|
|
291
|
+
componentMode,
|
|
292
|
+
minComponentArea,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (!baseContours.length) {
|
|
296
|
+
// Fallback: Return a rectangular outline matching dimensions
|
|
297
|
+
const w = options.scaleToWidth ?? width;
|
|
298
|
+
const h = options.scaleToHeight ?? height;
|
|
299
|
+
debugLog("fallback:no-base-contour", { width: w, height: h });
|
|
300
|
+
return {
|
|
301
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
302
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
303
|
+
bounds: { x: 0, y: 0, width: w, height: h },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const baseUnpaddedContours = baseContours
|
|
308
|
+
.map((contour) =>
|
|
309
|
+
contour.map((p) => ({
|
|
310
|
+
x: p.x - padding,
|
|
311
|
+
y: p.y - padding,
|
|
312
|
+
})),
|
|
313
|
+
)
|
|
314
|
+
.filter((contour) => contour.length > 2);
|
|
315
|
+
|
|
316
|
+
if (!baseUnpaddedContours.length) {
|
|
317
|
+
const w = options.scaleToWidth ?? width;
|
|
318
|
+
const h = options.scaleToHeight ?? height;
|
|
319
|
+
debugLog("fallback:empty-base-contours", { width: w, height: h });
|
|
320
|
+
return {
|
|
321
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
322
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
323
|
+
bounds: { x: 0, y: 0, width: w, height: h },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let baseBounds = this.boundsFromPoints(
|
|
328
|
+
this.flattenContours(baseUnpaddedContours),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
let maskExpanded = baseMask;
|
|
332
|
+
if (expand > 0) {
|
|
333
|
+
maskExpanded = circularMorphology(
|
|
334
|
+
baseMask,
|
|
335
|
+
paddedWidth,
|
|
336
|
+
paddedHeight,
|
|
337
|
+
expand,
|
|
338
|
+
"dilate",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const expandedContoursRaw = this.traceAllContours(
|
|
343
|
+
maskExpanded,
|
|
344
|
+
paddedWidth,
|
|
345
|
+
paddedHeight,
|
|
346
|
+
);
|
|
347
|
+
const expandedContours = this.selectContours(
|
|
348
|
+
expandedContoursRaw,
|
|
349
|
+
componentMode,
|
|
350
|
+
minComponentArea,
|
|
351
|
+
);
|
|
352
|
+
if (!expandedContours.length) {
|
|
353
|
+
debugLog("fallback:no-expanded-contour", {
|
|
354
|
+
baseBounds,
|
|
355
|
+
width,
|
|
356
|
+
height,
|
|
357
|
+
expand,
|
|
358
|
+
});
|
|
359
|
+
return {
|
|
360
|
+
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
361
|
+
baseBounds,
|
|
362
|
+
bounds: baseBounds,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
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).
|
|
369
|
+
const expandedUnpaddedContours = expandedContours
|
|
370
|
+
.map((contour) =>
|
|
371
|
+
contour.map((p) => ({
|
|
372
|
+
x: p.x - padding,
|
|
373
|
+
y: p.y - padding,
|
|
374
|
+
})),
|
|
375
|
+
)
|
|
376
|
+
.filter((contour) => contour.length > 2);
|
|
377
|
+
if (!expandedUnpaddedContours.length) {
|
|
378
|
+
debugLog("fallback:empty-expanded-contours", {
|
|
379
|
+
baseBounds,
|
|
380
|
+
width,
|
|
381
|
+
height,
|
|
382
|
+
expand,
|
|
383
|
+
});
|
|
384
|
+
return {
|
|
385
|
+
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
386
|
+
baseBounds,
|
|
387
|
+
bounds: baseBounds,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let globalBounds = this.boundsFromPoints(
|
|
392
|
+
this.flattenContours(expandedUnpaddedContours),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Post-processing (Scale)
|
|
396
|
+
let finalContours = expandedUnpaddedContours;
|
|
397
|
+
if (options.scaleToWidth && options.scaleToHeight) {
|
|
398
|
+
finalContours = this.scaleContours(
|
|
399
|
+
expandedUnpaddedContours,
|
|
400
|
+
options.scaleToWidth,
|
|
401
|
+
options.scaleToHeight,
|
|
402
|
+
globalBounds,
|
|
403
|
+
);
|
|
404
|
+
globalBounds = this.boundsFromPoints(this.flattenContours(finalContours));
|
|
405
|
+
|
|
406
|
+
const baseScaledContours = this.scaleContours(
|
|
407
|
+
baseUnpaddedContours,
|
|
408
|
+
options.scaleToWidth,
|
|
409
|
+
options.scaleToHeight,
|
|
410
|
+
baseBounds,
|
|
411
|
+
);
|
|
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
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Simplify and Generate SVG
|
|
461
|
+
debugLog("traceWithBounds:contours", {
|
|
462
|
+
baseContourCount: baseContoursRaw.length,
|
|
463
|
+
baseSelectedCount: baseContours.length,
|
|
464
|
+
expandedContourCount: expandedContoursRaw.length,
|
|
465
|
+
expandedSelectedCount: expandedContours.length,
|
|
466
|
+
baseBounds,
|
|
467
|
+
expandedBounds: globalBounds,
|
|
468
|
+
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
469
|
+
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
470
|
+
expandedMayOverflowImageBounds: expand > 0,
|
|
471
|
+
useSmoothing,
|
|
472
|
+
componentMode,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
if (useSmoothing) {
|
|
476
|
+
return {
|
|
477
|
+
pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
|
|
478
|
+
baseBounds,
|
|
479
|
+
bounds: globalBounds,
|
|
480
|
+
};
|
|
481
|
+
} else {
|
|
482
|
+
const simplifiedContours = finalContours
|
|
483
|
+
.map((points) => this.douglasPeucker(points, simplifyTolerance))
|
|
484
|
+
.filter((points) => points.length > 2);
|
|
485
|
+
const pathData =
|
|
486
|
+
this.contoursToSVG(simplifiedContours) ||
|
|
487
|
+
this.contoursToSVG(finalContours);
|
|
488
|
+
return {
|
|
489
|
+
pathData,
|
|
490
|
+
baseBounds,
|
|
491
|
+
bounds: globalBounds,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private static pickPrimaryContour(contours: Point[][]): Point[] | null {
|
|
497
|
+
if (contours.length === 0) return null;
|
|
498
|
+
return contours.reduce((best, cur) => {
|
|
499
|
+
if (!best) return cur;
|
|
500
|
+
const bestArea = Math.abs(polygonSignedArea(best));
|
|
501
|
+
const curArea = Math.abs(polygonSignedArea(cur));
|
|
502
|
+
if (curArea !== bestArea) return curArea > bestArea ? cur : best;
|
|
503
|
+
return cur.length > best.length ? cur : best;
|
|
504
|
+
}, contours[0]);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private static flattenContours(contours: Point[][]): Point[] {
|
|
508
|
+
return contours.flatMap((contour) => contour);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private static contourCentroid(points: Point[]): Point {
|
|
512
|
+
if (!points.length) return { x: 0, y: 0 };
|
|
513
|
+
const sum = points.reduce(
|
|
514
|
+
(acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }),
|
|
515
|
+
{ x: 0, y: 0 },
|
|
516
|
+
);
|
|
517
|
+
return {
|
|
518
|
+
x: sum.x / points.length,
|
|
519
|
+
y: sum.y / points.length,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private static pointInPolygon(point: Point, polygon: Point[]): boolean {
|
|
524
|
+
let inside = false;
|
|
525
|
+
const { x, y } = point;
|
|
526
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
527
|
+
const xi = polygon[i].x;
|
|
528
|
+
const yi = polygon[i].y;
|
|
529
|
+
const xj = polygon[j].x;
|
|
530
|
+
const yj = polygon[j].y;
|
|
531
|
+
const intersects =
|
|
532
|
+
yi > y !== yj > y &&
|
|
533
|
+
x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
|
|
534
|
+
if (intersects) inside = !inside;
|
|
535
|
+
}
|
|
536
|
+
return inside;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private static keepOutermostContours(contours: Point[][]): Point[][] {
|
|
540
|
+
if (contours.length <= 1) return contours;
|
|
541
|
+
|
|
542
|
+
const sorted = [...contours].sort(
|
|
543
|
+
(a, b) => Math.abs(polygonSignedArea(b)) - Math.abs(polygonSignedArea(a)),
|
|
544
|
+
);
|
|
545
|
+
const selected: Point[][] = [];
|
|
546
|
+
for (const contour of sorted) {
|
|
547
|
+
const centroid = this.contourCentroid(contour);
|
|
548
|
+
const isNested = selected.some((outer) =>
|
|
549
|
+
this.pointInPolygon(centroid, outer),
|
|
550
|
+
);
|
|
551
|
+
if (!isNested) {
|
|
552
|
+
selected.push(contour);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return selected;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private static summarizeAllContours(
|
|
559
|
+
mask: Uint8Array,
|
|
560
|
+
width: number,
|
|
561
|
+
height: number,
|
|
562
|
+
minComponentArea: number,
|
|
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
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private static findForceConnectResult(
|
|
573
|
+
sourceMask: Uint8Array,
|
|
574
|
+
width: number,
|
|
575
|
+
height: number,
|
|
576
|
+
minComponentArea: number,
|
|
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
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
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);
|
|
639
|
+
}
|
|
640
|
+
|
|
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
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let best = highResult;
|
|
653
|
+
while (low + 1 < high) {
|
|
654
|
+
const mid = Math.floor((low + high) / 2);
|
|
655
|
+
const midResult = evaluate(mid);
|
|
656
|
+
if (midResult.selectedCount <= 1) {
|
|
657
|
+
best = midResult;
|
|
658
|
+
high = mid;
|
|
659
|
+
} else {
|
|
660
|
+
low = mid;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
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
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private static selectContours(
|
|
675
|
+
contours: Point[][],
|
|
676
|
+
mode: ComponentMode,
|
|
677
|
+
minComponentArea: number,
|
|
678
|
+
): Point[][] {
|
|
679
|
+
if (!contours.length) return [];
|
|
680
|
+
if (mode === "largest") {
|
|
681
|
+
const primary = this.pickPrimaryContour(contours);
|
|
682
|
+
return primary ? [primary] : [];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const threshold = Math.max(0, minComponentArea);
|
|
686
|
+
if (threshold <= 0) {
|
|
687
|
+
return this.keepOutermostContours(contours);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const filtered = contours.filter(
|
|
691
|
+
(contour) => Math.abs(polygonSignedArea(contour)) >= threshold,
|
|
692
|
+
);
|
|
693
|
+
if (filtered.length > 0) {
|
|
694
|
+
return this.keepOutermostContours(filtered);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const primary = this.pickPrimaryContour(contours);
|
|
698
|
+
return primary ? [primary] : [];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private static boundsFromPoints(points: Point[]): Bounds {
|
|
702
|
+
let minX = Infinity;
|
|
703
|
+
let minY = Infinity;
|
|
704
|
+
let maxX = -Infinity;
|
|
705
|
+
let maxY = -Infinity;
|
|
706
|
+
|
|
707
|
+
for (const p of points) {
|
|
708
|
+
if (p.x < minX) minX = p.x;
|
|
709
|
+
if (p.y < minY) minY = p.y;
|
|
710
|
+
if (p.x > maxX) maxX = p.x;
|
|
711
|
+
if (p.y > maxY) maxY = p.y;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
|
715
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
x: minX,
|
|
720
|
+
y: minY,
|
|
721
|
+
width: maxX - minX,
|
|
722
|
+
height: maxY - minY,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Traces all contours in the mask with optimized start-point detection
|
|
728
|
+
*/
|
|
729
|
+
private static traceAllContours(
|
|
730
|
+
mask: Uint8Array,
|
|
731
|
+
width: number,
|
|
732
|
+
height: number,
|
|
733
|
+
): Point[][] {
|
|
734
|
+
const visited = new Uint8Array(width * height);
|
|
735
|
+
const allContours: Point[][] = [];
|
|
736
|
+
|
|
737
|
+
for (let y = 0; y < height; y++) {
|
|
738
|
+
for (let x = 0; x < width; x++) {
|
|
739
|
+
const idx = y * width + x;
|
|
740
|
+
if (mask[idx] && !visited[idx]) {
|
|
741
|
+
// Only start a new trace if it's a potential outer boundary (left edge)
|
|
742
|
+
const isLeftEdge = x === 0 || mask[idx - 1] === 0;
|
|
743
|
+
if (isLeftEdge) {
|
|
744
|
+
const contour = this.marchingSquares(
|
|
745
|
+
mask,
|
|
746
|
+
visited,
|
|
747
|
+
x,
|
|
748
|
+
y,
|
|
749
|
+
width,
|
|
750
|
+
height,
|
|
751
|
+
);
|
|
752
|
+
if (contour.length > 2) {
|
|
753
|
+
allContours.push(contour);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return allContours;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private static loadImage(url: string): Promise<HTMLImageElement> {
|
|
763
|
+
return new Promise((resolve, reject) => {
|
|
764
|
+
const img = new Image();
|
|
765
|
+
img.crossOrigin = "Anonymous";
|
|
766
|
+
img.onload = () => resolve(img);
|
|
767
|
+
img.onerror = (e) => reject(e);
|
|
768
|
+
img.src = url;
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Moore-Neighbor Tracing Algorithm
|
|
774
|
+
* More robust for irregular shapes than simple Marching Squares walker.
|
|
775
|
+
*/
|
|
776
|
+
private static marchingSquares(
|
|
777
|
+
mask: Uint8Array,
|
|
778
|
+
visited: Uint8Array,
|
|
779
|
+
startX: number,
|
|
780
|
+
startY: number,
|
|
781
|
+
width: number,
|
|
782
|
+
height: number,
|
|
783
|
+
): Point[] {
|
|
784
|
+
const isSolid = (x: number, y: number): boolean => {
|
|
785
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return false;
|
|
786
|
+
return mask[y * width + x] === 1;
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const points: Point[] = [];
|
|
790
|
+
|
|
791
|
+
// Moore-Neighbor Tracing
|
|
792
|
+
// We enter from the Left (since we scan Left->Right), so "backtrack" is Left.
|
|
793
|
+
// B = (startX - 1, startY)
|
|
794
|
+
// P = (startX, startY)
|
|
795
|
+
|
|
796
|
+
let cx = startX;
|
|
797
|
+
let cy = startY;
|
|
798
|
+
|
|
799
|
+
// Start backtrack direction: Left (since we found it scanning from left)
|
|
800
|
+
// Directions: 0=Up, 1=UpRight, 2=Right, 3=DownRight, 4=Down, 5=DownLeft, 6=Left, 7=UpLeft
|
|
801
|
+
// Offsets for 8 neighbors starting from Up (0,-1) clockwise
|
|
802
|
+
const neighbors = [
|
|
803
|
+
{ x: 0, y: -1 },
|
|
804
|
+
{ x: 1, y: -1 },
|
|
805
|
+
{ x: 1, y: 0 },
|
|
806
|
+
{ x: 1, y: 1 },
|
|
807
|
+
{ x: 0, y: 1 },
|
|
808
|
+
{ x: -1, y: 1 },
|
|
809
|
+
{ x: -1, y: 0 },
|
|
810
|
+
{ x: -1, y: -1 },
|
|
811
|
+
];
|
|
812
|
+
|
|
813
|
+
// Backtrack is Left -> Index 6.
|
|
814
|
+
let backtrack = 6;
|
|
815
|
+
|
|
816
|
+
const maxSteps = width * height * 3;
|
|
817
|
+
let steps = 0;
|
|
818
|
+
|
|
819
|
+
do {
|
|
820
|
+
points.push({ x: cx, y: cy });
|
|
821
|
+
visited[cy * width + cx] = 1; // Mark as visited to avoid re-starting here
|
|
822
|
+
|
|
823
|
+
// Search for next solid neighbor in clockwise order, starting from backtrack
|
|
824
|
+
let found = false;
|
|
825
|
+
|
|
826
|
+
for (let i = 0; i < 8; i++) {
|
|
827
|
+
const idx = (backtrack + 1 + i) % 8;
|
|
828
|
+
const nx = cx + neighbors[idx].x;
|
|
829
|
+
const ny = cy + neighbors[idx].y;
|
|
830
|
+
|
|
831
|
+
if (isSolid(nx, ny)) {
|
|
832
|
+
cx = nx;
|
|
833
|
+
cy = ny;
|
|
834
|
+
backtrack = (idx + 4 + 1) % 8;
|
|
835
|
+
found = true;
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (!found) break;
|
|
841
|
+
|
|
842
|
+
steps++;
|
|
843
|
+
} while ((cx !== startX || cy !== startY) && steps < maxSteps);
|
|
844
|
+
|
|
845
|
+
return points;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Douglas-Peucker Line Simplification
|
|
850
|
+
*/
|
|
851
|
+
private static douglasPeucker(points: Point[], tolerance: number): Point[] {
|
|
852
|
+
if (points.length <= 2) return points;
|
|
853
|
+
|
|
854
|
+
const sqTolerance = tolerance * tolerance;
|
|
855
|
+
let maxSqDist = 0;
|
|
856
|
+
let index = 0;
|
|
857
|
+
|
|
858
|
+
const first = points[0];
|
|
859
|
+
const last = points[points.length - 1];
|
|
860
|
+
|
|
861
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
862
|
+
const sqDist = this.getSqSegDist(points[i], first, last);
|
|
863
|
+
if (sqDist > maxSqDist) {
|
|
864
|
+
index = i;
|
|
865
|
+
maxSqDist = sqDist;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (maxSqDist > sqTolerance) {
|
|
870
|
+
// Check if closed loop?
|
|
871
|
+
// If closed loop, we shouldn't simplify start/end connection too much?
|
|
872
|
+
// Douglas-Peucker works on segments.
|
|
873
|
+
const left = this.douglasPeucker(points.slice(0, index + 1), tolerance);
|
|
874
|
+
const right = this.douglasPeucker(points.slice(index), tolerance);
|
|
875
|
+
return left.slice(0, left.length - 1).concat(right);
|
|
876
|
+
} else {
|
|
877
|
+
return [first, last];
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private static getSqSegDist(p: Point, p1: Point, p2: Point): number {
|
|
882
|
+
let x = p1.x;
|
|
883
|
+
let y = p1.y;
|
|
884
|
+
let dx = p2.x - x;
|
|
885
|
+
let dy = p2.y - y;
|
|
886
|
+
|
|
887
|
+
if (dx !== 0 || dy !== 0) {
|
|
888
|
+
const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
|
|
889
|
+
if (t > 1) {
|
|
890
|
+
x = p2.x;
|
|
891
|
+
y = p2.y;
|
|
892
|
+
} else if (t > 0) {
|
|
893
|
+
x += dx * t;
|
|
894
|
+
y += dy * t;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
dx = p.x - x;
|
|
899
|
+
dy = p.y - y;
|
|
900
|
+
|
|
901
|
+
return dx * dx + dy * dy;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private static scalePoints(
|
|
905
|
+
points: Point[],
|
|
906
|
+
targetWidth: number,
|
|
907
|
+
targetHeight: number,
|
|
908
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
909
|
+
): Point[] {
|
|
910
|
+
if (points.length === 0) return points;
|
|
911
|
+
|
|
912
|
+
if (bounds.width === 0 || bounds.height === 0) return points;
|
|
913
|
+
|
|
914
|
+
const scaleX = targetWidth / bounds.width;
|
|
915
|
+
const scaleY = targetHeight / bounds.height;
|
|
916
|
+
|
|
917
|
+
return points.map((p) => ({
|
|
918
|
+
x: (p.x - bounds.x) * scaleX,
|
|
919
|
+
y: (p.y - bounds.y) * scaleY,
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private static scaleContours(
|
|
924
|
+
contours: Point[][],
|
|
925
|
+
targetWidth: number,
|
|
926
|
+
targetHeight: number,
|
|
927
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
928
|
+
): Point[][] {
|
|
929
|
+
return contours.map((points) =>
|
|
930
|
+
this.scalePoints(points, targetWidth, targetHeight, bounds),
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
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
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private static pointsToSVG(points: Point[]): string {
|
|
948
|
+
if (points.length === 0) return "";
|
|
949
|
+
const head = points[0];
|
|
950
|
+
const tail = points.slice(1);
|
|
951
|
+
|
|
952
|
+
return (
|
|
953
|
+
`M ${head.x} ${head.y} ` +
|
|
954
|
+
tail.map((p) => `L ${p.x} ${p.y}`).join(" ") +
|
|
955
|
+
" Z"
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
private static contoursToSVG(contours: Point[][]): string {
|
|
960
|
+
return contours
|
|
961
|
+
.filter((points) => points.length > 2)
|
|
962
|
+
.map((points) => this.pointsToSVG(points))
|
|
963
|
+
.join(" ")
|
|
964
|
+
.trim();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private static ensurePaper() {
|
|
968
|
+
if (!paper.project) {
|
|
969
|
+
paper.setup(new paper.Size(100, 100));
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private static pointsToSVGPaper(points: Point[], tolerance: number): string {
|
|
974
|
+
if (points.length < 3) return this.pointsToSVG(points);
|
|
975
|
+
|
|
976
|
+
this.ensurePaper();
|
|
977
|
+
|
|
978
|
+
// Create Path
|
|
979
|
+
const path = new paper.Path({
|
|
980
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
981
|
+
closed: true,
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Simplify
|
|
985
|
+
path.simplify(tolerance);
|
|
986
|
+
|
|
987
|
+
const data = path.pathData;
|
|
988
|
+
path.remove();
|
|
989
|
+
|
|
990
|
+
return data;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
private static contoursToSVGPaper(
|
|
994
|
+
contours: Point[][],
|
|
995
|
+
tolerance: number,
|
|
996
|
+
): string {
|
|
997
|
+
const normalizedContours = contours.filter((points) => points.length > 2);
|
|
998
|
+
if (!normalizedContours.length) return "";
|
|
999
|
+
if (normalizedContours.length === 1) {
|
|
1000
|
+
return this.pointsToSVGPaper(normalizedContours[0], tolerance);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
this.ensurePaper();
|
|
1004
|
+
const compound = new paper.CompoundPath({ insert: false });
|
|
1005
|
+
for (const points of normalizedContours) {
|
|
1006
|
+
const child = new paper.Path({
|
|
1007
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
1008
|
+
closed: true,
|
|
1009
|
+
insert: false,
|
|
1010
|
+
});
|
|
1011
|
+
child.simplify(tolerance);
|
|
1012
|
+
compound.addChild(child);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const data = compound.pathData || this.contoursToSVG(normalizedContours);
|
|
1016
|
+
compound.remove();
|
|
1017
|
+
return data;
|
|
1018
|
+
}
|
|
1019
|
+
}
|