circuitjson-toolkit 1.0.3 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,773 @@
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_outline',
20
+ 'pcb_courtyard_overlap_error',
21
+ 'pcb_courtyard_pill',
22
+ 'pcb_courtyard_polygon',
23
+ 'pcb_courtyard_rect',
24
+ 'pcb_cutout',
25
+ 'pcb_fabrication_note_dimension',
26
+ 'pcb_fabrication_note_path',
27
+ 'pcb_fabrication_note_rect',
28
+ 'pcb_fabrication_note_text',
29
+ 'pcb_footprint_overlap_error',
30
+ 'pcb_ground_plane',
31
+ 'pcb_ground_plane_region',
32
+ 'pcb_group',
33
+ 'pcb_hole',
34
+ 'pcb_keepout',
35
+ 'pcb_manual_edit_conflict_warning',
36
+ 'pcb_missing_footprint_error',
37
+ 'pcb_net',
38
+ 'pcb_note_dimension',
39
+ 'pcb_note_line',
40
+ 'pcb_note_path',
41
+ 'pcb_note_rect',
42
+ 'pcb_note_text',
43
+ 'pcb_pad_pad_clearance_error',
44
+ 'pcb_pad_trace_clearance_error',
45
+ 'pcb_panel',
46
+ 'pcb_panelization_placement_error',
47
+ 'pcb_placement_error',
48
+ 'pcb_plated_hole',
49
+ 'pcb_port',
50
+ 'pcb_port_not_connected_error',
51
+ 'pcb_port_not_matched_error',
52
+ 'pcb_silkscreen_circle',
53
+ 'pcb_silkscreen_graphic',
54
+ 'pcb_silkscreen_line',
55
+ 'pcb_silkscreen_oval',
56
+ 'pcb_silkscreen_path',
57
+ 'pcb_silkscreen_pill',
58
+ 'pcb_silkscreen_rect',
59
+ 'pcb_silkscreen_text',
60
+ 'pcb_smtpad',
61
+ 'pcb_solder_paste',
62
+ 'pcb_text',
63
+ 'pcb_thermal_spoke',
64
+ 'pcb_trace',
65
+ 'pcb_trace_error',
66
+ 'pcb_trace_hint',
67
+ 'pcb_trace_missing_error',
68
+ 'pcb_trace_warning',
69
+ 'pcb_via',
70
+ 'pcb_via_clearance_error',
71
+ 'pcb_via_trace_clearance_error',
72
+ 'schematic_arc',
73
+ 'schematic_box',
74
+ 'schematic_circle',
75
+ 'schematic_component',
76
+ 'schematic_debug_object',
77
+ 'schematic_error',
78
+ 'schematic_group',
79
+ 'schematic_layout_error',
80
+ 'schematic_line',
81
+ 'schematic_manual_edit_conflict_warning',
82
+ 'schematic_net_label',
83
+ 'schematic_path',
84
+ 'schematic_port',
85
+ 'schematic_rect',
86
+ 'schematic_sheet',
87
+ 'schematic_symbol',
88
+ 'schematic_table',
89
+ 'schematic_table_cell',
90
+ 'schematic_text',
91
+ 'schematic_trace',
92
+ 'schematic_voltage_probe',
93
+ 'simulation_current_probe',
94
+ 'simulation_current_source',
95
+ 'simulation_experiment',
96
+ 'simulation_op_amp',
97
+ 'simulation_oscilloscope_trace',
98
+ 'simulation_spice_subcircuit',
99
+ 'simulation_switch',
100
+ 'simulation_transient_current_graph',
101
+ 'simulation_transient_voltage_graph',
102
+ 'simulation_unknown_experiment_error',
103
+ 'simulation_voltage_probe',
104
+ 'simulation_voltage_source',
105
+ 'source_ambiguous_port_reference',
106
+ 'source_board',
107
+ 'source_component',
108
+ 'source_component_internal_connection',
109
+ 'source_component_misconfigured_error',
110
+ 'source_component_pins_underspecified_warning',
111
+ 'source_failed_to_create_component_error',
112
+ 'source_group',
113
+ 'source_i2c_misconfigured_error',
114
+ 'source_interconnect',
115
+ 'source_invalid_component_property_error',
116
+ 'source_manually_placed_via',
117
+ 'source_missing_manufacturer_part_number_warning',
118
+ 'source_missing_property_error',
119
+ 'source_net',
120
+ 'source_no_ground_pin_defined_warning',
121
+ 'source_no_power_pin_defined_warning',
122
+ 'source_pcb_ground_plane',
123
+ 'source_pin_missing_trace_warning',
124
+ 'source_pin_must_be_connected_error',
125
+ 'source_port',
126
+ 'source_project_metadata',
127
+ 'source_property_ignored_warning',
128
+ 'source_trace',
129
+ 'source_trace_not_connected_error',
130
+ 'supplier_footprint_mismatch_warning',
131
+ 'unknown_error_finding_part'
132
+ ])
133
+
134
+ const ID_FIELD_EXCEPTIONS = new Set([
135
+ 'pcb_autorouting_error',
136
+ 'pcb_courtyard_overlap_error',
137
+ 'pcb_footprint_overlap_error',
138
+ 'pcb_port_not_matched_error',
139
+ 'pcb_via_clearance_error',
140
+ 'schematic_box',
141
+ 'schematic_debug_object',
142
+ 'source_project_metadata'
143
+ ])
144
+
145
+ const SOURCE_COMPONENT_FTYPES = new Set([
146
+ 'simple_ammeter',
147
+ 'simple_battery',
148
+ 'simple_capacitor',
149
+ 'simple_chip',
150
+ 'simple_connector',
151
+ 'simple_crystal',
152
+ 'simple_current_source',
153
+ 'simple_diode',
154
+ 'simple_fiducial',
155
+ 'simple_fuse',
156
+ 'simple_ground',
157
+ 'simple_inductor',
158
+ 'simple_led',
159
+ 'simple_mosfet',
160
+ 'simple_op_amp',
161
+ 'simple_pin_header',
162
+ 'simple_pinout',
163
+ 'simple_potentiometer',
164
+ 'simple_power_source',
165
+ 'simple_push_button',
166
+ 'simple_resistor',
167
+ 'simple_resonator',
168
+ 'simple_switch',
169
+ 'simple_test_point',
170
+ 'simple_transistor',
171
+ 'simple_voltage_probe',
172
+ 'simple_voltage_source'
173
+ ])
174
+
175
+ const LAYERS = new Set([
176
+ 'top',
177
+ 'bottom',
178
+ 'inner1',
179
+ 'inner2',
180
+ 'inner3',
181
+ 'inner4',
182
+ 'inner5',
183
+ 'inner6'
184
+ ])
185
+
186
+ const SMT_PAD_SHAPES = new Set([
187
+ 'circle',
188
+ 'rect',
189
+ 'rotated_rect',
190
+ 'rotated_pill',
191
+ 'pill',
192
+ 'polygon'
193
+ ])
194
+
195
+ /**
196
+ * Validates serialized CircuitJSON element objects without external runtime
197
+ * dependencies.
198
+ */
199
+ export class CircuitJsonElementValidator {
200
+ /**
201
+ * Returns validation errors for a candidate model.
202
+ * @param {unknown} value Candidate model.
203
+ * @returns {string[]}
204
+ */
205
+ static validateModel(value) {
206
+ if (!Array.isArray(value)) {
207
+ return ['Expected a CircuitJSON element array.']
208
+ }
209
+
210
+ return value.flatMap((element, index) =>
211
+ CircuitJsonElementValidator.validateElement(element, index)
212
+ )
213
+ }
214
+
215
+ /**
216
+ * Returns validation errors for one candidate element.
217
+ * @param {unknown} value Candidate element.
218
+ * @param {number} [index] Element index.
219
+ * @returns {string[]}
220
+ */
221
+ static validateElement(value, index = -1) {
222
+ const location = index >= 0 ? ' at index ' + index : ''
223
+ if (!CircuitJsonElementValidator.#isObject(value)) {
224
+ return ['Expected a CircuitJSON element object' + location + '.']
225
+ }
226
+
227
+ const type = String(value.type || '').trim()
228
+ if (!type) {
229
+ return ['CircuitJSON element type is required' + location + '.']
230
+ }
231
+
232
+ if (!KNOWN_ELEMENT_TYPES.has(type)) {
233
+ return ['Unsupported CircuitJSON element type: ' + type + '.']
234
+ }
235
+
236
+ const errors = []
237
+ CircuitJsonElementValidator.#validateId(value, type, errors)
238
+ CircuitJsonElementValidator.#validateCoreShape(value, type, errors)
239
+ return errors
240
+ }
241
+
242
+ /**
243
+ * Returns all known serialized element type names.
244
+ * @returns {string[]}
245
+ */
246
+ static knownElementTypes() {
247
+ return [...KNOWN_ELEMENT_TYPES]
248
+ }
249
+
250
+ /**
251
+ * Returns id convention exceptions from the current schema snapshot.
252
+ * @returns {string[]}
253
+ */
254
+ static idFieldExceptions() {
255
+ return [...ID_FIELD_EXCEPTIONS]
256
+ }
257
+
258
+ /**
259
+ * Compares current schema metadata against a saved snapshot.
260
+ * @param {{ elementTypes?: string[], idFieldExceptions?: string[] }} snapshot Schema snapshot.
261
+ * @returns {{ matches: boolean, missingElementTypes: string[], unexpectedElementTypes: string[], missingIdFieldExceptions: string[], unexpectedIdFieldExceptions: string[] }}
262
+ */
263
+ static compareSchemaSnapshot(snapshot = {}) {
264
+ const elementComparison = CircuitJsonElementValidator.#compareSets(
265
+ new Set(snapshot.elementTypes || []),
266
+ KNOWN_ELEMENT_TYPES
267
+ )
268
+ const exceptionComparison = CircuitJsonElementValidator.#compareSets(
269
+ new Set(snapshot.idFieldExceptions || []),
270
+ ID_FIELD_EXCEPTIONS
271
+ )
272
+
273
+ return {
274
+ matches:
275
+ elementComparison.missing.length === 0 &&
276
+ elementComparison.unexpected.length === 0 &&
277
+ exceptionComparison.missing.length === 0 &&
278
+ exceptionComparison.unexpected.length === 0,
279
+ missingElementTypes: elementComparison.missing,
280
+ unexpectedElementTypes: elementComparison.unexpected,
281
+ missingIdFieldExceptions: exceptionComparison.missing,
282
+ unexpectedIdFieldExceptions: exceptionComparison.unexpected
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Validates the common id convention.
288
+ * @param {Record<string, unknown>} element Element.
289
+ * @param {string} type Element type.
290
+ * @param {string[]} errors Error sink.
291
+ * @returns {void}
292
+ */
293
+ static #validateId(element, type, errors) {
294
+ if (ID_FIELD_EXCEPTIONS.has(type)) {
295
+ return
296
+ }
297
+
298
+ const idField = type + '_id'
299
+ if (!CircuitJsonElementValidator.#isNonEmptyString(element[idField])) {
300
+ errors.push(type + ' ' + idField + ' is required.')
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Validates type-specific fields used by core consumers.
306
+ * @param {Record<string, unknown>} element Element.
307
+ * @param {string} type Element type.
308
+ * @param {string[]} errors Error sink.
309
+ * @returns {void}
310
+ */
311
+ static #validateCoreShape(element, type, errors) {
312
+ if (type === 'source_component') {
313
+ CircuitJsonElementValidator.#validateSourceComponent(
314
+ element,
315
+ errors
316
+ )
317
+ return
318
+ }
319
+
320
+ if (type === 'source_port') {
321
+ CircuitJsonElementValidator.#validateSourcePort(element, errors)
322
+ return
323
+ }
324
+
325
+ if (type === 'schematic_component') {
326
+ CircuitJsonElementValidator.#validateSchematicComponent(
327
+ element,
328
+ errors
329
+ )
330
+ return
331
+ }
332
+
333
+ if (type === 'pcb_board') {
334
+ CircuitJsonElementValidator.#validatePcbBoard(element, errors)
335
+ return
336
+ }
337
+
338
+ if (type === 'pcb_component') {
339
+ CircuitJsonElementValidator.#validatePcbComponent(element, errors)
340
+ return
341
+ }
342
+
343
+ if (type === 'pcb_smtpad') {
344
+ CircuitJsonElementValidator.#validatePcbSmtPad(element, errors)
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Validates a source component.
350
+ * @param {Record<string, unknown>} element Element.
351
+ * @param {string[]} errors Error sink.
352
+ * @returns {void}
353
+ */
354
+ static #validateSourceComponent(element, errors) {
355
+ CircuitJsonElementValidator.#requireString(
356
+ element,
357
+ 'source_component',
358
+ 'name',
359
+ errors
360
+ )
361
+
362
+ if (
363
+ Object.hasOwn(element, 'supplier_part_numbers') &&
364
+ !CircuitJsonElementValidator.#isPlainObject(
365
+ element.supplier_part_numbers
366
+ )
367
+ ) {
368
+ errors.push(
369
+ 'source_component supplier_part_numbers must be an object.'
370
+ )
371
+ }
372
+
373
+ if (
374
+ Object.hasOwn(element, 'ftype') &&
375
+ !SOURCE_COMPONENT_FTYPES.has(String(element.ftype || ''))
376
+ ) {
377
+ errors.push('source_component ftype is not supported.')
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Validates a source port.
383
+ * @param {Record<string, unknown>} element Element.
384
+ * @param {string[]} errors Error sink.
385
+ * @returns {void}
386
+ */
387
+ static #validateSourcePort(element, errors) {
388
+ if (
389
+ Object.hasOwn(element, 'pin_number') &&
390
+ !Number.isFinite(element.pin_number)
391
+ ) {
392
+ errors.push('source_port pin_number must be a number.')
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Validates a schematic component.
398
+ * @param {Record<string, unknown>} element Element.
399
+ * @param {string[]} errors Error sink.
400
+ * @returns {void}
401
+ */
402
+ static #validateSchematicComponent(element, errors) {
403
+ CircuitJsonElementValidator.#requirePoint(
404
+ element,
405
+ 'schematic_component',
406
+ 'center',
407
+ errors
408
+ )
409
+ CircuitJsonElementValidator.#requireSize(
410
+ element,
411
+ 'schematic_component',
412
+ 'size',
413
+ errors
414
+ )
415
+ }
416
+
417
+ /**
418
+ * Validates a PCB board.
419
+ * @param {Record<string, unknown>} element Element.
420
+ * @param {string[]} errors Error sink.
421
+ * @returns {void}
422
+ */
423
+ static #validatePcbBoard(element, errors) {
424
+ CircuitJsonElementValidator.#requirePoint(
425
+ element,
426
+ 'pcb_board',
427
+ 'center',
428
+ errors
429
+ )
430
+ }
431
+
432
+ /**
433
+ * Validates a PCB component.
434
+ * @param {Record<string, unknown>} element Element.
435
+ * @param {string[]} errors Error sink.
436
+ * @returns {void}
437
+ */
438
+ static #validatePcbComponent(element, errors) {
439
+ CircuitJsonElementValidator.#requireString(
440
+ element,
441
+ 'pcb_component',
442
+ 'source_component_id',
443
+ errors
444
+ )
445
+ CircuitJsonElementValidator.#requirePoint(
446
+ element,
447
+ 'pcb_component',
448
+ 'center',
449
+ errors
450
+ )
451
+ CircuitJsonElementValidator.#requireLayer(
452
+ element,
453
+ 'pcb_component',
454
+ 'layer',
455
+ errors
456
+ )
457
+ CircuitJsonElementValidator.#optionalAngle(
458
+ element,
459
+ 'pcb_component',
460
+ 'rotation',
461
+ errors
462
+ )
463
+ CircuitJsonElementValidator.#optionalLength(
464
+ element,
465
+ 'pcb_component',
466
+ 'width',
467
+ errors
468
+ )
469
+ CircuitJsonElementValidator.#optionalLength(
470
+ element,
471
+ 'pcb_component',
472
+ 'height',
473
+ errors
474
+ )
475
+ }
476
+
477
+ /**
478
+ * Validates an SMT pad.
479
+ * @param {Record<string, unknown>} element Element.
480
+ * @param {string[]} errors Error sink.
481
+ * @returns {void}
482
+ */
483
+ static #validatePcbSmtPad(element, errors) {
484
+ const shape = String(element.shape || '')
485
+ if (!SMT_PAD_SHAPES.has(shape)) {
486
+ errors.push(
487
+ 'pcb_smtpad shape must be one of: ' +
488
+ [...SMT_PAD_SHAPES].join(', ') +
489
+ '.'
490
+ )
491
+ return
492
+ }
493
+
494
+ CircuitJsonElementValidator.#requireLayer(
495
+ element,
496
+ 'pcb_smtpad',
497
+ 'layer',
498
+ errors
499
+ )
500
+
501
+ if (shape === 'polygon') {
502
+ if (!Array.isArray(element.points)) {
503
+ errors.push('pcb_smtpad points is required.')
504
+ }
505
+ return
506
+ }
507
+
508
+ CircuitJsonElementValidator.#requireLength(
509
+ element,
510
+ 'pcb_smtpad',
511
+ 'x',
512
+ errors
513
+ )
514
+ CircuitJsonElementValidator.#requireLength(
515
+ element,
516
+ 'pcb_smtpad',
517
+ 'y',
518
+ errors
519
+ )
520
+
521
+ if (shape === 'circle') {
522
+ if (
523
+ !CircuitJsonElementValidator.#hasLength(element, 'radius') &&
524
+ !CircuitJsonElementValidator.#hasLength(element, 'diameter') &&
525
+ !(
526
+ CircuitJsonElementValidator.#hasLength(element, 'width') &&
527
+ CircuitJsonElementValidator.#hasLength(element, 'height')
528
+ )
529
+ ) {
530
+ errors.push(
531
+ 'pcb_smtpad radius, diameter, or width and height is required.'
532
+ )
533
+ }
534
+ return
535
+ }
536
+
537
+ CircuitJsonElementValidator.#requireLength(
538
+ element,
539
+ 'pcb_smtpad',
540
+ 'width',
541
+ errors
542
+ )
543
+ CircuitJsonElementValidator.#requireLength(
544
+ element,
545
+ 'pcb_smtpad',
546
+ 'height',
547
+ errors
548
+ )
549
+
550
+ if (shape.startsWith('rotated_')) {
551
+ CircuitJsonElementValidator.#requireAngle(
552
+ element,
553
+ 'pcb_smtpad',
554
+ 'ccw_rotation',
555
+ errors
556
+ )
557
+ }
558
+
559
+ if (shape.endsWith('pill')) {
560
+ CircuitJsonElementValidator.#optionalLength(
561
+ element,
562
+ 'pcb_smtpad',
563
+ 'radius',
564
+ errors
565
+ )
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Requires a non-empty string field.
571
+ * @param {Record<string, unknown>} element Element.
572
+ * @param {string} type Element type.
573
+ * @param {string} field Field name.
574
+ * @param {string[]} errors Error sink.
575
+ * @returns {void}
576
+ */
577
+ static #requireString(element, type, field, errors) {
578
+ if (!CircuitJsonElementValidator.#isNonEmptyString(element[field])) {
579
+ errors.push(type + ' ' + field + ' is required.')
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Requires a finite number field.
585
+ * @param {Record<string, unknown>} element Element.
586
+ * @param {string} type Element type.
587
+ * @param {string} field Field name.
588
+ * @param {string[]} errors Error sink.
589
+ * @returns {void}
590
+ */
591
+ static #requireNumber(element, type, field, errors) {
592
+ if (!Number.isFinite(element[field])) {
593
+ errors.push(type + ' ' + field + ' is required.')
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Requires a finite length field.
599
+ * @param {Record<string, unknown>} element Element.
600
+ * @param {string} type Element type.
601
+ * @param {string} field Field name.
602
+ * @param {string[]} errors Error sink.
603
+ * @returns {void}
604
+ */
605
+ static #requireLength(element, type, field, errors) {
606
+ if (CircuitJsonUnits.optionalLength(element[field]) === null) {
607
+ errors.push(type + ' ' + field + ' is required.')
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Returns true when a field is a finite length.
613
+ * @param {Record<string, unknown>} element Element.
614
+ * @param {string} field Field name.
615
+ * @returns {boolean}
616
+ */
617
+ static #hasLength(element, field) {
618
+ return CircuitJsonUnits.optionalLength(element[field]) !== null
619
+ }
620
+
621
+ /**
622
+ * Requires a finite angle field.
623
+ * @param {Record<string, unknown>} element Element.
624
+ * @param {string} type Element type.
625
+ * @param {string} field Field name.
626
+ * @param {string[]} errors Error sink.
627
+ * @returns {void}
628
+ */
629
+ static #requireAngle(element, type, field, errors) {
630
+ if (CircuitJsonUnits.optionalAngle(element[field]) === null) {
631
+ errors.push(type + ' ' + field + ' is required.')
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Validates an optional finite length field.
637
+ * @param {Record<string, unknown>} element Element.
638
+ * @param {string} type Element type.
639
+ * @param {string} field Field name.
640
+ * @param {string[]} errors Error sink.
641
+ * @returns {void}
642
+ */
643
+ static #optionalLength(element, type, field, errors) {
644
+ if (
645
+ Object.hasOwn(element, field) &&
646
+ CircuitJsonUnits.optionalLength(element[field]) === null
647
+ ) {
648
+ errors.push(type + ' ' + field + ' must be a finite length.')
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Validates an optional finite angle field.
654
+ * @param {Record<string, unknown>} element Element.
655
+ * @param {string} type Element type.
656
+ * @param {string} field Field name.
657
+ * @param {string[]} errors Error sink.
658
+ * @returns {void}
659
+ */
660
+ static #optionalAngle(element, type, field, errors) {
661
+ if (
662
+ Object.hasOwn(element, field) &&
663
+ CircuitJsonUnits.optionalAngle(element[field]) === null
664
+ ) {
665
+ errors.push(type + ' ' + field + ' must be a finite angle.')
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Requires a finite point object field.
671
+ * @param {Record<string, unknown>} element Element.
672
+ * @param {string} type Element type.
673
+ * @param {string} field Field name.
674
+ * @param {string[]} errors Error sink.
675
+ * @returns {void}
676
+ */
677
+ static #requirePoint(element, type, field, errors) {
678
+ const point = element[field]
679
+ if (!CircuitJsonElementValidator.#isObject(point)) {
680
+ errors.push(type + ' ' + field + ' is required.')
681
+ return
682
+ }
683
+
684
+ if (CircuitJsonUnits.optionalPoint(point) === null) {
685
+ errors.push(type + ' ' + field + ' is required.')
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Requires a finite size object field.
691
+ * @param {Record<string, unknown>} element Element.
692
+ * @param {string} type Element type.
693
+ * @param {string} field Field name.
694
+ * @param {string[]} errors Error sink.
695
+ * @returns {void}
696
+ */
697
+ static #requireSize(element, type, field, errors) {
698
+ const size = element[field]
699
+ if (!CircuitJsonElementValidator.#isObject(size)) {
700
+ errors.push(type + ' ' + field + ' is required.')
701
+ return
702
+ }
703
+
704
+ if (CircuitJsonUnits.optionalSize(size) === null) {
705
+ errors.push(type + ' ' + field + ' is required.')
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Requires a known layer field.
711
+ * @param {Record<string, unknown>} element Element.
712
+ * @param {string} type Element type.
713
+ * @param {string} field Field name.
714
+ * @param {string[]} errors Error sink.
715
+ * @returns {void}
716
+ */
717
+ static #requireLayer(element, type, field, errors) {
718
+ const layer =
719
+ typeof element[field] === 'object' && element[field] !== null
720
+ ? element[field].name
721
+ : element[field]
722
+ if (!LAYERS.has(String(layer || ''))) {
723
+ errors.push(type + ' ' + field + ' is required.')
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Returns true for non-null objects.
729
+ * @param {unknown} value Candidate.
730
+ * @returns {boolean}
731
+ */
732
+ static #isObject(value) {
733
+ return Boolean(value) && typeof value === 'object'
734
+ }
735
+
736
+ /**
737
+ * Returns true for plain object values.
738
+ * @param {unknown} value Candidate.
739
+ * @returns {boolean}
740
+ */
741
+ static #isPlainObject(value) {
742
+ return (
743
+ CircuitJsonElementValidator.#isObject(value) &&
744
+ !Array.isArray(value)
745
+ )
746
+ }
747
+
748
+ /**
749
+ * Returns true for non-empty strings.
750
+ * @param {unknown} value Candidate.
751
+ * @returns {boolean}
752
+ */
753
+ static #isNonEmptyString(value) {
754
+ return typeof value === 'string' && value.trim().length > 0
755
+ }
756
+
757
+ /**
758
+ * Compares expected and actual string sets.
759
+ * @param {Set<string>} expected Expected values.
760
+ * @param {Set<string>} actual Actual values.
761
+ * @returns {{ missing: string[], unexpected: string[] }}
762
+ */
763
+ static #compareSets(expected, actual) {
764
+ return {
765
+ missing: [...expected]
766
+ .filter((value) => !actual.has(value))
767
+ .sort((left, right) => left.localeCompare(right)),
768
+ unexpected: [...actual]
769
+ .filter((value) => !expected.has(value))
770
+ .sort((left, right) => left.localeCompare(right))
771
+ }
772
+ }
773
+ }