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.
- package/docs/model-format.md +2 -3
- package/package.json +1 -1
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
- package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +82 -3
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +470 -20
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
- package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
- package/src/ui/PcbScene3dPackages.mjs +15 -3
- package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +335 -29
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
|
@@ -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
|
+
}
|