altium-toolkit 1.1.25 → 1.1.26
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/README.md +5 -3
- package/docs/api.md +9 -4
- package/docs/model-format.md +15 -4
- package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
- package/package.json +1 -1
- package/spec/library-compatibility.md +65 -0
- package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
- package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
- package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
- package/src/core/altium/PcbLibModelParser.mjs +24 -2
- package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
- package/src/parser.mjs +1 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { LibraryCompatibilityGeometry } from './LibraryCompatibilityGeometry.mjs'
|
|
6
|
+
import { LibraryCompatibilityModelHintBuilder } from './LibraryCompatibilityModelHintBuilder.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds source-neutral compatibility diagnostics for parsed library models.
|
|
10
|
+
*/
|
|
11
|
+
export class LibraryCompatibilityReportBuilder {
|
|
12
|
+
static SCHEMA_ID = 'altium-toolkit.library.compatibility.a1'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds a read-only compatibility report.
|
|
16
|
+
* @param {{ schematicLibraries?: object[], pcbLibraries?: object[] }} options Library collections.
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
static build(options = {}) {
|
|
20
|
+
const schematicLibraries = Array.isArray(options.schematicLibraries)
|
|
21
|
+
? options.schematicLibraries
|
|
22
|
+
: []
|
|
23
|
+
const pcbLibraries = Array.isArray(options.pcbLibraries)
|
|
24
|
+
? options.pcbLibraries
|
|
25
|
+
: []
|
|
26
|
+
const symbolPins =
|
|
27
|
+
LibraryCompatibilityReportBuilder.#symbolPinRows(schematicLibraries)
|
|
28
|
+
const hiddenPins = symbolPins
|
|
29
|
+
.filter((pin) => pin.hidden)
|
|
30
|
+
.map((pin) => LibraryCompatibilityReportBuilder.#hiddenPinRow(pin))
|
|
31
|
+
const symbolBounds =
|
|
32
|
+
LibraryCompatibilityReportBuilder.#symbolBoundsRows(
|
|
33
|
+
schematicLibraries
|
|
34
|
+
)
|
|
35
|
+
const fieldPlacementRisks =
|
|
36
|
+
LibraryCompatibilityReportBuilder.#fieldPlacementRisks(
|
|
37
|
+
schematicLibraries,
|
|
38
|
+
symbolBounds
|
|
39
|
+
)
|
|
40
|
+
const footprintBounds =
|
|
41
|
+
LibraryCompatibilityReportBuilder.#footprintBoundsRows(pcbLibraries)
|
|
42
|
+
const padDiagnostics =
|
|
43
|
+
LibraryCompatibilityReportBuilder.#padDiagnostics(pcbLibraries)
|
|
44
|
+
const modelSuggestions =
|
|
45
|
+
LibraryCompatibilityModelHintBuilder.build(pcbLibraries)
|
|
46
|
+
const issues = [
|
|
47
|
+
...hiddenPins.map((pin) =>
|
|
48
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
49
|
+
code: 'library.compatibility.hidden-pin',
|
|
50
|
+
severity: 'info',
|
|
51
|
+
target:
|
|
52
|
+
pin.symbolName +
|
|
53
|
+
':' +
|
|
54
|
+
(pin.designator || pin.name || 'hidden'),
|
|
55
|
+
libraryFileName: pin.libraryFileName,
|
|
56
|
+
symbolName: pin.symbolName,
|
|
57
|
+
designator: pin.designator,
|
|
58
|
+
name: pin.name,
|
|
59
|
+
partId: pin.partId,
|
|
60
|
+
placementHint: pin.placementHint,
|
|
61
|
+
reason: pin.reason
|
|
62
|
+
})
|
|
63
|
+
),
|
|
64
|
+
...fieldPlacementRisks,
|
|
65
|
+
...padDiagnostics,
|
|
66
|
+
...modelSuggestions.map((suggestion) =>
|
|
67
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
68
|
+
code: 'library.compatibility.model-name-suggestion',
|
|
69
|
+
severity: 'info',
|
|
70
|
+
target: suggestion.footprintName,
|
|
71
|
+
libraryFileName: suggestion.libraryFileName,
|
|
72
|
+
footprintName: suggestion.footprintName,
|
|
73
|
+
packageClass: suggestion.packageClass,
|
|
74
|
+
keys: suggestion.keys,
|
|
75
|
+
pinOneDesignator: suggestion.pinOneDesignator,
|
|
76
|
+
pinOnePosition: suggestion.pinOnePosition,
|
|
77
|
+
rotationHint: suggestion.rotationHint,
|
|
78
|
+
reason: suggestion.reason
|
|
79
|
+
})
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
schema: LibraryCompatibilityReportBuilder.SCHEMA_ID,
|
|
85
|
+
summary: LibraryCompatibilityReportBuilder.#summary(
|
|
86
|
+
schematicLibraries,
|
|
87
|
+
pcbLibraries,
|
|
88
|
+
symbolPins,
|
|
89
|
+
hiddenPins,
|
|
90
|
+
symbolBounds,
|
|
91
|
+
fieldPlacementRisks,
|
|
92
|
+
footprintBounds,
|
|
93
|
+
padDiagnostics,
|
|
94
|
+
modelSuggestions,
|
|
95
|
+
issues
|
|
96
|
+
),
|
|
97
|
+
symbolPins,
|
|
98
|
+
hiddenPins,
|
|
99
|
+
symbolBounds,
|
|
100
|
+
fieldPlacementRisks,
|
|
101
|
+
footprintBounds,
|
|
102
|
+
padDiagnostics,
|
|
103
|
+
modelSuggestions,
|
|
104
|
+
issues
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Builds top-level report counters.
|
|
110
|
+
* @param {object[]} schematicLibraries Schematic library models.
|
|
111
|
+
* @param {object[]} pcbLibraries PCB library models.
|
|
112
|
+
* @param {object[]} symbolPins Symbol pin rows.
|
|
113
|
+
* @param {object[]} hiddenPins Hidden pin rows.
|
|
114
|
+
* @param {object[]} symbolBounds Symbol bounds rows.
|
|
115
|
+
* @param {object[]} fieldPlacementRisks Field placement risk rows.
|
|
116
|
+
* @param {object[]} footprintBounds Footprint bounds rows.
|
|
117
|
+
* @param {object[]} padDiagnostics Pad diagnostic rows.
|
|
118
|
+
* @param {object[]} modelSuggestions Model suggestion rows.
|
|
119
|
+
* @param {object[]} issues Flattened issue rows.
|
|
120
|
+
* @returns {object}
|
|
121
|
+
*/
|
|
122
|
+
static #summary(
|
|
123
|
+
schematicLibraries,
|
|
124
|
+
pcbLibraries,
|
|
125
|
+
symbolPins,
|
|
126
|
+
hiddenPins,
|
|
127
|
+
symbolBounds,
|
|
128
|
+
fieldPlacementRisks,
|
|
129
|
+
footprintBounds,
|
|
130
|
+
padDiagnostics,
|
|
131
|
+
modelSuggestions,
|
|
132
|
+
issues
|
|
133
|
+
) {
|
|
134
|
+
return {
|
|
135
|
+
schematicLibraryCount: schematicLibraries.length,
|
|
136
|
+
pcbLibraryCount: pcbLibraries.length,
|
|
137
|
+
symbolPinCount: symbolPins.length,
|
|
138
|
+
hiddenPinCount: hiddenPins.length,
|
|
139
|
+
symbolBoundsCount: symbolBounds.length,
|
|
140
|
+
fieldPlacementRiskCount: fieldPlacementRisks.length,
|
|
141
|
+
footprintBoundsCount: footprintBounds.length,
|
|
142
|
+
padDiagnosticCount: padDiagnostics.length,
|
|
143
|
+
modelSuggestionCount: modelSuggestions.length,
|
|
144
|
+
issuesBySeverity:
|
|
145
|
+
LibraryCompatibilityReportBuilder.#issueSeverityCounts(issues),
|
|
146
|
+
issueCount: issues.length
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Builds normalized pin rows for schematic library symbols.
|
|
152
|
+
* @param {object[]} schematicLibraries Schematic library models.
|
|
153
|
+
* @returns {object[]}
|
|
154
|
+
*/
|
|
155
|
+
static #symbolPinRows(schematicLibraries) {
|
|
156
|
+
const rows = []
|
|
157
|
+
|
|
158
|
+
for (const library of schematicLibraries || []) {
|
|
159
|
+
const libraryFileName = String(library?.fileName || '')
|
|
160
|
+
for (const symbol of library?.schematicLibrary?.symbols || []) {
|
|
161
|
+
const symbolName = String(symbol?.name || '')
|
|
162
|
+
for (const pin of symbol?.pins || []) {
|
|
163
|
+
const hidden =
|
|
164
|
+
Boolean(pin?.hidden) || Boolean(pin?.isHidden)
|
|
165
|
+
const row = LibraryCompatibilityReportBuilder.#stripEmpty({
|
|
166
|
+
libraryFileName,
|
|
167
|
+
symbolName,
|
|
168
|
+
designator: pin?.designator,
|
|
169
|
+
name: pin?.name,
|
|
170
|
+
partId: pin?.partId,
|
|
171
|
+
electricalRole:
|
|
172
|
+
LibraryCompatibilityReportBuilder.#electricalRole(
|
|
173
|
+
pin?.electricalType ?? pin?.electrical
|
|
174
|
+
),
|
|
175
|
+
edgeShape:
|
|
176
|
+
LibraryCompatibilityReportBuilder.#edgeShape(pin),
|
|
177
|
+
hidden,
|
|
178
|
+
labelVisibility: hidden ? 'hidden' : 'visible',
|
|
179
|
+
...(hidden
|
|
180
|
+
? {
|
|
181
|
+
placementHint:
|
|
182
|
+
LibraryCompatibilityReportBuilder.#hiddenPinPlacementHint(
|
|
183
|
+
pin
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
: {})
|
|
187
|
+
})
|
|
188
|
+
rows.push(row)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return rows
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Builds one hidden pin metadata row.
|
|
198
|
+
* @param {object} pin Normalized symbol pin row.
|
|
199
|
+
* @returns {object}
|
|
200
|
+
*/
|
|
201
|
+
static #hiddenPinRow(pin) {
|
|
202
|
+
return LibraryCompatibilityReportBuilder.#stripEmpty({
|
|
203
|
+
libraryFileName: pin.libraryFileName,
|
|
204
|
+
symbolName: pin.symbolName,
|
|
205
|
+
designator: pin.designator,
|
|
206
|
+
name: pin.name,
|
|
207
|
+
partId: pin.partId,
|
|
208
|
+
placementHint: pin.placementHint,
|
|
209
|
+
reason:
|
|
210
|
+
pin.placementHint === 'top'
|
|
211
|
+
? 'hidden pin carries a power-oriented label'
|
|
212
|
+
: 'hidden pin carries a reference-oriented label'
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolves one pin electrical role.
|
|
218
|
+
* @param {unknown} value Raw electrical type.
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
static #electricalRole(value) {
|
|
222
|
+
const text = String(value ?? '')
|
|
223
|
+
.trim()
|
|
224
|
+
.toLowerCase()
|
|
225
|
+
const numericRoles = new Map([
|
|
226
|
+
['0', 'input'],
|
|
227
|
+
['1', 'bidirectional'],
|
|
228
|
+
['2', 'output'],
|
|
229
|
+
['3', 'open-collector'],
|
|
230
|
+
['4', 'passive'],
|
|
231
|
+
['5', 'high-impedance'],
|
|
232
|
+
['6', 'open-emitter'],
|
|
233
|
+
['7', 'power']
|
|
234
|
+
])
|
|
235
|
+
const textRoles = new Map([
|
|
236
|
+
['input', 'input'],
|
|
237
|
+
['bidirectional', 'bidirectional'],
|
|
238
|
+
['i/o', 'bidirectional'],
|
|
239
|
+
['io', 'bidirectional'],
|
|
240
|
+
['output', 'output'],
|
|
241
|
+
['open collector', 'open-collector'],
|
|
242
|
+
['opencollector', 'open-collector'],
|
|
243
|
+
['passive', 'passive'],
|
|
244
|
+
['hiz', 'high-impedance'],
|
|
245
|
+
['high impedance', 'high-impedance'],
|
|
246
|
+
['open emitter', 'open-emitter'],
|
|
247
|
+
['openemitter', 'open-emitter'],
|
|
248
|
+
['power', 'power'],
|
|
249
|
+
['powerin', 'power'],
|
|
250
|
+
['powerout', 'power']
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
return numericRoles.get(text) || textRoles.get(text) || 'unknown'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resolves one decorative pin edge shape.
|
|
258
|
+
* @param {object} pin Source pin row.
|
|
259
|
+
* @returns {string}
|
|
260
|
+
*/
|
|
261
|
+
static #edgeShape(pin) {
|
|
262
|
+
const inner = LibraryCompatibilityReportBuilder.#edgeToken(
|
|
263
|
+
pin?.symbolInner ?? pin?.symbolInside
|
|
264
|
+
)
|
|
265
|
+
const outer = LibraryCompatibilityReportBuilder.#edgeToken(
|
|
266
|
+
pin?.symbolOuter ?? pin?.symbolOutside
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if (inner === 'clock' && outer === 'dot') return 'inverted-clock'
|
|
270
|
+
if (inner === 'clock' && outer === 'low-input') return 'low-clock'
|
|
271
|
+
if (inner === 'clock') return 'clock'
|
|
272
|
+
if (outer === 'logic-not') return 'logic-not'
|
|
273
|
+
if (outer === 'dot') return 'inverted'
|
|
274
|
+
if (outer === 'low-input') return 'low-input'
|
|
275
|
+
if (outer === 'low-output') return 'low-output'
|
|
276
|
+
|
|
277
|
+
return 'line'
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Normalizes one symbolic pin-edge token.
|
|
282
|
+
* @param {unknown} value Raw edge value.
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
static #edgeToken(value) {
|
|
286
|
+
const text = String(value ?? '')
|
|
287
|
+
.trim()
|
|
288
|
+
.toLowerCase()
|
|
289
|
+
.replace(/[_\s]+/gu, '-')
|
|
290
|
+
const numeric = new Map([
|
|
291
|
+
['1', 'dot'],
|
|
292
|
+
['3', 'clock'],
|
|
293
|
+
['4', 'low-input'],
|
|
294
|
+
['6', 'logic-not'],
|
|
295
|
+
['17', 'low-output']
|
|
296
|
+
])
|
|
297
|
+
const aliases = new Map([
|
|
298
|
+
['invert', 'dot'],
|
|
299
|
+
['inverted', 'dot'],
|
|
300
|
+
['dot', 'dot'],
|
|
301
|
+
['clock', 'clock'],
|
|
302
|
+
['lowinput', 'low-input'],
|
|
303
|
+
['low-input', 'low-input'],
|
|
304
|
+
['lowoutput', 'low-output'],
|
|
305
|
+
['low-output', 'low-output'],
|
|
306
|
+
['logicnot', 'logic-not'],
|
|
307
|
+
['logic-not', 'logic-not']
|
|
308
|
+
])
|
|
309
|
+
|
|
310
|
+
return numeric.get(text) || aliases.get(text) || ''
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Resolves a hidden pin placement hint from its label.
|
|
315
|
+
* @param {object} pin Source pin row.
|
|
316
|
+
* @returns {'top' | 'bottom'}
|
|
317
|
+
*/
|
|
318
|
+
static #hiddenPinPlacementHint(pin) {
|
|
319
|
+
const name = String(pin?.name || pin?.designator || '')
|
|
320
|
+
.trim()
|
|
321
|
+
.toUpperCase()
|
|
322
|
+
|
|
323
|
+
if (/^(VCC|VDD|V\+|VBAT|VIN|AVDD|DVDD|PVDD)$/u.test(name)) {
|
|
324
|
+
return 'top'
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return 'bottom'
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Builds one bounds row per bounded schematic library symbol.
|
|
332
|
+
* @param {object[]} schematicLibraries Schematic library models.
|
|
333
|
+
* @returns {object[]}
|
|
334
|
+
*/
|
|
335
|
+
static #symbolBoundsRows(schematicLibraries) {
|
|
336
|
+
const rows = []
|
|
337
|
+
|
|
338
|
+
for (const library of schematicLibraries || []) {
|
|
339
|
+
const libraryFileName = String(library?.fileName || '')
|
|
340
|
+
for (const symbol of library?.schematicLibrary?.symbols || []) {
|
|
341
|
+
const bounds = LibraryCompatibilityGeometry.symbolBounds(symbol)
|
|
342
|
+
if (!bounds) continue
|
|
343
|
+
|
|
344
|
+
rows.push({
|
|
345
|
+
libraryFileName,
|
|
346
|
+
symbolName: String(symbol?.name || ''),
|
|
347
|
+
...bounds
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return rows
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Builds visible field-placement risk rows for bounded symbols.
|
|
357
|
+
* @param {object[]} schematicLibraries Schematic library models.
|
|
358
|
+
* @param {object[]} symbolBounds Symbol bounds rows.
|
|
359
|
+
* @returns {object[]}
|
|
360
|
+
*/
|
|
361
|
+
static #fieldPlacementRisks(schematicLibraries, symbolBounds) {
|
|
362
|
+
const boundsBySymbol = new Map(
|
|
363
|
+
symbolBounds.map((row) => [
|
|
364
|
+
LibraryCompatibilityReportBuilder.#symbolKey(
|
|
365
|
+
row.libraryFileName,
|
|
366
|
+
row.symbolName
|
|
367
|
+
),
|
|
368
|
+
row.bounds
|
|
369
|
+
])
|
|
370
|
+
)
|
|
371
|
+
const rows = []
|
|
372
|
+
|
|
373
|
+
for (const library of schematicLibraries || []) {
|
|
374
|
+
const libraryFileName = String(library?.fileName || '')
|
|
375
|
+
for (const symbol of library?.schematicLibrary?.symbols || []) {
|
|
376
|
+
const symbolName = String(symbol?.name || '')
|
|
377
|
+
const bounds = boundsBySymbol.get(
|
|
378
|
+
LibraryCompatibilityReportBuilder.#symbolKey(
|
|
379
|
+
libraryFileName,
|
|
380
|
+
symbolName
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
if (!bounds) continue
|
|
384
|
+
|
|
385
|
+
for (const field of symbol?.texts || []) {
|
|
386
|
+
const fieldName = String(field?.name || field?.t || '')
|
|
387
|
+
if (
|
|
388
|
+
!LibraryCompatibilityReportBuilder.#fieldCanAffectPlacement(
|
|
389
|
+
field,
|
|
390
|
+
fieldName
|
|
391
|
+
)
|
|
392
|
+
) {
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const position =
|
|
397
|
+
LibraryCompatibilityReportBuilder.#point(field)
|
|
398
|
+
if (
|
|
399
|
+
position &&
|
|
400
|
+
LibraryCompatibilityReportBuilder.#pointInsideBounds(
|
|
401
|
+
position,
|
|
402
|
+
bounds
|
|
403
|
+
)
|
|
404
|
+
) {
|
|
405
|
+
rows.push(
|
|
406
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
407
|
+
code: 'library.compatibility.symbol-field-inside-bounds',
|
|
408
|
+
severity: 'warning',
|
|
409
|
+
target: symbolName + ':' + fieldName,
|
|
410
|
+
libraryFileName,
|
|
411
|
+
symbolName,
|
|
412
|
+
fieldName,
|
|
413
|
+
fieldText: field?.text ?? field?.value,
|
|
414
|
+
position,
|
|
415
|
+
reason: 'visible symbol field is placed inside the symbol bounds'
|
|
416
|
+
})
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return rows
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Builds a stable symbol lookup key.
|
|
428
|
+
* @param {string} libraryFileName Source library file name.
|
|
429
|
+
* @param {string} symbolName Symbol name.
|
|
430
|
+
* @returns {string}
|
|
431
|
+
*/
|
|
432
|
+
static #symbolKey(libraryFileName, symbolName) {
|
|
433
|
+
return libraryFileName + '\u0000' + symbolName
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Returns true when a visible field can affect deterministic field
|
|
438
|
+
* placement.
|
|
439
|
+
* @param {object} field Field row.
|
|
440
|
+
* @param {string} fieldName Field name.
|
|
441
|
+
* @returns {boolean}
|
|
442
|
+
*/
|
|
443
|
+
static #fieldCanAffectPlacement(field, fieldName) {
|
|
444
|
+
if (field?.hidden || field?.isHidden) return false
|
|
445
|
+
|
|
446
|
+
return ['designator', 'comment', 'value'].includes(
|
|
447
|
+
String(fieldName || '').toLowerCase()
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Returns one finite point from a row with x/y fields.
|
|
453
|
+
* @param {object} value Candidate row.
|
|
454
|
+
* @returns {{ x: number, y: number } | null}
|
|
455
|
+
*/
|
|
456
|
+
static #point(value) {
|
|
457
|
+
const x = LibraryCompatibilityReportBuilder.#finiteNumber(value?.x)
|
|
458
|
+
const y = LibraryCompatibilityReportBuilder.#finiteNumber(value?.y)
|
|
459
|
+
|
|
460
|
+
if (x === null || y === null) return null
|
|
461
|
+
|
|
462
|
+
return { x, y }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Returns true when a point is inside normalized bounds.
|
|
467
|
+
* @param {{ x: number, y: number }} point Point row.
|
|
468
|
+
* @param {object} bounds Bounds row.
|
|
469
|
+
* @returns {boolean}
|
|
470
|
+
*/
|
|
471
|
+
static #pointInsideBounds(point, bounds) {
|
|
472
|
+
return (
|
|
473
|
+
point.x >= Number(bounds.minX) &&
|
|
474
|
+
point.x <= Number(bounds.maxX) &&
|
|
475
|
+
point.y >= Number(bounds.minY) &&
|
|
476
|
+
point.y <= Number(bounds.maxY)
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Builds one bounds row per bounded footprint.
|
|
482
|
+
* @param {object[]} pcbLibraries PCB library models.
|
|
483
|
+
* @returns {object[]}
|
|
484
|
+
*/
|
|
485
|
+
static #footprintBoundsRows(pcbLibraries) {
|
|
486
|
+
const rows = []
|
|
487
|
+
|
|
488
|
+
for (const library of pcbLibraries || []) {
|
|
489
|
+
const libraryFileName = String(library?.fileName || '')
|
|
490
|
+
for (const footprint of library?.pcbLibrary?.footprints || []) {
|
|
491
|
+
const bounds =
|
|
492
|
+
LibraryCompatibilityReportBuilder.#footprintBounds(
|
|
493
|
+
footprint
|
|
494
|
+
)
|
|
495
|
+
if (!bounds) continue
|
|
496
|
+
|
|
497
|
+
rows.push({
|
|
498
|
+
libraryFileName,
|
|
499
|
+
footprintName: String(footprint?.name || ''),
|
|
500
|
+
bounds,
|
|
501
|
+
courtyard: LibraryCompatibilityGeometry.courtyard(bounds),
|
|
502
|
+
sourceCounts:
|
|
503
|
+
LibraryCompatibilityGeometry.sourceCounts(footprint)
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return rows
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Computes merged footprint bounds.
|
|
513
|
+
* @param {object} footprint Footprint row.
|
|
514
|
+
* @returns {object | null}
|
|
515
|
+
*/
|
|
516
|
+
static #footprintBounds(footprint) {
|
|
517
|
+
return LibraryCompatibilityGeometry.footprintBounds(footprint)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Builds pad diagnostics across PCB libraries.
|
|
522
|
+
* @param {object[]} pcbLibraries PCB library models.
|
|
523
|
+
* @returns {object[]}
|
|
524
|
+
*/
|
|
525
|
+
static #padDiagnostics(pcbLibraries) {
|
|
526
|
+
const diagnostics = []
|
|
527
|
+
|
|
528
|
+
for (const library of pcbLibraries || []) {
|
|
529
|
+
const libraryFileName = String(library?.fileName || '')
|
|
530
|
+
for (const footprint of library?.pcbLibrary?.footprints || []) {
|
|
531
|
+
const footprintName = String(footprint?.name || '')
|
|
532
|
+
for (const pad of footprint?.pads || []) {
|
|
533
|
+
diagnostics.push(
|
|
534
|
+
...LibraryCompatibilityReportBuilder.#padDiagnosticRows(
|
|
535
|
+
libraryFileName,
|
|
536
|
+
footprintName,
|
|
537
|
+
pad
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return diagnostics
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Builds diagnostics for one pad row.
|
|
549
|
+
* @param {string} libraryFileName Source library file name.
|
|
550
|
+
* @param {string} footprintName Footprint name.
|
|
551
|
+
* @param {object} pad Pad row.
|
|
552
|
+
* @returns {object[]}
|
|
553
|
+
*/
|
|
554
|
+
static #padDiagnosticRows(libraryFileName, footprintName, pad) {
|
|
555
|
+
const rows = []
|
|
556
|
+
const target =
|
|
557
|
+
footprintName + ':' + String(pad?.designator || rows.length)
|
|
558
|
+
const top = {
|
|
559
|
+
width: LibraryCompatibilityReportBuilder.#finiteNumber(
|
|
560
|
+
pad?.sizeTopX
|
|
561
|
+
),
|
|
562
|
+
height: LibraryCompatibilityReportBuilder.#finiteNumber(
|
|
563
|
+
pad?.sizeTopY
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
const bottom = {
|
|
567
|
+
width: LibraryCompatibilityReportBuilder.#finiteNumber(
|
|
568
|
+
pad?.sizeBottomX
|
|
569
|
+
),
|
|
570
|
+
height: LibraryCompatibilityReportBuilder.#finiteNumber(
|
|
571
|
+
pad?.sizeBottomY
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (
|
|
576
|
+
top.width !== null &&
|
|
577
|
+
top.height !== null &&
|
|
578
|
+
bottom.width !== null &&
|
|
579
|
+
bottom.height !== null &&
|
|
580
|
+
(top.width !== bottom.width || top.height !== bottom.height)
|
|
581
|
+
) {
|
|
582
|
+
rows.push(
|
|
583
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
584
|
+
code: 'library.compatibility.pad-top-bottom-size-mismatch',
|
|
585
|
+
severity: 'warning',
|
|
586
|
+
target,
|
|
587
|
+
libraryFileName,
|
|
588
|
+
footprintName,
|
|
589
|
+
padDesignator: pad?.designator,
|
|
590
|
+
top,
|
|
591
|
+
bottom,
|
|
592
|
+
reason: 'top and bottom pad sizes differ'
|
|
593
|
+
})
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (
|
|
598
|
+
LibraryCompatibilityReportBuilder.#hasExplicitPadSize(pad) &&
|
|
599
|
+
(!LibraryCompatibilityReportBuilder.#padWidth(pad) ||
|
|
600
|
+
!LibraryCompatibilityReportBuilder.#padHeight(pad))
|
|
601
|
+
) {
|
|
602
|
+
rows.push(
|
|
603
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
604
|
+
code: 'library.compatibility.pad-zero-size',
|
|
605
|
+
severity: 'warning',
|
|
606
|
+
target,
|
|
607
|
+
libraryFileName,
|
|
608
|
+
footprintName,
|
|
609
|
+
padDesignator: pad?.designator,
|
|
610
|
+
reason: 'pad has an explicit zero width or height'
|
|
611
|
+
})
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const unknownShape =
|
|
616
|
+
LibraryCompatibilityReportBuilder.#unknownPadShape(pad)
|
|
617
|
+
if (unknownShape) {
|
|
618
|
+
rows.push(
|
|
619
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
620
|
+
code: 'library.compatibility.pad-unknown-shape',
|
|
621
|
+
severity: 'warning',
|
|
622
|
+
target,
|
|
623
|
+
libraryFileName,
|
|
624
|
+
footprintName,
|
|
625
|
+
padDesignator: pad?.designator,
|
|
626
|
+
shape: unknownShape,
|
|
627
|
+
reason: 'pad uses an unknown shape code'
|
|
628
|
+
})
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (
|
|
633
|
+
LibraryCompatibilityReportBuilder.#hasExplicitPadSize(pad) &&
|
|
634
|
+
(pad?.layerId === null || pad?.layerId === undefined)
|
|
635
|
+
) {
|
|
636
|
+
rows.push(
|
|
637
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
638
|
+
code: 'library.compatibility.pad-unknown-layer',
|
|
639
|
+
severity: 'warning',
|
|
640
|
+
target,
|
|
641
|
+
libraryFileName,
|
|
642
|
+
footprintName,
|
|
643
|
+
padDesignator: pad?.designator,
|
|
644
|
+
reason: 'pad has geometry but no resolved layer id'
|
|
645
|
+
})
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
rows.push(
|
|
650
|
+
...LibraryCompatibilityReportBuilder.#customPadDiagnosticRows(
|
|
651
|
+
libraryFileName,
|
|
652
|
+
footprintName,
|
|
653
|
+
pad,
|
|
654
|
+
target
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
return rows
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Builds custom-pad shape diagnostics for one pad.
|
|
663
|
+
* @param {string} libraryFileName Source library file name.
|
|
664
|
+
* @param {string} footprintName Footprint name.
|
|
665
|
+
* @param {object} pad Pad row.
|
|
666
|
+
* @param {string} target Issue target.
|
|
667
|
+
* @returns {object[]}
|
|
668
|
+
*/
|
|
669
|
+
static #customPadDiagnosticRows(
|
|
670
|
+
libraryFileName,
|
|
671
|
+
footprintName,
|
|
672
|
+
pad,
|
|
673
|
+
target
|
|
674
|
+
) {
|
|
675
|
+
const rows = []
|
|
676
|
+
const layers = Array.isArray(pad?.customShape?.layers)
|
|
677
|
+
? pad.customShape.layers
|
|
678
|
+
: []
|
|
679
|
+
if (!layers.length) return rows
|
|
680
|
+
|
|
681
|
+
const sideBounds = new Map()
|
|
682
|
+
|
|
683
|
+
for (const layer of layers) {
|
|
684
|
+
const bounds =
|
|
685
|
+
LibraryCompatibilityGeometry.customShapeLayerBounds(layer)
|
|
686
|
+
const side =
|
|
687
|
+
LibraryCompatibilityReportBuilder.#customShapeSide(layer)
|
|
688
|
+
|
|
689
|
+
if (bounds) {
|
|
690
|
+
rows.push(
|
|
691
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
692
|
+
code: 'library.compatibility.pad-custom-shape-outline',
|
|
693
|
+
severity: 'info',
|
|
694
|
+
target,
|
|
695
|
+
libraryFileName,
|
|
696
|
+
footprintName,
|
|
697
|
+
padDesignator: pad?.designator,
|
|
698
|
+
layer: layer?.layer,
|
|
699
|
+
layerId: layer?.layerId,
|
|
700
|
+
bounds,
|
|
701
|
+
reason: 'pad uses custom outline geometry'
|
|
702
|
+
})
|
|
703
|
+
)
|
|
704
|
+
if (side) sideBounds.set(side, bounds)
|
|
705
|
+
} else {
|
|
706
|
+
rows.push(
|
|
707
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
708
|
+
code: 'library.compatibility.pad-custom-shape-missing-geometry',
|
|
709
|
+
severity: 'warning',
|
|
710
|
+
target,
|
|
711
|
+
libraryFileName,
|
|
712
|
+
footprintName,
|
|
713
|
+
padDesignator: pad?.designator,
|
|
714
|
+
layer: layer?.layer,
|
|
715
|
+
layerId: layer?.layerId,
|
|
716
|
+
reason: 'custom pad shape layer has no resolved geometry'
|
|
717
|
+
})
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (LibraryCompatibilityGeometry.hasZeroArea(bounds)) {
|
|
722
|
+
rows.push(
|
|
723
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
724
|
+
code: 'library.compatibility.pad-custom-shape-zero-area',
|
|
725
|
+
severity: 'warning',
|
|
726
|
+
target,
|
|
727
|
+
libraryFileName,
|
|
728
|
+
footprintName,
|
|
729
|
+
padDesignator: pad?.designator,
|
|
730
|
+
layer: layer?.layer,
|
|
731
|
+
layerId: layer?.layerId,
|
|
732
|
+
bounds,
|
|
733
|
+
reason: 'custom pad shape outline has zero area'
|
|
734
|
+
})
|
|
735
|
+
)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const topBounds = sideBounds.get('top') || null
|
|
740
|
+
const bottomBounds = sideBounds.get('bottom') || null
|
|
741
|
+
if (
|
|
742
|
+
(topBounds || bottomBounds) &&
|
|
743
|
+
!LibraryCompatibilityGeometry.sameBounds(topBounds, bottomBounds)
|
|
744
|
+
) {
|
|
745
|
+
rows.push(
|
|
746
|
+
LibraryCompatibilityReportBuilder.#issue({
|
|
747
|
+
code: 'library.compatibility.pad-custom-shape-side-asymmetry',
|
|
748
|
+
severity: 'warning',
|
|
749
|
+
target,
|
|
750
|
+
libraryFileName,
|
|
751
|
+
footprintName,
|
|
752
|
+
padDesignator: pad?.designator,
|
|
753
|
+
topBounds,
|
|
754
|
+
bottomBounds,
|
|
755
|
+
reason: 'custom pad shape top and bottom outlines differ'
|
|
756
|
+
})
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return rows
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Resolves one custom-shape layer side.
|
|
765
|
+
* @param {object} layer Custom-shape layer row.
|
|
766
|
+
* @returns {'top' | 'bottom' | ''}
|
|
767
|
+
*/
|
|
768
|
+
static #customShapeSide(layer) {
|
|
769
|
+
const layerId = Number(layer?.layerId)
|
|
770
|
+
const layerText = String(layer?.layer || '').toLowerCase()
|
|
771
|
+
|
|
772
|
+
if (layerId === 1 || /\b(top|f\.cu|front)\b/u.test(layerText)) {
|
|
773
|
+
return 'top'
|
|
774
|
+
}
|
|
775
|
+
if (
|
|
776
|
+
layerId === 32 ||
|
|
777
|
+
layerId === 74 ||
|
|
778
|
+
/\b(bottom|b\.cu|back)\b/u.test(layerText)
|
|
779
|
+
) {
|
|
780
|
+
return 'bottom'
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return ''
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Finds the first unknown pad shape label.
|
|
788
|
+
* @param {object} pad Pad row.
|
|
789
|
+
* @returns {string | null}
|
|
790
|
+
*/
|
|
791
|
+
static #unknownPadShape(pad) {
|
|
792
|
+
const shapes = [
|
|
793
|
+
pad?.shapeTopName,
|
|
794
|
+
pad?.shapeMidName,
|
|
795
|
+
pad?.shapeBottomName,
|
|
796
|
+
pad?.padShapeNames?.top,
|
|
797
|
+
pad?.padShapeNames?.middle,
|
|
798
|
+
pad?.padShapeNames?.bottom
|
|
799
|
+
]
|
|
800
|
+
.map((shape) => String(shape || ''))
|
|
801
|
+
.filter(Boolean)
|
|
802
|
+
|
|
803
|
+
return shapes.find((shape) => shape.startsWith('unknown-')) || null
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Returns true when a pad exposes explicit size fields.
|
|
808
|
+
* @param {object} pad Pad row.
|
|
809
|
+
* @returns {boolean}
|
|
810
|
+
*/
|
|
811
|
+
static #hasExplicitPadSize(pad) {
|
|
812
|
+
return [
|
|
813
|
+
pad?.sizeTopX,
|
|
814
|
+
pad?.sizeTopY,
|
|
815
|
+
pad?.sizeMidX,
|
|
816
|
+
pad?.sizeMidY,
|
|
817
|
+
pad?.sizeBottomX,
|
|
818
|
+
pad?.sizeBottomY
|
|
819
|
+
].some((value) => value !== null && value !== undefined)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Resolves the effective pad width.
|
|
824
|
+
* @param {object} pad Pad row.
|
|
825
|
+
* @returns {number | null}
|
|
826
|
+
*/
|
|
827
|
+
static #padWidth(pad) {
|
|
828
|
+
return LibraryCompatibilityReportBuilder.#maxPositive([
|
|
829
|
+
pad?.sizeTopX,
|
|
830
|
+
pad?.sizeMidX,
|
|
831
|
+
pad?.sizeBottomX,
|
|
832
|
+
...(pad?.padStack?.layers || []).map((layer) => layer?.width),
|
|
833
|
+
...(pad?.localPadStack?.layers || []).map((layer) => layer?.width)
|
|
834
|
+
])
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Resolves the effective pad height.
|
|
839
|
+
* @param {object} pad Pad row.
|
|
840
|
+
* @returns {number | null}
|
|
841
|
+
*/
|
|
842
|
+
static #padHeight(pad) {
|
|
843
|
+
return LibraryCompatibilityReportBuilder.#maxPositive([
|
|
844
|
+
pad?.sizeTopY,
|
|
845
|
+
pad?.sizeMidY,
|
|
846
|
+
pad?.sizeBottomY,
|
|
847
|
+
...(pad?.padStack?.layers || []).map((layer) => layer?.height),
|
|
848
|
+
...(pad?.localPadStack?.layers || []).map((layer) => layer?.height)
|
|
849
|
+
])
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Finds the largest finite absolute value from a list.
|
|
854
|
+
* @param {unknown[]} values Candidate values.
|
|
855
|
+
* @returns {number | null}
|
|
856
|
+
*/
|
|
857
|
+
static #maxPositive(values) {
|
|
858
|
+
const finiteValues = (values || [])
|
|
859
|
+
.map((value) =>
|
|
860
|
+
LibraryCompatibilityReportBuilder.#finiteNumber(value)
|
|
861
|
+
)
|
|
862
|
+
.filter((value) => value !== null)
|
|
863
|
+
.map((value) => Math.abs(value))
|
|
864
|
+
|
|
865
|
+
if (!finiteValues.length) return null
|
|
866
|
+
return Math.max(...finiteValues)
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Converts one value to a finite number.
|
|
871
|
+
* @param {unknown} value Candidate number.
|
|
872
|
+
* @returns {number | null}
|
|
873
|
+
*/
|
|
874
|
+
static #finiteNumber(value) {
|
|
875
|
+
const numeric = Number(value)
|
|
876
|
+
return Number.isFinite(numeric) ? numeric : null
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Rounds floating-point report values.
|
|
881
|
+
* @param {number} value Numeric value.
|
|
882
|
+
* @returns {number}
|
|
883
|
+
*/
|
|
884
|
+
static #round(value) {
|
|
885
|
+
return Number(Number(value).toFixed(6))
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Builds one issue row.
|
|
890
|
+
* @param {object} issue Issue fields.
|
|
891
|
+
* @returns {object}
|
|
892
|
+
*/
|
|
893
|
+
static #issue(issue) {
|
|
894
|
+
return LibraryCompatibilityReportBuilder.#stripEmpty(issue)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Counts issues by severity.
|
|
899
|
+
* @param {object[]} issues Issue rows.
|
|
900
|
+
* @returns {{ error: number, warning: number, info: number }}
|
|
901
|
+
*/
|
|
902
|
+
static #issueSeverityCounts(issues) {
|
|
903
|
+
const counts = { error: 0, warning: 0, info: 0 }
|
|
904
|
+
|
|
905
|
+
for (const issue of issues || []) {
|
|
906
|
+
const severity = String(issue?.severity || 'warning').toLowerCase()
|
|
907
|
+
if (Object.prototype.hasOwnProperty.call(counts, severity)) {
|
|
908
|
+
counts[severity] += 1
|
|
909
|
+
} else {
|
|
910
|
+
counts.warning += 1
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
return counts
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Removes undefined and empty string fields from an object.
|
|
919
|
+
* @param {object} value Source row.
|
|
920
|
+
* @returns {object}
|
|
921
|
+
*/
|
|
922
|
+
static #stripEmpty(value) {
|
|
923
|
+
return Object.fromEntries(
|
|
924
|
+
Object.entries(value || {}).filter(
|
|
925
|
+
([, entryValue]) =>
|
|
926
|
+
entryValue !== undefined && entryValue !== ''
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
}
|