altium-toolkit 1.1.24 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/docs/api.md +43 -17
- package/docs/model-format.md +51 -11
- package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
- package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +58 -1
- package/package.json +1 -1
- package/spec/library-compatibility.md +65 -0
- package/src/core/altium/AltiumParser.mjs +8 -1
- package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
- package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
- package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
- package/src/core/altium/PcbDimensionParser.mjs +1 -0
- package/src/core/altium/PcbDimensionReportBuilder.mjs +190 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +140 -10
- package/src/core/altium/PcbFabricationReadinessReportBuilder.mjs +607 -0
- package/src/core/altium/PcbInspectionReportBuilder.mjs +56 -3
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +138 -3
- package/src/core/altium/PcbLibModelParser.mjs +24 -2
- package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
- package/src/core/altium/PcbPrimitiveFieldOffsetReportBuilder.mjs +352 -0
- package/src/core/altium/PcbRuleImpactReportBuilder.mjs +313 -0
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +514 -0
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +442 -2
- package/src/core/altium/SchematicImageDiagnosticsBuilder.mjs +214 -0
- package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +523 -3
- package/src/parser.mjs +8 -0
- package/src/styles/altium-renderers.css +36 -0
- package/src/ui/PcbDimensionPrimitiveRenderer.mjs +422 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +6 -0
- package/src/ui/PcbScene3dBuilder.mjs +10 -2
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +225 -0
- package/src/ui/PcbSvgRenderer.mjs +78 -2
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds byte-offset evidence for preserved PCB primitive records.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbPrimitiveFieldOffsetReportBuilder {
|
|
9
|
+
static SCHEMA = 'altium-toolkit.pcb.primitive-field-offsets.a1'
|
|
10
|
+
|
|
11
|
+
static #DEFAULT_FIELD_MAPS = Object.freeze([
|
|
12
|
+
PcbPrimitiveFieldOffsetReportBuilder.#fieldMap(
|
|
13
|
+
'Tracks6/Data',
|
|
14
|
+
'payload',
|
|
15
|
+
[
|
|
16
|
+
['layerId', 0, 1, 'uint8'],
|
|
17
|
+
['flags', 1, 1, 'uint8'],
|
|
18
|
+
['keepout', 2, 1, 'uint8'],
|
|
19
|
+
['netIndex', 3, 2, 'int16-le'],
|
|
20
|
+
['polygonIndex', 5, 2, 'int16-le'],
|
|
21
|
+
['componentIndex', 7, 2, 'int16-le'],
|
|
22
|
+
['x1', 13, 4, 'mil-int32-le'],
|
|
23
|
+
['y1', 17, 4, 'mil-int32-le'],
|
|
24
|
+
['x2', 21, 4, 'mil-int32-le'],
|
|
25
|
+
['y2', 25, 4, 'mil-int32-le'],
|
|
26
|
+
['width', 29, 4, 'mil-int32-le'],
|
|
27
|
+
['unionIndex', 36, 1, 'uint8'],
|
|
28
|
+
['lengthTuning', 37, 1, 'uint8'],
|
|
29
|
+
['userRouted', 44, 1, 'uint8']
|
|
30
|
+
]
|
|
31
|
+
),
|
|
32
|
+
PcbPrimitiveFieldOffsetReportBuilder.#fieldMap(
|
|
33
|
+
'Arcs6/Data',
|
|
34
|
+
'payload',
|
|
35
|
+
[
|
|
36
|
+
['layerId', 0, 1, 'uint8'],
|
|
37
|
+
['netIndex', 3, 2, 'int16-le'],
|
|
38
|
+
['polygonIndex', 5, 2, 'int16-le'],
|
|
39
|
+
['componentIndex', 7, 2, 'int16-le'],
|
|
40
|
+
['x', 13, 4, 'mil-int32-le'],
|
|
41
|
+
['y', 17, 4, 'mil-int32-le'],
|
|
42
|
+
['radius', 21, 4, 'mil-int32-le'],
|
|
43
|
+
['startAngle', 25, 8, 'float64-le'],
|
|
44
|
+
['endAngle', 33, 8, 'float64-le'],
|
|
45
|
+
['width', 41, 4, 'mil-int32-le']
|
|
46
|
+
]
|
|
47
|
+
),
|
|
48
|
+
PcbPrimitiveFieldOffsetReportBuilder.#fieldMap(
|
|
49
|
+
'Fills6/Data',
|
|
50
|
+
'record',
|
|
51
|
+
[
|
|
52
|
+
['layerId', 5, 1, 'uint8'],
|
|
53
|
+
['netIndex', 8, 2, 'int16-le'],
|
|
54
|
+
['polygonIndex', 10, 2, 'int16-le'],
|
|
55
|
+
['componentIndex', 12, 2, 'int16-le'],
|
|
56
|
+
['x1', 18, 4, 'mil-int32-le'],
|
|
57
|
+
['y1', 22, 4, 'mil-int32-le'],
|
|
58
|
+
['x2', 26, 4, 'mil-int32-le'],
|
|
59
|
+
['y2', 30, 4, 'mil-int32-le'],
|
|
60
|
+
['layerCode', 46, 2, 'uint16-le']
|
|
61
|
+
]
|
|
62
|
+
),
|
|
63
|
+
PcbPrimitiveFieldOffsetReportBuilder.#fieldMap('Vias6/Data', 'record', [
|
|
64
|
+
['layerId', 5, 1, 'uint8'],
|
|
65
|
+
['netIndex', 8, 2, 'int16-le'],
|
|
66
|
+
['polygonIndex', 10, 2, 'int16-le'],
|
|
67
|
+
['componentIndex', 12, 2, 'int16-le'],
|
|
68
|
+
['x', 18, 4, 'mil-int32-le'],
|
|
69
|
+
['y', 22, 4, 'mil-int32-le'],
|
|
70
|
+
['diameter', 26, 4, 'mil-int32-le'],
|
|
71
|
+
['holeDiameter', 30, 4, 'mil-int32-le'],
|
|
72
|
+
['layerStartId', 34, 1, 'uint8'],
|
|
73
|
+
['layerEndId', 35, 1, 'uint8']
|
|
74
|
+
])
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds a field-offset report for preserved primitive records.
|
|
79
|
+
* @param {{ fileName?: string, rawRecords?: object[], records?: object[], fieldMaps?: object[] }} [input]
|
|
80
|
+
* @returns {object}
|
|
81
|
+
*/
|
|
82
|
+
static build(input = {}) {
|
|
83
|
+
const records = PcbPrimitiveFieldOffsetReportBuilder.#records(input)
|
|
84
|
+
const fieldMaps = [
|
|
85
|
+
...PcbPrimitiveFieldOffsetReportBuilder.#DEFAULT_FIELD_MAPS,
|
|
86
|
+
...PcbPrimitiveFieldOffsetReportBuilder.#normalizeFieldMaps(
|
|
87
|
+
input.fieldMaps || []
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
const fields = []
|
|
91
|
+
const unmatchedRecords = []
|
|
92
|
+
|
|
93
|
+
for (const record of records) {
|
|
94
|
+
const fieldMap =
|
|
95
|
+
PcbPrimitiveFieldOffsetReportBuilder.#matchingFieldMap(
|
|
96
|
+
record,
|
|
97
|
+
fieldMaps
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if (!fieldMap) {
|
|
101
|
+
unmatchedRecords.push(
|
|
102
|
+
PcbPrimitiveFieldOffsetReportBuilder.#recordKey(record)
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const field of fieldMap.fields) {
|
|
108
|
+
fields.push(
|
|
109
|
+
PcbPrimitiveFieldOffsetReportBuilder.#fieldRow(
|
|
110
|
+
record,
|
|
111
|
+
fieldMap,
|
|
112
|
+
field
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const streams =
|
|
119
|
+
PcbPrimitiveFieldOffsetReportBuilder.#streamSummaries(fields)
|
|
120
|
+
|
|
121
|
+
return PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
|
|
122
|
+
schema: PcbPrimitiveFieldOffsetReportBuilder.SCHEMA,
|
|
123
|
+
sourceDocument: input.fileName || undefined,
|
|
124
|
+
summary: {
|
|
125
|
+
recordCount: records.length,
|
|
126
|
+
mappedRecordCount: records.length - unmatchedRecords.length,
|
|
127
|
+
fieldCount: fields.length,
|
|
128
|
+
streamCount: streams.length,
|
|
129
|
+
unmatchedRecordCount: unmatchedRecords.length
|
|
130
|
+
},
|
|
131
|
+
streams,
|
|
132
|
+
fields,
|
|
133
|
+
unmatchedRecords
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Creates one built-in field-map descriptor.
|
|
139
|
+
* @param {string} sourceStream Native stream path.
|
|
140
|
+
* @param {'record' | 'payload'} relativeTo Offset origin.
|
|
141
|
+
* @param {Array<[string, number, number, string]>} fieldRows Field rows.
|
|
142
|
+
* @returns {object}
|
|
143
|
+
*/
|
|
144
|
+
static #fieldMap(sourceStream, relativeTo, fieldRows) {
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
sourceStream,
|
|
147
|
+
relativeTo,
|
|
148
|
+
fields: Object.freeze(
|
|
149
|
+
fieldRows.map(([name, offset, byteLength, encoding]) =>
|
|
150
|
+
Object.freeze({
|
|
151
|
+
name,
|
|
152
|
+
offset,
|
|
153
|
+
byteLength,
|
|
154
|
+
encoding
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalizes record input.
|
|
163
|
+
* @param {object} input Report input.
|
|
164
|
+
* @returns {object[]}
|
|
165
|
+
*/
|
|
166
|
+
static #records(input) {
|
|
167
|
+
if (Array.isArray(input.rawRecords)) return input.rawRecords
|
|
168
|
+
if (Array.isArray(input.records)) return input.records
|
|
169
|
+
return []
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Normalizes caller-provided field maps.
|
|
174
|
+
* @param {object[]} fieldMaps Field maps.
|
|
175
|
+
* @returns {object[]}
|
|
176
|
+
*/
|
|
177
|
+
static #normalizeFieldMaps(fieldMaps) {
|
|
178
|
+
return fieldMaps
|
|
179
|
+
.filter((fieldMap) => fieldMap?.sourceStream)
|
|
180
|
+
.map((fieldMap) => ({
|
|
181
|
+
sourceStream: String(fieldMap.sourceStream),
|
|
182
|
+
family: fieldMap.family ? String(fieldMap.family) : undefined,
|
|
183
|
+
type: fieldMap.type ? String(fieldMap.type) : undefined,
|
|
184
|
+
relativeTo:
|
|
185
|
+
fieldMap.relativeTo === 'record' ? 'record' : 'payload',
|
|
186
|
+
fields: (Array.isArray(fieldMap.fields) ? fieldMap.fields : [])
|
|
187
|
+
.filter((field) => field?.name)
|
|
188
|
+
.map((field) =>
|
|
189
|
+
PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
|
|
190
|
+
name: String(field.name),
|
|
191
|
+
offset: Number(field.offset || 0),
|
|
192
|
+
byteLength: Number(field.byteLength || 0),
|
|
193
|
+
encoding: field.encoding
|
|
194
|
+
? String(field.encoding)
|
|
195
|
+
: undefined,
|
|
196
|
+
relativeTo:
|
|
197
|
+
field.relativeTo === 'record' ||
|
|
198
|
+
field.relativeTo === 'payload'
|
|
199
|
+
? field.relativeTo
|
|
200
|
+
: undefined
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
}))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Finds the field-map matching one raw record.
|
|
208
|
+
* @param {object} record Raw record row.
|
|
209
|
+
* @param {object[]} fieldMaps Field-map descriptors.
|
|
210
|
+
* @returns {object | undefined}
|
|
211
|
+
*/
|
|
212
|
+
static #matchingFieldMap(record, fieldMaps) {
|
|
213
|
+
return fieldMaps.find((fieldMap) => {
|
|
214
|
+
if (fieldMap.sourceStream !== record.sourceStream) return false
|
|
215
|
+
if (fieldMap.family && fieldMap.family !== record.family) {
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
if (fieldMap.type && fieldMap.type !== record.type) return false
|
|
219
|
+
return true
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Builds one field evidence row.
|
|
225
|
+
* @param {object} record Raw record row.
|
|
226
|
+
* @param {object} fieldMap Field-map descriptor.
|
|
227
|
+
* @param {object} field Field descriptor.
|
|
228
|
+
* @returns {object}
|
|
229
|
+
*/
|
|
230
|
+
static #fieldRow(record, fieldMap, field) {
|
|
231
|
+
const relativeTo = field.relativeTo || fieldMap.relativeTo
|
|
232
|
+
const baseOffset =
|
|
233
|
+
Number(record.offset || 0) +
|
|
234
|
+
PcbPrimitiveFieldOffsetReportBuilder.#relativeOffset(
|
|
235
|
+
record,
|
|
236
|
+
relativeTo
|
|
237
|
+
)
|
|
238
|
+
const offset = Number(field.offset || 0)
|
|
239
|
+
const byteLength = Number(field.byteLength || 0)
|
|
240
|
+
const withinRecordOffset = baseOffset - Number(record.offset || 0)
|
|
241
|
+
|
|
242
|
+
return PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
|
|
243
|
+
sourceStream: record.sourceStream,
|
|
244
|
+
family: record.family,
|
|
245
|
+
type: record.type,
|
|
246
|
+
typeId: PcbPrimitiveFieldOffsetReportBuilder.#finiteOrUndefined(
|
|
247
|
+
record.typeId
|
|
248
|
+
),
|
|
249
|
+
recordIndex:
|
|
250
|
+
PcbPrimitiveFieldOffsetReportBuilder.#finiteOrUndefined(
|
|
251
|
+
record.recordIndex
|
|
252
|
+
),
|
|
253
|
+
name: field.name,
|
|
254
|
+
relativeTo,
|
|
255
|
+
offset,
|
|
256
|
+
absoluteOffset: baseOffset + offset,
|
|
257
|
+
byteLength,
|
|
258
|
+
endOffset: baseOffset + offset + byteLength,
|
|
259
|
+
encoding: field.encoding,
|
|
260
|
+
available:
|
|
261
|
+
byteLength > 0 &&
|
|
262
|
+
withinRecordOffset + offset + byteLength <=
|
|
263
|
+
Number(record.byteLength || 0)
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Returns the offset of a field origin within a raw record.
|
|
269
|
+
* @param {object} record Raw record row.
|
|
270
|
+
* @param {'record' | 'payload'} relativeTo Offset origin.
|
|
271
|
+
* @returns {number}
|
|
272
|
+
*/
|
|
273
|
+
static #relativeOffset(record, relativeTo) {
|
|
274
|
+
if (relativeTo !== 'payload') return 0
|
|
275
|
+
if (record.encoding === 'length-prefixed') return 5
|
|
276
|
+
return 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Summarizes mapped fields by source stream.
|
|
281
|
+
* @param {object[]} fields Field rows.
|
|
282
|
+
* @returns {object[]}
|
|
283
|
+
*/
|
|
284
|
+
static #streamSummaries(fields) {
|
|
285
|
+
const byStream = new Map()
|
|
286
|
+
|
|
287
|
+
for (const field of fields) {
|
|
288
|
+
if (!byStream.has(field.sourceStream)) {
|
|
289
|
+
byStream.set(field.sourceStream, {
|
|
290
|
+
sourceStream: field.sourceStream,
|
|
291
|
+
recordIndexes: new Set(),
|
|
292
|
+
fieldNames: [],
|
|
293
|
+
fieldNameSet: new Set()
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const summary = byStream.get(field.sourceStream)
|
|
298
|
+
summary.recordIndexes.add(field.recordIndex)
|
|
299
|
+
if (!summary.fieldNameSet.has(field.name)) {
|
|
300
|
+
summary.fieldNameSet.add(field.name)
|
|
301
|
+
summary.fieldNames.push(field.name)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return [...byStream.values()].map((summary) => ({
|
|
306
|
+
sourceStream: summary.sourceStream,
|
|
307
|
+
recordCount: summary.recordIndexes.size,
|
|
308
|
+
fieldCount: fields.filter(
|
|
309
|
+
(field) => field.sourceStream === summary.sourceStream
|
|
310
|
+
).length,
|
|
311
|
+
fieldNames: summary.fieldNames
|
|
312
|
+
}))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Builds a compact record key for unmatched records.
|
|
317
|
+
* @param {object} record Raw record row.
|
|
318
|
+
* @returns {object}
|
|
319
|
+
*/
|
|
320
|
+
static #recordKey(record) {
|
|
321
|
+
return PcbPrimitiveFieldOffsetReportBuilder.#stripUndefined({
|
|
322
|
+
sourceStream: record.sourceStream,
|
|
323
|
+
family: record.family,
|
|
324
|
+
type: record.type,
|
|
325
|
+
recordIndex:
|
|
326
|
+
PcbPrimitiveFieldOffsetReportBuilder.#finiteOrUndefined(
|
|
327
|
+
record.recordIndex
|
|
328
|
+
)
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Returns a finite number or undefined.
|
|
334
|
+
* @param {unknown} value Candidate number.
|
|
335
|
+
* @returns {number | undefined}
|
|
336
|
+
*/
|
|
337
|
+
static #finiteOrUndefined(value) {
|
|
338
|
+
const number = Number(value)
|
|
339
|
+
return Number.isFinite(number) ? number : undefined
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Removes undefined values from a report row.
|
|
344
|
+
* @param {object} row Report row.
|
|
345
|
+
* @returns {object}
|
|
346
|
+
*/
|
|
347
|
+
static #stripUndefined(row) {
|
|
348
|
+
return Object.fromEntries(
|
|
349
|
+
Object.entries(row).filter(([, value]) => value !== undefined)
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds deterministic design-rule impact summaries for PCB review workflows.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbRuleImpactReportBuilder {
|
|
9
|
+
static SCHEMA = 'altium-toolkit.pcb.rule-impact.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a rule impact report from a parser root or PCB model.
|
|
13
|
+
* @param {object} input Parser root, PCB model, or options object.
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(input = {}) {
|
|
17
|
+
const pcb = PcbRuleImpactReportBuilder.#pcb(input)
|
|
18
|
+
const rules = Array.isArray(pcb?.rules) ? pcb.rules : []
|
|
19
|
+
const rows = rules.map((rule) =>
|
|
20
|
+
PcbRuleImpactReportBuilder.#ruleRow(rule)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
schema: PcbRuleImpactReportBuilder.SCHEMA,
|
|
25
|
+
summary: PcbRuleImpactReportBuilder.#summary(rows),
|
|
26
|
+
primitiveCounts: PcbRuleImpactReportBuilder.#primitiveCounts(pcb),
|
|
27
|
+
rules: rows
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the PCB payload from a parser root or direct PCB model.
|
|
33
|
+
* @param {object} input Parser root or PCB model.
|
|
34
|
+
* @returns {object}
|
|
35
|
+
*/
|
|
36
|
+
static #pcb(input) {
|
|
37
|
+
return input?.pcb || input || {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds one public rule impact row.
|
|
42
|
+
* @param {object} rule Parsed design-rule row.
|
|
43
|
+
* @returns {object}
|
|
44
|
+
*/
|
|
45
|
+
static #ruleRow(rule) {
|
|
46
|
+
const kind = PcbRuleImpactReportBuilder.#kind(rule)
|
|
47
|
+
const category = PcbRuleImpactReportBuilder.#category(rule, kind)
|
|
48
|
+
|
|
49
|
+
return PcbRuleImpactReportBuilder.#stripEmpty({
|
|
50
|
+
ruleIndex: PcbRuleImpactReportBuilder.#integerOrFallback(
|
|
51
|
+
rule?.ruleIndex,
|
|
52
|
+
0
|
|
53
|
+
),
|
|
54
|
+
name: rule?.name || '',
|
|
55
|
+
uniqueId: rule?.uniqueId || '',
|
|
56
|
+
kind,
|
|
57
|
+
category,
|
|
58
|
+
displayName:
|
|
59
|
+
rule?.ruleType?.displayName || rule?.displayName || kind,
|
|
60
|
+
enabled: rule?.enabled !== false,
|
|
61
|
+
priority: rule?.priority ?? undefined,
|
|
62
|
+
affectedFamilies: PcbRuleImpactReportBuilder.#affectedFamilies(
|
|
63
|
+
kind,
|
|
64
|
+
category
|
|
65
|
+
),
|
|
66
|
+
scopes: PcbRuleImpactReportBuilder.#scopes(rule),
|
|
67
|
+
lengthConstraints:
|
|
68
|
+
PcbRuleImpactReportBuilder.#lengthConstraints(rule)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Builds top-level rule impact counters.
|
|
74
|
+
* @param {object[]} rows Rule impact rows.
|
|
75
|
+
* @returns {object}
|
|
76
|
+
*/
|
|
77
|
+
static #summary(rows) {
|
|
78
|
+
const enabledRows = rows.filter((row) => row.enabled !== false)
|
|
79
|
+
const impactedFamilies = new Set(
|
|
80
|
+
enabledRows.flatMap((row) => row.affectedFamilies || [])
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
ruleCount: rows.length,
|
|
85
|
+
enabledRuleCount: enabledRows.length,
|
|
86
|
+
disabledRuleCount: rows.length - enabledRows.length,
|
|
87
|
+
manufacturingRuleCount: enabledRows.filter(
|
|
88
|
+
(row) => row.category === 'manufacturing'
|
|
89
|
+
).length,
|
|
90
|
+
scopedRuleCount: rows.filter((row) => row.scopes?.length).length,
|
|
91
|
+
impactedFamilyCount: impactedFamilies.size,
|
|
92
|
+
lengthConstraintCount: rows.reduce(
|
|
93
|
+
(total, row) =>
|
|
94
|
+
total + Number(row.lengthConstraints?.length || 0),
|
|
95
|
+
0
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Builds primitive-family counts available to report consumers.
|
|
102
|
+
* @param {object} pcb PCB model.
|
|
103
|
+
* @returns {object}
|
|
104
|
+
*/
|
|
105
|
+
static #primitiveCounts(pcb) {
|
|
106
|
+
return Object.fromEntries(
|
|
107
|
+
[
|
|
108
|
+
'pads',
|
|
109
|
+
'vias',
|
|
110
|
+
'tracks',
|
|
111
|
+
'arcs',
|
|
112
|
+
'fills',
|
|
113
|
+
'regions',
|
|
114
|
+
'polygons',
|
|
115
|
+
'components'
|
|
116
|
+
].map((family) => [
|
|
117
|
+
family,
|
|
118
|
+
Array.isArray(pcb?.[family]) ? pcb[family].length : 0
|
|
119
|
+
])
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolves a normalized rule kind.
|
|
125
|
+
* @param {object} rule Rule row.
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
static #kind(rule) {
|
|
129
|
+
return (
|
|
130
|
+
String(
|
|
131
|
+
rule?.ruleType?.kind ||
|
|
132
|
+
rule?.kind ||
|
|
133
|
+
rule?.ruleKind ||
|
|
134
|
+
'unknown'
|
|
135
|
+
)
|
|
136
|
+
.trim()
|
|
137
|
+
.toLowerCase() || 'unknown'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolves a normalized rule category.
|
|
143
|
+
* @param {object} rule Rule row.
|
|
144
|
+
* @param {string} kind Normalized rule kind.
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
static #category(rule, kind) {
|
|
148
|
+
const explicit = String(
|
|
149
|
+
rule?.ruleType?.category || rule?.category || ''
|
|
150
|
+
)
|
|
151
|
+
.trim()
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
if (explicit) {
|
|
154
|
+
return explicit
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (/paste|mask|silk|annular|testpoint/u.test(kind)) {
|
|
158
|
+
return 'manufacturing'
|
|
159
|
+
}
|
|
160
|
+
if (/width|length|routing|fanout|via/u.test(kind)) {
|
|
161
|
+
return 'routing'
|
|
162
|
+
}
|
|
163
|
+
if (/clearance|short|unrouted/u.test(kind)) {
|
|
164
|
+
return 'electrical'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return 'other'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Infers broad primitive families affected by one rule kind.
|
|
172
|
+
* @param {string} kind Rule kind.
|
|
173
|
+
* @param {string} category Rule category.
|
|
174
|
+
* @returns {string[]}
|
|
175
|
+
*/
|
|
176
|
+
static #affectedFamilies(kind, category) {
|
|
177
|
+
if (/component/u.test(kind)) {
|
|
178
|
+
return ['components']
|
|
179
|
+
}
|
|
180
|
+
if (/paste/u.test(kind)) {
|
|
181
|
+
return ['pads']
|
|
182
|
+
}
|
|
183
|
+
if (/solder.*mask|mask.*expansion/u.test(kind)) {
|
|
184
|
+
return ['pads', 'vias']
|
|
185
|
+
}
|
|
186
|
+
if (/polygon|plane|thermal/u.test(kind)) {
|
|
187
|
+
return ['pads', 'polygons', 'vias']
|
|
188
|
+
}
|
|
189
|
+
if (/annular|fanout|via/u.test(kind)) {
|
|
190
|
+
return ['pads', 'vias']
|
|
191
|
+
}
|
|
192
|
+
if (/width/u.test(kind)) {
|
|
193
|
+
return ['arcs', 'tracks']
|
|
194
|
+
}
|
|
195
|
+
if (/length|routing|corner|topology/u.test(kind)) {
|
|
196
|
+
return ['arcs', 'tracks', 'vias']
|
|
197
|
+
}
|
|
198
|
+
if (/clearance|short/u.test(kind)) {
|
|
199
|
+
return ['arcs', 'fills', 'pads', 'regions', 'tracks', 'vias']
|
|
200
|
+
}
|
|
201
|
+
if (category === 'manufacturing') {
|
|
202
|
+
return ['pads', 'vias']
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return []
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Builds normalized scope rows for non-trivial rule scopes.
|
|
210
|
+
* @param {object} rule Rule row.
|
|
211
|
+
* @returns {object[]}
|
|
212
|
+
*/
|
|
213
|
+
static #scopes(rule) {
|
|
214
|
+
return [
|
|
215
|
+
PcbRuleImpactReportBuilder.#scopeRow('scope1', rule?.scope1),
|
|
216
|
+
PcbRuleImpactReportBuilder.#scopeRow('scope2', rule?.scope2)
|
|
217
|
+
].filter(Boolean)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Builds one normalized scope row.
|
|
222
|
+
* @param {string} side Scope side label.
|
|
223
|
+
* @param {object} scope Parsed scope expression.
|
|
224
|
+
* @returns {object | null}
|
|
225
|
+
*/
|
|
226
|
+
static #scopeRow(side, scope) {
|
|
227
|
+
const expression = String(scope?.rawExpression || '').trim()
|
|
228
|
+
const predicate = String(scope?.predicate || '').trim()
|
|
229
|
+
const isAll = scope?.isAll === true || /^all$/iu.test(predicate)
|
|
230
|
+
|
|
231
|
+
if (!expression && !predicate) {
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
if (isAll && !expression) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return PcbRuleImpactReportBuilder.#stripEmpty({
|
|
239
|
+
side,
|
|
240
|
+
expression,
|
|
241
|
+
predicate,
|
|
242
|
+
arguments: Array.isArray(scope?.arguments) ? scope.arguments : []
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Extracts length-valued typed constraints.
|
|
248
|
+
* @param {object} rule Rule row.
|
|
249
|
+
* @returns {object[]}
|
|
250
|
+
*/
|
|
251
|
+
static #lengthConstraints(rule) {
|
|
252
|
+
const source = Object.keys(rule?.typedConstraints || {}).length
|
|
253
|
+
? rule.typedConstraints
|
|
254
|
+
: rule?.constraintValues || {}
|
|
255
|
+
const rows = []
|
|
256
|
+
|
|
257
|
+
for (const [name, value] of Object.entries(source || {})) {
|
|
258
|
+
if (!PcbRuleImpactReportBuilder.#isLengthConstraint(value)) {
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
rows.push(
|
|
262
|
+
PcbRuleImpactReportBuilder.#stripEmpty({
|
|
263
|
+
name,
|
|
264
|
+
key: value.key,
|
|
265
|
+
raw: value.raw,
|
|
266
|
+
valueMil: value.valueMil,
|
|
267
|
+
valueMm: value.valueMm
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return rows.sort((left, right) =>
|
|
273
|
+
left.name.localeCompare(right.name, undefined, { numeric: true })
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Returns true when one parsed constraint carries length units.
|
|
279
|
+
* @param {object} value Parsed constraint value.
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
static #isLengthConstraint(value) {
|
|
283
|
+
return (
|
|
284
|
+
value?.type === 'length' ||
|
|
285
|
+
Number.isFinite(Number(value?.valueMil)) ||
|
|
286
|
+
Number.isFinite(Number(value?.valueMm))
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Parses an integer with a fallback.
|
|
292
|
+
* @param {unknown} value Numeric candidate.
|
|
293
|
+
* @param {number} fallback Fallback value.
|
|
294
|
+
* @returns {number}
|
|
295
|
+
*/
|
|
296
|
+
static #integerOrFallback(value, fallback) {
|
|
297
|
+
const number = Number(value)
|
|
298
|
+
return Number.isInteger(number) ? number : fallback
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Removes undefined and blank-string values from a shallow object.
|
|
303
|
+
* @param {Record<string, unknown>} row Input row.
|
|
304
|
+
* @returns {Record<string, unknown>}
|
|
305
|
+
*/
|
|
306
|
+
static #stripEmpty(row) {
|
|
307
|
+
return Object.fromEntries(
|
|
308
|
+
Object.entries(row).filter(
|
|
309
|
+
([, value]) => value !== undefined && value !== ''
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
}
|