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