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.
Files changed (54) hide show
  1. package/README.md +25 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +6 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. 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: { fills: { x1: number, y1: number, x2: number, y2: number, layerCode: number, layerId: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode: number, layerId: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode: number, layerId: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, rotation: number, isPlated: boolean }[] }, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null} pcbExtraction
26
- * @returns {{ kind: 'pcb', fileType: 'PcbDoc', fileName: string, summary: Record<string, number | string>, diagnostics: { severity: 'info' | 'warning', message: string }[], pcb: { boardOutline: { widthMil: number, heightMil: number, minX: number, minY: number, segments: Array<Record<string, number | string>> }, layers: { index: number, name: string, layerId: number | null }[], primitiveLayers: { layerId: number, name: string }[], components: { designator: string, x: number, y: number, layer: string, pattern: string, rotation: number, source: string, description: string, height: number | null }[], polygons: { layer: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode: number, layerId: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode: number, layerId: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode: number, layerId: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, rotation: number, isPlated: boolean }[] }, bom: { designators: string[], quantity: number, pattern: string, source: string, value: string }[] }}
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
- records
55
- .filter(
56
- (record) =>
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 = pcbExtraction?.binaryPrimitives?.tracks || []
96
- const arcs = pcbExtraction?.binaryPrimitives?.arcs || []
97
- const vias = pcbExtraction?.binaryPrimitives?.vias || []
98
- const fills = pcbExtraction?.binaryPrimitives?.fills || []
99
- const pads = pcbExtraction?.binaryPrimitives?.pads || []
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 componentBodies = PcbModelParser.#normalizeComponentBodies(
127
- extractedComponentBodies,
128
- boardOutline
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
- componentBodies
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
- * Flips embedded component-body placements into the viewer coordinate
295
- * system.
296
- * @param {{ 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 }[]} componentBodies
297
- * @param {{ minY: number, heightMil: number }} boardOutline
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 #normalizeComponentBodies(componentBodies, boardOutline) {
301
- const maxY =
302
- Number(boardOutline?.minY || 0) +
303
- Number(boardOutline?.heightMil || 0)
304
- const mirrorY = (value) =>
305
- Number(boardOutline?.minY || 0) + maxY - Number(value || 0)
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
- * Normalizes one angle into the range [0, 360).
328
- * @param {number} angle
329
- * @returns {number}
825
+ * Builds a normalized font lookup key.
826
+ * @param {string | undefined} family
827
+ * @param {string | undefined} style
828
+ * @returns {string}
330
829
  */
331
- static #normalizeAngle(angle) {
332
- const normalized = Number(angle || 0) % 360
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 normalized < 0 ? normalized + 360 : normalized
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
  }