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.
- package/docs/model-format.md +2 -3
- package/package.json +1 -1
- package/src/core/altium/AltiumGeneratedLibraryRecordBuilder.mjs +551 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +286 -2
- package/src/core/altium/AltiumPcbLibExporter.mjs +9 -2
- package/src/core/altium/AltiumSchLibExporter.mjs +1 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +52 -4
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +27 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +782 -0
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +595 -28
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
- package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
- package/src/ui/PcbScene3dPackages.mjs +15 -3
- package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +56 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +638 -16
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbScene3dStaticBodySymmetryRecovery } from './PcbScene3dStaticBodySymmetryRecovery.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recovers renderable static-body geometry and display metadata.
|
|
9
|
+
*/
|
|
10
|
+
export class PcbScene3dStaticBodyRecovery {
|
|
11
|
+
static #COVER_SIDE_EDGE_TOLERANCE_MIL = 80
|
|
12
|
+
static #COVER_SIDE_CLUSTER_TOLERANCE_MIL = 600
|
|
13
|
+
static #COVER_SIDE_DEFAULT_THICKNESS_MIL = 10
|
|
14
|
+
static #COVER_SIDE_MIN_THICKNESS_MIL = 1
|
|
15
|
+
static #COVER_FAMILY_IDENTITY_TOKENS = new Set([
|
|
16
|
+
'can',
|
|
17
|
+
'cover',
|
|
18
|
+
'emi',
|
|
19
|
+
'enclosure',
|
|
20
|
+
'lid',
|
|
21
|
+
'rfi',
|
|
22
|
+
'shield'
|
|
23
|
+
])
|
|
24
|
+
static #COVER_SIDE_IDENTITY_TOKENS = new Set([
|
|
25
|
+
'edge',
|
|
26
|
+
'frame',
|
|
27
|
+
'rail',
|
|
28
|
+
'side',
|
|
29
|
+
'wall'
|
|
30
|
+
])
|
|
31
|
+
static #COVER_TOP_IDENTITY_TOKENS = new Set(['lid', 'top'])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Recovers static geometries and inherited display metadata.
|
|
35
|
+
* @param {object[] | undefined} componentBodies Component bodies.
|
|
36
|
+
* @param {(object | null)[] | undefined} bodyMatches Matched owners.
|
|
37
|
+
* @returns {object[]}
|
|
38
|
+
*/
|
|
39
|
+
static recover(componentBodies, bodyMatches = []) {
|
|
40
|
+
const bodies = Array.isArray(componentBodies) ? componentBodies : []
|
|
41
|
+
const symmetricRecoveredBodies =
|
|
42
|
+
PcbScene3dStaticBodySymmetryRecovery.recover(bodies, bodyMatches)
|
|
43
|
+
const topRecoveredBodies = symmetricRecoveredBodies.map(
|
|
44
|
+
(componentBody) =>
|
|
45
|
+
PcbScene3dStaticBodyRecovery.#recoverCoverTopGeometry(
|
|
46
|
+
componentBody,
|
|
47
|
+
symmetricRecoveredBodies
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
const recoveredBodies = topRecoveredBodies.map((componentBody) =>
|
|
51
|
+
PcbScene3dStaticBodyRecovery.#recoverCoverSideGeometry(
|
|
52
|
+
componentBody,
|
|
53
|
+
topRecoveredBodies
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return PcbScene3dStaticBodyRecovery.#inheritBodyOpacity(recoveredBodies)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalizes opacity to values that should be sent to the renderer.
|
|
62
|
+
* @param {number | string | undefined} candidate Candidate opacity.
|
|
63
|
+
* @returns {number | undefined}
|
|
64
|
+
*/
|
|
65
|
+
static renderableOpacity(candidate) {
|
|
66
|
+
const opacity = Number(candidate)
|
|
67
|
+
|
|
68
|
+
return Number.isFinite(opacity) && opacity > 0 && opacity < 1
|
|
69
|
+
? opacity
|
|
70
|
+
: undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Copies positive translucency from matching sibling bodies when a row has
|
|
75
|
+
* omitted or zero opacity metadata.
|
|
76
|
+
* @param {object[]} componentBodies Component bodies.
|
|
77
|
+
* @returns {object[]}
|
|
78
|
+
*/
|
|
79
|
+
static #inheritBodyOpacity(componentBodies) {
|
|
80
|
+
const opacityByIdentityKey =
|
|
81
|
+
PcbScene3dStaticBodyRecovery.#bodyOpacityByIdentityKey(
|
|
82
|
+
componentBodies
|
|
83
|
+
)
|
|
84
|
+
const opacityByFamilyKey =
|
|
85
|
+
PcbScene3dStaticBodyRecovery.#bodyOpacityByFamilyKey(
|
|
86
|
+
componentBodies
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return componentBodies.map((componentBody) => {
|
|
90
|
+
const explicitOpacity =
|
|
91
|
+
PcbScene3dStaticBodyRecovery.renderableOpacity(
|
|
92
|
+
componentBody?.bodyOpacity
|
|
93
|
+
)
|
|
94
|
+
if (explicitOpacity !== undefined) {
|
|
95
|
+
return componentBody
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const identityKey =
|
|
99
|
+
PcbScene3dStaticBodyRecovery.#bodyOpacityIdentityKey(
|
|
100
|
+
componentBody
|
|
101
|
+
)
|
|
102
|
+
const familyKey =
|
|
103
|
+
PcbScene3dStaticBodyRecovery.#bodyOpacityFamilyKey(
|
|
104
|
+
componentBody
|
|
105
|
+
)
|
|
106
|
+
const inheritedOpacity =
|
|
107
|
+
opacityByIdentityKey.get(identityKey) ??
|
|
108
|
+
opacityByFamilyKey.get(familyKey)
|
|
109
|
+
if (inheritedOpacity === undefined) {
|
|
110
|
+
return componentBody
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...componentBody,
|
|
115
|
+
bodyOpacity: inheritedOpacity
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds a lookup of positive opacity values by exact body identity.
|
|
122
|
+
* @param {object[]} componentBodies Component bodies.
|
|
123
|
+
* @returns {Map<string, number>}
|
|
124
|
+
*/
|
|
125
|
+
static #bodyOpacityByIdentityKey(componentBodies) {
|
|
126
|
+
const opacityByIdentityKey = new Map()
|
|
127
|
+
const bodies = Array.isArray(componentBodies) ? componentBodies : []
|
|
128
|
+
|
|
129
|
+
bodies.forEach((componentBody) => {
|
|
130
|
+
const identityKey =
|
|
131
|
+
PcbScene3dStaticBodyRecovery.#bodyOpacityIdentityKey(
|
|
132
|
+
componentBody
|
|
133
|
+
)
|
|
134
|
+
const opacity = PcbScene3dStaticBodyRecovery.renderableOpacity(
|
|
135
|
+
componentBody?.bodyOpacity
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if (!identityKey || opacity === undefined) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
opacityByIdentityKey.set(identityKey, opacity)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return opacityByIdentityKey
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Builds a lookup of positive opacity values by cover/shield family.
|
|
150
|
+
* @param {object[]} componentBodies Component bodies.
|
|
151
|
+
* @returns {Map<string, number>}
|
|
152
|
+
*/
|
|
153
|
+
static #bodyOpacityByFamilyKey(componentBodies) {
|
|
154
|
+
const opacityByFamilyKey = new Map()
|
|
155
|
+
const bodies = Array.isArray(componentBodies) ? componentBodies : []
|
|
156
|
+
|
|
157
|
+
bodies.forEach((componentBody) => {
|
|
158
|
+
const familyKey =
|
|
159
|
+
PcbScene3dStaticBodyRecovery.#bodyOpacityFamilyKey(
|
|
160
|
+
componentBody
|
|
161
|
+
)
|
|
162
|
+
const opacity = PcbScene3dStaticBodyRecovery.renderableOpacity(
|
|
163
|
+
componentBody?.bodyOpacity
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if (!familyKey || opacity === undefined) {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
opacityByFamilyKey.set(familyKey, opacity)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return opacityByFamilyKey
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Builds the exact identity key used for sibling opacity inheritance.
|
|
178
|
+
* @param {{ identifier?: string, name?: string }} componentBody Component body.
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
static #bodyOpacityIdentityKey(componentBody) {
|
|
182
|
+
return [componentBody?.identifier, componentBody?.name]
|
|
183
|
+
.map((value) =>
|
|
184
|
+
String(value || '')
|
|
185
|
+
.trim()
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
)
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.join('|')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Builds a cover/shield family key for opacity inheritance.
|
|
194
|
+
* @param {{ identifier?: string, name?: string }} componentBody Component body.
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
static #bodyOpacityFamilyKey(componentBody) {
|
|
198
|
+
return PcbScene3dStaticBodyRecovery.#identityTokens(componentBody)
|
|
199
|
+
.filter((token) =>
|
|
200
|
+
PcbScene3dStaticBodyRecovery.#COVER_FAMILY_IDENTITY_TOKENS.has(
|
|
201
|
+
token
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
.sort()
|
|
205
|
+
.join('|')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Recovers a missing cover-top polygon from sibling cover side-wall bounds.
|
|
210
|
+
* @param {object} componentBody Candidate component body.
|
|
211
|
+
* @param {object[]} componentBodies All component bodies.
|
|
212
|
+
* @returns {object}
|
|
213
|
+
*/
|
|
214
|
+
static #recoverCoverTopGeometry(componentBody, componentBodies) {
|
|
215
|
+
if (
|
|
216
|
+
!PcbScene3dStaticBodyRecovery.#isRecoverableCoverTopBody(
|
|
217
|
+
componentBody
|
|
218
|
+
)
|
|
219
|
+
) {
|
|
220
|
+
return componentBody
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const bounds = PcbScene3dStaticBodyRecovery.#coverTopRecoveryBounds(
|
|
224
|
+
componentBody,
|
|
225
|
+
componentBodies
|
|
226
|
+
)
|
|
227
|
+
if (!bounds) {
|
|
228
|
+
return componentBody
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
...componentBody,
|
|
233
|
+
staticGeometry: {
|
|
234
|
+
...componentBody.staticGeometry,
|
|
235
|
+
status: 'complete',
|
|
236
|
+
verticesMil:
|
|
237
|
+
PcbScene3dStaticBodyRecovery.#boundsToVertices(bounds)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Checks whether one incomplete body is a safe cover-top recovery target.
|
|
244
|
+
* @param {object | undefined} componentBody Candidate component body.
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
static #isRecoverableCoverTopBody(componentBody) {
|
|
248
|
+
const geometry = componentBody?.staticGeometry
|
|
249
|
+
const tokens =
|
|
250
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
251
|
+
const heightMil = Number(geometry?.heightMil)
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
255
|
+
geometry?.status !== 'complete' &&
|
|
256
|
+
(!Array.isArray(geometry?.verticesMil) ||
|
|
257
|
+
geometry.verticesMil.length < 3) &&
|
|
258
|
+
Number.isFinite(heightMil) &&
|
|
259
|
+
heightMil > 0 &&
|
|
260
|
+
PcbScene3dStaticBodyRecovery.#hasToken(
|
|
261
|
+
tokens,
|
|
262
|
+
PcbScene3dStaticBodyRecovery.#COVER_TOP_IDENTITY_TOKENS
|
|
263
|
+
) &&
|
|
264
|
+
PcbScene3dStaticBodyRecovery.#hasToken(
|
|
265
|
+
tokens,
|
|
266
|
+
PcbScene3dStaticBodyRecovery.#COVER_FAMILY_IDENTITY_TOKENS
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Recovers a missing cover-side polygon from a sibling cover top.
|
|
273
|
+
* @param {object} componentBody Candidate component body.
|
|
274
|
+
* @param {object[]} componentBodies All component bodies after top recovery.
|
|
275
|
+
* @returns {object}
|
|
276
|
+
*/
|
|
277
|
+
static #recoverCoverSideGeometry(componentBody, componentBodies) {
|
|
278
|
+
if (
|
|
279
|
+
!PcbScene3dStaticBodyRecovery.#isRecoverableCoverSideBody(
|
|
280
|
+
componentBody
|
|
281
|
+
)
|
|
282
|
+
) {
|
|
283
|
+
return componentBody
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const bounds = PcbScene3dStaticBodyRecovery.#coverSideRecoveryBounds(
|
|
287
|
+
componentBody,
|
|
288
|
+
componentBodies
|
|
289
|
+
)
|
|
290
|
+
if (!bounds) {
|
|
291
|
+
return componentBody
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
...componentBody,
|
|
296
|
+
staticGeometry: {
|
|
297
|
+
...componentBody.staticGeometry,
|
|
298
|
+
status: 'complete',
|
|
299
|
+
verticesMil:
|
|
300
|
+
PcbScene3dStaticBodyRecovery.#boundsToVertices(bounds)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Checks whether one incomplete body is a safe cover-side recovery target.
|
|
307
|
+
* @param {object | undefined} componentBody Candidate component body.
|
|
308
|
+
* @returns {boolean}
|
|
309
|
+
*/
|
|
310
|
+
static #isRecoverableCoverSideBody(componentBody) {
|
|
311
|
+
const geometry = componentBody?.staticGeometry
|
|
312
|
+
const tokens =
|
|
313
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
314
|
+
const heightMil = Number(geometry?.heightMil)
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
318
|
+
geometry?.status !== 'complete' &&
|
|
319
|
+
(!Array.isArray(geometry?.verticesMil) ||
|
|
320
|
+
geometry.verticesMil.length < 3) &&
|
|
321
|
+
Number.isFinite(heightMil) &&
|
|
322
|
+
heightMil > 0 &&
|
|
323
|
+
PcbScene3dStaticBodyRecovery.#hasToken(
|
|
324
|
+
tokens,
|
|
325
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_IDENTITY_TOKENS
|
|
326
|
+
) &&
|
|
327
|
+
PcbScene3dStaticBodyRecovery.#hasToken(
|
|
328
|
+
tokens,
|
|
329
|
+
PcbScene3dStaticBodyRecovery.#COVER_FAMILY_IDENTITY_TOKENS
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Resolves the source-coordinate bounds for an inferred cover top.
|
|
336
|
+
* @param {object} componentBody Recoverable cover-top body.
|
|
337
|
+
* @param {object[]} componentBodies All component bodies.
|
|
338
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
339
|
+
*/
|
|
340
|
+
static #coverTopRecoveryBounds(componentBody, componentBodies) {
|
|
341
|
+
const source =
|
|
342
|
+
PcbScene3dStaticBodyRecovery.#sourcePosition(componentBody)
|
|
343
|
+
const topTokens =
|
|
344
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
345
|
+
const candidates = (
|
|
346
|
+
Array.isArray(componentBodies) ? componentBodies : []
|
|
347
|
+
)
|
|
348
|
+
.filter(
|
|
349
|
+
(candidate) =>
|
|
350
|
+
candidate !== componentBody &&
|
|
351
|
+
PcbScene3dStaticBodyRecovery.#isCoverSideBody(
|
|
352
|
+
candidate,
|
|
353
|
+
topTokens
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
.map((candidate) => ({
|
|
357
|
+
bounds: PcbScene3dStaticBodyRecovery.#geometryBounds(
|
|
358
|
+
candidate?.staticGeometry?.verticesMil
|
|
359
|
+
),
|
|
360
|
+
candidate
|
|
361
|
+
}))
|
|
362
|
+
.filter(({ bounds }) =>
|
|
363
|
+
PcbScene3dStaticBodyRecovery.#boundsOverlapPointAxis(
|
|
364
|
+
bounds,
|
|
365
|
+
source
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
.map(({ bounds, candidate }) => ({
|
|
369
|
+
bounds,
|
|
370
|
+
candidate,
|
|
371
|
+
distance:
|
|
372
|
+
PcbScene3dStaticBodyRecovery.#distanceBetweenPointAndBoundsCenter(
|
|
373
|
+
source,
|
|
374
|
+
bounds
|
|
375
|
+
)
|
|
376
|
+
}))
|
|
377
|
+
.sort((left, right) => left.distance - right.distance)
|
|
378
|
+
|
|
379
|
+
if (candidates.length < 2) {
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const closestDistance = candidates[0].distance
|
|
384
|
+
const groupedCandidates = candidates.filter(
|
|
385
|
+
(candidate) =>
|
|
386
|
+
candidate.distance <=
|
|
387
|
+
closestDistance +
|
|
388
|
+
PcbScene3dStaticBodyRecovery
|
|
389
|
+
.#COVER_SIDE_CLUSTER_TOLERANCE_MIL
|
|
390
|
+
)
|
|
391
|
+
if (groupedCandidates.length < 2) {
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const bounds = PcbScene3dStaticBodyRecovery.#mergeBounds(
|
|
396
|
+
groupedCandidates.map((candidate) => candidate.bounds)
|
|
397
|
+
)
|
|
398
|
+
if (
|
|
399
|
+
!bounds ||
|
|
400
|
+
!PcbScene3dStaticBodyRecovery.#boundsContainPoint(bounds, source)
|
|
401
|
+
) {
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return bounds
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Resolves the source-coordinate bounds for an inferred cover side.
|
|
410
|
+
* @param {object} componentBody Recoverable cover-side body.
|
|
411
|
+
* @param {object[]} componentBodies All component bodies after top recovery.
|
|
412
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
413
|
+
*/
|
|
414
|
+
static #coverSideRecoveryBounds(componentBody, componentBodies) {
|
|
415
|
+
const source =
|
|
416
|
+
PcbScene3dStaticBodyRecovery.#sourcePosition(componentBody)
|
|
417
|
+
const sideTokens =
|
|
418
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
419
|
+
const topCandidates = (
|
|
420
|
+
Array.isArray(componentBodies) ? componentBodies : []
|
|
421
|
+
)
|
|
422
|
+
.filter(
|
|
423
|
+
(candidate) =>
|
|
424
|
+
candidate !== componentBody &&
|
|
425
|
+
PcbScene3dStaticBodyRecovery.#isCoverTopBody(
|
|
426
|
+
candidate,
|
|
427
|
+
sideTokens
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
.map((candidate) => ({
|
|
431
|
+
bounds: PcbScene3dStaticBodyRecovery.#geometryBounds(
|
|
432
|
+
candidate?.staticGeometry?.verticesMil
|
|
433
|
+
),
|
|
434
|
+
candidate
|
|
435
|
+
}))
|
|
436
|
+
.filter(({ bounds }) =>
|
|
437
|
+
PcbScene3dStaticBodyRecovery.#boundsContainPointWithTolerance(
|
|
438
|
+
bounds,
|
|
439
|
+
source,
|
|
440
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_EDGE_TOLERANCE_MIL
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
.map(({ bounds, candidate }) => ({
|
|
444
|
+
bounds,
|
|
445
|
+
candidate,
|
|
446
|
+
distance:
|
|
447
|
+
PcbScene3dStaticBodyRecovery.#distanceBetweenPointAndBoundsCenter(
|
|
448
|
+
source,
|
|
449
|
+
bounds
|
|
450
|
+
)
|
|
451
|
+
}))
|
|
452
|
+
.sort((left, right) => left.distance - right.distance)
|
|
453
|
+
|
|
454
|
+
if (!topCandidates.length) {
|
|
455
|
+
return null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const topBounds = topCandidates[0].bounds
|
|
459
|
+
const edge = PcbScene3dStaticBodyRecovery.#nearestCoverEdge(
|
|
460
|
+
source,
|
|
461
|
+
topBounds
|
|
462
|
+
)
|
|
463
|
+
if (
|
|
464
|
+
!edge ||
|
|
465
|
+
edge.distance >
|
|
466
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_EDGE_TOLERANCE_MIL
|
|
467
|
+
) {
|
|
468
|
+
return null
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const thickness = PcbScene3dStaticBodyRecovery.#coverSideThickness(
|
|
472
|
+
componentBody,
|
|
473
|
+
componentBodies,
|
|
474
|
+
edge
|
|
475
|
+
)
|
|
476
|
+
if (
|
|
477
|
+
!Number.isFinite(thickness) ||
|
|
478
|
+
thickness <
|
|
479
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_MIN_THICKNESS_MIL
|
|
480
|
+
) {
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return PcbScene3dStaticBodyRecovery.#coverSideBoundsFromTopBounds(
|
|
485
|
+
topBounds,
|
|
486
|
+
edge.name,
|
|
487
|
+
thickness
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Checks whether one complete static body can contribute cover-side bounds.
|
|
493
|
+
* @param {object | undefined} componentBody Candidate side body.
|
|
494
|
+
* @param {Set<string>} topTokens Recoverable top identity tokens.
|
|
495
|
+
* @returns {boolean}
|
|
496
|
+
*/
|
|
497
|
+
static #isCoverSideBody(componentBody, topTokens) {
|
|
498
|
+
const geometry = componentBody?.staticGeometry
|
|
499
|
+
const tokens =
|
|
500
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
504
|
+
geometry?.status === 'complete' &&
|
|
505
|
+
PcbScene3dStaticBodyRecovery.#hasToken(
|
|
506
|
+
tokens,
|
|
507
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_IDENTITY_TOKENS
|
|
508
|
+
) &&
|
|
509
|
+
PcbScene3dStaticBodyRecovery.#sharesToken(
|
|
510
|
+
tokens,
|
|
511
|
+
topTokens,
|
|
512
|
+
PcbScene3dStaticBodyRecovery.#COVER_FAMILY_IDENTITY_TOKENS
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Checks whether one complete static body can contribute cover-top bounds.
|
|
519
|
+
* @param {object | undefined} componentBody Candidate top body.
|
|
520
|
+
* @param {Set<string>} sideTokens Recoverable side identity tokens.
|
|
521
|
+
* @returns {boolean}
|
|
522
|
+
*/
|
|
523
|
+
static #isCoverTopBody(componentBody, sideTokens) {
|
|
524
|
+
const geometry = componentBody?.staticGeometry
|
|
525
|
+
const tokens =
|
|
526
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
530
|
+
geometry?.status === 'complete' &&
|
|
531
|
+
PcbScene3dStaticBodyRecovery.#hasToken(
|
|
532
|
+
tokens,
|
|
533
|
+
PcbScene3dStaticBodyRecovery.#COVER_TOP_IDENTITY_TOKENS
|
|
534
|
+
) &&
|
|
535
|
+
PcbScene3dStaticBodyRecovery.#sharesToken(
|
|
536
|
+
tokens,
|
|
537
|
+
sideTokens,
|
|
538
|
+
PcbScene3dStaticBodyRecovery.#COVER_FAMILY_IDENTITY_TOKENS
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Collects normalized identity tokens from one body row.
|
|
545
|
+
* @param {{ identifier?: string, name?: string }} componentBody Component body.
|
|
546
|
+
* @returns {string[]}
|
|
547
|
+
*/
|
|
548
|
+
static #identityTokens(componentBody) {
|
|
549
|
+
return [componentBody?.identifier, componentBody?.name]
|
|
550
|
+
.join(' ')
|
|
551
|
+
.replace(/([a-z])([A-Z])/gu, '$1 $2')
|
|
552
|
+
.toLowerCase()
|
|
553
|
+
.split(/[^a-z0-9]+/g)
|
|
554
|
+
.flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
|
|
555
|
+
.filter(Boolean)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Collects normalized identity tokens into a set.
|
|
560
|
+
* @param {{ identifier?: string, name?: string }} componentBody Component body.
|
|
561
|
+
* @returns {Set<string>}
|
|
562
|
+
*/
|
|
563
|
+
static #identityTokenSet(componentBody) {
|
|
564
|
+
return new Set(
|
|
565
|
+
PcbScene3dStaticBodyRecovery.#identityTokens(componentBody)
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Checks whether any expected token is present.
|
|
571
|
+
* @param {Set<string>} tokens Candidate tokens.
|
|
572
|
+
* @param {Set<string>} expectedTokens Expected token set.
|
|
573
|
+
* @returns {boolean}
|
|
574
|
+
*/
|
|
575
|
+
static #hasToken(tokens, expectedTokens) {
|
|
576
|
+
return [...expectedTokens].some((token) => tokens.has(token))
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Checks whether two token sets share a token from an allowed family.
|
|
581
|
+
* @param {Set<string>} leftTokens First token set.
|
|
582
|
+
* @param {Set<string>} rightTokens Second token set.
|
|
583
|
+
* @param {Set<string>} allowedTokens Allowed shared tokens.
|
|
584
|
+
* @returns {boolean}
|
|
585
|
+
*/
|
|
586
|
+
static #sharesToken(leftTokens, rightTokens, allowedTokens) {
|
|
587
|
+
return [...allowedTokens].some(
|
|
588
|
+
(token) => leftTokens.has(token) && rightTokens.has(token)
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Resolves axis-aligned bounds for a vertex list.
|
|
594
|
+
* @param {{ x?: number, y?: number }[] | undefined} vertices Vertices.
|
|
595
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
596
|
+
*/
|
|
597
|
+
static #geometryBounds(vertices) {
|
|
598
|
+
const points = (Array.isArray(vertices) ? vertices : [])
|
|
599
|
+
.map((vertex) => ({
|
|
600
|
+
x: Number(vertex?.x || 0),
|
|
601
|
+
y: Number(vertex?.y || 0)
|
|
602
|
+
}))
|
|
603
|
+
.filter(
|
|
604
|
+
(point) => Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
if (points.length < 3) {
|
|
608
|
+
return null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const xs = points.map((point) => point.x)
|
|
612
|
+
const ys = points.map((point) => point.y)
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
minX: Math.min(...xs),
|
|
616
|
+
minY: Math.min(...ys),
|
|
617
|
+
maxX: Math.max(...xs),
|
|
618
|
+
maxY: Math.max(...ys)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Checks whether a point overlaps at least one bounds axis.
|
|
624
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
|
|
625
|
+
* @param {{ x: number, y: number }} point Point.
|
|
626
|
+
* @returns {boolean}
|
|
627
|
+
*/
|
|
628
|
+
static #boundsOverlapPointAxis(bounds, point) {
|
|
629
|
+
if (!bounds) {
|
|
630
|
+
return false
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
(point.x >= bounds.minX && point.x <= bounds.maxX) ||
|
|
635
|
+
(point.y >= bounds.minY && point.y <= bounds.maxY)
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Measures distance between a point and the center of bounds.
|
|
641
|
+
* @param {{ x: number, y: number }} point Point.
|
|
642
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
|
|
643
|
+
* @returns {number}
|
|
644
|
+
*/
|
|
645
|
+
static #distanceBetweenPointAndBoundsCenter(point, bounds) {
|
|
646
|
+
return Math.hypot(
|
|
647
|
+
point.x - (bounds.minX + bounds.maxX) / 2,
|
|
648
|
+
point.y - (bounds.minY + bounds.maxY) / 2
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Merges several axis-aligned bounds records.
|
|
654
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }[]} boundsList Bounds records.
|
|
655
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
656
|
+
*/
|
|
657
|
+
static #mergeBounds(boundsList) {
|
|
658
|
+
const normalized = (Array.isArray(boundsList) ? boundsList : []).filter(
|
|
659
|
+
Boolean
|
|
660
|
+
)
|
|
661
|
+
if (!normalized.length) {
|
|
662
|
+
return null
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
minX: Math.min(...normalized.map((bounds) => bounds.minX)),
|
|
667
|
+
minY: Math.min(...normalized.map((bounds) => bounds.minY)),
|
|
668
|
+
maxX: Math.max(...normalized.map((bounds) => bounds.maxX)),
|
|
669
|
+
maxY: Math.max(...normalized.map((bounds) => bounds.maxY))
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Checks whether bounds contain a point and describe real area.
|
|
675
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
|
|
676
|
+
* @param {{ x: number, y: number }} point Point.
|
|
677
|
+
* @returns {boolean}
|
|
678
|
+
*/
|
|
679
|
+
static #boundsContainPoint(bounds, point) {
|
|
680
|
+
return (
|
|
681
|
+
bounds.maxX > bounds.minX &&
|
|
682
|
+
bounds.maxY > bounds.minY &&
|
|
683
|
+
point.x >= bounds.minX &&
|
|
684
|
+
point.x <= bounds.maxX &&
|
|
685
|
+
point.y >= bounds.minY &&
|
|
686
|
+
point.y <= bounds.maxY
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Checks whether bounds contain a point with an expansion tolerance.
|
|
692
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
|
|
693
|
+
* @param {{ x: number, y: number }} point Point.
|
|
694
|
+
* @param {number} toleranceMil Expansion tolerance.
|
|
695
|
+
* @returns {boolean}
|
|
696
|
+
*/
|
|
697
|
+
static #boundsContainPointWithTolerance(bounds, point, toleranceMil) {
|
|
698
|
+
if (
|
|
699
|
+
!bounds ||
|
|
700
|
+
bounds.maxX <= bounds.minX ||
|
|
701
|
+
bounds.maxY <= bounds.minY
|
|
702
|
+
) {
|
|
703
|
+
return false
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const tolerance = Math.max(Number(toleranceMil || 0), 0)
|
|
707
|
+
|
|
708
|
+
return (
|
|
709
|
+
point.x >= bounds.minX - tolerance &&
|
|
710
|
+
point.x <= bounds.maxX + tolerance &&
|
|
711
|
+
point.y >= bounds.minY - tolerance &&
|
|
712
|
+
point.y <= bounds.maxY + tolerance
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Resolves the nearest top-bound edge to a side anchor.
|
|
718
|
+
* @param {{ x: number, y: number }} point Side anchor.
|
|
719
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Top bounds.
|
|
720
|
+
* @returns {{ name: 'left' | 'right' | 'bottom' | 'top', distance: number } | null}
|
|
721
|
+
*/
|
|
722
|
+
static #nearestCoverEdge(point, bounds) {
|
|
723
|
+
if (!bounds) {
|
|
724
|
+
return null
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return [
|
|
728
|
+
{ name: 'left', distance: Math.abs(point.x - bounds.minX) },
|
|
729
|
+
{ name: 'right', distance: Math.abs(point.x - bounds.maxX) },
|
|
730
|
+
{ name: 'bottom', distance: Math.abs(point.y - bounds.minY) },
|
|
731
|
+
{ name: 'top', distance: Math.abs(point.y - bounds.maxY) }
|
|
732
|
+
].sort((left, right) => left.distance - right.distance)[0]
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Resolves recovered cover-side wall thickness.
|
|
737
|
+
* @param {object} componentBody Recoverable side body.
|
|
738
|
+
* @param {object[]} componentBodies All component bodies after top recovery.
|
|
739
|
+
* @param {{ distance: number }} edge Nearest top-bound edge.
|
|
740
|
+
* @returns {number}
|
|
741
|
+
*/
|
|
742
|
+
static #coverSideThickness(componentBody, componentBodies, edge) {
|
|
743
|
+
const templateThickness =
|
|
744
|
+
PcbScene3dStaticBodyRecovery.#matchingCompleteSideThickness(
|
|
745
|
+
componentBody,
|
|
746
|
+
componentBodies
|
|
747
|
+
)
|
|
748
|
+
if (templateThickness !== null) {
|
|
749
|
+
return templateThickness
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const inferredThickness = Number(edge?.distance || 0) * 2
|
|
753
|
+
|
|
754
|
+
return inferredThickness >=
|
|
755
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_MIN_THICKNESS_MIL
|
|
756
|
+
? inferredThickness
|
|
757
|
+
: PcbScene3dStaticBodyRecovery.#COVER_SIDE_DEFAULT_THICKNESS_MIL
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Finds a complete same-model side wall and reuses its minor span.
|
|
762
|
+
* @param {object} componentBody Recoverable side body.
|
|
763
|
+
* @param {object[]} componentBodies All component bodies after top recovery.
|
|
764
|
+
* @returns {number | null}
|
|
765
|
+
*/
|
|
766
|
+
static #matchingCompleteSideThickness(componentBody, componentBodies) {
|
|
767
|
+
const identityKey =
|
|
768
|
+
PcbScene3dStaticBodyRecovery.#bodyModelIdentityKey(componentBody)
|
|
769
|
+
if (!identityKey) {
|
|
770
|
+
return null
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const tokens =
|
|
774
|
+
PcbScene3dStaticBodyRecovery.#identityTokenSet(componentBody)
|
|
775
|
+
const matchingSide = (
|
|
776
|
+
Array.isArray(componentBodies) ? componentBodies : []
|
|
777
|
+
).find(
|
|
778
|
+
(candidate) =>
|
|
779
|
+
candidate !== componentBody &&
|
|
780
|
+
PcbScene3dStaticBodyRecovery.#bodyModelIdentityKey(
|
|
781
|
+
candidate
|
|
782
|
+
) === identityKey &&
|
|
783
|
+
PcbScene3dStaticBodyRecovery.#isCoverSideBody(candidate, tokens)
|
|
784
|
+
)
|
|
785
|
+
const bounds = PcbScene3dStaticBodyRecovery.#geometryBounds(
|
|
786
|
+
matchingSide?.staticGeometry?.verticesMil
|
|
787
|
+
)
|
|
788
|
+
if (!bounds) {
|
|
789
|
+
return null
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const thickness = Math.min(
|
|
793
|
+
bounds.maxX - bounds.minX,
|
|
794
|
+
bounds.maxY - bounds.minY
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
return Number.isFinite(thickness) &&
|
|
798
|
+
thickness >=
|
|
799
|
+
PcbScene3dStaticBodyRecovery.#COVER_SIDE_MIN_THICKNESS_MIL
|
|
800
|
+
? thickness
|
|
801
|
+
: null
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Builds a same-model identity key for side thickness lookup.
|
|
806
|
+
* @param {{ modelId?: string, checksum?: number | string }} componentBody Component body.
|
|
807
|
+
* @returns {string}
|
|
808
|
+
*/
|
|
809
|
+
static #bodyModelIdentityKey(componentBody) {
|
|
810
|
+
return [componentBody?.modelId, componentBody?.checksum]
|
|
811
|
+
.map((value) =>
|
|
812
|
+
String(value ?? '')
|
|
813
|
+
.trim()
|
|
814
|
+
.toLowerCase()
|
|
815
|
+
)
|
|
816
|
+
.filter(Boolean)
|
|
817
|
+
.join('|')
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Builds one side-wall bounds record from top bounds and edge name.
|
|
822
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Top bounds.
|
|
823
|
+
* @param {'left' | 'right' | 'bottom' | 'top'} edgeName Edge name.
|
|
824
|
+
* @param {number} thickness Side-wall thickness.
|
|
825
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
826
|
+
*/
|
|
827
|
+
static #coverSideBoundsFromTopBounds(bounds, edgeName, thickness) {
|
|
828
|
+
if (!bounds || !Number.isFinite(thickness) || thickness <= 0) {
|
|
829
|
+
return null
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
switch (edgeName) {
|
|
833
|
+
case 'left':
|
|
834
|
+
return {
|
|
835
|
+
minX: bounds.minX,
|
|
836
|
+
maxX: bounds.minX + thickness,
|
|
837
|
+
minY: bounds.minY,
|
|
838
|
+
maxY: bounds.maxY
|
|
839
|
+
}
|
|
840
|
+
case 'right':
|
|
841
|
+
return {
|
|
842
|
+
minX: bounds.maxX - thickness,
|
|
843
|
+
maxX: bounds.maxX,
|
|
844
|
+
minY: bounds.minY,
|
|
845
|
+
maxY: bounds.maxY
|
|
846
|
+
}
|
|
847
|
+
case 'bottom':
|
|
848
|
+
return {
|
|
849
|
+
minX: bounds.minX,
|
|
850
|
+
maxX: bounds.maxX,
|
|
851
|
+
minY: bounds.minY,
|
|
852
|
+
maxY: bounds.minY + thickness
|
|
853
|
+
}
|
|
854
|
+
case 'top':
|
|
855
|
+
return {
|
|
856
|
+
minX: bounds.minX,
|
|
857
|
+
maxX: bounds.maxX,
|
|
858
|
+
minY: bounds.maxY - thickness,
|
|
859
|
+
maxY: bounds.maxY
|
|
860
|
+
}
|
|
861
|
+
default:
|
|
862
|
+
return null
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Converts bounds to a clockwise polygon.
|
|
868
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
|
|
869
|
+
* @returns {{ x: number, y: number }[]}
|
|
870
|
+
*/
|
|
871
|
+
static #boundsToVertices(bounds) {
|
|
872
|
+
return [
|
|
873
|
+
{ x: bounds.minX, y: bounds.minY },
|
|
874
|
+
{ x: bounds.maxX, y: bounds.minY },
|
|
875
|
+
{ x: bounds.maxX, y: bounds.maxY },
|
|
876
|
+
{ x: bounds.minX, y: bounds.maxY }
|
|
877
|
+
]
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Returns the native body anchor.
|
|
882
|
+
* @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
|
|
883
|
+
* @returns {{ x: number, y: number }}
|
|
884
|
+
*/
|
|
885
|
+
static #sourcePosition(componentBody) {
|
|
886
|
+
return {
|
|
887
|
+
x: Number(componentBody?.positionMil?.x || 0),
|
|
888
|
+
y: Number(componentBody?.positionMil?.y || 0)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|