altium-toolkit 1.0.8 → 1.0.10

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 (102) 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/ci_artifact_bundle_a1.schema.json +76 -0
  5. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  6. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
  7. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
  8. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  9. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
  10. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
  11. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  12. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  13. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  14. package/docs/testing.md +9 -3
  15. package/package.json +1 -1
  16. package/spec/library-scope.md +7 -1
  17. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  18. package/src/core/altium/AltiumParser.mjs +196 -45
  19. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  20. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  21. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  22. package/src/core/altium/IntLibModelParser.mjs +240 -0
  23. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  24. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  25. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  26. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  27. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  28. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  29. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  30. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  31. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  32. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  33. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  34. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  35. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  36. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  37. package/src/core/altium/PcbModelParser.mjs +495 -32
  38. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  39. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  40. package/src/core/altium/PcbPadStackParser.mjs +229 -2
  41. package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
  42. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  43. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  44. package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
  45. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  46. package/src/core/altium/PcbRuleParser.mjs +354 -33
  47. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  48. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  49. package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
  50. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  51. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  52. package/src/core/altium/PcbUnionParser.mjs +307 -0
  53. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  54. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  55. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  56. package/src/core/altium/PrjPcbModelParser.mjs +281 -7
  57. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  58. package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
  59. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  60. package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
  61. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  62. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  63. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  64. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  65. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  66. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  67. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  68. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  69. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  70. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  71. package/src/core/altium/SchematicImageParser.mjs +474 -3
  72. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  73. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  74. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  75. package/src/core/altium/SchematicPinParser.mjs +84 -1
  76. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  77. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  78. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  79. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  80. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  81. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  82. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  83. package/src/core/altium/SchematicTextParser.mjs +123 -0
  84. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  85. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  86. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  87. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  88. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  89. package/src/parser.mjs +35 -0
  90. package/src/styles/altium-renderers.css +19 -0
  91. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  92. package/src/ui/PcbInteractionIndex.mjs +9 -4
  93. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  94. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  95. package/src/ui/PcbSvgRenderer.mjs +1252 -34
  96. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  97. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  98. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  99. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  100. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  101. package/src/ui/SchematicTypography.mjs +48 -5
  102. 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 pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts" 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,1046 @@ export class PcbSvgRenderer {
380
536
  '>' +
381
537
  textMarkup +
382
538
  '</g>' +
383
- '<path class="board-outline board-outline--stroke pcb-layer pcb-layer--edge-cuts" data-layer-name="Edge.Cuts" 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
+ lookups: PcbSvgRenderer.#buildSemanticLookups(pcb, semanticContext),
833
+ boardOutline: {
834
+ feature: 'board-outline',
835
+ elementKeys: ['pcb-board-outline', 'pcb-board-outline-stroke']
836
+ },
837
+ layers: semanticContext.layerDescriptors,
838
+ elements: [
839
+ ...PcbSvgRenderer.#semanticMetadataEntries(
840
+ 'polygon',
841
+ 'polygons',
842
+ pcb?.polygons || [],
843
+ semanticContext
844
+ ),
845
+ ...PcbSvgRenderer.#semanticMetadataEntries(
846
+ 'fill',
847
+ 'fills',
848
+ pcb?.fills || [],
849
+ semanticContext
850
+ ),
851
+ ...PcbSvgRenderer.#semanticMetadataEntries(
852
+ 'track',
853
+ 'tracks',
854
+ pcb?.tracks || [],
855
+ semanticContext
856
+ ),
857
+ ...PcbSvgRenderer.#semanticMetadataEntries(
858
+ 'arc',
859
+ 'arcs',
860
+ pcb?.arcs || [],
861
+ semanticContext
862
+ ),
863
+ ...PcbSvgRenderer.#semanticMetadataEntries(
864
+ 'via',
865
+ 'vias',
866
+ pcb?.vias || [],
867
+ semanticContext
868
+ ),
869
+ ...PcbSvgRenderer.#semanticMetadataEntries(
870
+ 'pad',
871
+ 'pads',
872
+ pcb?.pads || [],
873
+ semanticContext
874
+ ),
875
+ ...PcbSvgRenderer.#semanticMetadataEntries(
876
+ 'text',
877
+ 'texts',
878
+ pcb?.texts || [],
879
+ semanticContext
880
+ ),
881
+ ...PcbSvgRenderer.#semanticMetadataEntries(
882
+ 'component',
883
+ 'components',
884
+ pcb?.components || [],
885
+ semanticContext
886
+ )
887
+ ]
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Builds stable lookup maps for semantic SVG consumers.
893
+ * @param {object} pcb Normalized PCB model.
894
+ * @param {object} semanticContext Semantic lookup context.
895
+ * @returns {object}
896
+ */
897
+ static #buildSemanticLookups(pcb, semanticContext) {
898
+ const netsByIndex = {}
899
+ const netIndexByName = {}
900
+ const netClassesByName = {}
901
+ const componentsByIndex = {}
902
+ const componentIndexByDesignator = {}
903
+ const layersByKey = {}
904
+ const layerKeyByDisplayName = {}
905
+
906
+ for (const net of pcb?.nets || []) {
907
+ const netIndex = Number(net?.netIndex)
908
+ if (Number.isInteger(netIndex) && net?.name) {
909
+ netsByIndex[netIndex] = net.name
910
+ netIndexByName[net.name] = netIndex
911
+ }
912
+ }
913
+
914
+ for (const [
915
+ netName,
916
+ classNames
917
+ ] of semanticContext.netClassNamesByNetName) {
918
+ netClassesByName[netName] = [...classNames].sort((left, right) =>
919
+ left.localeCompare(right, undefined, { numeric: true })
920
+ )
921
+ }
922
+
923
+ for (const [
924
+ componentIndex,
925
+ component
926
+ ] of semanticContext.componentsByIndex) {
927
+ componentsByIndex[componentIndex] =
928
+ PcbSvgRenderer.#stripEmptySemanticObject({
929
+ designator: component.designator,
930
+ uniqueId: component.uniqueId,
931
+ pattern: component.pattern
932
+ })
933
+ if (component.designator) {
934
+ componentIndexByDesignator[component.designator] =
935
+ componentIndex
936
+ }
937
+ }
938
+
939
+ for (const layer of semanticContext.layerDescriptors) {
940
+ layersByKey[layer.layerKey] = layer
941
+ layerKeyByDisplayName[layer.displayName] = layer.layerKey
942
+ }
943
+
944
+ return {
945
+ netsByIndex,
946
+ netIndexByName,
947
+ netClassesByName,
948
+ componentsByIndex,
949
+ componentIndexByDesignator,
950
+ layersByKey,
951
+ layerKeyByDisplayName
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Builds metadata for the rendered PCB view.
957
+ * @param {object} pcb Normalized PCB model.
958
+ * @param {object} semanticContext Semantic lookup context.
959
+ * @returns {object}
960
+ */
961
+ static #buildViewMetadata(pcb, semanticContext) {
962
+ return {
963
+ kind: semanticContext.viewKind || 'top-composite',
964
+ board: PcbSvgRenderer.#buildBoardViewMetadata(pcb),
965
+ layerSet: {
966
+ includedLayerIds:
967
+ PcbSvgRenderer.#includedLayerIds(semanticContext),
968
+ layerView: semanticContext.layerView || undefined,
969
+ roles: semanticContext.layerDescriptors.map((layer) =>
970
+ PcbSvgRenderer.#stripEmptySemanticObject({
971
+ layerId: layer.layerId,
972
+ layerKey: layer.layerKey,
973
+ displayName: layer.displayName,
974
+ role: layer.role
975
+ })
976
+ )
977
+ },
978
+ cutouts: PcbSvgRenderer.#boardCutoutMetadata(pcb),
979
+ drills: [
980
+ ...(pcb?.vias || [])
981
+ .filter((via) => Number(via?.holeDiameter || 0) > 0)
982
+ .map((via, index) =>
983
+ PcbSvgRenderer.#drillDescriptor(
984
+ 'via',
985
+ via,
986
+ 'pcb-via-hole-' +
987
+ PcbSvgRenderer.#primitiveIndex(
988
+ semanticContext,
989
+ 'vias',
990
+ via,
991
+ index
992
+ )
993
+ )
994
+ ),
995
+ ...(pcb?.pads || [])
996
+ .filter((pad) => Number(pad?.holeDiameter || 0) > 0)
997
+ .map((pad, index) =>
998
+ PcbSvgRenderer.#drillDescriptor(
999
+ 'pad',
1000
+ pad,
1001
+ 'pcb-pad-hole-' +
1002
+ PcbSvgRenderer.#primitiveIndex(
1003
+ semanticContext,
1004
+ 'pads',
1005
+ pad,
1006
+ index
1007
+ )
1008
+ )
1009
+ )
1010
+ ]
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * Builds board-level view metadata.
1016
+ * @param {object} pcb Normalized PCB model.
1017
+ * @returns {object}
1018
+ */
1019
+ static #buildBoardViewMetadata(pcb) {
1020
+ const outline = pcb?.boardOutline || {}
1021
+ const minX = Number(outline.minX || 0)
1022
+ const minY = Number(outline.minY || 0)
1023
+ const width = Number(outline.widthMil || 0)
1024
+ const height = Number(outline.heightMil || 0)
1025
+
1026
+ return {
1027
+ elementKey: 'pcb-board-outline',
1028
+ outlineOnly: PcbSvgRenderer.#isBoardOutlineOnly(pcb),
1029
+ centroid: {
1030
+ x: minX + width / 2,
1031
+ y: minY + height / 2
1032
+ },
1033
+ bounds: {
1034
+ minX,
1035
+ minY,
1036
+ maxX: minX + width,
1037
+ maxY: minY + height
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Builds board cutout sidecar entries.
1044
+ * @param {object} pcb Normalized PCB model.
1045
+ * @returns {object[]}
1046
+ */
1047
+ static #boardCutoutMetadata(pcb) {
1048
+ const outlineCutouts = Array.isArray(pcb?.boardOutline?.cutouts)
1049
+ ? pcb.boardOutline.cutouts
1050
+ : []
1051
+ const regionCutouts = (pcb?.boardRegions || []).filter(
1052
+ (region) => region?.isBoardCutout === true
1053
+ )
1054
+
1055
+ return [...outlineCutouts, ...regionCutouts].map((cutout, index) =>
1056
+ PcbSvgRenderer.#stripEmptySemanticObject({
1057
+ id: cutout.id || cutout.uniqueId || 'cutout-' + index,
1058
+ kind: cutout.kind || 'board-cutout',
1059
+ elementKey: 'pcb-board-cutout-' + index
1060
+ })
1061
+ )
1062
+ }
1063
+
1064
+ /**
1065
+ * Builds one drill sidecar entry.
1066
+ * @param {'pad' | 'via'} owner Drill owner kind.
1067
+ * @param {object} primitive Drill owner primitive.
1068
+ * @param {string} elementKey SVG element key.
1069
+ * @returns {object}
1070
+ */
1071
+ static #drillDescriptor(owner, primitive, elementKey) {
1072
+ return PcbSvgRenderer.#stripEmptySemanticObject({
1073
+ elementKey,
1074
+ owner,
1075
+ holeKind: owner,
1076
+ plating: PcbSvgRenderer.#drillPlating(primitive),
1077
+ renderState: PcbSvgRenderer.#drillRenderState(primitive),
1078
+ ipc4761Type:
1079
+ primitive?.ipc4761Type ?? primitive?.viaProtection?.ipc4761Type
1080
+ })
1081
+ }
1082
+
1083
+ /**
1084
+ * Builds sidecar entries for one primitive collection.
1085
+ * @param {string} primitiveKind Public primitive kind.
1086
+ * @param {string} collectionKey Primitive collection key.
1087
+ * @param {object[]} primitives Primitive records.
1088
+ * @param {object} semanticContext Semantic lookup context.
1089
+ * @returns {object[]}
1090
+ */
1091
+ static #semanticMetadataEntries(
1092
+ primitiveKind,
1093
+ collectionKey,
1094
+ primitives,
1095
+ semanticContext
1096
+ ) {
1097
+ return (primitives || []).map((primitive, fallbackIndex) => {
1098
+ const index = PcbSvgRenderer.#primitiveIndex(
1099
+ semanticContext,
1100
+ collectionKey,
1101
+ primitive,
1102
+ fallbackIndex
1103
+ )
1104
+ const layer = PcbSvgRenderer.#layerForPrimitive(
1105
+ primitive,
1106
+ semanticContext
1107
+ )
1108
+ const netName = PcbSvgRenderer.#netNameForPrimitive(
1109
+ primitive,
1110
+ semanticContext
1111
+ )
1112
+ const component = PcbSvgRenderer.#componentForPrimitive(
1113
+ primitive,
1114
+ semanticContext
1115
+ )
1116
+
1117
+ return PcbSvgRenderer.#stripEmptySemanticObject({
1118
+ elementKey: 'pcb-' + primitiveKind + '-' + index,
1119
+ primitive: primitiveKind,
1120
+ layerKey: layer?.layerKey,
1121
+ layerDisplayName: layer?.displayName,
1122
+ net: netName,
1123
+ netClasses: PcbSvgRenderer.#netClassNames(
1124
+ netName,
1125
+ semanticContext
1126
+ ),
1127
+ component: component?.designator,
1128
+ componentIndex: Number.isInteger(
1129
+ Number(primitive?.componentIndex)
1130
+ )
1131
+ ? Number(primitive.componentIndex)
1132
+ : undefined,
1133
+ padNumber:
1134
+ primitiveKind === 'pad'
1135
+ ? PcbSvgRenderer.#padNumber(primitive)
1136
+ : undefined,
1137
+ textRole:
1138
+ primitiveKind === 'text'
1139
+ ? primitive?.role || primitive?.textRole
1140
+ : undefined
1141
+ })
1142
+ })
1143
+ }
1144
+
1145
+ /**
1146
+ * Renders semantic data attributes for one SVG element.
1147
+ * @param {string} primitiveKind Public primitive kind.
1148
+ * @param {object} primitive Primitive record.
1149
+ * @param {number} index Stable primitive index.
1150
+ * @param {object} semanticContext Semantic lookup context.
1151
+ * @returns {string}
1152
+ */
1153
+ static #semanticAttributes(
1154
+ primitiveKind,
1155
+ primitive,
1156
+ index,
1157
+ semanticContext
1158
+ ) {
1159
+ const layer = PcbSvgRenderer.#layerForPrimitive(
1160
+ primitive,
1161
+ semanticContext
1162
+ )
1163
+ const netName = PcbSvgRenderer.#netNameForPrimitive(
1164
+ primitive,
1165
+ semanticContext
1166
+ )
1167
+ const component = PcbSvgRenderer.#componentForPrimitive(
1168
+ primitive,
1169
+ semanticContext
1170
+ )
1171
+ const netClasses = PcbSvgRenderer.#netClassNames(
1172
+ netName,
1173
+ semanticContext
1174
+ )
1175
+
1176
+ return PcbSvgRenderer.#renderDataAttributes({
1177
+ 'data-primitive': primitiveKind,
1178
+ 'data-element-key': 'pcb-' + primitiveKind + '-' + index,
1179
+ 'data-layer-key': layer?.layerKey,
1180
+ 'data-layer-display-name': layer?.displayName,
1181
+ 'data-layer-id': layer?.layerId,
1182
+ 'data-net': netName,
1183
+ 'data-net-index': primitive?.netIndex,
1184
+ 'data-net-class': netClasses[0],
1185
+ 'data-net-classes': netClasses.length > 1 ? netClasses : undefined,
1186
+ 'data-component': component?.designator,
1187
+ 'data-component-index': component?.componentIndex,
1188
+ 'data-component-unique-id': component?.uniqueId,
1189
+ 'data-pad-number':
1190
+ primitiveKind === 'pad'
1191
+ ? PcbSvgRenderer.#padNumber(primitive)
1192
+ : undefined
1193
+ })
1194
+ }
1195
+
1196
+ /**
1197
+ * Inserts generated attributes into a simple SVG element string.
1198
+ * @param {string} markup SVG element markup.
1199
+ * @param {string} attributes Rendered attributes.
1200
+ * @returns {string}
1201
+ */
1202
+ static #appendSvgAttributes(markup, attributes) {
1203
+ if (!attributes) {
1204
+ return markup
1205
+ }
1206
+
1207
+ return String(markup).replace(/(\s*\/?>)/u, attributes + '$1')
1208
+ }
1209
+
1210
+ /**
1211
+ * Returns a stable primitive index from the original source collection.
1212
+ * @param {object} semanticContext Semantic lookup context.
1213
+ * @param {string} collectionKey Primitive collection key.
1214
+ * @param {object} primitive Primitive record.
1215
+ * @param {number} fallbackIndex Index in the rendered collection.
1216
+ * @returns {number}
1217
+ */
1218
+ static #primitiveIndex(
1219
+ semanticContext,
1220
+ collectionKey,
1221
+ primitive,
1222
+ fallbackIndex
1223
+ ) {
1224
+ const resolved =
1225
+ semanticContext.primitiveIndexes?.[collectionKey]?.get(primitive)
1226
+
1227
+ return Number.isInteger(resolved) ? resolved : fallbackIndex
1228
+ }
1229
+
1230
+ /**
1231
+ * Renders a dictionary as SVG data attributes.
1232
+ * @param {Record<string, unknown>} attributes Attribute dictionary.
1233
+ * @returns {string}
1234
+ */
1235
+ static #renderDataAttributes(attributes) {
1236
+ return Object.entries(attributes || {})
1237
+ .filter(([, value]) => {
1238
+ if (Array.isArray(value)) {
1239
+ return value.length > 0
1240
+ }
1241
+ return value !== null && value !== undefined && value !== ''
1242
+ })
1243
+ .map(([name, value]) => {
1244
+ const renderedValue = Array.isArray(value)
1245
+ ? value.join(',')
1246
+ : String(value)
1247
+ return (
1248
+ ' ' +
1249
+ name +
1250
+ '="' +
1251
+ SchematicSvgUtils.escapeHtml(renderedValue) +
1252
+ '"'
1253
+ )
1254
+ })
1255
+ .join('')
1256
+ }
1257
+
1258
+ /**
1259
+ * Builds an object identity map for stable primitive indexes.
1260
+ * @param {object[]} records Primitive records.
1261
+ * @returns {Map<object, number>}
1262
+ */
1263
+ static #objectIndexMap(records) {
1264
+ return new Map((records || []).map((record, index) => [record, index]))
1265
+ }
1266
+
1267
+ /**
1268
+ * Builds a component lookup keyed by native component index.
1269
+ * @param {object[]} components Component records.
1270
+ * @returns {Map<number, object>}
1271
+ */
1272
+ static #componentIndexMap(components) {
1273
+ const componentsByIndex = new Map()
1274
+
1275
+ for (const component of components || []) {
1276
+ const componentIndex = Number(component?.componentIndex)
1277
+ if (Number.isInteger(componentIndex)) {
1278
+ componentsByIndex.set(componentIndex, component)
1279
+ }
1280
+ }
1281
+
1282
+ return componentsByIndex
1283
+ }
1284
+
1285
+ /**
1286
+ * Resolves a normalized layer descriptor from a layer record.
1287
+ * @param {object} layer Layer record.
1288
+ * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
1289
+ */
1290
+ static #layerDescriptor(layer) {
1291
+ if (!layer || typeof layer !== 'object') {
1292
+ return null
1293
+ }
1294
+
1295
+ const layerId = PcbSvgRenderer.#firstFiniteNumber([
1296
+ layer.layerId,
1297
+ layer.layerCode,
1298
+ layer.id,
1299
+ layer.index
1300
+ ])
1301
+ const displayName =
1302
+ layer.displayName || layer.name || layer.layerName || ''
1303
+ const layerKey = Number.isInteger(layerId)
1304
+ ? 'L' + layerId
1305
+ : PcbSvgRenderer.#normalizeSemanticLookup(displayName)
1306
+
1307
+ if (!layerKey && !displayName) {
1308
+ return null
1309
+ }
1310
+
1311
+ return PcbSvgRenderer.#stripEmptySemanticObject({
1312
+ layerId,
1313
+ layerKey,
1314
+ displayName: displayName || layerKey,
1315
+ role:
1316
+ layer.role ||
1317
+ layer.layerRole ||
1318
+ PcbSvgRenderer.#inferLayerRole(displayName)
1319
+ })
1320
+ }
1321
+
1322
+ /**
1323
+ * Returns included layer ids from semantic context.
1324
+ * @param {object} semanticContext Semantic lookup context.
1325
+ * @returns {number[]}
1326
+ */
1327
+ static #includedLayerIds(semanticContext) {
1328
+ return (semanticContext?.layerDescriptors || [])
1329
+ .map((layer) => layer.layerId)
1330
+ .filter((layerId) => Number.isInteger(layerId))
1331
+ }
1332
+
1333
+ /**
1334
+ * Returns true when a PCB view contains only board outline metadata.
1335
+ * @param {object} pcb Normalized PCB model.
1336
+ * @returns {boolean}
1337
+ */
1338
+ static #isBoardOutlineOnly(pcb) {
1339
+ return [
1340
+ 'polygons',
1341
+ 'fills',
1342
+ 'tracks',
1343
+ 'arcs',
1344
+ 'vias',
1345
+ 'pads',
1346
+ 'texts',
1347
+ 'components',
1348
+ 'regions',
1349
+ 'shapeBasedRegions'
1350
+ ].every((key) => !Array.isArray(pcb?.[key]) || pcb[key].length === 0)
1351
+ }
1352
+
1353
+ /**
1354
+ * Infers one broad rendering role from a layer name.
1355
+ * @param {string} displayName Layer display name.
1356
+ * @returns {string}
1357
+ */
1358
+ static #inferLayerRole(displayName) {
1359
+ const normalized = String(displayName || '').toLowerCase()
1360
+ if (/overlay|silk/u.test(normalized)) return 'overlay'
1361
+ if (/paste/u.test(normalized)) return 'paste'
1362
+ if (/mask/u.test(normalized)) return 'mask'
1363
+ if (/mechanical|dimension|outline/u.test(normalized)) {
1364
+ return 'mechanical'
1365
+ }
1366
+ if (/drill/u.test(normalized)) return 'drill'
1367
+ if (/layer|copper|plane/u.test(normalized)) return 'copper'
1368
+ return 'other'
1369
+ }
1370
+
1371
+ /**
1372
+ * Resolves drill plating metadata for SVG and sidecar output.
1373
+ * @param {object} primitive Drill owner primitive.
1374
+ * @returns {'plated' | 'non-plated'}
1375
+ */
1376
+ static #drillPlating(primitive) {
1377
+ return primitive?.isPlated === false ? 'non-plated' : 'plated'
1378
+ }
1379
+
1380
+ /**
1381
+ * Resolves the visible drill state from explicit metadata and
1382
+ * via-protection features.
1383
+ * @param {object} primitive Drill owner primitive.
1384
+ * @returns {'open' | 'covered' | 'filled' | 'capped'}
1385
+ */
1386
+ static #drillRenderState(primitive) {
1387
+ const explicit =
1388
+ primitive?.drillRenderState ||
1389
+ primitive?.renderState ||
1390
+ primitive?.drill?.renderState
1391
+ if (explicit) {
1392
+ return PcbSvgRenderer.#normalizeDrillRenderState(explicit)
1393
+ }
1394
+
1395
+ const featureText = (primitive?.viaProtection?.features || [])
1396
+ .flatMap((feature) => [feature.type, feature.material])
1397
+ .join(' ')
1398
+ .toLowerCase()
1399
+
1400
+ if (/cap/u.test(featureText)) return 'capped'
1401
+ if (/fill|plug/u.test(featureText)) return 'filled'
1402
+ if (/cover|tent|mask/u.test(featureText)) return 'covered'
1403
+
1404
+ const ipcType = Number(
1405
+ primitive?.ipc4761Type ?? primitive?.viaProtection?.ipc4761Type
1406
+ )
1407
+ if (ipcType === 6 || ipcType === 7) return 'capped'
1408
+ if (ipcType === 3 || ipcType === 4 || ipcType === 5) return 'filled'
1409
+ if (ipcType === 1 || ipcType === 2) return 'covered'
1410
+
1411
+ return 'open'
1412
+ }
1413
+
1414
+ /**
1415
+ * Normalizes a drill render-state label.
1416
+ * @param {unknown} value Raw state label.
1417
+ * @returns {'open' | 'covered' | 'filled' | 'capped'}
1418
+ */
1419
+ static #normalizeDrillRenderState(value) {
1420
+ const normalized = String(value || '').toLowerCase()
1421
+ if (/cap/u.test(normalized)) return 'capped'
1422
+ if (/fill|plug/u.test(normalized)) return 'filled'
1423
+ if (/cover|tent|mask/u.test(normalized)) return 'covered'
1424
+ return 'open'
1425
+ }
1426
+
1427
+ /**
1428
+ * Resolves a layer descriptor for one primitive.
1429
+ * @param {object} primitive Primitive record.
1430
+ * @param {object} semanticContext Semantic lookup context.
1431
+ * @returns {{ layerId?: number, layerKey: string, displayName: string } | null}
1432
+ */
1433
+ static #layerForPrimitive(primitive, semanticContext) {
1434
+ const layerId = PcbSvgRenderer.#firstFiniteNumber([
1435
+ primitive?.layerId,
1436
+ primitive?.layerCode
1437
+ ])
1438
+ if (Number.isInteger(layerId)) {
1439
+ return (
1440
+ semanticContext.layersById.get(layerId) || {
1441
+ layerId,
1442
+ layerKey: 'L' + layerId,
1443
+ displayName:
1444
+ primitive?.layerName ||
1445
+ primitive?.layer ||
1446
+ 'Layer ' + layerId
1447
+ }
1448
+ )
1449
+ }
1450
+
1451
+ const layerName =
1452
+ primitive?.layerName || primitive?.layer || primitive?.side || ''
1453
+ const byName = semanticContext.layersByName.get(
1454
+ PcbSvgRenderer.#normalizeSemanticLookup(layerName)
1455
+ )
1456
+
1457
+ return byName || null
1458
+ }
1459
+
1460
+ /**
1461
+ * Resolves a net name for one primitive.
1462
+ * @param {object} primitive Primitive record.
1463
+ * @param {object} semanticContext Semantic lookup context.
1464
+ * @returns {string}
1465
+ */
1466
+ static #netNameForPrimitive(primitive, semanticContext) {
1467
+ if (primitive?.netName) {
1468
+ return String(primitive.netName)
1469
+ }
1470
+
1471
+ const netIndex = Number(primitive?.netIndex)
1472
+ if (Number.isInteger(netIndex)) {
1473
+ return semanticContext.netByIndex.get(netIndex)?.name || ''
1474
+ }
1475
+
1476
+ return ''
1477
+ }
1478
+
1479
+ /**
1480
+ * Resolves a component owner for one primitive.
1481
+ * @param {object} primitive Primitive record.
1482
+ * @param {object} semanticContext Semantic lookup context.
1483
+ * @returns {object | null}
1484
+ */
1485
+ static #componentForPrimitive(primitive, semanticContext) {
1486
+ if (primitive?.designator && primitive?.pattern) {
1487
+ return primitive
1488
+ }
1489
+
1490
+ const componentIndex = Number(primitive?.componentIndex)
1491
+ if (Number.isInteger(componentIndex)) {
1492
+ return semanticContext.componentsByIndex.get(componentIndex) || null
1493
+ }
1494
+
1495
+ return null
1496
+ }
1497
+
1498
+ /**
1499
+ * Returns class names for one net name.
1500
+ * @param {string} netName Net name.
1501
+ * @param {object} semanticContext Semantic lookup context.
1502
+ * @returns {string[]}
1503
+ */
1504
+ static #netClassNames(netName, semanticContext) {
1505
+ return netName
1506
+ ? semanticContext.netClassNamesByNetName.get(netName) || []
1507
+ : []
1508
+ }
1509
+
1510
+ /**
1511
+ * Returns true when a class record describes nets.
1512
+ * @param {object} classRecord Class record.
1513
+ * @returns {boolean}
1514
+ */
1515
+ static #isNetClass(classRecord) {
1516
+ return (
1517
+ classRecord?.kindName === 'net' || Number(classRecord?.kind) === 0
1518
+ )
1519
+ }
1520
+
1521
+ /**
1522
+ * Returns a pad number-like label from pad metadata.
1523
+ * @param {object} pad Pad record.
1524
+ * @returns {string}
1525
+ */
1526
+ static #padNumber(pad) {
1527
+ return String(
1528
+ pad?.padNumber || pad?.designator || pad?.number || pad?.name || ''
1529
+ )
1530
+ }
1531
+
1532
+ /**
1533
+ * Returns the first finite numeric value from a list.
1534
+ * @param {unknown[]} values Candidate values.
1535
+ * @returns {number | undefined}
1536
+ */
1537
+ static #firstFiniteNumber(values) {
1538
+ for (const value of values) {
1539
+ const parsed = Number(value)
1540
+ if (Number.isFinite(parsed)) {
1541
+ return parsed
1542
+ }
1543
+ }
1544
+
1545
+ return undefined
1546
+ }
1547
+
1548
+ /**
1549
+ * Builds a case-insensitive lookup key for semantic names.
1550
+ * @param {unknown} value Raw value.
1551
+ * @returns {string}
1552
+ */
1553
+ static #normalizeSemanticLookup(value) {
1554
+ return String(value || '')
1555
+ .trim()
1556
+ .toUpperCase()
1557
+ }
1558
+
1559
+ /**
1560
+ * Removes empty fields from a semantic metadata object.
1561
+ * @param {Record<string, unknown>} value Metadata object.
1562
+ * @returns {Record<string, unknown>}
1563
+ */
1564
+ static #stripEmptySemanticObject(value) {
1565
+ return Object.fromEntries(
1566
+ Object.entries(value || {}).filter(([, entryValue]) => {
1567
+ if (Array.isArray(entryValue)) {
1568
+ return entryValue.length > 0
1569
+ }
1570
+ return (
1571
+ entryValue !== null &&
1572
+ entryValue !== undefined &&
1573
+ entryValue !== ''
1574
+ )
1575
+ })
1576
+ )
1577
+ }
1578
+
390
1579
  /**
391
1580
  * Builds a best-effort board path from outline segments.
392
1581
  * @param {Array<Record<string, number | string>>} segments
@@ -672,9 +1861,11 @@ export class PcbSvgRenderer {
672
1861
  /**
673
1862
  * Renders one through-hole pad as SVG.
674
1863
  * @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
1864
+ * @param {number} index Stable pad index.
1865
+ * @param {object} semanticContext Semantic lookup context.
675
1866
  * @returns {string}
676
1867
  */
677
- static #renderPad(pad) {
1868
+ static #renderPad(pad, index, semanticContext) {
678
1869
  const size = PcbSvgRenderer.#resolvePadSurfaceSize(pad)
679
1870
  const padIsCircular = PcbSvgRenderer.#isCircularPad(pad, size)
680
1871
  const ringRadius = Math.max(Math.max(size.width, size.height) / 2, 0.6)
@@ -702,7 +1893,7 @@ export class PcbSvgRenderer {
702
1893
  PcbSvgRenderer.#resolvePadCornerRadius(pad, size)
703
1894
  ) +
704
1895
  '" />'
705
- const holeMarkup = PcbSvgRenderer.#renderPadHole(pad)
1896
+ const holeMarkup = PcbSvgRenderer.#renderPadHole(pad, index)
706
1897
 
707
1898
  return (
708
1899
  '<g class="pcb-pad pcb-pad--' +
@@ -715,7 +1906,14 @@ export class PcbSvgRenderer {
715
1906
  SchematicSvgUtils.formatNumber(pad.y) +
716
1907
  ') rotate(' +
717
1908
  SchematicSvgUtils.formatNumber(Number(pad.rotation || 0)) +
718
- ')">' +
1909
+ ')"' +
1910
+ PcbSvgRenderer.#semanticAttributes(
1911
+ 'pad',
1912
+ pad,
1913
+ index,
1914
+ semanticContext
1915
+ ) +
1916
+ '>' +
719
1917
  ringMarkup +
720
1918
  holeMarkup +
721
1919
  '</g>'
@@ -725,9 +1923,10 @@ export class PcbSvgRenderer {
725
1923
  /**
726
1924
  * Renders one pad drill hole as SVG.
727
1925
  * @param {{ holeDiameter?: number, holeShape?: number | null, holeSlotLength?: number | null, holeRotation?: number | null }} pad
1926
+ * @param {number} index Stable pad index.
728
1927
  * @returns {string}
729
1928
  */
730
- static #renderPadHole(pad) {
1929
+ static #renderPadHole(pad, index) {
731
1930
  if (Number(pad.holeDiameter || 0) <= 0) {
732
1931
  return ''
733
1932
  }
@@ -755,7 +1954,17 @@ export class PcbSvgRenderer {
755
1954
  SchematicSvgUtils.formatNumber(holeDiameter) +
756
1955
  '" rx="' +
757
1956
  SchematicSvgUtils.formatNumber(holeRadius) +
758
- '" />' +
1957
+ '"' +
1958
+ PcbSvgRenderer.#renderDataAttributes({
1959
+ 'data-primitive': 'pad-hole',
1960
+ 'data-element-key': 'pcb-pad-hole-' + index,
1961
+ 'data-hole-owner': 'pad',
1962
+ 'data-hole-kind': 'pad',
1963
+ 'data-plating': PcbSvgRenderer.#drillPlating(pad),
1964
+ 'data-drill-render-state':
1965
+ PcbSvgRenderer.#drillRenderState(pad)
1966
+ }) +
1967
+ ' />' +
759
1968
  '</g>'
760
1969
  )
761
1970
  }
@@ -763,7 +1972,16 @@ export class PcbSvgRenderer {
763
1972
  return (
764
1973
  '<circle class="pcb-pad__hole" cx="0" cy="0" r="' +
765
1974
  SchematicSvgUtils.formatNumber(holeRadius) +
766
- '" />'
1975
+ '"' +
1976
+ PcbSvgRenderer.#renderDataAttributes({
1977
+ 'data-primitive': 'pad-hole',
1978
+ 'data-element-key': 'pcb-pad-hole-' + index,
1979
+ 'data-hole-owner': 'pad',
1980
+ 'data-hole-kind': 'pad',
1981
+ 'data-plating': PcbSvgRenderer.#drillPlating(pad),
1982
+ 'data-drill-render-state': PcbSvgRenderer.#drillRenderState(pad)
1983
+ }) +
1984
+ ' />'
767
1985
  )
768
1986
  }
769
1987