@pooder/kit 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.test-dist/src/CanvasService.js +249 -249
  2. package/.test-dist/src/ViewportSystem.js +75 -75
  3. package/.test-dist/src/background.js +203 -203
  4. package/.test-dist/src/bridgeSelection.js +20 -20
  5. package/.test-dist/src/constraints.js +237 -237
  6. package/.test-dist/src/dieline.js +818 -818
  7. package/.test-dist/src/edgeScale.js +12 -12
  8. package/.test-dist/src/extensions/background.js +203 -0
  9. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  10. package/.test-dist/src/extensions/constraints.js +237 -0
  11. package/.test-dist/src/extensions/dieline.js +828 -0
  12. package/.test-dist/src/extensions/edgeScale.js +12 -0
  13. package/.test-dist/src/extensions/feature.js +825 -0
  14. package/.test-dist/src/extensions/featureComplete.js +32 -0
  15. package/.test-dist/src/extensions/film.js +167 -0
  16. package/.test-dist/src/extensions/geometry.js +545 -0
  17. package/.test-dist/src/extensions/image.js +1529 -0
  18. package/.test-dist/src/extensions/index.js +30 -0
  19. package/.test-dist/src/extensions/maskOps.js +279 -0
  20. package/.test-dist/src/extensions/mirror.js +104 -0
  21. package/.test-dist/src/extensions/ruler.js +345 -0
  22. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  23. package/.test-dist/src/extensions/sceneLayoutModel.js +196 -0
  24. package/.test-dist/src/extensions/sceneVisibility.js +62 -0
  25. package/.test-dist/src/extensions/size.js +331 -0
  26. package/.test-dist/src/extensions/tracer.js +538 -0
  27. package/.test-dist/src/extensions/white-ink.js +1190 -0
  28. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  29. package/.test-dist/src/feature.js +826 -826
  30. package/.test-dist/src/featureComplete.js +32 -32
  31. package/.test-dist/src/film.js +167 -167
  32. package/.test-dist/src/geometry.js +506 -506
  33. package/.test-dist/src/image.js +1250 -1250
  34. package/.test-dist/src/index.js +2 -19
  35. package/.test-dist/src/maskOps.js +270 -270
  36. package/.test-dist/src/mirror.js +104 -104
  37. package/.test-dist/src/renderSpec.js +2 -2
  38. package/.test-dist/src/ruler.js +343 -343
  39. package/.test-dist/src/sceneLayout.js +99 -99
  40. package/.test-dist/src/sceneLayoutModel.js +196 -196
  41. package/.test-dist/src/sceneView.js +40 -40
  42. package/.test-dist/src/sceneVisibility.js +42 -42
  43. package/.test-dist/src/services/CanvasService.js +249 -0
  44. package/.test-dist/src/services/ViewportSystem.js +76 -0
  45. package/.test-dist/src/services/index.js +24 -0
  46. package/.test-dist/src/services/renderSpec.js +2 -0
  47. package/.test-dist/src/size.js +332 -332
  48. package/.test-dist/src/tracer.js +544 -544
  49. package/.test-dist/src/white-ink.js +829 -829
  50. package/.test-dist/src/wrappedOffsets.js +33 -33
  51. package/CHANGELOG.md +6 -0
  52. package/dist/index.d.mts +5 -0
  53. package/dist/index.d.ts +5 -0
  54. package/dist/index.js +411 -375
  55. package/dist/index.mjs +411 -375
  56. package/package.json +1 -1
  57. package/src/coordinate.ts +106 -106
  58. package/src/extensions/background.ts +230 -230
  59. package/src/extensions/bridgeSelection.ts +17 -17
  60. package/src/extensions/constraints.ts +322 -322
  61. package/src/extensions/dieline.ts +20 -17
  62. package/src/extensions/edgeScale.ts +19 -19
  63. package/src/extensions/feature.ts +1021 -1021
  64. package/src/extensions/featureComplete.ts +46 -46
  65. package/src/extensions/film.ts +194 -194
  66. package/src/extensions/geometry.ts +719 -719
  67. package/src/extensions/image.ts +1924 -1924
  68. package/src/extensions/index.ts +11 -11
  69. package/src/extensions/maskOps.ts +365 -299
  70. package/src/extensions/mirror.ts +128 -128
  71. package/src/extensions/ruler.ts +451 -451
  72. package/src/extensions/sceneLayout.ts +140 -140
  73. package/src/extensions/sceneLayoutModel.ts +342 -342
  74. package/src/extensions/sceneVisibility.ts +71 -71
  75. package/src/extensions/size.ts +389 -389
  76. package/src/extensions/tracer.ts +302 -370
  77. package/src/extensions/white-ink.ts +1489 -1366
  78. package/src/extensions/wrappedOffsets.ts +33 -33
  79. package/src/index.ts +2 -2
  80. package/src/services/CanvasService.ts +300 -300
  81. package/src/services/ViewportSystem.ts +95 -95
  82. package/src/services/index.ts +3 -3
  83. package/src/services/renderSpec.ts +18 -18
  84. package/src/units.ts +27 -27
  85. package/tests/run.ts +118 -118
  86. package/tsconfig.test.json +15 -15
package/dist/index.js CHANGED
@@ -2993,15 +2993,19 @@ function analyzeAlpha(imageData, alphaOpaqueCutoff) {
2993
2993
  };
2994
2994
  }
2995
2995
  function circularMorphology(mask, width, height, radius, op) {
2996
- const dilate = (m, r) => {
2996
+ const r = Math.max(0, Math.floor(radius));
2997
+ if (r <= 0) {
2998
+ return mask.slice();
2999
+ }
3000
+ const dilateDisk = (m, radiusPx) => {
2997
3001
  const horizontalDist = new Int32Array(width * height);
2998
3002
  for (let y = 0; y < height; y++) {
2999
- let lastSolid = -r * 2;
3003
+ let lastSolid = -radiusPx * 2;
3000
3004
  for (let x = 0; x < width; x++) {
3001
3005
  if (m[y * width + x]) lastSolid = x;
3002
3006
  horizontalDist[y * width + x] = x - lastSolid;
3003
3007
  }
3004
- lastSolid = width + r * 2;
3008
+ lastSolid = width + radiusPx * 2;
3005
3009
  for (let x = width - 1; x >= 0; x--) {
3006
3010
  if (m[y * width + x]) lastSolid = x;
3007
3011
  horizontalDist[y * width + x] = Math.min(
@@ -3011,12 +3015,12 @@ function circularMorphology(mask, width, height, radius, op) {
3011
3015
  }
3012
3016
  }
3013
3017
  const result = new Uint8Array(width * height);
3014
- const r2 = r * r;
3018
+ const r2 = radiusPx * radiusPx;
3015
3019
  for (let x = 0; x < width; x++) {
3016
3020
  for (let y = 0; y < height; y++) {
3017
3021
  let found = false;
3018
- const minY = Math.max(0, y - r);
3019
- const maxY = Math.min(height - 1, y + r);
3022
+ const minY = Math.max(0, y - radiusPx);
3023
+ const maxY = Math.min(height - 1, y + radiusPx);
3020
3024
  for (let dy = minY; dy <= maxY; dy++) {
3021
3025
  const dY = dy - y;
3022
3026
  const hDist = horizontalDist[dy * width + x];
@@ -3030,23 +3034,62 @@ function circularMorphology(mask, width, height, radius, op) {
3030
3034
  }
3031
3035
  return result;
3032
3036
  };
3033
- const erode = (m, r) => {
3034
- const inverted = new Uint8Array(m.length);
3035
- for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
3036
- const dilatedInverted = dilate(inverted, r);
3037
- const result = new Uint8Array(m.length);
3038
- for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
3039
- return result;
3037
+ const erodeDiamond = (m, radiusPx) => {
3038
+ if (radiusPx <= 0) return m.slice();
3039
+ let current = m;
3040
+ for (let step = 0; step < radiusPx; step++) {
3041
+ const next = new Uint8Array(width * height);
3042
+ for (let y = 1; y < height - 1; y++) {
3043
+ const row = y * width;
3044
+ for (let x = 1; x < width - 1; x++) {
3045
+ const idx = row + x;
3046
+ if (current[idx] && current[idx - 1] && current[idx + 1] && current[idx - width] && current[idx + width]) {
3047
+ next[idx] = 1;
3048
+ }
3049
+ }
3050
+ }
3051
+ current = next;
3052
+ }
3053
+ return current;
3054
+ };
3055
+ const restoreBridgePixels = (source, eroded) => {
3056
+ const restored = eroded.slice();
3057
+ for (let y = 1; y < height - 1; y++) {
3058
+ const row = y * width;
3059
+ for (let x = 1; x < width - 1; x++) {
3060
+ const idx = row + x;
3061
+ if (!source[idx] || restored[idx]) continue;
3062
+ const up = source[idx - width] === 1;
3063
+ const down = source[idx + width] === 1;
3064
+ const left = source[idx - 1] === 1;
3065
+ const right = source[idx + 1] === 1;
3066
+ const upLeft = source[idx - width - 1] === 1;
3067
+ const upRight = source[idx - width + 1] === 1;
3068
+ const downLeft = source[idx + width - 1] === 1;
3069
+ const downRight = source[idx + width + 1] === 1;
3070
+ const keepsBridge = left && right || up && down || upLeft && downRight || upRight && downLeft;
3071
+ if (keepsBridge) {
3072
+ restored[idx] = 1;
3073
+ }
3074
+ }
3075
+ }
3076
+ return restored;
3077
+ };
3078
+ const erodePreservingBridges = (m, radiusPx) => {
3079
+ const eroded = erodeDiamond(m, radiusPx);
3080
+ return restoreBridgePixels(m, eroded);
3040
3081
  };
3041
3082
  switch (op) {
3042
3083
  case "dilate":
3043
- return dilate(mask, radius);
3084
+ return dilateDisk(mask, r);
3044
3085
  case "erode":
3045
- return erode(mask, radius);
3046
- case "closing":
3047
- return erode(dilate(mask, radius), radius);
3086
+ return erodePreservingBridges(mask, r);
3087
+ case "closing": {
3088
+ const erodeRadius = Math.max(1, Math.floor(r * 0.65));
3089
+ return erodePreservingBridges(dilateDisk(mask, r), erodeRadius);
3090
+ }
3048
3091
  case "opening":
3049
- return dilate(erode(mask, radius), radius);
3092
+ return dilateDisk(erodePreservingBridges(mask, r), r);
3050
3093
  default:
3051
3094
  return mask;
3052
3095
  }
@@ -3109,78 +3152,6 @@ function fillHoles(mask, width, height) {
3109
3152
  }
3110
3153
  return filledMask;
3111
3154
  }
3112
- function countForeground(mask) {
3113
- let c = 0;
3114
- for (let i = 0; i < mask.length; i++) c += mask[i] ? 1 : 0;
3115
- return c;
3116
- }
3117
- function isMaskConnected8(mask, width, height) {
3118
- const total = countForeground(mask);
3119
- if (total === 0) return true;
3120
- let start = -1;
3121
- for (let i = 0; i < mask.length; i++) {
3122
- if (mask[i]) {
3123
- start = i;
3124
- break;
3125
- }
3126
- }
3127
- if (start === -1) return true;
3128
- const visited = new Uint8Array(mask.length);
3129
- const queue = [start];
3130
- visited[start] = 1;
3131
- let seen = 1;
3132
- let head = 0;
3133
- while (head < queue.length) {
3134
- const idx = queue[head++];
3135
- const x = idx % width;
3136
- const y = (idx - x) / width;
3137
- for (let dy = -1; dy <= 1; dy++) {
3138
- const ny = y + dy;
3139
- if (ny < 0 || ny >= height) continue;
3140
- for (let dx = -1; dx <= 1; dx++) {
3141
- if (dx === 0 && dy === 0) continue;
3142
- const nx = x + dx;
3143
- if (nx < 0 || nx >= width) continue;
3144
- const nidx = ny * width + nx;
3145
- if (mask[nidx] && !visited[nidx]) {
3146
- visited[nidx] = 1;
3147
- queue.push(nidx);
3148
- seen++;
3149
- }
3150
- }
3151
- }
3152
- }
3153
- return seen === total;
3154
- }
3155
- function findMinimalConnectRadius(mask, width, height, maxRadius) {
3156
- if (maxRadius <= 0) return 0;
3157
- if (isMaskConnected8(mask, width, height)) return 0;
3158
- let low = 0;
3159
- let high = 1;
3160
- while (high <= maxRadius) {
3161
- const closed = circularMorphology(mask, width, height, high, "closing");
3162
- if (isMaskConnected8(closed, width, height)) break;
3163
- high *= 2;
3164
- }
3165
- if (high > maxRadius) high = maxRadius;
3166
- if (!isMaskConnected8(
3167
- circularMorphology(mask, width, height, high, "closing"),
3168
- width,
3169
- height
3170
- )) {
3171
- return high;
3172
- }
3173
- while (low + 1 < high) {
3174
- const mid = Math.floor((low + high) / 2);
3175
- const closed = circularMorphology(mask, width, height, mid, "closing");
3176
- if (isMaskConnected8(closed, width, height)) {
3177
- high = mid;
3178
- } else {
3179
- low = mid;
3180
- }
3181
- }
3182
- return high;
3183
- }
3184
3155
  function polygonSignedArea(points) {
3185
3156
  if (points.length < 3) return 0;
3186
3157
  let sum = 0;
@@ -3204,10 +3175,19 @@ var ImageTracer = class {
3204
3175
  return pathData;
3205
3176
  }
3206
3177
  static async traceWithBounds(imageUrl, options = {}) {
3207
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n;
3178
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
3208
3179
  const img = await this.loadImage(imageUrl);
3209
3180
  const width = img.width;
3210
3181
  const height = img.height;
3182
+ if (width <= 0 || height <= 0) {
3183
+ const w = (_a = options.scaleToWidth) != null ? _a : 0;
3184
+ const h = (_b = options.scaleToHeight) != null ? _b : 0;
3185
+ return {
3186
+ pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
3187
+ baseBounds: { x: 0, y: 0, width: w, height: h },
3188
+ bounds: { x: 0, y: 0, width: w, height: h }
3189
+ };
3190
+ }
3211
3191
  const debug = options.debug === true;
3212
3192
  const debugLog = (message, payload) => {
3213
3193
  if (!debug) return;
@@ -3224,96 +3204,178 @@ var ImageTracer = class {
3224
3204
  if (!ctx) throw new Error("Could not get 2D context");
3225
3205
  ctx.drawImage(img, 0, 0);
3226
3206
  const imageData = ctx.getImageData(0, 0, width, height);
3227
- const threshold = (_a = options.threshold) != null ? _a : 10;
3228
- const componentMode = (_b = options.componentMode) != null ? _b : "largest";
3229
- const minComponentArea = Math.max(0, (_c = options.minComponentArea) != null ? _c : 0);
3230
- const forceConnected = options.forceConnected === true;
3231
- const adaptiveRadius = Math.max(
3232
- 5,
3233
- Math.floor(Math.max(width, height) * 0.02)
3207
+ const threshold = (_c = options.threshold) != null ? _c : 10;
3208
+ const expand = Math.max(0, Math.floor((_d = options.expand) != null ? _d : 0));
3209
+ const simplifyTolerance = (_e = options.simplifyTolerance) != null ? _e : 2.5;
3210
+ const useSmoothing = options.smoothing !== false;
3211
+ const componentMode = "all";
3212
+ const minComponentArea = 0;
3213
+ const maxDim = Math.max(width, height);
3214
+ const maskMode = "auto";
3215
+ const whiteThreshold = 240;
3216
+ const alphaOpaqueCutoff = 250;
3217
+ const preprocessDilateRadius = Math.max(
3218
+ 2,
3219
+ Math.floor(Math.max(maxDim * 0.012, expand * 0.35))
3220
+ );
3221
+ const preprocessErodeRadius = Math.max(
3222
+ 1,
3223
+ Math.floor(preprocessDilateRadius * 0.65)
3224
+ );
3225
+ const smoothDilateRadius = Math.max(
3226
+ 1,
3227
+ Math.floor(preprocessDilateRadius * 0.25)
3228
+ );
3229
+ const smoothErodeRadius = Math.max(1, Math.floor(smoothDilateRadius * 0.8));
3230
+ const connectStartDilateRadius = Math.max(
3231
+ 1,
3232
+ Math.floor(Math.max(maxDim * 6e-3, expand * 0.2))
3234
3233
  );
3235
- const radius = (_d = options.morphologyRadius) != null ? _d : adaptiveRadius;
3236
- const expand = (_e = options.expand) != null ? _e : 0;
3237
- const noChannels = options.noChannels !== false;
3238
- const alphaOpaqueCutoff = (_f = options.alphaOpaqueCutoff) != null ? _f : 250;
3239
- const resolvedMaskMode = ((_g = options.maskMode) != null ? _g : "auto") === "auto" ? inferMaskMode(imageData, alphaOpaqueCutoff) : options.maskMode;
3240
- const alphaAnalysis = analyzeAlpha(imageData, alphaOpaqueCutoff);
3234
+ const connectMaxDilateRadius = Math.max(
3235
+ connectStartDilateRadius,
3236
+ Math.floor(Math.max(maxDim * 0.2, expand * 2.5))
3237
+ );
3238
+ const connectErodeRatio = 0.65;
3241
3239
  debugLog("traceWithBounds:start", {
3242
3240
  width,
3243
3241
  height,
3244
3242
  threshold,
3245
- radius,
3246
3243
  expand,
3247
- noChannels,
3248
- maskMode: (_h = options.maskMode) != null ? _h : "auto",
3249
- resolvedMaskMode,
3250
- alphaOpaqueCutoff,
3251
- alpha: {
3252
- minAlpha: alphaAnalysis.minAlpha,
3253
- belowOpaqueRatio: Number(alphaAnalysis.belowOpaqueRatio.toFixed(4)),
3254
- veryTransparentRatio: Number(
3255
- alphaAnalysis.veryTransparentRatio.toFixed(4)
3256
- )
3257
- },
3258
- componentMode,
3259
- minComponentArea,
3260
- forceConnected,
3261
- simplifyTolerance: (_i = options.simplifyTolerance) != null ? _i : 2.5,
3262
- smoothing: options.smoothing !== false
3244
+ simplifyTolerance,
3245
+ smoothing: useSmoothing,
3246
+ strategy: {
3247
+ maskMode,
3248
+ whiteThreshold,
3249
+ alphaOpaqueCutoff,
3250
+ fillHoles: true,
3251
+ preprocessDilateRadius,
3252
+ preprocessErodeRadius,
3253
+ smoothDilateRadius,
3254
+ smoothErodeRadius,
3255
+ connectEnabled: true,
3256
+ connectStartDilateRadius,
3257
+ connectMaxDilateRadius,
3258
+ connectErodeRatio
3259
+ }
3263
3260
  });
3264
- const padding = radius + expand + 2;
3261
+ const padding = Math.max(
3262
+ preprocessDilateRadius,
3263
+ smoothDilateRadius,
3264
+ connectMaxDilateRadius,
3265
+ expand
3266
+ ) + 2;
3265
3267
  const paddedWidth = width + padding * 2;
3266
3268
  const paddedHeight = height + padding * 2;
3269
+ const summarizeMaskContours = (m) => {
3270
+ const summary = this.summarizeAllContours(
3271
+ m,
3272
+ paddedWidth,
3273
+ paddedHeight,
3274
+ minComponentArea
3275
+ );
3276
+ return {
3277
+ rawContourCount: summary.rawCount,
3278
+ selectedContourCount: summary.selectedCount
3279
+ };
3280
+ };
3267
3281
  let mask = createMask(imageData, {
3268
3282
  threshold,
3269
3283
  padding,
3270
3284
  paddedWidth,
3271
3285
  paddedHeight,
3272
- maskMode: options.maskMode,
3273
- whiteThreshold: options.whiteThreshold,
3286
+ maskMode,
3287
+ whiteThreshold,
3274
3288
  alphaOpaqueCutoff
3275
3289
  });
3276
- if (radius > 0) {
3277
- mask = circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
3278
- }
3279
- if (noChannels) {
3280
- mask = fillHoles(mask, paddedWidth, paddedHeight);
3290
+ if (debug) {
3291
+ debugLog(
3292
+ "traceWithBounds:mask:after-create",
3293
+ summarizeMaskContours(mask)
3294
+ );
3281
3295
  }
3282
- if (radius > 0) {
3283
- const smoothRadius = Math.max(2, Math.floor(radius * 0.3));
3284
- mask = circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
3296
+ mask = circularMorphology(
3297
+ mask,
3298
+ paddedWidth,
3299
+ paddedHeight,
3300
+ preprocessDilateRadius,
3301
+ "dilate"
3302
+ );
3303
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3304
+ mask = circularMorphology(
3305
+ mask,
3306
+ paddedWidth,
3307
+ paddedHeight,
3308
+ preprocessErodeRadius,
3309
+ "erode"
3310
+ );
3311
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3312
+ if (debug) {
3313
+ debugLog("traceWithBounds:mask:after-preprocess", {
3314
+ dilateRadius: preprocessDilateRadius,
3315
+ erodeRadius: preprocessErodeRadius,
3316
+ ...summarizeMaskContours(mask)
3317
+ });
3285
3318
  }
3286
- const autoConnectRadiusMax = Math.max(
3287
- 10,
3288
- Math.floor(Math.max(width, height) * 0.12)
3319
+ mask = circularMorphology(
3320
+ mask,
3321
+ paddedWidth,
3322
+ paddedHeight,
3323
+ smoothDilateRadius,
3324
+ "dilate"
3289
3325
  );
3290
- const requestedConnectRadiusMax = options.connectRadiusMax;
3291
- const connectRadiusMax = requestedConnectRadiusMax === void 0 ? autoConnectRadiusMax : requestedConnectRadiusMax > 0 ? requestedConnectRadiusMax : forceConnected ? autoConnectRadiusMax : 0;
3292
- let rConnect = 0;
3293
- if (connectRadiusMax > 0) {
3294
- rConnect = forceConnected ? this.findMinimalMergeRadiusByContourCount(
3295
- mask,
3296
- paddedWidth,
3297
- paddedHeight,
3298
- connectRadiusMax,
3299
- minComponentArea
3300
- ) : findMinimalConnectRadius(
3326
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3327
+ mask = circularMorphology(
3328
+ mask,
3329
+ paddedWidth,
3330
+ paddedHeight,
3331
+ smoothErodeRadius,
3332
+ "erode"
3333
+ );
3334
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3335
+ if (debug) {
3336
+ debugLog("traceWithBounds:mask:after-smooth", {
3337
+ dilateRadius: smoothDilateRadius,
3338
+ erodeRadius: smoothErodeRadius,
3339
+ ...summarizeMaskContours(mask)
3340
+ });
3341
+ }
3342
+ const beforeConnectSummary = summarizeMaskContours(mask);
3343
+ if (beforeConnectSummary.selectedContourCount <= 1) {
3344
+ debugLog("traceWithBounds:mask:connect-skipped", {
3345
+ reason: "already-single-component",
3346
+ before: beforeConnectSummary
3347
+ });
3348
+ } else {
3349
+ const connectResult = this.findForceConnectResult(
3301
3350
  mask,
3302
3351
  paddedWidth,
3303
3352
  paddedHeight,
3304
- connectRadiusMax
3353
+ minComponentArea,
3354
+ connectStartDilateRadius,
3355
+ connectMaxDilateRadius,
3356
+ connectErodeRatio
3305
3357
  );
3306
- if (rConnect > 0) {
3307
- mask = circularMorphology(
3308
- mask,
3309
- paddedWidth,
3310
- paddedHeight,
3311
- rConnect,
3312
- "closing"
3313
- );
3314
- if (noChannels) {
3315
- mask = fillHoles(mask, paddedWidth, paddedHeight);
3316
- }
3358
+ if (debug) {
3359
+ debugLog("traceWithBounds:mask:after-connect", {
3360
+ before: beforeConnectSummary,
3361
+ appliedDilateRadius: connectResult.appliedDilateRadius,
3362
+ appliedErodeRadius: connectResult.appliedErodeRadius,
3363
+ reachedSingleComponent: connectResult.reachedSingleComponent,
3364
+ after: {
3365
+ rawContourCount: connectResult.rawContourCount,
3366
+ selectedContourCount: connectResult.selectedContourCount
3367
+ }
3368
+ });
3369
+ }
3370
+ mask = connectResult.mask;
3371
+ }
3372
+ if (debug) {
3373
+ const afterConnectSummary = summarizeMaskContours(mask);
3374
+ if (afterConnectSummary.selectedContourCount > 1) {
3375
+ debugLog("traceWithBounds:mask:connect-warning", {
3376
+ reason: "still-multi-component-after-connect-search",
3377
+ summary: afterConnectSummary
3378
+ });
3317
3379
  }
3318
3380
  }
3319
3381
  const baseMask = mask;
@@ -3328,8 +3390,8 @@ var ImageTracer = class {
3328
3390
  minComponentArea
3329
3391
  );
3330
3392
  if (!baseContours.length) {
3331
- const w = (_j = options.scaleToWidth) != null ? _j : width;
3332
- const h = (_k = options.scaleToHeight) != null ? _k : height;
3393
+ const w = (_f = options.scaleToWidth) != null ? _f : width;
3394
+ const h = (_g = options.scaleToHeight) != null ? _g : height;
3333
3395
  debugLog("fallback:no-base-contour", { width: w, height: h });
3334
3396
  return {
3335
3397
  pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
@@ -3348,8 +3410,8 @@ var ImageTracer = class {
3348
3410
  )
3349
3411
  ).filter((contour) => contour.length > 2);
3350
3412
  if (!baseUnpaddedContours.length) {
3351
- const w = (_l = options.scaleToWidth) != null ? _l : width;
3352
- const h = (_m = options.scaleToHeight) != null ? _m : height;
3413
+ const w = (_h = options.scaleToWidth) != null ? _h : width;
3414
+ const h = (_i = options.scaleToHeight) != null ? _i : height;
3353
3415
  debugLog("fallback:empty-base-contours", { width: w, height: h });
3354
3416
  return {
3355
3417
  pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
@@ -3394,14 +3456,10 @@ var ImageTracer = class {
3394
3456
  };
3395
3457
  }
3396
3458
  const expandedUnpaddedContours = expandedContours.map(
3397
- (contour) => this.clampPointsToImageBounds(
3398
- contour.map((p) => ({
3399
- x: p.x - padding,
3400
- y: p.y - padding
3401
- })),
3402
- width,
3403
- height
3404
- )
3459
+ (contour) => contour.map((p) => ({
3460
+ x: p.x - padding,
3461
+ y: p.y - padding
3462
+ }))
3405
3463
  ).filter((contour) => contour.length > 2);
3406
3464
  if (!expandedUnpaddedContours.length) {
3407
3465
  debugLog("fallback:empty-expanded-contours", {
@@ -3434,39 +3492,31 @@ var ImageTracer = class {
3434
3492
  options.scaleToHeight,
3435
3493
  baseBounds
3436
3494
  );
3437
- baseBounds = this.boundsFromPoints(this.flattenContours(baseScaledContours));
3495
+ baseBounds = this.boundsFromPoints(
3496
+ this.flattenContours(baseScaledContours)
3497
+ );
3438
3498
  }
3439
- const useSmoothing = options.smoothing !== false;
3440
3499
  debugLog("traceWithBounds:contours", {
3441
3500
  baseContourCount: baseContoursRaw.length,
3442
3501
  baseSelectedCount: baseContours.length,
3443
3502
  expandedContourCount: expandedContoursRaw.length,
3444
3503
  expandedSelectedCount: expandedContours.length,
3445
- connectRadiusMax,
3446
- appliedConnectRadius: rConnect,
3447
3504
  baseBounds,
3448
3505
  expandedBounds: globalBounds,
3449
3506
  expandedDeltaX: globalBounds.width - baseBounds.width,
3450
3507
  expandedDeltaY: globalBounds.height - baseBounds.height,
3508
+ expandedMayOverflowImageBounds: expand > 0,
3451
3509
  useSmoothing,
3452
3510
  componentMode
3453
3511
  });
3454
3512
  if (useSmoothing) {
3455
3513
  return {
3456
- pathData: this.contoursToSVGPaper(
3457
- finalContours,
3458
- (_n = options.simplifyTolerance) != null ? _n : 2.5
3459
- ),
3514
+ pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
3460
3515
  baseBounds,
3461
3516
  bounds: globalBounds
3462
3517
  };
3463
3518
  } else {
3464
- const simplifiedContours = finalContours.map(
3465
- (points) => {
3466
- var _a2;
3467
- return this.douglasPeucker(points, (_a2 = options.simplifyTolerance) != null ? _a2 : 2);
3468
- }
3469
- ).filter((points) => points.length > 2);
3519
+ const simplifiedContours = finalContours.map((points) => this.douglasPeucker(points, simplifyTolerance)).filter((points) => points.length > 2);
3470
3520
  const pathData = this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
3471
3521
  return {
3472
3522
  pathData,
@@ -3529,39 +3579,101 @@ var ImageTracer = class {
3529
3579
  }
3530
3580
  return selected;
3531
3581
  }
3532
- static countSelectedContours(mask, width, height, minComponentArea) {
3533
- const contours = this.traceAllContours(mask, width, height);
3534
- return this.selectContours(contours, "all", minComponentArea).length;
3535
- }
3536
- static findMinimalMergeRadiusByContourCount(mask, width, height, maxRadius, minComponentArea) {
3537
- if (maxRadius <= 0) return 0;
3538
- if (this.countSelectedContours(mask, width, height, minComponentArea) <= 1) {
3539
- return 0;
3540
- }
3541
- let low = 0;
3542
- let high = 1;
3543
- while (high <= maxRadius) {
3544
- const closed = circularMorphology(mask, width, height, high, "closing");
3545
- if (this.countSelectedContours(closed, width, height, minComponentArea) <= 1) {
3546
- break;
3547
- }
3548
- high *= 2;
3582
+ static summarizeAllContours(mask, width, height, minComponentArea) {
3583
+ const raw = this.traceAllContours(mask, width, height);
3584
+ const selected = this.selectContours(raw, "all", minComponentArea);
3585
+ return {
3586
+ rawCount: raw.length,
3587
+ selectedCount: selected.length
3588
+ };
3589
+ }
3590
+ static findForceConnectResult(sourceMask, width, height, minComponentArea, startDilateRadius, maxDilateRadius, erodeRatio) {
3591
+ const initial = this.summarizeAllContours(
3592
+ sourceMask,
3593
+ width,
3594
+ height,
3595
+ minComponentArea
3596
+ );
3597
+ if (initial.selectedCount <= 1) {
3598
+ return {
3599
+ mask: sourceMask,
3600
+ appliedDilateRadius: 0,
3601
+ appliedErodeRadius: 0,
3602
+ reachedSingleComponent: true,
3603
+ rawContourCount: initial.rawCount,
3604
+ selectedContourCount: initial.selectedCount
3605
+ };
3606
+ }
3607
+ const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
3608
+ const normalizedMax = Math.max(
3609
+ normalizedStart,
3610
+ Math.floor(maxDilateRadius)
3611
+ );
3612
+ const normalizedErodeRatio = Math.max(0, erodeRatio);
3613
+ const evaluate = (dilateRadius) => {
3614
+ const erodeRadius = Math.max(
3615
+ 1,
3616
+ Math.floor(dilateRadius * normalizedErodeRatio)
3617
+ );
3618
+ let mask = sourceMask;
3619
+ mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
3620
+ mask = fillHoles(mask, width, height);
3621
+ mask = circularMorphology(mask, width, height, erodeRadius, "erode");
3622
+ mask = fillHoles(mask, width, height);
3623
+ const summary = this.summarizeAllContours(
3624
+ mask,
3625
+ width,
3626
+ height,
3627
+ minComponentArea
3628
+ );
3629
+ return {
3630
+ dilateRadius,
3631
+ erodeRadius,
3632
+ mask,
3633
+ rawCount: summary.rawCount,
3634
+ selectedCount: summary.selectedCount
3635
+ };
3636
+ };
3637
+ let low = normalizedStart - 1;
3638
+ let high = normalizedStart;
3639
+ let highResult = evaluate(high);
3640
+ while (high < normalizedMax && highResult.selectedCount > 1) {
3641
+ low = high;
3642
+ high = Math.min(
3643
+ normalizedMax,
3644
+ Math.max(high + 1, Math.floor(high * 1.6))
3645
+ );
3646
+ highResult = evaluate(high);
3549
3647
  }
3550
- if (high > maxRadius) high = maxRadius;
3551
- const highMask = circularMorphology(mask, width, height, high, "closing");
3552
- if (this.countSelectedContours(highMask, width, height, minComponentArea) > 1) {
3553
- return high;
3648
+ if (highResult.selectedCount > 1) {
3649
+ return {
3650
+ mask: highResult.mask,
3651
+ appliedDilateRadius: highResult.dilateRadius,
3652
+ appliedErodeRadius: highResult.erodeRadius,
3653
+ reachedSingleComponent: false,
3654
+ rawContourCount: highResult.rawCount,
3655
+ selectedContourCount: highResult.selectedCount
3656
+ };
3554
3657
  }
3658
+ let best = highResult;
3555
3659
  while (low + 1 < high) {
3556
3660
  const mid = Math.floor((low + high) / 2);
3557
- const midMask = circularMorphology(mask, width, height, mid, "closing");
3558
- if (this.countSelectedContours(midMask, width, height, minComponentArea) <= 1) {
3661
+ const midResult = evaluate(mid);
3662
+ if (midResult.selectedCount <= 1) {
3663
+ best = midResult;
3559
3664
  high = mid;
3560
3665
  } else {
3561
3666
  low = mid;
3562
3667
  }
3563
3668
  }
3564
- return high;
3669
+ return {
3670
+ mask: best.mask,
3671
+ appliedDilateRadius: best.dilateRadius,
3672
+ appliedErodeRadius: best.erodeRadius,
3673
+ reachedSingleComponent: true,
3674
+ rawContourCount: best.rawCount,
3675
+ selectedContourCount: best.selectedCount
3676
+ };
3565
3677
  }
3566
3678
  static selectContours(contours, mode, minComponentArea) {
3567
3679
  if (!contours.length) return [];
@@ -3603,154 +3715,6 @@ var ImageTracer = class {
3603
3715
  height: maxY - minY
3604
3716
  };
3605
3717
  }
3606
- static createMask(imageData, threshold, padding, paddedWidth, paddedHeight) {
3607
- const { width, height, data } = imageData;
3608
- const mask = new Uint8Array(paddedWidth * paddedHeight);
3609
- let hasTransparency = false;
3610
- for (let i = 3; i < data.length; i += 4) {
3611
- if (data[i] < 255) {
3612
- hasTransparency = true;
3613
- break;
3614
- }
3615
- }
3616
- for (let y = 0; y < height; y++) {
3617
- for (let x = 0; x < width; x++) {
3618
- const srcIdx = (y * width + x) * 4;
3619
- const r = data[srcIdx];
3620
- const g = data[srcIdx + 1];
3621
- const b = data[srcIdx + 2];
3622
- const a = data[srcIdx + 3];
3623
- const destIdx = (y + padding) * paddedWidth + (x + padding);
3624
- if (hasTransparency) {
3625
- if (a > threshold) {
3626
- mask[destIdx] = 1;
3627
- }
3628
- } else {
3629
- if (!(r > 240 && g > 240 && b > 240)) {
3630
- mask[destIdx] = 1;
3631
- }
3632
- }
3633
- }
3634
- }
3635
- return mask;
3636
- }
3637
- /**
3638
- * Fast circular morphology using a distance-transform inspired separable approach.
3639
- * O(N * R) complexity, where R is the radius.
3640
- */
3641
- static circularMorphology(mask, width, height, radius, op) {
3642
- const dilate = (m, r) => {
3643
- const horizontalDist = new Int32Array(width * height);
3644
- for (let y = 0; y < height; y++) {
3645
- let lastSolid = -r * 2;
3646
- for (let x = 0; x < width; x++) {
3647
- if (m[y * width + x]) lastSolid = x;
3648
- horizontalDist[y * width + x] = x - lastSolid;
3649
- }
3650
- lastSolid = width + r * 2;
3651
- for (let x = width - 1; x >= 0; x--) {
3652
- if (m[y * width + x]) lastSolid = x;
3653
- horizontalDist[y * width + x] = Math.min(
3654
- horizontalDist[y * width + x],
3655
- lastSolid - x
3656
- );
3657
- }
3658
- }
3659
- const result = new Uint8Array(width * height);
3660
- const r2 = r * r;
3661
- for (let x = 0; x < width; x++) {
3662
- for (let y = 0; y < height; y++) {
3663
- let found = false;
3664
- const minY = Math.max(0, y - r);
3665
- const maxY = Math.min(height - 1, y + r);
3666
- for (let dy = minY; dy <= maxY; dy++) {
3667
- const dY = dy - y;
3668
- const hDist = horizontalDist[dy * width + x];
3669
- if (hDist * hDist + dY * dY <= r2) {
3670
- found = true;
3671
- break;
3672
- }
3673
- }
3674
- if (found) result[y * width + x] = 1;
3675
- }
3676
- }
3677
- return result;
3678
- };
3679
- const erode = (m, r) => {
3680
- const inverted = new Uint8Array(m.length);
3681
- for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
3682
- const dilatedInverted = dilate(inverted, r);
3683
- const result = new Uint8Array(m.length);
3684
- for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
3685
- return result;
3686
- };
3687
- switch (op) {
3688
- case "dilate":
3689
- return dilate(mask, radius);
3690
- case "erode":
3691
- return erode(mask, radius);
3692
- case "closing":
3693
- return erode(dilate(mask, radius), radius);
3694
- case "opening":
3695
- return dilate(erode(mask, radius), radius);
3696
- default:
3697
- return mask;
3698
- }
3699
- }
3700
- /**
3701
- * Fills internal holes in the binary mask using flood fill from edges.
3702
- */
3703
- static fillHoles(mask, width, height) {
3704
- const background = new Uint8Array(width * height);
3705
- const queue = [];
3706
- for (let x = 0; x < width; x++) {
3707
- if (mask[x] === 0) {
3708
- background[x] = 1;
3709
- queue.push([x, 0]);
3710
- }
3711
- const lastRow = (height - 1) * width + x;
3712
- if (mask[lastRow] === 0) {
3713
- background[lastRow] = 1;
3714
- queue.push([x, height - 1]);
3715
- }
3716
- }
3717
- for (let y = 1; y < height - 1; y++) {
3718
- if (mask[y * width] === 0) {
3719
- background[y * width] = 1;
3720
- queue.push([0, y]);
3721
- }
3722
- if (mask[y * width + width - 1] === 0) {
3723
- background[y * width + width - 1] = 1;
3724
- queue.push([width - 1, y]);
3725
- }
3726
- }
3727
- const dirs = [
3728
- [0, 1],
3729
- [0, -1],
3730
- [1, 0],
3731
- [-1, 0]
3732
- ];
3733
- let head = 0;
3734
- while (head < queue.length) {
3735
- const [cx, cy] = queue[head++];
3736
- for (const [dx, dy] of dirs) {
3737
- const nx = cx + dx;
3738
- const ny = cy + dy;
3739
- if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
3740
- const nidx = ny * width + nx;
3741
- if (mask[nidx] === 0 && background[nidx] === 0) {
3742
- background[nidx] = 1;
3743
- queue.push([nx, ny]);
3744
- }
3745
- }
3746
- }
3747
- }
3748
- const filledMask = new Uint8Array(width * height);
3749
- for (let i = 0; i < width * height; i++) {
3750
- filledMask[i] = background[i] === 0 ? 1 : 0;
3751
- }
3752
- return filledMask;
3753
- }
3754
3718
  /**
3755
3719
  * Traces all contours in the mask with optimized start-point detection
3756
3720
  */
@@ -4287,10 +4251,17 @@ var DielineTool = class {
4287
4251
  command: "detectEdge",
4288
4252
  title: "Detect Edge from Image",
4289
4253
  handler: async (imageUrl, options) => {
4290
- var _a;
4254
+ var _a, _b, _c;
4291
4255
  try {
4292
4256
  const detectOptions = options || {};
4293
4257
  const debug = detectOptions.debug === true;
4258
+ const tracerOptions = {
4259
+ expand: (_a = detectOptions.expand) != null ? _a : 0,
4260
+ smoothing: (_b = detectOptions.smoothing) != null ? _b : true,
4261
+ simplifyTolerance: (_c = detectOptions.simplifyTolerance) != null ? _c : 2,
4262
+ threshold: detectOptions.threshold,
4263
+ debug
4264
+ };
4294
4265
  const loadImage = (url) => {
4295
4266
  return new Promise((resolve, reject) => {
4296
4267
  const img2 = new Image();
@@ -4302,7 +4273,7 @@ var DielineTool = class {
4302
4273
  };
4303
4274
  const [img, traced] = await Promise.all([
4304
4275
  loadImage(imageUrl),
4305
- ImageTracer.traceWithBounds(imageUrl, detectOptions)
4276
+ ImageTracer.traceWithBounds(imageUrl, tracerOptions)
4306
4277
  ]);
4307
4278
  const { pathData, baseBounds, bounds } = traced;
4308
4279
  if (debug) {
@@ -4313,21 +4284,8 @@ var DielineTool = class {
4313
4284
  expandedBounds: bounds,
4314
4285
  currentDielineWidth: s.width,
4315
4286
  currentDielineHeight: s.height,
4316
- options: {
4317
- expand: (_a = detectOptions.expand) != null ? _a : 0,
4318
- morphologyRadius: detectOptions.morphologyRadius,
4319
- connectRadiusMax: detectOptions.connectRadiusMax,
4320
- smoothing: detectOptions.smoothing,
4321
- simplifyTolerance: detectOptions.simplifyTolerance,
4322
- threshold: detectOptions.threshold,
4323
- maskMode: detectOptions.maskMode,
4324
- whiteThreshold: detectOptions.whiteThreshold,
4325
- alphaOpaqueCutoff: detectOptions.alphaOpaqueCutoff,
4326
- noChannels: detectOptions.noChannels,
4327
- componentMode: detectOptions.componentMode,
4328
- minComponentArea: detectOptions.minComponentArea,
4329
- forceConnected: detectOptions.forceConnected
4330
- }
4287
+ options: tracerOptions,
4288
+ strategy: "single-connected-silhouette"
4331
4289
  });
4332
4290
  }
4333
4291
  return {
@@ -7034,6 +6992,82 @@ var WhiteInkTool = class {
7034
6992
  height
7035
6993
  };
7036
6994
  }
6995
+ getImagePlacementState(id) {
6996
+ const rawItems = this.getConfig("image.items", []);
6997
+ if (!Array.isArray(rawItems) || rawItems.length === 0) return null;
6998
+ const matched = (id ? rawItems.find(
6999
+ (item) => item && typeof item === "object" && typeof item.id === "string" && item.id === id
7000
+ ) : void 0) || rawItems[0];
7001
+ if (!matched || typeof matched !== "object") return null;
7002
+ const sourceUrl = typeof matched.sourceUrl === "string" && matched.sourceUrl.length > 0 ? matched.sourceUrl : typeof matched.url === "string" ? matched.url : "";
7003
+ const committedUrl = typeof matched.committedUrl === "string" ? matched.committedUrl : "";
7004
+ return {
7005
+ id: typeof matched.id === "string" && matched.id.length > 0 ? matched.id : id || "image",
7006
+ sourceUrl,
7007
+ committedUrl,
7008
+ left: Number.isFinite(matched.left) ? Number(matched.left) : 0.5,
7009
+ top: Number.isFinite(matched.top) ? Number(matched.top) : 0.5,
7010
+ scale: Number.isFinite(matched.scale) ? Math.max(0.05, matched.scale) : 1,
7011
+ angle: Number.isFinite(matched.angle) ? matched.angle : 0
7012
+ };
7013
+ }
7014
+ shouldRestoreSnapshotToSource(snapshot, placement) {
7015
+ if (!placement.sourceUrl || !placement.committedUrl) return false;
7016
+ if (placement.sourceUrl === placement.committedUrl) return false;
7017
+ return snapshot.src === placement.committedUrl;
7018
+ }
7019
+ getCoverScale(frame, source) {
7020
+ const frameW = Math.max(1, frame.width);
7021
+ const frameH = Math.max(1, frame.height);
7022
+ const sourceW = Math.max(1, source.width);
7023
+ const sourceH = Math.max(1, source.height);
7024
+ return Math.max(frameW / sourceW, frameH / sourceH);
7025
+ }
7026
+ async ensureSourceSize(sourceUrl) {
7027
+ if (!sourceUrl) return null;
7028
+ const cached = this.getSourceSize(sourceUrl);
7029
+ if (cached) return cached;
7030
+ try {
7031
+ const image = await this.loadImageElement(sourceUrl);
7032
+ const size = this.getElementSize(image);
7033
+ if (!size) return null;
7034
+ this.rememberSourceSize(sourceUrl, size);
7035
+ return {
7036
+ width: size.width,
7037
+ height: size.height
7038
+ };
7039
+ } catch (e) {
7040
+ return null;
7041
+ }
7042
+ }
7043
+ async resolveAlignedImageSnapshot(snapshot) {
7044
+ const placement = this.getImagePlacementState(snapshot.id);
7045
+ if (!placement) return snapshot;
7046
+ if (!this.shouldRestoreSnapshotToSource(snapshot, placement)) {
7047
+ return snapshot;
7048
+ }
7049
+ const frame = this.getFrameRect();
7050
+ if (frame.width <= 0 || frame.height <= 0) {
7051
+ return snapshot;
7052
+ }
7053
+ const sourceSize = await this.ensureSourceSize(placement.sourceUrl);
7054
+ if (!sourceSize) return snapshot;
7055
+ const coverScale = this.getCoverScale(frame, sourceSize);
7056
+ return {
7057
+ ...snapshot,
7058
+ src: placement.sourceUrl,
7059
+ element: void 0,
7060
+ left: frame.left + placement.left * frame.width,
7061
+ top: frame.top + placement.top * frame.height,
7062
+ scaleX: coverScale * placement.scale,
7063
+ scaleY: coverScale * placement.scale,
7064
+ angle: placement.angle,
7065
+ originX: "center",
7066
+ originY: "center",
7067
+ width: sourceSize.width,
7068
+ height: sourceSize.height
7069
+ };
7070
+ }
7037
7071
  getImageElementFromObject(obj) {
7038
7072
  if (!obj) return null;
7039
7073
  if (typeof obj.getElement === "function") {
@@ -7406,9 +7440,11 @@ var WhiteInkTool = class {
7406
7440
  let whiteSpecs = [];
7407
7441
  let coverSpecs = [];
7408
7442
  if (previewActive) {
7409
- const snapshot = this.getImageSnapshot(this.getPrimaryImageObject());
7443
+ const baseSnapshot = this.getImageSnapshot(this.getPrimaryImageObject());
7410
7444
  const item = this.getEffectiveWhiteInkItem(this.resolveRenderItems());
7411
- if (snapshot && item) {
7445
+ if (baseSnapshot && item) {
7446
+ const snapshot = await this.resolveAlignedImageSnapshot(baseSnapshot);
7447
+ if (seq !== this.renderSeq) return;
7412
7448
  const sources = await this.resolveRenderSources(snapshot, item);
7413
7449
  if (seq !== this.renderSeq) return;
7414
7450
  if (sources == null ? void 0 : sources.whiteSrc) {