@pooder/kit 5.1.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 +12 -0
  52. package/dist/index.d.mts +14 -0
  53. package/dist/index.d.ts +14 -0
  54. package/dist/index.js +3521 -3220
  55. package/dist/index.mjs +3532 -3226
  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 -1594
  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
@@ -1,104 +1,104 @@
1
- export type MaskMode = "auto" | "alpha" | "whitebg";
2
-
3
- export interface CreateMaskOptions {
4
- threshold: number;
5
- padding: number;
6
- paddedWidth: number;
7
- paddedHeight: number;
8
- maskMode?: MaskMode;
9
- whiteThreshold?: number;
10
- alphaOpaqueCutoff?: number;
11
- }
12
-
13
- export interface AlphaAnalysis {
14
- total: number;
15
- minAlpha: number;
16
- belowOpaqueRatio: number;
17
- veryTransparentRatio: number;
18
- }
19
-
20
- export function createMask(
21
- imageData: ImageData,
22
- options: CreateMaskOptions,
23
- ): Uint8Array {
24
- const { width, height, data } = imageData;
25
- const {
26
- threshold,
27
- padding,
28
- paddedWidth,
29
- paddedHeight,
30
- maskMode = "auto",
31
- whiteThreshold = 240,
32
- alphaOpaqueCutoff = 250,
33
- } = options;
34
-
35
- const resolvedMode =
36
- maskMode === "auto" ? inferMaskMode(imageData, alphaOpaqueCutoff) : maskMode;
37
-
38
- const mask = new Uint8Array(paddedWidth * paddedHeight);
39
-
40
- for (let y = 0; y < height; y++) {
41
- for (let x = 0; x < width; x++) {
42
- const srcIdx = (y * width + x) * 4;
43
- const r = data[srcIdx];
44
- const g = data[srcIdx + 1];
45
- const b = data[srcIdx + 2];
46
- const a = data[srcIdx + 3];
47
- const destIdx = (y + padding) * paddedWidth + (x + padding);
48
-
49
- if (resolvedMode === "alpha") {
50
- if (a > threshold) mask[destIdx] = 1;
51
- } else {
52
- if (
53
- a > threshold &&
54
- !(r > whiteThreshold && g > whiteThreshold && b > whiteThreshold)
55
- ) {
56
- mask[destIdx] = 1;
57
- }
58
- }
59
- }
60
- }
61
-
62
- return mask;
63
- }
64
-
65
- export function inferMaskMode(
66
- imageData: ImageData,
67
- alphaOpaqueCutoff: number,
68
- ): MaskMode {
69
- const analysis = analyzeAlpha(imageData, alphaOpaqueCutoff);
70
- if (analysis.minAlpha === 255) return "whitebg";
71
- if (analysis.veryTransparentRatio >= 0.0005) return "alpha";
72
- if (analysis.belowOpaqueRatio >= 0.01) return "alpha";
73
- return "whitebg";
74
- }
75
-
76
- export function analyzeAlpha(
77
- imageData: ImageData,
78
- alphaOpaqueCutoff: number,
79
- ): AlphaAnalysis {
80
- const { data } = imageData;
81
- const total = data.length / 4;
82
-
83
- let belowOpaque = 0;
84
- let veryTransparent = 0;
85
- let minAlpha = 255;
86
-
87
- for (let i = 3; i < data.length; i += 4) {
88
- const a = data[i];
89
- if (a < minAlpha) minAlpha = a;
90
- if (a < alphaOpaqueCutoff) belowOpaque++;
91
- if (a < 32) veryTransparent++;
92
- }
93
-
94
- return {
95
- total,
96
- minAlpha,
97
- belowOpaqueRatio: belowOpaque / total,
98
- veryTransparentRatio: veryTransparent / total,
99
- };
100
- }
101
-
1
+ export type MaskMode = "auto" | "alpha" | "whitebg";
2
+
3
+ export interface CreateMaskOptions {
4
+ threshold: number;
5
+ padding: number;
6
+ paddedWidth: number;
7
+ paddedHeight: number;
8
+ maskMode?: MaskMode;
9
+ whiteThreshold?: number;
10
+ alphaOpaqueCutoff?: number;
11
+ }
12
+
13
+ export interface AlphaAnalysis {
14
+ total: number;
15
+ minAlpha: number;
16
+ belowOpaqueRatio: number;
17
+ veryTransparentRatio: number;
18
+ }
19
+
20
+ export function createMask(
21
+ imageData: ImageData,
22
+ options: CreateMaskOptions,
23
+ ): Uint8Array {
24
+ const { width, height, data } = imageData;
25
+ const {
26
+ threshold,
27
+ padding,
28
+ paddedWidth,
29
+ paddedHeight,
30
+ maskMode = "auto",
31
+ whiteThreshold = 240,
32
+ alphaOpaqueCutoff = 250,
33
+ } = options;
34
+
35
+ const resolvedMode =
36
+ maskMode === "auto" ? inferMaskMode(imageData, alphaOpaqueCutoff) : maskMode;
37
+
38
+ const mask = new Uint8Array(paddedWidth * paddedHeight);
39
+
40
+ for (let y = 0; y < height; y++) {
41
+ for (let x = 0; x < width; x++) {
42
+ const srcIdx = (y * width + x) * 4;
43
+ const r = data[srcIdx];
44
+ const g = data[srcIdx + 1];
45
+ const b = data[srcIdx + 2];
46
+ const a = data[srcIdx + 3];
47
+ const destIdx = (y + padding) * paddedWidth + (x + padding);
48
+
49
+ if (resolvedMode === "alpha") {
50
+ if (a > threshold) mask[destIdx] = 1;
51
+ } else {
52
+ if (
53
+ a > threshold &&
54
+ !(r > whiteThreshold && g > whiteThreshold && b > whiteThreshold)
55
+ ) {
56
+ mask[destIdx] = 1;
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ return mask;
63
+ }
64
+
65
+ export function inferMaskMode(
66
+ imageData: ImageData,
67
+ alphaOpaqueCutoff: number,
68
+ ): MaskMode {
69
+ const analysis = analyzeAlpha(imageData, alphaOpaqueCutoff);
70
+ if (analysis.minAlpha === 255) return "whitebg";
71
+ if (analysis.veryTransparentRatio >= 0.0005) return "alpha";
72
+ if (analysis.belowOpaqueRatio >= 0.01) return "alpha";
73
+ return "whitebg";
74
+ }
75
+
76
+ export function analyzeAlpha(
77
+ imageData: ImageData,
78
+ alphaOpaqueCutoff: number,
79
+ ): AlphaAnalysis {
80
+ const { data } = imageData;
81
+ const total = data.length / 4;
82
+
83
+ let belowOpaque = 0;
84
+ let veryTransparent = 0;
85
+ let minAlpha = 255;
86
+
87
+ for (let i = 3; i < data.length; i += 4) {
88
+ const a = data[i];
89
+ if (a < minAlpha) minAlpha = a;
90
+ if (a < alphaOpaqueCutoff) belowOpaque++;
91
+ if (a < 32) veryTransparent++;
92
+ }
93
+
94
+ return {
95
+ total,
96
+ minAlpha,
97
+ belowOpaqueRatio: belowOpaque / total,
98
+ veryTransparentRatio: veryTransparent / total,
99
+ };
100
+ }
101
+
102
102
  export function circularMorphology(
103
103
  mask: Uint8Array,
104
104
  width: number,
@@ -106,239 +106,305 @@ export function circularMorphology(
106
106
  radius: number,
107
107
  op: "dilate" | "erode" | "closing" | "opening",
108
108
  ): Uint8Array {
109
- const dilate = (m: Uint8Array, r: number) => {
109
+ const r = Math.max(0, Math.floor(radius));
110
+ if (r <= 0) {
111
+ return mask.slice();
112
+ }
113
+
114
+ // Disk kernel dilation (Euclidean metric).
115
+ const dilateDisk = (m: Uint8Array, radiusPx: number) => {
110
116
  const horizontalDist = new Int32Array(width * height);
111
117
  for (let y = 0; y < height; y++) {
112
- let lastSolid = -r * 2;
118
+ let lastSolid = -radiusPx * 2;
113
119
  for (let x = 0; x < width; x++) {
114
120
  if (m[y * width + x]) lastSolid = x;
115
121
  horizontalDist[y * width + x] = x - lastSolid;
116
122
  }
117
- lastSolid = width + r * 2;
123
+ lastSolid = width + radiusPx * 2;
118
124
  for (let x = width - 1; x >= 0; x--) {
119
125
  if (m[y * width + x]) lastSolid = x;
120
126
  horizontalDist[y * width + x] = Math.min(
121
127
  horizontalDist[y * width + x],
122
128
  lastSolid - x,
123
- );
124
- }
129
+ );
130
+ }
125
131
  }
126
132
 
127
133
  const result = new Uint8Array(width * height);
128
- const r2 = r * r;
134
+ const r2 = radiusPx * radiusPx;
129
135
  for (let x = 0; x < width; x++) {
130
136
  for (let y = 0; y < height; y++) {
131
137
  let found = false;
132
- const minY = Math.max(0, y - r);
133
- const maxY = Math.min(height - 1, y + r);
138
+ const minY = Math.max(0, y - radiusPx);
139
+ const maxY = Math.min(height - 1, y + radiusPx);
134
140
  for (let dy = minY; dy <= maxY; dy++) {
135
141
  const dY = dy - y;
136
142
  const hDist = horizontalDist[dy * width + x];
137
143
  if (hDist * hDist + dY * dY <= r2) {
138
144
  found = true;
139
- break;
140
- }
141
- }
142
- if (found) result[y * width + x] = 1;
143
- }
145
+ break;
146
+ }
147
+ }
148
+ if (found) result[y * width + x] = 1;
149
+ }
144
150
  }
145
151
  return result;
146
152
  };
147
153
 
148
- const erode = (m: Uint8Array, r: number) => {
149
- const inverted = new Uint8Array(m.length);
150
- for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
151
- const dilatedInverted = dilate(inverted, r);
152
- const result = new Uint8Array(m.length);
153
- for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
154
- return result;
155
- };
156
-
157
- switch (op) {
158
- case "dilate":
159
- return dilate(mask, radius);
160
- case "erode":
161
- return erode(mask, radius);
162
- case "closing":
163
- return erode(dilate(mask, radius), radius);
164
- case "opening":
165
- return dilate(erode(mask, radius), radius);
166
- default:
167
- return mask;
168
- }
169
- }
170
-
171
- export function fillHoles(
172
- mask: Uint8Array,
173
- width: number,
174
- height: number,
175
- ): Uint8Array {
176
- const background = new Uint8Array(width * height);
177
- const queue: number[] = [];
178
-
179
- for (let x = 0; x < width; x++) {
180
- if (mask[x] === 0) {
181
- background[x] = 1;
182
- queue.push(x);
183
- }
184
- const lastRowIdx = (height - 1) * width + x;
185
- if (mask[lastRowIdx] === 0) {
186
- background[lastRowIdx] = 1;
187
- queue.push(lastRowIdx);
188
- }
189
- }
190
- for (let y = 1; y < height - 1; y++) {
191
- const leftIdx = y * width;
192
- const rightIdx = y * width + (width - 1);
193
- if (mask[leftIdx] === 0) {
194
- background[leftIdx] = 1;
195
- queue.push(leftIdx);
196
- }
197
- if (mask[rightIdx] === 0) {
198
- background[rightIdx] = 1;
199
- queue.push(rightIdx);
200
- }
201
- }
202
-
203
- let head = 0;
204
- while (head < queue.length) {
205
- const idx = queue[head++];
206
- const x = idx % width;
207
- const y = (idx - x) / width;
208
-
209
- const up = y > 0 ? idx - width : -1;
210
- const down = y < height - 1 ? idx + width : -1;
211
- const left = x > 0 ? idx - 1 : -1;
212
- const right = x < width - 1 ? idx + 1 : -1;
213
-
214
- if (up >= 0 && mask[up] === 0 && background[up] === 0) {
215
- background[up] = 1;
216
- queue.push(up);
217
- }
218
- if (down >= 0 && mask[down] === 0 && background[down] === 0) {
219
- background[down] = 1;
220
- queue.push(down);
221
- }
222
- if (left >= 0 && mask[left] === 0 && background[left] === 0) {
223
- background[left] = 1;
224
- queue.push(left);
225
- }
226
- if (right >= 0 && mask[right] === 0 && background[right] === 0) {
227
- background[right] = 1;
228
- queue.push(right);
229
- }
230
- }
231
-
232
- const filledMask = new Uint8Array(width * height);
233
- for (let i = 0; i < width * height; i++) {
234
- filledMask[i] = background[i] === 0 ? 1 : 0;
235
- }
236
-
237
- return filledMask;
238
- }
239
-
240
- export function countForeground(mask: Uint8Array): number {
241
- let c = 0;
242
- for (let i = 0; i < mask.length; i++) c += mask[i] ? 1 : 0;
243
- return c;
244
- }
245
-
246
- export function isMaskConnected8(
247
- mask: Uint8Array,
248
- width: number,
249
- height: number,
250
- ): boolean {
251
- const total = countForeground(mask);
252
- if (total === 0) return true;
253
-
254
- let start = -1;
255
- for (let i = 0; i < mask.length; i++) {
256
- if (mask[i]) {
257
- start = i;
258
- break;
154
+ // Diamond kernel erosion (L1 metric), implemented as radius iterations of
155
+ // 4-neighbor erosion. This is intentionally different from dilation kernel.
156
+ const erodeDiamond = (m: Uint8Array, radiusPx: number) => {
157
+ if (radiusPx <= 0) return m.slice();
158
+
159
+ let current = m;
160
+ for (let step = 0; step < radiusPx; step++) {
161
+ const next = new Uint8Array(width * height);
162
+ for (let y = 1; y < height - 1; y++) {
163
+ const row = y * width;
164
+ for (let x = 1; x < width - 1; x++) {
165
+ const idx = row + x;
166
+ if (
167
+ current[idx] &&
168
+ current[idx - 1] &&
169
+ current[idx + 1] &&
170
+ current[idx - width] &&
171
+ current[idx + width]
172
+ ) {
173
+ next[idx] = 1;
174
+ }
175
+ }
176
+ }
177
+ current = next;
259
178
  }
260
- }
261
- if (start === -1) return true;
262
179
 
263
- const visited = new Uint8Array(mask.length);
264
- const queue: number[] = [start];
265
- visited[start] = 1;
266
- let seen = 1;
267
-
268
- let head = 0;
269
- while (head < queue.length) {
270
- const idx = queue[head++];
271
- const x = idx % width;
272
- const y = (idx - x) / width;
180
+ return current;
181
+ };
273
182
 
274
- for (let dy = -1; dy <= 1; dy++) {
275
- const ny = y + dy;
276
- if (ny < 0 || ny >= height) continue;
277
- for (let dx = -1; dx <= 1; dx++) {
278
- if (dx === 0 && dy === 0) continue;
279
- const nx = x + dx;
280
- if (nx < 0 || nx >= width) continue;
281
- const nidx = ny * width + nx;
282
- if (mask[nidx] && !visited[nidx]) {
283
- visited[nidx] = 1;
284
- queue.push(nidx);
285
- seen++;
183
+ // Restore thin bridges removed by erosion: if a removed pixel links two
184
+ // opposite neighbors in the source mask, bring it back.
185
+ const restoreBridgePixels = (source: Uint8Array, eroded: Uint8Array) => {
186
+ const restored = eroded.slice();
187
+ for (let y = 1; y < height - 1; y++) {
188
+ const row = y * width;
189
+ for (let x = 1; x < width - 1; x++) {
190
+ const idx = row + x;
191
+ if (!source[idx] || restored[idx]) continue;
192
+
193
+ const up = source[idx - width] === 1;
194
+ const down = source[idx + width] === 1;
195
+ const left = source[idx - 1] === 1;
196
+ const right = source[idx + 1] === 1;
197
+ const upLeft = source[idx - width - 1] === 1;
198
+ const upRight = source[idx - width + 1] === 1;
199
+ const downLeft = source[idx + width - 1] === 1;
200
+ const downRight = source[idx + width + 1] === 1;
201
+
202
+ const keepsBridge =
203
+ (left && right) ||
204
+ (up && down) ||
205
+ (upLeft && downRight) ||
206
+ (upRight && downLeft);
207
+ if (keepsBridge) {
208
+ restored[idx] = 1;
286
209
  }
287
210
  }
288
211
  }
289
- }
290
-
291
- return seen === total;
292
- }
293
212
 
294
- export function findMinimalConnectRadius(
295
- mask: Uint8Array,
296
- width: number,
297
- height: number,
298
- maxRadius: number,
299
- ): number {
300
- if (maxRadius <= 0) return 0;
301
- if (isMaskConnected8(mask, width, height)) return 0;
302
-
303
- let low = 0;
304
- let high = 1;
305
- while (high <= maxRadius) {
306
- const closed = circularMorphology(mask, width, height, high, "closing");
307
- if (isMaskConnected8(closed, width, height)) break;
308
- high *= 2;
309
- }
310
- if (high > maxRadius) high = maxRadius;
213
+ return restored;
214
+ };
311
215
 
312
- if (
313
- !isMaskConnected8(
314
- circularMorphology(mask, width, height, high, "closing"),
315
- width,
316
- height,
317
- )
318
- ) {
319
- return high;
320
- }
216
+ const erodePreservingBridges = (m: Uint8Array, radiusPx: number) => {
217
+ const eroded = erodeDiamond(m, radiusPx);
218
+ return restoreBridgePixels(m, eroded);
219
+ };
321
220
 
322
- while (low + 1 < high) {
323
- const mid = Math.floor((low + high) / 2);
324
- const closed = circularMorphology(mask, width, height, mid, "closing");
325
- if (isMaskConnected8(closed, width, height)) {
326
- high = mid;
327
- } else {
328
- low = mid;
221
+ switch (op) {
222
+ case "dilate":
223
+ return dilateDisk(mask, r);
224
+ case "erode":
225
+ return erodePreservingBridges(mask, r);
226
+ case "closing": {
227
+ const erodeRadius = Math.max(1, Math.floor(r * 0.65));
228
+ return erodePreservingBridges(dilateDisk(mask, r), erodeRadius);
329
229
  }
230
+ case "opening":
231
+ return dilateDisk(erodePreservingBridges(mask, r), r);
232
+ default:
233
+ return mask;
330
234
  }
331
-
332
- return high;
333
- }
334
-
335
- export function polygonSignedArea(points: Array<{ x: number; y: number }>): number {
336
- if (points.length < 3) return 0;
337
- let sum = 0;
338
- for (let i = 0; i < points.length; i++) {
339
- const a = points[i];
340
- const b = points[(i + 1) % points.length];
341
- sum += a.x * b.y - b.x * a.y;
342
- }
343
- return sum / 2;
344
235
  }
236
+
237
+ export function fillHoles(
238
+ mask: Uint8Array,
239
+ width: number,
240
+ height: number,
241
+ ): Uint8Array {
242
+ const background = new Uint8Array(width * height);
243
+ const queue: number[] = [];
244
+
245
+ for (let x = 0; x < width; x++) {
246
+ if (mask[x] === 0) {
247
+ background[x] = 1;
248
+ queue.push(x);
249
+ }
250
+ const lastRowIdx = (height - 1) * width + x;
251
+ if (mask[lastRowIdx] === 0) {
252
+ background[lastRowIdx] = 1;
253
+ queue.push(lastRowIdx);
254
+ }
255
+ }
256
+ for (let y = 1; y < height - 1; y++) {
257
+ const leftIdx = y * width;
258
+ const rightIdx = y * width + (width - 1);
259
+ if (mask[leftIdx] === 0) {
260
+ background[leftIdx] = 1;
261
+ queue.push(leftIdx);
262
+ }
263
+ if (mask[rightIdx] === 0) {
264
+ background[rightIdx] = 1;
265
+ queue.push(rightIdx);
266
+ }
267
+ }
268
+
269
+ let head = 0;
270
+ while (head < queue.length) {
271
+ const idx = queue[head++];
272
+ const x = idx % width;
273
+ const y = (idx - x) / width;
274
+
275
+ const up = y > 0 ? idx - width : -1;
276
+ const down = y < height - 1 ? idx + width : -1;
277
+ const left = x > 0 ? idx - 1 : -1;
278
+ const right = x < width - 1 ? idx + 1 : -1;
279
+
280
+ if (up >= 0 && mask[up] === 0 && background[up] === 0) {
281
+ background[up] = 1;
282
+ queue.push(up);
283
+ }
284
+ if (down >= 0 && mask[down] === 0 && background[down] === 0) {
285
+ background[down] = 1;
286
+ queue.push(down);
287
+ }
288
+ if (left >= 0 && mask[left] === 0 && background[left] === 0) {
289
+ background[left] = 1;
290
+ queue.push(left);
291
+ }
292
+ if (right >= 0 && mask[right] === 0 && background[right] === 0) {
293
+ background[right] = 1;
294
+ queue.push(right);
295
+ }
296
+ }
297
+
298
+ const filledMask = new Uint8Array(width * height);
299
+ for (let i = 0; i < width * height; i++) {
300
+ filledMask[i] = background[i] === 0 ? 1 : 0;
301
+ }
302
+
303
+ return filledMask;
304
+ }
305
+
306
+ export function countForeground(mask: Uint8Array): number {
307
+ let c = 0;
308
+ for (let i = 0; i < mask.length; i++) c += mask[i] ? 1 : 0;
309
+ return c;
310
+ }
311
+
312
+ export function isMaskConnected8(
313
+ mask: Uint8Array,
314
+ width: number,
315
+ height: number,
316
+ ): boolean {
317
+ const total = countForeground(mask);
318
+ if (total === 0) return true;
319
+
320
+ let start = -1;
321
+ for (let i = 0; i < mask.length; i++) {
322
+ if (mask[i]) {
323
+ start = i;
324
+ break;
325
+ }
326
+ }
327
+ if (start === -1) return true;
328
+
329
+ const visited = new Uint8Array(mask.length);
330
+ const queue: number[] = [start];
331
+ visited[start] = 1;
332
+ let seen = 1;
333
+
334
+ let head = 0;
335
+ while (head < queue.length) {
336
+ const idx = queue[head++];
337
+ const x = idx % width;
338
+ const y = (idx - x) / width;
339
+
340
+ for (let dy = -1; dy <= 1; dy++) {
341
+ const ny = y + dy;
342
+ if (ny < 0 || ny >= height) continue;
343
+ for (let dx = -1; dx <= 1; dx++) {
344
+ if (dx === 0 && dy === 0) continue;
345
+ const nx = x + dx;
346
+ if (nx < 0 || nx >= width) continue;
347
+ const nidx = ny * width + nx;
348
+ if (mask[nidx] && !visited[nidx]) {
349
+ visited[nidx] = 1;
350
+ queue.push(nidx);
351
+ seen++;
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ return seen === total;
358
+ }
359
+
360
+ export function findMinimalConnectRadius(
361
+ mask: Uint8Array,
362
+ width: number,
363
+ height: number,
364
+ maxRadius: number,
365
+ ): number {
366
+ if (maxRadius <= 0) return 0;
367
+ if (isMaskConnected8(mask, width, height)) return 0;
368
+
369
+ let low = 0;
370
+ let high = 1;
371
+ while (high <= maxRadius) {
372
+ const closed = circularMorphology(mask, width, height, high, "closing");
373
+ if (isMaskConnected8(closed, width, height)) break;
374
+ high *= 2;
375
+ }
376
+ if (high > maxRadius) high = maxRadius;
377
+
378
+ if (
379
+ !isMaskConnected8(
380
+ circularMorphology(mask, width, height, high, "closing"),
381
+ width,
382
+ height,
383
+ )
384
+ ) {
385
+ return high;
386
+ }
387
+
388
+ while (low + 1 < high) {
389
+ const mid = Math.floor((low + high) / 2);
390
+ const closed = circularMorphology(mask, width, height, mid, "closing");
391
+ if (isMaskConnected8(closed, width, height)) {
392
+ high = mid;
393
+ } else {
394
+ low = mid;
395
+ }
396
+ }
397
+
398
+ return high;
399
+ }
400
+
401
+ export function polygonSignedArea(points: Array<{ x: number; y: number }>): number {
402
+ if (points.length < 3) return 0;
403
+ let sum = 0;
404
+ for (let i = 0; i < points.length; i++) {
405
+ const a = points[i];
406
+ const b = points[(i + 1) % points.length];
407
+ sum += a.x * b.y - b.x * a.y;
408
+ }
409
+ return sum / 2;
410
+ }