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.
- package/docs/api.md +6 -2
- package/docs/model-format.md +29 -4
- package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -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 +166 -0
- 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/netlist_a1.schema.json +6 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
- package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
- 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/pcb_svg_semantics_a1.schema.json +27 -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_bundle_a1.schema.json +6 -0
- package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -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/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
- package/package.json +1 -1
- package/src/core/altium/AltiumParser.mjs +12 -2
- package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
- package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
- package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
- package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
- 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/ParserCompatibilityFuzzer.mjs +192 -0
- 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 +211 -22
- package/src/core/altium/PcbPadStackParser.mjs +171 -2
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
- package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
- package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
- 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/PcbRouteAnalysisBuilder.mjs +730 -0
- package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
- package/src/core/altium/PrintableTextDecoder.mjs +70 -6
- package/src/core/altium/PrjPcbModelParser.mjs +69 -2
- package/src/core/altium/PrjScrModelParser.mjs +386 -0
- package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
- package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
- package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
- package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
- package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/parser.mjs +21 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
- package/src/ui/PcbScene3dBuilder.mjs +26 -4
- package/src/ui/PcbSvgRenderer.mjs +65 -0
- package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
- 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
|
+
}
|