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,895 @@
1
+ import { CircuitJsonIndexer } from './CircuitJsonIndexer.mjs'
2
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
3
+ import { CircuitJsonPcbClearanceDiagnostics } from './CircuitJsonPcbClearanceDiagnostics.mjs'
4
+
5
+ const VIRTUAL_LAYER_ORDER = [
6
+ 'top_silkscreen',
7
+ 'bottom_silkscreen',
8
+ 'top_fabrication',
9
+ 'bottom_fabrication',
10
+ 'top_courtyard',
11
+ 'bottom_courtyard',
12
+ 'top_soldermask',
13
+ 'bottom_soldermask',
14
+ 'top_paste',
15
+ 'bottom_paste',
16
+ 'keepouts',
17
+ 'cutouts',
18
+ 'diagnostics',
19
+ 'groups',
20
+ 'anchor_offsets',
21
+ 'trace_lengths',
22
+ 'ratsnest'
23
+ ]
24
+
25
+ /**
26
+ * Builds non-geometric inspection overlays for CircuitJSON PCB primitives.
27
+ */
28
+ export class CircuitJsonPcbPrimitiveOverlays {
29
+ /**
30
+ * Builds virtual layers, diagnostics, and airwires.
31
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
32
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
33
+ * @param {object[]} primitives Primitive rows.
34
+ * @param {object} bounds Board bounds.
35
+ * @param {{ groups?: object[], anchorOffsets?: object[] }} [groupModel] Group overlay rows.
36
+ * @param {object[]} [extraDiagnostics] Precomputed diagnostic rows.
37
+ * @returns {{ virtualLayers: object[], diagnostics: object[], airwires: object[] }}
38
+ */
39
+ static build(
40
+ index,
41
+ componentsByPcbId,
42
+ primitives,
43
+ bounds,
44
+ groupModel = {},
45
+ extraDiagnostics = []
46
+ ) {
47
+ const ports = CircuitJsonPcbPrimitiveOverlays.#ports(index)
48
+ const diagnostics = [
49
+ ...CircuitJsonPcbPrimitiveOverlays.#diagnostics(
50
+ index,
51
+ componentsByPcbId,
52
+ bounds,
53
+ primitives
54
+ ),
55
+ ...CircuitJsonPcbPrimitiveOverlays.#extraDiagnostics(
56
+ extraDiagnostics,
57
+ bounds,
58
+ index,
59
+ primitives
60
+ ),
61
+ ...CircuitJsonPcbPrimitiveOverlays.#clearanceDiagnostics(
62
+ index,
63
+ primitives
64
+ )
65
+ ]
66
+ const airwires = CircuitJsonPcbPrimitiveOverlays.#airwires(ports)
67
+
68
+ return {
69
+ virtualLayers: CircuitJsonPcbPrimitiveOverlays.#virtualLayers({
70
+ primitives,
71
+ diagnostics,
72
+ airwires,
73
+ groups: groupModel.groups || [],
74
+ anchorOffsets: groupModel.anchorOffsets || []
75
+ }),
76
+ diagnostics,
77
+ airwires
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Builds source-net-aware PCB port rows.
83
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
84
+ * @returns {object[]}
85
+ */
86
+ static #ports(index) {
87
+ const sourceNetNames =
88
+ CircuitJsonPcbPrimitiveOverlays.#sourceNetNames(index)
89
+ const sourcePortNetNames =
90
+ CircuitJsonPcbPrimitiveOverlays.#sourcePortNetNames(
91
+ index,
92
+ sourceNetNames
93
+ )
94
+
95
+ return CircuitJsonPcbPrimitiveOverlays.#all(index, 'pcb_port')
96
+ .map((port) => {
97
+ const point = CircuitJsonPcbPrimitiveOverlays.#center(port)
98
+ if (!point) return null
99
+ const sourcePortId = String(port.source_port_id || '').trim()
100
+ return {
101
+ id: String(port.pcb_port_id || '').trim(),
102
+ netName:
103
+ CircuitJsonPcbPrimitiveOverlays.#netName(port) ||
104
+ sourcePortNetNames.get(sourcePortId) ||
105
+ '',
106
+ point
107
+ }
108
+ })
109
+ .filter(Boolean)
110
+ }
111
+
112
+ /**
113
+ * Builds source net display names.
114
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
115
+ * @returns {Map<string, string>}
116
+ */
117
+ static #sourceNetNames(index) {
118
+ return new Map(
119
+ CircuitJsonPcbPrimitiveOverlays.#all(index, 'source_net')
120
+ .map((net) => [
121
+ String(net.source_net_id || '').trim(),
122
+ String(
123
+ net.name || net.net || net.source_net_id || ''
124
+ ).trim()
125
+ ])
126
+ .filter(([id, name]) => id && name)
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Builds source-port to source-net display-name lookup data.
132
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
133
+ * @param {Map<string, string>} sourceNetNames Source net names.
134
+ * @returns {Map<string, string>}
135
+ */
136
+ static #sourcePortNetNames(index, sourceNetNames) {
137
+ const names = new Map()
138
+ for (const port of CircuitJsonPcbPrimitiveOverlays.#all(
139
+ index,
140
+ 'source_port'
141
+ )) {
142
+ const portId = String(port.source_port_id || '').trim()
143
+ if (!portId) continue
144
+ const netId = CircuitJsonPcbPrimitiveOverlays.#firstString([
145
+ port.source_net_id,
146
+ ...(Array.isArray(port.source_net_ids)
147
+ ? port.source_net_ids
148
+ : []),
149
+ ...(Array.isArray(port.connected_source_net_ids)
150
+ ? port.connected_source_net_ids
151
+ : [])
152
+ ])
153
+ const name =
154
+ sourceNetNames.get(netId) ||
155
+ CircuitJsonPcbPrimitiveOverlays.#netName(port)
156
+ if (name) names.set(portId, name)
157
+ }
158
+ return names
159
+ }
160
+
161
+ /**
162
+ * Builds diagnostic marker rows.
163
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
164
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
165
+ * @param {object} bounds Board bounds.
166
+ * @param {object[]} primitives Primitive rows.
167
+ * @returns {object[]}
168
+ */
169
+ static #diagnostics(index, componentsByPcbId, bounds, primitives) {
170
+ return CircuitJsonPcbPrimitiveOverlays.#elements(index)
171
+ .filter((element) => {
172
+ const type = String(element?.type || '').toLowerCase()
173
+ return type.includes('error') || type.includes('warning')
174
+ })
175
+ .map((element, rowIndex) =>
176
+ CircuitJsonPcbPrimitiveOverlays.#diagnostic(
177
+ element,
178
+ index,
179
+ rowIndex,
180
+ componentsByPcbId,
181
+ bounds,
182
+ primitives
183
+ )
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Builds one diagnostic marker.
189
+ * @param {object} element Diagnostic-like element.
190
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
191
+ * @param {number} rowIndex Diagnostic row index.
192
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
193
+ * @param {object} bounds Board bounds.
194
+ * @param {object[]} primitives Primitive rows.
195
+ * @returns {object}
196
+ */
197
+ static #diagnostic(
198
+ element,
199
+ index,
200
+ rowIndex,
201
+ componentsByPcbId,
202
+ bounds,
203
+ primitives
204
+ ) {
205
+ const component =
206
+ componentsByPcbId.get(
207
+ String(element.pcb_component_id || '').trim()
208
+ ) ||
209
+ CircuitJsonPcbPrimitiveOverlays.#sourceComponent(
210
+ element,
211
+ componentsByPcbId
212
+ )
213
+ const relatedPrimitives =
214
+ CircuitJsonPcbPrimitiveOverlays.#relatedPrimitives(
215
+ element,
216
+ index,
217
+ primitives
218
+ )
219
+ const relatedBounds = CircuitJsonPcbPrimitiveOverlays.#mergeBounds(
220
+ relatedPrimitives.map((primitive) => primitive.bounds)
221
+ )
222
+ const point = CircuitJsonPcbPrimitiveOverlays.#center(element) ||
223
+ (relatedBounds
224
+ ? CircuitJsonPcbPrimitiveOverlays.#boundsCenter(relatedBounds)
225
+ : null) ||
226
+ (component ? { x: component.x, y: component.y } : null) || {
227
+ x: (bounds.minX + bounds.maxX) / 2,
228
+ y: (bounds.minY + bounds.maxY) / 2
229
+ }
230
+ const severity = String(element.severity || '').toLowerCase()
231
+ const type = String(element.type || '').toLowerCase()
232
+ const isWarning = severity === 'warning' || type.includes('warning')
233
+
234
+ const code = String(
235
+ element.error_type ||
236
+ element.warning_type ||
237
+ element.code ||
238
+ element.type ||
239
+ ''
240
+ )
241
+
242
+ const diagnostic = {
243
+ id:
244
+ CircuitJsonIndexer.getElementId(element) ||
245
+ 'diagnostic:' + rowIndex,
246
+ kind: isWarning ? 'warning' : 'error',
247
+ severity: isWarning ? 'warning' : 'error',
248
+ category: CircuitJsonPcbPrimitiveOverlays.#diagnosticCategory(code),
249
+ code,
250
+ message: String(element.message || 'PCB diagnostic.'),
251
+ point,
252
+ componentKey: String(
253
+ component?.componentKey ||
254
+ relatedPrimitives.find(
255
+ (primitive) => primitive.componentKey
256
+ )?.componentKey ||
257
+ ''
258
+ ),
259
+ netName:
260
+ CircuitJsonPcbPrimitiveOverlays.#netName(element) ||
261
+ relatedPrimitives.find((primitive) => primitive.netName)
262
+ ?.netName ||
263
+ ''
264
+ }
265
+ if (relatedPrimitives.length) {
266
+ diagnostic.bounds = relatedBounds
267
+ diagnostic.relatedPrimitiveIds = relatedPrimitives
268
+ .map((primitive) => primitive.id)
269
+ .filter(Boolean)
270
+ }
271
+ return diagnostic
272
+ }
273
+
274
+ /**
275
+ * Applies defaults to precomputed diagnostic rows.
276
+ * @param {object[]} diagnostics Diagnostic rows.
277
+ * @param {object} bounds Board bounds.
278
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
279
+ * @param {object[]} primitives Primitive rows.
280
+ * @returns {object[]}
281
+ */
282
+ static #extraDiagnostics(diagnostics, bounds, index, primitives) {
283
+ return (diagnostics || []).map((diagnostic, rowIndex) => {
284
+ const relatedPrimitives =
285
+ CircuitJsonPcbPrimitiveOverlays.#relatedPrimitivesForDiagnostic(
286
+ diagnostic,
287
+ index,
288
+ primitives
289
+ )
290
+ const relatedBounds =
291
+ diagnostic.bounds ||
292
+ CircuitJsonPcbPrimitiveOverlays.#mergeBounds(
293
+ relatedPrimitives.map((primitive) => primitive.bounds)
294
+ )
295
+ return {
296
+ id: String(
297
+ diagnostic.id ||
298
+ diagnostic.elementId ||
299
+ 'diagnostic:extra:' + rowIndex
300
+ ),
301
+ kind: String(diagnostic.kind || 'warning'),
302
+ severity: String(diagnostic.severity || 'warning'),
303
+ category: String(diagnostic.category || 'general'),
304
+ code: String(
305
+ diagnostic.code ||
306
+ diagnostic.type ||
307
+ diagnostic.warningType ||
308
+ diagnostic.errorType ||
309
+ 'pcb_diagnostic'
310
+ ),
311
+ message: String(diagnostic.message || 'PCB diagnostic.'),
312
+ point:
313
+ diagnostic.point ||
314
+ (relatedBounds
315
+ ? CircuitJsonPcbPrimitiveOverlays.#boundsCenter(
316
+ relatedBounds
317
+ )
318
+ : CircuitJsonPcbPrimitiveOverlays.#boundsCenter(
319
+ bounds
320
+ )),
321
+ bounds: relatedBounds || null,
322
+ relatedPrimitiveIds:
323
+ CircuitJsonPcbPrimitiveOverlays.#relatedPrimitiveIds(
324
+ diagnostic,
325
+ relatedPrimitives
326
+ ),
327
+ componentKey: String(diagnostic.componentKey || ''),
328
+ netName: String(diagnostic.netName || ''),
329
+ ...CircuitJsonPcbPrimitiveOverlays.#diagnosticRelationFields(
330
+ diagnostic
331
+ )
332
+ }
333
+ })
334
+ }
335
+
336
+ /**
337
+ * Finds primitives related to a normalized diagnostic row.
338
+ * @param {object} diagnostic Diagnostic row.
339
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
340
+ * @param {object[]} primitives Primitive rows.
341
+ * @returns {object[]}
342
+ */
343
+ static #relatedPrimitivesForDiagnostic(diagnostic, index, primitives) {
344
+ const explicitIds = CircuitJsonPcbPrimitiveOverlays.#idValues([
345
+ diagnostic?.relatedPrimitiveIds,
346
+ diagnostic?.related_primitive_ids
347
+ ])
348
+ const explicit = primitives.filter((primitive) =>
349
+ explicitIds.includes(String(primitive.id || '').trim())
350
+ )
351
+ const direct = CircuitJsonPcbPrimitiveOverlays.#matchingPrimitives(
352
+ CircuitJsonPcbPrimitiveOverlays.#relatedPrimitiveIdPairs({
353
+ pcb_trace_id:
354
+ diagnostic?.pcbTraceId || diagnostic?.pcb_trace_id,
355
+ pcb_trace_ids:
356
+ diagnostic?.pcbTraceIds || diagnostic?.pcb_trace_ids,
357
+ pcb_smtpad_id:
358
+ diagnostic?.pcbSmtpadId || diagnostic?.pcb_smtpad_id,
359
+ pcb_smtpad_ids:
360
+ diagnostic?.pcbSmtpadIds || diagnostic?.pcb_smtpad_ids,
361
+ pcb_via_id: diagnostic?.pcbViaId || diagnostic?.pcb_via_id,
362
+ pcb_via_ids: diagnostic?.pcbViaIds || diagnostic?.pcb_via_ids,
363
+ pcb_plated_hole_id:
364
+ diagnostic?.pcbPlatedHoleId ||
365
+ diagnostic?.pcb_plated_hole_id,
366
+ pcb_plated_hole_ids:
367
+ diagnostic?.pcbPlatedHoleIds ||
368
+ diagnostic?.pcb_plated_hole_ids,
369
+ pcb_hole_id: diagnostic?.pcbHoleId || diagnostic?.pcb_hole_id,
370
+ pcb_hole_ids:
371
+ diagnostic?.pcbHoleIds || diagnostic?.pcb_hole_ids,
372
+ pcb_port_id: diagnostic?.pcbPortId || diagnostic?.pcb_port_id,
373
+ pcb_port_ids: diagnostic?.pcbPortIds || diagnostic?.pcb_port_ids
374
+ }),
375
+ primitives
376
+ )
377
+ const sourceTrace =
378
+ CircuitJsonPcbPrimitiveOverlays.#sourceTracePrimitives(
379
+ diagnostic,
380
+ primitives
381
+ )
382
+ const sourcePortIds =
383
+ CircuitJsonPcbPrimitiveOverlays.#pcbPortIdsForSourcePort(
384
+ index,
385
+ diagnostic?.sourcePortId || diagnostic?.source_port_id
386
+ )
387
+ const sourcePort = CircuitJsonPcbPrimitiveOverlays.#matchingPrimitives(
388
+ sourcePortIds.map((id) => ['pcb_port_id', id]),
389
+ primitives
390
+ )
391
+ return CircuitJsonPcbPrimitiveOverlays.#uniquePrimitives([
392
+ ...explicit,
393
+ ...direct,
394
+ ...sourceTrace,
395
+ ...sourcePort
396
+ ])
397
+ }
398
+
399
+ /**
400
+ * Finds primitives that came from one source trace id.
401
+ * @param {object} diagnostic Diagnostic row.
402
+ * @param {object[]} primitives Primitive rows.
403
+ * @returns {object[]}
404
+ */
405
+ static #sourceTracePrimitives(diagnostic, primitives) {
406
+ const sourceTraceIds = CircuitJsonPcbPrimitiveOverlays.#idValues([
407
+ diagnostic?.sourceTraceId,
408
+ diagnostic?.source_trace_id,
409
+ diagnostic?.sourceTraceIds,
410
+ diagnostic?.source_trace_ids
411
+ ])
412
+ if (!sourceTraceIds.length) return []
413
+ return primitives.filter((primitive) =>
414
+ sourceTraceIds.includes(
415
+ String(
416
+ primitive.sourceTraceId ||
417
+ primitive.source?.source_trace_id ||
418
+ ''
419
+ ).trim()
420
+ )
421
+ )
422
+ }
423
+
424
+ /**
425
+ * Builds related primitive ids for a diagnostic.
426
+ * @param {object} diagnostic Diagnostic row.
427
+ * @param {object[]} relatedPrimitives Matched primitives.
428
+ * @returns {string[]}
429
+ */
430
+ static #relatedPrimitiveIds(diagnostic, relatedPrimitives) {
431
+ return CircuitJsonPcbPrimitiveOverlays.#idValues([
432
+ diagnostic?.relatedPrimitiveIds,
433
+ diagnostic?.related_primitive_ids,
434
+ relatedPrimitives.map((primitive) => primitive.id)
435
+ ])
436
+ }
437
+
438
+ /**
439
+ * Keeps the first instance of each primitive id.
440
+ * @param {object[]} primitives Primitive rows.
441
+ * @returns {object[]}
442
+ */
443
+ static #uniquePrimitives(primitives) {
444
+ const seen = new Set()
445
+ return primitives.filter((primitive) => {
446
+ const id = String(primitive.id || '').trim()
447
+ if (!id || seen.has(id)) return false
448
+ seen.add(id)
449
+ return true
450
+ })
451
+ }
452
+
453
+ /**
454
+ * Extracts normalized relation fields from a diagnostic row.
455
+ * @param {object} diagnostic Diagnostic row.
456
+ * @returns {object}
457
+ */
458
+ static #diagnosticRelationFields(diagnostic) {
459
+ return Object.fromEntries(
460
+ [
461
+ ['sourceTraceId', diagnostic?.sourceTraceId],
462
+ ['sourcePortId', diagnostic?.sourcePortId],
463
+ ['sourceNetId', diagnostic?.sourceNetId],
464
+ ['pcbTraceId', diagnostic?.pcbTraceId],
465
+ ['pcbPortId', diagnostic?.pcbPortId],
466
+ ['pcbSmtpadId', diagnostic?.pcbSmtpadId],
467
+ ['pcbViaId', diagnostic?.pcbViaId],
468
+ ['pcbPlatedHoleId', diagnostic?.pcbPlatedHoleId],
469
+ ['pcbHoleId', diagnostic?.pcbHoleId]
470
+ ]
471
+ .map(([key, value]) => [key, String(value || '').trim()])
472
+ .filter(([_key, value]) => value)
473
+ )
474
+ }
475
+
476
+ /**
477
+ * Builds generic copper clearance diagnostics when board rules are present.
478
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
479
+ * @param {object[]} primitives Primitive rows.
480
+ * @returns {object[]}
481
+ */
482
+ static #clearanceDiagnostics(index, primitives) {
483
+ return CircuitJsonPcbClearanceDiagnostics.build(index, primitives)
484
+ }
485
+
486
+ /**
487
+ * Builds simple source connectivity airwires.
488
+ * @param {object[]} ports PCB port rows.
489
+ * @returns {object[]}
490
+ */
491
+ static #airwires(ports) {
492
+ const byNet = new Map()
493
+ for (const port of ports) {
494
+ const netName = String(port.netName || '').trim()
495
+ if (!netName) continue
496
+ if (!byNet.has(netName)) byNet.set(netName, [])
497
+ byNet.get(netName).push(port)
498
+ }
499
+
500
+ const lines = []
501
+ for (const [netName, netPorts] of byNet) {
502
+ const sorted = [...netPorts].sort((left, right) =>
503
+ String(left.id).localeCompare(String(right.id))
504
+ )
505
+ for (let index = 1; index < sorted.length; index += 1) {
506
+ lines.push({
507
+ id: 'airwire:' + netName + ':' + (index - 1),
508
+ netName,
509
+ start: { ...sorted[0].point },
510
+ end: { ...sorted[index].point }
511
+ })
512
+ }
513
+ }
514
+ return lines
515
+ }
516
+
517
+ /**
518
+ * Builds virtual layer rows for detail and overlay primitives.
519
+ * @param {{ primitives: object[], diagnostics: object[], airwires: object[], groups?: object[], anchorOffsets?: object[] }} model Model fragments.
520
+ * @returns {object[]}
521
+ */
522
+ static #virtualLayers(model) {
523
+ const keys = new Set()
524
+ for (const primitive of model.primitives) {
525
+ if (
526
+ ['silkscreen', 'silkscreen_text', 'silkscreen_line'].includes(
527
+ primitive.kind
528
+ )
529
+ ) {
530
+ keys.add(primitive.layer)
531
+ }
532
+ if (primitive.kind === 'fabrication') keys.add(primitive.layer)
533
+ if (primitive.kind === 'courtyard') keys.add(primitive.layer)
534
+ if (primitive.kind === 'solder-mask') keys.add(primitive.layer)
535
+ if (primitive.kind === 'solder-paste') keys.add(primitive.layer)
536
+ if (primitive.kind === 'keepout') keys.add('keepouts')
537
+ if (primitive.kind === 'cutout') keys.add('cutouts')
538
+ if (primitive.kind === 'track') keys.add('trace_lengths')
539
+ if (String(primitive.netName || '').trim()) keys.add('ratsnest')
540
+ }
541
+ if (model.diagnostics.length) keys.add('diagnostics')
542
+ if (model.groups?.length) keys.add('groups')
543
+ if (model.anchorOffsets?.length) keys.add('anchor_offsets')
544
+ if (model.airwires.length) keys.add('ratsnest')
545
+
546
+ return VIRTUAL_LAYER_ORDER.filter((key) => keys.has(key)).map(
547
+ (key) => ({
548
+ key,
549
+ id: key,
550
+ layer: key,
551
+ name: CircuitJsonPcbPrimitiveOverlays.#displayLayerName(key),
552
+ side: CircuitJsonPcbPrimitiveOverlays.#side(key),
553
+ type: 'drawing',
554
+ sourceFormat: 'circuitjson'
555
+ })
556
+ )
557
+ }
558
+
559
+ /**
560
+ * Returns indexed element rows by type.
561
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
562
+ * @param {string} type Element type.
563
+ * @returns {object[]}
564
+ */
565
+ static #all(index, type) {
566
+ return index.elementsByType.get(type) || []
567
+ }
568
+
569
+ /**
570
+ * Returns all indexed element rows.
571
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
572
+ * @returns {object[]}
573
+ */
574
+ static #elements(index) {
575
+ return Array.from(index.elementsByType.values()).flat()
576
+ }
577
+
578
+ /**
579
+ * Finds primitives referenced by a diagnostic element.
580
+ * @param {object} element Diagnostic element.
581
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
582
+ * @param {object[]} primitives Primitive rows.
583
+ * @returns {object[]}
584
+ */
585
+ static #relatedPrimitives(element, index, primitives) {
586
+ const directIds =
587
+ CircuitJsonPcbPrimitiveOverlays.#relatedPrimitiveIdPairs(element)
588
+ const direct = CircuitJsonPcbPrimitiveOverlays.#matchingPrimitives(
589
+ directIds,
590
+ primitives
591
+ )
592
+ if (direct.length) return direct
593
+
594
+ const portIds =
595
+ CircuitJsonPcbPrimitiveOverlays.#pcbPortIdsForSourcePort(
596
+ index,
597
+ element?.source_port_id
598
+ )
599
+ const sourcePort = CircuitJsonPcbPrimitiveOverlays.#matchingPrimitives(
600
+ portIds.map((id) => ['pcb_port_id', id]),
601
+ primitives
602
+ )
603
+ if (sourcePort.length) return sourcePort
604
+
605
+ const sourceComponentId = String(
606
+ element?.source_component_id || ''
607
+ ).trim()
608
+ if (sourceComponentId) {
609
+ const sourceComponent = primitives.filter(
610
+ (primitive) =>
611
+ String(primitive.sourceComponentId || '').trim() ===
612
+ sourceComponentId &&
613
+ CircuitJsonPcbPrimitiveOverlays.#isDiagnosticPrimitive(
614
+ primitive
615
+ )
616
+ )
617
+ if (sourceComponent.length) return sourceComponent
618
+ }
619
+
620
+ const componentIds = CircuitJsonPcbPrimitiveOverlays.#idValues([
621
+ element?.pcb_component_id,
622
+ element?.pcb_component_ids
623
+ ])
624
+ if (componentIds.length) {
625
+ return primitives.filter(
626
+ (primitive) =>
627
+ componentIds.includes(
628
+ String(primitive.componentId || '').trim()
629
+ ) &&
630
+ CircuitJsonPcbPrimitiveOverlays.#isDiagnosticPrimitive(
631
+ primitive
632
+ )
633
+ )
634
+ }
635
+
636
+ return []
637
+ }
638
+
639
+ /**
640
+ * Builds source field/value pairs for directly related primitives.
641
+ * @param {object} element Diagnostic row.
642
+ * @returns {Array<[string, string]>}
643
+ */
644
+ static #relatedPrimitiveIdPairs(element) {
645
+ return [
646
+ ['pcb_trace_id', [element?.pcb_trace_id, element?.pcb_trace_ids]],
647
+ [
648
+ 'pcb_smtpad_id',
649
+ [element?.pcb_smtpad_id, element?.pcb_smtpad_ids]
650
+ ],
651
+ ['pcb_via_id', [element?.pcb_via_id, element?.pcb_via_ids]],
652
+ [
653
+ 'pcb_plated_hole_id',
654
+ [element?.pcb_plated_hole_id, element?.pcb_plated_hole_ids]
655
+ ],
656
+ ['pcb_hole_id', [element?.pcb_hole_id, element?.pcb_hole_ids]],
657
+ ['pcb_port_id', [element?.pcb_port_id, element?.pcb_port_ids]]
658
+ ].flatMap(([field, values]) =>
659
+ CircuitJsonPcbPrimitiveOverlays.#idValues(values).map((value) => [
660
+ field,
661
+ value
662
+ ])
663
+ )
664
+ }
665
+
666
+ /**
667
+ * Normalizes scalar or array ID values.
668
+ * @param {unknown[]} values Candidate values.
669
+ * @returns {string[]}
670
+ */
671
+ static #idValues(values) {
672
+ return [
673
+ ...new Set(
674
+ values
675
+ .flatMap((value) =>
676
+ Array.isArray(value) ? value : [value]
677
+ )
678
+ .map((value) => String(value || '').trim())
679
+ .filter(Boolean)
680
+ )
681
+ ]
682
+ }
683
+
684
+ /**
685
+ * Returns true when a component-owned primitive should shape diagnostics.
686
+ * @param {object} primitive Primitive row.
687
+ * @returns {boolean}
688
+ */
689
+ static #isDiagnosticPrimitive(primitive) {
690
+ return [
691
+ 'pad',
692
+ 'track',
693
+ 'via',
694
+ 'zone',
695
+ 'solder-mask',
696
+ 'solder-paste'
697
+ ].includes(primitive.kind)
698
+ }
699
+
700
+ /**
701
+ * Finds primitives matching source id fields.
702
+ * @param {Array<[string, unknown]>} ids Field/value pairs.
703
+ * @param {object[]} primitives Primitive rows.
704
+ * @returns {object[]}
705
+ */
706
+ static #matchingPrimitives(ids, primitives) {
707
+ if (!ids.length) return []
708
+ return primitives.filter((primitive) =>
709
+ ids.some(
710
+ ([field, value]) =>
711
+ String(primitive.source?.[field] || '').trim() ===
712
+ String(value || '').trim()
713
+ )
714
+ )
715
+ }
716
+
717
+ /**
718
+ * Resolves PCB port ids linked to one source port.
719
+ * @param {{ elementsByType: Map<string, object[]> }} index Element index.
720
+ * @param {unknown} sourcePortId Source port id.
721
+ * @returns {string[]}
722
+ */
723
+ static #pcbPortIdsForSourcePort(index, sourcePortId) {
724
+ const id = String(sourcePortId || '').trim()
725
+ if (!id) return []
726
+ return CircuitJsonPcbPrimitiveOverlays.#all(index, 'pcb_port')
727
+ .filter((port) => String(port.source_port_id || '').trim() === id)
728
+ .map((port) => String(port.pcb_port_id || '').trim())
729
+ .filter(Boolean)
730
+ }
731
+
732
+ /**
733
+ * Resolves a PCB component by source component id.
734
+ * @param {object} element Diagnostic element.
735
+ * @param {Map<string, object>} componentsByPcbId Component lookup.
736
+ * @returns {object | undefined}
737
+ */
738
+ static #sourceComponent(element, componentsByPcbId) {
739
+ const sourceId = String(element?.source_component_id || '').trim()
740
+ if (!sourceId) return undefined
741
+ return [...componentsByPcbId.values()].find(
742
+ (component) =>
743
+ String(component.sourceComponentId || '').trim() === sourceId
744
+ )
745
+ }
746
+
747
+ /**
748
+ * Resolves the center point of bounds.
749
+ * @param {object} bounds Bounds record.
750
+ * @returns {{ x: number, y: number }}
751
+ */
752
+ static #boundsCenter(bounds) {
753
+ return {
754
+ x: bounds.minX + bounds.width / 2,
755
+ y: bounds.minY + bounds.height / 2
756
+ }
757
+ }
758
+
759
+ /**
760
+ * Merges bounds rows.
761
+ * @param {object[]} rows Bounds rows.
762
+ * @returns {object | null}
763
+ */
764
+ static #mergeBounds(rows) {
765
+ const validRows = rows.filter(Boolean)
766
+ if (!validRows.length) return null
767
+ const minX = Math.min(...validRows.map((bounds) => bounds.minX))
768
+ const minY = Math.min(...validRows.map((bounds) => bounds.minY))
769
+ const maxX = Math.max(...validRows.map((bounds) => bounds.maxX))
770
+ const maxY = Math.max(...validRows.map((bounds) => bounds.maxY))
771
+ return {
772
+ minX: CircuitJsonPcbPrimitiveOverlays.#round(minX),
773
+ minY: CircuitJsonPcbPrimitiveOverlays.#round(minY),
774
+ maxX: CircuitJsonPcbPrimitiveOverlays.#round(maxX),
775
+ maxY: CircuitJsonPcbPrimitiveOverlays.#round(maxY),
776
+ width: CircuitJsonPcbPrimitiveOverlays.#round(maxX - minX),
777
+ height: CircuitJsonPcbPrimitiveOverlays.#round(maxY - minY)
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Rounds one computed geometry value.
783
+ * @param {number} value Numeric value.
784
+ * @returns {number}
785
+ */
786
+ static #round(value) {
787
+ return Number(Number(value).toFixed(6))
788
+ }
789
+
790
+ /**
791
+ * Resolves a center point.
792
+ * @param {object} element Element row.
793
+ * @returns {{ x: number, y: number } | null}
794
+ */
795
+ static #center(element) {
796
+ return CircuitJsonUnits.optionalPoint(element?.center || element)
797
+ }
798
+
799
+ /**
800
+ * Resolves a net name from common fields.
801
+ * @param {object} element Element row.
802
+ * @returns {string}
803
+ */
804
+ static #netName(element) {
805
+ return String(
806
+ element?.netName ??
807
+ element?.net ??
808
+ element?.net_name ??
809
+ element?.source_net_name ??
810
+ ''
811
+ ).trim()
812
+ }
813
+
814
+ /**
815
+ * Resolves a broad diagnostic category from a diagnostic code.
816
+ * @param {string} code Diagnostic code.
817
+ * @returns {string}
818
+ */
819
+ static #diagnosticCategory(code) {
820
+ const text = String(code || '').toLowerCase()
821
+ if (text.includes('clearance')) return 'clearance'
822
+ if (text.includes('autorouting') || text.includes('trace_error')) {
823
+ return 'routing'
824
+ }
825
+ if (text.includes('placement') || text.includes('outside_board')) {
826
+ return 'placement'
827
+ }
828
+ if (
829
+ text.includes('trace_missing') ||
830
+ text.includes('missing_trace') ||
831
+ text.includes('not_connected') ||
832
+ text.includes('pin_missing_trace') ||
833
+ text.includes('pin_must_be_connected')
834
+ ) {
835
+ return 'connectivity'
836
+ }
837
+ if (text.includes('layout')) return 'layout'
838
+ if (text.includes('simulation')) return 'simulation'
839
+ if (text.includes('footprint')) return 'footprint'
840
+ if (
841
+ text.includes('pin_defined') ||
842
+ text.includes('pins_underspecified') ||
843
+ text.includes('ground_pin') ||
844
+ text.includes('power_pin')
845
+ ) {
846
+ return 'pin-definition'
847
+ }
848
+ if (
849
+ text.includes('manufacturer_part') ||
850
+ text.includes('missing_property') ||
851
+ text.includes('property_ignored')
852
+ ) {
853
+ return 'metadata'
854
+ }
855
+ if (text.includes('manual_edit_conflict')) return 'edit-conflict'
856
+ if (text.includes('configuration')) return 'configuration'
857
+ return 'general'
858
+ }
859
+
860
+ /**
861
+ * Resolves the first non-empty string.
862
+ * @param {unknown[]} values Candidate values.
863
+ * @returns {string}
864
+ */
865
+ static #firstString(values) {
866
+ for (const value of values) {
867
+ const text = String(value ?? '').trim()
868
+ if (text) return text
869
+ }
870
+ return ''
871
+ }
872
+
873
+ /**
874
+ * Resolves a side from a layer key.
875
+ * @param {string} layer Layer key.
876
+ * @returns {'top' | 'bottom' | ''}
877
+ */
878
+ static #side(layer) {
879
+ const text = String(layer || '').toLowerCase()
880
+ if (/\b(bottom|back)\b|\bb[._-]/u.test(text)) return 'bottom'
881
+ if (/\b(top|front)\b|\bf[._-]/u.test(text)) return 'top'
882
+ return ''
883
+ }
884
+
885
+ /**
886
+ * Formats a virtual layer name.
887
+ * @param {string} key Layer key.
888
+ * @returns {string}
889
+ */
890
+ static #displayLayerName(key) {
891
+ return String(key)
892
+ .replaceAll('_', ' ')
893
+ .replace(/\b\w/gu, (match) => match.toUpperCase())
894
+ }
895
+ }