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,560 @@
1
+ import { CircuitJsonDocument } from './CircuitJsonDocument.mjs'
2
+ import { CircuitJsonPcbPrimitiveBuilder } from './CircuitJsonPcbPrimitiveBuilder.mjs'
3
+
4
+ /**
5
+ * Builds renderer-neutral PCB primitives for element-array board documents.
6
+ */
7
+ export class PcbInteractionPrimitiveModel {
8
+ /**
9
+ * Builds a normalized primitive model from a parsed PCB document.
10
+ * @param {object | object[]} documentModel Parsed document model.
11
+ * @returns {{ bounds: object, layers: object[], virtualLayers: object[], components: object[], nets: object[], primitives: object[], anchors: object[], diagnostics: object[], airwires: object[], traceLengths: object[], groups: object[], anchorOffsets: object[] }}
12
+ */
13
+ static build(documentModel) {
14
+ if (
15
+ !PcbInteractionPrimitiveModel.#isElementArrayDocument(documentModel)
16
+ ) {
17
+ return PcbInteractionPrimitiveModel.#emptyModel()
18
+ }
19
+
20
+ return CircuitJsonPcbPrimitiveBuilder.build(documentModel)
21
+ }
22
+
23
+ /**
24
+ * Resolves physical and virtual PCB interaction layers.
25
+ * @param {object | object[]} documentModel Parsed document model.
26
+ * @returns {{ physicalLayers: object[], virtualLayers: object[] }}
27
+ */
28
+ static resolveLayerGroups(documentModel) {
29
+ if (
30
+ !PcbInteractionPrimitiveModel.#isElementArrayDocument(documentModel)
31
+ ) {
32
+ return { physicalLayers: [], virtualLayers: [] }
33
+ }
34
+
35
+ const model = CircuitJsonPcbPrimitiveBuilder.build(documentModel)
36
+ return {
37
+ physicalLayers: model.layers,
38
+ virtualLayers: model.virtualLayers || []
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Snaps a board point to the nearest primitive anchor within tolerance.
44
+ * @param {object | object[]} documentModel Parsed document model.
45
+ * @param {{ x?: unknown, y?: unknown }} point Board-space point.
46
+ * @param {{ tolerance?: number }} [options] Snap options.
47
+ * @returns {{ snapped: boolean, point: { x: number, y: number } }}
48
+ */
49
+ static resolveSnapPoint(documentModel, point, options = {}) {
50
+ const normalizedPoint = PcbInteractionPrimitiveModel.#point(point)
51
+ if (!normalizedPoint) return { snapped: false, point: { x: 0, y: 0 } }
52
+
53
+ const tolerance = Math.max(
54
+ PcbInteractionPrimitiveModel.#number(options.tolerance, 0),
55
+ 0
56
+ )
57
+ let bestPoint = null
58
+ let bestDistanceSq = Infinity
59
+
60
+ for (const anchor of PcbInteractionPrimitiveModel.build(documentModel)
61
+ .anchors) {
62
+ const distanceSq = PcbInteractionPrimitiveModel.#distanceSquared(
63
+ normalizedPoint,
64
+ anchor.point
65
+ )
66
+ if (distanceSq < bestDistanceSq) {
67
+ bestPoint = anchor.point
68
+ bestDistanceSq = distanceSq
69
+ }
70
+ }
71
+
72
+ if (!bestPoint || bestDistanceSq > tolerance * tolerance) {
73
+ return { snapped: false, point: normalizedPoint }
74
+ }
75
+
76
+ return { snapped: true, point: { x: bestPoint.x, y: bestPoint.y } }
77
+ }
78
+
79
+ /**
80
+ * Returns prioritized primitive hit candidates for one board point.
81
+ * @param {object | object[]} documentModel Parsed document model.
82
+ * @param {{ x?: unknown, y?: unknown }} point Board-space point.
83
+ * @param {{ side?: 'top' | 'bottom', hiddenLayers?: string[], hiddenObjects?: string[], tolerance?: number }} [options] Hit-test options.
84
+ * @returns {object[]}
85
+ */
86
+ static hitTest(documentModel, point, options = {}) {
87
+ const normalizedPoint = PcbInteractionPrimitiveModel.#point(point)
88
+ if (!normalizedPoint) return []
89
+
90
+ const tolerance = PcbInteractionPrimitiveModel.#number(
91
+ options.tolerance,
92
+ 0.2
93
+ )
94
+ const model = PcbInteractionPrimitiveModel.build(documentModel)
95
+ const groupsById = new Map(
96
+ (model.groups || []).map((group) => [String(group.id || ''), group])
97
+ )
98
+ const hits = []
99
+
100
+ for (const primitive of model.primitives) {
101
+ if (!PcbInteractionPrimitiveModel.#isVisible(primitive, options)) {
102
+ continue
103
+ }
104
+ const distance = PcbInteractionPrimitiveModel.#hitDistance(
105
+ primitive,
106
+ normalizedPoint,
107
+ tolerance
108
+ )
109
+ if (distance === null) continue
110
+
111
+ hits.push({
112
+ ...PcbInteractionPrimitiveModel.#candidate(
113
+ primitive,
114
+ groupsById
115
+ ),
116
+ distance
117
+ })
118
+ }
119
+
120
+ return hits.sort(
121
+ (left, right) =>
122
+ PcbInteractionPrimitiveModel.#priority(left.kind) -
123
+ PcbInteractionPrimitiveModel.#priority(right.kind) ||
124
+ left.distance - right.distance
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Returns true when the document uses the element-array source format.
130
+ * @param {object | object[]} documentModel Parsed document model.
131
+ * @returns {boolean}
132
+ */
133
+ static #isElementArrayDocument(documentModel) {
134
+ return CircuitJsonDocument.isModel(
135
+ CircuitJsonPcbPrimitiveBuilder.elements(documentModel)
136
+ )
137
+ }
138
+
139
+ /**
140
+ * Builds an empty primitive model.
141
+ * @returns {{ bounds: object, layers: object[], virtualLayers: object[], components: object[], nets: object[], primitives: object[], anchors: object[], diagnostics: object[], airwires: object[], traceLengths: object[], groups: object[], anchorOffsets: object[] }}
142
+ */
143
+ static #emptyModel() {
144
+ return {
145
+ bounds: PcbInteractionPrimitiveModel.#bounds(0, 0, 1, 1),
146
+ layers: [],
147
+ virtualLayers: [],
148
+ components: [],
149
+ nets: [],
150
+ primitives: [],
151
+ anchors: [],
152
+ diagnostics: [],
153
+ airwires: [],
154
+ traceLengths: [],
155
+ groups: [],
156
+ anchorOffsets: []
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Returns true when a primitive should participate in hit testing.
162
+ * @param {object} primitive Primitive row.
163
+ * @param {object} options Hit-test options.
164
+ * @returns {boolean}
165
+ */
166
+ static #isVisible(primitive, options) {
167
+ const hiddenLayers = new Set(
168
+ (Array.isArray(options.hiddenLayers) ? options.hiddenLayers : [])
169
+ .map(String)
170
+ .filter(Boolean)
171
+ )
172
+ if (primitive.layer && hiddenLayers.has(String(primitive.layer))) {
173
+ return false
174
+ }
175
+
176
+ const hiddenObjects = new Set(
177
+ (Array.isArray(options.hiddenObjects) ? options.hiddenObjects : [])
178
+ .map(String)
179
+ .filter(Boolean)
180
+ )
181
+ if (
182
+ hiddenObjects.has(
183
+ PcbInteractionPrimitiveModel.#componentSideObjectKey(primitive)
184
+ )
185
+ ) {
186
+ return false
187
+ }
188
+ if (
189
+ hiddenObjects.has(
190
+ PcbInteractionPrimitiveModel.#objectKey(primitive)
191
+ )
192
+ ) {
193
+ return false
194
+ }
195
+
196
+ const side = String(options.side || '')
197
+ return !side || !primitive.side || primitive.side === side
198
+ }
199
+
200
+ /**
201
+ * Resolves component-side object keys for component-backed primitives.
202
+ * @param {object} primitive Primitive row.
203
+ * @returns {string}
204
+ */
205
+ static #componentSideObjectKey(primitive) {
206
+ if (!primitive.componentKey && !primitive.componentId) return ''
207
+ if (primitive.side === 'top') return 'components-top'
208
+ if (primitive.side === 'bottom') return 'components-bottom'
209
+ return ''
210
+ }
211
+
212
+ /**
213
+ * Resolves the sidebar object key for a primitive.
214
+ * @param {object} primitive Primitive row.
215
+ * @returns {string}
216
+ */
217
+ static #objectKey(primitive) {
218
+ return (
219
+ {
220
+ board: 'page',
221
+ cutout: 'page',
222
+ pad: 'pads',
223
+ track: 'tracks',
224
+ via: 'vias',
225
+ zone: 'zones',
226
+ 'copper-text': 'footprint-text',
227
+ copper_text: 'footprint-text',
228
+ silkscreen: 'silkscreen',
229
+ silkscreen_text: 'silkscreen',
230
+ silkscreen_line: 'silkscreen',
231
+ fabrication: 'fabrication',
232
+ note: 'fabrication',
233
+ dimension: 'fabrication',
234
+ courtyard: 'courtyards',
235
+ 'solder-mask': 'solder-mask',
236
+ 'solder-paste': 'solder-paste',
237
+ ratsnest: 'rats-nest'
238
+ }[primitive.kind] || primitive.kind
239
+ )
240
+ }
241
+
242
+ /**
243
+ * Resolves one primitive hit distance.
244
+ * @param {object} primitive Primitive row.
245
+ * @param {{ x: number, y: number }} point Board point.
246
+ * @param {number} tolerance Hit tolerance.
247
+ * @returns {number | null}
248
+ */
249
+ static #hitDistance(primitive, point, tolerance) {
250
+ if (primitive.kind === 'track') {
251
+ const distance = PcbInteractionPrimitiveModel.#distanceToSegment(
252
+ point,
253
+ { x: primitive.x1, y: primitive.y1 },
254
+ { x: primitive.x2, y: primitive.y2 }
255
+ )
256
+ return distance <= primitive.width / 2 + tolerance ? distance : null
257
+ }
258
+ if (PcbInteractionPrimitiveModel.#isLineLikePrimitive(primitive)) {
259
+ const distance = PcbInteractionPrimitiveModel.#distanceToSegment(
260
+ point,
261
+ { x: primitive.x1, y: primitive.y1 },
262
+ { x: primitive.x2, y: primitive.y2 }
263
+ )
264
+ const width = PcbInteractionPrimitiveModel.#number(
265
+ primitive.width,
266
+ 0
267
+ )
268
+ return distance <= width / 2 + tolerance ? distance : null
269
+ }
270
+ if (primitive.kind === 'via') {
271
+ if (String(primitive.shape || 'circle') !== 'circle') {
272
+ return PcbInteractionPrimitiveModel.#inside(
273
+ point,
274
+ primitive.bounds,
275
+ tolerance
276
+ )
277
+ ? 0
278
+ : null
279
+ }
280
+ const distance = Math.sqrt(
281
+ PcbInteractionPrimitiveModel.#distanceSquared(point, primitive)
282
+ )
283
+ return distance <= primitive.diameter / 2 + tolerance
284
+ ? distance
285
+ : null
286
+ }
287
+ if (
288
+ ['pad', 'zone', 'keepout', 'cutout', 'courtyard'].includes(
289
+ primitive.kind
290
+ )
291
+ ) {
292
+ return PcbInteractionPrimitiveModel.#inside(
293
+ point,
294
+ primitive.bounds,
295
+ tolerance
296
+ )
297
+ ? 0
298
+ : null
299
+ }
300
+ if (primitive.kind === 'board') {
301
+ return PcbInteractionPrimitiveModel.#inside(
302
+ point,
303
+ primitive.bounds,
304
+ 0
305
+ )
306
+ ? Number.MAX_SAFE_INTEGER
307
+ : null
308
+ }
309
+ return null
310
+ }
311
+
312
+ /**
313
+ * Returns true when a primitive uses explicit segment geometry.
314
+ * @param {object} primitive Primitive row.
315
+ * @returns {boolean}
316
+ */
317
+ static #isLineLikePrimitive(primitive) {
318
+ if (
319
+ ![
320
+ 'silkscreen',
321
+ 'fabrication',
322
+ 'note',
323
+ 'dimension',
324
+ 'thermal-spoke',
325
+ 'route-hint',
326
+ 'courtyard'
327
+ ].includes(primitive.kind)
328
+ ) {
329
+ return false
330
+ }
331
+
332
+ return [primitive.x1, primitive.y1, primitive.x2, primitive.y2].every(
333
+ (value) => PcbInteractionPrimitiveModel.#finite(value) !== null
334
+ )
335
+ }
336
+
337
+ /**
338
+ * Builds a hit-test candidate from a primitive.
339
+ * @param {object} primitive Primitive row.
340
+ * @param {Map<string, object>} groupsById Group lookup.
341
+ * @returns {object}
342
+ */
343
+ static #candidate(primitive, groupsById) {
344
+ return {
345
+ id: String(primitive.id || ''),
346
+ role: primitive.kind,
347
+ kind: primitive.kind,
348
+ componentKey: String(primitive.componentKey || ''),
349
+ componentId: String(
350
+ primitive.componentId || primitive.componentKey || ''
351
+ ),
352
+ netName: String(primitive.netName || ''),
353
+ net: String(primitive.netName || ''),
354
+ layer: String(primitive.layer || ''),
355
+ layerKey: String(primitive.layer || ''),
356
+ groupIds: Array.isArray(primitive.groupIds)
357
+ ? primitive.groupIds
358
+ : [],
359
+ groups: PcbInteractionPrimitiveModel.#candidateGroups(
360
+ primitive,
361
+ groupsById
362
+ ),
363
+ source: primitive.source || primitive
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Builds group summaries for one candidate.
369
+ * @param {object} primitive Primitive row.
370
+ * @param {Map<string, object>} groupsById Group lookup.
371
+ * @returns {object[]}
372
+ */
373
+ static #candidateGroups(primitive, groupsById) {
374
+ return (Array.isArray(primitive.groupIds) ? primitive.groupIds : [])
375
+ .map((id) => groupsById.get(String(id || '')))
376
+ .filter(Boolean)
377
+ .map((group) => PcbInteractionPrimitiveModel.#groupSummary(group))
378
+ }
379
+
380
+ /**
381
+ * Builds a compact group summary for hit-test output.
382
+ * @param {object} group Group row.
383
+ * @returns {object}
384
+ */
385
+ static #groupSummary(group) {
386
+ return {
387
+ id: String(group.id || ''),
388
+ name: String(group.name || group.id || ''),
389
+ sourceGroupId: String(group.sourceGroupId || ''),
390
+ componentCount: Array.isArray(group.componentIds)
391
+ ? group.componentIds.length
392
+ : 0,
393
+ memberCount: Array.isArray(group.memberIds)
394
+ ? group.memberIds.length
395
+ : 0,
396
+ anchor: group.anchor || null,
397
+ ...PcbInteractionPrimitiveModel.#optionalCandidateField(
398
+ 'anchorAlignment',
399
+ group.anchorAlignment
400
+ ),
401
+ ...PcbInteractionPrimitiveModel.#optionalCandidateField(
402
+ 'positionMode',
403
+ group.positionMode
404
+ ),
405
+ ...PcbInteractionPrimitiveModel.#optionalCandidateField(
406
+ 'childLayoutMode',
407
+ group.childLayoutMode
408
+ ),
409
+ ...PcbInteractionPrimitiveModel.#optionalCandidateField(
410
+ 'layoutMode',
411
+ group.layoutMode
412
+ ),
413
+ ...PcbInteractionPrimitiveModel.#optionalCandidateNumber(
414
+ 'autorouterTraceClearance',
415
+ group.autorouterTraceClearance
416
+ ),
417
+ bounds: group.bounds || null
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Builds an optional string field for a candidate.
423
+ * @param {string} key Output key.
424
+ * @param {unknown} value Candidate value.
425
+ * @returns {object}
426
+ */
427
+ static #optionalCandidateField(key, value) {
428
+ const text = String(value || '').trim()
429
+ return text ? { [key]: text } : {}
430
+ }
431
+
432
+ /**
433
+ * Builds an optional number field for a candidate.
434
+ * @param {string} key Output key.
435
+ * @param {unknown} value Candidate value.
436
+ * @returns {object}
437
+ */
438
+ static #optionalCandidateNumber(key, value) {
439
+ const number = Number(value)
440
+ return Number.isFinite(number) ? { [key]: number } : {}
441
+ }
442
+
443
+ /**
444
+ * Returns sorting priority for one hit kind.
445
+ * @param {string} kind Primitive kind.
446
+ * @returns {number}
447
+ */
448
+ static #priority(kind) {
449
+ return { pad: 10, via: 20, track: 30, zone: 40, board: 100 }[kind] || 90
450
+ }
451
+
452
+ /**
453
+ * Returns true when a point is inside bounds.
454
+ * @param {{ x: number, y: number }} point Point.
455
+ * @param {object} bounds Bounds.
456
+ * @param {number} padding Padding.
457
+ * @returns {boolean}
458
+ */
459
+ static #inside(point, bounds, padding) {
460
+ return (
461
+ point.x >= bounds.minX - padding &&
462
+ point.x <= bounds.maxX + padding &&
463
+ point.y >= bounds.minY - padding &&
464
+ point.y <= bounds.maxY + padding
465
+ )
466
+ }
467
+
468
+ /**
469
+ * Resolves a point from common x/y fields.
470
+ * @param {object | null | undefined} value Point candidate.
471
+ * @returns {{ x: number, y: number } | null}
472
+ */
473
+ static #point(value) {
474
+ const x = PcbInteractionPrimitiveModel.#finite(value?.x)
475
+ const y = PcbInteractionPrimitiveModel.#finite(value?.y)
476
+ if (x === null || y === null) return null
477
+ return { x, y }
478
+ }
479
+
480
+ /**
481
+ * Builds a normalized bounds record.
482
+ * @param {number} minX Minimum x.
483
+ * @param {number} minY Minimum y.
484
+ * @param {number} maxX Maximum x.
485
+ * @param {number} maxY Maximum y.
486
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number }}
487
+ */
488
+ static #bounds(minX, minY, maxX, maxY) {
489
+ return {
490
+ minX,
491
+ minY,
492
+ maxX,
493
+ maxY,
494
+ width: maxX - minX,
495
+ height: maxY - minY
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Resolves squared point distance.
501
+ * @param {{ x: number, y: number }} left First point.
502
+ * @param {{ x: number, y: number }} right Second point.
503
+ * @returns {number}
504
+ */
505
+ static #distanceSquared(left, right) {
506
+ return (left.x - right.x) ** 2 + (left.y - right.y) ** 2
507
+ }
508
+
509
+ /**
510
+ * Resolves point-to-segment distance.
511
+ * @param {{ x: number, y: number }} point Point.
512
+ * @param {{ x: number, y: number }} start Segment start.
513
+ * @param {{ x: number, y: number }} end Segment end.
514
+ * @returns {number}
515
+ */
516
+ static #distanceToSegment(point, start, end) {
517
+ const dx = end.x - start.x
518
+ const dy = end.y - start.y
519
+ const lengthSq = dx * dx + dy * dy
520
+ if (!lengthSq) {
521
+ return Math.sqrt(
522
+ PcbInteractionPrimitiveModel.#distanceSquared(point, start)
523
+ )
524
+ }
525
+ const t = Math.max(
526
+ 0,
527
+ Math.min(
528
+ 1,
529
+ ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq
530
+ )
531
+ )
532
+ return Math.sqrt(
533
+ PcbInteractionPrimitiveModel.#distanceSquared(point, {
534
+ x: start.x + t * dx,
535
+ y: start.y + t * dy
536
+ })
537
+ )
538
+ }
539
+
540
+ /**
541
+ * Converts a value to a finite number or null.
542
+ * @param {unknown} value Numeric candidate.
543
+ * @returns {number | null}
544
+ */
545
+ static #finite(value) {
546
+ if (value === undefined || value === null || value === '') return null
547
+ const number = Number(value)
548
+ return Number.isFinite(number) ? number : null
549
+ }
550
+
551
+ /**
552
+ * Converts a value to a finite number with fallback.
553
+ * @param {unknown} value Numeric candidate.
554
+ * @param {number} fallback Fallback number.
555
+ * @returns {number}
556
+ */
557
+ static #number(value, fallback) {
558
+ return PcbInteractionPrimitiveModel.#finite(value) ?? fallback
559
+ }
560
+ }