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.
@@ -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
+ }