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,876 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Recovers incomplete static-body rows from symmetric sibling geometry.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbScene3dStaticBodySymmetryRecovery {
|
|
9
|
+
static #RECOVERY_TOLERANCE_MIL = 5
|
|
10
|
+
static #MIRROR_MIN_OFFSET_MIL = 20
|
|
11
|
+
static #FRAME_EDGE_TOLERANCE_MIL = 80
|
|
12
|
+
static #FRAME_SIDE_MIN_THICKNESS_MIL = 1
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Recovers same-family symmetric rows and inferred frame sides.
|
|
16
|
+
* @param {object[] | undefined} componentBodies Component bodies.
|
|
17
|
+
* @param {(object | null)[] | undefined} bodyMatches Matched owners.
|
|
18
|
+
* @returns {object[]}
|
|
19
|
+
*/
|
|
20
|
+
static recover(componentBodies, bodyMatches = []) {
|
|
21
|
+
const bodies = Array.isArray(componentBodies) ? componentBodies : []
|
|
22
|
+
const matches = Array.isArray(bodyMatches) ? bodyMatches : []
|
|
23
|
+
const siblingRecoveredBodies = bodies.map((componentBody, index) =>
|
|
24
|
+
PcbScene3dStaticBodySymmetryRecovery.#recoverSiblingGeometry(
|
|
25
|
+
componentBody,
|
|
26
|
+
index,
|
|
27
|
+
bodies,
|
|
28
|
+
matches
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return siblingRecoveredBodies.map((componentBody, index) =>
|
|
33
|
+
PcbScene3dStaticBodySymmetryRecovery.#recoverFrameSideGeometry(
|
|
34
|
+
componentBody,
|
|
35
|
+
index,
|
|
36
|
+
siblingRecoveredBodies,
|
|
37
|
+
matches
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Copies complete sibling vertices when an incomplete row is mirrored.
|
|
44
|
+
* @param {object} componentBody Target body.
|
|
45
|
+
* @param {number} index Target index.
|
|
46
|
+
* @param {object[]} componentBodies All original bodies.
|
|
47
|
+
* @param {(object | null)[]} bodyMatches Matched owners.
|
|
48
|
+
* @returns {object}
|
|
49
|
+
*/
|
|
50
|
+
static #recoverSiblingGeometry(
|
|
51
|
+
componentBody,
|
|
52
|
+
index,
|
|
53
|
+
componentBodies,
|
|
54
|
+
bodyMatches
|
|
55
|
+
) {
|
|
56
|
+
if (
|
|
57
|
+
!PcbScene3dStaticBodySymmetryRecovery.#isIncompleteExtrudedPolygon(
|
|
58
|
+
componentBody
|
|
59
|
+
)
|
|
60
|
+
) {
|
|
61
|
+
return componentBody
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const candidate =
|
|
65
|
+
PcbScene3dStaticBodySymmetryRecovery.#siblingRecoveryCandidates(
|
|
66
|
+
componentBody,
|
|
67
|
+
index,
|
|
68
|
+
componentBodies,
|
|
69
|
+
bodyMatches
|
|
70
|
+
)[0]
|
|
71
|
+
if (
|
|
72
|
+
!candidate ||
|
|
73
|
+
candidate.distance >
|
|
74
|
+
PcbScene3dStaticBodySymmetryRecovery.#RECOVERY_TOLERANCE_MIL
|
|
75
|
+
) {
|
|
76
|
+
return componentBody
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return PcbScene3dStaticBodySymmetryRecovery.#withRecoveredVertices(
|
|
80
|
+
componentBody,
|
|
81
|
+
candidate.componentBody?.staticGeometry?.verticesMil
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Infers missing orthogonal frame sides from complete same-family rows.
|
|
87
|
+
* @param {object} componentBody Target body.
|
|
88
|
+
* @param {number} index Target index.
|
|
89
|
+
* @param {object[]} componentBodies Bodies after sibling recovery.
|
|
90
|
+
* @param {(object | null)[]} bodyMatches Matched owners.
|
|
91
|
+
* @returns {object}
|
|
92
|
+
*/
|
|
93
|
+
static #recoverFrameSideGeometry(
|
|
94
|
+
componentBody,
|
|
95
|
+
index,
|
|
96
|
+
componentBodies,
|
|
97
|
+
bodyMatches
|
|
98
|
+
) {
|
|
99
|
+
if (
|
|
100
|
+
!PcbScene3dStaticBodySymmetryRecovery.#isIncompleteExtrudedPolygon(
|
|
101
|
+
componentBody
|
|
102
|
+
) ||
|
|
103
|
+
!PcbScene3dStaticBodySymmetryRecovery.#hasFrameIdentity(
|
|
104
|
+
componentBody
|
|
105
|
+
)
|
|
106
|
+
) {
|
|
107
|
+
return componentBody
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const source =
|
|
111
|
+
PcbScene3dStaticBodySymmetryRecovery.#sourcePosition(componentBody)
|
|
112
|
+
const entries =
|
|
113
|
+
PcbScene3dStaticBodySymmetryRecovery.#completeFrameSiblingEntries(
|
|
114
|
+
componentBody,
|
|
115
|
+
index,
|
|
116
|
+
componentBodies,
|
|
117
|
+
bodyMatches
|
|
118
|
+
)
|
|
119
|
+
if (entries.length < 2) {
|
|
120
|
+
return componentBody
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const groupBounds = PcbScene3dStaticBodySymmetryRecovery.#mergeBounds(
|
|
124
|
+
entries.map((entry) => entry.bounds)
|
|
125
|
+
)
|
|
126
|
+
const edge = PcbScene3dStaticBodySymmetryRecovery.#nearestFrameEdge(
|
|
127
|
+
source,
|
|
128
|
+
groupBounds
|
|
129
|
+
)
|
|
130
|
+
if (
|
|
131
|
+
!edge ||
|
|
132
|
+
edge.distance >
|
|
133
|
+
PcbScene3dStaticBodySymmetryRecovery.#FRAME_EDGE_TOLERANCE_MIL
|
|
134
|
+
) {
|
|
135
|
+
return componentBody
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const thickness =
|
|
139
|
+
PcbScene3dStaticBodySymmetryRecovery.#frameSideThickness(entries)
|
|
140
|
+
const bounds =
|
|
141
|
+
PcbScene3dStaticBodySymmetryRecovery.#frameSideBoundsFromSource(
|
|
142
|
+
source,
|
|
143
|
+
groupBounds,
|
|
144
|
+
edge.name,
|
|
145
|
+
thickness
|
|
146
|
+
)
|
|
147
|
+
if (!bounds) {
|
|
148
|
+
return componentBody
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return PcbScene3dStaticBodySymmetryRecovery.#withRecoveredVertices(
|
|
152
|
+
componentBody,
|
|
153
|
+
PcbScene3dStaticBodySymmetryRecovery.#boundsToVertices(bounds)
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Finds complete same-family sibling rows ordered by symmetry distance.
|
|
159
|
+
* @param {object} componentBody Target body.
|
|
160
|
+
* @param {number} index Target index.
|
|
161
|
+
* @param {object[]} componentBodies All bodies.
|
|
162
|
+
* @param {(object | null)[]} bodyMatches Matched owners.
|
|
163
|
+
* @returns {{ componentBody: object, distance: number }[]}
|
|
164
|
+
*/
|
|
165
|
+
static #siblingRecoveryCandidates(
|
|
166
|
+
componentBody,
|
|
167
|
+
index,
|
|
168
|
+
componentBodies,
|
|
169
|
+
bodyMatches
|
|
170
|
+
) {
|
|
171
|
+
return componentBodies
|
|
172
|
+
.map((candidate, candidateIndex) => ({
|
|
173
|
+
candidate,
|
|
174
|
+
candidateIndex
|
|
175
|
+
}))
|
|
176
|
+
.filter(
|
|
177
|
+
({ candidate, candidateIndex }) =>
|
|
178
|
+
candidateIndex !== index &&
|
|
179
|
+
PcbScene3dStaticBodySymmetryRecovery.#isCompleteExtrudedPolygon(
|
|
180
|
+
candidate
|
|
181
|
+
) &&
|
|
182
|
+
PcbScene3dStaticBodySymmetryRecovery.#canShareGeometry(
|
|
183
|
+
componentBody,
|
|
184
|
+
candidate,
|
|
185
|
+
index,
|
|
186
|
+
candidateIndex,
|
|
187
|
+
bodyMatches
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
.map(({ candidate, candidateIndex }) => ({
|
|
191
|
+
componentBody: candidate,
|
|
192
|
+
distance: PcbScene3dStaticBodySymmetryRecovery.#siblingDistance(
|
|
193
|
+
componentBody,
|
|
194
|
+
candidate,
|
|
195
|
+
index,
|
|
196
|
+
candidateIndex,
|
|
197
|
+
bodyMatches
|
|
198
|
+
)
|
|
199
|
+
}))
|
|
200
|
+
.sort((left, right) => left.distance - right.distance)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Returns complete same-family rows with source-effective bounds.
|
|
205
|
+
* @param {object} componentBody Target body.
|
|
206
|
+
* @param {number} index Target index.
|
|
207
|
+
* @param {object[]} componentBodies Bodies after sibling recovery.
|
|
208
|
+
* @param {(object | null)[]} bodyMatches Matched owners.
|
|
209
|
+
* @returns {{ componentBody: object, bounds: object }[]}
|
|
210
|
+
*/
|
|
211
|
+
static #completeFrameSiblingEntries(
|
|
212
|
+
componentBody,
|
|
213
|
+
index,
|
|
214
|
+
componentBodies,
|
|
215
|
+
bodyMatches
|
|
216
|
+
) {
|
|
217
|
+
return componentBodies
|
|
218
|
+
.map((candidate, candidateIndex) => ({
|
|
219
|
+
candidate,
|
|
220
|
+
candidateIndex
|
|
221
|
+
}))
|
|
222
|
+
.filter(
|
|
223
|
+
({ candidate, candidateIndex }) =>
|
|
224
|
+
candidateIndex !== index &&
|
|
225
|
+
PcbScene3dStaticBodySymmetryRecovery.#isCompleteExtrudedPolygon(
|
|
226
|
+
candidate
|
|
227
|
+
) &&
|
|
228
|
+
PcbScene3dStaticBodySymmetryRecovery.#canShareGeometry(
|
|
229
|
+
componentBody,
|
|
230
|
+
candidate,
|
|
231
|
+
index,
|
|
232
|
+
candidateIndex,
|
|
233
|
+
bodyMatches
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
.map(({ candidate, candidateIndex }) => ({
|
|
237
|
+
componentBody: candidate,
|
|
238
|
+
bounds: PcbScene3dStaticBodySymmetryRecovery.#effectiveGeometryBounds(
|
|
239
|
+
candidate,
|
|
240
|
+
bodyMatches[candidateIndex]
|
|
241
|
+
)
|
|
242
|
+
}))
|
|
243
|
+
.filter((entry) => Boolean(entry.bounds))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Checks whether two rows can share recovered static geometry.
|
|
248
|
+
* @param {object} left Left body.
|
|
249
|
+
* @param {object} right Right body.
|
|
250
|
+
* @param {number} leftIndex Left body index.
|
|
251
|
+
* @param {number} rightIndex Right body index.
|
|
252
|
+
* @param {(object | null)[]} bodyMatches Matched owners.
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
static #canShareGeometry(left, right, leftIndex, rightIndex, bodyMatches) {
|
|
256
|
+
return (
|
|
257
|
+
PcbScene3dStaticBodySymmetryRecovery.#sameBodyFamily(left, right) &&
|
|
258
|
+
PcbScene3dStaticBodySymmetryRecovery.#compatibleLayers(
|
|
259
|
+
left,
|
|
260
|
+
right
|
|
261
|
+
) &&
|
|
262
|
+
PcbScene3dStaticBodySymmetryRecovery.#sameComponentIndex(
|
|
263
|
+
left,
|
|
264
|
+
right
|
|
265
|
+
) &&
|
|
266
|
+
PcbScene3dStaticBodySymmetryRecovery.#sameMatchedOwner(
|
|
267
|
+
bodyMatches[leftIndex],
|
|
268
|
+
bodyMatches[rightIndex]
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Measures the closest direct or owner-mirrored center distance.
|
|
275
|
+
* @param {object} componentBody Target body.
|
|
276
|
+
* @param {object} candidate Complete candidate body.
|
|
277
|
+
* @param {number} index Target index.
|
|
278
|
+
* @param {number} candidateIndex Candidate index.
|
|
279
|
+
* @param {(object | null)[]} bodyMatches Matched owners.
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
static #siblingDistance(
|
|
283
|
+
componentBody,
|
|
284
|
+
candidate,
|
|
285
|
+
index,
|
|
286
|
+
candidateIndex,
|
|
287
|
+
bodyMatches
|
|
288
|
+
) {
|
|
289
|
+
const source =
|
|
290
|
+
PcbScene3dStaticBodySymmetryRecovery.#sourcePosition(componentBody)
|
|
291
|
+
const bounds = PcbScene3dStaticBodySymmetryRecovery.#geometryBounds(
|
|
292
|
+
candidate?.staticGeometry?.verticesMil
|
|
293
|
+
)
|
|
294
|
+
const center =
|
|
295
|
+
PcbScene3dStaticBodySymmetryRecovery.#boundsCenter(bounds)
|
|
296
|
+
const owner = bodyMatches[index] || bodyMatches[candidateIndex] || null
|
|
297
|
+
const centers =
|
|
298
|
+
PcbScene3dStaticBodySymmetryRecovery.#candidateMirrorCenters(
|
|
299
|
+
center,
|
|
300
|
+
owner
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return Math.min(
|
|
304
|
+
...centers.map((candidateCenter) =>
|
|
305
|
+
Math.hypot(
|
|
306
|
+
source.x - candidateCenter.x,
|
|
307
|
+
source.y - candidateCenter.y
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Builds direct and mirrored candidate centers around an owner.
|
|
315
|
+
* @param {{ x: number, y: number } | null} center Raw bounds center.
|
|
316
|
+
* @param {{ x?: number, y?: number } | null} owner Matched owner.
|
|
317
|
+
* @returns {{ x: number, y: number }[]}
|
|
318
|
+
*/
|
|
319
|
+
static #candidateMirrorCenters(center, owner) {
|
|
320
|
+
if (!center) {
|
|
321
|
+
return []
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const ownerX = Number(owner?.x)
|
|
325
|
+
const ownerY = Number(owner?.y)
|
|
326
|
+
const xValues = [center.x]
|
|
327
|
+
const yValues = [center.y]
|
|
328
|
+
if (Number.isFinite(ownerX)) {
|
|
329
|
+
xValues.push(2 * ownerX - center.x)
|
|
330
|
+
}
|
|
331
|
+
if (Number.isFinite(ownerY)) {
|
|
332
|
+
yValues.push(2 * ownerY - center.y)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return xValues.flatMap((x) => yValues.map((y) => ({ x, y })))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolves bounds after applying the source-coordinate mirror transform.
|
|
340
|
+
* @param {object} componentBody Candidate body.
|
|
341
|
+
* @param {{ x?: number, y?: number } | null} matchedComponent Owner.
|
|
342
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
343
|
+
*/
|
|
344
|
+
static #effectiveGeometryBounds(componentBody, matchedComponent) {
|
|
345
|
+
const vertices = componentBody?.staticGeometry?.verticesMil
|
|
346
|
+
const bounds =
|
|
347
|
+
PcbScene3dStaticBodySymmetryRecovery.#geometryBounds(vertices)
|
|
348
|
+
if (
|
|
349
|
+
!bounds ||
|
|
350
|
+
!PcbScene3dStaticBodySymmetryRecovery.#usesSourceCoordinateFrame(
|
|
351
|
+
componentBody,
|
|
352
|
+
vertices
|
|
353
|
+
)
|
|
354
|
+
) {
|
|
355
|
+
return bounds
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const mirror =
|
|
359
|
+
PcbScene3dStaticBodySymmetryRecovery.#sourceCoordinateMirror(
|
|
360
|
+
componentBody,
|
|
361
|
+
PcbScene3dStaticBodySymmetryRecovery.#boundsCenter(bounds),
|
|
362
|
+
matchedComponent
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return mirror
|
|
366
|
+
? PcbScene3dStaticBodySymmetryRecovery.#mirrorBounds(bounds, mirror)
|
|
367
|
+
: bounds
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolves owner-axis mirrors for a source-coordinate body.
|
|
372
|
+
* @param {object} componentBody Candidate body.
|
|
373
|
+
* @param {{ x: number, y: number }} boundsCenter Raw bounds center.
|
|
374
|
+
* @param {{ x?: number, y?: number } | null} matchedComponent Owner.
|
|
375
|
+
* @returns {{ mirrorAxisX?: number, mirrorAxisY?: number } | null}
|
|
376
|
+
*/
|
|
377
|
+
static #sourceCoordinateMirror(
|
|
378
|
+
componentBody,
|
|
379
|
+
boundsCenter,
|
|
380
|
+
matchedComponent
|
|
381
|
+
) {
|
|
382
|
+
const source =
|
|
383
|
+
PcbScene3dStaticBodySymmetryRecovery.#sourcePosition(componentBody)
|
|
384
|
+
const xDecision =
|
|
385
|
+
PcbScene3dStaticBodySymmetryRecovery.#axisMirrorDecision(
|
|
386
|
+
boundsCenter.x,
|
|
387
|
+
source.x,
|
|
388
|
+
Number(matchedComponent?.x)
|
|
389
|
+
)
|
|
390
|
+
const yDecision =
|
|
391
|
+
PcbScene3dStaticBodySymmetryRecovery.#axisMirrorDecision(
|
|
392
|
+
boundsCenter.y,
|
|
393
|
+
source.y,
|
|
394
|
+
Number(matchedComponent?.y)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if (
|
|
398
|
+
!xDecision.valid ||
|
|
399
|
+
!yDecision.valid ||
|
|
400
|
+
(!xDecision.mirrored && !yDecision.mirrored)
|
|
401
|
+
) {
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
mirrorAxisX: xDecision.mirrored
|
|
407
|
+
? Number(matchedComponent?.x)
|
|
408
|
+
: undefined,
|
|
409
|
+
mirrorAxisY: yDecision.mirrored
|
|
410
|
+
? Number(matchedComponent?.y)
|
|
411
|
+
: undefined
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Decides whether one axis is aligned, mirrored, or invalid.
|
|
417
|
+
* @param {number} center Raw center coordinate.
|
|
418
|
+
* @param {number} source Source body coordinate.
|
|
419
|
+
* @param {number} ownerAxis Owner coordinate.
|
|
420
|
+
* @returns {{ valid: boolean, mirrored: boolean }}
|
|
421
|
+
*/
|
|
422
|
+
static #axisMirrorDecision(center, source, ownerAxis) {
|
|
423
|
+
const offset = Math.abs(Number(center || 0) - Number(source || 0))
|
|
424
|
+
if (
|
|
425
|
+
offset <=
|
|
426
|
+
PcbScene3dStaticBodySymmetryRecovery.#RECOVERY_TOLERANCE_MIL
|
|
427
|
+
) {
|
|
428
|
+
return { valid: true, mirrored: false }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const mirroredSource = 2 * Number(ownerAxis) - Number(center || 0)
|
|
432
|
+
const mirrorError = Math.abs(mirroredSource - Number(source || 0))
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
valid:
|
|
436
|
+
Number.isFinite(ownerAxis) &&
|
|
437
|
+
offset >
|
|
438
|
+
PcbScene3dStaticBodySymmetryRecovery
|
|
439
|
+
.#MIRROR_MIN_OFFSET_MIL &&
|
|
440
|
+
mirrorError <=
|
|
441
|
+
PcbScene3dStaticBodySymmetryRecovery
|
|
442
|
+
.#RECOVERY_TOLERANCE_MIL,
|
|
443
|
+
mirrored: true
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Mirrors one bounds record around selected axes.
|
|
449
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
|
|
450
|
+
* @param {{ mirrorAxisX?: number, mirrorAxisY?: number }} mirror Mirror axes.
|
|
451
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
452
|
+
*/
|
|
453
|
+
static #mirrorBounds(bounds, mirror) {
|
|
454
|
+
const xValues = Number.isFinite(mirror?.mirrorAxisX)
|
|
455
|
+
? [
|
|
456
|
+
2 * mirror.mirrorAxisX - bounds.minX,
|
|
457
|
+
2 * mirror.mirrorAxisX - bounds.maxX
|
|
458
|
+
]
|
|
459
|
+
: [bounds.minX, bounds.maxX]
|
|
460
|
+
const yValues = Number.isFinite(mirror?.mirrorAxisY)
|
|
461
|
+
? [
|
|
462
|
+
2 * mirror.mirrorAxisY - bounds.minY,
|
|
463
|
+
2 * mirror.mirrorAxisY - bounds.maxY
|
|
464
|
+
]
|
|
465
|
+
: [bounds.minY, bounds.maxY]
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
minX: Math.min(...xValues),
|
|
469
|
+
maxX: Math.max(...xValues),
|
|
470
|
+
minY: Math.min(...yValues),
|
|
471
|
+
maxY: Math.max(...yValues)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Finds the nearest frame edge to an incomplete row source.
|
|
477
|
+
* @param {{ x: number, y: number }} point Source point.
|
|
478
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Group bounds.
|
|
479
|
+
* @returns {{ name: string, distance: number } | null}
|
|
480
|
+
*/
|
|
481
|
+
static #nearestFrameEdge(point, bounds) {
|
|
482
|
+
if (!bounds) {
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const tolerance =
|
|
487
|
+
PcbScene3dStaticBodySymmetryRecovery.#FRAME_EDGE_TOLERANCE_MIL
|
|
488
|
+
return [
|
|
489
|
+
{
|
|
490
|
+
name: 'left',
|
|
491
|
+
distance: Math.abs(point.x - bounds.minX),
|
|
492
|
+
inSpan:
|
|
493
|
+
point.y >= bounds.minY - tolerance &&
|
|
494
|
+
point.y <= bounds.maxY + tolerance
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'right',
|
|
498
|
+
distance: Math.abs(point.x - bounds.maxX),
|
|
499
|
+
inSpan:
|
|
500
|
+
point.y >= bounds.minY - tolerance &&
|
|
501
|
+
point.y <= bounds.maxY + tolerance
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: 'bottom',
|
|
505
|
+
distance: Math.abs(point.y - bounds.minY),
|
|
506
|
+
inSpan:
|
|
507
|
+
point.x >= bounds.minX - tolerance &&
|
|
508
|
+
point.x <= bounds.maxX + tolerance
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
name: 'top',
|
|
512
|
+
distance: Math.abs(point.y - bounds.maxY),
|
|
513
|
+
inSpan:
|
|
514
|
+
point.x >= bounds.minX - tolerance &&
|
|
515
|
+
point.x <= bounds.maxX + tolerance
|
|
516
|
+
}
|
|
517
|
+
]
|
|
518
|
+
.filter((edge) => edge.inSpan)
|
|
519
|
+
.sort((left, right) => left.distance - right.distance)[0]
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Resolves frame side thickness from complete sibling minor spans.
|
|
524
|
+
* @param {{ bounds: object }[]} entries Complete sibling entries.
|
|
525
|
+
* @returns {number}
|
|
526
|
+
*/
|
|
527
|
+
static #frameSideThickness(entries) {
|
|
528
|
+
const thicknesses = entries
|
|
529
|
+
.map((entry) =>
|
|
530
|
+
Math.min(
|
|
531
|
+
entry.bounds.maxX - entry.bounds.minX,
|
|
532
|
+
entry.bounds.maxY - entry.bounds.minY
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
.filter(
|
|
536
|
+
(thickness) =>
|
|
537
|
+
Number.isFinite(thickness) &&
|
|
538
|
+
thickness >=
|
|
539
|
+
PcbScene3dStaticBodySymmetryRecovery
|
|
540
|
+
.#FRAME_SIDE_MIN_THICKNESS_MIL
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return Math.min(...thicknesses)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Builds source-coordinate bounds for a recovered frame side.
|
|
548
|
+
* @param {{ x: number, y: number }} source Source point.
|
|
549
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Group bounds.
|
|
550
|
+
* @param {string} edgeName Edge name.
|
|
551
|
+
* @param {number} thickness Side thickness.
|
|
552
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
553
|
+
*/
|
|
554
|
+
static #frameSideBoundsFromSource(source, bounds, edgeName, thickness) {
|
|
555
|
+
if (!bounds || !Number.isFinite(thickness) || thickness <= 0) {
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const halfThickness = thickness / 2
|
|
560
|
+
if (edgeName === 'left' || edgeName === 'right') {
|
|
561
|
+
return {
|
|
562
|
+
minX: source.x - halfThickness,
|
|
563
|
+
maxX: source.x + halfThickness,
|
|
564
|
+
minY: bounds.minY,
|
|
565
|
+
maxY: bounds.maxY
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (edgeName === 'bottom' || edgeName === 'top') {
|
|
569
|
+
return {
|
|
570
|
+
minX: bounds.minX,
|
|
571
|
+
maxX: bounds.maxX,
|
|
572
|
+
minY: source.y - halfThickness,
|
|
573
|
+
maxY: source.y + halfThickness
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Checks whether a body is an incomplete extruded polygon.
|
|
582
|
+
* @param {object | undefined} componentBody Candidate body.
|
|
583
|
+
* @returns {boolean}
|
|
584
|
+
*/
|
|
585
|
+
static #isIncompleteExtrudedPolygon(componentBody) {
|
|
586
|
+
const geometry = componentBody?.staticGeometry
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
590
|
+
geometry?.status !== 'complete' &&
|
|
591
|
+
(!Array.isArray(geometry?.verticesMil) ||
|
|
592
|
+
geometry.verticesMil.length < 3) &&
|
|
593
|
+
Number(geometry?.heightMil) > 0
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Checks whether a body is a complete extruded polygon.
|
|
599
|
+
* @param {object | undefined} componentBody Candidate body.
|
|
600
|
+
* @returns {boolean}
|
|
601
|
+
*/
|
|
602
|
+
static #isCompleteExtrudedPolygon(componentBody) {
|
|
603
|
+
const geometry = componentBody?.staticGeometry
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
|
|
607
|
+
geometry?.status === 'complete' &&
|
|
608
|
+
Array.isArray(geometry?.verticesMil) &&
|
|
609
|
+
geometry.verticesMil.length >= 3
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Checks whether a row identity describes a generic frame piece.
|
|
615
|
+
* @param {object | undefined} componentBody Candidate body.
|
|
616
|
+
* @returns {boolean}
|
|
617
|
+
*/
|
|
618
|
+
static #hasFrameIdentity(componentBody) {
|
|
619
|
+
return PcbScene3dStaticBodySymmetryRecovery.#identityTokens(
|
|
620
|
+
componentBody
|
|
621
|
+
).some((token) => token === 'frame')
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Checks whether two bodies have equivalent row family metadata.
|
|
626
|
+
* @param {object} left Left body.
|
|
627
|
+
* @param {object} right Right body.
|
|
628
|
+
* @returns {boolean}
|
|
629
|
+
*/
|
|
630
|
+
static #sameBodyFamily(left, right) {
|
|
631
|
+
const leftKey =
|
|
632
|
+
PcbScene3dStaticBodySymmetryRecovery.#bodyFamilyKey(left)
|
|
633
|
+
const rightKey =
|
|
634
|
+
PcbScene3dStaticBodySymmetryRecovery.#bodyFamilyKey(right)
|
|
635
|
+
|
|
636
|
+
return Boolean(leftKey && leftKey === rightKey)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Builds a row-family key for symmetric geometry reuse.
|
|
641
|
+
* @param {object | undefined} componentBody Candidate body.
|
|
642
|
+
* @returns {string}
|
|
643
|
+
*/
|
|
644
|
+
static #bodyFamilyKey(componentBody) {
|
|
645
|
+
const geometry = componentBody?.staticGeometry || {}
|
|
646
|
+
|
|
647
|
+
return [
|
|
648
|
+
componentBody?.identifier,
|
|
649
|
+
componentBody?.name,
|
|
650
|
+
geometry.kind,
|
|
651
|
+
PcbScene3dStaticBodySymmetryRecovery.#numberKey(geometry.heightMil),
|
|
652
|
+
PcbScene3dStaticBodySymmetryRecovery.#numberKey(
|
|
653
|
+
geometry.standoffHeightMil ?? componentBody?.standoffHeightMil
|
|
654
|
+
)
|
|
655
|
+
]
|
|
656
|
+
.map((value) =>
|
|
657
|
+
String(value ?? '')
|
|
658
|
+
.trim()
|
|
659
|
+
.toLowerCase()
|
|
660
|
+
)
|
|
661
|
+
.join('|')
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Checks whether explicit mechanical layer values are compatible.
|
|
666
|
+
* @param {object | undefined} left Left body.
|
|
667
|
+
* @param {object | undefined} right Right body.
|
|
668
|
+
* @returns {boolean}
|
|
669
|
+
*/
|
|
670
|
+
static #compatibleLayers(left, right) {
|
|
671
|
+
const leftLayer = String(left?.layer || '')
|
|
672
|
+
.trim()
|
|
673
|
+
.toLowerCase()
|
|
674
|
+
const rightLayer = String(right?.layer || '')
|
|
675
|
+
.trim()
|
|
676
|
+
.toLowerCase()
|
|
677
|
+
|
|
678
|
+
return !leftLayer || !rightLayer || leftLayer === rightLayer
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Checks whether explicit component indexes are compatible.
|
|
683
|
+
* @param {object} left Left body.
|
|
684
|
+
* @param {object} right Right body.
|
|
685
|
+
* @returns {boolean}
|
|
686
|
+
*/
|
|
687
|
+
static #sameComponentIndex(left, right) {
|
|
688
|
+
const leftIndex = Number(left?.componentIndex)
|
|
689
|
+
const rightIndex = Number(right?.componentIndex)
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
!Number.isInteger(leftIndex) ||
|
|
693
|
+
!Number.isInteger(rightIndex) ||
|
|
694
|
+
leftIndex === rightIndex
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Checks whether matched owners are compatible.
|
|
700
|
+
* @param {object | null | undefined} leftOwner Left owner.
|
|
701
|
+
* @param {object | null | undefined} rightOwner Right owner.
|
|
702
|
+
* @returns {boolean}
|
|
703
|
+
*/
|
|
704
|
+
static #sameMatchedOwner(leftOwner, rightOwner) {
|
|
705
|
+
if (!leftOwner || !rightOwner) {
|
|
706
|
+
return true
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return (
|
|
710
|
+
String(leftOwner.designator || '') ===
|
|
711
|
+
String(rightOwner.designator || '')
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Checks whether polygon vertices use source coordinates.
|
|
717
|
+
* @param {object} componentBody Component body.
|
|
718
|
+
* @param {{ x?: number, y?: number }[] | undefined} vertices Vertices.
|
|
719
|
+
* @returns {boolean}
|
|
720
|
+
*/
|
|
721
|
+
static #usesSourceCoordinateFrame(componentBody, vertices) {
|
|
722
|
+
const bounds =
|
|
723
|
+
PcbScene3dStaticBodySymmetryRecovery.#geometryBounds(vertices)
|
|
724
|
+
const source =
|
|
725
|
+
PcbScene3dStaticBodySymmetryRecovery.#sourcePosition(componentBody)
|
|
726
|
+
if (!bounds) {
|
|
727
|
+
return false
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return (
|
|
731
|
+
Math.max(
|
|
732
|
+
Math.abs(bounds.minX),
|
|
733
|
+
Math.abs(bounds.maxX),
|
|
734
|
+
Math.abs(bounds.minY),
|
|
735
|
+
Math.abs(bounds.maxY),
|
|
736
|
+
Math.abs(source.x),
|
|
737
|
+
Math.abs(source.y)
|
|
738
|
+
) > 1000
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Returns a source position for one body.
|
|
744
|
+
* @param {object | undefined} componentBody Candidate body.
|
|
745
|
+
* @returns {{ x: number, y: number }}
|
|
746
|
+
*/
|
|
747
|
+
static #sourcePosition(componentBody) {
|
|
748
|
+
return {
|
|
749
|
+
x: Number(componentBody?.positionMil?.x || 0),
|
|
750
|
+
y: Number(componentBody?.positionMil?.y || 0)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Resolves axis-aligned bounds for vertices.
|
|
756
|
+
* @param {{ x?: number, y?: number }[] | undefined} vertices Vertices.
|
|
757
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
758
|
+
*/
|
|
759
|
+
static #geometryBounds(vertices) {
|
|
760
|
+
const points = (Array.isArray(vertices) ? vertices : [])
|
|
761
|
+
.map((vertex) => ({
|
|
762
|
+
x: Number(vertex?.x || 0),
|
|
763
|
+
y: Number(vertex?.y || 0)
|
|
764
|
+
}))
|
|
765
|
+
.filter(
|
|
766
|
+
(point) => Number.isFinite(point.x) && Number.isFinite(point.y)
|
|
767
|
+
)
|
|
768
|
+
if (points.length < 3) {
|
|
769
|
+
return null
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
minX: Math.min(...points.map((point) => point.x)),
|
|
774
|
+
maxX: Math.max(...points.map((point) => point.x)),
|
|
775
|
+
minY: Math.min(...points.map((point) => point.y)),
|
|
776
|
+
maxY: Math.max(...points.map((point) => point.y))
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Resolves the center of bounds.
|
|
782
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
|
|
783
|
+
* @returns {{ x: number, y: number } | null}
|
|
784
|
+
*/
|
|
785
|
+
static #boundsCenter(bounds) {
|
|
786
|
+
return bounds
|
|
787
|
+
? {
|
|
788
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
789
|
+
y: (bounds.minY + bounds.maxY) / 2
|
|
790
|
+
}
|
|
791
|
+
: null
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Merges bounds records.
|
|
796
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }[]} boundsList Bounds list.
|
|
797
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
|
|
798
|
+
*/
|
|
799
|
+
static #mergeBounds(boundsList) {
|
|
800
|
+
const normalized = boundsList.filter(Boolean)
|
|
801
|
+
if (!normalized.length) {
|
|
802
|
+
return null
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
minX: Math.min(...normalized.map((bounds) => bounds.minX)),
|
|
807
|
+
minY: Math.min(...normalized.map((bounds) => bounds.minY)),
|
|
808
|
+
maxX: Math.max(...normalized.map((bounds) => bounds.maxX)),
|
|
809
|
+
maxY: Math.max(...normalized.map((bounds) => bounds.maxY))
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Converts bounds to clockwise polygon vertices.
|
|
815
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
|
|
816
|
+
* @returns {{ x: number, y: number }[]}
|
|
817
|
+
*/
|
|
818
|
+
static #boundsToVertices(bounds) {
|
|
819
|
+
return [
|
|
820
|
+
{ x: bounds.minX, y: bounds.minY },
|
|
821
|
+
{ x: bounds.maxX, y: bounds.minY },
|
|
822
|
+
{ x: bounds.maxX, y: bounds.maxY },
|
|
823
|
+
{ x: bounds.minX, y: bounds.maxY }
|
|
824
|
+
]
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Returns a cloned body with complete recovered vertices.
|
|
829
|
+
* @param {object} componentBody Target body.
|
|
830
|
+
* @param {{ x?: number, y?: number }[] | undefined} vertices Vertices.
|
|
831
|
+
* @returns {object}
|
|
832
|
+
*/
|
|
833
|
+
static #withRecoveredVertices(componentBody, vertices) {
|
|
834
|
+
return {
|
|
835
|
+
...componentBody,
|
|
836
|
+
staticGeometry: {
|
|
837
|
+
...componentBody.staticGeometry,
|
|
838
|
+
status: 'complete',
|
|
839
|
+
verticesMil: (Array.isArray(vertices) ? vertices : []).map(
|
|
840
|
+
(vertex) => ({
|
|
841
|
+
x: Number(vertex?.x || 0),
|
|
842
|
+
y: Number(vertex?.y || 0)
|
|
843
|
+
})
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Converts a number into a stable key fragment.
|
|
851
|
+
* @param {number | string | undefined | null} value Candidate value.
|
|
852
|
+
* @returns {string}
|
|
853
|
+
*/
|
|
854
|
+
static #numberKey(value) {
|
|
855
|
+
const number = Number(value)
|
|
856
|
+
|
|
857
|
+
return Number.isFinite(number)
|
|
858
|
+
? String(Math.round(number * 10000) / 10000)
|
|
859
|
+
: ''
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Collects normalized identity tokens from one body row.
|
|
864
|
+
* @param {object | undefined} componentBody Component body.
|
|
865
|
+
* @returns {string[]}
|
|
866
|
+
*/
|
|
867
|
+
static #identityTokens(componentBody) {
|
|
868
|
+
return [componentBody?.identifier, componentBody?.name]
|
|
869
|
+
.join(' ')
|
|
870
|
+
.replace(/([a-z])([A-Z])/gu, '$1 $2')
|
|
871
|
+
.toLowerCase()
|
|
872
|
+
.split(/[^a-z0-9]+/g)
|
|
873
|
+
.flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
|
|
874
|
+
.filter(Boolean)
|
|
875
|
+
}
|
|
876
|
+
}
|