altium-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,852 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Shared raster and contour helpers for PCB outline recovery.
7
+ */
8
+ export class PcbOutlineRasterizer {
9
+ /**
10
+ * Resolves one raster cell size for mechanical boundary recovery.
11
+ * @param {{ widthMil: number, heightMil: number }} bounds
12
+ * @returns {number}
13
+ */
14
+ static resolveRasterResolution(bounds) {
15
+ const longestAxis = Math.max(bounds.widthMil, bounds.heightMil, 1)
16
+
17
+ return Math.max(Math.ceil(longestAxis / 280), 4)
18
+ }
19
+
20
+ /**
21
+ * Resolves one higher-fidelity raster cell size for closing an authored
22
+ * route contour into a board silhouette.
23
+ * @param {{ widthMil: number, heightMil: number }} bounds
24
+ * @returns {number}
25
+ */
26
+ static resolveSilhouetteResolution(bounds) {
27
+ const longestAxis = Math.max(bounds.widthMil, bounds.heightMil, 1)
28
+
29
+ return Math.max(Math.ceil(longestAxis / 900), 4)
30
+ }
31
+
32
+ /**
33
+ * Draws one closed outline segment family into a raster mask.
34
+ * @param {Array<Record<string, number | string>>} segments
35
+ * @param {number} rasterWidth
36
+ * @param {number} rasterHeight
37
+ * @param {number} resolutionMil
38
+ * @param {number} originX
39
+ * @param {number} originY
40
+ * @returns {Uint8Array}
41
+ */
42
+ static drawOutlineMask(
43
+ segments,
44
+ rasterWidth,
45
+ rasterHeight,
46
+ resolutionMil,
47
+ originX,
48
+ originY
49
+ ) {
50
+ const mask = new Uint8Array(rasterWidth * rasterHeight)
51
+
52
+ for (const segment of segments) {
53
+ if (segment.type === 'arc') {
54
+ PcbOutlineRasterizer.#drawArcMaskSegment(
55
+ mask,
56
+ segment,
57
+ rasterWidth,
58
+ rasterHeight,
59
+ resolutionMil,
60
+ originX,
61
+ originY
62
+ )
63
+ continue
64
+ }
65
+
66
+ PcbOutlineRasterizer.#drawLineMaskSegment(
67
+ mask,
68
+ segment,
69
+ rasterWidth,
70
+ rasterHeight,
71
+ resolutionMil,
72
+ originX,
73
+ originY
74
+ )
75
+ }
76
+
77
+ return mask
78
+ }
79
+
80
+ /**
81
+ * Draws one thick line boundary mask from the candidate track family.
82
+ * @param {{ x1: number, y1: number, x2: number, y2: number, width: number }[]} tracks
83
+ * @param {number} rasterWidth
84
+ * @param {number} rasterHeight
85
+ * @param {number} resolutionMil
86
+ * @param {number} originX
87
+ * @param {number} originY
88
+ * @returns {Uint8Array}
89
+ */
90
+ static drawBoundaryMask(
91
+ tracks,
92
+ rasterWidth,
93
+ rasterHeight,
94
+ resolutionMil,
95
+ originX,
96
+ originY
97
+ ) {
98
+ const mask = new Uint8Array(rasterWidth * rasterHeight)
99
+
100
+ for (const track of tracks) {
101
+ const startX = (track.x1 - originX) / resolutionMil
102
+ const startY = (track.y1 - originY) / resolutionMil
103
+ const endX = (track.x2 - originX) / resolutionMil
104
+ const endY = (track.y2 - originY) / resolutionMil
105
+ const steps = Math.max(
106
+ Math.ceil(Math.abs(endX - startX) * 2),
107
+ Math.ceil(Math.abs(endY - startY) * 2),
108
+ 1
109
+ )
110
+ const radius = Math.max(
111
+ 1,
112
+ Math.ceil(Number(track.width || 0) / resolutionMil / 2)
113
+ )
114
+
115
+ for (let step = 0; step <= steps; step += 1) {
116
+ const ratio = step / steps
117
+ const x = startX + (endX - startX) * ratio
118
+ const y = startY + (endY - startY) * ratio
119
+
120
+ PcbOutlineRasterizer.#paintDisk(
121
+ mask,
122
+ rasterWidth,
123
+ rasterHeight,
124
+ x,
125
+ y,
126
+ radius
127
+ )
128
+ }
129
+ }
130
+
131
+ return mask
132
+ }
133
+
134
+ /**
135
+ * Expands one raster mask by one eight-neighbor ring.
136
+ * @param {Uint8Array} mask
137
+ * @param {number} rasterWidth
138
+ * @param {number} rasterHeight
139
+ * @returns {Uint8Array}
140
+ */
141
+ static dilateMask(mask, rasterWidth, rasterHeight) {
142
+ const dilatedMask = mask.slice()
143
+
144
+ for (let y = 0; y < rasterHeight; y += 1) {
145
+ for (let x = 0; x < rasterWidth; x += 1) {
146
+ if (!mask[y * rasterWidth + x]) {
147
+ continue
148
+ }
149
+
150
+ for (let deltaY = -1; deltaY <= 1; deltaY += 1) {
151
+ for (let deltaX = -1; deltaX <= 1; deltaX += 1) {
152
+ const nextX = x + deltaX
153
+ const nextY = y + deltaY
154
+
155
+ if (
156
+ nextX < 0 ||
157
+ nextY < 0 ||
158
+ nextX >= rasterWidth ||
159
+ nextY >= rasterHeight
160
+ ) {
161
+ continue
162
+ }
163
+
164
+ dilatedMask[nextY * rasterWidth + nextX] = 1
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ return dilatedMask
171
+ }
172
+
173
+ /**
174
+ * Contracts one raster mask by one eight-neighbor ring.
175
+ * @param {Uint8Array} mask
176
+ * @param {number} rasterWidth
177
+ * @param {number} rasterHeight
178
+ * @returns {Uint8Array}
179
+ */
180
+ static erodeMask(mask, rasterWidth, rasterHeight) {
181
+ const erodedMask = new Uint8Array(mask.length)
182
+
183
+ for (let y = 0; y < rasterHeight; y += 1) {
184
+ for (let x = 0; x < rasterWidth; x += 1) {
185
+ if (!mask[y * rasterWidth + x]) {
186
+ continue
187
+ }
188
+
189
+ let keepCell = true
190
+
191
+ for (let deltaY = -1; deltaY <= 1 && keepCell; deltaY += 1) {
192
+ for (let deltaX = -1; deltaX <= 1; deltaX += 1) {
193
+ const nextX = x + deltaX
194
+ const nextY = y + deltaY
195
+
196
+ if (
197
+ nextX < 0 ||
198
+ nextY < 0 ||
199
+ nextX >= rasterWidth ||
200
+ nextY >= rasterHeight ||
201
+ !mask[nextY * rasterWidth + nextX]
202
+ ) {
203
+ keepCell = false
204
+ break
205
+ }
206
+ }
207
+ }
208
+
209
+ if (keepCell) {
210
+ erodedMask[y * rasterWidth + x] = 1
211
+ }
212
+ }
213
+ }
214
+
215
+ return erodedMask
216
+ }
217
+
218
+ /**
219
+ * Flood-fills the raster exterior from the padded image border.
220
+ * @param {Uint8Array} boundaryMask
221
+ * @param {number} rasterWidth
222
+ * @param {number} rasterHeight
223
+ * @returns {Uint8Array}
224
+ */
225
+ static floodExterior(boundaryMask, rasterWidth, rasterHeight) {
226
+ const exteriorMask = new Uint8Array(rasterWidth * rasterHeight)
227
+ const queue = []
228
+
229
+ const push = (x, y) => {
230
+ if (x < 0 || y < 0 || x >= rasterWidth || y >= rasterHeight) {
231
+ return
232
+ }
233
+
234
+ const index = y * rasterWidth + x
235
+
236
+ if (boundaryMask[index] || exteriorMask[index]) {
237
+ return
238
+ }
239
+
240
+ exteriorMask[index] = 1
241
+ queue.push({ x, y })
242
+ }
243
+
244
+ for (let x = 0; x < rasterWidth; x += 1) {
245
+ push(x, 0)
246
+ push(x, rasterHeight - 1)
247
+ }
248
+
249
+ for (let y = 0; y < rasterHeight; y += 1) {
250
+ push(0, y)
251
+ push(rasterWidth - 1, y)
252
+ }
253
+
254
+ while (queue.length) {
255
+ const cell = queue.shift()
256
+
257
+ push(cell.x + 1, cell.y)
258
+ push(cell.x - 1, cell.y)
259
+ push(cell.x, cell.y + 1)
260
+ push(cell.x, cell.y - 1)
261
+ }
262
+
263
+ return exteriorMask
264
+ }
265
+
266
+ /**
267
+ * Builds one solid region mask from one outline boundary and its flooded
268
+ * exterior.
269
+ * @param {Uint8Array} boundaryMask
270
+ * @param {Uint8Array} exteriorMask
271
+ * @returns {Uint8Array}
272
+ */
273
+ static buildSolidMask(boundaryMask, exteriorMask) {
274
+ const solidMask = new Uint8Array(boundaryMask.length)
275
+
276
+ for (let index = 0; index < boundaryMask.length; index += 1) {
277
+ if (!exteriorMask[index]) {
278
+ solidMask[index] = 1
279
+ }
280
+ }
281
+
282
+ return solidMask
283
+ }
284
+
285
+ /**
286
+ * Applies one binary closing pass family to a filled outline mask so small
287
+ * scallops merge back into the board body.
288
+ * @param {Uint8Array} solidMask
289
+ * @param {number} rasterWidth
290
+ * @param {number} rasterHeight
291
+ * @param {number} closingPasses
292
+ * @returns {Uint8Array}
293
+ */
294
+ static closeSolidMask(solidMask, rasterWidth, rasterHeight, closingPasses) {
295
+ let closedMask = solidMask.slice()
296
+
297
+ for (let pass = 0; pass < closingPasses; pass += 1) {
298
+ closedMask = PcbOutlineRasterizer.dilateMask(
299
+ closedMask,
300
+ rasterWidth,
301
+ rasterHeight
302
+ )
303
+ }
304
+
305
+ for (let pass = 0; pass < closingPasses; pass += 1) {
306
+ closedMask = PcbOutlineRasterizer.erodeMask(
307
+ closedMask,
308
+ rasterWidth,
309
+ rasterHeight
310
+ )
311
+ }
312
+
313
+ return closedMask
314
+ }
315
+
316
+ /**
317
+ * Returns true when one closed region still contains every component
318
+ * sample after one board-route closure pass.
319
+ * @param {Uint8Array} mask
320
+ * @param {number} rasterWidth
321
+ * @param {number} rasterHeight
322
+ * @param {{ x: number, y: number }[]} components
323
+ * @param {number} resolutionMil
324
+ * @param {number} originX
325
+ * @param {number} originY
326
+ * @returns {boolean}
327
+ */
328
+ static maskContainsAllComponents(
329
+ mask,
330
+ rasterWidth,
331
+ rasterHeight,
332
+ components,
333
+ resolutionMil,
334
+ originX,
335
+ originY
336
+ ) {
337
+ return components.every((component) => {
338
+ const componentCell = PcbOutlineRasterizer.coordinateToRasterCell(
339
+ component.x,
340
+ component.y,
341
+ resolutionMil,
342
+ originX,
343
+ originY,
344
+ rasterWidth,
345
+ rasterHeight
346
+ )
347
+
348
+ if (!componentCell) {
349
+ return false
350
+ }
351
+
352
+ return mask[componentCell.y * rasterWidth + componentCell.x] === 1
353
+ })
354
+ }
355
+
356
+ /**
357
+ * Chooses the smallest enclosed raster region that contains all sampled
358
+ * component coordinates.
359
+ * @param {Uint8Array} boundaryMask
360
+ * @param {Uint8Array} exteriorMask
361
+ * @param {number} rasterWidth
362
+ * @param {number} rasterHeight
363
+ * @param {{ x: number, y: number }[]} componentCells
364
+ * @param {{ centerX: number, centerY: number }} componentBounds
365
+ * @param {number} resolutionMil
366
+ * @param {number} originX
367
+ * @param {number} originY
368
+ * @returns {Uint8Array | null}
369
+ */
370
+ static recoverPlacementInterior(
371
+ boundaryMask,
372
+ exteriorMask,
373
+ rasterWidth,
374
+ rasterHeight,
375
+ componentCells,
376
+ componentBounds,
377
+ resolutionMil,
378
+ originX,
379
+ originY
380
+ ) {
381
+ const seeds = []
382
+ const seenSeedKeys = new Set()
383
+
384
+ for (const componentCell of componentCells) {
385
+ const seedKey = PcbOutlineRasterizer.#pointKey(
386
+ componentCell.x,
387
+ componentCell.y
388
+ )
389
+
390
+ if (seenSeedKeys.has(seedKey)) {
391
+ continue
392
+ }
393
+
394
+ seenSeedKeys.add(seedKey)
395
+ seeds.push(componentCell)
396
+ }
397
+
398
+ const centroidCell = PcbOutlineRasterizer.coordinateToRasterCell(
399
+ componentBounds.centerX,
400
+ componentBounds.centerY,
401
+ resolutionMil,
402
+ originX,
403
+ originY,
404
+ rasterWidth,
405
+ rasterHeight
406
+ )
407
+
408
+ if (centroidCell) {
409
+ const centroidKey = PcbOutlineRasterizer.#pointKey(
410
+ centroidCell.x,
411
+ centroidCell.y
412
+ )
413
+
414
+ if (!seenSeedKeys.has(centroidKey)) {
415
+ seeds.unshift(centroidCell)
416
+ }
417
+ }
418
+
419
+ let bestInteriorMask = null
420
+ let bestArea = Number.POSITIVE_INFINITY
421
+ const triedSeeds = new Set()
422
+
423
+ for (const seedCell of seeds) {
424
+ const seedIndex = seedCell.y * rasterWidth + seedCell.x
425
+ const seedKey = PcbOutlineRasterizer.#pointKey(
426
+ seedCell.x,
427
+ seedCell.y
428
+ )
429
+
430
+ if (
431
+ boundaryMask[seedIndex] ||
432
+ exteriorMask[seedIndex] ||
433
+ triedSeeds.has(seedKey)
434
+ ) {
435
+ continue
436
+ }
437
+
438
+ triedSeeds.add(seedKey)
439
+ const interiorMask = PcbOutlineRasterizer.#floodInterior(
440
+ boundaryMask,
441
+ exteriorMask,
442
+ rasterWidth,
443
+ rasterHeight,
444
+ seedCell
445
+ )
446
+
447
+ let area = 0
448
+
449
+ for (const value of interiorMask) {
450
+ area += value
451
+ }
452
+
453
+ if (!area || area >= bestArea) {
454
+ continue
455
+ }
456
+
457
+ const containsAllComponents = componentCells.every(
458
+ (componentCell) => {
459
+ const componentIndex =
460
+ componentCell.y * rasterWidth + componentCell.x
461
+
462
+ return interiorMask[componentIndex]
463
+ }
464
+ )
465
+
466
+ if (!containsAllComponents) {
467
+ continue
468
+ }
469
+
470
+ bestInteriorMask = interiorMask
471
+ bestArea = area
472
+ }
473
+
474
+ return bestInteriorMask
475
+ }
476
+
477
+ /**
478
+ * Converts one document-space coordinate into a bounded raster cell.
479
+ * @param {number} x
480
+ * @param {number} y
481
+ * @param {number} resolutionMil
482
+ * @param {number} originX
483
+ * @param {number} originY
484
+ * @param {number} rasterWidth
485
+ * @param {number} rasterHeight
486
+ * @returns {{ x: number, y: number } | null}
487
+ */
488
+ static coordinateToRasterCell(
489
+ x,
490
+ y,
491
+ resolutionMil,
492
+ originX,
493
+ originY,
494
+ rasterWidth,
495
+ rasterHeight
496
+ ) {
497
+ const cellX = Math.round((x - originX) / resolutionMil)
498
+ const cellY = Math.round((y - originY) / resolutionMil)
499
+
500
+ if (
501
+ cellX < 0 ||
502
+ cellY < 0 ||
503
+ cellX >= rasterWidth ||
504
+ cellY >= rasterHeight
505
+ ) {
506
+ return null
507
+ }
508
+
509
+ return { x: cellX, y: cellY }
510
+ }
511
+
512
+ /**
513
+ * Traces all closed contour loops around one raster interior mask.
514
+ * @param {Uint8Array} interiorMask
515
+ * @param {number} rasterWidth
516
+ * @param {number} rasterHeight
517
+ * @param {number} resolutionMil
518
+ * @param {number} originX
519
+ * @param {number} originY
520
+ * @returns {{ x: number, y: number }[][]}
521
+ */
522
+ static traceInteriorLoops(
523
+ interiorMask,
524
+ rasterWidth,
525
+ rasterHeight,
526
+ resolutionMil,
527
+ originX,
528
+ originY
529
+ ) {
530
+ const edges = []
531
+ const isInterior = (x, y) =>
532
+ x >= 0 &&
533
+ y >= 0 &&
534
+ x < rasterWidth &&
535
+ y < rasterHeight &&
536
+ interiorMask[y * rasterWidth + x]
537
+
538
+ for (let y = 0; y < rasterHeight; y += 1) {
539
+ for (let x = 0; x < rasterWidth; x += 1) {
540
+ if (!isInterior(x, y)) {
541
+ continue
542
+ }
543
+
544
+ if (!isInterior(x, y - 1)) {
545
+ edges.push([
546
+ { x, y },
547
+ { x: x + 1, y }
548
+ ])
549
+ }
550
+ if (!isInterior(x + 1, y)) {
551
+ edges.push([
552
+ { x: x + 1, y },
553
+ { x: x + 1, y: y + 1 }
554
+ ])
555
+ }
556
+ if (!isInterior(x, y + 1)) {
557
+ edges.push([
558
+ { x: x + 1, y: y + 1 },
559
+ { x, y: y + 1 }
560
+ ])
561
+ }
562
+ if (!isInterior(x - 1, y)) {
563
+ edges.push([
564
+ { x, y: y + 1 },
565
+ { x, y }
566
+ ])
567
+ }
568
+ }
569
+ }
570
+
571
+ const outgoingEdges = new Map()
572
+
573
+ for (const [start, end] of edges) {
574
+ const startKey = PcbOutlineRasterizer.#pointKey(start.x, start.y)
575
+
576
+ if (!outgoingEdges.has(startKey)) {
577
+ outgoingEdges.set(startKey, [])
578
+ }
579
+
580
+ outgoingEdges.get(startKey).push(end)
581
+ }
582
+
583
+ const loops = []
584
+
585
+ while (outgoingEdges.size) {
586
+ const [startKey] = outgoingEdges.entries().next().value
587
+ const startPoint = PcbOutlineRasterizer.#parsePointKey(startKey)
588
+ const loop = [{ x: startPoint.x, y: startPoint.y }]
589
+ let currentKey = startKey
590
+
591
+ while (true) {
592
+ const nextPoint = outgoingEdges.get(currentKey)?.shift()
593
+
594
+ if (!nextPoint) {
595
+ outgoingEdges.delete(currentKey)
596
+ break
597
+ }
598
+
599
+ if (!outgoingEdges.get(currentKey)?.length) {
600
+ outgoingEdges.delete(currentKey)
601
+ }
602
+
603
+ loop.push({ x: nextPoint.x, y: nextPoint.y })
604
+ currentKey = PcbOutlineRasterizer.#pointKey(
605
+ nextPoint.x,
606
+ nextPoint.y
607
+ )
608
+
609
+ if (currentKey === startKey) {
610
+ break
611
+ }
612
+ }
613
+
614
+ if (loop.length < 4) {
615
+ continue
616
+ }
617
+
618
+ loops.push(
619
+ loop.map((point) => ({
620
+ x: originX + point.x * resolutionMil,
621
+ y: originY + point.y * resolutionMil
622
+ }))
623
+ )
624
+ }
625
+
626
+ return loops
627
+ }
628
+
629
+ /**
630
+ * Draws one line outline segment into the raster mask.
631
+ * @param {Uint8Array} mask
632
+ * @param {Record<string, number | string>} segment
633
+ * @param {number} rasterWidth
634
+ * @param {number} rasterHeight
635
+ * @param {number} resolutionMil
636
+ * @param {number} originX
637
+ * @param {number} originY
638
+ * @returns {void}
639
+ */
640
+ static #drawLineMaskSegment(
641
+ mask,
642
+ segment,
643
+ rasterWidth,
644
+ rasterHeight,
645
+ resolutionMil,
646
+ originX,
647
+ originY
648
+ ) {
649
+ const startX = (Number(segment.x1 || 0) - originX) / resolutionMil
650
+ const startY = (Number(segment.y1 || 0) - originY) / resolutionMil
651
+ const endX = (Number(segment.x2 || 0) - originX) / resolutionMil
652
+ const endY = (Number(segment.y2 || 0) - originY) / resolutionMil
653
+ const steps = Math.max(
654
+ Math.ceil(Math.abs(endX - startX) * 3),
655
+ Math.ceil(Math.abs(endY - startY) * 3),
656
+ 1
657
+ )
658
+
659
+ for (let step = 0; step <= steps; step += 1) {
660
+ const ratio = step / steps
661
+ const x = startX + (endX - startX) * ratio
662
+ const y = startY + (endY - startY) * ratio
663
+
664
+ PcbOutlineRasterizer.#paintDisk(
665
+ mask,
666
+ rasterWidth,
667
+ rasterHeight,
668
+ x,
669
+ y,
670
+ 1
671
+ )
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Draws one arc outline segment into the raster mask.
677
+ * @param {Uint8Array} mask
678
+ * @param {Record<string, number | string>} segment
679
+ * @param {number} rasterWidth
680
+ * @param {number} rasterHeight
681
+ * @param {number} resolutionMil
682
+ * @param {number} originX
683
+ * @param {number} originY
684
+ * @returns {void}
685
+ */
686
+ static #drawArcMaskSegment(
687
+ mask,
688
+ segment,
689
+ rasterWidth,
690
+ rasterHeight,
691
+ resolutionMil,
692
+ originX,
693
+ originY
694
+ ) {
695
+ const startAngle = Number(segment.startAngle || 0)
696
+ const endAngle = Number(segment.endAngle || 0)
697
+ let delta = endAngle - startAngle
698
+
699
+ if (Math.abs(delta) < 1e-6) {
700
+ delta = 360
701
+ }
702
+
703
+ if (delta < 0) {
704
+ delta += 360
705
+ }
706
+
707
+ const radius = Math.max(Number(segment.radius) || 0, resolutionMil)
708
+ const steps = Math.max(
709
+ Math.ceil(
710
+ ((2 * Math.PI * radius * (delta / 360)) / resolutionMil) * 3
711
+ ),
712
+ 12
713
+ )
714
+
715
+ for (let step = 0; step <= steps; step += 1) {
716
+ const angle =
717
+ ((startAngle + delta * (step / steps)) * Math.PI) / 180
718
+ const x =
719
+ (Number(segment.cx || 0) + radius * Math.cos(angle) - originX) /
720
+ resolutionMil
721
+ const y =
722
+ (Number(segment.cy || 0) + radius * Math.sin(angle) - originY) /
723
+ resolutionMil
724
+
725
+ PcbOutlineRasterizer.#paintDisk(
726
+ mask,
727
+ rasterWidth,
728
+ rasterHeight,
729
+ x,
730
+ y,
731
+ 1
732
+ )
733
+ }
734
+ }
735
+
736
+ /**
737
+ * Paints one solid disk into the raster mask.
738
+ * @param {Uint8Array} mask
739
+ * @param {number} rasterWidth
740
+ * @param {number} rasterHeight
741
+ * @param {number} x
742
+ * @param {number} y
743
+ * @param {number} radius
744
+ * @returns {void}
745
+ */
746
+ static #paintDisk(mask, rasterWidth, rasterHeight, x, y, radius) {
747
+ const centerX = Math.round(x)
748
+ const centerY = Math.round(y)
749
+
750
+ for (let deltaY = -radius; deltaY <= radius; deltaY += 1) {
751
+ for (let deltaX = -radius; deltaX <= radius; deltaX += 1) {
752
+ if (deltaX * deltaX + deltaY * deltaY > radius * radius) {
753
+ continue
754
+ }
755
+
756
+ const nextX = centerX + deltaX
757
+ const nextY = centerY + deltaY
758
+
759
+ if (
760
+ nextX < 0 ||
761
+ nextY < 0 ||
762
+ nextX >= rasterWidth ||
763
+ nextY >= rasterHeight
764
+ ) {
765
+ continue
766
+ }
767
+
768
+ mask[nextY * rasterWidth + nextX] = 1
769
+ }
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Flood-fills the enclosed raster region containing one candidate seed.
775
+ * @param {Uint8Array} boundaryMask
776
+ * @param {Uint8Array} exteriorMask
777
+ * @param {number} rasterWidth
778
+ * @param {number} rasterHeight
779
+ * @param {{ x: number, y: number }} seedCell
780
+ * @returns {Uint8Array}
781
+ */
782
+ static #floodInterior(
783
+ boundaryMask,
784
+ exteriorMask,
785
+ rasterWidth,
786
+ rasterHeight,
787
+ seedCell
788
+ ) {
789
+ const interiorMask = new Uint8Array(rasterWidth * rasterHeight)
790
+ const queue = [seedCell]
791
+ interiorMask[seedCell.y * rasterWidth + seedCell.x] = 1
792
+
793
+ while (queue.length) {
794
+ const cell = queue.shift()
795
+
796
+ for (const neighbor of [
797
+ { x: cell.x + 1, y: cell.y },
798
+ { x: cell.x - 1, y: cell.y },
799
+ { x: cell.x, y: cell.y + 1 },
800
+ { x: cell.x, y: cell.y - 1 }
801
+ ]) {
802
+ if (
803
+ neighbor.x < 0 ||
804
+ neighbor.y < 0 ||
805
+ neighbor.x >= rasterWidth ||
806
+ neighbor.y >= rasterHeight
807
+ ) {
808
+ continue
809
+ }
810
+
811
+ const index = neighbor.y * rasterWidth + neighbor.x
812
+
813
+ if (
814
+ boundaryMask[index] ||
815
+ exteriorMask[index] ||
816
+ interiorMask[index]
817
+ ) {
818
+ continue
819
+ }
820
+
821
+ interiorMask[index] = 1
822
+ queue.push(neighbor)
823
+ }
824
+ }
825
+
826
+ return interiorMask
827
+ }
828
+
829
+ /**
830
+ * Returns one stable point key.
831
+ * @param {number} x
832
+ * @param {number} y
833
+ * @returns {string}
834
+ */
835
+ static #pointKey(x, y) {
836
+ return x + ':' + y
837
+ }
838
+
839
+ /**
840
+ * Parses one stable point key.
841
+ * @param {string} key
842
+ * @returns {{ x: number, y: number }}
843
+ */
844
+ static #parsePointKey(key) {
845
+ const [x, y] = String(key || '').split(':')
846
+
847
+ return {
848
+ x: Number(x),
849
+ y: Number(y)
850
+ }
851
+ }
852
+ }