altium-toolkit 0.1.23 → 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.
- package/README.md +4 -3
- package/docs/api.md +47 -8
- package/docs/model-format.md +30 -6
- package/package.json +2 -1
- package/spec/library-scope.md +2 -1
- package/src/core/altium/AltiumParser.mjs +17 -4
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +826 -0
- package/src/core/circuit-json/CircuitJsonModelAdapterPrimitives.mjs +354 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +83 -0
- package/src/core/netlist-query/CircuitTraversal.mjs +325 -0
- package/src/core/netlist-query/ComponentGrouping.mjs +355 -0
- package/src/core/netlist-query/LoadedDesignNetlistService.mjs +732 -0
- package/src/core/netlist-query/QueryNetlistBuilder.mjs +180 -0
- package/src/core/netlist-query/RegexPattern.mjs +89 -0
- package/src/netlist-query.mjs +12 -0
- package/src/parser.mjs +2 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +402 -0
- package/src/ui/PcbScene3dBuilder.mjs +16 -5
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +26 -15
|
@@ -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
|
+
}
|