altium-toolkit 1.1.0 → 1.1.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Compares generated ECAD keys in natural ASCII order without locale collation.
7
+ */
8
+ export class NaturalStringComparator {
9
+ /**
10
+ * Compares two strings with numeric runs ordered by numeric value.
11
+ * @param {string} left Left value.
12
+ * @param {string} right Right value.
13
+ * @returns {number}
14
+ */
15
+ static compare(left, right) {
16
+ const leftValue = String(left ?? '')
17
+ const rightValue = String(right ?? '')
18
+ let leftIndex = 0
19
+ let rightIndex = 0
20
+
21
+ while (leftIndex < leftValue.length && rightIndex < rightValue.length) {
22
+ const leftCode = leftValue.charCodeAt(leftIndex)
23
+ const rightCode = rightValue.charCodeAt(rightIndex)
24
+
25
+ if (leftCode > 0x7f || rightCode > 0x7f) {
26
+ return leftValue.localeCompare(rightValue, undefined, {
27
+ numeric: true
28
+ })
29
+ }
30
+
31
+ if (
32
+ NaturalStringComparator.#isDigit(leftCode) &&
33
+ NaturalStringComparator.#isDigit(rightCode)
34
+ ) {
35
+ const digitComparison =
36
+ NaturalStringComparator.#compareDigitRuns(
37
+ leftValue,
38
+ rightValue,
39
+ leftIndex,
40
+ rightIndex
41
+ )
42
+ if (digitComparison.comparison !== 0) {
43
+ return digitComparison.comparison
44
+ }
45
+ leftIndex = digitComparison.leftEnd
46
+ rightIndex = digitComparison.rightEnd
47
+ continue
48
+ }
49
+
50
+ const normalizedLeft =
51
+ NaturalStringComparator.#toLowerAsciiCode(leftCode)
52
+ const normalizedRight =
53
+ NaturalStringComparator.#toLowerAsciiCode(rightCode)
54
+ if (normalizedLeft !== normalizedRight) {
55
+ return normalizedLeft - normalizedRight
56
+ }
57
+ if (leftCode !== rightCode) {
58
+ return leftCode - rightCode
59
+ }
60
+
61
+ leftIndex += 1
62
+ rightIndex += 1
63
+ }
64
+
65
+ return leftValue.length - rightValue.length
66
+ }
67
+
68
+ /**
69
+ * Compares numeric runs starting at the provided offsets.
70
+ * @param {string} leftValue Left value.
71
+ * @param {string} rightValue Right value.
72
+ * @param {number} leftStart Left digit offset.
73
+ * @param {number} rightStart Right digit offset.
74
+ * @returns {{ comparison: number, leftEnd: number, rightEnd: number }}
75
+ */
76
+ static #compareDigitRuns(leftValue, rightValue, leftStart, rightStart) {
77
+ const leftEnd = NaturalStringComparator.#digitRunEnd(
78
+ leftValue,
79
+ leftStart
80
+ )
81
+ const rightEnd = NaturalStringComparator.#digitRunEnd(
82
+ rightValue,
83
+ rightStart
84
+ )
85
+ const leftSignificant = NaturalStringComparator.#skipLeadingZeros(
86
+ leftValue,
87
+ leftStart,
88
+ leftEnd
89
+ )
90
+ const rightSignificant = NaturalStringComparator.#skipLeadingZeros(
91
+ rightValue,
92
+ rightStart,
93
+ rightEnd
94
+ )
95
+ const leftLength = leftEnd - leftSignificant
96
+ const rightLength = rightEnd - rightSignificant
97
+
98
+ if (leftLength !== rightLength) {
99
+ return {
100
+ comparison: leftLength - rightLength,
101
+ leftEnd,
102
+ rightEnd
103
+ }
104
+ }
105
+
106
+ for (
107
+ let offset = 0;
108
+ offset < leftLength && offset < rightLength;
109
+ offset += 1
110
+ ) {
111
+ const comparison =
112
+ leftValue.charCodeAt(leftSignificant + offset) -
113
+ rightValue.charCodeAt(rightSignificant + offset)
114
+ if (comparison !== 0) {
115
+ return { comparison, leftEnd, rightEnd }
116
+ }
117
+ }
118
+
119
+ return {
120
+ comparison: leftEnd - leftStart - (rightEnd - rightStart),
121
+ leftEnd,
122
+ rightEnd
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Returns the offset after one ASCII digit run.
128
+ * @param {string} value Source value.
129
+ * @param {number} start Start offset.
130
+ * @returns {number}
131
+ */
132
+ static #digitRunEnd(value, start) {
133
+ let end = start
134
+ while (
135
+ end < value.length &&
136
+ NaturalStringComparator.#isDigit(value.charCodeAt(end))
137
+ ) {
138
+ end += 1
139
+ }
140
+ return end
141
+ }
142
+
143
+ /**
144
+ * Skips leading zeroes while leaving one digit for all-zero runs.
145
+ * @param {string} value Source value.
146
+ * @param {number} start Start offset.
147
+ * @param {number} end End offset.
148
+ * @returns {number}
149
+ */
150
+ static #skipLeadingZeros(value, start, end) {
151
+ let index = start
152
+ while (index < end - 1 && value.charCodeAt(index) === 48) {
153
+ index += 1
154
+ }
155
+ return index
156
+ }
157
+
158
+ /**
159
+ * Returns true for ASCII digit code points.
160
+ * @param {number} code Character code.
161
+ * @returns {boolean}
162
+ */
163
+ static #isDigit(code) {
164
+ return code >= 48 && code <= 57
165
+ }
166
+
167
+ /**
168
+ * Converts uppercase ASCII code points to lowercase.
169
+ * @param {number} code Character code.
170
+ * @returns {number}
171
+ */
172
+ static #toLowerAsciiCode(code) {
173
+ return code >= 65 && code <= 90 ? code + 32 : code
174
+ }
175
+ }
@@ -6,6 +6,9 @@
6
6
  * Shared parsing helpers for normalized Altium records.
7
7
  */
8
8
  export class ParserUtils {
9
+ /** @type {WeakMap<object, Map<string, { raw: unknown, rawUtf8: unknown, value: string }>>} */
10
+ static #fieldValueCache = new WeakMap()
11
+
9
12
  /**
10
13
  * Removes duplicate PCB placements by designator.
11
14
  * @param {{ designator: string }[]} components
@@ -141,16 +144,69 @@ export class ParserUtils {
141
144
  * @returns {string}
142
145
  */
143
146
  static #getPreferredFieldValue(fields, key, skipAsterisk) {
144
- if (!fields) return ''
147
+ if (!fields || typeof fields !== 'object') return ''
145
148
 
146
149
  const utf8Key = 'UTF8:' + key
147
- const utf8Value = ParserUtils.#pickFieldValue(
148
- fields[utf8Key],
149
- skipAsterisk
150
+ const rawUtf8 = fields[utf8Key]
151
+ const raw = fields[key]
152
+
153
+ if (!Array.isArray(rawUtf8) && !Array.isArray(raw)) {
154
+ const utf8Value = ParserUtils.#pickFieldValue(rawUtf8, skipAsterisk)
155
+ return utf8Value || ParserUtils.#pickFieldValue(raw, skipAsterisk)
156
+ }
157
+
158
+ const cacheKey = key + ':' + (skipAsterisk ? 'text' : 'field')
159
+ const cached = ParserUtils.#cachedFieldValue(
160
+ fields,
161
+ cacheKey,
162
+ raw,
163
+ rawUtf8
150
164
  )
151
- if (utf8Value) return utf8Value
165
+ if (cached !== null) return cached
166
+
167
+ const utf8Value = ParserUtils.#pickFieldValue(rawUtf8, skipAsterisk)
168
+ const value =
169
+ utf8Value || ParserUtils.#pickFieldValue(raw, skipAsterisk)
170
+
171
+ ParserUtils.#cacheFieldValue(fields, cacheKey, raw, rawUtf8, value)
172
+ return value
173
+ }
152
174
 
153
- return ParserUtils.#pickFieldValue(fields[key], skipAsterisk)
175
+ /**
176
+ * Returns a cached normalized value when the raw field references match.
177
+ * @param {object} fields Field object.
178
+ * @param {string} cacheKey Cache key.
179
+ * @param {unknown} raw Raw field payload.
180
+ * @param {unknown} rawUtf8 Raw UTF-8 field payload.
181
+ * @returns {string | null}
182
+ */
183
+ static #cachedFieldValue(fields, cacheKey, raw, rawUtf8) {
184
+ const fieldCache = ParserUtils.#fieldValueCache.get(fields)
185
+ const cached = fieldCache?.get(cacheKey)
186
+ if (!cached || cached.raw !== raw || cached.rawUtf8 !== rawUtf8) {
187
+ return null
188
+ }
189
+
190
+ return cached.value
191
+ }
192
+
193
+ /**
194
+ * Stores one normalized field value.
195
+ * @param {object} fields Field object.
196
+ * @param {string} cacheKey Cache key.
197
+ * @param {unknown} raw Raw field payload.
198
+ * @param {unknown} rawUtf8 Raw UTF-8 field payload.
199
+ * @param {string} value Normalized value.
200
+ * @returns {void}
201
+ */
202
+ static #cacheFieldValue(fields, cacheKey, raw, rawUtf8, value) {
203
+ let fieldCache = ParserUtils.#fieldValueCache.get(fields)
204
+ if (!fieldCache) {
205
+ fieldCache = new Map()
206
+ ParserUtils.#fieldValueCache.set(fields, fieldCache)
207
+ }
208
+
209
+ fieldCache.set(cacheKey, { raw, rawUtf8, value })
154
210
  }
155
211
 
156
212
  /**
@@ -160,14 +216,27 @@ export class ParserUtils {
160
216
  * @returns {string}
161
217
  */
162
218
  static #pickFieldValue(raw, skipAsterisk) {
163
- const values = Array.isArray(raw) ? raw : [raw]
219
+ if (!Array.isArray(raw)) {
220
+ const value = ParserUtils.#normalizeFieldValue(raw)
221
+ return value && (!skipAsterisk || value !== '*') ? value : ''
222
+ }
164
223
 
165
- return (
166
- values
167
- .map((value) => String(value || '').trim())
168
- .findLast(
169
- (value) => value && (!skipAsterisk || value !== '*')
170
- ) || ''
171
- )
224
+ for (let index = raw.length - 1; index >= 0; index -= 1) {
225
+ const value = ParserUtils.#normalizeFieldValue(raw[index])
226
+ if (value && (!skipAsterisk || value !== '*')) {
227
+ return value
228
+ }
229
+ }
230
+
231
+ return ''
232
+ }
233
+
234
+ /**
235
+ * Normalizes one field payload value.
236
+ * @param {string | undefined} value
237
+ * @returns {string}
238
+ */
239
+ static #normalizeFieldValue(value) {
240
+ return String(value || '').trim()
172
241
  }
173
242
  }
@@ -14,47 +14,54 @@ export class PcbComponentPrimitiveIndexer {
14
14
  * @returns {{ componentIndex: number, designator: string, pads: object[], tracks: object[], arcs: object[], fills: object[], vias: object[], regions: object[], shapeBasedRegions: object[], texts: object[], componentBodies: object[] }[]}
15
15
  */
16
16
  static buildGroups(components, pcb, componentBodies) {
17
+ const primitiveGroups =
18
+ PcbComponentPrimitiveIndexer.#primitiveGroupsByComponent(
19
+ pcb,
20
+ componentBodies
21
+ )
22
+
17
23
  return (components || []).map((component) => {
18
24
  const componentIndex = Number(component.componentIndex)
19
25
 
20
26
  return {
21
27
  componentIndex,
22
28
  designator: component.designator,
23
- pads: PcbComponentPrimitiveIndexer.#primitivesForComponent(
24
- pcb.pads,
29
+ pads: PcbComponentPrimitiveIndexer.#groupForComponent(
30
+ primitiveGroups.pads,
25
31
  componentIndex
26
32
  ),
27
- tracks: PcbComponentPrimitiveIndexer.#primitivesForComponent(
28
- pcb.tracks,
33
+ tracks: PcbComponentPrimitiveIndexer.#groupForComponent(
34
+ primitiveGroups.tracks,
29
35
  componentIndex
30
36
  ),
31
- arcs: PcbComponentPrimitiveIndexer.#primitivesForComponent(
32
- pcb.arcs,
37
+ arcs: PcbComponentPrimitiveIndexer.#groupForComponent(
38
+ primitiveGroups.arcs,
33
39
  componentIndex
34
40
  ),
35
- fills: PcbComponentPrimitiveIndexer.#primitivesForComponent(
36
- pcb.fills,
41
+ fills: PcbComponentPrimitiveIndexer.#groupForComponent(
42
+ primitiveGroups.fills,
37
43
  componentIndex
38
44
  ),
39
- vias: PcbComponentPrimitiveIndexer.#primitivesForComponent(
40
- pcb.vias,
45
+ vias: PcbComponentPrimitiveIndexer.#groupForComponent(
46
+ primitiveGroups.vias,
41
47
  componentIndex
42
48
  ),
43
- regions: PcbComponentPrimitiveIndexer.#primitivesForComponent(
44
- pcb.regions,
49
+ regions: PcbComponentPrimitiveIndexer.#groupForComponent(
50
+ primitiveGroups.regions,
45
51
  componentIndex
46
52
  ),
47
53
  shapeBasedRegions:
48
- PcbComponentPrimitiveIndexer.#primitivesForComponent(
49
- pcb.shapeBasedRegions,
54
+ PcbComponentPrimitiveIndexer.#groupForComponent(
55
+ primitiveGroups.shapeBasedRegions,
50
56
  componentIndex
51
57
  ),
52
- texts: (pcb.texts || []).filter(
53
- (text) => Number(text?.ownerIndex) === componentIndex
58
+ texts: PcbComponentPrimitiveIndexer.#groupForComponent(
59
+ primitiveGroups.texts,
60
+ componentIndex
54
61
  ),
55
62
  componentBodies:
56
- PcbComponentPrimitiveIndexer.#primitivesForComponent(
57
- componentBodies,
63
+ PcbComponentPrimitiveIndexer.#groupForComponent(
64
+ primitiveGroups.componentBodies,
58
65
  componentIndex
59
66
  )
60
67
  }
@@ -87,23 +94,87 @@ export class PcbComponentPrimitiveIndexer {
87
94
  }
88
95
 
89
96
  /**
90
- * Returns primitives linked to a component by native Altium index.
91
- * @param {{ componentIndex?: number | null }[] | undefined} primitives
92
- * @param {number} componentIndex
93
- * @returns {object[]}
97
+ * Builds primitive maps by native component ownership index.
98
+ * @param {{ fills?: object[], tracks?: object[], arcs?: object[], vias?: object[], pads?: object[], regions?: object[], shapeBasedRegions?: object[], texts?: object[] }} pcb
99
+ * @param {{ componentIndex?: number | null }[]} componentBodies
100
+ * @returns {{ pads: Map<number, object[]>, tracks: Map<number, object[]>, arcs: Map<number, object[]>, fills: Map<number, object[]>, vias: Map<number, object[]>, regions: Map<number, object[]>, shapeBasedRegions: Map<number, object[]>, texts: Map<number, object[]>, componentBodies: Map<number, object[]> }}
101
+ */
102
+ static #primitiveGroupsByComponent(pcb, componentBodies) {
103
+ return {
104
+ pads: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
105
+ pcb?.pads
106
+ ),
107
+ tracks: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
108
+ pcb?.tracks
109
+ ),
110
+ arcs: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
111
+ pcb?.arcs
112
+ ),
113
+ fills: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
114
+ pcb?.fills
115
+ ),
116
+ vias: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
117
+ pcb?.vias
118
+ ),
119
+ regions: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
120
+ pcb?.regions
121
+ ),
122
+ shapeBasedRegions:
123
+ PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
124
+ pcb?.shapeBasedRegions
125
+ ),
126
+ texts: PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
127
+ pcb?.texts,
128
+ 'ownerIndex'
129
+ ),
130
+ componentBodies:
131
+ PcbComponentPrimitiveIndexer.#groupPrimitivesByIndex(
132
+ componentBodies
133
+ )
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Groups primitives by one numeric owner-index field.
139
+ * @param {object[] | undefined} primitives Source primitives.
140
+ * @param {string} key Owner-index field name.
141
+ * @returns {Map<number, object[]>}
94
142
  */
95
- static #primitivesForComponent(primitives, componentIndex) {
96
- return (primitives || []).filter((primitive) => {
97
- const rawComponentIndex = primitive?.componentIndex
98
- if (
99
- rawComponentIndex === null ||
100
- rawComponentIndex === undefined ||
101
- rawComponentIndex === ''
102
- ) {
103
- return false
143
+ static #groupPrimitivesByIndex(primitives, key = 'componentIndex') {
144
+ const groupedPrimitives = new Map()
145
+
146
+ for (const primitive of primitives || []) {
147
+ const componentIndex = PcbComponentPrimitiveIndexer.#optionalIndex(
148
+ primitive?.[key]
149
+ )
150
+ if (componentIndex === null) continue
151
+ if (!groupedPrimitives.has(componentIndex)) {
152
+ groupedPrimitives.set(componentIndex, [])
104
153
  }
154
+ groupedPrimitives.get(componentIndex).push(primitive)
155
+ }
105
156
 
106
- return Number(rawComponentIndex) === componentIndex
107
- })
157
+ return groupedPrimitives
158
+ }
159
+
160
+ /**
161
+ * Returns primitives linked to one component index.
162
+ * @param {Map<number, object[]>} groupedPrimitives Grouped primitives.
163
+ * @param {number} componentIndex Component index.
164
+ * @returns {object[]}
165
+ */
166
+ static #groupForComponent(groupedPrimitives, componentIndex) {
167
+ return groupedPrimitives.get(componentIndex) || []
168
+ }
169
+
170
+ /**
171
+ * Parses one optional component owner index.
172
+ * @param {unknown} value Candidate index value.
173
+ * @returns {number | null}
174
+ */
175
+ static #optionalIndex(value) {
176
+ if (value === null || value === undefined || value === '') return null
177
+ const index = Number(value)
178
+ return Number.isFinite(index) ? index : null
108
179
  }
109
180
  }
@@ -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
  /**