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.
@@ -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
- startAngle: Number(current.startAngle),
219
- endAngle: Number(current.endAngle)
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
  /**