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,990 @@
1
+ import { CircuitJsonUnits } from './CircuitJsonUnits.mjs'
2
+
3
+ const KNOWN_ELEMENT_TYPES = new Set([
4
+ 'cad_component',
5
+ 'circuit_json_footprint_load_error',
6
+ 'external_footprint_load_error',
7
+ 'pcb_autorouting_error',
8
+ 'pcb_board',
9
+ 'pcb_breakout_point',
10
+ 'pcb_component',
11
+ 'pcb_component_invalid_layer_error',
12
+ 'pcb_component_not_on_board_edge_error',
13
+ 'pcb_component_outside_board_error',
14
+ 'pcb_connector_not_in_accessible_orientation_warning',
15
+ 'pcb_copper_pour',
16
+ 'pcb_copper_text',
17
+ 'pcb_courtyard',
18
+ 'pcb_courtyard_circle',
19
+ 'pcb_courtyard_line',
20
+ 'pcb_courtyard_outline',
21
+ 'pcb_courtyard_path',
22
+ 'pcb_courtyard_overlap_error',
23
+ 'pcb_courtyard_pill',
24
+ 'pcb_courtyard_polygon',
25
+ 'pcb_courtyard_rect',
26
+ 'pcb_cutout',
27
+ 'pcb_fabrication_note_dimension',
28
+ 'pcb_fabrication_note_path',
29
+ 'pcb_fabrication_note_rect',
30
+ 'pcb_fabrication_note_text',
31
+ 'pcb_footprint_overlap_error',
32
+ 'pcb_ground_plane',
33
+ 'pcb_ground_plane_region',
34
+ 'pcb_group',
35
+ 'pcb_hole',
36
+ 'pcb_keepout',
37
+ 'pcb_manual_edit_conflict_warning',
38
+ 'pcb_missing_footprint_error',
39
+ 'pcb_net',
40
+ 'pcb_note_dimension',
41
+ 'pcb_note_line',
42
+ 'pcb_note_path',
43
+ 'pcb_note_rect',
44
+ 'pcb_note_text',
45
+ 'pcb_pad_pad_clearance_error',
46
+ 'pcb_pad_trace_clearance_error',
47
+ 'pcb_panel',
48
+ 'pcb_panelization_placement_error',
49
+ 'pcb_placement_error',
50
+ 'pcb_plated_hole',
51
+ 'pcb_port',
52
+ 'pcb_port_not_connected_error',
53
+ 'pcb_port_not_matched_error',
54
+ 'pcb_silkscreen_circle',
55
+ 'pcb_silkscreen_graphic',
56
+ 'pcb_silkscreen_line',
57
+ 'pcb_silkscreen_oval',
58
+ 'pcb_silkscreen_path',
59
+ 'pcb_silkscreen_pill',
60
+ 'pcb_silkscreen_rect',
61
+ 'pcb_silkscreen_text',
62
+ 'pcb_smtpad',
63
+ 'pcb_solder_paste',
64
+ 'pcb_text',
65
+ 'pcb_thermal_spoke',
66
+ 'pcb_trace',
67
+ 'pcb_trace_error',
68
+ 'pcb_trace_hint',
69
+ 'pcb_trace_missing_error',
70
+ 'pcb_trace_warning',
71
+ 'pcb_via',
72
+ 'pcb_via_clearance_error',
73
+ 'pcb_via_trace_clearance_error',
74
+ 'schematic_arc',
75
+ 'schematic_box',
76
+ 'schematic_circle',
77
+ 'schematic_component',
78
+ 'schematic_debug_object',
79
+ 'schematic_error',
80
+ 'schematic_group',
81
+ 'schematic_layout_error',
82
+ 'schematic_line',
83
+ 'schematic_manual_edit_conflict_warning',
84
+ 'schematic_net_label',
85
+ 'schematic_path',
86
+ 'schematic_port',
87
+ 'schematic_rect',
88
+ 'schematic_sheet',
89
+ 'schematic_symbol',
90
+ 'schematic_table',
91
+ 'schematic_table_cell',
92
+ 'schematic_text',
93
+ 'schematic_trace',
94
+ 'schematic_voltage_probe',
95
+ 'simulation_current_probe',
96
+ 'simulation_current_source',
97
+ 'simulation_experiment',
98
+ 'simulation_op_amp',
99
+ 'simulation_oscilloscope_trace',
100
+ 'simulation_spice_subcircuit',
101
+ 'simulation_switch',
102
+ 'simulation_transient_current_graph',
103
+ 'simulation_transient_voltage_graph',
104
+ 'simulation_unknown_experiment_error',
105
+ 'simulation_voltage_probe',
106
+ 'simulation_voltage_source',
107
+ 'source_ambiguous_port_reference',
108
+ 'source_board',
109
+ 'source_component',
110
+ 'source_component_internal_connection',
111
+ 'source_component_misconfigured_error',
112
+ 'source_component_pins_underspecified_warning',
113
+ 'source_failed_to_create_component_error',
114
+ 'source_group',
115
+ 'source_i2c_misconfigured_error',
116
+ 'source_invalid_component_property_error',
117
+ 'source_manually_placed_via',
118
+ 'source_missing_manufacturer_part_number_warning',
119
+ 'source_missing_property_error',
120
+ 'source_net',
121
+ 'source_no_ground_pin_defined_warning',
122
+ 'source_no_power_pin_defined_warning',
123
+ 'source_pcb_ground_plane',
124
+ 'source_pin_missing_trace_warning',
125
+ 'source_pin_must_be_connected_error',
126
+ 'source_port',
127
+ 'source_project_metadata',
128
+ 'source_property_ignored_warning',
129
+ 'source_trace',
130
+ 'source_trace_not_connected_error',
131
+ 'supplier_footprint_mismatch_warning',
132
+ 'unknown_error_finding_part'
133
+ ])
134
+
135
+ const ID_FIELD_EXCEPTIONS = new Set([
136
+ 'pcb_autorouting_error',
137
+ 'pcb_courtyard_overlap_error',
138
+ 'pcb_footprint_overlap_error',
139
+ 'pcb_port_not_matched_error',
140
+ 'pcb_via_clearance_error',
141
+ 'schematic_box',
142
+ 'schematic_debug_object',
143
+ 'source_project_metadata'
144
+ ])
145
+
146
+ const SOURCE_COMPONENT_FTYPES = new Set([
147
+ 'interconnect',
148
+ 'simple_ammeter',
149
+ 'simple_battery',
150
+ 'simple_capacitor',
151
+ 'simple_chip',
152
+ 'simple_connector',
153
+ 'simple_crystal',
154
+ 'simple_current_source',
155
+ 'simple_diode',
156
+ 'simple_fiducial',
157
+ 'simple_fuse',
158
+ 'simple_ground',
159
+ 'simple_inductor',
160
+ 'simple_led',
161
+ 'simple_mosfet',
162
+ 'simple_op_amp',
163
+ 'simple_pin_header',
164
+ 'simple_pinout',
165
+ 'simple_potentiometer',
166
+ 'simple_power_source',
167
+ 'simple_push_button',
168
+ 'simple_resistor',
169
+ 'simple_resonator',
170
+ 'simple_switch',
171
+ 'simple_test_point',
172
+ 'simple_transistor',
173
+ 'simple_voltage_probe',
174
+ 'simple_voltage_source'
175
+ ])
176
+
177
+ const LAYERS = new Set(
178
+ 'top bottom inner1 inner2 inner3 inner4 inner5 inner6'.split(' ')
179
+ )
180
+
181
+ const SMT_PAD_SHAPES = new Set([
182
+ 'circle',
183
+ 'rect',
184
+ 'rotated_rect',
185
+ 'rotated_pill',
186
+ 'pill',
187
+ 'polygon',
188
+ 'rounded_rect'
189
+ ])
190
+
191
+ const PCB_HOLE_SHAPES = new Set([
192
+ 'circle',
193
+ 'circle_or_square',
194
+ 'oval',
195
+ 'pill',
196
+ 'rect',
197
+ 'rotated_pill',
198
+ 'round'
199
+ ])
200
+
201
+ const PCB_PLATED_HOLE_SHAPES = new Set([
202
+ 'circle',
203
+ 'circular_hole_with_rect_pad',
204
+ 'hole_with_polygon_pad',
205
+ 'oval',
206
+ 'pill',
207
+ 'pill_hole_with_rect_pad',
208
+ 'rotated_pill_hole_with_rect_pad'
209
+ ])
210
+
211
+ const PCB_PLATED_HOLE_HOLE_SHAPES = new Set([
212
+ 'circle',
213
+ 'oval',
214
+ 'pill',
215
+ 'rotated_pill'
216
+ ])
217
+
218
+ const PCB_SOLDER_PASTE_SHAPES = new Set([
219
+ 'circle',
220
+ 'oval',
221
+ 'pill',
222
+ 'rect',
223
+ 'rotated_rect'
224
+ ])
225
+
226
+ const PCB_BOARD_SHAPES = new Set(['polygon', 'rect'])
227
+
228
+ const PCB_CUTOUT_SHAPES = new Set(['circle', 'path', 'polygon', 'rect'])
229
+
230
+ const PCB_COPPER_POUR_SHAPES = new Set(['brep', 'polygon', 'rect'])
231
+
232
+ const SIMULATION_SOURCE_KINDS = new Set(['ac', 'dc'])
233
+
234
+ const SIMULATION_WAVE_SHAPES = new Set(
235
+ 'sawtooth sinewave square triangle'.split(' ')
236
+ )
237
+
238
+ const SIMULATION_EXPERIMENT_METHODS = new Set(['gear', 'trap'])
239
+
240
+ const VARIANT_SETS = {
241
+ sourceComponentFtypes: SOURCE_COMPONENT_FTYPES,
242
+ pcbBoardShapes: PCB_BOARD_SHAPES,
243
+ pcbSmtPadShapes: SMT_PAD_SHAPES,
244
+ pcbHoleShapes: PCB_HOLE_SHAPES,
245
+ pcbPlatedHoleShapes: PCB_PLATED_HOLE_SHAPES,
246
+ pcbPlatedHoleHoleShapes: PCB_PLATED_HOLE_HOLE_SHAPES,
247
+ pcbSolderPasteShapes: PCB_SOLDER_PASTE_SHAPES,
248
+ pcbCutoutShapes: PCB_CUTOUT_SHAPES,
249
+ pcbCopperPourShapes: PCB_COPPER_POUR_SHAPES,
250
+ simulationSourceKinds: SIMULATION_SOURCE_KINDS,
251
+ simulationWaveShapes: SIMULATION_WAVE_SHAPES,
252
+ simulationExperimentMethods: SIMULATION_EXPERIMENT_METHODS
253
+ }
254
+
255
+ const SORTED_VARIANT_SET_NAMES = Object.keys(VARIANT_SETS).sort()
256
+
257
+ /**
258
+ * @typedef {{ set: string, value: string }} CircuitJsonVariantDiff
259
+ */
260
+
261
+ /**
262
+ * @typedef {{ elementTypes: string[], idFieldExceptions: string[], variantSets: Record<string, string[]> }} CircuitJsonSchemaSnapshot
263
+ */
264
+
265
+ /**
266
+ * @typedef {{ matches: boolean, missingElementTypes: string[], unexpectedElementTypes: string[], missingIdFieldExceptions: string[], unexpectedIdFieldExceptions: string[], missingVariants: CircuitJsonVariantDiff[], unexpectedVariants: CircuitJsonVariantDiff[] }} CircuitJsonSchemaSnapshotComparison
267
+ */
268
+
269
+ /**
270
+ * Validates serialized CircuitJSON element objects without external runtime
271
+ * dependencies.
272
+ */
273
+ export class CircuitJsonElementValidator {
274
+ /**
275
+ * Returns validation errors for a candidate model.
276
+ * @param {unknown} value Candidate model.
277
+ * @returns {string[]}
278
+ */
279
+ static validateModel(value) {
280
+ if (!Array.isArray(value)) {
281
+ return ['Expected a CircuitJSON element array.']
282
+ }
283
+
284
+ return value.flatMap((element, index) =>
285
+ CircuitJsonElementValidator.validateElement(element, index)
286
+ )
287
+ }
288
+
289
+ /**
290
+ * Returns validation errors for one candidate element.
291
+ * @param {unknown} value Candidate element.
292
+ * @param {number} [index] Element index.
293
+ * @returns {string[]}
294
+ */
295
+ static validateElement(value, index = -1) {
296
+ const location = index >= 0 ? ' at index ' + index : ''
297
+ if (!CircuitJsonElementValidator.#isObject(value)) {
298
+ return ['Expected a CircuitJSON element object' + location + '.']
299
+ }
300
+
301
+ const type = String(value.type || '').trim()
302
+ if (!type) {
303
+ return ['CircuitJSON element type is required' + location + '.']
304
+ }
305
+
306
+ if (!KNOWN_ELEMENT_TYPES.has(type)) {
307
+ return ['Unsupported CircuitJSON element type: ' + type + '.']
308
+ }
309
+
310
+ const errors = []
311
+ CircuitJsonElementValidator.#validateId(value, type, errors)
312
+ CircuitJsonElementValidator.#validateCoreShape(value, type, errors)
313
+ return errors
314
+ }
315
+
316
+ /**
317
+ * Returns all known serialized element type names.
318
+ * @returns {string[]}
319
+ */
320
+ static knownElementTypes() {
321
+ return [...KNOWN_ELEMENT_TYPES]
322
+ }
323
+
324
+ /**
325
+ * Returns id convention exceptions from the current schema snapshot.
326
+ * @returns {string[]}
327
+ */
328
+ static idFieldExceptions() {
329
+ return [...ID_FIELD_EXCEPTIONS]
330
+ }
331
+
332
+ /**
333
+ * Returns schema metadata suitable for drift snapshots.
334
+ * @returns {CircuitJsonSchemaSnapshot}
335
+ */
336
+ static schemaSnapshot() {
337
+ return {
338
+ elementTypes: CircuitJsonElementValidator.knownElementTypes(),
339
+ idFieldExceptions: CircuitJsonElementValidator.idFieldExceptions(),
340
+ variantSets: CircuitJsonElementValidator.variantSets()
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Returns known variant discriminants from the active schema metadata.
346
+ * @returns {Record<string, string[]>}
347
+ */
348
+ static variantSets() {
349
+ return Object.fromEntries(
350
+ SORTED_VARIANT_SET_NAMES.map((name) => [
351
+ name,
352
+ [...VARIANT_SETS[name]].sort()
353
+ ])
354
+ )
355
+ }
356
+
357
+ /**
358
+ * Compares current schema metadata against a saved snapshot.
359
+ * @param {{ elementTypes?: string[], idFieldExceptions?: string[], variantSets?: Record<string, string[]> }} snapshot Schema snapshot.
360
+ * @returns {CircuitJsonSchemaSnapshotComparison}
361
+ */
362
+ static compareSchemaSnapshot(snapshot = {}) {
363
+ const elementComparison = CircuitJsonElementValidator.#compareSets(
364
+ new Set(snapshot.elementTypes || []),
365
+ KNOWN_ELEMENT_TYPES
366
+ )
367
+ const exceptionComparison = CircuitJsonElementValidator.#compareSets(
368
+ new Set(snapshot.idFieldExceptions || []),
369
+ ID_FIELD_EXCEPTIONS
370
+ )
371
+ const variantComparison =
372
+ CircuitJsonElementValidator.#compareVariantSets(
373
+ snapshot.variantSets || {}
374
+ )
375
+
376
+ return {
377
+ matches:
378
+ elementComparison.missing.length === 0 &&
379
+ elementComparison.unexpected.length === 0 &&
380
+ exceptionComparison.missing.length === 0 &&
381
+ exceptionComparison.unexpected.length === 0 &&
382
+ variantComparison.missing.length === 0 &&
383
+ variantComparison.unexpected.length === 0,
384
+ missingElementTypes: elementComparison.missing,
385
+ unexpectedElementTypes: elementComparison.unexpected,
386
+ missingIdFieldExceptions: exceptionComparison.missing,
387
+ unexpectedIdFieldExceptions: exceptionComparison.unexpected,
388
+ missingVariants: variantComparison.missing,
389
+ unexpectedVariants: variantComparison.unexpected
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Validates the common id convention.
395
+ * @param {Record<string, unknown>} element Element.
396
+ * @param {string} type Element type.
397
+ * @param {string[]} errors Error sink.
398
+ * @returns {void}
399
+ */
400
+ static #validateId(element, type, errors) {
401
+ if (ID_FIELD_EXCEPTIONS.has(type)) {
402
+ return
403
+ }
404
+
405
+ const idField = type + '_id'
406
+ if (!CircuitJsonElementValidator.#isNonEmptyString(element[idField])) {
407
+ errors.push(type + ' ' + idField + ' is required.')
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Validates type-specific fields used by core consumers.
413
+ * @param {Record<string, unknown>} element Element.
414
+ * @param {string} type Element type.
415
+ * @param {string[]} errors Error sink.
416
+ * @returns {void}
417
+ */
418
+ static #validateCoreShape(element, type, errors) {
419
+ if (type === 'source_component') {
420
+ CircuitJsonElementValidator.#validateSourceComponent(
421
+ element,
422
+ errors
423
+ )
424
+ return
425
+ }
426
+
427
+ if (type === 'source_port') {
428
+ CircuitJsonElementValidator.#validateSourcePort(element, errors)
429
+ return
430
+ }
431
+
432
+ if (type === 'schematic_component') {
433
+ CircuitJsonElementValidator.#validateSchematicComponent(
434
+ element,
435
+ errors
436
+ )
437
+ return
438
+ }
439
+
440
+ if (type === 'pcb_board') {
441
+ CircuitJsonElementValidator.#validatePcbBoard(element, errors)
442
+ return
443
+ }
444
+
445
+ if (type === 'pcb_component') {
446
+ CircuitJsonElementValidator.#validatePcbComponent(element, errors)
447
+ return
448
+ }
449
+
450
+ if (type === 'pcb_smtpad') {
451
+ CircuitJsonElementValidator.#validatePcbSmtPad(element, errors)
452
+ return
453
+ }
454
+
455
+ if (type === 'simulation_oscilloscope_trace') {
456
+ CircuitJsonElementValidator.#validateSimulationOscilloscopeTrace(
457
+ element,
458
+ errors
459
+ )
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Validates a source component.
465
+ * @param {Record<string, unknown>} element Element.
466
+ * @param {string[]} errors Error sink.
467
+ * @returns {void}
468
+ */
469
+ static #validateSourceComponent(element, errors) {
470
+ CircuitJsonElementValidator.#requireString(
471
+ element,
472
+ 'source_component',
473
+ 'name',
474
+ errors
475
+ )
476
+
477
+ if (
478
+ Object.hasOwn(element, 'supplier_part_numbers') &&
479
+ !CircuitJsonElementValidator.#isPlainObject(
480
+ element.supplier_part_numbers
481
+ )
482
+ ) {
483
+ errors.push(
484
+ 'source_component supplier_part_numbers must be an object.'
485
+ )
486
+ }
487
+
488
+ if (
489
+ Object.hasOwn(element, 'ftype') &&
490
+ !SOURCE_COMPONENT_FTYPES.has(String(element.ftype || ''))
491
+ ) {
492
+ errors.push('source_component ftype is not supported.')
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Validates a source port.
498
+ * @param {Record<string, unknown>} element Element.
499
+ * @param {string[]} errors Error sink.
500
+ * @returns {void}
501
+ */
502
+ static #validateSourcePort(element, errors) {
503
+ if (
504
+ Object.hasOwn(element, 'pin_number') &&
505
+ !Number.isFinite(element.pin_number)
506
+ ) {
507
+ errors.push('source_port pin_number must be a number.')
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Validates a schematic component.
513
+ * @param {Record<string, unknown>} element Element.
514
+ * @param {string[]} errors Error sink.
515
+ * @returns {void}
516
+ */
517
+ static #validateSchematicComponent(element, errors) {
518
+ CircuitJsonElementValidator.#requirePoint(
519
+ element,
520
+ 'schematic_component',
521
+ 'center',
522
+ errors
523
+ )
524
+ CircuitJsonElementValidator.#requireSize(
525
+ element,
526
+ 'schematic_component',
527
+ 'size',
528
+ errors
529
+ )
530
+ }
531
+
532
+ /**
533
+ * Validates a PCB board.
534
+ * @param {Record<string, unknown>} element Element.
535
+ * @param {string[]} errors Error sink.
536
+ * @returns {void}
537
+ */
538
+ static #validatePcbBoard(element, errors) {
539
+ CircuitJsonElementValidator.#requirePoint(
540
+ element,
541
+ 'pcb_board',
542
+ 'center',
543
+ errors
544
+ )
545
+ }
546
+
547
+ /**
548
+ * Validates a PCB component.
549
+ * @param {Record<string, unknown>} element Element.
550
+ * @param {string[]} errors Error sink.
551
+ * @returns {void}
552
+ */
553
+ static #validatePcbComponent(element, errors) {
554
+ CircuitJsonElementValidator.#requireString(
555
+ element,
556
+ 'pcb_component',
557
+ 'source_component_id',
558
+ errors
559
+ )
560
+ CircuitJsonElementValidator.#requirePoint(
561
+ element,
562
+ 'pcb_component',
563
+ 'center',
564
+ errors
565
+ )
566
+ CircuitJsonElementValidator.#requireLayer(
567
+ element,
568
+ 'pcb_component',
569
+ 'layer',
570
+ errors
571
+ )
572
+ CircuitJsonElementValidator.#optionalAngle(
573
+ element,
574
+ 'pcb_component',
575
+ 'rotation',
576
+ errors
577
+ )
578
+ CircuitJsonElementValidator.#optionalLength(
579
+ element,
580
+ 'pcb_component',
581
+ 'width',
582
+ errors
583
+ )
584
+ CircuitJsonElementValidator.#optionalLength(
585
+ element,
586
+ 'pcb_component',
587
+ 'height',
588
+ errors
589
+ )
590
+ }
591
+
592
+ /**
593
+ * Validates an SMT pad.
594
+ * @param {Record<string, unknown>} element Element.
595
+ * @param {string[]} errors Error sink.
596
+ * @returns {void}
597
+ */
598
+ static #validatePcbSmtPad(element, errors) {
599
+ const shape = String(element.shape || '')
600
+ if (!SMT_PAD_SHAPES.has(shape)) {
601
+ errors.push(
602
+ 'pcb_smtpad shape must be one of: ' +
603
+ [...SMT_PAD_SHAPES].join(', ') +
604
+ '.'
605
+ )
606
+ return
607
+ }
608
+
609
+ CircuitJsonElementValidator.#requireLayer(
610
+ element,
611
+ 'pcb_smtpad',
612
+ 'layer',
613
+ errors
614
+ )
615
+
616
+ if (shape === 'polygon') {
617
+ if (!Array.isArray(element.points)) {
618
+ errors.push('pcb_smtpad points is required.')
619
+ }
620
+ return
621
+ }
622
+
623
+ CircuitJsonElementValidator.#requireLength(
624
+ element,
625
+ 'pcb_smtpad',
626
+ 'x',
627
+ errors
628
+ )
629
+ CircuitJsonElementValidator.#requireLength(
630
+ element,
631
+ 'pcb_smtpad',
632
+ 'y',
633
+ errors
634
+ )
635
+
636
+ if (shape === 'circle') {
637
+ if (
638
+ !CircuitJsonElementValidator.#hasLength(element, 'radius') &&
639
+ !CircuitJsonElementValidator.#hasLength(element, 'diameter') &&
640
+ !(
641
+ CircuitJsonElementValidator.#hasLength(element, 'width') &&
642
+ CircuitJsonElementValidator.#hasLength(element, 'height')
643
+ )
644
+ ) {
645
+ errors.push(
646
+ 'pcb_smtpad radius, diameter, or width and height is required.'
647
+ )
648
+ }
649
+ return
650
+ }
651
+
652
+ CircuitJsonElementValidator.#requireLength(
653
+ element,
654
+ 'pcb_smtpad',
655
+ 'width',
656
+ errors
657
+ )
658
+ CircuitJsonElementValidator.#requireLength(
659
+ element,
660
+ 'pcb_smtpad',
661
+ 'height',
662
+ errors
663
+ )
664
+
665
+ if (shape.startsWith('rotated_')) {
666
+ CircuitJsonElementValidator.#requireAngle(
667
+ element,
668
+ 'pcb_smtpad',
669
+ 'ccw_rotation',
670
+ errors
671
+ )
672
+ }
673
+
674
+ if (shape.endsWith('pill')) {
675
+ CircuitJsonElementValidator.#optionalLength(
676
+ element,
677
+ 'pcb_smtpad',
678
+ 'radius',
679
+ errors
680
+ )
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Validates oscilloscope trace reference and unit constraints.
686
+ * @param {Record<string, unknown>} element Element.
687
+ * @param {string[]} errors Error sink.
688
+ * @returns {void}
689
+ */
690
+ static #validateSimulationOscilloscopeTrace(element, errors) {
691
+ const voltageReferences = [
692
+ element.simulation_transient_voltage_graph_id,
693
+ element.simulation_voltage_probe_id
694
+ ].filter((value) =>
695
+ CircuitJsonElementValidator.#isNonEmptyString(value)
696
+ ).length
697
+ const currentReferences = [
698
+ element.simulation_transient_current_graph_id,
699
+ element.simulation_current_probe_id
700
+ ].filter((value) =>
701
+ CircuitJsonElementValidator.#isNonEmptyString(value)
702
+ ).length
703
+
704
+ if (voltageReferences + currentReferences !== 1) {
705
+ errors.push(
706
+ 'simulation_oscilloscope_trace must reference exactly one graph or probe.'
707
+ )
708
+ }
709
+ if (voltageReferences > 0 && Object.hasOwn(element, 'amps_per_div')) {
710
+ errors.push(
711
+ 'voltage oscilloscope traces must use volts_per_div, not amps_per_div.'
712
+ )
713
+ }
714
+ if (currentReferences > 0 && Object.hasOwn(element, 'volts_per_div')) {
715
+ errors.push(
716
+ 'current oscilloscope traces must use amps_per_div, not volts_per_div.'
717
+ )
718
+ }
719
+ CircuitJsonElementValidator.#optionalPositiveNumber(
720
+ element,
721
+ 'simulation_oscilloscope_trace',
722
+ 'volts_per_div',
723
+ errors
724
+ )
725
+ CircuitJsonElementValidator.#optionalPositiveNumber(
726
+ element,
727
+ 'simulation_oscilloscope_trace',
728
+ 'amps_per_div',
729
+ errors
730
+ )
731
+ }
732
+
733
+ /**
734
+ * Requires a non-empty string field.
735
+ * @param {Record<string, unknown>} element Element.
736
+ * @param {string} type Element type.
737
+ * @param {string} field Field name.
738
+ * @param {string[]} errors Error sink.
739
+ * @returns {void}
740
+ */
741
+ static #requireString(element, type, field, errors) {
742
+ if (!CircuitJsonElementValidator.#isNonEmptyString(element[field])) {
743
+ errors.push(type + ' ' + field + ' is required.')
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Requires a finite number field.
749
+ * @param {Record<string, unknown>} element Element.
750
+ * @param {string} type Element type.
751
+ * @param {string} field Field name.
752
+ * @param {string[]} errors Error sink.
753
+ * @returns {void}
754
+ */
755
+ static #requireNumber(element, type, field, errors) {
756
+ if (!Number.isFinite(element[field])) {
757
+ errors.push(type + ' ' + field + ' is required.')
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Requires a finite length field.
763
+ * @param {Record<string, unknown>} element Element.
764
+ * @param {string} type Element type.
765
+ * @param {string} field Field name.
766
+ * @param {string[]} errors Error sink.
767
+ * @returns {void}
768
+ */
769
+ static #requireLength(element, type, field, errors) {
770
+ if (CircuitJsonUnits.optionalLength(element[field]) === null) {
771
+ errors.push(type + ' ' + field + ' is required.')
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Returns true when a field is a finite length.
777
+ * @param {Record<string, unknown>} element Element.
778
+ * @param {string} field Field name.
779
+ * @returns {boolean}
780
+ */
781
+ static #hasLength(element, field) {
782
+ return CircuitJsonUnits.optionalLength(element[field]) !== null
783
+ }
784
+
785
+ /**
786
+ * Requires a finite angle field.
787
+ * @param {Record<string, unknown>} element Element.
788
+ * @param {string} type Element type.
789
+ * @param {string} field Field name.
790
+ * @param {string[]} errors Error sink.
791
+ * @returns {void}
792
+ */
793
+ static #requireAngle(element, type, field, errors) {
794
+ if (CircuitJsonUnits.optionalAngle(element[field]) === null) {
795
+ errors.push(type + ' ' + field + ' is required.')
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Validates an optional finite length field.
801
+ * @param {Record<string, unknown>} element Element.
802
+ * @param {string} type Element type.
803
+ * @param {string} field Field name.
804
+ * @param {string[]} errors Error sink.
805
+ * @returns {void}
806
+ */
807
+ static #optionalLength(element, type, field, errors) {
808
+ if (
809
+ Object.hasOwn(element, field) &&
810
+ CircuitJsonUnits.optionalLength(element[field]) === null
811
+ ) {
812
+ errors.push(type + ' ' + field + ' must be a finite length.')
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Validates an optional finite angle field.
818
+ * @param {Record<string, unknown>} element Element.
819
+ * @param {string} type Element type.
820
+ * @param {string} field Field name.
821
+ * @param {string[]} errors Error sink.
822
+ * @returns {void}
823
+ */
824
+ static #optionalAngle(element, type, field, errors) {
825
+ if (
826
+ Object.hasOwn(element, field) &&
827
+ CircuitJsonUnits.optionalAngle(element[field]) === null
828
+ ) {
829
+ errors.push(type + ' ' + field + ' must be a finite angle.')
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Validates an optional positive finite number field.
835
+ * @param {Record<string, unknown>} element Element.
836
+ * @param {string} type Element type.
837
+ * @param {string} field Field name.
838
+ * @param {string[]} errors Error sink.
839
+ * @returns {void}
840
+ */
841
+ static #optionalPositiveNumber(element, type, field, errors) {
842
+ if (!Object.hasOwn(element, field)) {
843
+ return
844
+ }
845
+ const value = Number(element[field])
846
+ if (!Number.isFinite(value) || value <= 0) {
847
+ errors.push(type + ' ' + field + ' must be a positive number.')
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Requires a finite point object field.
853
+ * @param {Record<string, unknown>} element Element.
854
+ * @param {string} type Element type.
855
+ * @param {string} field Field name.
856
+ * @param {string[]} errors Error sink.
857
+ * @returns {void}
858
+ */
859
+ static #requirePoint(element, type, field, errors) {
860
+ const point = element[field]
861
+ if (!CircuitJsonElementValidator.#isObject(point)) {
862
+ errors.push(type + ' ' + field + ' is required.')
863
+ return
864
+ }
865
+
866
+ if (CircuitJsonUnits.optionalPoint(point) === null) {
867
+ errors.push(type + ' ' + field + ' is required.')
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Requires a finite size object field.
873
+ * @param {Record<string, unknown>} element Element.
874
+ * @param {string} type Element type.
875
+ * @param {string} field Field name.
876
+ * @param {string[]} errors Error sink.
877
+ * @returns {void}
878
+ */
879
+ static #requireSize(element, type, field, errors) {
880
+ const size = element[field]
881
+ if (!CircuitJsonElementValidator.#isObject(size)) {
882
+ errors.push(type + ' ' + field + ' is required.')
883
+ return
884
+ }
885
+
886
+ if (CircuitJsonUnits.optionalSize(size) === null) {
887
+ errors.push(type + ' ' + field + ' is required.')
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Requires a known layer field.
893
+ * @param {Record<string, unknown>} element Element.
894
+ * @param {string} type Element type.
895
+ * @param {string} field Field name.
896
+ * @param {string[]} errors Error sink.
897
+ * @returns {void}
898
+ */
899
+ static #requireLayer(element, type, field, errors) {
900
+ const layer =
901
+ typeof element[field] === 'object' && element[field] !== null
902
+ ? element[field].name
903
+ : element[field]
904
+ if (!LAYERS.has(String(layer || ''))) {
905
+ errors.push(type + ' ' + field + ' is required.')
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Returns true for non-null objects.
911
+ * @param {unknown} value Candidate.
912
+ * @returns {boolean}
913
+ */
914
+ static #isObject(value) {
915
+ return Boolean(value) && typeof value === 'object'
916
+ }
917
+
918
+ /**
919
+ * Returns true for plain object values.
920
+ * @param {unknown} value Candidate.
921
+ * @returns {boolean}
922
+ */
923
+ static #isPlainObject(value) {
924
+ return (
925
+ CircuitJsonElementValidator.#isObject(value) &&
926
+ !Array.isArray(value)
927
+ )
928
+ }
929
+
930
+ /**
931
+ * Returns true for non-empty strings.
932
+ * @param {unknown} value Candidate.
933
+ * @returns {boolean}
934
+ */
935
+ static #isNonEmptyString(value) {
936
+ return typeof value === 'string' && value.trim().length > 0
937
+ }
938
+
939
+ /**
940
+ * Compares expected and actual string sets.
941
+ * @param {Set<string>} expected Expected values.
942
+ * @param {Set<string>} actual Actual values.
943
+ * @returns {{ missing: string[], unexpected: string[] }}
944
+ */
945
+ static #compareSets(expected, actual) {
946
+ return {
947
+ missing: [...expected]
948
+ .filter((value) => !actual.has(value))
949
+ .sort((left, right) => left.localeCompare(right)),
950
+ unexpected: [...actual]
951
+ .filter((value) => !expected.has(value))
952
+ .sort((left, right) => left.localeCompare(right))
953
+ }
954
+ }
955
+
956
+ /**
957
+ * Compares expected and actual variant metadata sets.
958
+ * @param {Record<string, string[]>} snapshotSets Snapshot variant sets.
959
+ * @returns {{ missing: CircuitJsonVariantDiff[], unexpected: CircuitJsonVariantDiff[] }}
960
+ */
961
+ static #compareVariantSets(snapshotSets) {
962
+ const missing = []
963
+ const unexpected = []
964
+ const setNames = new Set([
965
+ ...SORTED_VARIANT_SET_NAMES,
966
+ ...Object.keys(snapshotSets || {})
967
+ ])
968
+
969
+ for (const setName of [...setNames].sort()) {
970
+ const comparison = CircuitJsonElementValidator.#compareSets(
971
+ new Set((snapshotSets?.[setName] || []).map(String)),
972
+ new Set(VARIANT_SETS[setName] || [])
973
+ )
974
+ missing.push(
975
+ ...comparison.missing.map((value) => ({
976
+ set: setName,
977
+ value
978
+ }))
979
+ )
980
+ unexpected.push(
981
+ ...comparison.unexpected.map((value) => ({
982
+ set: setName,
983
+ value
984
+ }))
985
+ )
986
+ }
987
+
988
+ return { missing, unexpected }
989
+ }
990
+ }