altium-toolkit 1.1.35 → 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.
Files changed (35) hide show
  1. package/AGENTS.md +17 -2
  2. package/package.json +1 -1
  3. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  4. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  5. package/src/core/altium/SchematicTextParser.mjs +18 -253
  6. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  7. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  8. package/src/parser.mjs +1 -0
  9. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
  10. package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
  11. package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
  12. package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
  15. package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
  16. package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
  17. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
  18. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +286 -135
  19. package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
  20. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
  21. package/src/ui/PcbScene3dBuilder.mjs +613 -117
  22. package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
  23. package/src/ui/PcbScene3dPackages.mjs +16 -1
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
  25. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +984 -0
  26. package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
  27. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +169 -23
  28. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  29. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
  30. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
  31. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +439 -0
  32. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
  33. package/src/ui/SchematicColorResolver.mjs +44 -0
  34. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  35. package/src/ui/SchematicShapeRenderer.mjs +13 -13
@@ -1,6 +1,4 @@
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
1
+ import { AltiumScene3dShapeStackOwnerConflictPolicy } from './AltiumScene3dShapeStackOwnerConflictPolicy.mjs'
4
2
 
5
3
  /**
6
4
  * Keeps authored shape-based sub-bodies grouped under their carrier owner.
@@ -10,7 +8,8 @@ export class AltiumScene3dShapeStackOwnerAdapter {
10
8
  static #EXACT_OWNER_TOLERANCE_MIL = 1
11
9
  static #STACK_BODY_RADIUS_MIL = 220
12
10
  static #HEIGHT_TOLERANCE_MIL = 0.1
13
-
11
+ static #OPPOSITE_SIDE_OWNER_DISTANCE_MARGIN_MIL = 10
12
+ static #STACK_BODY_OWNER_CONFLICT_MARGIN_MIL = 5
14
13
  /**
15
14
  * Applies authored stack ownership to static and external placements.
16
15
  * @param {object} sceneDescription Built scene description.
@@ -53,23 +52,25 @@ export class AltiumScene3dShapeStackOwnerAdapter {
53
52
  ])
54
53
  )
55
54
 
55
+ const repairedExternalPlacements =
56
+ AltiumScene3dShapeStackOwnerAdapter.#repairExternalPlacements(
57
+ sceneDescription.externalPlacements,
58
+ componentBodies,
59
+ assignments,
60
+ sceneDescription.board,
61
+ components
62
+ )
63
+
56
64
  return {
57
65
  ...sceneDescription,
58
66
  components: AltiumScene3dShapeStackOwnerAdapter.#markComponents(
59
67
  sceneDescription.components,
60
68
  ownerGroupKeys
61
69
  ),
62
- externalPlacements:
63
- AltiumScene3dShapeStackOwnerAdapter.#repairExternalPlacements(
64
- sceneDescription.externalPlacements,
65
- componentBodies,
66
- assignments,
67
- sceneDescription.board
68
- ),
70
+ externalPlacements: repairedExternalPlacements,
69
71
  staticBodyPlacements:
70
72
  AltiumScene3dShapeStackOwnerAdapter.#repairStaticPlacements(
71
73
  sceneDescription.staticBodyPlacements,
72
- componentBodies,
73
74
  assignments,
74
75
  sceneDescription.board
75
76
  )
@@ -102,17 +103,19 @@ export class AltiumScene3dShapeStackOwnerAdapter {
102
103
  * @param {object[]} componentBodies Source component-body rows.
103
104
  * @param {object[]} assignments Carrier assignments.
104
105
  * @param {object | undefined} board Scene board metadata.
106
+ * @param {object[]} components Source components.
105
107
  * @returns {object[]}
106
108
  */
107
109
  static #repairExternalPlacements(
108
110
  placements,
109
111
  componentBodies,
110
112
  assignments,
111
- board
113
+ board,
114
+ components
112
115
  ) {
113
116
  const usedComponentBodies = new Set()
114
117
 
115
- return (Array.isArray(placements) ? placements : []).map(
118
+ return (Array.isArray(placements) ? placements : []).flatMap(
116
119
  (placement) => {
117
120
  const componentBody =
118
121
  AltiumScene3dShapeStackOwnerAdapter.#resolveComponentBody(
@@ -127,18 +130,27 @@ export class AltiumScene3dShapeStackOwnerAdapter {
127
130
  const assignment =
128
131
  AltiumScene3dShapeStackOwnerAdapter.#assignmentForBody(
129
132
  componentBody,
130
- assignments
133
+ assignments,
134
+ components
131
135
  )
132
136
  if (!assignment) {
133
- return placement
137
+ return AltiumScene3dShapeStackOwnerAdapter.#shouldDropConflictingStackBody(
138
+ componentBody,
139
+ assignments,
140
+ components
141
+ )
142
+ ? []
143
+ : [placement]
134
144
  }
135
145
 
136
- return AltiumScene3dShapeStackOwnerAdapter.#withOwner(
137
- placement,
138
- componentBody,
139
- assignment,
140
- board
141
- )
146
+ return [
147
+ AltiumScene3dShapeStackOwnerAdapter.#withOwner(
148
+ placement,
149
+ componentBody,
150
+ assignment,
151
+ board
152
+ )
153
+ ]
142
154
  }
143
155
  )
144
156
  }
@@ -146,17 +158,11 @@ export class AltiumScene3dShapeStackOwnerAdapter {
146
158
  /**
147
159
  * Repairs carrier static body ownership.
148
160
  * @param {object[] | undefined} placements Scene static body placements.
149
- * @param {object[]} componentBodies Source component-body rows.
150
161
  * @param {object[]} assignments Carrier assignments.
151
162
  * @param {object | undefined} board Scene board metadata.
152
163
  * @returns {object[]}
153
164
  */
154
- static #repairStaticPlacements(
155
- placements,
156
- componentBodies,
157
- assignments,
158
- board
159
- ) {
165
+ static #repairStaticPlacements(placements, assignments, board) {
160
166
  return (Array.isArray(placements) ? placements : []).map(
161
167
  (placement) => {
162
168
  const assignment = assignments.find((candidate) =>
@@ -169,21 +175,103 @@ export class AltiumScene3dShapeStackOwnerAdapter {
169
175
  return placement
170
176
  }
171
177
 
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
- }
178
+ return AltiumScene3dShapeStackOwnerAdapter.#withStaticOwner(
179
+ placement,
180
+ assignment,
181
+ board
182
+ )
183
183
  }
184
184
  )
185
185
  }
186
186
 
187
+ /**
188
+ * Applies one stack owner to a repaired static carrier body.
189
+ * @param {object} placement Static body placement.
190
+ * @param {{ owner: object, groupKey: string }} assignment Stack assignment.
191
+ * @param {object | undefined} board Scene board metadata.
192
+ * @returns {object}
193
+ */
194
+ static #withStaticOwner(placement, assignment, board) {
195
+ const ownerDesignator = String(assignment?.owner?.designator || '')
196
+ const mountSide =
197
+ AltiumScene3dShapeStackOwnerAdapter.#mountSide(assignment?.owner) ||
198
+ placement?.mountSide
199
+ const sidePlacement =
200
+ AltiumScene3dShapeStackOwnerAdapter.#withStaticMountSide(
201
+ placement,
202
+ mountSide
203
+ )
204
+
205
+ return {
206
+ ...sidePlacement,
207
+ designator: ownerDesignator,
208
+ selectionKey: ownerDesignator || sidePlacement.selectionKey,
209
+ positionMil: AltiumScene3dShapeStackOwnerAdapter.#withOwnerAnchor(
210
+ sidePlacement?.positionMil,
211
+ assignment.owner,
212
+ board
213
+ ),
214
+ coLocatedVariantGroupKey: assignment.groupKey
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Applies a static placement side while preserving its height magnitude.
220
+ * @param {object} placement Static body placement.
221
+ * @param {string | undefined} mountSide Owner mount side.
222
+ * @returns {object}
223
+ */
224
+ static #withStaticMountSide(placement, mountSide) {
225
+ const currentSide =
226
+ AltiumScene3dShapeStackOwnerAdapter.#normalizeMountSide(
227
+ placement?.mountSide
228
+ )
229
+ const side = AltiumScene3dShapeStackOwnerAdapter.#normalizeMountSide(
230
+ mountSide || currentSide
231
+ )
232
+ const z = Math.abs(Number(placement?.positionMil?.z || 0))
233
+
234
+ return {
235
+ ...placement,
236
+ mountSide: side,
237
+ positionMil: {
238
+ ...(placement?.positionMil || {}),
239
+ z: side === 'bottom' ? -z : z
240
+ },
241
+ geometry:
242
+ currentSide === side
243
+ ? placement?.geometry
244
+ : AltiumScene3dShapeStackOwnerAdapter.#mirrorStaticGeometry(
245
+ placement
246
+ )
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Mirrors source-coordinate static geometry for the opposite board side.
252
+ * @param {{ sourceCoordinateFrame?: boolean, geometry?: object }} placement Static body placement.
253
+ * @returns {object | undefined}
254
+ */
255
+ static #mirrorStaticGeometry(placement) {
256
+ const geometry = placement?.geometry
257
+ if (
258
+ !placement?.sourceCoordinateFrame ||
259
+ !Array.isArray(geometry?.verticesMil)
260
+ ) {
261
+ return geometry
262
+ }
263
+
264
+ return {
265
+ ...geometry,
266
+ verticesMil: geometry.verticesMil.map((vertex) => ({
267
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(vertex?.x || 0),
268
+ y: AltiumScene3dShapeStackOwnerAdapter.#round(
269
+ -Number(vertex?.y || 0)
270
+ )
271
+ }))
272
+ }
273
+ }
274
+
187
275
  /**
188
276
  * Re-anchors a repaired static carrier on the owning component position.
189
277
  * @param {object | undefined} position Existing render position.
@@ -214,7 +302,9 @@ export class AltiumScene3dShapeStackOwnerAdapter {
214
302
  AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(body)
215
303
  )
216
304
  const timingComponents = components.filter((component) =>
217
- AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(component)
305
+ AltiumScene3dShapeStackOwnerConflictPolicy.isTimingComponent(
306
+ component
307
+ )
218
308
  )
219
309
  const groups =
220
310
  AltiumScene3dShapeStackOwnerAdapter.#groupCarrierBases(bases)
@@ -229,20 +319,26 @@ export class AltiumScene3dShapeStackOwnerAdapter {
229
319
  return []
230
320
  }
231
321
 
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(
322
+ const owners =
323
+ AltiumScene3dShapeStackOwnerConflictPolicy.preferredSideOwners(
324
+ group.anchor,
325
+ timingComponents.filter(
326
+ (component) =>
327
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
328
+ group.anchor,
329
+ component
330
+ ) <=
331
+ AltiumScene3dShapeStackOwnerAdapter
332
+ .#BASE_OWNER_TOLERANCE_MIL
333
+ ),
334
+ AltiumScene3dShapeStackOwnerAdapter
335
+ .#OPPOSITE_SIDE_OWNER_DISTANCE_MARGIN_MIL
336
+ ).sort(
243
337
  (left, right) =>
244
- AltiumScene3dShapeStackOwnerAdapter.#ownerRank(right) -
245
- AltiumScene3dShapeStackOwnerAdapter.#ownerRank(
338
+ AltiumScene3dShapeStackOwnerConflictPolicy.ownerRank(
339
+ right
340
+ ) -
341
+ AltiumScene3dShapeStackOwnerConflictPolicy.ownerRank(
246
342
  left
247
343
  ) ||
248
344
  String(left?.designator || '').localeCompare(
@@ -305,12 +401,12 @@ export class AltiumScene3dShapeStackOwnerAdapter {
305
401
  return (
306
402
  exactOwners.some(
307
403
  (component) =>
308
- !AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
404
+ !AltiumScene3dShapeStackOwnerConflictPolicy.isTimingComponent(
309
405
  component
310
406
  )
311
407
  ) &&
312
408
  !exactOwners.some((component) =>
313
- AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
409
+ AltiumScene3dShapeStackOwnerConflictPolicy.isTimingComponent(
314
410
  component
315
411
  )
316
412
  )
@@ -368,9 +464,109 @@ export class AltiumScene3dShapeStackOwnerAdapter {
368
464
  * Finds a carrier assignment for one positive-height sub-body.
369
465
  * @param {object | null} componentBody Source component-body row.
370
466
  * @param {object[]} assignments Carrier assignments.
467
+ * @param {object[]} components Source components.
371
468
  * @returns {object | null}
372
469
  */
373
- static #assignmentForBody(componentBody, assignments) {
470
+ static #assignmentForBody(componentBody, assignments, components) {
471
+ return (
472
+ AltiumScene3dShapeStackOwnerAdapter.#assignmentCandidatesForBody(
473
+ componentBody,
474
+ assignments
475
+ )
476
+ .filter((candidate) =>
477
+ AltiumScene3dShapeStackOwnerAdapter.#canAssignStackBodyCandidate(
478
+ componentBody,
479
+ candidate,
480
+ components
481
+ )
482
+ )
483
+ .sort(
484
+ (left, right) =>
485
+ left.heightError - right.heightError ||
486
+ left.distance - right.distance
487
+ )[0]?.assignment || null
488
+ )
489
+ }
490
+
491
+ /**
492
+ * Checks whether a candidate stack owner can claim a raised body.
493
+ * @param {object | null} componentBody Source component-body row.
494
+ * @param {{ assignment: { owner: object } }} candidate Candidate assignment row.
495
+ * @param {object[]} components Source components.
496
+ * @returns {boolean}
497
+ */
498
+ static #canAssignStackBodyCandidate(componentBody, candidate, components) {
499
+ const hasConflictingOwner =
500
+ AltiumScene3dShapeStackOwnerConflictPolicy.hasConflictingComponentOwner(
501
+ componentBody,
502
+ candidate.assignment.owner,
503
+ components,
504
+ AltiumScene3dShapeStackOwnerAdapter
505
+ .#STACK_BODY_OWNER_CONFLICT_MARGIN_MIL
506
+ )
507
+ if (!hasConflictingOwner) {
508
+ return true
509
+ }
510
+
511
+ return (
512
+ AltiumScene3dShapeStackOwnerConflictPolicy.hasStackDetailBodyIdentity(
513
+ componentBody
514
+ ) &&
515
+ !AltiumScene3dShapeStackOwnerConflictPolicy.hasExactOppositeSideOwner(
516
+ componentBody,
517
+ candidate.assignment.owner,
518
+ components,
519
+ AltiumScene3dShapeStackOwnerAdapter.#EXACT_OWNER_TOLERANCE_MIL
520
+ )
521
+ )
522
+ }
523
+
524
+ /**
525
+ * Checks whether an unassigned positive-height body should be suppressed
526
+ * because it conflicts with an opposite-side owner but has no exact owner
527
+ * of its own.
528
+ * @param {object | null} componentBody Source component-body row.
529
+ * @param {object[]} assignments Carrier assignments.
530
+ * @param {object[]} components Source components.
531
+ * @returns {boolean}
532
+ */
533
+ static #shouldDropConflictingStackBody(
534
+ componentBody,
535
+ assignments,
536
+ components
537
+ ) {
538
+ const conflict =
539
+ AltiumScene3dShapeStackOwnerAdapter.#assignmentCandidatesForBody(
540
+ componentBody,
541
+ assignments
542
+ ).find((candidate) =>
543
+ AltiumScene3dShapeStackOwnerConflictPolicy.hasConflictingComponentOwner(
544
+ componentBody,
545
+ candidate.assignment.owner,
546
+ components,
547
+ AltiumScene3dShapeStackOwnerAdapter
548
+ .#STACK_BODY_OWNER_CONFLICT_MARGIN_MIL
549
+ )
550
+ )
551
+
552
+ return Boolean(
553
+ conflict &&
554
+ !AltiumScene3dShapeStackOwnerConflictPolicy.hasExactOppositeSideOwner(
555
+ componentBody,
556
+ conflict.assignment.owner,
557
+ components,
558
+ AltiumScene3dShapeStackOwnerAdapter.#EXACT_OWNER_TOLERANCE_MIL
559
+ )
560
+ )
561
+ }
562
+
563
+ /**
564
+ * Builds stack assignment candidates for one positive-height body.
565
+ * @param {object | null} componentBody Source component-body row.
566
+ * @param {object[]} assignments Carrier assignments.
567
+ * @returns {{ assignment: object, heightError: number, distance: number }[]}
568
+ */
569
+ static #assignmentCandidatesForBody(componentBody, assignments) {
374
570
  if (
375
571
  !componentBody ||
376
572
  !AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
@@ -378,7 +574,7 @@ export class AltiumScene3dShapeStackOwnerAdapter {
378
574
  ) ||
379
575
  Number(componentBody?.standoffHeightMil || 0) <= 0
380
576
  ) {
381
- return null
577
+ return []
382
578
  }
383
579
 
384
580
  const standoff = Number(componentBody.standoffHeightMil)
@@ -386,33 +582,26 @@ export class AltiumScene3dShapeStackOwnerAdapter {
386
582
  componentBody.positionMil
387
583
  )
388
584
 
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
585
+ return (Array.isArray(assignments) ? assignments : [])
586
+ .map((assignment) => ({
587
+ assignment,
588
+ heightError: Math.abs(
589
+ Number(assignment.heightMil || 0) - standoff
590
+ ),
591
+ distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
592
+ bodyPosition,
593
+ assignment.owner
409
594
  )
410
- .sort(
411
- (left, right) =>
412
- left.heightError - right.heightError ||
413
- left.distance - right.distance
414
- )[0]?.assignment || null
415
- )
595
+ }))
596
+ .filter(
597
+ (candidate) =>
598
+ candidate.heightError <=
599
+ AltiumScene3dShapeStackOwnerAdapter
600
+ .#HEIGHT_TOLERANCE_MIL &&
601
+ candidate.distance <=
602
+ AltiumScene3dShapeStackOwnerAdapter
603
+ .#STACK_BODY_RADIUS_MIL
604
+ )
416
605
  }
417
606
 
418
607
  /**
@@ -619,57 +808,6 @@ export class AltiumScene3dShapeStackOwnerAdapter {
619
808
  : 0
620
809
  }
621
810
 
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
811
  /**
674
812
  * Checks whether one body row came from shape-based component metadata.
675
813
  * @param {object | null | undefined} componentBody Source body row.
@@ -691,6 +829,19 @@ export class AltiumScene3dShapeStackOwnerAdapter {
691
829
  return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
692
830
  }
693
831
 
832
+ /**
833
+ * Normalizes a static placement side token.
834
+ * @param {string | undefined} mountSide Candidate mount side.
835
+ * @returns {'top' | 'bottom'}
836
+ */
837
+ static #normalizeMountSide(mountSide) {
838
+ return String(mountSide || 'top')
839
+ .trim()
840
+ .toLowerCase() === 'bottom'
841
+ ? 'bottom'
842
+ : 'top'
843
+ }
844
+
694
845
  /**
695
846
  * Resolves one board face Z coordinate.
696
847
  * @param {string} mountSide Mount side.