altium-toolkit 1.4.13 → 1.4.15
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/spec/altium-3d-late-owner-recovery.md +40 -0
- package/src/convergence/AltiumDocumentBuilder.mjs +4 -1
- package/src/convergence/AltiumOleInputTailNormalizer.mjs +541 -0
- package/src/convergence/PcbScene3dBuilder.mjs +5 -1
- package/src/ui/AltiumScene3dGeometricOwnerRecovery.mjs +948 -0
- package/src/ui/AltiumScene3dRecoverySpatialIndex.mjs +94 -0
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import { AltiumScene3dRecoverySpatialIndex } from './AltiumScene3dRecoverySpatialIndex.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recovers component ownership for Altium STEP bodies whose native rows omit
|
|
8
|
+
* a direct owner reference.
|
|
9
|
+
*/
|
|
10
|
+
export class AltiumScene3dGeometricOwnerRecovery {
|
|
11
|
+
static #ANCHOR_TOLERANCE_MIL = 8
|
|
12
|
+
static #BODY_INDEX_CELL_MIL = 8
|
|
13
|
+
static #COMPONENT_INDEX_CELL_MIL = 500
|
|
14
|
+
static #MINIMUM_SCORE = 12
|
|
15
|
+
static #MINIMUM_SCORE_MARGIN = 4
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Applies geometry-backed owner recovery to final external placements.
|
|
19
|
+
* @param {object} sceneDescription Built scene description.
|
|
20
|
+
* @param {object} documentModel Parsed Altium document.
|
|
21
|
+
* @returns {object}
|
|
22
|
+
*/
|
|
23
|
+
static apply(sceneDescription, documentModel) {
|
|
24
|
+
if (
|
|
25
|
+
String(sceneDescription?.sourceFormat || '').toLowerCase() !==
|
|
26
|
+
'altium' ||
|
|
27
|
+
!Array.isArray(sceneDescription?.externalPlacements)
|
|
28
|
+
) {
|
|
29
|
+
return sceneDescription
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const components = Array.isArray(documentModel?.pcb?.components)
|
|
33
|
+
? documentModel.pcb.components
|
|
34
|
+
: []
|
|
35
|
+
const bodies = Array.isArray(documentModel?.pcb?.componentBodies)
|
|
36
|
+
? documentModel.pcb.componentBodies
|
|
37
|
+
: []
|
|
38
|
+
const pads = Array.isArray(documentModel?.pcb?.pads)
|
|
39
|
+
? documentModel.pcb.pads
|
|
40
|
+
: []
|
|
41
|
+
if (!components.length || !bodies.length || !pads.length) {
|
|
42
|
+
return sceneDescription
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const componentsByDesignator = new Map(
|
|
46
|
+
components.map((component) => [
|
|
47
|
+
String(component?.designator || ''),
|
|
48
|
+
component
|
|
49
|
+
])
|
|
50
|
+
)
|
|
51
|
+
const padsByComponent =
|
|
52
|
+
AltiumScene3dRecoverySpatialIndex.padsByComponent(pads)
|
|
53
|
+
const geometryByComponent = new Map(
|
|
54
|
+
components.map((component) => [
|
|
55
|
+
component,
|
|
56
|
+
AltiumScene3dGeometricOwnerRecovery.#componentPadGeometry(
|
|
57
|
+
component,
|
|
58
|
+
padsByComponent.get(Number(component?.componentIndex)) || []
|
|
59
|
+
)
|
|
60
|
+
])
|
|
61
|
+
)
|
|
62
|
+
const recoveryContext = {
|
|
63
|
+
componentsByDesignator,
|
|
64
|
+
bodyIndex: AltiumScene3dRecoverySpatialIndex.create(
|
|
65
|
+
bodies,
|
|
66
|
+
AltiumScene3dGeometricOwnerRecovery.#BODY_INDEX_CELL_MIL,
|
|
67
|
+
(body) => body?.positionMil
|
|
68
|
+
),
|
|
69
|
+
geometryByComponent,
|
|
70
|
+
componentIndex: AltiumScene3dRecoverySpatialIndex.create(
|
|
71
|
+
components
|
|
72
|
+
.map((component) => ({
|
|
73
|
+
component,
|
|
74
|
+
geometry: geometryByComponent.get(component)
|
|
75
|
+
}))
|
|
76
|
+
.filter(({ geometry }) => geometry),
|
|
77
|
+
AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL,
|
|
78
|
+
({ component }) => component
|
|
79
|
+
),
|
|
80
|
+
board: sceneDescription?.board
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...sceneDescription,
|
|
85
|
+
externalPlacements: sceneDescription.externalPlacements.map(
|
|
86
|
+
(placement) =>
|
|
87
|
+
AltiumScene3dGeometricOwnerRecovery.#recoverPlacement(
|
|
88
|
+
placement,
|
|
89
|
+
recoveryContext
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Recovers one placement or applies exact-owner package corrections.
|
|
97
|
+
* @param {object} placement External placement.
|
|
98
|
+
* @param {object} context Pre-indexed recovery data.
|
|
99
|
+
* @returns {object}
|
|
100
|
+
*/
|
|
101
|
+
static #recoverPlacement(placement, context) {
|
|
102
|
+
const body = AltiumScene3dGeometricOwnerRecovery.#resolveComponentBody(
|
|
103
|
+
placement,
|
|
104
|
+
context.bodyIndex
|
|
105
|
+
)
|
|
106
|
+
if (!body) return placement
|
|
107
|
+
|
|
108
|
+
const currentOwner = context.componentsByDesignator.get(
|
|
109
|
+
String(placement?.designator || '')
|
|
110
|
+
)
|
|
111
|
+
if (currentOwner) {
|
|
112
|
+
return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
|
|
113
|
+
placement,
|
|
114
|
+
currentOwner,
|
|
115
|
+
body,
|
|
116
|
+
context.geometryByComponent.get(currentOwner)
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const match =
|
|
121
|
+
AltiumScene3dGeometricOwnerRecovery.#resolveGeometricOwner(
|
|
122
|
+
placement,
|
|
123
|
+
body,
|
|
124
|
+
context
|
|
125
|
+
)
|
|
126
|
+
if (!match) return placement
|
|
127
|
+
|
|
128
|
+
const recovered = AltiumScene3dGeometricOwnerRecovery.#withOwner(
|
|
129
|
+
placement,
|
|
130
|
+
body,
|
|
131
|
+
match,
|
|
132
|
+
context.board
|
|
133
|
+
)
|
|
134
|
+
return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
|
|
135
|
+
recovered,
|
|
136
|
+
match.component,
|
|
137
|
+
body,
|
|
138
|
+
context.geometryByComponent.get(match.component)
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolves the source body row occupying a placement anchor.
|
|
144
|
+
* @param {object} placement External placement.
|
|
145
|
+
* @param {Map<string, object[]>} bodyIndex Spatial body index.
|
|
146
|
+
* @returns {object | null}
|
|
147
|
+
*/
|
|
148
|
+
static #resolveComponentBody(placement, bodyIndex) {
|
|
149
|
+
const anchor = placement?.bodyPositionMil
|
|
150
|
+
if (!anchor) return null
|
|
151
|
+
|
|
152
|
+
const matches = AltiumScene3dRecoverySpatialIndex.nearby(
|
|
153
|
+
bodyIndex,
|
|
154
|
+
anchor,
|
|
155
|
+
AltiumScene3dGeometricOwnerRecovery.#BODY_INDEX_CELL_MIL,
|
|
156
|
+
1
|
|
157
|
+
)
|
|
158
|
+
.map((body) => ({
|
|
159
|
+
body,
|
|
160
|
+
distance: AltiumScene3dGeometricOwnerRecovery.#distance(
|
|
161
|
+
anchor,
|
|
162
|
+
body?.positionMil
|
|
163
|
+
),
|
|
164
|
+
identity:
|
|
165
|
+
AltiumScene3dGeometricOwnerRecovery.#bodyIdentityScore(
|
|
166
|
+
placement,
|
|
167
|
+
body
|
|
168
|
+
)
|
|
169
|
+
}))
|
|
170
|
+
.filter(
|
|
171
|
+
(candidate) =>
|
|
172
|
+
candidate.distance <=
|
|
173
|
+
AltiumScene3dGeometricOwnerRecovery.#ANCHOR_TOLERANCE_MIL
|
|
174
|
+
)
|
|
175
|
+
.sort(
|
|
176
|
+
(left, right) =>
|
|
177
|
+
right.identity - left.identity ||
|
|
178
|
+
left.distance - right.distance
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const best = matches[0]
|
|
182
|
+
const second = matches[1]
|
|
183
|
+
if (
|
|
184
|
+
second &&
|
|
185
|
+
best.identity === second.identity &&
|
|
186
|
+
Math.abs(best.distance - second.distance) < 1e-6
|
|
187
|
+
) {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
return best?.body || null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Scores body identity without requiring vendor or library tokens.
|
|
195
|
+
* @param {object} placement External placement.
|
|
196
|
+
* @param {object} body Source body.
|
|
197
|
+
* @returns {number}
|
|
198
|
+
*/
|
|
199
|
+
static #bodyIdentityScore(placement, body) {
|
|
200
|
+
const placementText =
|
|
201
|
+
AltiumScene3dGeometricOwnerRecovery.#normalizeIdentity([
|
|
202
|
+
placement?.designator,
|
|
203
|
+
placement?.externalModel?.name
|
|
204
|
+
])
|
|
205
|
+
const bodyText = AltiumScene3dGeometricOwnerRecovery.#normalizeIdentity(
|
|
206
|
+
[body?.identifier, body?.name]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return placementText && bodyText && placementText.includes(bodyText)
|
|
210
|
+
? bodyText.length
|
|
211
|
+
: 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Selects only components within the model-dependent recovery radius.
|
|
216
|
+
* @param {object} placement External placement.
|
|
217
|
+
* @param {Map<string, object[]>} componentIndex Component spatial index.
|
|
218
|
+
* @returns {{ component: object, geometry: object }[]}
|
|
219
|
+
*/
|
|
220
|
+
static #nearbyComponentGeometry(placement, componentIndex) {
|
|
221
|
+
const longestModelDimension = Math.max(
|
|
222
|
+
0,
|
|
223
|
+
...AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
|
|
224
|
+
)
|
|
225
|
+
const radiusMil = Math.max(300, longestModelDimension)
|
|
226
|
+
const cellRadius = Math.ceil(
|
|
227
|
+
radiusMil /
|
|
228
|
+
AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL
|
|
229
|
+
)
|
|
230
|
+
return AltiumScene3dRecoverySpatialIndex.nearby(
|
|
231
|
+
componentIndex,
|
|
232
|
+
placement?.bodyPositionMil,
|
|
233
|
+
AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL,
|
|
234
|
+
cellRadius
|
|
235
|
+
).filter(
|
|
236
|
+
({ component }) =>
|
|
237
|
+
AltiumScene3dGeometricOwnerRecovery.#distance(
|
|
238
|
+
placement?.bodyPositionMil,
|
|
239
|
+
component
|
|
240
|
+
) <= radiusMil
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolves a unique geometry-backed owner candidate.
|
|
246
|
+
* @param {object} placement External placement.
|
|
247
|
+
* @param {object} body Source component body.
|
|
248
|
+
* @param {object} context Pre-indexed recovery data.
|
|
249
|
+
* @returns {{ component: object, mode: string, score: number } | null}
|
|
250
|
+
*/
|
|
251
|
+
static #resolveGeometricOwner(placement, body, context) {
|
|
252
|
+
const candidates =
|
|
253
|
+
AltiumScene3dGeometricOwnerRecovery.#nearbyComponentGeometry(
|
|
254
|
+
placement,
|
|
255
|
+
context.componentIndex
|
|
256
|
+
)
|
|
257
|
+
.map(({ component, geometry }) =>
|
|
258
|
+
AltiumScene3dGeometricOwnerRecovery.#scoreCandidate(
|
|
259
|
+
placement,
|
|
260
|
+
body,
|
|
261
|
+
component,
|
|
262
|
+
geometry
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.sort(
|
|
267
|
+
(left, right) =>
|
|
268
|
+
right.score - left.score ||
|
|
269
|
+
left.distance - right.distance
|
|
270
|
+
)
|
|
271
|
+
const best = candidates[0]
|
|
272
|
+
const second = candidates[1]
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
!best ||
|
|
276
|
+
best.score < AltiumScene3dGeometricOwnerRecovery.#MINIMUM_SCORE ||
|
|
277
|
+
(second &&
|
|
278
|
+
best.score - second.score <
|
|
279
|
+
AltiumScene3dGeometricOwnerRecovery.#MINIMUM_SCORE_MARGIN)
|
|
280
|
+
) {
|
|
281
|
+
return null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return best
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Scores one candidate from owned pad geometry and source dimensions.
|
|
289
|
+
* @param {object} placement External placement.
|
|
290
|
+
* @param {object} body Source component body.
|
|
291
|
+
* @param {object} component Candidate component.
|
|
292
|
+
* @param {object | null} geometry Precomputed pad geometry.
|
|
293
|
+
* @returns {{ component: object, mode: string, score: number, distance: number } | null}
|
|
294
|
+
*/
|
|
295
|
+
static #scoreCandidate(placement, body, component, geometry) {
|
|
296
|
+
if (!geometry || geometry.pads.length < 2) return null
|
|
297
|
+
|
|
298
|
+
const anchor = placement?.bodyPositionMil || {}
|
|
299
|
+
const distance = AltiumScene3dGeometricOwnerRecovery.#distance(
|
|
300
|
+
anchor,
|
|
301
|
+
component
|
|
302
|
+
)
|
|
303
|
+
const centroidDistance = AltiumScene3dGeometricOwnerRecovery.#distance(
|
|
304
|
+
anchor,
|
|
305
|
+
geometry.center
|
|
306
|
+
)
|
|
307
|
+
const corner = AltiumScene3dGeometricOwnerRecovery.#isModelCornerOrigin(
|
|
308
|
+
placement,
|
|
309
|
+
component,
|
|
310
|
+
geometry
|
|
311
|
+
)
|
|
312
|
+
const rowOrigin = AltiumScene3dGeometricOwnerRecovery.#isMultiRowOrigin(
|
|
313
|
+
placement,
|
|
314
|
+
geometry
|
|
315
|
+
)
|
|
316
|
+
const heightAgreement =
|
|
317
|
+
AltiumScene3dGeometricOwnerRecovery.#hasHeightAgreement(
|
|
318
|
+
body,
|
|
319
|
+
component
|
|
320
|
+
)
|
|
321
|
+
const nearFootprint =
|
|
322
|
+
AltiumScene3dGeometricOwnerRecovery.#isNearFootprint(
|
|
323
|
+
anchor,
|
|
324
|
+
geometry,
|
|
325
|
+
50
|
|
326
|
+
)
|
|
327
|
+
let score = 0
|
|
328
|
+
let mode = ''
|
|
329
|
+
|
|
330
|
+
if (
|
|
331
|
+
centroidDistance <=
|
|
332
|
+
AltiumScene3dGeometricOwnerRecovery.#ANCHOR_TOLERANCE_MIL
|
|
333
|
+
) {
|
|
334
|
+
score = 24
|
|
335
|
+
mode = 'pad-centroid'
|
|
336
|
+
} else if (corner) {
|
|
337
|
+
score = 20
|
|
338
|
+
mode = 'model-corner'
|
|
339
|
+
} else if (rowOrigin) {
|
|
340
|
+
score = 18
|
|
341
|
+
mode = 'multi-row-origin'
|
|
342
|
+
} else if (heightAgreement && nearFootprint && distance <= 250) {
|
|
343
|
+
score = 14
|
|
344
|
+
mode = 'height-backed-origin'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!score) return null
|
|
348
|
+
if (heightAgreement) score += 2
|
|
349
|
+
score += Math.max(0, 3 - distance / 100)
|
|
350
|
+
|
|
351
|
+
return { component, mode, score, distance }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Builds bounds and topology for pads owned by one component.
|
|
356
|
+
* @param {object} component PCB component.
|
|
357
|
+
* @param {object[]} ownedPads Pads already indexed to this component.
|
|
358
|
+
* @returns {object | null}
|
|
359
|
+
*/
|
|
360
|
+
static #componentPadGeometry(component, ownedPads) {
|
|
361
|
+
const componentIndex = Number(component?.componentIndex)
|
|
362
|
+
if (!Number.isFinite(componentIndex)) return null
|
|
363
|
+
if (!ownedPads.length) return null
|
|
364
|
+
|
|
365
|
+
const centerXs = ownedPads.map((pad) => Number(pad?.x || 0))
|
|
366
|
+
const centerYs = ownedPads.map((pad) => Number(pad?.y || 0))
|
|
367
|
+
const center = {
|
|
368
|
+
x:
|
|
369
|
+
centerXs.reduce((sum, value) => sum + value, 0) /
|
|
370
|
+
centerXs.length,
|
|
371
|
+
y: centerYs.reduce((sum, value) => sum + value, 0) / centerYs.length
|
|
372
|
+
}
|
|
373
|
+
const localPads = ownedPads.map((pad) => ({
|
|
374
|
+
source: pad,
|
|
375
|
+
...AltiumScene3dGeometricOwnerRecovery.#toLocalPoint(
|
|
376
|
+
pad,
|
|
377
|
+
center,
|
|
378
|
+
component?.rotation
|
|
379
|
+
)
|
|
380
|
+
}))
|
|
381
|
+
const localXs = localPads.map((pad) => pad.x)
|
|
382
|
+
const localYs = localPads.map((pad) => pad.y)
|
|
383
|
+
const minX = Math.min(
|
|
384
|
+
...ownedPads.map(
|
|
385
|
+
(pad) =>
|
|
386
|
+
Number(pad?.x || 0) -
|
|
387
|
+
AltiumScene3dGeometricOwnerRecovery.#padWidth(pad) / 2
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
const maxX = Math.max(
|
|
391
|
+
...ownedPads.map(
|
|
392
|
+
(pad) =>
|
|
393
|
+
Number(pad?.x || 0) +
|
|
394
|
+
AltiumScene3dGeometricOwnerRecovery.#padWidth(pad) / 2
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
const minY = Math.min(
|
|
398
|
+
...ownedPads.map(
|
|
399
|
+
(pad) =>
|
|
400
|
+
Number(pad?.y || 0) -
|
|
401
|
+
AltiumScene3dGeometricOwnerRecovery.#padDepth(pad) / 2
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
const maxY = Math.max(
|
|
405
|
+
...ownedPads.map(
|
|
406
|
+
(pad) =>
|
|
407
|
+
Number(pad?.y || 0) +
|
|
408
|
+
AltiumScene3dGeometricOwnerRecovery.#padDepth(pad) / 2
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
pads: ownedPads,
|
|
414
|
+
localPads,
|
|
415
|
+
center,
|
|
416
|
+
bounds: { minX, maxX, minY, maxY },
|
|
417
|
+
width: maxX - minX,
|
|
418
|
+
depth: maxY - minY,
|
|
419
|
+
xCount: AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
|
|
420
|
+
localXs
|
|
421
|
+
),
|
|
422
|
+
yCount: AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
|
|
423
|
+
localYs
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Rotates one board point into the component-local footprint frame.
|
|
430
|
+
* @param {object} point Board point.
|
|
431
|
+
* @param {object} center Footprint center.
|
|
432
|
+
* @param {unknown} rotationDeg Component rotation.
|
|
433
|
+
* @returns {{ x: number, y: number }}
|
|
434
|
+
*/
|
|
435
|
+
static #toLocalPoint(point, center, rotationDeg) {
|
|
436
|
+
const radians =
|
|
437
|
+
(-AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(rotationDeg) *
|
|
438
|
+
Math.PI) /
|
|
439
|
+
180
|
|
440
|
+
const dx = Number(point?.x || 0) - Number(center?.x || 0)
|
|
441
|
+
const dy = Number(point?.y || 0) - Number(center?.y || 0)
|
|
442
|
+
return {
|
|
443
|
+
x: dx * Math.cos(radians) - dy * Math.sin(radians),
|
|
444
|
+
y: dx * Math.sin(radians) + dy * Math.cos(radians)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Checks whether a body anchor is the opposite corner of measured model
|
|
450
|
+
* dimensions from the component origin.
|
|
451
|
+
* @param {object} placement External placement.
|
|
452
|
+
* @param {object} component Candidate component.
|
|
453
|
+
* @param {object} geometry Owned pad geometry.
|
|
454
|
+
* @returns {boolean}
|
|
455
|
+
*/
|
|
456
|
+
static #isModelCornerOrigin(placement, component, geometry) {
|
|
457
|
+
const offsetDimensions = [
|
|
458
|
+
Math.abs(
|
|
459
|
+
Number(placement?.bodyPositionMil?.x || 0) -
|
|
460
|
+
Number(component?.x || 0)
|
|
461
|
+
) * 2,
|
|
462
|
+
Math.abs(
|
|
463
|
+
Number(placement?.bodyPositionMil?.y || 0) -
|
|
464
|
+
Number(component?.y || 0)
|
|
465
|
+
) * 2
|
|
466
|
+
]
|
|
467
|
+
if (offsetDimensions.some((dimension) => dimension < 20)) return false
|
|
468
|
+
|
|
469
|
+
const modelDimensions =
|
|
470
|
+
AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
|
|
471
|
+
const offsetMatchesModel =
|
|
472
|
+
AltiumScene3dGeometricOwnerRecovery.#matchesDimensionPair(
|
|
473
|
+
offsetDimensions,
|
|
474
|
+
modelDimensions,
|
|
475
|
+
0.12,
|
|
476
|
+
25
|
|
477
|
+
)
|
|
478
|
+
const modelMatchesFootprint =
|
|
479
|
+
AltiumScene3dGeometricOwnerRecovery.#matchesDimensionPair(
|
|
480
|
+
[geometry.width, geometry.depth],
|
|
481
|
+
modelDimensions,
|
|
482
|
+
0.3,
|
|
483
|
+
45
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
return offsetMatchesModel && modelMatchesFootprint
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Checks whether a multi-row footprint and model share the same long span.
|
|
491
|
+
* @param {object} placement External placement.
|
|
492
|
+
* @param {object} geometry Owned pad geometry.
|
|
493
|
+
* @returns {boolean}
|
|
494
|
+
*/
|
|
495
|
+
static #isMultiRowOrigin(placement, geometry) {
|
|
496
|
+
const isGrid =
|
|
497
|
+
geometry.pads.length >= 6 &&
|
|
498
|
+
((geometry.xCount >= 3 && geometry.yCount === 2) ||
|
|
499
|
+
(geometry.yCount >= 3 && geometry.xCount === 2))
|
|
500
|
+
if (!isGrid) return false
|
|
501
|
+
if (
|
|
502
|
+
!AltiumScene3dGeometricOwnerRecovery.#isNearFootprint(
|
|
503
|
+
placement?.bodyPositionMil,
|
|
504
|
+
geometry,
|
|
505
|
+
10
|
|
506
|
+
)
|
|
507
|
+
) {
|
|
508
|
+
return false
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const modelDimensions =
|
|
512
|
+
AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
|
|
513
|
+
const modelLong = Math.max(...modelDimensions)
|
|
514
|
+
const footprintLong = Math.max(geometry.width, geometry.depth)
|
|
515
|
+
|
|
516
|
+
return AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
|
|
517
|
+
modelLong,
|
|
518
|
+
footprintLong,
|
|
519
|
+
0.18,
|
|
520
|
+
50
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Checks whether source and component heights agree.
|
|
526
|
+
* @param {object} body Source body.
|
|
527
|
+
* @param {object} component Candidate component.
|
|
528
|
+
* @returns {boolean}
|
|
529
|
+
*/
|
|
530
|
+
static #hasHeightAgreement(body, component) {
|
|
531
|
+
const sourceHeight = body?.overallHeightMil
|
|
532
|
+
const componentHeight = component?.height
|
|
533
|
+
if (
|
|
534
|
+
sourceHeight === null ||
|
|
535
|
+
sourceHeight === undefined ||
|
|
536
|
+
sourceHeight === '' ||
|
|
537
|
+
componentHeight === null ||
|
|
538
|
+
componentHeight === undefined ||
|
|
539
|
+
componentHeight === ''
|
|
540
|
+
) {
|
|
541
|
+
return false
|
|
542
|
+
}
|
|
543
|
+
const normalizedSourceHeight = Number(sourceHeight)
|
|
544
|
+
const normalizedComponentHeight = Number(componentHeight)
|
|
545
|
+
if (
|
|
546
|
+
!Number.isFinite(normalizedSourceHeight) ||
|
|
547
|
+
normalizedSourceHeight <= 0 ||
|
|
548
|
+
!Number.isFinite(normalizedComponentHeight) ||
|
|
549
|
+
normalizedComponentHeight <= 0
|
|
550
|
+
) {
|
|
551
|
+
return false
|
|
552
|
+
}
|
|
553
|
+
return AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
|
|
554
|
+
normalizedSourceHeight,
|
|
555
|
+
normalizedComponentHeight,
|
|
556
|
+
0.08,
|
|
557
|
+
3
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Applies a recovered owner and preserves only proven authored offsets.
|
|
563
|
+
* @param {object} placement External placement.
|
|
564
|
+
* @param {object} body Source body.
|
|
565
|
+
* @param {{ component: object, mode: string }} match Owner match.
|
|
566
|
+
* @param {object} board Board metadata.
|
|
567
|
+
* @returns {object}
|
|
568
|
+
*/
|
|
569
|
+
static #withOwner(placement, body, match, board) {
|
|
570
|
+
const component = match.component
|
|
571
|
+
const mountSide =
|
|
572
|
+
AltiumScene3dGeometricOwnerRecovery.#componentSide(component)
|
|
573
|
+
const preserveAnchor = match.mode === 'pad-centroid'
|
|
574
|
+
const offset = {
|
|
575
|
+
x:
|
|
576
|
+
Number(placement?.bodyPositionMil?.x || 0) -
|
|
577
|
+
Number(component?.x || 0),
|
|
578
|
+
y:
|
|
579
|
+
Number(placement?.bodyPositionMil?.y || 0) -
|
|
580
|
+
Number(component?.y || 0)
|
|
581
|
+
}
|
|
582
|
+
const verticalOffset =
|
|
583
|
+
AltiumScene3dGeometricOwnerRecovery.#verticalOffset(
|
|
584
|
+
body,
|
|
585
|
+
mountSide,
|
|
586
|
+
placement?.modelTransform
|
|
587
|
+
)
|
|
588
|
+
const modelTransform = {
|
|
589
|
+
...(placement?.modelTransform || {}),
|
|
590
|
+
dzMil: verticalOffset
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!preserveAnchor) {
|
|
594
|
+
modelTransform.ownerAnchorOffsetMil = offset
|
|
595
|
+
modelTransform.offsetMil = { x: 0, y: 0, z: verticalOffset }
|
|
596
|
+
} else if (modelTransform.offsetMil) {
|
|
597
|
+
modelTransform.offsetMil = {
|
|
598
|
+
...modelTransform.offsetMil,
|
|
599
|
+
z: verticalOffset
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
...placement,
|
|
605
|
+
designator: String(component?.designator || placement.designator),
|
|
606
|
+
mountSide,
|
|
607
|
+
positionMil: {
|
|
608
|
+
...placement.positionMil,
|
|
609
|
+
...(preserveAnchor
|
|
610
|
+
? {}
|
|
611
|
+
: {
|
|
612
|
+
x:
|
|
613
|
+
Number(component?.x || 0) -
|
|
614
|
+
Number(board?.centerX || 0),
|
|
615
|
+
y:
|
|
616
|
+
Number(component?.y || 0) -
|
|
617
|
+
Number(board?.centerY || 0)
|
|
618
|
+
}),
|
|
619
|
+
z: AltiumScene3dGeometricOwnerRecovery.#faceZ(mountSide, board)
|
|
620
|
+
},
|
|
621
|
+
modelTransform
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Corrects the source-frame half-turn for a four-pad tactile switch.
|
|
627
|
+
* @param {object} placement External placement.
|
|
628
|
+
* @param {object} component Resolved owner.
|
|
629
|
+
* @param {object} body Source body.
|
|
630
|
+
* @param {object | null} geometry Precomputed owner pad geometry.
|
|
631
|
+
* @returns {object}
|
|
632
|
+
*/
|
|
633
|
+
static #correctTactileYaw(placement, component, body, geometry) {
|
|
634
|
+
const identity = [
|
|
635
|
+
component?.designator,
|
|
636
|
+
component?.description,
|
|
637
|
+
component?.provenance?.footprintDescription
|
|
638
|
+
]
|
|
639
|
+
.map((value) => String(value || ''))
|
|
640
|
+
.join(' ')
|
|
641
|
+
const sourceTilt = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
642
|
+
body?.modelRotationDeg?.x
|
|
643
|
+
)
|
|
644
|
+
const currentYaw = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
645
|
+
placement?.rotationDeg
|
|
646
|
+
)
|
|
647
|
+
const componentYaw =
|
|
648
|
+
AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
649
|
+
component?.rotation
|
|
650
|
+
)
|
|
651
|
+
const isTactileSwitch =
|
|
652
|
+
/(?:^|[^a-z0-9])(?:tact|tactile|pushbutton)(?:$|[^a-z0-9])/i.test(
|
|
653
|
+
identity
|
|
654
|
+
) &&
|
|
655
|
+
AltiumScene3dGeometricOwnerRecovery.#hasTactileContactTopology(
|
|
656
|
+
geometry
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if (
|
|
660
|
+
!isTactileSwitch ||
|
|
661
|
+
(sourceTilt !== 90 && sourceTilt !== 270) ||
|
|
662
|
+
currentYaw !== componentYaw
|
|
663
|
+
) {
|
|
664
|
+
return placement
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
...placement,
|
|
669
|
+
rotationDeg: AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
670
|
+
currentYaw + 180
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Detects a two-by-two tactile contact layout with two duplicated routed
|
|
677
|
+
* contact pairs aligned along one footprint axis.
|
|
678
|
+
* @param {object | null} geometry Precomputed owner pad geometry.
|
|
679
|
+
* @returns {boolean}
|
|
680
|
+
*/
|
|
681
|
+
static #hasTactileContactTopology(geometry) {
|
|
682
|
+
if (
|
|
683
|
+
geometry?.pads?.length !== 4 ||
|
|
684
|
+
geometry.xCount !== 2 ||
|
|
685
|
+
geometry.yCount !== 2
|
|
686
|
+
) {
|
|
687
|
+
return false
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const groups = new Map()
|
|
691
|
+
for (const pad of geometry.localPads) {
|
|
692
|
+
const sourcePad = pad.source
|
|
693
|
+
const netName = String(sourcePad?.netName || '').trim()
|
|
694
|
+
const netIndex = sourcePad?.netIndex
|
|
695
|
+
const contactKey = netName
|
|
696
|
+
? `name:${netName}`
|
|
697
|
+
: netIndex !== null &&
|
|
698
|
+
netIndex !== undefined &&
|
|
699
|
+
netIndex !== '' &&
|
|
700
|
+
Number.isFinite(Number(netIndex))
|
|
701
|
+
? `index:${Number(netIndex)}`
|
|
702
|
+
: ''
|
|
703
|
+
if (!contactKey) return false
|
|
704
|
+
groups.set(contactKey, [...(groups.get(contactKey) || []), pad])
|
|
705
|
+
}
|
|
706
|
+
if (
|
|
707
|
+
groups.size !== 2 ||
|
|
708
|
+
[...groups.values()].some((group) => group.length !== 2)
|
|
709
|
+
) {
|
|
710
|
+
return false
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return [...groups.values()].every((group) => {
|
|
714
|
+
const sameX =
|
|
715
|
+
AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
|
|
716
|
+
group.map((pad) => Number(pad?.x || 0))
|
|
717
|
+
) === 1
|
|
718
|
+
const sameY =
|
|
719
|
+
AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
|
|
720
|
+
group.map((pad) => Number(pad?.y || 0))
|
|
721
|
+
) === 1
|
|
722
|
+
return sameX || sameY
|
|
723
|
+
})
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Resolves a body vertical offset after a late owner recovery.
|
|
728
|
+
* @param {object} body Source body.
|
|
729
|
+
* @param {'top' | 'bottom'} mountSide Recovered side.
|
|
730
|
+
* @param {object} currentTransform Existing model transform.
|
|
731
|
+
* @returns {number}
|
|
732
|
+
*/
|
|
733
|
+
static #verticalOffset(body, mountSide, currentTransform) {
|
|
734
|
+
const standoff = Number(body?.standoffHeightMil)
|
|
735
|
+
const overallHeight = Number(body?.overallHeightMil)
|
|
736
|
+
if (
|
|
737
|
+
mountSide === 'top' &&
|
|
738
|
+
Number.isFinite(standoff) &&
|
|
739
|
+
standoff < 0 &&
|
|
740
|
+
(!Number.isFinite(overallHeight) ||
|
|
741
|
+
overallHeight <= 0 ||
|
|
742
|
+
Math.abs(standoff) < overallHeight)
|
|
743
|
+
) {
|
|
744
|
+
return standoff
|
|
745
|
+
}
|
|
746
|
+
if (Number.isFinite(standoff) && Math.abs(standoff) < 1e-6) return 0
|
|
747
|
+
|
|
748
|
+
const current = Number(
|
|
749
|
+
currentTransform?.offsetMil?.z ?? currentTransform?.dzMil ?? 0
|
|
750
|
+
)
|
|
751
|
+
return Number.isFinite(current) ? current : 0
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Returns measured model dimensions.
|
|
756
|
+
* @param {object} placement External placement.
|
|
757
|
+
* @returns {number[]}
|
|
758
|
+
*/
|
|
759
|
+
static #modelDimensions(placement) {
|
|
760
|
+
return [
|
|
761
|
+
Number(placement?.projection?.boundsMil?.width),
|
|
762
|
+
Number(placement?.projection?.boundsMil?.depth),
|
|
763
|
+
Number(placement?.projection?.boundsMil?.height)
|
|
764
|
+
].filter((dimension) => Number.isFinite(dimension) && dimension > 0)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Checks whether two source dimensions match any distinct model axes.
|
|
769
|
+
* @param {number[]} sourceDimensions Two source dimensions.
|
|
770
|
+
* @param {number[]} modelDimensions Candidate model dimensions.
|
|
771
|
+
* @param {number} ratio Relative tolerance.
|
|
772
|
+
* @param {number} minimum Absolute tolerance.
|
|
773
|
+
* @returns {boolean}
|
|
774
|
+
*/
|
|
775
|
+
static #matchesDimensionPair(
|
|
776
|
+
sourceDimensions,
|
|
777
|
+
modelDimensions,
|
|
778
|
+
ratio,
|
|
779
|
+
minimum
|
|
780
|
+
) {
|
|
781
|
+
if (sourceDimensions.length < 2 || modelDimensions.length < 2) {
|
|
782
|
+
return false
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return modelDimensions.some((first, firstIndex) =>
|
|
786
|
+
modelDimensions.some(
|
|
787
|
+
(second, secondIndex) =>
|
|
788
|
+
firstIndex !== secondIndex &&
|
|
789
|
+
((AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
|
|
790
|
+
sourceDimensions[0],
|
|
791
|
+
first,
|
|
792
|
+
ratio,
|
|
793
|
+
minimum
|
|
794
|
+
) &&
|
|
795
|
+
AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
|
|
796
|
+
sourceDimensions[1],
|
|
797
|
+
second,
|
|
798
|
+
ratio,
|
|
799
|
+
minimum
|
|
800
|
+
)) ||
|
|
801
|
+
(AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
|
|
802
|
+
sourceDimensions[0],
|
|
803
|
+
second,
|
|
804
|
+
ratio,
|
|
805
|
+
minimum
|
|
806
|
+
) &&
|
|
807
|
+
AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
|
|
808
|
+
sourceDimensions[1],
|
|
809
|
+
first,
|
|
810
|
+
ratio,
|
|
811
|
+
minimum
|
|
812
|
+
)))
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Checks one dimension with combined absolute and relative tolerance.
|
|
819
|
+
* @param {number} actual Actual dimension.
|
|
820
|
+
* @param {number} expected Expected dimension.
|
|
821
|
+
* @param {number} ratio Relative tolerance.
|
|
822
|
+
* @param {number} minimum Absolute tolerance.
|
|
823
|
+
* @returns {boolean}
|
|
824
|
+
*/
|
|
825
|
+
static #matchesDimension(actual, expected, ratio, minimum) {
|
|
826
|
+
if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
|
|
827
|
+
return false
|
|
828
|
+
}
|
|
829
|
+
const tolerance = Math.max(minimum, Math.abs(expected) * ratio)
|
|
830
|
+
return Math.abs(actual - expected) <= tolerance
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Checks whether a point lies within expanded footprint bounds.
|
|
835
|
+
* @param {object} point Board point.
|
|
836
|
+
* @param {object} geometry Pad geometry.
|
|
837
|
+
* @param {number} expansion Bounds expansion.
|
|
838
|
+
* @returns {boolean}
|
|
839
|
+
*/
|
|
840
|
+
static #isNearFootprint(point, geometry, expansion) {
|
|
841
|
+
return (
|
|
842
|
+
Number(point?.x) >= geometry.bounds.minX - expansion &&
|
|
843
|
+
Number(point?.x) <= geometry.bounds.maxX + expansion &&
|
|
844
|
+
Number(point?.y) >= geometry.bounds.minY - expansion &&
|
|
845
|
+
Number(point?.y) <= geometry.bounds.maxY + expansion
|
|
846
|
+
)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Counts coordinates after grouping native numeric noise.
|
|
851
|
+
* @param {number[]} values Coordinates.
|
|
852
|
+
* @returns {number}
|
|
853
|
+
*/
|
|
854
|
+
static #distinctCoordinateCount(values) {
|
|
855
|
+
return new Set(values.map((value) => Math.round(value * 10))).size
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Resolves effective pad width.
|
|
860
|
+
* @param {object} pad PCB pad.
|
|
861
|
+
* @returns {number}
|
|
862
|
+
*/
|
|
863
|
+
static #padWidth(pad) {
|
|
864
|
+
return Math.max(
|
|
865
|
+
Number(pad?.sizeTopX || 0),
|
|
866
|
+
Number(pad?.sizeMidX || 0),
|
|
867
|
+
Number(pad?.sizeBottomX || 0)
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Resolves effective pad depth.
|
|
873
|
+
* @param {object} pad PCB pad.
|
|
874
|
+
* @returns {number}
|
|
875
|
+
*/
|
|
876
|
+
static #padDepth(pad) {
|
|
877
|
+
return Math.max(
|
|
878
|
+
Number(pad?.sizeTopY || 0),
|
|
879
|
+
Number(pad?.sizeMidY || 0),
|
|
880
|
+
Number(pad?.sizeBottomY || 0)
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Resolves component side.
|
|
886
|
+
* @param {object} component PCB component.
|
|
887
|
+
* @returns {'top' | 'bottom'}
|
|
888
|
+
*/
|
|
889
|
+
static #componentSide(component) {
|
|
890
|
+
return /bottom|bot/i.test(String(component?.layer || ''))
|
|
891
|
+
? 'bottom'
|
|
892
|
+
: 'top'
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Resolves board face Z.
|
|
897
|
+
* @param {'top' | 'bottom'} mountSide Placement side.
|
|
898
|
+
* @param {object} board Board metadata.
|
|
899
|
+
* @returns {number}
|
|
900
|
+
*/
|
|
901
|
+
static #faceZ(mountSide, board) {
|
|
902
|
+
const halfThickness = Number(board?.thicknessMil || 63) / 2
|
|
903
|
+
return mountSide === 'bottom' ? -halfThickness : halfThickness
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Measures planar distance.
|
|
908
|
+
* @param {object} first First point.
|
|
909
|
+
* @param {object} second Second point.
|
|
910
|
+
* @returns {number}
|
|
911
|
+
*/
|
|
912
|
+
static #distance(first, second) {
|
|
913
|
+
return Math.hypot(
|
|
914
|
+
Number(first?.x || 0) - Number(second?.x || 0),
|
|
915
|
+
Number(first?.y || 0) - Number(second?.y || 0)
|
|
916
|
+
)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Normalizes identity text.
|
|
921
|
+
* @param {unknown[]} values Identity values.
|
|
922
|
+
* @returns {string}
|
|
923
|
+
*/
|
|
924
|
+
static #normalizeIdentity(values) {
|
|
925
|
+
return values
|
|
926
|
+
.map((value) =>
|
|
927
|
+
String(value || '')
|
|
928
|
+
.replace(/\.[^.]+$/, '')
|
|
929
|
+
.toLowerCase()
|
|
930
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
931
|
+
)
|
|
932
|
+
.filter(Boolean)
|
|
933
|
+
.join(' ')
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Normalizes one angle.
|
|
938
|
+
* @param {unknown} angle Angle value.
|
|
939
|
+
* @returns {number}
|
|
940
|
+
*/
|
|
941
|
+
static #normalizeAngle(angle) {
|
|
942
|
+
const normalized = Number(angle || 0) % 360
|
|
943
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
Object.freeze(AltiumScene3dGeometricOwnerRecovery.prototype)
|
|
948
|
+
Object.freeze(AltiumScene3dGeometricOwnerRecovery)
|