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,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
+ }