altium-toolkit 1.1.26 → 1.1.30
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/package.json +1 -1
- package/src/ui/AltiumScene3dBottomPadRotationAdapter.mjs +127 -0
- package/src/ui/AltiumScene3dComponentBodyAdapter.mjs +153 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +919 -0
- package/src/ui/AltiumScene3dIdentityTokens.mjs +105 -0
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +357 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +576 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +54 -11
- package/src/ui/PcbScene3dBuilder.mjs +69 -32
- package/src/ui/PcbScene3dPadLocalSpanResolver.mjs +109 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const GENERIC_IDENTITY_TOKENS = new Set([
|
|
2
|
+
'body',
|
|
3
|
+
'component',
|
|
4
|
+
'crystal',
|
|
5
|
+
'footprint',
|
|
6
|
+
'library',
|
|
7
|
+
'metric',
|
|
8
|
+
'model',
|
|
9
|
+
'package',
|
|
10
|
+
'series'
|
|
11
|
+
])
|
|
12
|
+
const GENERIC_PACKAGE_PREFIX_TOKENS = new Set([
|
|
13
|
+
...GENERIC_IDENTITY_TOKENS,
|
|
14
|
+
'user'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds normalized Altium 3D body identity tokens for metadata matching.
|
|
19
|
+
*/
|
|
20
|
+
export class AltiumScene3dIdentityTokens {
|
|
21
|
+
/**
|
|
22
|
+
* Creates both full and delimiter-split identity tokens from one source.
|
|
23
|
+
* @param {string} value Source text.
|
|
24
|
+
* @returns {string[]}
|
|
25
|
+
*/
|
|
26
|
+
static fromText(value) {
|
|
27
|
+
const baseText = String(value || '')
|
|
28
|
+
.replace(/\.[^.]+$/, '')
|
|
29
|
+
.trim()
|
|
30
|
+
const fullToken = AltiumScene3dIdentityTokens.#normalize([baseText])
|
|
31
|
+
const normalizedParts = baseText
|
|
32
|
+
.split(/[^a-zA-Z0-9]+/g)
|
|
33
|
+
.map((part) => AltiumScene3dIdentityTokens.#normalize([part]))
|
|
34
|
+
const packageTokens =
|
|
35
|
+
AltiumScene3dIdentityTokens.#packageTokensForParts(normalizedParts)
|
|
36
|
+
|
|
37
|
+
return [
|
|
38
|
+
...new Set([
|
|
39
|
+
...[fullToken, ...normalizedParts].filter((token) =>
|
|
40
|
+
AltiumScene3dIdentityTokens.#isMeaningful(token)
|
|
41
|
+
),
|
|
42
|
+
...packageTokens
|
|
43
|
+
])
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates compact package-code tokens after generic library words are
|
|
49
|
+
* removed.
|
|
50
|
+
* @param {string[]} parts Normalized identity parts.
|
|
51
|
+
* @returns {string[]}
|
|
52
|
+
*/
|
|
53
|
+
static #packageTokensForParts(parts) {
|
|
54
|
+
const meaningfulParts = (Array.isArray(parts) ? parts : []).filter(
|
|
55
|
+
(part) => part && !GENERIC_PACKAGE_PREFIX_TOKENS.has(part)
|
|
56
|
+
)
|
|
57
|
+
const tokens = []
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < meaningfulParts.length - 1; index += 1) {
|
|
60
|
+
const token = meaningfulParts.slice(index).join('')
|
|
61
|
+
if (AltiumScene3dIdentityTokens.#isMeaningfulPackageCode(token)) {
|
|
62
|
+
tokens.push(token)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return tokens
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Checks whether one compact package code is strong enough for metadata
|
|
71
|
+
* matching.
|
|
72
|
+
* @param {string} token Normalized token.
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
static #isMeaningfulPackageCode(token) {
|
|
76
|
+
return (
|
|
77
|
+
token.length >= 4 &&
|
|
78
|
+
/[a-z]/u.test(token) &&
|
|
79
|
+
/\d/u.test(token) &&
|
|
80
|
+
!GENERIC_IDENTITY_TOKENS.has(token)
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks whether one identity token is strong enough for metadata matching.
|
|
86
|
+
* @param {string} token Normalized token.
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
static #isMeaningful(token) {
|
|
90
|
+
return token.length >= 6 && !GENERIC_IDENTITY_TOKENS.has(token)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalizes identity strings for exact substring matching.
|
|
95
|
+
* @param {unknown[]} values Source values.
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
static #normalize(values) {
|
|
99
|
+
return values
|
|
100
|
+
.map((value) => String(value || '').toLowerCase())
|
|
101
|
+
.join(' ')
|
|
102
|
+
.replace(/\.[a-z0-9]+\\b/g, '')
|
|
103
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
const PASSIVE_BODY_PATTERN =
|
|
2
|
+
/(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
|
|
3
|
+
const PIN_ONE_CORNER_PACKAGE_PATTERN =
|
|
4
|
+
/(?:^|[^a-z0-9])(?:[avw]?qfn|vfqfn|wqfn|v?qfp|lqfp|tqfp|pqfp|mqfp)(?:[0-9]+)?(?:$|[^a-z0-9])/i
|
|
5
|
+
const FIVE_LEAD_SOT_PATTERN =
|
|
6
|
+
/(?:^|[^a-z0-9])sot[-_ ]?(?:23[-_ ]?5|25|5)(?:$|[^a-z0-9])/i
|
|
7
|
+
const EDGE_CONNECTOR_TOKENS = new Set([
|
|
8
|
+
'antenna',
|
|
9
|
+
'coax',
|
|
10
|
+
'connector',
|
|
11
|
+
'edge',
|
|
12
|
+
'rf',
|
|
13
|
+
'sma',
|
|
14
|
+
'socket'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves generic Altium external-model yaw correction rules.
|
|
19
|
+
*/
|
|
20
|
+
export class AltiumScene3dPlacementRotationPolicy {
|
|
21
|
+
/**
|
|
22
|
+
* Checks whether an external placement needs a half-turn yaw correction.
|
|
23
|
+
* @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
static shouldCorrectYaw(context) {
|
|
27
|
+
return (
|
|
28
|
+
AltiumScene3dPlacementRotationPolicy.#needsSquarePinOneCorrection(
|
|
29
|
+
context
|
|
30
|
+
) ||
|
|
31
|
+
AltiumScene3dPlacementRotationPolicy.#needsFiveLeadSotCorrection(
|
|
32
|
+
context
|
|
33
|
+
) ||
|
|
34
|
+
AltiumScene3dPlacementRotationPolicy.#needsTiltedEdgeCorrection(
|
|
35
|
+
context
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detects square IC packages whose embedded source frame places the
|
|
42
|
+
* pin-one marker opposite the rendered footprint.
|
|
43
|
+
* @param {{ placement?: object, component?: object | null, componentBody?: object | null, isExactAnchoredOwner?: boolean }} context Rotation context.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
static #needsSquarePinOneCorrection(context) {
|
|
47
|
+
const { placement, component, componentBody } = context || {}
|
|
48
|
+
if (
|
|
49
|
+
!AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
|
|
50
|
+
context
|
|
51
|
+
) ||
|
|
52
|
+
!component ||
|
|
53
|
+
AltiumScene3dPlacementRotationPolicy.#isGenericPassiveBody(
|
|
54
|
+
componentBody
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return PIN_ONE_CORNER_PACKAGE_PATTERN.test(
|
|
61
|
+
AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
|
|
62
|
+
component,
|
|
63
|
+
componentBody
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
? AltiumScene3dPlacementRotationPolicy.#hasSquarePinOneFrameMismatch(
|
|
67
|
+
placement,
|
|
68
|
+
component,
|
|
69
|
+
componentBody
|
|
70
|
+
)
|
|
71
|
+
: false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Checks whether a square package side/source-frame combination needs a
|
|
76
|
+
* half-turn to align model pin one with the footprint convention.
|
|
77
|
+
* @param {object | undefined} placement External model placement.
|
|
78
|
+
* @param {object} component PCB component.
|
|
79
|
+
* @param {object | null | undefined} componentBody Source component body.
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
static #hasSquarePinOneFrameMismatch(placement, component, componentBody) {
|
|
83
|
+
const mountSide = String(placement?.mountSide || '').toLowerCase()
|
|
84
|
+
if (mountSide === 'top') {
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
mountSide === 'bottom' &&
|
|
90
|
+
AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
|
|
91
|
+
placement?.rotationDeg
|
|
92
|
+
) &&
|
|
93
|
+
AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
|
|
94
|
+
component?.rotation
|
|
95
|
+
) &&
|
|
96
|
+
AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
|
|
97
|
+
componentBody?.modelRotationDeg?.z ??
|
|
98
|
+
placement?.modelTransform?.rotationDeg?.z
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Detects exact five-lead SOT packages whose STEP source pin-one
|
|
105
|
+
* convention is opposite the asymmetric footprint pad convention.
|
|
106
|
+
* @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
static #needsFiveLeadSotCorrection(context) {
|
|
110
|
+
const { component, componentBody, pads } = context || {}
|
|
111
|
+
if (
|
|
112
|
+
!AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
|
|
113
|
+
context
|
|
114
|
+
) ||
|
|
115
|
+
!component ||
|
|
116
|
+
!FIVE_LEAD_SOT_PATTERN.test(
|
|
117
|
+
AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
|
|
118
|
+
component,
|
|
119
|
+
componentBody
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return AltiumScene3dPlacementRotationPolicy.#hasAsymmetricFivePads(
|
|
127
|
+
component,
|
|
128
|
+
pads
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Detects top-side edge connectors whose tilted model frame points inward
|
|
134
|
+
* unless the authored board-facing yaw is reversed.
|
|
135
|
+
* @param {{ placement?: object, component?: object | null, componentBody?: object | null }} context Rotation context.
|
|
136
|
+
* @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
static #needsTiltedEdgeCorrection(context) {
|
|
139
|
+
const { placement, component, componentBody } = context || {}
|
|
140
|
+
if (
|
|
141
|
+
!component ||
|
|
142
|
+
String(placement?.mountSide || '').toLowerCase() !== 'top' ||
|
|
143
|
+
!AltiumScene3dPlacementRotationPolicy.#hasInEnvelopeNegativeStandoff(
|
|
144
|
+
componentBody
|
|
145
|
+
) ||
|
|
146
|
+
!AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
|
|
147
|
+
componentBody
|
|
148
|
+
)
|
|
149
|
+
) {
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const identityText =
|
|
154
|
+
AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
|
|
155
|
+
component,
|
|
156
|
+
componentBody
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
AltiumScene3dPlacementRotationPolicy.#edgeConnectorTokenCount(
|
|
161
|
+
identityText
|
|
162
|
+
) >= 2
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Checks whether one placement is exact-anchored or pad-fallback projected.
|
|
168
|
+
* @param {{ placement?: object, isExactAnchoredOwner?: boolean }} context Rotation context.
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
static #isAnchoredOrPadFallback(context) {
|
|
172
|
+
return (
|
|
173
|
+
Boolean(context?.isExactAnchoredOwner) ||
|
|
174
|
+
String(context?.placement?.projection?.source || '') ===
|
|
175
|
+
'pad-fallback'
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Checks whether a component owns an asymmetric five-pad footprint.
|
|
181
|
+
* @param {object} component PCB component.
|
|
182
|
+
* @param {object[]} pads Source PCB pads.
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
static #hasAsymmetricFivePads(component, pads) {
|
|
186
|
+
const surfacePads = AltiumScene3dPlacementRotationPolicy.#surfacePads(
|
|
187
|
+
component,
|
|
188
|
+
pads
|
|
189
|
+
)
|
|
190
|
+
if (surfacePads.length !== 5) {
|
|
191
|
+
return false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const axis =
|
|
195
|
+
AltiumScene3dPlacementRotationPolicy.#spread(surfacePads, 'x') >=
|
|
196
|
+
AltiumScene3dPlacementRotationPolicy.#spread(surfacePads, 'y')
|
|
197
|
+
? 'x'
|
|
198
|
+
: 'y'
|
|
199
|
+
const values = surfacePads.map((pad) => Number(pad?.[axis] || 0))
|
|
200
|
+
const midpoint = (Math.min(...values) + Math.max(...values)) / 2
|
|
201
|
+
const lowerCount = values.filter((value) => value <= midpoint).length
|
|
202
|
+
const upperCount = values.length - lowerCount
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
Math.min(lowerCount, upperCount) === 2 &&
|
|
206
|
+
Math.max(lowerCount, upperCount) === 3
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Collects surface pads owned by one component.
|
|
212
|
+
* @param {object} component PCB component.
|
|
213
|
+
* @param {object[]} pads Source PCB pads.
|
|
214
|
+
* @returns {object[]}
|
|
215
|
+
*/
|
|
216
|
+
static #surfacePads(component, pads) {
|
|
217
|
+
const componentIndex = Number(component?.componentIndex)
|
|
218
|
+
if (!Number.isFinite(componentIndex)) {
|
|
219
|
+
return []
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ownedPads = (Array.isArray(pads) ? pads : []).filter(
|
|
223
|
+
(pad) => Number(pad?.componentIndex) === componentIndex
|
|
224
|
+
)
|
|
225
|
+
const bottom =
|
|
226
|
+
String(component?.layer || '')
|
|
227
|
+
.toUpperCase()
|
|
228
|
+
.includes('BOTTOM') ||
|
|
229
|
+
String(component?.layer || '').toUpperCase() === 'BOT'
|
|
230
|
+
const surfacePads = ownedPads.filter((pad) =>
|
|
231
|
+
bottom
|
|
232
|
+
? Boolean(pad?.hasBottomPasteMaskOpening)
|
|
233
|
+
: Boolean(pad?.hasTopPasteMaskOpening)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return surfacePads.length ? surfacePads : ownedPads
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Measures pad center spread on one axis.
|
|
241
|
+
* @param {object[]} pads Source PCB pads.
|
|
242
|
+
* @param {'x' | 'y'} axis Axis key.
|
|
243
|
+
* @returns {number}
|
|
244
|
+
*/
|
|
245
|
+
static #spread(pads, axis) {
|
|
246
|
+
const values = pads.map((pad) => Number(pad?.[axis] || 0))
|
|
247
|
+
|
|
248
|
+
return Math.max(...values) - Math.min(...values)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Checks whether a body has an intentional negative standoff within its
|
|
253
|
+
* own height envelope.
|
|
254
|
+
* @param {object | null | undefined} componentBody Source component body.
|
|
255
|
+
* @returns {boolean}
|
|
256
|
+
*/
|
|
257
|
+
static #hasInEnvelopeNegativeStandoff(componentBody) {
|
|
258
|
+
const standoff = Number(
|
|
259
|
+
componentBody?.standoffHeightMil ?? componentBody?.dzMil
|
|
260
|
+
)
|
|
261
|
+
const overallHeight = Number(componentBody?.overallHeightMil)
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
Number.isFinite(standoff) &&
|
|
265
|
+
Number.isFinite(overallHeight) &&
|
|
266
|
+
standoff < 0 &&
|
|
267
|
+
overallHeight > 0 &&
|
|
268
|
+
Math.abs(standoff) < overallHeight
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Checks whether the source model is laid over with a right-angle tilt.
|
|
274
|
+
* @param {object | null | undefined} componentBody Source component body.
|
|
275
|
+
* @returns {boolean}
|
|
276
|
+
*/
|
|
277
|
+
static #hasRightAngleModelTilt(componentBody) {
|
|
278
|
+
const angle = AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
|
|
279
|
+
Number(componentBody?.modelRotationDeg?.x || 0)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return angle === 90 || angle === 270
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Counts generic edge-connector identity tokens in package metadata.
|
|
287
|
+
* @param {string} identityText Package metadata text.
|
|
288
|
+
* @returns {number}
|
|
289
|
+
*/
|
|
290
|
+
static #edgeConnectorTokenCount(identityText) {
|
|
291
|
+
return new Set(
|
|
292
|
+
String(identityText || '')
|
|
293
|
+
.split(/[^a-zA-Z0-9]+/g)
|
|
294
|
+
.map((token) => token.toLowerCase())
|
|
295
|
+
.filter((token) => EDGE_CONNECTOR_TOKENS.has(token))
|
|
296
|
+
).size
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Checks whether a body is a generic passive package where body yaw is safe.
|
|
301
|
+
* @param {object | null | undefined} componentBody Source component body.
|
|
302
|
+
* @returns {boolean}
|
|
303
|
+
*/
|
|
304
|
+
static #isGenericPassiveBody(componentBody) {
|
|
305
|
+
return PASSIVE_BODY_PATTERN.test(
|
|
306
|
+
[componentBody?.identifier, componentBody?.name].join(' ')
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Builds package metadata text for generic package-family checks.
|
|
312
|
+
* @param {object} component PCB component.
|
|
313
|
+
* @param {object | null | undefined} componentBody Source component body.
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
static #packageIdentityText(component, componentBody) {
|
|
317
|
+
const parameterValues = Object.values(component?.parameters || {})
|
|
318
|
+
.map((value) => String(value || ''))
|
|
319
|
+
.join(' ')
|
|
320
|
+
|
|
321
|
+
return [
|
|
322
|
+
component?.designator,
|
|
323
|
+
component?.pattern,
|
|
324
|
+
component?.source,
|
|
325
|
+
component?.modelPath,
|
|
326
|
+
component?.description,
|
|
327
|
+
component?.provenance?.footprintDescription,
|
|
328
|
+
parameterValues,
|
|
329
|
+
componentBody?.identifier,
|
|
330
|
+
componentBody?.name
|
|
331
|
+
]
|
|
332
|
+
.map((value) => String(value || ''))
|
|
333
|
+
.join(' ')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Normalizes an angle into [0, 360).
|
|
338
|
+
* @param {number} angle Source angle.
|
|
339
|
+
* @returns {number}
|
|
340
|
+
*/
|
|
341
|
+
static #normalizeAngle(angle) {
|
|
342
|
+
const normalized = Number(angle || 0) % 360
|
|
343
|
+
|
|
344
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Checks whether an angle is a half-turn after normalization.
|
|
349
|
+
* @param {number} angle Source angle.
|
|
350
|
+
* @returns {boolean}
|
|
351
|
+
*/
|
|
352
|
+
static #isHalfTurnAngle(angle) {
|
|
353
|
+
return (
|
|
354
|
+
AltiumScene3dPlacementRotationPolicy.#normalizeAngle(angle) === 180
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|