altium-toolkit 1.1.33 → 1.1.35

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,358 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Assigns selectable keys to static 3D body placements.
7
+ */
8
+ export class PcbScene3dStaticBodySelectionKeyBuilder {
9
+ static #STATIC_SELECTION_CLUSTER_TOLERANCE_MIL = 20
10
+
11
+ /**
12
+ * Assigns stable selectable keys while preserving display designators.
13
+ * @param {{ placement: { designator: string, sourceIdentityKey?: string, mountSide?: string, rotationDeg?: number, positionMil?: { x?: number, y?: number }, geometry?: object }, matchedComponent: object | null }[]} placementRows Placement rows.
14
+ * @returns {{ designator: string, selectionKey: string }[]}
15
+ */
16
+ static assign(placementRows) {
17
+ const placements = placementRows.map((row) => ({ ...row.placement }))
18
+ const sourceGroups = new Map()
19
+
20
+ placementRows.forEach((row, index) => {
21
+ const groupKey =
22
+ PcbScene3dStaticBodySelectionKeyBuilder.#sourceGroupKey(
23
+ row.placement
24
+ )
25
+ if (!groupKey) {
26
+ placements[index].selectionKey =
27
+ PcbScene3dStaticBodySelectionKeyBuilder.#matchedDesignator(
28
+ row
29
+ ) || row.placement?.designator
30
+ return
31
+ }
32
+
33
+ if (!sourceGroups.has(groupKey)) {
34
+ sourceGroups.set(groupKey, [])
35
+ }
36
+ sourceGroups.get(groupKey)?.push(index)
37
+ })
38
+
39
+ sourceGroups.forEach((indexes) => {
40
+ const clusters =
41
+ PcbScene3dStaticBodySelectionKeyBuilder.#clusterPlacementIndexes(
42
+ placements,
43
+ indexes
44
+ )
45
+
46
+ if (clusters.length <= 1) {
47
+ const matchedDesignator =
48
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniqueMatchedDesignator(
49
+ placementRows,
50
+ indexes
51
+ )
52
+ indexes.forEach((index) => {
53
+ placements[index].selectionKey =
54
+ matchedDesignator || placements[index].designator
55
+ })
56
+ return
57
+ }
58
+
59
+ const usedKeys = new Set()
60
+ clusters
61
+ .sort((left, right) =>
62
+ PcbScene3dStaticBodySelectionKeyBuilder.#compareClusterCenters(
63
+ left,
64
+ right
65
+ )
66
+ )
67
+ .forEach((cluster) => {
68
+ const matchedDesignator =
69
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniqueMatchedDesignator(
70
+ placementRows,
71
+ cluster.indexes
72
+ )
73
+ const selectionKey =
74
+ matchedDesignator ||
75
+ PcbScene3dStaticBodySelectionKeyBuilder.#uniqueSelectionKey(
76
+ placements[cluster.indexes[0]],
77
+ cluster,
78
+ usedKeys
79
+ )
80
+ cluster.indexes.forEach((index) => {
81
+ placements[index].selectionKey = selectionKey
82
+ })
83
+ })
84
+ })
85
+
86
+ return placements
87
+ }
88
+
89
+ /**
90
+ * Builds the grouping key used to connect sibling static body shapes.
91
+ * @param {{ designator?: string, sourceIdentityKey?: string, mountSide?: string }} placement Static placement.
92
+ * @returns {string}
93
+ */
94
+ static #sourceGroupKey(placement) {
95
+ const sourceIdentity = String(
96
+ placement?.sourceIdentityKey || placement?.designator || ''
97
+ ).trim()
98
+ if (!sourceIdentity) {
99
+ return ''
100
+ }
101
+
102
+ return (
103
+ sourceIdentity +
104
+ '\u0000' +
105
+ String(placement?.mountSide || '').trim()
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Resolves the matched component designator from one placement row.
111
+ * @param {{ matchedComponent?: { designator?: string } | null }} row Placement row.
112
+ * @returns {string}
113
+ */
114
+ static #matchedDesignator(row) {
115
+ return String(row?.matchedComponent?.designator || '').trim()
116
+ }
117
+
118
+ /**
119
+ * Resolves one unambiguous matched owner for a static body cluster.
120
+ * @param {{ matchedComponent?: { designator?: string } | null }[]} placementRows Placement rows.
121
+ * @param {number[]} indexes Cluster indexes.
122
+ * @returns {string}
123
+ */
124
+ static #uniqueMatchedDesignator(placementRows, indexes) {
125
+ const matchedDesignators = new Set(
126
+ indexes
127
+ .map((index) =>
128
+ PcbScene3dStaticBodySelectionKeyBuilder.#matchedDesignator(
129
+ placementRows[index]
130
+ )
131
+ )
132
+ .filter(Boolean)
133
+ )
134
+
135
+ return matchedDesignators.size === 1 ? [...matchedDesignators][0] : ''
136
+ }
137
+
138
+ /**
139
+ * Clusters static body placements whose horizontal geometry touches.
140
+ * @param {object[]} placements Static body placements.
141
+ * @param {number[]} indexes Placement indexes in one display group.
142
+ * @returns {{ indexes: number[], bounds: { minX: number, minY: number, maxX: number, maxY: number }, center: { x: number, y: number } }[]}
143
+ */
144
+ static #clusterPlacementIndexes(placements, indexes) {
145
+ const clusters = []
146
+
147
+ indexes.forEach((index) => {
148
+ const bounds =
149
+ PcbScene3dStaticBodySelectionKeyBuilder.#placementBounds(
150
+ placements[index]
151
+ )
152
+ const matchingClusters = clusters.filter((cluster) =>
153
+ PcbScene3dStaticBodySelectionKeyBuilder.#boundsTouch(
154
+ cluster.bounds,
155
+ bounds,
156
+ PcbScene3dStaticBodySelectionKeyBuilder
157
+ .#STATIC_SELECTION_CLUSTER_TOLERANCE_MIL
158
+ )
159
+ )
160
+
161
+ if (!matchingClusters.length) {
162
+ clusters.push({
163
+ indexes: [index],
164
+ bounds,
165
+ center: PcbScene3dStaticBodySelectionKeyBuilder.#boundsCenter(
166
+ bounds
167
+ )
168
+ })
169
+ return
170
+ }
171
+
172
+ const targetCluster = matchingClusters[0]
173
+ targetCluster.indexes.push(index)
174
+ targetCluster.bounds =
175
+ PcbScene3dStaticBodySelectionKeyBuilder.#mergeBounds(
176
+ targetCluster.bounds,
177
+ bounds
178
+ )
179
+ targetCluster.center =
180
+ PcbScene3dStaticBodySelectionKeyBuilder.#boundsCenter(
181
+ targetCluster.bounds
182
+ )
183
+
184
+ matchingClusters.slice(1).forEach((cluster) => {
185
+ targetCluster.indexes.push(...cluster.indexes)
186
+ targetCluster.bounds =
187
+ PcbScene3dStaticBodySelectionKeyBuilder.#mergeBounds(
188
+ targetCluster.bounds,
189
+ cluster.bounds
190
+ )
191
+ targetCluster.center =
192
+ PcbScene3dStaticBodySelectionKeyBuilder.#boundsCenter(
193
+ targetCluster.bounds
194
+ )
195
+ clusters.splice(clusters.indexOf(cluster), 1)
196
+ })
197
+ })
198
+
199
+ return clusters
200
+ }
201
+
202
+ /**
203
+ * Builds an axis-aligned horizontal bounds record for one placement.
204
+ * @param {{ positionMil?: { x?: number, y?: number }, geometry?: object }} placement Static placement.
205
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
206
+ */
207
+ static #placementBounds(placement) {
208
+ const centerX = Number(placement?.positionMil?.x || 0)
209
+ const centerY = Number(placement?.positionMil?.y || 0)
210
+ const geometry = placement?.geometry || {}
211
+ const vertices = Array.isArray(geometry?.verticesMil)
212
+ ? geometry.verticesMil
213
+ : []
214
+
215
+ if (vertices.length) {
216
+ const xs = vertices.map((vertex) => Number(vertex?.x || 0))
217
+ const ys = vertices.map((vertex) => Number(vertex?.y || 0))
218
+
219
+ return {
220
+ minX: centerX + Math.min(...xs),
221
+ minY: centerY + Math.min(...ys),
222
+ maxX: centerX + Math.max(...xs),
223
+ maxY: centerY + Math.max(...ys)
224
+ }
225
+ }
226
+
227
+ const radius = Number(geometry?.radiusMil || 0)
228
+ const span = Number.isFinite(radius) && radius > 0 ? radius : 0
229
+
230
+ return {
231
+ minX: centerX - span,
232
+ minY: centerY - span,
233
+ maxX: centerX + span,
234
+ maxY: centerY + span
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Checks whether two bounds overlap or nearly touch.
240
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Bounds.
241
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Bounds.
242
+ * @param {number} toleranceMil Tolerance in mils.
243
+ * @returns {boolean}
244
+ */
245
+ static #boundsTouch(left, right, toleranceMil) {
246
+ const tolerance = Math.max(Number(toleranceMil || 0), 0)
247
+
248
+ return (
249
+ left.minX <= right.maxX + tolerance &&
250
+ left.maxX >= right.minX - tolerance &&
251
+ left.minY <= right.maxY + tolerance &&
252
+ left.maxY >= right.minY - tolerance
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Merges two bounds records.
258
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Bounds.
259
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Bounds.
260
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
261
+ */
262
+ static #mergeBounds(left, right) {
263
+ return {
264
+ minX: Math.min(left.minX, right.minX),
265
+ minY: Math.min(left.minY, right.minY),
266
+ maxX: Math.max(left.maxX, right.maxX),
267
+ maxY: Math.max(left.maxY, right.maxY)
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Resolves one bounds center.
273
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
274
+ * @returns {{ x: number, y: number }}
275
+ */
276
+ static #boundsCenter(bounds) {
277
+ return {
278
+ x: PcbScene3dStaticBodySelectionKeyBuilder.#roundMil(
279
+ (Number(bounds.minX || 0) + Number(bounds.maxX || 0)) / 2
280
+ ),
281
+ y: PcbScene3dStaticBodySelectionKeyBuilder.#roundMil(
282
+ (Number(bounds.minY || 0) + Number(bounds.maxY || 0)) / 2
283
+ )
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Sorts clusters by their stable center coordinates.
289
+ * @param {{ center: { x: number, y: number } }} left Left cluster.
290
+ * @param {{ center: { x: number, y: number } }} right Right cluster.
291
+ * @returns {number}
292
+ */
293
+ static #compareClusterCenters(left, right) {
294
+ return (
295
+ Number(left.center?.y || 0) - Number(right.center?.y || 0) ||
296
+ Number(left.center?.x || 0) - Number(right.center?.x || 0)
297
+ )
298
+ }
299
+
300
+ /**
301
+ * Builds a unique selection key for one static body cluster.
302
+ * @param {{ designator?: string, mountSide?: string }} placement Representative placement.
303
+ * @param {{ center: { x: number, y: number } }} cluster Static body cluster.
304
+ * @param {Set<string>} usedKeys Keys already assigned in this group.
305
+ * @returns {string}
306
+ */
307
+ static #uniqueSelectionKey(placement, cluster, usedKeys) {
308
+ const baseDesignator = String(
309
+ placement?.designator || 'static-body'
310
+ ).trim()
311
+ const mountSide = String(placement?.mountSide || 'top').trim() || 'top'
312
+ const baseKey =
313
+ baseDesignator +
314
+ '@' +
315
+ mountSide +
316
+ ':' +
317
+ PcbScene3dStaticBodySelectionKeyBuilder.#selectionCoordinate(
318
+ cluster.center?.x
319
+ ) +
320
+ ',' +
321
+ PcbScene3dStaticBodySelectionKeyBuilder.#selectionCoordinate(
322
+ cluster.center?.y
323
+ )
324
+ let selectionKey = baseKey
325
+ let suffix = 2
326
+
327
+ while (usedKeys.has(selectionKey)) {
328
+ selectionKey = baseKey + '#' + suffix
329
+ suffix += 1
330
+ }
331
+
332
+ usedKeys.add(selectionKey)
333
+ return selectionKey
334
+ }
335
+
336
+ /**
337
+ * Formats a scene coordinate for a static selection key.
338
+ * @param {number | undefined} value Coordinate in mils.
339
+ * @returns {string}
340
+ */
341
+ static #selectionCoordinate(value) {
342
+ return String(
343
+ PcbScene3dStaticBodySelectionKeyBuilder.#roundMil(
344
+ Number(value || 0)
345
+ )
346
+ )
347
+ }
348
+
349
+ /**
350
+ * Rounds one mil value for stable scene output.
351
+ * @param {number} value Candidate value.
352
+ * @returns {number}
353
+ */
354
+ static #roundMil(value) {
355
+ const rounded = Math.round(Number(value) * 10000) / 10000
356
+ return Object.is(rounded, -0) ? 0 : rounded
357
+ }
358
+ }