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
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds fixture-oriented parser value verification reports from path maps.
|
|
7
|
+
*/
|
|
8
|
+
export class ParserValueVerificationReportBuilder {
|
|
9
|
+
static SCHEMA = 'altium-toolkit.parser-value-verification.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a value verification report.
|
|
13
|
+
* @param {{ cases?: object[] }} [input] Report input.
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(input = {}) {
|
|
17
|
+
const cases = (input.cases || []).map((entry, index) =>
|
|
18
|
+
ParserValueVerificationReportBuilder.#caseReport(entry, index)
|
|
19
|
+
)
|
|
20
|
+
const failures = cases.flatMap((entry) =>
|
|
21
|
+
entry.failures.map((failure) => ({
|
|
22
|
+
caseKey: entry.key,
|
|
23
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
24
|
+
...failure
|
|
25
|
+
}))
|
|
26
|
+
)
|
|
27
|
+
const passedCount = cases.reduce(
|
|
28
|
+
(total, entry) => total + entry.passedCount,
|
|
29
|
+
0
|
|
30
|
+
)
|
|
31
|
+
const failedCount = cases.reduce(
|
|
32
|
+
(total, entry) => total + entry.failedCount,
|
|
33
|
+
0
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
schema: ParserValueVerificationReportBuilder.SCHEMA,
|
|
38
|
+
summary: {
|
|
39
|
+
caseCount: cases.length,
|
|
40
|
+
assertionCount: cases.reduce(
|
|
41
|
+
(total, entry) => total + entry.assertionCount,
|
|
42
|
+
0
|
|
43
|
+
),
|
|
44
|
+
passedCount,
|
|
45
|
+
failedCount,
|
|
46
|
+
mismatchCount: failures.filter(
|
|
47
|
+
(failure) => failure.status === 'mismatch'
|
|
48
|
+
).length,
|
|
49
|
+
missingCount: failures.filter(
|
|
50
|
+
(failure) => failure.status === 'missing'
|
|
51
|
+
).length,
|
|
52
|
+
status: failedCount ? 'failed' : 'passed'
|
|
53
|
+
},
|
|
54
|
+
cases,
|
|
55
|
+
failures
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds one case report.
|
|
61
|
+
* @param {object} entry Case input.
|
|
62
|
+
* @param {number} index Case index.
|
|
63
|
+
* @returns {object}
|
|
64
|
+
*/
|
|
65
|
+
static #caseReport(entry, index) {
|
|
66
|
+
const key = String(entry.key || 'case-' + index)
|
|
67
|
+
const actual = entry.actual || entry.model || entry.documentModel || {}
|
|
68
|
+
const assertionInput =
|
|
69
|
+
entry.expectedValues || entry.expected || entry.assertions || []
|
|
70
|
+
const assertions =
|
|
71
|
+
ParserValueVerificationReportBuilder.#assertions(assertionInput)
|
|
72
|
+
const failures = []
|
|
73
|
+
const passedAssertions = []
|
|
74
|
+
|
|
75
|
+
for (const assertion of assertions) {
|
|
76
|
+
const actualValue = ParserValueVerificationReportBuilder.#pathValue(
|
|
77
|
+
actual,
|
|
78
|
+
assertion.path
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if (!actualValue.present) {
|
|
82
|
+
failures.push(
|
|
83
|
+
ParserValueVerificationReportBuilder.#failure(
|
|
84
|
+
assertion,
|
|
85
|
+
actualValue,
|
|
86
|
+
'missing'
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
!ParserValueVerificationReportBuilder.#equal(
|
|
94
|
+
actualValue.value,
|
|
95
|
+
assertion.expected
|
|
96
|
+
)
|
|
97
|
+
) {
|
|
98
|
+
failures.push(
|
|
99
|
+
ParserValueVerificationReportBuilder.#failure(
|
|
100
|
+
assertion,
|
|
101
|
+
actualValue,
|
|
102
|
+
'mismatch'
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
passedAssertions.push({
|
|
109
|
+
path: assertion.path,
|
|
110
|
+
expected: assertion.expected,
|
|
111
|
+
...(assertion.label ? { label: assertion.label } : {})
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return ParserValueVerificationReportBuilder.#stripUndefined({
|
|
116
|
+
key,
|
|
117
|
+
source: entry.source,
|
|
118
|
+
status: failures.length ? 'failed' : 'passed',
|
|
119
|
+
assertionCount: assertions.length,
|
|
120
|
+
passedCount: assertions.length - failures.length,
|
|
121
|
+
failedCount: failures.length,
|
|
122
|
+
failures,
|
|
123
|
+
assertions:
|
|
124
|
+
ParserValueVerificationReportBuilder.#shouldIncludeAssertions(
|
|
125
|
+
assertionInput
|
|
126
|
+
)
|
|
127
|
+
? passedAssertions
|
|
128
|
+
: undefined
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Converts path-map or assertion-list input into assertion rows.
|
|
134
|
+
* @param {Record<string, unknown> | object[]} input Assertion input.
|
|
135
|
+
* @returns {{ path: string, expected: unknown, label?: string }[]}
|
|
136
|
+
*/
|
|
137
|
+
static #assertions(input) {
|
|
138
|
+
if (Array.isArray(input)) {
|
|
139
|
+
return input
|
|
140
|
+
.filter((entry) => entry?.path)
|
|
141
|
+
.map((entry) => ({
|
|
142
|
+
path: String(entry.path),
|
|
143
|
+
expected: entry.expected,
|
|
144
|
+
label: entry.label
|
|
145
|
+
}))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Object.entries(input || {}).map(([path, expected]) => ({
|
|
149
|
+
path,
|
|
150
|
+
expected
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Returns true when passing assertion detail should be emitted.
|
|
156
|
+
* @param {Record<string, unknown> | object[]} input Assertion input.
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
static #shouldIncludeAssertions(input) {
|
|
160
|
+
return Array.isArray(input)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Builds one failure row.
|
|
165
|
+
* @param {{ path: string, expected: unknown }} assertion Expected assertion.
|
|
166
|
+
* @param {{ present: boolean, value: unknown }} actualValue Actual path lookup.
|
|
167
|
+
* @param {'missing' | 'mismatch'} status Failure status.
|
|
168
|
+
* @returns {object}
|
|
169
|
+
*/
|
|
170
|
+
static #failure(assertion, actualValue, status) {
|
|
171
|
+
const actual = actualValue.present
|
|
172
|
+
? ParserValueVerificationReportBuilder.#jsonValue(actualValue.value)
|
|
173
|
+
: null
|
|
174
|
+
const expected = ParserValueVerificationReportBuilder.#jsonValue(
|
|
175
|
+
assertion.expected
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
path: assertion.path,
|
|
180
|
+
status,
|
|
181
|
+
expected,
|
|
182
|
+
actual,
|
|
183
|
+
message:
|
|
184
|
+
status === 'missing'
|
|
185
|
+
? 'Expected ' +
|
|
186
|
+
assertion.path +
|
|
187
|
+
' to equal ' +
|
|
188
|
+
ParserValueVerificationReportBuilder.#formatValue(
|
|
189
|
+
expected
|
|
190
|
+
) +
|
|
191
|
+
' but the path was missing.'
|
|
192
|
+
: 'Expected ' +
|
|
193
|
+
assertion.path +
|
|
194
|
+
' to equal ' +
|
|
195
|
+
ParserValueVerificationReportBuilder.#formatValue(
|
|
196
|
+
expected
|
|
197
|
+
) +
|
|
198
|
+
' but received ' +
|
|
199
|
+
ParserValueVerificationReportBuilder.#formatValue(
|
|
200
|
+
actual
|
|
201
|
+
) +
|
|
202
|
+
'.'
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolves one dot/bracket path from an object.
|
|
208
|
+
* @param {object} source Source object.
|
|
209
|
+
* @param {string} path Path expression.
|
|
210
|
+
* @returns {{ present: boolean, value: unknown }}
|
|
211
|
+
*/
|
|
212
|
+
static #pathValue(source, path) {
|
|
213
|
+
const parts = String(path || '')
|
|
214
|
+
.replace(/\[(\d+)\]/gu, '.$1')
|
|
215
|
+
.split('.')
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
let current = source
|
|
218
|
+
|
|
219
|
+
for (const part of parts) {
|
|
220
|
+
if (Array.isArray(current)) {
|
|
221
|
+
const index = Number(part)
|
|
222
|
+
if (
|
|
223
|
+
!Number.isInteger(index) ||
|
|
224
|
+
index < 0 ||
|
|
225
|
+
index >= current.length
|
|
226
|
+
) {
|
|
227
|
+
return { present: false, value: undefined }
|
|
228
|
+
}
|
|
229
|
+
current = current[index]
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!current || typeof current !== 'object' || !(part in current)) {
|
|
234
|
+
return { present: false, value: undefined }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
current = current[part]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { present: true, value: current }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Compares two values by stable JSON representation.
|
|
245
|
+
* @param {unknown} left First value.
|
|
246
|
+
* @param {unknown} right Second value.
|
|
247
|
+
* @returns {boolean}
|
|
248
|
+
*/
|
|
249
|
+
static #equal(left, right) {
|
|
250
|
+
return (
|
|
251
|
+
ParserValueVerificationReportBuilder.#stableJson(left) ===
|
|
252
|
+
ParserValueVerificationReportBuilder.#stableJson(right)
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Converts values into JSON-safe report payloads.
|
|
258
|
+
* @param {unknown} value Source value.
|
|
259
|
+
* @returns {unknown}
|
|
260
|
+
*/
|
|
261
|
+
static #jsonValue(value) {
|
|
262
|
+
return value === undefined ? null : value
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Formats a value for a concise diagnostic message.
|
|
267
|
+
* @param {unknown} value Report value.
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
static #formatValue(value) {
|
|
271
|
+
return JSON.stringify(value)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Produces a stable JSON representation for comparison.
|
|
276
|
+
* @param {unknown} value Source value.
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
static #stableJson(value) {
|
|
280
|
+
if (!value || typeof value !== 'object') {
|
|
281
|
+
return JSON.stringify(value)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
return (
|
|
286
|
+
'[' +
|
|
287
|
+
value
|
|
288
|
+
.map((entry) =>
|
|
289
|
+
ParserValueVerificationReportBuilder.#stableJson(entry)
|
|
290
|
+
)
|
|
291
|
+
.join(',') +
|
|
292
|
+
']'
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
'{' +
|
|
298
|
+
Object.keys(value)
|
|
299
|
+
.sort()
|
|
300
|
+
.map(
|
|
301
|
+
(key) =>
|
|
302
|
+
JSON.stringify(key) +
|
|
303
|
+
':' +
|
|
304
|
+
ParserValueVerificationReportBuilder.#stableJson(
|
|
305
|
+
value[key]
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
.join(',') +
|
|
309
|
+
'}'
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Removes undefined properties from one row.
|
|
315
|
+
* @param {object} row Source row.
|
|
316
|
+
* @returns {object}
|
|
317
|
+
*/
|
|
318
|
+
static #stripUndefined(row) {
|
|
319
|
+
return Object.fromEntries(
|
|
320
|
+
Object.entries(row).filter(([, value]) => value !== undefined)
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds deterministic summaries for normalized PCB class records.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbClassReportBuilder {
|
|
9
|
+
static SCHEMA = 'altium-toolkit.pcb.class-report.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a PCB class report.
|
|
13
|
+
* @param {object} pcb Normalized PCB model.
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(pcb = {}) {
|
|
17
|
+
const indexes = PcbClassReportBuilder.#memberIndexes(pcb)
|
|
18
|
+
const classes = (Array.isArray(pcb?.classes) ? pcb.classes : []).map(
|
|
19
|
+
(classRecord, index) =>
|
|
20
|
+
PcbClassReportBuilder.#classRow(classRecord, index, indexes)
|
|
21
|
+
)
|
|
22
|
+
const issues = PcbClassReportBuilder.#issues(classes)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
schema: PcbClassReportBuilder.SCHEMA,
|
|
26
|
+
summary: PcbClassReportBuilder.#summary(classes, issues),
|
|
27
|
+
byKind: PcbClassReportBuilder.#byKind(classes),
|
|
28
|
+
classes,
|
|
29
|
+
issues
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Builds lookup sets used to resolve class members.
|
|
35
|
+
* @param {object} pcb Normalized PCB model.
|
|
36
|
+
* @returns {Record<string, Set<string>>}
|
|
37
|
+
*/
|
|
38
|
+
static #memberIndexes(pcb) {
|
|
39
|
+
return {
|
|
40
|
+
net: PcbClassReportBuilder.#namedSet(pcb?.nets, ['name']),
|
|
41
|
+
component: PcbClassReportBuilder.#namedSet(pcb?.components, [
|
|
42
|
+
'designator',
|
|
43
|
+
'name'
|
|
44
|
+
]),
|
|
45
|
+
pad: PcbClassReportBuilder.#namedSet(pcb?.pads, [
|
|
46
|
+
'designator',
|
|
47
|
+
'padNumber',
|
|
48
|
+
'pinName',
|
|
49
|
+
'name'
|
|
50
|
+
]),
|
|
51
|
+
layer: PcbClassReportBuilder.#namedSet(
|
|
52
|
+
[
|
|
53
|
+
...PcbClassReportBuilder.#array(pcb?.layers),
|
|
54
|
+
...PcbClassReportBuilder.#array(pcb?.primitiveLayers)
|
|
55
|
+
],
|
|
56
|
+
['name', 'displayName', 'id', 'layerId']
|
|
57
|
+
),
|
|
58
|
+
polygon: PcbClassReportBuilder.#namedSet(pcb?.polygons, [
|
|
59
|
+
'name',
|
|
60
|
+
'netName'
|
|
61
|
+
]),
|
|
62
|
+
'diff-pair': PcbClassReportBuilder.#namedSet(
|
|
63
|
+
pcb?.differentialPairs,
|
|
64
|
+
['name']
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns an array value or an empty array for non-array input.
|
|
71
|
+
* @param {unknown} value Candidate array value.
|
|
72
|
+
* @returns {unknown[]}
|
|
73
|
+
*/
|
|
74
|
+
static #array(value) {
|
|
75
|
+
return Array.isArray(value) ? value : []
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds a set of names from candidate row fields.
|
|
80
|
+
* @param {object[] | undefined} rows Candidate rows.
|
|
81
|
+
* @param {string[]} keys Candidate field names.
|
|
82
|
+
* @returns {Set<string>}
|
|
83
|
+
*/
|
|
84
|
+
static #namedSet(rows, keys) {
|
|
85
|
+
const values = new Set()
|
|
86
|
+
|
|
87
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
const value = String(row?.[key] ?? '').trim()
|
|
90
|
+
if (value) {
|
|
91
|
+
values.add(value)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return values
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds one class summary row.
|
|
101
|
+
* @param {object} classRecord Normalized class record.
|
|
102
|
+
* @param {number} index Class index.
|
|
103
|
+
* @param {Record<string, Set<string>>} indexes Member lookup sets.
|
|
104
|
+
* @returns {object}
|
|
105
|
+
*/
|
|
106
|
+
static #classRow(classRecord, index, indexes) {
|
|
107
|
+
const kindName = PcbClassReportBuilder.#kindName(classRecord)
|
|
108
|
+
const members = (
|
|
109
|
+
Array.isArray(classRecord?.members) ? classRecord.members : []
|
|
110
|
+
)
|
|
111
|
+
.map((member) => String(member || '').trim())
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
const resolvedMembers = []
|
|
114
|
+
const unresolvedMembers = []
|
|
115
|
+
|
|
116
|
+
for (const member of members) {
|
|
117
|
+
const resolved = PcbClassReportBuilder.#resolveMember(
|
|
118
|
+
member,
|
|
119
|
+
kindName,
|
|
120
|
+
indexes
|
|
121
|
+
)
|
|
122
|
+
if (resolved) {
|
|
123
|
+
resolvedMembers.push(resolved)
|
|
124
|
+
} else {
|
|
125
|
+
unresolvedMembers.push(member)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return PcbClassReportBuilder.#stripEmpty({
|
|
130
|
+
classIndex: Number.isInteger(classRecord?.classIndex)
|
|
131
|
+
? classRecord.classIndex
|
|
132
|
+
: index,
|
|
133
|
+
name: String(classRecord?.name || '').trim(),
|
|
134
|
+
kind: Number.isFinite(Number(classRecord?.kind))
|
|
135
|
+
? Number(classRecord.kind)
|
|
136
|
+
: undefined,
|
|
137
|
+
kindName,
|
|
138
|
+
enabled: classRecord?.enabled !== false,
|
|
139
|
+
memberCount: members.length,
|
|
140
|
+
resolvedMemberCount: resolvedMembers.length,
|
|
141
|
+
unresolvedMemberCount: unresolvedMembers.length,
|
|
142
|
+
members,
|
|
143
|
+
resolvedMembers,
|
|
144
|
+
unresolvedMembers,
|
|
145
|
+
references: PcbClassReportBuilder.#references(resolvedMembers),
|
|
146
|
+
uniqueId: String(classRecord?.uniqueId || '').trim()
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolves the normalized class kind name.
|
|
152
|
+
* @param {object} classRecord Normalized class record.
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
static #kindName(classRecord) {
|
|
156
|
+
const kindName = String(classRecord?.kindName || '').trim()
|
|
157
|
+
if (kindName) {
|
|
158
|
+
return kindName
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
{
|
|
163
|
+
0: 'net',
|
|
164
|
+
1: 'component',
|
|
165
|
+
2: 'from-to',
|
|
166
|
+
3: 'pad',
|
|
167
|
+
4: 'layer',
|
|
168
|
+
6: 'diff-pair',
|
|
169
|
+
7: 'polygon'
|
|
170
|
+
}[Number(classRecord?.kind)] || 'unknown'
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolves one class member against the expected or known object indexes.
|
|
176
|
+
* @param {string} member Member name.
|
|
177
|
+
* @param {string} kindName Class kind name.
|
|
178
|
+
* @param {Record<string, Set<string>>} indexes Member lookup sets.
|
|
179
|
+
* @returns {{ name: string, kind: string } | null}
|
|
180
|
+
*/
|
|
181
|
+
static #resolveMember(member, kindName, indexes) {
|
|
182
|
+
const expectedKind = kindName === 'diff-pair' ? 'diff-pair' : kindName
|
|
183
|
+
const expectedIndex = indexes[expectedKind]
|
|
184
|
+
if (expectedIndex?.has(member)) {
|
|
185
|
+
return { name: member, kind: expectedKind }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (kindName !== 'unknown') {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const [kind, values] of Object.entries(indexes)) {
|
|
193
|
+
if (values.has(member)) {
|
|
194
|
+
return { name: member, kind }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Builds grouped reference lists from resolved members.
|
|
203
|
+
* @param {{ name: string, kind: string }[]} resolvedMembers Resolved member rows.
|
|
204
|
+
* @returns {object}
|
|
205
|
+
*/
|
|
206
|
+
static #references(resolvedMembers) {
|
|
207
|
+
const references = {
|
|
208
|
+
netNames: [],
|
|
209
|
+
componentDesignators: [],
|
|
210
|
+
padDesignators: [],
|
|
211
|
+
layerNames: [],
|
|
212
|
+
polygonNames: [],
|
|
213
|
+
differentialPairNames: []
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const member of resolvedMembers) {
|
|
217
|
+
if (member.kind === 'net') references.netNames.push(member.name)
|
|
218
|
+
if (member.kind === 'component') {
|
|
219
|
+
references.componentDesignators.push(member.name)
|
|
220
|
+
}
|
|
221
|
+
if (member.kind === 'pad')
|
|
222
|
+
references.padDesignators.push(member.name)
|
|
223
|
+
if (member.kind === 'layer') references.layerNames.push(member.name)
|
|
224
|
+
if (member.kind === 'polygon') {
|
|
225
|
+
references.polygonNames.push(member.name)
|
|
226
|
+
}
|
|
227
|
+
if (member.kind === 'diff-pair') {
|
|
228
|
+
references.differentialPairNames.push(member.name)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return Object.fromEntries(
|
|
233
|
+
Object.entries(references)
|
|
234
|
+
.map(([key, values]) => [
|
|
235
|
+
key,
|
|
236
|
+
[...values].sort(PcbClassReportBuilder.#naturalCompare)
|
|
237
|
+
])
|
|
238
|
+
.filter(([, values]) => values.length > 0)
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Builds deterministic class issues.
|
|
244
|
+
* @param {object[]} classes Class rows.
|
|
245
|
+
* @returns {object[]}
|
|
246
|
+
*/
|
|
247
|
+
static #issues(classes) {
|
|
248
|
+
const issues = []
|
|
249
|
+
|
|
250
|
+
for (const classRow of classes) {
|
|
251
|
+
for (const member of classRow.unresolvedMembers || []) {
|
|
252
|
+
issues.push({
|
|
253
|
+
code: 'pcb.class.unresolved-member',
|
|
254
|
+
severity: 'warning',
|
|
255
|
+
className: classRow.name,
|
|
256
|
+
kindName: classRow.kindName,
|
|
257
|
+
memberName: member
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (classRow.memberCount === 0) {
|
|
262
|
+
issues.push({
|
|
263
|
+
code: 'pcb.class.empty',
|
|
264
|
+
severity: 'info',
|
|
265
|
+
className: classRow.name,
|
|
266
|
+
kindName: classRow.kindName
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return issues
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Builds top-level class summary counters.
|
|
276
|
+
* @param {object[]} classes Class rows.
|
|
277
|
+
* @param {object[]} issues Issue rows.
|
|
278
|
+
* @returns {object}
|
|
279
|
+
*/
|
|
280
|
+
static #summary(classes, issues) {
|
|
281
|
+
return {
|
|
282
|
+
classCount: classes.length,
|
|
283
|
+
enabledClassCount: classes.filter((row) => row.enabled !== false)
|
|
284
|
+
.length,
|
|
285
|
+
disabledClassCount: classes.filter((row) => row.enabled === false)
|
|
286
|
+
.length,
|
|
287
|
+
netClassCount: PcbClassReportBuilder.#kindCount(classes, 'net'),
|
|
288
|
+
componentClassCount: PcbClassReportBuilder.#kindCount(
|
|
289
|
+
classes,
|
|
290
|
+
'component'
|
|
291
|
+
),
|
|
292
|
+
padClassCount: PcbClassReportBuilder.#kindCount(classes, 'pad'),
|
|
293
|
+
differentialPairClassCount: PcbClassReportBuilder.#kindCount(
|
|
294
|
+
classes,
|
|
295
|
+
'diff-pair'
|
|
296
|
+
),
|
|
297
|
+
emptyClassCount: classes.filter((row) => row.memberCount === 0)
|
|
298
|
+
.length,
|
|
299
|
+
unresolvedMemberCount: classes.reduce(
|
|
300
|
+
(total, row) => total + Number(row.unresolvedMemberCount || 0),
|
|
301
|
+
0
|
|
302
|
+
),
|
|
303
|
+
issueCount: issues.length
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Counts classes by kind.
|
|
309
|
+
* @param {object[]} classes Class rows.
|
|
310
|
+
* @param {string} kindName Class kind name.
|
|
311
|
+
* @returns {number}
|
|
312
|
+
*/
|
|
313
|
+
static #kindCount(classes, kindName) {
|
|
314
|
+
return classes.filter((row) => row.kindName === kindName).length
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Builds sorted class counts by kind.
|
|
319
|
+
* @param {object[]} classes Class rows.
|
|
320
|
+
* @returns {{ kindName: string, count: number }[]}
|
|
321
|
+
*/
|
|
322
|
+
static #byKind(classes) {
|
|
323
|
+
const counts = new Map()
|
|
324
|
+
|
|
325
|
+
for (const classRow of classes) {
|
|
326
|
+
counts.set(
|
|
327
|
+
classRow.kindName,
|
|
328
|
+
Number(counts.get(classRow.kindName) || 0) + 1
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return [...counts.entries()]
|
|
333
|
+
.map(([kindName, count]) => ({ kindName, count }))
|
|
334
|
+
.sort((left, right) =>
|
|
335
|
+
PcbClassReportBuilder.#naturalCompare(
|
|
336
|
+
left.kindName,
|
|
337
|
+
right.kindName
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Sorts strings with numeric chunks in human order.
|
|
344
|
+
* @param {string} left Left value.
|
|
345
|
+
* @param {string} right Right value.
|
|
346
|
+
* @returns {number}
|
|
347
|
+
*/
|
|
348
|
+
static #naturalCompare(left, right) {
|
|
349
|
+
return String(left).localeCompare(String(right), undefined, {
|
|
350
|
+
numeric: true
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Removes undefined and blank-string values from a shallow object.
|
|
356
|
+
* @param {Record<string, unknown>} row Input row.
|
|
357
|
+
* @returns {Record<string, unknown>}
|
|
358
|
+
*/
|
|
359
|
+
static #stripEmpty(row) {
|
|
360
|
+
return Object.fromEntries(
|
|
361
|
+
Object.entries(row).filter(
|
|
362
|
+
([, value]) => value !== undefined && value !== ''
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
}
|