@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.mjs CHANGED
@@ -2958,15 +2958,19 @@ function analyzeAlpha(imageData, alphaOpaqueCutoff) {
2958
2958
  };
2959
2959
  }
2960
2960
  function circularMorphology(mask, width, height, radius, op) {
2961
- const dilate = (m, r) => {
2961
+ const r = Math.max(0, Math.floor(radius));
2962
+ if (r <= 0) {
2963
+ return mask.slice();
2964
+ }
2965
+ const dilateDisk = (m, radiusPx) => {
2962
2966
  const horizontalDist = new Int32Array(width * height);
2963
2967
  for (let y = 0; y < height; y++) {
2964
- let lastSolid = -r * 2;
2968
+ let lastSolid = -radiusPx * 2;
2965
2969
  for (let x = 0; x < width; x++) {
2966
2970
  if (m[y * width + x]) lastSolid = x;
2967
2971
  horizontalDist[y * width + x] = x - lastSolid;
2968
2972
  }
2969
- lastSolid = width + r * 2;
2973
+ lastSolid = width + radiusPx * 2;
2970
2974
  for (let x = width - 1; x >= 0; x--) {
2971
2975
  if (m[y * width + x]) lastSolid = x;
2972
2976
  horizontalDist[y * width + x] = Math.min(
@@ -2976,12 +2980,12 @@ function circularMorphology(mask, width, height, radius, op) {
2976
2980
  }
2977
2981
  }
2978
2982
  const result = new Uint8Array(width * height);
2979
- const r2 = r * r;
2983
+ const r2 = radiusPx * radiusPx;
2980
2984
  for (let x = 0; x < width; x++) {
2981
2985
  for (let y = 0; y < height; y++) {
2982
2986
  let found = false;
2983
- const minY = Math.max(0, y - r);
2984
- const maxY = Math.min(height - 1, y + r);
2987
+ const minY = Math.max(0, y - radiusPx);
2988
+ const maxY = Math.min(height - 1, y + radiusPx);
2985
2989
  for (let dy = minY; dy <= maxY; dy++) {
2986
2990
  const dY = dy - y;
2987
2991
  const hDist = horizontalDist[dy * width + x];
@@ -2995,23 +2999,62 @@ function circularMorphology(mask, width, height, radius, op) {
2995
2999
  }
2996
3000
  return result;
2997
3001
  };
2998
- const erode = (m, r) => {
2999
- const inverted = new Uint8Array(m.length);
3000
- for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
3001
- const dilatedInverted = dilate(inverted, r);
3002
- const result = new Uint8Array(m.length);
3003
- for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
3004
- return result;
3002
+ const erodeDiamond = (m, radiusPx) => {
3003
+ if (radiusPx <= 0) return m.slice();
3004
+ let current = m;
3005
+ for (let step = 0; step < radiusPx; step++) {
3006
+ const next = new Uint8Array(width * height);
3007
+ for (let y = 1; y < height - 1; y++) {
3008
+ const row = y * width;
3009
+ for (let x = 1; x < width - 1; x++) {
3010
+ const idx = row + x;
3011
+ if (current[idx] && current[idx - 1] && current[idx + 1] && current[idx - width] && current[idx + width]) {
3012
+ next[idx] = 1;
3013
+ }
3014
+ }
3015
+ }
3016
+ current = next;
3017
+ }
3018
+ return current;
3019
+ };
3020
+ const restoreBridgePixels = (source, eroded) => {
3021
+ const restored = eroded.slice();
3022
+ for (let y = 1; y < height - 1; y++) {
3023
+ const row = y * width;
3024
+ for (let x = 1; x < width - 1; x++) {
3025
+ const idx = row + x;
3026
+ if (!source[idx] || restored[idx]) continue;
3027
+ const up = source[idx - width] === 1;
3028
+ const down = source[idx + width] === 1;
3029
+ const left = source[idx - 1] === 1;
3030
+ const right = source[idx + 1] === 1;
3031
+ const upLeft = source[idx - width - 1] === 1;
3032
+ const upRight = source[idx - width + 1] === 1;
3033
+ const downLeft = source[idx + width - 1] === 1;
3034
+ const downRight = source[idx + width + 1] === 1;
3035
+ const keepsBridge = left && right || up && down || upLeft && downRight || upRight && downLeft;
3036
+ if (keepsBridge) {
3037
+ restored[idx] = 1;
3038
+ }
3039
+ }
3040
+ }
3041
+ return restored;
3042
+ };
3043
+ const erodePreservingBridges = (m, radiusPx) => {
3044
+ const eroded = erodeDiamond(m, radiusPx);
3045
+ return restoreBridgePixels(m, eroded);
3005
3046
  };
3006
3047
  switch (op) {
3007
3048
  case "dilate":
3008
- return dilate(mask, radius);
3049
+ return dilateDisk(mask, r);
3009
3050
  case "erode":
3010
- return erode(mask, radius);
3011
- case "closing":
3012
- return erode(dilate(mask, radius), radius);
3051
+ return erodePreservingBridges(mask, r);
3052
+ case "closing": {
3053
+ const erodeRadius = Math.max(1, Math.floor(r * 0.65));
3054
+ return erodePreservingBridges(dilateDisk(mask, r), erodeRadius);
3055
+ }
3013
3056
  case "opening":
3014
- return dilate(erode(mask, radius), radius);
3057
+ return dilateDisk(erodePreservingBridges(mask, r), r);
3015
3058
  default:
3016
3059
  return mask;
3017
3060
  }
@@ -3074,78 +3117,6 @@ function fillHoles(mask, width, height) {
3074
3117
  }
3075
3118
  return filledMask;
3076
3119
  }
3077
- function countForeground(mask) {
3078
- let c = 0;
3079
- for (let i = 0; i < mask.length; i++) c += mask[i] ? 1 : 0;
3080
- return c;
3081
- }
3082
- function isMaskConnected8(mask, width, height) {
3083
- const total = countForeground(mask);
3084
- if (total === 0) return true;
3085
- let start = -1;
3086
- for (let i = 0; i < mask.length; i++) {
3087
- if (mask[i]) {
3088
- start = i;
3089
- break;
3090
- }
3091
- }
3092
- if (start === -1) return true;
3093
- const visited = new Uint8Array(mask.length);
3094
- const queue = [start];
3095
- visited[start] = 1;
3096
- let seen = 1;
3097
- let head = 0;
3098
- while (head < queue.length) {
3099
- const idx = queue[head++];
3100
- const x = idx % width;
3101
- const y = (idx - x) / width;
3102
- for (let dy = -1; dy <= 1; dy++) {
3103
- const ny = y + dy;
3104
- if (ny < 0 || ny >= height) continue;
3105
- for (let dx = -1; dx <= 1; dx++) {
3106
- if (dx === 0 && dy === 0) continue;
3107
- const nx = x + dx;
3108
- if (nx < 0 || nx >= width) continue;
3109
- const nidx = ny * width + nx;
3110
- if (mask[nidx] && !visited[nidx]) {
3111
- visited[nidx] = 1;
3112
- queue.push(nidx);
3113
- seen++;
3114
- }
3115
- }
3116
- }
3117
- }
3118
- return seen === total;
3119
- }
3120
- function findMinimalConnectRadius(mask, width, height, maxRadius) {
3121
- if (maxRadius <= 0) return 0;
3122
- if (isMaskConnected8(mask, width, height)) return 0;
3123
- let low = 0;
3124
- let high = 1;
3125
- while (high <= maxRadius) {
3126
- const closed = circularMorphology(mask, width, height, high, "closing");
3127
- if (isMaskConnected8(closed, width, height)) break;
3128
- high *= 2;
3129
- }
3130
- if (high > maxRadius) high = maxRadius;
3131
- if (!isMaskConnected8(
3132
- circularMorphology(mask, width, height, high, "closing"),
3133
- width,
3134
- height
3135
- )) {
3136
- return high;
3137
- }
3138
- while (low + 1 < high) {
3139
- const mid = Math.floor((low + high) / 2);
3140
- const closed = circularMorphology(mask, width, height, mid, "closing");
3141
- if (isMaskConnected8(closed, width, height)) {
3142
- high = mid;
3143
- } else {
3144
- low = mid;
3145
- }
3146
- }
3147
- return high;
3148
- }
3149
3120
  function polygonSignedArea(points) {
3150
3121
  if (points.length < 3) return 0;
3151
3122
  let sum = 0;
@@ -3169,10 +3140,19 @@ var ImageTracer = class {
3169
3140
  return pathData;
3170
3141
  }
3171
3142
  static async traceWithBounds(imageUrl, options = {}) {
3172
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n;
3143
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
3173
3144
  const img = await this.loadImage(imageUrl);
3174
3145
  const width = img.width;
3175
3146
  const height = img.height;
3147
+ if (width <= 0 || height <= 0) {
3148
+ const w = (_a = options.scaleToWidth) != null ? _a : 0;
3149
+ const h = (_b = options.scaleToHeight) != null ? _b : 0;
3150
+ return {
3151
+ pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
3152
+ baseBounds: { x: 0, y: 0, width: w, height: h },
3153
+ bounds: { x: 0, y: 0, width: w, height: h }
3154
+ };
3155
+ }
3176
3156
  const debug = options.debug === true;
3177
3157
  const debugLog = (message, payload) => {
3178
3158
  if (!debug) return;
@@ -3189,96 +3169,178 @@ var ImageTracer = class {
3189
3169
  if (!ctx) throw new Error("Could not get 2D context");
3190
3170
  ctx.drawImage(img, 0, 0);
3191
3171
  const imageData = ctx.getImageData(0, 0, width, height);
3192
- const threshold = (_a = options.threshold) != null ? _a : 10;
3193
- const componentMode = (_b = options.componentMode) != null ? _b : "largest";
3194
- const minComponentArea = Math.max(0, (_c = options.minComponentArea) != null ? _c : 0);
3195
- const forceConnected = options.forceConnected === true;
3196
- const adaptiveRadius = Math.max(
3197
- 5,
3198
- Math.floor(Math.max(width, height) * 0.02)
3172
+ const threshold = (_c = options.threshold) != null ? _c : 10;
3173
+ const expand = Math.max(0, Math.floor((_d = options.expand) != null ? _d : 0));
3174
+ const simplifyTolerance = (_e = options.simplifyTolerance) != null ? _e : 2.5;
3175
+ const useSmoothing = options.smoothing !== false;
3176
+ const componentMode = "all";
3177
+ const minComponentArea = 0;
3178
+ const maxDim = Math.max(width, height);
3179
+ const maskMode = "auto";
3180
+ const whiteThreshold = 240;
3181
+ const alphaOpaqueCutoff = 250;
3182
+ const preprocessDilateRadius = Math.max(
3183
+ 2,
3184
+ Math.floor(Math.max(maxDim * 0.012, expand * 0.35))
3185
+ );
3186
+ const preprocessErodeRadius = Math.max(
3187
+ 1,
3188
+ Math.floor(preprocessDilateRadius * 0.65)
3189
+ );
3190
+ const smoothDilateRadius = Math.max(
3191
+ 1,
3192
+ Math.floor(preprocessDilateRadius * 0.25)
3193
+ );
3194
+ const smoothErodeRadius = Math.max(1, Math.floor(smoothDilateRadius * 0.8));
3195
+ const connectStartDilateRadius = Math.max(
3196
+ 1,
3197
+ Math.floor(Math.max(maxDim * 6e-3, expand * 0.2))
3199
3198
  );
3200
- const radius = (_d = options.morphologyRadius) != null ? _d : adaptiveRadius;
3201
- const expand = (_e = options.expand) != null ? _e : 0;
3202
- const noChannels = options.noChannels !== false;
3203
- const alphaOpaqueCutoff = (_f = options.alphaOpaqueCutoff) != null ? _f : 250;
3204
- const resolvedMaskMode = ((_g = options.maskMode) != null ? _g : "auto") === "auto" ? inferMaskMode(imageData, alphaOpaqueCutoff) : options.maskMode;
3205
- const alphaAnalysis = analyzeAlpha(imageData, alphaOpaqueCutoff);
3199
+ const connectMaxDilateRadius = Math.max(
3200
+ connectStartDilateRadius,
3201
+ Math.floor(Math.max(maxDim * 0.2, expand * 2.5))
3202
+ );
3203
+ const connectErodeRatio = 0.65;
3206
3204
  debugLog("traceWithBounds:start", {
3207
3205
  width,
3208
3206
  height,
3209
3207
  threshold,
3210
- radius,
3211
3208
  expand,
3212
- noChannels,
3213
- maskMode: (_h = options.maskMode) != null ? _h : "auto",
3214
- resolvedMaskMode,
3215
- alphaOpaqueCutoff,
3216
- alpha: {
3217
- minAlpha: alphaAnalysis.minAlpha,
3218
- belowOpaqueRatio: Number(alphaAnalysis.belowOpaqueRatio.toFixed(4)),
3219
- veryTransparentRatio: Number(
3220
- alphaAnalysis.veryTransparentRatio.toFixed(4)
3221
- )
3222
- },
3223
- componentMode,
3224
- minComponentArea,
3225
- forceConnected,
3226
- simplifyTolerance: (_i = options.simplifyTolerance) != null ? _i : 2.5,
3227
- smoothing: options.smoothing !== false
3209
+ simplifyTolerance,
3210
+ smoothing: useSmoothing,
3211
+ strategy: {
3212
+ maskMode,
3213
+ whiteThreshold,
3214
+ alphaOpaqueCutoff,
3215
+ fillHoles: true,
3216
+ preprocessDilateRadius,
3217
+ preprocessErodeRadius,
3218
+ smoothDilateRadius,
3219
+ smoothErodeRadius,
3220
+ connectEnabled: true,
3221
+ connectStartDilateRadius,
3222
+ connectMaxDilateRadius,
3223
+ connectErodeRatio
3224
+ }
3228
3225
  });
3229
- const padding = radius + expand + 2;
3226
+ const padding = Math.max(
3227
+ preprocessDilateRadius,
3228
+ smoothDilateRadius,
3229
+ connectMaxDilateRadius,
3230
+ expand
3231
+ ) + 2;
3230
3232
  const paddedWidth = width + padding * 2;
3231
3233
  const paddedHeight = height + padding * 2;
3234
+ const summarizeMaskContours = (m) => {
3235
+ const summary = this.summarizeAllContours(
3236
+ m,
3237
+ paddedWidth,
3238
+ paddedHeight,
3239
+ minComponentArea
3240
+ );
3241
+ return {
3242
+ rawContourCount: summary.rawCount,
3243
+ selectedContourCount: summary.selectedCount
3244
+ };
3245
+ };
3232
3246
  let mask = createMask(imageData, {
3233
3247
  threshold,
3234
3248
  padding,
3235
3249
  paddedWidth,
3236
3250
  paddedHeight,
3237
- maskMode: options.maskMode,
3238
- whiteThreshold: options.whiteThreshold,
3251
+ maskMode,
3252
+ whiteThreshold,
3239
3253
  alphaOpaqueCutoff
3240
3254
  });
3241
- if (radius > 0) {
3242
- mask = circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
3243
- }
3244
- if (noChannels) {
3245
- mask = fillHoles(mask, paddedWidth, paddedHeight);
3255
+ if (debug) {
3256
+ debugLog(
3257
+ "traceWithBounds:mask:after-create",
3258
+ summarizeMaskContours(mask)
3259
+ );
3246
3260
  }
3247
- if (radius > 0) {
3248
- const smoothRadius = Math.max(2, Math.floor(radius * 0.3));
3249
- mask = circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
3261
+ mask = circularMorphology(
3262
+ mask,
3263
+ paddedWidth,
3264
+ paddedHeight,
3265
+ preprocessDilateRadius,
3266
+ "dilate"
3267
+ );
3268
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3269
+ mask = circularMorphology(
3270
+ mask,
3271
+ paddedWidth,
3272
+ paddedHeight,
3273
+ preprocessErodeRadius,
3274
+ "erode"
3275
+ );
3276
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3277
+ if (debug) {
3278
+ debugLog("traceWithBounds:mask:after-preprocess", {
3279
+ dilateRadius: preprocessDilateRadius,
3280
+ erodeRadius: preprocessErodeRadius,
3281
+ ...summarizeMaskContours(mask)
3282
+ });
3250
3283
  }
3251
- const autoConnectRadiusMax = Math.max(
3252
- 10,
3253
- Math.floor(Math.max(width, height) * 0.12)
3284
+ mask = circularMorphology(
3285
+ mask,
3286
+ paddedWidth,
3287
+ paddedHeight,
3288
+ smoothDilateRadius,
3289
+ "dilate"
3254
3290
  );
3255
- const requestedConnectRadiusMax = options.connectRadiusMax;
3256
- const connectRadiusMax = requestedConnectRadiusMax === void 0 ? autoConnectRadiusMax : requestedConnectRadiusMax > 0 ? requestedConnectRadiusMax : forceConnected ? autoConnectRadiusMax : 0;
3257
- let rConnect = 0;
3258
- if (connectRadiusMax > 0) {
3259
- rConnect = forceConnected ? this.findMinimalMergeRadiusByContourCount(
3260
- mask,
3261
- paddedWidth,
3262
- paddedHeight,
3263
- connectRadiusMax,
3264
- minComponentArea
3265
- ) : findMinimalConnectRadius(
3291
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3292
+ mask = circularMorphology(
3293
+ mask,
3294
+ paddedWidth,
3295
+ paddedHeight,
3296
+ smoothErodeRadius,
3297
+ "erode"
3298
+ );
3299
+ mask = fillHoles(mask, paddedWidth, paddedHeight);
3300
+ if (debug) {
3301
+ debugLog("traceWithBounds:mask:after-smooth", {
3302
+ dilateRadius: smoothDilateRadius,
3303
+ erodeRadius: smoothErodeRadius,
3304
+ ...summarizeMaskContours(mask)
3305
+ });
3306
+ }
3307
+ const beforeConnectSummary = summarizeMaskContours(mask);
3308
+ if (beforeConnectSummary.selectedContourCount <= 1) {
3309
+ debugLog("traceWithBounds:mask:connect-skipped", {
3310
+ reason: "already-single-component",
3311
+ before: beforeConnectSummary
3312
+ });
3313
+ } else {
3314
+ const connectResult = this.findForceConnectResult(
3266
3315
  mask,
3267
3316
  paddedWidth,
3268
3317
  paddedHeight,
3269
- connectRadiusMax
3318
+ minComponentArea,
3319
+ connectStartDilateRadius,
3320
+ connectMaxDilateRadius,
3321
+ connectErodeRatio
3270
3322
  );
3271
- if (rConnect > 0) {
3272
- mask = circularMorphology(
3273
- mask,
3274
- paddedWidth,
3275
- paddedHeight,
3276
- rConnect,
3277
- "closing"
3278
- );
3279
- if (noChannels) {
3280
- mask = fillHoles(mask, paddedWidth, paddedHeight);
3281
- }
3323
+ if (debug) {
3324
+ debugLog("traceWithBounds:mask:after-connect", {
3325
+ before: beforeConnectSummary,
3326
+ appliedDilateRadius: connectResult.appliedDilateRadius,
3327
+ appliedErodeRadius: connectResult.appliedErodeRadius,
3328
+ reachedSingleComponent: connectResult.reachedSingleComponent,
3329
+ after: {
3330
+ rawContourCount: connectResult.rawContourCount,
3331
+ selectedContourCount: connectResult.selectedContourCount
3332
+ }
3333
+ });
3334
+ }
3335
+ mask = connectResult.mask;
3336
+ }
3337
+ if (debug) {
3338
+ const afterConnectSummary = summarizeMaskContours(mask);
3339
+ if (afterConnectSummary.selectedContourCount > 1) {
3340
+ debugLog("traceWithBounds:mask:connect-warning", {
3341
+ reason: "still-multi-component-after-connect-search",
3342
+ summary: afterConnectSummary
3343
+ });
3282
3344
  }
3283
3345
  }
3284
3346
  const baseMask = mask;
@@ -3293,8 +3355,8 @@ var ImageTracer = class {
3293
3355
  minComponentArea
3294
3356
  );
3295
3357
  if (!baseContours.length) {
3296
- const w = (_j = options.scaleToWidth) != null ? _j : width;
3297
- const h = (_k = options.scaleToHeight) != null ? _k : height;
3358
+ const w = (_f = options.scaleToWidth) != null ? _f : width;
3359
+ const h = (_g = options.scaleToHeight) != null ? _g : height;
3298
3360
  debugLog("fallback:no-base-contour", { width: w, height: h });
3299
3361
  return {
3300
3362
  pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
@@ -3313,8 +3375,8 @@ var ImageTracer = class {
3313
3375
  )
3314
3376
  ).filter((contour) => contour.length > 2);
3315
3377
  if (!baseUnpaddedContours.length) {
3316
- const w = (_l = options.scaleToWidth) != null ? _l : width;
3317
- const h = (_m = options.scaleToHeight) != null ? _m : height;
3378
+ const w = (_h = options.scaleToWidth) != null ? _h : width;
3379
+ const h = (_i = options.scaleToHeight) != null ? _i : height;
3318
3380
  debugLog("fallback:empty-base-contours", { width: w, height: h });
3319
3381
  return {
3320
3382
  pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
@@ -3359,14 +3421,10 @@ var ImageTracer = class {
3359
3421
  };
3360
3422
  }
3361
3423
  const expandedUnpaddedContours = expandedContours.map(
3362
- (contour) => this.clampPointsToImageBounds(
3363
- contour.map((p) => ({
3364
- x: p.x - padding,
3365
- y: p.y - padding
3366
- })),
3367
- width,
3368
- height
3369
- )
3424
+ (contour) => contour.map((p) => ({
3425
+ x: p.x - padding,
3426
+ y: p.y - padding
3427
+ }))
3370
3428
  ).filter((contour) => contour.length > 2);
3371
3429
  if (!expandedUnpaddedContours.length) {
3372
3430
  debugLog("fallback:empty-expanded-contours", {
@@ -3399,39 +3457,31 @@ var ImageTracer = class {
3399
3457
  options.scaleToHeight,
3400
3458
  baseBounds
3401
3459
  );
3402
- baseBounds = this.boundsFromPoints(this.flattenContours(baseScaledContours));
3460
+ baseBounds = this.boundsFromPoints(
3461
+ this.flattenContours(baseScaledContours)
3462
+ );
3403
3463
  }
3404
- const useSmoothing = options.smoothing !== false;
3405
3464
  debugLog("traceWithBounds:contours", {
3406
3465
  baseContourCount: baseContoursRaw.length,
3407
3466
  baseSelectedCount: baseContours.length,
3408
3467
  expandedContourCount: expandedContoursRaw.length,
3409
3468
  expandedSelectedCount: expandedContours.length,
3410
- connectRadiusMax,
3411
- appliedConnectRadius: rConnect,
3412
3469
  baseBounds,
3413
3470
  expandedBounds: globalBounds,
3414
3471
  expandedDeltaX: globalBounds.width - baseBounds.width,
3415
3472
  expandedDeltaY: globalBounds.height - baseBounds.height,
3473
+ expandedMayOverflowImageBounds: expand > 0,
3416
3474
  useSmoothing,
3417
3475
  componentMode
3418
3476
  });
3419
3477
  if (useSmoothing) {
3420
3478
  return {
3421
- pathData: this.contoursToSVGPaper(
3422
- finalContours,
3423
- (_n = options.simplifyTolerance) != null ? _n : 2.5
3424
- ),
3479
+ pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
3425
3480
  baseBounds,
3426
3481
  bounds: globalBounds
3427
3482
  };
3428
3483
  } else {
3429
- const simplifiedContours = finalContours.map(
3430
- (points) => {
3431
- var _a2;
3432
- return this.douglasPeucker(points, (_a2 = options.simplifyTolerance) != null ? _a2 : 2);
3433
- }
3434
- ).filter((points) => points.length > 2);
3484
+ const simplifiedContours = finalContours.map((points) => this.douglasPeucker(points, simplifyTolerance)).filter((points) => points.length > 2);
3435
3485
  const pathData = this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
3436
3486
  return {
3437
3487
  pathData,
@@ -3494,39 +3544,101 @@ var ImageTracer = class {
3494
3544
  }
3495
3545
  return selected;
3496
3546
  }
3497
- static countSelectedContours(mask, width, height, minComponentArea) {
3498
- const contours = this.traceAllContours(mask, width, height);
3499
- return this.selectContours(contours, "all", minComponentArea).length;
3500
- }
3501
- static findMinimalMergeRadiusByContourCount(mask, width, height, maxRadius, minComponentArea) {
3502
- if (maxRadius <= 0) return 0;
3503
- if (this.countSelectedContours(mask, width, height, minComponentArea) <= 1) {
3504
- return 0;
3505
- }
3506
- let low = 0;
3507
- let high = 1;
3508
- while (high <= maxRadius) {
3509
- const closed = circularMorphology(mask, width, height, high, "closing");
3510
- if (this.countSelectedContours(closed, width, height, minComponentArea) <= 1) {
3511
- break;
3512
- }
3513
- high *= 2;
3547
+ static summarizeAllContours(mask, width, height, minComponentArea) {
3548
+ const raw = this.traceAllContours(mask, width, height);
3549
+ const selected = this.selectContours(raw, "all", minComponentArea);
3550
+ return {
3551
+ rawCount: raw.length,
3552
+ selectedCount: selected.length
3553
+ };
3554
+ }
3555
+ static findForceConnectResult(sourceMask, width, height, minComponentArea, startDilateRadius, maxDilateRadius, erodeRatio) {
3556
+ const initial = this.summarizeAllContours(
3557
+ sourceMask,
3558
+ width,
3559
+ height,
3560
+ minComponentArea
3561
+ );
3562
+ if (initial.selectedCount <= 1) {
3563
+ return {
3564
+ mask: sourceMask,
3565
+ appliedDilateRadius: 0,
3566
+ appliedErodeRadius: 0,
3567
+ reachedSingleComponent: true,
3568
+ rawContourCount: initial.rawCount,
3569
+ selectedContourCount: initial.selectedCount
3570
+ };
3571
+ }
3572
+ const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
3573
+ const normalizedMax = Math.max(
3574
+ normalizedStart,
3575
+ Math.floor(maxDilateRadius)
3576
+ );
3577
+ const normalizedErodeRatio = Math.max(0, erodeRatio);
3578
+ const evaluate = (dilateRadius) => {
3579
+ const erodeRadius = Math.max(
3580
+ 1,
3581
+ Math.floor(dilateRadius * normalizedErodeRatio)
3582
+ );
3583
+ let mask = sourceMask;
3584
+ mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
3585
+ mask = fillHoles(mask, width, height);
3586
+ mask = circularMorphology(mask, width, height, erodeRadius, "erode");
3587
+ mask = fillHoles(mask, width, height);
3588
+ const summary = this.summarizeAllContours(
3589
+ mask,
3590
+ width,
3591
+ height,
3592
+ minComponentArea
3593
+ );
3594
+ return {
3595
+ dilateRadius,
3596
+ erodeRadius,
3597
+ mask,
3598
+ rawCount: summary.rawCount,
3599
+ selectedCount: summary.selectedCount
3600
+ };
3601
+ };
3602
+ let low = normalizedStart - 1;
3603
+ let high = normalizedStart;
3604
+ let highResult = evaluate(high);
3605
+ while (high < normalizedMax && highResult.selectedCount > 1) {
3606
+ low = high;
3607
+ high = Math.min(
3608
+ normalizedMax,
3609
+ Math.max(high + 1, Math.floor(high * 1.6))
3610
+ );
3611
+ highResult = evaluate(high);
3514
3612
  }
3515
- if (high > maxRadius) high = maxRadius;
3516
- const highMask = circularMorphology(mask, width, height, high, "closing");
3517
- if (this.countSelectedContours(highMask, width, height, minComponentArea) > 1) {
3518
- return high;
3613
+ if (highResult.selectedCount > 1) {
3614
+ return {
3615
+ mask: highResult.mask,
3616
+ appliedDilateRadius: highResult.dilateRadius,
3617
+ appliedErodeRadius: highResult.erodeRadius,
3618
+ reachedSingleComponent: false,
3619
+ rawContourCount: highResult.rawCount,
3620
+ selectedContourCount: highResult.selectedCount
3621
+ };
3519
3622
  }
3623
+ let best = highResult;
3520
3624
  while (low + 1 < high) {
3521
3625
  const mid = Math.floor((low + high) / 2);
3522
- const midMask = circularMorphology(mask, width, height, mid, "closing");
3523
- if (this.countSelectedContours(midMask, width, height, minComponentArea) <= 1) {
3626
+ const midResult = evaluate(mid);
3627
+ if (midResult.selectedCount <= 1) {
3628
+ best = midResult;
3524
3629
  high = mid;
3525
3630
  } else {
3526
3631
  low = mid;
3527
3632
  }
3528
3633
  }
3529
- return high;
3634
+ return {
3635
+ mask: best.mask,
3636
+ appliedDilateRadius: best.dilateRadius,
3637
+ appliedErodeRadius: best.erodeRadius,
3638
+ reachedSingleComponent: true,
3639
+ rawContourCount: best.rawCount,
3640
+ selectedContourCount: best.selectedCount
3641
+ };
3530
3642
  }
3531
3643
  static selectContours(contours, mode, minComponentArea) {
3532
3644
  if (!contours.length) return [];
@@ -3568,154 +3680,6 @@ var ImageTracer = class {
3568
3680
  height: maxY - minY
3569
3681
  };
3570
3682
  }
3571
- static createMask(imageData, threshold, padding, paddedWidth, paddedHeight) {
3572
- const { width, height, data } = imageData;
3573
- const mask = new Uint8Array(paddedWidth * paddedHeight);
3574
- let hasTransparency = false;
3575
- for (let i = 3; i < data.length; i += 4) {
3576
- if (data[i] < 255) {
3577
- hasTransparency = true;
3578
- break;
3579
- }
3580
- }
3581
- for (let y = 0; y < height; y++) {
3582
- for (let x = 0; x < width; x++) {
3583
- const srcIdx = (y * width + x) * 4;
3584
- const r = data[srcIdx];
3585
- const g = data[srcIdx + 1];
3586
- const b = data[srcIdx + 2];
3587
- const a = data[srcIdx + 3];
3588
- const destIdx = (y + padding) * paddedWidth + (x + padding);
3589
- if (hasTransparency) {
3590
- if (a > threshold) {
3591
- mask[destIdx] = 1;
3592
- }
3593
- } else {
3594
- if (!(r > 240 && g > 240 && b > 240)) {
3595
- mask[destIdx] = 1;
3596
- }
3597
- }
3598
- }
3599
- }
3600
- return mask;
3601
- }
3602
- /**
3603
- * Fast circular morphology using a distance-transform inspired separable approach.
3604
- * O(N * R) complexity, where R is the radius.
3605
- */
3606
- static circularMorphology(mask, width, height, radius, op) {
3607
- const dilate = (m, r) => {
3608
- const horizontalDist = new Int32Array(width * height);
3609
- for (let y = 0; y < height; y++) {
3610
- let lastSolid = -r * 2;
3611
- for (let x = 0; x < width; x++) {
3612
- if (m[y * width + x]) lastSolid = x;
3613
- horizontalDist[y * width + x] = x - lastSolid;
3614
- }
3615
- lastSolid = width + r * 2;
3616
- for (let x = width - 1; x >= 0; x--) {
3617
- if (m[y * width + x]) lastSolid = x;
3618
- horizontalDist[y * width + x] = Math.min(
3619
- horizontalDist[y * width + x],
3620
- lastSolid - x
3621
- );
3622
- }
3623
- }
3624
- const result = new Uint8Array(width * height);
3625
- const r2 = r * r;
3626
- for (let x = 0; x < width; x++) {
3627
- for (let y = 0; y < height; y++) {
3628
- let found = false;
3629
- const minY = Math.max(0, y - r);
3630
- const maxY = Math.min(height - 1, y + r);
3631
- for (let dy = minY; dy <= maxY; dy++) {
3632
- const dY = dy - y;
3633
- const hDist = horizontalDist[dy * width + x];
3634
- if (hDist * hDist + dY * dY <= r2) {
3635
- found = true;
3636
- break;
3637
- }
3638
- }
3639
- if (found) result[y * width + x] = 1;
3640
- }
3641
- }
3642
- return result;
3643
- };
3644
- const erode = (m, r) => {
3645
- const inverted = new Uint8Array(m.length);
3646
- for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
3647
- const dilatedInverted = dilate(inverted, r);
3648
- const result = new Uint8Array(m.length);
3649
- for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
3650
- return result;
3651
- };
3652
- switch (op) {
3653
- case "dilate":
3654
- return dilate(mask, radius);
3655
- case "erode":
3656
- return erode(mask, radius);
3657
- case "closing":
3658
- return erode(dilate(mask, radius), radius);
3659
- case "opening":
3660
- return dilate(erode(mask, radius), radius);
3661
- default:
3662
- return mask;
3663
- }
3664
- }
3665
- /**
3666
- * Fills internal holes in the binary mask using flood fill from edges.
3667
- */
3668
- static fillHoles(mask, width, height) {
3669
- const background = new Uint8Array(width * height);
3670
- const queue = [];
3671
- for (let x = 0; x < width; x++) {
3672
- if (mask[x] === 0) {
3673
- background[x] = 1;
3674
- queue.push([x, 0]);
3675
- }
3676
- const lastRow = (height - 1) * width + x;
3677
- if (mask[lastRow] === 0) {
3678
- background[lastRow] = 1;
3679
- queue.push([x, height - 1]);
3680
- }
3681
- }
3682
- for (let y = 1; y < height - 1; y++) {
3683
- if (mask[y * width] === 0) {
3684
- background[y * width] = 1;
3685
- queue.push([0, y]);
3686
- }
3687
- if (mask[y * width + width - 1] === 0) {
3688
- background[y * width + width - 1] = 1;
3689
- queue.push([width - 1, y]);
3690
- }
3691
- }
3692
- const dirs = [
3693
- [0, 1],
3694
- [0, -1],
3695
- [1, 0],
3696
- [-1, 0]
3697
- ];
3698
- let head = 0;
3699
- while (head < queue.length) {
3700
- const [cx, cy] = queue[head++];
3701
- for (const [dx, dy] of dirs) {
3702
- const nx = cx + dx;
3703
- const ny = cy + dy;
3704
- if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
3705
- const nidx = ny * width + nx;
3706
- if (mask[nidx] === 0 && background[nidx] === 0) {
3707
- background[nidx] = 1;
3708
- queue.push([nx, ny]);
3709
- }
3710
- }
3711
- }
3712
- }
3713
- const filledMask = new Uint8Array(width * height);
3714
- for (let i = 0; i < width * height; i++) {
3715
- filledMask[i] = background[i] === 0 ? 1 : 0;
3716
- }
3717
- return filledMask;
3718
- }
3719
3683
  /**
3720
3684
  * Traces all contours in the mask with optimized start-point detection
3721
3685
  */
@@ -4252,10 +4216,17 @@ var DielineTool = class {
4252
4216
  command: "detectEdge",
4253
4217
  title: "Detect Edge from Image",
4254
4218
  handler: async (imageUrl, options) => {
4255
- var _a;
4219
+ var _a, _b, _c;
4256
4220
  try {
4257
4221
  const detectOptions = options || {};
4258
4222
  const debug = detectOptions.debug === true;
4223
+ const tracerOptions = {
4224
+ expand: (_a = detectOptions.expand) != null ? _a : 0,
4225
+ smoothing: (_b = detectOptions.smoothing) != null ? _b : true,
4226
+ simplifyTolerance: (_c = detectOptions.simplifyTolerance) != null ? _c : 2,
4227
+ threshold: detectOptions.threshold,
4228
+ debug
4229
+ };
4259
4230
  const loadImage = (url) => {
4260
4231
  return new Promise((resolve, reject) => {
4261
4232
  const img2 = new Image();
@@ -4267,7 +4238,7 @@ var DielineTool = class {
4267
4238
  };
4268
4239
  const [img, traced] = await Promise.all([
4269
4240
  loadImage(imageUrl),
4270
- ImageTracer.traceWithBounds(imageUrl, detectOptions)
4241
+ ImageTracer.traceWithBounds(imageUrl, tracerOptions)
4271
4242
  ]);
4272
4243
  const { pathData, baseBounds, bounds } = traced;
4273
4244
  if (debug) {
@@ -4278,21 +4249,8 @@ var DielineTool = class {
4278
4249
  expandedBounds: bounds,
4279
4250
  currentDielineWidth: s.width,
4280
4251
  currentDielineHeight: s.height,
4281
- options: {
4282
- expand: (_a = detectOptions.expand) != null ? _a : 0,
4283
- morphologyRadius: detectOptions.morphologyRadius,
4284
- connectRadiusMax: detectOptions.connectRadiusMax,
4285
- smoothing: detectOptions.smoothing,
4286
- simplifyTolerance: detectOptions.simplifyTolerance,
4287
- threshold: detectOptions.threshold,
4288
- maskMode: detectOptions.maskMode,
4289
- whiteThreshold: detectOptions.whiteThreshold,
4290
- alphaOpaqueCutoff: detectOptions.alphaOpaqueCutoff,
4291
- noChannels: detectOptions.noChannels,
4292
- componentMode: detectOptions.componentMode,
4293
- minComponentArea: detectOptions.minComponentArea,
4294
- forceConnected: detectOptions.forceConnected
4295
- }
4252
+ options: tracerOptions,
4253
+ strategy: "single-connected-silhouette"
4296
4254
  });
4297
4255
  }
4298
4256
  return {
@@ -7009,6 +6967,82 @@ var WhiteInkTool = class {
7009
6967
  height
7010
6968
  };
7011
6969
  }
6970
+ getImagePlacementState(id) {
6971
+ const rawItems = this.getConfig("image.items", []);
6972
+ if (!Array.isArray(rawItems) || rawItems.length === 0) return null;
6973
+ const matched = (id ? rawItems.find(
6974
+ (item) => item && typeof item === "object" && typeof item.id === "string" && item.id === id
6975
+ ) : void 0) || rawItems[0];
6976
+ if (!matched || typeof matched !== "object") return null;
6977
+ const sourceUrl = typeof matched.sourceUrl === "string" && matched.sourceUrl.length > 0 ? matched.sourceUrl : typeof matched.url === "string" ? matched.url : "";
6978
+ const committedUrl = typeof matched.committedUrl === "string" ? matched.committedUrl : "";
6979
+ return {
6980
+ id: typeof matched.id === "string" && matched.id.length > 0 ? matched.id : id || "image",
6981
+ sourceUrl,
6982
+ committedUrl,
6983
+ left: Number.isFinite(matched.left) ? Number(matched.left) : 0.5,
6984
+ top: Number.isFinite(matched.top) ? Number(matched.top) : 0.5,
6985
+ scale: Number.isFinite(matched.scale) ? Math.max(0.05, matched.scale) : 1,
6986
+ angle: Number.isFinite(matched.angle) ? matched.angle : 0
6987
+ };
6988
+ }
6989
+ shouldRestoreSnapshotToSource(snapshot, placement) {
6990
+ if (!placement.sourceUrl || !placement.committedUrl) return false;
6991
+ if (placement.sourceUrl === placement.committedUrl) return false;
6992
+ return snapshot.src === placement.committedUrl;
6993
+ }
6994
+ getCoverScale(frame, source) {
6995
+ const frameW = Math.max(1, frame.width);
6996
+ const frameH = Math.max(1, frame.height);
6997
+ const sourceW = Math.max(1, source.width);
6998
+ const sourceH = Math.max(1, source.height);
6999
+ return Math.max(frameW / sourceW, frameH / sourceH);
7000
+ }
7001
+ async ensureSourceSize(sourceUrl) {
7002
+ if (!sourceUrl) return null;
7003
+ const cached = this.getSourceSize(sourceUrl);
7004
+ if (cached) return cached;
7005
+ try {
7006
+ const image = await this.loadImageElement(sourceUrl);
7007
+ const size = this.getElementSize(image);
7008
+ if (!size) return null;
7009
+ this.rememberSourceSize(sourceUrl, size);
7010
+ return {
7011
+ width: size.width,
7012
+ height: size.height
7013
+ };
7014
+ } catch (e) {
7015
+ return null;
7016
+ }
7017
+ }
7018
+ async resolveAlignedImageSnapshot(snapshot) {
7019
+ const placement = this.getImagePlacementState(snapshot.id);
7020
+ if (!placement) return snapshot;
7021
+ if (!this.shouldRestoreSnapshotToSource(snapshot, placement)) {
7022
+ return snapshot;
7023
+ }
7024
+ const frame = this.getFrameRect();
7025
+ if (frame.width <= 0 || frame.height <= 0) {
7026
+ return snapshot;
7027
+ }
7028
+ const sourceSize = await this.ensureSourceSize(placement.sourceUrl);
7029
+ if (!sourceSize) return snapshot;
7030
+ const coverScale = this.getCoverScale(frame, sourceSize);
7031
+ return {
7032
+ ...snapshot,
7033
+ src: placement.sourceUrl,
7034
+ element: void 0,
7035
+ left: frame.left + placement.left * frame.width,
7036
+ top: frame.top + placement.top * frame.height,
7037
+ scaleX: coverScale * placement.scale,
7038
+ scaleY: coverScale * placement.scale,
7039
+ angle: placement.angle,
7040
+ originX: "center",
7041
+ originY: "center",
7042
+ width: sourceSize.width,
7043
+ height: sourceSize.height
7044
+ };
7045
+ }
7012
7046
  getImageElementFromObject(obj) {
7013
7047
  if (!obj) return null;
7014
7048
  if (typeof obj.getElement === "function") {
@@ -7381,9 +7415,11 @@ var WhiteInkTool = class {
7381
7415
  let whiteSpecs = [];
7382
7416
  let coverSpecs = [];
7383
7417
  if (previewActive) {
7384
- const snapshot = this.getImageSnapshot(this.getPrimaryImageObject());
7418
+ const baseSnapshot = this.getImageSnapshot(this.getPrimaryImageObject());
7385
7419
  const item = this.getEffectiveWhiteInkItem(this.resolveRenderItems());
7386
- if (snapshot && item) {
7420
+ if (baseSnapshot && item) {
7421
+ const snapshot = await this.resolveAlignedImageSnapshot(baseSnapshot);
7422
+ if (seq !== this.renderSeq) return;
7387
7423
  const sources = await this.resolveRenderSources(snapshot, item);
7388
7424
  if (seq !== this.renderSeq) return;
7389
7425
  if (sources == null ? void 0 : sources.whiteSrc) {