altium-toolkit 1.0.8 → 1.0.9

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 (88) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/styles/altium-renderers.css +19 -0
  77. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  78. package/src/ui/PcbInteractionIndex.mjs +9 -4
  79. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  80. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  81. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  82. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  83. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  84. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  85. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  86. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  87. package/src/ui/SchematicTypography.mjs +48 -5
  88. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,532 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic PCB QA and statistics summaries.
7
+ */
8
+ export class PcbStatisticsBuilder {
9
+ /**
10
+ * Builds statistics for one normalized PCB object.
11
+ * @param {object} pcb Normalized PCB model.
12
+ * @returns {object}
13
+ */
14
+ static build(pcb) {
15
+ return {
16
+ schema: 'altium-toolkit.pcb.statistics.a1',
17
+ board: PcbStatisticsBuilder.#boardStats(pcb?.boardOutline || {}),
18
+ drills: PcbStatisticsBuilder.#drillStats(
19
+ pcb?.pads || [],
20
+ pcb?.vias || []
21
+ ),
22
+ primitiveWidths: PcbStatisticsBuilder.#primitiveWidthStats(pcb),
23
+ layers: PcbStatisticsBuilder.#layerStats(pcb),
24
+ planning: PcbStatisticsBuilder.#planningStats(pcb)
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Builds board outline statistics.
30
+ * @param {object} boardOutline Board outline object.
31
+ * @returns {object}
32
+ */
33
+ static #boardStats(boardOutline) {
34
+ const widthMil = PcbStatisticsBuilder.#round(boardOutline.widthMil)
35
+ const heightMil = PcbStatisticsBuilder.#round(boardOutline.heightMil)
36
+ const minX = Number(boardOutline.minX || 0)
37
+ const minY = Number(boardOutline.minY || 0)
38
+
39
+ return {
40
+ widthMil,
41
+ heightMil,
42
+ centroidMil: {
43
+ x: PcbStatisticsBuilder.#round(minX + widthMil / 2),
44
+ y: PcbStatisticsBuilder.#round(minY + heightMil / 2)
45
+ },
46
+ outlineSegmentCount: Array.isArray(boardOutline.segments)
47
+ ? boardOutline.segments.length
48
+ : 0,
49
+ cutoutCount: Array.isArray(boardOutline.cutouts)
50
+ ? boardOutline.cutouts.length
51
+ : 0
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Builds drill and slot counters.
57
+ * @param {object[]} pads Pad primitives.
58
+ * @param {object[]} vias Via primitives.
59
+ * @returns {object}
60
+ */
61
+ static #drillStats(pads, vias) {
62
+ const padHoles = (pads || []).filter((pad) =>
63
+ PcbStatisticsBuilder.#hasHole(pad)
64
+ )
65
+ const viaHoles = (vias || []).filter((via) =>
66
+ PcbStatisticsBuilder.#hasHole(via)
67
+ )
68
+ const holes = [...padHoles, ...viaHoles]
69
+
70
+ return {
71
+ totalHoleCount: holes.length,
72
+ padHoleCount: padHoles.length,
73
+ viaHoleCount: viaHoles.length,
74
+ platedHoleCount: holes.filter((hole) => hole.isPlated !== false)
75
+ .length,
76
+ nonPlatedHoleCount: holes.filter((hole) => hole.isPlated === false)
77
+ .length,
78
+ slotCount: holes.filter((hole) =>
79
+ PcbStatisticsBuilder.#hasSlot(hole)
80
+ ).length,
81
+ holeDiameterMil: PcbStatisticsBuilder.#histogram(
82
+ holes.map((hole) => hole.holeDiameter)
83
+ ),
84
+ slotLengthMil: PcbStatisticsBuilder.#histogram(
85
+ holes
86
+ .filter((hole) => PcbStatisticsBuilder.#hasSlot(hole))
87
+ .map((hole) => hole.holeSlotLength || hole.slotLength)
88
+ )
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Builds primitive-width histograms.
94
+ * @param {object} pcb Normalized PCB model.
95
+ * @returns {object}
96
+ */
97
+ static #primitiveWidthStats(pcb) {
98
+ return {
99
+ tracksMil: PcbStatisticsBuilder.#histogram(
100
+ (pcb?.tracks || []).map((track) => track.width)
101
+ ),
102
+ arcsMil: PcbStatisticsBuilder.#histogram(
103
+ (pcb?.arcs || []).map((arc) => arc.width)
104
+ ),
105
+ viasMil: PcbStatisticsBuilder.#histogram(
106
+ (pcb?.vias || []).map((via) => via.diameter)
107
+ ),
108
+ padsTopXMil: PcbStatisticsBuilder.#histogram(
109
+ (pcb?.pads || []).map((pad) => pad.sizeTopX)
110
+ )
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Builds a layer-stack and per-layer primitive summary.
116
+ * @param {object} pcb Normalized PCB model.
117
+ * @returns {object}
118
+ */
119
+ static #layerStats(pcb) {
120
+ const layerIds = PcbStatisticsBuilder.#collectLayerIds(pcb)
121
+ const entries = layerIds.map((layerId) => {
122
+ const layer = PcbStatisticsBuilder.#findLayer(pcb, layerId)
123
+
124
+ return PcbStatisticsBuilder.#stripUndefined({
125
+ layerId,
126
+ name:
127
+ layer?.name ||
128
+ PcbStatisticsBuilder.#primitiveLayerName(pcb, layerId) ||
129
+ 'L' + layerId,
130
+ role: PcbStatisticsBuilder.#layerRole(layer),
131
+ material: layer?.material,
132
+ thicknessMil: PcbStatisticsBuilder.#optionalRound(
133
+ layer?.thicknessMil
134
+ ),
135
+ copperThicknessMil: PcbStatisticsBuilder.#optionalRound(
136
+ layer?.copperThicknessMil
137
+ ),
138
+ copperWeight: layer?.copperWeight,
139
+ dielectricConstant: PcbStatisticsBuilder.#optionalRound(
140
+ layer?.dielectricConstant
141
+ ),
142
+ dissipationFactor: PcbStatisticsBuilder.#optionalRound(
143
+ layer?.dissipationFactor
144
+ ),
145
+ primitiveCounts: PcbStatisticsBuilder.#primitiveCountsForLayer(
146
+ pcb,
147
+ layerId
148
+ )
149
+ })
150
+ })
151
+
152
+ return {
153
+ count: entries.length,
154
+ summary: PcbStatisticsBuilder.#layerMaterialSummary(
155
+ pcb?.layers || []
156
+ ),
157
+ entries
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Builds board-planning statistics for keepouts, rooms, and rigid-flex regions.
163
+ * @param {object} pcb Normalized PCB model.
164
+ * @returns {{ keepouts: object, rooms: object, boardRegions: object }}
165
+ */
166
+ static #planningStats(pcb) {
167
+ const regions = pcb?.regions || []
168
+ const shapeBasedRegions = pcb?.shapeBasedRegions || []
169
+ const boardRegions = pcb?.boardRegions || []
170
+ const roomNames = PcbStatisticsBuilder.#collectRoomNames(
171
+ pcb?.rules || []
172
+ )
173
+
174
+ return {
175
+ keepouts: {
176
+ totalCount:
177
+ PcbStatisticsBuilder.#keepoutCount(regions) +
178
+ PcbStatisticsBuilder.#keepoutCount(shapeBasedRegions) +
179
+ PcbStatisticsBuilder.#keepoutCount(boardRegions),
180
+ regionCount: PcbStatisticsBuilder.#keepoutCount(regions),
181
+ shapeBasedRegionCount:
182
+ PcbStatisticsBuilder.#keepoutCount(shapeBasedRegions),
183
+ boardRegionCount:
184
+ PcbStatisticsBuilder.#keepoutCount(boardRegions)
185
+ },
186
+ rooms: {
187
+ ruleCount: PcbStatisticsBuilder.#roomRuleCount(
188
+ pcb?.rules || []
189
+ ),
190
+ namedRoomCount: roomNames.length,
191
+ names: roomNames
192
+ },
193
+ boardRegions: {
194
+ boardRegionCount: boardRegions.length,
195
+ flexRegionCount: boardRegions.filter(
196
+ (region) => region?.isFlexRegion === true
197
+ ).length,
198
+ rigidRegionCount: boardRegions.filter(
199
+ (region) => region?.isRigidRegion === true
200
+ ).length,
201
+ locked3dCount: boardRegions.filter(
202
+ (region) => region?.locked3d === true
203
+ ).length,
204
+ bendingLineCount: boardRegions.reduce(
205
+ (total, region) =>
206
+ total + Number(region?.bendingLineCount || 0),
207
+ 0
208
+ ),
209
+ layerStacks:
210
+ PcbStatisticsBuilder.#boardRegionLayerStacks(boardRegions)
211
+ }
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Counts primitives marked as keepouts.
217
+ * @param {object[]} regions Region-like primitives.
218
+ * @returns {number}
219
+ */
220
+ static #keepoutCount(regions) {
221
+ return (regions || []).filter((region) => region?.isKeepout === true)
222
+ .length
223
+ }
224
+
225
+ /**
226
+ * Counts room-related design rules.
227
+ * @param {object[]} rules Parsed design rules.
228
+ * @returns {number}
229
+ */
230
+ static #roomRuleCount(rules) {
231
+ return (rules || []).filter((rule) =>
232
+ PcbStatisticsBuilder.#isRoomRule(rule)
233
+ ).length
234
+ }
235
+
236
+ /**
237
+ * Returns true when a rule references a placement room.
238
+ * @param {object} rule Parsed design rule.
239
+ * @returns {boolean}
240
+ */
241
+ static #isRoomRule(rule) {
242
+ const fields = [
243
+ rule?.ruleKind,
244
+ rule?.ruleType?.kind,
245
+ rule?.ruleType?.displayName,
246
+ rule?.scope1Expression,
247
+ rule?.scope2Expression
248
+ ]
249
+ .filter(Boolean)
250
+ .join(' ')
251
+ .toLowerCase()
252
+
253
+ return fields.includes('room')
254
+ }
255
+
256
+ /**
257
+ * Collects named placement rooms from rule scopes.
258
+ * @param {object[]} rules Parsed design rules.
259
+ * @returns {string[]}
260
+ */
261
+ static #collectRoomNames(rules) {
262
+ const names = new Set()
263
+
264
+ for (const rule of rules || []) {
265
+ for (const scope of [rule?.scope1, rule?.scope2]) {
266
+ if (scope?.predicate !== 'WithinRoom') {
267
+ continue
268
+ }
269
+
270
+ for (const roomName of scope.arguments || []) {
271
+ const normalized = String(roomName || '').trim()
272
+ if (normalized) {
273
+ names.add(normalized)
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ return [...names].sort((left, right) => left.localeCompare(right))
280
+ }
281
+
282
+ /**
283
+ * Builds a histogram of board-region layer-stack identities.
284
+ * @param {object[]} boardRegions Parsed board-region primitives.
285
+ * @returns {Record<string, number>}
286
+ */
287
+ static #boardRegionLayerStacks(boardRegions) {
288
+ const histogram = {}
289
+
290
+ for (const region of boardRegions || []) {
291
+ const layerStack = String(
292
+ region?.substackName || region?.layerStackId || ''
293
+ ).trim()
294
+ if (!layerStack) {
295
+ continue
296
+ }
297
+
298
+ histogram[layerStack] = (histogram[layerStack] || 0) + 1
299
+ }
300
+
301
+ return histogram
302
+ }
303
+
304
+ /**
305
+ * Collects layer ids mentioned by stack entries or primitives.
306
+ * @param {object} pcb Normalized PCB model.
307
+ * @returns {number[]}
308
+ */
309
+ static #collectLayerIds(pcb) {
310
+ const layerIds = new Set()
311
+
312
+ for (const layer of pcb?.layers || []) {
313
+ if (Number.isInteger(layer.layerId)) {
314
+ layerIds.add(layer.layerId)
315
+ }
316
+ }
317
+
318
+ for (const family of PcbStatisticsBuilder.#primitiveFamilies()) {
319
+ for (const primitive of pcb?.[family] || []) {
320
+ if (Number.isInteger(primitive?.layerId)) {
321
+ layerIds.add(primitive.layerId)
322
+ }
323
+ }
324
+ }
325
+
326
+ return [...layerIds].sort((left, right) => left - right)
327
+ }
328
+
329
+ /**
330
+ * Counts primitives on one layer.
331
+ * @param {object} pcb Normalized PCB model.
332
+ * @param {number} layerId Numeric layer id.
333
+ * @returns {Record<string, number>}
334
+ */
335
+ static #primitiveCountsForLayer(pcb, layerId) {
336
+ return Object.fromEntries(
337
+ PcbStatisticsBuilder.#primitiveFamilies().map((family) => [
338
+ family,
339
+ (pcb?.[family] || []).filter(
340
+ (primitive) => primitive?.layerId === layerId
341
+ ).length
342
+ ])
343
+ )
344
+ }
345
+
346
+ /**
347
+ * Returns primitive families included in layer summaries.
348
+ * @returns {string[]}
349
+ */
350
+ static #primitiveFamilies() {
351
+ return [
352
+ 'tracks',
353
+ 'arcs',
354
+ 'vias',
355
+ 'pads',
356
+ 'fills',
357
+ 'texts',
358
+ 'regions',
359
+ 'shapeBasedRegions'
360
+ ]
361
+ }
362
+
363
+ /**
364
+ * Finds a layer-stack entry by id.
365
+ * @param {object} pcb Normalized PCB model.
366
+ * @param {number} layerId Numeric layer id.
367
+ * @returns {object | null}
368
+ */
369
+ static #findLayer(pcb, layerId) {
370
+ return (
371
+ (pcb?.layers || []).find((layer) => layer?.layerId === layerId) ||
372
+ null
373
+ )
374
+ }
375
+
376
+ /**
377
+ * Finds a primitive-layer display name by id.
378
+ * @param {object} pcb Normalized PCB model.
379
+ * @param {number} layerId Numeric layer id.
380
+ * @returns {string}
381
+ */
382
+ static #primitiveLayerName(pcb, layerId) {
383
+ return String(
384
+ (pcb?.primitiveLayers || []).find(
385
+ (layer) => layer?.layerId === layerId
386
+ )?.name || ''
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Resolves a compact layer role.
392
+ * @param {object | null} layer Layer-stack entry.
393
+ * @returns {string}
394
+ */
395
+ static #layerRole(layer) {
396
+ const name = String(layer?.name || '').toLowerCase()
397
+ const kind = String(layer?.kind || layer?.role || '').toLowerCase()
398
+
399
+ if (kind.includes('dielectric')) return 'dielectric'
400
+ if (name.includes('mask')) return 'mask'
401
+ if (name.includes('paste')) return 'paste'
402
+ if (name.includes('silk') || name.includes('overlay')) return 'overlay'
403
+ if (name.includes('mechanical')) return 'mechanical'
404
+
405
+ return 'signal'
406
+ }
407
+
408
+ /**
409
+ * Builds aggregate layer-stack material and role statistics.
410
+ * @param {object[]} layers Layer-stack entries.
411
+ * @returns {object}
412
+ */
413
+ static #layerMaterialSummary(layers) {
414
+ const summary = {
415
+ signalLayerCount: 0,
416
+ dielectricLayerCount: 0,
417
+ copperLayerCount: 0,
418
+ dielectricThicknessMil: 0,
419
+ materials: {}
420
+ }
421
+
422
+ for (const layer of layers || []) {
423
+ const role = PcbStatisticsBuilder.#layerRole(layer)
424
+ const material = String(layer?.material || '').trim()
425
+ const kind = String(layer?.kind || '').toLowerCase()
426
+
427
+ if (role === 'signal') {
428
+ summary.signalLayerCount += 1
429
+ }
430
+ if (role === 'dielectric') {
431
+ summary.dielectricLayerCount += 1
432
+ summary.dielectricThicknessMil += Number(
433
+ layer?.thicknessMil || 0
434
+ )
435
+ }
436
+ if (
437
+ /copper/u.test(material.toLowerCase()) ||
438
+ /copper/u.test(kind)
439
+ ) {
440
+ summary.copperLayerCount += 1
441
+ }
442
+ if (material) {
443
+ summary.materials[material] =
444
+ (summary.materials[material] || 0) + 1
445
+ }
446
+ }
447
+
448
+ summary.dielectricThicknessMil = PcbStatisticsBuilder.#round(
449
+ summary.dielectricThicknessMil
450
+ )
451
+
452
+ return summary
453
+ }
454
+
455
+ /**
456
+ * Returns true when a primitive has a drill.
457
+ * @param {object} primitive Primitive object.
458
+ * @returns {boolean}
459
+ */
460
+ static #hasHole(primitive) {
461
+ return Number(primitive?.holeDiameter || 0) > 0
462
+ }
463
+
464
+ /**
465
+ * Returns true when a drill is a slot.
466
+ * @param {object} primitive Primitive object.
467
+ * @returns {boolean}
468
+ */
469
+ static #hasSlot(primitive) {
470
+ return (
471
+ Number(primitive?.holeSlotLength || primitive?.slotLength || 0) >
472
+ 0 || Number(primitive?.holeShape || 0) === 2
473
+ )
474
+ }
475
+
476
+ /**
477
+ * Builds a numeric histogram from values.
478
+ * @param {unknown[]} values Numeric values.
479
+ * @returns {Record<string, number>}
480
+ */
481
+ static #histogram(values) {
482
+ const histogram = {}
483
+
484
+ for (const value of values || []) {
485
+ const number = PcbStatisticsBuilder.#round(value)
486
+ if (!Number.isFinite(number) || number === 0) {
487
+ continue
488
+ }
489
+
490
+ histogram[String(number)] = (histogram[String(number)] || 0) + 1
491
+ }
492
+
493
+ return histogram
494
+ }
495
+
496
+ /**
497
+ * Rounds numeric values for stable JSON output.
498
+ * @param {unknown} value Numeric value.
499
+ * @returns {number}
500
+ */
501
+ static #round(value) {
502
+ const number = Number(value || 0)
503
+
504
+ return Number.isFinite(number) ? Math.round(number * 1000) / 1000 : 0
505
+ }
506
+
507
+ /**
508
+ * Rounds a numeric value only when it is present and finite.
509
+ * @param {unknown} value Candidate numeric value.
510
+ * @returns {number | undefined}
511
+ */
512
+ static #optionalRound(value) {
513
+ const number = Number(value)
514
+
515
+ return Number.isFinite(number)
516
+ ? Math.round(number * 1000) / 1000
517
+ : undefined
518
+ }
519
+
520
+ /**
521
+ * Removes undefined values from one statistics object.
522
+ * @param {object} value Source object.
523
+ * @returns {object}
524
+ */
525
+ static #stripUndefined(value) {
526
+ return Object.fromEntries(
527
+ Object.entries(value).filter(
528
+ ([, entryValue]) => entryValue !== undefined
529
+ )
530
+ )
531
+ }
532
+ }