altium-toolkit 1.0.8 → 1.0.10

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 (102) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  5. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  6. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
  7. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
  8. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  9. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
  10. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
  11. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  12. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  13. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  14. package/docs/testing.md +9 -3
  15. package/package.json +1 -1
  16. package/spec/library-scope.md +7 -1
  17. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  18. package/src/core/altium/AltiumParser.mjs +196 -45
  19. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  20. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  21. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  22. package/src/core/altium/IntLibModelParser.mjs +240 -0
  23. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  24. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  25. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  26. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  27. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  28. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  29. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  30. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  31. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  32. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  33. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  34. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  35. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  36. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  37. package/src/core/altium/PcbModelParser.mjs +495 -32
  38. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  39. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  40. package/src/core/altium/PcbPadStackParser.mjs +229 -2
  41. package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
  42. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  43. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  44. package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
  45. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  46. package/src/core/altium/PcbRuleParser.mjs +354 -33
  47. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  48. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  49. package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
  50. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  51. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  52. package/src/core/altium/PcbUnionParser.mjs +307 -0
  53. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  54. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  55. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  56. package/src/core/altium/PrjPcbModelParser.mjs +281 -7
  57. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  58. package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
  59. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  60. package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
  61. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  62. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  63. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  64. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  65. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  66. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  67. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  68. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  69. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  70. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  71. package/src/core/altium/SchematicImageParser.mjs +474 -3
  72. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  73. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  74. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  75. package/src/core/altium/SchematicPinParser.mjs +84 -1
  76. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  77. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  78. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  79. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  80. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  81. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  82. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  83. package/src/core/altium/SchematicTextParser.mjs +123 -0
  84. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  85. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  86. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  87. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  88. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  89. package/src/parser.mjs +35 -0
  90. package/src/styles/altium-renderers.css +19 -0
  91. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  92. package/src/ui/PcbInteractionIndex.mjs +9 -4
  93. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  94. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  95. package/src/ui/PcbSvgRenderer.mjs +1252 -34
  96. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  97. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  98. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  99. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  100. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  101. package/src/ui/SchematicTypography.mjs +48 -5
  102. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -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,244 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbSidecarRecordParser } from './PcbSidecarRecordParser.mjs'
6
+
7
+ /**
8
+ * Decodes custom pad shape sidecars and links pads to authored geometry.
9
+ */
10
+ export class PcbCustomPadShapeParser {
11
+ static #SOURCE_STREAM = 'CustomShapes/Data'
12
+
13
+ /**
14
+ * Parses custom pad shape sidecar records.
15
+ * @param {Uint8Array | ArrayBuffer | undefined} dataBytes
16
+ * @param {string} [sourceStream]
17
+ * @returns {{ entries: object[], byPrimitiveIndex: Record<string, object[]> }}
18
+ */
19
+ static parse(
20
+ dataBytes,
21
+ sourceStream = PcbCustomPadShapeParser.#SOURCE_STREAM
22
+ ) {
23
+ const entries = PcbSidecarRecordParser.parseLengthPrefixedRecords(
24
+ dataBytes,
25
+ sourceStream
26
+ )
27
+ .map((record) => PcbCustomPadShapeParser.#normalizeRecord(record))
28
+ .filter(Boolean)
29
+ const byPrimitiveIndex = {}
30
+
31
+ for (const entry of entries) {
32
+ const key = String(entry.primitiveIndex)
33
+ byPrimitiveIndex[key] = byPrimitiveIndex[key] || []
34
+ byPrimitiveIndex[key].push(entry)
35
+ }
36
+
37
+ return {
38
+ entries,
39
+ byPrimitiveIndex
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Links custom shape entries to pad records in place.
45
+ * @param {object[]} pads
46
+ * @param {{ byPrimitiveIndex?: Record<string, object[]> }} customShapes
47
+ * @param {{ regions?: object[], shapeBasedRegions?: object[], arcs?: object[], tracks?: object[], fills?: object[] }} geometry
48
+ */
49
+ static attachToPads(pads, customShapes, geometry = {}) {
50
+ if (!Array.isArray(pads) || !customShapes?.byPrimitiveIndex) {
51
+ return
52
+ }
53
+
54
+ for (let index = 0; index < pads.length; index += 1) {
55
+ const entries = customShapes.byPrimitiveIndex[String(index)] || []
56
+ if (!entries.length) {
57
+ continue
58
+ }
59
+
60
+ pads[index].customShape =
61
+ PcbCustomPadShapeParser.#buildPadCustomShape(
62
+ index,
63
+ entries,
64
+ geometry
65
+ )
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Normalizes one custom-shape sidecar record.
71
+ * @param {{ fields: Record<string, string>, sourceStream: string }} record
72
+ * @returns {object | null}
73
+ */
74
+ static #normalizeRecord(record) {
75
+ const primitiveIndex = PcbSidecarRecordParser.parseInteger(
76
+ PcbSidecarRecordParser.firstField(record.fields, [
77
+ 'PRIMITIVEINDEX',
78
+ 'PADINDEX',
79
+ 'ANCHORINDEX'
80
+ ])
81
+ )
82
+
83
+ if (primitiveIndex === null) {
84
+ return null
85
+ }
86
+
87
+ return {
88
+ primitiveIndex,
89
+ layer: PcbSidecarRecordParser.firstField(record.fields, ['LAYER']),
90
+ layerId: PcbSidecarRecordParser.parseInteger(
91
+ PcbSidecarRecordParser.firstField(record.fields, [
92
+ 'LAYERID',
93
+ 'LAYERINDEX'
94
+ ])
95
+ ),
96
+ pasteMask: PcbSidecarRecordParser.parseBoolean(
97
+ PcbSidecarRecordParser.firstField(record.fields, [
98
+ 'PASTEMASK',
99
+ 'HASPASTEMASK'
100
+ ])
101
+ ),
102
+ solderMask: PcbSidecarRecordParser.parseBoolean(
103
+ PcbSidecarRecordParser.firstField(record.fields, [
104
+ 'SOLDERMASK',
105
+ 'HASSOLDERMASK'
106
+ ])
107
+ ),
108
+ regionIndexes: PcbCustomPadShapeParser.#parseIndexList(
109
+ record.fields,
110
+ ['REGIONINDEX', 'REGIONINDEXES']
111
+ ),
112
+ shapeRegionIndexes: PcbCustomPadShapeParser.#parseIndexList(
113
+ record.fields,
114
+ ['SHAPEREGIONINDEX', 'SHAPEREGIONINDEXES']
115
+ ),
116
+ arcIndexes: PcbCustomPadShapeParser.#parseIndexList(record.fields, [
117
+ 'ARCINDEX',
118
+ 'ARCINDEXES'
119
+ ]),
120
+ trackIndexes: PcbCustomPadShapeParser.#parseIndexList(
121
+ record.fields,
122
+ ['TRACKINDEX', 'TRACKINDEXES']
123
+ ),
124
+ fillIndexes: PcbCustomPadShapeParser.#parseIndexList(
125
+ record.fields,
126
+ ['FILLINDEX', 'FILLINDEXES']
127
+ ),
128
+ sourceStream: record.sourceStream,
129
+ fields: record.fields
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Builds the custom-shape object attached to a pad.
135
+ * @param {number} primitiveIndex
136
+ * @param {object[]} entries
137
+ * @param {object} geometry
138
+ * @returns {object}
139
+ */
140
+ static #buildPadCustomShape(primitiveIndex, entries, geometry) {
141
+ return {
142
+ primitiveIndex,
143
+ sourceStream: entries[0]?.sourceStream || '',
144
+ layers: entries.map((entry) =>
145
+ PcbCustomPadShapeParser.#buildLayerShape(entry, geometry)
146
+ )
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Resolves one layer-specific shape entry.
152
+ * @param {object} entry
153
+ * @param {object} geometry
154
+ * @returns {object}
155
+ */
156
+ static #buildLayerShape(entry, geometry) {
157
+ return {
158
+ layer: entry.layer,
159
+ layerId: entry.layerId,
160
+ pasteMask: entry.pasteMask,
161
+ solderMask: entry.solderMask,
162
+ regions: PcbCustomPadShapeParser.#lookupMany(
163
+ geometry.regions || [],
164
+ entry.regionIndexes
165
+ ),
166
+ arcs: PcbCustomPadShapeParser.#lookupMany(
167
+ geometry.arcs || [],
168
+ entry.arcIndexes
169
+ ),
170
+ tracks: PcbCustomPadShapeParser.#lookupMany(
171
+ geometry.tracks || [],
172
+ entry.trackIndexes
173
+ ),
174
+ fills: PcbCustomPadShapeParser.#lookupMany(
175
+ geometry.fills || [],
176
+ entry.fillIndexes
177
+ ),
178
+ ...(entry.shapeRegionIndexes.length
179
+ ? {
180
+ shapeRegions: PcbCustomPadShapeParser.#lookupMany(
181
+ geometry.shapeBasedRegions || [],
182
+ entry.shapeRegionIndexes
183
+ )
184
+ }
185
+ : {})
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Resolves several primitive indexes into object references.
191
+ * @param {object[]} collection
192
+ * @param {number[]} indexes
193
+ * @returns {object[]}
194
+ */
195
+ static #lookupMany(collection, indexes) {
196
+ return indexes
197
+ .map((index) => collection[index])
198
+ .filter((value) => value && typeof value === 'object')
199
+ }
200
+
201
+ /**
202
+ * Parses one or more integer indexes from scalar or numbered fields.
203
+ * @param {Record<string, string>} fields
204
+ * @param {string[]} baseKeys
205
+ * @returns {number[]}
206
+ */
207
+ static #parseIndexList(fields, baseKeys) {
208
+ const indexes = []
209
+
210
+ for (const baseKey of baseKeys) {
211
+ const directValue = fields[baseKey]
212
+ if (directValue) {
213
+ indexes.push(
214
+ ...PcbCustomPadShapeParser.#splitIndexes(directValue)
215
+ )
216
+ }
217
+
218
+ for (const [key, value] of Object.entries(fields)) {
219
+ if (!key.startsWith(baseKey)) {
220
+ continue
221
+ }
222
+ if (key === baseKey) {
223
+ continue
224
+ }
225
+
226
+ indexes.push(...PcbCustomPadShapeParser.#splitIndexes(value))
227
+ }
228
+ }
229
+
230
+ return [...new Set(indexes)]
231
+ }
232
+
233
+ /**
234
+ * Parses a comma/semicolon-delimited index field.
235
+ * @param {string} value
236
+ * @returns {number[]}
237
+ */
238
+ static #splitIndexes(value) {
239
+ return String(value || '')
240
+ .split(/[;,\s]+/u)
241
+ .map((part) => PcbSidecarRecordParser.parseInteger(part))
242
+ .filter(Number.isInteger)
243
+ }
244
+ }
@@ -0,0 +1,171 @@
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, toColor } = ParserUtils
8
+
9
+ /**
10
+ * Builds read-only PCB and footprint-library default metadata.
11
+ */
12
+ export class PcbDefaultsParser {
13
+ static SCHEMA_ID = 'altium-toolkit.pcb.defaults.a1'
14
+
15
+ /**
16
+ * Parses a defaults read model from one field bag.
17
+ * @param {Record<string, string | string[]> | undefined} fields Source fields.
18
+ * @param {string} source Defaults source label.
19
+ * @returns {object | null}
20
+ */
21
+ static parse(fields, source) {
22
+ const board = PcbDefaultsParser.#stripEmpty({
23
+ defaultFontName: PcbDefaultsParser.#firstField(fields, [
24
+ 'DEFAULTFONTNAME',
25
+ 'FONTNAME',
26
+ 'TEXTFONTNAME'
27
+ ]),
28
+ units: PcbDefaultsParser.#firstField(fields, [
29
+ 'UNITS',
30
+ 'BOARDUNITS',
31
+ 'MEASUREMENTUNITS'
32
+ ])
33
+ })
34
+ const primitiveStyles = PcbDefaultsParser.#stripEmpty({
35
+ trackWidthMil: PcbDefaultsParser.#firstNumber(fields, [
36
+ 'TRACKWIDTH',
37
+ 'DEFAULTTRACKWIDTH',
38
+ 'ROUTINGWIDTH'
39
+ ]),
40
+ viaHoleSizeMil: PcbDefaultsParser.#firstNumber(fields, [
41
+ 'VIAHOLESIZE',
42
+ 'VIAHOLE',
43
+ 'DEFAULTVIAHOLESIZE'
44
+ ]),
45
+ viaDiameterMil: PcbDefaultsParser.#firstNumber(fields, [
46
+ 'VIADIAMETER',
47
+ 'VIASIZE',
48
+ 'DEFAULTVIADIAMETER'
49
+ ])
50
+ })
51
+ const maskPaste = PcbDefaultsParser.#stripEmpty({
52
+ solder: PcbDefaultsParser.#stripEmpty({
53
+ expansionMil: PcbDefaultsParser.#firstNumber(fields, [
54
+ 'SOLDERMASKEXPANSION',
55
+ 'SOLDERMASKEXPANSION_DEFAULT',
56
+ 'MASKEXPANSION'
57
+ ])
58
+ }),
59
+ paste: PcbDefaultsParser.#stripEmpty({
60
+ expansionMil: PcbDefaultsParser.#firstNumber(fields, [
61
+ 'PASTEMASKEXPANSION',
62
+ 'PASTEMASKEXPANSION_DEFAULT',
63
+ 'PASTEEXPANSION'
64
+ ])
65
+ })
66
+ })
67
+ const clearances = PcbDefaultsParser.#stripEmpty({
68
+ defaultClearanceMil: PcbDefaultsParser.#firstNumber(fields, [
69
+ 'CLEARANCE',
70
+ 'DEFAULTCLEARANCE',
71
+ 'MINCLEARANCE'
72
+ ])
73
+ })
74
+ const colors = PcbDefaultsParser.#stripEmpty({
75
+ defaultColor: PcbDefaultsParser.#firstColor(fields, [
76
+ 'DEFAULTCOLOR',
77
+ 'COLOR'
78
+ ]),
79
+ solderMaskTopColor: PcbDefaultsParser.#firstColor(fields, [
80
+ 'SOLDERMASKTOPCOLOR',
81
+ 'TOPSOLDERMASKCOLOR',
82
+ 'CFG3D.TOPSOLDERMASKCOLOR'
83
+ ]),
84
+ solderMaskBottomColor: PcbDefaultsParser.#firstColor(fields, [
85
+ 'SOLDERMASKBOTTOMCOLOR',
86
+ 'BOTTOMSOLDERMASKCOLOR',
87
+ 'CFG3D.BOTTOMSOLDERMASKCOLOR'
88
+ ])
89
+ })
90
+ const defaults = PcbDefaultsParser.#stripEmpty({
91
+ schema: PcbDefaultsParser.SCHEMA_ID,
92
+ source,
93
+ board,
94
+ primitiveStyles,
95
+ maskPaste,
96
+ clearances,
97
+ colors
98
+ })
99
+
100
+ return Object.keys(defaults).length > 2 ? defaults : null
101
+ }
102
+
103
+ /**
104
+ * Returns the first populated field value.
105
+ * @param {Record<string, string | string[]> | undefined} fields Source fields.
106
+ * @param {string[]} keys Candidate keys.
107
+ * @returns {string}
108
+ */
109
+ static #firstField(fields, keys) {
110
+ for (const key of keys) {
111
+ const value = getField(fields, key)
112
+ if (value) return value
113
+ }
114
+
115
+ return ''
116
+ }
117
+
118
+ /**
119
+ * Returns the first parsed numeric field.
120
+ * @param {Record<string, string | string[]> | undefined} fields Source fields.
121
+ * @param {string[]} keys Candidate keys.
122
+ * @returns {number | undefined}
123
+ */
124
+ static #firstNumber(fields, keys) {
125
+ for (const key of keys) {
126
+ const value = parseNumericField(fields, key)
127
+ if (value !== null) return value
128
+ }
129
+
130
+ return undefined
131
+ }
132
+
133
+ /**
134
+ * Returns the first parsed color field.
135
+ * @param {Record<string, string | string[]> | undefined} fields Source fields.
136
+ * @param {string[]} keys Candidate keys.
137
+ * @returns {string}
138
+ */
139
+ static #firstColor(fields, keys) {
140
+ for (const key of keys) {
141
+ if (getField(fields, key)) return toColor(fields[key], '')
142
+ }
143
+
144
+ return ''
145
+ }
146
+
147
+ /**
148
+ * Removes empty properties from one object.
149
+ * @param {Record<string, unknown>} object Source object.
150
+ * @returns {Record<string, unknown>}
151
+ */
152
+ static #stripEmpty(object) {
153
+ const result = {}
154
+
155
+ for (const [key, value] of Object.entries(object || {})) {
156
+ if (value === undefined || value === null || value === '') {
157
+ continue
158
+ }
159
+ if (
160
+ typeof value === 'object' &&
161
+ !Array.isArray(value) &&
162
+ Object.keys(value).length === 0
163
+ ) {
164
+ continue
165
+ }
166
+ result[key] = value
167
+ }
168
+
169
+ return result
170
+ }
171
+ }