altium-toolkit 0.1.1 → 0.1.17
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 +25 -6
- package/docs/api.md +42 -4
- package/docs/model-format.md +95 -5
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
- package/docs/testing.md +7 -2
- package/package.json +6 -2
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumParser.mjs +22 -325
- package/src/core/altium/NormalizedModelSchema.mjs +28 -0
- package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
- package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
- package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
- package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
- package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
- package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
- package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
- package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
- package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
- package/src/core/altium/PcbLibModelParser.mjs +202 -0
- package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
- package/src/core/altium/PcbModelParser.mjs +618 -66
- package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
- package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
- package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
- package/src/core/altium/PcbPadStackParser.mjs +903 -0
- package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
- package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
- package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
- package/src/core/altium/PcbRuleParser.mjs +587 -0
- package/src/core/altium/PcbStreamExtractor.mjs +127 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
- package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
- package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
- package/src/core/altium/PcbViaStackParser.mjs +548 -0
- package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
- package/src/core/altium/PrjPcbModelParser.mjs +797 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
- package/src/parser.mjs +13 -0
- package/src/renderers.mjs +5 -0
- package/src/styles/altium-renderers.css +11 -6
- package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
- package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
- package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
- package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
- package/src/ui/PcbSvgRenderer.mjs +101 -109
- package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
- package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { AltiumLayoutParser } from './AltiumLayoutParser.mjs'
|
|
6
|
+
import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
|
|
7
|
+
import { PcbBoardRegionSemanticsParser } from './PcbBoardRegionSemanticsParser.mjs'
|
|
8
|
+
import { PcbComponentAnnotationNormalizer } from './PcbComponentAnnotationNormalizer.mjs'
|
|
9
|
+
import { PcbComponentBodyPlacementNormalizer } from './PcbComponentBodyPlacementNormalizer.mjs'
|
|
10
|
+
import { PcbComponentPrimitiveIndexer } from './PcbComponentPrimitiveIndexer.mjs'
|
|
6
11
|
import { PcbOutlineRecovery } from './PcbOutlineRecovery.mjs'
|
|
12
|
+
import { PcbRuleParser } from './PcbRuleParser.mjs'
|
|
7
13
|
import { ParserUtils } from './ParserUtils.mjs'
|
|
8
14
|
|
|
9
15
|
const {
|
|
10
16
|
countMatchingKeys,
|
|
11
17
|
dedupeByDesignator,
|
|
12
18
|
getField,
|
|
19
|
+
parseBoolean,
|
|
13
20
|
parseNumericField,
|
|
14
|
-
stripExtension
|
|
21
|
+
stripExtension,
|
|
22
|
+
toColor
|
|
15
23
|
} = ParserUtils
|
|
16
24
|
|
|
17
25
|
/**
|
|
@@ -20,10 +28,13 @@ const {
|
|
|
20
28
|
export class PcbModelParser {
|
|
21
29
|
/**
|
|
22
30
|
* Parses a normalized PCB model.
|
|
31
|
+
*
|
|
32
|
+
* When Nets6/Data is present, the model exposes native net definitions and
|
|
33
|
+
* resolved primitive netName fields keyed by numeric netIndex values.
|
|
23
34
|
* @param {string} fileName
|
|
24
35
|
* @param {{ raw: string, fields: Record<string, string | string[]>, sourceStream?: string }[]} records
|
|
25
|
-
* @param {{ streamNames: string[], binaryPrimitives:
|
|
26
|
-
* @returns {{ kind: 'pcb', fileType: 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], pcb:
|
|
36
|
+
* @param {{ streamNames: string[], binaryPrimitives: Record<string, object[]>, primitiveParameters?: object, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
|
|
37
|
+
* @returns {{ schema: string, kind: 'pcb', fileType: 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], pcb: Record<string, unknown>, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
|
|
27
38
|
*/
|
|
28
39
|
static parse(fileName, records, pcbExtraction = null) {
|
|
29
40
|
const boardRecords = records.filter(
|
|
@@ -50,26 +61,19 @@ export class PcbModelParser {
|
|
|
50
61
|
/^V9_STACK_LAYER\d+_NAME$/
|
|
51
62
|
) > 0
|
|
52
63
|
)
|
|
64
|
+
const rawTextPrimitives = pcbExtraction?.binaryPrimitives?.texts || []
|
|
65
|
+
const rawComponentRecords =
|
|
66
|
+
PcbComponentAnnotationNormalizer.enrichComponents(
|
|
67
|
+
PcbModelParser.#normalizeComponentRecords(
|
|
68
|
+
PcbModelParser.#selectComponentRecords(records)
|
|
69
|
+
),
|
|
70
|
+
rawTextPrimitives,
|
|
71
|
+
pcbExtraction?.primitiveParameters
|
|
72
|
+
)
|
|
53
73
|
const componentRecords = dedupeByDesignator(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
getField(record.fields, 'PATTERN') &&
|
|
58
|
-
getField(record.fields, 'SOURCEDESIGNATOR')
|
|
59
|
-
)
|
|
60
|
-
.map((record) => ({
|
|
61
|
-
designator: getField(record.fields, 'SOURCEDESIGNATOR'),
|
|
62
|
-
x: parseNumericField(record.fields, 'X') || 0,
|
|
63
|
-
y: parseNumericField(record.fields, 'Y') || 0,
|
|
64
|
-
layer: getField(record.fields, 'LAYER') || 'TOP',
|
|
65
|
-
pattern: getField(record.fields, 'PATTERN'),
|
|
66
|
-
rotation: parseNumericField(record.fields, 'ROTATION') || 0,
|
|
67
|
-
source:
|
|
68
|
-
getField(record.fields, 'SOURCELIBREFERENCE') ||
|
|
69
|
-
getField(record.fields, 'SOURCEFOOTPRINTLIBRARY'),
|
|
70
|
-
description: getField(record.fields, 'SOURCEDESCRIPTION'),
|
|
71
|
-
height: parseNumericField(record.fields, 'HEIGHT')
|
|
72
|
-
}))
|
|
74
|
+
rawComponentRecords.map((component) =>
|
|
75
|
+
PcbModelParser.#publicComponentRecord(component)
|
|
76
|
+
)
|
|
73
77
|
)
|
|
74
78
|
const polygonRecords = records.filter(
|
|
75
79
|
(record) =>
|
|
@@ -82,9 +86,17 @@ export class PcbModelParser {
|
|
|
82
86
|
const layers = AltiumLayoutParser.parseLayerStack(
|
|
83
87
|
layerRecord?.fields || {}
|
|
84
88
|
)
|
|
89
|
+
const layerSubstacks =
|
|
90
|
+
PcbBoardRegionSemanticsParser.parseLayerSubstacks(
|
|
91
|
+
boardRecords.map((record) => record.fields)
|
|
92
|
+
)
|
|
85
93
|
const primitiveLayers = AltiumLayoutParser.parsePrimitiveLayerNames(
|
|
86
94
|
boardRecords.map((record) => record.fields)
|
|
87
95
|
)
|
|
96
|
+
const nets = PcbModelParser.#parseNetRecords(records)
|
|
97
|
+
const netNameByIndex = PcbModelParser.#buildNetNameMap(nets)
|
|
98
|
+
const classes = PcbModelParser.#parseClassRecords(records)
|
|
99
|
+
const rules = PcbRuleParser.parse(records)
|
|
88
100
|
const polygons = polygonRecords
|
|
89
101
|
.map((record) => ({
|
|
90
102
|
layer: getField(record.fields, 'LAYER') || 'UNKNOWN',
|
|
@@ -92,11 +104,41 @@ export class PcbModelParser {
|
|
|
92
104
|
.segments
|
|
93
105
|
}))
|
|
94
106
|
.filter((polygon) => polygon.segments.length > 0)
|
|
95
|
-
const tracks =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
107
|
+
const tracks = PcbModelParser.#annotatePrimitiveNetNames(
|
|
108
|
+
pcbExtraction?.binaryPrimitives?.tracks || [],
|
|
109
|
+
netNameByIndex
|
|
110
|
+
)
|
|
111
|
+
const arcs = PcbModelParser.#annotatePrimitiveNetNames(
|
|
112
|
+
pcbExtraction?.binaryPrimitives?.arcs || [],
|
|
113
|
+
netNameByIndex
|
|
114
|
+
)
|
|
115
|
+
const vias = PcbModelParser.#annotatePrimitiveNetNames(
|
|
116
|
+
pcbExtraction?.binaryPrimitives?.vias || [],
|
|
117
|
+
netNameByIndex
|
|
118
|
+
)
|
|
119
|
+
const fills = PcbModelParser.#annotatePrimitiveNetNames(
|
|
120
|
+
pcbExtraction?.binaryPrimitives?.fills || [],
|
|
121
|
+
netNameByIndex
|
|
122
|
+
)
|
|
123
|
+
const pads = PcbModelParser.#annotatePrimitiveNetNames(
|
|
124
|
+
pcbExtraction?.binaryPrimitives?.pads || [],
|
|
125
|
+
netNameByIndex
|
|
126
|
+
)
|
|
127
|
+
const regions = PcbModelParser.#annotatePrimitiveNetNames(
|
|
128
|
+
pcbExtraction?.binaryPrimitives?.regions || [],
|
|
129
|
+
netNameByIndex
|
|
130
|
+
)
|
|
131
|
+
const shapeBasedRegions = PcbModelParser.#annotatePrimitiveNetNames(
|
|
132
|
+
pcbExtraction?.binaryPrimitives?.shapeBasedRegions || [],
|
|
133
|
+
netNameByIndex
|
|
134
|
+
)
|
|
135
|
+
const boardRegions = PcbBoardRegionSemanticsParser.enrichBoardRegions(
|
|
136
|
+
PcbModelParser.#annotatePrimitiveNetNames(
|
|
137
|
+
pcbExtraction?.binaryPrimitives?.boardRegions || [],
|
|
138
|
+
netNameByIndex
|
|
139
|
+
),
|
|
140
|
+
layerSubstacks
|
|
141
|
+
)
|
|
100
142
|
const extractedEmbeddedModels = Array.isArray(
|
|
101
143
|
pcbExtraction?.embeddedModels?.models
|
|
102
144
|
)
|
|
@@ -107,6 +149,21 @@ export class PcbModelParser {
|
|
|
107
149
|
)
|
|
108
150
|
? pcbExtraction.embeddedModels.componentBodies
|
|
109
151
|
: []
|
|
152
|
+
const extractedEmbeddedFonts = Array.isArray(
|
|
153
|
+
pcbExtraction?.embeddedFonts?.fonts
|
|
154
|
+
)
|
|
155
|
+
? pcbExtraction.embeddedFonts.fonts
|
|
156
|
+
: []
|
|
157
|
+
const rawRecords = Array.isArray(pcbExtraction?.rawRecords)
|
|
158
|
+
? pcbExtraction.rawRecords
|
|
159
|
+
: []
|
|
160
|
+
const texts = PcbModelParser.#annotateTextFontMetrics(
|
|
161
|
+
PcbComponentAnnotationNormalizer.normalizeTexts(
|
|
162
|
+
rawTextPrimitives,
|
|
163
|
+
rawComponentRecords
|
|
164
|
+
),
|
|
165
|
+
extractedEmbeddedFonts
|
|
166
|
+
)
|
|
110
167
|
const recoveredOutline = PcbOutlineRecovery.recoverOutline({
|
|
111
168
|
fallbackOutline: fallbackBoardOutline,
|
|
112
169
|
components: componentRecords,
|
|
@@ -121,11 +178,33 @@ export class PcbModelParser {
|
|
|
121
178
|
arcs,
|
|
122
179
|
vias,
|
|
123
180
|
pads,
|
|
181
|
+
regions,
|
|
182
|
+
shapeBasedRegions,
|
|
183
|
+
boardRegions,
|
|
184
|
+
texts,
|
|
124
185
|
components: componentRecords
|
|
125
186
|
})
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
187
|
+
const boardRegionContexts =
|
|
188
|
+
PcbBoardRegionSemanticsParser.buildBoardRegionContexts(
|
|
189
|
+
normalizedPcb.boardRegions
|
|
190
|
+
)
|
|
191
|
+
const boardRegionSummary =
|
|
192
|
+
PcbBoardRegionSemanticsParser.summarizeBoardRegions(
|
|
193
|
+
normalizedPcb.boardRegions
|
|
194
|
+
)
|
|
195
|
+
const componentBodies =
|
|
196
|
+
PcbComponentBodyPlacementNormalizer.normalizeComponentBodies(
|
|
197
|
+
extractedComponentBodies,
|
|
198
|
+
boardOutline
|
|
199
|
+
)
|
|
200
|
+
const componentPrimitiveGroups =
|
|
201
|
+
PcbComponentPrimitiveIndexer.buildGroups(
|
|
202
|
+
normalizedPcb.components,
|
|
203
|
+
normalizedPcb,
|
|
204
|
+
componentBodies
|
|
205
|
+
)
|
|
206
|
+
const componentPrimitives = PcbComponentPrimitiveIndexer.indexGroups(
|
|
207
|
+
componentPrimitiveGroups
|
|
129
208
|
)
|
|
130
209
|
const bom = PcbModelParser.#groupBomRows(
|
|
131
210
|
componentRecords.map((component) => ({
|
|
@@ -152,9 +231,46 @@ export class PcbModelParser {
|
|
|
152
231
|
{
|
|
153
232
|
severity: 'info',
|
|
154
233
|
message: 'Recovered ' + layers.length + ' layer stack entries.'
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
severity: 'info',
|
|
237
|
+
message: 'Recovered ' + nets.length + ' PCB net definitions.'
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
severity: 'info',
|
|
241
|
+
message:
|
|
242
|
+
'Recovered ' + classes.length + ' PCB class definitions.'
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
severity: 'info',
|
|
246
|
+
message: 'Recovered ' + rules.length + ' PCB design rules.'
|
|
155
247
|
}
|
|
156
248
|
]
|
|
157
249
|
|
|
250
|
+
if (boardRegionSummary.boardRegionCount) {
|
|
251
|
+
diagnostics.push({
|
|
252
|
+
severity: 'info',
|
|
253
|
+
message:
|
|
254
|
+
'Recovered ' +
|
|
255
|
+
boardRegionSummary.boardRegionCount +
|
|
256
|
+
' board planning ' +
|
|
257
|
+
PcbModelParser.#plural(
|
|
258
|
+
boardRegionSummary.boardRegionCount,
|
|
259
|
+
'region',
|
|
260
|
+
'regions'
|
|
261
|
+
) +
|
|
262
|
+
' and ' +
|
|
263
|
+
boardRegionSummary.bendingLineCount +
|
|
264
|
+
' bending ' +
|
|
265
|
+
PcbModelParser.#plural(
|
|
266
|
+
boardRegionSummary.bendingLineCount,
|
|
267
|
+
'line',
|
|
268
|
+
'lines'
|
|
269
|
+
) +
|
|
270
|
+
'.'
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
158
274
|
if (pcbExtraction) {
|
|
159
275
|
diagnostics.push({
|
|
160
276
|
severity: 'info',
|
|
@@ -175,6 +291,12 @@ export class PcbModelParser {
|
|
|
175
291
|
' vias, ' +
|
|
176
292
|
pads.length +
|
|
177
293
|
' pads, ' +
|
|
294
|
+
texts.length +
|
|
295
|
+
' texts, and ' +
|
|
296
|
+
regions.length +
|
|
297
|
+
' regions, ' +
|
|
298
|
+
shapeBasedRegions.length +
|
|
299
|
+
' shape-based regions, and ' +
|
|
178
300
|
fills.length +
|
|
179
301
|
' fills, and ' +
|
|
180
302
|
polygons.length +
|
|
@@ -192,6 +314,26 @@ export class PcbModelParser {
|
|
|
192
314
|
})
|
|
193
315
|
}
|
|
194
316
|
|
|
317
|
+
if (extractedEmbeddedFonts.length) {
|
|
318
|
+
diagnostics.push({
|
|
319
|
+
severity: 'info',
|
|
320
|
+
message:
|
|
321
|
+
'Recovered ' +
|
|
322
|
+
extractedEmbeddedFonts.length +
|
|
323
|
+
' embedded PCB font payloads.'
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (rawRecords.length) {
|
|
328
|
+
diagnostics.push({
|
|
329
|
+
severity: 'info',
|
|
330
|
+
message:
|
|
331
|
+
'Preserved ' +
|
|
332
|
+
rawRecords.length +
|
|
333
|
+
' raw PCB primitive records.'
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
195
337
|
if (recoveredOutline.source === 'board-route') {
|
|
196
338
|
diagnostics.push({
|
|
197
339
|
severity: 'info',
|
|
@@ -218,7 +360,7 @@ export class PcbModelParser {
|
|
|
218
360
|
})
|
|
219
361
|
}
|
|
220
362
|
|
|
221
|
-
return {
|
|
363
|
+
return NormalizedModelSchema.attach({
|
|
222
364
|
kind: 'pcb',
|
|
223
365
|
fileType: 'PcbDoc',
|
|
224
366
|
fileName,
|
|
@@ -228,10 +370,18 @@ export class PcbModelParser {
|
|
|
228
370
|
layerCount: layers.length,
|
|
229
371
|
outlineSegmentCount: boardOutline.segments.length,
|
|
230
372
|
bomRowCount: bom.length,
|
|
373
|
+
netCount: nets.length,
|
|
374
|
+
classCount: classes.length,
|
|
375
|
+
ruleCount: rules.length,
|
|
231
376
|
polygonCount: polygons.length,
|
|
232
377
|
trackCount: tracks.length,
|
|
233
378
|
arcCount: arcs.length,
|
|
234
379
|
viaCount: vias.length,
|
|
380
|
+
boardRegionCount: boardRegionSummary.boardRegionCount,
|
|
381
|
+
flexRegionCount: boardRegionSummary.flexRegionCount,
|
|
382
|
+
bendingLineCount: boardRegionSummary.bendingLineCount,
|
|
383
|
+
embeddedFontCount: extractedEmbeddedFonts.length,
|
|
384
|
+
rawRecordCount: rawRecords.length,
|
|
235
385
|
boardWidthMil: Math.round(boardOutline.widthMil),
|
|
236
386
|
boardHeightMil: Math.round(boardOutline.heightMil)
|
|
237
387
|
},
|
|
@@ -239,7 +389,12 @@ export class PcbModelParser {
|
|
|
239
389
|
pcb: {
|
|
240
390
|
boardOutline: normalizedPcb.boardOutline,
|
|
241
391
|
layers,
|
|
392
|
+
layerSubstacks,
|
|
393
|
+
boardRegionContexts,
|
|
242
394
|
primitiveLayers,
|
|
395
|
+
nets,
|
|
396
|
+
classes,
|
|
397
|
+
rules,
|
|
243
398
|
components: normalizedPcb.components,
|
|
244
399
|
polygons: normalizedPcb.polygons,
|
|
245
400
|
fills: normalizedPcb.fills,
|
|
@@ -247,11 +402,343 @@ export class PcbModelParser {
|
|
|
247
402
|
arcs: normalizedPcb.arcs,
|
|
248
403
|
vias: normalizedPcb.vias,
|
|
249
404
|
pads: normalizedPcb.pads,
|
|
405
|
+
regions: normalizedPcb.regions,
|
|
406
|
+
shapeBasedRegions: normalizedPcb.shapeBasedRegions,
|
|
407
|
+
boardRegions: normalizedPcb.boardRegions,
|
|
408
|
+
texts: normalizedPcb.texts,
|
|
250
409
|
embeddedModels: extractedEmbeddedModels,
|
|
251
|
-
|
|
410
|
+
embeddedFonts: extractedEmbeddedFonts,
|
|
411
|
+
rawRecords,
|
|
412
|
+
componentBodies,
|
|
413
|
+
componentPrimitives,
|
|
414
|
+
componentPrimitiveGroups
|
|
252
415
|
},
|
|
253
416
|
bom
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Selects component placement records using the native component table when
|
|
422
|
+
* present and a legacy heuristic only for older extracted content.
|
|
423
|
+
* @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
|
|
424
|
+
* @returns {{ fields: Record<string, string | string[]>, sourceStream?: string }[]}
|
|
425
|
+
*/
|
|
426
|
+
static #selectComponentRecords(records) {
|
|
427
|
+
const nativeRecords = records.filter(
|
|
428
|
+
(record) => record.sourceStream === 'Components6/Data'
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if (nativeRecords.length) {
|
|
432
|
+
return nativeRecords
|
|
254
433
|
}
|
|
434
|
+
|
|
435
|
+
return records.filter(
|
|
436
|
+
(record) =>
|
|
437
|
+
getField(record.fields, 'PATTERN') &&
|
|
438
|
+
getField(record.fields, 'SOURCEDESIGNATOR')
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Normalizes component placement fields while preserving native index order.
|
|
444
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
445
|
+
* @returns {{ componentIndex: number, designator: string, uniqueId: string, x: number, y: number, layer: string, pattern: string, rotation: number, source: string, description: string, height: number | null, nameOn: boolean, commentOn: boolean }[]}
|
|
446
|
+
*/
|
|
447
|
+
static #normalizeComponentRecords(records) {
|
|
448
|
+
return records
|
|
449
|
+
.map((record, index) => ({
|
|
450
|
+
componentIndex: index,
|
|
451
|
+
designator: getField(record.fields, 'SOURCEDESIGNATOR'),
|
|
452
|
+
uniqueId:
|
|
453
|
+
getField(record.fields, 'UNIQUEID') ||
|
|
454
|
+
getField(record.fields, 'UID') ||
|
|
455
|
+
getField(record.fields, 'UNIQUEIDPRIMITIVEINFORMATION'),
|
|
456
|
+
x: parseNumericField(record.fields, 'X') || 0,
|
|
457
|
+
y: parseNumericField(record.fields, 'Y') || 0,
|
|
458
|
+
layer: getField(record.fields, 'LAYER') || 'TOP',
|
|
459
|
+
pattern: getField(record.fields, 'PATTERN'),
|
|
460
|
+
rotation: parseNumericField(record.fields, 'ROTATION') || 0,
|
|
461
|
+
source:
|
|
462
|
+
getField(record.fields, 'SOURCELIBREFERENCE') ||
|
|
463
|
+
getField(record.fields, 'SOURCEFOOTPRINTLIBRARY'),
|
|
464
|
+
description: getField(record.fields, 'SOURCEDESCRIPTION'),
|
|
465
|
+
height: parseNumericField(record.fields, 'HEIGHT'),
|
|
466
|
+
nameOn: parseBoolean(record.fields.NAMEON),
|
|
467
|
+
commentOn: parseBoolean(record.fields.COMMENTON)
|
|
468
|
+
}))
|
|
469
|
+
.filter((component) => component.pattern && component.designator)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Normalizes native Nets6/Data records in stream order.
|
|
474
|
+
* @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
|
|
475
|
+
* @returns {{ netIndex: number, name: string, uniqueId: string, color: string, visible: boolean, overrideColor: boolean, keepout: boolean, locked: boolean, userRouted: boolean, loopRemoval: boolean, jumpersVisible: boolean, polygonOutline: boolean, layer: string, unionIndex: number }[]}
|
|
476
|
+
*/
|
|
477
|
+
static #parseNetRecords(records) {
|
|
478
|
+
return records
|
|
479
|
+
.filter((record) => record.sourceStream === 'Nets6/Data')
|
|
480
|
+
.map((record, index) =>
|
|
481
|
+
PcbModelParser.#normalizeNetRecord(record.fields, index)
|
|
482
|
+
)
|
|
483
|
+
.filter((net) => net.name || net.uniqueId)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Normalizes one native Altium PCB net record.
|
|
488
|
+
* @param {Record<string, string | string[]>} fields
|
|
489
|
+
* @param {number} fallbackIndex
|
|
490
|
+
* @returns {{ netIndex: number, name: string, uniqueId: string, color: string, visible: boolean, overrideColor: boolean, keepout: boolean, locked: boolean, userRouted: boolean, loopRemoval: boolean, jumpersVisible: boolean, polygonOutline: boolean, layer: string, unionIndex: number }}
|
|
491
|
+
*/
|
|
492
|
+
static #normalizeNetRecord(fields, fallbackIndex) {
|
|
493
|
+
const explicitIndex = PcbModelParser.#firstIntegerField(fields, [
|
|
494
|
+
'NETINDEX',
|
|
495
|
+
'INDEX'
|
|
496
|
+
])
|
|
497
|
+
const uniqueId = getField(fields, 'UNIQUEID') || getField(fields, 'UID')
|
|
498
|
+
const name =
|
|
499
|
+
getField(fields, 'NAME') || getField(fields, 'NETNAME') || uniqueId
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
netIndex:
|
|
503
|
+
explicitIndex === null ? Number(fallbackIndex) : explicitIndex,
|
|
504
|
+
name,
|
|
505
|
+
uniqueId,
|
|
506
|
+
color: toColor(getField(fields, 'COLOR'), '#ffff00'),
|
|
507
|
+
visible: PcbModelParser.#parseBooleanField(fields, 'VISIBLE', true),
|
|
508
|
+
overrideColor: PcbModelParser.#parseBooleanField(
|
|
509
|
+
fields,
|
|
510
|
+
'OVERRIDECOLORFORDRAW',
|
|
511
|
+
false
|
|
512
|
+
),
|
|
513
|
+
keepout: PcbModelParser.#parseBooleanField(
|
|
514
|
+
fields,
|
|
515
|
+
'KEEPOUT',
|
|
516
|
+
false
|
|
517
|
+
),
|
|
518
|
+
locked: PcbModelParser.#parseBooleanField(fields, 'LOCKED', false),
|
|
519
|
+
userRouted: PcbModelParser.#parseBooleanField(
|
|
520
|
+
fields,
|
|
521
|
+
'USERROUTED',
|
|
522
|
+
true
|
|
523
|
+
),
|
|
524
|
+
loopRemoval: PcbModelParser.#parseBooleanField(
|
|
525
|
+
fields,
|
|
526
|
+
'LOOPREMOVAL',
|
|
527
|
+
true
|
|
528
|
+
),
|
|
529
|
+
jumpersVisible: PcbModelParser.#parseBooleanField(
|
|
530
|
+
fields,
|
|
531
|
+
'JUMPERSVISIBLE',
|
|
532
|
+
true
|
|
533
|
+
),
|
|
534
|
+
polygonOutline: PcbModelParser.#parseBooleanField(
|
|
535
|
+
fields,
|
|
536
|
+
'POLYGONOUTLINE',
|
|
537
|
+
false
|
|
538
|
+
),
|
|
539
|
+
layer: getField(fields, 'LAYER') || '',
|
|
540
|
+
unionIndex: parseNumericField(fields, 'UNIONINDEX') || 0
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Builds a net-name lookup keyed by native net index.
|
|
546
|
+
* @param {{ netIndex: number, name: string }[]} nets
|
|
547
|
+
* @returns {Map<number, string>}
|
|
548
|
+
*/
|
|
549
|
+
static #buildNetNameMap(nets) {
|
|
550
|
+
const netNameByIndex = new Map()
|
|
551
|
+
|
|
552
|
+
for (const net of nets) {
|
|
553
|
+
if (Number.isInteger(net.netIndex) && net.name) {
|
|
554
|
+
netNameByIndex.set(net.netIndex, net.name)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return netNameByIndex
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Adds resolved net names to decoded primitives without changing geometry.
|
|
563
|
+
* @param {{ netIndex?: number | string | null }[]} primitives
|
|
564
|
+
* @param {Map<number, string>} netNameByIndex
|
|
565
|
+
* @returns {object[]}
|
|
566
|
+
*/
|
|
567
|
+
static #annotatePrimitiveNetNames(primitives, netNameByIndex) {
|
|
568
|
+
return (primitives || []).map((primitive) => {
|
|
569
|
+
const netIndex = Number(primitive?.netIndex)
|
|
570
|
+
const netName = Number.isInteger(netIndex)
|
|
571
|
+
? netNameByIndex.get(netIndex)
|
|
572
|
+
: ''
|
|
573
|
+
|
|
574
|
+
return netName ? { ...primitive, netName } : primitive
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Normalizes native Classes6/Data records in stream order.
|
|
580
|
+
* @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
|
|
581
|
+
* @returns {{ classIndex: number, name: string, kind: number, kindName: string, memberCount: number, members: string[], enabled: boolean, uniqueId: string }[]}
|
|
582
|
+
*/
|
|
583
|
+
static #parseClassRecords(records) {
|
|
584
|
+
return PcbModelParser.#mergeClassRecordFields(
|
|
585
|
+
records.filter((record) => record.sourceStream === 'Classes6/Data')
|
|
586
|
+
)
|
|
587
|
+
.map((fields, index) =>
|
|
588
|
+
PcbModelParser.#normalizeClassRecord(fields, index)
|
|
589
|
+
)
|
|
590
|
+
.filter(
|
|
591
|
+
(classRecord) =>
|
|
592
|
+
classRecord.name ||
|
|
593
|
+
classRecord.uniqueId ||
|
|
594
|
+
classRecord.members.length
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Merges adjacent name/detail records while preserving standalone class
|
|
600
|
+
* records. Altium often stores class display fields and class membership
|
|
601
|
+
* fields in separate consecutive records.
|
|
602
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
603
|
+
* @returns {Record<string, string | string[]>[]}
|
|
604
|
+
*/
|
|
605
|
+
static #mergeClassRecordFields(records) {
|
|
606
|
+
const mergedRecords = []
|
|
607
|
+
let pendingNameFields = null
|
|
608
|
+
|
|
609
|
+
for (const record of records) {
|
|
610
|
+
const fields = record.fields || {}
|
|
611
|
+
const hasName = Boolean(getField(fields, 'NAME'))
|
|
612
|
+
const hasPayload = PcbModelParser.#hasClassPayload(fields)
|
|
613
|
+
|
|
614
|
+
if (pendingNameFields && hasName) {
|
|
615
|
+
mergedRecords.push(pendingNameFields)
|
|
616
|
+
pendingNameFields = null
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (hasName && !hasPayload) {
|
|
620
|
+
pendingNameFields = fields
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (pendingNameFields) {
|
|
625
|
+
mergedRecords.push({ ...pendingNameFields, ...fields })
|
|
626
|
+
pendingNameFields = null
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
mergedRecords.push(fields)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (pendingNameFields) {
|
|
634
|
+
mergedRecords.push(pendingNameFields)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return mergedRecords
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Normalizes one native Altium PCB class record.
|
|
642
|
+
* @param {Record<string, string | string[]>} fields
|
|
643
|
+
* @param {number} classIndex
|
|
644
|
+
* @returns {{ classIndex: number, name: string, kind: number, kindName: string, memberCount: number, members: string[], enabled: boolean, uniqueId: string }}
|
|
645
|
+
*/
|
|
646
|
+
static #normalizeClassRecord(fields, classIndex) {
|
|
647
|
+
const kind = parseNumericField(fields, 'KIND') || 0
|
|
648
|
+
const members = PcbModelParser.#parseClassMembers(fields)
|
|
649
|
+
const memberCount = parseNumericField(fields, 'MEMBERCOUNT')
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
classIndex,
|
|
653
|
+
name: getField(fields, 'NAME'),
|
|
654
|
+
kind,
|
|
655
|
+
kindName: PcbModelParser.#classKindName(kind),
|
|
656
|
+
memberCount: memberCount === null ? members.length : memberCount,
|
|
657
|
+
members,
|
|
658
|
+
enabled: PcbModelParser.#parseBooleanField(fields, 'ENABLED', true),
|
|
659
|
+
uniqueId: getField(fields, 'UNIQUEID') || getField(fields, 'UID')
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Returns true when one Classes6/Data field set carries semantic payload
|
|
665
|
+
* beyond an adjacent display-name record.
|
|
666
|
+
* @param {Record<string, string | string[]>} fields
|
|
667
|
+
* @returns {boolean}
|
|
668
|
+
*/
|
|
669
|
+
static #hasClassPayload(fields) {
|
|
670
|
+
return Object.keys(fields || {}).some(
|
|
671
|
+
(key) =>
|
|
672
|
+
key === 'KIND' ||
|
|
673
|
+
key === 'MEMBERCOUNT' ||
|
|
674
|
+
key === 'ENABLED' ||
|
|
675
|
+
key === 'UNIQUEID' ||
|
|
676
|
+
/^M\d+$/.test(key)
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Extracts ordered class members from M0, M1, ... fields.
|
|
682
|
+
* @param {Record<string, string | string[]>} fields
|
|
683
|
+
* @returns {string[]}
|
|
684
|
+
*/
|
|
685
|
+
static #parseClassMembers(fields) {
|
|
686
|
+
return Object.keys(fields || {})
|
|
687
|
+
.filter((key) => /^M\d+$/.test(key))
|
|
688
|
+
.sort(
|
|
689
|
+
(left, right) => Number(left.slice(1)) - Number(right.slice(1))
|
|
690
|
+
)
|
|
691
|
+
.map((key) => getField(fields, key))
|
|
692
|
+
.filter(Boolean)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Returns a stable display name for one native PCB class kind.
|
|
697
|
+
* @param {number} kind
|
|
698
|
+
* @returns {string}
|
|
699
|
+
*/
|
|
700
|
+
static #classKindName(kind) {
|
|
701
|
+
return (
|
|
702
|
+
{
|
|
703
|
+
0: 'net',
|
|
704
|
+
1: 'component',
|
|
705
|
+
2: 'from-to',
|
|
706
|
+
3: 'pad',
|
|
707
|
+
4: 'layer',
|
|
708
|
+
6: 'diff-pair',
|
|
709
|
+
7: 'polygon'
|
|
710
|
+
}[Number(kind)] || 'unknown'
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Parses one Altium boolean field with a default for omitted fields.
|
|
716
|
+
* @param {Record<string, string | string[]>} fields
|
|
717
|
+
* @param {string} key
|
|
718
|
+
* @param {boolean} fallback
|
|
719
|
+
* @returns {boolean}
|
|
720
|
+
*/
|
|
721
|
+
static #parseBooleanField(fields, key, fallback) {
|
|
722
|
+
const raw = getField(fields, key)
|
|
723
|
+
|
|
724
|
+
return raw ? parseBoolean(raw) : fallback
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Returns the first integer-like numeric field value.
|
|
729
|
+
* @param {Record<string, string | string[]>} fields
|
|
730
|
+
* @param {string[]} keys
|
|
731
|
+
* @returns {number | null}
|
|
732
|
+
*/
|
|
733
|
+
static #firstIntegerField(fields, keys) {
|
|
734
|
+
for (const key of keys) {
|
|
735
|
+
const parsed = parseNumericField(fields, key)
|
|
736
|
+
if (Number.isInteger(parsed)) {
|
|
737
|
+
return parsed
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return null
|
|
255
742
|
}
|
|
256
743
|
|
|
257
744
|
/**
|
|
@@ -291,46 +778,111 @@ export class PcbModelParser {
|
|
|
291
778
|
}
|
|
292
779
|
|
|
293
780
|
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* @param {{
|
|
297
|
-
* @
|
|
298
|
-
* @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
|
|
781
|
+
* Adds embedded-font metric references to decoded TrueType text primitives.
|
|
782
|
+
* @param {{ fontFamily?: string, fontName?: string, fontStyle?: string, fontWeight?: number }[]} texts
|
|
783
|
+
* @param {{ index: number, name: string, style: string, metrics?: object }[]} embeddedFonts
|
|
784
|
+
* @returns {object[]}
|
|
299
785
|
*/
|
|
300
|
-
static #
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return componentBodies.map((componentBody) => ({
|
|
308
|
-
...componentBody,
|
|
309
|
-
positionMil: {
|
|
310
|
-
x: Number(componentBody.positionMil?.x || 0),
|
|
311
|
-
y: mirrorY(componentBody.positionMil?.y || 0)
|
|
312
|
-
},
|
|
313
|
-
rotationDeg: PcbModelParser.#normalizeAngle(
|
|
314
|
-
360 - Number(componentBody.rotationDeg || 0)
|
|
315
|
-
),
|
|
316
|
-
modelRotationDeg: {
|
|
317
|
-
x: Number(componentBody.modelRotationDeg?.x || 0),
|
|
318
|
-
y: Number(componentBody.modelRotationDeg?.y || 0),
|
|
319
|
-
z: PcbModelParser.#normalizeAngle(
|
|
320
|
-
360 - Number(componentBody.modelRotationDeg?.z || 0)
|
|
321
|
-
)
|
|
786
|
+
static #annotateTextFontMetrics(texts, embeddedFonts) {
|
|
787
|
+
const fontsByKey = new Map()
|
|
788
|
+
|
|
789
|
+
for (const font of embeddedFonts || []) {
|
|
790
|
+
for (const key of PcbModelParser.#fontLookupKeys(font)) {
|
|
791
|
+
fontsByKey.set(key, font)
|
|
322
792
|
}
|
|
323
|
-
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return (texts || []).map((text) => {
|
|
796
|
+
const family = text.fontFamily || text.fontName || ''
|
|
797
|
+
const style = PcbModelParser.#textFontStyleName(text)
|
|
798
|
+
const font =
|
|
799
|
+
fontsByKey.get(PcbModelParser.#fontLookupKey(family, style)) ||
|
|
800
|
+
fontsByKey.get(PcbModelParser.#fontLookupKey(family, ''))
|
|
801
|
+
|
|
802
|
+
return font
|
|
803
|
+
? {
|
|
804
|
+
...text,
|
|
805
|
+
embeddedFontIndex: font.index,
|
|
806
|
+
fontMetrics: font.metrics || {}
|
|
807
|
+
}
|
|
808
|
+
: text
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Builds all lookup aliases for one embedded font.
|
|
814
|
+
* @param {{ name?: string, style?: string }} font
|
|
815
|
+
* @returns {string[]}
|
|
816
|
+
*/
|
|
817
|
+
static #fontLookupKeys(font) {
|
|
818
|
+
return [
|
|
819
|
+
PcbModelParser.#fontLookupKey(font.name, font.style),
|
|
820
|
+
PcbModelParser.#fontLookupKey(font.name, '')
|
|
821
|
+
]
|
|
324
822
|
}
|
|
325
823
|
|
|
326
824
|
/**
|
|
327
|
-
*
|
|
328
|
-
* @param {
|
|
329
|
-
* @
|
|
825
|
+
* Builds a normalized font lookup key.
|
|
826
|
+
* @param {string | undefined} family
|
|
827
|
+
* @param {string | undefined} style
|
|
828
|
+
* @returns {string}
|
|
330
829
|
*/
|
|
331
|
-
static #
|
|
332
|
-
|
|
830
|
+
static #fontLookupKey(family, style) {
|
|
831
|
+
return (
|
|
832
|
+
String(family || '')
|
|
833
|
+
.trim()
|
|
834
|
+
.toLowerCase() +
|
|
835
|
+
'\u0000' +
|
|
836
|
+
String(style || '')
|
|
837
|
+
.trim()
|
|
838
|
+
.toLowerCase()
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Converts SVG-style text font flags into the embedded-font style label.
|
|
844
|
+
* @param {{ fontStyle?: string, fontWeight?: number }} text
|
|
845
|
+
* @returns {'Regular' | 'Bold' | 'Italic' | 'Bold Italic'}
|
|
846
|
+
*/
|
|
847
|
+
static #textFontStyleName(text) {
|
|
848
|
+
const isBold = Number(text?.fontWeight || 0) >= 600
|
|
849
|
+
const isItalic =
|
|
850
|
+
String(text?.fontStyle || '').toLowerCase() === 'italic'
|
|
851
|
+
|
|
852
|
+
if (isBold && isItalic) return 'Bold Italic'
|
|
853
|
+
if (isBold) return 'Bold'
|
|
854
|
+
if (isItalic) return 'Italic'
|
|
333
855
|
|
|
334
|
-
return
|
|
856
|
+
return 'Regular'
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Removes parser-only display metadata from the public component model.
|
|
861
|
+
* @param {object} component
|
|
862
|
+
* @returns {object}
|
|
863
|
+
*/
|
|
864
|
+
static #publicComponentRecord(component) {
|
|
865
|
+
const {
|
|
866
|
+
nameOn: _nameOn,
|
|
867
|
+
commentOn: _commentOn,
|
|
868
|
+
...publicComponent
|
|
869
|
+
} = component
|
|
870
|
+
|
|
871
|
+
if (!publicComponent.uniqueId) {
|
|
872
|
+
delete publicComponent.uniqueId
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return publicComponent
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Chooses a singular or plural word based on a count.
|
|
880
|
+
* @param {number} count
|
|
881
|
+
* @param {string} singular
|
|
882
|
+
* @param {string} plural
|
|
883
|
+
* @returns {string}
|
|
884
|
+
*/
|
|
885
|
+
static #plural(count, singular, plural) {
|
|
886
|
+
return Number(count) === 1 ? singular : plural
|
|
335
887
|
}
|
|
336
888
|
}
|