altium-toolkit 1.1.1 → 1.1.3

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  3. package/src/core/altium/AltiumParser.mjs +240 -8
  4. package/src/core/altium/NaturalStringComparator.mjs +175 -0
  5. package/src/core/altium/ParserUtils.mjs +83 -14
  6. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +104 -33
  7. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  8. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +26 -4
  9. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +28 -5
  10. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +1 -3
  11. package/src/core/altium/PcbReviewMetadataBuilder.mjs +59 -28
  12. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +66 -29
  13. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +50 -16
  14. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  15. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  16. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  17. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +36 -11
  18. package/src/core/altium/SchematicImageParser.mjs +291 -6
  19. package/src/core/altium/SchematicImplementationParser.mjs +75 -36
  20. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  21. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  22. package/src/core/altium/SchematicPinParser.mjs +175 -4
  23. package/src/core/altium/SchematicQaReportBuilder.mjs +115 -38
  24. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  25. package/src/core/altium/SchematicTextParser.mjs +125 -11
  26. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  27. package/src/ui/PcbInteractionIndex.mjs +16 -1
  28. package/src/ui/SchematicColorResolver.mjs +78 -0
  29. package/src/ui/SchematicContentLayout.mjs +58 -1
  30. package/src/ui/SchematicImageRenderer.mjs +125 -10
  31. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  32. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  33. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  34. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  35. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  36. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  37. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  38. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  39. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  40. package/src/ui/SchematicSvgRenderer.mjs +293 -47
@@ -2,13 +2,16 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- import { unzlibSync } from 'fflate'
5
+ import { Unzlib } from 'fflate'
6
6
  import { PcbFontMetricsParser } from './PcbFontMetricsParser.mjs'
7
7
 
8
8
  /**
9
9
  * Extracts zlib-compressed embedded font payloads from PCB compound streams.
10
10
  */
11
11
  export class PcbEmbeddedFontExtractor {
12
+ static #BASE64_ALPHABET =
13
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
14
+
12
15
  static #CANDIDATE_STREAM_NAMES = [
13
16
  'EmbeddedFonts6/Data',
14
17
  'EmbeddedFonts/Data',
@@ -114,16 +117,19 @@ export class PcbEmbeddedFontExtractor {
114
117
  return null
115
118
  }
116
119
 
117
- const compressedEnd = PcbEmbeddedFontExtractor.#findCompressedEnd(
120
+ const payload = PcbEmbeddedFontExtractor.#inflateZlibPayloadAt(
118
121
  bytes,
119
122
  zlibOffset
120
123
  )
121
- if (compressedEnd <= zlibOffset) {
124
+ if (!payload) {
122
125
  return null
123
126
  }
124
127
 
125
- const compressedBytes = bytes.subarray(zlibOffset, compressedEnd)
126
- const payloadBytes = unzlibSync(compressedBytes)
128
+ const compressedBytes = bytes.subarray(
129
+ zlibOffset,
130
+ payload.compressedEnd
131
+ )
132
+ const payloadBytes = payload.bytes
127
133
  const metadata = PcbEmbeddedFontExtractor.#normalizeFontMetadata(
128
134
  familyField.text,
129
135
  alternateField.text,
@@ -151,7 +157,7 @@ export class PcbEmbeddedFontExtractor {
151
157
  PcbEmbeddedFontExtractor.#bytesToBase64(payloadBytes),
152
158
  metrics
153
159
  },
154
- nextOffset: compressedEnd
160
+ nextOffset: payload.compressedEnd
155
161
  }
156
162
  }
157
163
 
@@ -224,48 +230,146 @@ export class PcbEmbeddedFontExtractor {
224
230
  }
225
231
 
226
232
  /**
227
- * Finds the smallest trailing offset that fully contains a zlib payload.
233
+ * Inflates one zlib payload and returns its exact stream boundary.
228
234
  * @param {Uint8Array} bytes
229
235
  * @param {number} zlibOffset
236
+ * @returns {{ bytes: Uint8Array, compressedEnd: number } | null}
237
+ */
238
+ static #inflateZlibPayloadAt(bytes, zlibOffset) {
239
+ const input = bytes.subarray(zlibOffset)
240
+ const chunks = []
241
+ let inflater
242
+
243
+ try {
244
+ inflater = new Unzlib((chunk) => {
245
+ chunks.push(chunk)
246
+ })
247
+ inflater.push(input, false)
248
+ } catch {
249
+ return null
250
+ }
251
+
252
+ if (!Number(inflater?.s?.f || 0)) {
253
+ return null
254
+ }
255
+
256
+ const payloadBytes = PcbEmbeddedFontExtractor.#concatBytes(chunks)
257
+ const compressedByteCount =
258
+ PcbEmbeddedFontExtractor.#resolveCompressedByteCount(
259
+ input,
260
+ inflater,
261
+ payloadBytes
262
+ )
263
+
264
+ if (compressedByteCount <= 2) {
265
+ return null
266
+ }
267
+
268
+ return {
269
+ bytes: payloadBytes,
270
+ compressedEnd: zlibOffset + compressedByteCount
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Resolves the zlib stream length from fflate's remaining input buffer.
276
+ * @param {Uint8Array} input
277
+ * @param {Unzlib} inflater
278
+ * @param {Uint8Array} payloadBytes
230
279
  * @returns {number}
231
280
  */
232
- static #findCompressedEnd(bytes, zlibOffset) {
233
- let low = zlibOffset + 2
234
- let high = bytes.byteLength
281
+ static #resolveCompressedByteCount(input, inflater, payloadBytes) {
282
+ const remainingByteCount = Number(inflater?.p?.byteLength || 0)
283
+ if (remainingByteCount < 4) {
284
+ return -1
285
+ }
286
+
287
+ const baseByteCount = input.byteLength - remainingByteCount + 4
288
+ const checksum = PcbEmbeddedFontExtractor.#adler32(payloadBytes)
235
289
 
236
- while (low < high) {
237
- const midpoint = Math.floor((low + high) / 2)
290
+ // fflate can leave the final consumed deflate byte in `p` when the
291
+ // stream ends mid-byte, so validate both adjacent boundary candidates.
292
+ for (const compressedByteCount of [baseByteCount, baseByteCount + 1]) {
238
293
  if (
239
- PcbEmbeddedFontExtractor.#canInflate(
240
- bytes.subarray(zlibOffset, midpoint)
294
+ PcbEmbeddedFontExtractor.#hasZlibChecksumAt(
295
+ input,
296
+ compressedByteCount,
297
+ checksum
241
298
  )
242
299
  ) {
243
- high = midpoint
244
- } else {
245
- low = midpoint + 1
300
+ return compressedByteCount
246
301
  }
247
302
  }
248
303
 
249
- return PcbEmbeddedFontExtractor.#canInflate(
250
- bytes.subarray(zlibOffset, low)
251
- )
252
- ? low
253
- : -1
304
+ return -1
254
305
  }
255
306
 
256
307
  /**
257
- * Returns true when one byte slice can be inflated as a complete zlib
258
- * stream.
259
- * @param {Uint8Array} bytes
308
+ * Returns true when a candidate zlib boundary ends with the checksum.
309
+ * @param {Uint8Array} input
310
+ * @param {number} compressedByteCount
311
+ * @param {number} checksum
260
312
  * @returns {boolean}
261
313
  */
262
- static #canInflate(bytes) {
263
- try {
264
- unzlibSync(bytes)
265
- return true
266
- } catch {
314
+ static #hasZlibChecksumAt(input, compressedByteCount, checksum) {
315
+ if (compressedByteCount < 6 || compressedByteCount > input.byteLength) {
267
316
  return false
268
317
  }
318
+
319
+ const checksumOffset = compressedByteCount - 4
320
+ const actualChecksum = new DataView(
321
+ input.buffer,
322
+ input.byteOffset + checksumOffset,
323
+ 4
324
+ ).getUint32(0, false)
325
+
326
+ return actualChecksum === checksum
327
+ }
328
+
329
+ /**
330
+ * Computes the Adler-32 checksum used by zlib trailers.
331
+ * @param {Uint8Array} bytes
332
+ * @returns {number}
333
+ */
334
+ static #adler32(bytes) {
335
+ const modulo = 65521
336
+ let low = 1
337
+ let high = 0
338
+
339
+ for (let offset = 0; offset < bytes.byteLength; offset += 5552) {
340
+ const end = Math.min(offset + 5552, bytes.byteLength)
341
+ for (let index = offset; index < end; index += 1) {
342
+ low += bytes[index]
343
+ high += low
344
+ }
345
+ low %= modulo
346
+ high %= modulo
347
+ }
348
+
349
+ return ((high << 16) | low) >>> 0
350
+ }
351
+
352
+ /**
353
+ * Concatenates inflated output chunks.
354
+ * @param {Uint8Array[]} chunks
355
+ * @returns {Uint8Array}
356
+ */
357
+ static #concatBytes(chunks) {
358
+ if (chunks.length === 1) {
359
+ return chunks[0]
360
+ }
361
+
362
+ const bytes = new Uint8Array(
363
+ chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
364
+ )
365
+ let offset = 0
366
+
367
+ for (const chunk of chunks) {
368
+ bytes.set(chunk, offset)
369
+ offset += chunk.byteLength
370
+ }
371
+
372
+ return bytes
269
373
  }
270
374
 
271
375
  /**
@@ -451,22 +555,61 @@ export class PcbEmbeddedFontExtractor {
451
555
  * @returns {string}
452
556
  */
453
557
  static #bytesToBase64(bytes) {
454
- if (typeof btoa === 'function') {
455
- let binary = ''
456
- const chunkSize = 0x8000
457
- for (
458
- let offset = 0;
459
- offset < bytes.byteLength;
460
- offset += chunkSize
461
- ) {
462
- binary += String.fromCharCode(
463
- ...bytes.subarray(offset, offset + chunkSize)
464
- )
558
+ if (typeof Buffer === 'function' && typeof Buffer.from === 'function') {
559
+ return Buffer.from(bytes).toString('base64')
560
+ }
561
+
562
+ return PcbEmbeddedFontExtractor.#bytesToBase64Portable(bytes)
563
+ }
564
+
565
+ /**
566
+ * Encodes bytes as base64 without relying on Node APIs.
567
+ * @param {Uint8Array} bytes
568
+ * @returns {string}
569
+ */
570
+ static #bytesToBase64Portable(bytes) {
571
+ const alphabet = PcbEmbeddedFontExtractor.#BASE64_ALPHABET
572
+ const groupBuffer = new Array(4096)
573
+ const outputChunks = []
574
+ let groupIndex = 0
575
+ let byteIndex = 0
576
+
577
+ for (; byteIndex + 2 < bytes.byteLength; byteIndex += 3) {
578
+ const value =
579
+ (bytes[byteIndex] << 16) |
580
+ (bytes[byteIndex + 1] << 8) |
581
+ bytes[byteIndex + 2]
582
+ groupBuffer[groupIndex] =
583
+ alphabet[(value >> 18) & 63] +
584
+ alphabet[(value >> 12) & 63] +
585
+ alphabet[(value >> 6) & 63] +
586
+ alphabet[value & 63]
587
+ groupIndex += 1
588
+
589
+ if (groupIndex === groupBuffer.length) {
590
+ outputChunks.push(groupBuffer.join(''))
591
+ groupIndex = 0
465
592
  }
466
- return btoa(binary)
467
593
  }
468
594
 
469
- return Buffer.from(bytes).toString('base64')
595
+ if (byteIndex < bytes.byteLength) {
596
+ const hasSecondByte = byteIndex + 1 < bytes.byteLength
597
+ const value =
598
+ (bytes[byteIndex] << 16) |
599
+ ((hasSecondByte ? bytes[byteIndex + 1] : 0) << 8)
600
+ groupBuffer[groupIndex] =
601
+ alphabet[(value >> 18) & 63] +
602
+ alphabet[(value >> 12) & 63] +
603
+ (hasSecondByte ? alphabet[(value >> 6) & 63] : '=') +
604
+ '='
605
+ groupIndex += 1
606
+ }
607
+
608
+ if (groupIndex > 0) {
609
+ outputChunks.push(groupBuffer.slice(0, groupIndex).join(''))
610
+ }
611
+
612
+ return outputChunks.join('')
470
613
  }
471
614
 
472
615
  /**
@@ -14,6 +14,8 @@ const { parseNumericField } = ParserUtils
14
14
  export class PcbLayerStackReadModelBuilder {
15
15
  static SCHEMA_ID = 'altium-toolkit.pcb.layer-stack.a1'
16
16
 
17
+ static #fieldIndexes = new WeakMap()
18
+
17
19
  /**
18
20
  * Builds the layer-stack sidecar.
19
21
  * @param {{ fileName: string, boardRecords: { fields: Record<string, string | string[]>, sourceStream?: string }[], streamNames?: string[], layers: object[], primitiveLayers: object[], layerSubstacks: object[], boardRegions: object[] }} input Source model context.
@@ -799,17 +801,37 @@ export class PcbLayerStackReadModelBuilder {
799
801
  * @returns {string}
800
802
  */
801
803
  static #field(fields, key) {
802
- if (Object.hasOwn(fields, key)) {
804
+ if (Object.hasOwn(fields, key) || key in fields) {
803
805
  return ParserUtils.getField(fields, key)
804
806
  }
805
- const upperKey = key.toUpperCase()
806
- const realKey = Object.keys(fields).find(
807
- (fieldKey) => fieldKey.toUpperCase() === upperKey
807
+ const realKey = PcbLayerStackReadModelBuilder.#fieldIndex(fields).get(
808
+ key.toUpperCase()
808
809
  )
809
810
 
810
811
  return realKey ? ParserUtils.getField(fields, realKey) : ''
811
812
  }
812
813
 
814
+ /**
815
+ * Builds or returns a cached case-insensitive field-key index.
816
+ * @param {Record<string, string | string[]>} fields Source fields.
817
+ * @returns {Map<string, string>}
818
+ */
819
+ static #fieldIndex(fields) {
820
+ const cached = PcbLayerStackReadModelBuilder.#fieldIndexes.get(fields)
821
+ if (cached) return cached
822
+
823
+ const fieldIndex = new Map()
824
+ for (const fieldKey of Object.keys(fields)) {
825
+ const upperKey = fieldKey.toUpperCase()
826
+ if (!fieldIndex.has(upperKey)) {
827
+ fieldIndex.set(upperKey, fieldKey)
828
+ }
829
+ }
830
+
831
+ PcbLayerStackReadModelBuilder.#fieldIndexes.set(fields, fieldIndex)
832
+ return fieldIndex
833
+ }
834
+
813
835
  /**
814
836
  * Parses layer-id lists.
815
837
  * @param {string} value Raw list value.
@@ -8,6 +8,8 @@ import { ParserUtils } from './ParserUtils.mjs'
8
8
  * Parses source-only layer-stack metadata that is not part of core geometry.
9
9
  */
10
10
  export class PcbLayerStackSourceMetadataParser {
11
+ static #fieldIndexes = new WeakMap()
12
+
11
13
  /**
12
14
  * Parses source-aware extras for one layer-stack row.
13
15
  * @param {Record<string, string | string[]>} fields Source fields.
@@ -401,17 +403,38 @@ export class PcbLayerStackSourceMetadataParser {
401
403
  * @returns {string}
402
404
  */
403
405
  static #field(fields, key) {
404
- if (Object.hasOwn(fields, key)) {
406
+ if (Object.hasOwn(fields, key) || key in fields) {
405
407
  return ParserUtils.getField(fields, key)
406
408
  }
407
- const upperKey = key.toUpperCase()
408
- const realKey = Object.keys(fields).find(
409
- (fieldKey) => fieldKey.toUpperCase() === upperKey
410
- )
409
+ const realKey = PcbLayerStackSourceMetadataParser.#fieldIndex(
410
+ fields
411
+ ).get(key.toUpperCase())
411
412
 
412
413
  return realKey ? ParserUtils.getField(fields, realKey) : ''
413
414
  }
414
415
 
416
+ /**
417
+ * Builds or returns a cached case-insensitive field-key index.
418
+ * @param {Record<string, string | string[]>} fields Source fields.
419
+ * @returns {Map<string, string>}
420
+ */
421
+ static #fieldIndex(fields) {
422
+ const cached =
423
+ PcbLayerStackSourceMetadataParser.#fieldIndexes.get(fields)
424
+ if (cached) return cached
425
+
426
+ const fieldIndex = new Map()
427
+ for (const fieldKey of Object.keys(fields)) {
428
+ const upperKey = fieldKey.toUpperCase()
429
+ if (!fieldIndex.has(upperKey)) {
430
+ fieldIndex.set(upperKey, fieldKey)
431
+ }
432
+ }
433
+
434
+ PcbLayerStackSourceMetadataParser.#fieldIndexes.set(fields, fieldIndex)
435
+ return fieldIndex
436
+ }
437
+
415
438
  /**
416
439
  * Splits a native list field.
417
440
  * @param {string} value Raw list value.
@@ -228,9 +228,7 @@ export class PcbOwnershipGraphBuilder {
228
228
  if (!groups[key]) {
229
229
  groups[key] = fallbackGroup
230
230
  }
231
- if (!groups[key].primitiveKeys.includes(primitiveKey)) {
232
- groups[key].primitiveKeys.push(primitiveKey)
233
- }
231
+ groups[key].primitiveKeys.push(primitiveKey)
234
232
  }
235
233
 
236
234
  /**
@@ -5,6 +5,7 @@
5
5
  import { PcbReviewDrillMetadataBuilder } from './PcbReviewDrillMetadataBuilder.mjs'
6
6
  import { PcbReviewPolygonRealizationBuilder } from './PcbReviewPolygonRealizationBuilder.mjs'
7
7
  import { PcbReviewRouteHighlightProfileBuilder } from './PcbReviewRouteHighlightProfileBuilder.mjs'
8
+ import { NaturalStringComparator } from './NaturalStringComparator.mjs'
8
9
 
9
10
  /**
10
11
  * Builds PCB review metadata for routed-class and board-assembly workflows.
@@ -18,11 +19,15 @@ export class PcbReviewMetadataBuilder {
18
19
  * @returns {object}
19
20
  */
20
21
  static build(pcb = {}) {
22
+ const routeAnalysis = pcb.routeAnalysis || {}
23
+ const routeRowsByName =
24
+ PcbReviewMetadataBuilder.#routeRowsByName(routeAnalysis)
21
25
  const routeGroups = PcbReviewMetadataBuilder.#routeGroups(
22
- pcb.routeAnalysis || {}
26
+ routeAnalysis,
27
+ routeRowsByName
23
28
  )
24
29
  const routeHighlightProfiles =
25
- PcbReviewRouteHighlightProfileBuilder.build(pcb.routeAnalysis || {})
30
+ PcbReviewRouteHighlightProfileBuilder.build(routeAnalysis)
26
31
  const polygonRealizations =
27
32
  PcbReviewPolygonRealizationBuilder.build(pcb)
28
33
  const drillReview = PcbReviewDrillMetadataBuilder.build(pcb)
@@ -51,7 +56,7 @@ export class PcbReviewMetadataBuilder {
51
56
  polygonRealizations,
52
57
  drillReview,
53
58
  boardAssemblyViews,
54
- pcb.routeAnalysis || {}
59
+ routeAnalysis
55
60
  )
56
61
  }
57
62
  }
@@ -59,21 +64,29 @@ export class PcbReviewMetadataBuilder {
59
64
  /**
60
65
  * Builds route highlight groups from route analysis.
61
66
  * @param {object} routeAnalysis Route analysis model.
67
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
62
68
  * @returns {object[]}
63
69
  */
64
- static #routeGroups(routeAnalysis) {
70
+ static #routeGroups(routeAnalysis, routeRowsByName) {
65
71
  return [
66
- ...PcbReviewMetadataBuilder.#classGroups(routeAnalysis),
67
- ...PcbReviewMetadataBuilder.#differentialPairGroups(routeAnalysis)
72
+ ...PcbReviewMetadataBuilder.#classGroups(
73
+ routeAnalysis,
74
+ routeRowsByName
75
+ ),
76
+ ...PcbReviewMetadataBuilder.#differentialPairGroups(
77
+ routeAnalysis,
78
+ routeRowsByName
79
+ )
68
80
  ]
69
81
  }
70
82
 
71
83
  /**
72
84
  * Builds net-class route groups.
73
85
  * @param {object} routeAnalysis Route analysis model.
86
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
74
87
  * @returns {object[]}
75
88
  */
76
- static #classGroups(routeAnalysis) {
89
+ static #classGroups(routeAnalysis, routeRowsByName) {
77
90
  return (routeAnalysis.classes || []).map((classRow) =>
78
91
  PcbReviewMetadataBuilder.#stripEmpty({
79
92
  key:
@@ -83,11 +96,11 @@ export class PcbReviewMetadataBuilder {
83
96
  name: classRow.name,
84
97
  netNames: classRow.netNames || [],
85
98
  layerKeys: PcbReviewMetadataBuilder.#layerKeysForNets(
86
- routeAnalysis,
99
+ routeRowsByName,
87
100
  classRow.netNames || []
88
101
  ),
89
102
  primitiveKeys: PcbReviewMetadataBuilder.#primitiveKeysForNets(
90
- routeAnalysis,
103
+ routeRowsByName,
91
104
  classRow.netNames || []
92
105
  ),
93
106
  totalLengthMil: classRow.totalLengthMil
@@ -98,9 +111,10 @@ export class PcbReviewMetadataBuilder {
98
111
  /**
99
112
  * Builds differential-pair route groups.
100
113
  * @param {object} routeAnalysis Route analysis model.
114
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
101
115
  * @returns {object[]}
102
116
  */
103
- static #differentialPairGroups(routeAnalysis) {
117
+ static #differentialPairGroups(routeAnalysis, routeRowsByName) {
104
118
  return (routeAnalysis.differentialPairs || []).map((pair) => {
105
119
  const netNames = [
106
120
  pair.positiveNetName,
@@ -115,11 +129,11 @@ export class PcbReviewMetadataBuilder {
115
129
  name: pair.name,
116
130
  netNames,
117
131
  layerKeys: PcbReviewMetadataBuilder.#layerKeysForNets(
118
- routeAnalysis,
132
+ routeRowsByName,
119
133
  netNames
120
134
  ),
121
135
  primitiveKeys: PcbReviewMetadataBuilder.#primitiveKeysForNets(
122
- routeAnalysis,
136
+ routeRowsByName,
123
137
  netNames
124
138
  ),
125
139
  totalLengthMil: PcbReviewMetadataBuilder.#round(
@@ -226,20 +240,20 @@ export class PcbReviewMetadataBuilder {
226
240
  }
227
241
  return Object.fromEntries(
228
242
  Object.entries(entries).sort(([left], [right]) =>
229
- left.localeCompare(right, undefined, { numeric: true })
243
+ NaturalStringComparator.compare(left, right)
230
244
  )
231
245
  )
232
246
  }
233
247
 
234
248
  /**
235
249
  * Returns layer keys participating in a list of nets.
236
- * @param {object} routeAnalysis Route analysis model.
250
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
237
251
  * @param {string[]} netNames Net names.
238
252
  * @returns {string[]}
239
253
  */
240
- static #layerKeysForNets(routeAnalysis, netNames) {
254
+ static #layerKeysForNets(routeRowsByName, netNames) {
241
255
  const nets = PcbReviewMetadataBuilder.#netsByName(
242
- routeAnalysis,
256
+ routeRowsByName,
243
257
  netNames
244
258
  )
245
259
  return PcbReviewMetadataBuilder.#sortedStrings(
@@ -249,13 +263,13 @@ export class PcbReviewMetadataBuilder {
249
263
 
250
264
  /**
251
265
  * Returns primitive keys participating in a list of nets.
252
- * @param {object} routeAnalysis Route analysis model.
266
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
253
267
  * @param {string[]} netNames Net names.
254
268
  * @returns {string[]}
255
269
  */
256
- static #primitiveKeysForNets(routeAnalysis, netNames) {
270
+ static #primitiveKeysForNets(routeRowsByName, netNames) {
257
271
  const nets = PcbReviewMetadataBuilder.#netsByName(
258
- routeAnalysis,
272
+ routeRowsByName,
259
273
  netNames
260
274
  )
261
275
  return PcbReviewMetadataBuilder.#sortedStrings(
@@ -264,15 +278,32 @@ export class PcbReviewMetadataBuilder {
264
278
  }
265
279
 
266
280
  /**
267
- * Resolves net route rows by name.
281
+ * Builds route rows indexed by net name.
268
282
  * @param {object} routeAnalysis Route analysis model.
283
+ * @returns {Map<string, object[]>}
284
+ */
285
+ static #routeRowsByName(routeAnalysis) {
286
+ const rowsByName = new Map()
287
+ for (const net of routeAnalysis.byNet || []) {
288
+ const netName = String(net?.netName || '')
289
+ if (!netName) continue
290
+ if (!rowsByName.has(netName)) {
291
+ rowsByName.set(netName, [])
292
+ }
293
+ rowsByName.get(netName).push(net)
294
+ }
295
+ return rowsByName
296
+ }
297
+
298
+ /**
299
+ * Resolves net route rows by name.
300
+ * @param {Map<string, object[]>} routeRowsByName Route rows by net name.
269
301
  * @param {string[]} netNames Net names.
270
302
  * @returns {object[]}
271
303
  */
272
- static #netsByName(routeAnalysis, netNames) {
273
- const wanted = new Set(netNames || [])
274
- return (routeAnalysis.byNet || []).filter((net) =>
275
- wanted.has(net.netName)
304
+ static #netsByName(routeRowsByName, netNames) {
305
+ return (netNames || []).flatMap(
306
+ (netName) => routeRowsByName.get(netName) || []
276
307
  )
277
308
  }
278
309
 
@@ -323,10 +354,10 @@ export class PcbReviewMetadataBuilder {
323
354
  * @returns {string[]}
324
355
  */
325
356
  static #sortedStrings(values) {
326
- return [...new Set((values || []).filter(Boolean))].sort(
327
- (left, right) =>
328
- left.localeCompare(right, undefined, { numeric: true })
329
- )
357
+ const sortedValues = [...new Set((values || []).filter(Boolean))]
358
+ return sortedValues.length < 2
359
+ ? sortedValues
360
+ : sortedValues.sort(NaturalStringComparator.compare)
330
361
  }
331
362
 
332
363
  /**