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.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- 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
|
+
}
|