altium-toolkit 1.0.9 → 1.1.0

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 (79) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,730 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic routed-net summaries from normalized PCB primitives.
7
+ */
8
+ export class PcbRouteAnalysisBuilder {
9
+ static SCHEMA = 'altium-toolkit.pcb.route-analysis.a1'
10
+
11
+ /**
12
+ * Builds a route-analysis read model.
13
+ * @param {object} pcb Normalized PCB model and metadata.
14
+ * @returns {object}
15
+ */
16
+ static build(pcb = {}) {
17
+ const layerLookup = PcbRouteAnalysisBuilder.#layerLookup(pcb)
18
+ const routePrimitives = PcbRouteAnalysisBuilder.#routePrimitives(
19
+ pcb,
20
+ layerLookup
21
+ )
22
+ const viaRows = PcbRouteAnalysisBuilder.#viaRows(pcb, layerLookup)
23
+ const netRows = PcbRouteAnalysisBuilder.#netRows(
24
+ pcb,
25
+ routePrimitives,
26
+ viaRows
27
+ )
28
+ const summary = PcbRouteAnalysisBuilder.#summary(
29
+ pcb,
30
+ routePrimitives,
31
+ viaRows,
32
+ netRows
33
+ )
34
+
35
+ return {
36
+ schema: PcbRouteAnalysisBuilder.SCHEMA,
37
+ units: {
38
+ coordinate: 'mil',
39
+ length: 'mil',
40
+ angle: 'deg'
41
+ },
42
+ summary,
43
+ byNet: netRows,
44
+ classes: PcbRouteAnalysisBuilder.#classRows(pcb, netRows),
45
+ differentialPairs: PcbRouteAnalysisBuilder.#differentialPairRows(
46
+ pcb,
47
+ netRows
48
+ )
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Builds layer descriptors keyed by id.
54
+ * @param {object} pcb Normalized PCB model.
55
+ * @returns {Map<number, object>}
56
+ */
57
+ static #layerLookup(pcb) {
58
+ const lookup = new Map()
59
+
60
+ for (const layer of [
61
+ ...(pcb?.layers || []),
62
+ ...(pcb?.primitiveLayers || [])
63
+ ]) {
64
+ const layerId = PcbRouteAnalysisBuilder.#layerId(layer)
65
+ if (!Number.isInteger(layerId) || lookup.has(layerId)) {
66
+ continue
67
+ }
68
+ lookup.set(layerId, {
69
+ layerId,
70
+ layerKey: 'L' + layerId,
71
+ displayName:
72
+ layer.displayName || layer.name || 'Layer ' + layerId
73
+ })
74
+ }
75
+
76
+ return lookup
77
+ }
78
+
79
+ /**
80
+ * Builds route primitive rows for tracks and arcs.
81
+ * @param {object} pcb Normalized PCB model.
82
+ * @param {Map<number, object>} layerLookup Layer lookup.
83
+ * @returns {object[]}
84
+ */
85
+ static #routePrimitives(pcb, layerLookup) {
86
+ return [
87
+ ...(pcb?.tracks || []).map((track, index) =>
88
+ PcbRouteAnalysisBuilder.#trackRow(track, index, layerLookup)
89
+ ),
90
+ ...(pcb?.arcs || []).map((arc, index) =>
91
+ PcbRouteAnalysisBuilder.#arcRow(arc, index, layerLookup)
92
+ )
93
+ ].filter((primitive) => primitive.netName && primitive.lengthMil > 0)
94
+ }
95
+
96
+ /**
97
+ * Builds one track route row.
98
+ * @param {object} track Track primitive.
99
+ * @param {number} index Track index.
100
+ * @param {Map<number, object>} layerLookup Layer lookup.
101
+ * @returns {object}
102
+ */
103
+ static #trackRow(track, index, layerLookup) {
104
+ const layer = PcbRouteAnalysisBuilder.#primitiveLayer(
105
+ track,
106
+ layerLookup
107
+ )
108
+ const start = PcbRouteAnalysisBuilder.#point(track.x1, track.y1)
109
+ const end = PcbRouteAnalysisBuilder.#point(track.x2, track.y2)
110
+
111
+ return PcbRouteAnalysisBuilder.#stripEmpty({
112
+ primitiveKey: 'track-' + index,
113
+ kind: 'track',
114
+ netName: PcbRouteAnalysisBuilder.#netName(track),
115
+ layerId: layer?.layerId,
116
+ layerKey: layer?.layerKey,
117
+ layerDisplayName: layer?.displayName,
118
+ lengthMil: PcbRouteAnalysisBuilder.#round(
119
+ PcbRouteAnalysisBuilder.#distance(start, end)
120
+ ),
121
+ endpoints: [start, end]
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Builds one arc route row.
127
+ * @param {object} arc Arc primitive.
128
+ * @param {number} index Arc index.
129
+ * @param {Map<number, object>} layerLookup Layer lookup.
130
+ * @returns {object}
131
+ */
132
+ static #arcRow(arc, index, layerLookup) {
133
+ const layer = PcbRouteAnalysisBuilder.#primitiveLayer(arc, layerLookup)
134
+ const endpoints = PcbRouteAnalysisBuilder.#arcEndpoints(arc)
135
+
136
+ return PcbRouteAnalysisBuilder.#stripEmpty({
137
+ primitiveKey: 'arc-' + index,
138
+ kind: 'arc',
139
+ netName: PcbRouteAnalysisBuilder.#netName(arc),
140
+ layerId: layer?.layerId,
141
+ layerKey: layer?.layerKey,
142
+ layerDisplayName: layer?.displayName,
143
+ lengthMil: PcbRouteAnalysisBuilder.#round(
144
+ Number(arc.radius || 0) *
145
+ Math.abs(PcbRouteAnalysisBuilder.#arcSweepRadians(arc))
146
+ ),
147
+ endpoints
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Builds via participation rows.
153
+ * @param {object} pcb Normalized PCB model.
154
+ * @param {Map<number, object>} layerLookup Layer lookup.
155
+ * @returns {object[]}
156
+ */
157
+ static #viaRows(pcb, layerLookup) {
158
+ return (pcb?.vias || [])
159
+ .map((via, index) => {
160
+ const layer = PcbRouteAnalysisBuilder.#primitiveLayer(
161
+ via,
162
+ layerLookup
163
+ )
164
+
165
+ return PcbRouteAnalysisBuilder.#stripEmpty({
166
+ primitiveKey: 'via-' + index,
167
+ kind: 'via',
168
+ netName: PcbRouteAnalysisBuilder.#netName(via),
169
+ layerId: layer?.layerId,
170
+ layerKey: layer?.layerKey,
171
+ layerDisplayName: layer?.displayName,
172
+ point: PcbRouteAnalysisBuilder.#point(via.x, via.y)
173
+ })
174
+ })
175
+ .filter((via) => via.netName)
176
+ }
177
+
178
+ /**
179
+ * Builds deterministic net route rows.
180
+ * @param {object} pcb Normalized PCB model.
181
+ * @param {object[]} routePrimitives Route primitives.
182
+ * @param {object[]} viaRows Via rows.
183
+ * @returns {object[]}
184
+ */
185
+ static #netRows(pcb, routePrimitives, viaRows) {
186
+ const netNames = new Set([
187
+ ...(pcb?.nets || []).map((net) => net.name).filter(Boolean),
188
+ ...routePrimitives.map((primitive) => primitive.netName),
189
+ ...viaRows.map((via) => via.netName)
190
+ ])
191
+
192
+ return [...netNames]
193
+ .map((netName) =>
194
+ PcbRouteAnalysisBuilder.#netRow(
195
+ netName,
196
+ routePrimitives.filter(
197
+ (primitive) => primitive.netName === netName
198
+ ),
199
+ viaRows.filter((via) => via.netName === netName)
200
+ )
201
+ )
202
+ .filter((net) => net.totalLengthMil > 0 || net.viaCount > 0)
203
+ .sort((left, right) =>
204
+ left.netName.localeCompare(right.netName, undefined, {
205
+ numeric: true
206
+ })
207
+ )
208
+ }
209
+
210
+ /**
211
+ * Builds one net route row.
212
+ * @param {string} netName Net name.
213
+ * @param {object[]} primitives Route primitives.
214
+ * @param {object[]} vias Via rows.
215
+ * @returns {object}
216
+ */
217
+ static #netRow(netName, primitives, vias) {
218
+ const trackRows = primitives.filter(
219
+ (primitive) => primitive.kind === 'track'
220
+ )
221
+ const arcRows = primitives.filter(
222
+ (primitive) => primitive.kind === 'arc'
223
+ )
224
+
225
+ return {
226
+ netName,
227
+ totalLengthMil: PcbRouteAnalysisBuilder.#sumLength(primitives),
228
+ trackLengthMil: PcbRouteAnalysisBuilder.#sumLength(trackRows),
229
+ arcLengthMil: PcbRouteAnalysisBuilder.#sumLength(arcRows),
230
+ trackCount: trackRows.length,
231
+ arcCount: arcRows.length,
232
+ viaCount: vias.length,
233
+ layers: PcbRouteAnalysisBuilder.#layerKeys(primitives, vias),
234
+ layerParticipation: PcbRouteAnalysisBuilder.#layerParticipation(
235
+ primitives,
236
+ vias
237
+ ),
238
+ connectedRouteGroups:
239
+ PcbRouteAnalysisBuilder.#connectedRouteGroups(primitives)
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Builds per-net-class length summaries.
245
+ * @param {object} pcb Normalized PCB model.
246
+ * @param {object[]} netRows Net rows.
247
+ * @returns {object[]}
248
+ */
249
+ static #classRows(pcb, netRows) {
250
+ const lengthByNet = PcbRouteAnalysisBuilder.#lengthByNet(netRows)
251
+ const knownNetNames = new Set(netRows.map((net) => net.netName))
252
+
253
+ return (pcb?.classes || [])
254
+ .filter((classRecord) =>
255
+ PcbRouteAnalysisBuilder.#isNetClass(classRecord)
256
+ )
257
+ .map((classRecord) => {
258
+ const netNames = (classRecord.members || [])
259
+ .filter((member) => knownNetNames.has(member))
260
+ .sort((left, right) =>
261
+ left.localeCompare(right, undefined, { numeric: true })
262
+ )
263
+
264
+ return PcbRouteAnalysisBuilder.#stripEmpty({
265
+ name: classRecord.name,
266
+ kindName: classRecord.kindName,
267
+ members: [...(classRecord.members || [])],
268
+ netNames,
269
+ totalLengthMil: PcbRouteAnalysisBuilder.#round(
270
+ netNames.reduce(
271
+ (total, netName) =>
272
+ total + Number(lengthByNet.get(netName) || 0),
273
+ 0
274
+ )
275
+ )
276
+ })
277
+ })
278
+ .filter((classRow) => classRow.name && classRow.netNames?.length)
279
+ .sort((left, right) =>
280
+ left.name.localeCompare(right.name, undefined, {
281
+ numeric: true
282
+ })
283
+ )
284
+ }
285
+
286
+ /**
287
+ * Builds differential-pair route summaries.
288
+ * @param {object} pcb Normalized PCB model.
289
+ * @param {object[]} netRows Net rows.
290
+ * @returns {object[]}
291
+ */
292
+ static #differentialPairRows(pcb, netRows) {
293
+ const lengthByNet = PcbRouteAnalysisBuilder.#lengthByNet(netRows)
294
+
295
+ return (pcb?.differentialPairs || [])
296
+ .map((pair) => {
297
+ const positiveLength = Number(
298
+ lengthByNet.get(pair.positiveNetName) || 0
299
+ )
300
+ const negativeLength = Number(
301
+ lengthByNet.get(pair.negativeNetName) || 0
302
+ )
303
+
304
+ return PcbRouteAnalysisBuilder.#stripEmpty({
305
+ name: pair.name,
306
+ positiveNetName: pair.positiveNetName,
307
+ negativeNetName: pair.negativeNetName,
308
+ positiveLengthMil:
309
+ PcbRouteAnalysisBuilder.#round(positiveLength),
310
+ negativeLengthMil:
311
+ PcbRouteAnalysisBuilder.#round(negativeLength),
312
+ skewLengthMil: PcbRouteAnalysisBuilder.#round(
313
+ Math.abs(positiveLength - negativeLength)
314
+ ),
315
+ classes: pair.classNames || []
316
+ })
317
+ })
318
+ .filter(
319
+ (pair) =>
320
+ pair.name &&
321
+ (pair.positiveLengthMil > 0 || pair.negativeLengthMil > 0)
322
+ )
323
+ .sort((left, right) =>
324
+ left.name.localeCompare(right.name, undefined, {
325
+ numeric: true
326
+ })
327
+ )
328
+ }
329
+
330
+ /**
331
+ * Builds top-level route summary counters.
332
+ * @param {object} pcb Normalized PCB model.
333
+ * @param {object[]} routePrimitives Route primitives.
334
+ * @param {object[]} viaRows Via rows.
335
+ * @param {object[]} netRows Net rows.
336
+ * @returns {object}
337
+ */
338
+ static #summary(pcb, routePrimitives, viaRows, netRows) {
339
+ return {
340
+ netCount: (pcb?.nets || []).length || netRows.length,
341
+ routedNetCount: netRows.length,
342
+ totalLengthMil: PcbRouteAnalysisBuilder.#sumLength(routePrimitives),
343
+ trackCount: routePrimitives.filter(
344
+ (primitive) => primitive.kind === 'track'
345
+ ).length,
346
+ arcCount: routePrimitives.filter(
347
+ (primitive) => primitive.kind === 'arc'
348
+ ).length,
349
+ viaCount: viaRows.length,
350
+ connectedRouteGroupCount: netRows.reduce(
351
+ (total, net) => total + net.connectedRouteGroups.length,
352
+ 0
353
+ ),
354
+ differentialPairCount: (pcb?.differentialPairs || []).filter(
355
+ (pair) =>
356
+ netRows.some(
357
+ (net) =>
358
+ net.netName === pair.positiveNetName ||
359
+ net.netName === pair.negativeNetName
360
+ )
361
+ ).length
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Builds connected route groups from shared endpoints.
367
+ * @param {object[]} primitives Route primitives for one net.
368
+ * @returns {object[]}
369
+ */
370
+ static #connectedRouteGroups(primitives) {
371
+ const parent = primitives.map((_, index) => index)
372
+ const endpointOwners = new Map()
373
+
374
+ primitives.forEach((primitive, primitiveIndex) => {
375
+ for (const endpoint of primitive.endpoints || []) {
376
+ const key = PcbRouteAnalysisBuilder.#pointKey(endpoint)
377
+ if (endpointOwners.has(key)) {
378
+ PcbRouteAnalysisBuilder.#union(
379
+ parent,
380
+ primitiveIndex,
381
+ endpointOwners.get(key)
382
+ )
383
+ } else {
384
+ endpointOwners.set(key, primitiveIndex)
385
+ }
386
+ }
387
+ })
388
+
389
+ const groups = new Map()
390
+ primitives.forEach((primitive, primitiveIndex) => {
391
+ const groupIndex = PcbRouteAnalysisBuilder.#find(
392
+ parent,
393
+ primitiveIndex
394
+ )
395
+ if (!groups.has(groupIndex)) {
396
+ groups.set(groupIndex, [])
397
+ }
398
+ groups.get(groupIndex).push(primitive)
399
+ })
400
+
401
+ return [...groups.values()]
402
+ .map((group, index) => ({
403
+ groupIndex: index,
404
+ primitiveKeys: group.map((primitive) => primitive.primitiveKey),
405
+ lengthMil: PcbRouteAnalysisBuilder.#sumLength(group),
406
+ layerIds: PcbRouteAnalysisBuilder.#layerIds(group, []),
407
+ layerKeys: PcbRouteAnalysisBuilder.#layerKeys(group, []),
408
+ endpoints: PcbRouteAnalysisBuilder.#groupEndpoints(group)
409
+ }))
410
+ .sort((left, right) => left.groupIndex - right.groupIndex)
411
+ }
412
+
413
+ /**
414
+ * Builds layer participation rows.
415
+ * @param {object[]} primitives Route primitives.
416
+ * @param {object[]} vias Via rows.
417
+ * @returns {object[]}
418
+ */
419
+ static #layerParticipation(primitives, vias) {
420
+ const rowsByKey = new Map()
421
+
422
+ for (const row of [...primitives, ...vias]) {
423
+ const layerKey = row.layerKey || ''
424
+ if (!layerKey) {
425
+ continue
426
+ }
427
+ if (!rowsByKey.has(layerKey)) {
428
+ rowsByKey.set(layerKey, {
429
+ layerId: row.layerId,
430
+ layerKey,
431
+ displayName: row.layerDisplayName,
432
+ totalLengthMil: 0,
433
+ primitiveCount: 0,
434
+ viaCount: 0
435
+ })
436
+ }
437
+ const entry = rowsByKey.get(layerKey)
438
+ entry.totalLengthMil += Number(row.lengthMil || 0)
439
+ entry.primitiveCount += row.kind === 'via' ? 0 : 1
440
+ entry.viaCount += row.kind === 'via' ? 1 : 0
441
+ }
442
+
443
+ return [...rowsByKey.values()]
444
+ .map((entry) => ({
445
+ ...entry,
446
+ totalLengthMil: PcbRouteAnalysisBuilder.#round(
447
+ entry.totalLengthMil
448
+ )
449
+ }))
450
+ .sort((left, right) =>
451
+ left.layerKey.localeCompare(right.layerKey, undefined, {
452
+ numeric: true
453
+ })
454
+ )
455
+ }
456
+
457
+ /**
458
+ * Returns layer keys used by primitive rows.
459
+ * @param {object[]} primitives Route primitives.
460
+ * @param {object[]} vias Via rows.
461
+ * @returns {string[]}
462
+ */
463
+ static #layerKeys(primitives, vias) {
464
+ return [
465
+ ...new Set(
466
+ [...primitives, ...vias]
467
+ .map((row) => row.layerKey)
468
+ .filter(Boolean)
469
+ )
470
+ ].sort((left, right) =>
471
+ left.localeCompare(right, undefined, { numeric: true })
472
+ )
473
+ }
474
+
475
+ /**
476
+ * Returns layer ids used by primitive rows.
477
+ * @param {object[]} primitives Route primitives.
478
+ * @param {object[]} vias Via rows.
479
+ * @returns {number[]}
480
+ */
481
+ static #layerIds(primitives, vias) {
482
+ return [
483
+ ...new Set(
484
+ [...primitives, ...vias]
485
+ .map((row) => row.layerId)
486
+ .filter((layerId) => Number.isInteger(layerId))
487
+ )
488
+ ].sort((left, right) => left - right)
489
+ }
490
+
491
+ /**
492
+ * Builds deduplicated group endpoint rows.
493
+ * @param {object[]} group Route group primitives.
494
+ * @returns {object[]}
495
+ */
496
+ static #groupEndpoints(group) {
497
+ const endpointsByKey = new Map()
498
+ for (const primitive of group) {
499
+ for (const endpoint of primitive.endpoints || []) {
500
+ endpointsByKey.set(
501
+ PcbRouteAnalysisBuilder.#pointKey(endpoint),
502
+ endpoint
503
+ )
504
+ }
505
+ }
506
+ return [...endpointsByKey.values()]
507
+ }
508
+
509
+ /**
510
+ * Resolves one primitive layer descriptor.
511
+ * @param {object} primitive Primitive row.
512
+ * @param {Map<number, object>} layerLookup Layer lookup.
513
+ * @returns {object | null}
514
+ */
515
+ static #primitiveLayer(primitive, layerLookup) {
516
+ const layerId = PcbRouteAnalysisBuilder.#layerId(primitive)
517
+ if (!Number.isInteger(layerId)) {
518
+ return null
519
+ }
520
+
521
+ return (
522
+ layerLookup.get(layerId) || {
523
+ layerId,
524
+ layerKey: 'L' + layerId,
525
+ displayName: 'Layer ' + layerId
526
+ }
527
+ )
528
+ }
529
+
530
+ /**
531
+ * Resolves a layer id from several native field spellings.
532
+ * @param {object} value Candidate object.
533
+ * @returns {number | undefined}
534
+ */
535
+ static #layerId(value) {
536
+ for (const key of ['layerId', 'layerCode', 'id', 'index']) {
537
+ const number = Number(value?.[key])
538
+ if (Number.isInteger(number)) {
539
+ return number
540
+ }
541
+ }
542
+
543
+ return undefined
544
+ }
545
+
546
+ /**
547
+ * Resolves a primitive net name.
548
+ * @param {object} primitive Primitive row.
549
+ * @returns {string}
550
+ */
551
+ static #netName(primitive) {
552
+ return String(
553
+ primitive?.netName || primitive?.net || primitive?.netLabel || ''
554
+ ).trim()
555
+ }
556
+
557
+ /**
558
+ * Builds a normalized point.
559
+ * @param {unknown} x X coordinate.
560
+ * @param {unknown} y Y coordinate.
561
+ * @returns {{ x: number, y: number }}
562
+ */
563
+ static #point(x, y) {
564
+ return {
565
+ x: PcbRouteAnalysisBuilder.#round(x),
566
+ y: PcbRouteAnalysisBuilder.#round(y)
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Computes Euclidean distance between two points.
572
+ * @param {{ x: number, y: number }} start Start point.
573
+ * @param {{ x: number, y: number }} end End point.
574
+ * @returns {number}
575
+ */
576
+ static #distance(start, end) {
577
+ return Math.hypot(
578
+ Number(end.x) - Number(start.x),
579
+ Number(end.y) - Number(start.y)
580
+ )
581
+ }
582
+
583
+ /**
584
+ * Builds approximate endpoints for an arc primitive.
585
+ * @param {object} arc Arc primitive.
586
+ * @returns {object[]}
587
+ */
588
+ static #arcEndpoints(arc) {
589
+ const radius = Number(arc.radius || 0)
590
+ const centerX = Number(arc.x || arc.centerX || 0)
591
+ const centerY = Number(arc.y || arc.centerY || 0)
592
+ const startAngle = (Number(arc.startAngle || 0) * Math.PI) / 180
593
+ const endAngle = (Number(arc.endAngle || 0) * Math.PI) / 180
594
+
595
+ return [
596
+ PcbRouteAnalysisBuilder.#point(
597
+ centerX + radius * Math.cos(startAngle),
598
+ centerY + radius * Math.sin(startAngle)
599
+ ),
600
+ PcbRouteAnalysisBuilder.#point(
601
+ centerX + radius * Math.cos(endAngle),
602
+ centerY + radius * Math.sin(endAngle)
603
+ )
604
+ ]
605
+ }
606
+
607
+ /**
608
+ * Resolves an arc sweep in radians.
609
+ * @param {object} arc Arc primitive.
610
+ * @returns {number}
611
+ */
612
+ static #arcSweepRadians(arc) {
613
+ if (Number.isFinite(Number(arc.sweepAngle))) {
614
+ return (Number(arc.sweepAngle) * Math.PI) / 180
615
+ }
616
+
617
+ const start = Number(arc.startAngle || 0)
618
+ const end = Number(arc.endAngle || 0)
619
+ let sweep = end - start
620
+ while (sweep <= -180) sweep += 360
621
+ while (sweep > 180) sweep -= 360
622
+ return (sweep * Math.PI) / 180
623
+ }
624
+
625
+ /**
626
+ * Builds a stable endpoint key with small coordinate tolerance.
627
+ * @param {{ x: number, y: number }} point Endpoint.
628
+ * @returns {string}
629
+ */
630
+ static #pointKey(point) {
631
+ return (
632
+ PcbRouteAnalysisBuilder.#round(point.x).toFixed(3) +
633
+ ',' +
634
+ PcbRouteAnalysisBuilder.#round(point.y).toFixed(3)
635
+ )
636
+ }
637
+
638
+ /**
639
+ * Returns a map of net names to routed length.
640
+ * @param {object[]} netRows Net rows.
641
+ * @returns {Map<string, number>}
642
+ */
643
+ static #lengthByNet(netRows) {
644
+ return new Map(
645
+ netRows.map((net) => [net.netName, Number(net.totalLengthMil || 0)])
646
+ )
647
+ }
648
+
649
+ /**
650
+ * Sums route primitive lengths.
651
+ * @param {object[]} primitives Route primitive rows.
652
+ * @returns {number}
653
+ */
654
+ static #sumLength(primitives) {
655
+ return PcbRouteAnalysisBuilder.#round(
656
+ (primitives || []).reduce(
657
+ (total, primitive) => total + Number(primitive.lengthMil || 0),
658
+ 0
659
+ )
660
+ )
661
+ }
662
+
663
+ /**
664
+ * Returns true when a class describes nets.
665
+ * @param {object} classRecord Class row.
666
+ * @returns {boolean}
667
+ */
668
+ static #isNetClass(classRecord) {
669
+ return (
670
+ classRecord?.kindName === 'net' || Number(classRecord?.kind) === 0
671
+ )
672
+ }
673
+
674
+ /**
675
+ * Finds a union-find root.
676
+ * @param {number[]} parent Parent table.
677
+ * @param {number} index Entry index.
678
+ * @returns {number}
679
+ */
680
+ static #find(parent, index) {
681
+ if (parent[index] !== index) {
682
+ parent[index] = PcbRouteAnalysisBuilder.#find(parent, parent[index])
683
+ }
684
+ return parent[index]
685
+ }
686
+
687
+ /**
688
+ * Unions two route group indexes.
689
+ * @param {number[]} parent Parent table.
690
+ * @param {number} left Left index.
691
+ * @param {number} right Right index.
692
+ */
693
+ static #union(parent, left, right) {
694
+ const leftRoot = PcbRouteAnalysisBuilder.#find(parent, left)
695
+ const rightRoot = PcbRouteAnalysisBuilder.#find(parent, right)
696
+ if (leftRoot !== rightRoot) {
697
+ parent[rightRoot] = leftRoot
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Rounds numeric values for stable JSON.
703
+ * @param {unknown} value Candidate numeric value.
704
+ * @returns {number}
705
+ */
706
+ static #round(value) {
707
+ const number = Number(value || 0)
708
+ return Number.isFinite(number) ? Math.round(number * 1000) / 1000 : 0
709
+ }
710
+
711
+ /**
712
+ * Removes empty object fields while preserving false and zero.
713
+ * @param {Record<string, unknown>} value Candidate object.
714
+ * @returns {Record<string, unknown>}
715
+ */
716
+ static #stripEmpty(value) {
717
+ return Object.fromEntries(
718
+ Object.entries(value || {}).filter(([, entryValue]) => {
719
+ if (Array.isArray(entryValue)) {
720
+ return entryValue.length > 0
721
+ }
722
+ return (
723
+ entryValue !== null &&
724
+ entryValue !== undefined &&
725
+ entryValue !== ''
726
+ )
727
+ })
728
+ )
729
+ }
730
+ }