altium-toolkit 1.1.22 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +134 -23
  3. package/docs/model-format.md +174 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +511 -1
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +2 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/src/core/BinaryReader.mjs +213 -2
  35. package/src/core/altium/AltiumParser.mjs +352 -14
  36. package/src/core/altium/AltiumUnits.mjs +205 -0
  37. package/src/core/altium/AsciiRecordParser.mjs +9 -0
  38. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  39. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  40. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  41. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  42. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  43. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  44. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  45. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  46. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  47. package/src/core/altium/ParameterCollection.mjs +431 -0
  48. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  49. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  50. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  51. package/src/core/altium/ParserErrors.mjs +90 -0
  52. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  53. package/src/core/altium/ParserUtils.mjs +24 -0
  54. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  55. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  56. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  57. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  58. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  59. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  60. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  61. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  62. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  63. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  64. package/src/core/altium/PcbModelParser.mjs +223 -4
  65. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  66. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  67. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  68. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  69. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  70. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  71. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  72. package/src/core/altium/SchLibModelParser.mjs +840 -0
  73. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  74. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  75. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  76. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  77. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  78. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  79. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  80. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  81. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  82. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  83. package/src/core/altium/SchematicPinParser.mjs +12 -45
  84. package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
  85. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  86. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  87. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  88. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  89. package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
  90. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  91. package/src/core/altium/SchematicTextParser.mjs +28 -12
  92. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  93. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  94. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  95. package/src/parser.mjs +35 -1
  96. package/src/renderers.mjs +1 -0
  97. package/src/ui/SchematicShapeRenderer.mjs +49 -6
  98. package/src/ui/SchematicSvgRenderer.mjs +37 -8
  99. package/src/ui/SchematicTypography.mjs +4 -3
@@ -12,6 +12,7 @@ import { PcbComponentKindPolicy } from './PcbComponentKindPolicy.mjs'
12
12
  import { PcbComponentPrimitiveIndexer } from './PcbComponentPrimitiveIndexer.mjs'
13
13
  import { PcbCustomPadShapeParser } from './PcbCustomPadShapeParser.mjs'
14
14
  import { PcbDimensionParser } from './PcbDimensionParser.mjs'
15
+ import { PcbLayerStackCustomDataParser } from './PcbLayerStackCustomDataParser.mjs'
15
16
  import { PcbLayerStackReadModelBuilder } from './PcbLayerStackReadModelBuilder.mjs'
16
17
  import { PcbMechanicalLayerPairParser } from './PcbMechanicalLayerPairParser.mjs'
17
18
  import { PcbDefaultsParser } from './PcbDefaultsParser.mjs'
@@ -50,13 +51,21 @@ export class PcbModelParser {
50
51
  * resolved primitive netName fields keyed by numeric netIndex values.
51
52
  * @param {string} fileName
52
53
  * @param {{ raw: string, fields: Record<string, string | string[]>, sourceStream?: string }[]} records
53
- * @param {{ streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters?: object, viaStructures?: object, customPadShapes?: object, extendedPrimitiveInformation?: object, unions?: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
54
+ * @param {{ streamNames: string[], nativeStreams?: object, binaryPrimitives: Record<string, object[]>, primitiveParameters?: object, viaStructures?: object, customPadShapes?: object, extendedPrimitiveInformation?: object, unions?: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
54
55
  * @returns {{ schema: string, kind: 'pcb', fileType: 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], pcb: Record<string, unknown>, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
55
56
  */
56
57
  static parse(fileName, records, pcbExtraction = null) {
57
58
  const boardRecords = records.filter(
58
59
  (record) => record.sourceStream === 'Board6/Data'
59
60
  )
61
+ const stackCustomData =
62
+ PcbLayerStackCustomDataParser.parseBoardRecordsWithDiagnostics(
63
+ boardRecords
64
+ )
65
+ const layerStackBoardRecords = [
66
+ ...boardRecords,
67
+ ...stackCustomData.records
68
+ ]
60
69
  const boardRecord =
61
70
  boardRecords.find(
62
71
  (record) =>
@@ -64,7 +73,7 @@ export class PcbModelParser {
64
73
  record.sourceStream === 'Board6/Data'
65
74
  ) || records.find((record) => getField(record.fields, 'KIND0'))
66
75
  const layerRecord =
67
- boardRecords.find(
76
+ layerStackBoardRecords.find(
68
77
  (record) =>
69
78
  countMatchingKeys(
70
79
  record.fields,
@@ -100,7 +109,7 @@ export class PcbModelParser {
100
109
  )
101
110
  const layerSubstacks =
102
111
  PcbBoardRegionSemanticsParser.parseLayerSubstacks(
103
- boardRecords.map((record) => record.fields)
112
+ layerStackBoardRecords.map((record) => record.fields)
104
113
  )
105
114
  const primitiveLayers = AltiumLayoutParser.parsePrimitiveLayerNames(
106
115
  boardRecords.map((record) => record.fields)
@@ -120,6 +129,9 @@ export class PcbModelParser {
120
129
  PcbModelParser.#parseDifferentialPairRecords(records),
121
130
  classes
122
131
  )
132
+ const embeddedBoards =
133
+ PcbModelParser.#parseEmbeddedBoardRecords(records)
134
+ const rooms = PcbModelParser.#parseRoomRecords(records)
123
135
  const rules = PcbRuleParser.parse(records)
124
136
  const defaults = PcbDefaultsParser.parse(
125
137
  boardRecord?.fields || {},
@@ -190,6 +202,7 @@ export class PcbModelParser {
190
202
  const rawRecords = Array.isArray(pcbExtraction?.rawRecords)
191
203
  ? pcbExtraction.rawRecords
192
204
  : []
205
+ const nativeStreams = pcbExtraction?.nativeStreams || null
193
206
  const viaStructures = pcbExtraction?.viaStructures || {
194
207
  structures: [],
195
208
  links: [],
@@ -257,7 +270,7 @@ export class PcbModelParser {
257
270
  )
258
271
  const layerStackReadModel = PcbLayerStackReadModelBuilder.build({
259
272
  fileName,
260
- boardRecords,
273
+ boardRecords: layerStackBoardRecords,
261
274
  streamNames: pcbExtraction?.streamNames || [],
262
275
  layers,
263
276
  primitiveLayers,
@@ -406,6 +419,36 @@ export class PcbModelParser {
406
419
  })
407
420
  }
408
421
 
422
+ if (embeddedBoards.length || rooms.length) {
423
+ diagnostics.push({
424
+ severity: 'info',
425
+ message:
426
+ 'Recovered ' +
427
+ embeddedBoards.length +
428
+ ' embedded board ' +
429
+ PcbModelParser.#plural(
430
+ embeddedBoards.length,
431
+ 'placement',
432
+ 'placements'
433
+ ) +
434
+ ' and ' +
435
+ rooms.length +
436
+ ' placement ' +
437
+ PcbModelParser.#plural(rooms.length, 'room', 'rooms') +
438
+ '.'
439
+ })
440
+ }
441
+
442
+ for (const issue of stackCustomData.diagnostics) {
443
+ diagnostics.push({
444
+ severity: issue.severity || 'warning',
445
+ code: issue.code,
446
+ message: issue.message,
447
+ sourceStream: issue.sourceStream,
448
+ fieldName: issue.fieldName
449
+ })
450
+ }
451
+
409
452
  for (const issue of layerStackReadModel?.diagnostics || []) {
410
453
  diagnostics.push({
411
454
  severity: issue.severity || 'warning',
@@ -607,6 +650,10 @@ export class PcbModelParser {
607
650
  differentialPairData.differentialPairs.length,
608
651
  differentialPairClassCount:
609
652
  differentialPairData.differentialPairClasses.length,
653
+ ...(embeddedBoards.length
654
+ ? { embeddedBoardCount: embeddedBoards.length }
655
+ : {}),
656
+ ...(rooms.length ? { roomCount: rooms.length } : {}),
610
657
  ruleCount: rules.length,
611
658
  routeReviewGroupCount:
612
659
  reviewMetadata.summary.routeGroupCount || 0,
@@ -649,6 +696,11 @@ export class PcbModelParser {
649
696
  embeddedFontCount: extractedEmbeddedFonts.length,
650
697
  embeddedFileCount: embeddedFiles.files.length,
651
698
  rawRecordCount: rawRecords.length,
699
+ ...(nativeStreams?.summary?.streamCount
700
+ ? {
701
+ nativeStreamCount: nativeStreams.summary.streamCount
702
+ }
703
+ : {}),
652
704
  boardWidthMil: Math.round(boardOutline.widthMil),
653
705
  boardHeightMil: Math.round(boardOutline.heightMil)
654
706
  },
@@ -669,6 +721,8 @@ export class PcbModelParser {
669
721
  differentialPairs: differentialPairData.differentialPairs,
670
722
  differentialPairClasses:
671
723
  differentialPairData.differentialPairClasses,
724
+ ...(embeddedBoards.length ? { embeddedBoards } : {}),
725
+ ...(rooms.length ? { rooms } : {}),
672
726
  rules,
673
727
  ...(defaults ? { defaults } : {}),
674
728
  maskPaste,
@@ -698,6 +752,7 @@ export class PcbModelParser {
698
752
  embeddedFonts: extractedEmbeddedFonts,
699
753
  embeddedFiles,
700
754
  rawRecords,
755
+ ...(nativeStreams ? { nativeStreams } : {}),
701
756
  componentBodies,
702
757
  componentPrimitives,
703
758
  componentPrimitiveGroups,
@@ -995,6 +1050,136 @@ export class PcbModelParser {
995
1050
  return netNameByIndex
996
1051
  }
997
1052
 
1053
+ /**
1054
+ * Parses panel embedded-board placement records.
1055
+ * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records Source records.
1056
+ * @returns {object[]}
1057
+ */
1058
+ static #parseEmbeddedBoardRecords(records) {
1059
+ return (records || [])
1060
+ .filter((record) => record.sourceStream === 'EmbeddedBoards6/Data')
1061
+ .map((record, index) =>
1062
+ PcbModelParser.#normalizeEmbeddedBoardRecord(
1063
+ record.fields,
1064
+ index
1065
+ )
1066
+ )
1067
+ .filter(
1068
+ (embeddedBoard) =>
1069
+ embeddedBoard.documentPath || embeddedBoard.uniqueId
1070
+ )
1071
+ }
1072
+
1073
+ /**
1074
+ * Normalizes one embedded-board placement.
1075
+ * @param {Record<string, string | string[]>} fields Native fields.
1076
+ * @param {number} embeddedBoardIndex Fallback index.
1077
+ * @returns {object}
1078
+ */
1079
+ static #normalizeEmbeddedBoardRecord(fields, embeddedBoardIndex) {
1080
+ const documentPath = PcbModelParser.#firstField(fields, [
1081
+ 'DOCUMENTPATH',
1082
+ 'DOCUMENTNAME',
1083
+ 'FILENAME'
1084
+ ])
1085
+ const array = PcbModelParser.#stripEmptyObject({
1086
+ columns: PcbModelParser.#firstNumberField(fields, [
1087
+ 'COLCOUNT',
1088
+ 'COLUMNCOUNT',
1089
+ 'COLUMNS'
1090
+ ]),
1091
+ rows: PcbModelParser.#firstNumberField(fields, [
1092
+ 'ROWCOUNT',
1093
+ 'ROWS'
1094
+ ]),
1095
+ columnSpacingMil: PcbModelParser.#firstNumberField(fields, [
1096
+ 'COLUMNSPACING',
1097
+ 'COLSPACING',
1098
+ 'XSPACING'
1099
+ ]),
1100
+ rowSpacingMil: PcbModelParser.#firstNumberField(fields, [
1101
+ 'ROWSPACING',
1102
+ 'YSPACING'
1103
+ ])
1104
+ })
1105
+ const flags = PcbModelParser.#stripEmptyObject({
1106
+ keepIn: PcbModelParser.#optionalBooleanAny(fields, [
1107
+ 'KEEPIN',
1108
+ 'KEEPINSIDE'
1109
+ ]),
1110
+ keepout: PcbModelParser.#optionalBooleanAny(fields, ['KEEPOUT']),
1111
+ electrical: PcbModelParser.#optionalBooleanAny(fields, [
1112
+ 'ELECTRICAL'
1113
+ ]),
1114
+ preRoute: PcbModelParser.#optionalBooleanAny(fields, ['PREROUTE']),
1115
+ teardrops: PcbModelParser.#optionalBooleanAny(fields, [
1116
+ 'TEARDROPS'
1117
+ ]),
1118
+ polygons: PcbModelParser.#optionalBooleanAny(fields, ['POLYGONS']),
1119
+ transmitBoardShape: PcbModelParser.#optionalBooleanAny(fields, [
1120
+ 'TRANSMITBOARDSHAPE'
1121
+ ])
1122
+ })
1123
+
1124
+ return PcbModelParser.#stripEmptyObject({
1125
+ embeddedBoardIndex,
1126
+ documentPath,
1127
+ fileName: PcbModelParser.#basenameFromAltiumPath(documentPath),
1128
+ layer: PcbModelParser.#firstField(fields, ['LAYER', 'LAYERNAME']),
1129
+ rotation: PcbModelParser.#firstNumberField(fields, ['ROTATION']),
1130
+ mirrored: PcbModelParser.#optionalBooleanAny(fields, [
1131
+ 'MIRRORFLAG',
1132
+ 'MIRRORED'
1133
+ ]),
1134
+ originMode: PcbModelParser.#firstNumberField(fields, [
1135
+ 'ORIGINMODE'
1136
+ ]),
1137
+ scale: PcbModelParser.#firstNumberField(fields, ['SCALE']),
1138
+ ...(Object.keys(array).length ? { array } : {}),
1139
+ uniqueId: PcbModelParser.#firstField(fields, ['UNIQUEID', 'UID']),
1140
+ ...(Object.keys(flags).length ? { flags } : {})
1141
+ })
1142
+ }
1143
+
1144
+ /**
1145
+ * Parses PCB placement room records.
1146
+ * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records Source records.
1147
+ * @returns {object[]}
1148
+ */
1149
+ static #parseRoomRecords(records) {
1150
+ return (records || [])
1151
+ .filter((record) => record.sourceStream === 'Rooms6/Data')
1152
+ .map((record, index) =>
1153
+ PcbModelParser.#normalizeRoomRecord(record.fields, index)
1154
+ )
1155
+ .filter(
1156
+ (room) => room.name || room.uniqueId || room.members?.length
1157
+ )
1158
+ }
1159
+
1160
+ /**
1161
+ * Normalizes one placement room record.
1162
+ * @param {Record<string, string | string[]>} fields Native fields.
1163
+ * @param {number} roomIndex Fallback index.
1164
+ * @returns {object}
1165
+ */
1166
+ static #normalizeRoomRecord(fields, roomIndex) {
1167
+ const bounds = PcbModelParser.#stripEmptyObject({
1168
+ x1: PcbModelParser.#firstNumberField(fields, ['X1', 'LEFT']),
1169
+ y1: PcbModelParser.#firstNumberField(fields, ['Y1', 'TOP']),
1170
+ x2: PcbModelParser.#firstNumberField(fields, ['X2', 'RIGHT']),
1171
+ y2: PcbModelParser.#firstNumberField(fields, ['Y2', 'BOTTOM'])
1172
+ })
1173
+
1174
+ return PcbModelParser.#stripEmptyObject({
1175
+ roomIndex,
1176
+ name: PcbModelParser.#firstField(fields, ['NAME', 'ROOMNAME']),
1177
+ uniqueId: PcbModelParser.#firstField(fields, ['UNIQUEID', 'UID']),
1178
+ members: PcbModelParser.#parseClassMembers(fields),
1179
+ ...(Object.keys(bounds).length ? { bounds } : {})
1180
+ })
1181
+ }
1182
+
998
1183
  /**
999
1184
  * Parses native DifferentialPairs6/Data records in stream order.
1000
1185
  * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
@@ -1150,6 +1335,23 @@ export class PcbModelParser {
1150
1335
  return raw ? parseBoolean(raw) : null
1151
1336
  }
1152
1337
 
1338
+ /**
1339
+ * Reads the first optional boolean from a list of field aliases.
1340
+ * @param {Record<string, string | string[]>} fields Native fields.
1341
+ * @param {string[]} keys Candidate field names.
1342
+ * @returns {boolean | null}
1343
+ */
1344
+ static #optionalBooleanAny(fields, keys) {
1345
+ for (const key of keys || []) {
1346
+ const raw = getField(fields, key)
1347
+ if (raw) {
1348
+ return parseBoolean(raw)
1349
+ }
1350
+ }
1351
+
1352
+ return null
1353
+ }
1354
+
1153
1355
  /**
1154
1356
  * Builds a case-insensitive lookup key for class and pair names.
1155
1357
  * @param {string | undefined} value Raw lookup value.
@@ -1435,6 +1637,23 @@ export class PcbModelParser {
1435
1637
  return null
1436
1638
  }
1437
1639
 
1640
+ /**
1641
+ * Returns the first numeric field value from a list of field aliases.
1642
+ * @param {Record<string, string | string[]>} fields Source fields.
1643
+ * @param {string[]} keys Candidate keys.
1644
+ * @returns {number | null}
1645
+ */
1646
+ static #firstNumberField(fields, keys) {
1647
+ for (const key of keys || []) {
1648
+ const parsed = parseNumericField(fields, key)
1649
+ if (Number.isFinite(parsed)) {
1650
+ return parsed
1651
+ }
1652
+ }
1653
+
1654
+ return null
1655
+ }
1656
+
1438
1657
  /**
1439
1658
  * Returns the first non-empty printable field value.
1440
1659
  * @param {Record<string, string | string[]>} fields Source fields.
@@ -0,0 +1,270 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic per-net primitive ownership summaries for PCB models.
7
+ */
8
+ export class PcbNetMembershipReportBuilder {
9
+ static SCHEMA = 'altium-toolkit.pcb.net-membership.a1'
10
+
11
+ /**
12
+ * Builds a PCB net-membership report from normalized PCB primitives.
13
+ * @param {object} pcb Normalized PCB model.
14
+ * @returns {object}
15
+ */
16
+ static build(pcb = {}) {
17
+ const declaredNetNames =
18
+ PcbNetMembershipReportBuilder.#declaredNetNames(pcb)
19
+ const rowsByNet = new Map()
20
+ const unownedPrimitives = []
21
+ let primitiveCount = 0
22
+
23
+ for (const family of PcbNetMembershipReportBuilder.#families()) {
24
+ const rows = Array.isArray(pcb?.[family]) ? pcb[family] : []
25
+ primitiveCount += rows.length
26
+
27
+ rows.forEach((primitive, index) => {
28
+ const row = PcbNetMembershipReportBuilder.#primitiveRow(
29
+ family,
30
+ primitive,
31
+ index
32
+ )
33
+
34
+ if (!row.netName) {
35
+ unownedPrimitives.push(
36
+ PcbNetMembershipReportBuilder.#stripEmpty({
37
+ primitiveKey: row.primitiveKey,
38
+ family,
39
+ index,
40
+ layerId: row.layerId
41
+ })
42
+ )
43
+ return
44
+ }
45
+
46
+ if (!rowsByNet.has(row.netName)) {
47
+ rowsByNet.set(row.netName, [])
48
+ }
49
+ rowsByNet.get(row.netName).push(row)
50
+ })
51
+ }
52
+
53
+ const observedNetNames = new Set(rowsByNet.keys())
54
+ const byNet = [...rowsByNet.entries()]
55
+ .map(([netName, rows]) =>
56
+ PcbNetMembershipReportBuilder.#netRow(
57
+ netName,
58
+ rows,
59
+ declaredNetNames.has(netName)
60
+ )
61
+ )
62
+ .sort((left, right) =>
63
+ PcbNetMembershipReportBuilder.#naturalCompare(
64
+ left.netName,
65
+ right.netName
66
+ )
67
+ )
68
+ const emptyDeclaredNets = [...declaredNetNames]
69
+ .filter((netName) => !observedNetNames.has(netName))
70
+ .sort(PcbNetMembershipReportBuilder.#naturalCompare)
71
+ const undeclaredNets = [...observedNetNames]
72
+ .filter((netName) => !declaredNetNames.has(netName))
73
+ .sort(PcbNetMembershipReportBuilder.#naturalCompare)
74
+ const possibleUnroutedNets = byNet
75
+ .filter(
76
+ (net) =>
77
+ net.declared &&
78
+ net.padCount >= 2 &&
79
+ net.trackCount + net.arcCount + net.viaCount === 0
80
+ )
81
+ .map((net) => net.netName)
82
+
83
+ return {
84
+ schema: PcbNetMembershipReportBuilder.SCHEMA,
85
+ summary: {
86
+ declaredNetCount: declaredNetNames.size,
87
+ observedNetCount: observedNetNames.size,
88
+ matchedNetCount: [...observedNetNames].filter((netName) =>
89
+ declaredNetNames.has(netName)
90
+ ).length,
91
+ undeclaredNetCount: undeclaredNets.length,
92
+ emptyDeclaredNetCount: emptyDeclaredNets.length,
93
+ netCount: new Set([...declaredNetNames, ...observedNetNames])
94
+ .size,
95
+ primitiveCount,
96
+ ownedPrimitiveCount: primitiveCount - unownedPrimitives.length,
97
+ unownedPrimitiveCount: unownedPrimitives.length,
98
+ possibleUnroutedNetCount: possibleUnroutedNets.length
99
+ },
100
+ byNet,
101
+ emptyDeclaredNets,
102
+ undeclaredNets,
103
+ possibleUnroutedNets,
104
+ unownedPrimitives
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Returns primitive families that can carry net ownership.
110
+ * @returns {string[]}
111
+ */
112
+ static #families() {
113
+ return [
114
+ 'pads',
115
+ 'tracks',
116
+ 'arcs',
117
+ 'vias',
118
+ 'fills',
119
+ 'regions',
120
+ 'shapeBasedRegions',
121
+ 'polygons'
122
+ ]
123
+ }
124
+
125
+ /**
126
+ * Collects declared net names from the normalized net table.
127
+ * @param {object} pcb Normalized PCB model.
128
+ * @returns {Set<string>}
129
+ */
130
+ static #declaredNetNames(pcb) {
131
+ return new Set(
132
+ (Array.isArray(pcb?.nets) ? pcb.nets : [])
133
+ .map((net) => String(net?.name || '').trim())
134
+ .filter(Boolean)
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Builds one normalized primitive membership row.
140
+ * @param {string} family Primitive family name.
141
+ * @param {object} primitive Primitive row.
142
+ * @param {number} index Primitive index within its family.
143
+ * @returns {object}
144
+ */
145
+ static #primitiveRow(family, primitive, index) {
146
+ return PcbNetMembershipReportBuilder.#stripEmpty({
147
+ primitiveKey: family + '-' + index,
148
+ family,
149
+ index,
150
+ netName: PcbNetMembershipReportBuilder.#netName(primitive),
151
+ layerId: PcbNetMembershipReportBuilder.#layerId(primitive),
152
+ padDesignator:
153
+ family === 'pads'
154
+ ? PcbNetMembershipReportBuilder.#padDesignator(primitive)
155
+ : undefined
156
+ })
157
+ }
158
+
159
+ /**
160
+ * Builds one per-net aggregate row.
161
+ * @param {string} netName Net name.
162
+ * @param {object[]} rows Primitive rows owned by the net.
163
+ * @param {boolean} declared Whether the net is declared in the net table.
164
+ * @returns {object}
165
+ */
166
+ static #netRow(netName, rows, declared) {
167
+ const counts = Object.fromEntries(
168
+ PcbNetMembershipReportBuilder.#families().map((family) => [
169
+ family,
170
+ 0
171
+ ])
172
+ )
173
+ const layerIds = new Set()
174
+ const padDesignators = new Set()
175
+ const memberKeys = []
176
+
177
+ for (const row of rows) {
178
+ counts[row.family] = Number(counts[row.family] || 0) + 1
179
+ memberKeys.push(row.primitiveKey)
180
+ if (Number.isInteger(row.layerId)) {
181
+ layerIds.add(row.layerId)
182
+ }
183
+ if (row.padDesignator) {
184
+ padDesignators.add(row.padDesignator)
185
+ }
186
+ }
187
+
188
+ return {
189
+ netName,
190
+ declared,
191
+ totalPrimitiveCount: rows.length,
192
+ padCount: counts.pads,
193
+ trackCount: counts.tracks,
194
+ arcCount: counts.arcs,
195
+ viaCount: counts.vias,
196
+ fillCount: counts.fills,
197
+ regionCount: counts.regions,
198
+ shapeBasedRegionCount: counts.shapeBasedRegions,
199
+ polygonCount: counts.polygons,
200
+ layers: [...layerIds].sort((left, right) => left - right),
201
+ padDesignators: [...padDesignators].sort(
202
+ PcbNetMembershipReportBuilder.#naturalCompare
203
+ ),
204
+ memberKeys
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Resolves a primitive net name from normalized field spellings.
210
+ * @param {object} primitive Primitive row.
211
+ * @returns {string}
212
+ */
213
+ static #netName(primitive) {
214
+ return String(
215
+ primitive?.netName || primitive?.net || primitive?.netLabel || ''
216
+ ).trim()
217
+ }
218
+
219
+ /**
220
+ * Resolves a primitive layer id from normalized field spellings.
221
+ * @param {object} primitive Primitive row.
222
+ * @returns {number | undefined}
223
+ */
224
+ static #layerId(primitive) {
225
+ for (const key of ['layerId', 'layerCode', 'id', 'index']) {
226
+ const number = Number(primitive?.[key])
227
+ if (Number.isInteger(number)) {
228
+ return number
229
+ }
230
+ }
231
+
232
+ return undefined
233
+ }
234
+
235
+ /**
236
+ * Resolves a pad designator from normalized field spellings.
237
+ * @param {object} pad Pad row.
238
+ * @returns {string}
239
+ */
240
+ static #padDesignator(pad) {
241
+ return String(
242
+ pad?.designator || pad?.padNumber || pad?.pinName || pad?.name || ''
243
+ ).trim()
244
+ }
245
+
246
+ /**
247
+ * Sorts strings with numeric chunks in human order.
248
+ * @param {string} left Left value.
249
+ * @param {string} right Right value.
250
+ * @returns {number}
251
+ */
252
+ static #naturalCompare(left, right) {
253
+ return String(left).localeCompare(String(right), undefined, {
254
+ numeric: true
255
+ })
256
+ }
257
+
258
+ /**
259
+ * Removes undefined and blank-string values from a shallow object.
260
+ * @param {Record<string, unknown>} row Input row.
261
+ * @returns {Record<string, unknown>}
262
+ */
263
+ static #stripEmpty(row) {
264
+ return Object.fromEntries(
265
+ Object.entries(row).filter(
266
+ ([, value]) => value !== undefined && value !== ''
267
+ )
268
+ )
269
+ }
270
+ }