altium-toolkit 1.4.16 → 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/docs/api.md +17 -0
- package/docs/migration.md +13 -3
- package/docs/testing.md +17 -5
- package/package.json +2 -2
- 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/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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver.mjs'
|
|
5
|
+
import { PcbScene3dPadYawResolver } from './PcbScene3dPadYawResolver.mjs'
|
|
6
|
+
|
|
7
|
+
/** Resolves footprint geometry with indexes scoped to one scene build. */
|
|
8
|
+
export class PcbScene3dComponentGeometryResolver {
|
|
9
|
+
#components
|
|
10
|
+
#pads
|
|
11
|
+
#ownedPads = new Map()
|
|
12
|
+
#surfacePads = new Map()
|
|
13
|
+
#drilledCandidates = null
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Indexes pad ownership once, retaining source order and numeric coercion.
|
|
17
|
+
* @param {object[]} components Source components.
|
|
18
|
+
* @param {object[]} pads Source pads.
|
|
19
|
+
*/
|
|
20
|
+
constructor(components, pads) {
|
|
21
|
+
this.#components = components
|
|
22
|
+
this.#pads = pads
|
|
23
|
+
for (const pad of pads) {
|
|
24
|
+
const index = Number(pad?.componentIndex)
|
|
25
|
+
if (!Number.isFinite(index)) continue
|
|
26
|
+
const owned = this.#ownedPads.get(index) || []
|
|
27
|
+
owned.push(pad)
|
|
28
|
+
this.#ownedPads.set(index, owned)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns owned pads, preferring the mounted paste-mask surface.
|
|
34
|
+
* @param {object | null} component Source component.
|
|
35
|
+
* @param {string} [mountSide] Mounted surface override.
|
|
36
|
+
* @returns {object[]}
|
|
37
|
+
*/
|
|
38
|
+
componentPads(
|
|
39
|
+
component,
|
|
40
|
+
mountSide = PcbScene3dComponentGeometryResolver.#mountSide(component)
|
|
41
|
+
) {
|
|
42
|
+
const index = Number(component?.componentIndex)
|
|
43
|
+
if (!Number.isFinite(index)) return []
|
|
44
|
+
const side =
|
|
45
|
+
String(mountSide || '').toLowerCase() === 'bottom'
|
|
46
|
+
? 'bottom'
|
|
47
|
+
: 'top'
|
|
48
|
+
const key = `${index}:${side}`
|
|
49
|
+
if (!this.#surfacePads.has(key)) {
|
|
50
|
+
const owned = this.#ownedPads.get(index) || []
|
|
51
|
+
const surface = owned.filter((pad) =>
|
|
52
|
+
side === 'bottom'
|
|
53
|
+
? Boolean(pad?.hasBottomPasteMaskOpening)
|
|
54
|
+
: Boolean(pad?.hasTopPasteMaskOpening)
|
|
55
|
+
)
|
|
56
|
+
this.#surfacePads.set(key, surface.length ? surface : owned)
|
|
57
|
+
}
|
|
58
|
+
return this.#surfacePads.get(key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a footprint-local span, searching nearby pads only if unowned.
|
|
63
|
+
* @param {object} component Source component.
|
|
64
|
+
* @param {number} rotationDeg Visible rotation.
|
|
65
|
+
* @returns {{ width: number, depth: number }}
|
|
66
|
+
*/
|
|
67
|
+
resolvePadSpan(component, rotationDeg = Number(component?.rotation || 0)) {
|
|
68
|
+
const owned = this.componentPads(component)
|
|
69
|
+
const pads = owned.length
|
|
70
|
+
? owned
|
|
71
|
+
: this.#pads.filter(
|
|
72
|
+
(pad) =>
|
|
73
|
+
Math.abs(Number(pad.x || 0) - Number(component.x || 0)) <=
|
|
74
|
+
160 &&
|
|
75
|
+
Math.abs(Number(pad.y || 0) - Number(component.y || 0)) <=
|
|
76
|
+
160
|
|
77
|
+
)
|
|
78
|
+
if (!pads.length) return { width: 0, depth: 0 }
|
|
79
|
+
return (
|
|
80
|
+
PcbScene3dPadLocalSpanResolver.resolve(
|
|
81
|
+
{ ...component, rotation: rotationDeg },
|
|
82
|
+
pads,
|
|
83
|
+
PcbScene3dComponentGeometryResolver.#mountSide(component)
|
|
84
|
+
) || { width: 0, depth: 0 }
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves visible yaw from the owning footprint's pad centers.
|
|
90
|
+
* @param {object} component Source component.
|
|
91
|
+
* @param {string} mountSide Mounted surface.
|
|
92
|
+
* @returns {number}
|
|
93
|
+
*/
|
|
94
|
+
resolveComponentRotation(component, mountSide) {
|
|
95
|
+
return (
|
|
96
|
+
PcbScene3dPadYawResolver.resolve(
|
|
97
|
+
component,
|
|
98
|
+
this.componentPads(component),
|
|
99
|
+
mountSide
|
|
100
|
+
) ?? Number(component?.rotation || 0)
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Checks for drilled pads after mounted-surface preference is applied.
|
|
106
|
+
* @param {object | null} component Source component.
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
componentHasThroughHolePads(component) {
|
|
110
|
+
return this.componentPads(component).some((pad) =>
|
|
111
|
+
PcbScene3dComponentGeometryResolver.hasDrilledPadOpening(pad)
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolves a body owner from drilled-pad anchor areas, preserving ties in
|
|
117
|
+
* component and pad source order.
|
|
118
|
+
* @param {object | null | undefined} sourcePosition Body anchor.
|
|
119
|
+
* @returns {object | null}
|
|
120
|
+
*/
|
|
121
|
+
resolveComponentFromOwnedDrilledPad(sourcePosition) {
|
|
122
|
+
const x = Number(sourcePosition?.x)
|
|
123
|
+
const y = Number(sourcePosition?.y)
|
|
124
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
|
125
|
+
this.#drilledCandidates ??= this.#buildDrilledCandidates()
|
|
126
|
+
const matches = []
|
|
127
|
+
for (const candidate of this.#drilledCandidates) {
|
|
128
|
+
const padDistance = Math.max(
|
|
129
|
+
0,
|
|
130
|
+
Math.hypot(candidate.x - x, candidate.y - y) - candidate.radius
|
|
131
|
+
)
|
|
132
|
+
if (!(padDistance <= 1)) continue
|
|
133
|
+
matches.push({
|
|
134
|
+
component: candidate.component,
|
|
135
|
+
padDistance,
|
|
136
|
+
componentDistance: Math.hypot(
|
|
137
|
+
Number(candidate.component?.x || 0) - x,
|
|
138
|
+
Number(candidate.component?.y || 0) - y
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
matches.sort(
|
|
143
|
+
(left, right) =>
|
|
144
|
+
left.padDistance - right.padDistance ||
|
|
145
|
+
left.componentDistance - right.componentDistance
|
|
146
|
+
)
|
|
147
|
+
return matches[0]?.component || null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Prepares drill anchor geometry once when owner recovery first needs it.
|
|
152
|
+
* @returns {object[]}
|
|
153
|
+
*/
|
|
154
|
+
#buildDrilledCandidates() {
|
|
155
|
+
const candidates = []
|
|
156
|
+
for (const component of this.#components) {
|
|
157
|
+
for (const pad of this.componentPads(component)) {
|
|
158
|
+
if (
|
|
159
|
+
!PcbScene3dComponentGeometryResolver.hasDrilledPadOpening(
|
|
160
|
+
pad
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
continue
|
|
164
|
+
const radius =
|
|
165
|
+
PcbScene3dComponentGeometryResolver.#padAnchorRadiusMil(pad)
|
|
166
|
+
if (radius <= 0) continue
|
|
167
|
+
candidates.push({
|
|
168
|
+
component,
|
|
169
|
+
radius,
|
|
170
|
+
x: Number(pad?.x || 0),
|
|
171
|
+
y: Number(pad?.y || 0)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return candidates
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Measures distance to a drilled pad's effective anchor area.
|
|
180
|
+
* @param {object} point Body anchor.
|
|
181
|
+
* @param {object} pad Source pad.
|
|
182
|
+
* @returns {number}
|
|
183
|
+
*/
|
|
184
|
+
static distanceToPadAnchor(point, pad) {
|
|
185
|
+
const distance = Math.hypot(
|
|
186
|
+
Number(pad?.x || 0) - Number(point.x || 0),
|
|
187
|
+
Number(pad?.y || 0) - Number(point.y || 0)
|
|
188
|
+
)
|
|
189
|
+
const radius =
|
|
190
|
+
PcbScene3dComponentGeometryResolver.#padAnchorRadiusMil(pad)
|
|
191
|
+
return radius > 0 ? Math.max(0, distance - radius) : Infinity
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Checks for drilled or slotted board openings.
|
|
196
|
+
* @param {object} pad Source pad.
|
|
197
|
+
* @returns {boolean}
|
|
198
|
+
*/
|
|
199
|
+
static hasDrilledPadOpening(pad) {
|
|
200
|
+
const hole = pad?.holeGeometry || {}
|
|
201
|
+
return [
|
|
202
|
+
pad?.holeDiameter,
|
|
203
|
+
pad?.drillDiameter,
|
|
204
|
+
pad?.holeSlotLength,
|
|
205
|
+
pad?.slotLength,
|
|
206
|
+
hole?.diameter,
|
|
207
|
+
hole?.length,
|
|
208
|
+
hole?.slotLength
|
|
209
|
+
].some((value) => Number(value || 0) > 0)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolves the effective radius including copper and slot extents.
|
|
214
|
+
* @param {object} pad Source pad.
|
|
215
|
+
* @returns {number}
|
|
216
|
+
*/
|
|
217
|
+
static #padAnchorRadiusMil(pad) {
|
|
218
|
+
const hole = pad?.holeGeometry || {}
|
|
219
|
+
const diameter = Math.max(
|
|
220
|
+
...[
|
|
221
|
+
pad?.sizeTopX,
|
|
222
|
+
pad?.sizeTopY,
|
|
223
|
+
pad?.sizeMidX,
|
|
224
|
+
pad?.sizeMidY,
|
|
225
|
+
pad?.sizeBottomX,
|
|
226
|
+
pad?.sizeBottomY,
|
|
227
|
+
pad?.holeDiameter,
|
|
228
|
+
pad?.drillDiameter,
|
|
229
|
+
pad?.holeSlotLength,
|
|
230
|
+
pad?.slotLength,
|
|
231
|
+
hole?.diameter,
|
|
232
|
+
hole?.length,
|
|
233
|
+
hole?.slotLength
|
|
234
|
+
].map((value) => Number(value || 0))
|
|
235
|
+
)
|
|
236
|
+
return Number.isFinite(diameter) && diameter > 0 ? diameter / 2 : 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Resolves the scene builder's canonical component surface.
|
|
241
|
+
* @param {object | null} component Source component.
|
|
242
|
+
* @returns {'top' | 'bottom'}
|
|
243
|
+
*/
|
|
244
|
+
static #mountSide(component) {
|
|
245
|
+
return String(component?.layer || 'TOP').toUpperCase() === 'BOTTOM'
|
|
246
|
+
? 'bottom'
|
|
247
|
+
: 'top'
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -71,7 +71,13 @@ export class PcbScene3dCopperRegionDetailBuilder {
|
|
|
71
71
|
* @returns {boolean}
|
|
72
72
|
*/
|
|
73
73
|
static #isCutoutOrKeepout(region) {
|
|
74
|
+
const kind = region?.kind ?? region?.properties?.KIND
|
|
75
|
+
// Native KIND=0 is copper; cutouts, outlines and cavities are not
|
|
76
|
+
// positive copper even when the optional boolean flags are absent.
|
|
77
|
+
const hasNonCopperKind =
|
|
78
|
+
kind != null && Number.isFinite(Number(kind)) && Number(kind) !== 0
|
|
74
79
|
return (
|
|
80
|
+
hasNonCopperKind ||
|
|
75
81
|
region?.isKeepout === true ||
|
|
76
82
|
region?.isBoardCutout === true ||
|
|
77
83
|
region?.isPolygonPourCutout === true ||
|
|
@@ -215,9 +221,37 @@ export class PcbScene3dCopperRegionDetailBuilder {
|
|
|
215
221
|
x: center.x,
|
|
216
222
|
y: center.y,
|
|
217
223
|
radius,
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
...PcbScene3dCopperRegionDetailBuilder.#arcTraversal(
|
|
225
|
+
current,
|
|
226
|
+
center
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Preserves the authored arc and its traversal through the region contour.
|
|
233
|
+
* @param {object} point Region vertex with normalized arc metadata.
|
|
234
|
+
* @param {{ x: number, y: number }} center Normalized arc center.
|
|
235
|
+
* @returns {{ startAngle: number, endAngle: number, sweepAngle: number }}
|
|
236
|
+
*/
|
|
237
|
+
static #arcTraversal(point, center) {
|
|
238
|
+
const startAngle = Number(point.startAngle)
|
|
239
|
+
const endAngle = Number(point.endAngle)
|
|
240
|
+
const radius = Number(point.radius)
|
|
241
|
+
/** @param {number} angle Endpoint angle. @returns {number} Vertex distance. */
|
|
242
|
+
const distanceToEndpoint = (angle) => {
|
|
243
|
+
const radians = (angle * Math.PI) / 180
|
|
244
|
+
return Math.hypot(
|
|
245
|
+
Number(point.x) - center.x - radius * Math.cos(radians),
|
|
246
|
+
Number(point.y) - center.y - radius * Math.sin(radians)
|
|
247
|
+
)
|
|
220
248
|
}
|
|
249
|
+
// Altium's counterclockwise arc becomes clockwise after the parser's
|
|
250
|
+
// Y reflection. A contour may traverse that same arc from either end.
|
|
251
|
+
const sweep = (((startAngle - endAngle) % 360) + 360) % 360 || 360
|
|
252
|
+
return distanceToEndpoint(startAngle) <= distanceToEndpoint(endAngle)
|
|
253
|
+
? { startAngle, endAngle, sweepAngle: -sweep }
|
|
254
|
+
: { startAngle: endAngle, endAngle: startAngle, sweepAngle: sweep }
|
|
221
255
|
}
|
|
222
256
|
|
|
223
257
|
/**
|