altium-toolkit 1.4.15 → 1.4.17
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/README.md +5 -0
- package/docs/api.md +17 -0
- package/docs/migration.md +13 -3
- package/docs/release-notes-v1.4.16.md +27 -0
- package/docs/testing.md +17 -5
- package/package.json +2 -2
- package/spec/altium-3d-late-owner-recovery.md +10 -10
- package/spec/feature-preservation.json +1 -1
- package/spec/library-scope.md +4 -3
- package/src/convergence/AltiumWorkerClient.mjs +3 -12
- package/src/ui/AltiumScene3dBodyPlacementIndex.mjs +95 -0
- package/src/ui/AltiumScene3dComponentBodyAdapter.mjs +12 -32
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +5 -61
- package/src/ui/AltiumScene3dGeometricOwnerRecovery.mjs +11 -118
- package/src/ui/PcbInteractionIndex.mjs +59 -78
- package/src/ui/PcbInteractionLayerModel.mjs +2 -32
- package/src/ui/PcbInteractionSourceMetadata.mjs +122 -0
- package/src/ui/PcbScene3dBuilder.mjs +38 -267
- package/src/ui/PcbScene3dComponentGeometryResolver.mjs +249 -0
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +36 -2
package/spec/library-scope.md
CHANGED
|
@@ -7,8 +7,8 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
|
|
7
7
|
# Library Scope
|
|
8
8
|
|
|
9
9
|
Altium Toolkit provides reusable native Altium parsing behind the common ECAD
|
|
10
|
-
toolkit API. Canonical results use CircuitJSON; source-only facts and
|
|
11
|
-
|
|
10
|
+
toolkit API. Canonical results use CircuitJSON; source-only facts and historical
|
|
11
|
+
1.1.41 APIs remain explicit Altium extensions with maintained implementations.
|
|
12
12
|
|
|
13
13
|
## In Scope
|
|
14
14
|
|
|
@@ -18,7 +18,8 @@ historical 1.1.41 contracts remain explicit Altium extensions.
|
|
|
18
18
|
reusable document contexts
|
|
19
19
|
- Shared CircuitJSON render, interaction, query, manufacturing, simulation,
|
|
20
20
|
and data-only 3D scene services
|
|
21
|
-
-
|
|
21
|
+
- Preservation of audited Altium 1.1.41 `/extensions` API signatures and public
|
|
22
|
+
asset entrypoints and targets, with tested bug fixes and performance improvements
|
|
22
23
|
- `.SchDoc`, `.PcbDoc`, `.PCBDwf`, `.SchLib`, `.PcbLib`, `.PrjPcb`, `.PrjScr`,
|
|
23
24
|
and `.IntLib` parsing from `ArrayBuffer`
|
|
24
25
|
- OLE and binary stream helpers needed by parser recovery
|
|
@@ -74,18 +74,9 @@ export class AltiumWorkerClient {
|
|
|
74
74
|
*/
|
|
75
75
|
static #client() {
|
|
76
76
|
if (!client) {
|
|
77
|
-
client =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return Reflect.construct(WorkerConstructor, [
|
|
81
|
-
new URL(
|
|
82
|
-
'../workers/parser.worker.mjs',
|
|
83
|
-
import.meta.url
|
|
84
|
-
),
|
|
85
|
-
{ type: 'module' }
|
|
86
|
-
])
|
|
87
|
-
}
|
|
88
|
-
})
|
|
77
|
+
client = ParserWorkerClient.fromWorkerUrl(
|
|
78
|
+
new URL('../workers/parser.worker.mjs', import.meta.url)
|
|
79
|
+
)
|
|
89
80
|
}
|
|
90
81
|
return client
|
|
91
82
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/** Matches placements to authored bodies within one adapter invocation. */
|
|
5
|
+
export class AltiumScene3dBodyPlacementIndex {
|
|
6
|
+
#cells = new Map()
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Groups authored body positions into one-mil cells in source order.
|
|
10
|
+
* @param {object[]} componentBodies Source component bodies.
|
|
11
|
+
*/
|
|
12
|
+
constructor(componentBodies) {
|
|
13
|
+
componentBodies.forEach((componentBody, order) => {
|
|
14
|
+
const x = Number(componentBody?.positionMil?.x || 0)
|
|
15
|
+
const y = Number(componentBody?.positionMil?.y || 0)
|
|
16
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
17
|
+
const key = `${Math.floor(x)}:${Math.floor(y)}`
|
|
18
|
+
const cell = this.#cells.get(key) || []
|
|
19
|
+
cell.push({ componentBody, x, y, order })
|
|
20
|
+
this.#cells.set(key, cell)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Selects by identity affinity, distance, then original source order.
|
|
26
|
+
* @param {object} placement Built external placement.
|
|
27
|
+
* @returns {object | null}
|
|
28
|
+
*/
|
|
29
|
+
resolve(placement) {
|
|
30
|
+
const x = Number(placement?.bodyPositionMil?.x || 0)
|
|
31
|
+
const y = Number(placement?.bodyPositionMil?.y || 0)
|
|
32
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
|
33
|
+
const placementText = AltiumScene3dBodyPlacementIndex.#identityText([
|
|
34
|
+
placement?.designator,
|
|
35
|
+
placement?.externalModel?.name
|
|
36
|
+
])
|
|
37
|
+
let best = null
|
|
38
|
+
let bestScore = -1
|
|
39
|
+
let bestDistance = Infinity
|
|
40
|
+
for (const dx of [-1, 0, 1]) {
|
|
41
|
+
for (const dy of [-1, 0, 1]) {
|
|
42
|
+
const cell =
|
|
43
|
+
this.#cells.get(
|
|
44
|
+
`${Math.floor(x) + dx}:${Math.floor(y) + dy}`
|
|
45
|
+
) || []
|
|
46
|
+
for (const candidate of cell) {
|
|
47
|
+
const distance = Math.hypot(
|
|
48
|
+
x - candidate.x,
|
|
49
|
+
y - candidate.y
|
|
50
|
+
)
|
|
51
|
+
if (!(distance <= 0.01)) continue
|
|
52
|
+
const bodyText =
|
|
53
|
+
AltiumScene3dBodyPlacementIndex.#identityText([
|
|
54
|
+
candidate.componentBody?.identifier,
|
|
55
|
+
candidate.componentBody?.name
|
|
56
|
+
])
|
|
57
|
+
const score =
|
|
58
|
+
placementText &&
|
|
59
|
+
bodyText &&
|
|
60
|
+
placementText.includes(bodyText)
|
|
61
|
+
? bodyText.length
|
|
62
|
+
: 0
|
|
63
|
+
if (
|
|
64
|
+
best &&
|
|
65
|
+
!(
|
|
66
|
+
score > bestScore ||
|
|
67
|
+
(score === bestScore &&
|
|
68
|
+
(distance < bestDistance ||
|
|
69
|
+
(distance === bestDistance &&
|
|
70
|
+
candidate.order < best.order)))
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
continue
|
|
74
|
+
best = candidate
|
|
75
|
+
bestScore = score
|
|
76
|
+
bestDistance = distance
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best?.componentBody || null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Normalizes metadata with the placement adapter's identity convention.
|
|
85
|
+
* @param {unknown[]} values Source identity fields.
|
|
86
|
+
* @returns {string}
|
|
87
|
+
*/
|
|
88
|
+
static #identityText(values) {
|
|
89
|
+
return values
|
|
90
|
+
.map((value) => String(value || '').toLowerCase())
|
|
91
|
+
.join(' ')
|
|
92
|
+
.replace(/\.[a-z0-9]+\\b/g, '')
|
|
93
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PcbScene3dComponentGeometryResolver } from './PcbScene3dComponentGeometryResolver.mjs'
|
|
1
2
|
import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver.mjs'
|
|
2
3
|
|
|
3
4
|
const REFINABLE_FAMILIES = new Set(['chip', 'diode', 'generic', 'ic', 'sot'])
|
|
@@ -33,6 +34,10 @@ export class AltiumScene3dComponentBodyAdapter {
|
|
|
33
34
|
return sceneDescription
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
const componentGeometry = new PcbScene3dComponentGeometryResolver(
|
|
38
|
+
sourceComponents,
|
|
39
|
+
pads
|
|
40
|
+
)
|
|
36
41
|
const sourceByDesignator = new Map(
|
|
37
42
|
sourceComponents.map((component) => [
|
|
38
43
|
String(component?.designator || ''),
|
|
@@ -46,7 +51,7 @@ export class AltiumScene3dComponentBodyAdapter {
|
|
|
46
51
|
AltiumScene3dComponentBodyAdapter.#refineComponent(
|
|
47
52
|
component,
|
|
48
53
|
sourceByDesignator.get(String(component?.designator || '')),
|
|
49
|
-
|
|
54
|
+
componentGeometry
|
|
50
55
|
)
|
|
51
56
|
)
|
|
52
57
|
}
|
|
@@ -56,10 +61,10 @@ export class AltiumScene3dComponentBodyAdapter {
|
|
|
56
61
|
* Refines one procedural component body when nearby pads overinflated it.
|
|
57
62
|
* @param {object} component Scene component.
|
|
58
63
|
* @param {object | undefined} sourceComponent Source PCB component.
|
|
59
|
-
* @param {
|
|
64
|
+
* @param {PcbScene3dComponentGeometryResolver} componentGeometry Build-scoped geometry.
|
|
60
65
|
* @returns {object}
|
|
61
66
|
*/
|
|
62
|
-
static #refineComponent(component, sourceComponent,
|
|
67
|
+
static #refineComponent(component, sourceComponent, componentGeometry) {
|
|
63
68
|
const family = String(component?.body?.family || '')
|
|
64
69
|
if (
|
|
65
70
|
component?.externalModel ||
|
|
@@ -72,7 +77,7 @@ export class AltiumScene3dComponentBodyAdapter {
|
|
|
72
77
|
const span = AltiumScene3dComponentBodyAdapter.#ownedPadSpan(
|
|
73
78
|
sourceComponent,
|
|
74
79
|
component.mountSide,
|
|
75
|
-
|
|
80
|
+
componentGeometry
|
|
76
81
|
)
|
|
77
82
|
const size = component?.body?.sizeMil || {}
|
|
78
83
|
if (
|
|
@@ -99,42 +104,17 @@ export class AltiumScene3dComponentBodyAdapter {
|
|
|
99
104
|
* Resolves the owned surface-pad span for one source component.
|
|
100
105
|
* @param {object} component Source component.
|
|
101
106
|
* @param {string} mountSide Component mount side.
|
|
102
|
-
* @param {
|
|
107
|
+
* @param {PcbScene3dComponentGeometryResolver} componentGeometry Build-scoped geometry.
|
|
103
108
|
* @returns {{ width: number, depth: number } | null}
|
|
104
109
|
*/
|
|
105
|
-
static #ownedPadSpan(component, mountSide,
|
|
106
|
-
const componentIndex = Number(component?.componentIndex)
|
|
107
|
-
if (!Number.isFinite(componentIndex)) {
|
|
108
|
-
return null
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const ownedPads = pads.filter(
|
|
112
|
-
(pad) => Number(pad?.componentIndex) === componentIndex
|
|
113
|
-
)
|
|
114
|
-
const surfacePads = ownedPads.filter((pad) =>
|
|
115
|
-
AltiumScene3dComponentBodyAdapter.#isSurfacePad(pad, mountSide)
|
|
116
|
-
)
|
|
117
|
-
const spanPads = surfacePads.length ? surfacePads : ownedPads
|
|
118
|
-
|
|
110
|
+
static #ownedPadSpan(component, mountSide, componentGeometry) {
|
|
119
111
|
return PcbScene3dPadLocalSpanResolver.resolve(
|
|
120
112
|
component,
|
|
121
|
-
|
|
113
|
+
componentGeometry.componentPads(component, mountSide),
|
|
122
114
|
mountSide
|
|
123
115
|
)
|
|
124
116
|
}
|
|
125
117
|
|
|
126
|
-
/**
|
|
127
|
-
* Checks whether one pad belongs to the component's mounted surface.
|
|
128
|
-
* @param {object} pad Source pad.
|
|
129
|
-
* @param {string} mountSide Component mount side.
|
|
130
|
-
* @returns {boolean}
|
|
131
|
-
*/
|
|
132
|
-
static #isSurfacePad(pad, mountSide) {
|
|
133
|
-
return String(mountSide || '').toLowerCase() === 'bottom'
|
|
134
|
-
? Boolean(pad?.hasBottomPasteMaskOpening)
|
|
135
|
-
: Boolean(pad?.hasTopPasteMaskOpening)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
118
|
/**
|
|
139
119
|
* Checks whether the current body is clearly larger than owned pads.
|
|
140
120
|
* @param {object} size Current body size.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AltiumScene3dBodyPlacementIndex } from './AltiumScene3dBodyPlacementIndex.mjs'
|
|
1
2
|
import { AltiumScene3dIdentityTokens } from './AltiumScene3dIdentityTokens.mjs'
|
|
2
3
|
import { AltiumScene3dAuthoredConnectorYawPolicy } from './AltiumScene3dAuthoredConnectorYawPolicy.mjs'
|
|
3
4
|
import { AltiumScene3dPlacementRotationPolicy } from './AltiumScene3dPlacementRotationPolicy.mjs'
|
|
@@ -59,6 +60,7 @@ export class AltiumScene3dExternalPlacementAdapter {
|
|
|
59
60
|
])
|
|
60
61
|
)
|
|
61
62
|
|
|
63
|
+
const bodyIndex = new AltiumScene3dBodyPlacementIndex(componentBodies)
|
|
62
64
|
const repairedScene = {
|
|
63
65
|
...sceneDescription,
|
|
64
66
|
externalPlacements: sceneDescription.externalPlacements
|
|
@@ -67,7 +69,7 @@ export class AltiumScene3dExternalPlacementAdapter {
|
|
|
67
69
|
placement,
|
|
68
70
|
components,
|
|
69
71
|
componentByDesignator,
|
|
70
|
-
|
|
72
|
+
bodyIndex,
|
|
71
73
|
pads,
|
|
72
74
|
sceneDescription?.board
|
|
73
75
|
)
|
|
@@ -86,7 +88,7 @@ export class AltiumScene3dExternalPlacementAdapter {
|
|
|
86
88
|
placement,
|
|
87
89
|
components,
|
|
88
90
|
componentByDesignator,
|
|
89
|
-
|
|
91
|
+
bodyIndex,
|
|
90
92
|
pads,
|
|
91
93
|
board
|
|
92
94
|
) {
|
|
@@ -101,11 +103,7 @@ export class AltiumScene3dExternalPlacementAdapter {
|
|
|
101
103
|
return placement
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
const componentBody =
|
|
105
|
-
AltiumScene3dExternalPlacementAdapter.#resolveComponentBody(
|
|
106
|
-
placement,
|
|
107
|
-
componentBodies
|
|
108
|
-
)
|
|
106
|
+
const componentBody = bodyIndex.resolve(placement)
|
|
109
107
|
const currentComponent = componentByDesignator.get(
|
|
110
108
|
String(placement?.designator || '')
|
|
111
109
|
)
|
|
@@ -1542,60 +1540,6 @@ export class AltiumScene3dExternalPlacementAdapter {
|
|
|
1542
1540
|
]
|
|
1543
1541
|
}
|
|
1544
1542
|
|
|
1545
|
-
/**
|
|
1546
|
-
* Resolves the source component body row for one placement.
|
|
1547
|
-
* @param {object} placement External model placement.
|
|
1548
|
-
* @param {object[]} componentBodies Source component body rows.
|
|
1549
|
-
* @returns {object | null}
|
|
1550
|
-
*/
|
|
1551
|
-
static #resolveComponentBody(placement, componentBodies) {
|
|
1552
|
-
const candidates = componentBodies
|
|
1553
|
-
.map((componentBody) => ({
|
|
1554
|
-
componentBody,
|
|
1555
|
-
distance:
|
|
1556
|
-
AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
|
|
1557
|
-
placement?.bodyPositionMil,
|
|
1558
|
-
componentBody?.positionMil
|
|
1559
|
-
),
|
|
1560
|
-
identityScore:
|
|
1561
|
-
AltiumScene3dExternalPlacementAdapter.#bodyPlacementIdentityScore(
|
|
1562
|
-
placement,
|
|
1563
|
-
componentBody
|
|
1564
|
-
)
|
|
1565
|
-
}))
|
|
1566
|
-
.filter((candidate) => candidate.distance <= 0.01)
|
|
1567
|
-
.sort(
|
|
1568
|
-
(left, right) =>
|
|
1569
|
-
right.identityScore - left.identityScore ||
|
|
1570
|
-
left.distance - right.distance
|
|
1571
|
-
)
|
|
1572
|
-
|
|
1573
|
-
return candidates[0]?.componentBody || null
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
/**
|
|
1577
|
-
* Scores whether a source body row belongs to one placement.
|
|
1578
|
-
* @param {object} placement External model placement.
|
|
1579
|
-
* @param {object} componentBody Source component body.
|
|
1580
|
-
* @returns {number}
|
|
1581
|
-
*/
|
|
1582
|
-
static #bodyPlacementIdentityScore(placement, componentBody) {
|
|
1583
|
-
const placementText =
|
|
1584
|
-
AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
|
|
1585
|
-
placement?.designator,
|
|
1586
|
-
placement?.externalModel?.name
|
|
1587
|
-
])
|
|
1588
|
-
const bodyText =
|
|
1589
|
-
AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
|
|
1590
|
-
componentBody?.identifier,
|
|
1591
|
-
componentBody?.name
|
|
1592
|
-
])
|
|
1593
|
-
|
|
1594
|
-
return placementText && bodyText && placementText.includes(bodyText)
|
|
1595
|
-
? bodyText.length
|
|
1596
|
-
: 0
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
1543
|
/**
|
|
1600
1544
|
* Repairs model-local Altium rotation signs for embedded body transforms.
|
|
1601
1545
|
* @param {object | null | undefined} modelTransform Placement transform.
|
|
@@ -13,6 +13,7 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
13
13
|
static #COMPONENT_INDEX_CELL_MIL = 500
|
|
14
14
|
static #MINIMUM_SCORE = 12
|
|
15
15
|
static #MINIMUM_SCORE_MARGIN = 4
|
|
16
|
+
static #PAD_CENTROID_TOLERANCE_MIL = 12
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Applies geometry-backed owner recovery to final external placements.
|
|
@@ -108,14 +109,7 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
108
109
|
const currentOwner = context.componentsByDesignator.get(
|
|
109
110
|
String(placement?.designator || '')
|
|
110
111
|
)
|
|
111
|
-
if (currentOwner)
|
|
112
|
-
return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
|
|
113
|
-
placement,
|
|
114
|
-
currentOwner,
|
|
115
|
-
body,
|
|
116
|
-
context.geometryByComponent.get(currentOwner)
|
|
117
|
-
)
|
|
118
|
-
}
|
|
112
|
+
if (currentOwner) return placement
|
|
119
113
|
|
|
120
114
|
const match =
|
|
121
115
|
AltiumScene3dGeometricOwnerRecovery.#resolveGeometricOwner(
|
|
@@ -125,18 +119,12 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
125
119
|
)
|
|
126
120
|
if (!match) return placement
|
|
127
121
|
|
|
128
|
-
|
|
122
|
+
return AltiumScene3dGeometricOwnerRecovery.#withOwner(
|
|
129
123
|
placement,
|
|
130
124
|
body,
|
|
131
125
|
match,
|
|
132
126
|
context.board
|
|
133
127
|
)
|
|
134
|
-
return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
|
|
135
|
-
recovered,
|
|
136
|
-
match.component,
|
|
137
|
-
body,
|
|
138
|
-
context.geometryByComponent.get(match.component)
|
|
139
|
-
)
|
|
140
128
|
}
|
|
141
129
|
|
|
142
130
|
/**
|
|
@@ -329,7 +317,7 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
329
317
|
|
|
330
318
|
if (
|
|
331
319
|
centroidDistance <=
|
|
332
|
-
AltiumScene3dGeometricOwnerRecovery.#
|
|
320
|
+
AltiumScene3dGeometricOwnerRecovery.#PAD_CENTROID_TOLERANCE_MIL
|
|
333
321
|
) {
|
|
334
322
|
score = 24
|
|
335
323
|
mode = 'pad-centroid'
|
|
@@ -570,7 +558,9 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
570
558
|
const component = match.component
|
|
571
559
|
const mountSide =
|
|
572
560
|
AltiumScene3dGeometricOwnerRecovery.#componentSide(component)
|
|
573
|
-
const preserveAnchor =
|
|
561
|
+
const preserveAnchor =
|
|
562
|
+
match.mode === 'pad-centroid' ||
|
|
563
|
+
match.mode === 'height-backed-origin'
|
|
574
564
|
const offset = {
|
|
575
565
|
x:
|
|
576
566
|
Number(placement?.bodyPositionMil?.x || 0) -
|
|
@@ -590,6 +580,10 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
590
580
|
dzMil: verticalOffset
|
|
591
581
|
}
|
|
592
582
|
|
|
583
|
+
if (match.mode === 'height-backed-origin') {
|
|
584
|
+
modelTransform.preserveSourceAnchor = true
|
|
585
|
+
}
|
|
586
|
+
|
|
593
587
|
if (!preserveAnchor) {
|
|
594
588
|
modelTransform.ownerAnchorOffsetMil = offset
|
|
595
589
|
modelTransform.offsetMil = { x: 0, y: 0, z: verticalOffset }
|
|
@@ -622,107 +616,6 @@ export class AltiumScene3dGeometricOwnerRecovery {
|
|
|
622
616
|
}
|
|
623
617
|
}
|
|
624
618
|
|
|
625
|
-
/**
|
|
626
|
-
* Corrects the source-frame half-turn for a four-pad tactile switch.
|
|
627
|
-
* @param {object} placement External placement.
|
|
628
|
-
* @param {object} component Resolved owner.
|
|
629
|
-
* @param {object} body Source body.
|
|
630
|
-
* @param {object | null} geometry Precomputed owner pad geometry.
|
|
631
|
-
* @returns {object}
|
|
632
|
-
*/
|
|
633
|
-
static #correctTactileYaw(placement, component, body, geometry) {
|
|
634
|
-
const identity = [
|
|
635
|
-
component?.designator,
|
|
636
|
-
component?.description,
|
|
637
|
-
component?.provenance?.footprintDescription
|
|
638
|
-
]
|
|
639
|
-
.map((value) => String(value || ''))
|
|
640
|
-
.join(' ')
|
|
641
|
-
const sourceTilt = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
642
|
-
body?.modelRotationDeg?.x
|
|
643
|
-
)
|
|
644
|
-
const currentYaw = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
645
|
-
placement?.rotationDeg
|
|
646
|
-
)
|
|
647
|
-
const componentYaw =
|
|
648
|
-
AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
649
|
-
component?.rotation
|
|
650
|
-
)
|
|
651
|
-
const isTactileSwitch =
|
|
652
|
-
/(?:^|[^a-z0-9])(?:tact|tactile|pushbutton)(?:$|[^a-z0-9])/i.test(
|
|
653
|
-
identity
|
|
654
|
-
) &&
|
|
655
|
-
AltiumScene3dGeometricOwnerRecovery.#hasTactileContactTopology(
|
|
656
|
-
geometry
|
|
657
|
-
)
|
|
658
|
-
|
|
659
|
-
if (
|
|
660
|
-
!isTactileSwitch ||
|
|
661
|
-
(sourceTilt !== 90 && sourceTilt !== 270) ||
|
|
662
|
-
currentYaw !== componentYaw
|
|
663
|
-
) {
|
|
664
|
-
return placement
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
return {
|
|
668
|
-
...placement,
|
|
669
|
-
rotationDeg: AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
|
|
670
|
-
currentYaw + 180
|
|
671
|
-
)
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* Detects a two-by-two tactile contact layout with two duplicated routed
|
|
677
|
-
* contact pairs aligned along one footprint axis.
|
|
678
|
-
* @param {object | null} geometry Precomputed owner pad geometry.
|
|
679
|
-
* @returns {boolean}
|
|
680
|
-
*/
|
|
681
|
-
static #hasTactileContactTopology(geometry) {
|
|
682
|
-
if (
|
|
683
|
-
geometry?.pads?.length !== 4 ||
|
|
684
|
-
geometry.xCount !== 2 ||
|
|
685
|
-
geometry.yCount !== 2
|
|
686
|
-
) {
|
|
687
|
-
return false
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const groups = new Map()
|
|
691
|
-
for (const pad of geometry.localPads) {
|
|
692
|
-
const sourcePad = pad.source
|
|
693
|
-
const netName = String(sourcePad?.netName || '').trim()
|
|
694
|
-
const netIndex = sourcePad?.netIndex
|
|
695
|
-
const contactKey = netName
|
|
696
|
-
? `name:${netName}`
|
|
697
|
-
: netIndex !== null &&
|
|
698
|
-
netIndex !== undefined &&
|
|
699
|
-
netIndex !== '' &&
|
|
700
|
-
Number.isFinite(Number(netIndex))
|
|
701
|
-
? `index:${Number(netIndex)}`
|
|
702
|
-
: ''
|
|
703
|
-
if (!contactKey) return false
|
|
704
|
-
groups.set(contactKey, [...(groups.get(contactKey) || []), pad])
|
|
705
|
-
}
|
|
706
|
-
if (
|
|
707
|
-
groups.size !== 2 ||
|
|
708
|
-
[...groups.values()].some((group) => group.length !== 2)
|
|
709
|
-
) {
|
|
710
|
-
return false
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
return [...groups.values()].every((group) => {
|
|
714
|
-
const sameX =
|
|
715
|
-
AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
|
|
716
|
-
group.map((pad) => Number(pad?.x || 0))
|
|
717
|
-
) === 1
|
|
718
|
-
const sameY =
|
|
719
|
-
AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
|
|
720
|
-
group.map((pad) => Number(pad?.y || 0))
|
|
721
|
-
) === 1
|
|
722
|
-
return sameX || sameY
|
|
723
|
-
})
|
|
724
|
-
}
|
|
725
|
-
|
|
726
619
|
/**
|
|
727
620
|
* Resolves a body vertical offset after a late owner recovery.
|
|
728
621
|
* @param {object} body Source body.
|