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.
- package/README.md +34 -5
- package/docs/api.md +134 -23
- package/docs/model-format.md +174 -20
- package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
- package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
- package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
- package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
- package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
- package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
- package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +511 -1
- package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
- package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
- package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
- package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
- package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
- package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
- package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
- package/docs/testing.md +2 -0
- package/examples/README.md +21 -0
- package/examples/cli-utils.mjs +148 -0
- package/examples/corpus-smoke.mjs +523 -0
- package/examples/extract-bom.mjs +47 -0
- package/examples/generate-pnp.mjs +59 -0
- package/examples/inspect-board.mjs +70 -0
- package/examples/inspect-schematic.mjs +406 -0
- package/examples/library-catalog.mjs +115 -0
- package/examples/net-report.mjs +61 -0
- package/examples/validate-library.mjs +59 -0
- package/package.json +1 -1
- package/src/core/BinaryReader.mjs +213 -2
- package/src/core/altium/AltiumParser.mjs +352 -14
- package/src/core/altium/AltiumUnits.mjs +205 -0
- package/src/core/altium/AsciiRecordParser.mjs +9 -0
- package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
- package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
- package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
- package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
- package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
- package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
- package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
- package/src/core/altium/NormalizedModelSchema.mjs +3 -31
- package/src/core/altium/ParameterCollection.mjs +431 -0
- package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
- package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
- package/src/core/altium/ParserErrors.mjs +90 -0
- package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
- package/src/core/altium/ParserUtils.mjs +24 -0
- package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
- package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
- package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
- package/src/core/altium/PcbLayerGroups.mjs +308 -0
- package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
- package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
- package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
- package/src/core/altium/PcbLibModelParser.mjs +20 -4
- package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
- package/src/core/altium/PcbModelParser.mjs +223 -4
- package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
- package/src/core/altium/PcbStreamExtractor.mjs +130 -6
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
- package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
- package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
- package/src/core/altium/SchLibModelParser.mjs +840 -0
- package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
- package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
- package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
- package/src/core/altium/SchematicHarnessParser.mjs +9 -3
- package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
- package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
- package/src/core/altium/SchematicPinParser.mjs +12 -45
- package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
- package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
- package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
- package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
- package/src/core/altium/SchematicSheetParser.mjs +8 -2
- package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
- package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
- package/src/core/altium/SchematicTextParser.mjs +28 -12
- package/src/core/altium/SchematicTextRunParser.mjs +81 -0
- package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
- package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
- package/src/parser.mjs +35 -1
- package/src/renderers.mjs +1 -0
- package/src/ui/SchematicShapeRenderer.mjs +49 -6
- package/src/ui/SchematicSvgRenderer.mjs +37 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|