@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/index.d.mts +239 -269
  3. package/dist/index.d.ts +239 -269
  4. package/dist/index.js +6485 -5833
  5. package/dist/index.mjs +6587 -5923
  6. package/package.json +2 -2
  7. package/src/{background.ts → extensions/background.ts} +1 -1
  8. package/src/{dieline.ts → extensions/dieline.ts} +39 -17
  9. package/src/{feature.ts → extensions/feature.ts} +80 -67
  10. package/src/{film.ts → extensions/film.ts} +1 -1
  11. package/src/{geometry.ts → extensions/geometry.ts} +151 -105
  12. package/src/{image.ts → extensions/image.ts} +190 -192
  13. package/src/extensions/index.ts +11 -0
  14. package/src/{maskOps.ts → extensions/maskOps.ts} +28 -10
  15. package/src/{mirror.ts → extensions/mirror.ts} +1 -1
  16. package/src/{ruler.ts → extensions/ruler.ts} +5 -3
  17. package/src/extensions/sceneLayout.ts +140 -0
  18. package/src/{sceneLayoutModel.ts → extensions/sceneLayoutModel.ts} +17 -10
  19. package/src/extensions/sceneVisibility.ts +71 -0
  20. package/src/{size.ts → extensions/size.ts} +23 -13
  21. package/src/{tracer.ts → extensions/tracer.ts} +374 -45
  22. package/src/{white-ink.ts → extensions/white-ink.ts} +620 -236
  23. package/src/index.ts +2 -14
  24. package/src/{ViewportSystem.ts → services/ViewportSystem.ts} +5 -2
  25. package/src/services/index.ts +3 -0
  26. package/src/sceneLayout.ts +0 -121
  27. package/src/sceneVisibility.ts +0 -49
  28. /package/src/{bridgeSelection.ts → extensions/bridgeSelection.ts} +0 -0
  29. /package/src/{constraints.ts → extensions/constraints.ts} +0 -0
  30. /package/src/{edgeScale.ts → extensions/edgeScale.ts} +0 -0
  31. /package/src/{featureComplete.ts → extensions/featureComplete.ts} +0 -0
  32. /package/src/{wrappedOffsets.ts → extensions/wrappedOffsets.ts} +0 -0
  33. /package/src/{CanvasService.ts → services/CanvasService.ts} +0 -0
  34. /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: options.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 baseContour = this.pickPrimaryContour(
165
- this.traceAllContours(baseMask, paddedWidth, paddedHeight),
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 (!baseContour) {
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 baseUnpadded = baseContour.map(p => ({
181
- x: p.x - padding,
182
- y: p.y - padding,
183
- }));
184
- let baseBounds = this.boundsFromPoints(baseUnpadded);
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(baseMask, paddedWidth, paddedHeight, expand, "dilate");
279
+ maskExpanded = circularMorphology(
280
+ baseMask,
281
+ paddedWidth,
282
+ paddedHeight,
283
+ expand,
284
+ "dilate",
285
+ );
189
286
  }
190
287
 
191
- const expandedContour = this.pickPrimaryContour(
192
- this.traceAllContours(maskExpanded, paddedWidth, paddedHeight),
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 (!expandedContour) {
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 expandedUnpadded = expandedContour.map(p => ({
209
- x: p.x - padding,
210
- y: p.y - padding,
211
- }));
212
- let globalBounds = this.boundsFromPoints(expandedUnpadded);
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 finalPoints = expandedUnpadded;
343
+ let finalContours = expandedUnpaddedContours;
216
344
  if (options.scaleToWidth && options.scaleToHeight) {
217
- finalPoints = this.scalePoints(
218
- expandedUnpadded,
345
+ finalContours = this.scaleContours(
346
+ expandedUnpaddedContours,
219
347
  options.scaleToWidth,
220
348
  options.scaleToHeight,
221
349
  globalBounds,
222
350
  );
223
- globalBounds = this.boundsFromPoints(finalPoints);
351
+ globalBounds = this.boundsFromPoints(this.flattenContours(finalContours));
224
352
 
225
- const baseScaled = this.scalePoints(
226
- baseUnpadded,
353
+ const baseScaledContours = this.scaleContours(
354
+ baseUnpaddedContours,
227
355
  options.scaleToWidth,
228
356
  options.scaleToHeight,
229
357
  baseBounds,
230
358
  );
231
- baseBounds = this.boundsFromPoints(baseScaled);
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.pointsToSVGPaper(finalPoints, options.simplifyTolerance ?? 2.5),
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 simplifiedPoints = this.douglasPeucker(
252
- finalPoints,
253
- options.simplifyTolerance ?? 2.0,
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: this.pointsToSVG(simplifiedPoints),
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
  }