altium-toolkit 1.0.10 → 1.1.0
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/docs/api.md +6 -2
- package/docs/model-format.md +29 -4
- package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +4 -0
- package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
- package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
- package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +132 -1
- package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
- package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
- package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +692 -2
- package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
- package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
- package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
- package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
- package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
- package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
- package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
- package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
- package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
- package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
- package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +7 -2
- package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
- package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
- package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
- package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
- package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
- package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
- package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
- package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
- package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
- package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
- package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
- package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
- package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
- package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
- package/src/core/altium/PcbLibModelParser.mjs +2 -0
- package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
- package/src/core/altium/PcbModelParser.mjs +182 -18
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
- package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
- package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
- package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
- package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
- package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
- package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
- package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
- package/src/core/altium/PrintableTextDecoder.mjs +70 -6
- package/src/core/altium/PrjPcbModelParser.mjs +45 -0
- package/src/core/altium/PrjScrModelParser.mjs +386 -0
- package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
- package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
- package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
- package/src/parser.mjs +15 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
- package/src/ui/PcbScene3dBuilder.mjs +26 -4
- package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
- package/src/ui/SchematicSvgRenderer.mjs +48 -2
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
|
|
6
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses Altium script-project files into a read-only project-script digest.
|
|
10
|
+
*/
|
|
11
|
+
export class PrjScrModelParser {
|
|
12
|
+
/**
|
|
13
|
+
* Parses one script-project ArrayBuffer.
|
|
14
|
+
* @param {string} fileName Source file name.
|
|
15
|
+
* @param {ArrayBuffer} arrayBuffer Source bytes.
|
|
16
|
+
* @param {{ existingPaths?: string[] }} options Parser options.
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
static parse(fileName, arrayBuffer, options = {}) {
|
|
20
|
+
return PrjScrModelParser.parseText(
|
|
21
|
+
fileName,
|
|
22
|
+
PrjScrModelParser.#decodeText(arrayBuffer),
|
|
23
|
+
options
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses one script-project text payload.
|
|
29
|
+
* @param {string} fileName Source file name.
|
|
30
|
+
* @param {string} text Source text.
|
|
31
|
+
* @param {{ existingPaths?: string[] }} options Parser options.
|
|
32
|
+
* @returns {object}
|
|
33
|
+
*/
|
|
34
|
+
static parseText(fileName, text, options = {}) {
|
|
35
|
+
const sections = PrjScrModelParser.#parseIniSections(text)
|
|
36
|
+
const design = PrjScrModelParser.#sectionFields(
|
|
37
|
+
PrjScrModelParser.#findSection(sections, 'Design')
|
|
38
|
+
)
|
|
39
|
+
const existingPaths = new Set(
|
|
40
|
+
(options.existingPaths || []).map((path) =>
|
|
41
|
+
PrjScrModelParser.#normalizePath(path)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
const documents = PrjScrModelParser.#extractDocuments(
|
|
45
|
+
sections,
|
|
46
|
+
existingPaths,
|
|
47
|
+
options
|
|
48
|
+
)
|
|
49
|
+
const scripts = documents
|
|
50
|
+
.filter((document) => document.kind === 'script')
|
|
51
|
+
.map((document) => PrjScrModelParser.#publicScript(document))
|
|
52
|
+
const diagnostics = PrjScrModelParser.#diagnostics(documents)
|
|
53
|
+
|
|
54
|
+
return NormalizedModelSchema.attach({
|
|
55
|
+
kind: 'project-script',
|
|
56
|
+
fileType: 'PrjScr',
|
|
57
|
+
fileName,
|
|
58
|
+
summary: {
|
|
59
|
+
title: ParserUtils.stripExtension(fileName),
|
|
60
|
+
documentCount: documents.length,
|
|
61
|
+
scriptCount: scripts.length,
|
|
62
|
+
missingPathCount: scripts.filter(
|
|
63
|
+
(script) => script.exists === false
|
|
64
|
+
).length,
|
|
65
|
+
diagnosticCount: diagnostics.length
|
|
66
|
+
},
|
|
67
|
+
diagnostics,
|
|
68
|
+
projectScript: {
|
|
69
|
+
name: ParserUtils.stripExtension(fileName),
|
|
70
|
+
design,
|
|
71
|
+
documents,
|
|
72
|
+
scripts,
|
|
73
|
+
sections: PrjScrModelParser.#serializeSections(sections)
|
|
74
|
+
},
|
|
75
|
+
bom: []
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decodes text with common project-file encodings.
|
|
81
|
+
* @param {ArrayBuffer} arrayBuffer Source bytes.
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
static #decodeText(arrayBuffer) {
|
|
85
|
+
const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0))
|
|
86
|
+
for (const encoding of ['utf-8', 'windows-1252']) {
|
|
87
|
+
try {
|
|
88
|
+
return new TextDecoder(encoding, { fatal: true })
|
|
89
|
+
.decode(bytes)
|
|
90
|
+
.replace(/^\uFEFF/u, '')
|
|
91
|
+
} catch {
|
|
92
|
+
// Try the next legacy-compatible project encoding.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new TextDecoder('windows-1252')
|
|
97
|
+
.decode(bytes)
|
|
98
|
+
.replace(/^\uFEFF/u, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parses INI sections while preserving option order.
|
|
103
|
+
* @param {string} text Source text.
|
|
104
|
+
* @returns {{ name: string, index: number, entries: object[] }[]}
|
|
105
|
+
*/
|
|
106
|
+
static #parseIniSections(text) {
|
|
107
|
+
const sections = []
|
|
108
|
+
let current = null
|
|
109
|
+
const lines = String(text || '')
|
|
110
|
+
.replace(/\r\n?/gu, '\n')
|
|
111
|
+
.split('\n')
|
|
112
|
+
|
|
113
|
+
for (const [lineIndex, rawLine] of lines.entries()) {
|
|
114
|
+
const trimmed = rawLine.trim()
|
|
115
|
+
if (
|
|
116
|
+
!trimmed ||
|
|
117
|
+
trimmed.startsWith(';') ||
|
|
118
|
+
trimmed.startsWith('#')
|
|
119
|
+
) {
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sectionMatch = /^\[([^\]]+)\]$/u.exec(trimmed)
|
|
124
|
+
if (sectionMatch) {
|
|
125
|
+
current = {
|
|
126
|
+
name: sectionMatch[1].trim(),
|
|
127
|
+
index: sections.length,
|
|
128
|
+
entries: []
|
|
129
|
+
}
|
|
130
|
+
sections.push(current)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!current) continue
|
|
135
|
+
const separatorIndex = rawLine.indexOf('=')
|
|
136
|
+
if (separatorIndex < 0) continue
|
|
137
|
+
|
|
138
|
+
current.entries.push({
|
|
139
|
+
key: rawLine.slice(0, separatorIndex).trim(),
|
|
140
|
+
value: rawLine.slice(separatorIndex + 1).trim(),
|
|
141
|
+
line: lineIndex + 1
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return sections
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extracts numbered document entries.
|
|
150
|
+
* @param {object[]} sections Parsed sections.
|
|
151
|
+
* @param {Set<string>} existingPaths Normalized existing paths.
|
|
152
|
+
* @param {{ existingPaths?: string[] }} options Parser options.
|
|
153
|
+
* @returns {object[]}
|
|
154
|
+
*/
|
|
155
|
+
static #extractDocuments(sections, existingPaths, options) {
|
|
156
|
+
return PrjScrModelParser.#numberedSections(sections, 'Document').map(
|
|
157
|
+
({ section, number }) => {
|
|
158
|
+
const fields = PrjScrModelParser.#sectionFields(section)
|
|
159
|
+
const path = String(fields.DocumentPath || '')
|
|
160
|
+
const normalizedPath = PrjScrModelParser.#normalizePath(path)
|
|
161
|
+
const base = {
|
|
162
|
+
index: number,
|
|
163
|
+
section: section.name,
|
|
164
|
+
path,
|
|
165
|
+
normalizedPath,
|
|
166
|
+
fileName: PrjScrModelParser.#basename(path),
|
|
167
|
+
extension: PrjScrModelParser.#extension(path),
|
|
168
|
+
kind:
|
|
169
|
+
PrjScrModelParser.#extension(path).toLowerCase() ===
|
|
170
|
+
'.pas'
|
|
171
|
+
? 'script'
|
|
172
|
+
: 'unsupported',
|
|
173
|
+
...(options.existingPaths
|
|
174
|
+
? { exists: existingPaths.has(normalizedPath) }
|
|
175
|
+
: {}),
|
|
176
|
+
annotationEnabled: PrjScrModelParser.#optionalBoolean(
|
|
177
|
+
fields.AnnotationEnabled
|
|
178
|
+
),
|
|
179
|
+
classGeneration: PrjScrModelParser.#classGeneration(fields),
|
|
180
|
+
updatePolicies: PrjScrModelParser.#updatePolicies(fields),
|
|
181
|
+
options: fields
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return PrjScrModelParser.#stripEmpty(base)
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Builds class-generation option metadata.
|
|
191
|
+
* @param {Record<string, string>} fields Document fields.
|
|
192
|
+
* @returns {object | undefined}
|
|
193
|
+
*/
|
|
194
|
+
static #classGeneration(fields) {
|
|
195
|
+
return PrjScrModelParser.#stripEmpty({
|
|
196
|
+
classGenCcAutoEnabled: PrjScrModelParser.#optionalBoolean(
|
|
197
|
+
fields.ClassGenCCAutoEnabled
|
|
198
|
+
),
|
|
199
|
+
classGenCcAutoRoomEnabled: PrjScrModelParser.#optionalBoolean(
|
|
200
|
+
fields.ClassGenCCAutoRoomEnabled
|
|
201
|
+
),
|
|
202
|
+
classGenNcAutoScope: fields.ClassGenNCAutoScope
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Builds update-policy metadata.
|
|
208
|
+
* @param {Record<string, string>} fields Document fields.
|
|
209
|
+
* @returns {object | undefined}
|
|
210
|
+
*/
|
|
211
|
+
static #updatePolicies(fields) {
|
|
212
|
+
return PrjScrModelParser.#stripEmpty({
|
|
213
|
+
doLibraryUpdate: PrjScrModelParser.#optionalBoolean(
|
|
214
|
+
fields.DoLibraryUpdate
|
|
215
|
+
),
|
|
216
|
+
doDatabaseUpdate: PrjScrModelParser.#optionalBoolean(
|
|
217
|
+
fields.DoDatabaseUpdate
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Builds structured parser diagnostics.
|
|
224
|
+
* @param {object[]} documents Parsed documents.
|
|
225
|
+
* @returns {object[]}
|
|
226
|
+
*/
|
|
227
|
+
static #diagnostics(documents) {
|
|
228
|
+
return [
|
|
229
|
+
...documents
|
|
230
|
+
.filter(
|
|
231
|
+
(document) =>
|
|
232
|
+
document.kind === 'script' && document.exists === false
|
|
233
|
+
)
|
|
234
|
+
.map((document) => ({
|
|
235
|
+
code: 'project-script.missing-document-path',
|
|
236
|
+
severity: 'warning',
|
|
237
|
+
message: 'Script project document path was not found.',
|
|
238
|
+
path: document.path,
|
|
239
|
+
normalizedPath: document.normalizedPath
|
|
240
|
+
})),
|
|
241
|
+
...documents
|
|
242
|
+
.filter((document) => document.kind === 'unsupported')
|
|
243
|
+
.map((document) => ({
|
|
244
|
+
code: 'project-script.unsupported-document-kind',
|
|
245
|
+
severity: 'warning',
|
|
246
|
+
message:
|
|
247
|
+
'Script project document is not a supported script file.',
|
|
248
|
+
path: document.path,
|
|
249
|
+
normalizedPath: document.normalizedPath
|
|
250
|
+
}))
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Builds the public script convenience row.
|
|
256
|
+
* @param {object} document Parsed document row.
|
|
257
|
+
* @returns {object}
|
|
258
|
+
*/
|
|
259
|
+
static #publicScript(document) {
|
|
260
|
+
const { kind: _kind, ...script } = document
|
|
261
|
+
return script
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Finds numbered sections with a common prefix.
|
|
266
|
+
* @param {object[]} sections Parsed sections.
|
|
267
|
+
* @param {string} prefix Section prefix.
|
|
268
|
+
* @returns {{ section: object, number: number }[]}
|
|
269
|
+
*/
|
|
270
|
+
static #numberedSections(sections, prefix) {
|
|
271
|
+
const pattern = new RegExp('^' + prefix + '(\\d+)$', 'iu')
|
|
272
|
+
|
|
273
|
+
return (sections || [])
|
|
274
|
+
.map((section) => ({
|
|
275
|
+
section,
|
|
276
|
+
match: pattern.exec(section.name)
|
|
277
|
+
}))
|
|
278
|
+
.filter(({ match }) => match)
|
|
279
|
+
.map(({ section, match }) => ({
|
|
280
|
+
section,
|
|
281
|
+
number: Number.parseInt(match[1], 10)
|
|
282
|
+
}))
|
|
283
|
+
.sort((left, right) => left.number - right.number)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Finds one section by case-insensitive name.
|
|
288
|
+
* @param {object[]} sections Parsed sections.
|
|
289
|
+
* @param {string} name Section name.
|
|
290
|
+
* @returns {object | undefined}
|
|
291
|
+
*/
|
|
292
|
+
static #findSection(sections, name) {
|
|
293
|
+
const normalized = String(name || '').toLowerCase()
|
|
294
|
+
return (sections || []).find(
|
|
295
|
+
(section) => section.name.toLowerCase() === normalized
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Converts one section to a key-value map.
|
|
301
|
+
* @param {{ entries?: { key: string, value: string }[] } | undefined} section Parsed section.
|
|
302
|
+
* @returns {Record<string, string>}
|
|
303
|
+
*/
|
|
304
|
+
static #sectionFields(section) {
|
|
305
|
+
return Object.fromEntries(
|
|
306
|
+
(section?.entries || []).map((entry) => [entry.key, entry.value])
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Serializes preserved sections.
|
|
312
|
+
* @param {object[]} sections Parsed sections.
|
|
313
|
+
* @returns {object[]}
|
|
314
|
+
*/
|
|
315
|
+
static #serializeSections(sections) {
|
|
316
|
+
return (sections || []).map((section) => ({
|
|
317
|
+
name: section.name,
|
|
318
|
+
entries: (section.entries || []).map((entry) => ({
|
|
319
|
+
key: entry.key,
|
|
320
|
+
value: entry.value
|
|
321
|
+
}))
|
|
322
|
+
}))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Parses optional boolean option values.
|
|
327
|
+
* @param {string | undefined} value Raw value.
|
|
328
|
+
* @returns {boolean | undefined}
|
|
329
|
+
*/
|
|
330
|
+
static #optionalBoolean(value) {
|
|
331
|
+
const normalized = String(value ?? '')
|
|
332
|
+
.trim()
|
|
333
|
+
.toLowerCase()
|
|
334
|
+
if (!normalized) return undefined
|
|
335
|
+
return ['1', 'true', 't', 'yes'].includes(normalized)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Normalizes project-relative paths.
|
|
340
|
+
* @param {string} path Source path.
|
|
341
|
+
* @returns {string}
|
|
342
|
+
*/
|
|
343
|
+
static #normalizePath(path) {
|
|
344
|
+
return String(path || '').replace(/\\/gu, '/')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Returns a basename from a project path.
|
|
349
|
+
* @param {string} path Source path.
|
|
350
|
+
* @returns {string}
|
|
351
|
+
*/
|
|
352
|
+
static #basename(path) {
|
|
353
|
+
return PrjScrModelParser.#normalizePath(path).split('/').pop() || ''
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Returns a lower-level extension token.
|
|
358
|
+
* @param {string} path Source path.
|
|
359
|
+
* @returns {string}
|
|
360
|
+
*/
|
|
361
|
+
static #extension(path) {
|
|
362
|
+
const name = PrjScrModelParser.#basename(path)
|
|
363
|
+
const dotIndex = name.lastIndexOf('.')
|
|
364
|
+
return dotIndex >= 0 ? name.slice(dotIndex) : ''
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Removes undefined and empty object fields.
|
|
369
|
+
* @param {Record<string, unknown>} value Source object.
|
|
370
|
+
* @returns {object}
|
|
371
|
+
*/
|
|
372
|
+
static #stripEmpty(value) {
|
|
373
|
+
return Object.fromEntries(
|
|
374
|
+
Object.entries(value || {}).filter(([, entryValue]) => {
|
|
375
|
+
if (
|
|
376
|
+
entryValue &&
|
|
377
|
+
typeof entryValue === 'object' &&
|
|
378
|
+
!Array.isArray(entryValue)
|
|
379
|
+
) {
|
|
380
|
+
return Object.keys(entryValue).length > 0
|
|
381
|
+
}
|
|
382
|
+
return entryValue !== undefined && entryValue !== ''
|
|
383
|
+
})
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds deterministic BOM/PnP reconciliation metadata for a project bundle.
|
|
7
|
+
*/
|
|
8
|
+
export class ProjectBomPnpReconciliationBuilder {
|
|
9
|
+
static SCHEMA_ID = 'altium-toolkit.project.bom-pnp-reconciliation.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a reconciliation report from bundle and source document models.
|
|
13
|
+
* @param {{ bundle?: object, documentModels?: object[], effectiveVariant?: object }} options Report options.
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(options = {}) {
|
|
17
|
+
const bundle = options.bundle || {}
|
|
18
|
+
const documentModels = Array.isArray(options.documentModels)
|
|
19
|
+
? options.documentModels
|
|
20
|
+
: []
|
|
21
|
+
const schematicBomDesignators =
|
|
22
|
+
ProjectBomPnpReconciliationBuilder.#bomDesignators(
|
|
23
|
+
documentModels.filter((model) => model?.kind === 'schematic')
|
|
24
|
+
)
|
|
25
|
+
const pcbBomDesignators =
|
|
26
|
+
ProjectBomPnpReconciliationBuilder.#bomDesignators(
|
|
27
|
+
documentModels.filter((model) => model?.kind === 'pcb')
|
|
28
|
+
)
|
|
29
|
+
const pnpDesignators =
|
|
30
|
+
ProjectBomPnpReconciliationBuilder.#pnpDesignators(documentModels)
|
|
31
|
+
const effectiveBomDesignators =
|
|
32
|
+
ProjectBomPnpReconciliationBuilder.#effectiveBomDesignators(
|
|
33
|
+
bundle,
|
|
34
|
+
options.effectiveVariant
|
|
35
|
+
)
|
|
36
|
+
const noBomDesignators =
|
|
37
|
+
ProjectBomPnpReconciliationBuilder.#noBomDesignators(documentModels)
|
|
38
|
+
const issues = [
|
|
39
|
+
...ProjectBomPnpReconciliationBuilder.#missingIssues(
|
|
40
|
+
schematicBomDesignators,
|
|
41
|
+
pcbBomDesignators,
|
|
42
|
+
'reconciliation.schematic-bom-without-pcb-bom',
|
|
43
|
+
'Schematic BOM designator was not present in the PCB-backed BOM.'
|
|
44
|
+
),
|
|
45
|
+
...ProjectBomPnpReconciliationBuilder.#missingIssues(
|
|
46
|
+
pcbBomDesignators,
|
|
47
|
+
schematicBomDesignators,
|
|
48
|
+
'reconciliation.pcb-bom-without-schematic-bom',
|
|
49
|
+
'PCB-backed BOM designator was not present in the schematic BOM.'
|
|
50
|
+
),
|
|
51
|
+
...ProjectBomPnpReconciliationBuilder.#missingIssues(
|
|
52
|
+
pcbBomDesignators,
|
|
53
|
+
pnpDesignators,
|
|
54
|
+
'reconciliation.bom-without-pnp',
|
|
55
|
+
'PCB-backed BOM designator did not have a PnP placement.'
|
|
56
|
+
),
|
|
57
|
+
...ProjectBomPnpReconciliationBuilder.#missingIssues(
|
|
58
|
+
pnpDesignators,
|
|
59
|
+
pcbBomDesignators,
|
|
60
|
+
'reconciliation.pnp-without-bom',
|
|
61
|
+
'PnP placement designator was not present in the PCB-backed BOM.'
|
|
62
|
+
),
|
|
63
|
+
...ProjectBomPnpReconciliationBuilder.#intersectionIssues(
|
|
64
|
+
noBomDesignators,
|
|
65
|
+
pcbBomDesignators,
|
|
66
|
+
'reconciliation.no-bom-component-in-pcb-bom',
|
|
67
|
+
'Component marked as no-BOM appeared in the PCB-backed BOM.'
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
schema: ProjectBomPnpReconciliationBuilder.SCHEMA_ID,
|
|
73
|
+
summary: {
|
|
74
|
+
schematicBomDesignatorCount: schematicBomDesignators.length,
|
|
75
|
+
pcbBomDesignatorCount: pcbBomDesignators.length,
|
|
76
|
+
pnpDesignatorCount: pnpDesignators.length,
|
|
77
|
+
effectiveBomDesignatorCount: effectiveBomDesignators.length,
|
|
78
|
+
noBomComponentCount: noBomDesignators.length,
|
|
79
|
+
issueCount: issues.length
|
|
80
|
+
},
|
|
81
|
+
schematicBomDesignators,
|
|
82
|
+
pcbBomDesignators,
|
|
83
|
+
pnpDesignators,
|
|
84
|
+
effectiveBomDesignators,
|
|
85
|
+
noBomDesignators,
|
|
86
|
+
issues
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extracts designators from BOM rows.
|
|
92
|
+
* @param {object[]} models Parsed document models.
|
|
93
|
+
* @returns {string[]}
|
|
94
|
+
*/
|
|
95
|
+
static #bomDesignators(models) {
|
|
96
|
+
const designators = new Set()
|
|
97
|
+
|
|
98
|
+
for (const model of models) {
|
|
99
|
+
for (const row of model?.bom || []) {
|
|
100
|
+
for (const designator of row.designators || []) {
|
|
101
|
+
ProjectBomPnpReconciliationBuilder.#addDesignator(
|
|
102
|
+
designators,
|
|
103
|
+
designator
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
ProjectBomPnpReconciliationBuilder.#addDesignator(
|
|
107
|
+
designators,
|
|
108
|
+
row.designator
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return ProjectBomPnpReconciliationBuilder.#sorted([...designators])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Extracts designators from pick-place entries.
|
|
118
|
+
* @param {object[]} models Parsed document models.
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
static #pnpDesignators(models) {
|
|
122
|
+
const designators = new Set()
|
|
123
|
+
|
|
124
|
+
for (const model of models.filter((item) => item?.kind === 'pcb')) {
|
|
125
|
+
const pnp = model.pnp || model.pcb?.pickPlace || {}
|
|
126
|
+
for (const entry of pnp.entries || []) {
|
|
127
|
+
ProjectBomPnpReconciliationBuilder.#addDesignator(
|
|
128
|
+
designators,
|
|
129
|
+
entry.designator
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return ProjectBomPnpReconciliationBuilder.#sorted([...designators])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extracts designators from the active effective variant or bundle BOM.
|
|
139
|
+
* @param {object} bundle Project design bundle.
|
|
140
|
+
* @param {object | undefined} effectiveVariant Effective variant view.
|
|
141
|
+
* @returns {string[]}
|
|
142
|
+
*/
|
|
143
|
+
static #effectiveBomDesignators(bundle, effectiveVariant) {
|
|
144
|
+
if (effectiveVariant?.bom) {
|
|
145
|
+
return ProjectBomPnpReconciliationBuilder.#bomDesignators([
|
|
146
|
+
{ bom: effectiveVariant.bom }
|
|
147
|
+
])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return ProjectBomPnpReconciliationBuilder.#bomDesignators([
|
|
151
|
+
{ bom: bundle.bom || [] }
|
|
152
|
+
])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Extracts component designators explicitly excluded from BOMs.
|
|
157
|
+
* @param {object[]} models Parsed document models.
|
|
158
|
+
* @returns {string[]}
|
|
159
|
+
*/
|
|
160
|
+
static #noBomDesignators(models) {
|
|
161
|
+
const designators = new Set()
|
|
162
|
+
|
|
163
|
+
for (const model of models.filter((item) => item?.kind === 'pcb')) {
|
|
164
|
+
for (const component of model.pcb?.components || []) {
|
|
165
|
+
if (component.componentKind?.includeInBom !== false) continue
|
|
166
|
+
ProjectBomPnpReconciliationBuilder.#addDesignator(
|
|
167
|
+
designators,
|
|
168
|
+
component.designator
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return ProjectBomPnpReconciliationBuilder.#sorted([...designators])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Adds a normalized designator to a set.
|
|
178
|
+
* @param {Set<string>} designators Target set.
|
|
179
|
+
* @param {unknown} value Raw designator value.
|
|
180
|
+
* @returns {void}
|
|
181
|
+
*/
|
|
182
|
+
static #addDesignator(designators, value) {
|
|
183
|
+
const designator = String(value || '').trim()
|
|
184
|
+
if (designator) designators.add(designator)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Builds missing-designator issue rows.
|
|
189
|
+
* @param {string[]} source Source designators.
|
|
190
|
+
* @param {string[]} target Target designators.
|
|
191
|
+
* @param {string} code Diagnostic code.
|
|
192
|
+
* @param {string} message Diagnostic message.
|
|
193
|
+
* @returns {object[]}
|
|
194
|
+
*/
|
|
195
|
+
static #missingIssues(source, target, code, message) {
|
|
196
|
+
const targetSet = new Set(target)
|
|
197
|
+
return source
|
|
198
|
+
.filter((designator) => !targetSet.has(designator))
|
|
199
|
+
.map((designator) => ({
|
|
200
|
+
severity: 'warning',
|
|
201
|
+
code,
|
|
202
|
+
designator,
|
|
203
|
+
message
|
|
204
|
+
}))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Builds issue rows for designators present in both sets.
|
|
209
|
+
* @param {string[]} left Left designators.
|
|
210
|
+
* @param {string[]} right Right designators.
|
|
211
|
+
* @param {string} code Diagnostic code.
|
|
212
|
+
* @param {string} message Diagnostic message.
|
|
213
|
+
* @returns {object[]}
|
|
214
|
+
*/
|
|
215
|
+
static #intersectionIssues(left, right, code, message) {
|
|
216
|
+
const rightSet = new Set(right)
|
|
217
|
+
return left
|
|
218
|
+
.filter((designator) => rightSet.has(designator))
|
|
219
|
+
.map((designator) => ({
|
|
220
|
+
severity: 'warning',
|
|
221
|
+
code,
|
|
222
|
+
designator,
|
|
223
|
+
message
|
|
224
|
+
}))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Sorts designators in a stable human-friendly order.
|
|
229
|
+
* @param {string[]} values Designator values.
|
|
230
|
+
* @returns {string[]}
|
|
231
|
+
*/
|
|
232
|
+
static #sorted(values) {
|
|
233
|
+
return [...values].sort((left, right) =>
|
|
234
|
+
left.localeCompare(right, undefined, { numeric: true })
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|