altium-toolkit 1.1.26 → 1.1.30
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/ui/AltiumScene3dBottomPadRotationAdapter.mjs +127 -0
- package/src/ui/AltiumScene3dComponentBodyAdapter.mjs +153 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +919 -0
- package/src/ui/AltiumScene3dIdentityTokens.mjs +105 -0
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +357 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +576 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +54 -11
- package/src/ui/PcbScene3dBuilder.mjs +69 -32
- package/src/ui/PcbScene3dPadLocalSpanResolver.mjs +109 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { AltiumScene3dIdentityTokens } from './AltiumScene3dIdentityTokens.mjs'
|
|
2
|
+
import { AltiumScene3dPlacementRotationPolicy } from './AltiumScene3dPlacementRotationPolicy.mjs'
|
|
3
|
+
import { AltiumScene3dRepeatedModelOwnerRepair } from './AltiumScene3dRepeatedModelOwnerRepair.mjs'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Repairs Altium explicit 3D body placements after toolkit scene conversion.
|
|
7
|
+
*/
|
|
8
|
+
export class AltiumScene3dExternalPlacementAdapter {
|
|
9
|
+
static #EXACT_ANCHOR_TOLERANCE_MIL = 5
|
|
10
|
+
static #NEAR_ANCHOR_TOLERANCE_MIL = 20
|
|
11
|
+
static #FAR_OWNER_DISTANCE_MIL = 100
|
|
12
|
+
static #DEFAULT_BOARD_THICKNESS_MIL = 63
|
|
13
|
+
static #PASSIVE_BODY_PATTERN =
|
|
14
|
+
/(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
|
|
15
|
+
static #MECHANICAL_OWNER_PATTERN =
|
|
16
|
+
/(?:^|[^a-z0-9])(?:mech|mechanical|shield|frame|cover|hardware)(?:$|[^a-z0-9])/i
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Applies exact-anchor repairs to Altium external 3D placements.
|
|
20
|
+
* @param {object} sceneDescription Built scene description.
|
|
21
|
+
* @param {object} documentModel Source document model.
|
|
22
|
+
* @returns {object}
|
|
23
|
+
*/
|
|
24
|
+
static apply(sceneDescription, documentModel) {
|
|
25
|
+
if (
|
|
26
|
+
String(sceneDescription?.sourceFormat || '').toLowerCase() !==
|
|
27
|
+
'altium' ||
|
|
28
|
+
!Array.isArray(sceneDescription?.externalPlacements)
|
|
29
|
+
) {
|
|
30
|
+
return sceneDescription
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const components = Array.isArray(documentModel?.pcb?.components)
|
|
34
|
+
? documentModel.pcb.components
|
|
35
|
+
: []
|
|
36
|
+
const pads = Array.isArray(documentModel?.pcb?.pads)
|
|
37
|
+
? documentModel.pcb.pads
|
|
38
|
+
: []
|
|
39
|
+
const componentBodies = Array.isArray(
|
|
40
|
+
documentModel?.pcb?.componentBodies
|
|
41
|
+
)
|
|
42
|
+
? documentModel.pcb.componentBodies
|
|
43
|
+
: []
|
|
44
|
+
if (!components.length) {
|
|
45
|
+
return sceneDescription
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const componentByDesignator = new Map(
|
|
49
|
+
components.map((component) => [
|
|
50
|
+
String(component?.designator || ''),
|
|
51
|
+
component
|
|
52
|
+
])
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const repairedScene = {
|
|
56
|
+
...sceneDescription,
|
|
57
|
+
externalPlacements: sceneDescription.externalPlacements
|
|
58
|
+
.map((placement) =>
|
|
59
|
+
AltiumScene3dExternalPlacementAdapter.#repairPlacement(
|
|
60
|
+
placement,
|
|
61
|
+
components,
|
|
62
|
+
componentByDesignator,
|
|
63
|
+
componentBodies,
|
|
64
|
+
pads,
|
|
65
|
+
sceneDescription?.board
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return AltiumScene3dRepeatedModelOwnerRepair.apply(
|
|
72
|
+
repairedScene,
|
|
73
|
+
documentModel
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Repairs one placement when a weak name match displaced an anchor. */
|
|
78
|
+
static #repairPlacement(
|
|
79
|
+
placement,
|
|
80
|
+
components,
|
|
81
|
+
componentByDesignator,
|
|
82
|
+
componentBodies,
|
|
83
|
+
pads,
|
|
84
|
+
board
|
|
85
|
+
) {
|
|
86
|
+
if (!placement?.bodyPositionMil || !placement?.positionMil) {
|
|
87
|
+
return placement
|
|
88
|
+
}
|
|
89
|
+
const componentBody =
|
|
90
|
+
AltiumScene3dExternalPlacementAdapter.#resolveComponentBody(
|
|
91
|
+
placement,
|
|
92
|
+
componentBodies
|
|
93
|
+
)
|
|
94
|
+
const currentComponent = componentByDesignator.get(
|
|
95
|
+
String(placement?.designator || '')
|
|
96
|
+
)
|
|
97
|
+
const currentDistance = currentComponent
|
|
98
|
+
? AltiumScene3dExternalPlacementAdapter.#distanceToBody(
|
|
99
|
+
placement,
|
|
100
|
+
currentComponent
|
|
101
|
+
)
|
|
102
|
+
: Number.POSITIVE_INFINITY
|
|
103
|
+
const currentHasMetadataAffinity = currentComponent
|
|
104
|
+
? AltiumScene3dExternalPlacementAdapter.#hasMetadataAffinity(
|
|
105
|
+
placement,
|
|
106
|
+
componentBody,
|
|
107
|
+
currentComponent
|
|
108
|
+
)
|
|
109
|
+
: false
|
|
110
|
+
const currentHasPartCodeAffinity = currentComponent
|
|
111
|
+
? AltiumScene3dExternalPlacementAdapter.#hasPartCodeAffinity(
|
|
112
|
+
placement,
|
|
113
|
+
componentBody,
|
|
114
|
+
currentComponent
|
|
115
|
+
)
|
|
116
|
+
: false
|
|
117
|
+
const currentIsMechanicalOwner =
|
|
118
|
+
currentComponent &&
|
|
119
|
+
AltiumScene3dExternalPlacementAdapter.#MECHANICAL_OWNER_PATTERN.test(
|
|
120
|
+
AltiumScene3dExternalPlacementAdapter.#packageIdentityText(
|
|
121
|
+
currentComponent,
|
|
122
|
+
componentBody
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
const exactComponent =
|
|
126
|
+
AltiumScene3dExternalPlacementAdapter.#resolveAnchorComponent(
|
|
127
|
+
placement,
|
|
128
|
+
currentComponent,
|
|
129
|
+
components,
|
|
130
|
+
currentHasMetadataAffinity,
|
|
131
|
+
currentHasPartCodeAffinity,
|
|
132
|
+
componentBody
|
|
133
|
+
)
|
|
134
|
+
const isExactAnchoredOwner =
|
|
135
|
+
(currentComponent &&
|
|
136
|
+
currentDistance <=
|
|
137
|
+
AltiumScene3dExternalPlacementAdapter
|
|
138
|
+
.#EXACT_ANCHOR_TOLERANCE_MIL) ||
|
|
139
|
+
(exactComponent &&
|
|
140
|
+
AltiumScene3dExternalPlacementAdapter.#distanceToBody(
|
|
141
|
+
placement,
|
|
142
|
+
exactComponent
|
|
143
|
+
) <=
|
|
144
|
+
AltiumScene3dExternalPlacementAdapter
|
|
145
|
+
.#EXACT_ANCHOR_TOLERANCE_MIL)
|
|
146
|
+
const isFarCurrentOwner =
|
|
147
|
+
currentComponent &&
|
|
148
|
+
currentDistance >
|
|
149
|
+
AltiumScene3dExternalPlacementAdapter.#FAR_OWNER_DISTANCE_MIL
|
|
150
|
+
const metadataComponent =
|
|
151
|
+
!exactComponent &&
|
|
152
|
+
(!currentComponent ||
|
|
153
|
+
(isFarCurrentOwner && !currentHasPartCodeAffinity))
|
|
154
|
+
? AltiumScene3dExternalPlacementAdapter.#resolveMetadataComponent(
|
|
155
|
+
placement,
|
|
156
|
+
componentBody,
|
|
157
|
+
components
|
|
158
|
+
)
|
|
159
|
+
: null
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
isFarCurrentOwner &&
|
|
163
|
+
!exactComponent &&
|
|
164
|
+
!metadataComponent &&
|
|
165
|
+
!currentHasMetadataAffinity &&
|
|
166
|
+
!currentIsMechanicalOwner
|
|
167
|
+
) {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const resolvedComponent =
|
|
172
|
+
exactComponent || metadataComponent || currentComponent
|
|
173
|
+
const mountSide = resolvedComponent
|
|
174
|
+
? AltiumScene3dExternalPlacementAdapter.#resolveComponentMountSide(
|
|
175
|
+
resolvedComponent
|
|
176
|
+
) || placement.mountSide
|
|
177
|
+
: placement.mountSide
|
|
178
|
+
const nextPlacement =
|
|
179
|
+
exactComponent || metadataComponent
|
|
180
|
+
? {
|
|
181
|
+
...placement,
|
|
182
|
+
designator: String(
|
|
183
|
+
resolvedComponent?.designator || placement.designator
|
|
184
|
+
),
|
|
185
|
+
mountSide,
|
|
186
|
+
positionMil: {
|
|
187
|
+
...placement.positionMil,
|
|
188
|
+
z: AltiumScene3dExternalPlacementAdapter.#resolveFaceZ(
|
|
189
|
+
mountSide,
|
|
190
|
+
board
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
: placement
|
|
195
|
+
const shouldUseComponentYaw =
|
|
196
|
+
Boolean(metadataComponent && !exactComponent) ||
|
|
197
|
+
Boolean(
|
|
198
|
+
!exactComponent &&
|
|
199
|
+
isFarCurrentOwner &&
|
|
200
|
+
currentHasMetadataAffinity &&
|
|
201
|
+
!currentIsMechanicalOwner
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const repairedPlacement =
|
|
205
|
+
AltiumScene3dExternalPlacementAdapter.#repairRotation(
|
|
206
|
+
nextPlacement,
|
|
207
|
+
resolvedComponent,
|
|
208
|
+
componentBody,
|
|
209
|
+
shouldUseComponentYaw,
|
|
210
|
+
AltiumScene3dPlacementRotationPolicy.shouldCorrectYaw({
|
|
211
|
+
placement: nextPlacement,
|
|
212
|
+
component: resolvedComponent,
|
|
213
|
+
componentBody,
|
|
214
|
+
pads,
|
|
215
|
+
isExactAnchoredOwner
|
|
216
|
+
})
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return AltiumScene3dExternalPlacementAdapter.#withContactPadHints(
|
|
220
|
+
repairedPlacement,
|
|
221
|
+
resolvedComponent,
|
|
222
|
+
pads,
|
|
223
|
+
board
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Adds pad contact hints for mixed SMT/mechanical connector footprints. */
|
|
228
|
+
static #withContactPadHints(placement, component, pads, board) {
|
|
229
|
+
const contactPads =
|
|
230
|
+
AltiumScene3dExternalPlacementAdapter.#resolveContactPads(
|
|
231
|
+
placement,
|
|
232
|
+
component,
|
|
233
|
+
pads,
|
|
234
|
+
board
|
|
235
|
+
)
|
|
236
|
+
if (!contactPads.length) {
|
|
237
|
+
return placement
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
...placement,
|
|
242
|
+
modelTransform: {
|
|
243
|
+
...(placement?.modelTransform || {}),
|
|
244
|
+
contactPadsMil: contactPads
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolves board-local SMT pad centers for mixed connector footprints.
|
|
251
|
+
* @param {object} placement External model placement.
|
|
252
|
+
* @param {object | null | undefined} component Owning component.
|
|
253
|
+
* @param {object[]} pads Source PCB pads.
|
|
254
|
+
* @param {object} board Scene board metadata.
|
|
255
|
+
* @returns {{ x: number, y: number, width: number, depth: number }[]}
|
|
256
|
+
*/
|
|
257
|
+
static #resolveContactPads(placement, component, pads, board) {
|
|
258
|
+
if (
|
|
259
|
+
!component ||
|
|
260
|
+
String(placement?.mountSide || '').toLowerCase() !== 'top' ||
|
|
261
|
+
String(placement?.projection?.source || '') !==
|
|
262
|
+
'model-anchor-fallback'
|
|
263
|
+
) {
|
|
264
|
+
return []
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const componentPads =
|
|
268
|
+
AltiumScene3dExternalPlacementAdapter.#componentPads(
|
|
269
|
+
component,
|
|
270
|
+
pads
|
|
271
|
+
)
|
|
272
|
+
const surfacePads = componentPads.filter((pad) =>
|
|
273
|
+
AltiumScene3dExternalPlacementAdapter.#isTopSurfacePad(pad)
|
|
274
|
+
)
|
|
275
|
+
const mechanicalPads = componentPads.filter((pad) =>
|
|
276
|
+
AltiumScene3dExternalPlacementAdapter.#isMechanicalAnchorPad(pad)
|
|
277
|
+
)
|
|
278
|
+
if (surfacePads.length < 2 || !mechanicalPads.length) {
|
|
279
|
+
return []
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const centerX = Number(board?.centerX || 0)
|
|
283
|
+
const centerY = Number(board?.centerY || 0)
|
|
284
|
+
|
|
285
|
+
return surfacePads
|
|
286
|
+
.map((pad) => ({
|
|
287
|
+
x: Number(pad?.x || 0) - centerX,
|
|
288
|
+
y: Number(pad?.y || 0) - centerY,
|
|
289
|
+
width: Number(pad?.sizeTopX || pad?.sizeMidX || 0),
|
|
290
|
+
depth: Number(pad?.sizeTopY || pad?.sizeMidY || 0)
|
|
291
|
+
}))
|
|
292
|
+
.filter(
|
|
293
|
+
(pad) =>
|
|
294
|
+
Number.isFinite(pad.x) &&
|
|
295
|
+
Number.isFinite(pad.y) &&
|
|
296
|
+
pad.width > 0 &&
|
|
297
|
+
pad.depth > 0
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Returns pads owned by one component index.
|
|
303
|
+
* @param {object} component Owning component.
|
|
304
|
+
* @param {object[]} pads Source PCB pads.
|
|
305
|
+
* @returns {object[]}
|
|
306
|
+
*/
|
|
307
|
+
static #componentPads(component, pads) {
|
|
308
|
+
const componentIndex = Number(component?.componentIndex)
|
|
309
|
+
if (!Number.isFinite(componentIndex)) {
|
|
310
|
+
return []
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (Array.isArray(pads) ? pads : []).filter(
|
|
314
|
+
(pad) => Number(pad?.componentIndex) === componentIndex
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Checks whether a pad exposes top paste and should be soldered on the
|
|
320
|
+
* top face.
|
|
321
|
+
* @param {object} pad Source PCB pad.
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
static #isTopSurfacePad(pad) {
|
|
325
|
+
return (
|
|
326
|
+
Boolean(pad?.hasTopPasteMaskOpening) &&
|
|
327
|
+
Number(pad?.sizeTopX || 0) > 0 &&
|
|
328
|
+
Number(pad?.sizeTopY || 0) > 0
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Checks whether a pad is a non-paste mechanical lock or guide.
|
|
334
|
+
* @param {object} pad Source PCB pad.
|
|
335
|
+
* @returns {boolean}
|
|
336
|
+
*/
|
|
337
|
+
static #isMechanicalAnchorPad(pad) {
|
|
338
|
+
return (
|
|
339
|
+
!pad?.hasTopPasteMaskOpening &&
|
|
340
|
+
(Number(pad?.holeSize || 0) > 0 ||
|
|
341
|
+
Number(pad?.holeShape || 0) > 0 ||
|
|
342
|
+
Number(pad?.layerCode || 0) > 16)
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Resolves an anchor component only when the current owner is not close.
|
|
348
|
+
* @param {object} placement External model placement.
|
|
349
|
+
* @param {object | undefined} currentComponent Current matched component.
|
|
350
|
+
* @param {object[]} components PCB components.
|
|
351
|
+
* @param {boolean} currentHasMetadataAffinity Whether source metadata confirms the current owner.
|
|
352
|
+
* @param {boolean} currentHasPartCodeAffinity Whether a strong part code confirms the current owner.
|
|
353
|
+
* @param {object | null} componentBody Source component body.
|
|
354
|
+
* @returns {object | null}
|
|
355
|
+
*/
|
|
356
|
+
static #resolveAnchorComponent(
|
|
357
|
+
placement,
|
|
358
|
+
currentComponent,
|
|
359
|
+
components,
|
|
360
|
+
currentHasMetadataAffinity,
|
|
361
|
+
currentHasPartCodeAffinity,
|
|
362
|
+
componentBody
|
|
363
|
+
) {
|
|
364
|
+
const currentDistance = currentComponent
|
|
365
|
+
? AltiumScene3dExternalPlacementAdapter.#distanceToBody(
|
|
366
|
+
placement,
|
|
367
|
+
currentComponent
|
|
368
|
+
)
|
|
369
|
+
: Number.POSITIVE_INFINITY
|
|
370
|
+
if (
|
|
371
|
+
currentComponent &&
|
|
372
|
+
currentDistance <=
|
|
373
|
+
AltiumScene3dExternalPlacementAdapter
|
|
374
|
+
.#EXACT_ANCHOR_TOLERANCE_MIL
|
|
375
|
+
) {
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const exactComponent =
|
|
380
|
+
AltiumScene3dExternalPlacementAdapter.#nearestAnchorComponent(
|
|
381
|
+
placement,
|
|
382
|
+
components,
|
|
383
|
+
AltiumScene3dExternalPlacementAdapter
|
|
384
|
+
.#EXACT_ANCHOR_TOLERANCE_MIL
|
|
385
|
+
)
|
|
386
|
+
if (exactComponent) {
|
|
387
|
+
if (
|
|
388
|
+
currentHasMetadataAffinity &&
|
|
389
|
+
!AltiumScene3dExternalPlacementAdapter.#isGenericPassiveComponent(
|
|
390
|
+
currentComponent
|
|
391
|
+
) &&
|
|
392
|
+
AltiumScene3dExternalPlacementAdapter.#isGenericPassiveComponent(
|
|
393
|
+
exactComponent
|
|
394
|
+
) &&
|
|
395
|
+
AltiumScene3dExternalPlacementAdapter.#metadataScore(
|
|
396
|
+
AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
|
|
397
|
+
placement,
|
|
398
|
+
componentBody
|
|
399
|
+
),
|
|
400
|
+
exactComponent
|
|
401
|
+
) === 0
|
|
402
|
+
) {
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return exactComponent
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (
|
|
410
|
+
currentDistance <=
|
|
411
|
+
AltiumScene3dExternalPlacementAdapter.#FAR_OWNER_DISTANCE_MIL
|
|
412
|
+
) {
|
|
413
|
+
return null
|
|
414
|
+
}
|
|
415
|
+
if (currentHasPartCodeAffinity) {
|
|
416
|
+
return null
|
|
417
|
+
}
|
|
418
|
+
if (
|
|
419
|
+
String(placement?.projection?.source || '') ===
|
|
420
|
+
'model-anchor-fallback'
|
|
421
|
+
) {
|
|
422
|
+
return null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return AltiumScene3dExternalPlacementAdapter.#nearestAnchorComponent(
|
|
426
|
+
placement,
|
|
427
|
+
components,
|
|
428
|
+
AltiumScene3dExternalPlacementAdapter.#NEAR_ANCHOR_TOLERANCE_MIL
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Repairs orientation fields once a source body and owner are known.
|
|
434
|
+
* @param {object} placement External model placement.
|
|
435
|
+
* @param {object | null | undefined} component Owning component.
|
|
436
|
+
* @param {object | null} componentBody Source component body.
|
|
437
|
+
* @param {boolean} useComponentYaw Whether component yaw should override body yaw.
|
|
438
|
+
* @param {boolean} correctPinOneYaw Whether a square IC pin-one correction applies.
|
|
439
|
+
* @returns {object}
|
|
440
|
+
*/
|
|
441
|
+
static #repairRotation(
|
|
442
|
+
placement,
|
|
443
|
+
component,
|
|
444
|
+
componentBody,
|
|
445
|
+
useComponentYaw,
|
|
446
|
+
correctPinOneYaw
|
|
447
|
+
) {
|
|
448
|
+
const modelTransform =
|
|
449
|
+
AltiumScene3dExternalPlacementAdapter.#repairModelTransform(
|
|
450
|
+
placement?.modelTransform,
|
|
451
|
+
componentBody
|
|
452
|
+
)
|
|
453
|
+
const isGenericPassiveBody =
|
|
454
|
+
AltiumScene3dExternalPlacementAdapter.#isGenericPassiveBody(
|
|
455
|
+
componentBody
|
|
456
|
+
)
|
|
457
|
+
const componentYaw =
|
|
458
|
+
AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
|
|
459
|
+
Number(component?.rotation || 0)
|
|
460
|
+
)
|
|
461
|
+
const placementYaw =
|
|
462
|
+
AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
|
|
463
|
+
Number(placement?.rotationDeg || 0)
|
|
464
|
+
)
|
|
465
|
+
const baseRotation =
|
|
466
|
+
component && useComponentYaw && !isGenericPassiveBody
|
|
467
|
+
? componentYaw
|
|
468
|
+
: placementYaw
|
|
469
|
+
const rotationDeg =
|
|
470
|
+
correctPinOneYaw && !isGenericPassiveBody
|
|
471
|
+
? AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
|
|
472
|
+
baseRotation + 180
|
|
473
|
+
)
|
|
474
|
+
: baseRotation
|
|
475
|
+
if (!component || !useComponentYaw || isGenericPassiveBody) {
|
|
476
|
+
return {
|
|
477
|
+
...placement,
|
|
478
|
+
rotationDeg,
|
|
479
|
+
modelTransform
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
...placement,
|
|
485
|
+
rotationDeg,
|
|
486
|
+
modelTransform
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Finds the nearest component whose anchor is effectively the body anchor.
|
|
492
|
+
* @param {object} placement External model placement.
|
|
493
|
+
* @param {object[]} components PCB components.
|
|
494
|
+
* @returns {object | null}
|
|
495
|
+
*/
|
|
496
|
+
static #nearestAnchorComponent(placement, components, toleranceMil) {
|
|
497
|
+
const candidates = components
|
|
498
|
+
.map((component) => ({
|
|
499
|
+
component,
|
|
500
|
+
distance: AltiumScene3dExternalPlacementAdapter.#distanceToBody(
|
|
501
|
+
placement,
|
|
502
|
+
component
|
|
503
|
+
)
|
|
504
|
+
}))
|
|
505
|
+
.filter((candidate) => candidate.distance <= toleranceMil)
|
|
506
|
+
.sort((left, right) => left.distance - right.distance)
|
|
507
|
+
return candidates[0]?.component || null
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Builds package metadata text for generic package-family checks.
|
|
512
|
+
* @param {object} component PCB component.
|
|
513
|
+
* @param {object | null} componentBody Source component body.
|
|
514
|
+
* @returns {string}
|
|
515
|
+
*/
|
|
516
|
+
static #packageIdentityText(component, componentBody) {
|
|
517
|
+
const parameterValues = Object.values(component?.parameters || {})
|
|
518
|
+
.map((value) => String(value || ''))
|
|
519
|
+
.join(' ')
|
|
520
|
+
|
|
521
|
+
return [
|
|
522
|
+
component?.pattern,
|
|
523
|
+
component?.source,
|
|
524
|
+
component?.description,
|
|
525
|
+
component?.provenance?.footprintDescription,
|
|
526
|
+
parameterValues,
|
|
527
|
+
componentBody?.identifier,
|
|
528
|
+
componentBody?.name
|
|
529
|
+
]
|
|
530
|
+
.map((value) => String(value || ''))
|
|
531
|
+
.join(' ')
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Matches a standalone offset body back to a component from metadata.
|
|
536
|
+
* @param {object} placement External model placement.
|
|
537
|
+
* @param {object | null} componentBody Source component body.
|
|
538
|
+
* @param {object[]} components PCB components.
|
|
539
|
+
* @returns {object | null}
|
|
540
|
+
*/
|
|
541
|
+
static #resolveMetadataComponent(placement, componentBody, components) {
|
|
542
|
+
if (
|
|
543
|
+
AltiumScene3dExternalPlacementAdapter.#isGenericPassiveBody(
|
|
544
|
+
componentBody
|
|
545
|
+
)
|
|
546
|
+
) {
|
|
547
|
+
return null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const tokens =
|
|
551
|
+
AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
|
|
552
|
+
placement,
|
|
553
|
+
componentBody
|
|
554
|
+
)
|
|
555
|
+
if (!tokens.length) {
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const candidates = components
|
|
560
|
+
.map((component) => ({
|
|
561
|
+
component,
|
|
562
|
+
score: AltiumScene3dExternalPlacementAdapter.#metadataScore(
|
|
563
|
+
tokens,
|
|
564
|
+
component
|
|
565
|
+
),
|
|
566
|
+
distance: AltiumScene3dExternalPlacementAdapter.#distanceToBody(
|
|
567
|
+
placement,
|
|
568
|
+
component
|
|
569
|
+
)
|
|
570
|
+
}))
|
|
571
|
+
.filter((candidate) => candidate.score > 0)
|
|
572
|
+
.sort(
|
|
573
|
+
(left, right) =>
|
|
574
|
+
right.score - left.score || left.distance - right.distance
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return candidates[0]?.component || null
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Checks whether a weak far owner is still supported by source metadata.
|
|
582
|
+
* @param {object} placement External model placement.
|
|
583
|
+
* @param {object | null} componentBody Source component body.
|
|
584
|
+
* @param {object} component PCB component.
|
|
585
|
+
* @returns {boolean}
|
|
586
|
+
*/
|
|
587
|
+
static #hasMetadataAffinity(placement, componentBody, component) {
|
|
588
|
+
const tokens =
|
|
589
|
+
AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
|
|
590
|
+
placement,
|
|
591
|
+
componentBody
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
tokens.length > 0 &&
|
|
596
|
+
AltiumScene3dExternalPlacementAdapter.#metadataScore(
|
|
597
|
+
tokens,
|
|
598
|
+
component
|
|
599
|
+
) > 0
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Checks whether source metadata confirms ownership with a part-like code
|
|
605
|
+
* instead of only generic package words.
|
|
606
|
+
* @param {object} placement External model placement.
|
|
607
|
+
* @param {object | null} componentBody Source component body.
|
|
608
|
+
* @param {object} component PCB component.
|
|
609
|
+
* @returns {boolean}
|
|
610
|
+
*/
|
|
611
|
+
static #hasPartCodeAffinity(placement, componentBody, component) {
|
|
612
|
+
const haystack =
|
|
613
|
+
AltiumScene3dExternalPlacementAdapter.#metadataHaystack(component)
|
|
614
|
+
|
|
615
|
+
return AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
|
|
616
|
+
placement,
|
|
617
|
+
componentBody
|
|
618
|
+
).some(
|
|
619
|
+
(token) =>
|
|
620
|
+
/[a-z]/u.test(token) &&
|
|
621
|
+
/\d/u.test(token) &&
|
|
622
|
+
haystack.includes(token)
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Scores one component against body identity tokens.
|
|
628
|
+
* @param {string[]} tokens Body identity tokens.
|
|
629
|
+
* @param {object} component PCB component.
|
|
630
|
+
* @returns {number}
|
|
631
|
+
*/
|
|
632
|
+
static #metadataScore(tokens, component) {
|
|
633
|
+
const haystack =
|
|
634
|
+
AltiumScene3dExternalPlacementAdapter.#metadataHaystack(component)
|
|
635
|
+
|
|
636
|
+
return tokens.reduce(
|
|
637
|
+
(score, token) =>
|
|
638
|
+
score + (haystack.includes(token) ? token.length : 0),
|
|
639
|
+
0
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Builds searchable component metadata text.
|
|
645
|
+
* @param {object} component PCB component.
|
|
646
|
+
* @returns {string}
|
|
647
|
+
*/
|
|
648
|
+
static #metadataHaystack(component) {
|
|
649
|
+
const parameterValues = Object.values(component?.parameters || {})
|
|
650
|
+
.map((value) => String(value || ''))
|
|
651
|
+
.join(' ')
|
|
652
|
+
|
|
653
|
+
return AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
|
|
654
|
+
component?.designator,
|
|
655
|
+
component?.pattern,
|
|
656
|
+
component?.source,
|
|
657
|
+
component?.modelPath,
|
|
658
|
+
parameterValues
|
|
659
|
+
])
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Collects body identity tokens suitable for exact metadata matching.
|
|
664
|
+
* @param {object} placement External model placement.
|
|
665
|
+
* @param {object | null} componentBody Source component body.
|
|
666
|
+
* @returns {string[]}
|
|
667
|
+
*/
|
|
668
|
+
static #bodyIdentityTokens(placement, componentBody) {
|
|
669
|
+
const text = [
|
|
670
|
+
placement?.designator,
|
|
671
|
+
placement?.externalModel?.name,
|
|
672
|
+
placement?.externalModel?.relativePath,
|
|
673
|
+
componentBody?.identifier,
|
|
674
|
+
componentBody?.name
|
|
675
|
+
]
|
|
676
|
+
.map((value) =>
|
|
677
|
+
String(value || '')
|
|
678
|
+
.replace(/\.[^.]+$/, '')
|
|
679
|
+
.trim()
|
|
680
|
+
)
|
|
681
|
+
.filter(Boolean)
|
|
682
|
+
|
|
683
|
+
return [
|
|
684
|
+
...new Set(
|
|
685
|
+
text.flatMap((value) =>
|
|
686
|
+
AltiumScene3dIdentityTokens.fromText(value)
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
]
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Resolves the source component body row for one placement.
|
|
694
|
+
* @param {object} placement External model placement.
|
|
695
|
+
* @param {object[]} componentBodies Source component body rows.
|
|
696
|
+
* @returns {object | null}
|
|
697
|
+
*/
|
|
698
|
+
static #resolveComponentBody(placement, componentBodies) {
|
|
699
|
+
const candidates = componentBodies
|
|
700
|
+
.map((componentBody) => ({
|
|
701
|
+
componentBody,
|
|
702
|
+
distance:
|
|
703
|
+
AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
|
|
704
|
+
placement?.bodyPositionMil,
|
|
705
|
+
componentBody?.positionMil
|
|
706
|
+
),
|
|
707
|
+
identityScore:
|
|
708
|
+
AltiumScene3dExternalPlacementAdapter.#bodyPlacementIdentityScore(
|
|
709
|
+
placement,
|
|
710
|
+
componentBody
|
|
711
|
+
)
|
|
712
|
+
}))
|
|
713
|
+
.filter((candidate) => candidate.distance <= 0.01)
|
|
714
|
+
.sort(
|
|
715
|
+
(left, right) =>
|
|
716
|
+
right.identityScore - left.identityScore ||
|
|
717
|
+
left.distance - right.distance
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
return candidates[0]?.componentBody || null
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Scores whether a source body row belongs to one placement.
|
|
725
|
+
* @param {object} placement External model placement.
|
|
726
|
+
* @param {object} componentBody Source component body.
|
|
727
|
+
* @returns {number}
|
|
728
|
+
*/
|
|
729
|
+
static #bodyPlacementIdentityScore(placement, componentBody) {
|
|
730
|
+
const placementText =
|
|
731
|
+
AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
|
|
732
|
+
placement?.designator,
|
|
733
|
+
placement?.externalModel?.name
|
|
734
|
+
])
|
|
735
|
+
const bodyText =
|
|
736
|
+
AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
|
|
737
|
+
componentBody?.identifier,
|
|
738
|
+
componentBody?.name
|
|
739
|
+
])
|
|
740
|
+
|
|
741
|
+
return placementText && bodyText && placementText.includes(bodyText)
|
|
742
|
+
? bodyText.length
|
|
743
|
+
: 0
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Repairs model-local Altium rotation signs for embedded body transforms.
|
|
748
|
+
* @param {object | null | undefined} modelTransform Placement transform.
|
|
749
|
+
* @param {object | null} componentBody Source component body.
|
|
750
|
+
* @returns {object | null | undefined}
|
|
751
|
+
*/
|
|
752
|
+
static #repairModelTransform(modelTransform, componentBody) {
|
|
753
|
+
const rotationDeg = modelTransform?.rotationDeg || {}
|
|
754
|
+
const repairedTransform = {
|
|
755
|
+
...(modelTransform || {}),
|
|
756
|
+
dzMil: AltiumScene3dExternalPlacementAdapter.#repairVerticalOffset(
|
|
757
|
+
modelTransform,
|
|
758
|
+
componentBody
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
if (modelTransform?.offsetMil) {
|
|
762
|
+
repairedTransform.offsetMil = {
|
|
763
|
+
...modelTransform.offsetMil,
|
|
764
|
+
z: repairedTransform.dzMil
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return {
|
|
769
|
+
...repairedTransform,
|
|
770
|
+
rotationDeg: {
|
|
771
|
+
...rotationDeg,
|
|
772
|
+
x: Number(rotationDeg.x ?? 0),
|
|
773
|
+
y: Number(rotationDeg.y ?? 0),
|
|
774
|
+
z: Number(rotationDeg.z ?? 0)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Clamps only negative standoffs that exceed the source model's height
|
|
781
|
+
* envelope and would sink a seated STEP model through the PCB face.
|
|
782
|
+
* @param {object | null | undefined} modelTransform Placement transform.
|
|
783
|
+
* @param {object | null} componentBody Source component body.
|
|
784
|
+
* @returns {number}
|
|
785
|
+
*/
|
|
786
|
+
static #repairVerticalOffset(modelTransform, componentBody) {
|
|
787
|
+
const offsetMil = modelTransform?.offsetMil || {}
|
|
788
|
+
const value = Number(offsetMil.z ?? modelTransform?.dzMil ?? 0)
|
|
789
|
+
if (!Number.isFinite(value)) {
|
|
790
|
+
return 0
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (value >= 0) {
|
|
794
|
+
return value
|
|
795
|
+
}
|
|
796
|
+
const overallHeight = Number(componentBody?.overallHeightMil || 0)
|
|
797
|
+
if (overallHeight > 0 && Math.abs(value) < overallHeight) {
|
|
798
|
+
return value
|
|
799
|
+
}
|
|
800
|
+
return 0
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Checks whether a body is a generic passive package where body yaw is safe.
|
|
805
|
+
* @param {object | null} componentBody Source component body.
|
|
806
|
+
* @returns {boolean}
|
|
807
|
+
*/
|
|
808
|
+
static #isGenericPassiveBody(componentBody) {
|
|
809
|
+
return AltiumScene3dExternalPlacementAdapter.#PASSIVE_BODY_PATTERN.test(
|
|
810
|
+
[componentBody?.identifier, componentBody?.name].join(' ')
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Checks whether a component's footprint metadata looks like a generic
|
|
816
|
+
* passive package.
|
|
817
|
+
* @param {object | null | undefined} component PCB component.
|
|
818
|
+
* @returns {boolean}
|
|
819
|
+
*/
|
|
820
|
+
static #isGenericPassiveComponent(component) {
|
|
821
|
+
const parameterValues = Object.values(component?.parameters || {})
|
|
822
|
+
.map((value) => String(value || ''))
|
|
823
|
+
.join(' ')
|
|
824
|
+
|
|
825
|
+
return AltiumScene3dExternalPlacementAdapter.#PASSIVE_BODY_PATTERN.test(
|
|
826
|
+
[
|
|
827
|
+
component?.pattern,
|
|
828
|
+
component?.source,
|
|
829
|
+
component?.modelPath,
|
|
830
|
+
parameterValues
|
|
831
|
+
].join(' ')
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Measures the XY distance between one body anchor and one component.
|
|
837
|
+
* @param {object} placement External model placement.
|
|
838
|
+
* @param {object} component PCB component.
|
|
839
|
+
* @returns {number}
|
|
840
|
+
*/
|
|
841
|
+
static #distanceToBody(placement, component) {
|
|
842
|
+
return AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
|
|
843
|
+
{ x: component?.x, y: component?.y },
|
|
844
|
+
placement?.bodyPositionMil
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Measures the XY distance between two points.
|
|
850
|
+
* @param {{ x?: number, y?: number } | null | undefined} first First point.
|
|
851
|
+
* @param {{ x?: number, y?: number } | null | undefined} second Second point.
|
|
852
|
+
* @returns {number}
|
|
853
|
+
*/
|
|
854
|
+
static #distanceBetweenPoints(first, second) {
|
|
855
|
+
return Math.hypot(
|
|
856
|
+
Number(first?.x || 0) - Number(second?.x || 0),
|
|
857
|
+
Number(first?.y || 0) - Number(second?.y || 0)
|
|
858
|
+
)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Resolves one component's board side from its layer.
|
|
863
|
+
* @param {object} component PCB component.
|
|
864
|
+
* @returns {'top' | 'bottom' | null}
|
|
865
|
+
*/
|
|
866
|
+
static #resolveComponentMountSide(component) {
|
|
867
|
+
const layer = String(component?.layer || '').toUpperCase()
|
|
868
|
+
if (layer.includes('BOTTOM') || layer === 'BOT') {
|
|
869
|
+
return 'bottom'
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (layer.includes('TOP')) {
|
|
873
|
+
return 'top'
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return null
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Resolves the board face Z coordinate for one mount side.
|
|
881
|
+
* @param {string} mountSide Mount side.
|
|
882
|
+
* @param {object} board Scene board metadata.
|
|
883
|
+
* @returns {number}
|
|
884
|
+
*/
|
|
885
|
+
static #resolveFaceZ(mountSide, board) {
|
|
886
|
+
const thickness =
|
|
887
|
+
Number(board?.thicknessMil) ||
|
|
888
|
+
AltiumScene3dExternalPlacementAdapter.#DEFAULT_BOARD_THICKNESS_MIL
|
|
889
|
+
const halfThickness = thickness / 2
|
|
890
|
+
|
|
891
|
+
return String(mountSide || '').toLowerCase() === 'bottom'
|
|
892
|
+
? -halfThickness
|
|
893
|
+
: halfThickness
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Normalizes one angle into [0, 360).
|
|
898
|
+
* @param {number} angle Source angle.
|
|
899
|
+
* @returns {number}
|
|
900
|
+
*/
|
|
901
|
+
static #normalizeAngle(angle) {
|
|
902
|
+
const normalized = Number(angle || 0) % 360
|
|
903
|
+
|
|
904
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Normalizes identity strings for exact substring matching.
|
|
909
|
+
* @param {unknown[]} values Source values.
|
|
910
|
+
* @returns {string}
|
|
911
|
+
*/
|
|
912
|
+
static #normalizeIdentityText(values) {
|
|
913
|
+
return values
|
|
914
|
+
.map((value) => String(value || '').toLowerCase())
|
|
915
|
+
.join(' ')
|
|
916
|
+
.replace(/\.[a-z0-9]+\\b/g, '')
|
|
917
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
918
|
+
}
|
|
919
|
+
}
|