altium-toolkit 0.1.22 → 1.0.1

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.
@@ -0,0 +1,325 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ComponentGrouping } from './ComponentGrouping.mjs'
6
+
7
+ /**
8
+ * Circuit traversal helpers for loaded query netlists.
9
+ */
10
+ export class CircuitTraversal {
11
+ /**
12
+ * Returns true when a net is a recognized ground rail.
13
+ * @param {string} name Net name.
14
+ * @returns {boolean}
15
+ */
16
+ static isGroundNet(name) {
17
+ return /^(GND|VSS|AGND|DGND|PGND|SGND|CGND)$/i.test(String(name || ''))
18
+ }
19
+
20
+ /**
21
+ * Returns true when a net is a recognized power rail.
22
+ * @param {string} name Net name.
23
+ * @returns {boolean}
24
+ */
25
+ static isPowerNet(name) {
26
+ return /^(VCC\w*|VDD\w*|VIN\w*|VOUT\w*|VBAT\w*|VBUS\w*|VSYS\w*|PWR_\w+|RAIL_\w+|PP\w*|PN\w*|LD_PP\w*|LD_PN\w*|[+-]?\d+V\d*\w*|[+-].+)$/i.test(
27
+ String(name || '')
28
+ )
29
+ }
30
+
31
+ /**
32
+ * Returns true when traversal should stop at a net.
33
+ * @param {string} name Net name.
34
+ * @returns {boolean}
35
+ */
36
+ static isStopNet(name) {
37
+ return (
38
+ CircuitTraversal.isGroundNet(name) ||
39
+ CircuitTraversal.isPowerNet(name)
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Returns the alphabetic reference-designator prefix.
45
+ * @param {string} refdes Reference designator.
46
+ * @returns {string}
47
+ */
48
+ static getRefdesPrefix(refdes) {
49
+ return (
50
+ String(refdes || '')
51
+ .match(/^[A-Za-z]+/)?.[0]
52
+ ?.toUpperCase() || ''
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Traverses connectivity from one net.
58
+ * @param {string} startNet Starting net.
59
+ * @param {object} nets Net-to-pin map.
60
+ * @param {object} components Component map.
61
+ * @param {{ skipTypes?: string[], includeDns?: boolean }} [options] Options.
62
+ * @returns {{ components: object[], visited_nets: string[], skipped: Record<string, number> }}
63
+ */
64
+ static traverseCircuitFromNet(startNet, nets, components, options = {}) {
65
+ const queue = [String(startNet || '')]
66
+ const queued = new Set(queue)
67
+ const visitedNets = []
68
+ const componentMap = new Map()
69
+ const skipped = {}
70
+ const skipTypes = new Set(
71
+ (options.skipTypes || []).map((type) =>
72
+ String(type || '').toUpperCase()
73
+ )
74
+ )
75
+
76
+ while (queue.length) {
77
+ const netName = queue.shift()
78
+ if (!netName || visitedNets.includes(netName)) {
79
+ continue
80
+ }
81
+
82
+ visitedNets.push(netName)
83
+ const connections = nets?.[netName] || {}
84
+
85
+ for (const [refdes, pinValue] of Object.entries(connections)) {
86
+ const component = components?.[refdes] || { pins: {} }
87
+ const prefix = CircuitTraversal.getRefdesPrefix(refdes)
88
+
89
+ if (
90
+ !options.includeDns &&
91
+ ComponentGrouping.isDnsComponent(component)
92
+ ) {
93
+ CircuitTraversal.#countSkipped(skipped, prefix)
94
+ continue
95
+ }
96
+
97
+ if (skipTypes.has(prefix)) {
98
+ CircuitTraversal.#countSkipped(skipped, prefix)
99
+ continue
100
+ }
101
+
102
+ const passive = CircuitTraversal.#isTraversablePassive(
103
+ refdes,
104
+ component
105
+ )
106
+ const componentPins = passive
107
+ ? CircuitTraversal.#componentPinEntries(component)
108
+ : CircuitTraversal.#netPinEntries(pinValue, netName)
109
+
110
+ CircuitTraversal.#addComponent(
111
+ componentMap,
112
+ refdes,
113
+ component,
114
+ componentPins
115
+ )
116
+
117
+ if (passive) {
118
+ CircuitTraversal.#enqueuePassiveNets(
119
+ componentPins,
120
+ netName,
121
+ queue,
122
+ queued,
123
+ visitedNets
124
+ )
125
+ }
126
+ }
127
+ }
128
+
129
+ return {
130
+ components: [...componentMap.values()],
131
+ visited_nets: visitedNets,
132
+ skipped
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Computes a stable short hash for a circuit component list.
138
+ * @param {object[]} components Circuit components.
139
+ * @returns {string}
140
+ */
141
+ static computeCircuitHash(components) {
142
+ if (!Array.isArray(components) || !components.length) {
143
+ return '0000000000000000'
144
+ }
145
+
146
+ const canonical = [...components]
147
+ .sort((left, right) =>
148
+ ComponentGrouping.naturalSort(left.refdes, right.refdes)
149
+ )
150
+ .map((component) => ({
151
+ refdes: component.refdes,
152
+ mpn: component.mpn,
153
+ value: component.value,
154
+ connections: (component.connections || [])
155
+ .map((connection) => ({
156
+ net: connection.net,
157
+ pins: [...(connection.pins || [])].sort(
158
+ ComponentGrouping.naturalSort
159
+ )
160
+ }))
161
+ .sort((left, right) => left.net.localeCompare(right.net))
162
+ }))
163
+
164
+ return CircuitTraversal.#hashString(JSON.stringify(canonical))
165
+ }
166
+
167
+ /**
168
+ * Adds one component to the traversal result map.
169
+ * @param {Map<string, object>} componentMap Component map.
170
+ * @param {string} refdes Reference designator.
171
+ * @param {object} component Source component.
172
+ * @param {{ pin: string, net: string }[]} pinEntries Pin entries.
173
+ */
174
+ static #addComponent(componentMap, refdes, component, pinEntries) {
175
+ if (!componentMap.has(refdes)) {
176
+ componentMap.set(refdes, {
177
+ refdes,
178
+ type: CircuitTraversal.getRefdesPrefix(refdes),
179
+ mpn: component.mpn,
180
+ description: component.description,
181
+ comment: component.comment,
182
+ value: component.value,
183
+ dns: component.dns || undefined,
184
+ connections: []
185
+ })
186
+ }
187
+
188
+ const result = componentMap.get(refdes)
189
+ for (const entry of pinEntries) {
190
+ if (!entry.pin || !entry.net) continue
191
+ let connection = result.connections.find(
192
+ (candidate) => candidate.net === entry.net
193
+ )
194
+ if (!connection) {
195
+ connection = { net: entry.net, pins: [] }
196
+ result.connections.push(connection)
197
+ }
198
+ if (!connection.pins.includes(entry.pin)) {
199
+ connection.pins.push(entry.pin)
200
+ connection.pins.sort(ComponentGrouping.naturalSort)
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Enqueues non-stop nets reachable through one passive component.
207
+ * @param {{ pin: string, net: string }[]} pinEntries Passive pins.
208
+ * @param {string} currentNet Current net.
209
+ * @param {string[]} queue Net queue.
210
+ * @param {Set<string>} queued Queued nets.
211
+ * @param {string[]} visitedNets Visited nets.
212
+ */
213
+ static #enqueuePassiveNets(
214
+ pinEntries,
215
+ currentNet,
216
+ queue,
217
+ queued,
218
+ visitedNets
219
+ ) {
220
+ for (const entry of pinEntries) {
221
+ if (!entry.net || entry.net === currentNet) continue
222
+ if (visitedNets.includes(entry.net)) continue
223
+
224
+ if (CircuitTraversal.isStopNet(entry.net)) {
225
+ if (!visitedNets.includes(entry.net)) {
226
+ visitedNets.push(entry.net)
227
+ }
228
+ continue
229
+ }
230
+
231
+ if (!queued.has(entry.net)) {
232
+ queued.add(entry.net)
233
+ queue.push(entry.net)
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Counts one skipped component by type.
240
+ * @param {Record<string, number>} skipped Skipped map.
241
+ * @param {string} prefix Component prefix.
242
+ */
243
+ static #countSkipped(skipped, prefix) {
244
+ skipped[prefix] = (skipped[prefix] || 0) + 1
245
+ }
246
+
247
+ /**
248
+ * Returns true when a component can be traversed as a two-pin passive.
249
+ * @param {string} refdes Reference designator.
250
+ * @param {object} component Component metadata.
251
+ * @returns {boolean}
252
+ */
253
+ static #isTraversablePassive(refdes, component) {
254
+ return (
255
+ CircuitTraversal.#passivePrefixes().has(
256
+ CircuitTraversal.getRefdesPrefix(refdes)
257
+ ) && CircuitTraversal.#componentPinEntries(component).length === 2
258
+ )
259
+ }
260
+
261
+ /**
262
+ * Returns all component pin entries.
263
+ * @param {object} component Component metadata.
264
+ * @returns {{ pin: string, net: string }[]}
265
+ */
266
+ static #componentPinEntries(component) {
267
+ return Object.entries(component?.pins || []).map(([pin, entry]) => ({
268
+ pin: String(pin),
269
+ net: CircuitTraversal.#pinNet(entry)
270
+ }))
271
+ }
272
+
273
+ /**
274
+ * Builds pin entries for the current net connection.
275
+ * @param {string | string[]} pinValue Pin value.
276
+ * @param {string} netName Net name.
277
+ * @returns {{ pin: string, net: string }[]}
278
+ */
279
+ static #netPinEntries(pinValue, netName) {
280
+ const pins = Array.isArray(pinValue) ? pinValue : [pinValue]
281
+ return pins.map((pin) => ({
282
+ pin: String(pin || ''),
283
+ net: netName
284
+ }))
285
+ }
286
+
287
+ /**
288
+ * Extracts a net name from a pin entry.
289
+ * @param {string | { net?: string }} entry Pin entry.
290
+ * @returns {string}
291
+ */
292
+ static #pinNet(entry) {
293
+ return typeof entry === 'string' ? entry : String(entry?.net || '')
294
+ }
295
+
296
+ /**
297
+ * Returns traversable passive prefixes.
298
+ * @returns {Set<string>}
299
+ */
300
+ static #passivePrefixes() {
301
+ return new Set(['R', 'RS', 'FR', 'C', 'L', 'FB'])
302
+ }
303
+
304
+ /**
305
+ * Hashes a string with a deterministic browser-safe FNV-1a variant.
306
+ * @param {string} value Input value.
307
+ * @returns {string}
308
+ */
309
+ static #hashString(value) {
310
+ let left = 0x811c9dc5
311
+ let right = 0x01000193
312
+
313
+ for (let index = 0; index < value.length; index += 1) {
314
+ left ^= value.charCodeAt(index)
315
+ left = Math.imul(left, 0x01000193) >>> 0
316
+ right ^= value.charCodeAt(value.length - index - 1)
317
+ right = Math.imul(right, 0x811c9dc5) >>> 0
318
+ }
319
+
320
+ return (
321
+ left.toString(16).padStart(8, '0') +
322
+ right.toString(16).padStart(8, '0')
323
+ ).slice(0, 16)
324
+ }
325
+ }
@@ -0,0 +1,355 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ export const MPN_MISSING_NOTE =
6
+ 'MPN not found in loaded design metadata. Add a part number to the symbol properties or provide a BOM.'
7
+
8
+ /**
9
+ * Component grouping helpers for compact netlist query responses.
10
+ */
11
+ export class ComponentGrouping {
12
+ /**
13
+ * Compacts a single-element array to its scalar value.
14
+ * @template T
15
+ * @param {T[]} values Values to compact.
16
+ * @returns {T | T[]}
17
+ */
18
+ static compactArray(values) {
19
+ return values.length === 1 ? values[0] : values
20
+ }
21
+
22
+ /**
23
+ * Groups components by MPN while leaving no-MPN components separate.
24
+ * @param {[string, object][]} entries Component entries.
25
+ * @param {boolean} includeDns Whether DNS components are included.
26
+ * @returns {object[]}
27
+ */
28
+ static groupComponentsByMpn(entries, includeDns = false) {
29
+ const groups = new Map()
30
+
31
+ for (const [refdes, component] of entries || []) {
32
+ const normalizedComponent =
33
+ ComponentGrouping.#normalizeComponent(component)
34
+ const dns = ComponentGrouping.isDnsComponent(normalizedComponent)
35
+ if (!includeDns && dns) {
36
+ continue
37
+ }
38
+
39
+ const groupKey = ComponentGrouping.#componentGroupKey(
40
+ refdes,
41
+ normalizedComponent,
42
+ dns
43
+ )
44
+ if (!groups.has(groupKey)) {
45
+ groups.set(groupKey, {
46
+ ...ComponentGrouping.#componentMetadata(
47
+ normalizedComponent
48
+ ),
49
+ dns: dns || undefined,
50
+ notes: normalizedComponent.mpn
51
+ ? undefined
52
+ : [MPN_MISSING_NOTE],
53
+ refdes: []
54
+ })
55
+ }
56
+
57
+ groups.get(groupKey).refdes.push(String(refdes || ''))
58
+ }
59
+
60
+ return [...groups.values()]
61
+ .map((group) => ComponentGrouping.#buildComponentGroup(group))
62
+ .sort((left, right) =>
63
+ String(left.mpn || '').localeCompare(String(right.mpn || ''))
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Aggregates circuit components by MPN or description.
69
+ * @param {object[]} components Circuit components.
70
+ * @returns {object[]}
71
+ */
72
+ static aggregateCircuitByMpn(components) {
73
+ const groups = new Map()
74
+
75
+ for (const component of components || []) {
76
+ const normalized = ComponentGrouping.#normalizeComponent(component)
77
+ const key = ComponentGrouping.#circuitGroupKey(normalized)
78
+ if (!groups.has(key)) {
79
+ groups.set(key, {
80
+ ...ComponentGrouping.#componentMetadata(normalized),
81
+ dns: normalized.dns || undefined,
82
+ notes: normalized.mpn ? undefined : [MPN_MISSING_NOTE],
83
+ orientations: new Map()
84
+ })
85
+ }
86
+
87
+ const group = groups.get(key)
88
+ const orientationKey = ComponentGrouping.#orientationKey(
89
+ normalized.connections || []
90
+ )
91
+ if (!group.orientations.has(orientationKey)) {
92
+ group.orientations.set(orientationKey, {
93
+ count: 0,
94
+ refdes: [],
95
+ connections: normalized.connections || []
96
+ })
97
+ }
98
+
99
+ const orientation = group.orientations.get(orientationKey)
100
+ orientation.count += 1
101
+ orientation.refdes.push(normalized.refdes)
102
+ }
103
+
104
+ return [...groups.values()]
105
+ .map((group) => ComponentGrouping.#buildAggregatedGroup(group))
106
+ .sort((left, right) => right.total_count - left.total_count)
107
+ }
108
+
109
+ /**
110
+ * Returns true when a component carries a DNS marker.
111
+ * @param {object} component Component metadata.
112
+ * @returns {boolean}
113
+ */
114
+ static isDnsComponent(component) {
115
+ if (component?.dns === true || component?.excludeFromBom === true) {
116
+ return true
117
+ }
118
+
119
+ const haystack = [
120
+ component?.mpn,
121
+ component?.description,
122
+ component?.comment,
123
+ component?.value
124
+ ]
125
+ .map((value) => String(value || ''))
126
+ .join(' ')
127
+
128
+ return ComponentGrouping.#dnsPattern().test(haystack)
129
+ }
130
+
131
+ /**
132
+ * Returns a natural sort comparator.
133
+ * @param {string} left Left value.
134
+ * @param {string} right Right value.
135
+ * @returns {number}
136
+ */
137
+ static naturalSort(left, right) {
138
+ const leftKey = ComponentGrouping.#naturalSortKey(left)
139
+ const rightKey = ComponentGrouping.#naturalSortKey(right)
140
+ const length = Math.min(leftKey.length, rightKey.length)
141
+
142
+ for (let index = 0; index < length; index += 1) {
143
+ if (leftKey[index] < rightKey[index]) return -1
144
+ if (leftKey[index] > rightKey[index]) return 1
145
+ }
146
+
147
+ return leftKey.length - rightKey.length
148
+ }
149
+
150
+ /**
151
+ * Builds one grouped component response.
152
+ * @param {object} group Internal group.
153
+ * @returns {object}
154
+ */
155
+ static #buildComponentGroup(group) {
156
+ const refdes = group.refdes
157
+ .filter(Boolean)
158
+ .sort(ComponentGrouping.naturalSort)
159
+ const result = {
160
+ ...ComponentGrouping.#componentMetadata(group),
161
+ count: refdes.length,
162
+ refdes: ComponentGrouping.compactArray(refdes)
163
+ }
164
+
165
+ if (group.dns) result.dns = true
166
+ if (group.notes) result.notes = group.notes
167
+
168
+ return ComponentGrouping.#withoutUndefined(result)
169
+ }
170
+
171
+ /**
172
+ * Builds one aggregated circuit response group.
173
+ * @param {object} group Internal circuit group.
174
+ * @returns {object}
175
+ */
176
+ static #buildAggregatedGroup(group) {
177
+ const orientations = [...group.orientations.values()]
178
+ const totalCount = orientations.reduce(
179
+ (sum, orientation) => sum + orientation.count,
180
+ 0
181
+ )
182
+ const result = {
183
+ ...ComponentGrouping.#componentMetadata(group),
184
+ total_count: totalCount
185
+ }
186
+
187
+ if (group.dns) result.dns = true
188
+ if (group.notes) result.notes = group.notes
189
+
190
+ if (orientations.length === 1) {
191
+ result.refdes = ComponentGrouping.compactArray(
192
+ orientations[0].refdes.sort(ComponentGrouping.naturalSort)
193
+ )
194
+ result.connections = ComponentGrouping.#compactConnections(
195
+ orientations[0].connections
196
+ )
197
+ } else {
198
+ result.orientations = orientations.map((orientation) => ({
199
+ count: orientation.count,
200
+ refdes: ComponentGrouping.compactArray(
201
+ orientation.refdes.sort(ComponentGrouping.naturalSort)
202
+ ),
203
+ connections: ComponentGrouping.#compactConnections(
204
+ orientation.connections
205
+ )
206
+ }))
207
+ }
208
+
209
+ return ComponentGrouping.#withoutUndefined(result)
210
+ }
211
+
212
+ /**
213
+ * Returns component metadata fields with empty values omitted.
214
+ * @param {object} component Component metadata.
215
+ * @returns {object}
216
+ */
217
+ static #componentMetadata(component) {
218
+ return ComponentGrouping.#withoutUndefined({
219
+ mpn: ComponentGrouping.#trim(component?.mpn),
220
+ description: ComponentGrouping.#trim(component?.description),
221
+ comment: ComponentGrouping.#trim(component?.comment),
222
+ value: ComponentGrouping.#trim(component?.value)
223
+ })
224
+ }
225
+
226
+ /**
227
+ * Normalizes one component object.
228
+ * @param {object} component Component metadata.
229
+ * @returns {object}
230
+ */
231
+ static #normalizeComponent(component) {
232
+ return component && typeof component === 'object' ? component : {}
233
+ }
234
+
235
+ /**
236
+ * Builds a stable grouping key for component lists.
237
+ * @param {string} refdes Reference designator.
238
+ * @param {object} component Component metadata.
239
+ * @param {boolean} dns DNS flag.
240
+ * @returns {string}
241
+ */
242
+ static #componentGroupKey(refdes, component, dns) {
243
+ const mpn = ComponentGrouping.#trim(component?.mpn)
244
+ return [
245
+ mpn ? 'mpn:' + mpn : 'refdes:' + String(refdes || ''),
246
+ dns ? 'dns:1' : 'dns:0'
247
+ ].join('|')
248
+ }
249
+
250
+ /**
251
+ * Builds a stable grouping key for circuit components.
252
+ * @param {object} component Circuit component.
253
+ * @returns {string}
254
+ */
255
+ static #circuitGroupKey(component) {
256
+ const metadata =
257
+ ComponentGrouping.#trim(component?.mpn) ||
258
+ ComponentGrouping.#trim(component?.description) ||
259
+ String(component?.refdes || '')
260
+ const nets = (component?.connections || [])
261
+ .map((connection) => String(connection.net || ''))
262
+ .sort()
263
+ .join(',')
264
+
265
+ return [metadata, nets, component?.dns ? 'dns:1' : 'dns:0'].join('|')
266
+ }
267
+
268
+ /**
269
+ * Builds a stable orientation key for pin-to-net connections.
270
+ * @param {object[]} connections Circuit connections.
271
+ * @returns {string}
272
+ */
273
+ static #orientationKey(connections) {
274
+ return (connections || [])
275
+ .map((connection) => {
276
+ const pins = Array.isArray(connection.pins)
277
+ ? connection.pins
278
+ : [connection.pins]
279
+ return (
280
+ pins
281
+ .map(String)
282
+ .sort(ComponentGrouping.naturalSort)
283
+ .join(',') +
284
+ ':' +
285
+ String(connection.net || '')
286
+ )
287
+ })
288
+ .join('|')
289
+ }
290
+
291
+ /**
292
+ * Compacts circuit pin arrays in response connections.
293
+ * @param {object[]} connections Circuit connections.
294
+ * @returns {object[]}
295
+ */
296
+ static #compactConnections(connections) {
297
+ return (connections || []).map((connection) => ({
298
+ net: String(connection.net || ''),
299
+ pins: ComponentGrouping.compactArray(
300
+ (Array.isArray(connection.pins)
301
+ ? connection.pins
302
+ : [connection.pins]
303
+ )
304
+ .filter((pin) => pin !== undefined && pin !== null)
305
+ .map(String)
306
+ .sort(ComponentGrouping.naturalSort)
307
+ )
308
+ }))
309
+ }
310
+
311
+ /**
312
+ * Returns the DNS marker pattern.
313
+ * @returns {RegExp}
314
+ */
315
+ static #dnsPattern() {
316
+ return /(?:^|[_,\s])(DNS|DNP|DNF|DNI|DNM|NF|NC)(?:$|[_,\s])|DO\s*NOT\s*(STUFF|POPULATE|INSTALL|FIT|MOUNT)|NOT\s*(POPULATED|FITTED|CONNECTED|MOUNTED)|NO\s*POP/i
317
+ }
318
+
319
+ /**
320
+ * Returns a string without surrounding whitespace or undefined.
321
+ * @param {unknown} value Raw value.
322
+ * @returns {string | undefined}
323
+ */
324
+ static #trim(value) {
325
+ const trimmed = String(value || '').trim()
326
+ return trimmed || undefined
327
+ }
328
+
329
+ /**
330
+ * Removes undefined values from an object.
331
+ * @param {object} value Object value.
332
+ * @returns {object}
333
+ */
334
+ static #withoutUndefined(value) {
335
+ return Object.fromEntries(
336
+ Object.entries(value).filter(([, entryValue]) => {
337
+ return entryValue !== undefined
338
+ })
339
+ )
340
+ }
341
+
342
+ /**
343
+ * Builds a natural sort key from a string.
344
+ * @param {string | number} value Sort value.
345
+ * @returns {(string | number)[]}
346
+ */
347
+ static #naturalSortKey(value) {
348
+ return String(value)
349
+ .split(/(\d+)/)
350
+ .map((part) => {
351
+ const numeric = Number.parseInt(part, 10)
352
+ return Number.isNaN(numeric) ? part.toLowerCase() : numeric
353
+ })
354
+ }
355
+ }