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.
- 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,354 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
const MILS_PER_MM = 39.37007874015748
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared primitive conversions for Circuit JSON adapters.
|
|
9
|
+
*/
|
|
10
|
+
export class CircuitJsonModelAdapterPrimitives {
|
|
11
|
+
/**
|
|
12
|
+
* Returns a deterministic id scope for one parsed file.
|
|
13
|
+
* @param {Record<string, unknown>} model
|
|
14
|
+
* @param {string} sourceFormat
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
static idScope(model, sourceFormat) {
|
|
18
|
+
return CircuitJsonModelAdapterPrimitives.id('', [
|
|
19
|
+
sourceFormat,
|
|
20
|
+
model.fileType || 'document',
|
|
21
|
+
model.fileName || model.summary?.title || 'untitled'
|
|
22
|
+
])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns a deterministic Circuit JSON id.
|
|
27
|
+
* @param {string} scope
|
|
28
|
+
* @param {unknown[]} parts
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
static id(scope, parts) {
|
|
32
|
+
const idParts = [scope, ...parts]
|
|
33
|
+
.filter(
|
|
34
|
+
(part) => part !== undefined && part !== null && part !== ''
|
|
35
|
+
)
|
|
36
|
+
.map((part) => CircuitJsonModelAdapterPrimitives.#idPart(part))
|
|
37
|
+
|
|
38
|
+
return ['cj', ...idParts].filter(Boolean).join('_')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns a string value.
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @param {string} fallback
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
static string(value, fallback) {
|
|
48
|
+
const text = String(value ?? '').trim()
|
|
49
|
+
return text || fallback
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns a finite number value.
|
|
54
|
+
* @param {unknown} value
|
|
55
|
+
* @param {number | null} fallback
|
|
56
|
+
* @returns {number | null}
|
|
57
|
+
*/
|
|
58
|
+
static number(value, fallback) {
|
|
59
|
+
const numeric = Number(value)
|
|
60
|
+
return Number.isFinite(numeric) ? numeric : fallback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Converts a mil value to millimeters.
|
|
65
|
+
* @param {unknown} value
|
|
66
|
+
* @param {number} fallback
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
static milNumber(value, fallback) {
|
|
70
|
+
return CircuitJsonModelAdapterPrimitives.round(
|
|
71
|
+
(CircuitJsonModelAdapterPrimitives.number(value, fallback) || 0) /
|
|
72
|
+
MILS_PER_MM
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns an unscaled point.
|
|
78
|
+
* @param {unknown} x
|
|
79
|
+
* @param {unknown} y
|
|
80
|
+
* @returns {{ x: number, y: number }}
|
|
81
|
+
*/
|
|
82
|
+
static point(x, y) {
|
|
83
|
+
return {
|
|
84
|
+
x: CircuitJsonModelAdapterPrimitives.round(
|
|
85
|
+
CircuitJsonModelAdapterPrimitives.number(x, 0) || 0
|
|
86
|
+
),
|
|
87
|
+
y: CircuitJsonModelAdapterPrimitives.round(
|
|
88
|
+
CircuitJsonModelAdapterPrimitives.number(y, 0) || 0
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns a mil-scaled point.
|
|
95
|
+
* @param {unknown} x
|
|
96
|
+
* @param {unknown} y
|
|
97
|
+
* @returns {{ x: number, y: number }}
|
|
98
|
+
*/
|
|
99
|
+
static milPoint(x, y) {
|
|
100
|
+
return {
|
|
101
|
+
x: CircuitJsonModelAdapterPrimitives.milNumber(x, 0),
|
|
102
|
+
y: CircuitJsonModelAdapterPrimitives.milNumber(y, 0)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rounds model coordinates to a stable precision.
|
|
108
|
+
* @param {number} value
|
|
109
|
+
* @returns {number}
|
|
110
|
+
*/
|
|
111
|
+
static round(value) {
|
|
112
|
+
return Math.round(value * 1_000_000) / 1_000_000
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns array values only.
|
|
117
|
+
* @param {unknown} value
|
|
118
|
+
* @returns {unknown[]}
|
|
119
|
+
*/
|
|
120
|
+
static array(value) {
|
|
121
|
+
return Array.isArray(value) ? value : []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Infers the source format label.
|
|
126
|
+
* @param {Record<string, unknown>} model
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
static sourceFormat(model) {
|
|
130
|
+
if (model.sourceFormat) return String(model.sourceFormat)
|
|
131
|
+
if (
|
|
132
|
+
String(model.fileType || '')
|
|
133
|
+
.toLowerCase()
|
|
134
|
+
.includes('kicad')
|
|
135
|
+
) {
|
|
136
|
+
return 'KiCad'
|
|
137
|
+
}
|
|
138
|
+
return 'Altium Designer'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns a component map key.
|
|
143
|
+
* @param {Record<string, unknown>} component
|
|
144
|
+
* @param {number} componentIndex
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
static componentKey(component, componentIndex) {
|
|
148
|
+
return String(component.componentIndex ?? componentIndex)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns a source port id.
|
|
153
|
+
* @param {string} idScope
|
|
154
|
+
* @param {Record<string, unknown>} primitive
|
|
155
|
+
* @param {number} index
|
|
156
|
+
* @param {string} sourceComponentId
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
static sourcePortId(idScope, primitive, index, sourceComponentId) {
|
|
160
|
+
return CircuitJsonModelAdapterPrimitives.id(idScope, [
|
|
161
|
+
'source_port',
|
|
162
|
+
sourceComponentId,
|
|
163
|
+
primitive.name || primitive.pinName || primitive.pinNumber || index
|
|
164
|
+
])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns a source net id.
|
|
169
|
+
* @param {string} idScope
|
|
170
|
+
* @param {unknown} netName
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
static sourceNetId(idScope, netName) {
|
|
174
|
+
return CircuitJsonModelAdapterPrimitives.id(idScope, [
|
|
175
|
+
'source_net',
|
|
176
|
+
netName
|
|
177
|
+
])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Returns or creates a source net id for a PCB primitive.
|
|
182
|
+
* @param {string} idScope
|
|
183
|
+
* @param {Record<string, unknown>} primitive
|
|
184
|
+
* @param {Map<string, string>} sourceNetIds
|
|
185
|
+
* @returns {string | undefined}
|
|
186
|
+
*/
|
|
187
|
+
static netIdForPrimitive(idScope, primitive, sourceNetIds) {
|
|
188
|
+
const key = String(
|
|
189
|
+
primitive.netName || primitive.net || primitive.netIndex || ''
|
|
190
|
+
)
|
|
191
|
+
if (!key) return undefined
|
|
192
|
+
if (!sourceNetIds.has(key)) {
|
|
193
|
+
sourceNetIds.set(
|
|
194
|
+
key,
|
|
195
|
+
CircuitJsonModelAdapterPrimitives.sourceNetId(idScope, key)
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
return sourceNetIds.get(key)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns true when a PCB pad has a drill hole.
|
|
203
|
+
* @param {Record<string, unknown>} pad
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
*/
|
|
206
|
+
static isThroughHolePad(pad) {
|
|
207
|
+
return (
|
|
208
|
+
(CircuitJsonModelAdapterPrimitives.number(pad.holeDiameter, 0) ||
|
|
209
|
+
0) > 0
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Returns a Circuit JSON pad shape label.
|
|
215
|
+
* @param {Record<string, unknown>} pad
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
static padShape(pad) {
|
|
219
|
+
const shape = String(
|
|
220
|
+
pad.shapeTopName || pad.shapeName || pad.shape || ''
|
|
221
|
+
).toLowerCase()
|
|
222
|
+
if (shape.includes('round') || shape.includes('circle')) return 'circle'
|
|
223
|
+
if (shape.includes('oval')) return 'pill'
|
|
224
|
+
return 'rect'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns a normalized board side.
|
|
229
|
+
* @param {unknown} layer
|
|
230
|
+
* @returns {'top' | 'bottom'}
|
|
231
|
+
*/
|
|
232
|
+
static side(layer) {
|
|
233
|
+
return String(layer || '')
|
|
234
|
+
.toLowerCase()
|
|
235
|
+
.includes('bottom')
|
|
236
|
+
? 'bottom'
|
|
237
|
+
: 'top'
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Returns a normalized copper layer name.
|
|
242
|
+
* @param {Record<string, unknown>} primitive
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
static layerName(primitive) {
|
|
246
|
+
if (primitive.layerName) return String(primitive.layerName)
|
|
247
|
+
if (primitive.layer) return String(primitive.layer).toLowerCase()
|
|
248
|
+
if (primitive.layerId === 1) return 'top'
|
|
249
|
+
if (primitive.layerId === 32) return 'bottom'
|
|
250
|
+
return 'top'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Returns schematic port facing direction.
|
|
255
|
+
* @param {Record<string, unknown>} pin
|
|
256
|
+
* @returns {string | null}
|
|
257
|
+
*/
|
|
258
|
+
static facingDirection(pin) {
|
|
259
|
+
const orientation = String(pin.orientation || '').toLowerCase()
|
|
260
|
+
if (['left', 'right', 'up', 'down'].includes(orientation)) {
|
|
261
|
+
return orientation
|
|
262
|
+
}
|
|
263
|
+
if (orientation === 'top') return 'up'
|
|
264
|
+
if (orientation === 'bottom') return 'down'
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Returns true when a schematic text represents a net label.
|
|
270
|
+
* @param {Record<string, unknown>} text
|
|
271
|
+
* @returns {boolean}
|
|
272
|
+
*/
|
|
273
|
+
static isNetLabel(text) {
|
|
274
|
+
const role = String(text.role || text.kind || text.recordType || '')
|
|
275
|
+
.toLowerCase()
|
|
276
|
+
.trim()
|
|
277
|
+
return (
|
|
278
|
+
role.includes('net') ||
|
|
279
|
+
role.includes('label') ||
|
|
280
|
+
role.includes('power')
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Converts a renderer outline to Circuit JSON points.
|
|
286
|
+
* @param {Record<string, unknown> | undefined} boardOutline
|
|
287
|
+
* @returns {{ x: number, y: number }[]}
|
|
288
|
+
*/
|
|
289
|
+
static outlinePoints(boardOutline) {
|
|
290
|
+
const segments = CircuitJsonModelAdapterPrimitives.array(
|
|
291
|
+
boardOutline?.segments
|
|
292
|
+
)
|
|
293
|
+
if (segments.length > 0) {
|
|
294
|
+
const points = segments.map((segment) =>
|
|
295
|
+
CircuitJsonModelAdapterPrimitives.milPoint(
|
|
296
|
+
segment.x1,
|
|
297
|
+
segment.y1
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
const last = segments[segments.length - 1]
|
|
301
|
+
points.push(
|
|
302
|
+
CircuitJsonModelAdapterPrimitives.milPoint(last.x2, last.y2)
|
|
303
|
+
)
|
|
304
|
+
return points
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const minX =
|
|
308
|
+
CircuitJsonModelAdapterPrimitives.number(boardOutline?.minX, 0) || 0
|
|
309
|
+
const minY =
|
|
310
|
+
CircuitJsonModelAdapterPrimitives.number(boardOutline?.minY, 0) || 0
|
|
311
|
+
const width =
|
|
312
|
+
CircuitJsonModelAdapterPrimitives.number(
|
|
313
|
+
boardOutline?.widthMil,
|
|
314
|
+
0
|
|
315
|
+
) || 0
|
|
316
|
+
const height =
|
|
317
|
+
CircuitJsonModelAdapterPrimitives.number(
|
|
318
|
+
boardOutline?.heightMil,
|
|
319
|
+
0
|
|
320
|
+
) || 0
|
|
321
|
+
|
|
322
|
+
return [
|
|
323
|
+
CircuitJsonModelAdapterPrimitives.milPoint(minX, minY),
|
|
324
|
+
CircuitJsonModelAdapterPrimitives.milPoint(minX + width, minY),
|
|
325
|
+
CircuitJsonModelAdapterPrimitives.milPoint(
|
|
326
|
+
minX + width,
|
|
327
|
+
minY + height
|
|
328
|
+
),
|
|
329
|
+
CircuitJsonModelAdapterPrimitives.milPoint(minX, minY + height)
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Strips the extension from a file name.
|
|
335
|
+
* @param {unknown} fileName
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
static stripExtension(fileName) {
|
|
339
|
+
return String(fileName || '').replace(/\.[^.]+$/u, '')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Normalizes one id part.
|
|
344
|
+
* @param {unknown} value
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
static #idPart(value) {
|
|
348
|
+
return String(value)
|
|
349
|
+
.trim()
|
|
350
|
+
.toLowerCase()
|
|
351
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
352
|
+
.replace(/^_+|_+$/g, '')
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Defines the Circuit JSON contract emitted by public parser roots.
|
|
7
|
+
*/
|
|
8
|
+
export class CircuitJsonModelSchema {
|
|
9
|
+
static CURRENT_SCHEMA_ID = 'https://github.com/tscircuit/circuit-json'
|
|
10
|
+
|
|
11
|
+
static CURRENT_SCHEMA_VERSION = '0.0.431'
|
|
12
|
+
|
|
13
|
+
static FORMAT_NAME = 'circuit-json'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Marks a Circuit JSON array with non-serialized schema metadata.
|
|
17
|
+
* @template {object[]} T
|
|
18
|
+
* @param {T} circuitJson
|
|
19
|
+
* @returns {T}
|
|
20
|
+
*/
|
|
21
|
+
static attach(circuitJson) {
|
|
22
|
+
CircuitJsonModelSchema.assertModel(circuitJson)
|
|
23
|
+
Object.defineProperties(circuitJson, {
|
|
24
|
+
circuitJsonSchema: {
|
|
25
|
+
configurable: true,
|
|
26
|
+
enumerable: false,
|
|
27
|
+
value: CircuitJsonModelSchema.CURRENT_SCHEMA_ID,
|
|
28
|
+
writable: true
|
|
29
|
+
},
|
|
30
|
+
circuitJsonVersion: {
|
|
31
|
+
configurable: true,
|
|
32
|
+
enumerable: false,
|
|
33
|
+
value: CircuitJsonModelSchema.CURRENT_SCHEMA_VERSION,
|
|
34
|
+
writable: true
|
|
35
|
+
},
|
|
36
|
+
circuitJsonFormat: {
|
|
37
|
+
configurable: true,
|
|
38
|
+
enumerable: false,
|
|
39
|
+
value: CircuitJsonModelSchema.FORMAT_NAME,
|
|
40
|
+
writable: true
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return circuitJson
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns true when the value is a Circuit JSON element.
|
|
49
|
+
* @param {unknown} value
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
static isElement(value) {
|
|
53
|
+
return (
|
|
54
|
+
!!value &&
|
|
55
|
+
typeof value === 'object' &&
|
|
56
|
+
typeof value.type === 'string' &&
|
|
57
|
+
value.type.length > 0
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns true when the value is a Circuit JSON model array.
|
|
63
|
+
* @param {unknown} value
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
static isModel(value) {
|
|
67
|
+
return (
|
|
68
|
+
Array.isArray(value) &&
|
|
69
|
+
value.every((element) => CircuitJsonModelSchema.isElement(element))
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Throws when a value is not a Circuit JSON model array.
|
|
75
|
+
* @param {unknown} value
|
|
76
|
+
* @returns {void}
|
|
77
|
+
*/
|
|
78
|
+
static assertModel(value) {
|
|
79
|
+
if (!CircuitJsonModelSchema.isModel(value)) {
|
|
80
|
+
throw new TypeError('Expected a Circuit JSON element array.')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|