altium-toolkit 1.1.36 → 1.1.37
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/AGENTS.md +15 -0
- package/package.json +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
- package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
- package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
- package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
- package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
- package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +180 -158
- package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
- package/src/ui/PcbScene3dBuilder.mjs +613 -117
- package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +87 -2
- package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +32 -16
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +1 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver.mjs'
|
|
2
|
+
|
|
3
|
+
const PASSIVE_BODY_PATTERN =
|
|
4
|
+
/(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|lqw|lqg)(?:$|[^a-z0-9])/i
|
|
5
|
+
const PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN =
|
|
6
|
+
/(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|shield|sma|socket)(?:$|[^a-z0-9])/i
|
|
7
|
+
const USB_ANCHOR_IDENTITY_PATTERN = /(?:^|[^a-z0-9])usb(?:$|[^a-z0-9])/i
|
|
8
|
+
const INTEGRATED_CIRCUIT_PACKAGE_PATTERN =
|
|
9
|
+
/(?:^|[^a-z0-9])(?:u?qfn|v?qfn|dfn|qfp|lqfp|tqfp|bga|lga|sop|soic|ssop|tssop|msop|so[-_ ]?\d+)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Collapses repeated Altium shape rows that duplicate one full-footprint body.
|
|
13
|
+
*/
|
|
14
|
+
export class AltiumScene3dRepeatedFullFootprintBodyCollapse {
|
|
15
|
+
static #PAD_ANCHOR_TOLERANCE_MIL = 5
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Collapses duplicate shape rows that all describe one full footprint body.
|
|
19
|
+
* @param {object[]} placements Scene placements.
|
|
20
|
+
* @param {Map<string, object>} componentByDesignator Components by designator.
|
|
21
|
+
* @param {object[]} pads Source PCB pads.
|
|
22
|
+
* @returns {object[]}
|
|
23
|
+
*/
|
|
24
|
+
static apply(placements, componentByDesignator, pads) {
|
|
25
|
+
const groups = AltiumScene3dRepeatedFullFootprintBodyCollapse.#groups(
|
|
26
|
+
placements,
|
|
27
|
+
componentByDesignator
|
|
28
|
+
)
|
|
29
|
+
const replacements = new Map()
|
|
30
|
+
const removedIndexes = new Set()
|
|
31
|
+
|
|
32
|
+
groups.forEach((records) => {
|
|
33
|
+
const representativePlacement = records[0]?.placement
|
|
34
|
+
const component = componentByDesignator.get(
|
|
35
|
+
String(representativePlacement?.designator || '')
|
|
36
|
+
)
|
|
37
|
+
if (
|
|
38
|
+
!AltiumScene3dRepeatedFullFootprintBodyCollapse.#shouldCollapse(
|
|
39
|
+
records,
|
|
40
|
+
component,
|
|
41
|
+
pads
|
|
42
|
+
)
|
|
43
|
+
) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const representative =
|
|
48
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#representative(
|
|
49
|
+
records,
|
|
50
|
+
component
|
|
51
|
+
)
|
|
52
|
+
replacements.set(
|
|
53
|
+
representative.index,
|
|
54
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#withCollapsedProjection(
|
|
55
|
+
representative.placement,
|
|
56
|
+
records.length
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
records.forEach((record) => {
|
|
60
|
+
if (record.index !== representative.index) {
|
|
61
|
+
removedIndexes.add(record.index)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!replacements.size && !removedIndexes.size) {
|
|
67
|
+
return placements
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return placements
|
|
71
|
+
.map((placement, index) => {
|
|
72
|
+
if (removedIndexes.has(index)) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return replacements.get(index) || placement
|
|
77
|
+
})
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Groups pad-fallback placements that share one owner and model identity.
|
|
83
|
+
* @param {object[]} placements Scene placements.
|
|
84
|
+
* @param {Map<string, object>} componentByDesignator Components by designator.
|
|
85
|
+
* @returns {{ index: number, placement: object }[][]}
|
|
86
|
+
*/
|
|
87
|
+
static #groups(placements, componentByDesignator) {
|
|
88
|
+
const groups = new Map()
|
|
89
|
+
|
|
90
|
+
placements.forEach((placement, index) => {
|
|
91
|
+
if (
|
|
92
|
+
!AltiumScene3dRepeatedFullFootprintBodyCollapse.#isCandidate(
|
|
93
|
+
placement,
|
|
94
|
+
componentByDesignator
|
|
95
|
+
)
|
|
96
|
+
) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const key =
|
|
101
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#groupKey(
|
|
102
|
+
placement
|
|
103
|
+
)
|
|
104
|
+
if (!key) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const records = groups.get(key) || []
|
|
109
|
+
records.push({ index, placement })
|
|
110
|
+
groups.set(key, records)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return [...groups.values()].filter((records) => records.length > 1)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checks whether one placement can participate in full-footprint collapse.
|
|
118
|
+
* @param {object} placement Scene placement.
|
|
119
|
+
* @param {Map<string, object>} componentByDesignator Components by designator.
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
static #isCandidate(placement, componentByDesignator) {
|
|
123
|
+
const projectionSource = String(
|
|
124
|
+
placement?.projection?.source || ''
|
|
125
|
+
).toLowerCase()
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
(projectionSource === 'pad-fallback' ||
|
|
129
|
+
projectionSource === 'model-bounds') &&
|
|
130
|
+
Boolean(placement?.externalModel) &&
|
|
131
|
+
Boolean(placement?.positionMil) &&
|
|
132
|
+
Boolean(placement?.bodyPositionMil) &&
|
|
133
|
+
Boolean(placement?.projection?.boundsMil) &&
|
|
134
|
+
componentByDesignator.has(String(placement?.designator || ''))
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Builds a stable duplicate full-footprint model key.
|
|
140
|
+
* @param {object} placement Scene placement.
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
static #groupKey(placement) {
|
|
144
|
+
const designator = String(placement?.designator || '').trim()
|
|
145
|
+
const model = placement?.externalModel || {}
|
|
146
|
+
const modelParts = [
|
|
147
|
+
model?.origin,
|
|
148
|
+
model?.sourceStream,
|
|
149
|
+
model?.relativePath,
|
|
150
|
+
model?.name,
|
|
151
|
+
model?.format
|
|
152
|
+
].map((value) => String(value || '').trim())
|
|
153
|
+
|
|
154
|
+
return designator && modelParts.some(Boolean)
|
|
155
|
+
? [
|
|
156
|
+
designator,
|
|
157
|
+
String(placement?.mountSide || '').toLowerCase(),
|
|
158
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#normalizeAngle(
|
|
159
|
+
placement?.rotationDeg
|
|
160
|
+
),
|
|
161
|
+
...modelParts
|
|
162
|
+
].join('::')
|
|
163
|
+
: ''
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Checks whether a repeated group describes duplicate rows of one body.
|
|
168
|
+
* @param {{ placement: object }[]} records Group records.
|
|
169
|
+
* @param {object | undefined} component Owning component.
|
|
170
|
+
* @param {object[]} pads Source PCB pads.
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
static #shouldCollapse(records, component, pads) {
|
|
174
|
+
if (!component || records.length < 2) {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const identityText =
|
|
179
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#identityText(
|
|
180
|
+
records[0]?.placement,
|
|
181
|
+
component
|
|
182
|
+
)
|
|
183
|
+
if (
|
|
184
|
+
PASSIVE_BODY_PATTERN.test(identityText) ||
|
|
185
|
+
!AltiumScene3dRepeatedFullFootprintBodyCollapse.#hasAuthoredAnchorIdentity(
|
|
186
|
+
identityText
|
|
187
|
+
)
|
|
188
|
+
) {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const ownedDrilledPads =
|
|
193
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#ownedDrilledPads(
|
|
194
|
+
component,
|
|
195
|
+
pads
|
|
196
|
+
)
|
|
197
|
+
if (ownedDrilledPads.length <= records.length) {
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const mountSide =
|
|
202
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#mountSide(
|
|
203
|
+
component
|
|
204
|
+
) ||
|
|
205
|
+
String(records[0]?.placement?.mountSide || '').toLowerCase() ||
|
|
206
|
+
'top'
|
|
207
|
+
const padSpan = PcbScene3dPadLocalSpanResolver.resolve(
|
|
208
|
+
component,
|
|
209
|
+
ownedDrilledPads,
|
|
210
|
+
mountSide
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
Boolean(padSpan) &&
|
|
215
|
+
records.every((record) =>
|
|
216
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#isOwnedDrilledPadAnchor(
|
|
217
|
+
record.placement,
|
|
218
|
+
ownedDrilledPads
|
|
219
|
+
)
|
|
220
|
+
) &&
|
|
221
|
+
records.every((record) =>
|
|
222
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#projectionMatchesPadSpan(
|
|
223
|
+
record.placement?.projection,
|
|
224
|
+
padSpan
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Checks whether identity text describes an authored hardware anchor.
|
|
232
|
+
* @param {string} identityText Searchable package identity.
|
|
233
|
+
* @returns {boolean}
|
|
234
|
+
*/
|
|
235
|
+
static #hasAuthoredAnchorIdentity(identityText) {
|
|
236
|
+
return (
|
|
237
|
+
PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN.test(identityText) ||
|
|
238
|
+
(USB_ANCHOR_IDENTITY_PATTERN.test(identityText) &&
|
|
239
|
+
!INTEGRATED_CIRCUIT_PACKAGE_PATTERN.test(identityText))
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Builds searchable package metadata for collapse policy checks.
|
|
245
|
+
* @param {object} placement Scene placement.
|
|
246
|
+
* @param {object} component Source component.
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
static #identityText(placement, component) {
|
|
250
|
+
return [
|
|
251
|
+
placement?.designator,
|
|
252
|
+
placement?.externalModel?.name,
|
|
253
|
+
placement?.externalModel?.relativePath,
|
|
254
|
+
placement?.externalModel?.sourceStream,
|
|
255
|
+
component?.pattern,
|
|
256
|
+
component?.source,
|
|
257
|
+
component?.description,
|
|
258
|
+
...Object.values(component?.parameters || {})
|
|
259
|
+
]
|
|
260
|
+
.map((value) => String(value || ''))
|
|
261
|
+
.join(' ')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolves drilled pads owned by one component.
|
|
266
|
+
* @param {object} component Source component.
|
|
267
|
+
* @param {object[]} pads Source PCB pads.
|
|
268
|
+
* @returns {object[]}
|
|
269
|
+
*/
|
|
270
|
+
static #ownedDrilledPads(component, pads) {
|
|
271
|
+
const componentIndex = Number(component?.componentIndex)
|
|
272
|
+
if (!Number.isFinite(componentIndex)) {
|
|
273
|
+
return []
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return (Array.isArray(pads) ? pads : []).filter(
|
|
277
|
+
(pad) =>
|
|
278
|
+
Number(pad?.componentIndex) === componentIndex &&
|
|
279
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#hasDrilledPadOpening(
|
|
280
|
+
pad
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Checks whether one pad contains a through-hole opening.
|
|
287
|
+
* @param {object} pad Source PCB pad.
|
|
288
|
+
* @returns {boolean}
|
|
289
|
+
*/
|
|
290
|
+
static #hasDrilledPadOpening(pad) {
|
|
291
|
+
const holeGeometry = pad?.holeGeometry || {}
|
|
292
|
+
|
|
293
|
+
return [
|
|
294
|
+
pad?.holeDiameter,
|
|
295
|
+
pad?.drillDiameter,
|
|
296
|
+
pad?.holeSize,
|
|
297
|
+
pad?.holeSlotLength,
|
|
298
|
+
pad?.slotLength,
|
|
299
|
+
holeGeometry?.diameter,
|
|
300
|
+
holeGeometry?.length,
|
|
301
|
+
holeGeometry?.slotLength
|
|
302
|
+
].some((value) => Number(value || 0) > 0)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Checks whether a placement anchor occupies an owned drilled pad.
|
|
307
|
+
* @param {object} placement Scene placement.
|
|
308
|
+
* @param {object[]} ownedDrilledPads Drilled pads owned by the component.
|
|
309
|
+
* @returns {boolean}
|
|
310
|
+
*/
|
|
311
|
+
static #isOwnedDrilledPadAnchor(placement, ownedDrilledPads) {
|
|
312
|
+
const bodyPosition = placement?.bodyPositionMil
|
|
313
|
+
if (
|
|
314
|
+
!AltiumScene3dRepeatedFullFootprintBodyCollapse.#hasFinitePoint(
|
|
315
|
+
bodyPosition
|
|
316
|
+
)
|
|
317
|
+
) {
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return ownedDrilledPads.some((pad) =>
|
|
322
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#padContainsPoint(
|
|
323
|
+
pad,
|
|
324
|
+
bodyPosition
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Checks whether one pad contains a board-space point.
|
|
331
|
+
* @param {object} pad Source PCB pad.
|
|
332
|
+
* @param {{ x?: number, y?: number }} point Board-space point.
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
static #padContainsPoint(pad, point) {
|
|
336
|
+
if (
|
|
337
|
+
!AltiumScene3dRepeatedFullFootprintBodyCollapse.#hasFinitePoint(pad)
|
|
338
|
+
) {
|
|
339
|
+
return false
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const radius =
|
|
343
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#padAnchorRadiusMil(
|
|
344
|
+
pad
|
|
345
|
+
)
|
|
346
|
+
return (
|
|
347
|
+
radius > 0 &&
|
|
348
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#distance(
|
|
349
|
+
pad,
|
|
350
|
+
point
|
|
351
|
+
) <=
|
|
352
|
+
radius +
|
|
353
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse
|
|
354
|
+
.#PAD_ANCHOR_TOLERANCE_MIL
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Resolves the effective drilled pad anchor radius.
|
|
360
|
+
* @param {object} pad Source PCB pad.
|
|
361
|
+
* @returns {number}
|
|
362
|
+
*/
|
|
363
|
+
static #padAnchorRadiusMil(pad) {
|
|
364
|
+
const holeGeometry = pad?.holeGeometry || {}
|
|
365
|
+
const diameter = Math.max(
|
|
366
|
+
Number(pad?.sizeTopX || 0),
|
|
367
|
+
Number(pad?.sizeTopY || 0),
|
|
368
|
+
Number(pad?.sizeMidX || 0),
|
|
369
|
+
Number(pad?.sizeMidY || 0),
|
|
370
|
+
Number(pad?.sizeBottomX || 0),
|
|
371
|
+
Number(pad?.sizeBottomY || 0),
|
|
372
|
+
Number(pad?.holeDiameter || 0),
|
|
373
|
+
Number(pad?.drillDiameter || 0),
|
|
374
|
+
Number(pad?.holeSize || 0),
|
|
375
|
+
Number(pad?.holeSlotLength || 0),
|
|
376
|
+
Number(pad?.slotLength || 0),
|
|
377
|
+
Number(holeGeometry?.diameter || 0),
|
|
378
|
+
Number(holeGeometry?.length || 0),
|
|
379
|
+
Number(holeGeometry?.slotLength || 0)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
return Number.isFinite(diameter) && diameter > 0 ? diameter / 2 : 0
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Checks whether projection bounds match the full owned drilled-pad span.
|
|
387
|
+
* @param {object | undefined} projection Placement projection metadata.
|
|
388
|
+
* @param {{ width: number, depth: number }} padSpan Owned drilled-pad span.
|
|
389
|
+
* @returns {boolean}
|
|
390
|
+
*/
|
|
391
|
+
static #projectionMatchesPadSpan(projection, padSpan) {
|
|
392
|
+
const bounds = projection?.boundsMil || {}
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#matchesDimension(
|
|
396
|
+
Number(bounds?.width),
|
|
397
|
+
Number(padSpan?.width)
|
|
398
|
+
) &&
|
|
399
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#matchesDimension(
|
|
400
|
+
Number(bounds?.depth),
|
|
401
|
+
Number(padSpan?.depth)
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Checks whether two dimensions are close enough for source-origin repair.
|
|
408
|
+
* @param {number} actual Actual dimension.
|
|
409
|
+
* @param {number} expected Expected dimension.
|
|
410
|
+
* @returns {boolean}
|
|
411
|
+
*/
|
|
412
|
+
static #matchesDimension(actual, expected) {
|
|
413
|
+
if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
|
|
414
|
+
return false
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const tolerance = Math.max(35, Math.abs(expected) * 0.08)
|
|
418
|
+
return Math.abs(actual - expected) <= tolerance
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Picks a source-origin representative that cannot be mistaken for center.
|
|
423
|
+
* @param {{ index: number, placement: object }[]} records Group records.
|
|
424
|
+
* @param {object} component Owning component.
|
|
425
|
+
* @returns {{ index: number, placement: object }}
|
|
426
|
+
*/
|
|
427
|
+
static #representative(records, component) {
|
|
428
|
+
return [...records].sort(
|
|
429
|
+
(left, right) =>
|
|
430
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#distance(
|
|
431
|
+
right.placement?.bodyPositionMil,
|
|
432
|
+
component
|
|
433
|
+
) -
|
|
434
|
+
AltiumScene3dRepeatedFullFootprintBodyCollapse.#distance(
|
|
435
|
+
left.placement?.bodyPositionMil,
|
|
436
|
+
component
|
|
437
|
+
)
|
|
438
|
+
)[0]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Marks one representative as the collapsed pad-fallback source origin.
|
|
443
|
+
* @param {object} placement Representative placement.
|
|
444
|
+
* @param {number} duplicateBodyCount Number of collapsed body rows.
|
|
445
|
+
* @returns {object}
|
|
446
|
+
*/
|
|
447
|
+
static #withCollapsedProjection(placement, duplicateBodyCount) {
|
|
448
|
+
const { ownerAnchorOffsetMil, ...renderTransform } =
|
|
449
|
+
placement?.modelTransform || {}
|
|
450
|
+
const projectionSource = String(
|
|
451
|
+
placement?.projection?.source || ''
|
|
452
|
+
).toLowerCase()
|
|
453
|
+
const collapsedProjection = {
|
|
454
|
+
...(placement.projection || {}),
|
|
455
|
+
reason:
|
|
456
|
+
projectionSource === 'model-bounds'
|
|
457
|
+
? 'Repeated Altium body rows share one full-footprint model-bounds projection, so duplicate rows were collapsed while preserving the authored source origin.'
|
|
458
|
+
: 'Repeated Altium body rows share one full-footprint pad projection, so duplicate rows were collapsed while preserving source-origin centering.',
|
|
459
|
+
duplicateBodyCount
|
|
460
|
+
}
|
|
461
|
+
if (projectionSource === 'pad-fallback') {
|
|
462
|
+
collapsedProjection.preservePadFallbackCentering = true
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
...placement,
|
|
467
|
+
projection: collapsedProjection,
|
|
468
|
+
modelTransform: renderTransform
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Resolves one component's mount side.
|
|
474
|
+
* @param {object} component PCB component.
|
|
475
|
+
* @returns {'top' | 'bottom' | ''}
|
|
476
|
+
*/
|
|
477
|
+
static #mountSide(component) {
|
|
478
|
+
const layer = String(component?.layer || '').toUpperCase()
|
|
479
|
+
|
|
480
|
+
return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Checks whether a value has finite XY coordinates.
|
|
485
|
+
* @param {object | undefined} point Source point.
|
|
486
|
+
* @returns {boolean}
|
|
487
|
+
*/
|
|
488
|
+
static #hasFinitePoint(point) {
|
|
489
|
+
return (
|
|
490
|
+
Number.isFinite(Number(point?.x)) &&
|
|
491
|
+
Number.isFinite(Number(point?.y))
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Measures XY distance between two board points.
|
|
497
|
+
* @param {object} first First point.
|
|
498
|
+
* @param {object} second Second point.
|
|
499
|
+
* @returns {number}
|
|
500
|
+
*/
|
|
501
|
+
static #distance(first, second) {
|
|
502
|
+
return Math.hypot(
|
|
503
|
+
Number(first?.x || 0) - Number(second?.x || 0),
|
|
504
|
+
Number(first?.y || 0) - Number(second?.y || 0)
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Normalizes an angle into [0, 360).
|
|
510
|
+
* @param {number} angle Source angle.
|
|
511
|
+
* @returns {number}
|
|
512
|
+
*/
|
|
513
|
+
static #normalizeAngle(angle) {
|
|
514
|
+
const normalized = Number(angle || 0) % 360
|
|
515
|
+
|
|
516
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
517
|
+
}
|
|
518
|
+
}
|