altium-toolkit 0.1.1 → 0.1.17

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/README.md +25 -6
  2. package/docs/api.md +42 -4
  3. package/docs/model-format.md +95 -5
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +553 -0
  5. package/docs/testing.md +7 -2
  6. package/package.json +6 -2
  7. package/spec/library-scope.md +7 -1
  8. package/src/core/altium/AltiumParser.mjs +22 -325
  9. package/src/core/altium/NormalizedModelSchema.mjs +28 -0
  10. package/src/core/altium/PcbArcPrimitiveParser.mjs +87 -0
  11. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +43 -370
  12. package/src/core/altium/PcbBoardRegionSemanticsParser.mjs +477 -0
  13. package/src/core/altium/PcbComponentAnnotationNormalizer.mjs +290 -0
  14. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +52 -0
  15. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +109 -0
  16. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +484 -0
  17. package/src/core/altium/PcbFillPrimitiveParser.mjs +84 -0
  18. package/src/core/altium/PcbFontMetricsParser.mjs +308 -0
  19. package/src/core/altium/PcbGeometryFlipper.mjs +244 -0
  20. package/src/core/altium/PcbLayerIdCodec.mjs +136 -0
  21. package/src/core/altium/PcbLibModelParser.mjs +202 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +968 -0
  23. package/src/core/altium/PcbModelParser.mjs +618 -66
  24. package/src/core/altium/PcbOutlineRecovery.mjs +4 -112
  25. package/src/core/altium/PcbPadPrimitiveParser.mjs +347 -0
  26. package/src/core/altium/PcbPadShapeCodec.mjs +158 -0
  27. package/src/core/altium/PcbPadStackParser.mjs +903 -0
  28. package/src/core/altium/PcbPrimitiveOwnershipIndexParser.mjs +60 -0
  29. package/src/core/altium/PcbPrimitiveParameterParser.mjs +212 -0
  30. package/src/core/altium/PcbPrimitiveRecordSlicer.mjs +243 -0
  31. package/src/core/altium/PcbRawRecordRegistry.mjs +831 -0
  32. package/src/core/altium/PcbRegionPrimitiveParser.mjs +317 -0
  33. package/src/core/altium/PcbRuleParser.mjs +587 -0
  34. package/src/core/altium/PcbStreamExtractor.mjs +127 -4
  35. package/src/core/altium/PcbTextPrimitiveParser.mjs +537 -0
  36. package/src/core/altium/PcbTrackPrimitiveParser.mjs +87 -0
  37. package/src/core/altium/PcbViaPrimitiveParser.mjs +88 -0
  38. package/src/core/altium/PcbViaStackParser.mjs +548 -0
  39. package/src/core/altium/PcbWideStringTableParser.mjs +108 -0
  40. package/src/core/altium/PrjPcbModelParser.mjs +797 -0
  41. package/src/core/altium/SchematicComponentTextResolver.mjs +355 -0
  42. package/src/parser.mjs +13 -0
  43. package/src/renderers.mjs +5 -0
  44. package/src/styles/altium-renderers.css +11 -6
  45. package/src/ui/PcbCopperPrimitiveSplitter.mjs +113 -0
  46. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +6 -5
  47. package/src/ui/PcbEmbeddedFontFaceRenderer.mjs +126 -0
  48. package/src/ui/PcbFootprintPrimitiveSelector.mjs +27 -6
  49. package/src/ui/PcbRegionPrimitiveRenderer.mjs +243 -0
  50. package/src/ui/PcbSideResolvedRenderModel.mjs +336 -0
  51. package/src/ui/PcbSvgRenderer.mjs +101 -109
  52. package/src/ui/PcbTextPrimitiveRenderer.mjs +252 -0
  53. package/src/ui/SchematicSheetChromeRenderer.mjs +2 -93
  54. package/src/ui/SchematicSheetZoneRenderer.mjs +104 -0
@@ -0,0 +1,587 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getField, parseBoolean, parseNumericField } = ParserUtils
8
+
9
+ const COMMON_RULE_FIELDS = new Set([
10
+ 'SELECTION',
11
+ 'LAYER',
12
+ 'LOCKED',
13
+ 'POLYGONOUTLINE',
14
+ 'USERROUTED',
15
+ 'KEEPOUT',
16
+ 'UNIONINDEX',
17
+ 'RULEKIND',
18
+ 'NETSCOPE',
19
+ 'LAYERKIND',
20
+ 'SCOPE1EXPRESSION',
21
+ 'SCOPE2EXPRESSION',
22
+ 'NAME',
23
+ 'ENABLED',
24
+ 'PRIORITY',
25
+ 'COMMENT',
26
+ 'UNIQUEID',
27
+ 'DEFINEDBYLOGICALDOCUMENT'
28
+ ])
29
+
30
+ const RULE_TYPES = new Map([
31
+ ['WIDTH', { kind: 'width', category: 'routing', displayName: 'Width' }],
32
+ [
33
+ 'CLEARANCE',
34
+ {
35
+ kind: 'clearance',
36
+ category: 'electrical',
37
+ displayName: 'Clearance'
38
+ }
39
+ ],
40
+ [
41
+ 'ROUTINGVIAS',
42
+ {
43
+ kind: 'routing-vias',
44
+ category: 'routing',
45
+ displayName: 'Routing Vias'
46
+ }
47
+ ],
48
+ [
49
+ 'ROUTINGLAYERS',
50
+ {
51
+ kind: 'routing-layers',
52
+ category: 'routing',
53
+ displayName: 'Routing Layers'
54
+ }
55
+ ],
56
+ [
57
+ 'DIFFERENTIALPAIRROUTING',
58
+ {
59
+ kind: 'differential-pair-routing',
60
+ category: 'routing',
61
+ displayName: 'Differential Pair Routing'
62
+ }
63
+ ],
64
+ [
65
+ 'SHORTCIRCUIT',
66
+ {
67
+ kind: 'short-circuit',
68
+ category: 'electrical',
69
+ displayName: 'Short Circuit'
70
+ }
71
+ ],
72
+ [
73
+ 'UNROUTEDNET',
74
+ {
75
+ kind: 'unrouted-net',
76
+ category: 'electrical',
77
+ displayName: 'Unrouted Net'
78
+ }
79
+ ],
80
+ [
81
+ 'SILKSCREENOVERCOMPONENTPADS',
82
+ {
83
+ kind: 'silkscreen-over-component-pads',
84
+ category: 'manufacturing',
85
+ displayName: 'Silkscreen Over Component Pads'
86
+ }
87
+ ]
88
+ ])
89
+
90
+ /**
91
+ * Normalizes native Altium PCB design-rule records from Rules6/Data.
92
+ */
93
+ export class PcbRuleParser {
94
+ /**
95
+ * Parses normalized PCB design rules in native stream order.
96
+ * @param {{ fields: Record<string, string | string[]>, sourceStream?: string }[]} records
97
+ * @returns {Array<Record<string, unknown> & { ruleIndex: number, name: string, ruleKind: string, constraints: Record<string, string>, constraintValues: Record<string, Record<string, unknown>>, typedConstraints: Record<string, Record<string, unknown>> }>}
98
+ */
99
+ static parse(records) {
100
+ return records
101
+ .filter((record) => record.sourceStream === 'Rules6/Data')
102
+ .map((record, index) =>
103
+ PcbRuleParser.#normalizeRuleRecord(record.fields, index)
104
+ )
105
+ .filter((rule) => rule.name || rule.ruleKind || rule.uniqueId)
106
+ }
107
+
108
+ /**
109
+ * Normalizes one native design-rule record.
110
+ * @param {Record<string, string | string[]>} fields
111
+ * @param {number} ruleIndex
112
+ * @returns {{ ruleIndex: number, name: string, ruleKind: string, enabled: boolean | null, priority: number | null, uniqueId: string, comment: string, selection: boolean | null, layer: string, locked: boolean | null, polygonOutline: boolean | null, userRouted: boolean | null, keepout: boolean | null, unionIndex: number | null, netScope: string, layerKind: string, scope1Expression: string, scope2Expression: string, scope1: { rawExpression: string, predicate: string, arguments: string[], isAll: boolean }, scope2: { rawExpression: string, predicate: string, arguments: string[], isAll: boolean }, ruleType: { rawKind: string, kind: string, category: string, displayName: string }, constraints: Record<string, string>, constraintValues: Record<string, Record<string, unknown>>, typedConstraints: Record<string, Record<string, unknown>> }}
113
+ */
114
+ static #normalizeRuleRecord(fields, ruleIndex) {
115
+ const scope1Expression = getField(fields, 'SCOPE1EXPRESSION')
116
+ const scope2Expression = getField(fields, 'SCOPE2EXPRESSION')
117
+ const ruleKind = getField(fields, 'RULEKIND')
118
+ const constraints = PcbRuleParser.#parseConstraints(fields)
119
+ const constraintValues =
120
+ PcbRuleParser.#parseConstraintValues(constraints)
121
+ const ruleType = PcbRuleParser.#parseRuleType(ruleKind)
122
+
123
+ return {
124
+ ruleIndex,
125
+ name: getField(fields, 'NAME'),
126
+ ruleKind,
127
+ enabled: PcbRuleParser.#parseOptionalBoolean(fields, 'ENABLED'),
128
+ priority: parseNumericField(fields, 'PRIORITY'),
129
+ uniqueId: getField(fields, 'UNIQUEID') || getField(fields, 'UID'),
130
+ comment: getField(fields, 'COMMENT'),
131
+ selection: PcbRuleParser.#parseOptionalBoolean(fields, 'SELECTION'),
132
+ layer: getField(fields, 'LAYER'),
133
+ locked: PcbRuleParser.#parseOptionalBoolean(fields, 'LOCKED'),
134
+ polygonOutline: PcbRuleParser.#parseOptionalBoolean(
135
+ fields,
136
+ 'POLYGONOUTLINE'
137
+ ),
138
+ userRouted: PcbRuleParser.#parseOptionalBoolean(
139
+ fields,
140
+ 'USERROUTED'
141
+ ),
142
+ keepout: PcbRuleParser.#parseOptionalBoolean(fields, 'KEEPOUT'),
143
+ unionIndex: parseNumericField(fields, 'UNIONINDEX'),
144
+ netScope: getField(fields, 'NETSCOPE'),
145
+ layerKind: getField(fields, 'LAYERKIND'),
146
+ scope1Expression,
147
+ scope2Expression,
148
+ scope1: PcbRuleParser.parseScopeExpression(scope1Expression),
149
+ scope2: PcbRuleParser.parseScopeExpression(scope2Expression),
150
+ ruleType,
151
+ constraints,
152
+ constraintValues,
153
+ typedConstraints: PcbRuleParser.#parseTypedConstraints(
154
+ ruleType,
155
+ constraintValues
156
+ )
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Parses one Altium rule scope predicate expression.
162
+ * @param {string} expression
163
+ * @returns {{ rawExpression: string, predicate: string, arguments: string[], isAll: boolean }}
164
+ */
165
+ static parseScopeExpression(expression) {
166
+ const rawExpression = String(expression || '').trim()
167
+
168
+ if (!rawExpression) {
169
+ return {
170
+ rawExpression: '',
171
+ predicate: '',
172
+ arguments: [],
173
+ isAll: false
174
+ }
175
+ }
176
+
177
+ if (rawExpression.toUpperCase() === 'ALL') {
178
+ return {
179
+ rawExpression,
180
+ predicate: 'All',
181
+ arguments: [],
182
+ isAll: true
183
+ }
184
+ }
185
+
186
+ const match = rawExpression.match(/^\s*([A-Za-z0-9_]+)\((.*)\)\s*$/u)
187
+ if (!match) {
188
+ return {
189
+ rawExpression,
190
+ predicate: rawExpression,
191
+ arguments: [],
192
+ isAll: false
193
+ }
194
+ }
195
+
196
+ return {
197
+ rawExpression,
198
+ predicate: match[1],
199
+ arguments: PcbRuleParser.#parseScopeArguments(match[2]),
200
+ isAll: false
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Parses one comma-separated Altium scope argument list.
206
+ * @param {string} text
207
+ * @returns {string[]}
208
+ */
209
+ static #parseScopeArguments(text) {
210
+ const args = []
211
+ let token = ''
212
+ let inQuote = false
213
+
214
+ for (let index = 0; index < text.length; index += 1) {
215
+ const character = text[index]
216
+
217
+ if (character === "'") {
218
+ if (inQuote && text[index + 1] === "'") {
219
+ token += "'"
220
+ index += 1
221
+ continue
222
+ }
223
+ inQuote = !inQuote
224
+ continue
225
+ }
226
+
227
+ if (character === ',' && !inQuote) {
228
+ PcbRuleParser.#pushScopeArgument(args, token)
229
+ token = ''
230
+ continue
231
+ }
232
+
233
+ token += character
234
+ }
235
+
236
+ PcbRuleParser.#pushScopeArgument(args, token)
237
+ return args
238
+ }
239
+
240
+ /**
241
+ * Adds one trimmed scope argument when it is not empty.
242
+ * @param {string[]} args
243
+ * @param {string} token
244
+ */
245
+ static #pushScopeArgument(args, token) {
246
+ const value = token.trim()
247
+
248
+ if (value) {
249
+ args.push(value)
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Keeps rule-specific fields as raw string constraints.
255
+ * @param {Record<string, string | string[]>} fields
256
+ * @returns {Record<string, string>}
257
+ */
258
+ static #parseConstraints(fields) {
259
+ const constraints = {}
260
+
261
+ for (const key of Object.keys(fields || {})) {
262
+ const normalizedKey = key.toUpperCase()
263
+ const value = getField(fields, key)
264
+
265
+ if (!COMMON_RULE_FIELDS.has(normalizedKey) && value) {
266
+ constraints[normalizedKey] = value
267
+ }
268
+ }
269
+
270
+ return constraints
271
+ }
272
+
273
+ /**
274
+ * Parses all raw constraints into typed values while preserving their keys.
275
+ * @param {Record<string, string>} constraints
276
+ * @returns {Record<string, Record<string, unknown>>}
277
+ */
278
+ static #parseConstraintValues(constraints) {
279
+ return Object.fromEntries(
280
+ Object.entries(constraints).map(([key, raw]) => [
281
+ key,
282
+ PcbRuleParser.#parseConstraintValue(raw)
283
+ ])
284
+ )
285
+ }
286
+
287
+ /**
288
+ * Parses one rule-specific constraint value.
289
+ * @param {string} raw
290
+ * @returns {Record<string, unknown>}
291
+ */
292
+ static #parseConstraintValue(raw) {
293
+ const text = String(raw || '').trim()
294
+ const numeric = PcbRuleParser.#parseNumericUnit(text)
295
+
296
+ if (numeric) {
297
+ return numeric
298
+ }
299
+ if (/^(TRUE|FALSE|T|F)$/iu.test(text)) {
300
+ return {
301
+ raw: text,
302
+ type: 'boolean',
303
+ value: parseBoolean(text)
304
+ }
305
+ }
306
+
307
+ return {
308
+ raw: text,
309
+ type: 'string',
310
+ value: text
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Parses a numeric value with a common Altium unit suffix.
316
+ * @param {string} text
317
+ * @returns {Record<string, unknown> | null}
318
+ */
319
+ static #parseNumericUnit(text) {
320
+ const match = text.match(
321
+ /^\s*(-?\d+(?:\.\d+)?(?:E[+-]?\d+)?)\s*([A-Za-z%]*)\s*$/iu
322
+ )
323
+
324
+ if (!match) {
325
+ return null
326
+ }
327
+
328
+ const value = Number(match[1])
329
+ if (!Number.isFinite(value)) {
330
+ return null
331
+ }
332
+
333
+ const unit = PcbRuleParser.#normalizeUnit(match[2])
334
+ if (PcbRuleParser.#isLengthUnit(unit)) {
335
+ return {
336
+ raw: text,
337
+ type: 'length',
338
+ value,
339
+ unit,
340
+ valueMil: PcbRuleParser.#roundUnit(
341
+ PcbRuleParser.#toMil(value, unit)
342
+ ),
343
+ valueMm: PcbRuleParser.#roundUnit(
344
+ PcbRuleParser.#toMm(value, unit)
345
+ )
346
+ }
347
+ }
348
+ if (unit === 'deg') {
349
+ return {
350
+ raw: text,
351
+ type: 'angle',
352
+ value,
353
+ unit,
354
+ valueDeg: value
355
+ }
356
+ }
357
+ if (unit === '%') {
358
+ return {
359
+ raw: text,
360
+ type: 'percent',
361
+ value,
362
+ unit,
363
+ ratio: PcbRuleParser.#roundUnit(value / 100)
364
+ }
365
+ }
366
+
367
+ return {
368
+ raw: text,
369
+ type: 'number',
370
+ value,
371
+ unit: unit || null
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Normalizes a parsed unit suffix.
377
+ * @param {string} rawUnit
378
+ * @returns {string}
379
+ */
380
+ static #normalizeUnit(rawUnit) {
381
+ const unit = String(rawUnit || '')
382
+ .trim()
383
+ .toLowerCase()
384
+
385
+ if (unit === 'mils') {
386
+ return 'mil'
387
+ }
388
+ if (unit === 'millimeter' || unit === 'millimeters') {
389
+ return 'mm'
390
+ }
391
+ if (unit === 'inch' || unit === 'inches') {
392
+ return 'in'
393
+ }
394
+ if (unit === 'degree' || unit === 'degrees') {
395
+ return 'deg'
396
+ }
397
+
398
+ return unit
399
+ }
400
+
401
+ /**
402
+ * Returns whether a normalized unit represents a length.
403
+ * @param {string} unit
404
+ * @returns {boolean}
405
+ */
406
+ static #isLengthUnit(unit) {
407
+ return unit === 'mil' || unit === 'mm' || unit === 'in' || unit === 'um'
408
+ }
409
+
410
+ /**
411
+ * Converts one normalized length to mils.
412
+ * @param {number} value
413
+ * @param {string} unit
414
+ * @returns {number}
415
+ */
416
+ static #toMil(value, unit) {
417
+ if (unit === 'mm') {
418
+ return value / 0.0254
419
+ }
420
+ if (unit === 'in') {
421
+ return value * 1000
422
+ }
423
+ if (unit === 'um') {
424
+ return value / 25.4
425
+ }
426
+
427
+ return value
428
+ }
429
+
430
+ /**
431
+ * Converts one normalized length to millimeters.
432
+ * @param {number} value
433
+ * @param {string} unit
434
+ * @returns {number}
435
+ */
436
+ static #toMm(value, unit) {
437
+ if (unit === 'mil') {
438
+ return value * 0.0254
439
+ }
440
+ if (unit === 'in') {
441
+ return value * 25.4
442
+ }
443
+ if (unit === 'um') {
444
+ return value / 1000
445
+ }
446
+
447
+ return value
448
+ }
449
+
450
+ /**
451
+ * Rounds converted values to avoid floating-point noise in model output.
452
+ * @param {number} value
453
+ * @returns {number}
454
+ */
455
+ static #roundUnit(value) {
456
+ return Number(value.toFixed(6))
457
+ }
458
+
459
+ /**
460
+ * Parses one native rule kind into a stable typed descriptor.
461
+ * @param {string} ruleKind
462
+ * @returns {{ rawKind: string, kind: string, category: string, displayName: string }}
463
+ */
464
+ static #parseRuleType(ruleKind) {
465
+ const rawKind = String(ruleKind || '').trim()
466
+ const key = rawKind.replace(/[^A-Za-z0-9]/gu, '').toUpperCase()
467
+ const mapped = RULE_TYPES.get(key)
468
+
469
+ if (mapped) {
470
+ return {
471
+ rawKind,
472
+ ...mapped
473
+ }
474
+ }
475
+
476
+ return {
477
+ rawKind,
478
+ kind: PcbRuleParser.#toKebabCase(rawKind),
479
+ category: 'custom',
480
+ displayName: PcbRuleParser.#displayRuleKind(rawKind)
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Builds semantic typed constraint aliases for known rule kinds.
486
+ * @param {{ kind: string }} ruleType
487
+ * @param {Record<string, Record<string, unknown>>} constraintValues
488
+ * @returns {Record<string, Record<string, unknown>>}
489
+ */
490
+ static #parseTypedConstraints(ruleType, constraintValues) {
491
+ if (ruleType.kind === 'width') {
492
+ return PcbRuleParser.#parseWidthConstraints(constraintValues)
493
+ }
494
+ if (ruleType.kind === 'clearance') {
495
+ return PcbRuleParser.#parseClearanceConstraints(constraintValues)
496
+ }
497
+
498
+ return {}
499
+ }
500
+
501
+ /**
502
+ * Builds semantic aliases for width-rule constraints.
503
+ * @param {Record<string, Record<string, unknown>>} constraintValues
504
+ * @returns {Record<string, Record<string, unknown>>}
505
+ */
506
+ static #parseWidthConstraints(constraintValues) {
507
+ return PcbRuleParser.#pickTypedConstraints(constraintValues, {
508
+ minWidth: ['MINLIMIT', 'MINWIDTH'],
509
+ preferredWidth: ['PREFEREDWIDTH', 'PREFERREDWIDTH'],
510
+ maxWidth: ['MAXLIMIT', 'MAXWIDTH']
511
+ })
512
+ }
513
+
514
+ /**
515
+ * Builds semantic aliases for clearance-rule constraints.
516
+ * @param {Record<string, Record<string, unknown>>} constraintValues
517
+ * @returns {Record<string, Record<string, unknown>>}
518
+ */
519
+ static #parseClearanceConstraints(constraintValues) {
520
+ return PcbRuleParser.#pickTypedConstraints(constraintValues, {
521
+ minClearance: ['GAP', 'MINDISTANCE', 'CLEARANCE'],
522
+ genericClearance: ['GENERICCLEARANCE']
523
+ })
524
+ }
525
+
526
+ /**
527
+ * Picks the first available typed constraint value for each semantic alias.
528
+ * @param {Record<string, Record<string, unknown>>} constraintValues
529
+ * @param {Record<string, string[]>} aliases
530
+ * @returns {Record<string, Record<string, unknown>>}
531
+ */
532
+ static #pickTypedConstraints(constraintValues, aliases) {
533
+ const typed = {}
534
+
535
+ for (const [alias, keys] of Object.entries(aliases)) {
536
+ const key = keys.find((candidate) => constraintValues[candidate])
537
+
538
+ if (key) {
539
+ typed[alias] = {
540
+ key,
541
+ ...constraintValues[key]
542
+ }
543
+ }
544
+ }
545
+
546
+ return typed
547
+ }
548
+
549
+ /**
550
+ * Converts a raw rule kind to a lower-kebab fallback id.
551
+ * @param {string} rawKind
552
+ * @returns {string}
553
+ */
554
+ static #toKebabCase(rawKind) {
555
+ return String(rawKind || 'unknown')
556
+ .replace(/([a-z0-9])([A-Z])/gu, '$1-$2')
557
+ .replace(/[^A-Za-z0-9]+/gu, '-')
558
+ .replace(/^-|-$/gu, '')
559
+ .toLowerCase()
560
+ }
561
+
562
+ /**
563
+ * Builds a readable fallback display name from a raw rule kind.
564
+ * @param {string} rawKind
565
+ * @returns {string}
566
+ */
567
+ static #displayRuleKind(rawKind) {
568
+ const spaced = String(rawKind || 'Unknown')
569
+ .replace(/([a-z0-9])([A-Z])/gu, '$1 $2')
570
+ .replace(/[_-]+/gu, ' ')
571
+ .trim()
572
+
573
+ return spaced || 'Unknown'
574
+ }
575
+
576
+ /**
577
+ * Parses an optional Altium boolean field.
578
+ * @param {Record<string, string | string[]>} fields
579
+ * @param {string} key
580
+ * @returns {boolean | null}
581
+ */
582
+ static #parseOptionalBoolean(fields, key) {
583
+ const raw = getField(fields, key)
584
+
585
+ return raw ? parseBoolean(raw) : null
586
+ }
587
+ }