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
@@ -12,6 +12,7 @@ import { PcbRegionPrimitiveRenderer } from './PcbRegionPrimitiveRenderer.mjs'
12
12
  import { PcbScene3dBoardOutlineRefiner } from './PcbScene3dBoardOutlineRefiner.mjs'
13
13
  import { PcbTextPrimitiveRenderer } from './PcbTextPrimitiveRenderer.mjs'
14
14
  import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
15
+ import { TextGeometrySidecarBuilder } from './TextGeometrySidecarBuilder.mjs'
15
16
  /**
16
17
  * Renders normalized PCB models into HTML and SVG markup.
17
18
  */
@@ -19,16 +20,25 @@ export class PcbSvgRenderer {
19
20
  static #PAD_SHAPE_RECTANGULAR = 2
20
21
  static #PAD_HOLE_SHAPE_SLOT = 2
21
22
  static #GENERIC_DETAIL_SEARCH_HALF_EXTENT = 240
23
+ static #SEMANTIC_SCHEMA = 'altium-toolkit.pcb.svg.semantics.a1'
22
24
  /**
23
25
  * Renders a normalized PCB model into HTML and SVG markup.
24
26
  * @param {{ summary: { title?: string }, pcb?: { boardOutline: { segments: Array<Record<string, number | string>>, minX: number, minY: number, widthMil: number, heightMil: number }, layers: { name: string }[], primitiveLayers?: { layerId: number, name: string }[], 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 }[], texts?: { text: string, x: number, y: number, height?: number, rotation?: number, layerId?: number, visible?: boolean }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] } }} documentModel
27
+ * @param {{ viewKind?: string, layerView?: object } | undefined} options Render options.
25
28
  * @returns {string}
26
29
  */
27
- static render(documentModel) {
28
- const pcb = documentModel?.pcb
29
- if (!pcb) {
30
+ static render(documentModel, options = {}) {
31
+ const sourcePcb = documentModel?.pcb
32
+ if (!sourcePcb) {
30
33
  return '<section class="altium-renderer-empty">No PCB entities were recovered from this file.</section>'
31
34
  }
35
+ const viewOptions = PcbSvgRenderer.#normalizeViewOptions(options)
36
+ const pcb = viewOptions.layerView
37
+ ? PcbSvgRenderer.#filterPcbForLayer(
38
+ sourcePcb,
39
+ viewOptions.layerView
40
+ )
41
+ : sourcePcb
32
42
  const outline = PcbScene3dBoardOutlineRefiner.refine(
33
43
  { board: pcb.boardOutline },
34
44
  documentModel
@@ -47,7 +57,20 @@ export class PcbSvgRenderer {
47
57
  const components = pcb.components.slice(0, 260)
48
58
  const stackLayers = Array.isArray(pcb.layers) ? pcb.layers : []
49
59
  const primitiveLayers = pcb.primitiveLayers || []
50
- const displayLayers = stackLayers.length ? stackLayers : primitiveLayers
60
+ const displayLayers = viewOptions.layerView
61
+ ? [viewOptions.layerView]
62
+ : stackLayers.length
63
+ ? stackLayers
64
+ : primitiveLayers
65
+ const semanticContext = PcbSvgRenderer.#buildSemanticContext(
66
+ pcb,
67
+ displayLayers,
68
+ viewOptions
69
+ )
70
+ const semanticMetadata = PcbSvgRenderer.#buildSemanticMetadata(
71
+ pcb,
72
+ semanticContext
73
+ )
51
74
  const copperGroups = PcbCopperPrimitiveSplitter.split(
52
75
  polygons,
53
76
  fills,
@@ -120,19 +143,31 @@ export class PcbSvgRenderer {
120
143
  const polygonMarkup = (polygonList, visibilityClass) =>
121
144
  polygonList
122
145
  .map(
123
- (polygon) =>
146
+ (polygon, index) =>
124
147
  '<path class="pcb-polygon pcb-polygon--' +
125
148
  visibilityClass +
126
149
  '" d="' +
127
150
  SchematicSvgUtils.escapeHtml(
128
151
  PcbSvgRenderer.#buildBoardPath(polygon.segments)
129
152
  ) +
130
- '" />'
153
+ '"' +
154
+ PcbSvgRenderer.#semanticAttributes(
155
+ 'polygon',
156
+ polygon,
157
+ PcbSvgRenderer.#primitiveIndex(
158
+ semanticContext,
159
+ 'polygons',
160
+ polygon,
161
+ index
162
+ ),
163
+ semanticContext
164
+ ) +
165
+ ' />'
131
166
  )
132
167
  .join('')
133
168
  const fillMarkup = (fillList, visibilityClass) =>
134
169
  fillList
135
- .map((fill) => {
170
+ .map((fill, index) => {
136
171
  const x = Math.min(fill.x1, fill.x2)
137
172
  const y = Math.min(fill.y1, fill.y2)
138
173
  const width = Math.abs(fill.x2 - fill.x1)
@@ -153,14 +188,26 @@ export class PcbSvgRenderer {
153
188
  SchematicSvgUtils.formatNumber(
154
189
  Math.min(width, height) / 6
155
190
  ) +
156
- '" />'
191
+ '"' +
192
+ PcbSvgRenderer.#semanticAttributes(
193
+ 'fill',
194
+ fill,
195
+ PcbSvgRenderer.#primitiveIndex(
196
+ semanticContext,
197
+ 'fills',
198
+ fill,
199
+ index
200
+ ),
201
+ semanticContext
202
+ ) +
203
+ ' />'
157
204
  )
158
205
  })
159
206
  .join('')
160
207
  const trackMarkup = (trackList, visibilityClass) =>
161
208
  trackList
162
209
  .map(
163
- (track) =>
210
+ (track, index) =>
164
211
  '<line class="pcb-track pcb-track--' +
165
212
  visibilityClass +
166
213
  '" x1="' +
@@ -175,15 +222,40 @@ export class PcbSvgRenderer {
175
222
  SchematicSvgUtils.formatNumber(
176
223
  Math.max(track.width || 0, 1)
177
224
  ) +
178
- '" />'
225
+ '"' +
226
+ PcbSvgRenderer.#semanticAttributes(
227
+ 'track',
228
+ track,
229
+ PcbSvgRenderer.#primitiveIndex(
230
+ semanticContext,
231
+ 'tracks',
232
+ track,
233
+ index
234
+ ),
235
+ semanticContext
236
+ ) +
237
+ ' />'
179
238
  )
180
239
  .join('')
181
240
  const arcMarkup = (arcList, visibilityClass) =>
182
241
  arcList
183
- .map((arc) =>
184
- PcbArcUtils.buildMarkup(
185
- arc,
186
- 'pcb-arc pcb-arc--' + visibilityClass
242
+ .map((arc, index) =>
243
+ PcbSvgRenderer.#appendSvgAttributes(
244
+ PcbArcUtils.buildMarkup(
245
+ arc,
246
+ 'pcb-arc pcb-arc--' + visibilityClass
247
+ ),
248
+ PcbSvgRenderer.#semanticAttributes(
249
+ 'arc',
250
+ arc,
251
+ PcbSvgRenderer.#primitiveIndex(
252
+ semanticContext,
253
+ 'arcs',
254
+ arc,
255
+ index
256
+ ),
257
+ semanticContext
258
+ )
187
259
  )
188
260
  )
189
261
  .join('')
@@ -193,11 +265,24 @@ export class PcbSvgRenderer {
193
265
  'pcb-region pcb-region--' + visibilityClass
194
266
  )
195
267
  const viaMarkup = vias
196
- .map((via) => {
268
+ .map((via, index) => {
197
269
  const ringRadius = Math.max((via.diameter || 0) / 2, 1)
198
270
  const holeRadius = Math.max((via.holeDiameter || 0) / 2, 0.6)
271
+ const viaIndex = PcbSvgRenderer.#primitiveIndex(
272
+ semanticContext,
273
+ 'vias',
274
+ via,
275
+ index
276
+ )
199
277
  return (
200
- '<g class="pcb-via">' +
278
+ '<g class="pcb-via"' +
279
+ PcbSvgRenderer.#semanticAttributes(
280
+ 'via',
281
+ via,
282
+ viaIndex,
283
+ semanticContext
284
+ ) +
285
+ '>' +
201
286
  '<circle class="pcb-via__pad" cx="' +
202
287
  SchematicSvgUtils.formatNumber(via.x) +
203
288
  '" cy="' +
@@ -211,13 +296,34 @@ export class PcbSvgRenderer {
211
296
  SchematicSvgUtils.formatNumber(via.y) +
212
297
  '" r="' +
213
298
  SchematicSvgUtils.formatNumber(holeRadius) +
214
- '" />' +
299
+ '"' +
300
+ PcbSvgRenderer.#renderDataAttributes({
301
+ 'data-primitive': 'via-hole',
302
+ 'data-element-key': 'pcb-via-hole-' + viaIndex,
303
+ 'data-hole-owner': 'via',
304
+ 'data-hole-kind': 'via',
305
+ 'data-plating': PcbSvgRenderer.#drillPlating(via),
306
+ 'data-drill-render-state':
307
+ PcbSvgRenderer.#drillRenderState(via)
308
+ }) +
309
+ ' />' +
215
310
  '</g>'
216
311
  )
217
312
  })
218
313
  .join('')
219
314
  const padMarkup = pads
220
- .map((pad) => PcbSvgRenderer.#renderPad(pad))
315
+ .map((pad, index) =>
316
+ PcbSvgRenderer.#renderPad(
317
+ pad,
318
+ PcbSvgRenderer.#primitiveIndex(
319
+ semanticContext,
320
+ 'pads',
321
+ pad,
322
+ index
323
+ ),
324
+ semanticContext
325
+ )
326
+ )
221
327
  .join('')
222
328
  const footprintFillMarkup = footprintPrimitives.fills
223
329
  .map((fill) => {
@@ -267,7 +373,15 @@ export class PcbSvgRenderer {
267
373
  footprintPrimitives.regions,
268
374
  'pcb-footprint-region'
269
375
  )
270
- const textMarkup = PcbTextPrimitiveRenderer.render(texts)
376
+ const textMarkup = PcbTextPrimitiveRenderer.render(texts, {
377
+ semanticContext
378
+ })
379
+ const textGeometryMarkup = viewOptions.includeTextGeometrySidecar
380
+ ? PcbSvgRenderer.#buildTextGeometryMetadataMarkup(
381
+ texts,
382
+ semanticContext
383
+ )
384
+ : ''
271
385
  const textGroupTransform = PcbSvgRenderer.#renderTextGroupTransform(
272
386
  pcb.textGroupTransform
273
387
  )
@@ -276,7 +390,7 @@ export class PcbSvgRenderer {
276
390
  )
277
391
 
278
392
  const componentMarkup = components
279
- .map((component) => {
393
+ .map((component, index) => {
280
394
  const bodyGeometry = PcbSvgRenderer.#footprintSize(
281
395
  component.pattern
282
396
  )
@@ -310,7 +424,19 @@ export class PcbSvgRenderer {
310
424
  SchematicSvgUtils.formatNumber(component.y) +
311
425
  ') rotate(' +
312
426
  SchematicSvgUtils.formatNumber(component.rotation) +
313
- ')">' +
427
+ ')"' +
428
+ PcbSvgRenderer.#semanticAttributes(
429
+ 'component',
430
+ component,
431
+ PcbSvgRenderer.#primitiveIndex(
432
+ semanticContext,
433
+ 'components',
434
+ component,
435
+ index
436
+ ),
437
+ semanticContext
438
+ ) +
439
+ '>' +
314
440
  bodyMarkup +
315
441
  '</g>'
316
442
  )
@@ -331,9 +457,24 @@ export class PcbSvgRenderer {
331
457
  '<aside class="pcb-legend"><h4>Board stack</h4><p>Top-facing composite view</p><ul>' +
332
458
  layerMarkup +
333
459
  '</ul></aside>' +
334
- '<svg class="pcb-svg" viewBox="' +
335
- SchematicSvgUtils.escapeHtml(viewBox) +
336
- '" preserveAspectRatio="xMidYMid meet" aria-label="PCB view">' +
460
+ '<svg class="pcb-svg"' +
461
+ PcbSvgRenderer.#renderRootViewBoxAttributes(viewOptions, viewBox) +
462
+ ' preserveAspectRatio="xMidYMid meet" aria-label="PCB view" data-semantic-schema="' +
463
+ PcbSvgRenderer.#SEMANTIC_SCHEMA +
464
+ '"' +
465
+ PcbSvgRenderer.#renderDataAttributes({
466
+ 'data-doc-id': viewOptions.documentId,
467
+ 'data-doc-ver': viewOptions.documentVersion,
468
+ 'data-view-kind': semanticContext.viewKind,
469
+ 'data-layer-view-key': semanticContext.layerView?.layerKey,
470
+ 'data-layer-view-display-name':
471
+ semanticContext.layerView?.displayName,
472
+ 'data-included-layer-ids':
473
+ PcbSvgRenderer.#includedLayerIds(semanticContext),
474
+ 'data-board-outline-only':
475
+ PcbSvgRenderer.#isBoardOutlineOnly(pcb)
476
+ }) +
477
+ '">' +
337
478
  '<defs>' +
338
479
  fontFaceMarkup +
339
480
  '<clipPath id="' +
@@ -341,9 +482,24 @@ export class PcbSvgRenderer {
341
482
  '"><path d="' +
342
483
  SchematicSvgUtils.escapeHtml(path) +
343
484
  '" /></clipPath></defs>' +
344
- '<path class="board-outline" d="' +
485
+ '<metadata id="pcb-semantic-metadata" data-schema="' +
486
+ PcbSvgRenderer.#SEMANTIC_SCHEMA +
487
+ '">' +
488
+ SchematicSvgUtils.escapeHtml(JSON.stringify(semanticMetadata)) +
489
+ '</metadata>' +
490
+ textGeometryMarkup +
491
+ '<path class="board-outline pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts"' +
492
+ PcbSvgRenderer.#renderDataAttributes({
493
+ 'data-primitive': 'board-outline',
494
+ 'data-element-key': 'pcb-board-outline',
495
+ 'data-feature': 'board-outline',
496
+ 'data-layer-key': 'EDGE',
497
+ 'data-layer-display-name': 'Edge.Cuts'
498
+ }) +
499
+ ' d="' +
345
500
  SchematicSvgUtils.escapeHtml(path) +
346
- '" />' +
501
+ '"' +
502
+ ' />' +
347
503
  '<g class="pcb-copper-layers" clip-path="url(#' +
348
504
  clipPathId +
349
505
  ')">' +
@@ -380,13 +536,981 @@ export class PcbSvgRenderer {
380
536
  '>' +
381
537
  textMarkup +
382
538
  '</g>' +
383
- '<path class="board-outline board-outline--stroke" d="' +
539
+ '<path class="board-outline board-outline--stroke pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts"' +
540
+ PcbSvgRenderer.#renderDataAttributes({
541
+ 'data-primitive': 'board-outline',
542
+ 'data-element-key': 'pcb-board-outline-stroke',
543
+ 'data-feature': 'board-outline',
544
+ 'data-layer-key': 'EDGE',
545
+ 'data-layer-display-name': 'Edge.Cuts'
546
+ }) +
547
+ ' d="' +
384
548
  SchematicSvgUtils.escapeHtml(path) +
385
- '" />' +
549
+ '"' +
550
+ ' />' +
386
551
  '</svg></div></section>'
387
552
  )
388
553
  }
389
554
 
555
+ /**
556
+ * Renders one deterministic SVG entry per physical or primitive layer.
557
+ * @param {object} documentModel Normalized PCB document model.
558
+ * @returns {{ layerId?: number, layerKey: string, displayName: string, role: string, svg: string }[]}
559
+ */
560
+ static renderLayerSvgs(documentModel) {
561
+ const pcb = documentModel?.pcb
562
+ if (!pcb) {
563
+ return []
564
+ }
565
+
566
+ return PcbSvgRenderer.#displayLayerDescriptors(pcb).map(
567
+ (layerView) => ({
568
+ ...layerView,
569
+ svg: PcbSvgRenderer.render(documentModel, {
570
+ viewKind: 'layer',
571
+ layerView
572
+ })
573
+ })
574
+ )
575
+ }
576
+
577
+ /**
578
+ * Normalizes renderer view options.
579
+ * @param {{ viewKind?: string, layerView?: object } | undefined} options Render options.
580
+ * @returns {{ viewKind: string, layerView: object | null, includeViewBox: boolean, documentId: string, documentVersion: string, includeTextGeometrySidecar: boolean }}
581
+ */
582
+ static #normalizeViewOptions(options) {
583
+ const includeViewBox =
584
+ options?.includeViewBox ?? options?.include_view_box
585
+
586
+ return {
587
+ viewKind: String(options?.viewKind || 'top-composite'),
588
+ layerView: options?.layerView
589
+ ? PcbSvgRenderer.#layerDescriptor(options.layerView)
590
+ : null,
591
+ includeViewBox: includeViewBox === false ? false : true,
592
+ documentId: String(options?.documentId || options?.docId || ''),
593
+ documentVersion: String(
594
+ options?.documentVersion || options?.documentVer || ''
595
+ ),
596
+ includeTextGeometrySidecar:
597
+ options?.includeTextGeometrySidecar === true ||
598
+ options?.textGeometry === 'sidecar'
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Renders root SVG viewBox attributes according to export options.
604
+ * @param {{ includeViewBox: boolean }} options Normalized options.
605
+ * @param {string} viewBox ViewBox value.
606
+ * @returns {string}
607
+ */
608
+ static #renderRootViewBoxAttributes(options, viewBox) {
609
+ return options.includeViewBox
610
+ ? ' viewBox="' + SchematicSvgUtils.escapeHtml(viewBox) + '"'
611
+ : ''
612
+ }
613
+
614
+ /**
615
+ * Builds optional PCB text geometry metadata markup.
616
+ * @param {object[]} texts Text rows.
617
+ * @param {object} semanticContext Semantic context.
618
+ * @returns {string}
619
+ */
620
+ static #buildTextGeometryMetadataMarkup(texts, semanticContext) {
621
+ const metadata = TextGeometrySidecarBuilder.buildPcb(
622
+ texts,
623
+ semanticContext.primitiveIndexes?.texts
624
+ )
625
+
626
+ return (
627
+ '<metadata id="pcb-text-geometry" data-schema="' +
628
+ TextGeometrySidecarBuilder.SCHEMA_ID +
629
+ '">' +
630
+ SchematicSvgUtils.escapeHtml(JSON.stringify(metadata)) +
631
+ '</metadata>'
632
+ )
633
+ }
634
+
635
+ /**
636
+ * Returns stable display layer descriptors for per-layer exports.
637
+ * @param {object} pcb Normalized PCB model.
638
+ * @returns {object[]}
639
+ */
640
+ static #displayLayerDescriptors(pcb) {
641
+ const layers = Array.isArray(pcb?.layers) ? pcb.layers : []
642
+ const primitiveLayers = Array.isArray(pcb?.primitiveLayers)
643
+ ? pcb.primitiveLayers
644
+ : []
645
+ const sourceLayers = layers.length ? layers : primitiveLayers
646
+ const byKey = new Map()
647
+
648
+ for (const layer of sourceLayers) {
649
+ const descriptor = PcbSvgRenderer.#layerDescriptor(layer)
650
+ if (descriptor && !byKey.has(descriptor.layerKey)) {
651
+ byKey.set(descriptor.layerKey, descriptor)
652
+ }
653
+ }
654
+
655
+ return [...byKey.values()].sort(
656
+ (left, right) =>
657
+ Number(left.layerId ?? Number.MAX_SAFE_INTEGER) -
658
+ Number(right.layerId ?? Number.MAX_SAFE_INTEGER) ||
659
+ left.displayName.localeCompare(right.displayName, undefined, {
660
+ numeric: true
661
+ })
662
+ )
663
+ }
664
+
665
+ /**
666
+ * Clones and filters the PCB model down to one layer view.
667
+ * @param {object} pcb Normalized PCB model.
668
+ * @param {object} layerView Layer descriptor.
669
+ * @returns {object}
670
+ */
671
+ static #filterPcbForLayer(pcb, layerView) {
672
+ const filter = (primitive) =>
673
+ PcbSvgRenderer.#primitiveBelongsToLayer(primitive, layerView)
674
+
675
+ return {
676
+ ...pcb,
677
+ layers: [layerView],
678
+ primitiveLayers: [layerView],
679
+ polygons: (pcb?.polygons || []).filter(filter),
680
+ fills: (pcb?.fills || []).filter(filter),
681
+ tracks: (pcb?.tracks || []).filter(filter),
682
+ arcs: (pcb?.arcs || []).filter(filter),
683
+ vias: (pcb?.vias || []).filter(filter),
684
+ pads: (pcb?.pads || []).filter(filter),
685
+ regions: (pcb?.regions || []).filter(filter),
686
+ shapeBasedRegions: (pcb?.shapeBasedRegions || []).filter(filter),
687
+ texts: (pcb?.texts || []).filter(filter),
688
+ components: (pcb?.components || []).filter(filter)
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Returns true when one primitive belongs to the requested layer.
694
+ * @param {object} primitive Primitive row.
695
+ * @param {object} layerView Layer descriptor.
696
+ * @returns {boolean}
697
+ */
698
+ static #primitiveBelongsToLayer(primitive, layerView) {
699
+ const layerId = PcbSvgRenderer.#firstFiniteNumber([
700
+ primitive?.layerId,
701
+ primitive?.layerCode
702
+ ])
703
+ if (Number.isInteger(layerId) && Number.isInteger(layerView?.layerId)) {
704
+ return layerId === layerView.layerId
705
+ }
706
+
707
+ const primitiveName =
708
+ primitive?.layerName || primitive?.layer || primitive?.side || ''
709
+ if (primitiveName && layerView?.displayName) {
710
+ return (
711
+ PcbSvgRenderer.#normalizeSemanticLookup(primitiveName) ===
712
+ PcbSvgRenderer.#normalizeSemanticLookup(layerView.displayName)
713
+ )
714
+ }
715
+
716
+ return (
717
+ !Number.isInteger(layerId) &&
718
+ !primitiveName &&
719
+ ['pad', 'via', 'copper'].includes(layerView?.role)
720
+ )
721
+ }
722
+
723
+ /**
724
+ * Builds reusable semantic lookup data for one PCB render.
725
+ * @param {object} pcb Normalized PCB model.
726
+ * @param {{ layerId?: number, layerCode?: number, index?: number, name?: string, displayName?: string }[]} displayLayers Visible layer records.
727
+ * @param {{ viewKind?: string, layerView?: object }} viewOptions View options.
728
+ * @returns {{ viewKind: string, layerView?: object, layersById: Map<number, object>, layersByName: Map<string, object>, layerDescriptors: object[], netByIndex: Map<number, object>, netClassNamesByNetName: Map<string, string[]>, componentsByIndex: Map<number, object>, primitiveIndexes: Record<string, Map<object, number>> }}
729
+ */
730
+ static #buildSemanticContext(pcb, displayLayers, viewOptions = {}) {
731
+ const layerRecords = [
732
+ ...(displayLayers || []),
733
+ ...(pcb?.primitiveLayers || [])
734
+ ]
735
+ const layersById = new Map()
736
+ const layersByName = new Map()
737
+ const layerDescriptors = []
738
+
739
+ for (const layer of layerRecords) {
740
+ const descriptor = PcbSvgRenderer.#layerDescriptor(layer)
741
+ if (!descriptor) {
742
+ continue
743
+ }
744
+ if (
745
+ Number.isInteger(descriptor.layerId) &&
746
+ !layersById.has(descriptor.layerId)
747
+ ) {
748
+ layersById.set(descriptor.layerId, descriptor)
749
+ }
750
+ const normalizedName = PcbSvgRenderer.#normalizeSemanticLookup(
751
+ descriptor.displayName
752
+ )
753
+ if (normalizedName && !layersByName.has(normalizedName)) {
754
+ layersByName.set(normalizedName, descriptor)
755
+ }
756
+ if (
757
+ !layerDescriptors.some(
758
+ (existing) => existing.layerKey === descriptor.layerKey
759
+ )
760
+ ) {
761
+ layerDescriptors.push(descriptor)
762
+ }
763
+ }
764
+
765
+ const netByIndex = new Map()
766
+ const netNameByLookup = new Map()
767
+ for (const net of pcb?.nets || []) {
768
+ const netIndex = Number(net?.netIndex)
769
+ if (Number.isInteger(netIndex)) {
770
+ netByIndex.set(netIndex, net)
771
+ }
772
+ if (net?.name) {
773
+ netNameByLookup.set(
774
+ PcbSvgRenderer.#normalizeSemanticLookup(net.name),
775
+ net.name
776
+ )
777
+ }
778
+ }
779
+
780
+ const netClassNamesByNetName = new Map()
781
+ for (const classRecord of pcb?.classes || []) {
782
+ if (!PcbSvgRenderer.#isNetClass(classRecord)) {
783
+ continue
784
+ }
785
+ for (const member of classRecord.members || []) {
786
+ const netName =
787
+ netNameByLookup.get(
788
+ PcbSvgRenderer.#normalizeSemanticLookup(member)
789
+ ) || member
790
+ const classNames = netClassNamesByNetName.get(netName) || []
791
+ classNames.push(classRecord.name)
792
+ netClassNamesByNetName.set(netName, classNames)
793
+ }
794
+ }
795
+
796
+ return {
797
+ viewKind: viewOptions.viewKind || 'top-composite',
798
+ layerView: viewOptions.layerView || null,
799
+ layersById,
800
+ layersByName,
801
+ layerDescriptors,
802
+ netByIndex,
803
+ netClassNamesByNetName,
804
+ componentsByIndex: PcbSvgRenderer.#componentIndexMap(
805
+ pcb?.components || []
806
+ ),
807
+ primitiveIndexes: {
808
+ polygons: PcbSvgRenderer.#objectIndexMap(pcb?.polygons || []),
809
+ fills: PcbSvgRenderer.#objectIndexMap(pcb?.fills || []),
810
+ tracks: PcbSvgRenderer.#objectIndexMap(pcb?.tracks || []),
811
+ arcs: PcbSvgRenderer.#objectIndexMap(pcb?.arcs || []),
812
+ vias: PcbSvgRenderer.#objectIndexMap(pcb?.vias || []),
813
+ pads: PcbSvgRenderer.#objectIndexMap(pcb?.pads || []),
814
+ texts: PcbSvgRenderer.#objectIndexMap(pcb?.texts || []),
815
+ components: PcbSvgRenderer.#objectIndexMap(
816
+ pcb?.components || []
817
+ )
818
+ }
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Builds a compact JSON sidecar describing semantic SVG element keys.
824
+ * @param {object} pcb Normalized PCB model.
825
+ * @param {object} semanticContext Semantic lookup context.
826
+ * @returns {{ schema: string, boardOutline: object, layers: object[], elements: object[] }}
827
+ */
828
+ static #buildSemanticMetadata(pcb, semanticContext) {
829
+ return {
830
+ schema: PcbSvgRenderer.#SEMANTIC_SCHEMA,
831
+ view: PcbSvgRenderer.#buildViewMetadata(pcb, semanticContext),
832
+ boardOutline: {
833
+ feature: 'board-outline',
834
+ elementKeys: ['pcb-board-outline', 'pcb-board-outline-stroke']
835
+ },
836
+ layers: semanticContext.layerDescriptors,
837
+ elements: [
838
+ ...PcbSvgRenderer.#semanticMetadataEntries(
839
+ 'polygon',
840
+ 'polygons',
841
+ pcb?.polygons || [],
842
+ semanticContext
843
+ ),
844
+ ...PcbSvgRenderer.#semanticMetadataEntries(
845
+ 'fill',
846
+ 'fills',
847
+ pcb?.fills || [],
848
+ semanticContext
849
+ ),
850
+ ...PcbSvgRenderer.#semanticMetadataEntries(
851
+ 'track',
852
+ 'tracks',
853
+ pcb?.tracks || [],
854
+ semanticContext
855
+ ),
856
+ ...PcbSvgRenderer.#semanticMetadataEntries(
857
+ 'arc',
858
+ 'arcs',
859
+ pcb?.arcs || [],
860
+ semanticContext
861
+ ),
862
+ ...PcbSvgRenderer.#semanticMetadataEntries(
863
+ 'via',
864
+ 'vias',
865
+ pcb?.vias || [],
866
+ semanticContext
867
+ ),
868
+ ...PcbSvgRenderer.#semanticMetadataEntries(
869
+ 'pad',
870
+ 'pads',
871
+ pcb?.pads || [],
872
+ semanticContext
873
+ ),
874
+ ...PcbSvgRenderer.#semanticMetadataEntries(
875
+ 'text',
876
+ 'texts',
877
+ pcb?.texts || [],
878
+ semanticContext
879
+ ),
880
+ ...PcbSvgRenderer.#semanticMetadataEntries(
881
+ 'component',
882
+ 'components',
883
+ pcb?.components || [],
884
+ semanticContext
885
+ )
886
+ ]
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Builds metadata for the rendered PCB view.
892
+ * @param {object} pcb Normalized PCB model.
893
+ * @param {object} semanticContext Semantic lookup context.
894
+ * @returns {object}
895
+ */
896
+ static #buildViewMetadata(pcb, semanticContext) {
897
+ return {
898
+ kind: semanticContext.viewKind || 'top-composite',
899
+ board: PcbSvgRenderer.#buildBoardViewMetadata(pcb),
900
+ layerSet: {
901
+ includedLayerIds:
902
+ PcbSvgRenderer.#includedLayerIds(semanticContext),
903
+ layerView: semanticContext.layerView || undefined,
904
+ roles: semanticContext.layerDescriptors.map((layer) =>
905
+ PcbSvgRenderer.#stripEmptySemanticObject({
906
+ layerId: layer.layerId,
907
+ layerKey: layer.layerKey,
908
+ displayName: layer.displayName,
909
+ role: layer.role
910
+ })
911
+ )
912
+ },
913
+ cutouts: PcbSvgRenderer.#boardCutoutMetadata(pcb),
914
+ drills: [
915
+ ...(pcb?.vias || [])
916
+ .filter((via) => Number(via?.holeDiameter || 0) > 0)
917
+ .map((via, index) =>
918
+ PcbSvgRenderer.#drillDescriptor(
919
+ 'via',
920
+ via,
921
+ 'pcb-via-hole-' +
922
+ PcbSvgRenderer.#primitiveIndex(
923
+ semanticContext,
924
+ 'vias',
925
+ via,
926
+ index
927
+ )
928
+ )
929
+ ),
930
+ ...(pcb?.pads || [])
931
+ .filter((pad) => Number(pad?.holeDiameter || 0) > 0)
932
+ .map((pad, index) =>
933
+ PcbSvgRenderer.#drillDescriptor(
934
+ 'pad',
935
+ pad,
936
+ 'pcb-pad-hole-' +
937
+ PcbSvgRenderer.#primitiveIndex(
938
+ semanticContext,
939
+ 'pads',
940
+ pad,
941
+ index
942
+ )
943
+ )
944
+ )
945
+ ]
946
+ }
947
+ }
948
+
949
+ /**
950
+ * Builds board-level view metadata.
951
+ * @param {object} pcb Normalized PCB model.
952
+ * @returns {object}
953
+ */
954
+ static #buildBoardViewMetadata(pcb) {
955
+ const outline = pcb?.boardOutline || {}
956
+ const minX = Number(outline.minX || 0)
957
+ const minY = Number(outline.minY || 0)
958
+ const width = Number(outline.widthMil || 0)
959
+ const height = Number(outline.heightMil || 0)
960
+
961
+ return {
962
+ elementKey: 'pcb-board-outline',
963
+ outlineOnly: PcbSvgRenderer.#isBoardOutlineOnly(pcb),
964
+ centroid: {
965
+ x: minX + width / 2,
966
+ y: minY + height / 2
967
+ },
968
+ bounds: {
969
+ minX,
970
+ minY,
971
+ maxX: minX + width,
972
+ maxY: minY + height
973
+ }
974
+ }
975
+ }
976
+
977
+ /**
978
+ * Builds board cutout sidecar entries.
979
+ * @param {object} pcb Normalized PCB model.
980
+ * @returns {object[]}
981
+ */
982
+ static #boardCutoutMetadata(pcb) {
983
+ const outlineCutouts = Array.isArray(pcb?.boardOutline?.cutouts)
984
+ ? pcb.boardOutline.cutouts
985
+ : []
986
+ const regionCutouts = (pcb?.boardRegions || []).filter(
987
+ (region) => region?.isBoardCutout === true
988
+ )
989
+
990
+ return [...outlineCutouts, ...regionCutouts].map((cutout, index) =>
991
+ PcbSvgRenderer.#stripEmptySemanticObject({
992
+ id: cutout.id || cutout.uniqueId || 'cutout-' + index,
993
+ kind: cutout.kind || 'board-cutout',
994
+ elementKey: 'pcb-board-cutout-' + index
995
+ })
996
+ )
997
+ }
998
+
999
+ /**
1000
+ * Builds one drill sidecar entry.
1001
+ * @param {'pad' | 'via'} owner Drill owner kind.
1002
+ * @param {object} primitive Drill owner primitive.
1003
+ * @param {string} elementKey SVG element key.
1004
+ * @returns {object}
1005
+ */
1006
+ static #drillDescriptor(owner, primitive, elementKey) {
1007
+ return PcbSvgRenderer.#stripEmptySemanticObject({
1008
+ elementKey,
1009
+ owner,
1010
+ holeKind: owner,
1011
+ plating: PcbSvgRenderer.#drillPlating(primitive),
1012
+ renderState: PcbSvgRenderer.#drillRenderState(primitive),
1013
+ ipc4761Type:
1014
+ primitive?.ipc4761Type ?? primitive?.viaProtection?.ipc4761Type
1015
+ })
1016
+ }
1017
+
1018
+ /**
1019
+ * Builds sidecar entries for one primitive collection.
1020
+ * @param {string} primitiveKind Public primitive kind.
1021
+ * @param {string} collectionKey Primitive collection key.
1022
+ * @param {object[]} primitives Primitive records.
1023
+ * @param {object} semanticContext Semantic lookup context.
1024
+ * @returns {object[]}
1025
+ */
1026
+ static #semanticMetadataEntries(
1027
+ primitiveKind,
1028
+ collectionKey,
1029
+ primitives,
1030
+ semanticContext
1031
+ ) {
1032
+ return (primitives || []).map((primitive, fallbackIndex) => {
1033
+ const index = PcbSvgRenderer.#primitiveIndex(
1034
+ semanticContext,
1035
+ collectionKey,
1036
+ primitive,
1037
+ fallbackIndex
1038
+ )
1039
+ const layer = PcbSvgRenderer.#layerForPrimitive(
1040
+ primitive,
1041
+ semanticContext
1042
+ )
1043
+ const netName = PcbSvgRenderer.#netNameForPrimitive(
1044
+ primitive,
1045
+ semanticContext
1046
+ )
1047
+ const component = PcbSvgRenderer.#componentForPrimitive(
1048
+ primitive,
1049
+ semanticContext
1050
+ )
1051
+
1052
+ return PcbSvgRenderer.#stripEmptySemanticObject({
1053
+ elementKey: 'pcb-' + primitiveKind + '-' + index,
1054
+ primitive: primitiveKind,
1055
+ layerKey: layer?.layerKey,
1056
+ layerDisplayName: layer?.displayName,
1057
+ net: netName,
1058
+ netClasses: PcbSvgRenderer.#netClassNames(
1059
+ netName,
1060
+ semanticContext
1061
+ ),
1062
+ component: component?.designator,
1063
+ componentIndex: Number.isInteger(
1064
+ Number(primitive?.componentIndex)
1065
+ )
1066
+ ? Number(primitive.componentIndex)
1067
+ : undefined,
1068
+ padNumber:
1069
+ primitiveKind === 'pad'
1070
+ ? PcbSvgRenderer.#padNumber(primitive)
1071
+ : undefined,
1072
+ textRole:
1073
+ primitiveKind === 'text'
1074
+ ? primitive?.role || primitive?.textRole
1075
+ : undefined
1076
+ })
1077
+ })
1078
+ }
1079
+
1080
+ /**
1081
+ * Renders semantic data attributes for one SVG element.
1082
+ * @param {string} primitiveKind Public primitive kind.
1083
+ * @param {object} primitive Primitive record.
1084
+ * @param {number} index Stable primitive index.
1085
+ * @param {object} semanticContext Semantic lookup context.
1086
+ * @returns {string}
1087
+ */
1088
+ static #semanticAttributes(
1089
+ primitiveKind,
1090
+ primitive,
1091
+ index,
1092
+ semanticContext
1093
+ ) {
1094
+ const layer = PcbSvgRenderer.#layerForPrimitive(
1095
+ primitive,
1096
+ semanticContext
1097
+ )
1098
+ const netName = PcbSvgRenderer.#netNameForPrimitive(
1099
+ primitive,
1100
+ semanticContext
1101
+ )
1102
+ const component = PcbSvgRenderer.#componentForPrimitive(
1103
+ primitive,
1104
+ semanticContext
1105
+ )
1106
+ const netClasses = PcbSvgRenderer.#netClassNames(
1107
+ netName,
1108
+ semanticContext
1109
+ )
1110
+
1111
+ return PcbSvgRenderer.#renderDataAttributes({
1112
+ 'data-primitive': primitiveKind,
1113
+ 'data-element-key': 'pcb-' + primitiveKind + '-' + index,
1114
+ 'data-layer-key': layer?.layerKey,
1115
+ 'data-layer-display-name': layer?.displayName,
1116
+ 'data-layer-id': layer?.layerId,
1117
+ 'data-net': netName,
1118
+ 'data-net-index': primitive?.netIndex,
1119
+ 'data-net-class': netClasses[0],
1120
+ 'data-net-classes': netClasses.length > 1 ? netClasses : undefined,
1121
+ 'data-component': component?.designator,
1122
+ 'data-component-index': component?.componentIndex,
1123
+ 'data-component-unique-id': component?.uniqueId,
1124
+ 'data-pad-number':
1125
+ primitiveKind === 'pad'
1126
+ ? PcbSvgRenderer.#padNumber(primitive)
1127
+ : undefined
1128
+ })
1129
+ }
1130
+
1131
+ /**
1132
+ * Inserts generated attributes into a simple SVG element string.
1133
+ * @param {string} markup SVG element markup.
1134
+ * @param {string} attributes Rendered attributes.
1135
+ * @returns {string}
1136
+ */
1137
+ static #appendSvgAttributes(markup, attributes) {
1138
+ if (!attributes) {
1139
+ return markup
1140
+ }
1141
+
1142
+ return String(markup).replace(/(\s*\/?>)/u, attributes + '$1')
1143
+ }
1144
+
1145
+ /**
1146
+ * Returns a stable primitive index from the original source collection.
1147
+ * @param {object} semanticContext Semantic lookup context.
1148
+ * @param {string} collectionKey Primitive collection key.
1149
+ * @param {object} primitive Primitive record.
1150
+ * @param {number} fallbackIndex Index in the rendered collection.
1151
+ * @returns {number}
1152
+ */
1153
+ static #primitiveIndex(
1154
+ semanticContext,
1155
+ collectionKey,
1156
+ primitive,
1157
+ fallbackIndex
1158
+ ) {
1159
+ const resolved =
1160
+ semanticContext.primitiveIndexes?.[collectionKey]?.get(primitive)
1161
+
1162
+ return Number.isInteger(resolved) ? resolved : fallbackIndex
1163
+ }
1164
+
1165
+ /**
1166
+ * Renders a dictionary as SVG data attributes.
1167
+ * @param {Record<string, unknown>} attributes Attribute dictionary.
1168
+ * @returns {string}
1169
+ */
1170
+ static #renderDataAttributes(attributes) {
1171
+ return Object.entries(attributes || {})
1172
+ .filter(([, value]) => {
1173
+ if (Array.isArray(value)) {
1174
+ return value.length > 0
1175
+ }
1176
+ return value !== null && value !== undefined && value !== ''
1177
+ })
1178
+ .map(([name, value]) => {
1179
+ const renderedValue = Array.isArray(value)
1180
+ ? value.join(',')
1181
+ : String(value)
1182
+ return (
1183
+ ' ' +
1184
+ name +
1185
+ '="' +
1186
+ SchematicSvgUtils.escapeHtml(renderedValue) +
1187
+ '"'
1188
+ )
1189
+ })
1190
+ .join('')
1191
+ }
1192
+
1193
+ /**
1194
+ * Builds an object identity map for stable primitive indexes.
1195
+ * @param {object[]} records Primitive records.
1196
+ * @returns {Map<object, number>}
1197
+ */
1198
+ static #objectIndexMap(records) {
1199
+ return new Map((records || []).map((record, index) => [record, index]))
1200
+ }
1201
+
1202
+ /**
1203
+ * Builds a component lookup keyed by native component index.
1204
+ * @param {object[]} components Component records.
1205
+ * @returns {Map<number, object>}
1206
+ */
1207
+ static #componentIndexMap(components) {
1208
+ const componentsByIndex = new Map()
1209
+
1210
+ for (const component of components || []) {
1211
+ const componentIndex = Number(component?.componentIndex)
1212
+ if (Number.isInteger(componentIndex)) {
1213
+ componentsByIndex.set(componentIndex, component)
1214
+ }
1215
+ }
1216
+
1217
+ return componentsByIndex
1218
+ }
1219
+
1220
+ /**
1221
+ * Resolves a normalized layer descriptor from a layer record.
1222
+ * @param {object} layer Layer record.
1223
+ * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
1224
+ */
1225
+ static #layerDescriptor(layer) {
1226
+ if (!layer || typeof layer !== 'object') {
1227
+ return null
1228
+ }
1229
+
1230
+ const layerId = PcbSvgRenderer.#firstFiniteNumber([
1231
+ layer.layerId,
1232
+ layer.layerCode,
1233
+ layer.id,
1234
+ layer.index
1235
+ ])
1236
+ const displayName =
1237
+ layer.displayName || layer.name || layer.layerName || ''
1238
+ const layerKey = Number.isInteger(layerId)
1239
+ ? 'L' + layerId
1240
+ : PcbSvgRenderer.#normalizeSemanticLookup(displayName)
1241
+
1242
+ if (!layerKey && !displayName) {
1243
+ return null
1244
+ }
1245
+
1246
+ return PcbSvgRenderer.#stripEmptySemanticObject({
1247
+ layerId,
1248
+ layerKey,
1249
+ displayName: displayName || layerKey,
1250
+ role:
1251
+ layer.role ||
1252
+ layer.layerRole ||
1253
+ PcbSvgRenderer.#inferLayerRole(displayName)
1254
+ })
1255
+ }
1256
+
1257
+ /**
1258
+ * Returns included layer ids from semantic context.
1259
+ * @param {object} semanticContext Semantic lookup context.
1260
+ * @returns {number[]}
1261
+ */
1262
+ static #includedLayerIds(semanticContext) {
1263
+ return (semanticContext?.layerDescriptors || [])
1264
+ .map((layer) => layer.layerId)
1265
+ .filter((layerId) => Number.isInteger(layerId))
1266
+ }
1267
+
1268
+ /**
1269
+ * Returns true when a PCB view contains only board outline metadata.
1270
+ * @param {object} pcb Normalized PCB model.
1271
+ * @returns {boolean}
1272
+ */
1273
+ static #isBoardOutlineOnly(pcb) {
1274
+ return [
1275
+ 'polygons',
1276
+ 'fills',
1277
+ 'tracks',
1278
+ 'arcs',
1279
+ 'vias',
1280
+ 'pads',
1281
+ 'texts',
1282
+ 'components',
1283
+ 'regions',
1284
+ 'shapeBasedRegions'
1285
+ ].every((key) => !Array.isArray(pcb?.[key]) || pcb[key].length === 0)
1286
+ }
1287
+
1288
+ /**
1289
+ * Infers one broad rendering role from a layer name.
1290
+ * @param {string} displayName Layer display name.
1291
+ * @returns {string}
1292
+ */
1293
+ static #inferLayerRole(displayName) {
1294
+ const normalized = String(displayName || '').toLowerCase()
1295
+ if (/overlay|silk/u.test(normalized)) return 'overlay'
1296
+ if (/paste/u.test(normalized)) return 'paste'
1297
+ if (/mask/u.test(normalized)) return 'mask'
1298
+ if (/mechanical|dimension|outline/u.test(normalized)) {
1299
+ return 'mechanical'
1300
+ }
1301
+ if (/drill/u.test(normalized)) return 'drill'
1302
+ if (/layer|copper|plane/u.test(normalized)) return 'copper'
1303
+ return 'other'
1304
+ }
1305
+
1306
+ /**
1307
+ * Resolves drill plating metadata for SVG and sidecar output.
1308
+ * @param {object} primitive Drill owner primitive.
1309
+ * @returns {'plated' | 'non-plated'}
1310
+ */
1311
+ static #drillPlating(primitive) {
1312
+ return primitive?.isPlated === false ? 'non-plated' : 'plated'
1313
+ }
1314
+
1315
+ /**
1316
+ * Resolves the visible drill state from explicit metadata and
1317
+ * via-protection features.
1318
+ * @param {object} primitive Drill owner primitive.
1319
+ * @returns {'open' | 'covered' | 'filled' | 'capped'}
1320
+ */
1321
+ static #drillRenderState(primitive) {
1322
+ const explicit =
1323
+ primitive?.drillRenderState ||
1324
+ primitive?.renderState ||
1325
+ primitive?.drill?.renderState
1326
+ if (explicit) {
1327
+ return PcbSvgRenderer.#normalizeDrillRenderState(explicit)
1328
+ }
1329
+
1330
+ const featureText = (primitive?.viaProtection?.features || [])
1331
+ .flatMap((feature) => [feature.type, feature.material])
1332
+ .join(' ')
1333
+ .toLowerCase()
1334
+
1335
+ if (/cap/u.test(featureText)) return 'capped'
1336
+ if (/fill|plug/u.test(featureText)) return 'filled'
1337
+ if (/cover|tent|mask/u.test(featureText)) return 'covered'
1338
+
1339
+ const ipcType = Number(
1340
+ primitive?.ipc4761Type ?? primitive?.viaProtection?.ipc4761Type
1341
+ )
1342
+ if (ipcType === 6 || ipcType === 7) return 'capped'
1343
+ if (ipcType === 3 || ipcType === 4 || ipcType === 5) return 'filled'
1344
+ if (ipcType === 1 || ipcType === 2) return 'covered'
1345
+
1346
+ return 'open'
1347
+ }
1348
+
1349
+ /**
1350
+ * Normalizes a drill render-state label.
1351
+ * @param {unknown} value Raw state label.
1352
+ * @returns {'open' | 'covered' | 'filled' | 'capped'}
1353
+ */
1354
+ static #normalizeDrillRenderState(value) {
1355
+ const normalized = String(value || '').toLowerCase()
1356
+ if (/cap/u.test(normalized)) return 'capped'
1357
+ if (/fill|plug/u.test(normalized)) return 'filled'
1358
+ if (/cover|tent|mask/u.test(normalized)) return 'covered'
1359
+ return 'open'
1360
+ }
1361
+
1362
+ /**
1363
+ * Resolves a layer descriptor for one primitive.
1364
+ * @param {object} primitive Primitive record.
1365
+ * @param {object} semanticContext Semantic lookup context.
1366
+ * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
1367
+ */
1368
+ static #layerForPrimitive(primitive, semanticContext) {
1369
+ const layerId = PcbSvgRenderer.#firstFiniteNumber([
1370
+ primitive?.layerId,
1371
+ primitive?.layerCode
1372
+ ])
1373
+ if (Number.isInteger(layerId)) {
1374
+ return (
1375
+ semanticContext.layersById.get(layerId) || {
1376
+ layerId,
1377
+ layerKey: 'L' + layerId,
1378
+ displayName:
1379
+ primitive?.layerName ||
1380
+ primitive?.layer ||
1381
+ 'Layer ' + layerId
1382
+ }
1383
+ )
1384
+ }
1385
+
1386
+ const layerName =
1387
+ primitive?.layerName || primitive?.layer || primitive?.side || ''
1388
+ const byName = semanticContext.layersByName.get(
1389
+ PcbSvgRenderer.#normalizeSemanticLookup(layerName)
1390
+ )
1391
+
1392
+ return byName || null
1393
+ }
1394
+
1395
+ /**
1396
+ * Resolves a net name for one primitive.
1397
+ * @param {object} primitive Primitive record.
1398
+ * @param {object} semanticContext Semantic lookup context.
1399
+ * @returns {string}
1400
+ */
1401
+ static #netNameForPrimitive(primitive, semanticContext) {
1402
+ if (primitive?.netName) {
1403
+ return String(primitive.netName)
1404
+ }
1405
+
1406
+ const netIndex = Number(primitive?.netIndex)
1407
+ if (Number.isInteger(netIndex)) {
1408
+ return semanticContext.netByIndex.get(netIndex)?.name || ''
1409
+ }
1410
+
1411
+ return ''
1412
+ }
1413
+
1414
+ /**
1415
+ * Resolves a component owner for one primitive.
1416
+ * @param {object} primitive Primitive record.
1417
+ * @param {object} semanticContext Semantic lookup context.
1418
+ * @returns {object | null}
1419
+ */
1420
+ static #componentForPrimitive(primitive, semanticContext) {
1421
+ if (primitive?.designator && primitive?.pattern) {
1422
+ return primitive
1423
+ }
1424
+
1425
+ const componentIndex = Number(primitive?.componentIndex)
1426
+ if (Number.isInteger(componentIndex)) {
1427
+ return semanticContext.componentsByIndex.get(componentIndex) || null
1428
+ }
1429
+
1430
+ return null
1431
+ }
1432
+
1433
+ /**
1434
+ * Returns class names for one net name.
1435
+ * @param {string} netName Net name.
1436
+ * @param {object} semanticContext Semantic lookup context.
1437
+ * @returns {string[]}
1438
+ */
1439
+ static #netClassNames(netName, semanticContext) {
1440
+ return netName
1441
+ ? semanticContext.netClassNamesByNetName.get(netName) || []
1442
+ : []
1443
+ }
1444
+
1445
+ /**
1446
+ * Returns true when a class record describes nets.
1447
+ * @param {object} classRecord Class record.
1448
+ * @returns {boolean}
1449
+ */
1450
+ static #isNetClass(classRecord) {
1451
+ return (
1452
+ classRecord?.kindName === 'net' || Number(classRecord?.kind) === 0
1453
+ )
1454
+ }
1455
+
1456
+ /**
1457
+ * Returns a pad number-like label from pad metadata.
1458
+ * @param {object} pad Pad record.
1459
+ * @returns {string}
1460
+ */
1461
+ static #padNumber(pad) {
1462
+ return String(
1463
+ pad?.padNumber || pad?.designator || pad?.number || pad?.name || ''
1464
+ )
1465
+ }
1466
+
1467
+ /**
1468
+ * Returns the first finite numeric value from a list.
1469
+ * @param {unknown[]} values Candidate values.
1470
+ * @returns {number | undefined}
1471
+ */
1472
+ static #firstFiniteNumber(values) {
1473
+ for (const value of values) {
1474
+ const parsed = Number(value)
1475
+ if (Number.isFinite(parsed)) {
1476
+ return parsed
1477
+ }
1478
+ }
1479
+
1480
+ return undefined
1481
+ }
1482
+
1483
+ /**
1484
+ * Builds a case-insensitive lookup key for semantic names.
1485
+ * @param {unknown} value Raw value.
1486
+ * @returns {string}
1487
+ */
1488
+ static #normalizeSemanticLookup(value) {
1489
+ return String(value || '')
1490
+ .trim()
1491
+ .toUpperCase()
1492
+ }
1493
+
1494
+ /**
1495
+ * Removes empty fields from a semantic metadata object.
1496
+ * @param {Record<string, unknown>} value Metadata object.
1497
+ * @returns {Record<string, unknown>}
1498
+ */
1499
+ static #stripEmptySemanticObject(value) {
1500
+ return Object.fromEntries(
1501
+ Object.entries(value || {}).filter(([, entryValue]) => {
1502
+ if (Array.isArray(entryValue)) {
1503
+ return entryValue.length > 0
1504
+ }
1505
+ return (
1506
+ entryValue !== null &&
1507
+ entryValue !== undefined &&
1508
+ entryValue !== ''
1509
+ )
1510
+ })
1511
+ )
1512
+ }
1513
+
390
1514
  /**
391
1515
  * Builds a best-effort board path from outline segments.
392
1516
  * @param {Array<Record<string, number | string>>} segments
@@ -672,9 +1796,11 @@ export class PcbSvgRenderer {
672
1796
  /**
673
1797
  * Renders one through-hole pad as SVG.
674
1798
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, shapeTop?: number, rotation?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null, offsetTopX?: number, offsetTopY?: number, hasRoundedRect?: boolean, roundedRectShapeTop?: number | null, cornerRadiusTop?: number | null }} pad
1799
+ * @param {number} index Stable pad index.
1800
+ * @param {object} semanticContext Semantic lookup context.
675
1801
  * @returns {string}
676
1802
  */
677
- static #renderPad(pad) {
1803
+ static #renderPad(pad, index, semanticContext) {
678
1804
  const size = PcbSvgRenderer.#resolvePadSurfaceSize(pad)
679
1805
  const padIsCircular = PcbSvgRenderer.#isCircularPad(pad, size)
680
1806
  const ringRadius = Math.max(Math.max(size.width, size.height) / 2, 0.6)
@@ -702,7 +1828,7 @@ export class PcbSvgRenderer {
702
1828
  PcbSvgRenderer.#resolvePadCornerRadius(pad, size)
703
1829
  ) +
704
1830
  '" />'
705
- const holeMarkup = PcbSvgRenderer.#renderPadHole(pad)
1831
+ const holeMarkup = PcbSvgRenderer.#renderPadHole(pad, index)
706
1832
 
707
1833
  return (
708
1834
  '<g class="pcb-pad pcb-pad--' +
@@ -715,7 +1841,14 @@ export class PcbSvgRenderer {
715
1841
  SchematicSvgUtils.formatNumber(pad.y) +
716
1842
  ') rotate(' +
717
1843
  SchematicSvgUtils.formatNumber(Number(pad.rotation || 0)) +
718
- ')">' +
1844
+ ')"' +
1845
+ PcbSvgRenderer.#semanticAttributes(
1846
+ 'pad',
1847
+ pad,
1848
+ index,
1849
+ semanticContext
1850
+ ) +
1851
+ '>' +
719
1852
  ringMarkup +
720
1853
  holeMarkup +
721
1854
  '</g>'
@@ -725,9 +1858,10 @@ export class PcbSvgRenderer {
725
1858
  /**
726
1859
  * Renders one pad drill hole as SVG.
727
1860
  * @param {{ holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null }} pad
1861
+ * @param {number} index Stable pad index.
728
1862
  * @returns {string}
729
1863
  */
730
- static #renderPadHole(pad) {
1864
+ static #renderPadHole(pad, index) {
731
1865
  if (Number(pad.holeDiameter || 0) <= 0) {
732
1866
  return ''
733
1867
  }
@@ -755,7 +1889,17 @@ export class PcbSvgRenderer {
755
1889
  SchematicSvgUtils.formatNumber(holeDiameter) +
756
1890
  '" rx="' +
757
1891
  SchematicSvgUtils.formatNumber(holeRadius) +
758
- '" />' +
1892
+ '"' +
1893
+ PcbSvgRenderer.#renderDataAttributes({
1894
+ 'data-primitive': 'pad-hole',
1895
+ 'data-element-key': 'pcb-pad-hole-' + index,
1896
+ 'data-hole-owner': 'pad',
1897
+ 'data-hole-kind': 'pad',
1898
+ 'data-plating': PcbSvgRenderer.#drillPlating(pad),
1899
+ 'data-drill-render-state':
1900
+ PcbSvgRenderer.#drillRenderState(pad)
1901
+ }) +
1902
+ ' />' +
759
1903
  '</g>'
760
1904
  )
761
1905
  }
@@ -763,7 +1907,16 @@ export class PcbSvgRenderer {
763
1907
  return (
764
1908
  '<circle class="pcb-pad__hole" cx="0" cy="0" r="' +
765
1909
  SchematicSvgUtils.formatNumber(holeRadius) +
766
- '" />'
1910
+ '"' +
1911
+ PcbSvgRenderer.#renderDataAttributes({
1912
+ 'data-primitive': 'pad-hole',
1913
+ 'data-element-key': 'pcb-pad-hole-' + index,
1914
+ 'data-hole-owner': 'pad',
1915
+ 'data-hole-kind': 'pad',
1916
+ 'data-plating': PcbSvgRenderer.#drillPlating(pad),
1917
+ 'data-drill-render-state': PcbSvgRenderer.#drillRenderState(pad)
1918
+ }) +
1919
+ ' />'
767
1920
  )
768
1921
  }
769
1922