altium-toolkit 1.0.9 → 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.
Files changed (79) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,192 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AltiumParser } from './AltiumParser.mjs'
6
+ import { DraftsmanDigestParser } from './DraftsmanDigestParser.mjs'
7
+ import { PcbModelParser } from './PcbModelParser.mjs'
8
+ import { PrjPcbModelParser } from './PrjPcbModelParser.mjs'
9
+
10
+ /**
11
+ * Runs deterministic synthetic compatibility cases against parser entrypoints.
12
+ */
13
+ export class ParserCompatibilityFuzzer {
14
+ static SCHEMA = 'altium-toolkit.parser-compatibility-fuzz.a1'
15
+
16
+ /**
17
+ * Runs all built-in synthetic parser compatibility cases.
18
+ * @returns {object}
19
+ */
20
+ static run() {
21
+ const cases = ParserCompatibilityFuzzer.#cases().map((entry) =>
22
+ ParserCompatibilityFuzzer.#runCase(entry)
23
+ )
24
+
25
+ return {
26
+ schema: ParserCompatibilityFuzzer.SCHEMA,
27
+ summary: {
28
+ caseCount: cases.length,
29
+ failureCount: cases.filter((entry) => entry.status === 'fail')
30
+ .length,
31
+ diagnosticCount: cases.reduce(
32
+ (total, entry) =>
33
+ total + Number(entry.diagnosticCount || 0),
34
+ 0
35
+ )
36
+ },
37
+ cases
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Lists deterministic compatibility cases.
43
+ * @returns {{ key: string, parse: () => object }[]}
44
+ */
45
+ static #cases() {
46
+ return [
47
+ {
48
+ key: 'sch-record-ordering',
49
+ parse: () =>
50
+ AltiumParser.parseArrayBufferToRendererModel(
51
+ 'fuzz-order.SchDoc',
52
+ ParserCompatibilityFuzzer.#encodeText(
53
+ '|RECORD=999|Text=Unknown First|' +
54
+ '|HEADER=Schematic Document|' +
55
+ '|RECORD=31|CustomX=120|CustomY=80|BorderOn=F|TitleBlockOn=F|' +
56
+ '|RECORD=13|Location.X=10|Location.Y=10|Corner.X=80|Corner.Y=10|LineWidth=1|'
57
+ )
58
+ )
59
+ },
60
+ {
61
+ key: 'sch-odd-encoding',
62
+ parse: () =>
63
+ AltiumParser.parseArrayBufferToRendererModel(
64
+ 'fuzz-encoding.SchDoc',
65
+ ParserCompatibilityFuzzer.#windows1252Schematic()
66
+ )
67
+ },
68
+ {
69
+ key: 'pcb-malformed-sidecars',
70
+ parse: () =>
71
+ PcbModelParser.parse('fuzz-sidecar.PcbDoc', [
72
+ {
73
+ sourceStream: 'Pads6/Data',
74
+ fields: {
75
+ X: 'not-a-number',
76
+ Y: '20mil',
77
+ HOLESIZE: 'malformed',
78
+ NET: 'NET_A'
79
+ }
80
+ },
81
+ {
82
+ sourceStream: 'ExtendedPrimitiveInformation/Data',
83
+ fields: {
84
+ PRIMITIVEINDEX: 'not-a-number',
85
+ SolderMaskExpansionMode: 'Manual',
86
+ SolderMaskExpansion: 'bad'
87
+ }
88
+ },
89
+ {
90
+ sourceStream: 'UnsupportedSidecar/Data',
91
+ fields: { RECORD: '777', VALUE: 'preserve' }
92
+ }
93
+ ])
94
+ },
95
+ {
96
+ key: 'project-sparse-documents',
97
+ parse: () =>
98
+ PrjPcbModelParser.parseText(
99
+ 'fuzz-project.PrjPcb',
100
+ '[Design]\n\n[Document1]\nDocumentUniqueId=EMPTY\n'
101
+ )
102
+ },
103
+ {
104
+ key: 'draftsman-unsupported-container',
105
+ parse: () =>
106
+ DraftsmanDigestParser.parse(
107
+ 'fuzz.PCBDwf',
108
+ new Uint8Array([0, 1, 2, 3]).buffer
109
+ )
110
+ }
111
+ ]
112
+ }
113
+
114
+ /**
115
+ * Executes one compatibility case.
116
+ * @param {{ key: string, parse: () => object }} entry Case descriptor.
117
+ * @returns {object}
118
+ */
119
+ static #runCase(entry) {
120
+ try {
121
+ const model = entry.parse()
122
+ return {
123
+ key: entry.key,
124
+ status: 'pass',
125
+ kind: model?.kind || '',
126
+ fileType: model?.fileType || '',
127
+ diagnosticCount: (model?.diagnostics || []).length,
128
+ summary: ParserCompatibilityFuzzer.#stableSummary(
129
+ model?.summary || {}
130
+ )
131
+ }
132
+ } catch (error) {
133
+ return {
134
+ key: entry.key,
135
+ status: 'fail',
136
+ diagnosticCount: 1,
137
+ error: {
138
+ name: error?.name || 'Error',
139
+ message: error?.message || String(error)
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Builds a stable compact summary object.
147
+ * @param {object} summary Parser summary.
148
+ * @returns {object}
149
+ */
150
+ static #stableSummary(summary) {
151
+ return Object.fromEntries(
152
+ Object.entries(summary || {}).filter(([, value]) =>
153
+ ['number', 'string', 'boolean'].includes(typeof value)
154
+ )
155
+ )
156
+ }
157
+
158
+ /**
159
+ * Encodes text as UTF-8.
160
+ * @param {string} text Text payload.
161
+ * @returns {ArrayBuffer}
162
+ */
163
+ static #encodeText(text) {
164
+ const bytes = new TextEncoder().encode(text)
165
+ return bytes.buffer.slice(
166
+ bytes.byteOffset,
167
+ bytes.byteOffset + bytes.length
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Builds a schematic payload with one Windows-1252 punctuation byte.
173
+ * @returns {ArrayBuffer}
174
+ */
175
+ static #windows1252Schematic() {
176
+ const prefix = new TextEncoder().encode(
177
+ '|HEADER=Schematic Document|' +
178
+ '|RECORD=31|CustomX=120|CustomY=80|BorderOn=F|TitleBlockOn=F|' +
179
+ '|RECORD=4|Location.X=20|Location.Y=20|TEXT=ESD'
180
+ )
181
+ const suffix = new TextEncoder().encode('TVS|')
182
+ const bytes = new Uint8Array(prefix.length + 1 + suffix.length)
183
+ bytes.set(prefix, 0)
184
+ bytes[prefix.length] = 0x96
185
+ bytes.set(suffix, prefix.length + 1)
186
+
187
+ return bytes.buffer.slice(
188
+ bytes.byteOffset,
189
+ bytes.byteOffset + bytes.length
190
+ )
191
+ }
192
+ }
@@ -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
+ }