altium-toolkit 1.1.32 → 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/AltiumGeneratedLibraryRecordBuilder.mjs +551 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +286 -2
- package/src/core/altium/AltiumPcbLibExporter.mjs +9 -2
- package/src/core/altium/AltiumSchLibExporter.mjs +1 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +52 -4
- 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 +27 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +782 -0
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +595 -28
- 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/PcbScene3dPlacementSideResolver.mjs +56 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +638 -16
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
const TIMING_PACKAGE_PATTERN =
|
|
2
|
+
/(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
|
|
3
|
+
const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keeps authored shape-based sub-bodies grouped under their carrier owner.
|
|
7
|
+
*/
|
|
8
|
+
export class AltiumScene3dShapeStackOwnerAdapter {
|
|
9
|
+
static #BASE_OWNER_TOLERANCE_MIL = 60
|
|
10
|
+
static #EXACT_OWNER_TOLERANCE_MIL = 1
|
|
11
|
+
static #STACK_BODY_RADIUS_MIL = 220
|
|
12
|
+
static #HEIGHT_TOLERANCE_MIL = 0.1
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Applies authored stack ownership to static and external placements.
|
|
16
|
+
* @param {object} sceneDescription Built scene description.
|
|
17
|
+
* @param {object} documentModel Source document model.
|
|
18
|
+
* @returns {object}
|
|
19
|
+
*/
|
|
20
|
+
static apply(sceneDescription, documentModel) {
|
|
21
|
+
if (
|
|
22
|
+
String(sceneDescription?.sourceFormat || '').toLowerCase() !==
|
|
23
|
+
'altium'
|
|
24
|
+
) {
|
|
25
|
+
return sceneDescription
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const components = Array.isArray(documentModel?.pcb?.components)
|
|
29
|
+
? documentModel.pcb.components
|
|
30
|
+
: []
|
|
31
|
+
const componentBodies = Array.isArray(
|
|
32
|
+
documentModel?.pcb?.componentBodies
|
|
33
|
+
)
|
|
34
|
+
? documentModel.pcb.componentBodies
|
|
35
|
+
: []
|
|
36
|
+
if (!components.length || !componentBodies.length) {
|
|
37
|
+
return sceneDescription
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const assignments =
|
|
41
|
+
AltiumScene3dShapeStackOwnerAdapter.#carrierAssignments(
|
|
42
|
+
componentBodies,
|
|
43
|
+
components
|
|
44
|
+
)
|
|
45
|
+
if (!assignments.length) {
|
|
46
|
+
return sceneDescription
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ownerGroupKeys = new Map(
|
|
50
|
+
assignments.map((assignment) => [
|
|
51
|
+
String(assignment.owner.designator || ''),
|
|
52
|
+
assignment.groupKey
|
|
53
|
+
])
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...sceneDescription,
|
|
58
|
+
components: AltiumScene3dShapeStackOwnerAdapter.#markComponents(
|
|
59
|
+
sceneDescription.components,
|
|
60
|
+
ownerGroupKeys
|
|
61
|
+
),
|
|
62
|
+
externalPlacements:
|
|
63
|
+
AltiumScene3dShapeStackOwnerAdapter.#repairExternalPlacements(
|
|
64
|
+
sceneDescription.externalPlacements,
|
|
65
|
+
componentBodies,
|
|
66
|
+
assignments,
|
|
67
|
+
sceneDescription.board
|
|
68
|
+
),
|
|
69
|
+
staticBodyPlacements:
|
|
70
|
+
AltiumScene3dShapeStackOwnerAdapter.#repairStaticPlacements(
|
|
71
|
+
sceneDescription.staticBodyPlacements,
|
|
72
|
+
componentBodies,
|
|
73
|
+
assignments,
|
|
74
|
+
sceneDescription.board
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Marks stack owners so the viewer skips their generated fallback body.
|
|
81
|
+
* @param {object[] | undefined} components Scene components.
|
|
82
|
+
* @param {Map<string, string>} ownerGroupKeys Authored stack owners.
|
|
83
|
+
* @returns {object[]}
|
|
84
|
+
*/
|
|
85
|
+
static #markComponents(components, ownerGroupKeys) {
|
|
86
|
+
return (Array.isArray(components) ? components : []).map((component) =>
|
|
87
|
+
ownerGroupKeys.has(String(component?.designator || ''))
|
|
88
|
+
? {
|
|
89
|
+
...component,
|
|
90
|
+
renderFallbackBody: false,
|
|
91
|
+
coLocatedVariantGroupKey: ownerGroupKeys.get(
|
|
92
|
+
String(component?.designator || '')
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
: component
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Repairs external model ownership for authored stack sub-bodies.
|
|
101
|
+
* @param {object[] | undefined} placements Scene external placements.
|
|
102
|
+
* @param {object[]} componentBodies Source component-body rows.
|
|
103
|
+
* @param {object[]} assignments Carrier assignments.
|
|
104
|
+
* @param {object | undefined} board Scene board metadata.
|
|
105
|
+
* @returns {object[]}
|
|
106
|
+
*/
|
|
107
|
+
static #repairExternalPlacements(
|
|
108
|
+
placements,
|
|
109
|
+
componentBodies,
|
|
110
|
+
assignments,
|
|
111
|
+
board
|
|
112
|
+
) {
|
|
113
|
+
const usedComponentBodies = new Set()
|
|
114
|
+
|
|
115
|
+
return (Array.isArray(placements) ? placements : []).map(
|
|
116
|
+
(placement) => {
|
|
117
|
+
const componentBody =
|
|
118
|
+
AltiumScene3dShapeStackOwnerAdapter.#resolveComponentBody(
|
|
119
|
+
placement,
|
|
120
|
+
componentBodies,
|
|
121
|
+
usedComponentBodies
|
|
122
|
+
)
|
|
123
|
+
if (componentBody) {
|
|
124
|
+
usedComponentBodies.add(componentBody)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const assignment =
|
|
128
|
+
AltiumScene3dShapeStackOwnerAdapter.#assignmentForBody(
|
|
129
|
+
componentBody,
|
|
130
|
+
assignments
|
|
131
|
+
)
|
|
132
|
+
if (!assignment) {
|
|
133
|
+
return placement
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return AltiumScene3dShapeStackOwnerAdapter.#withOwner(
|
|
137
|
+
placement,
|
|
138
|
+
componentBody,
|
|
139
|
+
assignment,
|
|
140
|
+
board
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Repairs carrier static body ownership.
|
|
148
|
+
* @param {object[] | undefined} placements Scene static body placements.
|
|
149
|
+
* @param {object[]} componentBodies Source component-body rows.
|
|
150
|
+
* @param {object[]} assignments Carrier assignments.
|
|
151
|
+
* @param {object | undefined} board Scene board metadata.
|
|
152
|
+
* @returns {object[]}
|
|
153
|
+
*/
|
|
154
|
+
static #repairStaticPlacements(
|
|
155
|
+
placements,
|
|
156
|
+
componentBodies,
|
|
157
|
+
assignments,
|
|
158
|
+
board
|
|
159
|
+
) {
|
|
160
|
+
return (Array.isArray(placements) ? placements : []).map(
|
|
161
|
+
(placement) => {
|
|
162
|
+
const assignment = assignments.find((candidate) =>
|
|
163
|
+
AltiumScene3dShapeStackOwnerAdapter.#isSameStaticBody(
|
|
164
|
+
placement,
|
|
165
|
+
candidate.base
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if (!assignment) {
|
|
169
|
+
return placement
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
...placement,
|
|
174
|
+
designator: String(assignment.owner.designator || ''),
|
|
175
|
+
positionMil:
|
|
176
|
+
AltiumScene3dShapeStackOwnerAdapter.#withOwnerAnchor(
|
|
177
|
+
placement?.positionMil,
|
|
178
|
+
assignment.owner,
|
|
179
|
+
board
|
|
180
|
+
),
|
|
181
|
+
coLocatedVariantGroupKey: assignment.groupKey
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Re-anchors a repaired static carrier on the owning component position.
|
|
189
|
+
* @param {object | undefined} position Existing render position.
|
|
190
|
+
* @param {{ x?: number, y?: number }} owner Owner component.
|
|
191
|
+
* @param {object | undefined} board Scene board metadata.
|
|
192
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
193
|
+
*/
|
|
194
|
+
static #withOwnerAnchor(position, owner, board) {
|
|
195
|
+
return {
|
|
196
|
+
...(position || {}),
|
|
197
|
+
x: AltiumScene3dShapeStackOwnerAdapter.#round(
|
|
198
|
+
Number(owner?.x || 0) - Number(board?.centerX || 0)
|
|
199
|
+
),
|
|
200
|
+
y: AltiumScene3dShapeStackOwnerAdapter.#round(
|
|
201
|
+
Number(owner?.y || 0) - Number(board?.centerY || 0)
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Builds carrier-base to owner-component assignments.
|
|
208
|
+
* @param {object[]} componentBodies Source component-body rows.
|
|
209
|
+
* @param {object[]} components Source components.
|
|
210
|
+
* @returns {{ base: object, owner: object, heightMil: number, groupKey: string }[]}
|
|
211
|
+
*/
|
|
212
|
+
static #carrierAssignments(componentBodies, components) {
|
|
213
|
+
const bases = componentBodies.filter((body) =>
|
|
214
|
+
AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(body)
|
|
215
|
+
)
|
|
216
|
+
const timingComponents = components.filter((component) =>
|
|
217
|
+
AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(component)
|
|
218
|
+
)
|
|
219
|
+
const groups =
|
|
220
|
+
AltiumScene3dShapeStackOwnerAdapter.#groupCarrierBases(bases)
|
|
221
|
+
|
|
222
|
+
return groups.flatMap((group) => {
|
|
223
|
+
if (
|
|
224
|
+
AltiumScene3dShapeStackOwnerAdapter.#hasExactNonTimingOwner(
|
|
225
|
+
group.anchor,
|
|
226
|
+
components
|
|
227
|
+
)
|
|
228
|
+
) {
|
|
229
|
+
return []
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const owners = timingComponents
|
|
233
|
+
.filter(
|
|
234
|
+
(component) =>
|
|
235
|
+
AltiumScene3dShapeStackOwnerAdapter.#distance(
|
|
236
|
+
group.anchor,
|
|
237
|
+
component
|
|
238
|
+
) <=
|
|
239
|
+
AltiumScene3dShapeStackOwnerAdapter
|
|
240
|
+
.#BASE_OWNER_TOLERANCE_MIL
|
|
241
|
+
)
|
|
242
|
+
.sort(
|
|
243
|
+
(left, right) =>
|
|
244
|
+
AltiumScene3dShapeStackOwnerAdapter.#ownerRank(right) -
|
|
245
|
+
AltiumScene3dShapeStackOwnerAdapter.#ownerRank(
|
|
246
|
+
left
|
|
247
|
+
) ||
|
|
248
|
+
String(left?.designator || '').localeCompare(
|
|
249
|
+
String(right?.designator || '')
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if (!owners.length) {
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
const groupKey = AltiumScene3dShapeStackOwnerAdapter.#groupKey(
|
|
256
|
+
group.anchor
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return group.bases
|
|
260
|
+
.slice()
|
|
261
|
+
.sort(
|
|
262
|
+
(left, right) =>
|
|
263
|
+
Number(
|
|
264
|
+
left?.staticGeometry?.heightMil ??
|
|
265
|
+
left?.overallHeightMil ??
|
|
266
|
+
0
|
|
267
|
+
) -
|
|
268
|
+
Number(
|
|
269
|
+
right?.staticGeometry?.heightMil ??
|
|
270
|
+
right?.overallHeightMil ??
|
|
271
|
+
0
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
.map((base, index) => ({
|
|
275
|
+
base,
|
|
276
|
+
owner: owners[Math.min(index, owners.length - 1)],
|
|
277
|
+
groupKey,
|
|
278
|
+
heightMil: Number(
|
|
279
|
+
base?.staticGeometry?.heightMil ??
|
|
280
|
+
base?.overallHeightMil ??
|
|
281
|
+
0
|
|
282
|
+
)
|
|
283
|
+
}))
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Checks whether one carrier base already sits on a non-timing component.
|
|
289
|
+
* @param {{ x?: number, y?: number }} anchor Carrier base anchor.
|
|
290
|
+
* @param {object[]} components Source components.
|
|
291
|
+
* @returns {boolean}
|
|
292
|
+
*/
|
|
293
|
+
static #hasExactNonTimingOwner(anchor, components) {
|
|
294
|
+
const exactOwners = (
|
|
295
|
+
Array.isArray(components) ? components : []
|
|
296
|
+
).filter(
|
|
297
|
+
(component) =>
|
|
298
|
+
AltiumScene3dShapeStackOwnerAdapter.#distance(
|
|
299
|
+
anchor,
|
|
300
|
+
component
|
|
301
|
+
) <=
|
|
302
|
+
AltiumScene3dShapeStackOwnerAdapter.#EXACT_OWNER_TOLERANCE_MIL
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
exactOwners.some(
|
|
307
|
+
(component) =>
|
|
308
|
+
!AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
|
|
309
|
+
component
|
|
310
|
+
)
|
|
311
|
+
) &&
|
|
312
|
+
!exactOwners.some((component) =>
|
|
313
|
+
AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
|
|
314
|
+
component
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Groups carrier bases that share one source anchor.
|
|
322
|
+
* @param {object[]} bases Candidate carrier bases.
|
|
323
|
+
* @returns {{ anchor: { x: number, y: number }, bases: object[] }[]}
|
|
324
|
+
*/
|
|
325
|
+
static #groupCarrierBases(bases) {
|
|
326
|
+
const groups = []
|
|
327
|
+
|
|
328
|
+
bases.forEach((base) => {
|
|
329
|
+
const anchor = AltiumScene3dShapeStackOwnerAdapter.#point(
|
|
330
|
+
base?.positionMil
|
|
331
|
+
)
|
|
332
|
+
const group = groups.find(
|
|
333
|
+
(candidate) =>
|
|
334
|
+
AltiumScene3dShapeStackOwnerAdapter.#distance(
|
|
335
|
+
candidate.anchor,
|
|
336
|
+
anchor
|
|
337
|
+
) <= 1
|
|
338
|
+
)
|
|
339
|
+
if (group) {
|
|
340
|
+
group.bases.push(base)
|
|
341
|
+
} else {
|
|
342
|
+
groups.push({ anchor, bases: [base] })
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return groups
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Checks whether one body row describes an authored carrier base.
|
|
351
|
+
* @param {object} componentBody Source component-body row.
|
|
352
|
+
* @returns {boolean}
|
|
353
|
+
*/
|
|
354
|
+
static #isCarrierBase(componentBody) {
|
|
355
|
+
const geometry = componentBody?.staticGeometry || {}
|
|
356
|
+
return (
|
|
357
|
+
AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
|
|
358
|
+
componentBody
|
|
359
|
+
) &&
|
|
360
|
+
!componentBody?.embedded &&
|
|
361
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
362
|
+
String(geometry?.status || '').toLowerCase() === 'complete' &&
|
|
363
|
+
Number(geometry?.heightMil ?? componentBody?.overallHeightMil) > 0
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Finds a carrier assignment for one positive-height sub-body.
|
|
369
|
+
* @param {object | null} componentBody Source component-body row.
|
|
370
|
+
* @param {object[]} assignments Carrier assignments.
|
|
371
|
+
* @returns {object | null}
|
|
372
|
+
*/
|
|
373
|
+
static #assignmentForBody(componentBody, assignments) {
|
|
374
|
+
if (
|
|
375
|
+
!componentBody ||
|
|
376
|
+
!AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
|
|
377
|
+
componentBody
|
|
378
|
+
) ||
|
|
379
|
+
Number(componentBody?.standoffHeightMil || 0) <= 0
|
|
380
|
+
) {
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const standoff = Number(componentBody.standoffHeightMil)
|
|
385
|
+
const bodyPosition = AltiumScene3dShapeStackOwnerAdapter.#point(
|
|
386
|
+
componentBody.positionMil
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return (
|
|
390
|
+
assignments
|
|
391
|
+
.map((assignment) => ({
|
|
392
|
+
assignment,
|
|
393
|
+
heightError: Math.abs(
|
|
394
|
+
Number(assignment.heightMil || 0) - standoff
|
|
395
|
+
),
|
|
396
|
+
distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
|
|
397
|
+
bodyPosition,
|
|
398
|
+
assignment.owner
|
|
399
|
+
)
|
|
400
|
+
}))
|
|
401
|
+
.filter(
|
|
402
|
+
(candidate) =>
|
|
403
|
+
candidate.heightError <=
|
|
404
|
+
AltiumScene3dShapeStackOwnerAdapter
|
|
405
|
+
.#HEIGHT_TOLERANCE_MIL &&
|
|
406
|
+
candidate.distance <=
|
|
407
|
+
AltiumScene3dShapeStackOwnerAdapter
|
|
408
|
+
.#STACK_BODY_RADIUS_MIL
|
|
409
|
+
)
|
|
410
|
+
.sort(
|
|
411
|
+
(left, right) =>
|
|
412
|
+
left.heightError - right.heightError ||
|
|
413
|
+
left.distance - right.distance
|
|
414
|
+
)[0]?.assignment || null
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Applies one owner component to an external placement.
|
|
420
|
+
* @param {object} placement External placement.
|
|
421
|
+
* @param {object} componentBody Source component-body row.
|
|
422
|
+
* @param {{ owner: object, groupKey: string }} assignment Stack assignment.
|
|
423
|
+
* @param {object | undefined} board Scene board metadata.
|
|
424
|
+
* @returns {object}
|
|
425
|
+
*/
|
|
426
|
+
static #withOwner(placement, componentBody, assignment, board) {
|
|
427
|
+
const owner = assignment.owner
|
|
428
|
+
const mountSide =
|
|
429
|
+
AltiumScene3dShapeStackOwnerAdapter.#mountSide(owner) ||
|
|
430
|
+
placement.mountSide
|
|
431
|
+
const rotationDeg = AltiumScene3dShapeStackOwnerAdapter.#normalizeAngle(
|
|
432
|
+
owner.rotation
|
|
433
|
+
)
|
|
434
|
+
const ownerOffset = {
|
|
435
|
+
x:
|
|
436
|
+
Number(componentBody?.positionMil?.x || 0) -
|
|
437
|
+
Number(owner?.x || 0),
|
|
438
|
+
y:
|
|
439
|
+
Number(componentBody?.positionMil?.y || 0) -
|
|
440
|
+
Number(owner?.y || 0)
|
|
441
|
+
}
|
|
442
|
+
const standoff = Number(componentBody?.standoffHeightMil || 0)
|
|
443
|
+
const renderableOffset =
|
|
444
|
+
AltiumScene3dShapeStackOwnerAdapter.#renderableOffset(
|
|
445
|
+
{ mountSide, rotationDeg },
|
|
446
|
+
ownerOffset,
|
|
447
|
+
standoff
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
...placement,
|
|
452
|
+
designator: String(owner?.designator || placement.designator),
|
|
453
|
+
mountSide,
|
|
454
|
+
rotationDeg,
|
|
455
|
+
positionMil: {
|
|
456
|
+
...(placement.positionMil || {}),
|
|
457
|
+
x: Number(owner?.x || 0) - Number(board?.centerX || 0),
|
|
458
|
+
y: Number(owner?.y || 0) - Number(board?.centerY || 0),
|
|
459
|
+
z: AltiumScene3dShapeStackOwnerAdapter.#faceZ(mountSide, board)
|
|
460
|
+
},
|
|
461
|
+
projection: {
|
|
462
|
+
...(placement.projection || {}),
|
|
463
|
+
source: 'authored-shape-stack',
|
|
464
|
+
reason: 'Altium shape-based sub-body is seated on an authored carrier stack.'
|
|
465
|
+
},
|
|
466
|
+
coLocatedVariantGroupKey: assignment.groupKey,
|
|
467
|
+
modelTransform: {
|
|
468
|
+
...(placement.modelTransform || {}),
|
|
469
|
+
dzMil: standoff,
|
|
470
|
+
offsetMil: renderableOffset,
|
|
471
|
+
ownerAnchorOffsetMil: ownerOffset
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Converts a board-space owner offset into render local coordinates.
|
|
478
|
+
* @param {{ mountSide?: string, rotationDeg?: number }} placement Placement.
|
|
479
|
+
* @param {{ x: number, y: number }} offset Board-space offset.
|
|
480
|
+
* @param {number} standoff Vertical stack offset.
|
|
481
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
482
|
+
*/
|
|
483
|
+
static #renderableOffset(placement, offset, standoff) {
|
|
484
|
+
const rotationRad =
|
|
485
|
+
(-AltiumScene3dShapeStackOwnerAdapter.#normalizeAngle(
|
|
486
|
+
placement.rotationDeg
|
|
487
|
+
) *
|
|
488
|
+
Math.PI) /
|
|
489
|
+
180
|
|
490
|
+
const cos = Math.cos(rotationRad)
|
|
491
|
+
const sin = Math.sin(rotationRad)
|
|
492
|
+
const sourceY =
|
|
493
|
+
String(placement?.mountSide || '').toLowerCase() === 'bottom'
|
|
494
|
+
? Number(offset.y || 0)
|
|
495
|
+
: -Number(offset.y || 0)
|
|
496
|
+
const x = Number(offset.x || 0) * cos - sourceY * sin
|
|
497
|
+
const y = Number(offset.x || 0) * sin + sourceY * cos
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
x: AltiumScene3dShapeStackOwnerAdapter.#round(x),
|
|
501
|
+
y:
|
|
502
|
+
String(placement?.mountSide || '').toLowerCase() === 'bottom'
|
|
503
|
+
? AltiumScene3dShapeStackOwnerAdapter.#round(-y)
|
|
504
|
+
: AltiumScene3dShapeStackOwnerAdapter.#round(y),
|
|
505
|
+
z: AltiumScene3dShapeStackOwnerAdapter.#round(standoff)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Checks whether one static placement came from one carrier base row.
|
|
511
|
+
* @param {object} placement Static placement.
|
|
512
|
+
* @param {object} base Source base row.
|
|
513
|
+
* @returns {boolean}
|
|
514
|
+
*/
|
|
515
|
+
static #isSameStaticBody(placement, base) {
|
|
516
|
+
return (
|
|
517
|
+
AltiumScene3dShapeStackOwnerAdapter.#distance(
|
|
518
|
+
placement?.bodyPositionMil,
|
|
519
|
+
base?.positionMil
|
|
520
|
+
) <= 0.01 &&
|
|
521
|
+
Math.abs(
|
|
522
|
+
Number(placement?.geometry?.heightMil || 0) -
|
|
523
|
+
Number(
|
|
524
|
+
base?.staticGeometry?.heightMil ??
|
|
525
|
+
base?.overallHeightMil ??
|
|
526
|
+
0
|
|
527
|
+
)
|
|
528
|
+
) <= AltiumScene3dShapeStackOwnerAdapter.#HEIGHT_TOLERANCE_MIL
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Resolves the source component body row for one external placement.
|
|
534
|
+
* @param {object} placement External placement.
|
|
535
|
+
* @param {object[]} componentBodies Source body rows.
|
|
536
|
+
* @param {Set<object>} usedComponentBodies Already consumed body rows.
|
|
537
|
+
* @returns {object | null}
|
|
538
|
+
*/
|
|
539
|
+
static #resolveComponentBody(
|
|
540
|
+
placement,
|
|
541
|
+
componentBodies,
|
|
542
|
+
usedComponentBodies
|
|
543
|
+
) {
|
|
544
|
+
return (
|
|
545
|
+
componentBodies
|
|
546
|
+
.filter(
|
|
547
|
+
(componentBody) =>
|
|
548
|
+
!usedComponentBodies.has(componentBody) &&
|
|
549
|
+
!AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(
|
|
550
|
+
componentBody
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
.map((componentBody) => ({
|
|
554
|
+
componentBody,
|
|
555
|
+
distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
|
|
556
|
+
placement?.bodyPositionMil,
|
|
557
|
+
componentBody?.positionMil
|
|
558
|
+
),
|
|
559
|
+
standoffError:
|
|
560
|
+
AltiumScene3dShapeStackOwnerAdapter.#standoffError(
|
|
561
|
+
placement,
|
|
562
|
+
componentBody
|
|
563
|
+
),
|
|
564
|
+
score: AltiumScene3dShapeStackOwnerAdapter.#identityScore(
|
|
565
|
+
placement,
|
|
566
|
+
componentBody
|
|
567
|
+
)
|
|
568
|
+
}))
|
|
569
|
+
.filter((candidate) => candidate.distance <= 0.01)
|
|
570
|
+
.sort(
|
|
571
|
+
(left, right) =>
|
|
572
|
+
right.score - left.score ||
|
|
573
|
+
left.standoffError - right.standoffError ||
|
|
574
|
+
left.distance - right.distance
|
|
575
|
+
)[0]?.componentBody || null
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Scores whether a source body row matches an already-known stack height.
|
|
581
|
+
* @param {object} placement External placement.
|
|
582
|
+
* @param {object} componentBody Source body row.
|
|
583
|
+
* @returns {number}
|
|
584
|
+
*/
|
|
585
|
+
static #standoffError(placement, componentBody) {
|
|
586
|
+
const placementHeight = Number(
|
|
587
|
+
placement?.modelTransform?.offsetMil?.z ??
|
|
588
|
+
placement?.modelTransform?.dzMil
|
|
589
|
+
)
|
|
590
|
+
const bodyStandoff = Number(componentBody?.standoffHeightMil)
|
|
591
|
+
if (
|
|
592
|
+
!Number.isFinite(placementHeight) ||
|
|
593
|
+
!Number.isFinite(bodyStandoff) ||
|
|
594
|
+
placementHeight <= 0
|
|
595
|
+
) {
|
|
596
|
+
return 0
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return Math.abs(placementHeight - bodyStandoff)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Scores whether a source body row belongs to one placement.
|
|
604
|
+
* @param {object} placement External placement.
|
|
605
|
+
* @param {object} componentBody Source body row.
|
|
606
|
+
* @returns {number}
|
|
607
|
+
*/
|
|
608
|
+
static #identityScore(placement, componentBody) {
|
|
609
|
+
const placementText = AltiumScene3dShapeStackOwnerAdapter.#identityText(
|
|
610
|
+
[placement?.externalModel?.name, placement?.designator]
|
|
611
|
+
)
|
|
612
|
+
const bodyText = AltiumScene3dShapeStackOwnerAdapter.#identityText([
|
|
613
|
+
componentBody?.name,
|
|
614
|
+
componentBody?.identifier
|
|
615
|
+
])
|
|
616
|
+
|
|
617
|
+
return placementText && bodyText && placementText.includes(bodyText)
|
|
618
|
+
? bodyText.length
|
|
619
|
+
: 0
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Checks whether a component is a timing-package owner.
|
|
624
|
+
* @param {object} component Source component.
|
|
625
|
+
* @returns {boolean}
|
|
626
|
+
*/
|
|
627
|
+
static #isTimingComponent(component) {
|
|
628
|
+
const designator = String(component?.designator || '').trim()
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
TIMING_DESIGNATOR_PATTERN.test(designator) ||
|
|
632
|
+
TIMING_PACKAGE_PATTERN.test(
|
|
633
|
+
[
|
|
634
|
+
component?.pattern,
|
|
635
|
+
component?.source,
|
|
636
|
+
component?.description,
|
|
637
|
+
component?.provenance?.footprintDescription,
|
|
638
|
+
...Object.values(component?.parameters || {})
|
|
639
|
+
]
|
|
640
|
+
.map((value) => String(value || ''))
|
|
641
|
+
.join(' ')
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Scores how strongly a component looks like the fitted stack owner.
|
|
648
|
+
* @param {object} component Source component.
|
|
649
|
+
* @returns {number}
|
|
650
|
+
*/
|
|
651
|
+
static #ownerRank(component) {
|
|
652
|
+
const parameters = Object.values(component?.parameters || {}).filter(
|
|
653
|
+
(value) => String(value || '').trim()
|
|
654
|
+
)
|
|
655
|
+
return (
|
|
656
|
+
parameters.length * 10 +
|
|
657
|
+
(TIMING_DESIGNATOR_PATTERN.test(component?.designator) ? 5 : 0) +
|
|
658
|
+
(TIMING_PACKAGE_PATTERN.test(
|
|
659
|
+
[
|
|
660
|
+
component?.pattern,
|
|
661
|
+
component?.source,
|
|
662
|
+
component?.description,
|
|
663
|
+
component?.provenance?.footprintDescription
|
|
664
|
+
]
|
|
665
|
+
.map((value) => String(value || ''))
|
|
666
|
+
.join(' ')
|
|
667
|
+
)
|
|
668
|
+
? 3
|
|
669
|
+
: 0)
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Checks whether one body row came from shape-based component metadata.
|
|
675
|
+
* @param {object | null | undefined} componentBody Source body row.
|
|
676
|
+
* @returns {boolean}
|
|
677
|
+
*/
|
|
678
|
+
static #isShapeBasedBody(componentBody) {
|
|
679
|
+
return String(componentBody?.sourceStream || '').includes(
|
|
680
|
+
'ShapeBasedComponentBodies'
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Resolves one component's mount side.
|
|
686
|
+
* @param {object} component Source component.
|
|
687
|
+
* @returns {'top' | 'bottom'}
|
|
688
|
+
*/
|
|
689
|
+
static #mountSide(component) {
|
|
690
|
+
const layer = String(component?.layer || '').toUpperCase()
|
|
691
|
+
return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Resolves one board face Z coordinate.
|
|
696
|
+
* @param {string} mountSide Mount side.
|
|
697
|
+
* @param {object | undefined} board Scene board metadata.
|
|
698
|
+
* @returns {number}
|
|
699
|
+
*/
|
|
700
|
+
static #faceZ(mountSide, board) {
|
|
701
|
+
const thickness = Number(board?.thicknessMil) || 63
|
|
702
|
+
return String(mountSide || '').toLowerCase() === 'bottom'
|
|
703
|
+
? -thickness / 2
|
|
704
|
+
: thickness / 2
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Normalizes an angle into [0, 360).
|
|
709
|
+
* @param {number} angle Candidate angle.
|
|
710
|
+
* @returns {number}
|
|
711
|
+
*/
|
|
712
|
+
static #normalizeAngle(angle) {
|
|
713
|
+
const normalized = Number(angle || 0) % 360
|
|
714
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Measures the XY distance between two board points.
|
|
719
|
+
* @param {object | undefined} first First point.
|
|
720
|
+
* @param {object | undefined} second Second point.
|
|
721
|
+
* @returns {number}
|
|
722
|
+
*/
|
|
723
|
+
static #distance(first, second) {
|
|
724
|
+
return Math.hypot(
|
|
725
|
+
Number(first?.x || 0) - Number(second?.x || 0),
|
|
726
|
+
Number(first?.y || 0) - Number(second?.y || 0)
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Normalizes one point.
|
|
732
|
+
* @param {object | undefined} point Source point.
|
|
733
|
+
* @returns {{ x: number, y: number }}
|
|
734
|
+
*/
|
|
735
|
+
static #point(point) {
|
|
736
|
+
return {
|
|
737
|
+
x: Number(point?.x || 0),
|
|
738
|
+
y: Number(point?.y || 0)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Builds a stable key for co-located authored stack variants.
|
|
744
|
+
* @param {{ x?: number, y?: number }} point Stack anchor.
|
|
745
|
+
* @returns {string}
|
|
746
|
+
*/
|
|
747
|
+
static #groupKey(point) {
|
|
748
|
+
return [
|
|
749
|
+
'altium-shape-stack',
|
|
750
|
+
AltiumScene3dShapeStackOwnerAdapter.#round(point?.x || 0),
|
|
751
|
+
AltiumScene3dShapeStackOwnerAdapter.#round(point?.y || 0)
|
|
752
|
+
].join(':')
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Builds normalized identity text.
|
|
757
|
+
* @param {unknown[]} values Identity values.
|
|
758
|
+
* @returns {string}
|
|
759
|
+
*/
|
|
760
|
+
static #identityText(values) {
|
|
761
|
+
return values
|
|
762
|
+
.map((value) =>
|
|
763
|
+
String(value || '')
|
|
764
|
+
.replace(/\.[^.]+$/, '')
|
|
765
|
+
.trim()
|
|
766
|
+
.toLowerCase()
|
|
767
|
+
.replace(/[^a-z0-9]+/gu, '')
|
|
768
|
+
)
|
|
769
|
+
.filter(Boolean)
|
|
770
|
+
.join(' ')
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Rounds one mil value for stable scene output.
|
|
775
|
+
* @param {number} value Candidate value.
|
|
776
|
+
* @returns {number}
|
|
777
|
+
*/
|
|
778
|
+
static #round(value) {
|
|
779
|
+
const rounded = Math.round(Number(value) * 10000) / 10000
|
|
780
|
+
return Object.is(rounded, -0) ? 0 : rounded
|
|
781
|
+
}
|
|
782
|
+
}
|