altium-toolkit 1.1.35 → 1.1.36
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/AGENTS.md +2 -2
- package/package.json +1 -1
- package/src/core/altium/PrintableTextDecoder.mjs +20 -1
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
- package/src/core/altium/SchematicTextParser.mjs +18 -253
- package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
- package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
- package/src/parser.mjs +1 -0
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +149 -20
- package/src/ui/PcbScene3dPackages.mjs +16 -1
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +899 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +138 -8
- package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +438 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
- package/src/ui/SchematicColorResolver.mjs +44 -0
- package/src/ui/SchematicLineColorResolver.mjs +1 -1
- package/src/ui/SchematicShapeRenderer.mjs +13 -13
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbScene3dStaticBodySymmetricOwnerPromotion } from './PcbScene3dStaticBodySymmetricOwnerPromotion.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Promotes compact static body fragments to one clear touching owner.
|
|
9
|
+
*/
|
|
10
|
+
export class PcbScene3dStaticBodyOwnerPromotion {
|
|
11
|
+
static #NEAREST_OWNER_DISTANCE_MIL = 20
|
|
12
|
+
static #NEAREST_OWNER_AMBIGUITY_MARGIN_MIL = 2
|
|
13
|
+
static #CLUSTER_CENTER_MAX_SPAN_MIL = 45
|
|
14
|
+
static #GENERIC_SUBPART_OWNER_MAX_SPAN_MIL = 80
|
|
15
|
+
static #GENERIC_SUBPART_TOKENS = new Set(['plastic'])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Promotes generic compact fragments that physically touch one clear owner.
|
|
19
|
+
* @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
|
|
20
|
+
* @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} [components] PCB components.
|
|
21
|
+
* @param {{ centerX?: number, centerY?: number } | null} [board] Board context.
|
|
22
|
+
*/
|
|
23
|
+
static promote(placementRows, components = [], board = null) {
|
|
24
|
+
PcbScene3dStaticBodyOwnerPromotion.#promoteNearestOwners(
|
|
25
|
+
placementRows,
|
|
26
|
+
components
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PcbScene3dStaticBodySymmetricOwnerPromotion.promote(
|
|
30
|
+
placementRows,
|
|
31
|
+
components
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const clusters =
|
|
35
|
+
PcbScene3dStaticBodyOwnerPromotion.#clusters(placementRows)
|
|
36
|
+
|
|
37
|
+
PcbScene3dStaticBodyOwnerPromotion.#promoteClusterCenterOwners(
|
|
38
|
+
placementRows,
|
|
39
|
+
clusters,
|
|
40
|
+
components,
|
|
41
|
+
board
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
clusters.forEach((cluster) => {
|
|
45
|
+
const ownerRow = PcbScene3dStaticBodyOwnerPromotion.#uniqueOwnerRow(
|
|
46
|
+
placementRows,
|
|
47
|
+
cluster.indexes
|
|
48
|
+
)
|
|
49
|
+
if (
|
|
50
|
+
!ownerRow ||
|
|
51
|
+
!cluster.indexes.every((index) =>
|
|
52
|
+
PcbScene3dStaticBodyOwnerPromotion.#canPromote(
|
|
53
|
+
placementRows[index]
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cluster.indexes.forEach((index) => {
|
|
61
|
+
placementRows[index].matchedComponent =
|
|
62
|
+
ownerRow.matchedComponent
|
|
63
|
+
placementRows[index].placement =
|
|
64
|
+
PcbScene3dStaticBodyOwnerPromotion.#withOwner(
|
|
65
|
+
placementRows[index].placement,
|
|
66
|
+
ownerRow.matchedComponent
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reassigns compact clusters to the component nearest the cluster center.
|
|
74
|
+
* @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
|
|
75
|
+
* @param {{ indexes: number[], bounds: object }[]} clusters Touching clusters.
|
|
76
|
+
* @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} components PCB components.
|
|
77
|
+
* @param {{ centerX?: number, centerY?: number } | null} board Board context.
|
|
78
|
+
*/
|
|
79
|
+
static #promoteClusterCenterOwners(
|
|
80
|
+
placementRows,
|
|
81
|
+
clusters,
|
|
82
|
+
components,
|
|
83
|
+
board
|
|
84
|
+
) {
|
|
85
|
+
clusters
|
|
86
|
+
.flatMap((cluster) =>
|
|
87
|
+
PcbScene3dStaticBodyOwnerPromotion.#orientationSubclusters(
|
|
88
|
+
cluster,
|
|
89
|
+
placementRows
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
.forEach((cluster) => {
|
|
93
|
+
if (
|
|
94
|
+
cluster.indexes.length <= 1 ||
|
|
95
|
+
PcbScene3dStaticBodyOwnerPromotion.#boundsMaxSpan(
|
|
96
|
+
cluster.bounds
|
|
97
|
+
) >
|
|
98
|
+
PcbScene3dStaticBodyOwnerPromotion
|
|
99
|
+
.#CLUSTER_CENTER_MAX_SPAN_MIL ||
|
|
100
|
+
!cluster.indexes.every((index) =>
|
|
101
|
+
PcbScene3dStaticBodyOwnerPromotion.#isCompactRow(
|
|
102
|
+
placementRows[index]
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const owner =
|
|
110
|
+
PcbScene3dStaticBodyOwnerPromotion.#nearestOwnerToSourcePoint(
|
|
111
|
+
PcbScene3dStaticBodyOwnerPromotion.#clusterSourceCenter(
|
|
112
|
+
cluster,
|
|
113
|
+
placementRows,
|
|
114
|
+
board
|
|
115
|
+
),
|
|
116
|
+
PcbScene3dStaticBodyOwnerPromotion.#componentsForClusterSide(
|
|
117
|
+
components,
|
|
118
|
+
cluster.ownerSide
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
if (!owner) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
cluster.indexes.forEach((index) => {
|
|
126
|
+
placementRows[index].matchedComponent = owner
|
|
127
|
+
placementRows[index].placement =
|
|
128
|
+
PcbScene3dStaticBodyOwnerPromotion.#withOwner(
|
|
129
|
+
placementRows[index].placement,
|
|
130
|
+
owner
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Splits one touching cluster into dominant-orientation subclusters.
|
|
138
|
+
* @param {{ indexes: number[] }} cluster Touching cluster.
|
|
139
|
+
* @param {{ placement: object }[]} placementRows Placement rows.
|
|
140
|
+
* @returns {{ indexes: number[], bounds: object }[]}
|
|
141
|
+
*/
|
|
142
|
+
static #orientationSubclusters(cluster, placementRows) {
|
|
143
|
+
const buckets = new Map()
|
|
144
|
+
|
|
145
|
+
cluster.indexes.forEach((index) => {
|
|
146
|
+
const key = PcbScene3dStaticBodyOwnerPromotion.#orientationBucket(
|
|
147
|
+
placementRows[index]?.placement
|
|
148
|
+
)
|
|
149
|
+
if (!buckets.has(key)) {
|
|
150
|
+
buckets.set(key, [])
|
|
151
|
+
}
|
|
152
|
+
buckets.get(key)?.push(index)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return [...buckets.entries()].map(([key, indexes]) => ({
|
|
156
|
+
indexes,
|
|
157
|
+
ownerSide:
|
|
158
|
+
PcbScene3dStaticBodyOwnerPromotion.#ownerSideFromBucketKey(key),
|
|
159
|
+
bounds: PcbScene3dStaticBodyOwnerPromotion.#boundsForIndexes(
|
|
160
|
+
placementRows,
|
|
161
|
+
indexes
|
|
162
|
+
)
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Filters candidate components by a side-specific cluster key.
|
|
168
|
+
* @param {object[]} components PCB components.
|
|
169
|
+
* @param {string} ownerSide Required owner side.
|
|
170
|
+
* @returns {object[]}
|
|
171
|
+
*/
|
|
172
|
+
static #componentsForClusterSide(components, ownerSide) {
|
|
173
|
+
const side = String(ownerSide || '').trim()
|
|
174
|
+
|
|
175
|
+
return side
|
|
176
|
+
? (Array.isArray(components) ? components : []).filter(
|
|
177
|
+
(component) =>
|
|
178
|
+
PcbScene3dStaticBodyOwnerPromotion.#componentMountSide(
|
|
179
|
+
component
|
|
180
|
+
) === side
|
|
181
|
+
)
|
|
182
|
+
: components
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolves a side constraint from an orientation bucket key.
|
|
187
|
+
* @param {string} key Orientation bucket key.
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
static #ownerSideFromBucketKey(key) {
|
|
191
|
+
const [orientation, side] = String(key || '').split(':')
|
|
192
|
+
|
|
193
|
+
return orientation === 'axis' || orientation === 'unknown'
|
|
194
|
+
? String(side || '').trim()
|
|
195
|
+
: ''
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Builds merged horizontal bounds for selected placement rows.
|
|
200
|
+
* @param {{ placement: object }[]} placementRows Placement rows.
|
|
201
|
+
* @param {number[]} indexes Row indexes.
|
|
202
|
+
* @returns {object}
|
|
203
|
+
*/
|
|
204
|
+
static #boundsForIndexes(placementRows, indexes) {
|
|
205
|
+
return indexes
|
|
206
|
+
.map((index) =>
|
|
207
|
+
PcbScene3dStaticBodyOwnerPromotion.#bounds(
|
|
208
|
+
placementRows[index]?.placement
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
.reduce((bounds, candidate) =>
|
|
212
|
+
bounds
|
|
213
|
+
? PcbScene3dStaticBodyOwnerPromotion.#merge(
|
|
214
|
+
bounds,
|
|
215
|
+
candidate
|
|
216
|
+
)
|
|
217
|
+
: candidate
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves a broad orientation bucket for compact polygon ownership.
|
|
223
|
+
* @param {{ geometry?: object } | null} placement Static placement.
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
static #orientationBucket(placement) {
|
|
227
|
+
const angle = PcbScene3dStaticBodyOwnerPromotion.#dominantEdgeAngle(
|
|
228
|
+
placement?.geometry?.verticesMil
|
|
229
|
+
)
|
|
230
|
+
const side = String(placement?.mountSide || '')
|
|
231
|
+
.trim()
|
|
232
|
+
.toLowerCase()
|
|
233
|
+
if (!Number.isFinite(angle)) {
|
|
234
|
+
return 'unknown:' + side
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const normalized = ((angle % 90) + 90) % 90
|
|
238
|
+
return normalized > 22.5 && normalized < 67.5
|
|
239
|
+
? 'diagonal'
|
|
240
|
+
: 'axis:' + side
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolves the longest polygon-edge angle.
|
|
245
|
+
* @param {{ x?: number, y?: number }[] | undefined} vertices Vertices.
|
|
246
|
+
* @returns {number | null}
|
|
247
|
+
*/
|
|
248
|
+
static #dominantEdgeAngle(vertices) {
|
|
249
|
+
const points = Array.isArray(vertices) ? vertices : []
|
|
250
|
+
if (points.length < 2) {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let bestEdge = null
|
|
255
|
+
points.forEach((point, index) => {
|
|
256
|
+
const next = points[(index + 1) % points.length]
|
|
257
|
+
const dx = Number(next?.x || 0) - Number(point?.x || 0)
|
|
258
|
+
const dy = Number(next?.y || 0) - Number(point?.y || 0)
|
|
259
|
+
const length = Math.hypot(dx, dy)
|
|
260
|
+
if (!bestEdge || length > bestEdge.length) {
|
|
261
|
+
bestEdge = {
|
|
262
|
+
length,
|
|
263
|
+
angle: Math.atan2(dy, dx) * (180 / Math.PI)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return bestEdge ? ((bestEdge.angle % 180) + 180) % 180 : null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Promotes compact generic fragments that sit directly on one component.
|
|
273
|
+
* @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
|
|
274
|
+
* @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} components PCB components.
|
|
275
|
+
*/
|
|
276
|
+
static #promoteNearestOwners(placementRows, components) {
|
|
277
|
+
placementRows.forEach((row) => {
|
|
278
|
+
if (
|
|
279
|
+
!PcbScene3dStaticBodyOwnerPromotion.#canPromoteNearestOwner(row)
|
|
280
|
+
) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const owner =
|
|
285
|
+
PcbScene3dStaticBodyOwnerPromotion.#nearestOwnerForPlacement(
|
|
286
|
+
row.placement,
|
|
287
|
+
components
|
|
288
|
+
)
|
|
289
|
+
if (!owner) {
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
row.matchedComponent = owner
|
|
294
|
+
row.placement = PcbScene3dStaticBodyOwnerPromotion.#withOwner(
|
|
295
|
+
row.placement,
|
|
296
|
+
owner
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Resolves one owner for a compact placement using distance and offset axis.
|
|
303
|
+
* @param {{ bodyPositionMil?: { x?: number, y?: number }, mountSide?: string, mountSideLocked?: boolean }} placement Static placement.
|
|
304
|
+
* @param {{ designator?: string, x?: number, y?: number, rotation?: number, layer?: string }[]} components PCB components.
|
|
305
|
+
* @returns {object | null}
|
|
306
|
+
*/
|
|
307
|
+
static #nearestOwnerForPlacement(placement, components) {
|
|
308
|
+
const position =
|
|
309
|
+
PcbScene3dStaticBodyOwnerPromotion.#sourceBodyPosition(placement)
|
|
310
|
+
if (!position) {
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
const lockedPlacementSide = placement?.mountSideLocked
|
|
314
|
+
? PcbScene3dStaticBodyOwnerPromotion.#normalizeMountSide(
|
|
315
|
+
placement?.mountSide
|
|
316
|
+
)
|
|
317
|
+
: null
|
|
318
|
+
|
|
319
|
+
const candidates = (Array.isArray(components) ? components : [])
|
|
320
|
+
.map((component) => ({
|
|
321
|
+
component,
|
|
322
|
+
distance: Math.hypot(
|
|
323
|
+
Number(component?.x || 0) - position.x,
|
|
324
|
+
Number(component?.y || 0) - position.y
|
|
325
|
+
)
|
|
326
|
+
}))
|
|
327
|
+
.filter(
|
|
328
|
+
({ component, distance }) =>
|
|
329
|
+
String(component?.designator || '').trim() &&
|
|
330
|
+
Number.isFinite(distance) &&
|
|
331
|
+
distance <=
|
|
332
|
+
PcbScene3dStaticBodyOwnerPromotion
|
|
333
|
+
.#NEAREST_OWNER_DISTANCE_MIL &&
|
|
334
|
+
(!lockedPlacementSide ||
|
|
335
|
+
PcbScene3dStaticBodyOwnerPromotion.#componentMountSide(
|
|
336
|
+
component
|
|
337
|
+
) === lockedPlacementSide) &&
|
|
338
|
+
PcbScene3dStaticBodyOwnerPromotion.#componentOffsetCompatible(
|
|
339
|
+
position,
|
|
340
|
+
component,
|
|
341
|
+
distance
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
.sort((left, right) => left.distance - right.distance)
|
|
345
|
+
|
|
346
|
+
return PcbScene3dStaticBodyOwnerPromotion.#unambiguousNearest(
|
|
347
|
+
candidates
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolves one unambiguous nearby owner for a source-space point.
|
|
353
|
+
* @param {{ x: number, y: number } | null} position Source-space point.
|
|
354
|
+
* @param {{ designator?: string, x?: number, y?: number }[]} components PCB components.
|
|
355
|
+
* @returns {object | null}
|
|
356
|
+
*/
|
|
357
|
+
static #nearestOwnerToSourcePoint(position, components) {
|
|
358
|
+
if (!position) {
|
|
359
|
+
return null
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const candidates = (Array.isArray(components) ? components : [])
|
|
363
|
+
.map((component) => ({
|
|
364
|
+
component,
|
|
365
|
+
distance: Math.hypot(
|
|
366
|
+
Number(component?.x || 0) - position.x,
|
|
367
|
+
Number(component?.y || 0) - position.y
|
|
368
|
+
)
|
|
369
|
+
}))
|
|
370
|
+
.filter(
|
|
371
|
+
({ component, distance }) =>
|
|
372
|
+
String(component?.designator || '').trim() &&
|
|
373
|
+
Number.isFinite(distance) &&
|
|
374
|
+
distance <=
|
|
375
|
+
PcbScene3dStaticBodyOwnerPromotion
|
|
376
|
+
.#NEAREST_OWNER_DISTANCE_MIL
|
|
377
|
+
)
|
|
378
|
+
.sort((left, right) => left.distance - right.distance)
|
|
379
|
+
|
|
380
|
+
return PcbScene3dStaticBodyOwnerPromotion.#unambiguousNearest(
|
|
381
|
+
candidates
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolves an unambiguous nearest component from distance candidates.
|
|
387
|
+
* @param {{ component: object, distance: number }[]} candidates Candidates.
|
|
388
|
+
* @returns {object | null}
|
|
389
|
+
*/
|
|
390
|
+
static #unambiguousNearest(candidates) {
|
|
391
|
+
const nearest = candidates[0]
|
|
392
|
+
if (!nearest) {
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const nextNearest = candidates[1]
|
|
397
|
+
const ambiguityMargin =
|
|
398
|
+
nearest.distance <= 1
|
|
399
|
+
? 0.25
|
|
400
|
+
: PcbScene3dStaticBodyOwnerPromotion
|
|
401
|
+
.#NEAREST_OWNER_AMBIGUITY_MARGIN_MIL
|
|
402
|
+
if (
|
|
403
|
+
nextNearest &&
|
|
404
|
+
nextNearest.distance - nearest.distance <= ambiguityMargin
|
|
405
|
+
) {
|
|
406
|
+
return null
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return nearest.component
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Checks whether a fragment offset aligns with a component's package axis.
|
|
414
|
+
* @param {{ x: number, y: number }} position Fragment source position.
|
|
415
|
+
* @param {{ x?: number, y?: number, rotation?: number }} component Candidate component.
|
|
416
|
+
* @param {number} distanceMil Fragment/component distance.
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
static #componentOffsetCompatible(position, component, distanceMil) {
|
|
420
|
+
if (Number(distanceMil || 0) <= 1) {
|
|
421
|
+
return true
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const dx = position.x - Number(component?.x || 0)
|
|
425
|
+
const dy = position.y - Number(component?.y || 0)
|
|
426
|
+
const offsetAngle = Math.atan2(dy, dx) * (180 / Math.PI)
|
|
427
|
+
const delta = PcbScene3dStaticBodyOwnerPromotion.#axisAngleDifference(
|
|
428
|
+
offsetAngle,
|
|
429
|
+
Number(component?.rotation || 0)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return delta <= 30
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Resolves the acute difference between two 180-degree axes.
|
|
437
|
+
* @param {number} leftAngle First angle in degrees.
|
|
438
|
+
* @param {number} rightAngle Second angle in degrees.
|
|
439
|
+
* @returns {number}
|
|
440
|
+
*/
|
|
441
|
+
static #axisAngleDifference(leftAngle, rightAngle) {
|
|
442
|
+
const normalized = (((leftAngle - rightAngle) % 180) + 180) % 180
|
|
443
|
+
|
|
444
|
+
return Math.min(normalized, 180 - normalized)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolves a touching cluster center in source-space coordinates.
|
|
449
|
+
* @param {{ indexes: number[], bounds: { minX: number, minY: number, maxX: number, maxY: number } }} cluster Touching cluster.
|
|
450
|
+
* @param {{ placement: object }[]} placementRows Placement rows.
|
|
451
|
+
* @param {{ centerX?: number, centerY?: number } | null} board Board context.
|
|
452
|
+
* @returns {{ x: number, y: number } | null}
|
|
453
|
+
*/
|
|
454
|
+
static #clusterSourceCenter(cluster, placementRows, board) {
|
|
455
|
+
const center = PcbScene3dStaticBodyOwnerPromotion.#boundsCenter(
|
|
456
|
+
cluster.bounds
|
|
457
|
+
)
|
|
458
|
+
const boardCenter = PcbScene3dStaticBodyOwnerPromotion.#boardCenter(
|
|
459
|
+
board,
|
|
460
|
+
placementRows[cluster.indexes[0]]?.placement
|
|
461
|
+
)
|
|
462
|
+
if (!center || !boardCenter) {
|
|
463
|
+
return null
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
x: center.x + boardCenter.x,
|
|
468
|
+
y: center.y + boardCenter.y
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Resolves board center from explicit board data or one placement row.
|
|
474
|
+
* @param {{ centerX?: number, centerY?: number } | null} board Board context.
|
|
475
|
+
* @param {{ positionMil?: { x?: number, y?: number }, bodyPositionMil?: { x?: number, y?: number } } | null} placement Placement row.
|
|
476
|
+
* @returns {{ x: number, y: number } | null}
|
|
477
|
+
*/
|
|
478
|
+
static #boardCenter(board, placement) {
|
|
479
|
+
const boardX = Number(board?.centerX)
|
|
480
|
+
const boardY = Number(board?.centerY)
|
|
481
|
+
if (Number.isFinite(boardX) && Number.isFinite(boardY)) {
|
|
482
|
+
return { x: boardX, y: boardY }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const bodyX = Number(placement?.bodyPositionMil?.x)
|
|
486
|
+
const bodyY = Number(placement?.bodyPositionMil?.y)
|
|
487
|
+
const positionX = Number(placement?.positionMil?.x)
|
|
488
|
+
const positionY = Number(placement?.positionMil?.y)
|
|
489
|
+
|
|
490
|
+
return Number.isFinite(bodyX) &&
|
|
491
|
+
Number.isFinite(bodyY) &&
|
|
492
|
+
Number.isFinite(positionX) &&
|
|
493
|
+
Number.isFinite(positionY)
|
|
494
|
+
? { x: bodyX - positionX, y: bodyY - positionY }
|
|
495
|
+
: null
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Resolves the center of horizontal bounds.
|
|
500
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
|
|
501
|
+
* @returns {{ x: number, y: number } | null}
|
|
502
|
+
*/
|
|
503
|
+
static #boundsCenter(bounds) {
|
|
504
|
+
return bounds
|
|
505
|
+
? {
|
|
506
|
+
x: (bounds.minX + bounds.maxX) / 2,
|
|
507
|
+
y: (bounds.minY + bounds.maxY) / 2
|
|
508
|
+
}
|
|
509
|
+
: null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Resolves the largest span of one bounds record.
|
|
514
|
+
* @param {{ minX?: number, minY?: number, maxX?: number, maxY?: number } | null} bounds Bounds.
|
|
515
|
+
* @returns {number}
|
|
516
|
+
*/
|
|
517
|
+
static #boundsMaxSpan(bounds) {
|
|
518
|
+
if (!bounds) {
|
|
519
|
+
return Infinity
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return Math.max(
|
|
523
|
+
Number(bounds.maxX || 0) - Number(bounds.minX || 0),
|
|
524
|
+
Number(bounds.maxY || 0) - Number(bounds.minY || 0)
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Resolves the source-space body anchor used for owner proximity.
|
|
530
|
+
* @param {{ bodyPositionMil?: { x?: number, y?: number } }} placement Static placement.
|
|
531
|
+
* @returns {{ x: number, y: number } | null}
|
|
532
|
+
*/
|
|
533
|
+
static #sourceBodyPosition(placement) {
|
|
534
|
+
const x = Number(placement?.bodyPositionMil?.x)
|
|
535
|
+
const y = Number(placement?.bodyPositionMil?.y)
|
|
536
|
+
|
|
537
|
+
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Resolves the board side from one component layer.
|
|
542
|
+
* @param {{ layer?: string }} component PCB component.
|
|
543
|
+
* @returns {'top' | 'bottom'}
|
|
544
|
+
*/
|
|
545
|
+
static #componentMountSide(component) {
|
|
546
|
+
return String(component?.layer || '')
|
|
547
|
+
.trim()
|
|
548
|
+
.toLowerCase()
|
|
549
|
+
.includes('bottom')
|
|
550
|
+
? 'bottom'
|
|
551
|
+
: 'top'
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Builds touching placement clusters across source identities and sides.
|
|
556
|
+
* @param {{ placement: object }[]} placementRows Placement rows.
|
|
557
|
+
* @returns {{ indexes: number[], bounds: { minX: number, minY: number, maxX: number, maxY: number } }[]}
|
|
558
|
+
*/
|
|
559
|
+
static #clusters(placementRows) {
|
|
560
|
+
const clusters = []
|
|
561
|
+
|
|
562
|
+
placementRows.forEach((row, index) => {
|
|
563
|
+
const bounds = PcbScene3dStaticBodyOwnerPromotion.#bounds(
|
|
564
|
+
row.placement
|
|
565
|
+
)
|
|
566
|
+
const touching = clusters.filter((cluster) =>
|
|
567
|
+
PcbScene3dStaticBodyOwnerPromotion.#touches(
|
|
568
|
+
cluster.bounds,
|
|
569
|
+
bounds,
|
|
570
|
+
2
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if (!touching.length) {
|
|
575
|
+
clusters.push({ indexes: [index], bounds })
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const target = touching[0]
|
|
580
|
+
target.indexes.push(index)
|
|
581
|
+
target.bounds = PcbScene3dStaticBodyOwnerPromotion.#merge(
|
|
582
|
+
target.bounds,
|
|
583
|
+
bounds
|
|
584
|
+
)
|
|
585
|
+
touching.slice(1).forEach((cluster) => {
|
|
586
|
+
target.indexes.push(...cluster.indexes)
|
|
587
|
+
target.bounds = PcbScene3dStaticBodyOwnerPromotion.#merge(
|
|
588
|
+
target.bounds,
|
|
589
|
+
cluster.bounds
|
|
590
|
+
)
|
|
591
|
+
clusters.splice(clusters.indexOf(cluster), 1)
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
return clusters
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Resolves one unambiguous owner row for a touching cluster.
|
|
600
|
+
* @param {{ placement: object, matchedComponent: object | null }[]} placementRows Placement rows.
|
|
601
|
+
* @param {number[]} indexes Cluster indexes.
|
|
602
|
+
* @returns {{ placement: object, matchedComponent: object } | null}
|
|
603
|
+
*/
|
|
604
|
+
static #uniqueOwnerRow(placementRows, indexes) {
|
|
605
|
+
const owners = indexes
|
|
606
|
+
.map((index) => placementRows[index])
|
|
607
|
+
.filter((row) => row.matchedComponent)
|
|
608
|
+
const designators = new Set(
|
|
609
|
+
owners.map((row) =>
|
|
610
|
+
String(row.matchedComponent?.designator || '').trim()
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
return designators.size === 1 ? owners[0] : null
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Checks whether one row may inherit a touching cluster owner.
|
|
619
|
+
* @param {{ placement: object, matchedComponent: object | null }} row Placement row.
|
|
620
|
+
* @returns {boolean}
|
|
621
|
+
*/
|
|
622
|
+
static #canPromote(row) {
|
|
623
|
+
if (row.matchedComponent) {
|
|
624
|
+
return true
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return (
|
|
628
|
+
PcbScene3dStaticBodyOwnerPromotion.#isGenericDesignator(
|
|
629
|
+
row.placement
|
|
630
|
+
) &&
|
|
631
|
+
PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
|
|
632
|
+
row.placement?.geometry
|
|
633
|
+
) <= 40
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Checks whether one row may claim the nearest exact physical owner.
|
|
639
|
+
* @param {{ placement: object, matchedComponent: object | null }} row Placement row.
|
|
640
|
+
* @returns {boolean}
|
|
641
|
+
*/
|
|
642
|
+
static #canPromoteNearestOwner(row) {
|
|
643
|
+
if (
|
|
644
|
+
PcbScene3dStaticBodyOwnerPromotion.#canPromote(row) ||
|
|
645
|
+
PcbScene3dStaticBodyOwnerPromotion.#isCompactRow(row)
|
|
646
|
+
) {
|
|
647
|
+
return true
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
PcbScene3dStaticBodyOwnerPromotion.#isGenericSubpartDesignator(
|
|
652
|
+
row.placement
|
|
653
|
+
) &&
|
|
654
|
+
PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
|
|
655
|
+
row.placement?.geometry
|
|
656
|
+
) <=
|
|
657
|
+
PcbScene3dStaticBodyOwnerPromotion
|
|
658
|
+
.#GENERIC_SUBPART_OWNER_MAX_SPAN_MIL
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Checks whether one row is compact enough for cluster-center ownership.
|
|
664
|
+
* @param {{ placement: object }} row Placement row.
|
|
665
|
+
* @returns {boolean}
|
|
666
|
+
*/
|
|
667
|
+
static #isCompactRow(row) {
|
|
668
|
+
return (
|
|
669
|
+
PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
|
|
670
|
+
row.placement?.geometry
|
|
671
|
+
) <= 40
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Checks whether a placement uses only the generic static-body label.
|
|
677
|
+
* @param {{ designator?: string } | null} placement Static placement.
|
|
678
|
+
* @returns {boolean}
|
|
679
|
+
*/
|
|
680
|
+
static #isGenericDesignator(placement) {
|
|
681
|
+
const designator = String(placement?.designator || '').trim()
|
|
682
|
+
return !designator || designator === '3D body'
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Checks whether a placement identity names a generic package sub-part.
|
|
687
|
+
* @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
|
|
688
|
+
* @returns {boolean}
|
|
689
|
+
*/
|
|
690
|
+
static #isGenericSubpartDesignator(placement) {
|
|
691
|
+
return PcbScene3dStaticBodyOwnerPromotion.#identityTokens(
|
|
692
|
+
placement
|
|
693
|
+
).some((token) =>
|
|
694
|
+
PcbScene3dStaticBodyOwnerPromotion.#GENERIC_SUBPART_TOKENS.has(
|
|
695
|
+
token
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Collects normalized identity tokens from one placement.
|
|
702
|
+
* @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
|
|
703
|
+
* @returns {string[]}
|
|
704
|
+
*/
|
|
705
|
+
static #identityTokens(placement) {
|
|
706
|
+
return [placement?.designator, placement?.sourceIdentityKey]
|
|
707
|
+
.join(' ')
|
|
708
|
+
.replace(/([a-z])([A-Z])/gu, '$1 $2')
|
|
709
|
+
.toLowerCase()
|
|
710
|
+
.split(/[^a-z0-9]+/g)
|
|
711
|
+
.flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
|
|
712
|
+
.filter(Boolean)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Applies a mount side while preserving the placement's XY anchor.
|
|
717
|
+
* @param {object} placement Static placement.
|
|
718
|
+
* @param {string | undefined} mountSide Owner mount side.
|
|
719
|
+
* @returns {object}
|
|
720
|
+
*/
|
|
721
|
+
static #withMountSide(placement, mountSide) {
|
|
722
|
+
const currentSide =
|
|
723
|
+
PcbScene3dStaticBodyOwnerPromotion.#normalizeMountSide(
|
|
724
|
+
placement?.mountSide
|
|
725
|
+
)
|
|
726
|
+
const side = PcbScene3dStaticBodyOwnerPromotion.#normalizeMountSide(
|
|
727
|
+
mountSide || currentSide
|
|
728
|
+
)
|
|
729
|
+
const z = Math.abs(Number(placement?.positionMil?.z || 0))
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
...placement,
|
|
733
|
+
mountSide: side,
|
|
734
|
+
positionMil: {
|
|
735
|
+
...(placement?.positionMil || {}),
|
|
736
|
+
z: side === 'bottom' ? -z : z
|
|
737
|
+
},
|
|
738
|
+
geometry:
|
|
739
|
+
currentSide === side
|
|
740
|
+
? placement?.geometry
|
|
741
|
+
: PcbScene3dStaticBodyOwnerPromotion.#mirrorSourceCoordinateGeometry(
|
|
742
|
+
placement
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Normalizes a mount side token.
|
|
749
|
+
* @param {string | undefined} mountSide Candidate mount side.
|
|
750
|
+
* @returns {'top' | 'bottom'}
|
|
751
|
+
*/
|
|
752
|
+
static #normalizeMountSide(mountSide) {
|
|
753
|
+
return String(mountSide || 'top')
|
|
754
|
+
.trim()
|
|
755
|
+
.toLowerCase() === 'bottom'
|
|
756
|
+
? 'bottom'
|
|
757
|
+
: 'top'
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Mirrors source-coordinate geometry into the opposite mount-local frame.
|
|
762
|
+
* @param {{ sourceCoordinateFrame?: boolean, geometry?: object }} placement Static placement.
|
|
763
|
+
* @returns {object | undefined}
|
|
764
|
+
*/
|
|
765
|
+
static #mirrorSourceCoordinateGeometry(placement) {
|
|
766
|
+
const geometry = placement?.geometry
|
|
767
|
+
if (
|
|
768
|
+
!placement?.sourceCoordinateFrame ||
|
|
769
|
+
!Array.isArray(geometry?.verticesMil)
|
|
770
|
+
) {
|
|
771
|
+
return geometry
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
...geometry,
|
|
776
|
+
verticesMil: geometry.verticesMil.map((vertex) => ({
|
|
777
|
+
x: PcbScene3dStaticBodyOwnerPromotion.#roundMil(
|
|
778
|
+
Number(vertex?.x || 0)
|
|
779
|
+
),
|
|
780
|
+
y: PcbScene3dStaticBodyOwnerPromotion.#roundMil(
|
|
781
|
+
-Number(vertex?.y || 0)
|
|
782
|
+
)
|
|
783
|
+
}))
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Applies a component owner to a placement.
|
|
789
|
+
* @param {object} placement Static placement.
|
|
790
|
+
* @param {{ designator?: string, layer?: string }} owner Owner component.
|
|
791
|
+
* @returns {object}
|
|
792
|
+
*/
|
|
793
|
+
static #withOwner(placement, owner) {
|
|
794
|
+
return {
|
|
795
|
+
...PcbScene3dStaticBodyOwnerPromotion.#withMountSide(
|
|
796
|
+
placement,
|
|
797
|
+
PcbScene3dStaticBodyOwnerPromotion.#componentMountSide(owner)
|
|
798
|
+
),
|
|
799
|
+
designator:
|
|
800
|
+
String(owner?.designator || '').trim() || placement?.designator
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Builds horizontal bounds for one static placement.
|
|
806
|
+
* @param {{ positionMil?: { x?: number, y?: number }, geometry?: object }} placement Static placement.
|
|
807
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
808
|
+
*/
|
|
809
|
+
static #bounds(placement) {
|
|
810
|
+
const centerX = Number(placement?.positionMil?.x || 0)
|
|
811
|
+
const centerY = Number(placement?.positionMil?.y || 0)
|
|
812
|
+
const vertices = Array.isArray(placement?.geometry?.verticesMil)
|
|
813
|
+
? placement.geometry.verticesMil
|
|
814
|
+
: []
|
|
815
|
+
if (!vertices.length) {
|
|
816
|
+
return {
|
|
817
|
+
minX: centerX,
|
|
818
|
+
minY: centerY,
|
|
819
|
+
maxX: centerX,
|
|
820
|
+
maxY: centerY
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const xs = vertices.map((vertex) => Number(vertex?.x || 0))
|
|
825
|
+
const ys = vertices.map((vertex) => Number(vertex?.y || 0))
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
minX: centerX + Math.min(...xs),
|
|
829
|
+
minY: centerY + Math.min(...ys),
|
|
830
|
+
maxX: centerX + Math.max(...xs),
|
|
831
|
+
maxY: centerY + Math.max(...ys)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Resolves the largest horizontal geometry span.
|
|
837
|
+
* @param {object | undefined} geometry Static geometry.
|
|
838
|
+
* @returns {number}
|
|
839
|
+
*/
|
|
840
|
+
static #maxSpan(geometry) {
|
|
841
|
+
const vertices = Array.isArray(geometry?.verticesMil)
|
|
842
|
+
? geometry.verticesMil
|
|
843
|
+
: []
|
|
844
|
+
if (!vertices.length) {
|
|
845
|
+
return 0
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const xs = vertices.map((vertex) => Number(vertex?.x || 0))
|
|
849
|
+
const ys = vertices.map((vertex) => Number(vertex?.y || 0))
|
|
850
|
+
|
|
851
|
+
return Math.max(
|
|
852
|
+
Math.max(...xs) - Math.min(...xs),
|
|
853
|
+
Math.max(...ys) - Math.min(...ys)
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Checks whether two horizontal bounds overlap or nearly touch.
|
|
859
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Bounds.
|
|
860
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Bounds.
|
|
861
|
+
* @param {number} toleranceMil Tolerance in mils.
|
|
862
|
+
* @returns {boolean}
|
|
863
|
+
*/
|
|
864
|
+
static #touches(left, right, toleranceMil) {
|
|
865
|
+
const tolerance = Math.max(Number(toleranceMil || 0), 0)
|
|
866
|
+
|
|
867
|
+
return (
|
|
868
|
+
left.minX <= right.maxX + tolerance &&
|
|
869
|
+
left.maxX >= right.minX - tolerance &&
|
|
870
|
+
left.minY <= right.maxY + tolerance &&
|
|
871
|
+
left.maxY >= right.minY - tolerance
|
|
872
|
+
)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Rounds one mil value for stable promoted geometry output.
|
|
877
|
+
* @param {number} value Candidate value.
|
|
878
|
+
* @returns {number}
|
|
879
|
+
*/
|
|
880
|
+
static #roundMil(value) {
|
|
881
|
+
const rounded = Math.round(Number(value) * 10000) / 10000
|
|
882
|
+
return Object.is(rounded, -0) ? 0 : rounded
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Merges two bounds records.
|
|
887
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Bounds.
|
|
888
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Bounds.
|
|
889
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
|
|
890
|
+
*/
|
|
891
|
+
static #merge(left, right) {
|
|
892
|
+
return {
|
|
893
|
+
minX: Math.min(left.minX, right.minX),
|
|
894
|
+
minY: Math.min(left.minY, right.minY),
|
|
895
|
+
maxX: Math.max(left.maxX, right.maxX),
|
|
896
|
+
maxY: Math.max(left.maxY, right.maxY)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|