altium-toolkit 1.1.30 → 1.1.31

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,609 @@
1
+ const MIN_GROUP_PAD_COUNT = 4
2
+ const MIN_ASPECT_RATIO = 1.4
3
+ const MIN_DIRECTION_STABILITY = 0.35
4
+ const PARALLEL_TOLERANCE_DEG = 30
5
+ const MIN_PARALLEL_RATIO = 0.6
6
+ const ROW_GAP_SHORT_SIZE_FACTOR = 1.5
7
+
8
+ /**
9
+ * Normalizes rectangular pad axes for parsed two-row surface-mount footprints.
10
+ */
11
+ export class PcbFootprintPadAxisNormalizer {
12
+ static #documentCache = new WeakMap()
13
+
14
+ /**
15
+ * Returns a document model with row-parallel rectangular pads turned across
16
+ * their owning two-row footprint.
17
+ * @param {object} documentModel Document model.
18
+ * @returns {object}
19
+ */
20
+ static apply(documentModel) {
21
+ if (!PcbFootprintPadAxisNormalizer.#canCache(documentModel)) {
22
+ return documentModel
23
+ }
24
+
25
+ const cachedDocument =
26
+ PcbFootprintPadAxisNormalizer.#documentCache.get(documentModel)
27
+ if (cachedDocument) return cachedDocument
28
+
29
+ const pcb = documentModel?.pcb
30
+ const sourcePads =
31
+ PcbFootprintPadAxisNormalizer.#firstPadList(pcb) || []
32
+ const updates =
33
+ PcbFootprintPadAxisNormalizer.#resolvePadRotationUpdates(sourcePads)
34
+ if (!updates.objects.size && !updates.signatures.size) {
35
+ PcbFootprintPadAxisNormalizer.#documentCache.set(
36
+ documentModel,
37
+ documentModel
38
+ )
39
+ return documentModel
40
+ }
41
+
42
+ const normalizedDocument =
43
+ PcbFootprintPadAxisNormalizer.#applyPadUpdatesToDocument(
44
+ documentModel,
45
+ updates
46
+ )
47
+ PcbFootprintPadAxisNormalizer.#documentCache.set(
48
+ documentModel,
49
+ normalizedDocument
50
+ )
51
+ return normalizedDocument
52
+ }
53
+
54
+ /**
55
+ * Returns true when a value can be used as a WeakMap key.
56
+ * @param {unknown} value Value to test.
57
+ * @returns {boolean}
58
+ */
59
+ static #canCache(value) {
60
+ return (
61
+ value !== null &&
62
+ (typeof value === 'object' || typeof value === 'function')
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Returns the first PCB pad list present in the document model.
68
+ * @param {object | null | undefined} pcb PCB model.
69
+ * @returns {object[] | null}
70
+ */
71
+ static #firstPadList(pcb) {
72
+ if (Array.isArray(pcb?.pads)) return pcb.pads
73
+ if (Array.isArray(pcb?.kicadBoard?.pads)) return pcb.kicadBoard.pads
74
+ return null
75
+ }
76
+
77
+ /**
78
+ * Resolves pad rotation updates keyed by object identity and signature.
79
+ * @param {object[]} pads PCB pad list.
80
+ * @returns {{ objects: Map<object, number>, signatures: Map<string, number> }}
81
+ */
82
+ static #resolvePadRotationUpdates(pads) {
83
+ const updates = {
84
+ objects: new Map(),
85
+ signatures: new Map()
86
+ }
87
+ const groups = PcbFootprintPadAxisNormalizer.#groupPadsByOwner(pads)
88
+
89
+ for (const groupPads of groups.values()) {
90
+ PcbFootprintPadAxisNormalizer.#addGroupPadRotationUpdates(
91
+ groupPads,
92
+ updates
93
+ )
94
+ }
95
+
96
+ return updates
97
+ }
98
+
99
+ /**
100
+ * Groups pads by their owning component or footprint.
101
+ * @param {object[]} pads PCB pad list.
102
+ * @returns {Map<string, object[]>}
103
+ */
104
+ static #groupPadsByOwner(pads) {
105
+ const groups = new Map()
106
+
107
+ for (const pad of pads || []) {
108
+ const key = PcbFootprintPadAxisNormalizer.#padOwnerKey(pad)
109
+ if (!key) continue
110
+
111
+ const group = groups.get(key) || []
112
+ group.push(pad)
113
+ groups.set(key, group)
114
+ }
115
+
116
+ return groups
117
+ }
118
+
119
+ /**
120
+ * Adds inferred rotation updates for one footprint pad group.
121
+ * @param {object[]} groupPads Pads owned by one footprint.
122
+ * @param {{ objects: Map<object, number>, signatures: Map<string, number> }} updates Rotation updates.
123
+ * @returns {void}
124
+ */
125
+ static #addGroupPadRotationUpdates(groupPads, updates) {
126
+ const candidates = groupPads.filter((pad) =>
127
+ PcbFootprintPadAxisNormalizer.#isCandidateSurfacePad(pad)
128
+ )
129
+ if (candidates.length < MIN_GROUP_PAD_COUNT) return
130
+
131
+ const rowAngle =
132
+ PcbFootprintPadAxisNormalizer.#resolveNearestNeighborLineAngle(
133
+ candidates
134
+ )
135
+ if (rowAngle === null) return
136
+ if (
137
+ !PcbFootprintPadAxisNormalizer.#hasSeparatedRows(
138
+ candidates,
139
+ rowAngle
140
+ )
141
+ ) {
142
+ return
143
+ }
144
+
145
+ const parallelPads = candidates.filter((pad) =>
146
+ PcbFootprintPadAxisNormalizer.#isPadLongAxisParallelToRow(
147
+ pad,
148
+ rowAngle
149
+ )
150
+ )
151
+ const minimumParallelPads = Math.max(
152
+ MIN_GROUP_PAD_COUNT,
153
+ Math.ceil(candidates.length * MIN_PARALLEL_RATIO)
154
+ )
155
+ if (parallelPads.length < minimumParallelPads) return
156
+
157
+ for (const pad of parallelPads) {
158
+ const rotation = PcbFootprintPadAxisNormalizer.#normalizeAngle(
159
+ Number(pad?.rotation || 0) + 90
160
+ )
161
+ updates.objects.set(pad, rotation)
162
+ updates.signatures.set(
163
+ PcbFootprintPadAxisNormalizer.#padSignature(pad),
164
+ rotation
165
+ )
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Returns true when one pad is a rectangular-ish surface pad with a clear
171
+ * long axis.
172
+ * @param {object} pad PCB pad.
173
+ * @returns {boolean}
174
+ */
175
+ static #isCandidateSurfacePad(pad) {
176
+ if (PcbFootprintPadAxisNormalizer.#hasDrill(pad)) return false
177
+
178
+ const dimensions = PcbFootprintPadAxisNormalizer.#padDimensions(pad)
179
+ if (!dimensions) return false
180
+
181
+ const shortest = Math.min(dimensions.width, dimensions.height)
182
+ const longest = Math.max(dimensions.width, dimensions.height)
183
+ if (shortest <= 0) return false
184
+
185
+ return longest / shortest >= MIN_ASPECT_RATIO
186
+ }
187
+
188
+ /**
189
+ * Returns true when one pad has a drill hole or slot.
190
+ * @param {object} pad PCB pad.
191
+ * @returns {boolean}
192
+ */
193
+ static #hasDrill(pad) {
194
+ const values = [
195
+ pad?.holeDiameter,
196
+ pad?.drillDiameter,
197
+ pad?.holeSize,
198
+ pad?.slotLength,
199
+ pad?.holeSlotLength
200
+ ]
201
+
202
+ return values.some(
203
+ (value) =>
204
+ Number.isFinite(Number(value)) && Math.abs(Number(value)) > 0
205
+ )
206
+ }
207
+
208
+ /**
209
+ * Resolves the visible rectangular pad dimensions.
210
+ * @param {object} pad PCB pad.
211
+ * @returns {{ width: number, height: number } | null}
212
+ */
213
+ static #padDimensions(pad) {
214
+ const width = PcbFootprintPadAxisNormalizer.#firstFiniteNumber([
215
+ pad?.width,
216
+ pad?.sizeTopX,
217
+ pad?.sizeBottomX
218
+ ])
219
+ const height = PcbFootprintPadAxisNormalizer.#firstFiniteNumber([
220
+ pad?.height,
221
+ pad?.sizeTopY,
222
+ pad?.sizeBottomY
223
+ ])
224
+
225
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null
226
+ if (width <= 0 || height <= 0) return null
227
+
228
+ return { width, height }
229
+ }
230
+
231
+ /**
232
+ * Returns the first finite numeric value in a list.
233
+ * @param {unknown[]} values Values to scan.
234
+ * @returns {number}
235
+ */
236
+ static #firstFiniteNumber(values) {
237
+ for (const value of values) {
238
+ const number = Number(value)
239
+ if (Number.isFinite(number)) return number
240
+ }
241
+
242
+ return NaN
243
+ }
244
+
245
+ /**
246
+ * Resolves the dominant nearest-neighbor line angle for one two-row group.
247
+ * @param {object[]} pads Candidate pads.
248
+ * @returns {number | null}
249
+ */
250
+ static #resolveNearestNeighborLineAngle(pads) {
251
+ const angles = []
252
+
253
+ for (const pad of pads) {
254
+ const neighbor = PcbFootprintPadAxisNormalizer.#nearestNeighbor(
255
+ pad,
256
+ pads
257
+ )
258
+ if (!neighbor) continue
259
+
260
+ const angle =
261
+ (Math.atan2(
262
+ Number(neighbor.y) - Number(pad.y),
263
+ Number(neighbor.x) - Number(pad.x)
264
+ ) *
265
+ 180) /
266
+ Math.PI
267
+ angles.push(
268
+ PcbFootprintPadAxisNormalizer.#normalizeLineAngle(angle)
269
+ )
270
+ }
271
+
272
+ return PcbFootprintPadAxisNormalizer.#meanLineAngle(angles)
273
+ }
274
+
275
+ /**
276
+ * Finds the nearest distinct neighboring pad.
277
+ * @param {object} pad Source pad.
278
+ * @param {object[]} pads Candidate pads.
279
+ * @returns {object | null}
280
+ */
281
+ static #nearestNeighbor(pad, pads) {
282
+ let nearest = null
283
+ let nearestDistance = Infinity
284
+
285
+ for (const candidate of pads) {
286
+ if (candidate === pad) continue
287
+
288
+ const dx = Number(candidate?.x) - Number(pad?.x)
289
+ const dy = Number(candidate?.y) - Number(pad?.y)
290
+ const distance = dx * dx + dy * dy
291
+ if (!Number.isFinite(distance) || distance <= 0) continue
292
+ if (distance >= nearestDistance) continue
293
+
294
+ nearest = candidate
295
+ nearestDistance = distance
296
+ }
297
+
298
+ return nearest
299
+ }
300
+
301
+ /**
302
+ * Returns the mean line angle using doubled-angle vector averaging.
303
+ * @param {number[]} angles Line angles in degrees.
304
+ * @returns {number | null}
305
+ */
306
+ static #meanLineAngle(angles) {
307
+ if (!angles.length) return null
308
+
309
+ let x = 0
310
+ let y = 0
311
+ for (const angle of angles) {
312
+ const radians =
313
+ (PcbFootprintPadAxisNormalizer.#normalizeLineAngle(angle) *
314
+ Math.PI *
315
+ 2) /
316
+ 180
317
+ x += Math.cos(radians)
318
+ y += Math.sin(radians)
319
+ }
320
+
321
+ const magnitude = Math.hypot(x, y) / angles.length
322
+ if (magnitude < MIN_DIRECTION_STABILITY) return null
323
+
324
+ return PcbFootprintPadAxisNormalizer.#normalizeLineAngle(
325
+ (Math.atan2(y, x) * 90) / Math.PI
326
+ )
327
+ }
328
+
329
+ /**
330
+ * Returns true when candidate pads form at least two separated rows.
331
+ * @param {object[]} pads Candidate pads.
332
+ * @param {number} rowAngle Row line angle in degrees.
333
+ * @returns {boolean}
334
+ */
335
+ static #hasSeparatedRows(pads, rowAngle) {
336
+ const radians = (rowAngle * Math.PI) / 180
337
+ const sin = Math.sin(radians)
338
+ const cos = Math.cos(radians)
339
+ const values = pads
340
+ .map((pad) => -sin * Number(pad?.x) + cos * Number(pad?.y))
341
+ .filter((value) => Number.isFinite(value))
342
+ .sort((a, b) => a - b)
343
+ if (values.length < MIN_GROUP_PAD_COUNT) return false
344
+
345
+ let gap = 0
346
+ let gapIndex = -1
347
+ for (let index = 1; index < values.length; index += 1) {
348
+ const nextGap = values[index] - values[index - 1]
349
+ if (nextGap > gap) {
350
+ gap = nextGap
351
+ gapIndex = index - 1
352
+ }
353
+ }
354
+
355
+ if (gapIndex < 1) return false
356
+ if (values.length - gapIndex - 1 < 2) return false
357
+
358
+ const shortSize =
359
+ PcbFootprintPadAxisNormalizer.#medianPadShortSize(pads)
360
+ return gap > Math.max(shortSize * ROW_GAP_SHORT_SIZE_FACTOR, 0.001)
361
+ }
362
+
363
+ /**
364
+ * Resolves the median short side for candidate pad rectangles.
365
+ * @param {object[]} pads Candidate pads.
366
+ * @returns {number}
367
+ */
368
+ static #medianPadShortSize(pads) {
369
+ const values = pads
370
+ .map((pad) => {
371
+ const dimensions =
372
+ PcbFootprintPadAxisNormalizer.#padDimensions(pad)
373
+ if (!dimensions) return null
374
+ return Math.min(dimensions.width, dimensions.height)
375
+ })
376
+ .filter((value) => Number.isFinite(value))
377
+ .sort((a, b) => a - b)
378
+ if (!values.length) return 0
379
+
380
+ return values[Math.floor(values.length / 2)]
381
+ }
382
+
383
+ /**
384
+ * Returns true when a pad's long rectangle axis is parallel to its row.
385
+ * @param {object} pad PCB pad.
386
+ * @param {number} rowAngle Row line angle.
387
+ * @returns {boolean}
388
+ */
389
+ static #isPadLongAxisParallelToRow(pad, rowAngle) {
390
+ const dimensions = PcbFootprintPadAxisNormalizer.#padDimensions(pad)
391
+ if (!dimensions) return false
392
+
393
+ const longAxisAngle =
394
+ Number(pad?.rotation || 0) +
395
+ (dimensions.height > dimensions.width ? 90 : 0)
396
+ const delta = PcbFootprintPadAxisNormalizer.#lineAngleDelta(
397
+ longAxisAngle,
398
+ rowAngle
399
+ )
400
+
401
+ return delta <= PARALLEL_TOLERANCE_DEG
402
+ }
403
+
404
+ /**
405
+ * Applies resolved pad updates to all pad lists in a document.
406
+ * @param {object} documentModel Document model.
407
+ * @param {{ objects: Map<object, number>, signatures: Map<string, number> }} updates Rotation updates.
408
+ * @returns {object}
409
+ */
410
+ static #applyPadUpdatesToDocument(documentModel, updates) {
411
+ const pcb = documentModel?.pcb || {}
412
+ const pads = Array.isArray(pcb.pads)
413
+ ? PcbFootprintPadAxisNormalizer.#applyPadUpdates(pcb.pads, updates)
414
+ : pcb.pads
415
+ const kicadBoard =
416
+ PcbFootprintPadAxisNormalizer.#applyKicadBoardPadUpdates(
417
+ pcb,
418
+ pads,
419
+ updates
420
+ )
421
+
422
+ return {
423
+ ...documentModel,
424
+ pcb: {
425
+ ...pcb,
426
+ pads,
427
+ kicadBoard
428
+ }
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Applies resolved pad updates to a KiCad board pad list.
434
+ * @param {object} pcb PCB model.
435
+ * @param {object[] | undefined} pads Updated top-level pad list.
436
+ * @param {{ objects: Map<object, number>, signatures: Map<string, number> }} updates Rotation updates.
437
+ * @returns {object | undefined}
438
+ */
439
+ static #applyKicadBoardPadUpdates(pcb, pads, updates) {
440
+ const kicadBoard = pcb?.kicadBoard
441
+ if (!kicadBoard || !Array.isArray(kicadBoard.pads)) {
442
+ return kicadBoard
443
+ }
444
+
445
+ return {
446
+ ...kicadBoard,
447
+ pads:
448
+ kicadBoard.pads === pcb.pads
449
+ ? pads
450
+ : PcbFootprintPadAxisNormalizer.#applyPadUpdates(
451
+ kicadBoard.pads,
452
+ updates
453
+ )
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Applies resolved pad updates to one pad list.
459
+ * @param {object[] | undefined} pads PCB pads.
460
+ * @param {{ objects: Map<object, number>, signatures: Map<string, number> }} updates Rotation updates.
461
+ * @returns {object[] | undefined}
462
+ */
463
+ static #applyPadUpdates(pads, updates) {
464
+ if (!Array.isArray(pads)) return pads
465
+
466
+ return pads.map((pad) => {
467
+ const rotation =
468
+ updates.objects.get(pad) ??
469
+ updates.signatures.get(
470
+ PcbFootprintPadAxisNormalizer.#padSignature(pad)
471
+ )
472
+ if (rotation === undefined) return pad
473
+ if (
474
+ PcbFootprintPadAxisNormalizer.#anglesEqual(
475
+ rotation,
476
+ pad?.rotation
477
+ )
478
+ ) {
479
+ return pad
480
+ }
481
+
482
+ return {
483
+ ...pad,
484
+ rotation
485
+ }
486
+ })
487
+ }
488
+
489
+ /**
490
+ * Resolves a stable owner key for one pad.
491
+ * @param {object} pad PCB pad.
492
+ * @returns {string}
493
+ */
494
+ static #padOwnerKey(pad) {
495
+ const componentIndex = PcbFootprintPadAxisNormalizer.#firstPresent([
496
+ pad?.componentIndex,
497
+ pad?.ownerIndex
498
+ ])
499
+ if (componentIndex !== null) return 'component:' + componentIndex
500
+
501
+ const footprintId = PcbFootprintPadAxisNormalizer.#trimmedString(
502
+ pad?.footprintId
503
+ )
504
+ if (footprintId) return 'footprint:' + footprintId
505
+
506
+ const reference = PcbFootprintPadAxisNormalizer.#trimmedString(
507
+ pad?.footprintReference ?? pad?.designator ?? pad?.ref
508
+ )
509
+ return reference ? 'reference:' + reference : ''
510
+ }
511
+
512
+ /**
513
+ * Resolves a stable signature for matching cloned pad lists.
514
+ * @param {object} pad PCB pad.
515
+ * @returns {string}
516
+ */
517
+ static #padSignature(pad) {
518
+ return [
519
+ PcbFootprintPadAxisNormalizer.#padOwnerKey(pad),
520
+ PcbFootprintPadAxisNormalizer.#trimmedString(
521
+ pad?.number ?? pad?.name ?? pad?.id
522
+ ),
523
+ PcbFootprintPadAxisNormalizer.#signatureNumber(pad?.x),
524
+ PcbFootprintPadAxisNormalizer.#signatureNumber(pad?.y),
525
+ PcbFootprintPadAxisNormalizer.#signatureNumber(pad?.rotation)
526
+ ].join('|')
527
+ }
528
+
529
+ /**
530
+ * Resolves the first non-empty value.
531
+ * @param {unknown[]} values Values to scan.
532
+ * @returns {string | number | null}
533
+ */
534
+ static #firstPresent(values) {
535
+ for (const value of values) {
536
+ if (value === undefined || value === null || value === '') continue
537
+ return value
538
+ }
539
+
540
+ return null
541
+ }
542
+
543
+ /**
544
+ * Converts one value to a trimmed string.
545
+ * @param {unknown} value Value to stringify.
546
+ * @returns {string}
547
+ */
548
+ static #trimmedString(value) {
549
+ return String(value ?? '').trim()
550
+ }
551
+
552
+ /**
553
+ * Formats a number for a cloned-pad signature.
554
+ * @param {unknown} value Numeric value.
555
+ * @returns {string}
556
+ */
557
+ static #signatureNumber(value) {
558
+ const number = Number(value)
559
+ return Number.isFinite(number) ? number.toFixed(6) : ''
560
+ }
561
+
562
+ /**
563
+ * Returns true when two rotations are equal after normalization.
564
+ * @param {unknown} left First angle.
565
+ * @param {unknown} right Second angle.
566
+ * @returns {boolean}
567
+ */
568
+ static #anglesEqual(left, right) {
569
+ return (
570
+ Math.abs(
571
+ PcbFootprintPadAxisNormalizer.#normalizeAngle(left) -
572
+ PcbFootprintPadAxisNormalizer.#normalizeAngle(right)
573
+ ) < 0.001
574
+ )
575
+ }
576
+
577
+ /**
578
+ * Resolves the acute delta between two line angles.
579
+ * @param {number} left First angle.
580
+ * @param {number} right Second angle.
581
+ * @returns {number}
582
+ */
583
+ static #lineAngleDelta(left, right) {
584
+ const delta = Math.abs(
585
+ PcbFootprintPadAxisNormalizer.#normalizeLineAngle(left) -
586
+ PcbFootprintPadAxisNormalizer.#normalizeLineAngle(right)
587
+ )
588
+ return Math.min(delta, 180 - delta)
589
+ }
590
+
591
+ /**
592
+ * Normalizes an angle into [0, 360).
593
+ * @param {unknown} angle Angle in degrees.
594
+ * @returns {number}
595
+ */
596
+ static #normalizeAngle(angle) {
597
+ const value = Number(angle) || 0
598
+ return ((value % 360) + 360) % 360
599
+ }
600
+
601
+ /**
602
+ * Normalizes an angle into [0, 180).
603
+ * @param {unknown} angle Angle in degrees.
604
+ * @returns {number}
605
+ */
606
+ static #normalizeLineAngle(angle) {
607
+ return PcbFootprintPadAxisNormalizer.#normalizeAngle(angle) % 180
608
+ }
609
+ }
@@ -8,12 +8,14 @@ import { PcbScene3dDrillCutoutBuilder } from './PcbScene3dDrillCutoutBuilder.mjs
8
8
  import { AltiumScene3dExternalPlacementAdapter } from './AltiumScene3dExternalPlacementAdapter.mjs'
9
9
  import { AltiumScene3dBottomPadRotationAdapter } from './AltiumScene3dBottomPadRotationAdapter.mjs'
10
10
  import { AltiumScene3dComponentBodyAdapter } from './AltiumScene3dComponentBodyAdapter.mjs'
11
+ import { AltiumScene3dAuthoredBodyAnchorAdapter } from './AltiumScene3dAuthoredBodyAnchorAdapter.mjs'
11
12
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
12
13
  import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver.mjs'
13
14
  import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
14
15
  import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
15
16
  import { PcbScene3dStaticBodyPlacementBuilder } from './PcbScene3dStaticBodyPlacementBuilder.mjs'
16
17
  import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
18
+ import { PcbFootprintPadAxisNormalizer } from './PcbFootprintPadAxisNormalizer.mjs'
17
19
 
18
20
  /**
19
21
  * Builds deterministic 3D scene data from the normalized PCB model.
@@ -36,7 +38,9 @@ export class PcbScene3dBuilder {
36
38
  * @returns {{ board: { widthMil: number, heightMil: number, thicknessMil: number, minX: number, minY: number, centerX: number, centerY: number, segments: Array<Record<string, number | string>> }, boardAssemblyModel: { origin: string, name: string, format: string, file?: File | Blob | null, relativePath?: string } | null, components: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }[], externalPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } }[], staticBodyPlacements: { designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[], detail: { pads: any[], tracks: any[], arcs: any[], fills: any[], vias: any[], polygons: any[], silkscreen: { top: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number }, bottom: { fills: any[], tracks: any[], arcs: any[], texts: any[], fillColor?: number, strokeColor?: number } } } }}
37
39
  */
38
40
  static build(documentModel, options = {}) {
39
- const pcb = documentModel?.pcb || {}
41
+ const sceneDocumentModel =
42
+ PcbFootprintPadAxisNormalizer.apply(documentModel)
43
+ const pcb = sceneDocumentModel?.pcb || {}
40
44
  const appearance3d = pcb.appearance3d || {}
41
45
  const boardOutline = pcb.boardOutline || {}
42
46
  const primitiveLayers = Array.isArray(pcb.primitiveLayers)
@@ -171,16 +175,18 @@ export class PcbScene3dBuilder {
171
175
  }
172
176
  }
173
177
 
174
- return AltiumScene3dBottomPadRotationAdapter.apply(
175
- AltiumScene3dComponentBodyAdapter.apply(
176
- AltiumScene3dExternalPlacementAdapter.apply(
177
- PcbScene3dBoardOutlineRefiner.refine(
178
- sceneDescription,
179
- documentModel
178
+ return AltiumScene3dAuthoredBodyAnchorAdapter.apply(
179
+ AltiumScene3dBottomPadRotationAdapter.apply(
180
+ AltiumScene3dComponentBodyAdapter.apply(
181
+ AltiumScene3dExternalPlacementAdapter.apply(
182
+ PcbScene3dBoardOutlineRefiner.refine(
183
+ sceneDescription,
184
+ sceneDocumentModel
185
+ ),
186
+ sceneDocumentModel
180
187
  ),
181
- documentModel
182
- ),
183
- documentModel
188
+ sceneDocumentModel
189
+ )
184
190
  )
185
191
  )
186
192
  }