altium-toolkit 1.0.7 → 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.
Files changed (93) 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/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/renderers.mjs +3 -0
  77. package/src/styles/altium-renderers.css +25 -0
  78. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  79. package/src/ui/PcbInteractionGeometry.mjs +350 -0
  80. package/src/ui/PcbInteractionIndex.mjs +593 -0
  81. package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
  82. package/src/ui/PcbInteractionLayerModel.mjs +99 -0
  83. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
  84. package/src/ui/PcbScene3dBuilder.mjs +169 -7
  85. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  86. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  87. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  88. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  89. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  90. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  91. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  92. package/src/ui/SchematicTypography.mjs +48 -5
  93. 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
+ }