altium-toolkit 1.1.3 → 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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -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 +513 -3
  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 +7 -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/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,656 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicRecordTypeRegistry } from './SchematicRecordTypeRegistry.mjs'
6
+
7
+ const SCHEMATIC_FIELD_MAPS = Object.freeze({
8
+ label: {
9
+ Text: 'schematic.texts[].text',
10
+ 'Location.X': 'schematic.texts[].x',
11
+ 'Location.Y': 'schematic.texts[].y',
12
+ Color: 'schematic.texts[].color'
13
+ },
14
+ line: {
15
+ 'Location.X': 'schematic.lines[].x1',
16
+ 'Location.Y': 'schematic.lines[].y1',
17
+ 'Corner.X': 'schematic.lines[].x2',
18
+ 'Corner.Y': 'schematic.lines[].y2',
19
+ LineWidth: 'schematic.lines[].width',
20
+ Color: 'schematic.lines[].color',
21
+ LineStyle: 'schematic.lines[].lineStyle'
22
+ },
23
+ rectangle: {
24
+ 'Location.X': 'schematic.rectangles[].x',
25
+ 'Location.Y': 'schematic.rectangles[].y',
26
+ 'Corner.X': 'schematic.rectangles[].width',
27
+ 'Corner.Y': 'schematic.rectangles[].height',
28
+ LineWidth: 'schematic.rectangles[].lineWidth',
29
+ AreaColor: 'schematic.rectangles[].fill',
30
+ Color: 'schematic.rectangles[].color'
31
+ },
32
+ pin: {
33
+ Name: 'schematic.pins[].name',
34
+ Designator: 'schematic.pins[].designator',
35
+ 'Location.X': 'schematic.pins[].x',
36
+ 'Location.Y': 'schematic.pins[].y',
37
+ PinLength: 'schematic.pins[].length',
38
+ Electrical: 'schematic.pins[].electrical'
39
+ },
40
+ wire: {
41
+ LocationCount: 'schematic.lines[].pointCount',
42
+ LineWidth: 'schematic.lines[].width',
43
+ Color: 'schematic.lines[].color',
44
+ LineStyle: 'schematic.lines[].lineStyle'
45
+ },
46
+ component: {
47
+ LibReference: 'schematic.components[].libReference',
48
+ DesignItemId: 'schematic.components[].designItemId',
49
+ 'Location.X': 'schematic.components[].x',
50
+ 'Location.Y': 'schematic.components[].y',
51
+ CurrentPartID: 'schematic.components[].currentPartId',
52
+ PartCount: 'schematic.components[].partCount'
53
+ },
54
+ 'net-label': {
55
+ Text: 'schematic.nets[].labels[].text',
56
+ 'Location.X': 'schematic.nets[].labels[].x',
57
+ 'Location.Y': 'schematic.nets[].labels[].y'
58
+ },
59
+ port: {
60
+ Name: 'schematic.ports[].name',
61
+ 'Location.X': 'schematic.ports[].x',
62
+ 'Location.Y': 'schematic.ports[].y',
63
+ Width: 'schematic.ports[].width',
64
+ Height: 'schematic.ports[].height'
65
+ },
66
+ 'power-port': {
67
+ Text: 'schematic.texts[].text',
68
+ 'Location.X': 'schematic.texts[].x',
69
+ 'Location.Y': 'schematic.texts[].y',
70
+ Style: 'schematic.texts[].powerPortDirection'
71
+ },
72
+ junction: {
73
+ 'Location.X': 'schematic.junctions[].x',
74
+ 'Location.Y': 'schematic.junctions[].y',
75
+ Color: 'schematic.junctions[].color'
76
+ },
77
+ sheet: {
78
+ CustomX: 'schematic.sheet.width',
79
+ CustomY: 'schematic.sheet.height',
80
+ BorderOn: 'schematic.sheet.borderOn',
81
+ TitleBlockOn: 'schematic.sheet.titleBlockOn'
82
+ },
83
+ image: {
84
+ 'Location.X': 'schematic.images[].x',
85
+ 'Location.Y': 'schematic.images[].y',
86
+ 'Corner.X': 'schematic.images[].width',
87
+ 'Corner.Y': 'schematic.images[].height',
88
+ FileName: 'schematic.images[].fileName'
89
+ },
90
+ hyperlink: {
91
+ URL: 'schematic.hyperlinks[].url',
92
+ Text: 'schematic.hyperlinks[].text',
93
+ 'Location.X': 'schematic.hyperlinks[].x',
94
+ 'Location.Y': 'schematic.hyperlinks[].y',
95
+ FontID: 'schematic.hyperlinks[].fontId',
96
+ Color: 'schematic.hyperlinks[].color',
97
+ AreaColor: 'schematic.hyperlinks[].areaColor',
98
+ OwnerIndex: 'schematic.hyperlinks[].ownerIndex',
99
+ OwnerPartID: 'schematic.hyperlinks[].ownerPartId',
100
+ UniqueID: 'schematic.hyperlinks[].uniqueId',
101
+ IndexInSheet: 'schematic.hyperlinks[].indexInSheet',
102
+ Orientation: 'schematic.hyperlinks[].orientation',
103
+ Justification: 'schematic.hyperlinks[].justification',
104
+ IsNotAccesible: 'schematic.hyperlinks[].isNotAccessible',
105
+ IsNotAccessible: 'schematic.hyperlinks[].isNotAccessible'
106
+ }
107
+ })
108
+
109
+ const PCB_FIELD_MAPS = Object.freeze({
110
+ tracks: {
111
+ X1: 'pcb.tracks[].x1',
112
+ Y1: 'pcb.tracks[].y1',
113
+ X2: 'pcb.tracks[].x2',
114
+ Y2: 'pcb.tracks[].y2',
115
+ WIDTH: 'pcb.tracks[].width',
116
+ LAYER: 'pcb.tracks[].layerId',
117
+ NET: 'pcb.tracks[].netName',
118
+ UNIQUEID: 'pcb.tracks[].uniqueId'
119
+ },
120
+ pads: {
121
+ NAME: 'pcb.pads[].designator',
122
+ X: 'pcb.pads[].x',
123
+ Y: 'pcb.pads[].y',
124
+ HOLESIZE: 'pcb.pads[].holeSize',
125
+ LAYER: 'pcb.pads[].layerId',
126
+ NET: 'pcb.pads[].netName',
127
+ UNIQUEID: 'pcb.pads[].uniqueId'
128
+ },
129
+ vias: {
130
+ X: 'pcb.vias[].x',
131
+ Y: 'pcb.vias[].y',
132
+ SIZE: 'pcb.vias[].diameter',
133
+ HOLESIZE: 'pcb.vias[].holeSize',
134
+ LOWLAYER: 'pcb.vias[].layerStartId',
135
+ HIGHLAYER: 'pcb.vias[].layerEndId',
136
+ NET: 'pcb.vias[].netName',
137
+ UNIQUEID: 'pcb.vias[].uniqueId'
138
+ },
139
+ arcs: {
140
+ X: 'pcb.arcs[].x',
141
+ Y: 'pcb.arcs[].y',
142
+ RADIUS: 'pcb.arcs[].radius',
143
+ STARTANGLE: 'pcb.arcs[].startAngle',
144
+ ENDANGLE: 'pcb.arcs[].endAngle',
145
+ WIDTH: 'pcb.arcs[].width',
146
+ LAYER: 'pcb.arcs[].layerId',
147
+ UNIQUEID: 'pcb.arcs[].uniqueId'
148
+ },
149
+ fills: {
150
+ X1: 'pcb.fills[].x1',
151
+ Y1: 'pcb.fills[].y1',
152
+ X2: 'pcb.fills[].x2',
153
+ Y2: 'pcb.fills[].y2',
154
+ LAYER: 'pcb.fills[].layerId',
155
+ UNIQUEID: 'pcb.fills[].uniqueId'
156
+ },
157
+ regions: {
158
+ LAYER: 'pcb.regions[].layerId',
159
+ KIND: 'pcb.regions[].kind',
160
+ NAME: 'pcb.regions[].name',
161
+ UNIQUEID: 'pcb.regions[].uniqueId'
162
+ },
163
+ texts: {
164
+ TEXT: 'pcb.texts[].text',
165
+ X: 'pcb.texts[].x',
166
+ Y: 'pcb.texts[].y',
167
+ HEIGHT: 'pcb.texts[].height',
168
+ ROTATION: 'pcb.texts[].rotation',
169
+ LAYER: 'pcb.texts[].layerId',
170
+ FONTNAME: 'pcb.texts[].fontName',
171
+ UNIQUEID: 'pcb.texts[].uniqueId'
172
+ }
173
+ })
174
+
175
+ /**
176
+ * Builds field-level parser coverage reports from native-style records.
177
+ */
178
+ export class ParserFieldCoverageReportBuilder {
179
+ static SCHEMA = 'altium-toolkit.parser-field-coverage.a1'
180
+
181
+ /**
182
+ * Builds a parser field coverage report.
183
+ * @param {{ models?: object[], schematicRecords?: object[], pcbRecords?: object[] }} [input]
184
+ * @returns {object}
185
+ */
186
+ static build(input = {}) {
187
+ const families = [
188
+ ...ParserFieldCoverageReportBuilder.#schematicFamilies(input),
189
+ ...ParserFieldCoverageReportBuilder.#pcbFamilies(input)
190
+ ].sort(
191
+ (left, right) =>
192
+ ParserFieldCoverageReportBuilder.#domainRank(left.domain) -
193
+ ParserFieldCoverageReportBuilder.#domainRank(
194
+ right.domain
195
+ ) || left.family.localeCompare(right.family)
196
+ )
197
+
198
+ return {
199
+ schema: ParserFieldCoverageReportBuilder.SCHEMA,
200
+ summary: {
201
+ familyCount: families.length,
202
+ observedFieldCount:
203
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
204
+ families,
205
+ 'observedFields'
206
+ ),
207
+ mappedFieldCount:
208
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
209
+ families,
210
+ 'mappedFields'
211
+ ),
212
+ missingFieldCount:
213
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
214
+ families,
215
+ 'missingFields'
216
+ ),
217
+ unsupportedFieldCount:
218
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
219
+ families,
220
+ 'unsupportedFields'
221
+ ),
222
+ supportedFamilyCount: families.filter(
223
+ (family) => family.supported
224
+ ).length,
225
+ unsupportedFamilyCount: families.filter(
226
+ (family) => !family.supported
227
+ ).length,
228
+ completeFamilyCount: families.filter(
229
+ (family) => family.coverage.status === 'complete'
230
+ ).length,
231
+ partialFamilyCount: families.filter(
232
+ (family) => family.coverage.status === 'partial'
233
+ ).length,
234
+ mappedFieldCoverageRatio:
235
+ ParserFieldCoverageReportBuilder.#ratio(
236
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
237
+ families,
238
+ 'mappedFields'
239
+ ),
240
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
241
+ families,
242
+ 'mappedFields'
243
+ ) +
244
+ ParserFieldCoverageReportBuilder.#sumFieldCount(
245
+ families,
246
+ 'missingFields'
247
+ )
248
+ )
249
+ },
250
+ matrix: ParserFieldCoverageReportBuilder.#matrix(families),
251
+ families
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Builds schematic coverage family rows.
257
+ * @param {object} input Build input.
258
+ * @returns {object[]}
259
+ */
260
+ static #schematicFamilies(input) {
261
+ const groups = new Map()
262
+ for (const record of ParserFieldCoverageReportBuilder.#schematicRecords(
263
+ input
264
+ )) {
265
+ const descriptor = SchematicRecordTypeRegistry.get(
266
+ ParserFieldCoverageReportBuilder.#fieldValue(
267
+ record.fields,
268
+ 'RECORD'
269
+ )
270
+ )
271
+ const family = descriptor.name
272
+ const key = 'schematic:' + family
273
+ const group =
274
+ groups.get(key) ||
275
+ ParserFieldCoverageReportBuilder.#createGroup({
276
+ domain: 'schematic',
277
+ family,
278
+ supported: descriptor.supported,
279
+ source: record.sourceStream || ''
280
+ })
281
+
282
+ group.count += 1
283
+ group.sources.add(record.sourceStream || '')
284
+ ParserFieldCoverageReportBuilder.#collectFields({
285
+ group,
286
+ fields: record.fields || {},
287
+ mapping: SCHEMATIC_FIELD_MAPS[family] || {},
288
+ supported: descriptor.supported,
289
+ ignoredFields: descriptor.supported ? new Set(['RECORD']) : null
290
+ })
291
+ groups.set(key, group)
292
+ }
293
+
294
+ return [...groups.values()].map((group) =>
295
+ ParserFieldCoverageReportBuilder.#finalizeGroup(group)
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Builds PCB coverage family rows.
301
+ * @param {object} input Build input.
302
+ * @returns {object[]}
303
+ */
304
+ static #pcbFamilies(input) {
305
+ const groups = new Map()
306
+ for (const record of ParserFieldCoverageReportBuilder.#pcbRecords(
307
+ input
308
+ )) {
309
+ const family =
310
+ ParserFieldCoverageReportBuilder.#pcbFamilyForRecord(record)
311
+ const key = 'pcb:' + family
312
+ const group =
313
+ groups.get(key) ||
314
+ ParserFieldCoverageReportBuilder.#createGroup({
315
+ domain: 'pcb',
316
+ family,
317
+ supported: Boolean(PCB_FIELD_MAPS[family]),
318
+ source: record.sourceStream || ''
319
+ })
320
+
321
+ group.count += 1
322
+ group.sources.add(record.sourceStream || '')
323
+ ParserFieldCoverageReportBuilder.#collectFields({
324
+ group,
325
+ fields: record.fields || {},
326
+ mapping: PCB_FIELD_MAPS[family] || {},
327
+ supported: Boolean(PCB_FIELD_MAPS[family]),
328
+ ignoredFields: new Set(['RECORD', 'KIND'])
329
+ })
330
+ groups.set(key, group)
331
+ }
332
+
333
+ return [...groups.values()].map((group) =>
334
+ ParserFieldCoverageReportBuilder.#finalizeGroup(group)
335
+ )
336
+ }
337
+
338
+ /**
339
+ * Collects schematic source records from direct input and parser roots.
340
+ * @param {object} input Build input.
341
+ * @returns {object[]}
342
+ */
343
+ static #schematicRecords(input) {
344
+ return [
345
+ ...(Array.isArray(input?.schematicRecords)
346
+ ? input.schematicRecords
347
+ : []),
348
+ ...ParserFieldCoverageReportBuilder.#models(input).flatMap(
349
+ (model) => model?.schematic?.sourceRecords || []
350
+ )
351
+ ]
352
+ }
353
+
354
+ /**
355
+ * Collects PCB source records from direct input and parser roots.
356
+ * @param {object} input Build input.
357
+ * @returns {object[]}
358
+ */
359
+ static #pcbRecords(input) {
360
+ return [
361
+ ...(Array.isArray(input?.pcbRecords) ? input.pcbRecords : []),
362
+ ...ParserFieldCoverageReportBuilder.#models(input).flatMap(
363
+ (model) => model?.pcb?.sourceRecords || []
364
+ )
365
+ ]
366
+ }
367
+
368
+ /**
369
+ * Normalizes models input.
370
+ * @param {object} input Build input.
371
+ * @returns {object[]}
372
+ */
373
+ static #models(input) {
374
+ return Array.isArray(input?.models) ? input.models : []
375
+ }
376
+
377
+ /**
378
+ * Creates one mutable family group.
379
+ * @param {{ domain: string, family: string, supported: boolean, source: string }} options Group options.
380
+ * @returns {object}
381
+ */
382
+ static #createGroup(options) {
383
+ return {
384
+ domain: options.domain,
385
+ family: options.family,
386
+ source: options.source,
387
+ supported: options.supported,
388
+ count: 0,
389
+ sources: new Set(options.source ? [options.source] : []),
390
+ observedFields: new Set(),
391
+ mappedFields: new Map(),
392
+ missingFields: new Set(),
393
+ unsupportedFields: new Set()
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Adds fields from one record to a family group.
399
+ * @param {{ group: object, fields: object, mapping: object, supported: boolean, ignoredFields: Set<string> | null }} options Collection options.
400
+ * @returns {void}
401
+ */
402
+ static #collectFields(options) {
403
+ const mappingByLower = ParserFieldCoverageReportBuilder.#lowercaseMap(
404
+ options.mapping
405
+ )
406
+ const ignored = ParserFieldCoverageReportBuilder.#lowercaseSet(
407
+ options.ignoredFields || new Set()
408
+ )
409
+
410
+ for (const field of Object.keys(options.fields || {})) {
411
+ if (ignored.has(field.toLowerCase())) continue
412
+ options.group.observedFields.add(field)
413
+
414
+ if (!options.supported) {
415
+ options.group.unsupportedFields.add(field)
416
+ continue
417
+ }
418
+
419
+ const modelField = mappingByLower.get(field.toLowerCase())
420
+ if (modelField) {
421
+ options.group.mappedFields.set(field, modelField)
422
+ } else {
423
+ options.group.missingFields.add(field)
424
+ }
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Builds a case-insensitive mapping from source field to model field.
430
+ * @param {Record<string, string>} mapping Field mapping.
431
+ * @returns {Map<string, string>}
432
+ */
433
+ static #lowercaseMap(mapping) {
434
+ return new Map(
435
+ Object.entries(mapping || {}).map(([sourceField, modelField]) => [
436
+ sourceField.toLowerCase(),
437
+ modelField
438
+ ])
439
+ )
440
+ }
441
+
442
+ /**
443
+ * Builds a case-insensitive lookup set.
444
+ * @param {Set<string>} values Source values.
445
+ * @returns {Set<string>}
446
+ */
447
+ static #lowercaseSet(values) {
448
+ return new Set(
449
+ [...(values || new Set())].map((value) =>
450
+ String(value || '').toLowerCase()
451
+ )
452
+ )
453
+ }
454
+
455
+ /**
456
+ * Finalizes one mutable group into a JSON-friendly row.
457
+ * @param {object} group Mutable group.
458
+ * @returns {object}
459
+ */
460
+ static #finalizeGroup(group) {
461
+ const sources = ParserFieldCoverageReportBuilder.#sortedStrings([
462
+ ...group.sources
463
+ ])
464
+ const observedFields = ParserFieldCoverageReportBuilder.#sortedStrings([
465
+ ...group.observedFields
466
+ ])
467
+ const mappedFields = [...group.mappedFields.entries()]
468
+ .map(([sourceField, modelField]) => ({
469
+ sourceField,
470
+ modelField
471
+ }))
472
+ .sort((left, right) =>
473
+ left.sourceField.localeCompare(right.sourceField)
474
+ )
475
+ const missingFields = ParserFieldCoverageReportBuilder.#sortedStrings([
476
+ ...group.missingFields
477
+ ])
478
+ const unsupportedFields =
479
+ ParserFieldCoverageReportBuilder.#sortedStrings([
480
+ ...group.unsupportedFields
481
+ ])
482
+ const coverage = ParserFieldCoverageReportBuilder.#coverage({
483
+ supported: group.supported,
484
+ observedFields,
485
+ mappedFields,
486
+ missingFields,
487
+ unsupportedFields
488
+ })
489
+
490
+ return ParserFieldCoverageReportBuilder.#stripEmpty({
491
+ domain: group.domain,
492
+ family: group.family,
493
+ source: sources[0] || group.source,
494
+ sources: sources.length > 1 ? sources : undefined,
495
+ supported: group.supported,
496
+ count: group.count,
497
+ coverage,
498
+ observedFields,
499
+ mappedFields,
500
+ missingFields,
501
+ unsupportedFields
502
+ })
503
+ }
504
+
505
+ /**
506
+ * Builds compact coverage counts for one family.
507
+ * @param {{ supported: boolean, observedFields: string[], mappedFields: object[], missingFields: string[], unsupportedFields: string[] }} options Coverage inputs.
508
+ * @returns {object}
509
+ */
510
+ static #coverage(options) {
511
+ const mappedFieldCount = options.mappedFields.length
512
+ const missingFieldCount = options.missingFields.length
513
+ const unsupportedFieldCount = options.unsupportedFields.length
514
+ const supportedFieldCount = mappedFieldCount + missingFieldCount
515
+ const status = !options.supported
516
+ ? 'unsupported'
517
+ : missingFieldCount === 0
518
+ ? 'complete'
519
+ : 'partial'
520
+
521
+ return {
522
+ status,
523
+ observedFieldCount: options.observedFields.length,
524
+ mappedFieldCount,
525
+ missingFieldCount,
526
+ unsupportedFieldCount,
527
+ mappedRatio: options.supported
528
+ ? ParserFieldCoverageReportBuilder.#ratio(
529
+ mappedFieldCount,
530
+ supportedFieldCount
531
+ )
532
+ : null
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Builds a matrix view of family coverage.
538
+ * @param {object[]} families Coverage family rows.
539
+ * @returns {object}
540
+ */
541
+ static #matrix(families) {
542
+ return {
543
+ columns: [
544
+ 'domain',
545
+ 'family',
546
+ 'status',
547
+ 'observedFieldCount',
548
+ 'mappedFieldCount',
549
+ 'missingFieldCount',
550
+ 'unsupportedFieldCount',
551
+ 'mappedRatio'
552
+ ],
553
+ rows: families.map((family) => ({
554
+ domain: family.domain,
555
+ family: family.family,
556
+ status: family.coverage.status,
557
+ observedFieldCount: family.coverage.observedFieldCount,
558
+ mappedFieldCount: family.coverage.mappedFieldCount,
559
+ missingFieldCount: family.coverage.missingFieldCount,
560
+ unsupportedFieldCount: family.coverage.unsupportedFieldCount,
561
+ mappedRatio: family.coverage.mappedRatio
562
+ }))
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Resolves the PCB family for one source record.
568
+ * @param {object} record Source record.
569
+ * @returns {string}
570
+ */
571
+ static #pcbFamilyForRecord(record) {
572
+ const stream = String(record?.sourceStream || '').toLowerCase()
573
+ for (const family of Object.keys(PCB_FIELD_MAPS)) {
574
+ if (stream.startsWith(family.toLowerCase())) return family
575
+ }
576
+ return 'unknown'
577
+ }
578
+
579
+ /**
580
+ * Reads one case-insensitive field value.
581
+ * @param {object} fields Field map.
582
+ * @param {string} key Field key.
583
+ * @returns {unknown}
584
+ */
585
+ static #fieldValue(fields, key) {
586
+ if (!fields) return undefined
587
+ if (key in fields) return fields[key]
588
+ const normalized = key.toLowerCase()
589
+ const match = Object.keys(fields).find(
590
+ (field) => field.toLowerCase() === normalized
591
+ )
592
+ return match ? fields[match] : undefined
593
+ }
594
+
595
+ /**
596
+ * Sums one array field length across family rows.
597
+ * @param {object[]} families Family rows.
598
+ * @param {string} field Field name.
599
+ * @returns {number}
600
+ */
601
+ static #sumFieldCount(families, field) {
602
+ return families.reduce(
603
+ (sum, family) => sum + (family[field] || []).length,
604
+ 0
605
+ )
606
+ }
607
+
608
+ /**
609
+ * Computes a stable four-decimal ratio.
610
+ * @param {number} numerator Ratio numerator.
611
+ * @param {number} denominator Ratio denominator.
612
+ * @returns {number}
613
+ */
614
+ static #ratio(numerator, denominator) {
615
+ if (!denominator) {
616
+ return 0
617
+ }
618
+
619
+ return Math.round((numerator / denominator) * 10000) / 10000
620
+ }
621
+
622
+ /**
623
+ * Returns the sort rank for a parser domain.
624
+ * @param {string} domain Parser domain.
625
+ * @returns {number}
626
+ */
627
+ static #domainRank(domain) {
628
+ return domain === 'schematic' ? 0 : 1
629
+ }
630
+
631
+ /**
632
+ * Sorts strings deterministically.
633
+ * @param {string[]} values Values to sort.
634
+ * @returns {string[]}
635
+ */
636
+ static #sortedStrings(values) {
637
+ return values
638
+ .filter((value) => value !== '')
639
+ .sort((left, right) => left.localeCompare(right))
640
+ }
641
+
642
+ /**
643
+ * Removes empty optional arrays and undefined values.
644
+ * @param {object} row Row to strip.
645
+ * @returns {object}
646
+ */
647
+ static #stripEmpty(row) {
648
+ return Object.fromEntries(
649
+ Object.entries(row).filter(([, value]) => {
650
+ if (value === undefined) return false
651
+ if (Array.isArray(value) && value.length === 0) return false
652
+ return true
653
+ })
654
+ )
655
+ }
656
+ }
@@ -91,6 +91,30 @@ export class ParserUtils {
91
91
  return whole + (fraction / 100000) * sign
92
92
  }
93
93
 
94
+ /**
95
+ * Parses schematic line-width codes into normalized drawing stroke widths.
96
+ * @param {Record<string, string | string[]> | undefined} fields
97
+ * @param {string} [key]
98
+ * @returns {number}
99
+ */
100
+ static parseSchematicLineWidth(fields, key = 'LineWidth') {
101
+ const value = ParserUtils.parseNumericField(fields, key)
102
+
103
+ switch (value) {
104
+ case null:
105
+ case 0:
106
+ return 0.4
107
+ case 1:
108
+ return 1
109
+ case 2:
110
+ return 2
111
+ case 3:
112
+ return 4
113
+ default:
114
+ return value > 0 ? value : 0.4
115
+ }
116
+ }
117
+
94
118
  /**
95
119
  * Parses an Altium-style boolean flag.
96
120
  * @param {string | string[] | undefined} raw