altium-toolkit 1.1.36 → 1.1.38

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.
@@ -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
+ }