altium-toolkit 1.0.8 → 1.0.9
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/README.md +18 -6
- package/docs/api.md +78 -16
- package/docs/model-format.md +229 -8
- package/docs/schemas/altium_toolkit/netlist_a1.schema.json +47 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
- package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
- package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
- package/docs/testing.md +9 -3
- package/package.json +1 -1
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumLayoutParser.mjs +104 -8
- package/src/core/altium/AltiumParser.mjs +191 -45
- package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
- package/src/core/altium/IntLibModelParser.mjs +240 -0
- package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
- package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
- package/src/core/altium/LibrarySearchIndex.mjs +215 -0
- package/src/core/altium/NormalizedModelSchema.mjs +36 -0
- package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
- package/src/core/altium/PcbDefaultsParser.mjs +171 -0
- package/src/core/altium/PcbDimensionParser.mjs +229 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
- package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
- package/src/core/altium/PcbLibModelParser.mjs +235 -14
- package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
- package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
- package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
- package/src/core/altium/PcbModelParser.mjs +466 -28
- package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
- package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
- package/src/core/altium/PcbPadStackParser.mjs +58 -0
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
- package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
- package/src/core/altium/PcbRuleParser.mjs +354 -33
- package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
- package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
- package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
- package/src/core/altium/PcbStreamExtractor.mjs +111 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
- package/src/core/altium/PcbUnionParser.mjs +307 -0
- package/src/core/altium/PcbViaStackParser.mjs +98 -10
- package/src/core/altium/PcbViaStructureParser.mjs +335 -0
- package/src/core/altium/PrintableTextDecoder.mjs +53 -3
- package/src/core/altium/PrjPcbModelParser.mjs +257 -5
- package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
- package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
- package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
- package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
- package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
- package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
- package/src/core/altium/SchematicHarnessParser.mjs +302 -0
- package/src/core/altium/SchematicImageParser.mjs +474 -3
- package/src/core/altium/SchematicImplementationParser.mjs +518 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
- package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
- package/src/core/altium/SchematicPinParser.mjs +84 -1
- package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
- package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
- package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
- package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
- package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
- package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
- package/src/core/altium/SchematicTemplateParser.mjs +256 -0
- package/src/core/altium/SchematicTextParser.mjs +123 -0
- package/src/core/ole/OleCompoundDocument.mjs +20 -0
- package/src/parser.mjs +29 -0
- package/src/styles/altium-renderers.css +19 -0
- package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
- package/src/ui/PcbInteractionIndex.mjs +9 -4
- package/src/ui/PcbScene3dBuilder.mjs +137 -3
- package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
- package/src/ui/PcbSvgRenderer.mjs +1187 -34
- package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
- package/src/ui/SchematicNoteRenderer.mjs +9 -2
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
- package/src/ui/SchematicShapeRenderer.mjs +362 -0
- package/src/ui/SchematicSvgRenderer.mjs +1442 -92
- package/src/ui/SchematicTypography.mjs +48 -5
- package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Normalizes read-only PCB dimension records.
|
|
11
|
+
*/
|
|
12
|
+
export class PcbDimensionParser {
|
|
13
|
+
/**
|
|
14
|
+
* Parses Dimensions6/Data records into public dimension objects.
|
|
15
|
+
* @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
|
|
16
|
+
* @returns {object[]}
|
|
17
|
+
*/
|
|
18
|
+
static parse(records) {
|
|
19
|
+
return (records || [])
|
|
20
|
+
.filter((record) => record.sourceStream === 'Dimensions6/Data')
|
|
21
|
+
.map((record, index) =>
|
|
22
|
+
PcbDimensionParser.#normalizeDimension(record.fields, index)
|
|
23
|
+
)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalizes one dimension record.
|
|
29
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
30
|
+
* @param {number} index Source record index within the dimensions stream.
|
|
31
|
+
* @returns {object | null}
|
|
32
|
+
*/
|
|
33
|
+
static #normalizeDimension(fields, index) {
|
|
34
|
+
const kindCode = PcbDimensionParser.#firstStringField(fields, [
|
|
35
|
+
'DIMENSIONTYPE',
|
|
36
|
+
'DIMENSIONKIND',
|
|
37
|
+
'KIND',
|
|
38
|
+
'TYPE'
|
|
39
|
+
])
|
|
40
|
+
const references = PcbDimensionParser.#parseReferences(fields)
|
|
41
|
+
const textLocation = PcbDimensionParser.#parsePoint(fields, [
|
|
42
|
+
'TEXTLOCATION',
|
|
43
|
+
'TEXT',
|
|
44
|
+
'LOCATION'
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
if (!kindCode && !references.length && !getField(fields, 'TEXT')) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
dimensionIndex: index,
|
|
53
|
+
kind: PcbDimensionParser.#normalizeKind(kindCode),
|
|
54
|
+
kindCode,
|
|
55
|
+
name: getField(fields, 'NAME'),
|
|
56
|
+
layer: getField(fields, 'LAYER'),
|
|
57
|
+
text: getField(fields, 'TEXT'),
|
|
58
|
+
prefix: getField(fields, 'PREFIX'),
|
|
59
|
+
suffix: PcbDimensionParser.#rawStringField(fields, 'SUFFIX'),
|
|
60
|
+
precision: PcbDimensionParser.#nullableNumber(
|
|
61
|
+
parseNumericField(fields, 'PRECISION')
|
|
62
|
+
),
|
|
63
|
+
measuredValue: PcbDimensionParser.#nullableNumber(
|
|
64
|
+
PcbDimensionParser.#firstNumericField(fields, [
|
|
65
|
+
'MEASUREDVALUE',
|
|
66
|
+
'MEASURED',
|
|
67
|
+
'VALUE'
|
|
68
|
+
])
|
|
69
|
+
),
|
|
70
|
+
angleValue: PcbDimensionParser.#nullableNumber(
|
|
71
|
+
PcbDimensionParser.#firstNumericField(fields, [
|
|
72
|
+
'ANGLEVALUE',
|
|
73
|
+
'ANGLE',
|
|
74
|
+
'MEASUREDANGLE'
|
|
75
|
+
])
|
|
76
|
+
),
|
|
77
|
+
unit: PcbDimensionParser.#resolveUnit(fields),
|
|
78
|
+
references,
|
|
79
|
+
textLocation,
|
|
80
|
+
raw: { ...fields }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parses reference points from indexed field names.
|
|
86
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
87
|
+
* @returns {{ index: number, x: number, y: number }[]}
|
|
88
|
+
*/
|
|
89
|
+
static #parseReferences(fields) {
|
|
90
|
+
const references = []
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < 16; index += 1) {
|
|
93
|
+
const point = PcbDimensionParser.#parsePoint(fields, [
|
|
94
|
+
'REFERENCE' + index,
|
|
95
|
+
'REF' + index,
|
|
96
|
+
'POINT' + index
|
|
97
|
+
])
|
|
98
|
+
if (!point) {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
references.push({ index, ...point })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return references
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parses a point from common field prefixes.
|
|
109
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
110
|
+
* @param {string[]} prefixes Candidate field prefixes.
|
|
111
|
+
* @returns {{ x: number, y: number } | null}
|
|
112
|
+
*/
|
|
113
|
+
static #parsePoint(fields, prefixes) {
|
|
114
|
+
for (const prefix of prefixes) {
|
|
115
|
+
const x = PcbDimensionParser.#firstNumericField(fields, [
|
|
116
|
+
prefix + '_X',
|
|
117
|
+
prefix + '.X',
|
|
118
|
+
prefix + 'X'
|
|
119
|
+
])
|
|
120
|
+
const y = PcbDimensionParser.#firstNumericField(fields, [
|
|
121
|
+
prefix + '_Y',
|
|
122
|
+
prefix + '.Y',
|
|
123
|
+
prefix + 'Y'
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
if (x !== null && y !== null) {
|
|
127
|
+
return { x, y }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reads the first non-empty string field.
|
|
136
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
137
|
+
* @param {string[]} keys Candidate keys.
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
static #firstStringField(fields, keys) {
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const value = getField(fields, key)
|
|
143
|
+
if (value) {
|
|
144
|
+
return value
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return ''
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reads the first finite numeric field.
|
|
153
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
154
|
+
* @param {string[]} keys Candidate keys.
|
|
155
|
+
* @returns {number | null}
|
|
156
|
+
*/
|
|
157
|
+
static #firstNumericField(fields, keys) {
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
const value = parseNumericField(fields, key)
|
|
160
|
+
if (value !== null) {
|
|
161
|
+
return value
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Normalizes a dimension kind code.
|
|
170
|
+
* @param {string} kindCode Raw dimension kind.
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
static #normalizeKind(kindCode) {
|
|
174
|
+
const normalized = String(kindCode || '').toLowerCase()
|
|
175
|
+
if (/ang/u.test(normalized)) return 'angular'
|
|
176
|
+
if (/radial|radius/u.test(normalized)) return 'radial'
|
|
177
|
+
if (/datum/u.test(normalized)) return 'datum'
|
|
178
|
+
if (/baseline/u.test(normalized)) return 'baseline'
|
|
179
|
+
if (/ordinate/u.test(normalized)) return 'ordinate'
|
|
180
|
+
return 'linear'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns a number when finite, otherwise null.
|
|
185
|
+
* @param {number | null} value Numeric candidate.
|
|
186
|
+
* @returns {number | null}
|
|
187
|
+
*/
|
|
188
|
+
static #nullableNumber(value) {
|
|
189
|
+
return Number.isFinite(value) ? value : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolves a display unit from explicit or value-bearing fields.
|
|
194
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
static #resolveUnit(fields) {
|
|
198
|
+
const explicit = PcbDimensionParser.#firstStringField(fields, [
|
|
199
|
+
'UNIT',
|
|
200
|
+
'UNITS'
|
|
201
|
+
])
|
|
202
|
+
if (explicit) {
|
|
203
|
+
return explicit
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const suffix = getField(fields, 'SUFFIX').trim()
|
|
207
|
+
if (suffix) {
|
|
208
|
+
return suffix
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const measured = getField(fields, 'MEASUREDVALUE')
|
|
212
|
+
const match = measured.match(/[a-zA-Z]+$/u)
|
|
213
|
+
return match ? match[0] : ''
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reads one field without trimming display-significant whitespace.
|
|
218
|
+
* @param {Record<string, string | string[]>} fields Source fields.
|
|
219
|
+
* @param {string} key Field key.
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
static #rawStringField(fields, key) {
|
|
223
|
+
const value = fields?.[key]
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
return String(value.findLast((entry) => entry !== '') || '')
|
|
226
|
+
}
|
|
227
|
+
return String(value || '')
|
|
228
|
+
}
|
|
229
|
+
}
|