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.
@@ -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
+ }