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