altium-toolkit 0.1.23 → 1.0.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/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/styles/altium-renderers.css +12 -1
- package/src/ui/PcbNativeTextKnockoutDetector.mjs +130 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +402 -0
- package/src/ui/PcbScene3dBuilder.mjs +16 -5
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +26 -15
- package/src/ui/PcbSvgRenderer.mjs +83 -10
- package/src/ui/PcbTextPrimitiveRenderer.mjs +648 -19
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ComponentGrouping, MPN_MISSING_NOTE } from './ComponentGrouping.mjs'
|
|
6
|
+
import { CircuitTraversal } from './CircuitTraversal.mjs'
|
|
7
|
+
import { QueryNetlistBuilder } from './QueryNetlistBuilder.mjs'
|
|
8
|
+
import { RegexPattern } from './RegexPattern.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds query responses from loaded toolkit document models.
|
|
12
|
+
*/
|
|
13
|
+
export class LoadedDesignNetlistService {
|
|
14
|
+
/** @type {() => object[]} */
|
|
15
|
+
#getDocuments
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ getDocuments: () => object[] }} dependencies Dependencies.
|
|
19
|
+
*/
|
|
20
|
+
constructor(dependencies = {}) {
|
|
21
|
+
this.#getDocuments =
|
|
22
|
+
typeof dependencies.getDocuments === 'function'
|
|
23
|
+
? dependencies.getDocuments
|
|
24
|
+
: () => []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Lists loaded session designs.
|
|
29
|
+
* @param {{ pattern?: string, max_results?: number }} [args] Query args.
|
|
30
|
+
* @returns {object[] | { error: string }}
|
|
31
|
+
*/
|
|
32
|
+
listDesigns(args = {}) {
|
|
33
|
+
const loaded = this.#loadedEntries()
|
|
34
|
+
const pattern = String(args.pattern || '.*')
|
|
35
|
+
const parsed = RegexPattern.parse(pattern)
|
|
36
|
+
if (parsed.error) return parsed
|
|
37
|
+
|
|
38
|
+
return loaded
|
|
39
|
+
.filter((entry) => parsed.regex.test(entry.name))
|
|
40
|
+
.slice(0, LoadedDesignNetlistService.#maxResults(args.max_results))
|
|
41
|
+
.map((entry) => ({
|
|
42
|
+
id: entry.id,
|
|
43
|
+
name: entry.name,
|
|
44
|
+
fileName: entry.fileName,
|
|
45
|
+
kind: entry.kind,
|
|
46
|
+
active: entry.active,
|
|
47
|
+
hasConnectivity: entry.hasConnectivity
|
|
48
|
+
}))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lists components matching one reference-designator prefix.
|
|
53
|
+
* @param {{ design?: string, type?: string, include_dns?: boolean }} [args] Query args.
|
|
54
|
+
* @returns {{ components: object[] } | { error: string }}
|
|
55
|
+
*/
|
|
56
|
+
listComponents(args = {}) {
|
|
57
|
+
const resolved = this.#resolveDesign(args.design)
|
|
58
|
+
if (resolved.error) return resolved
|
|
59
|
+
|
|
60
|
+
const prefix = String(args.type || '')
|
|
61
|
+
.trim()
|
|
62
|
+
.toUpperCase()
|
|
63
|
+
if (!prefix) {
|
|
64
|
+
return { error: 'Component type prefix is required.' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const entries = Object.entries(resolved.netlist.components).filter(
|
|
68
|
+
([refdes]) =>
|
|
69
|
+
LoadedDesignNetlistService.#refdesPrefix(refdes) === prefix
|
|
70
|
+
)
|
|
71
|
+
if (!entries.length) {
|
|
72
|
+
return {
|
|
73
|
+
error:
|
|
74
|
+
"No components with prefix '" +
|
|
75
|
+
prefix +
|
|
76
|
+
"' found in design '" +
|
|
77
|
+
resolved.entry.name +
|
|
78
|
+
"'. Available prefixes: [" +
|
|
79
|
+
this.#availablePrefixes(resolved.netlist).join(', ') +
|
|
80
|
+
']'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
components: ComponentGrouping.groupComponentsByMpn(
|
|
86
|
+
entries,
|
|
87
|
+
args.include_dns === true
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lists net names for one loaded design.
|
|
94
|
+
* @param {{ design?: string }} [args] Query args.
|
|
95
|
+
* @returns {{ nets: string[] } | { error: string }}
|
|
96
|
+
*/
|
|
97
|
+
listNets(args = {}) {
|
|
98
|
+
const resolved = this.#resolveDesignWithConnectivity(args.design)
|
|
99
|
+
if (resolved.error) return resolved
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
nets: Object.keys(resolved.netlist.nets).sort((left, right) =>
|
|
103
|
+
left.localeCompare(right)
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Searches net names by regex.
|
|
110
|
+
* @param {{ design?: string, pattern?: string }} [args] Query args.
|
|
111
|
+
* @returns {{ results: Record<string, string[]>, notes?: string[] } | { error: string }}
|
|
112
|
+
*/
|
|
113
|
+
searchNets(args = {}) {
|
|
114
|
+
const resolved = this.#resolveDesignWithConnectivity(args.design)
|
|
115
|
+
if (resolved.error) return resolved
|
|
116
|
+
|
|
117
|
+
const parsed = RegexPattern.parse(args.pattern)
|
|
118
|
+
if (parsed.error) return parsed
|
|
119
|
+
|
|
120
|
+
const allNets = Object.keys(resolved.netlist.nets)
|
|
121
|
+
if (RegexPattern.rejectsBroadMatch(args.pattern, allNets)) {
|
|
122
|
+
return {
|
|
123
|
+
error: 'Pattern matches every net. Use list_nets for full net lists.'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const matches = allNets
|
|
128
|
+
.filter((net) => {
|
|
129
|
+
parsed.regex.lastIndex = 0
|
|
130
|
+
return parsed.regex.test(net)
|
|
131
|
+
})
|
|
132
|
+
.sort((left, right) => left.localeCompare(right))
|
|
133
|
+
const response = {
|
|
134
|
+
results: {
|
|
135
|
+
[resolved.entry.name]: matches
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!matches.length) {
|
|
140
|
+
response.notes = [
|
|
141
|
+
"No nets matched pattern '" + String(args.pattern || '') + "'"
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return response
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Searches components by reference designator.
|
|
150
|
+
* @param {{ design?: string, pattern?: string, include_dns?: boolean }} [args] Query args.
|
|
151
|
+
* @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
|
|
152
|
+
*/
|
|
153
|
+
searchComponentsByRefdes(args = {}) {
|
|
154
|
+
return this.#searchComponents(args, 'refdes')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Searches components by MPN.
|
|
159
|
+
* @param {{ design?: string, pattern?: string, include_dns?: boolean }} [args] Query args.
|
|
160
|
+
* @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
|
|
161
|
+
*/
|
|
162
|
+
searchComponentsByMpn(args = {}) {
|
|
163
|
+
return this.#searchComponents(args, 'mpn')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Searches components by description.
|
|
168
|
+
* @param {{ design?: string, pattern?: string, include_dns?: boolean }} [args] Query args.
|
|
169
|
+
* @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
|
|
170
|
+
*/
|
|
171
|
+
searchComponentsByDescription(args = {}) {
|
|
172
|
+
return this.#searchComponents(args, 'description')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Queries one component and all known pin connections.
|
|
177
|
+
* @param {{ design?: string, refdes?: string }} [args] Query args.
|
|
178
|
+
* @returns {object | { error: string }}
|
|
179
|
+
*/
|
|
180
|
+
queryComponent(args = {}) {
|
|
181
|
+
const resolved = this.#resolveDesign(args.design)
|
|
182
|
+
if (resolved.error) return resolved
|
|
183
|
+
|
|
184
|
+
const refdes = String(args.refdes || '').trim()
|
|
185
|
+
const entry = Object.entries(resolved.netlist.components).find(
|
|
186
|
+
([candidate]) => candidate.toLowerCase() === refdes.toLowerCase()
|
|
187
|
+
)
|
|
188
|
+
if (!entry) {
|
|
189
|
+
return {
|
|
190
|
+
error:
|
|
191
|
+
"Component '" +
|
|
192
|
+
refdes +
|
|
193
|
+
"' not found in design '" +
|
|
194
|
+
resolved.entry.name +
|
|
195
|
+
"'. Use list_components or search_components_by_refdes."
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return LoadedDesignNetlistService.#componentDetails(entry)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Queries extended connectivity starting from a net name.
|
|
204
|
+
* @param {{ design?: string, net_name?: string, skip_types?: string[], include_dns?: boolean }} [args] Query args.
|
|
205
|
+
* @returns {object | { error: string }}
|
|
206
|
+
*/
|
|
207
|
+
queryXnetByNetName(args = {}) {
|
|
208
|
+
const resolved = this.#resolveDesignWithConnectivity(args.design)
|
|
209
|
+
if (resolved.error) return resolved
|
|
210
|
+
|
|
211
|
+
const netName = this.#resolveNetName(resolved.netlist, args.net_name)
|
|
212
|
+
if (!netName) {
|
|
213
|
+
return {
|
|
214
|
+
error:
|
|
215
|
+
"Net '" +
|
|
216
|
+
String(args.net_name || '') +
|
|
217
|
+
"' not found in design '" +
|
|
218
|
+
resolved.entry.name +
|
|
219
|
+
"'. Use search_nets to find available nets."
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (CircuitTraversal.isStopNet(netName)) {
|
|
223
|
+
return {
|
|
224
|
+
error:
|
|
225
|
+
netName + ' is a power or ground net and cannot be queried.'
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this.#buildTraversalResponse(
|
|
230
|
+
netName,
|
|
231
|
+
netName,
|
|
232
|
+
'',
|
|
233
|
+
resolved.netlist,
|
|
234
|
+
args
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Queries extended connectivity starting from a component pin.
|
|
240
|
+
* @param {{ design?: string, pin_name?: string, skip_types?: string[], include_dns?: boolean }} [args] Query args.
|
|
241
|
+
* @returns {object | { error: string }}
|
|
242
|
+
*/
|
|
243
|
+
queryXnetByPinName(args = {}) {
|
|
244
|
+
const resolved = this.#resolveDesignWithConnectivity(args.design)
|
|
245
|
+
if (resolved.error) return resolved
|
|
246
|
+
|
|
247
|
+
const pinSpec = LoadedDesignNetlistService.#parsePinSpec(args.pin_name)
|
|
248
|
+
if (pinSpec.error) return pinSpec
|
|
249
|
+
|
|
250
|
+
const componentEntry = Object.entries(resolved.netlist.components).find(
|
|
251
|
+
([refdes]) => refdes.toLowerCase() === pinSpec.refdes.toLowerCase()
|
|
252
|
+
)
|
|
253
|
+
if (!componentEntry) {
|
|
254
|
+
return {
|
|
255
|
+
error:
|
|
256
|
+
"Component '" +
|
|
257
|
+
pinSpec.refdes +
|
|
258
|
+
"' not found in design '" +
|
|
259
|
+
resolved.entry.name +
|
|
260
|
+
"'. Use list_components or search_components_by_refdes."
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const [resolvedRefdes, component] = componentEntry
|
|
265
|
+
const pinKey = Object.keys(component.pins || {}).find((candidate) => {
|
|
266
|
+
return candidate.toLowerCase() === pinSpec.pin.toLowerCase()
|
|
267
|
+
})
|
|
268
|
+
if (!pinKey) {
|
|
269
|
+
return {
|
|
270
|
+
error:
|
|
271
|
+
"Pin '" +
|
|
272
|
+
resolvedRefdes +
|
|
273
|
+
'.' +
|
|
274
|
+
pinSpec.pin +
|
|
275
|
+
"' not found. Component " +
|
|
276
|
+
resolvedRefdes +
|
|
277
|
+
' has pins: [' +
|
|
278
|
+
Object.keys(component.pins || {})
|
|
279
|
+
.sort(ComponentGrouping.naturalSort)
|
|
280
|
+
.join(', ') +
|
|
281
|
+
']'
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const netName = LoadedDesignNetlistService.#pinNet(
|
|
286
|
+
component.pins[pinKey]
|
|
287
|
+
)
|
|
288
|
+
if (netName === 'NC') {
|
|
289
|
+
const startingPoint = resolvedRefdes + '.' + pinKey
|
|
290
|
+
return {
|
|
291
|
+
starting_point: startingPoint,
|
|
292
|
+
net: netName,
|
|
293
|
+
total_components: 0,
|
|
294
|
+
unique_configurations: 0,
|
|
295
|
+
components_by_mpn: [],
|
|
296
|
+
visited_nets: ['NC'],
|
|
297
|
+
circuit_hash: 'nc-' + startingPoint
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (CircuitTraversal.isStopNet(netName)) {
|
|
301
|
+
return {
|
|
302
|
+
error:
|
|
303
|
+
'Pin ' +
|
|
304
|
+
resolvedRefdes +
|
|
305
|
+
'.' +
|
|
306
|
+
pinKey +
|
|
307
|
+
' is connected to ' +
|
|
308
|
+
netName +
|
|
309
|
+
' and cannot be queried.'
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return this.#buildTraversalResponse(
|
|
314
|
+
resolvedRefdes + '.' + pinKey,
|
|
315
|
+
netName,
|
|
316
|
+
netName,
|
|
317
|
+
resolved.netlist,
|
|
318
|
+
args
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Searches components on one metadata field.
|
|
324
|
+
* @param {{ design?: string, pattern?: string, include_dns?: boolean }} args Query args.
|
|
325
|
+
* @param {'refdes' | 'mpn' | 'description'} field Search field.
|
|
326
|
+
* @returns {{ results: Record<string, object[]>, notes?: string[] } | { error: string }}
|
|
327
|
+
*/
|
|
328
|
+
#searchComponents(args, field) {
|
|
329
|
+
const resolved = this.#resolveDesign(args.design)
|
|
330
|
+
if (resolved.error) return resolved
|
|
331
|
+
|
|
332
|
+
const parsed = RegexPattern.parse(args.pattern)
|
|
333
|
+
if (parsed.error) return parsed
|
|
334
|
+
|
|
335
|
+
const allEntries = Object.entries(resolved.netlist.components)
|
|
336
|
+
const searchableEntries = allEntries.filter(([refdes, component]) => {
|
|
337
|
+
return LoadedDesignNetlistService.#searchValue(
|
|
338
|
+
refdes,
|
|
339
|
+
component,
|
|
340
|
+
field
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
const matches = searchableEntries.filter(([refdes, component]) => {
|
|
344
|
+
parsed.regex.lastIndex = 0
|
|
345
|
+
return parsed.regex.test(
|
|
346
|
+
LoadedDesignNetlistService.#searchValue(
|
|
347
|
+
refdes,
|
|
348
|
+
component,
|
|
349
|
+
field
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
if (
|
|
355
|
+
RegexPattern.rejectsBroadMatch(
|
|
356
|
+
args.pattern,
|
|
357
|
+
searchableEntries.map(([refdes, component]) =>
|
|
358
|
+
LoadedDesignNetlistService.#searchValue(
|
|
359
|
+
refdes,
|
|
360
|
+
component,
|
|
361
|
+
field
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
) {
|
|
366
|
+
return {
|
|
367
|
+
error: 'Pattern matches every component. Use list_components for prefix-based lists.'
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const response = {
|
|
372
|
+
results: {
|
|
373
|
+
[resolved.entry.name]: ComponentGrouping.groupComponentsByMpn(
|
|
374
|
+
matches,
|
|
375
|
+
args.include_dns === true
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!response.results[resolved.entry.name].length) {
|
|
381
|
+
response.notes = [
|
|
382
|
+
"No components matched pattern '" +
|
|
383
|
+
String(args.pattern || '') +
|
|
384
|
+
"'."
|
|
385
|
+
]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return response
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Resolves one design and requires schematic connectivity.
|
|
393
|
+
* @param {string | undefined} design Design selector.
|
|
394
|
+
* @returns {object | { error: string }}
|
|
395
|
+
*/
|
|
396
|
+
#resolveDesignWithConnectivity(design) {
|
|
397
|
+
const resolved = this.#resolveDesign(design)
|
|
398
|
+
if (resolved.error) return resolved
|
|
399
|
+
if (!Object.keys(resolved.netlist.nets).length) {
|
|
400
|
+
return {
|
|
401
|
+
error: 'No schematic connectivity is available for this loaded design.'
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return resolved
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Builds an aggregated traversal response.
|
|
410
|
+
* @param {string} startingPoint Response starting point.
|
|
411
|
+
* @param {string} netName Starting net.
|
|
412
|
+
* @param {string} responseNet Optional response net field.
|
|
413
|
+
* @param {{ nets: object, components: object }} netlist Query netlist.
|
|
414
|
+
* @param {{ skip_types?: string[], include_dns?: boolean }} args Query args.
|
|
415
|
+
* @returns {object}
|
|
416
|
+
*/
|
|
417
|
+
#buildTraversalResponse(
|
|
418
|
+
startingPoint,
|
|
419
|
+
netName,
|
|
420
|
+
responseNet,
|
|
421
|
+
netlist,
|
|
422
|
+
args
|
|
423
|
+
) {
|
|
424
|
+
const traversal = CircuitTraversal.traverseCircuitFromNet(
|
|
425
|
+
netName,
|
|
426
|
+
netlist.nets,
|
|
427
|
+
netlist.components,
|
|
428
|
+
{
|
|
429
|
+
skipTypes: Array.isArray(args.skip_types)
|
|
430
|
+
? args.skip_types
|
|
431
|
+
: [],
|
|
432
|
+
includeDns: args.include_dns === true
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
const componentsByMpn = ComponentGrouping.aggregateCircuitByMpn(
|
|
436
|
+
traversal.components
|
|
437
|
+
)
|
|
438
|
+
const response = {
|
|
439
|
+
starting_point: startingPoint,
|
|
440
|
+
net: responseNet || undefined,
|
|
441
|
+
total_components: traversal.components.length,
|
|
442
|
+
unique_configurations:
|
|
443
|
+
LoadedDesignNetlistService.#uniqueConfigurations(
|
|
444
|
+
componentsByMpn
|
|
445
|
+
),
|
|
446
|
+
components_by_mpn: componentsByMpn,
|
|
447
|
+
visited_nets: traversal.visited_nets,
|
|
448
|
+
circuit_hash: CircuitTraversal.computeCircuitHash(
|
|
449
|
+
traversal.components
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (Object.keys(traversal.skipped || {}).length) {
|
|
454
|
+
response.skipped = traversal.skipped
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return LoadedDesignNetlistService.#withoutUndefined(response)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolves a loaded design selector.
|
|
462
|
+
* @param {string | undefined} design Design selector.
|
|
463
|
+
* @returns {object | { error: string }}
|
|
464
|
+
*/
|
|
465
|
+
#resolveDesign(design) {
|
|
466
|
+
const entries = this.#loadedEntries()
|
|
467
|
+
if (!entries.length) {
|
|
468
|
+
return { error: 'No design is loaded in the current session.' }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const selector = String(design || 'active').trim()
|
|
472
|
+
if (!selector || selector.toLowerCase() === 'active') {
|
|
473
|
+
const activeEntry =
|
|
474
|
+
entries.find((entry) => entry.active) || entries[0]
|
|
475
|
+
return {
|
|
476
|
+
entry: activeEntry,
|
|
477
|
+
netlist: QueryNetlistBuilder.build(activeEntry.documentModel)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const matches = entries.filter((entry) =>
|
|
482
|
+
LoadedDesignNetlistService.#entryMatchesSelector(entry, selector)
|
|
483
|
+
)
|
|
484
|
+
if (matches.length > 1) {
|
|
485
|
+
return {
|
|
486
|
+
error:
|
|
487
|
+
"Design selector '" +
|
|
488
|
+
selector +
|
|
489
|
+
"' is ambiguous. Use a loaded document id."
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (!matches.length) {
|
|
493
|
+
return {
|
|
494
|
+
error:
|
|
495
|
+
"Design selector '" +
|
|
496
|
+
selector +
|
|
497
|
+
"' did not match a loaded design."
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
entry: matches[0],
|
|
503
|
+
netlist: QueryNetlistBuilder.build(matches[0].documentModel)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Returns loaded design entries with derived metadata.
|
|
509
|
+
* @returns {object[]}
|
|
510
|
+
*/
|
|
511
|
+
#loadedEntries() {
|
|
512
|
+
return (Array.isArray(this.#getDocuments()) ? this.#getDocuments() : [])
|
|
513
|
+
.map((entry) => LoadedDesignNetlistService.#normalizeEntry(entry))
|
|
514
|
+
.filter((entry) => entry.id && entry.documentModel)
|
|
515
|
+
.map((entry) => {
|
|
516
|
+
const netlist = QueryNetlistBuilder.build(entry.documentModel)
|
|
517
|
+
return {
|
|
518
|
+
...entry,
|
|
519
|
+
name:
|
|
520
|
+
String(entry.documentModel?.summary?.title || '') ||
|
|
521
|
+
LoadedDesignNetlistService.#baseName(
|
|
522
|
+
entry.documentModel?.fileName
|
|
523
|
+
),
|
|
524
|
+
fileName: String(entry.documentModel?.fileName || ''),
|
|
525
|
+
baseName: LoadedDesignNetlistService.#baseName(
|
|
526
|
+
entry.documentModel?.fileName
|
|
527
|
+
),
|
|
528
|
+
kind: String(entry.documentModel?.kind || 'document'),
|
|
529
|
+
hasConnectivity: Boolean(Object.keys(netlist.nets).length)
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Returns available reference-designator prefixes.
|
|
536
|
+
* @param {{ components: object }} netlist Query netlist.
|
|
537
|
+
* @returns {string[]}
|
|
538
|
+
*/
|
|
539
|
+
#availablePrefixes(netlist) {
|
|
540
|
+
return [
|
|
541
|
+
...new Set(
|
|
542
|
+
Object.keys(netlist.components)
|
|
543
|
+
.map((refdes) =>
|
|
544
|
+
LoadedDesignNetlistService.#refdesPrefix(refdes)
|
|
545
|
+
)
|
|
546
|
+
.filter(Boolean)
|
|
547
|
+
)
|
|
548
|
+
].sort()
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Resolves an exact net name case-insensitively.
|
|
553
|
+
* @param {{ nets: object }} netlist Query netlist.
|
|
554
|
+
* @param {string | undefined} netName Requested net.
|
|
555
|
+
* @returns {string}
|
|
556
|
+
*/
|
|
557
|
+
#resolveNetName(netlist, netName) {
|
|
558
|
+
const requested = String(netName || '')
|
|
559
|
+
.trim()
|
|
560
|
+
.toLowerCase()
|
|
561
|
+
return (
|
|
562
|
+
Object.keys(netlist.nets).find((candidate) => {
|
|
563
|
+
return candidate.toLowerCase() === requested
|
|
564
|
+
}) || ''
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Normalizes a loaded document entry.
|
|
570
|
+
* @param {object} entry Raw entry.
|
|
571
|
+
* @returns {object}
|
|
572
|
+
*/
|
|
573
|
+
static #normalizeEntry(entry) {
|
|
574
|
+
const documentModel = entry?.documentModel || entry
|
|
575
|
+
return {
|
|
576
|
+
id: String(entry?.id || documentModel?.id || ''),
|
|
577
|
+
active: entry?.active === true,
|
|
578
|
+
documentModel
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Returns one component query response from an entry.
|
|
584
|
+
* @param {[string, object]} entry Component entry.
|
|
585
|
+
* @returns {object}
|
|
586
|
+
*/
|
|
587
|
+
static #componentDetails(entry) {
|
|
588
|
+
const [refdes, component] = entry
|
|
589
|
+
const dns = ComponentGrouping.isDnsComponent(component)
|
|
590
|
+
const result = {
|
|
591
|
+
refdes,
|
|
592
|
+
mpn: LoadedDesignNetlistService.#trim(component.mpn),
|
|
593
|
+
description: LoadedDesignNetlistService.#trim(
|
|
594
|
+
component.description
|
|
595
|
+
),
|
|
596
|
+
comment: LoadedDesignNetlistService.#trim(component.comment),
|
|
597
|
+
value: LoadedDesignNetlistService.#trim(component.value),
|
|
598
|
+
dns: dns || undefined,
|
|
599
|
+
pins: component.pins || {},
|
|
600
|
+
notes: component.mpn ? undefined : [MPN_MISSING_NOTE]
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return LoadedDesignNetlistService.#withoutUndefined(result)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Returns the searchable field value for a component.
|
|
608
|
+
* @param {string} refdes Component refdes.
|
|
609
|
+
* @param {object} component Component metadata.
|
|
610
|
+
* @param {'refdes' | 'mpn' | 'description'} field Search field.
|
|
611
|
+
* @returns {string}
|
|
612
|
+
*/
|
|
613
|
+
static #searchValue(refdes, component, field) {
|
|
614
|
+
if (field === 'refdes') return String(refdes || '')
|
|
615
|
+
return String(component?.[field] || '')
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Returns true when an entry matches a design selector.
|
|
620
|
+
* @param {object} entry Loaded entry.
|
|
621
|
+
* @param {string} selector Selector.
|
|
622
|
+
* @returns {boolean}
|
|
623
|
+
*/
|
|
624
|
+
static #entryMatchesSelector(entry, selector) {
|
|
625
|
+
const normalized = selector.toLowerCase()
|
|
626
|
+
return (
|
|
627
|
+
entry.id.toLowerCase() === normalized ||
|
|
628
|
+
entry.fileName.toLowerCase() === normalized ||
|
|
629
|
+
entry.baseName.toLowerCase() === normalized
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Parses a `REFDES.PIN` pin spec.
|
|
635
|
+
* @param {string | undefined} value Pin spec.
|
|
636
|
+
* @returns {{ refdes: string, pin: string } | { error: string }}
|
|
637
|
+
*/
|
|
638
|
+
static #parsePinSpec(value) {
|
|
639
|
+
const raw = String(value || '').trim()
|
|
640
|
+
const separator = raw.indexOf('.')
|
|
641
|
+
if (separator <= 0 || separator === raw.length - 1) {
|
|
642
|
+
return {
|
|
643
|
+
error: "Invalid pin name '" + raw + "'. Expected 'REFDES.PIN'."
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
refdes: raw.slice(0, separator),
|
|
649
|
+
pin: raw.slice(separator + 1)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Extracts a net name from a pin entry.
|
|
655
|
+
* @param {string | { net?: string }} entry Pin entry.
|
|
656
|
+
* @returns {string}
|
|
657
|
+
*/
|
|
658
|
+
static #pinNet(entry) {
|
|
659
|
+
return typeof entry === 'string' ? entry : String(entry?.net || '')
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Counts unique component orientation configurations.
|
|
664
|
+
* @param {object[]} groups Aggregated groups.
|
|
665
|
+
* @returns {number}
|
|
666
|
+
*/
|
|
667
|
+
static #uniqueConfigurations(groups) {
|
|
668
|
+
return (groups || []).reduce((count, group) => {
|
|
669
|
+
return (
|
|
670
|
+
count +
|
|
671
|
+
(Array.isArray(group.orientations)
|
|
672
|
+
? group.orientations.length
|
|
673
|
+
: 1)
|
|
674
|
+
)
|
|
675
|
+
}, 0)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Resolves a file base name.
|
|
680
|
+
* @param {string | undefined} fileName File name.
|
|
681
|
+
* @returns {string}
|
|
682
|
+
*/
|
|
683
|
+
static #baseName(fileName) {
|
|
684
|
+
return String(fileName || '').replace(/\.[^.]+$/, '')
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Resolves a reference-designator prefix.
|
|
689
|
+
* @param {string} refdes Reference designator.
|
|
690
|
+
* @returns {string}
|
|
691
|
+
*/
|
|
692
|
+
static #refdesPrefix(refdes) {
|
|
693
|
+
return (
|
|
694
|
+
String(refdes || '')
|
|
695
|
+
.match(/^[A-Za-z]+/)?.[0]
|
|
696
|
+
?.toUpperCase() || ''
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Resolves a max-results limit.
|
|
702
|
+
* @param {unknown} value Raw value.
|
|
703
|
+
* @returns {number}
|
|
704
|
+
*/
|
|
705
|
+
static #maxResults(value) {
|
|
706
|
+
const parsed = Number.parseInt(String(value || ''), 10)
|
|
707
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 50
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Returns a trimmed string or undefined.
|
|
712
|
+
* @param {unknown} value Raw value.
|
|
713
|
+
* @returns {string | undefined}
|
|
714
|
+
*/
|
|
715
|
+
static #trim(value) {
|
|
716
|
+
const trimmed = String(value || '').trim()
|
|
717
|
+
return trimmed || undefined
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Removes undefined values from an object.
|
|
722
|
+
* @param {object} value Object value.
|
|
723
|
+
* @returns {object}
|
|
724
|
+
*/
|
|
725
|
+
static #withoutUndefined(value) {
|
|
726
|
+
return Object.fromEntries(
|
|
727
|
+
Object.entries(value).filter(([, entryValue]) => {
|
|
728
|
+
return entryValue !== undefined
|
|
729
|
+
})
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
}
|