circuitjson-toolkit 1.0.3 → 1.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +5 -3
  2. package/README.md +21 -2
  3. package/docs/api.md +50 -4
  4. package/docs/model-format.md +21 -3
  5. package/package.json +3 -2
  6. package/spec/library-scope.md +4 -1
  7. package/src/core/CircuitJsonBomBuilder.mjs +143 -0
  8. package/src/core/CircuitJsonDocument.mjs +46 -13
  9. package/src/core/CircuitJsonElementValidator.mjs +990 -0
  10. package/src/core/CircuitJsonIndexer.mjs +773 -4
  11. package/src/core/CircuitJsonManufacturingBuilder.mjs +898 -0
  12. package/src/core/CircuitJsonManufacturingDownloadBuilder.mjs +196 -0
  13. package/src/core/CircuitJsonParser.mjs +22 -6
  14. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +329 -0
  15. package/src/core/CircuitJsonPcbCopperGeometry.mjs +503 -0
  16. package/src/core/CircuitJsonPcbDrawingStyle.mjs +88 -0
  17. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +172 -0
  18. package/src/core/CircuitJsonPcbNetMetadata.mjs +247 -0
  19. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +70 -0
  20. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +992 -0
  21. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +872 -0
  22. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +233 -0
  23. package/src/core/CircuitJsonPcbPrimitiveGeometry.mjs +142 -0
  24. package/src/core/CircuitJsonPcbPrimitiveGroups.mjs +305 -0
  25. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +65 -0
  26. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +895 -0
  27. package/src/core/CircuitJsonPcbTraceLengthModel.mjs +257 -0
  28. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +683 -0
  29. package/src/core/CircuitJsonSourceMetadata.mjs +233 -0
  30. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +481 -0
  31. package/src/core/CircuitJsonUnits.mjs +133 -8
  32. package/src/core/PcbBoundsSelectionModel.mjs +250 -0
  33. package/src/core/PcbCandidateSelectionModel.mjs +77 -0
  34. package/src/core/PcbDiagnosticFocusModel.mjs +423 -0
  35. package/src/core/PcbInteractionPrimitiveModel.mjs +560 -0
  36. package/src/core/SelectedPartCircuitJsonExportAdapter.mjs +335 -0
  37. package/src/core/spice/SpiceCompatibilityPreprocessor.mjs +139 -0
  38. package/src/core/spice/SpiceDirectiveParser.mjs +231 -0
  39. package/src/core/spice/SpiceFallbackSimulationEngine.mjs +168 -0
  40. package/src/core/spice/SpiceSimulationDiagnostics.mjs +234 -0
  41. package/src/core/spice/SpiceSimulationGraphBuilder.mjs +421 -0
  42. package/src/core/spice/SpiceSimulationGraphSummary.mjs +90 -0
  43. package/src/core/spice/SpiceSimulationService.mjs +92 -0
  44. package/src/core/spice/SpiceTimeSeriesNormalizer.mjs +132 -0
  45. package/src/index.mjs +8 -0
  46. package/src/renderers.mjs +29 -0
  47. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +128 -0
  48. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +964 -0
  49. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +168 -0
  50. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +138 -0
  51. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +114 -0
  52. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +130 -0
  53. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +994 -0
  54. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +439 -0
@@ -0,0 +1,964 @@
1
+ import { PcbInteractionPrimitiveModel } from '../core/PcbInteractionPrimitiveModel.mjs'
2
+ import { CircuitJsonPcbPrimitiveAttributeRenderer } from './CircuitJsonPcbPrimitiveAttributeRenderer.mjs'
3
+ import { CircuitJsonPcbViaSvgRenderer } from './CircuitJsonPcbViaSvgRenderer.mjs'
4
+ /**
5
+ * Renders standards-shaped PCB element arrays into app-compatible SVG.
6
+ */
7
+ export class CircuitJsonPcbSvgRenderer {
8
+ /**
9
+ * Renders one PCB document into SVG markup.
10
+ * @param {object | object[]} documentModel Parsed document model.
11
+ * @param {{ side?: 'top' | 'bottom' }} [options] Render options.
12
+ * @returns {string}
13
+ */
14
+ static render(documentModel, options = {}) {
15
+ const side = options.side === 'bottom' ? 'bottom' : 'top'
16
+ const model = PcbInteractionPrimitiveModel.build(documentModel)
17
+ const viewBox = CircuitJsonPcbSvgRenderer.#viewBox(model.bounds)
18
+ return (
19
+ '<svg class="pcb-svg pcb-svg--app-palette pcb-svg--circuitjson pcb-svg--' +
20
+ side +
21
+ '" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="' +
22
+ CircuitJsonPcbSvgRenderer.#formatViewBox(viewBox) +
23
+ '">' +
24
+ CircuitJsonPcbSvgRenderer.#renderBoard(model) +
25
+ CircuitJsonPcbSvgRenderer.#renderCopper(model, side) +
26
+ CircuitJsonPcbSvgRenderer.#renderComponentLabels(model) +
27
+ CircuitJsonPcbSvgRenderer.#renderOverlays(model, side) +
28
+ '</svg>'
29
+ )
30
+ }
31
+ /**
32
+ * Renders the board substrate.
33
+ * @param {{ primitives: object[] }} model Primitive model.
34
+ * @returns {string}
35
+ */
36
+ static #renderBoard(model) {
37
+ const board = model.primitives.find(
38
+ (primitive) => primitive.kind === 'board'
39
+ )
40
+ if (!board?.bounds) return ''
41
+ if (Array.isArray(board.points) && board.points.length >= 3) {
42
+ return (
43
+ '<polygon class="pcb-board" data-layer="board" points="' +
44
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
45
+ CircuitJsonPcbSvgRenderer.#pointsAttribute(board.points)
46
+ ) +
47
+ '"></polygon>'
48
+ )
49
+ }
50
+ return (
51
+ '<rect class="pcb-board" x="' +
52
+ CircuitJsonPcbSvgRenderer.#formatNumber(board.bounds.minX) +
53
+ '" y="' +
54
+ CircuitJsonPcbSvgRenderer.#formatNumber(board.bounds.minY) +
55
+ '" width="' +
56
+ CircuitJsonPcbSvgRenderer.#formatNumber(board.bounds.width) +
57
+ '" height="' +
58
+ CircuitJsonPcbSvgRenderer.#formatNumber(board.bounds.height) +
59
+ '" rx="' +
60
+ CircuitJsonPcbSvgRenderer.#formatNumber(
61
+ Math.min(board.bounds.width, board.bounds.height) * 0.018
62
+ ) +
63
+ '" data-layer="board"></rect>'
64
+ )
65
+ }
66
+ /**
67
+ * Renders copper and drilled primitives.
68
+ * @param {{ primitives: object[] }} model Primitive model.
69
+ * @param {'top' | 'bottom'} side Active side.
70
+ * @returns {string}
71
+ */
72
+ static #renderCopper(model, side) {
73
+ const surface = []
74
+ const subsurface = []
75
+ for (const primitive of model.primitives) {
76
+ const markup =
77
+ CircuitJsonPcbSvgRenderer.#renderCopperPrimitive(primitive)
78
+ if (!markup) continue
79
+ const target = CircuitJsonPcbSvgRenderer.#isSurfacePrimitive(
80
+ primitive,
81
+ side
82
+ )
83
+ ? surface
84
+ : subsurface
85
+ target.push(markup)
86
+ }
87
+ return (
88
+ '<g class="pcb-copper pcb-copper--subsurface">' +
89
+ subsurface.join('') +
90
+ '</g>' +
91
+ '<g class="pcb-copper pcb-copper--surface">' +
92
+ surface.join('') +
93
+ '</g>'
94
+ )
95
+ }
96
+ /**
97
+ * Renders one copper primitive.
98
+ * @param {object} primitive Primitive row.
99
+ * @returns {string}
100
+ */
101
+ static #renderCopperPrimitive(primitive) {
102
+ if (primitive.kind === 'pad') {
103
+ return CircuitJsonPcbSvgRenderer.#renderPad(primitive)
104
+ }
105
+ if (primitive.kind === 'track') {
106
+ return CircuitJsonPcbSvgRenderer.#renderTrack(primitive)
107
+ }
108
+ if (primitive.kind === 'via') {
109
+ return CircuitJsonPcbSvgRenderer.#renderVia(primitive)
110
+ }
111
+ if (primitive.kind === 'zone') {
112
+ return CircuitJsonPcbSvgRenderer.#renderZone(primitive)
113
+ }
114
+ if (
115
+ [
116
+ 'silkscreen',
117
+ 'silkscreen_text',
118
+ 'silkscreen_line',
119
+ 'fabrication',
120
+ 'courtyard',
121
+ 'keepout',
122
+ 'cutout',
123
+ 'copper-text',
124
+ 'note',
125
+ 'dimension',
126
+ 'solder-mask',
127
+ 'solder-paste',
128
+ 'thermal-spoke',
129
+ 'route-hint',
130
+ 'breakout-point',
131
+ 'panel'
132
+ ].includes(primitive.kind)
133
+ ) {
134
+ return CircuitJsonPcbSvgRenderer.#renderDetailPrimitive(primitive)
135
+ }
136
+ return ''
137
+ }
138
+ /**
139
+ * Renders one pad primitive.
140
+ * @param {object} primitive Pad primitive.
141
+ * @returns {string}
142
+ */
143
+ static #renderPad(primitive) {
144
+ const attributes =
145
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive)
146
+ const shape = String(primitive.shape || 'rect').toLowerCase()
147
+ const className =
148
+ 'pcb-pad pcb-smd-pad pcb-pad--' +
149
+ CircuitJsonPcbSvgRenderer.#escapeHtml(shape)
150
+ if (shape === 'polygon' && Array.isArray(primitive.points)) {
151
+ return (
152
+ '<polygon class="' +
153
+ className +
154
+ '" ' +
155
+ attributes +
156
+ ' points="' +
157
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
158
+ CircuitJsonPcbSvgRenderer.#pointsAttribute(primitive.points)
159
+ ) +
160
+ '"></polygon>'
161
+ )
162
+ }
163
+
164
+ if (shape === 'circle') {
165
+ return (
166
+ '<circle class="' +
167
+ className +
168
+ '" ' +
169
+ attributes +
170
+ ' cx="' +
171
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x) +
172
+ '" cy="' +
173
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y) +
174
+ '" r="' +
175
+ CircuitJsonPcbSvgRenderer.#formatNumber(
176
+ Math.max(primitive.width, primitive.height) / 2
177
+ ) +
178
+ '"></circle>'
179
+ )
180
+ }
181
+
182
+ return (
183
+ '<rect class="' +
184
+ className +
185
+ '" ' +
186
+ attributes +
187
+ ' x="' +
188
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.minX) +
189
+ '" y="' +
190
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.minY) +
191
+ '" width="' +
192
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.width) +
193
+ '" height="' +
194
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.height) +
195
+ '" rx="' +
196
+ CircuitJsonPcbSvgRenderer.#formatNumber(
197
+ primitive.radius ||
198
+ Math.min(primitive.bounds.width, primitive.bounds.height) *
199
+ 0.18
200
+ ) +
201
+ '"' +
202
+ CircuitJsonPcbSvgRenderer.#rotationAttribute(primitive) +
203
+ '></rect>'
204
+ )
205
+ }
206
+
207
+ /**
208
+ * Renders one track primitive.
209
+ * @param {object} primitive Track primitive.
210
+ * @returns {string}
211
+ */
212
+ static #renderTrack(primitive) {
213
+ return (
214
+ '<line class="pcb-track pcb-segment" ' +
215
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
216
+ ' x1="' +
217
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x1) +
218
+ '" y1="' +
219
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y1) +
220
+ '" x2="' +
221
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x2) +
222
+ '" y2="' +
223
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y2) +
224
+ '" stroke-width="' +
225
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.width) +
226
+ '" stroke-linecap="round"></line>'
227
+ )
228
+ }
229
+
230
+ /**
231
+ * Renders one via primitive.
232
+ * @param {object} primitive Via primitive.
233
+ * @returns {string}
234
+ */
235
+ static #renderVia(primitive) {
236
+ return CircuitJsonPcbViaSvgRenderer.render(
237
+ primitive,
238
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive)
239
+ )
240
+ }
241
+
242
+ /**
243
+ * Renders one polygonal zone primitive.
244
+ * @param {object} primitive Zone primitive.
245
+ * @returns {string}
246
+ */
247
+ static #renderZone(primitive) {
248
+ if (Array.isArray(primitive.rings) && primitive.rings.length) {
249
+ const path = CircuitJsonPcbSvgRenderer.#ringsPathData(
250
+ primitive.rings
251
+ )
252
+ if (!path) return ''
253
+ return (
254
+ '<path class="pcb-zone" ' +
255
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
256
+ ' d="' +
257
+ CircuitJsonPcbSvgRenderer.#escapeHtml(path) +
258
+ '" fill-rule="evenodd" clip-rule="evenodd"></path>'
259
+ )
260
+ }
261
+
262
+ const points = CircuitJsonPcbSvgRenderer.#pointsAttribute(
263
+ primitive.points || []
264
+ )
265
+ if (!points) return ''
266
+
267
+ return (
268
+ '<polygon class="pcb-zone" ' +
269
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
270
+ ' points="' +
271
+ CircuitJsonPcbSvgRenderer.#escapeHtml(points) +
272
+ '"></polygon>'
273
+ )
274
+ }
275
+
276
+ /**
277
+ * Renders one non-copper detail primitive.
278
+ * @param {object} primitive Detail primitive.
279
+ * @returns {string}
280
+ */
281
+ static #renderDetailPrimitive(primitive) {
282
+ if (primitive.kind === 'dimension') {
283
+ return CircuitJsonPcbSvgRenderer.#renderDimension(primitive)
284
+ }
285
+ if (primitive.text !== undefined) {
286
+ return CircuitJsonPcbSvgRenderer.#renderDetailText(primitive)
287
+ }
288
+ if (Number.isFinite(Number(primitive.x1))) {
289
+ return CircuitJsonPcbSvgRenderer.#renderDetailLine(primitive)
290
+ }
291
+ if (Array.isArray(primitive.points) && primitive.points.length >= 3) {
292
+ return CircuitJsonPcbSvgRenderer.#renderDetailPolygon(primitive)
293
+ }
294
+ if (String(primitive.shape || '').toLowerCase() === 'circle') {
295
+ return CircuitJsonPcbSvgRenderer.#renderDetailCircle(primitive)
296
+ }
297
+ return CircuitJsonPcbSvgRenderer.#renderDetailRect(primitive)
298
+ }
299
+
300
+ /**
301
+ * Renders a dimension line with a centered label.
302
+ * @param {object} primitive Dimension primitive.
303
+ * @returns {string}
304
+ */
305
+ static #renderDimension(primitive) {
306
+ const midX = (Number(primitive.x1) + Number(primitive.x2)) / 2
307
+ const midY = (Number(primitive.y1) + Number(primitive.y2)) / 2
308
+ return (
309
+ '<g class="' +
310
+ CircuitJsonPcbSvgRenderer.#detailClass(primitive) +
311
+ '" ' +
312
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
313
+ '><line x1="' +
314
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x1) +
315
+ '" y1="' +
316
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y1) +
317
+ '" x2="' +
318
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x2) +
319
+ '" y2="' +
320
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y2) +
321
+ '" stroke-width="' +
322
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.width || 0.08) +
323
+ '"></line><text x="' +
324
+ CircuitJsonPcbSvgRenderer.#formatNumber(midX) +
325
+ '" y="' +
326
+ CircuitJsonPcbSvgRenderer.#formatNumber(midY) +
327
+ '" font-size="0.32" text-anchor="middle" dominant-baseline="central">' +
328
+ CircuitJsonPcbSvgRenderer.#escapeHtml(primitive.text || '') +
329
+ '</text></g>'
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Renders a detail text primitive.
335
+ * @param {object} primitive Text primitive.
336
+ * @returns {string}
337
+ */
338
+ static #renderDetailText(primitive) {
339
+ return (
340
+ '<text class="' +
341
+ CircuitJsonPcbSvgRenderer.#detailClass(primitive) +
342
+ '" ' +
343
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
344
+ ' x="' +
345
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x) +
346
+ '" y="' +
347
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y) +
348
+ '" font-size="' +
349
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.fontSize || 1) +
350
+ '" text-anchor="' +
351
+ CircuitJsonPcbSvgRenderer.#textAnchor(primitive) +
352
+ '" dominant-baseline="' +
353
+ CircuitJsonPcbSvgRenderer.#textBaseline(primitive) +
354
+ '"' +
355
+ CircuitJsonPcbSvgRenderer.#rotationAttribute(primitive) +
356
+ '>' +
357
+ CircuitJsonPcbSvgRenderer.#escapeHtml(primitive.text) +
358
+ '</text>'
359
+ )
360
+ }
361
+
362
+ /**
363
+ * Renders a detail line primitive.
364
+ * @param {object} primitive Line primitive.
365
+ * @returns {string}
366
+ */
367
+ static #renderDetailLine(primitive) {
368
+ return (
369
+ '<line class="' +
370
+ CircuitJsonPcbSvgRenderer.#detailClass(primitive) +
371
+ '" ' +
372
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
373
+ ' x1="' +
374
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x1) +
375
+ '" y1="' +
376
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y1) +
377
+ '" x2="' +
378
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x2) +
379
+ '" y2="' +
380
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y2) +
381
+ '" stroke-width="' +
382
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.width || 0.12) +
383
+ '" stroke-linecap="round"></line>'
384
+ )
385
+ }
386
+
387
+ /**
388
+ * Renders a detail polygon primitive.
389
+ * @param {object} primitive Polygon primitive.
390
+ * @returns {string}
391
+ */
392
+ static #renderDetailPolygon(primitive) {
393
+ return (
394
+ '<polygon class="' +
395
+ CircuitJsonPcbSvgRenderer.#detailClass(primitive) +
396
+ '" ' +
397
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
398
+ ' points="' +
399
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
400
+ CircuitJsonPcbSvgRenderer.#pointsAttribute(primitive.points)
401
+ ) +
402
+ '"></polygon>'
403
+ )
404
+ }
405
+
406
+ /**
407
+ * Renders a detail circle primitive.
408
+ * @param {object} primitive Circle primitive.
409
+ * @returns {string}
410
+ */
411
+ static #renderDetailCircle(primitive) {
412
+ return (
413
+ '<circle class="' +
414
+ CircuitJsonPcbSvgRenderer.#detailClass(primitive) +
415
+ '" ' +
416
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
417
+ ' cx="' +
418
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x) +
419
+ '" cy="' +
420
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y) +
421
+ '" r="' +
422
+ CircuitJsonPcbSvgRenderer.#formatNumber(
423
+ primitive.radius ||
424
+ Math.max(primitive.width || 0, primitive.height || 0) / 2
425
+ ) +
426
+ '"></circle>'
427
+ )
428
+ }
429
+
430
+ /**
431
+ * Renders a detail rectangle primitive.
432
+ * @param {object} primitive Rectangle primitive.
433
+ * @returns {string}
434
+ */
435
+ static #renderDetailRect(primitive) {
436
+ if (!primitive.bounds) return ''
437
+ return (
438
+ '<rect class="' +
439
+ CircuitJsonPcbSvgRenderer.#detailClass(primitive) +
440
+ '" ' +
441
+ CircuitJsonPcbSvgRenderer.#primitiveAttributes(primitive) +
442
+ ' x="' +
443
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.minX) +
444
+ '" y="' +
445
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.minY) +
446
+ '" width="' +
447
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.width) +
448
+ '" height="' +
449
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.bounds.height) +
450
+ '" rx="' +
451
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.radius || 0) +
452
+ '"' +
453
+ CircuitJsonPcbSvgRenderer.#rotationAttribute(primitive) +
454
+ '></rect>'
455
+ )
456
+ }
457
+
458
+ /**
459
+ * Renders diagnostic markers, length labels, groups, and rats-nest lines.
460
+ * @param {{ diagnostics?: object[], airwires?: object[], traceLengths?: object[], groups?: object[], anchorOffsets?: object[] }} model Primitive model.
461
+ * @param {'top' | 'bottom'} side Active board side.
462
+ * @returns {string}
463
+ */
464
+ static #renderOverlays(model, side) {
465
+ return (
466
+ CircuitJsonPcbSvgRenderer.#renderGroups(model.groups || []) +
467
+ CircuitJsonPcbSvgRenderer.#renderAnchorOffsets(
468
+ model.anchorOffsets || []
469
+ ) +
470
+ CircuitJsonPcbSvgRenderer.#renderAirwires(model.airwires || []) +
471
+ CircuitJsonPcbSvgRenderer.#renderTraceLengths(
472
+ model.traceLengths || [],
473
+ side
474
+ ) +
475
+ CircuitJsonPcbSvgRenderer.#renderDiagnostics(
476
+ model.diagnostics || []
477
+ )
478
+ )
479
+ }
480
+
481
+ /**
482
+ * Renders PCB group outlines.
483
+ * @param {object[]} groups Group rows.
484
+ * @returns {string}
485
+ */
486
+ static #renderGroups(groups) {
487
+ const outlines = groups
488
+ .filter((group) => group.bounds)
489
+ .map(
490
+ (group) =>
491
+ '<rect class="pcb-group-outline" data-layer="groups" data-pcb-group-id="' +
492
+ CircuitJsonPcbSvgRenderer.#escapeHtml(group.id) +
493
+ '" data-source-group-id="' +
494
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
495
+ group.sourceGroupId || ''
496
+ ) +
497
+ '" x="' +
498
+ CircuitJsonPcbSvgRenderer.#formatNumber(group.bounds.minX) +
499
+ '" y="' +
500
+ CircuitJsonPcbSvgRenderer.#formatNumber(group.bounds.minY) +
501
+ '" width="' +
502
+ CircuitJsonPcbSvgRenderer.#formatNumber(
503
+ group.bounds.width
504
+ ) +
505
+ '" height="' +
506
+ CircuitJsonPcbSvgRenderer.#formatNumber(
507
+ group.bounds.height
508
+ ) +
509
+ '"><title>' +
510
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
511
+ group.name || group.id
512
+ ) +
513
+ '</title></rect>'
514
+ )
515
+ return outlines.length
516
+ ? '<g class="pcb-groups" data-layer="groups">' +
517
+ outlines.join('') +
518
+ '</g>'
519
+ : ''
520
+ }
521
+
522
+ /**
523
+ * Renders group anchor offset lines.
524
+ * @param {object[]} anchorOffsets Anchor offset rows.
525
+ * @returns {string}
526
+ */
527
+ static #renderAnchorOffsets(anchorOffsets) {
528
+ const lines = anchorOffsets.map(
529
+ (offset) =>
530
+ '<line class="pcb-anchor-offset" data-layer="anchor_offsets" data-pcb-group-id="' +
531
+ CircuitJsonPcbSvgRenderer.#escapeHtml(offset.sourceId || '') +
532
+ '" data-target-id="' +
533
+ CircuitJsonPcbSvgRenderer.#escapeHtml(offset.targetId || '') +
534
+ '" x1="' +
535
+ CircuitJsonPcbSvgRenderer.#formatNumber(offset.start?.x) +
536
+ '" y1="' +
537
+ CircuitJsonPcbSvgRenderer.#formatNumber(offset.start?.y) +
538
+ '" x2="' +
539
+ CircuitJsonPcbSvgRenderer.#formatNumber(offset.end?.x) +
540
+ '" y2="' +
541
+ CircuitJsonPcbSvgRenderer.#formatNumber(offset.end?.y) +
542
+ '"></line>'
543
+ )
544
+ return lines.length
545
+ ? '<g class="pcb-anchor-offsets" data-layer="anchor_offsets">' +
546
+ lines.join('') +
547
+ '</g>'
548
+ : ''
549
+ }
550
+
551
+ /**
552
+ * Renders routed trace length labels.
553
+ * @param {object[]} traceLengths Trace length rows.
554
+ * @param {'top' | 'bottom'} side Active board side.
555
+ * @returns {string}
556
+ */
557
+ static #renderTraceLengths(traceLengths, side) {
558
+ const labels = traceLengths
559
+ .filter((row) => !row.side || row.side === side)
560
+ .map(
561
+ (row) =>
562
+ '<text class="' +
563
+ CircuitJsonPcbSvgRenderer.#traceLengthClass(row) +
564
+ '" data-layer="trace_lengths" data-pcb-trace-length-id="' +
565
+ CircuitJsonPcbSvgRenderer.#escapeHtml(row.id) +
566
+ '" data-net="' +
567
+ CircuitJsonPcbSvgRenderer.#escapeHtml(row.netName || '') +
568
+ '" x="' +
569
+ CircuitJsonPcbSvgRenderer.#formatNumber(row.point?.x) +
570
+ '" y="' +
571
+ CircuitJsonPcbSvgRenderer.#formatNumber(row.point?.y) +
572
+ '" text-anchor="middle" dominant-baseline="central">' +
573
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
574
+ CircuitJsonPcbSvgRenderer.#traceLengthLabel(row)
575
+ ) +
576
+ '</text>'
577
+ )
578
+ return labels.length
579
+ ? '<g class="pcb-trace-lengths" data-layer="trace_lengths">' +
580
+ labels.join('') +
581
+ '</g>'
582
+ : ''
583
+ }
584
+
585
+ /**
586
+ * Builds trace length label classes.
587
+ * @param {object} row Trace length row.
588
+ * @returns {string}
589
+ */
590
+ static #traceLengthClass(row) {
591
+ return (
592
+ 'pcb-trace-length-label' +
593
+ (row?.overLimit ? ' pcb-trace-length-label--over-limit' : '')
594
+ )
595
+ }
596
+
597
+ /**
598
+ * Builds trace length label text.
599
+ * @param {object} row Trace length row.
600
+ * @returns {string}
601
+ */
602
+ static #traceLengthLabel(row) {
603
+ return String(row?.label || '').trim() || row.length + ' mm'
604
+ }
605
+
606
+ /**
607
+ * Renders rats-nest airwire lines.
608
+ * @param {object[]} airwires Airwire rows.
609
+ * @returns {string}
610
+ */
611
+ static #renderAirwires(airwires) {
612
+ const lines = airwires.map(
613
+ (airwire) =>
614
+ '<line class="pcb-ratsnest-line" data-layer="ratsnest" data-net="' +
615
+ CircuitJsonPcbSvgRenderer.#escapeHtml(airwire.netName) +
616
+ '" data-pcb-ratsnest-net="' +
617
+ CircuitJsonPcbSvgRenderer.#escapeHtml(airwire.netName) +
618
+ '" x1="' +
619
+ CircuitJsonPcbSvgRenderer.#formatNumber(airwire.start?.x) +
620
+ '" y1="' +
621
+ CircuitJsonPcbSvgRenderer.#formatNumber(airwire.start?.y) +
622
+ '" x2="' +
623
+ CircuitJsonPcbSvgRenderer.#formatNumber(airwire.end?.x) +
624
+ '" y2="' +
625
+ CircuitJsonPcbSvgRenderer.#formatNumber(airwire.end?.y) +
626
+ '"></line>'
627
+ )
628
+ return lines.length
629
+ ? '<g class="pcb-ratsnest" data-layer="ratsnest">' +
630
+ lines.join('') +
631
+ '</g>'
632
+ : ''
633
+ }
634
+
635
+ /**
636
+ * Renders diagnostic marker rows.
637
+ * @param {object[]} diagnostics Diagnostic rows.
638
+ * @returns {string}
639
+ */
640
+ static #renderDiagnostics(diagnostics) {
641
+ const markers = diagnostics.map(
642
+ (diagnostic) =>
643
+ '<g class="pcb-diagnostic-marker pcb-diagnostic-marker--' +
644
+ CircuitJsonPcbSvgRenderer.#escapeHtml(diagnostic.severity) +
645
+ '" data-layer="diagnostics" data-pcb-diagnostic-id="' +
646
+ CircuitJsonPcbSvgRenderer.#escapeHtml(diagnostic.id) +
647
+ '" data-component-key="' +
648
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
649
+ diagnostic.componentKey || ''
650
+ ) +
651
+ '" data-net="' +
652
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
653
+ diagnostic.netName || ''
654
+ ) +
655
+ '">' +
656
+ '<title>' +
657
+ CircuitJsonPcbSvgRenderer.#escapeHtml(diagnostic.message) +
658
+ '</title><circle cx="' +
659
+ CircuitJsonPcbSvgRenderer.#formatNumber(diagnostic.point?.x) +
660
+ '" cy="' +
661
+ CircuitJsonPcbSvgRenderer.#formatNumber(diagnostic.point?.y) +
662
+ '" r="0.24"></circle></g>'
663
+ )
664
+ return markers.length
665
+ ? '<g class="pcb-diagnostics" data-layer="diagnostics">' +
666
+ markers.join('') +
667
+ '</g>'
668
+ : ''
669
+ }
670
+
671
+ /**
672
+ * Renders component reference labels.
673
+ * @param {{ components: object[] }} model Primitive model.
674
+ * @returns {string}
675
+ */
676
+ static #renderComponentLabels(model) {
677
+ const labels = model.components
678
+ .filter((component) => component.componentKey)
679
+ .map((component) => {
680
+ const key = String(component.componentKey || '')
681
+ return (
682
+ '<text class="pcb-label pcb-reference pcb-footprint-text" data-component-key="' +
683
+ CircuitJsonPcbSvgRenderer.#escapeHtml(key) +
684
+ '" data-footprint-id="' +
685
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
686
+ 'footprint:' + key + ':label'
687
+ ) +
688
+ '" x="' +
689
+ CircuitJsonPcbSvgRenderer.#formatNumber(component.x || 0) +
690
+ '" y="' +
691
+ CircuitJsonPcbSvgRenderer.#formatNumber(component.y || 0) +
692
+ '" text-anchor="middle" dominant-baseline="central">' +
693
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
694
+ component.designator || key
695
+ ) +
696
+ '</text>'
697
+ )
698
+ })
699
+
700
+ return labels.length
701
+ ? '<g class="pcb-footprints pcb-texts">' + labels.join('') + '</g>'
702
+ : ''
703
+ }
704
+
705
+ /**
706
+ * Resolves CSS classes for detail primitives.
707
+ * @param {object} primitive Detail primitive.
708
+ * @returns {string}
709
+ */
710
+ static #detailClass(primitive) {
711
+ const kind = String(primitive.kind || '')
712
+ if (kind === 'silkscreen_text') {
713
+ return (
714
+ 'pcb-silkscreen pcb-silkscreen-text pcb-footprint-text' +
715
+ (primitive.isKnockout ? ' pcb-silkscreen-text--knockout' : '')
716
+ )
717
+ }
718
+ if (kind === 'silkscreen_line') {
719
+ return 'pcb-silkscreen pcb-silkscreen-line'
720
+ }
721
+ if (kind === 'silkscreen') return 'pcb-silkscreen'
722
+ if (kind === 'fabrication') return 'pcb-fabrication'
723
+ if (kind === 'courtyard') return 'pcb-courtyard'
724
+ if (kind === 'keepout') return 'pcb-keepout'
725
+ if (kind === 'cutout') return 'pcb-cutout'
726
+ if (kind === 'note') return 'pcb-note'
727
+ if (kind === 'dimension') return 'pcb-dimension'
728
+ if (kind === 'solder-mask') return 'pcb-solder-mask'
729
+ if (kind === 'solder-paste') {
730
+ return (
731
+ 'pcb-solder-paste pcb-solder-paste--' +
732
+ CircuitJsonPcbSvgRenderer.#escapeHtml(
733
+ String(primitive.shape || 'rect').toLowerCase()
734
+ )
735
+ )
736
+ }
737
+ if (kind === 'thermal-spoke') return 'pcb-thermal-spoke'
738
+ if (kind === 'route-hint') return 'pcb-route-hint'
739
+ if (kind === 'breakout-point') return 'pcb-breakout-point'
740
+ if (kind === 'panel') return 'pcb-panel-outline'
741
+ if (kind === 'copper-text') {
742
+ return 'pcb-copper-text pcb-footprint-text'
743
+ }
744
+ return 'pcb-detail'
745
+ }
746
+
747
+ /**
748
+ * Returns true when a primitive belongs to the active surface.
749
+ * @param {object} primitive Primitive row.
750
+ * @param {'top' | 'bottom'} side Active side.
751
+ * @returns {boolean}
752
+ */
753
+ static #isSurfacePrimitive(primitive, side) {
754
+ const primitiveSide = String(primitive.side || '').trim()
755
+ if (primitiveSide) return primitiveSide === side
756
+
757
+ const layer = String(primitive.layer || '').toLowerCase()
758
+ if (side === 'bottom') return layer === 'bottom'
759
+
760
+ return layer === 'top' || !layer || layer === 'board'
761
+ }
762
+
763
+ /**
764
+ * Renders shared primitive data attributes.
765
+ * @param {object} primitive Primitive row.
766
+ * @returns {string}
767
+ */
768
+ static #primitiveAttributes(primitive) {
769
+ return CircuitJsonPcbPrimitiveAttributeRenderer.render(primitive)
770
+ }
771
+
772
+ /**
773
+ * Resolves the SVG horizontal anchor for a text primitive.
774
+ * @param {object} primitive Text primitive.
775
+ * @returns {'start' | 'middle' | 'end'}
776
+ */
777
+ static #textAnchor(primitive) {
778
+ const alignment =
779
+ CircuitJsonPcbSvgRenderer.#normalizedAlignment(primitive)
780
+ if (
781
+ CircuitJsonPcbSvgRenderer.#alignmentHas(alignment, 'right') ||
782
+ CircuitJsonPcbSvgRenderer.#alignmentHas(alignment, 'end')
783
+ ) {
784
+ return 'end'
785
+ }
786
+ if (
787
+ CircuitJsonPcbSvgRenderer.#alignmentHas(alignment, 'left') ||
788
+ CircuitJsonPcbSvgRenderer.#alignmentHas(alignment, 'start')
789
+ ) {
790
+ return 'start'
791
+ }
792
+ return 'middle'
793
+ }
794
+
795
+ /**
796
+ * Resolves the SVG vertical baseline for a text primitive.
797
+ * @param {object} primitive Text primitive.
798
+ * @returns {'text-before-edge' | 'central' | 'text-after-edge'}
799
+ */
800
+ static #textBaseline(primitive) {
801
+ const alignment =
802
+ CircuitJsonPcbSvgRenderer.#normalizedAlignment(primitive)
803
+ if (CircuitJsonPcbSvgRenderer.#alignmentHas(alignment, 'top')) {
804
+ return 'text-before-edge'
805
+ }
806
+ if (CircuitJsonPcbSvgRenderer.#alignmentHas(alignment, 'bottom')) {
807
+ return 'text-after-edge'
808
+ }
809
+ return 'central'
810
+ }
811
+
812
+ /**
813
+ * Normalizes a text alignment token for matching.
814
+ * @param {object} primitive Text primitive.
815
+ * @returns {string}
816
+ */
817
+ static #normalizedAlignment(primitive) {
818
+ return String(
819
+ primitive.anchorAlignment || primitive.anchor_alignment || ''
820
+ )
821
+ .trim()
822
+ .toLowerCase()
823
+ .replaceAll('-', '_')
824
+ }
825
+
826
+ /**
827
+ * Returns true when a normalized alignment contains one token.
828
+ * @param {string} alignment Normalized alignment text.
829
+ * @param {string} token Token to match.
830
+ * @returns {boolean}
831
+ */
832
+ static #alignmentHas(alignment, token) {
833
+ return alignment
834
+ .split(/[^a-z0-9]+/u)
835
+ .filter(Boolean)
836
+ .includes(token)
837
+ }
838
+
839
+ /**
840
+ * Builds a polygon points attribute.
841
+ * @param {{ x: number, y: number }[]} points Points.
842
+ * @returns {string}
843
+ */
844
+ static #pointsAttribute(points) {
845
+ return (points || [])
846
+ .map(
847
+ (point) =>
848
+ CircuitJsonPcbSvgRenderer.#formatNumber(point.x) +
849
+ ',' +
850
+ CircuitJsonPcbSvgRenderer.#formatNumber(point.y)
851
+ )
852
+ .join(' ')
853
+ }
854
+
855
+ /**
856
+ * Builds an SVG path for zone rings.
857
+ * @param {{ points?: { x: number, y: number }[] }[]} rings Zone rings.
858
+ * @returns {string}
859
+ */
860
+ static #ringsPathData(rings) {
861
+ return (rings || [])
862
+ .map((ring) => CircuitJsonPcbSvgRenderer.#ringPathData(ring))
863
+ .filter(Boolean)
864
+ .join(' ')
865
+ }
866
+
867
+ /**
868
+ * Builds an SVG path segment for one closed ring.
869
+ * @param {{ points?: { x: number, y: number }[] }} ring Zone ring.
870
+ * @returns {string}
871
+ */
872
+ static #ringPathData(ring) {
873
+ const points = Array.isArray(ring?.points) ? ring.points : []
874
+ if (points.length < 3) return ''
875
+ const [first, ...rest] = points
876
+ return (
877
+ 'M ' +
878
+ CircuitJsonPcbSvgRenderer.#formatNumber(first.x) +
879
+ ' ' +
880
+ CircuitJsonPcbSvgRenderer.#formatNumber(first.y) +
881
+ rest
882
+ .map(
883
+ (point) =>
884
+ ' L ' +
885
+ CircuitJsonPcbSvgRenderer.#formatNumber(point.x) +
886
+ ' ' +
887
+ CircuitJsonPcbSvgRenderer.#formatNumber(point.y)
888
+ )
889
+ .join('') +
890
+ ' Z'
891
+ )
892
+ }
893
+
894
+ /**
895
+ * Builds a rotation transform attribute for centered primitives.
896
+ * @param {object} primitive Primitive row.
897
+ * @returns {string}
898
+ */
899
+ static #rotationAttribute(primitive) {
900
+ const rotation = Number(primitive.rotation || 0)
901
+ if (!Number.isFinite(rotation) || rotation === 0) return ''
902
+ return (
903
+ ' transform="rotate(' +
904
+ CircuitJsonPcbSvgRenderer.#formatNumber(rotation) +
905
+ ' ' +
906
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.x) +
907
+ ' ' +
908
+ CircuitJsonPcbSvgRenderer.#formatNumber(primitive.y) +
909
+ ')"'
910
+ )
911
+ }
912
+
913
+ /**
914
+ * Builds a padded SVG viewBox for model bounds.
915
+ * @param {{ minX: number, minY: number, width: number, height: number }} bounds Model bounds.
916
+ * @returns {{ minX: number, minY: number, width: number, height: number }}
917
+ */
918
+ static #viewBox(bounds) {
919
+ const padding = Math.max(bounds.width, bounds.height, 1) * 0.06
920
+
921
+ return {
922
+ minX: bounds.minX - padding,
923
+ minY: bounds.minY - padding,
924
+ width: bounds.width + padding * 2,
925
+ height: bounds.height + padding * 2
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Formats a viewBox record.
931
+ * @param {{ minX: number, minY: number, width: number, height: number }} viewBox ViewBox record.
932
+ * @returns {string}
933
+ */
934
+ static #formatViewBox(viewBox) {
935
+ return [viewBox.minX, viewBox.minY, viewBox.width, viewBox.height]
936
+ .map((value) => CircuitJsonPcbSvgRenderer.#formatNumber(value))
937
+ .join(' ')
938
+ }
939
+
940
+ /**
941
+ * Formats one SVG number.
942
+ * @param {number} value Number.
943
+ * @returns {string}
944
+ */
945
+ static #formatNumber(value) {
946
+ const number = Number(value)
947
+ if (!Number.isFinite(number)) return '0'
948
+
949
+ return Number(number.toFixed(6)).toString()
950
+ }
951
+
952
+ /**
953
+ * Escapes markup text.
954
+ * @param {unknown} value Raw value.
955
+ * @returns {string}
956
+ */
957
+ static #escapeHtml(value) {
958
+ return String(value ?? '')
959
+ .replaceAll('&', '&amp;')
960
+ .replaceAll('<', '&lt;')
961
+ .replaceAll('>', '&gt;')
962
+ .replaceAll('"', '&quot;')
963
+ }
964
+ }