altium-toolkit 1.0.2 → 1.0.8
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 +1 -1
- package/src/core/altium/AltiumParser.mjs +17 -6
- package/src/core/altium/AsciiRecordParser.mjs +28 -1
- package/src/core/altium/SchematicPinDesignatorInferer.mjs +36 -3
- package/src/core/altium/SchematicPinParser.mjs +139 -14
- package/src/core/altium/SchematicPrimitiveParser.mjs +75 -4
- package/src/core/altium/SchematicTextParser.mjs +22 -18
- package/src/core/altium/SchematicTextPostProcessor.mjs +127 -10
- package/src/core/altium/SchematicWireNormalizer.mjs +1162 -0
- package/src/renderers.mjs +3 -0
- package/src/styles/altium-renderers.css +6 -0
- package/src/ui/PcbInteractionGeometry.mjs +350 -0
- package/src/ui/PcbInteractionIndex.mjs +588 -0
- package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
- package/src/ui/PcbInteractionLayerModel.mjs +99 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
- package/src/ui/PcbScene3dBuilder.mjs +32 -4
- package/src/ui/PcbSvgRenderer.mjs +2 -2
- package/src/ui/PcbTextPrimitiveRenderer.mjs +58 -2
- package/src/ui/SchematicContentLayout.mjs +124 -22
- package/src/ui/SchematicJunctionRenderer.mjs +21 -3
- package/src/ui/SchematicNoteRenderer.mjs +75 -10
- package/src/ui/SchematicPinSvgRenderer.mjs +48 -7
- package/src/ui/SchematicPowerPortRenderer.mjs +53 -160
- package/src/ui/SchematicSheetChromeRenderer.mjs +29 -15
- package/src/ui/SchematicSvgRenderer.mjs +341 -39
- package/src/ui/SchematicSvgUtils.mjs +9 -2
- package/src/ui/SchematicTypography.mjs +13 -10
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbInteractionGeometry } from './PcbInteractionGeometry.mjs'
|
|
6
|
+
import { PcbInteractionItemRegistry } from './PcbInteractionItemRegistry.mjs'
|
|
7
|
+
|
|
8
|
+
const TYPE_PRIORITY = {
|
|
9
|
+
track: 100,
|
|
10
|
+
pad: 90,
|
|
11
|
+
via: 80,
|
|
12
|
+
component: 50,
|
|
13
|
+
text: 30,
|
|
14
|
+
zone: 10
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PLURAL_TYPE_KEYS = {
|
|
18
|
+
component: 'components',
|
|
19
|
+
pad: 'pads',
|
|
20
|
+
text: 'footprint-text',
|
|
21
|
+
track: 'tracks',
|
|
22
|
+
via: 'vias',
|
|
23
|
+
zone: 'zones'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds and queries selectable PCB items.
|
|
28
|
+
*/
|
|
29
|
+
export class PcbInteractionIndex {
|
|
30
|
+
/**
|
|
31
|
+
* Builds all selectable items for a PCB document.
|
|
32
|
+
* @param {object} documentModel Toolkit document model.
|
|
33
|
+
* @returns {object[]}
|
|
34
|
+
*/
|
|
35
|
+
static build(documentModel) {
|
|
36
|
+
const pcb = documentModel?.pcb || {}
|
|
37
|
+
const context = PcbInteractionIndex.#context(pcb)
|
|
38
|
+
|
|
39
|
+
return PcbInteractionIndex.#defaultRegistry()
|
|
40
|
+
.extract(documentModel, context)
|
|
41
|
+
.map((item, index) =>
|
|
42
|
+
PcbInteractionIndex.#normalizeItem(item, index)
|
|
43
|
+
)
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns all hit candidates at the requested point.
|
|
49
|
+
* @param {object} documentModel Toolkit document model.
|
|
50
|
+
* @param {{ x?: unknown, y?: unknown }} point Hit-test point.
|
|
51
|
+
* @param {object} [options] Hit-test options.
|
|
52
|
+
* @returns {object[]}
|
|
53
|
+
*/
|
|
54
|
+
static hitTest(documentModel, point, options = {}) {
|
|
55
|
+
return PcbInteractionIndex.build(documentModel)
|
|
56
|
+
.filter((item) =>
|
|
57
|
+
PcbInteractionIndex.#isVisibleCandidate(item, options)
|
|
58
|
+
)
|
|
59
|
+
.filter((item) =>
|
|
60
|
+
PcbInteractionGeometry.containsPoint(
|
|
61
|
+
item.geometry,
|
|
62
|
+
point,
|
|
63
|
+
Number(options?.tolerance) || 0
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
.sort(PcbInteractionIndex.#compareCandidates)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Picks the highest-priority candidate at the requested point.
|
|
71
|
+
* @param {object} documentModel Toolkit document model.
|
|
72
|
+
* @param {{ x?: unknown, y?: unknown }} point Hit-test point.
|
|
73
|
+
* @param {object} [options] Hit-test options.
|
|
74
|
+
* @returns {object | null}
|
|
75
|
+
*/
|
|
76
|
+
static pick(documentModel, point, options = {}) {
|
|
77
|
+
return (
|
|
78
|
+
PcbInteractionIndex.hitTest(documentModel, point, options)[0] ||
|
|
79
|
+
null
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates extraction context for one PCB model.
|
|
85
|
+
* @param {object} pcb PCB model.
|
|
86
|
+
* @returns {object}
|
|
87
|
+
*/
|
|
88
|
+
static #context(pcb) {
|
|
89
|
+
return {
|
|
90
|
+
components: Array.isArray(pcb.components) ? pcb.components : [],
|
|
91
|
+
layerNameFor: PcbInteractionIndex.#layerNameResolver(pcb)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Creates the default item extractor registry.
|
|
97
|
+
* @returns {PcbInteractionItemRegistry}
|
|
98
|
+
*/
|
|
99
|
+
static #defaultRegistry() {
|
|
100
|
+
return PcbInteractionItemRegistry.create()
|
|
101
|
+
.register('zones', PcbInteractionIndex.#extractZones)
|
|
102
|
+
.register('tracks', PcbInteractionIndex.#extractTracks)
|
|
103
|
+
.register('pads', PcbInteractionIndex.#extractPads)
|
|
104
|
+
.register('vias', PcbInteractionIndex.#extractVias)
|
|
105
|
+
.register('components', PcbInteractionIndex.#extractComponents)
|
|
106
|
+
.register('footprint-text', PcbInteractionIndex.#extractTexts)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extracts selectable copper zones.
|
|
111
|
+
* @param {object} documentModel Toolkit document model.
|
|
112
|
+
* @param {object} context Extraction context.
|
|
113
|
+
* @returns {object[]}
|
|
114
|
+
*/
|
|
115
|
+
static #extractZones(documentModel, context) {
|
|
116
|
+
const pcb = documentModel?.pcb || {}
|
|
117
|
+
const zones = [
|
|
118
|
+
...(Array.isArray(pcb.regions) ? pcb.regions : []),
|
|
119
|
+
...(Array.isArray(pcb.shapeBasedRegions)
|
|
120
|
+
? pcb.shapeBasedRegions
|
|
121
|
+
: []),
|
|
122
|
+
...(Array.isArray(pcb.polygons) ? pcb.polygons : [])
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
return zones
|
|
126
|
+
.map((zone, index) => {
|
|
127
|
+
const geometry = PcbInteractionIndex.#zoneGeometry(zone)
|
|
128
|
+
if (!geometry) return null
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
id: PcbInteractionIndex.#itemId('zone', zone, index),
|
|
132
|
+
type: 'zone',
|
|
133
|
+
label: PcbInteractionIndex.#label('Zone', index),
|
|
134
|
+
layerKeys: PcbInteractionIndex.#layerKeys(zone, context),
|
|
135
|
+
netName: PcbInteractionIndex.#netName(zone),
|
|
136
|
+
side: PcbInteractionIndex.#sideForLayer(zone, context),
|
|
137
|
+
geometry,
|
|
138
|
+
source: zone
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extracts selectable tracks.
|
|
146
|
+
* @param {object} documentModel Toolkit document model.
|
|
147
|
+
* @param {object} context Extraction context.
|
|
148
|
+
* @returns {object[]}
|
|
149
|
+
*/
|
|
150
|
+
static #extractTracks(documentModel, context) {
|
|
151
|
+
const tracks = Array.isArray(documentModel?.pcb?.tracks)
|
|
152
|
+
? documentModel.pcb.tracks
|
|
153
|
+
: []
|
|
154
|
+
|
|
155
|
+
return tracks.map((track, index) => ({
|
|
156
|
+
id: PcbInteractionIndex.#itemId('track', track, index),
|
|
157
|
+
type: 'track',
|
|
158
|
+
label: PcbInteractionIndex.#label('Track', index),
|
|
159
|
+
layerKeys: PcbInteractionIndex.#layerKeys(track, context),
|
|
160
|
+
netName: PcbInteractionIndex.#netName(track),
|
|
161
|
+
side: PcbInteractionIndex.#sideForLayer(track, context),
|
|
162
|
+
geometry: PcbInteractionGeometry.segment(
|
|
163
|
+
{ x: track.x1, y: track.y1 },
|
|
164
|
+
{ x: track.x2, y: track.y2 },
|
|
165
|
+
Math.max(0.5, Number(track.width) / 2 || 0.5)
|
|
166
|
+
),
|
|
167
|
+
source: track
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extracts selectable pads.
|
|
173
|
+
* @param {object} documentModel Toolkit document model.
|
|
174
|
+
* @param {object} context Extraction context.
|
|
175
|
+
* @returns {object[]}
|
|
176
|
+
*/
|
|
177
|
+
static #extractPads(documentModel, context) {
|
|
178
|
+
const pads = Array.isArray(documentModel?.pcb?.pads)
|
|
179
|
+
? documentModel.pcb.pads
|
|
180
|
+
: []
|
|
181
|
+
|
|
182
|
+
return pads.map((pad, index) => {
|
|
183
|
+
const component = PcbInteractionIndex.#componentForPad(pad, context)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
id: PcbInteractionIndex.#itemId('pad', pad, index),
|
|
187
|
+
type: 'pad',
|
|
188
|
+
label: PcbInteractionIndex.#padLabel(pad, index),
|
|
189
|
+
layerKeys: PcbInteractionIndex.#layerKeys(pad, context),
|
|
190
|
+
netName: PcbInteractionIndex.#netName(pad),
|
|
191
|
+
componentKey: PcbInteractionIndex.#componentKey(component),
|
|
192
|
+
componentId: PcbInteractionIndex.#componentKey(component),
|
|
193
|
+
side: PcbInteractionIndex.#sideForLayer(pad, context),
|
|
194
|
+
geometry: PcbInteractionGeometry.rotatedRectangle({
|
|
195
|
+
x: pad.x,
|
|
196
|
+
y: pad.y,
|
|
197
|
+
width: PcbInteractionIndex.#padWidth(pad),
|
|
198
|
+
height: PcbInteractionIndex.#padHeight(pad),
|
|
199
|
+
rotation: pad.rotation
|
|
200
|
+
}),
|
|
201
|
+
source: pad
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extracts selectable vias.
|
|
208
|
+
* @param {object} documentModel Toolkit document model.
|
|
209
|
+
* @returns {object[]}
|
|
210
|
+
*/
|
|
211
|
+
static #extractVias(documentModel) {
|
|
212
|
+
const vias = Array.isArray(documentModel?.pcb?.vias)
|
|
213
|
+
? documentModel.pcb.vias
|
|
214
|
+
: []
|
|
215
|
+
|
|
216
|
+
return vias.map((via, index) => ({
|
|
217
|
+
id: PcbInteractionIndex.#itemId('via', via, index),
|
|
218
|
+
type: 'via',
|
|
219
|
+
label: PcbInteractionIndex.#label('Via', index),
|
|
220
|
+
layerKeys: [],
|
|
221
|
+
netName: PcbInteractionIndex.#netName(via),
|
|
222
|
+
side: 'both',
|
|
223
|
+
geometry: PcbInteractionGeometry.circle(
|
|
224
|
+
{ x: via.x, y: via.y },
|
|
225
|
+
Math.max(0.5, Number(via.diameter) / 2 || 0.5)
|
|
226
|
+
),
|
|
227
|
+
source: via
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Extracts selectable component body estimates.
|
|
233
|
+
* @param {object} documentModel Toolkit document model.
|
|
234
|
+
* @returns {object[]}
|
|
235
|
+
*/
|
|
236
|
+
static #extractComponents(documentModel) {
|
|
237
|
+
const components = Array.isArray(documentModel?.pcb?.components)
|
|
238
|
+
? documentModel.pcb.components
|
|
239
|
+
: []
|
|
240
|
+
|
|
241
|
+
return components.map((component, index) => {
|
|
242
|
+
const size = PcbInteractionIndex.#componentSize(component)
|
|
243
|
+
return {
|
|
244
|
+
id: PcbInteractionIndex.#itemId('component', component, index),
|
|
245
|
+
type: 'component',
|
|
246
|
+
label: PcbInteractionIndex.#componentKey(component),
|
|
247
|
+
componentKey: PcbInteractionIndex.#componentKey(component),
|
|
248
|
+
componentId: PcbInteractionIndex.#componentKey(component),
|
|
249
|
+
layerKeys: [],
|
|
250
|
+
side:
|
|
251
|
+
String(component?.layer || '').toUpperCase() === 'BOTTOM'
|
|
252
|
+
? 'back'
|
|
253
|
+
: 'front',
|
|
254
|
+
geometry: PcbInteractionGeometry.rotatedRectangle({
|
|
255
|
+
x: component.x,
|
|
256
|
+
y: component.y,
|
|
257
|
+
width: size.width,
|
|
258
|
+
height: size.height,
|
|
259
|
+
rotation: component.rotation
|
|
260
|
+
}),
|
|
261
|
+
source: component
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Extracts selectable footprint text.
|
|
268
|
+
* @param {object} documentModel Toolkit document model.
|
|
269
|
+
* @param {object} context Extraction context.
|
|
270
|
+
* @returns {object[]}
|
|
271
|
+
*/
|
|
272
|
+
static #extractTexts(documentModel, context) {
|
|
273
|
+
const texts = Array.isArray(documentModel?.pcb?.texts)
|
|
274
|
+
? documentModel.pcb.texts
|
|
275
|
+
: []
|
|
276
|
+
|
|
277
|
+
return texts
|
|
278
|
+
.filter((text) => text?.visible !== false)
|
|
279
|
+
.map((text, index) => {
|
|
280
|
+
const component = PcbInteractionIndex.#componentForPad(
|
|
281
|
+
text,
|
|
282
|
+
context
|
|
283
|
+
)
|
|
284
|
+
const height = Math.max(1, Number(text?.height) || 1)
|
|
285
|
+
const width =
|
|
286
|
+
Math.max(1, String(text?.text || '').length) * height * 0.6
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
id: PcbInteractionIndex.#itemId('text', text, index),
|
|
290
|
+
type: 'text',
|
|
291
|
+
label: String(text?.text || 'Text'),
|
|
292
|
+
layerKeys: PcbInteractionIndex.#layerKeys(text, context),
|
|
293
|
+
componentKey: PcbInteractionIndex.#componentKey(component),
|
|
294
|
+
componentId: PcbInteractionIndex.#componentKey(component),
|
|
295
|
+
side: PcbInteractionIndex.#sideForLayer(text, context),
|
|
296
|
+
geometry: PcbInteractionGeometry.rotatedRectangle({
|
|
297
|
+
x: text.x,
|
|
298
|
+
y: text.y,
|
|
299
|
+
width,
|
|
300
|
+
height,
|
|
301
|
+
rotation: text.rotation
|
|
302
|
+
}),
|
|
303
|
+
source: text
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Builds geometry for a zone-like object.
|
|
310
|
+
* @param {object} zone Zone-like object.
|
|
311
|
+
* @returns {object | null}
|
|
312
|
+
*/
|
|
313
|
+
static #zoneGeometry(zone) {
|
|
314
|
+
if (Array.isArray(zone?.points) && zone.points.length >= 3) {
|
|
315
|
+
return PcbInteractionGeometry.polygon(zone.points)
|
|
316
|
+
}
|
|
317
|
+
if (Array.isArray(zone?.segments) && zone.segments.length >= 3) {
|
|
318
|
+
return PcbInteractionGeometry.polygon(
|
|
319
|
+
zone.segments.map((segment) => ({
|
|
320
|
+
x: segment.x1,
|
|
321
|
+
y: segment.y1
|
|
322
|
+
}))
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
Number.isFinite(Number(zone?.x1)) &&
|
|
327
|
+
Number.isFinite(Number(zone?.y1)) &&
|
|
328
|
+
Number.isFinite(Number(zone?.x2)) &&
|
|
329
|
+
Number.isFinite(Number(zone?.y2))
|
|
330
|
+
) {
|
|
331
|
+
return PcbInteractionGeometry.bounds({
|
|
332
|
+
minX: Math.min(Number(zone.x1), Number(zone.x2)),
|
|
333
|
+
minY: Math.min(Number(zone.y1), Number(zone.y2)),
|
|
334
|
+
maxX: Math.max(Number(zone.x1), Number(zone.x2)),
|
|
335
|
+
maxY: Math.max(Number(zone.y1), Number(zone.y2))
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Normalizes item metadata.
|
|
344
|
+
* @param {object | null} item Extracted item.
|
|
345
|
+
* @param {number} index Stable item order.
|
|
346
|
+
* @returns {object | null}
|
|
347
|
+
*/
|
|
348
|
+
static #normalizeItem(item, index) {
|
|
349
|
+
if (!item || typeof item !== 'object' || !item.geometry) return null
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
priority: TYPE_PRIORITY[item.type] || 0,
|
|
353
|
+
order: index,
|
|
354
|
+
bounds: PcbInteractionGeometry.boundsFor(item.geometry),
|
|
355
|
+
...item
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Returns whether an item is visible under hit-test filters.
|
|
361
|
+
* @param {object} item Interaction item.
|
|
362
|
+
* @param {object} options Hit-test options.
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
static #isVisibleCandidate(item, options) {
|
|
366
|
+
const side = PcbInteractionIndex.#normalizeSide(options?.side)
|
|
367
|
+
if (item.side !== 'both' && item.side !== side) return false
|
|
368
|
+
|
|
369
|
+
const hiddenObjects = new Set(
|
|
370
|
+
(Array.isArray(options?.hiddenObjects)
|
|
371
|
+
? options.hiddenObjects
|
|
372
|
+
: []
|
|
373
|
+
).map(String)
|
|
374
|
+
)
|
|
375
|
+
if (
|
|
376
|
+
hiddenObjects.has(item.objectKey) ||
|
|
377
|
+
hiddenObjects.has(PLURAL_TYPE_KEYS[item.type] || item.type)
|
|
378
|
+
) {
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const hiddenLayers = new Set(
|
|
383
|
+
(Array.isArray(options?.hiddenLayers)
|
|
384
|
+
? options.hiddenLayers
|
|
385
|
+
: []
|
|
386
|
+
).map(String)
|
|
387
|
+
)
|
|
388
|
+
return (
|
|
389
|
+
!item.layerKeys?.length ||
|
|
390
|
+
item.layerKeys.some((layerKey) => !hiddenLayers.has(layerKey))
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Compares candidates by priority and stable extraction order.
|
|
396
|
+
* @param {object} first First item.
|
|
397
|
+
* @param {object} second Second item.
|
|
398
|
+
* @returns {number}
|
|
399
|
+
*/
|
|
400
|
+
static #compareCandidates(first, second) {
|
|
401
|
+
return second.priority - first.priority || first.order - second.order
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Creates a layer-name resolver for layer-id based primitives.
|
|
406
|
+
* @param {object} pcb PCB model.
|
|
407
|
+
* @returns {(item: object) => string[]}
|
|
408
|
+
*/
|
|
409
|
+
static #layerNameResolver(pcb) {
|
|
410
|
+
const byId = new Map()
|
|
411
|
+
const layers = [
|
|
412
|
+
...(Array.isArray(pcb?.layers) ? pcb.layers : []),
|
|
413
|
+
...(Array.isArray(pcb?.primitiveLayers) ? pcb.primitiveLayers : [])
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
for (const layer of layers) {
|
|
417
|
+
const layerId = Number(layer?.layerId)
|
|
418
|
+
const name = String(layer?.name || '').trim()
|
|
419
|
+
if (Number.isInteger(layerId) && name) {
|
|
420
|
+
byId.set(layerId, name)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return (item) => {
|
|
425
|
+
const directLayer = String(item?.layer || '').trim()
|
|
426
|
+
if (directLayer) return [directLayer]
|
|
427
|
+
|
|
428
|
+
const layerId = Number(item?.layerId ?? item?.layerCode)
|
|
429
|
+
if (Number.isInteger(layerId) && byId.has(layerId)) {
|
|
430
|
+
return [byId.get(layerId)]
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return []
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Resolves physical layer keys for an item.
|
|
439
|
+
* @param {object} item Source item.
|
|
440
|
+
* @param {object} context Extraction context.
|
|
441
|
+
* @returns {string[]}
|
|
442
|
+
*/
|
|
443
|
+
static #layerKeys(item, context) {
|
|
444
|
+
return context.layerNameFor(item).filter(Boolean)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolves the board side from the primary layer.
|
|
449
|
+
* @param {object} item Source item.
|
|
450
|
+
* @param {object} context Extraction context.
|
|
451
|
+
* @returns {'front' | 'back' | 'both'}
|
|
452
|
+
*/
|
|
453
|
+
static #sideForLayer(item, context) {
|
|
454
|
+
const layerKey = PcbInteractionIndex.#layerKeys(item, context)[0] || ''
|
|
455
|
+
const normalized = layerKey.toLowerCase()
|
|
456
|
+
if (normalized.includes('bottom')) return 'back'
|
|
457
|
+
if (normalized.includes('top')) return 'front'
|
|
458
|
+
|
|
459
|
+
return 'both'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Resolves a component for a pad-like primitive.
|
|
464
|
+
* @param {object} primitive Primitive.
|
|
465
|
+
* @param {object} context Extraction context.
|
|
466
|
+
* @returns {object | null}
|
|
467
|
+
*/
|
|
468
|
+
static #componentForPad(primitive, context) {
|
|
469
|
+
const componentIndex = Number(primitive?.componentIndex)
|
|
470
|
+
if (!Number.isInteger(componentIndex)) return null
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
context.components.find(
|
|
474
|
+
(component, index) =>
|
|
475
|
+
Number(component?.componentIndex) === componentIndex ||
|
|
476
|
+
index === componentIndex
|
|
477
|
+
) || null
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Resolves a stable component key.
|
|
483
|
+
* @param {object | null} component Component.
|
|
484
|
+
* @returns {string}
|
|
485
|
+
*/
|
|
486
|
+
static #componentKey(component) {
|
|
487
|
+
return String(component?.designator || component?.id || '').trim()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Resolves a pad display label.
|
|
492
|
+
* @param {object} pad Pad.
|
|
493
|
+
* @param {number} index Pad index.
|
|
494
|
+
* @returns {string}
|
|
495
|
+
*/
|
|
496
|
+
static #padLabel(pad, index) {
|
|
497
|
+
const name = String(pad?.name || pad?.number || '').trim()
|
|
498
|
+
return name || PcbInteractionIndex.#label('Pad', index)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Resolves a net name from common primitive fields.
|
|
503
|
+
* @param {object} item Primitive.
|
|
504
|
+
* @returns {string}
|
|
505
|
+
*/
|
|
506
|
+
static #netName(item) {
|
|
507
|
+
return String(item?.netName || item?.net || '').trim()
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Resolves a pad width.
|
|
512
|
+
* @param {object} pad Pad.
|
|
513
|
+
* @returns {number}
|
|
514
|
+
*/
|
|
515
|
+
static #padWidth(pad) {
|
|
516
|
+
return Math.max(
|
|
517
|
+
1,
|
|
518
|
+
Number(pad?.sizeTopX) ||
|
|
519
|
+
Number(pad?.sizeMidX) ||
|
|
520
|
+
Number(pad?.sizeBottomX) ||
|
|
521
|
+
Number(pad?.width) ||
|
|
522
|
+
1
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Resolves a pad height.
|
|
528
|
+
* @param {object} pad Pad.
|
|
529
|
+
* @returns {number}
|
|
530
|
+
*/
|
|
531
|
+
static #padHeight(pad) {
|
|
532
|
+
return Math.max(
|
|
533
|
+
1,
|
|
534
|
+
Number(pad?.sizeTopY) ||
|
|
535
|
+
Number(pad?.sizeMidY) ||
|
|
536
|
+
Number(pad?.sizeBottomY) ||
|
|
537
|
+
Number(pad?.height) ||
|
|
538
|
+
PcbInteractionIndex.#padWidth(pad)
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Resolves a footprint body estimate for interaction.
|
|
544
|
+
* @param {object} component Component.
|
|
545
|
+
* @returns {{ width: number, height: number }}
|
|
546
|
+
*/
|
|
547
|
+
static #componentSize(component) {
|
|
548
|
+
const pattern = String(component?.pattern || '').toUpperCase()
|
|
549
|
+
if (pattern.includes('QFN') || pattern.includes('QFP')) {
|
|
550
|
+
return { width: 180, height: 180 }
|
|
551
|
+
}
|
|
552
|
+
if (pattern.includes('SOT')) return { width: 140, height: 90 }
|
|
553
|
+
if (pattern.includes('0805')) return { width: 92, height: 48 }
|
|
554
|
+
if (pattern.includes('0603')) return { width: 72, height: 36 }
|
|
555
|
+
if (pattern.includes('0402')) return { width: 52, height: 28 }
|
|
556
|
+
return { width: 96, height: 60 }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Builds a stable fallback id.
|
|
561
|
+
* @param {string} type Item type.
|
|
562
|
+
* @param {object} source Source primitive.
|
|
563
|
+
* @param {number} index Item index.
|
|
564
|
+
* @returns {string}
|
|
565
|
+
*/
|
|
566
|
+
static #itemId(type, source, index) {
|
|
567
|
+
return String(source?.id || source?.uuid || `${type}:${index}`)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Builds a fallback label.
|
|
572
|
+
* @param {string} base Label base.
|
|
573
|
+
* @param {number} index Item index.
|
|
574
|
+
* @returns {string}
|
|
575
|
+
*/
|
|
576
|
+
static #label(base, index) {
|
|
577
|
+
return `${base} ${index + 1}`
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Normalizes side input.
|
|
582
|
+
* @param {unknown} side Side input.
|
|
583
|
+
* @returns {'front' | 'back'}
|
|
584
|
+
*/
|
|
585
|
+
static #normalizeSide(side) {
|
|
586
|
+
return side === 'bottom' || side === 'back' ? 'back' : 'front'
|
|
587
|
+
}
|
|
588
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stores PCB interaction item extractors by rendered object group.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbInteractionItemRegistry {
|
|
9
|
+
/** @type {{ objectKey: string, extractor: (input: object, context: object) => object[] }[]} */
|
|
10
|
+
#entries
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates an empty registry.
|
|
14
|
+
*/
|
|
15
|
+
constructor() {
|
|
16
|
+
this.#entries = []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates an empty registry.
|
|
21
|
+
* @returns {PcbInteractionItemRegistry}
|
|
22
|
+
*/
|
|
23
|
+
static create() {
|
|
24
|
+
return new PcbInteractionItemRegistry()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adds an extractor for one object group.
|
|
29
|
+
* @param {string} objectKey Object visibility key.
|
|
30
|
+
* @param {(input: object, context: object) => object[]} extractor Extractor.
|
|
31
|
+
* @returns {PcbInteractionItemRegistry}
|
|
32
|
+
*/
|
|
33
|
+
register(objectKey, extractor) {
|
|
34
|
+
if (typeof extractor !== 'function') return this
|
|
35
|
+
this.#entries.push({
|
|
36
|
+
objectKey: String(objectKey || ''),
|
|
37
|
+
extractor
|
|
38
|
+
})
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extracts interaction items from all registered groups.
|
|
44
|
+
* @param {object} input Toolkit document model.
|
|
45
|
+
* @param {object} [context] Shared extraction context.
|
|
46
|
+
* @returns {object[]}
|
|
47
|
+
*/
|
|
48
|
+
extract(input, context = {}) {
|
|
49
|
+
const items = []
|
|
50
|
+
|
|
51
|
+
for (const entry of this.#entries) {
|
|
52
|
+
const extracted = entry.extractor(input, {
|
|
53
|
+
...context,
|
|
54
|
+
objectKey: entry.objectKey
|
|
55
|
+
})
|
|
56
|
+
for (const item of Array.isArray(extracted) ? extracted : []) {
|
|
57
|
+
items.push({
|
|
58
|
+
objectKey: entry.objectKey,
|
|
59
|
+
...item
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return items
|
|
65
|
+
}
|
|
66
|
+
}
|