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.
- package/docs/model-format.md +2 -3
- package/package.json +1 -1
- package/src/core/altium/AltiumGeneratedLibraryRecordBuilder.mjs +551 -0
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +286 -2
- package/src/core/altium/AltiumPcbLibExporter.mjs +9 -2
- package/src/core/altium/AltiumSchLibExporter.mjs +1 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +52 -4
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +27 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +782 -0
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +595 -28
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
- package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
- package/src/ui/PcbScene3dPackages.mjs +15 -3
- package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +56 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +638 -16
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts parsed PCB region primitives into 3D copper fill detail.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbScene3dCopperRegionDetailBuilder {
|
|
9
|
+
/**
|
|
10
|
+
* Builds fill-compatible region primitives for 3D copper rendering.
|
|
11
|
+
* @param {{ regions?: object[], shapeBasedRegions?: object[] } | null} pcb Parsed PCB model.
|
|
12
|
+
* @returns {object[]}
|
|
13
|
+
*/
|
|
14
|
+
static build(pcb) {
|
|
15
|
+
return PcbScene3dCopperRegionDetailBuilder.#sourceRegions(pcb)
|
|
16
|
+
.filter((region) =>
|
|
17
|
+
PcbScene3dCopperRegionDetailBuilder.#isCopperFillRegion(region)
|
|
18
|
+
)
|
|
19
|
+
.map((region) =>
|
|
20
|
+
PcbScene3dCopperRegionDetailBuilder.#withSegmentContours(region)
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves the region family used by the 2D PCB renderer.
|
|
26
|
+
* @param {{ regions?: object[], shapeBasedRegions?: object[] } | null} pcb Parsed PCB model.
|
|
27
|
+
* @returns {object[]}
|
|
28
|
+
*/
|
|
29
|
+
static #sourceRegions(pcb) {
|
|
30
|
+
const shapeBasedRegions = Array.isArray(pcb?.shapeBasedRegions)
|
|
31
|
+
? pcb.shapeBasedRegions
|
|
32
|
+
: []
|
|
33
|
+
if (shapeBasedRegions.length) {
|
|
34
|
+
return shapeBasedRegions
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Array.isArray(pcb?.regions) ? pcb.regions : []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks whether one region should become rendered copper.
|
|
42
|
+
* @param {object | null} region Parsed region primitive.
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
static #isCopperFillRegion(region) {
|
|
46
|
+
return (
|
|
47
|
+
PcbScene3dCopperRegionDetailBuilder.#isCopperLayerId(
|
|
48
|
+
region?.layerId
|
|
49
|
+
) &&
|
|
50
|
+
!PcbScene3dCopperRegionDetailBuilder.#isCutoutOrKeepout(region) &&
|
|
51
|
+
PcbScene3dCopperRegionDetailBuilder.#pointLoop(region?.points)
|
|
52
|
+
.length >= 3
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks whether one layer id belongs to Altium copper.
|
|
58
|
+
* @param {unknown} layerId Source layer id.
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
static #isCopperLayerId(layerId) {
|
|
62
|
+
const normalized = Number(layerId)
|
|
63
|
+
return (
|
|
64
|
+
Number.isInteger(normalized) && normalized >= 1 && normalized <= 32
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks whether one region describes clearance instead of copper.
|
|
70
|
+
* @param {object | null} region Parsed region primitive.
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
static #isCutoutOrKeepout(region) {
|
|
74
|
+
return (
|
|
75
|
+
region?.isKeepout === true ||
|
|
76
|
+
region?.isBoardCutout === true ||
|
|
77
|
+
region?.isPolygonPourCutout === true ||
|
|
78
|
+
region?.isCutout === true ||
|
|
79
|
+
Boolean(region?.cutoutClassification) ||
|
|
80
|
+
PcbScene3dCopperRegionDetailBuilder.#looksLikeCutout(
|
|
81
|
+
region?.classification
|
|
82
|
+
) ||
|
|
83
|
+
PcbScene3dCopperRegionDetailBuilder.#looksLikeCutout(
|
|
84
|
+
region?.rawKind
|
|
85
|
+
) ||
|
|
86
|
+
PcbScene3dCopperRegionDetailBuilder.#looksLikeCutout(
|
|
87
|
+
region?.properties?.KIND
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Checks a native classification token for cutout semantics.
|
|
94
|
+
* @param {unknown} value Classification token.
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
static #looksLikeCutout(value) {
|
|
98
|
+
return String(value || '')
|
|
99
|
+
.replace(/[^a-z0-9]/giu, '')
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.includes('cutout')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Adds segment contours when a region includes arc vertices.
|
|
106
|
+
* @param {object} region Parsed region primitive.
|
|
107
|
+
* @returns {object}
|
|
108
|
+
*/
|
|
109
|
+
static #withSegmentContours(region) {
|
|
110
|
+
const loops = [
|
|
111
|
+
region?.points,
|
|
112
|
+
...(Array.isArray(region?.holes) ? region.holes : [])
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
!loops.some((loop) =>
|
|
117
|
+
PcbScene3dCopperRegionDetailBuilder.#hasArcSegment(loop)
|
|
118
|
+
)
|
|
119
|
+
) {
|
|
120
|
+
return { ...region }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const contours = loops
|
|
124
|
+
.map((loop) =>
|
|
125
|
+
PcbScene3dCopperRegionDetailBuilder.#segmentContour(loop)
|
|
126
|
+
)
|
|
127
|
+
.filter((contour) => contour.length >= 3)
|
|
128
|
+
|
|
129
|
+
return contours.length ? { ...region, contours } : { ...region }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Checks whether a loop carries arc metadata.
|
|
134
|
+
* @param {unknown[]} points Candidate loop points.
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
static #hasArcSegment(points) {
|
|
138
|
+
return PcbScene3dCopperRegionDetailBuilder.#pointLoop(points).some(
|
|
139
|
+
(point) => PcbScene3dCopperRegionDetailBuilder.#isArcPoint(point)
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Converts one point loop into line and arc segments.
|
|
145
|
+
* @param {unknown[]} points Candidate loop points.
|
|
146
|
+
* @returns {object[]}
|
|
147
|
+
*/
|
|
148
|
+
static #segmentContour(points) {
|
|
149
|
+
const loop = PcbScene3dCopperRegionDetailBuilder.#pointLoop(points)
|
|
150
|
+
if (loop.length < 3) {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const segments = []
|
|
155
|
+
for (let index = 0; index < loop.length; index += 1) {
|
|
156
|
+
const current = loop[index]
|
|
157
|
+
const next = loop[(index + 1) % loop.length]
|
|
158
|
+
const segment =
|
|
159
|
+
PcbScene3dCopperRegionDetailBuilder.#arcSegment(
|
|
160
|
+
current,
|
|
161
|
+
next
|
|
162
|
+
) ||
|
|
163
|
+
PcbScene3dCopperRegionDetailBuilder.#lineSegment(current, next)
|
|
164
|
+
segments.push(segment)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return segments
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Builds a straight segment between two region vertices.
|
|
172
|
+
* @param {{ x: number, y: number }} current Start vertex.
|
|
173
|
+
* @param {{ x: number, y: number }} next End vertex.
|
|
174
|
+
* @returns {object}
|
|
175
|
+
*/
|
|
176
|
+
static #lineSegment(current, next) {
|
|
177
|
+
return {
|
|
178
|
+
type: 'line',
|
|
179
|
+
x1: Number(current.x),
|
|
180
|
+
y1: Number(current.y),
|
|
181
|
+
x2: Number(next.x),
|
|
182
|
+
y2: Number(next.y)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Builds an arc segment when the start vertex carries arc metadata.
|
|
188
|
+
* @param {{ x: number, y: number }} current Start vertex.
|
|
189
|
+
* @param {{ x: number, y: number }} next End vertex.
|
|
190
|
+
* @returns {object | null}
|
|
191
|
+
*/
|
|
192
|
+
static #arcSegment(current, next) {
|
|
193
|
+
if (!PcbScene3dCopperRegionDetailBuilder.#isArcPoint(current)) {
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const center = PcbScene3dCopperRegionDetailBuilder.#arcCenter(current)
|
|
198
|
+
const radius = Number(current.radius)
|
|
199
|
+
if (
|
|
200
|
+
!center ||
|
|
201
|
+
!Number.isFinite(radius) ||
|
|
202
|
+
radius <= 0 ||
|
|
203
|
+
!Number.isFinite(Number(current.startAngle)) ||
|
|
204
|
+
!Number.isFinite(Number(current.endAngle))
|
|
205
|
+
) {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
type: 'arc',
|
|
211
|
+
x1: Number(current.x),
|
|
212
|
+
y1: Number(current.y),
|
|
213
|
+
x2: Number(next.x),
|
|
214
|
+
y2: Number(next.y),
|
|
215
|
+
x: center.x,
|
|
216
|
+
y: center.y,
|
|
217
|
+
radius,
|
|
218
|
+
startAngle: Number(current.startAngle),
|
|
219
|
+
endAngle: Number(current.endAngle)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolves one arc center from known region vertex fields.
|
|
225
|
+
* @param {object} point Region vertex.
|
|
226
|
+
* @returns {{ x: number, y: number } | null}
|
|
227
|
+
*/
|
|
228
|
+
static #arcCenter(point) {
|
|
229
|
+
const x = Number(point?.centerX ?? point?.cx ?? point?.center?.x)
|
|
230
|
+
const y = Number(point?.centerY ?? point?.cy ?? point?.center?.y)
|
|
231
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Checks whether a point starts an arc segment.
|
|
236
|
+
* @param {object | null} point Region vertex.
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
static #isArcPoint(point) {
|
|
240
|
+
return point?.isArc === true && Number(point?.radius) > 0
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Builds a finite point loop and removes a duplicate closing vertex.
|
|
245
|
+
* @param {unknown[] | undefined} points Candidate loop points.
|
|
246
|
+
* @returns {object[]}
|
|
247
|
+
*/
|
|
248
|
+
static #pointLoop(points) {
|
|
249
|
+
const loop = (Array.isArray(points) ? points : []).filter((point) =>
|
|
250
|
+
PcbScene3dCopperRegionDetailBuilder.#isFinitePoint(point)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if (loop.length < 2) {
|
|
254
|
+
return loop
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const first = loop[0]
|
|
258
|
+
const last = loop[loop.length - 1]
|
|
259
|
+
if (
|
|
260
|
+
Math.abs(Number(first.x) - Number(last.x)) < 1e-6 &&
|
|
261
|
+
Math.abs(Number(first.y) - Number(last.y)) < 1e-6
|
|
262
|
+
) {
|
|
263
|
+
return loop.slice(0, -1)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return loop
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Checks whether one object carries finite point coordinates.
|
|
271
|
+
* @param {unknown} point Candidate point.
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
static #isFinitePoint(point) {
|
|
275
|
+
return (
|
|
276
|
+
Number.isFinite(Number(point?.x)) &&
|
|
277
|
+
Number.isFinite(Number(point?.y))
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -104,6 +104,22 @@ export class PcbScene3dModelRegistry {
|
|
|
104
104
|
return 'step'
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
if (lowerCasePath.endsWith('.glb')) {
|
|
108
|
+
return 'glb'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (lowerCasePath.endsWith('.gltf')) {
|
|
112
|
+
return 'gltf'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (lowerCasePath.endsWith('.stl')) {
|
|
116
|
+
return 'stl'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (lowerCasePath.endsWith('.obj')) {
|
|
120
|
+
return 'obj'
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
return ''
|
|
108
124
|
}
|
|
109
125
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves package dimensions from structured component metadata.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dPackageDimensionResolver {
|
|
5
|
+
static #MIL_PER_MM = 39.3700787402
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves explicit planar package dimensions in mils.
|
|
9
|
+
* @param {{ parameters?: Record<string, unknown> } | null | undefined} component Source component.
|
|
10
|
+
* @returns {{ width: number, depth: number } | null}
|
|
11
|
+
*/
|
|
12
|
+
static resolvePlanarSize(component) {
|
|
13
|
+
const parameters = component?.parameters
|
|
14
|
+
if (
|
|
15
|
+
!parameters ||
|
|
16
|
+
typeof parameters !== 'object' ||
|
|
17
|
+
Array.isArray(parameters)
|
|
18
|
+
) {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const length = PcbScene3dPackageDimensionResolver.#dimensionByKeys(
|
|
23
|
+
parameters,
|
|
24
|
+
['length', 'package length', 'body length']
|
|
25
|
+
)
|
|
26
|
+
const width = PcbScene3dPackageDimensionResolver.#dimensionByKeys(
|
|
27
|
+
parameters,
|
|
28
|
+
['width', 'package width', 'body width']
|
|
29
|
+
)
|
|
30
|
+
if (!length || !width) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { width: length, depth: width }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Finds a dimension parameter by normalized key.
|
|
39
|
+
* @param {Record<string, unknown>} parameters Component parameters.
|
|
40
|
+
* @param {string[]} keys Accepted keys.
|
|
41
|
+
* @returns {number | null}
|
|
42
|
+
*/
|
|
43
|
+
static #dimensionByKeys(parameters, keys) {
|
|
44
|
+
const acceptedKeys = new Set(
|
|
45
|
+
keys.map((key) =>
|
|
46
|
+
PcbScene3dPackageDimensionResolver.#normalizeKey(key)
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
51
|
+
if (
|
|
52
|
+
acceptedKeys.has(
|
|
53
|
+
PcbScene3dPackageDimensionResolver.#normalizeKey(key)
|
|
54
|
+
)
|
|
55
|
+
) {
|
|
56
|
+
const dimension =
|
|
57
|
+
PcbScene3dPackageDimensionResolver.#parseDimensionMil(value)
|
|
58
|
+
if (dimension) {
|
|
59
|
+
return dimension
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parses one dimension string into mils.
|
|
69
|
+
* @param {unknown} value Source parameter value.
|
|
70
|
+
* @returns {number | null}
|
|
71
|
+
*/
|
|
72
|
+
static #parseDimensionMil(value) {
|
|
73
|
+
const text = String(value || '').replace(/,/gu, '.')
|
|
74
|
+
const match = text.match(
|
|
75
|
+
/(-?\d+(?:\.\d+)?)\s*(mm|millimeters?|mils?|in(?:ch(?:es)?)?|")/iu
|
|
76
|
+
)
|
|
77
|
+
if (!match) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const amount = Number(match[1])
|
|
82
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const unit = String(match[2] || '').toLowerCase()
|
|
87
|
+
if (unit === 'mm' || unit.startsWith('millimeter')) {
|
|
88
|
+
return amount * PcbScene3dPackageDimensionResolver.#MIL_PER_MM
|
|
89
|
+
}
|
|
90
|
+
if (unit === 'mil' || unit === 'mils') {
|
|
91
|
+
return amount
|
|
92
|
+
}
|
|
93
|
+
return amount * 1000
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalizes one parameter key.
|
|
98
|
+
* @param {unknown} key Source key.
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
static #normalizeKey(key) {
|
|
102
|
+
return String(key || '')
|
|
103
|
+
.trim()
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.replace(/\s+/gu, ' ')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
|
|
5
|
+
import { PcbScene3dPackageDimensionResolver } from './PcbScene3dPackageDimensionResolver.mjs'
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Resolves procedural PCB package families and dimensions.
|
|
7
9
|
*/
|
|
8
10
|
export class PcbScene3dPackages {
|
|
9
11
|
/**
|
|
10
12
|
* Resolves one procedural package description for a component.
|
|
11
|
-
* @param {{ pattern?: string, height?: number | null }} component
|
|
13
|
+
* @param {{ pattern?: string, height?: number | null, parameters?: Record<string, unknown> }} component
|
|
12
14
|
* @param {{ width: number, depth: number }} [padSpan]
|
|
13
15
|
* @returns {{ family: string, sizeMil: { width: number, depth: number, height: number } }}
|
|
14
16
|
*/
|
|
@@ -23,12 +25,22 @@ export class PcbScene3dPackages {
|
|
|
23
25
|
Number.isFinite(explicitHeight) && explicitHeight > 0
|
|
24
26
|
? explicitHeight
|
|
25
27
|
: defaults.height
|
|
28
|
+
const explicitSize =
|
|
29
|
+
PcbScene3dPackageDimensionResolver.resolvePlanarSize(component)
|
|
26
30
|
|
|
27
31
|
return {
|
|
28
32
|
family,
|
|
29
33
|
sizeMil: {
|
|
30
|
-
width: Math.max(
|
|
31
|
-
|
|
34
|
+
width: Math.max(
|
|
35
|
+
defaults.width,
|
|
36
|
+
Number(padSpan.width) || 0,
|
|
37
|
+
Number(explicitSize?.width) || 0
|
|
38
|
+
),
|
|
39
|
+
depth: Math.max(
|
|
40
|
+
defaults.depth,
|
|
41
|
+
Number(padSpan.depth) || 0,
|
|
42
|
+
Number(explicitSize?.depth) || 0
|
|
43
|
+
),
|
|
32
44
|
height
|
|
33
45
|
}
|
|
34
46
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves procedural body yaw from owned PCB pad geometry.
|
|
7
|
+
*/
|
|
8
|
+
export class PcbScene3dPadYawResolver {
|
|
9
|
+
static #DOMINANT_AXIS_RATIO = 1.25
|
|
10
|
+
static #EPSILON = 1e-9
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves a component yaw from its owned pad-center distribution.
|
|
14
|
+
* @param {{ componentIndex?: number, rotation?: number }} component Source component.
|
|
15
|
+
* @param {object[]} pads Source PCB pads.
|
|
16
|
+
* @param {'top' | 'bottom' | string} [mountSide] Component mount side.
|
|
17
|
+
* @returns {number | null}
|
|
18
|
+
*/
|
|
19
|
+
static resolve(component, pads, mountSide = 'top') {
|
|
20
|
+
const points = PcbScene3dPadYawResolver.#ownedPadCenters(
|
|
21
|
+
component,
|
|
22
|
+
pads,
|
|
23
|
+
mountSide
|
|
24
|
+
)
|
|
25
|
+
if (points.length < 2) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const axis = PcbScene3dPadYawResolver.#dominantAxis(points)
|
|
30
|
+
if (axis === null) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return PcbScene3dPadYawResolver.#closestHalfTurnEquivalent(
|
|
35
|
+
axis,
|
|
36
|
+
Number(component?.rotation || 0)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves finite owned pad centers, preferring mounted-surface pads.
|
|
42
|
+
* @param {{ componentIndex?: number }} component Source component.
|
|
43
|
+
* @param {object[]} pads Source PCB pads.
|
|
44
|
+
* @param {'top' | 'bottom' | string} mountSide Component mount side.
|
|
45
|
+
* @returns {{ x: number, y: number }[]}
|
|
46
|
+
*/
|
|
47
|
+
static #ownedPadCenters(component, pads, mountSide) {
|
|
48
|
+
const componentIndex = Number(component?.componentIndex)
|
|
49
|
+
if (!Number.isFinite(componentIndex) || !Array.isArray(pads)) {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ownedPads = pads.filter(
|
|
54
|
+
(pad) => Number(pad?.componentIndex) === componentIndex
|
|
55
|
+
)
|
|
56
|
+
const surfacePads = ownedPads.filter((pad) =>
|
|
57
|
+
PcbScene3dPadYawResolver.#isSurfacePad(pad, mountSide)
|
|
58
|
+
)
|
|
59
|
+
const yawPads = surfacePads.length ? surfacePads : ownedPads
|
|
60
|
+
|
|
61
|
+
return yawPads
|
|
62
|
+
.map((pad) => PcbScene3dPadYawResolver.#padCenter(pad))
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Checks whether one pad belongs to the mounted paste-mask side.
|
|
68
|
+
* @param {object} pad Source pad.
|
|
69
|
+
* @param {'top' | 'bottom' | string} mountSide Component mount side.
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
static #isSurfacePad(pad, mountSide) {
|
|
73
|
+
return String(mountSide || '').toLowerCase() === 'bottom'
|
|
74
|
+
? Boolean(pad?.hasBottomPasteMaskOpening)
|
|
75
|
+
: Boolean(pad?.hasTopPasteMaskOpening)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves one finite pad center.
|
|
80
|
+
* @param {{ x?: number, y?: number }} pad Source pad.
|
|
81
|
+
* @returns {{ x: number, y: number } | null}
|
|
82
|
+
*/
|
|
83
|
+
static #padCenter(pad) {
|
|
84
|
+
const x = Number(pad?.x)
|
|
85
|
+
const y = Number(pad?.y)
|
|
86
|
+
|
|
87
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolves a dominant pad-center axis, modulo 180 degrees.
|
|
92
|
+
* @param {{ x: number, y: number }[]} points Pad centers.
|
|
93
|
+
* @returns {number | null}
|
|
94
|
+
*/
|
|
95
|
+
static #dominantAxis(points) {
|
|
96
|
+
const mean = PcbScene3dPadYawResolver.#meanPoint(points)
|
|
97
|
+
let xx = 0
|
|
98
|
+
let xy = 0
|
|
99
|
+
let yy = 0
|
|
100
|
+
|
|
101
|
+
for (const point of points) {
|
|
102
|
+
const dx = point.x - mean.x
|
|
103
|
+
const dy = point.y - mean.y
|
|
104
|
+
xx += dx * dx
|
|
105
|
+
xy += dx * dy
|
|
106
|
+
yy += dy * dy
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
xx /= points.length
|
|
110
|
+
xy /= points.length
|
|
111
|
+
yy /= points.length
|
|
112
|
+
|
|
113
|
+
const trace = xx + yy
|
|
114
|
+
const discriminant = Math.sqrt((xx - yy) ** 2 + 4 * xy * xy)
|
|
115
|
+
const primary = (trace + discriminant) / 2
|
|
116
|
+
const secondary = (trace - discriminant) / 2
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
primary <= PcbScene3dPadYawResolver.#EPSILON ||
|
|
120
|
+
(secondary > PcbScene3dPadYawResolver.#EPSILON &&
|
|
121
|
+
primary / secondary <
|
|
122
|
+
PcbScene3dPadYawResolver.#DOMINANT_AXIS_RATIO)
|
|
123
|
+
) {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return PcbScene3dPadYawResolver.#normalizeHalfTurn(
|
|
128
|
+
(Math.atan2(2 * xy, xx - yy) * 90) / Math.PI
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolves the mean point for a non-empty point set.
|
|
134
|
+
* @param {{ x: number, y: number }[]} points Pad centers.
|
|
135
|
+
* @returns {{ x: number, y: number }}
|
|
136
|
+
*/
|
|
137
|
+
static #meanPoint(points) {
|
|
138
|
+
const sum = points.reduce(
|
|
139
|
+
(accumulator, point) => ({
|
|
140
|
+
x: accumulator.x + point.x,
|
|
141
|
+
y: accumulator.y + point.y
|
|
142
|
+
}),
|
|
143
|
+
{ x: 0, y: 0 }
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
x: sum.x / points.length,
|
|
148
|
+
y: sum.y / points.length
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Selects the 180-degree equivalent closest to the source rotation.
|
|
154
|
+
* @param {number} axis Dominant axis in degrees.
|
|
155
|
+
* @param {number} sourceRotation Source component rotation.
|
|
156
|
+
* @returns {number}
|
|
157
|
+
*/
|
|
158
|
+
static #closestHalfTurnEquivalent(axis, sourceRotation) {
|
|
159
|
+
const first = PcbScene3dPadYawResolver.#normalizeAngle(axis)
|
|
160
|
+
const second = PcbScene3dPadYawResolver.#normalizeAngle(axis + 180)
|
|
161
|
+
const source = PcbScene3dPadYawResolver.#normalizeAngle(sourceRotation)
|
|
162
|
+
|
|
163
|
+
return PcbScene3dPadYawResolver.#angularDistance(first, source) <=
|
|
164
|
+
PcbScene3dPadYawResolver.#angularDistance(second, source)
|
|
165
|
+
? first
|
|
166
|
+
: second
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolves the smallest angular distance between two directions.
|
|
171
|
+
* @param {number} left First angle.
|
|
172
|
+
* @param {number} right Second angle.
|
|
173
|
+
* @returns {number}
|
|
174
|
+
*/
|
|
175
|
+
static #angularDistance(left, right) {
|
|
176
|
+
const distance = Math.abs(
|
|
177
|
+
PcbScene3dPadYawResolver.#normalizeAngle(left - right)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return distance > 180 ? 360 - distance : distance
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Normalizes an angle to 0 inclusive through 180 exclusive.
|
|
185
|
+
* @param {number} angle Angle in degrees.
|
|
186
|
+
* @returns {number}
|
|
187
|
+
*/
|
|
188
|
+
static #normalizeHalfTurn(angle) {
|
|
189
|
+
return ((Number(angle || 0) % 180) + 180) % 180
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Normalizes an angle to 0 inclusive through 360 exclusive.
|
|
194
|
+
* @param {number} angle Angle in degrees.
|
|
195
|
+
* @returns {number}
|
|
196
|
+
*/
|
|
197
|
+
static #normalizeAngle(angle) {
|
|
198
|
+
return ((Number(angle || 0) % 360) + 360) % 360
|
|
199
|
+
}
|
|
200
|
+
}
|