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.
Files changed (28) hide show
  1. package/docs/model-format.md +2 -3
  2. package/package.json +1 -1
  3. package/src/core/altium/AltiumGeneratedLibraryRecordBuilder.mjs +551 -0
  4. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +286 -2
  5. package/src/core/altium/AltiumPcbLibExporter.mjs +9 -2
  6. package/src/core/altium/AltiumSchLibExporter.mjs +1 -1
  7. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
  8. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +52 -4
  9. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
  10. package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
  11. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  12. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +27 -2
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
  15. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
  16. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +782 -0
  17. package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
  18. package/src/ui/PcbScene3dBuilder.mjs +595 -28
  19. package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
  20. package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
  21. package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
  22. package/src/ui/PcbScene3dPackages.mjs +15 -3
  23. package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +56 -0
  25. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +638 -16
  26. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
  27. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
  28. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
@@ -0,0 +1,280 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Converts parsed PCB region primitives into 3D copper fill detail.
7
+ */
8
+ export class PcbScene3dCopperRegionDetailBuilder {
9
+ /**
10
+ * Builds fill-compatible region primitives for 3D copper rendering.
11
+ * @param {{ regions?: object[], shapeBasedRegions?: object[] } | null} pcb Parsed PCB model.
12
+ * @returns {object[]}
13
+ */
14
+ static build(pcb) {
15
+ return PcbScene3dCopperRegionDetailBuilder.#sourceRegions(pcb)
16
+ .filter((region) =>
17
+ PcbScene3dCopperRegionDetailBuilder.#isCopperFillRegion(region)
18
+ )
19
+ .map((region) =>
20
+ PcbScene3dCopperRegionDetailBuilder.#withSegmentContours(region)
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Resolves the region family used by the 2D PCB renderer.
26
+ * @param {{ regions?: object[], shapeBasedRegions?: object[] } | null} pcb Parsed PCB model.
27
+ * @returns {object[]}
28
+ */
29
+ static #sourceRegions(pcb) {
30
+ const shapeBasedRegions = Array.isArray(pcb?.shapeBasedRegions)
31
+ ? pcb.shapeBasedRegions
32
+ : []
33
+ if (shapeBasedRegions.length) {
34
+ return shapeBasedRegions
35
+ }
36
+
37
+ return Array.isArray(pcb?.regions) ? pcb.regions : []
38
+ }
39
+
40
+ /**
41
+ * Checks whether one region should become rendered copper.
42
+ * @param {object | null} region Parsed region primitive.
43
+ * @returns {boolean}
44
+ */
45
+ static #isCopperFillRegion(region) {
46
+ return (
47
+ PcbScene3dCopperRegionDetailBuilder.#isCopperLayerId(
48
+ region?.layerId
49
+ ) &&
50
+ !PcbScene3dCopperRegionDetailBuilder.#isCutoutOrKeepout(region) &&
51
+ PcbScene3dCopperRegionDetailBuilder.#pointLoop(region?.points)
52
+ .length >= 3
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Checks whether one layer id belongs to Altium copper.
58
+ * @param {unknown} layerId Source layer id.
59
+ * @returns {boolean}
60
+ */
61
+ static #isCopperLayerId(layerId) {
62
+ const normalized = Number(layerId)
63
+ return (
64
+ Number.isInteger(normalized) && normalized >= 1 && normalized <= 32
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Checks whether one region describes clearance instead of copper.
70
+ * @param {object | null} region Parsed region primitive.
71
+ * @returns {boolean}
72
+ */
73
+ static #isCutoutOrKeepout(region) {
74
+ return (
75
+ region?.isKeepout === true ||
76
+ region?.isBoardCutout === true ||
77
+ region?.isPolygonPourCutout === true ||
78
+ region?.isCutout === true ||
79
+ Boolean(region?.cutoutClassification) ||
80
+ PcbScene3dCopperRegionDetailBuilder.#looksLikeCutout(
81
+ region?.classification
82
+ ) ||
83
+ PcbScene3dCopperRegionDetailBuilder.#looksLikeCutout(
84
+ region?.rawKind
85
+ ) ||
86
+ PcbScene3dCopperRegionDetailBuilder.#looksLikeCutout(
87
+ region?.properties?.KIND
88
+ )
89
+ )
90
+ }
91
+
92
+ /**
93
+ * Checks a native classification token for cutout semantics.
94
+ * @param {unknown} value Classification token.
95
+ * @returns {boolean}
96
+ */
97
+ static #looksLikeCutout(value) {
98
+ return String(value || '')
99
+ .replace(/[^a-z0-9]/giu, '')
100
+ .toLowerCase()
101
+ .includes('cutout')
102
+ }
103
+
104
+ /**
105
+ * Adds segment contours when a region includes arc vertices.
106
+ * @param {object} region Parsed region primitive.
107
+ * @returns {object}
108
+ */
109
+ static #withSegmentContours(region) {
110
+ const loops = [
111
+ region?.points,
112
+ ...(Array.isArray(region?.holes) ? region.holes : [])
113
+ ]
114
+
115
+ if (
116
+ !loops.some((loop) =>
117
+ PcbScene3dCopperRegionDetailBuilder.#hasArcSegment(loop)
118
+ )
119
+ ) {
120
+ return { ...region }
121
+ }
122
+
123
+ const contours = loops
124
+ .map((loop) =>
125
+ PcbScene3dCopperRegionDetailBuilder.#segmentContour(loop)
126
+ )
127
+ .filter((contour) => contour.length >= 3)
128
+
129
+ return contours.length ? { ...region, contours } : { ...region }
130
+ }
131
+
132
+ /**
133
+ * Checks whether a loop carries arc metadata.
134
+ * @param {unknown[]} points Candidate loop points.
135
+ * @returns {boolean}
136
+ */
137
+ static #hasArcSegment(points) {
138
+ return PcbScene3dCopperRegionDetailBuilder.#pointLoop(points).some(
139
+ (point) => PcbScene3dCopperRegionDetailBuilder.#isArcPoint(point)
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Converts one point loop into line and arc segments.
145
+ * @param {unknown[]} points Candidate loop points.
146
+ * @returns {object[]}
147
+ */
148
+ static #segmentContour(points) {
149
+ const loop = PcbScene3dCopperRegionDetailBuilder.#pointLoop(points)
150
+ if (loop.length < 3) {
151
+ return []
152
+ }
153
+
154
+ const segments = []
155
+ for (let index = 0; index < loop.length; index += 1) {
156
+ const current = loop[index]
157
+ const next = loop[(index + 1) % loop.length]
158
+ const segment =
159
+ PcbScene3dCopperRegionDetailBuilder.#arcSegment(
160
+ current,
161
+ next
162
+ ) ||
163
+ PcbScene3dCopperRegionDetailBuilder.#lineSegment(current, next)
164
+ segments.push(segment)
165
+ }
166
+
167
+ return segments
168
+ }
169
+
170
+ /**
171
+ * Builds a straight segment between two region vertices.
172
+ * @param {{ x: number, y: number }} current Start vertex.
173
+ * @param {{ x: number, y: number }} next End vertex.
174
+ * @returns {object}
175
+ */
176
+ static #lineSegment(current, next) {
177
+ return {
178
+ type: 'line',
179
+ x1: Number(current.x),
180
+ y1: Number(current.y),
181
+ x2: Number(next.x),
182
+ y2: Number(next.y)
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Builds an arc segment when the start vertex carries arc metadata.
188
+ * @param {{ x: number, y: number }} current Start vertex.
189
+ * @param {{ x: number, y: number }} next End vertex.
190
+ * @returns {object | null}
191
+ */
192
+ static #arcSegment(current, next) {
193
+ if (!PcbScene3dCopperRegionDetailBuilder.#isArcPoint(current)) {
194
+ return null
195
+ }
196
+
197
+ const center = PcbScene3dCopperRegionDetailBuilder.#arcCenter(current)
198
+ const radius = Number(current.radius)
199
+ if (
200
+ !center ||
201
+ !Number.isFinite(radius) ||
202
+ radius <= 0 ||
203
+ !Number.isFinite(Number(current.startAngle)) ||
204
+ !Number.isFinite(Number(current.endAngle))
205
+ ) {
206
+ return null
207
+ }
208
+
209
+ return {
210
+ type: 'arc',
211
+ x1: Number(current.x),
212
+ y1: Number(current.y),
213
+ x2: Number(next.x),
214
+ y2: Number(next.y),
215
+ x: center.x,
216
+ y: center.y,
217
+ radius,
218
+ startAngle: Number(current.startAngle),
219
+ endAngle: Number(current.endAngle)
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Resolves one arc center from known region vertex fields.
225
+ * @param {object} point Region vertex.
226
+ * @returns {{ x: number, y: number } | null}
227
+ */
228
+ static #arcCenter(point) {
229
+ const x = Number(point?.centerX ?? point?.cx ?? point?.center?.x)
230
+ const y = Number(point?.centerY ?? point?.cy ?? point?.center?.y)
231
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
232
+ }
233
+
234
+ /**
235
+ * Checks whether a point starts an arc segment.
236
+ * @param {object | null} point Region vertex.
237
+ * @returns {boolean}
238
+ */
239
+ static #isArcPoint(point) {
240
+ return point?.isArc === true && Number(point?.radius) > 0
241
+ }
242
+
243
+ /**
244
+ * Builds a finite point loop and removes a duplicate closing vertex.
245
+ * @param {unknown[] | undefined} points Candidate loop points.
246
+ * @returns {object[]}
247
+ */
248
+ static #pointLoop(points) {
249
+ const loop = (Array.isArray(points) ? points : []).filter((point) =>
250
+ PcbScene3dCopperRegionDetailBuilder.#isFinitePoint(point)
251
+ )
252
+
253
+ if (loop.length < 2) {
254
+ return loop
255
+ }
256
+
257
+ const first = loop[0]
258
+ const last = loop[loop.length - 1]
259
+ if (
260
+ Math.abs(Number(first.x) - Number(last.x)) < 1e-6 &&
261
+ Math.abs(Number(first.y) - Number(last.y)) < 1e-6
262
+ ) {
263
+ return loop.slice(0, -1)
264
+ }
265
+
266
+ return loop
267
+ }
268
+
269
+ /**
270
+ * Checks whether one object carries finite point coordinates.
271
+ * @param {unknown} point Candidate point.
272
+ * @returns {boolean}
273
+ */
274
+ static #isFinitePoint(point) {
275
+ return (
276
+ Number.isFinite(Number(point?.x)) &&
277
+ Number.isFinite(Number(point?.y))
278
+ )
279
+ }
280
+ }
@@ -104,6 +104,22 @@ export class PcbScene3dModelRegistry {
104
104
  return 'step'
105
105
  }
106
106
 
107
+ if (lowerCasePath.endsWith('.glb')) {
108
+ return 'glb'
109
+ }
110
+
111
+ if (lowerCasePath.endsWith('.gltf')) {
112
+ return 'gltf'
113
+ }
114
+
115
+ if (lowerCasePath.endsWith('.stl')) {
116
+ return 'stl'
117
+ }
118
+
119
+ if (lowerCasePath.endsWith('.obj')) {
120
+ return 'obj'
121
+ }
122
+
107
123
  return ''
108
124
  }
109
125
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Resolves package dimensions from structured component metadata.
3
+ */
4
+ export class PcbScene3dPackageDimensionResolver {
5
+ static #MIL_PER_MM = 39.3700787402
6
+
7
+ /**
8
+ * Resolves explicit planar package dimensions in mils.
9
+ * @param {{ parameters?: Record<string, unknown> } | null | undefined} component Source component.
10
+ * @returns {{ width: number, depth: number } | null}
11
+ */
12
+ static resolvePlanarSize(component) {
13
+ const parameters = component?.parameters
14
+ if (
15
+ !parameters ||
16
+ typeof parameters !== 'object' ||
17
+ Array.isArray(parameters)
18
+ ) {
19
+ return null
20
+ }
21
+
22
+ const length = PcbScene3dPackageDimensionResolver.#dimensionByKeys(
23
+ parameters,
24
+ ['length', 'package length', 'body length']
25
+ )
26
+ const width = PcbScene3dPackageDimensionResolver.#dimensionByKeys(
27
+ parameters,
28
+ ['width', 'package width', 'body width']
29
+ )
30
+ if (!length || !width) {
31
+ return null
32
+ }
33
+
34
+ return { width: length, depth: width }
35
+ }
36
+
37
+ /**
38
+ * Finds a dimension parameter by normalized key.
39
+ * @param {Record<string, unknown>} parameters Component parameters.
40
+ * @param {string[]} keys Accepted keys.
41
+ * @returns {number | null}
42
+ */
43
+ static #dimensionByKeys(parameters, keys) {
44
+ const acceptedKeys = new Set(
45
+ keys.map((key) =>
46
+ PcbScene3dPackageDimensionResolver.#normalizeKey(key)
47
+ )
48
+ )
49
+
50
+ for (const [key, value] of Object.entries(parameters)) {
51
+ if (
52
+ acceptedKeys.has(
53
+ PcbScene3dPackageDimensionResolver.#normalizeKey(key)
54
+ )
55
+ ) {
56
+ const dimension =
57
+ PcbScene3dPackageDimensionResolver.#parseDimensionMil(value)
58
+ if (dimension) {
59
+ return dimension
60
+ }
61
+ }
62
+ }
63
+
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * Parses one dimension string into mils.
69
+ * @param {unknown} value Source parameter value.
70
+ * @returns {number | null}
71
+ */
72
+ static #parseDimensionMil(value) {
73
+ const text = String(value || '').replace(/,/gu, '.')
74
+ const match = text.match(
75
+ /(-?\d+(?:\.\d+)?)\s*(mm|millimeters?|mils?|in(?:ch(?:es)?)?|")/iu
76
+ )
77
+ if (!match) {
78
+ return null
79
+ }
80
+
81
+ const amount = Number(match[1])
82
+ if (!Number.isFinite(amount) || amount <= 0) {
83
+ return null
84
+ }
85
+
86
+ const unit = String(match[2] || '').toLowerCase()
87
+ if (unit === 'mm' || unit.startsWith('millimeter')) {
88
+ return amount * PcbScene3dPackageDimensionResolver.#MIL_PER_MM
89
+ }
90
+ if (unit === 'mil' || unit === 'mils') {
91
+ return amount
92
+ }
93
+ return amount * 1000
94
+ }
95
+
96
+ /**
97
+ * Normalizes one parameter key.
98
+ * @param {unknown} key Source key.
99
+ * @returns {string}
100
+ */
101
+ static #normalizeKey(key) {
102
+ return String(key || '')
103
+ .trim()
104
+ .toLowerCase()
105
+ .replace(/\s+/gu, ' ')
106
+ }
107
+ }
@@ -2,13 +2,15 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { PcbScene3dPackageDimensionResolver } from './PcbScene3dPackageDimensionResolver.mjs'
6
+
5
7
  /**
6
8
  * Resolves procedural PCB package families and dimensions.
7
9
  */
8
10
  export class PcbScene3dPackages {
9
11
  /**
10
12
  * Resolves one procedural package description for a component.
11
- * @param {{ pattern?: string, height?: number | null }} component
13
+ * @param {{ pattern?: string, height?: number | null, parameters?: Record<string, unknown> }} component
12
14
  * @param {{ width: number, depth: number }} [padSpan]
13
15
  * @returns {{ family: string, sizeMil: { width: number, depth: number, height: number } }}
14
16
  */
@@ -23,12 +25,22 @@ export class PcbScene3dPackages {
23
25
  Number.isFinite(explicitHeight) && explicitHeight > 0
24
26
  ? explicitHeight
25
27
  : defaults.height
28
+ const explicitSize =
29
+ PcbScene3dPackageDimensionResolver.resolvePlanarSize(component)
26
30
 
27
31
  return {
28
32
  family,
29
33
  sizeMil: {
30
- width: Math.max(defaults.width, Number(padSpan.width) || 0),
31
- depth: Math.max(defaults.depth, Number(padSpan.depth) || 0),
34
+ width: Math.max(
35
+ defaults.width,
36
+ Number(padSpan.width) || 0,
37
+ Number(explicitSize?.width) || 0
38
+ ),
39
+ depth: Math.max(
40
+ defaults.depth,
41
+ Number(padSpan.depth) || 0,
42
+ Number(explicitSize?.depth) || 0
43
+ ),
32
44
  height
33
45
  }
34
46
  }
@@ -0,0 +1,200 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Resolves procedural body yaw from owned PCB pad geometry.
7
+ */
8
+ export class PcbScene3dPadYawResolver {
9
+ static #DOMINANT_AXIS_RATIO = 1.25
10
+ static #EPSILON = 1e-9
11
+
12
+ /**
13
+ * Resolves a component yaw from its owned pad-center distribution.
14
+ * @param {{ componentIndex?: number, rotation?: number }} component Source component.
15
+ * @param {object[]} pads Source PCB pads.
16
+ * @param {'top' | 'bottom' | string} [mountSide] Component mount side.
17
+ * @returns {number | null}
18
+ */
19
+ static resolve(component, pads, mountSide = 'top') {
20
+ const points = PcbScene3dPadYawResolver.#ownedPadCenters(
21
+ component,
22
+ pads,
23
+ mountSide
24
+ )
25
+ if (points.length < 2) {
26
+ return null
27
+ }
28
+
29
+ const axis = PcbScene3dPadYawResolver.#dominantAxis(points)
30
+ if (axis === null) {
31
+ return null
32
+ }
33
+
34
+ return PcbScene3dPadYawResolver.#closestHalfTurnEquivalent(
35
+ axis,
36
+ Number(component?.rotation || 0)
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Resolves finite owned pad centers, preferring mounted-surface pads.
42
+ * @param {{ componentIndex?: number }} component Source component.
43
+ * @param {object[]} pads Source PCB pads.
44
+ * @param {'top' | 'bottom' | string} mountSide Component mount side.
45
+ * @returns {{ x: number, y: number }[]}
46
+ */
47
+ static #ownedPadCenters(component, pads, mountSide) {
48
+ const componentIndex = Number(component?.componentIndex)
49
+ if (!Number.isFinite(componentIndex) || !Array.isArray(pads)) {
50
+ return []
51
+ }
52
+
53
+ const ownedPads = pads.filter(
54
+ (pad) => Number(pad?.componentIndex) === componentIndex
55
+ )
56
+ const surfacePads = ownedPads.filter((pad) =>
57
+ PcbScene3dPadYawResolver.#isSurfacePad(pad, mountSide)
58
+ )
59
+ const yawPads = surfacePads.length ? surfacePads : ownedPads
60
+
61
+ return yawPads
62
+ .map((pad) => PcbScene3dPadYawResolver.#padCenter(pad))
63
+ .filter(Boolean)
64
+ }
65
+
66
+ /**
67
+ * Checks whether one pad belongs to the mounted paste-mask side.
68
+ * @param {object} pad Source pad.
69
+ * @param {'top' | 'bottom' | string} mountSide Component mount side.
70
+ * @returns {boolean}
71
+ */
72
+ static #isSurfacePad(pad, mountSide) {
73
+ return String(mountSide || '').toLowerCase() === 'bottom'
74
+ ? Boolean(pad?.hasBottomPasteMaskOpening)
75
+ : Boolean(pad?.hasTopPasteMaskOpening)
76
+ }
77
+
78
+ /**
79
+ * Resolves one finite pad center.
80
+ * @param {{ x?: number, y?: number }} pad Source pad.
81
+ * @returns {{ x: number, y: number } | null}
82
+ */
83
+ static #padCenter(pad) {
84
+ const x = Number(pad?.x)
85
+ const y = Number(pad?.y)
86
+
87
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
88
+ }
89
+
90
+ /**
91
+ * Resolves a dominant pad-center axis, modulo 180 degrees.
92
+ * @param {{ x: number, y: number }[]} points Pad centers.
93
+ * @returns {number | null}
94
+ */
95
+ static #dominantAxis(points) {
96
+ const mean = PcbScene3dPadYawResolver.#meanPoint(points)
97
+ let xx = 0
98
+ let xy = 0
99
+ let yy = 0
100
+
101
+ for (const point of points) {
102
+ const dx = point.x - mean.x
103
+ const dy = point.y - mean.y
104
+ xx += dx * dx
105
+ xy += dx * dy
106
+ yy += dy * dy
107
+ }
108
+
109
+ xx /= points.length
110
+ xy /= points.length
111
+ yy /= points.length
112
+
113
+ const trace = xx + yy
114
+ const discriminant = Math.sqrt((xx - yy) ** 2 + 4 * xy * xy)
115
+ const primary = (trace + discriminant) / 2
116
+ const secondary = (trace - discriminant) / 2
117
+
118
+ if (
119
+ primary <= PcbScene3dPadYawResolver.#EPSILON ||
120
+ (secondary > PcbScene3dPadYawResolver.#EPSILON &&
121
+ primary / secondary <
122
+ PcbScene3dPadYawResolver.#DOMINANT_AXIS_RATIO)
123
+ ) {
124
+ return null
125
+ }
126
+
127
+ return PcbScene3dPadYawResolver.#normalizeHalfTurn(
128
+ (Math.atan2(2 * xy, xx - yy) * 90) / Math.PI
129
+ )
130
+ }
131
+
132
+ /**
133
+ * Resolves the mean point for a non-empty point set.
134
+ * @param {{ x: number, y: number }[]} points Pad centers.
135
+ * @returns {{ x: number, y: number }}
136
+ */
137
+ static #meanPoint(points) {
138
+ const sum = points.reduce(
139
+ (accumulator, point) => ({
140
+ x: accumulator.x + point.x,
141
+ y: accumulator.y + point.y
142
+ }),
143
+ { x: 0, y: 0 }
144
+ )
145
+
146
+ return {
147
+ x: sum.x / points.length,
148
+ y: sum.y / points.length
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Selects the 180-degree equivalent closest to the source rotation.
154
+ * @param {number} axis Dominant axis in degrees.
155
+ * @param {number} sourceRotation Source component rotation.
156
+ * @returns {number}
157
+ */
158
+ static #closestHalfTurnEquivalent(axis, sourceRotation) {
159
+ const first = PcbScene3dPadYawResolver.#normalizeAngle(axis)
160
+ const second = PcbScene3dPadYawResolver.#normalizeAngle(axis + 180)
161
+ const source = PcbScene3dPadYawResolver.#normalizeAngle(sourceRotation)
162
+
163
+ return PcbScene3dPadYawResolver.#angularDistance(first, source) <=
164
+ PcbScene3dPadYawResolver.#angularDistance(second, source)
165
+ ? first
166
+ : second
167
+ }
168
+
169
+ /**
170
+ * Resolves the smallest angular distance between two directions.
171
+ * @param {number} left First angle.
172
+ * @param {number} right Second angle.
173
+ * @returns {number}
174
+ */
175
+ static #angularDistance(left, right) {
176
+ const distance = Math.abs(
177
+ PcbScene3dPadYawResolver.#normalizeAngle(left - right)
178
+ )
179
+
180
+ return distance > 180 ? 360 - distance : distance
181
+ }
182
+
183
+ /**
184
+ * Normalizes an angle to 0 inclusive through 180 exclusive.
185
+ * @param {number} angle Angle in degrees.
186
+ * @returns {number}
187
+ */
188
+ static #normalizeHalfTurn(angle) {
189
+ return ((Number(angle || 0) % 180) + 180) % 180
190
+ }
191
+
192
+ /**
193
+ * Normalizes an angle to 0 inclusive through 360 exclusive.
194
+ * @param {number} angle Angle in degrees.
195
+ * @returns {number}
196
+ */
197
+ static #normalizeAngle(angle) {
198
+ return ((Number(angle || 0) % 360) + 360) % 360
199
+ }
200
+ }