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,263 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds PCB-only BOM grouping and parameter-normalization profiles.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbBomProfileBuilder {
|
|
9
|
+
static SCHEMA_ID = 'altium-toolkit.pcb.bom-profile.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a deterministic BOM profile from PCB component rows.
|
|
13
|
+
* @param {object[]} components Normalized PCB component rows.
|
|
14
|
+
* @param {{ source?: string }} options Build options.
|
|
15
|
+
* @returns {object}
|
|
16
|
+
*/
|
|
17
|
+
static build(components, options = {}) {
|
|
18
|
+
const normalizedComponents = (components || []).map((component) =>
|
|
19
|
+
PcbBomProfileBuilder.#component(component)
|
|
20
|
+
)
|
|
21
|
+
const included = normalizedComponents.filter(
|
|
22
|
+
(component) => component.includeInBom
|
|
23
|
+
)
|
|
24
|
+
const groups = PcbBomProfileBuilder.#groups(included)
|
|
25
|
+
const exclusions = normalizedComponents
|
|
26
|
+
.filter((component) => !component.includeInBom)
|
|
27
|
+
.map((component) => ({
|
|
28
|
+
designator: component.designator,
|
|
29
|
+
reason: 'component-kind:no-bom'
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
schema: PcbBomProfileBuilder.SCHEMA_ID,
|
|
34
|
+
source: String(options.source || 'pcb-document'),
|
|
35
|
+
summary: {
|
|
36
|
+
componentCount: normalizedComponents.length,
|
|
37
|
+
includedComponentCount: included.length,
|
|
38
|
+
excludedComponentCount: exclusions.length,
|
|
39
|
+
groupCount: groups.length,
|
|
40
|
+
normalizedParameterCount:
|
|
41
|
+
PcbBomProfileBuilder.#normalizedParameterCount(included)
|
|
42
|
+
},
|
|
43
|
+
groups,
|
|
44
|
+
components: normalizedComponents,
|
|
45
|
+
exclusions
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalizes one component into a BOM profile row.
|
|
51
|
+
* @param {object} component Source component.
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
static #component(component) {
|
|
55
|
+
const normalizedParameters = PcbBomProfileBuilder.#normalizedParameters(
|
|
56
|
+
component?.parameters || {}
|
|
57
|
+
)
|
|
58
|
+
const sourceParameterNames = Object.keys(component?.parameters || {})
|
|
59
|
+
const includeInBom =
|
|
60
|
+
component?.componentKind?.includeInBom === false ? false : true
|
|
61
|
+
const value =
|
|
62
|
+
normalizedParameters.comment ||
|
|
63
|
+
component?.description ||
|
|
64
|
+
component?.value ||
|
|
65
|
+
component?.pattern ||
|
|
66
|
+
''
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
designator: String(component?.designator || ''),
|
|
70
|
+
includeInBom,
|
|
71
|
+
componentKind: component?.componentKind?.name || 'standard',
|
|
72
|
+
pattern: String(component?.pattern || ''),
|
|
73
|
+
source: String(component?.source || ''),
|
|
74
|
+
value,
|
|
75
|
+
normalizedParameters,
|
|
76
|
+
sourceParameterNames
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds grouped BOM rows from included components.
|
|
82
|
+
* @param {object[]} components Included normalized components.
|
|
83
|
+
* @returns {object[]}
|
|
84
|
+
*/
|
|
85
|
+
static #groups(components) {
|
|
86
|
+
const byKey = new Map()
|
|
87
|
+
|
|
88
|
+
for (const component of components || []) {
|
|
89
|
+
const key = PcbBomProfileBuilder.#groupKey(component)
|
|
90
|
+
if (!byKey.has(key)) {
|
|
91
|
+
byKey.set(key, {
|
|
92
|
+
key,
|
|
93
|
+
quantity: 0,
|
|
94
|
+
designators: [],
|
|
95
|
+
pattern: component.pattern,
|
|
96
|
+
source: component.source,
|
|
97
|
+
value: component.value,
|
|
98
|
+
normalizedParameters: {}
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
const group = byKey.get(key)
|
|
102
|
+
group.quantity += 1
|
|
103
|
+
group.designators.push(component.designator)
|
|
104
|
+
group.normalizedParameters = PcbBomProfileBuilder.#mergeParameters(
|
|
105
|
+
group.normalizedParameters,
|
|
106
|
+
component.normalizedParameters
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return [...byKey.values()].map((group) => ({
|
|
111
|
+
...group,
|
|
112
|
+
designators: PcbBomProfileBuilder.#sortDesignators(
|
|
113
|
+
group.designators
|
|
114
|
+
),
|
|
115
|
+
normalizedParameters:
|
|
116
|
+
PcbBomProfileBuilder.#stripGroupingOnlyParameters(
|
|
117
|
+
group.normalizedParameters
|
|
118
|
+
)
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builds the stable grouping key for one included component.
|
|
124
|
+
* @param {object} component Normalized component row.
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
static #groupKey(component) {
|
|
128
|
+
const parameters = component.normalizedParameters || {}
|
|
129
|
+
return [
|
|
130
|
+
parameters.manufacturer || '',
|
|
131
|
+
parameters.manufacturerPartNumber || '',
|
|
132
|
+
parameters.supplierPartNumber || '',
|
|
133
|
+
component.pattern || '',
|
|
134
|
+
component.value || ''
|
|
135
|
+
].join('|')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Converts source parameters into normalized BOM aliases.
|
|
140
|
+
* @param {Record<string, string>} parameters Source parameters.
|
|
141
|
+
* @returns {Record<string, string>}
|
|
142
|
+
*/
|
|
143
|
+
static #normalizedParameters(parameters) {
|
|
144
|
+
const normalized = {}
|
|
145
|
+
|
|
146
|
+
for (const [name, value] of Object.entries(parameters || {})) {
|
|
147
|
+
const text = String(value || '').trim()
|
|
148
|
+
if (!text) continue
|
|
149
|
+
|
|
150
|
+
const alias = PcbBomProfileBuilder.#aliasForName(name)
|
|
151
|
+
if (alias && !normalized[alias]) {
|
|
152
|
+
normalized[alias] = text
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
alias === 'supplierPartNumber' &&
|
|
156
|
+
/jlcpcb/iu.test(String(name || '')) &&
|
|
157
|
+
!normalized.supplier
|
|
158
|
+
) {
|
|
159
|
+
normalized.supplier = 'JLCPCB'
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return normalized
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolves a normalized alias for a source parameter name.
|
|
168
|
+
* @param {string} name Source parameter name.
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
static #aliasForName(name) {
|
|
172
|
+
const key = String(name || '')
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.replace(/[^a-z0-9]+/gu, '')
|
|
175
|
+
|
|
176
|
+
if (['manufacturer', 'mfr', 'mfg'].includes(key)) {
|
|
177
|
+
return 'manufacturer'
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
[
|
|
181
|
+
'manufacturerpartnumber',
|
|
182
|
+
'manufacturerpn',
|
|
183
|
+
'mfrpartnumber',
|
|
184
|
+
'mfgpartnumber',
|
|
185
|
+
'mpn'
|
|
186
|
+
].includes(key)
|
|
187
|
+
) {
|
|
188
|
+
return 'manufacturerPartNumber'
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
[
|
|
192
|
+
'supplierpartnumber',
|
|
193
|
+
'supplierpn',
|
|
194
|
+
'supplierpart',
|
|
195
|
+
'jlcpcbpart',
|
|
196
|
+
'jlcpcbpartnumber',
|
|
197
|
+
'jlcpcbpartno'
|
|
198
|
+
].includes(key)
|
|
199
|
+
) {
|
|
200
|
+
return 'supplierPartNumber'
|
|
201
|
+
}
|
|
202
|
+
if (key === 'category') return 'category'
|
|
203
|
+
if (key === 'comment' || key === 'value') return 'comment'
|
|
204
|
+
|
|
205
|
+
return ''
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Merges normalized parameters while preserving the first non-empty value.
|
|
210
|
+
* @param {object} existing Existing values.
|
|
211
|
+
* @param {object} incoming Candidate values.
|
|
212
|
+
* @returns {object}
|
|
213
|
+
*/
|
|
214
|
+
static #mergeParameters(existing, incoming) {
|
|
215
|
+
const merged = { ...(existing || {}) }
|
|
216
|
+
|
|
217
|
+
for (const [key, value] of Object.entries(incoming || {})) {
|
|
218
|
+
if (merged[key] || value === undefined || value === '') continue
|
|
219
|
+
merged[key] = value
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return merged
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Removes fields that are component-specific rather than group identity.
|
|
227
|
+
* @param {object} parameters Normalized parameters.
|
|
228
|
+
* @returns {object}
|
|
229
|
+
*/
|
|
230
|
+
static #stripGroupingOnlyParameters(parameters) {
|
|
231
|
+
const { comment: _comment, ...rest } = parameters || {}
|
|
232
|
+
return rest
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Counts normalized source aliases used for group identity.
|
|
237
|
+
* @param {object[]} components Included component rows.
|
|
238
|
+
* @returns {number}
|
|
239
|
+
*/
|
|
240
|
+
static #normalizedParameterCount(components) {
|
|
241
|
+
return (components || []).reduce(
|
|
242
|
+
(count, component) =>
|
|
243
|
+
count +
|
|
244
|
+
Object.keys(component.normalizedParameters || {}).filter(
|
|
245
|
+
(key) => !['comment', 'supplier'].includes(key)
|
|
246
|
+
).length,
|
|
247
|
+
0
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Sorts designators in a stable natural-ish order.
|
|
253
|
+
* @param {string[]} designators Designator list.
|
|
254
|
+
* @returns {string[]}
|
|
255
|
+
*/
|
|
256
|
+
static #sortDesignators(designators) {
|
|
257
|
+
return (designators || []).slice().sort((left, right) =>
|
|
258
|
+
String(left).localeCompare(String(right), undefined, {
|
|
259
|
+
numeric: true
|
|
260
|
+
})
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
|
|
7
|
+
const { getField, parseNumericField } = ParserUtils
|
|
8
|
+
|
|
9
|
+
const KIND_BY_VALUE = {
|
|
10
|
+
0: {
|
|
11
|
+
name: 'standard',
|
|
12
|
+
displayName: 'Standard',
|
|
13
|
+
includeInBom: true,
|
|
14
|
+
includeInNetlist: true,
|
|
15
|
+
includeInPnp: true
|
|
16
|
+
},
|
|
17
|
+
1: {
|
|
18
|
+
name: 'mechanical',
|
|
19
|
+
displayName: 'Mechanical',
|
|
20
|
+
includeInBom: true,
|
|
21
|
+
includeInNetlist: true,
|
|
22
|
+
includeInPnp: true
|
|
23
|
+
},
|
|
24
|
+
2: {
|
|
25
|
+
name: 'graphical',
|
|
26
|
+
displayName: 'Graphical',
|
|
27
|
+
includeInBom: false,
|
|
28
|
+
includeInNetlist: false,
|
|
29
|
+
includeInPnp: false
|
|
30
|
+
},
|
|
31
|
+
3: {
|
|
32
|
+
name: 'net-tie-bom',
|
|
33
|
+
displayName: 'Net Tie BOM',
|
|
34
|
+
includeInBom: true,
|
|
35
|
+
includeInNetlist: true,
|
|
36
|
+
includeInPnp: true
|
|
37
|
+
},
|
|
38
|
+
4: {
|
|
39
|
+
name: 'net-tie-no-bom',
|
|
40
|
+
displayName: 'Net Tie No BOM',
|
|
41
|
+
includeInBom: false,
|
|
42
|
+
includeInNetlist: true,
|
|
43
|
+
includeInPnp: true
|
|
44
|
+
},
|
|
45
|
+
5: {
|
|
46
|
+
name: 'standard-no-bom',
|
|
47
|
+
displayName: 'Standard No BOM',
|
|
48
|
+
includeInBom: false,
|
|
49
|
+
includeInNetlist: true,
|
|
50
|
+
includeInPnp: true
|
|
51
|
+
},
|
|
52
|
+
6: {
|
|
53
|
+
name: 'jumper',
|
|
54
|
+
displayName: 'Jumper',
|
|
55
|
+
includeInBom: true,
|
|
56
|
+
includeInNetlist: true,
|
|
57
|
+
includeInPnp: true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalizes native PCB component kind fields and participation policy.
|
|
63
|
+
*/
|
|
64
|
+
export class PcbComponentKindPolicy {
|
|
65
|
+
/**
|
|
66
|
+
* Parses native versioned component-kind fields.
|
|
67
|
+
* @param {Record<string, string | string[]>} fields Native component row.
|
|
68
|
+
* @returns {{ value: number, name: string, displayName: string, includeInBom: boolean, includeInNetlist: boolean, includeInPnp: boolean } | undefined}
|
|
69
|
+
*/
|
|
70
|
+
static parse(fields) {
|
|
71
|
+
if (!PcbComponentKindPolicy.#hasKindField(fields)) return undefined
|
|
72
|
+
|
|
73
|
+
const value = PcbComponentKindPolicy.#kindValue(fields)
|
|
74
|
+
const policy = KIND_BY_VALUE[value] || {
|
|
75
|
+
name: 'unknown',
|
|
76
|
+
displayName: 'Unknown',
|
|
77
|
+
includeInBom: true,
|
|
78
|
+
includeInNetlist: true,
|
|
79
|
+
includeInPnp: true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
value,
|
|
84
|
+
...policy
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns true when a component row carries any native kind field.
|
|
90
|
+
* @param {Record<string, string | string[]>} fields Native component row.
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
static #hasKindField(fields) {
|
|
94
|
+
return [
|
|
95
|
+
'COMPONENTKIND',
|
|
96
|
+
'ComponentKind',
|
|
97
|
+
'COMPONENTKINDVERSION2',
|
|
98
|
+
'ComponentKindVersion2',
|
|
99
|
+
'COMPONENTKINDVERSION3',
|
|
100
|
+
'ComponentKindVersion3'
|
|
101
|
+
].some((key) => Object.hasOwn(fields || {}, key))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves the effective native component kind from versioned fields.
|
|
106
|
+
* @param {Record<string, string | string[]>} fields Native component row.
|
|
107
|
+
* @returns {number}
|
|
108
|
+
*/
|
|
109
|
+
static #kindValue(fields) {
|
|
110
|
+
const v1 = PcbComponentKindPolicy.#numericField(fields, [
|
|
111
|
+
'COMPONENTKIND',
|
|
112
|
+
'ComponentKind'
|
|
113
|
+
])
|
|
114
|
+
const v2 = PcbComponentKindPolicy.#numericField(fields, [
|
|
115
|
+
'COMPONENTKINDVERSION2',
|
|
116
|
+
'ComponentKindVersion2'
|
|
117
|
+
])
|
|
118
|
+
const v3 = PcbComponentKindPolicy.#numericField(fields, [
|
|
119
|
+
'COMPONENTKINDVERSION3',
|
|
120
|
+
'ComponentKindVersion3'
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
if (v3 === 6) return v3
|
|
124
|
+
if (v2 !== null && v2 >= 5) return v2
|
|
125
|
+
return v1 ?? 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns the first finite numeric value from possible field names.
|
|
130
|
+
* @param {Record<string, string | string[]>} fields Native fields.
|
|
131
|
+
* @param {string[]} keys Candidate keys.
|
|
132
|
+
* @returns {number | null}
|
|
133
|
+
*/
|
|
134
|
+
static #numericField(fields, keys) {
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
const value = parseNumericField(fields, key)
|
|
137
|
+
if (Number.isFinite(value)) return value
|
|
138
|
+
|
|
139
|
+
const raw = getField(fields, key)
|
|
140
|
+
const parsed = Number.parseInt(String(raw ?? '').trim(), 10)
|
|
141
|
+
if (Number.isFinite(parsed)) return parsed
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Classifies layer-stack source evidence and regeneration limits.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbLayerStackFidelityReportBuilder {
|
|
9
|
+
static SCHEMA_ID = 'altium-toolkit.pcb.layer-stack-fidelity.a1'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a source-fidelity report for a layer-stack read model.
|
|
13
|
+
* @param {object} readModel Layer-stack read model.
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
static build(readModel) {
|
|
17
|
+
const semanticSections =
|
|
18
|
+
PcbLayerStackFidelityReportBuilder.#semanticSections(readModel)
|
|
19
|
+
const nativeCacheSections =
|
|
20
|
+
PcbLayerStackFidelityReportBuilder.#nativeCacheSections(readModel)
|
|
21
|
+
const interchangeOnlySections =
|
|
22
|
+
PcbLayerStackFidelityReportBuilder.#interchangeOnlySections(
|
|
23
|
+
readModel
|
|
24
|
+
)
|
|
25
|
+
const diagnostics = readModel?.diagnostics || []
|
|
26
|
+
const unsupportedRegeneration =
|
|
27
|
+
PcbLayerStackFidelityReportBuilder.#unsupportedRegeneration(
|
|
28
|
+
nativeCacheSections,
|
|
29
|
+
diagnostics
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
schema: PcbLayerStackFidelityReportBuilder.SCHEMA_ID,
|
|
34
|
+
sourceDocument: String(readModel?.source?.fileName || ''),
|
|
35
|
+
summary: {
|
|
36
|
+
semanticLayerCount: (readModel?.layers || []).length,
|
|
37
|
+
nativeCacheFeatureCount: nativeCacheSections.length,
|
|
38
|
+
interchangeOnlyFeatureCount: interchangeOnlySections.length,
|
|
39
|
+
unsupportedRegenerationCount: unsupportedRegeneration.length,
|
|
40
|
+
diagnosticCount: diagnostics.length
|
|
41
|
+
},
|
|
42
|
+
capabilities: {
|
|
43
|
+
semanticRead: semanticSections.length > 0,
|
|
44
|
+
nativeCacheRead: nativeCacheSections.length > 0,
|
|
45
|
+
interchangeRead: interchangeOnlySections.length > 0,
|
|
46
|
+
deterministicReport: true,
|
|
47
|
+
nativeRegeneration: false
|
|
48
|
+
},
|
|
49
|
+
semanticSections,
|
|
50
|
+
nativeCacheSections,
|
|
51
|
+
interchangeOnlySections,
|
|
52
|
+
unsupportedRegeneration,
|
|
53
|
+
diagnostics
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Lists semantic layer-stack sections in the model.
|
|
59
|
+
* @param {object} readModel Layer-stack read model.
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
static #semanticSections(readModel) {
|
|
63
|
+
return [
|
|
64
|
+
['layers', readModel?.layers],
|
|
65
|
+
['substacks', readModel?.substacks],
|
|
66
|
+
['branches', readModel?.branches],
|
|
67
|
+
['impedanceProfiles', readModel?.impedanceProfiles]
|
|
68
|
+
]
|
|
69
|
+
.filter(([, values]) => (values || []).length > 0)
|
|
70
|
+
.map(([section]) => section)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lists native-cache-only sections represented in the model.
|
|
75
|
+
* @param {object} readModel Layer-stack read model.
|
|
76
|
+
* @returns {string[]}
|
|
77
|
+
*/
|
|
78
|
+
static #nativeCacheSections(readModel) {
|
|
79
|
+
const sections = []
|
|
80
|
+
const sourceMap = readModel?.sourceMap || {}
|
|
81
|
+
|
|
82
|
+
if (sourceMap.registryEntryCount > 0) sections.push('source.registry')
|
|
83
|
+
if (sourceMap.sourceKeyCount > 0) sections.push('source.keys')
|
|
84
|
+
if ((readModel?.topLevelBendLines || []).length > 0) {
|
|
85
|
+
sections.push('bendLines')
|
|
86
|
+
}
|
|
87
|
+
if (sourceMap.cavityRegionCount > 0) sections.push('cavities')
|
|
88
|
+
if (sourceMap.surfaceFinishCount > 0) sections.push('surfaceFinish')
|
|
89
|
+
|
|
90
|
+
return sections
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Lists fields that are preserved as interchange-specific metadata.
|
|
95
|
+
* @param {object} readModel Layer-stack read model.
|
|
96
|
+
* @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
static #interchangeOnlySections(readModel) {
|
|
99
|
+
const layers = readModel?.layers || []
|
|
100
|
+
const sections = []
|
|
101
|
+
|
|
102
|
+
if (layers.some((layer) => layer.stackupxShared !== undefined)) {
|
|
103
|
+
sections.push('layers.stackupxShared')
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
layers.some(
|
|
107
|
+
(layer) =>
|
|
108
|
+
Object.keys(layer.stackupxProperties || {}).length > 0
|
|
109
|
+
)
|
|
110
|
+
) {
|
|
111
|
+
sections.push('layers.stackupxProperties')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return sections
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Describes why native stack regeneration is intentionally unsupported.
|
|
119
|
+
* @param {string[]} nativeCacheSections Native cache sections.
|
|
120
|
+
* @param {object[]} diagnostics Read-model diagnostics.
|
|
121
|
+
* @returns {object[]}
|
|
122
|
+
*/
|
|
123
|
+
static #unsupportedRegeneration(nativeCacheSections, diagnostics) {
|
|
124
|
+
const issues = []
|
|
125
|
+
|
|
126
|
+
if (nativeCacheSections.length > 0) {
|
|
127
|
+
issues.push({
|
|
128
|
+
section: 'native-cache',
|
|
129
|
+
reason: 'Native cache metadata is preserved for review but not regenerated.'
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if ((diagnostics || []).length > 0) {
|
|
133
|
+
issues.push({
|
|
134
|
+
section: 'diagnostics',
|
|
135
|
+
reason: 'Unresolved references prevent equivalent native regeneration.'
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return issues
|
|
140
|
+
}
|
|
141
|
+
}
|